Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
377 changes: 377 additions & 0 deletions custom_components/localthings/cloudcourse.py

Large diffs are not rendered by default.

411 changes: 407 additions & 4 deletions custom_components/localthings/config_flow.py

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions custom_components/localthings/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,15 @@
CONF_LEARN_MODES = "learn_device_modes"
DEFAULT_LEARN_MODES = True

# entry.data key: cloud "Download" programs discovered on a laundry device
# (issue #342). Same rationale as CONF_LEARNED_MODES -- a program's full
# replay payload is only ever visible while the device happens to be sitting
# on it, so it has to survive a restart -- but a richer shape, because a
# cloud program also needs a user-supplied name and the device's own
# Download course code. See cloudcourse.py, which owns the shape. Shape:
# {"download_course": "87"|null, "slots": {slot: {"blob": ..., "name": ...}}}
CONF_CLOUD_COURSES = "cloud_courses"

# Options-flow key (entry.options, not entry.data): lets a user override
# the device-wide remote-control-off write block for a specific device
# (issue #54). Some devices accept certain writes even while reporting
Expand Down
229 changes: 221 additions & 8 deletions custom_components/localthings/coordinator.py

Large diffs are not rendered by default.

26 changes: 24 additions & 2 deletions custom_components/localthings/diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@
from homeassistant.core import HomeAssistant
from homeassistant.loader import async_get_integration

from . import cloudcourse
from .const import DOMAIN
from .coordinator import LocalThingsCoordinator
from .registry.capabilities.laundry import cycle_options
from .registry.redact import redact_resources
from .registry.subdevices import MAIN

