diff --git a/.claude/skills/adding-device-support/SKILL.md b/.claude/skills/adding-device-support/SKILL.md index df717c2..1ef6355 100644 --- a/.claude/skills/adding-device-support/SKILL.md +++ b/.claude/skills/adding-device-support/SKILL.md @@ -218,10 +218,14 @@ This is a rule about **writes and entities**, not about reading. A speculative `GET` of an href a dump doesn't contain is fine and the codebase already relies on it: `read_identity` reads `/oic/p`, `/oic/d` and `/oic/res`, and `subdevices.enumerate_subdevices` probes `/device/`, `//device/0` and -`/multidevice/vs/0` on every device. A RETRIEVE is non-mutating and a 4.04 is -tolerated everywhere in that path, so the cost of a wrong guess is one wasted -round trip. Guessing a *write* against live hardware is the thing this rule -forbids — as is materializing an entity from a field you can't explain. +`/multidevice/vs/0` on every device — and, when a prefixed candidate's own +`//device/0` doesn't answer (issue #205: not guaranteed even on the +board this pattern was built against), every href the master itself +answered this cycle, individually under that UUID's prefix (see §11). A +RETRIEVE is non-mutating and a 4.04 is tolerated everywhere in that path, so +the cost of a wrong guess is one wasted round trip. Guessing a *write* +against live hardware is the thing this rule forbids — as is materializing +an entity from a field you can't explain. ## 6. Select options: read them from the device, don't hardcode @@ -372,22 +376,38 @@ the dump in this order; each step rules out a different cause. 1. **`subdevice_probes`** — did we even look? Every seed attempted appears here with what it returned. An absent seed means enumeration never tried - that path; a `false` means it tried and got nothing. -2. **`subdevices_skipped`** — did we find it and reject it? A candidate lands - here when its seed answered but it produced no *primary* (non-diagnostic) - entity with a populated value. Its `resources` block holds the exact reps - the gate judged, so you can check the call yourself. If every - power/mode/temperature rep is `{}`, the subdevice is an unused slot and the - skip is correct. If they're populated, the gate is wrong — that's a bug - worth a fixture. -3. **`multidevice.numofsubdevice`** — the board's own count, where it - reports one. Disagreement with `len(subdevices) + 1` is a strong hint, - not proof; only one board family is known to expose it. -4. **Which pattern is this board?** `identity.resources['/oic/res']` listing + that path; a `false` means it tried and got nothing. On a UUID-prefixed + board whose `//device/0` reads `false` (issue #205 — this isn't + rare, not even on the board the pattern was built against), the report + also carries one probe per href the master itself answered that cycle, + individually under that prefix (`subdevices.enumerate_subdevices`'s flat + fallback) — a `true` there is real, confirmed-live evidence for that one + href, not a guess. +2. **`subdevices`**/**`flat_hrefs`** — for a *materialized* subdevice found + this way, `flat_hrefs` lists exactly which hrefs it's actually being + polled on (individually, no Collection endpoint to batch through) — + compare against the master's own hrefs to see what's still unconfirmed + for that sibling. +3. **`subdevices_skipped`** — did we find it and reject it? A candidate lands + here when its seed(s) answered but it produced no *primary* + (non-diagnostic) entity with a populated value. Its `resources` block + holds the exact reps the gate judged, so you can check the call + yourself. If every power/mode/temperature rep is `{}`, the subdevice is + an unused slot and the skip is correct. If they're populated, the gate + is wrong — that's a bug worth a fixture. A flat-fallback candidate whose + only confirmed href is `/information/vs/0` (never bound to any entity — + only ever read for device-type resolution) will *always* land here until + more of its hrefs are confirmed live; that's the gate working as + intended, not a bug to chase. +4. **`multidevice.numofsubdevice`** — the board's own count, where it + reports one. Disagreement with `len(subdevices) + len(subdevices_skipped)` + is a strong hint, not proof; only one board family is known to expose it. +5. **Which pattern is this board?** `identity.resources['/oic/res']` listing `/device/1`, `/device/2` means indexed siblings. `resources['/subdevices/ vs/0']` carrying a `subdeviceIdList` means a UUID-prefixed tree, and that - same UUID usually shows up as an href prefix in `/oic/res` too. Neither - present, on a device the owner insists has two subdevices, is the + same UUID usually shows up as an href prefix in `/oic/res` too — enumerate + whether or not `//device/0` itself answers, per §5's fallback. + Neither present, on a device the owner insists has two subdevices, is the interesting case — that's a third mechanism and needs a new dump, not a code guess. diff --git a/custom_components/localthings/coordinator.py b/custom_components/localthings/coordinator.py index d2c0899..88000b2 100644 --- a/custom_components/localthings/coordinator.py +++ b/custom_components/localthings/coordinator.py @@ -414,6 +414,8 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): sess = self._session if sess is None: return {} + if subdevice.flat_hrefs: + return self._poll_subdevice_flat_hrefs(subdevice) try: code, payload = sess.get(list(subdevice.seed_path), timeout=10.0) if code == 0x45 and payload: @@ -424,6 +426,36 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]): self._log.debug("subdevice %s seed poll failed: %s", subdevice.key, e) return {} + def _poll_subdevice_flat_hrefs(self, subdevice: Subdevice) -> dict[str, dict]: + """Re-poll a flat-mode prefixed subdevice's hrefs individually + (issue #205) -- it has no Collection endpoint to batch-refresh + through (see enumerate_subdevices' fallback), so each canonical + href confirmed at enumeration time gets its own GET under the + subdevice's prefix. A href failing to answer this cycle just drops + out of the result, same "never let a sibling's flakiness fail the + master's poll" posture as the Collection path above.""" + sess = self._session + result: dict[str, dict] = {} + first = True + for href in subdevice.flat_hrefs: + if not first: + sess.pace() + first = False + actual = subdevice.to_actual(href) + try: + path = [s for s in actual.strip('/').split('/')] + code, payload = sess.get(path, timeout=10.0) + if code == 0x45 and payload: + rep = cbor2.loads(payload) + if isinstance(rep, dict): + result[actual] = rep + except Exception as e: + self._log.debug( + "subdevice %s flat href %s poll failed: %s", + subdevice.key, href, e, + ) + return result + def _poll_hrefs_blocking(self, hrefs: list[str]) -> dict[str, dict]: """GET individual hrefs sequentially. Does not reconnect on failure. Blocking.""" if self._session is None: diff --git a/custom_components/localthings/diagnostics.py b/custom_components/localthings/diagnostics.py index 2a1c010..060a326 100644 --- a/custom_components/localthings/diagnostics.py +++ b/custom_components/localthings/diagnostics.py @@ -41,6 +41,17 @@ async def async_get_config_entry_diagnostics( # registry/identity.py. identity = coordinator._identity + def _seed_diag(su) -> dict: + # A flat-mode subdevice (issue #205 -- no working //device/0 + # Collection, so its state comes from individually-polled hrefs + # instead) has no meaningful seed_path; report the flat_hrefs list + # in its place rather than the misleading bare "/" a joined empty + # tuple would otherwise produce. + return { + "seed_path": ("/" + "/".join(su.seed_path)) if su.seed_path else None, + "flat_hrefs": list(su.flat_hrefs), + } + def _subdevice_diag(su) -> dict: # One pass over coordinator.bound for both fields below (count and # the distinct hrefs), and one redaction of this subdevice's canonical @@ -53,7 +64,7 @@ async def async_get_config_entry_diagnostics( return { "kind": su.kind, "key": su.key, - "seed_path": "/" + "/".join(su.seed_path), + **_seed_diag(su), "bound_entity_count": len(matching), "hrefs": sorted({b.href for b in matching}), "model": res.get('/information/vs/0', {}).get('x.com.samsung.da.modelNum', ''), @@ -110,7 +121,7 @@ async def async_get_config_entry_diagnostics( { "kind": skip.subdevice.kind, "key": skip.subdevice.key, - "seed_path": "/" + "/".join(skip.subdevice.seed_path), + **_seed_diag(skip.subdevice), "hrefs": list(skip.hrefs), # The reps the liveness gate actually judged, canonicalized # like the materialized subdevices above. These are the one diff --git a/custom_components/localthings/registry/subdevices.py b/custom_components/localthings/registry/subdevices.py index 8d5590e..01fe1b8 100644 --- a/custom_components/localthings/registry/subdevices.py +++ b/custom_components/localthings/registry/subdevices.py @@ -18,11 +18,17 @@ Pattern B -- UUID-prefixed tree (`TP2X_FAC_BORA_21K`, jhkwon19's board). `/oic/res` hides the whole appliance tree; `/device/0`'s batch instead carries `x.com.samsung.da.subdeviceIdList` on `/subdevices/vs/0`, and that same UUID appears as a literal href prefix in `/oic/res` -(`//file/list/vs/0`, ...). `GET //device/0` returns the second -subdevice's own Collection batch, confirmed live by the reporter to carry a -different model/serial than the master (`TP2X_FAC_BORA_RAC_21K`, the -wall-mounted subdevice, vs. the master's `TP2X_FAC_BORA_21K`, the floor -subdevice). +(`//file/list/vs/0`, ...). On jhkwon19's own first unit, `GET +//device/0` returned the second subdevice's own Collection batch, +confirmed live to carry a different model/serial than the master +(`TP2X_FAC_BORA_RAC_21K`, the wall-mounted subdevice, vs. the master's +`TP2X_FAC_BORA_21K`, the floor subdevice) -- but issue #205, a second +TP2X_FAC_BORA_21K unit, showed that same `//device/0` probe coming +back empty, so it isn't a property of the board family, only of the +individual unit/firmware. When it's empty, `enumerate_subdevices` falls back +to probing every href the master itself answered this cycle individually +under the UUID prefix, on the assumption that a composite device's siblings +share the master's resource surface -- see `Subdevice.flat_hrefs`. Both are "the same thing wearing different clothes": a logical subdevice is a seed collection path to poll, plus an href transform between the canonical @@ -90,10 +96,19 @@ class Subdevice: ('1', '2', ...) or the full subdevice UUID, and `seed_path` is the Collection href (as path segments) whose batch response enumerates/refreshes that subdevice. + + `flat_hrefs` is non-empty only for a 'prefixed' subdevice that doesn't + expose its own Collection at `seed_path` (issue #205 -- not even + TP2X_FAC_BORA_21K, the board this pattern was built against, always + does). When set, `seed_path` is meaningless (left as `()`) and this + subdevice's state comes from GETting each of these canonical hrefs + individually under its prefix instead of one Collection batch -- see + enumerate_subdevices' fallback and coordinator._poll_subdevice_seed. """ kind: str # 'main' | 'indexed' | 'prefixed' key: str # '' | '1' | '6c2dff6d-ee5c-dad1-6a5e-000000000001' seed_path: tuple[str, ...] + flat_hrefs: tuple[str, ...] = () def to_actual(self, canonical: str) -> str: """Canonical registry href (e.g. '/mode/vs/0') -> the real, @@ -319,11 +334,41 @@ def enumerate_subdevices( seed = (sub_id, 'device', '0') batch = _get_batch(sess, seed) _probed(_seed_href(seed), batch) - if not batch: + if batch: + subdevice = Subdevice(kind='prefixed', key=sub_id, seed_path=seed) + fetched.update(normalize_seed_batch(subdevice, batch)) + subdevices.append(subdevice) continue - subdevice = Subdevice(kind='prefixed', key=sub_id, seed_path=seed) - fetched.update(normalize_seed_batch(subdevice, batch)) - subdevices.append(subdevice) + # Fallback (issue #205): TP2X_FAC_BORA_21K itself -- the board this + # pattern was built against -- turns out not to always expose its own + # `//device/0` Collection either, so "every prefixed subdevice + # has one" doesn't hold even on the reference hardware. With no + # Collection to seed from and no per-UUID entry in `/oic/res` to + # enumerate hrefs from, the only signal left is that a composite + # device's siblings are the same physical board family as the + # subdevice this config entry already talks to -- so probe every + # href the master itself answered this cycle, individually, under + # this UUID's prefix, and keep whichever ones answer. Each is a + # plain tolerated-404 RETRIEVE, same posture as every other probe in + # this function. + flat_hrefs = [] + first = True + for href in sorted(resources): + if not first: + sess.pace() + first = False + actual = f'/{sub_id}{href}' + rep = _get_property(sess, tuple(actual.strip('/').split('/'))) + _probed(actual, bool(rep)) + if rep: + flat_hrefs.append(href) + fetched[actual] = rep + if not flat_hrefs: + continue + subdevices.append(Subdevice( + kind='prefixed', key=sub_id, seed_path=(), + flat_hrefs=tuple(flat_hrefs), + )) # --- Pattern A: indexed siblings (ARTIK051_DONGLE_FAC_18K) -------------- indices = sorted({ diff --git a/tests/fixtures/airconditioner_fac_bora_205_flat_device.json b/tests/fixtures/airconditioner_fac_bora_205_flat_device.json new file mode 100644 index 0000000..bc4bc1f --- /dev/null +++ b/tests/fixtures/airconditioner_fac_bora_205_flat_device.json @@ -0,0 +1,720 @@ +{ + "device0": [ + { + "rt": [ + "x.com.samsung.devcol", + "oic.wk.col" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ] + }, + { + "href": "/alarms/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Alarm", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "ErrorCode_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-30T01:48:51" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Alarm", + "x.com.samsung.da.alarmType": "Device", + "x.com.samsung.da.code": "AC_V_0002_OFF", + "x.com.samsung.da.triggeredTime": "2026-07-30T01:48:51" + } + ] + } + }, + { + "href": "/availablecontrolsets/vs/0", + "rep": { + "x.com.samsung.da.sets": "000000B4012C0000404B04000000", + "x.com.samsung.da.id": "FAC", + "x.com.samsung.da.version": "1.0" + } + }, + { + "href": "/configuration/vs/0", + "rep": { + "x.com.samsung.da.region": "3017000000", + "x.com.samsung.da.airconOptionList": [ + "HOMECARE_WIZARD_V2", + "ENERGY_2.0", + "AI_2.0", + "DeviceTypeMaster", + "SingleCommand_1" + ] + } + }, + { + "href": "/diagnosis/vs/0", + "rep": {} + }, + { + "href": "/drlc/0", + "rep": { + "DRLevel": 0, + "start": "1970-01-01T00:00:00Z", + "duration": 0, + "override": false + } + }, + { + "href": "/drlc/vs/0", + "rep": { + "x.com.samsung.da.drlcLevel": "0", + "x.com.samsung.da.duration": "00:00:00", + "x.com.samsung.da.drlcStartTime": "1970-01-01T00:00:00Z", + "x.com.samsung.da.override": "Off" + } + }, + { + "href": "/energy/consumption/0", + "rep": { + "energy": 800.0, + "power": 65278.0 + } + }, + { + "href": "/energy/consumption/vs/0", + "rep": { + "x.com.samsung.da.cumulativeConsumption": "800.000000", + "x.com.samsung.da.instantaneousPower": "65278.000000", + "x.com.samsung.da.usageThreshold": "0.000000", + "x.com.samsung.da.cumulativePower": "544088", + "x.com.samsung.da.cumulativeUnit": "Wh", + "x.com.samsung.da.instantaneousPowerUnit": "W", + "x.com.samsung.da.cumulativePowerType": "individual" + } + }, + { + "href": "/file/information/vs/0", + "rep": { + "x.com.samsung.timeoffset": "+09:00", + "x.com.samsung.supprtedtype": 1 + } + }, + { + "href": "/filter/airdustfilter/vs/0", + "rep": { + "x.com.samsung.da.filterUsage": "0", + "x.com.samsung.da.filterUsageResolution": "1", + "x.com.samsung.da.filterDesiredUsage": "112", + "x.com.samsung.da.filterStatus": "normal", + "x.com.samsung.da.filterCapacity": "112", + "x.com.samsung.da.filterCapacityUnit": "Hour", + "x.com.samsung.da.filterResetType": [ + "washable" + ], + "x.com.samsung.da.supportedFilterDesiredUsage": [ + "112", + "224", + "336", + "448" + ] + } + }, + { + "href": "/humidity/0", + "rep": { + "humidity": 0 + } + }, + { + "href": "/humidity/vs/0", + "rep": { + "x.com.samsung.da.humidity": "0.000000", + "x.com.samsung.da.fivepercentHumidity": "58" + } + }, + { + "href": "/information/vs/0", + "rep": { + "x.com.samsung.da.modelNum": "TP2X_FAC_BORA_21K|10233041|600001110015110006000C1200830000", + "x.com.samsung.da.description": "TP2X_FAC_BORA_21K", + "x.com.samsung.da.serialNum": "**REDACTED**", + "x.com.samsung.da.otnDUID": "**REDACTED**", + "x.com.samsung.da.diagProtocolType": "WIFI_HTTPS", + "x.com.samsung.da.diagLogType": [ + "errCode", + "dump" + ], + "x.com.samsung.da.diagDumpType": "file", + "x.com.samsung.da.diagEndPoint": "SSM", + "x.com.samsung.da.diagMnid": "0AJT", + "x.com.samsung.da.diagSetupid": "000", + "x.com.samsung.da.diagMinVersion": "1.0", + "x.com.samsung.da.serialNumOption": "**REDACTED**", + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Software", + "x.com.samsung.da.number": "02337A260424", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "1", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Firmware", + "x.com.samsung.da.number": "2102240021022200", + "x.com.samsung.da.newVersionAvailable": "0" + }, + { + "x.com.samsung.da.id": "2", + "x.com.samsung.da.description": "Version", + "x.com.samsung.da.type": "Outdoor", + "x.com.samsung.da.number": "2103300110000300" + } + ] + } + }, + { + "href": "/keepnormalstate/vs/0", + "rep": { + "x.com.samsung.da.keepnormal": 5 + } + }, + { + "href": "/mode/convenient/vs/0", + "rep": { + "x.com.samsung.da.modes": "Off", + "x.com.samsung.da.supportedModes": [ + "Off", + "Sleep", + "Quiet", + "Speed" + ] + } + }, + { + "href": "/mode/vs/0", + "rep": { + "x.com.samsung.da.supportedModes": [ + "AIComfort", + "Cool", + "Dry", + "Wind" + ], + "x.com.samsung.da.modes": [ + "Wind" + ], + "x.com.samsung.da.options": [ + "Operation_Family", + "Blooming_0", + "OnTimer_0", + "OffTimer_0", + "Sleep_16", + "ArtificialWorking_Off", + "ComfortAICooling_Off", + "AiTempChanged_Off", + "AiTemp_270", + "welcomecare_Off", + "Panel_Close", + "Weather_Off", + "Volume_100", + "StopAutoClean_Idle", + "DiagnosisAI_Off", + "Display_Off", + "ProgressDiagnosisAI_1", + "ResultDiagnosisAI_Normal", + "Service_Off", + "SmartCoolClean_Off", + "ProgressSmartClean_0", + "OutDoorVentil_Off", + "FreezeAlarmSetting_Off", + "DesiredFreezeAlarm_240", + "OptionCode_529", + "ExtendOptionCode_16975", + "RacInfo_First", + "RacInfo_None_Second", + "ModelInfo_16K_BORA_VENT2", + "UpdateAllow_NotAllowed", + "EnergySaveIcon_Off", + "DurationOn_0", + "welcomecareElapsedTime_0", + "welcomecareThresholdTemp_0", + "welcomecareStartDate_0000", + "welcomecareEndDate_0000", + "welcomecareSeason_None" + ] + } + }, + { + "href": "/option/autoclean/vs/0", + "rep": { + "x.com.samsung.da.status": "Stop", + "x.com.samsung.da.settingStatus": "On", + "x.com.samsung.da.progress": "0", + "x.com.samsung.da.supportedStatus": [ + "Start", + "SpeedClean", + "QuietClean", + "Stop" + ], + "x.com.samsung.da.supportedSettingStatus": [ + "On", + "SpeedClean", + "QuietClean", + "Off" + ] + } + }, + { + "href": "/otninformation/vs/0", + "rep": { + "x.com.samsung.da.target": "", + "x.com.samsung.da.newVersionAvailable": "false", + "x.com.samsung.da.newVersionNo": "00000000", + "x.com.samsung.da.currentVersionInfo": "00000000", + "otnStatus": "None", + "flashingProgress": "", + "otnTarget": "main", + "otnCompleteDate": "noHistory", + "otnList": [ + { + "type": "WIFI", + "modelId": "AFA-KR-TP2-21-AF9X00", + "versions": [ + "10260424" + ], + "visVersion": "260424" + }, + { + "type": "Micom", + "modelId": "04511023304110232941", + "versions": [ + "21022400", + "21022200" + ], + "visVersion": "210224" + }, + { + "type": "Micom", + "modelId": "04511022974110229941", + "versions": [ + "21033001", + "10000300" + ], + "visVersion": "210330" + }, + { + "type": "Micom", + "modelId": "045110230741FFFFFFFF", + "versions": [ + "22050300", + "FFFFFFFF" + ], + "visVersion": "220503" + } + ] + } + }, + { + "href": "/personality/presence/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "", + "x.com.samsung.da.deviceId": "**REDACTED**", + "x.com.samsung.da.value": "" + } + ] + } + }, + { + "href": "/power/0", + "rep": { + "value": false + } + }, + { + "href": "/power/vs/0", + "rep": { + "x.com.samsung.da.power": "Off" + } + }, + { + "href": "/realtimenotiforclient/vs/0", + "rep": { + "x.com.samsung.da.timeforshortnoti": "0", + "x.com.samsung.da.longnotisubscription": "true", + "x.com.samsung.da.periodicnotisubscription": "true" + } + }, + { + "href": "/runn/vs/0", + "rep": { + "x.com.samsung.da.runningMode": 0 + } + }, + { + "href": "/subdevices/vs/0", + "rep": { + "x.com.samsung.da.subdeviceIdList": [ + "6c2dff6d-ee5c-dad1-6a5e-000000000001" + ] + } + }, + { + "href": "/temperature/control/vs/0", + "rep": { + "x.com.samsung.da.increment": "1" + } + }, + { + "href": "/temperature/current/0", + "rep": { + "range": [ + 18.0, + 30.0 + ], + "units": "C", + "temperature": 32.0 + } + }, + { + "href": "/temperature/desired/0", + "rep": { + "range": [ + 18.0, + 30.0 + ], + "units": "C", + "temperature": 24.0 + } + }, + { + "href": "/temperatures/vs/0", + "rep": { + "x.com.samsung.da.items": [ + { + "x.com.samsung.da.id": "0", + "x.com.samsung.da.description": "Temperature", + "x.com.samsung.da.desired": "24.0", + "x.com.samsung.da.current": "32.0", + "x.com.samsung.da.maximum": "30", + "x.com.samsung.da.minimum": "18", + "x.com.samsung.da.increment": "1.0", + "x.com.samsung.da.unit": "Celsius" + } + ] + } + }, + { + "href": "/timezone/vs/0", + "rep": { + "timezoneid": "Asia/Seoul", + "offset": "+09:00", + "DST": "OFF" + } + }, + { + "href": "/wind/direction/vs/0", + "rep": { + "x.com.samsung.da.modes": "NotSupported", + "x.com.samsung.da.supportedModes": [ + "NotSupported" + ] + } + }, + { + "href": "/wind/strength/vs/0", + "rep": { + "x.com.samsung.da.modes": "2", + "x.com.samsung.da.supportedModes": [ + "0", + "2", + "3", + "4" + ], + "x.com.samsung.da.modesName": [ + "Auto", + "Mid", + "High", + "Turbo" + ] + } + } + ], + "oic_res": [ + { + "di": "**REDACTED**", + "links": [ + { + "href": "/oic/sec/doxm", + "rt": [ + "oic.r.doxm" + ], + "if": [ + "oic.if.baseline" + ], + "p": { + "bm": 1, + "sec": true, + "port": 49154, + "x.org.iotivity.tls": 0 + } + }, + { + "href": "/oic/sec/pstat", + "rt": [ + "oic.r.pstat" + ], + "if": [ + "oic.if.baseline" + ], + "p": { + "bm": 1, + "sec": true, + "port": 49154, + "x.org.iotivity.tls": 0 + } + }, + { + "href": "/oic/d", + "rt": [ + "oic.wk.d", + "oic.d.airconditioner" + ], + "if": [ + "oic.if.baseline", + "oic.if.r" + ], + "p": { + "bm": 1, + "sec": false, + "x.org.iotivity.tcp": 0 + } + }, + { + "href": "/oic/p", + "rt": [ + "oic.wk.p" + ], + "if": [ + "oic.if.baseline", + "oic.if.r" + ], + "p": { + "bm": 1, + "sec": false, + "x.org.iotivity.tcp": 0 + } + }, + { + "href": "/hass/state/vs/0", + "rt": [ + "x.com.samsung.da.hass.state" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": false, + "x.org.iotivity.tcp": 0 + } + }, + { + "href": "/hass/command/vs/0", + "rt": [ + "x.com.samsung.da.hass.command" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": false, + "x.org.iotivity.tcp": 0 + } + }, + { + "href": "/file/transfer/chunk/vs/0", + "rt": [ + "x.com.samsung.file.chunk" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 1, + "sec": false, + "x.org.iotivity.tcp": 0 + } + }, + { + "href": "/file/list/vs/0", + "rt": [ + "x.com.samsung.file.list" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ], + "p": { + "bm": 1, + "sec": false, + "x.org.iotivity.tcp": 0 + } + }, + { + "href": "/file/transfer/vs/0", + "rt": [ + "x.com.samsung.file.transfer" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": false, + "x.org.iotivity.tcp": 0 + } + }, + { + "href": "/6c2dff6d-ee5c-dad1-6a5e-000000000001/file/list/vs/0", + "rt": [ + "x.com.samsung.file.list" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ], + "p": { + "bm": 1, + "sec": false, + "x.org.iotivity.tcp": 0 + } + }, + { + "href": "/6c2dff6d-ee5c-dad1-6a5e-000000000001/file/transfer/vs/0", + "rt": [ + "x.com.samsung.file.transfer" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 3, + "sec": false, + "x.org.iotivity.tcp": 0 + } + }, + { + "href": "/EasySetupResURI", + "rt": [ + "oic.r.easysetup" + ], + "if": [ + "oic.if.baseline", + "oic.if.ll", + "oic.if.b" + ], + "p": { + "bm": 1, + "sec": true, + "port": 49154, + "x.org.iotivity.tls": 0 + } + }, + { + "href": "/WiFiConfResURI", + "rt": [ + "oic.wk.wifi" + ], + "if": [ + "oic.if.baseline" + ], + "p": { + "bm": 1, + "sec": true, + "port": 49154, + "x.org.iotivity.tls": 0 + } + }, + { + "href": "/CoapCloudConfResURI", + "rt": [ + "oic.wk.cloudserver" + ], + "if": [ + "oic.if.baseline" + ], + "p": { + "bm": 1, + "sec": true, + "port": 49154, + "x.org.iotivity.tls": 0 + } + }, + { + "href": "/DevConfResURI", + "rt": [ + "oic.wk.devconf" + ], + "if": [ + "oic.if.baseline" + ], + "p": { + "bm": 1, + "sec": true, + "port": 49154, + "x.org.iotivity.tls": 0 + } + }, + { + "href": "/sec/provisioninginfo", + "rt": [ + "x.com.samsung.provisioninginfo" + ], + "if": [ + "oic.if.baseline", + "oic.if.a" + ], + "p": { + "bm": 1, + "sec": false, + "x.org.iotivity.tcp": 0 + } + }, + { + "href": "/sec/accesspointlist", + "rt": [ + "x.com.samsung.accesspointlist" + ], + "if": [ + "oic.if.baseline", + "oic.if.s" + ], + "p": { + "bm": 1, + "sec": false, + "x.org.iotivity.tcp": 0 + } + } + ] + } + ], + "probes": { + "/6c2dff6d-ee5c-dad1-6a5e-000000000001/information/vs/0": { + "x.com.samsung.da.modelNum": "TP2X_FAC_BORA_RAC_21K|10233041|60010610001500014600081200810000", + "x.com.samsung.da.description": "TP2X_FAC_BORA_RAC_21K", + "x.com.samsung.da.serialNum": "TEST-SUBDEVICE-SERIAL-0000" + } + }, + "seeds_note": "device0 and oic_res are real, captured from jhkwon19's issue #205 report. subdeviceIdList in device0's /subdevices/vs/0 is restored to the real ['6c2dff6d-ee5c-dad1-6a5e-000000000001'] (HA's diagnostics download redacts it, matching redact.py's 'deviceid' substring rule, not because it's account data -- same restoration airconditioner_fac_bora_2in1_device.json documents for the same field), and every other value is otherwise exactly what HA's download redacts (serials/otnDUID etc.) against the same physical TP2X_FAC_BORA_21K unit as airconditioner_fac_bora_2in1_device.json -- same subdeviceIdList UUID, same wall subdevice. Filed specifically because, contrary to the assumption that fixture's seed batch was built on, this unit's /6c2dff6d-ee5c-dad1-6a5e-000000000001/device/0 does NOT answer (subdevice_probes in the report shows it False), so the Collection-batch fallback this fixture exercises (registry.subdevices.enumerate_subdevices' per-href probe, issue #205) is what has to find the subdevice instead. The one probes entry, /6c2dff6d-ee5c-dad1-6a5e-000000000001/information/vs/0, is the same real capture already used in airconditioner_fac_bora_2in1_device.json's seed batch (issue #177 comment 5113518087) -- the only href ever actually confirmed to answer under this UUID prefix. No other /6c2dff6d-ee5c-dad1-6a5e-000000000001/* href has been confirmed live yet, so none are seeded here; this fixture's expected outcome is the candidate being found by the flat-href probe but then correctly held back by discover_partitioned's liveness gate (information alone binds no entity), matching where the real issue stands -- not a materialized climate entity, which would require guessing at unconfirmed hrefs." +} diff --git a/tests/test_diagnostics_subdevices.py b/tests/test_diagnostics_subdevices.py index 2cba348..1406272 100644 --- a/tests/test_diagnostics_subdevices.py +++ b/tests/test_diagnostics_subdevices.py @@ -105,3 +105,25 @@ async def test_diagnostics_reports_prefixed_subdevice( # not the master's TP2X_FAC_BORA_21K. assert diag['subdevices'][0]['model'].startswith('TP2X_FAC_BORA_RAC_21K') assert diag['subdevices_skipped'] == [] + + +async def test_diagnostics_reports_flat_hrefs_for_skipped_prefixed_candidate( + hass: HomeAssistant, enable_custom_integrations, +) -> None: + """issue #205: a prefixed candidate found through the per-href flat + fallback (no working //device/0 Collection) has no meaningful + seed_path -- diagnostics reports None there instead of the misleading + bare "/" an empty tuple would otherwise join to, and lists the actual + hrefs the fallback confirmed instead.""" + coordinator = _coordinator(hass) + await _discover(coordinator, 'airconditioner_fac_bora_205_flat') + hass.data.setdefault(DOMAIN, {})[coordinator._entry.entry_id] = coordinator + + diag = await async_get_config_entry_diagnostics(hass, coordinator._entry) + + assert diag['subdevices'] == [] + assert len(diag['subdevices_skipped']) == 1 + skipped = diag['subdevices_skipped'][0] + assert skipped['kind'] == 'prefixed' + assert skipped['seed_path'] is None + assert skipped['flat_hrefs'] == ['/information/vs/0'] diff --git a/tests/test_subdevice_discovery.py b/tests/test_subdevice_discovery.py index 5e96ca4..02498ae 100644 --- a/tests/test_subdevice_discovery.py +++ b/tests/test_subdevice_discovery.py @@ -182,6 +182,119 @@ async def test_fac_bora_2in1_unique_ids_include_subdevice_prefix(hass: HomeAssis ) +# --------------------------------------------------------------------------- +# jhkwon19 again -- issue #205, same physical unit/UUID as above, but this +# time //device/0 doesn't answer. Exercises enumerate_subdevices' +# per-href flat-probe fallback (registry/subdevices.py) against a real +# capture instead of the synthetic sessions test_subdevices.py uses. +# --------------------------------------------------------------------------- + +async def test_fac_bora_205_flat_fallback_finds_candidate_but_gate_holds_it_back( + hass: HomeAssistant, +): + """The fixture's only seeded UUID-prefixed href is /information/vs/0 -- + the one href ever actually confirmed live under this prefix (issue #177 + comment 5113518087) -- since nothing else has been confirmed yet for + this unit. That's enough for the flat-probe fallback to find a + candidate, but /information/vs/0 binds no entity on its own (it's only + ever read for device-type resolution, never bound as a capability), so + discover_partitioned's liveness gate correctly holds it back rather than + materializing a phantom climate card from unconfirmed hrefs. This is the + honest current state of issue #205, not a guess at its resolution.""" + coordinator = _coordinator(hass) + await _discover(coordinator, 'airconditioner_fac_bora_205_flat') + + assert coordinator.subdevices == [] + assert [s.subdevice.key for s in coordinator._skipped_subdevices] == [_SUB_UUID] + skipped = coordinator._skipped_subdevices[0].subdevice + assert skipped.kind == 'prefixed' + assert skipped.seed_path == () + assert skipped.flat_hrefs == ('/information/vs/0',) + + assert coordinator._subdevice_probes[f'/{_SUB_UUID}/device/0'] is False + assert coordinator._subdevice_probes[f'/{_SUB_UUID}/information/vs/0'] is True + + # Confirms the master itself is completely unaffected by its sibling's + # Collection endpoint not answering -- same guarantee every other + # subdevice test in this file relies on. + assert _climate_bound(coordinator, None) is not None + + +class _FakeCollectionSession: + """Minimal session that only ever answers a Collection GET -- used to + prove the flat-mode re-poll path (issue #205) is only taken when + flat_hrefs is actually set, not whenever seed_path happens to be + unusual.""" + + def __init__(self, table): + self.table = table + self.calls: list[tuple[str, ...]] = [] + + def get(self, path, timeout=10.0): + self.calls.append(tuple(path)) + body = self.table.get(tuple(path)) + if body is None: + return 0x84, b'' + import cbor2 + return 0x45, cbor2.dumps(body) + + def pace(self): + pass + + +def test_poll_subdevice_seed_collection_mode_unaffected_by_flat_fallback( + hass: HomeAssistant, +): + """A subdevice with a working Collection endpoint (flat_hrefs empty) + keeps re-polling it with a single Collection GET, unchanged by issue + #205's fallback.""" + from custom_components.localthings.registry.subdevices import Subdevice + + coordinator = _coordinator(hass) + devcol_rep = {'rt': ['x.com.samsung.devcol', 'oic.wk.col']} + sess = _FakeCollectionSession({ + (_SUB_UUID, 'device', '0'): [ + devcol_rep, {'href': '/mode/vs/0', 'rep': {'mode': 'cool'}}, + ], + }) + coordinator._session = sess + subdevice = Subdevice(kind='prefixed', key=_SUB_UUID, seed_path=(_SUB_UUID, 'device', '0')) + + result = coordinator._poll_subdevice_seed(subdevice) + + assert result == {f'/{_SUB_UUID}/mode/vs/0': {'mode': 'cool'}} + assert sess.calls == [(_SUB_UUID, 'device', '0')] + + +def test_poll_subdevice_seed_flat_mode_polls_each_href_individually( + hass: HomeAssistant, +): + """A flat-mode subdevice (issue #205) has no Collection to batch-refresh + through, so each confirmed href is GET individually under the prefix on + every re-poll -- a href that stops answering just drops out, same + "never fail the master's poll over a sibling" posture as the Collection + path.""" + from custom_components.localthings.registry.subdevices import Subdevice + + coordinator = _coordinator(hass) + sess = _FakeCollectionSession({ + (_SUB_UUID, 'mode', 'vs', '0'): {'mode': 'cool'}, + # (_SUB_UUID, 'power', 'vs', '0') deliberately absent -> drops out. + }) + coordinator._session = sess + subdevice = Subdevice( + kind='prefixed', key=_SUB_UUID, seed_path=(), + flat_hrefs=('/mode/vs/0', '/power/vs/0'), + ) + + result = coordinator._poll_subdevice_seed(subdevice) + + assert result == {f'/{_SUB_UUID}/mode/vs/0': {'mode': 'cool'}} + assert sess.calls == [ + (_SUB_UUID, 'mode', 'vs', '0'), (_SUB_UUID, 'power', 'vs', '0'), + ] + + async def test_multidevice_probe_never_reaches_discovery_or_the_cache( hass: HomeAssistant, ): diff --git a/tests/test_subdevices.py b/tests/test_subdevices.py index ea698c5..00abf40 100644 --- a/tests/test_subdevices.py +++ b/tests/test_subdevices.py @@ -206,6 +206,9 @@ class _FakeSession: return 0x84, b'' return 0x45, cbor2.dumps(body) + def pace(self): + pass + _DEVCOL_REP = {'rt': ['x.com.samsung.devcol', 'oic.wk.col']} @@ -264,6 +267,57 @@ def test_enumerate_prefixed_from_subdevice_id_list(): assert extra == {f'/{_UUID}/mode/vs/0': {'m': 'cool'}} +def test_enumerate_prefixed_falls_back_to_flat_hrefs_when_device0_collection_is_empty(): + """issue #205: not every prefixed subdevice exposes its own + //device/0 Collection -- not even TP2X_FAC_BORA_21K, the board + this pattern was built against, always does. When it doesn't, + enumeration probes every href the master itself answered this cycle, + individually, under the UUID prefix, and keeps whichever ones answer.""" + resources = { + '/subdevices/vs/0': {'x.com.samsung.da.subdeviceIdList': [_UUID]}, + '/mode/vs/0': {'m': 'cool'}, + '/power/vs/0': {'p': 'On'}, + } + sess = _FakeSession({ + # (_UUID, 'device', '0') deliberately absent -> Collection probe fails. + (_UUID, 'mode', 'vs', '0'): {'mode': 'cool'}, + # (_UUID, 'power', 'vs', '0') deliberately absent -> drops out. + }) + subdevices, extra = enumerate_subdevices(sess, resources, oic_res_links=[]) + assert len(subdevices) == 1 + subdevice = subdevices[0] + assert (subdevice.kind, subdevice.key) == ('prefixed', _UUID) + assert subdevice.seed_path == () + assert subdevice.flat_hrefs == ('/mode/vs/0',) + assert extra == {f'/{_UUID}/mode/vs/0': {'mode': 'cool'}} + + +def test_enumerate_prefixed_flat_fallback_materializes_nothing_when_no_href_answers(): + """Same posture as every other candidate check in this module: nothing + answering means no candidate, not a crash.""" + resources = { + '/subdevices/vs/0': {'x.com.samsung.da.subdeviceIdList': [_UUID]}, + '/mode/vs/0': {'m': 'cool'}, + } + subdevices, extra = enumerate_subdevices(_FakeSession({}), resources, oic_res_links=[]) + assert subdevices == [] + assert extra == {} + + +def test_enumerate_prefixed_flat_fallback_probe_log_reports_every_href_tried(): + resources = { + '/subdevices/vs/0': {'x.com.samsung.da.subdeviceIdList': [_UUID]}, + '/mode/vs/0': {'m': 'cool'}, + } + sess = _FakeSession({ + (_UUID, 'mode', 'vs', '0'): {'mode': 'cool'}, + }) + probes: dict[str, bool] = {} + enumerate_subdevices(sess, resources, oic_res_links=[], probe_log=probes.__setitem__) + assert probes[f'/{_UUID}/device/0'] is False + assert probes[f'/{_UUID}/mode/vs/0'] is True + + def test_enumerate_prefixed_tolerates_redacted_string_id_list(): """subdeviceIdList matches redact.py's 'deviceid' substring rule, and the real airconditioner_fac_bora_device.json fixture carries the literal