Skip to content
33 changes: 25 additions & 8 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,14 @@ Provide a maintainable local-control platform for Devialet Phantom volume, with:
- `ports.py`: contracts (`VolumeGateway`, discovery target models)
- `src/devialetctl/infrastructure`
- `devialet_gateway.py`: async HTTP calls to Devialet API (`httpx.AsyncClient`)
- `mdns_gateway.py`: zeroconf discovery + filtering
- `mdns_gateway.py`: mDNS/zeroconf discovery + filtering
- `upnp_gateway.py`: SSDP/UPnP discovery (`MediaRenderer:2`)
- `cec_adapter.py`: Linux CEC kernel adapter (`/dev/cec0`, ioctl, async event stream)
- `keyboard_adapter.py`: single-key or line-based keyboard input
- `config.py`: typed runtime config (TOML + env overrides)
- `src/devialetctl/interfaces`
- `cli.py`: argparse and command wiring
- `topology.py`: topology tree building/rendering and system-name target selection
- Compatibility shims
- `src/devialetctl/api.py`
- `src/devialetctl/discovery.py`
Expand Down Expand Up @@ -62,6 +64,7 @@ flowchart LR
keyboardAdapter[KeyboardAdapter]
httpGateway[DevialetHttpGateway]
mdnsGateway[MdnsDiscoveryGateway]
upnpGateway[UpnpDiscoveryGateway]
configLoader[ConfigLoader]
end

Expand All @@ -79,25 +82,39 @@ flowchart LR
volumeService --> httpGateway
httpGateway --> devialetSpeaker
devialetSpeaker --> mdnsGateway
devialetSpeaker --> upnpGateway
daemonInterface --> mdnsGateway
daemonInterface --> upnpGateway
cliInterface --> mdnsGateway
cliInterface --> upnpGateway
configLoader --> daemonInterface
configLoader --> cliInterface
```

## Command Surface

- Direct control:
- `list`, `systems`, `getvol`, `setvol`, `volup`, `voldown`, `mute`
- `list`, `tree`, `systems`, `getvol`, `setvol`, `volup`, `voldown`, `mute`
- Daemon:
- `daemon --input cec`
- `daemon --input keyboard`
- Target selection:
- global args and daemon-specific target args (`--ip`, `--port`, `--base-path`, `--index`, `--discover-timeout`)
- global args (`--ip`, `--port`, `--system`, `--discover-timeout`)
- daemon-specific CEC args (`--cec-device`, `--cec-osd-name`, `--cec-vendor-compat`)
- `--ip` and `--system` are mutually exclusive
- `list` and `tree` reject `--ip` / `--system` because they are discovery-only

## Runtime Behavior

- mDNS discovery filters likely Devialet devices by service identity heuristics.
- Discovery uses merged mDNS + UPnP:
- mDNS path: `_whatsup._tcp.local` browsing.
- UPnP path: SSDP `M-SEARCH` with target `urn:schemas-upnp-org:device:MediaRenderer:2`.
- targets are deduplicated by `(address, port, base_path)` before selection.
- `tree` command builds a topology from "current" endpoints:
- per discovered dispatcher: `/devices/current`
- per inferred system: `/systems/current`
- groups are rebuilt from `groupId`/`systemId` relationships.
- system-targeted selection (`--system`) prefers `isSystemLeader` when available.
- Base path is normalized defensively:
- `None`, `""`, `/` -> `/ipcontrol/v1`
- missing leading slash is corrected.
Expand Down Expand Up @@ -153,13 +170,13 @@ sequenceDiagram
## Configuration Model

Config source priority:
1. CLI daemon target args (when provided)
1. CLI global target args (when provided)
2. Environment variables (`DEVIALETCTL_IP`, `DEVIALETCTL_PORT`, `DEVIALETCTL_BASE_PATH`)
3. TOML config (`~/.config/devialetctl/config.toml`)
4. built-in defaults

Relevant settings:
- target: `ip`, `port`, `base_path`, `discover_timeout`, `index`
- target: `ip`, `port`, `base_path`, `discover_timeout`
- daemon: `cec_device`, `cec_osd_name`, `cec_vendor_compat`, `reconnect_delay_s`, `log_level`
- policy: `dedupe_window_s`, `min_interval_s`

Expand All @@ -178,8 +195,8 @@ Current test suite covers:
- keyboard parser behavior
- event policy dedupe/rate-limit
- daemon routing in CEC and keyboard modes
- CLI regressions (including daemon argument handling)
- mDNS filtering
- CLI regressions (including daemon argument handling and `tree --json`)
- mDNS + UPnP discovery integration
- base-path normalization
- compatibility wrappers

Expand Down
32 changes: 23 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@ Use cases:

## Features

- mDNS discovery (`_http._tcp.local`)
- mDNS discovery (`_whatsup._tcp.local`) merged with UPnP discovery
- volume commands: `getvol`, `setvol`, `volup`, `voldown`, `mute`
- manual target override (`--ip`, `--port`, `--base-path`)
- target selection with `--system <name>` (preferred for multi-device setups)
- manual target override (`--ip`, `--port`)
- long-running daemon mode (`daemon --input cec`)
- keyboard test mode (`daemon --input keyboard`)
- typed config via TOML + env overrides
Expand Down Expand Up @@ -66,7 +67,13 @@ uv run devialetctl setvol 35
Use explicit target:

```bash
uv run devialetctl --ip 192.168.1.42 --port 80 --base-path /ipcontrol/v1 getvol
uv run devialetctl --ip 192.168.1.42 --port 80 getvol
```

Use system-name target selection (from `tree` output):

```bash
uv run devialetctl --system "TV" getvol
```

## Daemon (CEC Input)
Expand All @@ -77,6 +84,12 @@ Run daemon with config:
uv run devialetctl daemon --input cec
```