Expand All @@ -32,6 +34,7 @@ async def async_get_config_entry_diagnostics(
# disk (listdir + open + read_text), which trips HA's event-loop blocking
# detector when called inline here. Offload it to the executor.
stl_version = await hass.async_add_executor_job(pkg_version, "smartthings-local")
cloud_courses = coordinator.cloud_courses.snapshot()

# /oic/p, /oic/d, and /oic/res sit outside the /device/0 batch captured
# below, so they'd otherwise never reach an issue report. /oic/d's `rt`
Expand All @@ -54,7 +57,7 @@ def _subdevice_diag(su) -> dict:
# than redacting /information/vs/0 again -- modelNum never matches
# redact.py's substring rules, so the value is the same either way.
matching = [b for b in coordinator.bound if b.subdevice == su]
res = redact_resources(coordinator.canonical_resources(su))
res = redact_resources(coordinator.device_resources(su))
return {
"kind": su.kind,
"key": su.key,
Expand Down Expand Up @@ -88,7 +91,7 @@ def _subdevice_diag(su) -> dict:
# /mode/vs/0 under no attribution. Each sibling reports its own
# resources in `subdevices` below instead. For a device with no
# subdevices, this is byte-identical to `last_resources`.
"resources": redact_resources(coordinator.canonical_resources(MAIN)),
"resources": redact_resources(coordinator.device_resources(MAIN)),
# Sibling indoor subdevices discovered on this connection (issue
# #177). subdeviceIdList (the UUID a prefixed subdevice's key comes
# from) is deliberately NOT redacted here, unlike elsewhere in
Expand Down Expand Up @@ -137,6 +140,25 @@ def _subdevice_diag(su) -> dict:
"enabled": coordinator.learning_enabled,
"codes": coordinator.learned_snapshot(),
},
# Cloud "Download" programs discovered on this device (issue #342),
# reported separately from `resources` for the same reason as
# learned_modes above -- the dump there stays exactly what the device
# said, and this is what the integration made of it.
#
# Reported in full, names included. Half of what can go wrong with
# this feature is a configuration question -- which programs got
# named, which Download course was confirmed, whether a payload was
# ever captured for a slot the device advertises -- and none of that
# is answerable from the payloads alone. The names are the user's own
# words, so this is the one place they appear; they reach a dump only
# because its owner chose to download and share it.
"cloud_courses": {
"advertised_slots": cloudcourse.advertised_slots(coordinator.cloud_course_rep()),
"cloud_slots": cloudcourse.cloud_slots(
coordinator.cloud_course_rep(), cycle_options(coordinator.device_resources(MAIN))
),
**cloud_courses,
},
"integration_version": integration.version,
"smartthings_local_version": stl_version,
"observe_mode": coordinator.observe_mode,
Expand Down
22 changes: 22 additions & 0 deletions custom_components/localthings/registry/capabilities/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,28 @@ def _active_alarm_codes(items):
return ", ".join(codes) if codes else "none"


def hex_pairs(codes):
"""'1C1D21...' -> ['1C', '1D', '21', ...]."""
return [codes[i : i + 2] for i in range(0, len(codes) - 1, 2)]


def option_value(options, prefix):
"""Find `<prefix>_<value>` in an options[] array and return <value>.

Lives here rather than in laundry.py, which is where it grew, because
cloudcourse.py needs it too and laundry.py imports *that* -- so the
reverse import would be a module cycle. The coordinator already imports
from this module, so nothing about the dependency direction is unusual;
it is specifically the laundry/cloudcourse pair that can't reach each
other. Anchored at position 0 so 'Course_' never matches
'CloudCourse_'/'OneTimeCloudCourse_'.
"""
for o in options or []:
if isinstance(o, str) and o.startswith(prefix + "_"):
return o.split("_", 1)[1]
return None


def merge_options_field(cached, new_tokens):
"""Merge freshly-written `<Prefix>_<Value>` tokens into a cached
x.com.samsung.da.options[]-style array the same way the device itself
Expand Down
151 changes: 133 additions & 18 deletions custom_components/localthings/registry/capabilities/laundry.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,11 @@
from datetime import UTC, datetime
from datetime import time as dt_time

from ... import cloudcourse
from ...catalog import has_entity_translation
from ..capability import Capability
from ..entities import NumberDesc, SelectDesc, SensorDesc, SwitchDesc, TimeDesc
from .common import hex_pairs, option_value

_LED_LEVELS = ("Low", "High")
_SOUND_MODES = ("voice", "tone", "mute")
Expand Down Expand Up @@ -198,11 +200,6 @@ def _sound_mode_write(p, rep, href=None):
# boards expose the same /course/vs/0 options contract.


def hex_pairs(codes):
"""'1C1D21...' -> ['1C', '1D', '21', ...]."""
return [codes[i : i + 2] for i in range(0, len(codes) - 1, 2)]


def parse_edit_course_list(raw):
"""'EditCourseList_1C1D21...' -> ['1C', '1D', '21', ...]."""
if not isinstance(raw, str) or "_" not in raw:
Expand All @@ -218,14 +215,6 @@ def cycle_options(resources):
return _course_codes_from_supported_options(resources.get("/course/vs/0") or {})


def option_value(options, prefix):
"""Find `<prefix>_<value>` in the options array and return <value>."""
for o in options or []:
if isinstance(o, str) and o.startswith(prefix + "_"):
return o.split("_", 1)[1]
return None


# Drum Clean+ maintenance tracking, from the same options[] array as the
# selected course -- shared by washer.py (issue #9) and dryer.py (issue
# #258), identical DrumCleanProposal_/WashingTimes_/DrumCleanLog_ tokens.
Expand Down Expand Up @@ -309,20 +298,124 @@ def _course_codes_from_supported_options(course_rep):
return []


def option_tokens(*pairs):
"""[(prefix, value), ...] -> ['<prefix>_<value>', ...] -- the general
form of option_write, for the one write that needs two tokens to land in
the same options[] array together (see cycle_write's cloud branch)."""
return [f"{prefix}_{value}" for prefix, value in pairs]


def option_write(prefix, new_value):
"""A one-token x.com.samsung.da.options write -- see the module comment
above for why this doesn't read/rewrite the whole array."""
return [f"{prefix}_{new_value}"]
return option_tokens((prefix, new_value))


# ---------------------------------------------------------------------------
# Cloud "Download" programs, folded into this same cycle select (issue #342).
#
# A device that has downloaded programs advertises them on the same
# /course/vs/0 options array; cloudcourse.py owns the token shapes, the
# learned store, and the reasoning for all of it. Everything below is just
# how that store reaches the select: the coordinator merges it onto this
# href's rep under cloudcourse.FIELD, so the option list, current value,
# label, and write path each read it from the rep or snapshot they already
# receive.
#
# They ride in the cycle select rather than a select of their own because
# that is what they are to a user -- on the appliance's own controls,
# "Download" occupies one position among the ordinary courses, and picking a
# downloaded program is picking a cycle. Their raw values are namespaced
# ('cloud:<slot>') so they can never be confused with, or collide with, a
# two-hex-char local course code.
#
# Bound by whichever families declare it. Washers are where this was worked
# out, but a DW5000C dishwasher advertises the same token (see
# cloudcourse.py), so nothing below is washer-specific.
#
# Confirmed on hardware before any of this was written (issue #342): writing
# the program token alone, while some other course is selected, is silently
# ignored -- the course token has to switch to Download in the *same* write.
# Hence the two-token write, the only one in this module.


def _cloud_state(rep):
return rep.get(cloudcourse.FIELD) or {}


def cloud_options(rep):
"""Namespaced raw values for every named, learned cloud program."""
return [
f"{cloudcourse.RAW_PREFIX}{slot}" for slot in sorted(_cloud_state(rep).get("programs", {}))
]


def cloud_label(value, resources):
"""The user's own name for a 'cloud:<slot>' value.

Cloud program names are user-supplied, never translated: the appliance
reports only an opaque slot id, and inventing an English label for one
is exactly what this module refuses to do for unrecognized local course
codes (see washer_cycle_fallback).
"""
if not isinstance(value, str) or not value.startswith(cloudcourse.RAW_PREFIX):
return None
slot = value[len(cloudcourse.RAW_PREFIX) :]
rep = resources.get(cloudcourse.COURSE_HREF) or {}
program = _cloud_state(rep).get("programs", {}).get(slot)
return program["name"] if program else None


def cloud_current(rep):
"""'cloud:<slot>' when a named cloud program is the live selection.

Gated on the course actually being this device's confirmed Download
course: tokens in this array are replaced by prefix and never evicted, so
a one-time program token outlives the run it belonged to and would
otherwise report "Jeans" while an ordinary cotton cycle runs.
"""
state = _cloud_state(rep)
download = state.get("download_course")
options = rep.get("x.com.samsung.da.options")
if not download or option_value(options, "Course") != download:
return None
blob = option_value(options, cloudcourse.ONESHOT_PREFIX)
slot = cloudcourse.slot_of(blob)
if slot is None:
# No one-time override loaded: the appliance falls back to whatever
# the persisted default holds (confirmed with the issue #342
# reporter -- leaving Download and returning to it re-selects the
# saved program, not the last one-time one).
slot = cloudcourse.slot_of(option_value(options, cloudcourse.DEFAULT_PREFIX))
if slot is None or slot not in state.get("programs", {}):
return None
return f"{cloudcourse.RAW_PREFIX}{slot}"


def cycle_write(p, rep, href=None):
if not rep.get("x.com.samsung.da.options"):
return None
if isinstance(p, str) and p.startswith(cloudcourse.RAW_PREFIX):
return _cloud_cycle_write(p, rep)
return ["course", "vs", "0"], {
"x.com.samsung.da.options": option_write("Course", p),
}


def _cloud_cycle_write(p, rep):
state = _cloud_state(rep)
download = state.get("download_course")
program = state.get("programs", {}).get(p[len(cloudcourse.RAW_PREFIX) :])
if not download or program is None:
return None
# Order matches what the appliance was confirmed to accept.
return ["course", "vs", "0"], {
"x.com.samsung.da.options": option_tokens(
("Course", download), (cloudcourse.ONESHOT_PREFIX, program["blob"])
),
}


def personal_course_labels(resources, href="/wm/personalcourse/vs/0"):
"""Return device-provided personal course names keyed by course code.

Expand Down Expand Up @@ -399,6 +492,11 @@ def cycle_select(*, translation_key, icon, table_href=None, display_fn=None):
Left at its default for dishwasher, which has no equivalent table-id
resource and no evidence its codes vary by table the way washer/
dryer's do.

Any cloud "Download" programs the user has discovered and named join the
same option list, after the local courses -- see the cloud section above.
A device with none (or one whose owner hasn't named any yet) gets exactly
the list it got before they existed.
"""
key = translation_key
if table_href is not None:
Expand All @@ -410,14 +508,31 @@ def key(resources):
candidate = f"{translation_key}_{table.lower()}"
return candidate if has_entity_translation("select", candidate) else "cycle"

def options(resources):
rep = resources.get(cloudcourse.COURSE_HREF) or {}
# Local courses first: a user-supplied cloud name that happens to
# match a translated course name resolves back to the real local
# course on write, which is the safer of the two. The options flow
# rejects such a name outright, so this is a backstop, not the fix.
return [*cycle_options(resources), *cloud_options(rep)]

def current(rep):
return cloud_current(rep) or option_value(rep.get("x.com.samsung.da.options"), "Course")

def label(value, resources):
cloud = cloud_label(value, resources)
if cloud is not None:
return cloud
return display_fn(value, resources) if display_fn is not None else None

return SelectDesc(
key="cycle",
icon=icon,
translation_key=key,
options=cycle_options,
exists_fn=lambda rep, resources: bool(cycle_options(resources)),
rep_fn=lambda rep: option_value(rep.get("x.com.samsung.da.options"), "Course"),
display_fn=display_fn,
options=options,
exists_fn=lambda rep, resources: bool(options(resources)),
rep_fn=current,
display_fn=label,
write_fn=cycle_write,
)

Expand Down
31 changes: 15 additions & 16 deletions custom_components/localthings/select.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,22 +67,21 @@ def _display(value, translation_key: str | None, fallback_fn=None):
"""
if not isinstance(value, str):
return value
if translation_key:
known = translated_states("select", translation_key)
if not known:
# No state table for this key: either the entity isn't translated
# at all, or its name is translated but its options deliberately
# aren't (an unrecognized course table, say). Give an explicit
# device-specific fallback the opportunity to make an opaque value
# readable; otherwise the raw device value remains the best choice.
if fallback_fn is None:
return value
elif translated := _translation_state(value, known):
return translated
if fallback_fn is not None:
fallback = fallback_fn(value)
if fallback is not None:
return fallback
known = translated_states("select", translation_key) if translation_key else frozenset()
if translated := _translation_state(value, known):
return translated
if fallback_fn is not None and (fallback := fallback_fn(value)) is not None:
return fallback
if translation_key and not known:
# No state table for this key: either the entity isn't translated at
# all, or its name is translated but its options deliberately aren't
# (an unrecognized course table, say). Nothing named this value, so
# the raw device value is the best choice -- the cosmetic reshaping
# below would only mangle an opaque code, turning a course '0E' into
# '0 E'. Reached only when the fallback *declined* the value, not
# merely when none was supplied: cycle_select always supplies one now
# (it labels cloud programs) and returns None for everything else.
return value
if value.islower():
return value.replace("_", " ").title()
return _CAMEL_BOUNDARY_RE.sub(" ", value)
Expand Down
5 changes: 4 additions & 1 deletion custom_components/localthings/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,10 @@ async def _async_read_resource(hass: HomeAssistant, call: ServiceCall) -> Servic
# href: lets a user enumerate what exists without hammering the
# device (see this module's docstring and the coordinator's
# canonical_resources).
snapshot: dict[str, Any] = {"resources": coordinator.canonical_resources(subdevice)}
# device_resources, not canonical_resources: this response is what
# the appliance reported, without the fields this integration merges
# on for its own use (see coordinator.entity_resources).
snapshot: dict[str, Any] = {"resources": coordinator.device_resources(subdevice)}
return cast(ServiceResponse, snapshot)

# Same normalize-before-translate order as the write path above.
Expand Down
Loading
Loading