diff --git a/pyproject.toml b/pyproject.toml index a6e912a..108bfb0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,9 @@ dependencies = [ cli = [ "rich>=13.0.0", ] +lan = [ + "zeroconf>=0.132.0", +] nrf-ota = [ "nrf-ota>=0.4.0", ] diff --git a/src/opendisplay/__init__.py b/src/opendisplay/__init__.py index 633008e..5e63c5b 100644 --- a/src/opendisplay/__init__.py +++ b/src/opendisplay/__init__.py @@ -7,7 +7,7 @@ from .battery import voltage_to_percent from .device import OpenDisplayDevice, prepare_image -from .discovery import discover_devices, discover_devices_with_adv +from .discovery import discover_devices, discover_devices_with_adv, discover_lan_devices from .exceptions import ( AuthenticationError, AuthenticationFailedError, @@ -77,7 +77,7 @@ from .models.firmware import firmware_ota_asset, firmware_release_repo from .models.led_flash import LedFlashConfig, LedFlashStep from .ota import find_nrf_dfu_device, perform_nrf_dfu, perform_silabs_ota -from .protocol import MANUFACTURER_ID, SERVICE_UUID +from .protocol import LAN_CHUNK_SIZE, MANUFACTURER_ID, SERVICE_UUID __version__ = "0.1.0" @@ -86,6 +86,7 @@ "OpenDisplayDevice", "discover_devices", "discover_devices_with_adv", + "discover_lan_devices", "prepare_image", # Exceptions "OpenDisplayError", @@ -167,4 +168,5 @@ # Constants "SERVICE_UUID", "MANUFACTURER_ID", + "LAN_CHUNK_SIZE", ] diff --git a/src/opendisplay/cli.py b/src/opendisplay/cli.py index 7ee0f71..ef29148 100644 --- a/src/opendisplay/cli.py +++ b/src/opendisplay/cli.py @@ -7,8 +7,10 @@ import json import logging import os +import socket import sys -from collections.abc import Coroutine +from collections.abc import Coroutine, Mapping +from datetime import datetime from pathlib import Path from typing import Any, NoReturn, TypeVar @@ -23,7 +25,13 @@ from .battery import voltage_to_percent from .device import OpenDisplayDevice -from .discovery import discover_devices_with_adv +from .discovery import ( + MDNS_TXT_KEY_MSD, + OPENDISPLAY_MDNS_SERVICE_TYPE, + discover_devices_with_adv, + discover_lan_devices, + msd_bytes_from_mdns_txt_properties, +) from .exceptions import ( AuthenticationFailedError, AuthenticationRequiredError, @@ -31,6 +39,7 @@ BLETimeoutError, OpenDisplayError, ) +from .models.advertisement import AdvertisementData, AdvertisementTracker, ButtonChangeEvent, parse_advertisement from .models.enums import ( CapacityEstimator, FitMode, @@ -44,6 +53,8 @@ ) from .partial import PartialState +_LOGGER = logging.getLogger(__name__) + _T = TypeVar("_T") _console = Console(stderr=True) # status, spinners, tables, errors → stderr @@ -110,14 +121,45 @@ def _parse_compression_value(flag: str, value: str) -> float | str: return f +def _is_ble_device_id(device: str) -> bool: + """True if device looks like a BLE MAC or macOS BLE UUID.""" + if len(device) == 36 and device.count("-") == 4: + return True + parts = device.split(":") + if len(parts) != 6 or not all(len(p) == 2 for p in parts): + return False + try: + bytes.fromhex("".join(parts)) + except ValueError: + return False + return True + + +def _parse_lan_address(device: str) -> tuple[str, int] | None: + """Parse ``HOST:PORT`` for WiFi/LAN; None if not a LAN address.""" + if _is_ble_device_id(device) or ":" not in device: + return None + host, _, port_s = device.rpartition(":") + host = host.strip() + if not host or not port_s.isdigit(): + return None + port = int(port_s) + if not 1 <= port <= 65535: + return None + return host, port + + def _device_kwargs(device: str, key: bytes | None, timeout: float) -> dict[str, Any]: """Build OpenDisplayDevice constructor kwargs from CLI args. - Detects MAC addresses (contains ':') and macOS UUIDs (36-char with 4 dashes) - vs. human-readable device names. + Accepts BLE MAC/UUID, BLE device name, or LAN ``HOST:PORT``. """ kwargs: dict[str, Any] = {"timeout": timeout, "encryption_key": key} - if ":" in device or (len(device) == 36 and device.count("-") == 4): + lan = _parse_lan_address(device) + if lan is not None: + kwargs["lan_address"] = lan + return kwargs + if _is_ble_device_id(device): kwargs["mac_address"] = device else: kwargs["device_name"] = device @@ -126,14 +168,19 @@ def _device_kwargs(device: str, key: bytes | None, timeout: float) -> dict[str, def _add_device_options(parser: argparse.ArgumentParser) -> None: """Add shared --device, --key, --timeout options to a subcommand parser.""" - parser.add_argument("--device", required=True, metavar="ADDR", help="Device MAC address or name") + parser.add_argument( + "--device", + required=True, + metavar="ADDR", + help="BLE MAC/UUID, BLE name, or LAN HOST:PORT (e.g. 192.168.1.50:2446)", + ) parser.add_argument("--key", default=None, metavar="HEX", help="Encryption key as 32 hex characters") parser.add_argument( "--timeout", type=float, default=10.0, metavar="SECS", - help="BLE timeout in seconds (default: 10.0)", + help="Connect/IO timeout in seconds (default: 10.0)", ) @@ -171,7 +218,12 @@ def _spinner() -> Progress: def _add_scan_parser(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None: - p = subparsers.add_parser("scan", help="Scan for nearby OpenDisplay BLE devices") + p = subparsers.add_parser("scan", help="Scan for nearby OpenDisplay devices (BLE or LAN)") + p.add_argument( + "--lan", + action="store_true", + help="Browse mDNS for WiFi/LAN devices (_opendisplay._tcp; requires py-opendisplay[lan])", + ) p.add_argument( "--timeout", type=float, @@ -184,7 +236,10 @@ def _add_scan_parser(subparsers: argparse._SubParsersAction[argparse.ArgumentPar def _cmd_scan(args: argparse.Namespace) -> None: - _run(_scan(args.timeout, args.output_json)) + if args.lan: + _run(_scan_lan(args.timeout, args.output_json)) + else: + _run(_scan(args.timeout, args.output_json)) async def _scan(timeout: float, output_json: bool) -> None: @@ -230,6 +285,242 @@ async def _scan(timeout: float, output_json: bool) -> None: _console.print(table) +async def _scan_lan(scan_seconds: float, output_json: bool) -> None: + with _spinner() as progress: + progress.add_task(f"Browsing mDNS for {scan_seconds:.0f}s...", total=None) + try: + devices = await discover_lan_devices(scan_seconds=scan_seconds) + except ImportError as exc: + _error(str(exc)) + except OpenDisplayError as exc: + _error(str(exc)) + + if output_json: + rows = [{"name": name, "host": host, "port": port} for host, port, name in sorted(devices, key=lambda r: r[2])] + _stdout.print_json(json.dumps({"devices": rows})) + return + + if not devices: + _console.print("No OpenDisplay LAN devices found.") + return + + table = Table(show_header=True) + table.add_column("Service") + table.add_column("Host") + table.add_column("Port") + for host, port, name in sorted(devices, key=lambda r: r[2]): + table.add_row(name, host, str(port)) + _console.print(table) + + +def _listen_timestamp() -> str: + return datetime.now().strftime("%H:%M:%S") + + +def _msd_status_json(service: str, adv: AdvertisementData, *, host: str | None, port: int | None) -> dict[str, Any]: + row: dict[str, Any] = { + "type": "msd", + "service": service, + "host": host, + "port": port, + "format_version": adv.format_version, + "battery_mv": adv.battery_mv, + "temperature_c": adv.temperature_c, + "loop_counter": adv.loop_counter, + } + if adv.format_version == "v1": + row["reboot_flag"] = adv.reboot_flag + row["connection_requested"] = adv.connection_requested + row["dynamic_data_hex"] = adv.dynamic_data.hex() + return row + + +def _button_event_json(service: str, ev: ButtonChangeEvent) -> dict[str, Any]: + return { + "type": "button", + "service": service, + "event_type": ev.event_type, + "byte_index": ev.byte_index, + "button_id": ev.button_id, + "pressed": ev.pressed, + "press_count": ev.press_count, + "previous_press_count": ev.previous_press_count, + "raw": ev.raw, + "previous_raw": ev.previous_raw, + "timestamp": ev.timestamp, + } + + +def _print_lan_msd_status(service: str, adv: AdvertisementData, *, host: str | None, port: int | None) -> None: + host_part = f" {host}:{port}" if host and port else "" + base = ( + f"[{_listen_timestamp()}] {service}{host_part} " + f"format={adv.format_version} battery={adv.battery_mv}mV " + f"temp={adv.temperature_c:.1f}C loop={adv.loop_counter}" + ) + if adv.format_version == "v1": + base += ( + f" reboot={adv.reboot_flag} conn_req={adv.connection_requested} " + f"dyn={adv.dynamic_data.hex()}" + ) + _console.print(base) + + +def _print_lan_button_event(service: str, ev: ButtonChangeEvent) -> None: + state = "down" if ev.pressed else "up" + _console.print( + f"[{_listen_timestamp()}] EVENT {service} type={ev.event_type} " + f"byte={ev.byte_index} button_id={ev.button_id} state={state} count={ev.press_count}" + ) + + +class _LanMdnsMsdListener: + """Track mDNS TXT ``msd`` updates and emit MSD/button events.""" + + def __init__(self, aiozc: Any, *, output_json: bool, print_all: bool) -> None: + self._aiozc = aiozc + self._output_json = output_json + self._print_all = print_all + self._trackers: dict[str, AdvertisementTracker] = {} + self._last_raw: dict[str, bytes] = {} + self._hosts: dict[str, tuple[str | None, int | None]] = {} + + def add_service(self, _zc: Any, type_: str, name: str) -> None: + self._schedule(type_, name) + + def update_service(self, _zc: Any, type_: str, name: str) -> None: + self._schedule(type_, name) + + def remove_service(self, _zc: Any, type_: str, name: str) -> None: + self._trackers.pop(name, None) + self._last_raw.pop(name, None) + self._hosts.pop(name, None) + if self._output_json: + _stdout.print_json(json.dumps({"type": "removed", "service": name})) + else: + _console.print(f"[{_listen_timestamp()}] removed {name}") + + def _schedule(self, type_: str, name: str) -> None: + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return + loop.create_task(self._async_handle(type_, name)) + + async def _async_handle(self, type_: str, name: str) -> None: + info = await self._aiozc.async_get_service_info(type_, name, timeout=3000) + if info is None: + _LOGGER.debug("No ServiceInfo for %s", name) + return + + host: str | None = None + port: int | None = int(info.port) if info.port else None + v4 = next((a for a in info.addresses if len(a) == 4), None) + if v4 is not None: + host = socket.inet_ntoa(v4) + self._hosts[name] = (host, port) + + props: Mapping[Any, Any] | None = dict(info.properties) if info.properties else None + raw = msd_bytes_from_mdns_txt_properties(props) + if raw is None: + _LOGGER.debug("No valid TXT %s for %s", MDNS_TXT_KEY_MSD, name) + return + + prev = self._last_raw.get(name) + if not self._print_all and prev is not None and prev == raw: + return + self._last_raw[name] = raw + + try: + adv = parse_advertisement(raw) + except ValueError as exc: + _LOGGER.warning("parse_advertisement failed for %s: %s", name, exc) + return + + host, port = self._hosts.get(name, (None, None)) + if self._output_json: + _stdout.print_json(json.dumps(_msd_status_json(name, adv, host=host, port=port))) + else: + _print_lan_msd_status(name, adv, host=host, port=port) + + tracker = self._trackers.setdefault(name, AdvertisementTracker()) + for ev in tracker.update(name, adv): + if self._output_json: + _stdout.print_json(json.dumps(_button_event_json(name, ev))) + else: + _print_lan_button_event(name, ev) + + +async def _listen_lan(duration: float, output_json: bool, print_all: bool) -> None: + try: + from zeroconf import ServiceListener + from zeroconf.asyncio import AsyncZeroconf + except ImportError as exc: + raise ImportError("LAN listen requires the 'zeroconf' package (pip install py-opendisplay[lan])") from exc + + class Listener(_LanMdnsMsdListener, ServiceListener): + pass + + aiozc = AsyncZeroconf() + listener = Listener(aiozc, output_json=output_json, print_all=print_all) + try: + await aiozc.async_add_service_listener(OPENDISPLAY_MDNS_SERVICE_TYPE, listener) + if not output_json: + _console.print( + f"Listening on {OPENDISPLAY_MDNS_SERVICE_TYPE} (TXT key {MDNS_TXT_KEY_MSD!r})… " + f"{'all updates' if print_all else 'changed MSD only'}. Ctrl+C to stop." + ) + if duration > 0: + await asyncio.sleep(duration) + else: + while True: + await asyncio.sleep(3600) + finally: + await aiozc.async_remove_all_service_listeners() + await aiozc.async_close() + + +# ── listen ──────────────────────────────────────────────────────────────────── + + +def _add_listen_parser(subparsers: argparse._SubParsersAction[argparse.ArgumentParser]) -> None: + p = subparsers.add_parser( + "listen", + help="Monitor passive MSD updates over WiFi/LAN mDNS (battery, buttons)", + ) + p.add_argument( + "--lan", + action="store_true", + help="Listen via mDNS TXT 'msd' on _opendisplay._tcp (requires py-opendisplay[lan])", + ) + p.add_argument("--json", dest="output_json", action="store_true", help="Emit NDJSON events on stdout") + p.add_argument( + "--all", + action="store_true", + help="Print every mDNS MSD update (default: only when the 14-byte payload changes)", + ) + p.add_argument( + "--duration", + type=float, + default=0.0, + metavar="SECS", + help="Listen duration in seconds (0 = until Ctrl+C, default: 0)", + ) + p.set_defaults(func=_cmd_listen) + + +def _cmd_listen(args: argparse.Namespace) -> None: + if not args.lan: + _error("listen requires --lan for WiFi/LAN mDNS monitoring") + try: + _run(_listen_lan(args.duration, args.output_json, args.all)) + except ImportError as exc: + _error(str(exc)) + except KeyboardInterrupt: + if not args.output_json: + _console.print("Stopped.") + + # ── info ────────────────────────────────────────────────────────────────────── @@ -783,12 +1074,13 @@ def main() -> None: """Entry point for the opendisplay CLI.""" parser = argparse.ArgumentParser( prog="opendisplay", - description="OpenDisplay BLE command-line tool", + description="OpenDisplay command-line tool (BLE and WiFi/LAN)", ) parser.add_argument("-v", "--verbose", action="store_true", help="Enable debug logging") subparsers = parser.add_subparsers(dest="command", required=True) _add_scan_parser(subparsers) + _add_listen_parser(subparsers) _add_info_parser(subparsers) _add_upload_parser(subparsers) _add_reboot_parser(subparsers) diff --git a/src/opendisplay/device.py b/src/opendisplay/device.py index b7ee997..a297a89 100644 --- a/src/opendisplay/device.py +++ b/src/opendisplay/device.py @@ -58,6 +58,7 @@ from .protocol import ( CHUNK_SIZE, ENCRYPTED_CHUNK_SIZE, + LAN_CHUNK_SIZE, MAX_COMPRESSED_SIZE, MAX_START_PAYLOAD, CommandCode, @@ -88,7 +89,7 @@ strip_command_echo, unpack_command_code, ) -from .transport import BLEConnection +from .transport import BLEConnection, LANConnection if TYPE_CHECKING: from bleak.backends.device import BLEDevice @@ -266,15 +267,19 @@ def prepare_image( class OpenDisplayDevice: - """OpenDisplay BLE e-paper device. + """OpenDisplay e-paper device over BLE or LAN (ESP32 WiFi TCP). - Main API for communicating with OpenDisplay BLE tags. + Main API for communicating with OpenDisplay tags. Usage: - # Auto-interrogate on first connect + # Auto-interrogate on first connect (BLE) async with OpenDisplayDevice("AA:BB:CC:DD:EE:FF") as device: await device.upload_image(image) + # WiFi/LAN (ESP32 TCP server) + async with OpenDisplayDevice(lan_address=("192.168.1.50", 2446)) as device: + await device.upload_image(image) + # Skip interrogation with cached config async with OpenDisplayDevice(mac, config=cached_config) as device: await device.upload_image(image) @@ -305,6 +310,7 @@ def __init__( mac_address: str | None = None, device_name: str | None = None, ble_device: BLEDevice | None = None, + lan_address: tuple[str, int] | None = None, config: GlobalConfig | None = None, capabilities: DeviceCapabilities | None = None, timeout: float = 10.0, @@ -317,27 +323,31 @@ def __init__( """Initialize OpenDisplay device. Args: - mac_address: Device MAC address (mutually exclusive with device_name) + mac_address: Device MAC address (mutually exclusive with device_name and lan_address) device_name: Device name to resolve via BLE scan (mutually exclusive with mac_address) - ble_device: Optional BLEDevice from HA bluetooth integration + ble_device: Optional BLEDevice from HA bluetooth integration (BLE only) + lan_address: ``(host, port)`` for WiFi/LAN (ESP32 TCP server); mutually exclusive with BLE params config: Optional full TLV config (skips interrogation) capabilities: Optional minimal device info (skips interrogation) - timeout: BLE operation timeout in seconds (default: 10) - discovery_timeout: Timeout for name resolution scan (default: 10) + timeout: Connect / IO timeout in seconds (default: 10) + discovery_timeout: Timeout for BLE name resolution scan (default: 10) max_attempts: Maximum connection attempts for bleak-retry-connector (default: 4) use_services_cache: Enable GATT service caching for faster reconnections (default: True) use_measured_palettes: Use measured color palettes when available (default: True) encryption_key: 16-byte AES-128 master key for encrypted devices (optional). Raises: - ValueError: If neither or both mac_address and device_name provided + ValueError: If connection target parameters are missing or conflicting """ - # Validation: exactly one of mac_address or device_name must be provided - if mac_address and device_name: + if lan_address is not None: + if mac_address or device_name or ble_device: + raise ValueError("lan_address cannot be combined with mac_address, device_name, or ble_device") + elif mac_address and device_name: raise ValueError("Provide either mac_address or device_name, not both") - if not mac_address and not device_name: - raise ValueError("Must provide either mac_address or device_name") + elif not mac_address and not device_name: + raise ValueError("Must provide mac_address, device_name, or lan_address") + self._lan_address = lan_address # Store for resolution in __aenter__ self._mac_address_param = mac_address self._device_name = device_name @@ -349,8 +359,8 @@ def __init__( self._use_measured_palettes = use_measured_palettes # Will be set after resolution - self.mac_address = mac_address or "" # Resolved in __aenter__ - self._connection: BLEConnection | None = None # Created after MAC resolution + self.mac_address = mac_address or "" # Resolved in __aenter__ (or lan:host:port) + self._connection: BLEConnection | LANConnection | None = None # Created after MAC resolution self._config = config self._capabilities = capabilities @@ -366,6 +376,20 @@ def __init__( async def __aenter__(self) -> OpenDisplayDevice: """Connect and optionally interrogate device.""" + if self._lan_address is not None: + host, port = self._lan_address + self.mac_address = f"lan:{host}:{port}" + self._connection = LANConnection(host, port, self._timeout) + await self._conn.connect() + if self._encryption_key is not None: + await self.authenticate(self._encryption_key) + if self._config is None and self._capabilities is None: + _LOGGER.info("No config provided, auto-interrogating device (LAN)") + await self.interrogate() + if self._config and not self._capabilities: + self._capabilities = self._extract_capabilities_from_config() + return self + # Resolve device name to MAC address if needed if self._device_name: _LOGGER.debug("Resolving device name '%s' to MAC address", self._device_name) @@ -428,12 +452,19 @@ async def __aexit__( await self._conn.disconnect() @property - def _conn(self) -> BLEConnection: - """Return active BLE connection, raising RuntimeError if not connected.""" + def _conn(self) -> BLEConnection | LANConnection: + """Return active connection, raising RuntimeError if not connected.""" if self._connection is None: raise RuntimeError("Device not connected") return self._connection + def _data_chunk_limit(self) -> int: + if self._session_key is not None: + return ENCRYPTED_CHUNK_SIZE + if self._lan_address is not None: + return LAN_CHUNK_SIZE + return CHUNK_SIZE + async def _write(self, data: bytes) -> None: """Write a command, encrypting it if an active session exists.""" if self._session_key is not None and self._session_id is not None: @@ -1308,13 +1339,13 @@ async def _send_partial_chunks( progress_callback: Callable[[int, int], None] | None = None, ) -> None: """Send remaining 0x71 chunks and update upload progress.""" - chunk_size = ENCRYPTED_CHUNK_SIZE if self._session_key is not None else CHUNK_SIZE + chunk_size = self._data_chunk_limit() total_stream_bytes = len(stream_bytes) bytes_sent = total_stream_bytes - len(remaining) offset = 0 while offset < len(remaining): chunk = remaining[offset : offset + chunk_size] - await self._write(build_direct_write_data_command(chunk)) + await self._write(build_direct_write_data_command(chunk, max_data_len=chunk_size)) ack = await self._read(self.TIMEOUT_ACK) nack = parse_nack(ack) if nack is not None: @@ -1561,10 +1592,10 @@ async def _send_data_chunks( chunks_sent = 0 while bytes_sent < len(image_data): - chunk_size = ENCRYPTED_CHUNK_SIZE if self._session_key is not None else CHUNK_SIZE - chunk_data = image_data[bytes_sent : bytes_sent + chunk_size] + chunk_limit = self._data_chunk_limit() + chunk_data = image_data[bytes_sent : bytes_sent + chunk_limit] - await self._write(build_direct_write_data_command(chunk_data)) + await self._write(build_direct_write_data_command(chunk_data, max_data_len=chunk_limit)) bytes_sent += len(chunk_data) chunks_sent += 1 diff --git a/src/opendisplay/discovery.py b/src/opendisplay/discovery.py index 08f5f91..d643edf 100644 --- a/src/opendisplay/discovery.py +++ b/src/opendisplay/discovery.py @@ -1,8 +1,13 @@ -"""BLE device discovery for OpenDisplay devices.""" +"""BLE and LAN discovery for OpenDisplay devices.""" from __future__ import annotations +import asyncio import logging +import socket +import time +from collections.abc import Mapping +from typing import Any from bleak import BleakScanner @@ -12,6 +17,86 @@ _LOGGER = logging.getLogger(__name__) +_OPENDISPLAY_TCP_TYPE = "_opendisplay._tcp.local." +# Same string as ``_OPENDISPLAY_TCP_TYPE`` (ESP32 ``MDNS.addService("opendisplay", "tcp", ...)``). +OPENDISPLAY_MDNS_SERVICE_TYPE = _OPENDISPLAY_TCP_TYPE +# TXT key for 14-byte v1 MSD payload (company ID excluded), lowercase hex (28 chars). +MDNS_TXT_KEY_MSD = "msd" + + +def _discover_lan_sync(scan_seconds: float) -> list[tuple[str, int, str]]: + try: + from zeroconf import ServiceBrowser, ServiceListener, Zeroconf + except ImportError as e: + raise ImportError("LAN discovery requires the 'zeroconf' package (pip install py-opendisplay[lan])") from e + + found: list[tuple[str, int, str]] = [] + + class _Listener(ServiceListener): + def add_service(self, zc: Zeroconf, type_: str, name: str) -> None: + info = zc.get_service_info(type_, name, timeout=3000) + if not info or info.port == 0: + return + v4 = next((a for a in info.addresses if len(a) == 4), None) + if not v4: + return + ip = socket.inet_ntoa(v4) + found.append((ip, int(info.port or 0), name)) + _LOGGER.debug("LAN service %s -> %s:%s", name, ip, info.port) + + def update_service(self, zc: Zeroconf, type_: str, name: str) -> None: # noqa: ARG002 + pass + + def remove_service(self, zc: Zeroconf, type_: str, name: str) -> None: # noqa: ARG002 + pass + + zc = Zeroconf() + try: + ServiceBrowser(zc, _OPENDISPLAY_TCP_TYPE, _Listener()) + time.sleep(max(scan_seconds, 0.1)) + finally: + zc.close() + + return found + + +def msd_bytes_from_mdns_txt_properties(properties: Mapping[Any, Any] | None) -> bytes | None: + """Decode 14-byte v1 MSD from mDNS TXT ``msd`` (28 lowercase hex chars). + + Returns: + 14 bytes matching BLE ``parse_advertisement`` v1 payload, or None if missing/invalid. + """ + if not properties: + return None + raw_val: bytes | None = None + for k, v in properties.items(): + key = k if isinstance(k, bytes) else str(k).encode("utf-8") + if key.lower() != b"msd": + continue + if v is None: + return None + raw_val = v if isinstance(v, bytes) else str(v).encode("utf-8") + break + if raw_val is None: + return None + s = raw_val.decode("ascii", errors="replace").strip() + if len(s) != 28: + return None + try: + out = bytes.fromhex(s) + except ValueError: + return None + return out if len(out) == 14 else None + + +async def discover_lan_devices(scan_seconds: float = 3.0) -> list[tuple[str, int, str]]: + """Browse mDNS for ``_opendisplay._tcp`` (display TCP server on LAN). + + Returns: + List of ``(ipv4, port, service_instance_name)``. + """ + return await asyncio.to_thread(_discover_lan_sync, scan_seconds) + async def discover_devices_with_adv( timeout: float = 10.0, diff --git a/src/opendisplay/protocol/__init__.py b/src/opendisplay/protocol/__init__.py index d78d935..05a107a 100644 --- a/src/opendisplay/protocol/__init__.py +++ b/src/opendisplay/protocol/__init__.py @@ -3,6 +3,7 @@ from .commands import ( CHUNK_SIZE, ENCRYPTED_CHUNK_SIZE, + LAN_CHUNK_SIZE, MANUFACTURER_ID, MAX_COMPRESSED_SIZE, MAX_START_PAYLOAD, @@ -43,6 +44,7 @@ "MANUFACTURER_ID", "CHUNK_SIZE", "ENCRYPTED_CHUNK_SIZE", + "LAN_CHUNK_SIZE", "PIPELINE_CHUNKS", "MAX_COMPRESSED_SIZE", "MAX_START_PAYLOAD", diff --git a/src/opendisplay/protocol/commands.py b/src/opendisplay/protocol/commands.py index 5fd7c00..2eee07c 100644 --- a/src/opendisplay/protocol/commands.py +++ b/src/opendisplay/protocol/commands.py @@ -44,7 +44,9 @@ class CommandCode(IntEnum): RESPONSE_HIGH_BIT_FLAG = 0x8000 # High bit set in response codes indicates ACK # Chunking constants -CHUNK_SIZE = 230 # Maximum data bytes per chunk (unencrypted) +CHUNK_SIZE = 230 # Maximum data bytes per chunk (unencrypted BLE — MTU-limited) +# LAN/TCP frame inner payload max is WIFI_LAN_MAX_PAYLOAD (4096); command 0x0071 uses 2-byte prefix → 4094 data bytes +LAN_CHUNK_SIZE = 4094 ENCRYPTED_CHUNK_SIZE = 154 # Maximum data bytes per chunk when session is active # Encrypted packet: cmd(2)+nonce(16)+len(1)+data(154)+tag(12) = 185 bytes CONFIG_CHUNK_SIZE = 200 # Maximum config chunk size (verified from firmware) @@ -193,22 +195,24 @@ def build_direct_write_partial_start( return cmd + fixed + initial, remaining -def build_direct_write_data_command(chunk_data: bytes) -> bytes: +def build_direct_write_data_command(chunk_data: bytes, *, max_data_len: int | None = None) -> bytes: """Build command to send image data chunk. Args: - chunk_data: Image data chunk (max CHUNK_SIZE bytes) + chunk_data: Raw image bytes for this chunk + max_data_len: Max ``chunk_data`` length (default: :data:`CHUNK_SIZE` for BLE) Returns: Command bytes: 0x0071 + chunk_data Format: - [cmd:2][data:230] + [cmd:2][data] - cmd: 0x0071 (big-endian) - - data: Image data chunk + - data: Image chunk (up to ``max_data_len``, or :data:`LAN_CHUNK_SIZE` over WiFi) """ - if len(chunk_data) > CHUNK_SIZE: - raise ValueError(f"Chunk size {len(chunk_data)} exceeds maximum {CHUNK_SIZE}") + limit = CHUNK_SIZE if max_data_len is None else max_data_len + if len(chunk_data) > limit: + raise ValueError(f"Chunk size {len(chunk_data)} exceeds maximum {limit}") cmd = CommandCode.DIRECT_WRITE_DATA.to_bytes(2, byteorder="big") return cmd + chunk_data diff --git a/src/opendisplay/transport/__init__.py b/src/opendisplay/transport/__init__.py index 7f2780d..0e2d9e7 100644 --- a/src/opendisplay/transport/__init__.py +++ b/src/opendisplay/transport/__init__.py @@ -1,7 +1,10 @@ -"""BLE transport layer.""" +"""Transport layer (BLE and LAN).""" from .connection import BLEConnection +from .lan import LANConnection, LAN_MAX_FRAME_PAYLOAD __all__ = [ "BLEConnection", + "LANConnection", + "LAN_MAX_FRAME_PAYLOAD", ] diff --git a/src/opendisplay/transport/lan.py b/src/opendisplay/transport/lan.py new file mode 100644 index 0000000..a1d4c70 --- /dev/null +++ b/src/opendisplay/transport/lan.py @@ -0,0 +1,90 @@ +"""TCP transport: same BLE command/response bytes, length-prefixed (uint16 LE).""" + +from __future__ import annotations + +import asyncio +import logging + +from ..exceptions import BLEConnectionError, BLETimeoutError + +_LOGGER = logging.getLogger(__name__) + +# Must match Firmware WIFI_LAN_MAX_PAYLOAD +LAN_MAX_FRAME_PAYLOAD = 4096 + + +class LANConnection: + """TCP client to ESP32 OpenDisplay LAN server (BLE-shaped frames).""" + + def __init__(self, host: str, port: int, timeout: float = 10.0) -> None: + self.host = host + self.port = port + self.timeout = timeout + self.device_name: str | None = None + self._reader: asyncio.StreamReader | None = None + self._writer: asyncio.StreamWriter | None = None + + async def connect(self) -> None: + if self._writer is not None: + return + try: + self._reader, self._writer = await asyncio.wait_for( + asyncio.open_connection(self.host, self.port), + timeout=self.timeout, + ) + except TimeoutError as e: + raise BLETimeoutError(f"LAN connect timeout after {self.timeout}s") from e + except OSError as e: + raise BLEConnectionError(f"LAN connect failed: {e}") from e + self.device_name = self.host + _LOGGER.debug("LAN connected to %s:%s", self.host, self.port) + + async def disconnect(self) -> None: + if self._writer is not None: + self._writer.close() + try: + await self._writer.wait_closed() + except OSError: + pass + self._reader = None + self._writer = None + self.device_name = None + + async def clear_cache(self) -> bool: + """No-op for LAN (GATT cache is BLE-only).""" + return False + + async def write_command(self, data: bytes) -> None: + if self._writer is None: + raise BLEConnectionError("LAN not connected") + if len(data) > 0xFFFF: + raise BLEConnectionError("LAN command exceeds uint16 length") + frame = len(data).to_bytes(2, "little") + data + try: + self._writer.write(frame) + await self._writer.drain() + except OSError as e: + raise BLEConnectionError(f"LAN write failed: {e}") from e + + async def read_response(self, timeout: float = 5.0) -> bytes: + if self._reader is None: + raise BLEConnectionError("LAN not connected") + try: + hdr = await asyncio.wait_for(self._reader.readexactly(2), timeout=timeout) + except TimeoutError as e: + raise BLETimeoutError(f"No LAN response within {timeout}s") from e + except asyncio.IncompleteReadError as e: + raise BLEConnectionError(f"LAN connection closed ({e!r})") from e + ln = int.from_bytes(hdr, "little") + if ln == 0 or ln > LAN_MAX_FRAME_PAYLOAD: + raise BLEConnectionError(f"Invalid LAN frame length: {ln}") + try: + return await asyncio.wait_for(self._reader.readexactly(ln), timeout=timeout) + except TimeoutError as e: + raise BLETimeoutError(f"LAN response body timeout ({ln} bytes)") from e + except asyncio.IncompleteReadError as e: + raise BLEConnectionError(f"LAN truncated response ({e!r})") from e + + @property + def is_connected(self) -> bool: + return self._writer is not None and not self._writer.is_closing() diff --git a/tests/unit/test_protocol_commands.py b/tests/unit/test_protocol_commands.py index a646187..b390037 100644 --- a/tests/unit/test_protocol_commands.py +++ b/tests/unit/test_protocol_commands.py @@ -4,6 +4,7 @@ from opendisplay.models.led_flash import LedFlashConfig, LedFlashStep from opendisplay.protocol.commands import ( CHUNK_SIZE, + LAN_CHUNK_SIZE, CommandCode, build_buzzer_activate_command, build_direct_write_data_command, @@ -129,6 +130,18 @@ def test_build_direct_write_data_command_too_large(self): with pytest.raises(ValueError, match="exceeds maximum"): build_direct_write_data_command(chunk) + def test_build_direct_write_data_command_lan_size(self): + """LAN/WiFi allows up to LAN_CHUNK_SIZE bytes per 0x0071 payload.""" + chunk = b"Z" * LAN_CHUNK_SIZE + cmd = build_direct_write_data_command(chunk, max_data_len=LAN_CHUNK_SIZE) + assert len(cmd) == 2 + LAN_CHUNK_SIZE + assert cmd[:2] == b"\x00\x71" + + def test_build_direct_write_data_command_lan_too_large(self): + chunk = b"A" * (LAN_CHUNK_SIZE + 1) + with pytest.raises(ValueError, match="exceeds maximum"): + build_direct_write_data_command(chunk, max_data_len=LAN_CHUNK_SIZE) + def test_build_direct_write_end_command(self, real_upload_end_command): """Test END command includes refresh mode.""" # Default refresh mode (0 = FULL) diff --git a/uv.lock b/uv.lock index 7a812ed..8131b13 100644 --- a/uv.lock +++ b/uv.lock @@ -472,6 +472,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/19/89/0f50dd0d92e8a7dffc24f69ab910ff81db89b2f082ba42682bd57695e4d2/hypothesis-6.152.4-py3-none-any.whl", hash = "sha256:e730fd93c7578182efadc7f90b3c5437ee4d55edf738930eb5043c81ac1d97e8", size = 532145, upload-time = "2026-04-27T20:18:35.043Z" }, ] +[[package]] +name = "ifaddr" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/ac/fb4c578f4a3256561548cd825646680edcadb9440f3f68add95ade1eb791/ifaddr-0.2.0.tar.gz", hash = "sha256:cc0cbfcaabf765d44595825fb96a99bb12c79716b73b44330ea38ee2b0c4aed4", size = 10485, upload-time = "2022-06-15T21:40:27.561Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/1f/19ebc343cc71a7ffa78f17018535adc5cbdd87afb31d7c34874680148b32/ifaddr-0.2.0-py3-none-any.whl", hash = "sha256:085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748", size = 12314, upload-time = "2022-06-15T21:40:25.756Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -908,6 +917,9 @@ dependencies = [ cli = [ { name = "rich" }, ] +lan = [ + { name = "zeroconf" }, +] nrf-ota = [ { name = "nrf-ota" }, ] @@ -946,8 +958,9 @@ requires-dist = [ { name = "pytest-xdist", marker = "extra == 'test'", specifier = ">=3.8.0" }, { name = "rich", marker = "extra == 'cli'", specifier = ">=13.0.0" }, { name = "silabs-ble-ota", marker = "extra == 'silabs-ota'", specifier = ">=0.1.0" }, + { name = "zeroconf", marker = "extra == 'lan'", specifier = ">=0.132.0" }, ] -provides-extras = ["cli", "nrf-ota", "silabs-ota", "test"] +provides-extras = ["cli", "lan", "nrf-ota", "silabs-ota", "test"] [package.metadata.requires-dev] dev = [ @@ -1472,3 +1485,70 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/3d/aae50b1d0e37b5a61055759aedd42c6c99d7c17ab8c3e568ab33c0288938/winrt_windows_storage_streams-3.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:3c5bf41d725369b9986e6d64bad7079372b95c329897d684f955d7028c7f27a0", size = 135566, upload-time = "2025-09-20T07:17:17.69Z" }, { url = "https://files.pythonhosted.org/packages/bb/c3/6d3ce7a58e6c828e0795c9db8790d0593dd7fdf296e513c999150deb98d4/winrt_windows_storage_streams-3.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:293e09825559d0929bbe5de01e1e115f7a6283d8996ab55652e5af365f032987", size = 134393, upload-time = "2025-09-20T07:17:18.802Z" }, ] + +[[package]] +name = "zeroconf" +version = "0.149.16" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ifaddr" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/34/c981e760690f7b7dc91532d4d4ad21e3922887aaa425a0e7bff8067152da/zeroconf-0.149.16.tar.gz", hash = "sha256:5e6b5a3b153c2cc2a8d9e6f6f189ec5638f7d9c86fc3e88a6c53eb6863761a5e", size = 196586, upload-time = "2026-05-21T14:04:17.781Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/4e/e0c1a9a0b5b80ab286c8269a653068960e3ec280bd54d5fd137bbf8960bd/zeroconf-0.149.16-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d04ed04cda62f787c0378270724333f5707f560b1fa51489ac4677a2fa99cb90", size = 1672025, upload-time = "2026-05-21T14:31:35.507Z" }, + { url = "https://files.pythonhosted.org/packages/c4/76/db455e0663e2043f368f81b78387f678ff68beb0a18d3acd29b4fa1e5af4/zeroconf-0.149.16-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8b18624311eb11aeeea2ac85fd0ff2763d22f25a2d282110efb91b66c8e43b0", size = 2154441, upload-time = "2026-05-21T14:31:37.142Z" }, + { url = "https://files.pythonhosted.org/packages/8f/f0/296c719ea8829f3f4ef457a80b3ebd40c7b75283482d3b18cabaf28552cb/zeroconf-0.149.16-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8457aee89e1e561835e1abd32816eb2cdf4888af54f4c15e2d94326c1a467bb7", size = 1924672, upload-time = "2026-05-21T14:31:38.907Z" }, + { url = "https://files.pythonhosted.org/packages/cd/6a/1e8a987a2b2805a59d355cd2e1d5fce03bc1da8859a0efcad4edb26892af/zeroconf-0.149.16-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:414bf6758e4e389162a6d00c179c0eafd8932a36e58bb3791570eafbb194c28a", size = 2236440, upload-time = "2026-05-21T14:31:42.846Z" }, + { url = "https://files.pythonhosted.org/packages/c6/24/11364b8db6a5db39e8fdaf0d89923c5ac1f33478f5bf53cc76ba5ef05da6/zeroconf-0.149.16-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f9aaf85163be00178aeee4d02644d9ccbe8273ab52fa43e12d7d9f78a4d0cde", size = 2195767, upload-time = "2026-05-21T14:31:44.401Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3d/735eb897ba82213c307a04c4910b3924faf08669e4221f6f54744acc9aff/zeroconf-0.149.16-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f117a9b49d735cfa101514df6c1ca52d01ad98be55896d4b9cc63eed64840bea", size = 2179382, upload-time = "2026-05-21T14:31:46.324Z" }, + { url = "https://files.pythonhosted.org/packages/2f/0a/7befcb3646d25051762218d911c554bbfd6492f3076c6f17ac3d02ca8e09/zeroconf-0.149.16-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:63c4f279d18ecb6bcd8d3b6c34c4ae3bfb34a57321b0303b35683e13a2048fd9", size = 1977895, upload-time = "2026-05-21T14:31:48.105Z" }, + { url = "https://files.pythonhosted.org/packages/f6/89/378dcd9b3567597d560e2dbe92931653b6cf0e6f258492615aed3d5c6d8b/zeroconf-0.149.16-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8d2545501c6d4711fa9cfbe2f731f409156c40ddf5e822b5b461445e1b1aafdb", size = 2201422, upload-time = "2026-05-21T14:31:50.083Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/82e4902a95ca8c22094f8fbf9cec4712ef22ff7fc171bb4aa4bf0d51d6f1/zeroconf-0.149.16-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c96231f5f5fbd101c55257bb5e57c9a0bde36a6259104637c40afa941db0b367", size = 2258970, upload-time = "2026-05-21T14:31:52.178Z" }, + { url = "https://files.pythonhosted.org/packages/42/a7/70b6e6e999dd9594d6205f88e1aeb50d9173a2648ef15f313f2e9b1902b9/zeroconf-0.149.16-cp311-cp311-win32.whl", hash = "sha256:dad44dfcf58b7919247e0d12bde93a05296dedcccf00bae772a98f9be5a854c0", size = 1275248, upload-time = "2026-05-21T14:31:54.836Z" }, + { url = "https://files.pythonhosted.org/packages/de/f4/678d0d04e27bc25db1cbe252d3055f2bb35df0e7e7b2428d4a84be8e6cbe/zeroconf-0.149.16-cp311-cp311-win_amd64.whl", hash = "sha256:7a1ad0c328fe79172ef654dd5fa79e819c757cde06e38e520a40b7cbbbfe0753", size = 1517265, upload-time = "2026-05-21T14:31:56.55Z" }, + { url = "https://files.pythonhosted.org/packages/62/3c/a34998e51b9923ce7446c06016df1dca4dce3fe9074d7673678dfb939fb7/zeroconf-0.149.16-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e1d403ca6145268664af9d87a7522f9ad7e5e388482db0d4b055553616564087", size = 1676206, upload-time = "2026-05-21T14:31:58.331Z" }, + { url = "https://files.pythonhosted.org/packages/6b/24/07eaf020c2f206dfd35378db721e81e9f48792d8f6297663763ca9739157/zeroconf-0.149.16-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50c9ec1fb46e336ca7b098d44cae5fc21baf9d44cd758b776c06b453b2d3d73e", size = 2076988, upload-time = "2026-05-21T14:32:00.114Z" }, + { url = "https://files.pythonhosted.org/packages/f5/71/3ea6bbd4151e2afc9249fa6c345c68f5fffafcb608a0147b3a4e49df75d3/zeroconf-0.149.16-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95b8681ab0a3209d04debc96818998bcc86642be32a17b17f6ecda5d7ff560f9", size = 1879896, upload-time = "2026-05-21T14:32:02.172Z" }, + { url = "https://files.pythonhosted.org/packages/ad/21/9dd0d3a9587155b7efd10d5f5484df836c2ec2b54ae719ef1c972a9a90a9/zeroconf-0.149.16-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:65b312685ef3e12284f907c07217df3c6873438313ce0e6cf53e01a182aaf0c4", size = 2175116, upload-time = "2026-05-21T14:32:04.072Z" }, + { url = "https://files.pythonhosted.org/packages/63/1c/452e418d4fadb10118ea381056099714eec5cec42d6fbee7f28eeebb41d2/zeroconf-0.149.16-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ca881fbf6edd21651b247710cdaa7b72593982bb65433bcf80c509df9550d12", size = 2110395, upload-time = "2026-05-21T14:32:06.524Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0f/1883b7bed80fd33627c802593b49377e2aa0a458c430ebc3710cfce062ac/zeroconf-0.149.16-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1ea9acdb58407bb73c11cad6d4965bdc01bec3c2284727c4a482d444f943092c", size = 2101220, upload-time = "2026-05-21T14:32:08.557Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d6/460c6a4ebbd350935073fb7501b38d143791e8478d737934cae296fffc4a/zeroconf-0.149.16-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cf3e109ec0181bab93ac954b17f2921eebac28a97c81ebf3559aeaad7a3b7a67", size = 1969601, upload-time = "2026-05-21T14:32:10.435Z" }, + { url = "https://files.pythonhosted.org/packages/4e/7e/f4bdad2ba7623e131907db61ce436bdb3bbef7e08917448170b87aa11446/zeroconf-0.149.16-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4fd08c930004ffaad2dc192762f9a2cd7f1db34b5fb1a36f38993dde2c4d55d9", size = 2114226, upload-time = "2026-05-21T14:32:12.331Z" }, + { url = "https://files.pythonhosted.org/packages/b1/fd/7b5c0244c8db96e79c355ebb48865663ada68de096d02d09e743861b1662/zeroconf-0.149.16-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a46c8fc8a4133574484b24b6bef3ae46e7e0958167d2c44ee84cc2edff2b4c9f", size = 2196367, upload-time = "2026-05-21T14:32:14.314Z" }, + { url = "https://files.pythonhosted.org/packages/3b/42/ba94573fa62df4e623d7e7a0ca68e2aab644c729e83c64117da39ef7f9a3/zeroconf-0.149.16-cp312-cp312-win32.whl", hash = "sha256:48e0847568b35d3ccce0eaf0546313d3f35541f794f244097e6c8e80e75ec78c", size = 1272101, upload-time = "2026-05-21T14:32:16.168Z" }, + { url = "https://files.pythonhosted.org/packages/03/bb/f829895ed725d58e5891632e7b2c507b4f88508d2d301b57452ce57190ab/zeroconf-0.149.16-cp312-cp312-win_amd64.whl", hash = "sha256:813c9f0223c97d67970b4013cedeea072a28c0809962d58750d00d502066d2d0", size = 1508797, upload-time = "2026-05-21T14:32:17.93Z" }, + { url = "https://files.pythonhosted.org/packages/e0/f9/ebaa279ecdb453c7b09d4ee20fef74d8684e2d9b76ea5b6fca7bb39e0d73/zeroconf-0.149.16-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:575a548bdc820223e2360fdcf56fb1eea4d8345f286c441c3ef1cd8f68d853f1", size = 1662398, upload-time = "2026-05-21T14:32:19.878Z" }, + { url = "https://files.pythonhosted.org/packages/17/db/18b63b074b966f6374d03d8cd62a5276ab8796021dc798bf79c4723a04ab/zeroconf-0.149.16-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:58efbb338f1ac44b62cfe92999e4476c2051288729de2f6ba8939c977acdd0c5", size = 2070137, upload-time = "2026-05-21T14:32:21.848Z" }, + { url = "https://files.pythonhosted.org/packages/83/55/3f0803f6c6c006761b236117e440e00815c366568ffead49a99a83b10295/zeroconf-0.149.16-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a14610cc2eb90e1530e042183c9ea6981260cdcb97c4ecc7b11ca3749a433664", size = 1875696, upload-time = "2026-05-21T14:32:23.686Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9e/8a7eef9c9c4af9f5a90e95703db1adbfc932453d01679f719f84323bcf0a/zeroconf-0.149.16-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6cb2873ae74265e28756f83454d1fde53eaaed9bc380bb43ccb2ac42b4b5a5a", size = 2169690, upload-time = "2026-05-21T14:32:25.558Z" }, + { url = "https://files.pythonhosted.org/packages/6d/dc/0bd920ece38ca90053d330dee74a1f01089cb0eb7bc7632cf7e87f412311/zeroconf-0.149.16-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:74fedb7c4a073cdc410770e54c27e8c87072c5a4e68eb8d680c0111c84364c0d", size = 2106805, upload-time = "2026-05-21T14:32:27.821Z" }, + { url = "https://files.pythonhosted.org/packages/b5/14/26ce98ef1420aa46a5b02ef24349cf41218313726481949d67807213e218/zeroconf-0.149.16-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b76b7228db26895090ff0e156ef8e193039e971c443d3c14fa8589f08bae2c6e", size = 2100414, upload-time = "2026-05-21T14:32:29.67Z" }, + { url = "https://files.pythonhosted.org/packages/1e/aa/3a2a09a4f2699ddb7506ef0b802d41b6858d09224c42a9b672ea9db8a086/zeroconf-0.149.16-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:cd4b56d4951d62a6e7629286877c123703ac27b2a35e7744a63cc860ee3aaf39", size = 1963115, upload-time = "2026-05-21T14:32:31.448Z" }, + { url = "https://files.pythonhosted.org/packages/24/d9/74f5e6f4530dfb615f3ecc1d23c4ac72b6c86b1bdd4a6fe9dab15466f609/zeroconf-0.149.16-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:7c1003a992ef65a10a29194630a58c2bfd4a87b9117a161c2ad9a1773d8191e1", size = 2117608, upload-time = "2026-05-21T14:32:33.41Z" }, + { url = "https://files.pythonhosted.org/packages/15/69/cb4ed1b97ab3a4f4d8a328439c2050211f6826cefec2d0bd6e0bcd59b4bd/zeroconf-0.149.16-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:63d48d9ed65f2bf05d237fc42773dab74d8ddf6d241c88e65297769c5ba87931", size = 2193839, upload-time = "2026-05-21T14:32:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/be/d9/064323d0b9ba42e93c9afd9db3aa6d14c7ddbfe07dc242d897c5f9abf7b5/zeroconf-0.149.16-cp313-cp313-win32.whl", hash = "sha256:9dbff444474354460f19b592b1f57b72688f23455d8f208f616ca49e8ecbc4ef", size = 1269059, upload-time = "2026-05-21T14:32:37.584Z" }, + { url = "https://files.pythonhosted.org/packages/11/77/c15f97a2e3e373bd6e1431238028700f5537667e502d7fa0b218619ac6ad/zeroconf-0.149.16-cp313-cp313-win_amd64.whl", hash = "sha256:8f669e6aed52feaf73316a6205eff09696bfddce3e4f39fbc2c6e28500366dda", size = 1505791, upload-time = "2026-05-21T14:32:39.903Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d8/6524389c593f6963224cc0604bdb0424f97a7baa0417690120a652bfbba5/zeroconf-0.149.16-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:379ee7aadefffa2be227a501025356ef9685397a38b471df42032c30eff9e54d", size = 1678965, upload-time = "2026-05-21T14:32:41.797Z" }, + { url = "https://files.pythonhosted.org/packages/09/17/a7c58cb389ee7f344a30014ba16c2badbf85eb4cc0ae5c89bedd74070561/zeroconf-0.149.16-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb7fb399b475ec1d18cfef70ffce89bd168a37f3f5db9387ce37d7a180baa678", size = 2095711, upload-time = "2026-05-21T14:32:43.718Z" }, + { url = "https://files.pythonhosted.org/packages/92/3a/de0fff96e52cc04441f6194aa3db2b8e4e886b93b5c5ece370db5e921f14/zeroconf-0.149.16-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:57029dca8e6c9be83bd94130d41f6e1c2ce8b3f4678480b27bf24aa2053ade24", size = 1852716, upload-time = "2026-05-21T14:32:45.675Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/d067e60f39ceee21e3c12b1ed7181743001b46c331b3657717d7b0f8eab2/zeroconf-0.149.16-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fa4576c6d9b029ea425c2fffa586dd5d25295ec78d28b219f35e9a2d6786cb7f", size = 2182010, upload-time = "2026-05-21T14:32:47.845Z" }, + { url = "https://files.pythonhosted.org/packages/1e/1e/6e7d8b87d59b6cb81127377aa8a8008c3dabe3f12d7fc0b709ef29bfcc86/zeroconf-0.149.16-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d58bd30d137402f101b1b7e5c7ab508632e101007fea38f3b3907e983cd32789", size = 2117823, upload-time = "2026-05-21T14:32:49.768Z" }, + { url = "https://files.pythonhosted.org/packages/22/53/d5ca316708c3fa1c81423b88aa778f9d19e8bd48ca8dc3d035e53f687844/zeroconf-0.149.16-cp314-cp314-manylinux_2_41_x86_64.whl", hash = "sha256:1559b089d58e47a057a68cfe36e9910acc01d500b2d51ae410a38dbda5d66230", size = 2178304, upload-time = "2026-05-21T14:04:14.129Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8d/1d139ddf2c9e8484c14a5cf5dd08033513a894e061843fe64ffee9c30f64/zeroconf-0.149.16-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ddf6db171e2a44dda0cb68956c50e165a6f106b0f4ec480575ff2ef91134dfef", size = 2126907, upload-time = "2026-05-21T14:32:52.089Z" }, + { url = "https://files.pythonhosted.org/packages/39/a6/e85aae306295b7bdd66e53f01fe62c18da6fd9a93cb89033950c52b536b9/zeroconf-0.149.16-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5d8308cad249e52fc7466c2a9315438d42e9a142c5c33f622b60601d9f0e7ef7", size = 1934204, upload-time = "2026-05-21T14:32:54.064Z" }, + { url = "https://files.pythonhosted.org/packages/07/f1/26b033222d669d7e96a3a1b2cdb252905686288d9071ea96716b8899d548/zeroconf-0.149.16-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:56a14eb52d65c683a180f91d007c87ff7922a5f6e839dd1fb74d81675d783111", size = 2127389, upload-time = "2026-05-21T14:32:56.182Z" }, + { url = "https://files.pythonhosted.org/packages/70/92/b57d3569ad362f57ab9dd1f5a48c22c2a81fe0dedf5e31c92b7853b109d4/zeroconf-0.149.16-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f817bf1b0319885882a208511249e5e2fe5ceb02fe3a9060ce295f064d13c3bd", size = 2204573, upload-time = "2026-05-21T14:32:58.303Z" }, + { url = "https://files.pythonhosted.org/packages/ad/7c/35d5ac05eaed8de2f39af47ea9501244a98ffe2a70b1ddfac04d06a1b070/zeroconf-0.149.16-cp314-cp314-win32.whl", hash = "sha256:aad4b1941354bf7d0b4005b22495e6fc99218e37c9ebf0f556da9cf1ac94060f", size = 1301124, upload-time = "2026-05-21T14:33:00.147Z" }, + { url = "https://files.pythonhosted.org/packages/09/64/ee0c34a953c474a2c512252cd2bfd5932882afbc9519adceb3102d1c4261/zeroconf-0.149.16-cp314-cp314-win_amd64.whl", hash = "sha256:f0814028202457aac116f4315c8eff407b77aa0b441b0f27a60ac83699a04641", size = 1544411, upload-time = "2026-05-21T14:33:02.193Z" }, + { url = "https://files.pythonhosted.org/packages/a5/4b/e8e2208c874b381264b21bac29aef50212c5e357988ff009200c7ed32074/zeroconf-0.149.16-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:35301c63f8dd0328e1e78fc8444cdc39bd472e6cda2570dd185b78126f576c41", size = 3354872, upload-time = "2026-05-21T14:33:04.485Z" }, + { url = "https://files.pythonhosted.org/packages/7a/73/b6b017f67d745c37c1ebc4cde3c51ff10edcd88adf7a6924c21105b175bd/zeroconf-0.149.16-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2bdd9052521a7795d6386021b1346a8f87ec66da6a917d904a327e947a6c87ce", size = 4010851, upload-time = "2026-05-21T14:33:06.692Z" }, + { url = "https://files.pythonhosted.org/packages/69/5b/9e5e15e5b0428a25b768348cdec870d533b0a54fe6f0e773630329fbd159/zeroconf-0.149.16-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3e77d9235a33a79ebe57fa711fe5a1f9a225e64b2964ab329d320d22cdcf1486", size = 3556413, upload-time = "2026-05-21T14:33:09.547Z" }, + { url = "https://files.pythonhosted.org/packages/ef/24/8d8a89e2775a86e4bd1501ff91db689320bcfc7b93f725d8530f42211cfa/zeroconf-0.149.16-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:79cb12a3a189cf20a5903af3a76fc0a12d32b44a1e75391c0c4875d82d959843", size = 4166790, upload-time = "2026-05-21T14:33:12.107Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f3/7c8f1d999d37d8c6f186cad6b5eacfc270fadb3412bf6dcd354e1696e4fc/zeroconf-0.149.16-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:752942a1a803cc4aa1dc83227d0ce408912b8c5f7b22db0d9ee9b6541cb1f32b", size = 4039796, upload-time = "2026-05-21T14:33:14.288Z" }, + { url = "https://files.pythonhosted.org/packages/78/0c/f0d563de36cb4c6b7c68cbe588a0ffe129a12f1b59d880b6216dde967f05/zeroconf-0.149.16-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:37b93d889804f029d782059f77d22073b7fcff055a3e48f414164e2a79dd758b", size = 4075206, upload-time = "2026-05-21T14:33:16.623Z" }, + { url = "https://files.pythonhosted.org/packages/4f/65/df2cb77eefde839cd095f8262a41044be851130dce5ed84125896dd5e9b4/zeroconf-0.149.16-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:9aa9ba48ceec11f5b768a81335409672eff294d7ac9c6ac5dcbcc1b0d3538a49", size = 3707566, upload-time = "2026-05-21T14:33:18.79Z" }, + { url = "https://files.pythonhosted.org/packages/34/59/d76eabba71dca1cb17ad806ed79ad9c96f5150304cf4cfc532a9db30edcf/zeroconf-0.149.16-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2f5a409d2df93e02137d4cd86c6bcab5636ee4b80bc40963eb1ece32a631c848", size = 4065727, upload-time = "2026-05-21T14:33:21.374Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9f/a830a10b69844cc3f3e5bb82a1622b9757e627c5d68236f58ebbfe6c16b1/zeroconf-0.149.16-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25ace9939767ed7bda7da6d08be50ba3ad90a9055ddbc28288d5c8d080a7c7c3", size = 4215403, upload-time = "2026-05-21T14:33:23.571Z" }, + { url = "https://files.pythonhosted.org/packages/0e/74/0f52fd22323e7142dc9a72b5f46ba6e8170835a5d7fc15325ab98f574569/zeroconf-0.149.16-cp314-cp314t-win32.whl", hash = "sha256:f8445c04cdb544a3aa58e5e30cb54dfd4c1cb82ab6389e7155872fa1c7ba7c0f", size = 2595104, upload-time = "2026-05-21T14:33:25.958Z" }, + { url = "https://files.pythonhosted.org/packages/8e/fd/f44626de06585216091baef6da5c56ea834f36de4ec754e2a2a79b659987/zeroconf-0.149.16-cp314-cp314t-win_amd64.whl", hash = "sha256:fc77736d1b8c22c1ad2c13d48aab0374a75f8aa1a21bae075478eb1c54a2c1bf", size = 3103203, upload-time = "2026-05-21T14:33:28.109Z" }, +]