Skip to content

Commit 06a8249

Browse files
authored
Merge pull request #48 from LedgerHQ/add/apex
Trying to ease device management in Ledgered, to be used elsewhere
2 parents 4df7d8f + e552b47 commit 06a8249

File tree

12 files changed

+159
-19
lines changed

12 files changed

+159
-19
lines changed

.github/workflows/build_and_tests.yml

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,6 @@ jobs:
4545
pip install -U pip
4646
pip install -U .[dev]
4747
48-
- name: Run tests and generate coverage
49-
run: pytest -v --tb=short tests/ --cov ledgered --cov-report xml
50-
5148
- name: Run unit tests and generate coverage
5249
run: pytest -v --tb=short tests/unit --cov ledgered --cov-report xml
5350

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.10.0] - 2025-05-09
9+
10+
### Added
11+
12+
- Added `devices` module, declaring Ledger devices as class / enum, dynamically generated from a
13+
JSON configuration file. This should enable to add new devices more easily, and device
14+
characteristics are now reachable from a centralized place.
15+
816
## [0.9.1] - 2025-03-26
917

1018
### Fixed

MANIFEST.in

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
include src/ledgered/devices.json

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,10 @@ classifiers = [
3030
dynamic = [ "version" ]
3131
requires-python = ">=3.9"
3232
dependencies = [
33-
"toml",
33+
"pydantic",
3434
"pyelftools",
3535
"pygithub",
36+
"toml",
3637
]
3738

3839
[project.optional-dependencies]

src/ledgered/devices/__init__.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import dataclasses
2+
import json
3+
from enum import IntEnum, auto
4+
from pathlib import Path
5+
from pydantic.dataclasses import dataclass
6+
7+
8+
class DeviceType(IntEnum):
9+
NANOS = auto()
10+
NANOSP = auto()
11+
NANOX = auto()
12+
STAX = auto()
13+
FLEX = auto()
14+
15+
16+
@dataclass
17+
class Resolution:
18+
x: int
19+
y: int
20+
21+
22+
@dataclass
23+
class Device:
24+
type: DeviceType
25+
resolution: Resolution
26+
touchable: bool = True
27+
deprecated: bool = False
28+
names: list[str] = dataclasses.field(default_factory=lambda: [])
29+
30+
@property
31+
def name(self) -> str:
32+
"""
33+
Returns the name of the current firmware's device
34+
"""
35+
return self.type.name.lower()
36+
37+
@property
38+
def is_nano(self):
39+
"""
40+
States if the firmware's name starts with 'nano' or not.
41+
"""
42+
return self.type in [DeviceType.NANOS, DeviceType.NANOSP, DeviceType.NANOX]
43+
44+
@classmethod
45+
def from_dict(cls, dico: dict) -> "Device":
46+
type = dico.pop("type")
47+
dico["type"] = DeviceType[type.upper()]
48+
return Device(**dico)
49+
50+
51+
class Devices:
52+
_devices_file = Path(__file__).absolute().parent / "devices.json"
53+
with _devices_file.open() as filee:
54+
_devices = json.load(filee)
55+
56+
DEVICE_DATA = {item.type: item for item in [Device.from_dict(i) for i in _devices]}
57+
58+
def __iter__(self):
59+
for d in self.DEVICE_DATA.values():
60+
yield d
61+
62+
@classmethod
63+
def get_by_type(cls, device_type: DeviceType) -> Device:
64+
return cls.DEVICE_DATA[device_type]
65+
66+
@classmethod
67+
def get_by_name(cls, name: str) -> Device:
68+
for device in cls.DEVICE_DATA.values():
69+
if name.lower() == device.name or name.lower() in device.names:
70+
return device
71+
raise KeyError(f"Device named '{name}' unknown")

src/ledgered/devices/devices.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
[
2+
{"type": "nanos", "resolution": {"x": 128, "y": 32}, "touchable": false, "deprecated": true},
3+
{"type": "nanosp", "resolution": {"x": 128, "y": 64}, "touchable": false, "names": ["nanos+", "nanos2", "nanosplus"]},
4+
{"type": "nanox", "resolution": {"x": 128, "y": 64}, "touchable": false},
5+
{"type": "flex", "resolution": {"x": 480, "y": 600}},
6+
{"type": "stax", "resolution": {"x": 400, "y": 670}}
7+
]

src/ledgered/manifest/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from .app import AppConfig
2-
from .constants import EXISTING_DEVICES, MANIFEST_FILE_NAME
2+
from .constants import MANIFEST_FILE_NAME
33
from .manifest import Manifest
44
from .tests import TestsConfig
55

6-
__all__ = ["AppConfig", "EXISTING_DEVICES", "Manifest", "MANIFEST_FILE_NAME", "TestsConfig"]
6+
__all__ = ["AppConfig", "Manifest", "MANIFEST_FILE_NAME", "TestsConfig"]

src/ledgered/manifest/app.py

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from typing import Iterable, Union
44

55
from ledgered.serializers import Jsonable, JsonSet
6-
from .constants import EXISTING_DEVICES
6+
from ledgered.devices import Devices
77

88

99
@dataclass
@@ -18,12 +18,7 @@ def __init__(self, sdk: str, build_directory: Union[str, Path], devices: Iterabl
1818
raise ValueError(f"'{sdk}' unknown. Must be either 'C' or 'Rust'")
1919
self.sdk = sdk
2020
self.build_directory = Path(build_directory)
21-
devices = JsonSet(device.lower() for device in devices)
22-
unknown_devices = devices.difference(EXISTING_DEVICES)
23-
if unknown_devices:
24-
unknown_devices_str = "', '".join(unknown_devices)
25-
raise ValueError(f"Unknown devices: '{unknown_devices_str}'")
26-
self.devices = devices
21+
self.devices = JsonSet(Devices.get_by_name(device).name for device in devices)
2722

2823
@property
2924
def is_rust(self) -> bool:

src/ledgered/manifest/constants.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,2 @@
1-
EXISTING_DEVICES = ["nanos", "nanox", "nanos+", "stax", "flex"]
21
MANIFEST_FILE_NAME = "ledger_app.toml"
32
DEFAULT_USE_CASE = "default"

tests/unit/devices/__init__.py

Whitespace-only changes.

tests/unit/devices/test___init__.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
from ledgered.devices import Device, DeviceType, Devices, Resolution
2+
from unittest import TestCase
3+
4+
5+
class TestDevice(TestCase):
6+
def setUp(self):
7+
self.res = Resolution(x=10, y=20)
8+
9+
def test___init__simple(self):
10+
type = DeviceType.NANOS
11+
device = Device(type, self.res)
12+
self.assertEqual(device.type, type)
13+
self.assertEqual(device.touchable, True)
14+
self.assertFalse(device.deprecated)
15+
self.assertEqual(device.name, type.name.lower())
16+
self.assertEqual(device.resolution, self.res)
17+
18+
def test_is_nano(self):
19+
device = Device(DeviceType.NANOS, self.res)
20+
self.assertTrue(device.is_nano)
21+
device = Device(DeviceType.STAX, self.res)
22+
self.assertFalse(device.is_nano)
23+
24+
def test_from_dict(self):
25+
type, touchable, deprecated = "flex", False, True
26+
device = Device.from_dict(
27+
{
28+
"type": type,
29+
"resolution": {"x": self.res.x, "y": self.res.y},
30+
"touchable": touchable,
31+
"deprecated": deprecated,
32+
}
33+
)
34+
self.assertEqual(device.type, DeviceType[type.upper()])
35+
self.assertEqual(device.touchable, touchable)
36+
self.assertEqual(device.deprecated, deprecated)
37+
self.assertEqual(device.name, type)
38+
self.assertEqual(device.resolution, Resolution(x=self.res.x, y=self.res.y))
39+
40+
41+
class TestDevices(TestCase):
42+
def test_get_type_ok(self):
43+
type = DeviceType.STAX
44+
device = Devices.get_by_type(type)
45+
self.assertIsInstance(device, Device)
46+
self.assertEqual(device.type, type)
47+
48+
def test_get_type_nok(self):
49+
with self.assertRaises(KeyError):
50+
Devices.get_by_type(9)
51+
52+
def test_get_by_name_ok(self):
53+
device = Devices.get_by_name("nanos+")
54+
self.assertIsInstance(device, Device)
55+
self.assertEqual(device.type, DeviceType.NANOSP)
56+
57+
def test_get_by_name_nok(self):
58+
with self.assertRaises(KeyError):
59+
Devices.get_by_name("non existent")
60+
61+
def test___iter__(self):
62+
for d in Devices():
63+
self.assertIsInstance(d, Device)

tests/unit/manifest/test_app.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ def test___init___ok_complete(self):
1212
config = AppConfig(sdk=sdk, build_directory=str(bd), devices=devices)
1313
self.assertEqual(config.sdk, sdk.lower())
1414
self.assertEqual(config.build_directory, bd)
15-
self.assertEqual(config.devices, {device.lower() for device in devices})
15+
self.assertEqual(config.devices, {"nanos", "nanosp"})
1616
self.assertTrue(config.is_rust)
1717
self.assertFalse(config.is_c)
1818

@@ -21,8 +21,6 @@ def test___init___nok_unknown_sdk(self):
2121
AppConfig(sdk="Java", build_directory=str(), devices=set())
2222

2323
def test___init___nok_unknown_device(self):
24-
devices = {"hic sunt", "dracones"}
25-
with self.assertRaises(ValueError) as error:
24+
devices = {"nanosp", "flex", "hic sunt", "dracones"}
25+
with self.assertRaises(KeyError):
2626
AppConfig(sdk="rust", build_directory=str(), devices=devices)
27-
for device in devices:
28-
self.assertIn(device, str(error.exception))

0 commit comments

Comments
 (0)