Skip to content

feat: host-side helpers so callers stop reimplementing the protocol - #158

Closed
g4bri3lDev wants to merge 3 commits into
mainfrom
feat/host-side-helpers
Closed

feat: host-side helpers so callers stop reimplementing the protocol#158
g4bri3lDev wants to merge 3 commits into
mainfrom
feat/host-side-helpers

Conversation

@g4bri3lDev

Copy link
Copy Markdown
Member

Hosts driving OpenDisplay devices have had to reimplement pieces of the protocol this library already knew: packing an LED colour, sizing an NDEF record, formatting a firmware version to match its release tag, parsing a stored encryption key, and working out when a sleeping device is reachable. Each reimplementation is somewhere a host can drift from the firmware — and the library had drifted from itself, which is what prompted two of these commits.

New API

All additive; no existing signature changes meaning.

  • pack_led_color / unpack_led_color / ms_to_loop_delay_units / ms_to_inter_delay_units, plus LedFlashStep.from_rgb() and .rgb
  • build_nfc_payload() — the same payload write_nfc_* sends, so a record can be validated before a connection is spent on it
  • format_firmware_version() — the single definition of how a version maps to a release tag
  • supports_ble_ota_install() — deliberately narrower than "an asset exists": it answers whether a host should offer the install over a Bluetooth proxy, which nRF Legacy DFU cannot survive
  • parse_encryption_key() and a typed InvalidEncryptionKeyError
  • BinaryInputs.enabled_button_ids, DisplayConfig.canvas_size(), DisplayConfig.supports_compression
  • SleepModel — the post-wake window and whether a device has gone dark again, with host-side slack passed in rather than assumed
  • prepare_image(compress=None) derives compression from the device config

Two user-visible changes

  • opendisplay info always printed three version parts, showing 1.6.0 for a device whose release tag is 1.6. It now formats through the library, so the CLI, a host's device registry and the tag all agree. --json keeps its separate major/minor/patch fields, which is a machine-readable contract rather than a rendering.
  • encryption_key accepts hex as well as raw bytes, and a malformed key now raises InvalidEncryptionKeyError at construction instead of TypeError: key must be bytes-like inside authenticate() — after the connection had already been made. On a deep-sleeping device that cost a whole wake window to learn the key was the wrong type. Raw bytes of the wrong length are rejected up front too, rather than reaching AES and failing there.

Internal call sites now use these

The library no longer keeps its own copies of the NFC payload assembly, the compression capability check, the firmware version format, or the key parser. cli.py's _parse_hex_key is gone; it accepted colon- and space-separated hex, so parse_encryption_key accepts those too and nothing regresses.

Firmware verification

The 10 s default wake window and the sleep-entry condition were checked against firmware upstream/main rather than taken from comments: DEFAULT_IDLE_HOLD_MS in src/main.h, and the gate in platformIdle() (src/main.cpp). That also surfaced a nuance worth encoding — the window is measured from the last activity and re-armed by it, and a button wake holds it open for min_wake_time_seconds regardless. So probably_asleep() is documented as a prediction that errs towards asleep, not a fact.

Verification

uv run pytest — 1150 passed (was 1071).
uv run prek run --all-files — ruff, ruff-format, mypy strict and pylint all pass, idempotent on re-run.

Hosts driving OpenDisplay devices have had to reimplement pieces of the
protocol the library already knew: packing an LED colour, sizing an NDEF
record, formatting a firmware version to match its release tag, parsing a
stored encryption key, and working out when a sleeping device is reachable.
Every reimplementation is somewhere a host can drift from the firmware.

All additive; no existing signature changes meaning.

- pack_led_color / unpack_led_color / ms_to_loop_delay_units /
  ms_to_inter_delay_units, plus LedFlashStep.from_rgb() and .rgb so callers
  can work in RGB and milliseconds instead of the packed wire encoding
- build_nfc_payload(), the same payload write_nfc_* sends, so a record can be
  validated before a connection is spent on it
- format_firmware_version(), the single definition of how a version maps to a
  release tag. A host that formats it differently from the tag it compares
  against shows a permanently pending update
- supports_ble_ota_install(), deliberately narrower than "an asset exists":
  it answers whether a host should offer the install over a Bluetooth proxy,
  which nRF Legacy DFU cannot survive
- parse_encryption_key(), accepting a key however it was pasted, raising a
  typed InvalidEncryptionKeyError
- BinaryInputs.enabled_button_ids and DisplayConfig.canvas_size()
- SleepModel: the post-wake window and whether a device has gone dark again,
  with host-side slack passed in rather than assumed
- prepare_image(compress=None) derives compression from the device config

Internal call sites now use these too, so the library no longer keeps its own
copies of the NFC payload assembly or the compression capability check.

Verified against firmware upstream/main: the 10 s default wake window is
DEFAULT_IDLE_HOLD_MS in src/main.h, and the sleep-entry condition is the one
in platformIdle(). The window is re-armed by activity and held open by a
button wake, so probably_asleep() is documented as a prediction that errs
towards asleep rather than a fact.
`opendisplay info` always printed three version parts, so a device on the 1.6
release showed as 1.6.0 and never matched the tag it came from. It now formats
through the library, so the CLI, a host's device registry and the GitHub tag
all agree on what a device is running.

The --json output is unchanged: it still reports separate major, minor and
patch fields, which is a machine-readable contract rather than a rendering.

--key now goes through the library's parser as well. It accepts everything it
accepted before, including colon- and space-separated hex, and reports a
malformed key with the same wording every other host will use.
OpenDisplayDevice took encryption_key as bytes. A hex string, which is how
every host actually stores a key, was accepted silently and then failed inside
authenticate() with "TypeError: key must be bytes-like" - after the connection
had already been made. On a deep-sleeping device that cost a whole wake window
to learn the key was the wrong type, and the error named neither the parameter
nor the fix.

encryption_key (and authenticate()) now take the 16 raw bytes or 32 hex
characters, in any capitalization and with optional colon or space separators.
The value is normalized and validated in the constructor, so a malformed key
raises InvalidEncryptionKeyError before a single frame is sent and can never
be mistaken for a device refusing a good key.

parse_encryption_key() widens the same way, so a host holding either form can
pass it straight through instead of branching. Raw bytes of the wrong length
are now rejected too, rather than reaching AES and failing there.
@codecov

codecov Bot commented Aug 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.74436% with 3 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/opendisplay/cli.py 50.00% 3 Missing ⚠️

📢 Thoughts on this report? Let us know!

@g4bri3lDev g4bri3lDev closed this Aug 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant