From 8985ebd2b9514f8efc204489acbcfbee8693b478 Mon Sep 17 00:00:00 2001 From: Alex J Lennon Date: Sat, 18 Jul 2026 20:11:00 +0100 Subject: [PATCH 1/2] python3-improv: expose device info + network status over BLE (imx93-jaguar-eink) Add the SIG Device Information Service (0x180A: manufacturer, model, full SoC UID as Serial Number, firmware/hardware/software revisions) and a Dynamic Devices vendor Network Status characteristic (versioned JSON: state/ssid/ipv4/rssi/iface; read+notify). DIS/vendor services are added after the Improv service so only Improv is advertised (bless advertises services[0]), keeping the legacy advert within budget. Identity is auto-detected (soc0 serial, device-tree model, os-release) and env-overridable for multi-board reuse. Network status is cached and refreshed off the BLE event loop, notifying on Wi-Fi connect. Link state is derived from NetworkManager's numeric device-state code (avoids the "disconnected" contains "connected" substring trap); SSID/RSSI are read only when connected, with an SSID fallback to the active connection. improv.service now advertises eink- (IMPROV_SERVICE_NAME override removed) and sets IMPROV_SERVER_HOST to the live onboarding API. Co-authored-by: Cursor --- .../imx93-jaguar-eink/improv.service | 11 +- .../imx93-jaguar-eink/onboarding-server.py | 273 ++++++++++++++++++ 2 files changed, 283 insertions(+), 1 deletion(-) diff --git a/recipes-devtools/python/python3-improv/imx93-jaguar-eink/improv.service b/recipes-devtools/python/python3-improv/imx93-jaguar-eink/improv.service index f366e510..b6f04e69 100644 --- a/recipes-devtools/python/python3-improv/imx93-jaguar-eink/improv.service +++ b/recipes-devtools/python/python3-improv/imx93-jaguar-eink/improv.service @@ -11,8 +11,17 @@ ExecStart=/usr/share/improv/onboarding-server.py Restart=always RestartSec=12 Environment="IMPROV_WIFI_INTERFACE=wlan0" -Environment="IMPROV_SERVICE_NAME=Improv-Eink" +# Advertise as eink- (SoC serial suffix). This is the default in +# onboarding-server.py; it stays <=10 chars (BLE UUID advertising limit) and is +# what the Active-ESL Onboard app matches to identify the board and its id. +# Environment="IMPROV_SERVICE_NAME=Improv-Eink" Environment="IMPROV_CONNECTION_NAME=improv-eink" +# Host used to build the Improv success URL: https://${IMPROV_SERVER_HOST}?ip_address=..&token=.. +# The Active-ESL Onboard app reads the token over BLE to claim the device; this +# also aligns the URL host for a web/QR onboarding fallback. +# Live Active-ESL onboarding API (dedicated Cloudflare account, workers.dev URL for +# now). TODO: switch to the custom domain onboard.active-esl.com when it's set up. +Environment="IMPROV_SERVER_HOST=active-esl-onboard.active-esl.workers.dev" [Install] WantedBy=default.target diff --git a/recipes-devtools/python/python3-improv/imx93-jaguar-eink/onboarding-server.py b/recipes-devtools/python/python3-improv/imx93-jaguar-eink/onboarding-server.py index 154939a7..89b074f8 100644 --- a/recipes-devtools/python/python3-improv/imx93-jaguar-eink/onboarding-server.py +++ b/recipes-devtools/python/python3-improv/imx93-jaguar-eink/onboarding-server.py @@ -23,10 +23,34 @@ import os import re import subprocess +import json +import socket +import struct +import fcntl logging.basicConfig(level=logging.DEBUG) logger = logging.getLogger(name=__name__) +# Version of this onboarding server; exposed as DIS Software Revision (0x2A28). +__version__ = "1.1.0" + +# --- Device Information Service (SIG standard, 0x180A) ------------------------ +# Full 128-bit forms so bless/BlueZ resolves characteristics by UUID. +DIS_SERVICE_UUID = "0000180a-0000-1000-8000-00805f9b34fb" +DIS_MANUFACTURER_UUID = "00002a29-0000-1000-8000-00805f9b34fb" +DIS_MODEL_UUID = "00002a24-0000-1000-8000-00805f9b34fb" +DIS_SERIAL_UUID = "00002a25-0000-1000-8000-00805f9b34fb" +DIS_FW_REV_UUID = "00002a26-0000-1000-8000-00805f9b34fb" +DIS_HW_REV_UUID = "00002a27-0000-1000-8000-00805f9b34fb" +DIS_SW_REV_UUID = "00002a28-0000-1000-8000-00805f9b34fb" + +# --- Dynamic Devices vendor Network Status service --------------------------- +# No SIG standard exists for live IP/SSID/link state, so use a vendor service. +# Base UUID generated once and reused across the board range: service ...-0001, +# network-status characteristic ...-0002 (room for future characteristics). +NET_STATUS_SERVICE_UUID = "e5f10001-9d3a-4b7c-8a21-6f2c9b4d7e10" +NET_STATUS_CHAR_UUID = "e5f10002-9d3a-4b7c-8a21-6f2c9b4d7e10" + trigger: Union[asyncio.Event, threading.Event] if sys.platform in ["darwin", "win32"]: trigger = threading.Event() @@ -67,6 +91,34 @@ def build_gatt(): }, } } + + # Device Information Service (identity), read-only. Added AFTER the Improv + # service so bless advertises only services[0] (Improv); DIS and the vendor + # service are discovered post-connect but are not advertised (keeping the + # legacy advertisement within the 31-byte budget). + def _ro(): + return { + "Properties": GATTCharacteristicProperties.read, + "Permissions": GATTAttributePermissions.readable, + } + + gatt[DIS_SERVICE_UUID] = { + DIS_MANUFACTURER_UUID: _ro(), + DIS_MODEL_UUID: _ro(), + DIS_SERIAL_UUID: _ro(), + DIS_FW_REV_UUID: _ro(), + DIS_HW_REV_UUID: _ro(), + DIS_SW_REV_UUID: _ro(), + } + + # Vendor Network Status service, read + notify, carrying a JSON snapshot. + gatt[NET_STATUS_SERVICE_UUID] = { + NET_STATUS_CHAR_UUID: { + "Properties": (GATTCharacteristicProperties.read | + GATTCharacteristicProperties.notify), + "Permissions": GATTAttributePermissions.readable, + }, + } return gatt """ @@ -95,6 +147,58 @@ def get_board_id(): logger.error(f"Error reading board ID: {e}") return "0000" + +def get_soc_serial(): + """Get the full SoC unique serial (hex, uppercased) for DIS Serial Number.""" + try: + soc_serial_path = "/sys/devices/soc0/serial_number" + if os.path.exists(soc_serial_path): + with open(soc_serial_path, 'r') as f: + serial = re.sub(r'[^0-9a-fA-F]', '', f.read().strip()).upper() + if serial: + return serial + except Exception as e: + logger.error(f"Error reading SoC serial: {e}") + return "UNKNOWN" + + +def get_board_model(): + """Human-readable board model from the device tree, else MACHINE/env.""" + try: + dt_model = "/proc/device-tree/model" + if os.path.exists(dt_model): + with open(dt_model, 'rb') as f: + model = f.read().decode('utf-8', 'replace').replace('\x00', '').strip() + if model: + return model + except Exception as e: + logger.debug(f"Error reading board model: {e}") + return os.getenv("MACHINE", "imx93-jaguar-eink") + + +def get_fw_revision(): + """Firmware/OS revision parsed from /etc/os-release.""" + try: + data = {} + with open("/etc/os-release") as f: + for line in f: + if "=" in line: + k, v = line.rstrip("\n").split("=", 1) + data[k] = v.strip().strip('"') + for key in ("IMAGE_VERSION", "BUILD_ID", "VERSION_ID", "VERSION"): + if data.get(key): + return data[key] + if data.get("PRETTY_NAME"): + return data["PRETTY_NAME"] + except Exception as e: + logger.debug(f"Error reading firmware revision: {e}") + return "unknown" + + +def get_hw_revision(): + """Hardware revision. No standard source on this SoC; overridable via env.""" + return "unknown" + # Board-specific configuration for imx93-jaguar-eink (overridable via environment) SERVER_HOST = os.getenv("IMPROV_SERVER_HOST", "api.co.uk") BOARD_ID = get_board_id() @@ -104,6 +208,14 @@ def get_board_id(): INTERFACE = os.getenv("IMPROV_WIFI_INTERFACE", "wlan0") TIMEOUT = int(os.getenv("IMPROV_CONNECTION_TIMEOUT", "10000")) +# Device Information Service values (auto-detected, overridable via environment +# for multi-board reuse). +SOC_SERIAL = get_soc_serial() +MANUFACTURER = os.getenv("IMPROV_MANUFACTURER", "Dynamic Devices Ltd") +MODEL = os.getenv("IMPROV_MODEL", get_board_model()) +FW_REV = os.getenv("IMPROV_FW_REV", get_fw_revision()) +HW_REV = os.getenv("IMPROV_HW_REV", get_hw_revision()) + try: loop = asyncio.get_running_loop() except RuntimeError: @@ -111,6 +223,148 @@ def get_board_id(): asyncio.set_event_loop(loop) server = BlessServer(name=SERVICE_NAME, loop=loop) +# --- Network status (custom vendor characteristic) --------------------------- +# Cached JSON snapshot served on BLE reads; recomputed off the BLE event loop so +# reads never block on nmcli. +_net_status_json_bytes = bytearray( + json.dumps({"v": 1, "state": "disconnected", "iface": INTERFACE}, + separators=(",", ":")).encode("utf-8")) + + +def get_ipv4(iface): + """Return the interface IPv4 via ioctl (no subprocess), or None.""" + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + packed = struct.pack('256s', iface[:15].encode('utf-8')) + addr = fcntl.ioctl(s.fileno(), 0x8915, packed)[20:24] # SIOCGIFADDR + return socket.inet_ntoa(addr) + finally: + s.close() + except Exception: + return None + + +def get_ssid(iface): + """Best-effort current SSID via nmcli (called off the BLE read path).""" + # 1) Active AP from the scan list. + try: + out = subprocess.run(["nmcli", "-t", "-f", "ACTIVE,SSID", "dev", "wifi"], + capture_output=True, timeout=4, text=True) + for line in out.stdout.splitlines(): + if line.startswith("yes:"): + ssid = line.split(":", 1)[1].strip() + if ssid: + return ssid + except Exception: + pass + # 2) Fall back to the SSID stored on the interface's active connection + # (reliable right after provisioning, before the scan cache updates). + try: + conn = (nmcli.device.show(iface) or {}).get("GENERAL.CONNECTION") + if conn and conn not in ("--", ""): + out = subprocess.run( + ["nmcli", "-s", "-g", "802-11-wireless.ssid", + "connection", "show", conn], + capture_output=True, timeout=4, text=True) + ssid = out.stdout.strip() + if ssid: + return ssid + except Exception: + pass + return None + + +def compute_net_status(): + """Build the current network-status dict (blocking; call off the BLE loop).""" + status = {"v": 1, "state": "disconnected", "iface": INTERFACE} + ip = get_ipv4(INTERFACE) + if ip: + status["ipv4"] = ip + # Derive link state from NetworkManager's numeric device-state code + # (100=connected, 40..99=connecting/configuring, else disconnected). String + # matching is unsafe here because "disconnected" contains "connected". + try: + d = nmcli.device.show(INTERFACE) + m = re.match(r"\s*(\d+)", d.get("GENERAL.STATE") or "") + code = int(m.group(1)) if m else 0 + if code >= 100: + status["state"] = "connected" if ip else "connecting" + elif code >= 40: + status["state"] = "connecting" + else: + status["state"] = "connected" if ip else "disconnected" + except Exception: + status["state"] = "connected" if ip else "disconnected" + # SSID/RSSI are only meaningful when connected. + if status["state"] == "connected": + ssid = get_ssid(INTERFACE) + if ssid: + status["ssid"] = ssid + try: + with open("/proc/net/wireless") as f: + for line in f: + line = line.strip() + if line.startswith(INTERFACE + ":"): + parts = line.split() + if len(parts) > 3: + rssi = int(float(parts[3].rstrip("."))) + if -120 <= rssi <= 0: + status["rssi"] = rssi + except Exception: + pass + return status + + +def _publish_net_status(status_dict, notify=True): + """Update the cached JSON + characteristic value; notify on change.""" + global _net_status_json_bytes + data = json.dumps(status_dict, separators=(",", ":")).encode("utf-8") + changed = bytes(data) != bytes(_net_status_json_bytes) + _net_status_json_bytes = bytearray(data) + try: + ch = server.get_characteristic(NET_STATUS_CHAR_UUID) + if ch is not None: + ch.value = bytearray(data) + if notify and changed: + server.update_value(NET_STATUS_SERVICE_UUID, NET_STATUS_CHAR_UUID) + except Exception as e: + logger.debug(f"net status publish failed: {e}") + return changed + + +def _set_dis_values(): + """Populate the static Device Information Service characteristic values.""" + values = { + DIS_MANUFACTURER_UUID: MANUFACTURER, + DIS_MODEL_UUID: MODEL, + DIS_SERIAL_UUID: SOC_SERIAL, + DIS_FW_REV_UUID: FW_REV, + DIS_HW_REV_UUID: HW_REV, + DIS_SW_REV_UUID: __version__, + } + for uuid_, val in values.items(): + try: + ch = server.get_characteristic(uuid_) + if ch is not None: + ch.value = bytearray((val or "").encode("utf-8")) + except Exception as e: + logger.debug(f"set DIS {uuid_} failed: {e}") + + +async def net_status_loop(loop): + """Periodically refresh network status off the BLE event loop.""" + while True: + try: + status = await loop.run_in_executor(None, compute_net_status) + _publish_net_status(status) + except asyncio.CancelledError: + raise + except Exception as e: + logger.debug(f"net status loop error: {e}") + await asyncio.sleep(5) + + def wifi_connect(ssid: str, passwd: str) -> Optional[list[str]]: logger.warning( f"Creating Improv WiFi connection for '{ssid.decode('utf-8')}' with password: '{passwd.decode('utf-8')}'") @@ -197,6 +451,12 @@ def wifi_connect(ssid: str, passwd: str) -> Optional[list[str]]: print('Error connecting') return None + # Publish fresh network status (state/SSID/IP) and notify subscribers. + try: + _publish_net_status(compute_net_status(), notify=True) + except Exception as e: + logger.debug(f"net status publish after connect failed: {e}") + token = uuid.uuid4() server = f"https://{SERVER_HOST}?ip_address={ip_addr}&token={token}" return [server] @@ -209,6 +469,8 @@ def read_request(characteristic: BlessGATTCharacteristic, **kwargs) -> bytearray logger.info(f"Reading {improv_char} : {characteristic}") except Exception: logger.info(f"Reading {characteristic.uuid}") + if str(characteristic.uuid).lower() == NET_STATUS_CHAR_UUID: + return bytearray(_net_status_json_bytes) if characteristic.service_uuid == ImprovUUID.SERVICE_UUID.value: return improv_server.handle_read(characteristic.uuid) return characteristic.value @@ -241,6 +503,15 @@ async def run(loop): await server.start() logger.info("Server started") + # Populate the static Device Information Service values. + _set_dis_values() + # Seed network status now, then refresh periodically off the BLE loop. + try: + _publish_net_status(compute_net_status(), notify=False) + except Exception as e: + logger.debug(f"initial net status failed: {e}") + net_task = loop.create_task(net_status_loop(loop)) + try: trigger.clear() if trigger.__module__ == "threading": @@ -249,6 +520,8 @@ async def run(loop): await trigger.wait() except KeyboardInterrupt: logger.debug("Shutting Down") + finally: + net_task.cancel() await server.stop() try: From f9381630bee336e2913f3e226de7c88d5a96f126 Mon Sep 17 00:00:00 2001 From: Alex J Lennon Date: Sat, 18 Jul 2026 20:35:42 +0100 Subject: [PATCH 2/2] python3-improv: keep nmcli off the BLE loop; don't trust stale IP for link state - compute_net_status: NetworkManager's numeric device state is authoritative; a lingering interface IP no longer upgrades a disconnected/failed device to "connected", and IP/SSID/RSSI are only surfaced when genuinely connected. - Remove the synchronous compute_net_status() calls from wifi_connect() and run() startup, which ran multi-second nmcli subprocesses on the asyncio/BLE event loop and stalled Improv read/write handling. Startup seeds via an executor; provisioning triggers an immediate off-loop refresh through a new asyncio.Event consumed by net_status_loop. Addresses Bugbot findings on PR #37. Co-authored-by: Cursor --- .../imx93-jaguar-eink/onboarding-server.py | 64 +++++++++++++------ 1 file changed, 45 insertions(+), 19 deletions(-) diff --git a/recipes-devtools/python/python3-improv/imx93-jaguar-eink/onboarding-server.py b/recipes-devtools/python/python3-improv/imx93-jaguar-eink/onboarding-server.py index 89b074f8..b93c3467 100644 --- a/recipes-devtools/python/python3-improv/imx93-jaguar-eink/onboarding-server.py +++ b/recipes-devtools/python/python3-improv/imx93-jaguar-eink/onboarding-server.py @@ -230,6 +230,10 @@ def get_hw_revision(): json.dumps({"v": 1, "state": "disconnected", "iface": INTERFACE}, separators=(",", ":")).encode("utf-8")) +# Set to ask net_status_loop to refresh immediately (e.g. just after a +# successful provision) without running the blocking nmcli work on the BLE loop. +_net_refresh_event = asyncio.Event() + def get_ipv4(iface): """Return the interface IPv4 via ioctl (no subprocess), or None.""" @@ -278,26 +282,33 @@ def get_ssid(iface): def compute_net_status(): """Build the current network-status dict (blocking; call off the BLE loop).""" status = {"v": 1, "state": "disconnected", "iface": INTERFACE} - ip = get_ipv4(INTERFACE) - if ip: - status["ipv4"] = ip # Derive link state from NetworkManager's numeric device-state code # (100=connected, 40..99=connecting/configuring, else disconnected). String # matching is unsafe here because "disconnected" contains "connected". + # + # NM is authoritative: a lingering interface IP must NOT upgrade a + # disconnected/failed device to "connected". If we cannot read NM's state we + # stay "disconnected" (conservative) rather than trusting a possibly-stale IP. + code = 0 try: d = nmcli.device.show(INTERFACE) m = re.match(r"\s*(\d+)", d.get("GENERAL.STATE") or "") code = int(m.group(1)) if m else 0 - if code >= 100: - status["state"] = "connected" if ip else "connecting" - elif code >= 40: - status["state"] = "connecting" - else: - status["state"] = "connected" if ip else "disconnected" - except Exception: - status["state"] = "connected" if ip else "disconnected" - # SSID/RSSI are only meaningful when connected. + except Exception as e: + logger.debug(f"nmcli device state read failed: {e}") + code = 0 + ip = get_ipv4(INTERFACE) + if code >= 100: + status["state"] = "connected" if ip else "connecting" + elif code >= 40: + status["state"] = "connecting" + else: + status["state"] = "disconnected" + # Only surface IP/SSID/RSSI when actually connected; a stale IP on a + # down interface would otherwise be misreported as a live connection. if status["state"] == "connected": + if ip: + status["ipv4"] = ip ssid = get_ssid(INTERFACE) if ssid: status["ssid"] = ssid @@ -353,7 +364,12 @@ def _set_dis_values(): async def net_status_loop(loop): - """Periodically refresh network status off the BLE event loop.""" + """Periodically refresh network status off the BLE event loop. + + The blocking nmcli work runs in an executor so the asyncio/BLE loop stays + responsive; publishing (which touches the BlueZ characteristic) happens back + on the loop thread. Wakes early when `_net_refresh_event` is set. + """ while True: try: status = await loop.run_in_executor(None, compute_net_status) @@ -362,7 +378,12 @@ async def net_status_loop(loop): raise except Exception as e: logger.debug(f"net status loop error: {e}") - await asyncio.sleep(5) + try: + await asyncio.wait_for(_net_refresh_event.wait(), timeout=5) + except asyncio.TimeoutError: + pass + finally: + _net_refresh_event.clear() def wifi_connect(ssid: str, passwd: str) -> Optional[list[str]]: @@ -451,11 +472,14 @@ def wifi_connect(ssid: str, passwd: str) -> Optional[list[str]]: print('Error connecting') return None - # Publish fresh network status (state/SSID/IP) and notify subscribers. + # Ask the status loop to refresh immediately so the connected state/SSID/IP + # notify promptly. We deliberately do NOT call compute_net_status() here: + # this runs on the BLE event loop, and nmcli subprocesses would stall Improv + # read/write handling right after provisioning. try: - _publish_net_status(compute_net_status(), notify=True) + _net_refresh_event.set() except Exception as e: - logger.debug(f"net status publish after connect failed: {e}") + logger.debug(f"net status refresh request failed: {e}") token = uuid.uuid4() server = f"https://{SERVER_HOST}?ip_address={ip_addr}&token={token}" @@ -505,9 +529,11 @@ async def run(loop): # Populate the static Device Information Service values. _set_dis_values() - # Seed network status now, then refresh periodically off the BLE loop. + # Seed network status once (off the BLE loop so nmcli doesn't stall startup), + # then refresh periodically / on demand. try: - _publish_net_status(compute_net_status(), notify=False) + status = await loop.run_in_executor(None, compute_net_status) + _publish_net_status(status, notify=False) except Exception as e: logger.debug(f"initial net status failed: {e}") net_task = loop.create_task(net_status_loop(loop))