Files
Jack Nagy 3fdc735141 feat(packaging): nest protocol/ + ocf/ under smartthings_local, add PyPI packaging
Nest the two library packages under a single import namespace so they
can ship as one distribution:

  protocol/ -> smartthings_local/protocol/
  ocf/      -> smartthings_local/ocf/   (git mv, history preserved)

- Rewrite all imports protocol.* -> smartthings_local.protocol.*,
  ocf.* -> smartthings_local.ocf.* across the ocf modules, mqtt_demo/
  (bridge, descriptor, samples), and tests.
- Add pyproject.toml: dist name `smartthings-local`, hatch-vcs versioning
  from v* tags, wheel ships only smartthings_local/.
- Add .github/workflows/publish.yml: build + PyPI Trusted Publishing on
  v* tags (OIDC, no stored token).
- Force-include protocol/ocf_root_ca.pem via [tool.hatch.build] artifacts:
  it is tracked but matches .gitignore's *.pem, so hatchling's VCS file
  selection would drop it — and dtls_session.py loads it at runtime.
- Update mqtt_demo Dockerfile COPY and deploy.sh tar allowlist to the
  single smartthings_local/ package.
- .gitignore: build artifacts (_version.py, dist/, *.egg-info/).

Validated: pytest tests/ (11 passed), python -m build produces sdist +
wheel with the pem bundled, fresh pip install resolves all nested imports
with the pem readable from site-packages.
2026-07-06 20:29:19 +01:00

504 lines
18 KiB
Python