Override daemon CEC settings for one run (global options stay before subcommand):

```bash
uv run devialetctl --system "TV" daemon --input cec --cec-vendor-compat samsung
```

The daemon:
- consumes CEC key events from Linux CEC (`/dev/cec0`, ioctl backend)
- normalizes to volume actions
Expand All @@ -100,13 +113,13 @@ Run daemon in a container (CEC mode):
docker run --rm -it \
--network host \
--device /dev/cec0:/dev/cec0 \
ghcr.io/<owner>/<repo>:latest \
daemon --input cec --cec-vendor-compat="samsung"
ghcr.io/clementperon/devialet-phantom-ctl:latest \
--system "TV" daemon --input cec --cec-vendor-compat="samsung"
```

Notes:
- `--device /dev/cec0:/dev/cec0` passes the Linux CEC device into the container.
- `--network host` is required for mDNS discovery (`_http._tcp.local`) from the container.
- `--network host` is required for mDNS discovery (`_whatsup._tcp.local`) from the container.
- `--cec-vendor-compat samsung` enables Samsung vendor compatibility mode for this run.
- alternatively, set `DEVIALETCTL_CEC_VENDOR_COMPAT=samsung` to make it the environment default.
- with a fixed target IP (`DEVIALETCTL_IP`), you can usually run without host networking.
Expand Down Expand Up @@ -145,9 +158,6 @@ min_interval_s = 0.12
[target]
ip = "192.168.1.42"
port = 80
base_path = "/ipcontrol/v1"
discover_timeout = 3.0
index = 0
```

Use `log_level = "DEBUG"` (or `DEVIALETCTL_LOG_LEVEL=DEBUG`) to log raw HDMI-CEC frames:
Expand All @@ -162,6 +172,10 @@ Environment overrides:
- `DEVIALETCTL_LOG_LEVEL`
- `DEVIALETCTL_CEC_DEVICE`

CLI target selection notes:
- `--ip` and `--system` are mutually exclusive.
- `list` and `tree` are discovery-only commands and reject `--ip` / `--system`.

Kernel CEC permissions note:
- the daemon user must have read/write access to `/dev/cec0` (typically via `video` group or udev rule)
- if startup fails with ioctl/device access errors, verify `ls -l /dev/cec*` and group membership
Expand Down
12 changes: 10 additions & 2 deletions src/devialetctl/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from typing import List

from devialetctl.infrastructure.mdns_gateway import MdnsDiscoveryGateway
from devialetctl.infrastructure.upnp_gateway import UpnpDiscoveryGateway


@dataclass(frozen=True)
Expand All @@ -15,8 +16,15 @@ class DevialetService:
def discover(
timeout_s: float = 3.0, service_type: str = "_whatsup._tcp.local."
) -> List[DevialetService]:
gateway = MdnsDiscoveryGateway(service_type=service_type)
results = gateway.discover(timeout_s=timeout_s)
results = []
seen: set[tuple[str, int, str]] = set()
for gateway in (MdnsDiscoveryGateway(service_type=service_type), UpnpDiscoveryGateway()):
for svc in gateway.discover(timeout_s=timeout_s):
key = (svc.address, svc.port, svc.base_path)
if key in seen:
continue
seen.add(key)
results.append(svc)
return [
DevialetService(name=r.name, address=r.address, port=r.port, base_path=r.base_path)
for r in results
Expand Down
2 changes: 1 addition & 1 deletion src/devialetctl/domain/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,4 @@ class InputEvent:
muted: bool | None = None
vendor_subcommand: int | None = None
vendor_mode: int | None = None
vendor_payload: tuple[int, ...] | None = None
vendor_payload: tuple[int, ...] | None = None
3 changes: 3 additions & 0 deletions src/devialetctl/infrastructure/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from .devialet_gateway import DevialetHttpGateway
from .keyboard_adapter import KeyboardAdapter, parse_keyboard_command
from .mdns_gateway import MdnsDiscoveryGateway, MdnsService
from .upnp_gateway import UpnpDiscoveryGateway, UpnpService

__all__ = [
"DaemonConfig",
Expand All @@ -12,4 +13,6 @@
"parse_keyboard_command",
"MdnsDiscoveryGateway",
"MdnsService",
"UpnpDiscoveryGateway",
"UpnpService",
]
3 changes: 3 additions & 0 deletions src/devialetctl/infrastructure/devialet_gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ async def _apost(self, path: str, payload: dict[str, Any] | None = None) -> None
)
r.raise_for_status()

