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
Original file line number Diff line number Diff line change
Expand Up @@ -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-<BOARD_ID> (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
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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

"""
Expand Down Expand Up @@ -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()
Expand All @@ -104,13 +208,184 @@ 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:
loop = asyncio.new_event_loop()
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"))

# 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."""
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}
# 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
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
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.

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)
_publish_net_status(status)
except asyncio.CancelledError:
raise
except Exception as e:
logger.debug(f"net status loop error: {e}")
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]]:
logger.warning(
f"Creating Improv WiFi connection for '{ssid.decode('utf-8')}' with password: '{passwd.decode('utf-8')}'")
Expand Down Expand Up @@ -197,6 +472,15 @@ def wifi_connect(ssid: str, passwd: str) -> Optional[list[str]]:
print('Error connecting')
return None

# 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:
_net_refresh_event.set()
except Exception as e:
logger.debug(f"net status refresh request failed: {e}")

token = uuid.uuid4()
server = f"https://{SERVER_HOST}?ip_address={ip_addr}&token={token}"
return [server]
Expand All @@ -209,6 +493,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
Expand Down Expand Up @@ -241,6 +527,17 @@ async def run(loop):
await server.start()
logger.info("Server started")

# Populate the static Device Information Service values.
_set_dis_values()
# Seed network status once (off the BLE loop so nmcli doesn't stall startup),
# then refresh periodically / on demand.
try:
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))

try:
trigger.clear()
if trigger.__module__ == "threading":
Expand All @@ -249,6 +546,8 @@ async def run(loop):
await trigger.wait()
except KeyboardInterrupt:
logger.debug("Shutting Down")
finally:
net_task.cancel()
await server.stop()

try:
Expand Down
Loading