"""Dryer descriptor.
Lifts the dryer-specific OBSERVE paths, sensor flattening, HA discovery
inventory, and MQTT command handlers out of the original
samsung_dryer/{bridge,sensors,discovery}.py modules into one place.
"""
import time
from ..descriptor import (
ApplianceDescriptor,
avail_base,
avail_with_remote,
device_block,
encode,
)
from smartthings_local.ocf.poll_scheduler import PollTier
# --- OBSERVE paths -----------------------------------------------------
# Only Samsung's `/<x>/vs/0` siblings actually push notifications; the
# OCF-standard `/<x>/0` paths accept registration silently but never
# fire. flatten() derives the OCF-shaped values from the live /vs/0
# strings.
OBSERVE_PATHS = [
['operational', 'state', 'vs', '0'], # state, remainingTime, progress
['power', 'vs', '0'], # power on/off
['kidslock', 'vs', '0'], # child lock
['remotectrl', 'vs', '0'], # remote control enabled
['energy', 'consumption', 'vs', '0'],
['course', 'vs', '0'],
['washer', 'vs', '0'], # dryLevel, dryTime, type
['diagnosis', 'vs', '0'],
['alarms', 'vs', '0'],
['st', 'dryercourse', 'vs', '0'],
['wm', 'jobbeginingstatus', 'vs', '0'],
]
# --- Course table ------------------------------------------------------
# Captured 2026-05-29 by dialing every course on a
# DA_WM_TP2_20_COMMON_DV5000T dryer. Other Samsung dryers may report a
# different Table_NN; capture a fresh table for them with
# local-tools/course_mapper.py.
COURSE_NAMES = {
'Table_03': {
0x16: 'Cotton',
0x18: 'Synthetics',
0x19: 'Delicates',
0x1A: 'Wool',
0x1B: 'Bedding',
0x1C: 'Shirts',
0x1D: 'Towels',
0x1E: 'Outdoor',
0x1F: 'Mixed Load',
0x20: 'Iron Dry',
0x23: 'Quick Dry 35',
0x24: 'Cool Air',
0x25: 'Warm Air',
0x27: 'Time Dry',
},
}
_COURSE_CODE_BY_NAME = {
name: code
for table_codes in COURSE_NAMES.values()
for code, name in table_codes.items()
}
def _decode_course(s):
"""`Table_03_Course_16` → `Cotton`. Pass through verbatim if the
table or code isn't in our lookup."""
if not isinstance(s, str) or '_Course_' not in s:
return s
table_part, _, code_str = s.partition('_Course_')
table = COURSE_NAMES.get(table_part)
if not table:
return s
try:
code = int(code_str, 16)
except ValueError:
return s
return table.get(code, s)
def _encode_course(name):
"""`Cotton` → `Course_16`. Returns None for unknown names so the
caller refuses rather than POST garbage."""
code = _COURSE_CODE_BY_NAME.get(name)
if code is None:
return None
return f"Course_{code:02X}"
def _course_options():
"""Stable-sorted human course names for the HA select dropdown."""
return sorted(_COURSE_CODE_BY_NAME.keys())
# --- Samsung-state → OCF currentMachineState ---------------------------
_SAMSUNG_STATE_TO_OCF = {
'Ready': 'idle',
'Run': 'active',
'Running': 'active',
'Pause': 'pause',
'Paused': 'pause',
'End': 'idle',
}
def _num(v):
try:
return float(v)
except (TypeError, ValueError):
return None
def _int(v):
try:
return int(v)
except (TypeError, ValueError):
return None
# --- flatten -----------------------------------------------------------
def flatten(links):
"""Map a /device/0 link dict to the flat sensor dict that's
published to MQTT. Every field reads from `/<x>/vs/0` paths so push
updates immediately drive every entity."""
g = lambda href, k, default=None: (links.get(href) or {}).get(k, default)
inst_w = _num(g('/energy/consumption/vs/0',
'x.com.samsung.da.instantaneousPower'))
cum_wh = _num(g('/energy/consumption/vs/0',
'x.com.samsung.da.cumulativePower'))
if inst_w is not None and inst_w < 0:
# The dryer reports a phantom -500W when idle; HA energy
# dashboard hates negatives.
inst_w = 0.0
sam_state = g('/operational/state/vs/0', 'x.com.samsung.da.state')
machine_state = (_SAMSUNG_STATE_TO_OCF.get(sam_state, sam_state)
if sam_state is not None
else g('/operational/state/0', 'currentMachineState'))
progress = g('/operational/state/vs/0', 'x.com.samsung.da.progress')
job_state = progress or g('/operational/state/0', 'currentJobState')
# HA's value_template treats the literal "None" as null (renders as
# "Unknown"). Substitute something we can render verbatim.
if job_state in (None, 'None'):
job_state = 'Idle'
if progress in (None, 'None'):
progress = 'Idle'
remaining = (g('/operational/state/vs/0',
'x.com.samsung.da.remainingTime')
or g('/operational/state/0', 'remainingTime'))
rem_min = None
if remaining:
try:
h, m, s = remaining.split(':')
rem_min = int(h) * 60 + int(m) + (1 if int(s) > 0 else 0)
except Exception:
pass
sam_power = g('/power/vs/0', 'x.com.samsung.da.power')
sam_kids = g('/kidslock/vs/0', 'x.com.samsung.da.kidsLock')
sam_rc = g('/remotectrl/vs/0',
'x.com.samsung.da.remoteControlEnabled')
power_bin = (sam_power == 'On') if sam_power is not None else None
kids_bin = (sam_kids != 'Ready') if sam_kids is not None else None
rc_bin = (str(sam_rc).lower() == 'true') if sam_rc is not None else None
return {
'machine_state': machine_state,
'job_state': job_state,
'progress': progress,
'progress_percentage': _int(g('/operational/state/vs/0',
'x.com.samsung.da.progressPercentage')
or g('/operational/state/0',
'progressPercentage')),
'completion_time': remaining,
'completion_minutes': rem_min,
'delay_end_time': g('/operational/state/vs/0',
'x.com.samsung.da.delayEndTime'),
'power_state': sam_power,
'power_state_binary': power_bin,
'child_lock': sam_kids,
'child_lock_binary': kids_bin,
'remote_control': sam_rc,
'remote_control_binary': rc_bin,
'power_watts': inst_w,
'energy_kwh': round(cum_wh / 1000.0, 2)
if cum_wh is not None else None,
'energy_wh_cumulative': int(cum_wh) if cum_wh is not None else None,
'dryer_mode': _decode_course(
g('/st/dryercourse/vs/0',
'x.com.samsung.da.st.dryerMode')),
'dry_level': _int(g('/washer/vs/0',
'x.com.samsung.da.dryLevel')),
'dry_time': g('/washer/vs/0',
'x.com.samsung.da.dryTime'),
'dryer_type': g('/washer/vs/0',
'x.com.samsung.da.dryerType'),
'wrinkle_prevent': g('/washer/vs/0',
'x.com.samsung.da.wrinklePrevent'),
'diagnosis': g('/diagnosis/vs/0',
'x.com.samsung.da.diagnosisStart'),
'country_code': g('/configuration/vs/0',
'x.com.samsung.da.countryCode'),
}
# --- Remaining-time anchor + extrapolation ----------------------------
# The dryer pushes /operational/state/vs/0 on state transitions but not
# on remainingTime ticks. Anchor = (timestamp, total_seconds) at last
# push; project() extrapolates downward while machine_state == 'active'.
def on_observation(state, href, rep):
if href != '/operational/state/vs/0':
return
rem = rep.get('x.com.samsung.da.remainingTime')
if not isinstance(rem, str):
return
try:
h, m, s = rem.split(':')
state['remaining_anchor'] = (time.time(),
int(h) * 3600 + int(m) * 60 + int(s))
except (ValueError, AttributeError):
pass
def project(state, sensors):
anchor = state.get('remaining_anchor')
if sensors.get('machine_state') != 'active' or anchor is None:
return sensors
ts, total = anchor
remaining = max(0, int(total - (time.time() - ts)))
h, rest = divmod(remaining, 3600)
m, s = divmod(rest, 60)
sensors = dict(sensors)
sensors['completion_time'] = f"{h}:{m:02d}:{s:02d}"
sensors['completion_minutes'] = h * 60 + m + (1 if s > 0 else 0)
return sensors
# --- Log-line ----------------------------------------------------------
def log_state_change(sensors):
return (f"machine={sensors.get('machine_state')} "
f"power={sensors.get('power_watts')}W "
f"energy={sensors.get('energy_kwh')}kWh")
# --- HA discovery ------------------------------------------------------
MODEL = 'OCF dryer (TizenRT-iotivity)'
# (key, friendly name, extra-config-dict)
_SENSORS = [
('machine_state', 'Machine state', {'icon': 'mdi:tumble-dryer'}),
('job_state', 'Job state', {}),
('progress', 'Progress', {}),
('progress_percentage', 'Progress percent',
{'unit_of_measurement': '%', 'state_class': 'measurement'}),
('completion_time', 'Completion time', {'icon': 'mdi:timer-sand'}),
('completion_minutes', 'Remaining minutes',
{'unit_of_measurement': 'min', 'device_class': 'duration',
'state_class': 'measurement'}),
('delay_end_time', 'Delay end time', {'icon': 'mdi:timer'}),
('power_state', 'Power state', {}),
('power_watts', 'Power',
{'unit_of_measurement': 'W', 'device_class': 'power',
'state_class': 'measurement'}),
('energy_kwh', 'Energy',
{'unit_of_measurement': 'kWh', 'device_class': 'energy',
'state_class': 'total_increasing'}),
('dryer_mode', 'Dryer mode', {}),
('dry_level', 'Dry level', {}),
('dry_time', 'Dry time', {}),
('dryer_type', 'Dryer type', {}),
('wrinkle_prevent', 'Wrinkle prevent', {}),
('diagnosis', 'Diagnosis', {}),
('country_code', 'Country code', {}),
]
# (key, friendly name, value_template, device_class)
_BINARY_SENSORS = [
('running', 'Running',
"{{ 'ON' if value_json.machine_state == 'active' else 'OFF' }}",
'running'),
('power_switch', 'Power switch',
"{{ 'ON' if value_json.power_state_binary else 'OFF' }}",
'power'),
('child_lock_active', 'Child lock',
"{{ 'ON' if value_json.child_lock_binary else 'OFF' }}",
'lock'),
('remote_control_enabled', 'Remote control',
"{{ 'ON' if value_json.remote_control_binary else 'OFF' }}",
'connectivity'),
]
# MQTT command-topic suffixes. The bridge subscribes to <prefix>/cmd/#
# and dispatches by suffix.
CMD_WRINKLE_PREVENT = 'cmd/wrinkle_prevent'
CMD_OPERATIONAL = 'cmd/operational_state'
CMD_DRYER_MODE = 'cmd/dryer_mode'
def build_discovery(topic_prefix, ha_prefix, device_name):
"""Return list of (discovery_topic, payload_bytes) tuples ready to
publish (retained) on MQTT connect."""
state_topic = f"{topic_prefix}/state"
avail_topic = f"{topic_prefix}/availability"
remote_topic = f"{topic_prefix}/remote_available"
dev = device_block(topic_prefix, device_name, MODEL)
out = []
# read-only sensors
for key, name, extra in _SENSORS:
cfg = {
'name': name,
'unique_id': f"{topic_prefix}_{key}",
'object_id': f"{topic_prefix}_{key}",
'state_topic': state_topic,
'value_template': f"{{{{ value_json.{key} }}}}",
'availability': avail_base(avail_topic),
'device': dev,
}
cfg.update(extra)
out.append((f"{ha_prefix}/sensor/{topic_prefix}/{key}/config",
encode(cfg)))
for key, name, template, dclass in _BINARY_SENSORS:
cfg = {
'name': name,
'unique_id': f"{topic_prefix}_{key}",
'object_id': f"{topic_prefix}_{key}",
'state_topic': state_topic,
'value_template': template,
'payload_on': 'ON',
'payload_off': 'OFF',
'device_class': dclass,
'availability': avail_base(avail_topic),
'device': dev,
}
out.append((f"{ha_prefix}/binary_sensor/{topic_prefix}/{key}/config",
encode(cfg)))
# switch: wrinkle prevent (always available)
cfg = {
'name': 'Wrinkle prevent',
'unique_id': f"{topic_prefix}_wrinkle_prevent_switch",
'object_id': f"{topic_prefix}_wrinkle_prevent_switch",
'state_topic': state_topic,
'value_template': '{{ value_json.wrinkle_prevent }}',
'state_on': 'On',
'state_off': 'Off',
'command_topic': f"{topic_prefix}/{CMD_WRINKLE_PREVENT}",
'payload_on': 'On',
'payload_off': 'Off',
'icon': 'mdi:iron',
'availability': avail_base(avail_topic),
'device': dev,
}
out.append((f"{ha_prefix}/switch/{topic_prefix}/wrinkle_prevent/config",
encode(cfg)))
# buttons: Start / Pause / Stop (gated on remote control)
buttons = [
('start', 'Start cycle', 'Run', 'mdi:play'),
('pause', 'Pause cycle', 'Pause', 'mdi:pause'),
('stop', 'Stop cycle', 'Ready', 'mdi:stop'),
]
for key, name, payload_press, icon in buttons:
cfg = {
'name': name,
'unique_id': f"{topic_prefix}_{key}",
'object_id': f"{topic_prefix}_{key}",
'command_topic': f"{topic_prefix}/{CMD_OPERATIONAL}",
'payload_press': payload_press,
'icon': icon,
'availability': avail_with_remote(avail_topic, remote_topic),
'availability_mode': 'all',
'device': dev,
}
out.append((f"{ha_prefix}/button/{topic_prefix}/{key}/config",
encode(cfg)))
# select: course (gated on remote control)
cfg = {
'name': 'Course',
'unique_id': f"{topic_prefix}_course_select",
'object_id': f"{topic_prefix}_course_select",
'state_topic': state_topic,
'value_template': '{{ value_json.dryer_mode }}',
'command_topic': f"{topic_prefix}/{CMD_DRYER_MODE}",
'options': _course_options(),
'icon': 'mdi:tumble-dryer',
'availability': avail_with_remote(avail_topic, remote_topic),
'availability_mode': 'all',
'device': dev,
}
out.append((f"{ha_prefix}/select/{topic_prefix}/course/config",
encode(cfg)))
return out
# --- MQTT command handlers --------------------------------------------
def command_handlers():
"""topic_suffix → fn(payload, links) → (path_segs, body_dict) | None.
`None` means refuse the command (caller logs & drops). Dryer
handlers don't need the links snapshot — they're all single-field
writes."""
def _wrinkle(p, _links):
if p not in ('On', 'Off'):
return None
return ['washer', 'vs', '0'], {'x.com.samsung.da.wrinklePrevent': p}
def _operational(p, _links):
if p not in ('Run', 'Pause', 'Ready'):
return None
return ['operational', 'state', 'vs', '0'], {'x.com.samsung.da.state': p}
def _course(p, _links):
code = _encode_course(p)
if code is None:
return None
return ['st', 'dryercourse', 'vs', '0'], {'x.com.samsung.da.st.dryerMode': code}
return {
CMD_WRINKLE_PREVENT: _wrinkle,
CMD_OPERATIONAL: _operational,
CMD_DRYER_MODE: _course,
}
# --- Poll tiers --------------------------------------------------------
# Empirical ceiling on this firmware is ~14 req/s (probe_poll_rate_combined.py
# 2026-06-03). The hot tier sits at 1s idle / 0.5s active — comfortably under
# the ceiling and leaves headroom for the warm + sweep budgets.
# Per-tier timeouts are scaled to cadence: hot tier retries every 1s, so
# a tight 2s ceiling caps the cascade damage from one wedged poll. Warm
# tier has more headroom; sweep is multi-block Block2 and tolerates ~15s.
DRYER_POLL_TIERS = [
PollTier(
name='hot',
interval_s=1.0,
active_interval_s=0.5,
timeout_s=2.0,
paths=(
('operational', 'state', 'vs', '0'),
),
),
PollTier(
name='warm',
interval_s=15.0,
timeout_s=4.0,
paths=(
('power', 'vs', '0'),
('kidslock', 'vs', '0'),
('remotectrl', 'vs', '0'),
('alarms', 'vs', '0'),
('course', 'vs', '0'),
('washer', 'vs', '0'),
('st', 'dryercourse', 'vs', '0'),
('wm', 'jobbeginingstatus', 'vs', '0'),
('energy', 'consumption', 'vs', '0'),
('diagnosis', 'vs', '0'),
),
),
PollTier(
name='sweep',
interval_s=300.0,
timeout_s=15.0,
paths=(('device', '0'),),
is_sweep=True,
),
]
def _is_active(links: dict) -> bool:
rep = links.get('/operational/state/vs/0') or {}
sam_state = rep.get('x.com.samsung.da.state')
return _SAMSUNG_STATE_TO_OCF.get(sam_state) == 'active'
# --- Descriptor --------------------------------------------------------
DRYER = ApplianceDescriptor(
name='dryer',
default_observe_port=49155,
observe_paths=OBSERVE_PATHS,
seed_path=['device', '0'],
flatten=flatten,
build_discovery=build_discovery,
command_handlers=command_handlers,
on_observation=on_observation,
project=project,
remote_available_field='remote_control_binary',
log_state_change=log_state_change,
poll_tiers=DRYER_POLL_TIERS,
is_active=_is_active,
)