async def fetch_json_async(self, path: str) -> dict[str, Any]:
return await self._aget(path)

async def systems_async(self) -> dict[str, Any]:
try:
return await self._aget("/systems")
Expand Down
139 changes: 139 additions & 0 deletions src/devialetctl/infrastructure/upnp_gateway.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import logging
import re
import socket
import time
from dataclasses import dataclass
from typing import Iterable
from urllib.parse import urlparse

import httpx # type: ignore[reportMissingImports]

from devialetctl.application.ports import DiscoveryPort, Target

_SSDP_ADDR = ("239.255.255.250", 1900)
_SSDP_SEARCH_TARGET = "urn:schemas-upnp-org:device:MediaRenderer:2"
_DEFAULT_BASE_PATH = "/ipcontrol/v1"
_DEFAULT_PORT = 80
LOG = logging.getLogger(__name__)


@dataclass(frozen=True)
class UpnpService:
name: str
address: str
port: int
base_path: str


def _parse_ssdp_headers(payload: bytes) -> dict[str, str]:
text = payload.decode("utf-8", errors="ignore")
lines = [line.strip() for line in text.splitlines() if line.strip()]
headers: dict[str, str] = {}
for line in lines[1:]:
if ":" not in line:
continue
key, value = line.split(":", 1)
headers[key.strip().lower()] = value.strip()
return headers


def _iter_ssdp_responses(timeout_s: float) -> Iterable[dict[str, str]]:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) as sock:
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2)
sock.settimeout(max(0.2, min(timeout_s, 1.0)))
msg = "\r\n".join(
[
"M-SEARCH * HTTP/1.1",
"HOST: 239.255.255.250:1900",
'MAN: "ssdp:discover"',
"MX: 1",
f"ST: {_SSDP_SEARCH_TARGET}",
"",
"",
]
).encode("ascii")
LOG.debug("UPnP SSDP M-SEARCH start st=%s timeout_s=%.2f", _SSDP_SEARCH_TARGET, timeout_s)
sock.sendto(msg, _SSDP_ADDR)

deadline = time.monotonic() + max(0.1, timeout_s)
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
LOG.debug("UPnP SSDP M-SEARCH finished (timeout reached)")
return
sock.settimeout(max(0.05, min(remaining, 0.5)))
try:
payload, _ = sock.recvfrom(8192)
except TimeoutError:
continue
except OSError:
LOG.debug("UPnP SSDP receive aborted due to socket error")
return
headers = _parse_ssdp_headers(payload)
if headers:
LOG.debug(
"UPnP SSDP response location=%s st=%s usn=%s",
headers.get("location", ""),
headers.get("st", ""),
headers.get("usn", ""),
)
yield headers


def _is_devialet_manufacturer(location: str, timeout_s: float) -> bool:
timeout = max(0.3, min(timeout_s, 1.5))
try:
with httpx.Client(timeout=timeout) as client:
response = client.get(location)
response.raise_for_status()
xml_text = response.text
except Exception as exc:
LOG.debug("UPnP XML fetch failed location=%s err=%s", location, exc)
return False

if not re.search(
r"<manufacturer>\s*Devialet\s*</manufacturer>",
xml_text,
flags=re.IGNORECASE,
):
LOG.debug("UPnP XML rejected location=%s manufacturer_tag_not_found", location)
return False

LOG.debug("UPnP XML accepted location=%s manufacturer=Devialet", location)
return True


class UpnpDiscoveryGateway(DiscoveryPort):
def discover(self, timeout_s: float = 3.0) -> list[Target]:
uniq: dict[str, UpnpService] = {}
LOG.debug(
"UPnP discovery begin timeout_s=%.2f target=%s",
timeout_s,
_SSDP_SEARCH_TARGET,
)
for headers in _iter_ssdp_responses(timeout_s):
location = headers.get("location", "")
if not _is_devialet_manufacturer(location=location, timeout_s=timeout_s):
continue
parsed = urlparse(location)
host = parsed.hostname
if not host:
LOG.debug("UPnP response ignored (missing host in location): %s", location)
continue
if host in uniq:
continue

uniq[host] = UpnpService(
name=f"UPnP:{host}",
address=host,
port=_DEFAULT_PORT,
base_path=_DEFAULT_BASE_PATH,
)
LOG.debug("UPnP device accepted host=%s base_path=%s", host, _DEFAULT_BASE_PATH)

targets = [
Target(address=s.address, port=s.port, base_path=s.base_path, name=s.name)
for s in uniq.values()
]
LOG.debug("UPnP discovery done found=%d", len(targets))
return targets
Comment on lines 106 to 139
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new UPnP discovery gateway lacks unit tests. The repository has comprehensive test coverage for similar components (e.g., test_mdns_gateway_listener.py, test_mdns_gateway_cleanup.py). Consider adding similar unit tests for the UPnP gateway to cover: SSDP header parsing, response iteration with timeouts, socket error handling, deduplication logic, and the discovery method.

Copilot uses AI. Check for mistakes.
Loading
Loading