Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/opendisplay/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,12 @@
AdvertisementTracker,
ButtonChangeEvent,
ButtonEventData,
Sht40Reading,
TouchChangeEvent,
TouchEventData,
TouchTracker,
decode_button_event,
decode_sht40,
parse_advertisement,
)
from .models.buzzer_activate import BuzzerActivateConfig, BuzzerPattern, BuzzerStep, note_to_index
Expand Down Expand Up @@ -79,6 +81,7 @@
RefreshMode,
Rotation,
SeeedBoardType,
SensorType,
SolumBoardType,
TouchIcType,
WaveshareBoardType,
Expand All @@ -90,6 +93,7 @@
from .ota import find_nrf_dfu_device, perform_nrf_dfu, perform_silabs_ota
from .partial import PartialState
from .protocol import MANUFACTURER_ID, SERVICE_UUID
from .sensors import SensorReading, read_sensor_values
from .transport import BleTransport, TcpTransport, Transport

__version__ = "0.1.0"
Expand Down Expand Up @@ -146,6 +150,9 @@
"firmware_ota_asset",
"firmware_release_repo",
"SensorData",
"SensorReading",
"SensorType",
"Sht40Reading",
"DataBus",
"BinaryInputs",
"PassiveBuzzer",
Expand Down Expand Up @@ -189,7 +196,9 @@
"get_board_type_name",
"get_manufacturer_name",
# Utilities
"decode_sht40",
"parse_advertisement",
"read_sensor_values",
"decode_button_event",
"voltage_to_percent",
"build_landing_url",
Expand Down
82 changes: 76 additions & 6 deletions src/opendisplay/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import os
import sys
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, NoReturn, TypeVar

Expand All @@ -18,7 +18,7 @@
from rich.console import Console
from rich.live import Live
from rich.logging import RichHandler
from rich.progress import BarColumn, Progress, SpinnerColumn, TaskProgressColumn, TextColumn
from rich.progress import BarColumn, Progress, SpinnerColumn, TaskID, TaskProgressColumn, TextColumn
from rich.table import Table
from rich.tree import Tree

Expand All @@ -32,7 +32,7 @@
BLETimeoutError,
OpenDisplayError,
)
from .models.config import GlobalConfig
from .models.config import GlobalConfig, SensorData
from .models.enums import (
PANEL_IC_NAMES,
BinaryInputType,
Expand All @@ -54,6 +54,9 @@
)
from .models.firmware import FirmwareVersion
from .partial import PartialState
from .sensors import SensorReading

_LOGGER = logging.getLogger(__name__)

_T = TypeVar("_T")

Expand Down Expand Up @@ -337,6 +340,51 @@ def _sensor_name(sensor_type: int) -> str:
return _enum_name(SensorType, sensor_type, digits=4) or ""


@dataclass(frozen=True)
class _SensorRow:
"""A configured sensor paired with its current reading, if it has one."""

sensor: SensorData
reading: SensorReading | None


def _sensor_rows(ctx: _InfoContext) -> list[_SensorRow]:
"""Pair each configured sensor with its live reading."""
if not ctx.config:
return []
return [
_SensorRow(sensor=sensor, reading=ctx.sensor_readings.get(sensor.instance_number))
for sensor in ctx.config.sensors
]


def _sensor_line(row: _SensorRow) -> str:
"""Tree line for one sensor, with live values appended when available."""
sensor = row.sensor
line = f"Sensor {sensor.instance_number} {_sensor_name(sensor.sensor_type)} (bus {sensor.bus_id})"
values = [
f"{value:.1f} {unit}"
for value, unit in (
(row.reading.temperature_c if row.reading else None, "°C"),
(row.reading.humidity_percent if row.reading else None, "%RH"),
)
if value is not None
]
return f"{line} {' '.join(values)}" if values else line


def _sensor_entry(row: _SensorRow) -> dict[str, Any]:
"""JSON object for one sensor; live values are null when unavailable."""
sensor = row.sensor
return {
"instance": sensor.instance_number,
"type": _sensor_name(sensor.sensor_type),
"bus": sensor.bus_id,
"temperature_c": row.reading.temperature_c if row.reading else None,
"humidity_percent": row.reading.humidity_percent if row.reading else None,
}


@dataclass(frozen=True)
class _InfoContext:
"""Everything the info report renders, gathered once from the device."""
Expand All @@ -350,6 +398,8 @@ class _InfoContext:
color_scheme_name: str
rotation: Any
board_type_name: str | None
# Live sensor values by instance number; empty when none could be read.
sensor_readings: dict[int, SensorReading] = field(default_factory=dict)

