fix(subdevices): fall back to per-href probing when a prefixed subdevice has no /device/0 Collection

Issue #205 shows the UUID-prefixed pattern's own reference device
(TP2X_FAC_BORA_21K) doesn't always answer /<uuid>/device/0, contrary to
what the pattern was built against. When that Collection GET comes back
empty, enumerate_subdevices now probes every href the master itself
answered this cycle individually under the UUID prefix, keeping whichever
ones respond. Subdevice gains a flat_hrefs field for this, and the
coordinator re-polls those hrefs individually each cycle instead of
re-fetching a Collection batch that doesn't exist.

Built a fixture from the reporter's real #205 diagnostics dump: the
fallback finds a candidate through the one href already confirmed live
under this UUID (/information/vs/0, from the #177 thread), and
discover_partitioned's liveness gate correctly holds it back since that
href alone binds no entity -- honest current state, not a guessed
resolution.
This commit is contained in:
Marc Billow
2026-07-30 02:25:26 +00:00
parent 7c31bb1682
commit e78d941af3
8 changed files with 1046 additions and 29 deletions
+38 -18
View File
@@ -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/<n>`, `/<uuid>/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
`/<uuid>/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 `/<uuid>/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 `/<uuid>/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.
@@ -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:
+13 -2
View File
@@ -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 /<uuid>/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
@@ -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`
(`/<uuid>/file/list/vs/0`, ...). `GET /<uuid>/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).
(`/<uuid>/file/list/vs/0`, ...). On jhkwon19's own first unit, `GET
/<uuid>/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 `/<uuid>/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:
continue
if batch:
subdevice = Subdevice(kind='prefixed', key=sub_id, seed_path=seed)
fetched.update(normalize_seed_batch(subdevice, batch))
subdevices.append(subdevice)
continue
# Fallback (issue #205): TP2X_FAC_BORA_21K itself -- the board this
# pattern was built against -- turns out not to always expose its own
# `/<uuid>/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({
@@ -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."
}
+22
View File
@@ -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 /<uuid>/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']
+113
View File
@@ -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 /<uuid>/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,
):
+54
View File
@@ -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
/<uuid>/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