diff --git a/src/opendisplay/cli.py b/src/opendisplay/cli.py index a4a5c16..60d1948 100644 --- a/src/opendisplay/cli.py +++ b/src/opendisplay/cli.py @@ -8,7 +8,8 @@ import logging import os import sys -from collections.abc import Coroutine +from collections.abc import Callable, Coroutine +from dataclasses import dataclass from pathlib import Path from typing import Any, NoReturn, TypeVar @@ -31,17 +32,27 @@ BLETimeoutError, OpenDisplayError, ) +from .models.config import GlobalConfig from .models.enums import ( + PANEL_IC_NAMES, + BinaryInputType, + BusType, CapacityEstimator, + DisplayTechnology, FitMode, + FlashIcType, ICType, LedType, + NfcIcType, + PartialUpdateSupport, PowerMode, RefreshMode, Rotation, SensorType, + TouchIcType, WifiEncryption, ) +from .models.firmware import FirmwareVersion from .partial import PartialState _T = TypeVar("_T") @@ -307,165 +318,583 @@ async def _scan(timeout: float, output_json: bool) -> None: # ── info ────────────────────────────────────────────────────────────────────── -def _led_name(led_type: int) -> str: +def _enum_name(enum_cls: type[Any], value: int | None, digits: int = 2) -> str | None: + """Enum member name for ``value``, or a hex literal when unrecognised.""" + if value is None: + return None try: - return LedType(led_type).name + name: str = enum_cls(value).name except ValueError: - return f"0x{led_type:02x}" + return f"0x{value:0{digits}x}" + return name + + +def _led_name(led_type: int) -> str: + return _enum_name(LedType, led_type) or "" def _sensor_name(sensor_type: int) -> str: + return _enum_name(SensorType, sensor_type, digits=4) or "" + + +@dataclass(frozen=True) +class _InfoContext: + """Everything the info report renders, gathered once from the device.""" + + mac: str + device_name: str | None + fw: FirmwareVersion + config: GlobalConfig | None + width: int + height: int + color_scheme_name: str + rotation: Any + board_type_name: str | None + + @property + def display(self) -> Any: + """The primary display packet, or None when the device returned no config.""" + if self.config and self.config.displays: + return self.config.displays[0] + return None + + +def _is_meaningful(value: Any) -> bool: + """Whether a value earns a line in the tree. + + Zero is kept — ``Sleep 0s`` and ``0 mAh`` are real answers — but ``None`` + (field absent for this device) and empty strings/lists are dropped so the + report stays about what the device actually has. + """ + if value is None: + return False + if isinstance(value, str | list | tuple): + return len(value) > 0 + return True + + +@dataclass(frozen=True) +class _Field: + """One line of the report: a tree label plus a JSON key.""" + + label: str + key: str + value: Callable[[_InfoContext], Any] + render: Callable[[Any], str] | None = None + # Tree-only override, for lines that combine several JSON keys (e.g. WxH). + tree_value: Callable[[_InfoContext], Any] | None = None + # Hide this line in the tree when the value is falsy-but-present (e.g. 0 mC). + hide_when_zero: bool = False + + def text(self, value: Any) -> str: + """Format ``value`` for the tree.""" + return self.render(value) if self.render else str(value) + + def visible(self, value: Any) -> bool: + """Whether this field earns a line in the tree.""" + if self.hide_when_zero and not value: + return False + return _is_meaningful(value) + + +@dataclass(frozen=True) +class _Section: + """A keyed group of fields, rendered as a tree branch and a JSON object.""" + + title: str + key: str + fields: tuple[_Field, ...] + # When present and returning False, the whole section is omitted (JSON: null). + available: Callable[[_InfoContext], bool] | None = None + # Back-compat: emit the JSON key even when the section is unavailable. + json_nullable: bool = False + + def is_available(self, ctx: _InfoContext) -> bool: + """Whether this section applies to the device at all.""" + return self.available(ctx) if self.available else True + + +@dataclass(frozen=True) +class _ListSection: + """A repeatable packet: one tree line and one JSON object per instance.""" + + title: str + key: str + items: Callable[[_InfoContext], list[Any]] + line: Callable[[Any], str] + entry: Callable[[Any], dict[str, Any]] + # Existing JSON nests leds/sensors/buttons under "hardware"; keep that shape. + json_parent: str | None = None + + +# ── field extractors ───────────────────────────────────────────────────────── + + +def _panel_label(ctx: _InfoContext) -> str | None: + """Panel description plus its raw id; hex alone when the id is unrecognised.""" + panel_id = _display_attr(ctx, "panel_ic_type") + if panel_id is None: + return None + described = PANEL_IC_NAMES.get(panel_id) + return f"{described} [dim](0x{panel_id:04x})[/dim]" if described else f"0x{panel_id:04x}" + + +_PARTIAL_UPDATE_LABELS: dict[PartialUpdateSupport, str] = { + PartialUpdateSupport.NONE: "Not supported", + PartialUpdateSupport.PARTIAL: "Supported", + PartialUpdateSupport.FULL_FRAME: "Supported (full-frame stream required)", +} + + +def _partial_update_label(ctx: _InfoContext) -> str | None: + display = ctx.display + if display is None: + return None try: - return SensorType(sensor_type).name + return _PARTIAL_UPDATE_LABELS[PartialUpdateSupport(display.partial_update_support)] except ValueError: - return f"0x{sensor_type:04x}" - - -def _info_to_json(data: dict[str, Any]) -> dict[str, Any]: - security = data["security"] - wifi = data["wifi"] - enc_enum = wifi.encryption_type_enum if wifi else None - fw = data["fw"] - diagonal = data["diagonal"] - panel_ic_type = data["panel_ic_type"] - return { - "mac": data["mac"], - "display": { - "width": data["width"], - "height": data["height"], - "active_width_mm": data["active_w_mm"], - "active_height_mm": data["active_h_mm"], - "diagonal_inches": round(diagonal, 1) if diagonal is not None else None, - "color_scheme": data["color_str"], - "rotation": data["rotation"], - "panel_ic_type": f"0x{panel_ic_type:04x}" if panel_ic_type is not None else None, - "full_update_mc": data["full_update_mc"], - "transmission_modes": data["transmission_modes"], + return f"0x{display.partial_update_support:02x}" + + +def _transmission_modes(ctx: _InfoContext) -> list[str]: + display = ctx.display + if display is None: + return [] + return [ + label + for flag, label in ( + (display.supports_streaming_decompression, "STREAMING"), + (display.supports_zip, "ZIP"), + (display.supports_g5, "G5"), + (display.supports_direct_write, "DIRECT_WRITE"), + (display.supports_pipe_write, "PIPE_WRITE"), + ) + if flag + ] + + +def _physical_size(ctx: _InfoContext) -> str | None: + display = ctx.display + if display is None or not (display.active_width_mm and display.active_height_mm): + return None + diagonal = display.screen_diagonal_inches + suffix = f' ({diagonal:.1f}")' if diagonal is not None else "" + return f"{display.active_width_mm}x{display.active_height_mm} mm{suffix}" + + +def _ic_label(ctx: _InfoContext) -> str: + if not ctx.config: + return "Unknown" + return _enum_name(ICType, ctx.config.system.ic_type, digits=4) or "Unknown" + + +def _board_label(ctx: _InfoContext) -> str | None: + if not ctx.config: + return None + mfr = ctx.config.manufacturer.manufacturer_name or "Unknown" + board = f"{mfr} / {ctx.board_type_name or 'Unknown'}" + if ctx.config.manufacturer.board_revision: + board += f" (rev. {ctx.config.manufacturer.board_revision})" + return board + + +def _power_mode_label(ctx: _InfoContext) -> str: + if not ctx.config: + return "Unknown" + power = ctx.config.power + mode = _enum_name(PowerMode, power.power_mode) or "Unknown" + if power.battery_mah: + mode += f" {power.battery_mah} mAh" + chemistry = _enum_name(CapacityEstimator, power.capacity_estimator) + if chemistry: + mode += f" ({chemistry})" + return mode + + +def _sleep_seconds(ctx: _InfoContext) -> float | None: + """Sleep timeout in SECONDS for JSON; the packet stores milliseconds.""" + milliseconds = _config_attr(ctx, "power", "sleep_timeout_ms") + return milliseconds / 1000 if milliseconds else None + + +def _sleep_tree_label(ctx: _InfoContext) -> str | None: + milliseconds = _config_attr(ctx, "power", "sleep_timeout_ms") + if milliseconds is None: + return None + return "Never" if milliseconds == 0 else f"{milliseconds / 1000:.0f}s" + + +def _deep_sleep_label(ctx: _InfoContext) -> str | None: + if not ctx.config or not ctx.config.power.deep_sleep_time_seconds: + return None + power = ctx.config.power + micro_amps = f" @ {power.deep_sleep_current_ua} µA" if power.deep_sleep_current_ua else "" + return f"{power.deep_sleep_time_seconds}s{micro_amps}" + + +def _wifi_encryption(ctx: _InfoContext) -> str | None: + wifi = ctx.config.wifi_config if ctx.config else None + if wifi is None: + return None + encryption = wifi.encryption_type_enum + if isinstance(encryption, WifiEncryption): + return encryption.name + return f"0x{encryption:02x}" + + +def _wifi_server(ctx: _InfoContext) -> str | None: + wifi = ctx.config.wifi_config if ctx.config else None + if wifi is None or not wifi.server_url_text: + return None + return f"{wifi.server_url_text}:{wifi.server_port}" + + +def _has_wifi(ctx: _InfoContext) -> bool: + wifi = ctx.config.wifi_config if ctx.config else None + return bool(wifi and wifi.ssid_text) + + +def _has_security(ctx: _InfoContext) -> bool: + return bool(ctx.config and ctx.config.security_config) + + +def _has_identity(ctx: _InfoContext) -> bool: + extended = ctx.config.data_extended if ctx.config else None + if extended is None: + return False + return any( + ( + extended.model_name_text, + extended.serial_number_text, + extended.friendly_name_text, + extended.device_location_text, + extended.device_id_text, + ) + ) + + +def _extended(ctx: _InfoContext, attr: str) -> str | None: + extended = ctx.config.data_extended if ctx.config else None + return getattr(extended, f"{attr}_text") if extended else None + + +def _config_attr(ctx: _InfoContext, section: str, attr: str) -> Any: + return getattr(getattr(ctx.config, section), attr) if ctx.config else None + + +def _display_attr(ctx: _InfoContext, attr: str) -> Any: + display = ctx.display + return getattr(display, attr) if display else None + + +# ── the report spec ────────────────────────────────────────────────────────── + +_INFO_SECTIONS: tuple[_Section, ...] = ( + _Section( + title="Display", + key="display", + fields=( + _Field( + "Resolution", + "width", + lambda c: c.width, + tree_value=lambda c: f"{c.width}x{c.height}px", + ), + _Field("", "height", lambda c: c.height), + _Field("Physical", "active_width_mm", _physical_size), + _Field("", "active_height_mm", lambda c: _display_attr(c, "active_height_mm")), + _Field("", "diagonal_inches", lambda c: _display_attr(c, "screen_diagonal_inches")), + _Field("Color", "color_scheme", lambda c: c.color_scheme_name, _color_scheme_label), + _Field("Rotation", "rotation", lambda c: c.rotation, lambda v: f"{v}°"), + _Field( + "Panel", + "panel_ic_type", + lambda c: PANEL_IC_NAMES.get(_display_attr(c, "panel_ic_type")), + tree_value=_panel_label, + ), + _Field( + "Technology", + "display_technology", + lambda c: _enum_name(DisplayTechnology, _display_attr(c, "display_technology")), + ), + _Field("Partial", "partial_update", _partial_update_label), + _Field( + "Full update", + "full_update_mc", + lambda c: _display_attr(c, "full_update_mC"), + lambda v: f"{v} mC", + hide_when_zero=True, + ), + _Field("Transmission", "transmission_modes", _transmission_modes, " ".join), + ), + ), + _Section( + title="Hardware", + key="hardware", + fields=( + _Field("MCU", "ic", _ic_label), + _Field("Board", "board_type", _board_label), + _Field("", "manufacturer", lambda c: _config_attr(c, "manufacturer", "manufacturer_name")), + _Field("", "board_revision", lambda c: _config_attr(c, "manufacturer", "board_revision")), + ), + ), + _Section( + title="Power", + key="power", + fields=( + _Field("Mode", "mode", _power_mode_label), + _Field("", "battery_mah", lambda c: _config_attr(c, "power", "battery_mah")), + _Field( + "", "chemistry", lambda c: _enum_name(CapacityEstimator, _config_attr(c, "power", "capacity_estimator")) + ), + _Field("Sleep", "sleep_timeout_s", _sleep_seconds, tree_value=_sleep_tree_label), + _Field( + "Screen off", + "screen_timeout_s", + lambda c: _config_attr(c, "power", "screen_timeout_seconds"), + lambda v: f"{v}s", + hide_when_zero=True, + ), + _Field( + "Min wake", + "min_wake_time_s", + lambda c: _config_attr(c, "power", "min_wake_time_seconds"), + lambda v: f"{v}s", + hide_when_zero=True, + ), + _Field( + "Deep sleep", + "deep_sleep_time_s", + lambda c: _config_attr(c, "power", "deep_sleep_time_seconds") or None, + tree_value=_deep_sleep_label, + ), + _Field("", "deep_sleep_current_ua", lambda c: _config_attr(c, "power", "deep_sleep_current_ua") or None), + _Field("TX power", "tx_power_dbm", lambda c: _config_attr(c, "power", "tx_power"), lambda v: f"{v} dBm"), + ), + ), + _Section( + title="Security", + key="security", + available=_has_security, + json_nullable=True, + fields=( + _Field( + "Encryption", + "encryption", + lambda c: ( + c.config.security_config.encryption_enabled_flag if c.config and c.config.security_config else None + ), + lambda v: "[green]Enabled[/green]" if v else "[dim]Disabled[/dim]", + ), + _Field( + "Session", + "session_timeout_s", + lambda c: ( + (c.config.security_config.session_timeout_seconds or None) + if c.config and c.config.security_config + else None + ), + lambda v: f"{v}s", + ), + _Field( + "Rewrite", + "rewrite_allowed", + lambda c: c.config.security_config.rewrite_allowed if c.config and c.config.security_config else None, + lambda v: "[green]Allowed[/green]" if v else "[red]Denied[/red]", + ), + ), + ), + _Section( + title="WiFi", + key="wifi", + available=_has_wifi, + json_nullable=True, + fields=( + _Field( + "SSID", "ssid", lambda c: c.config.wifi_config.ssid_text if c.config and c.config.wifi_config else None + ), + _Field("Server", "server", _wifi_server), + _Field("Encryption", "encryption", _wifi_encryption), + ), + ), + _Section( + title="Identity", + key="identity", + available=_has_identity, + json_nullable=True, + fields=( + _Field("Model", "model_name", lambda c: _extended(c, "model_name")), + _Field("Serial", "serial_number", lambda c: _extended(c, "serial_number")), + _Field("Name", "friendly_name", lambda c: _extended(c, "friendly_name")), + _Field("Location", "device_location", lambda c: _extended(c, "device_location")), + _Field("Device ID", "device_id", lambda c: _extended(c, "device_id")), + ), + ), +) + + +def _bus_line(bus: Any) -> str: + speed = f"{bus.bus_speed_hz / 1000:.0f} kHz" if bus.bus_speed_hz else "unset" + bus_type = _enum_name(BusType, bus.bus_type) or "?" + return f"Bus {bus.instance_number} {bus_type} {speed}" + + +_INFO_LIST_SECTIONS: tuple[_ListSection, ...] = ( + _ListSection( + title="LEDs", + key="leds", + json_parent="hardware", + items=lambda c: c.config.leds if c.config else [], + line=lambda led: f"LED {led.instance_number} {_led_name(led.led_type)}", + entry=lambda led: {"instance": led.instance_number, "type": _led_name(led.led_type)}, + ), + _ListSection( + title="Sensors", + key="sensors", + json_parent="hardware", + items=lambda c: c.config.sensors if c.config else [], + line=lambda s: f"Sensor {s.instance_number} {_sensor_name(s.sensor_type)} (bus {s.bus_id})", + entry=lambda s: {"instance": s.instance_number, "type": _sensor_name(s.sensor_type), "bus": s.bus_id}, + ), + _ListSection( + title="Buttons", + key="buttons", + json_parent="hardware", + items=lambda c: c.config.binary_inputs if c.config else [], + line=lambda b: f"Button {b.instance_number} {_enum_name(BinaryInputType, b.input_type)}", + entry=lambda b: { + "instance": b.instance_number, + "input_type": b.input_type, + "type": _enum_name(BinaryInputType, b.input_type), }, - "hardware": { - "ic": data["ic_str"], - "manufacturer": data["mfr_name"], - "board_type": data["board_type_name"], - "board_revision": data["board_revision"], - "leds": [{"instance": led.instance_number, "type": _led_name(led.led_type)} for led in data["leds"]], - "sensors": [ - {"instance": s.instance_number, "type": _sensor_name(s.sensor_type), "bus": s.bus_id} - for s in data["sensors"] - ], - "buttons": [{"instance": b.instance_number, "input_type": b.input_type} for b in data["binary_inputs"]], + ), + _ListSection( + title="Touch", + key="touch_controllers", + json_parent="hardware", + items=lambda c: c.config.touch_controllers if c.config else [], + line=lambda t: ( + f"Touch {t.instance_number} {_enum_name(TouchIcType, t.touch_ic_type)}" + f" (i2c 0x{t.i2c_addr_7bit:02x}, bus {t.bus_id})" + ), + entry=lambda t: { + "instance": t.instance_number, + "type": _enum_name(TouchIcType, t.touch_ic_type), + "i2c_addr": f"0x{t.i2c_addr_7bit:02x}", + "bus": t.bus_id, + "poll_interval_ms": t.poll_interval_ms, }, - "power": { - "mode": data["power_mode_str"], - "battery_mah": data["battery_mah"], - "chemistry": data["cap_str"], - "sleep_timeout_s": data["sleep_timeout_ms"] / 1000 if data["sleep_timeout_ms"] else None, - "deep_sleep_time_s": data["deep_sleep_time_s"] or None, - "deep_sleep_current_ua": data["deep_sleep_ua"] or None, - "tx_power_dbm": data["tx_power"], + ), + _ListSection( + title="Buzzers", + key="buzzers", + json_parent="hardware", + items=lambda c: c.config.buzzers if c.config else [], + line=lambda b: f"Buzzer {b.instance_number} pin {b.drive_pin}, {b.duty_percent}% duty", + entry=lambda b: {"instance": b.instance_number, "drive_pin": b.drive_pin, "duty_percent": b.duty_percent}, + ), + _ListSection( + title="Buses", + key="buses", + json_parent="hardware", + items=lambda c: c.config.data_buses if c.config else [], + line=_bus_line, + entry=lambda bus: { + "instance": bus.instance_number, + "type": _enum_name(BusType, bus.bus_type), + "speed_hz": bus.bus_speed_hz, }, - "security": { - "encryption": security.encryption_enabled_flag, - "session_timeout_s": security.session_timeout_seconds or None, - "rewrite_allowed": security.rewrite_allowed, - } - if security - else None, - "wifi": { - "ssid": wifi.ssid_text, - "server": f"{wifi.server_url_text}:{wifi.server_port}" if wifi.server_url_text else None, - "encryption": enc_enum.name - if isinstance(enc_enum, WifiEncryption) - else (f"0x{enc_enum:02x}" if enc_enum is not None else None), - } - if wifi and wifi.ssid_text - else None, - "firmware": { - "major": fw["major"], - "minor": fw["minor"], - "patch": fw.get("patch", 0), - "sha": fw["sha"], + ), + _ListSection( + title="NFC", + key="nfc", + json_parent="hardware", + items=lambda c: c.config.nfc_configs if c.config else [], + line=lambda n: f"NFC {n.instance_number} {_enum_name(NfcIcType, n.nfc_ic_type)} (bus {n.bus_instance})", + entry=lambda n: { + "instance": n.instance_number, + "type": _enum_name(NfcIcType, n.nfc_ic_type), + "bus": n.bus_instance, }, - } + ), + _ListSection( + title="Flash", + key="flash", + json_parent="hardware", + items=lambda c: c.config.flash_configs if c.config else [], + line=lambda f: ( + f"Flash {f.instance_number} {_enum_name(FlashIcType, f.flash_ic_type)} (bus {f.bus_instance})" + ), + entry=lambda f: { + "instance": f.instance_number, + "type": _enum_name(FlashIcType, f.flash_ic_type), + "bus": f.bus_instance, + }, + ), +) + + +# ── rendering ──────────────────────────────────────────────────────────────── + + +def _info_to_json(ctx: _InfoContext) -> dict[str, Any]: + rendered: dict[str, Any] = {"mac": ctx.mac} + for section in _INFO_SECTIONS: + if not section.is_available(ctx): + if section.json_nullable: + rendered[section.key] = None + continue + rendered[section.key] = {field.key: field.value(ctx) for field in section.fields} -def _build_info_tree(data: dict[str, Any]) -> Tree: - mac = data["mac"] - device_name = data["device_name"] - fw = data["fw"] - security = data["security"] - wifi = data["wifi"] + for list_section in _INFO_LIST_SECTIONS: + entries = [list_section.entry(item) for item in list_section.items(ctx)] + target = rendered.setdefault(list_section.json_parent, {}) if list_section.json_parent else rendered + target[list_section.key] = entries - root_label = f"{device_name} ({mac})" if device_name else mac + fw = ctx.fw + rendered["firmware"] = { + "major": fw["major"], + "minor": fw["minor"], + "patch": fw.get("patch", 0), + "sha": fw["sha"], + } + return rendered + + +def _build_info_tree(ctx: _InfoContext) -> Tree: + root_label = f"{ctx.device_name} ({ctx.mac})" if ctx.device_name else ctx.mac tree = Tree(root_label, guide_style="cyan dim") - disp = tree.add("[bold]Display[/bold]") - disp.add(f"Resolution {data['width']}x{data['height']}px") - if data["active_w_mm"] and data["active_h_mm"]: - diag_suffix = f' ({data["diagonal"]:.1f}")' if data["diagonal"] is not None else "" - disp.add(f"Physical {data['active_w_mm']}x{data['active_h_mm']} mm{diag_suffix}") - disp.add(f"Color {_color_scheme_label(data['color_str'])}") - disp.add(f"Rotation {data['rotation']}°") - if data["panel_ic_type"] is not None: - disp.add(f"Panel 0x{data['panel_ic_type']:04x}") - if data["full_update_mc"]: - disp.add(f"Full update {data['full_update_mc']} mC") - if data["transmission_modes"]: - disp.add(f"Transmission {' '.join(data['transmission_modes'])}") - - hw = tree.add("[bold]Hardware[/bold]") - hw.add(f"MCU {data['ic_str']}") - board_str = f"{data['mfr_name'] or 'Unknown'} / {data['board_type_name'] or 'Unknown'}" - if data["board_revision"]: - board_str += f" (rev. {data['board_revision']})" - hw.add(f"Board {board_str}") - if data["leds"]: - leds_branch = hw.add("LEDs") - for led in data["leds"]: - leds_branch.add(f"LED {led.instance_number} {_led_name(led.led_type)}") - if data["sensors"]: - sensors_branch = hw.add("Sensors") - for s in data["sensors"]: - sensors_branch.add(f"Sensor {s.instance_number} {_sensor_name(s.sensor_type)} (bus {s.bus_id})") - if data["binary_inputs"]: - buttons_branch = hw.add("Buttons") - for b in data["binary_inputs"]: - buttons_branch.add(f"Button {b.instance_number} type 0x{b.input_type:02x}") - - pwr = tree.add("[bold]Power[/bold]") - mode_line = data["power_mode_str"] - if data["battery_mah"]: - mode_line += f" {data['battery_mah']} mAh" - if data["cap_str"]: - mode_line += f" ({data['cap_str']})" - pwr.add(f"Mode {mode_line}") - if data["sleep_timeout_ms"] is not None: - sleep_str = "Never" if data["sleep_timeout_ms"] == 0 else f"{data['sleep_timeout_ms'] / 1000:.0f}s" - pwr.add(f"Sleep {sleep_str}") - if data["deep_sleep_time_s"]: - ua_str = f" @ {data['deep_sleep_ua']} µA" if data["deep_sleep_ua"] else "" - pwr.add(f"Deep sleep {data['deep_sleep_time_s']}s{ua_str}") - if data["tx_power"] is not None: - pwr.add(f"TX power {data['tx_power']} dBm") - - if security: - sec = tree.add("[bold]Security[/bold]") - enc_label = "[green]Enabled[/green]" if security.encryption_enabled_flag else "[dim]Disabled[/dim]" - sec.add(f"Encryption {enc_label}") - if security.session_timeout_seconds: - sec.add(f"Session {security.session_timeout_seconds}s") - rewrite_label = "[green]Allowed[/green]" if security.rewrite_allowed else "[red]Denied[/red]" - sec.add(f"Rewrite {rewrite_label}") - - if wifi and wifi.ssid_text: - wf = tree.add("[bold]WiFi[/bold]") - wf.add(f"SSID {wifi.ssid_text}") - if wifi.server_url_text: - wf.add(f"Server {wifi.server_url_text}:{wifi.server_port}") - enc_enum = wifi.encryption_type_enum - enc_str = enc_enum.name if isinstance(enc_enum, WifiEncryption) else f"0x{enc_enum:02x}" - wf.add(f"Encryption {enc_str}") - - tree.add(f"[bold]Firmware[/bold] {fw['major']}.{fw['minor']}.{fw['patch']} [dim](sha: {fw['sha']})[/dim]") + branches: dict[str, Tree] = {} + for section in _INFO_SECTIONS: + if not section.is_available(ctx): + continue + lines = [ + (field.label, field.text(value)) + for field in section.fields + if field.label and field.visible(value := (field.tree_value or field.value)(ctx)) + ] + nested = any(ls.json_parent == section.key and ls.items(ctx) for ls in _INFO_LIST_SECTIONS) + if not lines and not nested: + continue + branch = tree.add(f"[bold]{section.title}[/bold]") + branches[section.key] = branch + for label, text in lines: + branch.add(f"{label:<13} {text}") + + for list_section in _INFO_LIST_SECTIONS: + items = list_section.items(ctx) + if not items: + continue + parent = branches.get(list_section.json_parent or "", tree) + group = parent.add(list_section.title) + for item in items: + group.add(list_section.line(item)) + + fw = ctx.fw + version = f"{fw['major']}.{fw['minor']}.{fw.get('patch', 0)}" + tree.add(f"[bold]Firmware[/bold] {version} [dim](sha: {fw['sha']})[/dim]") return tree @@ -490,65 +919,25 @@ async def _info(device_kwargs: dict[str, Any], output_json: bool) -> None: fw = await device.read_firmware_version() config = device.config display = config.displays[0] if config and config.displays else None - - transmission_modes: list[str] = [] - if display: - for flag, label in [ - (display.supports_streaming_decompression, "STREAMING"), - (display.supports_zip, "ZIP"), - (display.supports_g5, "G5"), - (display.supports_direct_write, "DIRECT_WRITE"), - ]: - if flag: - transmission_modes.append(label) - - ic_type_enum = config.system.ic_type_enum if config else None - power_mode_enum = config.power.power_mode_enum if config else None - cap_est = config.power.capacity_estimator_enum if config else None - - data: dict[str, Any] = { - "mac": device.mac_address, - "device_name": device.device_name, - "fw": fw, - "width": device.width, - "height": device.height, - "color_str": device.color_scheme.name, - "rotation": display.rotation_enum if display else device.rotation, - "active_w_mm": display.active_width_mm if display else None, - "active_h_mm": display.active_height_mm if display else None, - "diagonal": display.screen_diagonal_inches if display else None, - "panel_ic_type": display.panel_ic_type if display else None, - "full_update_mc": display.full_update_mC if display else None, - "transmission_modes": transmission_modes, - "ic_str": ic_type_enum.name - if isinstance(ic_type_enum, ICType) - else (f"0x{ic_type_enum:04x}" if ic_type_enum is not None else "Unknown"), - "power_mode_str": power_mode_enum.name - if isinstance(power_mode_enum, PowerMode) - else (str(power_mode_enum) if power_mode_enum is not None else "Unknown"), - "battery_mah": config.power.battery_mah if config else None, - "cap_str": cap_est.name if isinstance(cap_est, CapacityEstimator) else None, - "sleep_timeout_ms": config.power.sleep_timeout_ms if config else None, - "tx_power": config.power.tx_power if config else None, - "deep_sleep_time_s": config.power.deep_sleep_time_seconds if config else None, - "deep_sleep_ua": config.power.deep_sleep_current_ua if config else None, - "mfr_name": config.manufacturer.manufacturer_name if config else None, - "board_type_name": device.get_board_type_name() if config else None, - "board_revision": config.manufacturer.board_revision if config else None, - "security": config.security_config if config else None, - "wifi": config.wifi_config if config else None, - "leds": config.leds if config else [], - "sensors": config.sensors if config else [], - "binary_inputs": config.binary_inputs if config else [], - } + ctx = _InfoContext( + mac=device.mac_address, + device_name=device.device_name, + fw=fw, + config=config, + width=device.width, + height=device.height, + color_scheme_name=device.color_scheme.name, + rotation=display.rotation_enum if display else device.rotation, + board_type_name=device.get_board_type_name() if config else None, + ) except OpenDisplayError as exc: _handle_ble_error(exc) if output_json: - _stdout.print_json(json.dumps(_info_to_json(data))) + _stdout.print_json(json.dumps(_info_to_json(ctx))) return - _console.print(_build_info_tree(data)) + _console.print(_build_info_tree(ctx)) # ── upload ──────────────────────────────────────────────────────────────────── diff --git a/src/opendisplay/models/enums.py b/src/opendisplay/models/enums.py index 63b14d8..93ce63a 100644 --- a/src/opendisplay/models/enums.py +++ b/src/opendisplay/models/enums.py @@ -210,6 +210,135 @@ class BinaryInputType(IntEnum): ADC_LADDER = 3 # buttons share one ADC pin, distinguished by voltage (polled) +class DisplayTechnology(IntEnum): + """Display technology (DisplayConfig.display_technology).""" + + UNDEFINED = 0 + E_PAPER = 1 + LCD = 2 + LED_MATRIX = 3 + + +# Panel IC id -> human-readable panel description. +# +# Generated from the canonical protocol schema published by the Config Builder +# (Web repo, httpdocs/firmware/toolbox/config.yaml, ble_proto minor_version 4); +# the firmware's own mapping lives in mapEpd(). Ids absent here are still valid +# on newer firmware -- callers should fall back to the raw hex id. +PANEL_IC_NAMES: Final[dict[int, str]] = { + 0: "undefined / unknown panel", + 1: "WFT0420CZ15", + 2: "DEPG0420BN / GDEY042T81", + 3: 'Waveshare 2.13"', + 4: 'GDEY0213B74 (Inky pHAT 2.13" B/W newer)', + 5: "128x296 panel", + 6: 'Waveshare newer 2.9" 1-bit 128x296', + 7: 'harvested Solum 2.9" BW ESLs', + 8: "4-gray variant", + 9: "GDEY0266T90", + 10: "GDEW0102T4", + 11: "GDEY027T91", + 12: "tricolor panel", + 13: "GDEM0122T61", + 14: '1.54" B/W/R', + 15: "400x300 tricolor", + 16: "GDEQ042Z21", + 17: "GDEY037T03", + 18: 'CROWPANEL 3.7"', + 19: "InkyPHAT 2.13 B/W", + 20: "GDEY075T7 (older version)", + 21: "GDEW075T7 (older version) in 4 gray mode", + 22: "GDEY075T7 older panel, darker grays needed", + 23: "Pimoroni Badger2040", + 24: "Pimoroni Badger2040 4-gray", + 25: "Inky pHAT 2.13 B/W/R", + 26: 'Waveshare 2.0" B/W', + 27: "DEPG01540BN", + 28: "GDEY0266F51", + 29: "GDEY029F51", + 30: "GDEY029F51H", + 31: "DEPG0583BN", + 32: 'Waveshare 2.9" 128x296 B/W V2', + 33: 'Solum 2.6" B/W/R', + 34: "GEDY073D46 (slower, EOL 7-color)", + 35: "Spectra 6/7-color 800x480", + 36: "640x384 panel", + 37: "4-bits per pixel panel", + 38: "Waveshare 800x480 3-color", + 39: 'Waveshare 4.26" B/W 800x480', + 40: '4.26" 2-bit grayscale mode', + 41: 'Adafruit 2.9" Tricolor FeatherWing', + 42: "EInk ED040TC1 SPI UC81xx", + 43: 'Spectra 8.1" 1024x576 6-color', + 44: "ED070EC1", + 45: "UC8151 3-color", + 46: 'SSD1680 (CrowPanel 2.9")', + 47: "SSD1680 4-gray", + 48: 'SSD1680 CrowPanel 2.13"', + 49: 'CrowPanel 2.13" 4-gray mode', + 50: 'CrowPanel 1.54"', + 51: 'CrowPanel 5.79"', + 52: "GDEY0213F52", + 53: "GDEM037F51", + 54: "GDEM035F51", + 55: "GDEM0397F81", + 56: "GDEM0154F51H", + 57: "GDEY0266F52H", + 58: "GDEM042F52", + 59: "GEDY075-D2 (Waveshare/Xiao V2 panels)", + 60: "GDEY075T7-D2 (newer version) in 4 gray mode", + 61: 'Waveshare 2.15" 4 color', + 62: "GDEM1085T51", + 63: "GDEQ031T10 LilyGo T-Deck Pro", + 64: "EP75YR_800x480", + 67: 'Waveshare 1.54"', + 68: "DEPG0420BN / GDEY042T81", + 69: "GDEM0397T81P", + 70: "GDEM0397T81P 4-gray", + 71: 'xteink X3 3.68" B/W', + 72: 'xteink X3 3.68" 4-gray', + 73: 'LilyGo T3S3 2.13"', + 74: 'GDEP040E01 Spectra 6 4" 400x600', + 75: "Badger2350 B/W", + 76: "Badger2350 4-gray", + 1000: 'UC8176 4.2" B/W', + 1001: 'SSD1619 4.2" B/W/R', + 1002: 'UC8176 4.2" B/W/R', + 1003: 'SSD1619 4.2" B/W', + 1004: 'JD79668 4.2" B/W/R/Y', + 1005: 'UC8179 7.5" B/W', + 1006: 'UC8179 7.5" B/W/R', + 1007: 'UC8159 7.5" Low Power B/W', + 1008: 'UC8159 7.5" Low Power B/W/R', + 1009: 'SSD1677 7.5" HD B/W', + 1010: 'SSD1677 7.5" HD B/W/R', + 1011: 'JD79665 7.5" B/W/R/Y', + 1012: 'JD79665 5.83" B/W/R/Y', + 1013: 'UC8151 2.9" 168x384 B/W', + 1014: 'UC8151 2.9" 168x384 B/W/R', + 1015: 'SSD1619 2.9" 168x384 B/W', + 1016: 'SSD1619 2.9" 168x384 B/W/R', + 1017: 'SSD1619 1.6" 200x200 B/W', + 1018: 'SSD1619 1.6" 200x200 B/W/R', + 1019: 'SSD1619 2.2" 240x320 B/W', + 1020: 'SSD1619 2.2" 240x320 B/W/R', + 1021: 'SSD1619 2.6" 296x152 B/W', + 1022: 'SSD1619 2.6" 296x152 B/W/R', + 1023: 'UC8151 2.7" 200x300 B/W', + 1024: 'UC8151 2.7" 200x300 B/W/R', + 1025: 'UC variant 4.3" 152x522 B/W', + 1026: 'UC variant 4.3" 152x522 B/W/R', + 1027: 'SSD1619 1.3" 144x200 B/W', + 1028: 'SSD1619 1.3" 144x200 B/W/R', + 1029: 'SSD1619 M3 Lite 2.2" 250x128 B/W', + 1030: 'SSD1619 M3 Lite 2.2" 250x128 B/W/R', + 3000: 'E Ink ED103TC2 + IT8951 (10.3", 1872x1404, 1bpp)', + 3001: "Same panel as 3000", + 3002: "Soldered Inkplate 5 V2 (ED050WROW, 1280x720)", + 3003: "Soldered Inkplate 10 (ED097TC2, 1200x825)", +} + + MANUFACTURER_NAMES: Final[dict[BoardManufacturer, str]] = { BoardManufacturer.DIY: "DIY", BoardManufacturer.SEEED: "Seeed Studio", diff --git a/tests/unit/test_cli_info_report.py b/tests/unit/test_cli_info_report.py new file mode 100644 index 0000000..d8d571c --- /dev/null +++ b/tests/unit/test_cli_info_report.py @@ -0,0 +1,515 @@ +"""Tests for the `opendisplay info` report. + +The renderer is driven by a declarative section spec (``_INFO_SECTIONS``) so +that packets added to ``GlobalConfig`` surface without a hand-written branch per +field. These tests pin two things: that every populated packet reaches both the +tree and the ``--json`` output, and that packets absent from a device's config +stay absent from the report. +""" + +from __future__ import annotations + +from typing import Any + +import pytest +from rich.console import Console + +from opendisplay.cli import _build_info_tree, _info_to_json, _InfoContext +from opendisplay.models.config import ( + DataBus, + DataExtended, + DisplayConfig, + FlashConfig, + GlobalConfig, + LedConfig, + ManufacturerData, + NfcConfig, + PassiveBuzzer, + PowerOption, + SensorData, + SystemConfig, + TouchController, + WifiConfig, +) +from opendisplay.models.enums import LedType + +_FW = {"major": 2, "minor": 26, "patch": 0, "sha": "987c6d9"} + + +def _display(**overrides: Any) -> DisplayConfig: + base: dict[str, Any] = dict( + instance_number=0, + display_technology=1, + panel_ic_type=0x0BB8, + pixel_width=1872, + pixel_height=1404, + active_width_mm=209, + active_height_mm=157, + tag_type=0, + rotation=0, + reset_pin=0x0C, + busy_pin=0x0D, + dc_pin=0x08, + cs_pin=0x0A, + data_pin=0x09, + partial_update_support=1, + color_scheme=0, + transmission_modes=0x19, # STREAMING | DIRECT_WRITE | PIPE_WRITE + clk_pin=0x07, + reserved_pins=b"\xff" * 7, + full_update_mC=0, + reserved=b"\x00" * 13, + ) + base.update(overrides) + return DisplayConfig(**base) + + +def _config(**overrides: Any) -> GlobalConfig: + base: dict[str, Any] = dict( + system=SystemConfig( + ic_type=2, + communication_modes=0x05, + device_flags=0, + pwr_pin=0xFF, + reserved=b"\x00" * 16, + pwr_pin_2=0, + pwr_pin_3=0, + ), + manufacturer=ManufacturerData( + manufacturer_id=1, + board_type=1, + board_revision=1, + reserved=b"\x00" * 14, + simple_config_driver_index=0, + simple_config_display_index=0, + simple_config_power_index=0, + simple_config_configured_at=0, + ), + power=PowerOption( + power_mode=1, + battery_capacity_mah=(3000).to_bytes(3, "little"), + sleep_timeout_ms=40000, + tx_power=8, + sleep_flags=0, + battery_sense_pin=1, + battery_sense_enable_pin=0x28, + battery_sense_flags=0, + capacity_estimator=5, + voltage_scaling_factor=0xA1, + deep_sleep_current_ua=0, + deep_sleep_time_seconds=0, + charge_enable_pin=0, + charge_state_pin=0, + charger_flags=0, + min_wake_time_seconds=0, + screen_timeout_seconds=0, + reserved=b"\x00" * 4, + ), + displays=[_display()], + ) + base.update(overrides) + return GlobalConfig(**base) + + +def _ctx(config: GlobalConfig | None) -> _InfoContext: + return _InfoContext( + mac="AA:BB:CC:DD:EE:FF", + device_name="OD405BD8", + fw=_FW, + config=config, + width=1872, + height=1404, + color_scheme_name="MONO", + rotation=0, + board_type_name="reTerminal E1003", + ) + + +def _text(config: GlobalConfig | None) -> str: + console = Console(width=120, record=True, force_terminal=False) + console.print(_build_info_tree(_ctx(config))) + return console.export_text() + + +# ── transmission modes ─────────────────────────────────────────────────────── + + +def test_pipe_write_is_reported_when_advertised() -> None: + assert "PIPE_WRITE" in _text(_config()) + + +def test_pipe_write_is_absent_when_not_advertised() -> None: + cfg = _config(displays=[_display(transmission_modes=0x09)]) + assert "PIPE_WRITE" not in _text(cfg) + + +def test_transmission_modes_in_json_include_pipe_write() -> None: + assert "PIPE_WRITE" in _info_to_json(_ctx(_config()))["display"]["transmission_modes"] + + +# ── display capability fields ──────────────────────────────────────────────── + + +def test_partial_update_support_is_reported() -> None: + assert "Partial" in _text(_config()) + + +def test_partial_update_full_frame_is_distinguished_from_line_rect() -> None: + line_rect = _text(_config(displays=[_display(partial_update_support=1)])) + full_frame = _text(_config(displays=[_display(partial_update_support=2)])) + assert line_rect != full_frame + + +# ── packets that were previously never rendered ────────────────────────────── + + +def test_touch_controller_is_reported() -> None: + tc = TouchController( + instance_number=0, + touch_ic_type=1, + bus_id=0, + i2c_addr_7bit=0x14, + int_pin=0x02, + rst_pin=0x30, + display_instance=0, + flags=0, + poll_interval_ms=100, + touch_data_start_byte=1, + reserved=b"\x00" * 21, + ) + out = _text(_config(touch_controllers=[tc])) + assert "Touch" in out + assert "0x14" in out + + +def test_passive_buzzer_is_reported() -> None: + bz = PassiveBuzzer( + instance_number=0, + drive_pin=0x2D, + enable_pin=0xFF, + flags=0x01, + duty_percent=50, + reserved=b"\x00" * 27, + ) + assert "Buzzer" in _text(_config(buzzers=[bz])) + + +def _c_str(value: str) -> bytes: + """Encode as the firmware does: 32-byte null-terminated, zero-padded.""" + return value.encode().ljust(32, b"\x00") + + +def test_data_extended_identity_strings_are_reported() -> None: + ext = DataExtended( + manufacturer_name=_c_str("Seeed Studio"), + model_name=_c_str("reTerminal E1003"), + serial_number=_c_str("SN-12345"), + friendly_name=_c_str("Kitchen display"), + device_location=_c_str("Kitchen"), + device_id=_c_str("dev-1"), + ) + out = _text(_config(data_extended=ext)) + assert "SN-12345" in out + assert "Kitchen display" in out + + +def test_identity_strings_are_decoded_not_raw_bytes() -> None: + """The packet holds 32-byte buffers; the report must show text, not repr().""" + ext = DataExtended(model_name=_c_str("reTerminal E1003")) + out = _text(_config(data_extended=ext)) + assert "\\x00" not in out + assert "b'" not in out + + +def test_empty_identity_fields_are_omitted() -> None: + """An all-zero buffer means 'unset' and must not produce a line.""" + ext = DataExtended(model_name=_c_str("reTerminal E1003")) + out = _text(_config(data_extended=ext)) + assert "Serial" not in out + assert "Location" not in out + + +# ── human-readable values ──────────────────────────────────────────────────── + + +def test_panel_ic_type_is_named_not_just_hex() -> None: + """0x0bb8 (3000) is the ED103TC2; a bare hex id tells a reader nothing.""" + out = _text(_config()) + assert "ED103TC2" in out.upper() + + +def test_unknown_panel_ic_type_falls_back_to_hex() -> None: + cfg = _config(displays=[_display(panel_ic_type=0xABCD)]) + assert "0xabcd" in _text(cfg) + + +def test_display_technology_is_named() -> None: + assert "E_PAPER" in _text(_config()).upper() + + +def test_button_input_type_is_named_not_hex() -> None: + from opendisplay.models.config import BinaryInputs + + button = BinaryInputs( + instance_number=0, + input_type=1, + display_as=0, + reserved_pins=b"\xff" * 3, + input_flags=0, + invert=0, + pullups=0, + pulldowns=0, + button_data_byte_index=0, + power_off_flags=0, + power_off_hold_sec=0, + reserved=b"\x00" * 12, + ) + out = _text(_config(binary_inputs=[button])) + assert "DIGITAL" in out.upper() + assert "type 0x01" not in out + + +def test_full_frame_partial_support_is_spelled_out() -> None: + cfg = _config(displays=[_display(partial_update_support=2)]) + assert "full-frame" in _text(cfg).lower() + + +# ── display resolution ─────────────────────────────────────────────────────── + + +def test_resolution_reports_both_dimensions() -> None: + assert "1872x1404px" in _text(_config()) + + +def test_data_bus_is_reported() -> None: + bus = DataBus( + instance_number=0, + bus_type=1, + pin_1=0x14, + pin_2=0x13, + pin_3=0, + pin_4=0, + pin_5=0, + pin_6=0, + pin_7=0, + bus_speed_hz=400000, + bus_flags=0, + pullups=3, + pulldowns=0, + reserved=b"\x00" * 8, + ) + assert "Bus" in _text(_config(data_buses=[bus])) + + +def test_nfc_config_is_reported() -> None: + nfc = NfcConfig( + instance_number=0, + nfc_ic_type=1, + bus_instance=0, + flags=0, + field_detect_pin=0xFF, + field_detect_mode=0, + field_detect_active=0, + field_detect_debounce_ms=0, + power_pin=0xFF, + power_active=0, + power_on_delay_ms=0, + power_off_delay_ms=0, + adv_button_byte_index=0, + adv_button_button_id=0, + reserved_pin_1=0xFF, + reserved_pin_2=0xFF, + reserved=b"\x00" * 14, + ) + assert "NFC" in _text(_config(nfc_configs=[nfc])) + + +def test_flash_config_is_reported() -> None: + flash = FlashConfig( + instance_number=0, + flash_ic_type=1, + bus_instance=0, + flags=0, + mosi_pin=0x10, + sck_pin=0x11, + cs_pin=0x12, + power_pin=0xFF, + power_active=0, + power_on_delay_ms=0, + power_off_delay_ms=0, + mode=0, + reserved=b"\x00" * 17, + ) + assert "Flash" in _text(_config(flash_configs=[flash])) + + +# ── absent packets stay absent ─────────────────────────────────────────────── + + +@pytest.mark.parametrize("label", ["Touch", "Buzzer", "NFC", "Flash"]) +def test_absent_packets_are_not_rendered(label: str) -> None: + assert label not in _text(_config()) + + +def test_absent_packets_are_not_in_json() -> None: + rendered = _info_to_json(_ctx(_config())) + for key in ("touch_controllers", "buzzers", "nfc", "flash"): + assert rendered.get(key) in (None, []) + + +# ── JSON back-compatibility ────────────────────────────────────────────────── + + +def test_existing_json_keys_are_preserved() -> None: + rendered = _info_to_json(_ctx(_config())) + assert set(rendered) >= {"mac", "display", "hardware", "power", "firmware"} + assert set(rendered["display"]) >= { + "width", + "height", + "active_width_mm", + "active_height_mm", + "diagonal_inches", + "color_scheme", + "rotation", + "panel_ic_type", + "full_update_mc", + "transmission_modes", + } + assert set(rendered["hardware"]) >= { + "ic", + "manufacturer", + "board_type", + "board_revision", + "leds", + "sensors", + "buttons", + } + assert set(rendered["power"]) >= {"mode", "battery_mah", "chemistry", "sleep_timeout_s", "tx_power_dbm"} + assert rendered["firmware"] == {"major": 2, "minor": 26, "patch": 0, "sha": "987c6d9"} + + +def test_sleep_timeout_json_is_seconds_not_milliseconds() -> None: + """The key is `_s`; the packet stores ms. Scripts parsing this must not break.""" + assert _info_to_json(_ctx(_config()))["power"]["sleep_timeout_s"] == 40.0 + + +def test_zero_valued_power_fields_are_null_in_json() -> None: + """Preserves the pre-existing `or None` behaviour these keys shipped with.""" + power = _info_to_json(_ctx(_config()))["power"] + assert power["deep_sleep_time_s"] is None + assert power["deep_sleep_current_ua"] is None + + +def test_deep_sleep_json_is_numeric_seconds() -> None: + cfg = _config() + cfg.power.deep_sleep_time_seconds = 30 + cfg.power.deep_sleep_current_ua = 12 + assert _info_to_json(_ctx(cfg))["power"]["deep_sleep_time_s"] == 30 + + +# ── LEDs and sensors ───────────────────────────────────────────────────────── + + +def _led(led_type: int) -> LedConfig: + return LedConfig( + instance_number=0, + led_type=led_type, + led_1_r=0, + led_2_g=0, + led_3_b=0, + led_4=0, + led_flags=0, + reserved=b"\x00" * 14, + ) + + +def test_led_type_is_named() -> None: + assert "RGB" in _text(_config(leds=[_led(LedType.RGB)])) + + +def test_unknown_led_type_falls_back_to_hex() -> None: + assert "0x7f" in _text(_config(leds=[_led(0x7F)])) + + +def test_sensor_is_reported_with_bus() -> None: + sensor = SensorData( + instance_number=0, + sensor_type=1, + bus_id=2, + i2c_addr_7bit=0x44, + msd_data_start_byte=0, + reserved=b"\x00" * 24, + ) + out = _text(_config(sensors=[sensor])) + assert "Sensor 0" in out + assert "bus 2" in out + + +# ── WiFi section ───────────────────────────────────────────────────────────── + + +def _wifi(**overrides: Any) -> WifiConfig: + base: dict[str, Any] = dict( + ssid="Antarctica-IOT", + password="secret", + encryption_type=3, + server_url="", + server_port=2446, + ) + base.update(overrides) + return WifiConfig.from_strings(**base) + + +def test_wifi_section_reports_ssid_and_encryption() -> None: + out = _text(_config(wifi_config=_wifi())) + assert "Antarctica-IOT" in out + assert "WPA2" in out + + +def test_wifi_section_is_absent_without_an_ssid() -> None: + assert "SSID" not in _text(_config(wifi_config=_wifi(ssid=""))) + + +def test_wifi_server_is_shown_when_configured() -> None: + out = _text(_config(wifi_config=_wifi(server_url="hub.local", server_port=2447))) + assert "hub.local:2447" in out + + +def test_wifi_server_is_omitted_when_unset() -> None: + assert "Server" not in _text(_config(wifi_config=_wifi())) + + +def test_unknown_wifi_encryption_falls_back_to_hex() -> None: + assert "0x7f" in _text(_config(wifi_config=_wifi(encryption_type=0x7F))) + + +# ── deep sleep ─────────────────────────────────────────────────────────────── + + +def test_deep_sleep_line_includes_current_draw() -> None: + cfg = _config() + cfg.power.deep_sleep_time_seconds = 30 + cfg.power.deep_sleep_current_ua = 12 + assert "30s @ 12 µA" in _text(cfg) + + +def test_deep_sleep_line_omits_current_when_unknown() -> None: + cfg = _config() + cfg.power.deep_sleep_time_seconds = 30 + assert "30s" in _text(cfg) + assert "µA" not in _text(cfg) + + +# ── unknown enum fallbacks ─────────────────────────────────────────────────── + + +def test_unknown_partial_update_value_falls_back_to_hex() -> None: + cfg = _config(displays=[_display(partial_update_support=0x7F)]) + assert "0x7f" in _text(cfg) + + +def test_report_survives_missing_config() -> None: + """A device that never returned a config still renders MAC and firmware.""" + out = _text(None) + assert "OD405BD8" in out + assert "2.26" in out