@property
def display(self) -> Any:
Expand Down Expand Up @@ -752,9 +802,9 @@ def _bus_line(bus: Any) -> str:
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},
items=_sensor_rows,
line=_sensor_line,
entry=_sensor_entry,
),
_ListSection(
title="Buttons",
Expand Down Expand Up @@ -910,6 +960,25 @@ def _cmd_info(args: argparse.Namespace) -> None:
_run(_info(_device_kwargs(args.device, key, args.timeout, args.host, args.port, args.tls), args.output_json))


async def _read_sensors(device: OpenDisplayDevice, progress: Progress, task: TaskID) -> dict[int, SensorReading]:
"""Read live sensor values, keyed by instance number.

Best-effort: firmware too old for READ_MSD (0x0044) still reports its sensor
hardware, just without values, so a failure here degrades the report rather
than failing the command.
"""
if not device.config or not device.config.sensors:
return {}

progress.update(task, description="Reading sensors...")
try:
readings = await device.read_sensors()
except OpenDisplayError as exc:
_LOGGER.debug("Could not read sensor values: %s", exc)
return {}
return {reading.instance_number: reading for reading in readings}


async def _info(device_kwargs: dict[str, Any], output_json: bool) -> None:
try:
with _spinner() as progress:
Expand All @@ -929,6 +998,7 @@ async def _info(device_kwargs: dict[str, Any], output_json: bool) -> None:
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,
sensor_readings=await _read_sensors(device, progress, task),
)
except OpenDisplayError as exc:
_handle_ble_error(exc)
Expand Down
40 changes: 40 additions & 0 deletions src/opendisplay/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
TruncatedConfigError,
)
from .landing import build_landing_url
from .models.advertisement import AdvertisementData, parse_advertisement
from .models.buzzer_activate import BuzzerActivateConfig
from .models.capabilities import DeviceCapabilities
from .models.config import GlobalConfig
Expand Down Expand Up @@ -108,6 +109,7 @@
build_pipe_write_start_command,
build_read_config_command,
build_read_fw_version_command,
build_read_msd_command,
build_reboot_command,
build_write_config_command,
classify_pipe_frame,
Expand Down Expand Up @@ -135,10 +137,12 @@
is_compressed_failure_frame,
parse_authenticate_challenge,
parse_authenticate_success,
parse_read_msd,
strip_command_echo,
unpack_command_code,
validate_nfc_response,
)
from .sensors import SensorReading, read_sensor_values
from .transport import BLEConnection, TcpTransport, Transport

if TYPE_CHECKING:
Expand Down Expand Up @@ -1140,6 +1144,42 @@ async def read_firmware_version(self) -> FirmwareVersion:

return self._fw_version

@_serialized
async def read_msd(self) -> AdvertisementData:
"""Read the device's manufacturer-specific data record.

Returns the same 16 bytes the device broadcasts in its advertisement,
but over the open connection -- so it works on transports where no BLE
advertisement is observable, and needs no scan.

Returns:
The parsed record, including the dynamic block that carries live
sensor readings. Use :meth:`read_sensors` to decode those.

Raises:
InvalidResponseError: If the device returns a malformed record
"""
_LOGGER.debug("Reading MSD from device %s", self.mac_address)

await self._write(build_read_msd_command())
response = await self._read(self.TIMEOUT_ACK)

return parse_advertisement(parse_read_msd(response))

async def read_sensors(self) -> list[SensorReading]:
"""Read live values from the device's configured sensors.

Combines the device config (which sensors exist, and where each one's
bytes sit in the MSD) with a fresh :meth:`read_msd`.

Returns:
One entry per sensor with a usable reading. Empty when the device
has no sensors configured, or none currently has a valid reading.
"""
if not self.config or not self.config.sensors:
return []
return read_sensor_values(self.config, await self.read_msd())

@_serialized
async def reboot(self) -> None:
"""Reboot the device.
Expand Down
4 changes: 4 additions & 0 deletions src/opendisplay/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
AdvertisementTracker,
ButtonChangeEvent,
ButtonEventData,
Sht40Reading,
decode_button_event,
decode_sht40,
parse_advertisement,
)
from .buzzer_activate import BuzzerActivateConfig, BuzzerPattern, BuzzerStep, note_to_index
Expand Down Expand Up @@ -59,6 +61,8 @@
"AdvertisementTracker",
"ButtonChangeEvent",
"ButtonEventData",
"Sht40Reading",
"decode_sht40",
"parse_advertisement",
"decode_button_event",
"ActiveLevel",
Expand Down
Loading