Compare commits

...
24 Commits
Author SHA1 Message Date
Marc Billow d912bff5d3 Bump version to 0.23.0 2026-08-16 05:32:31 +00:00
Marc Billow 330479d389 Merge pull request #380 from mbillow/issue-364-cloud-courses-disable
Add a global disable for downloaded cycles and clarify setup instructions
2026-08-16 00:27:52 -05:00
Marc Billow c0ef45c7c8 Harden the cloud-courses toggle against three review findings
- _on_cloud_courses_changed cleared the canonical-view cache but never
  pushed state: select.py's current_option reads coordinator.data,
  which only moves on async_set_updated_data, so a change here (the
  new toggle, or apply_cloud_courses naming a program -- which has
  called this same method since before the toggle existed) sat stale
  in the UI until an unrelated poll or observe happened to run next.
  Now calls _push_cache_snapshot() too.

- _refresh_cloud_course_issue now runs from __init__.py's
  options-update listener on every entry save, not just a
  cloud-course-specific one. Saving an unrelated option
  (CONF_BYPASS_REMOTE_CONTROL, say) before this device's first poll,
  or while it's rehydrated offline, read /course/vs/0 as empty --
  indistinguishable from "nothing pending" -- and would delete a
  Repair a real poll had every reason to raise. Now a no-op on an
  empty rep, leaving whatever issue state already exists untouched
  until a real poll can judge it.

- The "cloud_courses" menu's off-state note was a raw English string
  built in config_flow.py and substituted via description_placeholders
  into all 7 locales' descriptions -- unlike the SmartThings screen
  names quoted elsewhere (deliberately English everywhere; that's a
  third-party app's own label, not ours), this one named LocalThings'
  own "Offer downloaded cycles"/"Device settings" labels, which are
  translated per locale and should have matched. Replaced with a
  permanent, state-independent sentence translated in the catalog
  itself, in all 7 locales, instead of conditional Python-built text.

Also restores a word an earlier edit dropped from
async_step_cloud_courses's docstring ("can complete confidently").

Tests: two new regression tests, each confirmed to fail against the
pre-fix code before being fixed -- one drives coordinator.data through
a toggle via a fixture already sitting on a one-time cloud override, so
current_option actually depends on the cloud store instead of falling
back to the raw course code; the other simulates a second coordinator
against the same entry with an empty resource cache (a not-yet-polled
restart) and confirms an existing Repair survives an unrelated option
save. Full suite (1585 tests), ruff, and `ty check custom_components
tests` all pass.
2026-08-16 05:25:36 +00:00
Marc Billow d5dc6421ba Add a global disable for downloaded cycles and clarify setup instructions
Issue #364: several reporters got the "downloaded cycles not set up"
Repair despite never meaning to use the feature -- one device appears
to auto-populate a slot from a SmartThings-provided example. The two
reporters who did complete setup successfully both hit the same root
cause for their earlier failures: the SmartThings app has two
similarly-named screens ("Cycle", which lists everything including
local courses, and "Download cycles", the one that actually matters
here), and nothing in our instructions said to use the second one
specifically.

Global disable (CONF_CLOUD_COURSES_ENABLED, entry.options, default
on):
- New coordinator.cloud_courses_enabled property, mirroring
  CONF_LEARN_MODES' shape -- off stops the Repair and stops offering
  already-named programs as cycles, without discarding anything
  already learned or named.
- Deliberately does NOT stop _observe_cloud_courses' passive recording:
  guided/manual setup depend on live observation to detect a newly
  selected program at all, and leaving it running means turning the
  option back on immediately surfaces anything set up in the meantime
  instead of asking the user to redo it. Documented on the const and
  on the property.
- New __init__.py options-update listener calls a new
  coordinator._on_cloud_courses_changed(), which both clears the
  canonical-view cache (memoized, so a stale view would otherwise keep
  answering with pre-toggle state -- caught by two failing tests
  before this) and refreshes the Repair. Nothing else needed this
  because every other option is read live on its own next use; cloud
  courses is the only one with standing Repair/cache state to refresh
  immediately rather than on the next unrelated change.
- Toggle exposed in Device settings as "Offer downloaded cycles",
  alongside prose explaining why some devices show the Repair
  unprompted.

Instructions, in every shipped locale (en/de/es/it/cs/nl/ko) --
otherwise a locale missing the new/changed strings would silently show
English or the old text, the same gap issue #376 already tests for:
- Every guided-setup screen, the manual edit form, and the Repair
  itself now say explicitly: open the SmartThings app (not the
  appliance), and tap "Download cycles" specifically -- a separate row
  from "Cycle", further down the screen -- not the general cycle
  picker. Also states plainly that the appliance doesn't need to be
  nearby or running the cycle, just powered on and connected.
  SmartThings' own screen names are kept in English in every locale
  (verified only in the English app via the reporter's screenshots;
  translating them without evidence of what Samsung's own localized
  app shows would be a guess this codebase's translations otherwise
  avoid).
- The Repair's description now also points at the new toggle for
  anyone who doesn't want the feature at all.
- The "cloud_courses" menu screen shows a note when the option is
  currently off, since guided/manual setup still work in that state
  but nothing named there will appear as a selectable cycle until it's
  turned back on.

Tests: coordinator-level tests cover the option defaulting on,
suppressing a new Repair, clearing an already-open one, hiding/
restoring the cycle-select entry as the option flips (which caught the
canonical-cache bug above), and that passive observation keeps running
regardless of the option. Options-flow tests cover the new field's
default and that it persists. Translation catalog tests
(test_every_language_mirrors_the_english_catalog et al.) cover every
locale's topology and placeholders for the changed/added strings.

Full suite (1583 tests), ruff, and `ty check custom_components tests`
(CI's exact invocation) all pass.
2026-08-16 05:04:44 +00:00
Marc Billow 93cd6ae6fe Merge pull request #379 from mbillow/fix-particulate-unit-deprecation
Stop importing the deprecated CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
2026-08-15 23:33:02 -05:00
Marc Billow ffc0eec322 Merge pull request #378 from mbillow/issue-376-cycle-labels
Add washer/dryer Table_02/Table_03 cycle labels for WF21T6500KV/DV19T8745BV
2026-08-15 23:26:44 -05:00
Marc Billow f5d9f0e31b Stop importing the deprecated CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
HA logs a removal warning (2027.8) every time this name is accessed on
releases that carry UnitOfDensity, attributed straight to this
integration since it's a plain module-level import. UnitOfDensity is
the replacement, but hacs.json's floor (2025.1.0) predates it existing
at all -- pytest-homeassistant-custom-component 0.13.316, the newest
available, still has no UnitOfDensity either, so this can't be a
static import on either branch without breaking support for part of
the version range.

Resolved with a runtime getattr instead: reads UnitOfDensity off the
homeassistant.const module if present and uses its
MICROGRAMS_PER_CUBIC_METER member, otherwise falls back to the plain
(un-deprecated, on those older releases) constant. The getattr
short-circuits before the deprecated name is ever touched on a release
new enough to have UnitOfDensity, so the warning stops firing there
without dropping support for anything still on the old one. Same
feature-detection shape _relabel_particulate_statistics already uses a
few lines down for new_unit_class.

Verified the resolution logic directly: against the installed HA
(2026.2.3, pre-UnitOfDensity) it resolves to the plain constant with no
warning; a simulated future homeassistant.const with UnitOfDensity
present resolves to it without ever touching the deprecated name (a
guard that raises on that access never fires).

Full suite (1573 tests), ruff, and `ty check custom_components tests`
(CI's exact invocation) all pass.
2026-08-16 04:26:29 +00:00
Marc Billow 55486526cc Relabel washer Table_02 06 from XXL Laundry to Bedding
06's Korean text ('이불') is identical to the confirmed Bedding codes
24/6f, and Bedding reads better than the guessed 'XXL Laundry' wording
issue #342 originally gave it. Applied across all 7 locale catalogs
(matching each locale's own already-translated Bedding text, not a
fresh translation) and folded into issue #376's WF21T6500KV test as a
21st confirmed code instead of a flagged exclusion.

test_confirmed_washer_table_02_missing_course_names (#342) updated to
match; its docstring now notes 06's wording was later corrected by
#376 rather than pinning the old value as if still current.
2026-08-16 04:19:04 +00:00
Marc Billow a18663d5e0 Add washer/dryer Table_02/Table_03 cycle labels for WF21T6500KV/DV19T8745BV
Issue #376 reported Korean UI labels for 21 washer (Table_02) and 18
dryer (Table_03) codes that had no translation and were rendering as
raw hex in the UI, from a WF21T6500KV washer (DA_WM_A51_20_COMMON) and
DV19T8745BV dryer (DA_WM_TP1_21_COMMON).

Cross-checked every reported code against translations/ko.json before
translating anything: several share their exact Korean text with a
code the catalog already has a confirmed label for (washer '19'/'AI
맞춤세탁' matches '2b'/'69'; dryer '3a'/'살균건조' matches '21'; dryer
'3c'/'피트니스' even matches washer '2f', a cross-table reuse; etc.) --
those reuse the established label instead of a fresh translation. The
rest (Wool/Lingerie, Boil Wash, Soft Bubble, Padding Care, and others
with no catalog precedent) are new translations of the reporter's
Korean text.

One code is deliberately NOT applied: washer '06' ('이불', Bedding per
this report) conflicts with 'XXL Laundry', already locked in by
test_confirmed_washer_table_02_missing_course_names (issue #342). Two
reports of the same nominal Table_02 disagreeing on one code is a real
discrepancy, not a wording question -- left alone pending the reporter
(or another Table_02 owner) confirming which device's '06' is actually
wrong, same caution as the existing '24'/'33' transposition history
(issue #343).

Added to all 7 locale catalogs (en/de/es/it/cs/nl/ko), not just
English: HA falls back to English for any key a locale is missing, so
translations/en.json alone would still pass
test_every_language_mirrors_the_english_catalog's topology check while
leaving every other locale showing English text for these codes.

Tests: two new tests lock in the English labels and, for every reused
code, that every locale's label actually matches its anchor code (not
just English) -- the same gap issue #343 fell through, since the
topology test alone can't catch a locale-specific mistranslation.
Full suite (1575 tests), ruff, and ty all pass.
2026-08-16 04:15:02 +00:00
Marc Billow 322e436c22 Merge pull request #377 from mbillow/chore/smartthings-local-0-1-8
Bump smartthings-local floor to 0.1.8
2026-08-15 22:47:52 -05:00
Marc Billow ba089dbb5c Bump smartthings-local floor to 0.1.8
Two releases landed since the 0.1.6 pin, both confirmed by upstream
(QuiteYellow, in issue #361) as additive/opt-in with no interface
changes on our side:

- 0.1.7: server-certificate profiles (SamsungServerProfile), a bounded
  DTLS handshake deadline (connect() now defaults to a 12s bound
  instead of none), and a cancellable connect() via
  ConnectCancellation. Our connect() call sites in coordinator.py and
  config_flow.py pass no args, so they pick up the bounded handshake
  for free; the cert-profile and cancellation pieces are opt-in and
  unused here.

- 0.1.8: fixes blockwise OBSERVE notification reassembly
  (QuiteYellow/SmartThings-Local#39) -- a notification carrying only
  the first Block2 block was previously handed straight to
  on_notification instead of being reassembled, and separately, the
  Block2 loop could append a retransmitted/late block as if it were
  the next one, or miscompute the next block offset after a mid-
  transfer size downshift. Both corrupt a multi-block observed
  resource without necessarily truncating it -- the "premature end of
  stream" / "error decoding unicode string" CBOR failures reported in
  issue #361 on /mode/vs/0. All error types stay within the existing
  compatible-built-in table (ConnectionError/TimeoutError subclasses),
  so no exception handling changes.

`>=0.1.6` already permitted pip to resolve 0.1.8 on a fresh install,
but an environment that already has 0.1.6 or 0.1.7 satisfying that
floor won't be upgraded by Home Assistant's requirement check -- which
is what #361's reporter is very likely still hitting on 0.22.0.
Raising the floor to >=0.1.8 forces that upgrade on the next release.

Verified against smartthings-local 0.1.8 from PyPI: full suite (1573
tests), ruff, and ty all pass. No source changes needed beyond the
three version pins (manifest.json, requirements-dev.txt, Dockerfile).
2026-08-16 03:44:11 +00:00
Marc Billow 0d0ffb57dd Merge pull request #375 from mbillow/claude/issue-367-hnrc8y
airconditioner: ungate outdoor_temperature from is_legacy_board
2026-08-15 16:47:45 -05:00
Marc Billow fe0db8586f airconditioner: ungate outdoor_temperature from is_legacy_board
The OutdoorTemp_ options token was only surfaced on legacy boards
(is_legacy_board), even though 14 of 17 fixtures carrying the token are
non-legacy. issue #367 confirmed with a 48h field capture (r=0.92
against weather.forecast_home) that the token tracks real outdoor
temperature independent of board generation, and that no non-legacy
board exposes an alternative outdoor-temperature resource.

Split a token-presence-only exists_fn (_has_option_token_any_board) for
this token, leaving _has_option_token's legacy gate untouched for the
other options[] settings that still need it. The -55 offset itself was
only field-validated on Celsius-locale boards, so a second gate
(_reports_celsius, reading the board's own /temperatures/vs/0) keeps
the sensor off the one Fahrenheit-locale fixture on record rather than
guess whether the same offset and unit still apply there. Ships
enabled_default=False since multi-split installs report the same token
on every indoor head, which would otherwise create one duplicate active
sensor per head.

Updates the golden fixtures for the 13 affected Celsius-locale boards
and the artik051_krac test that had asserted outdoor_temperature stays
off newer boards; adds coverage for the Fahrenheit-locale gate.
2026-08-15 21:38:56 +00:00
Marc Billow 8814fffa4f airconditioner: ungate outdoor_temperature from is_legacy_board
The OutdoorTemp_ options token was only surfaced on legacy boards
(is_legacy_board), even though 14 of 17 fixtures carrying the token are
non-legacy. issue #367 confirmed with a 48h field capture (r=0.92
against weather.forecast_home) that the token tracks real outdoor
temperature independent of board generation, and that no non-legacy
board exposes an alternative outdoor-temperature resource.

Split a token-presence-only exists_fn (_has_option_token_any_board) for
this token, leaving _has_option_token's legacy gate untouched for the
other options[] settings that still need it. Ships enabled_default=False
since multi-split installs report the same token on every indoor head,
which would otherwise create one duplicate active sensor per head.

Updates the golden fixtures for the 14 affected boards and the
artik051_krac test that had asserted outdoor_temperature stays off
newer boards.
2026-08-15 21:22:09 +00:00
Marc Billow 12922909c0 Merge pull request #374 from mbillow/claude/pr-303-code-review-6mghcs
Load a config entry offline from the last discovery snapshot
2026-08-15 15:54:25 -05:00
Marc Billow 9d3782a29a Harden the snapshot path against three review findings
Widen async_rehydrate's guard to cover the identity and Subdevice rebuild,
not just the replay. A stored row missing a field the current dataclass
declares raised KeyError straight out of async_setup_entry, which only
handles ConfigEntryNotReady -- so the entry landed in SETUP_ERROR, which HA
never retries, with its DTLS session left open on the fixed source port the
next attempt binds. It now fails the same way an unreachable device does.

Write the snapshot immediately instead of through async_delay_save. A
deferred write outlives whatever queued it: removing an entry inside the
delay window deleted the file and then had it recreated, orphaned, when the
timer fired; and a reload scheduled by _reconcile_rehydrated read the
pre-reload snapshot back off disk, so a device going quiet again mid-reload
rehydrated the stale set and reconciled a second time. Banking it before the
reconcile fixes the ordering. Failures are logged rather than raised -- a
board reporting something the JSON encoder rejects must not break polling.
2026-08-15 20:32:46 +00:00
Marc Billow edff7385c6 Raise the coverage-gap Repair only from a live poll
A coverage gap is a claim about what the device currently reports, so
replaying a discovery snapshot shouldn't make it. Offline it would restate
the last live poll's conclusion while pointing the user at a diagnostics
download that stays empty until the appliance answers, and any drift in the
resolved device name between snapshot and live would churn the issue.

Not deduplication: HA already keys issues on (domain, issue_id), preserves
dismissed_version across async_get_or_create, and reloads non-persistent
issues with their dismissal intact -- one row per entry, and an "Ignore"
survives restarts.
2026-08-15 20:13:02 +00:00
Marc Billow e684146f61 Load a config entry offline from the last discovery snapshot (#295)
An appliance switched off at the wall used to take its whole config entry
down with it: async_setup_entry raised ConfigEntryNotReady, so the device
read as failed and its entities existed only as registry rows until the
appliance came back.

Loading the entry anyway isn't enough on its own. Entities here are the
output of discovery, discovery only runs inside a successful poll, and
platforms enumerate `bound` exactly once at forward time -- so an entry
that loads while offline loads empty, and with no listeners subscribed the
base coordinator stops rescheduling and never polls again.

Bank the resources dict each successful first cycle hands _run_discovery,
along with the subdevice candidate list and the /oic identity that route
the registry, and replay it through _run_discovery when the first refresh
fails. Storing the poll input rather than a rendered entity list keeps one
implementation of discovery instead of two: the offline entity set is
produced by the same code that produced the live one.

Three things fall out of that:

- Platforms judge entity existence against `discovery_resources`, not the
  live cache. The live cache deliberately stays empty, which is what keeps
  a restored entity `unavailable` rather than rendering a stale value for
  an appliance nobody can currently reach.
- A live discovery that disagrees with the snapshot reloads the entry --
  platforms can't adopt a changed set in place, so a firmware update or a
  sibling subdevice that starts answering needs a fresh setup.
- The entry holds one coordinator listener for its lifetime, so polling is
  scheduled regardless of how many entities are live.

An entry that has never reached the device has no snapshot, keeps raising
ConfigEntryNotReady, and closes its session on the way out as before -- no
metadata to build a device from, and it leaves room for setup flows that
need to interact with the appliance (#168).

Restores the two tests PR #303 rewrote, narrowed to that no-snapshot path.
2026-08-15 20:05:58 +00:00
Marc Billow bc03ada208 docs(offline-setup): what PR #303 measures, and what a working version needs
PR #303 loads the entry when the first poll fails. Measured on its
branch, that produces an entry with zero bound entities and zero
coordinator listeners, so DataUpdateCoordinator never reschedules and
the device never recovers without a manual reload.

Record why entities can't be created offline here (discovery is the only
source of `bound`, and platforms enumerate it once), what a working
version would need (persisted discovery snapshot, reconcile-on-reconnect,
a listener that keeps polling alive), and the cheaper retry-and-reload
option that solves the filed issue on its own.
2026-08-15 19:38:28 +00:00
firstof9@gmail.com 67012c57d7 Allow non-blocking setup when device is offline (#295)
When a device is offline or unreachable during Home Assistant startup,
 previously raised . HA's built-in
retry mechanism uses exponential backoff up to 15 minutes, which leads to
a poor user experience for local LAN devices.

Catch initial connection errors during  and log a
warning instead of failing setup. This allows platforms to set up and
entities to be created (in an unavailable state), while the coordinator
continues background retry polling.
2026-08-15 19:38:20 +00:00
Andy Warwick dbc5c55a97 docs(ac-filter-reset): record a board family with no local reset (#354)
The investigation is written around the FilterTime_<N> option token on
/mode/vs/0, and its conclusion holds for the ARTIK051_KRAC_18K it was
measured on. An ARTIK051_PRAC_20K has no such token: no FilterTime, no
FilterAlarmTime, no FilterCleanAlarm anywhere in its options blob. It
keeps the counter in /filter/airdustfilter/vs/0 as a percentage of a
500-hour interval instead.

Neither route resets it. FilterCleanAlarm_Clear to /mode/vs/0 returns
4.00 with the options blob byte-identical; writing filterUsage as the
string "0" returns 4.00; writing it as an integer returns 5.00. That
last difference is the useful part — two payloads differing only in JSON
type returning different codes rules out an unresolved href or an
unrecognised field name, leaving read-only as the reading.

Adds a scope line to the intro and a section documenting the board, its
two resource dumps, the attempt table, and an observation-only
workaround for percentage-counter boards.
2026-08-14 22:00:49 -05:00
Marc Billow 19a2c03609 Merge pull request #372 from mbillow/claude/dryer-type-translations
dryer/dishwasher: dryer_type translation (#366) + missing progress state
2026-08-14 21:59:27 -05:00
Marc Billow 76f2c3c0cd progress: add dryingwithdooropen ('Venting') across all locales
Confirmed on a live dishwasher's sensor.*_progress history (not in any
shipped fixture): Prewash -> Wash -> Rinse -> Drying ->
DryingWithDoorOpen -> Finish -> Idle. The door-open drying-assist stage
had no catalog entry, so it fell through to the sensor.py fallback and
rendered as the raw lowercased string 'dryingwithdooropen' instead of a
readable name.

No code change needed -- operational.py's progress SensorDesc already
derives its options from the catalog via translated_states(), so a new
state key is picked up automatically.
2026-08-15 02:56:50 +00:00
Marc Billow a59ef6cca9 dryer: translate dryer_type, folding in #366's Dutch additions
#366 added Dutch state translations for dryer_type ('Electricity') and
progress, but predates #371's lowercase-key normalization: its progress
states were already superseded (every one it added is already in nl.json's
current 'state' table, lowercase), and its dryer_type addition used the
pre-#371 capitalized key.

dryer_type itself was never wired for translation at all -- no
device_class, no options -- so nothing in any locale's dryer_type.state
table was ever read. Give it device_class=enum, options=("electricity",)
(the only value confirmed across shipped fixtures), and the same
value_fn=lower() normalization #371 used elsewhere, then add the
'electricity' state across all seven locale catalogs, not just Dutch.
2026-08-15 02:44:01 +00:00
43 changed files with 1999 additions and 168 deletions
+1 -1
View File
@@ -6,4 +6,4 @@ FROM ghcr.io/home-assistant/home-assistant:stable
# repeats the install attempt on every container recreate. Baking
# smartthings-local into the image keeps the dev container usable
# offline and avoids relying on that runtime install path.
RUN pip3 install --no-cache-dir "smartthings-local>=0.1.6"
RUN pip3 install --no-cache-dir "smartthings-local>=0.1.8"
+6
View File
@@ -270,6 +270,12 @@ If reconnects become persistent (more than a handful per minute), something's ac
Deregistering a device in SmartThings causes a reset of its network settings as soon as it accesses Samsung's servers, dropping it off Wi-Fi until it's re-onboarded through the SmartThings app. As such, consider keeping devices registered even if egress-blocked, to avoid them resetting upon brief internet access.
### Restarting while an appliance is powered off
If Home Assistant restarts while an appliance is unplugged or switched off at the wall, its device and entities still load — restored from the last successful discovery, showing `unavailable` until the appliance answers again. Automations and dashboards keep referring to entities that exist, and the integration retries in the background, so the device comes back on its own within a poll cycle of being powered on. Entities read `unavailable` rather than their last known values on purpose: the integration can't verify what a disconnected appliance is doing, and recorded history is kept by the recorder either way.
This only applies to an appliance the integration has reached at least once. A brand-new device that has never answered has nothing to restore from, so setting it up still requires it to be reachable.
### Multi-subdevice ("2-in-1") air conditioner systems
Some Samsung installs run more than one indoor subdevice off a single outdoor unit, all reachable over the *one* IP/DTLS session your config entry connects to (a floor-standing + wall-mounted 2-in-1 is a common shape). The integration discovers any sibling subdevices automatically, once, right after the first successful poll — there's nothing to configure. Each discovered subdevice gets its own HA device (linked to the main one via "via device") and its own `climate` card, so it lands in its own room in the dashboard instead of being invisible or mixed into the master's state.
+69 -13
View File
@@ -7,10 +7,8 @@ import logging
import re
from typing import Any
from homeassistant import const as ha_const
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER as PARTICULATE_UNIT,
)
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryNotReady
@@ -19,12 +17,28 @@ from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.typing import ConfigType
from .const import CONF_DEVICE_TYPE, CONF_HOST, CONF_PORT, CONF_SERIAL, DOMAIN, PLATFORMS
from .coordinator import LocalThingsCoordinator
from .coordinator import LocalThingsCoordinator, snapshot_store
from .registry.identity import resolve_serial
from .services import async_setup_services
_LOGGER = logging.getLogger(__name__)
# UnitOfDensity is the non-deprecated home for this value from whichever HA
# release introduces it; CONCENTRATION_MICROGRAMS_PER_CUBIC_METER logs a
# removal warning (2027.8) on those releases every time it's accessed.
# hacs.json's floor (2025.1.0) predates UnitOfDensity existing at all, so
# this is a runtime getattr rather than a static import ty could only ever
# resolve against one HA generation -- see _relabel_particulate_statistics'
# own new_unit_class feature-detection below for the same pattern. The
# getattr short-circuits before the deprecated name is ever touched on a
# release new enough to have UnitOfDensity.
_unit_of_density = getattr(ha_const, "UnitOfDensity", None)
PARTICULATE_UNIT = (
_unit_of_density.MICROGRAMS_PER_CUBIC_METER
if _unit_of_density is not None
else ha_const.CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
# Services are process-global, registered once here rather than per
@@ -260,17 +274,30 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data.setdefault(DOMAIN, {})
coordinator = LocalThingsCoordinator(hass, entry)
# Before the first refresh, so the coordinator keeps rescheduling even
# when that refresh fails and leaves nothing subscribed: the base class
# only re-arms its timer while it has listeners, and an offline load can
# legitimately have zero live entities (every one of them disabled, say).
# Without this the entry loads once and never polls again (issue #295).
entry.async_on_unload(coordinator.async_add_listener(lambda: None))
try:
await coordinator.async_config_entry_first_refresh()
except Exception as err:
# `_poll_once` deliberately leaves the session up on a TimeoutError
# (see its docstring), so a refresh failing that way leaves a live,
# bound UDP socket nothing would ever close. HA retries setup with a
# new coordinator, and the source port is fixed by design
# (`_local_source_port`), so an abandoned socket would squat the
# exact port the next attempt binds.
await coordinator.async_close()
raise ConfigEntryNotReady(f"Cannot connect to device: {err}") from err
except ConfigEntryNotReady:
# An entry that has polled successfully before comes up on its last
# known entity set and keeps retrying on the normal poll interval,
# rather than sitting in setup-retry with a device that reads as
# broken and entities that exist only as registry rows (issue #295).
#
# An entry that has never reached the device has no snapshot, so
# there is nothing to show and no device metadata to name it with --
# that case still fails, which is also what keeps the door open for
# setup flows that need to interact with the device (issue #168).
if not await coordinator.async_rehydrate():
await coordinator.async_close()
raise
hass.data[DOMAIN][entry.entry_id] = coordinator
# Send the DTLS close_notify on Core shutdown, not just on unload (issue
@@ -290,6 +317,25 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_close_on_stop)
)
# Nothing else reacts to an options-flow save (issue #364): the cloud-
# courses Repair and canonical-view cache are only refreshed from
# _on_cloud_courses_changed, which only runs when a poll/observe
# actually reports new data. Without this, turning "Download cycles"
# off would leave an already-open Repair sitting there -- and an
# already-named program still offered as a cycle -- until the next
# unrelated change happened to fire it, indistinguishable from the
# toggle not working. Every other option (CONF_LEARN_MODES,
# CONF_BYPASS_REMOTE_CONTROL, ...) is read live on its own next use and
# has no comparable standing state to refresh, so this listener exists
# for cloud courses specifically rather than as a general
# options-changed hook. Must be a coroutine function -- HA wraps
# whatever an update listener returns in async_create_task, which raises
# on a plain callback's None.
async def _async_options_updated(_hass: HomeAssistant, _entry: ConfigEntry) -> None:
coordinator._on_cloud_courses_changed()
entry.async_on_unload(entry.add_update_listener(_async_options_updated))
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
@@ -327,6 +373,16 @@ async def async_remove_config_entry_device(
return not (device.identifiers & live)
async def async_remove_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
"""Delete the discovery snapshot this entry accumulated (issue #295).
Nothing else would: the store is keyed on entry_id, so re-adding the same
appliance mints a new one and the old file would linger in .storage
forever.
"""
await snapshot_store(hass, entry).async_remove()
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
unloaded = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unloaded:
@@ -42,6 +42,7 @@ from .const import (
CONF_BYPASS_REMOTE_CONTROL,
CONF_CA_CERT_PEM,
CONF_CA_KEY_PEM,
CONF_CLOUD_COURSES_ENABLED,
CONF_DEVICE_TYPE,
CONF_FINISH_TIME_HYSTERESIS_MINUTES,
CONF_HOST,
@@ -52,6 +53,7 @@ from .const import (
CONF_MODEL,
CONF_PORT,
CONF_SERIAL,
DEFAULT_CLOUD_COURSES_ENABLED,
DEFAULT_FINISH_TIME_HYSTERESIS_MINUTES,
DEFAULT_LEARN_MODES,
DOMAIN,
@@ -991,6 +993,12 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow):
CONF_LEARN_MODES, DEFAULT_LEARN_MODES
),
): bool,
vol.Required(
CONF_CLOUD_COURSES_ENABLED,
default=self.config_entry.options.get(
CONF_CLOUD_COURSES_ENABLED, DEFAULT_CLOUD_COURSES_ENABLED
),
): bool,
}
),
)
@@ -1038,6 +1046,18 @@ class LocalThingsOptionsFlow(config_entries.OptionsFlow):
program in the moment they select it, rather than about a list of hex
ids some time later. The bulk form stays for renaming afterwards,
which guided setup is bad at.
Setup still works with cloud_courses_enabled off (see
CONF_CLOUD_COURSES_ENABLED's own comment for why) -- someone who
wants to name one cycle without turning the feature fully on for
everything else still can (issue #364). The catalog's own
description covers that state directly rather than through a
description_placeholder built here: a placeholder is a literal
substitution HA never runs back through translation, so an
English sentence assembled in Python would show untranslated text
in every other locale -- unlike the SmartThings screen names
quoted elsewhere in this catalog, which stay English everywhere
because that's a third-party app's own label, not one of ours.
"""
return self.async_show_menu(
step_id="cloud_courses",
+15
View File
@@ -57,6 +57,21 @@ DEFAULT_LEARN_MODES = True
# {"download_course": "87"|null, "slots": {slot: {"blob": ..., "name": ...}}}
CONF_CLOUD_COURSES = "cloud_courses"
# Options-flow key: whether downloaded programs are offered as selectable
# cycles and nagged about via the "not set up yet" Repair (issue #364).
# Defaults to on. Unlike CONF_LEARN_MODES this does not also stop passive
# observation -- a device that merely *advertises* download slots without
# the owner ever meaning to use them (SmartThings appears to seed one from
# the cloud automatically, per #364's reporters) is exactly the case this
# exists for, and turning it off is the fix. Guided/manual setup stay
# reachable and still record what they see either way: they are a deliberate
# per-session action, not the passive background behavior this silences, and
# leaving them working means flipping the option back on immediately surfaces
# anything set up in the meantime instead of asking the user to redo it. See
# coordinator.cloud_courses_enabled for exactly what it gates.
CONF_CLOUD_COURSES_ENABLED = "cloud_courses_enabled"
DEFAULT_CLOUD_COURSES_ENABLED = True
# 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
+285 -9
View File
@@ -9,6 +9,7 @@ import logging
import threading
import time
import zlib
from dataclasses import asdict
from datetime import timedelta
from typing import Any, cast
@@ -18,6 +19,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.storage import Store
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from smartthings_local.ocf.state_cache import StateCache
from smartthings_local.protocol.dtls_session import DtlsCoapSession
@@ -28,6 +30,7 @@ from .cloudcourse import persist as cloud_persist
from .const import (
CONF_BYPASS_REMOTE_CONTROL,
CONF_CLOUD_COURSES,
CONF_CLOUD_COURSES_ENABLED,
CONF_DEVICE_TYPE,
CONF_HOST,
CONF_LEAF_CERT_PEM,
@@ -38,6 +41,7 @@ from .const import (
CONF_MODEL,
CONF_PORT,
CONF_SERIAL,
DEFAULT_CLOUD_COURSES_ENABLED,
DEFAULT_LEARN_MODES,
DEVICE_SUPPORT_ISSUE_URL,
DOMAIN,
@@ -47,7 +51,7 @@ from .const import (
from .learned import LEARNABLE, LearnedModes, persist
from .observe import GRACE_PERIOD_S, MODE_OBSERVE, MODE_POLL, ObserveManager
from .registry import CAPABILITIES
from .registry.adapter import flatten
from .registry.adapter import _key, flatten
from .registry.batch import parse_device0_batch
from .registry.by_type import resolve as resolve_registry
from .registry.capabilities.common import (
@@ -83,6 +87,20 @@ _LOGGER = logging.getLogger(__name__)
_SEED_PATH = ["device", "0"]
# Discovery snapshot (issue #295): exactly what the last successful first
# cycle fed _run_discovery, so a restart can register the same entities
# while the appliance is unreachable. Kept in .storage rather than on the
# config entry -- it's device state, not configuration, and runs to tens of
# kilobytes.
_SNAPSHOT_VERSION = 1
def snapshot_store(hass: HomeAssistant, entry: ConfigEntry) -> Store[dict[str, Any]]:
"""This entry's discovery-snapshot store. A free function so
`async_remove_entry` can delete the file without standing up a whole
coordinator to reach it."""
return Store(hass, _SNAPSHOT_VERSION, f"{DOMAIN}.{entry.entry_id}.discovery")
class _NoOpDescriptor:
"""No-op: StateCache requires an on_observation hook; this integration
@@ -248,6 +266,11 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._identity: DeviceIdentity | None = None
self._discovered = False
self.bound = []
self._snapshot_store = snapshot_store(hass, entry)
# Both set only when this entry loaded from a snapshot instead of a
# live poll -- see async_rehydrate.
self._rehydrate_resources: dict[str, dict] | None = None
self._rehydrated_keys: frozenset[tuple[str, str]] | None = None
# Sibling indoor subdevices on this connection (issue #177); set
# once at first discovery, narrowed to the ones with live state (see
# subdevices.discover_partitioned). Never includes MAIN itself.
@@ -358,12 +381,21 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
rep = snapshot.get(cloudcourse.COURSE_HREF)
if rep is None:
return snapshot
view = self._cloud.view()
view = self._cloud_view()
if not view:
return snapshot
snapshot[cloudcourse.COURSE_HREF] = {**rep, cloudcourse.FIELD: view}
return snapshot
def _cloud_view(self) -> dict:
"""`self._cloud.view()`, or nothing while cloud_courses_enabled is
off (issue #364) -- entity_resources/entity_rep's one gate so a
previously-named program stops being offered the moment the option
is turned off, symmetric with how it starts being offered again the
moment it's turned back on. The store itself is untouched either
way; only what these two hand to the registry changes."""
return self._cloud.view() if self.cloud_courses_enabled else {}
def entity_rep(self, href: str) -> dict:
"""One href's rep as descriptors see it -- `resource()` plus the
merge `entity_resources` would have applied. Exists so the write path
@@ -372,7 +404,7 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
rep = self.resource(href)
if href != cloudcourse.COURSE_HREF or not rep:
return rep
view = self._cloud.view()
view = self._cloud_view()
return {**rep, cloudcourse.FIELD: view} if view else rep
def device_resources(self, subdevice: Subdevice) -> dict[str, dict]:
@@ -405,6 +437,35 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._canonical_cache[view_key] = view
return view
@property
def rehydrated(self) -> bool:
"""True while this entry's entities came from a snapshot rather than
a live poll (issue #295)."""
return self._rehydrate_resources is not None
@property
def discovery_resources(self) -> dict[str, dict]:
"""What entity._is_included should judge an entity's existence
against: the rehydration snapshot on an offline load, the live cache
otherwise.
Deliberately separate from `last_resources`, which stays empty until
the device answers -- that emptiness is what keeps a rehydrated
entity `unavailable` instead of rendering a snapshot's stale value.
Only read while platforms are being forwarded; nothing consults it
once the entities exist.
"""
if self._rehydrate_resources is None:
return self.last_resources
return self._rehydrate_resources
def discovery_canonical(self, subdevice: Subdevice) -> dict[str, dict]:
"""`discovery_resources` in `subdevice`'s canonical view -- the
exists_fn counterpart to canonical_resources."""
if self._rehydrate_resources is None:
return self.canonical_resources(subdevice)
return canonical_view(subdevice, self._rehydrate_resources, self.subdevices)
# ------------------------------------------------------------------
# Learned modes (issue #327)
# ------------------------------------------------------------------
@@ -490,15 +551,29 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# Cloud "Download" programs (issue #342) -- see cloudcourse.py
# ------------------------------------------------------------------
@property
def cloud_courses_enabled(self) -> bool:
"""Whether downloaded programs are offered as cycles and nagged
about via the "not set up yet" Repair (issue #364).
Deliberately does NOT gate `_observe_cloud_courses` below -- see
CONF_CLOUD_COURSES_ENABLED's own comment for why passive recording
keeps running (cheap, and what guided/manual setup depend on)
while only the Repair and the entity offering stop.
"""
return bool(
self._entry.options.get(CONF_CLOUD_COURSES_ENABLED, DEFAULT_CLOUD_COURSES_ENABLED)
)
def _observe_cloud_courses(self, rep: dict) -> None:
"""Learn a downloaded program's replay payload from one applied
/course/vs/0 rep. Runs on whichever thread applied the update, so
both the persist and the Repairs refresh go through hass.add_job.
Unlike learned modes this has no opt-out option: it records only
Always records, regardless of cloud_courses_enabled: it records only
what the appliance itself reports about programs it itself
advertises, and nothing is offered in the UI until the user names it.
"""
advertises, and nothing is offered in the UI until both the option
is on and the user has named it."""
if not self._cloud.observe(rep):
return
self.hass.add_job(self._persist_cloud_courses)
@@ -506,9 +581,33 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
@callback
def _persist_cloud_courses(self) -> None:
cloud_persist(self.hass, self._entry, self._cloud.snapshot())
# A newly learned program changes what entity_resources() hands out.
self._on_cloud_courses_changed()
@callback
def _on_cloud_courses_changed(self) -> None:
"""Refresh everything that depends on the cloud-course store or the
cloud_courses_enabled option: the canonical-view cache (a newly
learned/named program, or the option flipping, changes what
entity_resources() hands out), the live entities, and the Repair.
Also the __init__.py options-update listener's one hook (issue
#364): toggling cloud_courses_enabled changes _cloud_view()'s
answer without touching last_resources, so a canonical view cached
from before the toggle would otherwise keep answering with it.
_push_cache_snapshot is what actually gets a change here in front
of a user, not just correct on the next read: select.py's
current_option comes from coordinator.data, which only moves on
async_set_updated_data, and nothing prompts Home Assistant to
re-read a live `options` property (cycle's callable options included)
without the state-changed signal that call sends. Without it, both
this and a name applied through apply_cloud_courses -- which has
called this same method since before this option existed -- would
sit stale until whatever poll or observe happened to run next.
"""
self._canonical_cache.clear()
self._refresh_cloud_course_issue()
self._push_cache_snapshot()
@property
def cloud_courses(self) -> CloudCourses:
@@ -588,9 +687,33 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
nothing they do in Home Assistant could clear it. Running one
downloaded program is what turns the nudge on, and naming them is
what turns it off.
Also gated on cloud_courses_enabled (issue #364): a device can seed a
slot's payload before the owner has ever meant to use the feature --
reporters observed one auto-populate from a SmartThings-provided
example -- so "at least one payload seen" alone isn't proof of
intent to use it. Checked here rather than skipping
_observe_cloud_courses' recording, so turning the option back on
re-evaluates against everything already learned instead of only
what arrives afterward.
Also a no-op with an empty cloud_course_rep (issue #364): this now
runs from __init__.py's options-update listener on every entry
save, including an unrelated one (CONF_BYPASS_REMOTE_CONTROL, say)
made before this device's first poll or while it's rehydrated
offline. /course/vs/0 unpolled reads as no advertised slots, which
would otherwise delete a Repair a real poll had every reason to
raise, on evidence that only means "haven't asked the device yet."
Leaves whatever issue state already exists untouched rather than
guess either way; the next real poll re-evaluates for real.
"""
issue_id = f"cloud_courses_{self._entry.entry_id}"
if not self.cloud_courses_enabled:
ir.async_delete_issue(self.hass, DOMAIN, issue_id)
return
rep = self.cloud_course_rep()
if not rep:
return
record = self._cloud.snapshot()
courses = cycle_options(self.canonical_resources(MAIN))
pending = cloudcourse.undiscovered(rep, record, courses)
@@ -999,7 +1122,148 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self._entry, data={**self._entry.data, **identity}
)
def _run_discovery(self, resources: dict[str, dict]) -> None:
# ------------------------------------------------------------------
# Discovery snapshot (issue #295)
# ------------------------------------------------------------------
def _bound_keys(self) -> frozenset[tuple[str, str]]:
"""This entity set's identity: one (subdevice, state key) pair per
bound entity. `_key` is the unique_id suffix, so two discoveries that
agree here would register byte-identical entities."""
return frozenset((b.subdevice.key, _key(b)) for b in self.bound)
async def _async_save_snapshot(
self, resources: dict[str, dict], candidates: list[Subdevice]
) -> None:
"""Record what this first cycle handed `_run_discovery`, so the next
restart can replay it while the appliance is unreachable.
`candidates` is the pre-narrowing subdevice list (issue #177):
`discover_partitioned` takes candidates and returns the live ones, so
replaying against the narrowed list would rediscover nothing for a
composite appliance's siblings.
Written now rather than through `async_delay_save`, because a pending
delayed write outlives whatever queued it: it lands after
`async_remove_entry` has deleted the file and recreates it orphaned,
and a reload scheduled by `_reconcile_rehydrated` would read the
pre-reload snapshot back off disk. This runs once per entry load, so
the immediate write costs nothing worth deferring.
"""
ident = self._identity
try:
await self._snapshot_store.async_save(
{
"resources": dict(resources),
"subdevice_candidates": [asdict(su) for su in candidates],
"identity": asdict(ident) if ident is not None else None,
}
)
except Exception as e:
# Never fail a poll over the snapshot -- a board reporting
# something the JSON encoder rejects would otherwise break
# polling outright. Worst case this entry can't load offline,
# which is where it was before any of this existed.
self._log.warning("could not write discovery snapshot: %s", e)
async def async_rehydrate(self) -> bool:
"""Register the last known entity set without reaching the device.
Replays the stored snapshot through `_run_discovery`, which is what
makes this faithful: same code path, same registry resolution, so an
offline load produces the entity set the device last actually
reported rather than a guess reconstructed from a parallel format.
Returns False when there's nothing stored (an entry that has never
polled successfully) or the replay produced nothing usable -- the
caller raises ConfigEntryNotReady in that case, exactly as before.
"""
try:
stored = await self._snapshot_store.async_load()
except Exception as e: # corrupt or unreadable store
self._log.warning("could not read discovery snapshot: %s", e)
return False
if not stored or not stored.get("resources"):
return False
resources = stored["resources"]
try:
ident = stored.get("identity")
if ident is not None:
self._identity = DeviceIdentity(
manufacturer=ident.get("manufacturer") or "",
model=ident.get("model") or "",
name=ident.get("name") or "",
serial=ident.get("serial"),
device_types=tuple(ident.get("device_types") or ()),
raw=ident.get("raw") or {},
)
# JSON gives lists back where Subdevice declares tuples, and it's
# a frozen (hashable) dataclass used as a dict key in flatten().
self.subdevices = [
Subdevice(
kind=su["kind"],
key=su["key"],
seed_path=tuple(su.get("seed_path") or ()),
flat_hrefs=tuple(su.get("flat_hrefs") or ()),
)
for su in stored.get("subdevice_candidates") or ()
]
self._run_discovery(resources, from_snapshot=True)
except Exception as e:
# A snapshot written by an older release can outlive both the
# stored shape and the registry it was discovered against. This
# has to catch the rebuild as well as the replay: an exception
# escaping here reaches async_setup_entry, which only handles
# ConfigEntryNotReady, so the entry would land in SETUP_ERROR --
# never retried, and with its session left open.
self._log.warning("discovery snapshot could not be replayed: %s", e, exc_info=True)
self.bound = []
self.subdevices = []
return False
# _run_discovery sets _discovered; put it back. The snapshot only
# supplied an entity set to register -- the first live poll must
# still enumerate subdevices and rediscover for real.
self._discovered = False
self._rehydrate_resources = resources
self._rehydrated_keys = self._bound_keys()
if not self.bound:
return False
self._log.info(
"device unreachable; restored %d entities from the last discovery "
"snapshot and will keep retrying every %ds",
len(self.bound),
SUMMARY_INTERVAL_S,
)
return True
@callback
def _reconcile_rehydrated(self) -> None:
"""Reload the entry when a live discovery disagrees with the snapshot
this load registered from.
Platforms enumerate `bound` exactly once, at forward time, so a
firmware update, a newly-answering sibling subdevice or a different
appliance at the same IP can't be picked up in place -- the entry has
to come back up against the live set.
"""
if self._rehydrated_keys is None:
return
stale = self._rehydrated_keys
self._rehydrated_keys = None
live = self._bound_keys()
if live == stale:
return
self._log.info(
"live discovery differs from the snapshot this entry loaded from "
"(%d entities gone, %d new); reloading",
len(stale - live),
len(live - stale),
)
self.hass.config_entries.async_schedule_reload(self._entry.entry_id)
def _run_discovery(self, resources: dict[str, dict], from_snapshot: bool = False) -> None:
# Diagnostics only -- names the firmware generation (e.g. '7.0 Air
# conditioner' is Tizen Lite); doesn't route, since every device
# that reports it is already typed by modelNum.
@@ -1115,7 +1379,13 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
model=model,
)
self._persist_identity(serial, model, mfr, device_type_name)
self._update_coverage_gap_issue(device_type_name is None, unbound, name)
if not from_snapshot:
# A coverage gap is a claim about what the device reports, so only
# a live poll gets to make it. Replaying a snapshot would restate
# last run's conclusion while pointing the user at a diagnostics
# download that is empty until the appliance answers, and any
# drift in the device name between the two would churn the issue.
self._update_coverage_gap_issue(device_type_name is None, unbound, name)
self._hot_hrefs = sorted(hot)
self._warm_hrefs = sorted(warm)
@@ -1422,7 +1692,13 @@ class LocalThingsCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# StateCache has no eviction, so the only way to keep them out
# is to not put them in. Safe to reorder: _run_discovery reads
# the passed dict, never the cache.
candidates = list(self.subdevices)
self._run_discovery(resources)
# Banked before the reconcile below, so a reload it schedules
# comes up against this discovery rather than the one that is
# being replaced.
await self._async_save_snapshot(resources, candidates)
self._reconcile_rehydrated()
resources = self._live_subdevice_resources(resources)
sweep_mismatch = False
if self._observe.mode == MODE_OBSERVE:
+6 -2
View File
@@ -35,12 +35,16 @@ def _is_included(bound: BoundEntity, coordinator: LocalThingsCoordinator) -> boo
`bound`'s own subdevice's canonical view instead of the raw snapshot,
same rule as everywhere else a whole-resources-dict scan happens --
this is a free function, so it can't use self._resources.
Reads `discovery_resources`, not `last_resources`: on an offline load
(issue #295) the live cache is still empty, and judging existence
against it would filter every rehydrated entity away.
"""
rep = coordinator.last_resources.get(bound.href)
rep = coordinator.discovery_resources.get(bound.href)
if rep is None:
return False
if bound.desc.exists_fn is not None:
return bound.desc.exists_fn(rep, coordinator.canonical_resources(bound.subdevice))
return bound.desc.exists_fn(rep, coordinator.discovery_canonical(bound.subdevice))
if bound.desc.field:
if not rep or is_stub_rep(rep):
return True
+2 -2
View File
@@ -11,7 +11,7 @@
"requirements": [
"cbor2>=5.4.6",
"pyOpenSSL>=23.0",
"smartthings-local>=0.1.6"
"smartthings-local>=0.1.8"
],
"version": "0.22.0"
"version": "0.23.0"
}
@@ -372,6 +372,33 @@ def _has_option_token(prefix):
)
def _has_option_token_any_board(prefix):
"""Token-presence test with no board-generation gate.
`_has_option_token` above requires `is_legacy_board`, which was right for
the settings it guards but wrong for a token whose presence is itself the
only signal that needs checking -- issue #367 found `OutdoorTemp_` live
on 14 of 23 recorded fixtures despite none of them being legacy boards.
Same shape as `beep`'s Volume_ test and `_has_display_light_option`,
which already treat token presence as sufficient across generations."""
return lambda rep, resources: _option_token(rep, prefix) is not None
def _reports_celsius(resources):
"""Whether this subdevice's own /temperatures/vs/0 declares Celsius (or
says nothing at all, `_temps_vs_unit`'s default).
Guards `OutdoorTemp_`'s -55 offset below: issue #367's field validation
(48h against weather.forecast_home, r=0.92) ran on Celsius-locale boards
only -- 13 of the 14 non-legacy fixtures that carry the token declare
Celsius, and the offset's own calibration comment is a Celsius reading
too. The one Fahrenheit-locale exception on record
(`airconditioner_lnx_rac_heatpump`) has nothing to confirm the same
additive constant, or degrees C rather than F, still hold -- so this
stays off boards that declare anything else, rather than guess."""
return _temps_vs_unit(resources.get(HREF_TEMPS_VS) or {}) == "°C"
def _option_token_on(prefix):
return lambda rep: _option_token(rep, prefix) == "On"
@@ -782,13 +809,34 @@ CLIMATE = Capability(
),
# Outdoor temperature, offset by 55 -- calibrated against an
# independent thermometer (token 75 while it read 20.3°C).
#
# exists_fn is token-presence-only (issue #367), not is_legacy_board:
# gating it there dropped the sensor on every non-legacy board that
# reports it -- 14 of 17 fixtures carrying the token in the issue's
# own survey. A 48h/289-sample field capture correlated it against
# weather.forecast_home at r=0.92, ruling out a firmware constant.
# `_reports_celsius` keeps that presence check from also claiming
# the -55 Celsius offset for board generations it was never
# validated on -- see its own docstring.
#
# enabled_default=False: multi-split installs (multiple indoor heads
# on one outdoor condenser) report the same token on every head, so
# a fix here creates one identical sensor per head rather than one
# per physical unit (same duplication as the shared energy/power
# counters in issue #329). Left disabled so a user with several
# heads can enable just one instead of getting N duplicates active
# by default.
SensorDesc(
key="outdoor_temperature",
rep_fn=_option_token_num("OutdoorTemp", offset=55),
exists_fn=_has_option_token("OutdoorTemp"),
exists_fn=lambda rep, resources: (
_has_option_token_any_board("OutdoorTemp")(rep, resources)
and _reports_celsius(resources)
),
device_class="temperature",
state_class="measurement",
unit="°C",
enabled_default=False,
icon="mdi:home-thermometer-outline",
),
# Filter time in tenths of an hour, counting UP since last filter
@@ -27,7 +27,17 @@ DRYER_SETTINGS = Capability(
entities=(
SensorDesc(key="dry_level", field="x.com.samsung.da.dryLevel", icon="mdi:water-percent"),
SensorDesc(key="dry_time", field="x.com.samsung.da.dryTime", icon="mdi:timer"),
SensorDesc(key="dryer_type", field="x.com.samsung.da.dryerType", icon="mdi:tumble-dryer"),
SensorDesc(
key="dryer_type",
field="x.com.samsung.da.dryerType",
icon="mdi:tumble-dryer",
device_class="enum",
# Only 'Electricity' confirmed across shipped fixtures (#366); an
# unrecognized value still passes through raw via sensor.py's
# options property rather than breaking the entity.
options=("electricity",),
value_fn=lambda v: v.lower() if isinstance(v, str) else v,
),
SwitchDesc(
key="wrinkle_prevent",
field="x.com.samsung.da.wrinklePrevent",
@@ -376,7 +376,25 @@
"53": "AI sušení+",
"4e": "Automatické sušení",
"26": "Provětrání",
"2a": "Hygienické sušení+"
"2a": "Hygienické sušení+",
"02": "AI sušení",
"03": "Super Speed",
"05": "Ložní prádlo",
"07": "Jemné prádlo",
"09": "Košile",
"0b": "Péče o prošívané bundy",
"0c": "Outdoor impregnace",
"0e": "Ručníky",
"0f": "Vlna",
"11": "Studený vzduch",
"28": "Vnitřní dezinfekce horkým vzduchem",
"37": "Halenky",
"38": "Žehlení",
"39": "Odvlhčování místnosti",
"3a": "Hygienické sušení",
"3b": "Ložní prádlo/Odstranění prachu",
"3c": "Sportovní oblečení",
"3d": "Džíny"
}
},
"favorite_capacity": {
@@ -647,11 +665,31 @@
"name": "Cyklus",
"state": {
"01": "Normální",
"02": "Extra intenzivní",
"03": "Super Eco",
"04": "Rychlé praní",
"06": "XXL prádlo",
"05": "Vlna/Spodní prádlo",
"06": "Ložní prádlo",
"07": "Outdoor",
"08": "Máchání+odstřeďování",
"09": "Čištění bubnu",
"0a": "Ručníky",
"0b": "Praní varem",
"0c": "Dětské potřeby",
"0d": "Pouze odstřeďování",
"0e": "Zataženo",
"0f": "Čisté praní",
"10": "Sušicí odstřeďování",
"11": "Letní ložní prádlo",
"12": "Bavlna",
"13": "Černá bavlna",
"14": "Jemné spodní prádlo",
"15": "Sportovní oblečení",
"16": "Halenky",
"17": "Stažený program",
"18": "Soft Bubble",
"19": "AI praní",
"1a": "Košile",
"1b": "Bavlna",
"1c": "Eco 40-60",
"1d": "Super rychlé",
@@ -676,6 +714,7 @@
"32": "Košile",
"33": "Ručníky",
"34": "Smíšené",
"35": "Eko bavlna",
"36": "Praní+sušení",
"37": "Sušení vzduchem",
"38": "Sušení bavlny",
@@ -717,8 +756,7 @@
"8f": "Intenzivní studená",
"96": "Méně mikrovláken",
"a0": "15min rychlé praní",
"b0": "Smíšená náplň",
"35": "Eko bavlna"
"b0": "Smíšená náplň"
}
},
"washer_dry_level": {
@@ -958,7 +996,10 @@
"name": "Doba sušení"
},
"dryer_type": {
"name": "Typ sušičky"
"name": "Typ sušičky",
"state": {
"electricity": "Elektřina"
}
},
"dust": {
"name": "Prach"
@@ -1098,6 +1139,7 @@
"steaming": "Napařování",
"airwashing": "Osvěžení vzduchem",
"drying": "Sušení",
"dryingwithdooropen": "Větrání",
"cooling": "Chlazení",
"predrain": "Vypouštění",
"prewash": "Předpírka"
@@ -1480,11 +1522,12 @@
},
"settings": {
"title": "Nastavení zápisu pro dálkové ovládání",
"description": "Některá zařízení přijímají určité zápisy (např. výchozí dávkování pracího prostředku/aviváže v pračce) i když hlásí vypnuté dálkové ovládání. LocalThings ve výchozím nastavení blokuje každý zápis srozumitelnou chybou, kdykoli zařízení hlásí vypnuté dálkové ovládání, místo aby jej zařízení tiše odmítlo. Povolte toto pouze tehdy, pokud jste ověřili, že zápisy na tomto zařízení skutečně fungují i s vypnutým dálkovým ovládáním – jinak tuto srozumitelnou chybu vyměníte za tiché selhání.\n\nNěkterá zařízení hlásí režim, který nikdy neuvedou mezi podporovanými – například klimatizace běžící v režimu Quiet, která nabízí pouze Off/Sleep/Speed. LocalThings si každý takový režim zapamatuje a dál jej nabízí, takže zůstane volitelný, jakmile v něm zařízení alespoň jednou bylo. Vypnutím této volby se budou nabízet pouze režimy, které zařízení samo uvádí; k vymazání již zapamatovaných použijte „Zapomenout zapamatované režimy“ v předchozí nabídce.",
"description": "Některá zařízení přijímají určité zápisy (např. výchozí dávkování pracího prostředku/aviváže v pračce) i když hlásí vypnuté dálkové ovládání. LocalThings ve výchozím nastavení blokuje každý zápis srozumitelnou chybou, kdykoli zařízení hlásí vypnuté dálkové ovládání, místo aby jej zařízení tiše odmítlo. Povolte toto pouze tehdy, pokud jste ověřili, že zápisy na tomto zařízení skutečně fungují i s vypnutým dálkovým ovládáním – jinak tuto srozumitelnou chybu vyměníte za tiché selhání.\n\nNěkterá zařízení hlásí režim, který nikdy neuvedou mezi podporovanými – například klimatizace běžící v režimu Quiet, která nabízí pouze Off/Sleep/Speed. LocalThings si každý takový režim zapamatuje a dál jej nabízí, takže zůstane volitelný, jakmile v něm zařízení alespoň jednou bylo. Vypnutím této volby se budou nabízet pouze režimy, které zařízení samo uvádí; k vymazání již zapamatovaných použijte „Zapomenout zapamatované režimy“ v předchozí nabídce. Zařízení pro praní se staženými cykly z cloudu zde dostane připomenutí, jakmile nahlásí cyklus, který Home Assistant zatím nemůže nabídnout -- některá zařízení jej nahlásí, i když jste obrazovku „Stažené cykly“ v aplikaci SmartThings sami nikdy neotevřeli. Vypněte tuto možnost, chcete-li připomenutí zastavit a skrýt už pojmenované stažené cykly ze seznamu cyklů; nic z toho, co je již nastaveno, se neztratí, a po opětovném zapnutí se to hned znovu objeví.",
"data": {
"bypass_remote_control_lock": "Povolit zápis i když je dálkové ovládání hlášeno jako vypnuté",
"finish_time_hysteresis_minutes": "Odhadovaný konec -- minimální změna (minuty)",
"learn_device_modes": "Pamatovat si režimy, které zařízení hlásí, ale neuvádí jako podporované"
"learn_device_modes": "Pamatovat si režimy, které zařízení hlásí, ale neuvádí jako podporované",
"cloud_courses_enabled": "Nabízet stažené cykly"
}
},
"forget_learned_modes": {
@@ -1515,14 +1558,14 @@
},
"cloud_manual": {
"title": "Stažené cykly",
"description": "Toto zařízení hlásí {total} stažených cyklů; {found} jich bylo dosud zjištěno.\n\nNastavení staženého cyklu jsou viditelná pouze tehdy, když je daný cyklus právě načtený, a zařízení nikdy nehlásí jejich názvy. Chcete-li doplnit chybějící ({pending}): na zařízení vyberte Stažený program, poté postupně projděte jednotlivé stažené programy, u každého se na pár sekund zastavte, a vraťte se sem.\n\nKaždému z nich zadejte název, který chcete vidět v Home Assistant. Necháte-li název prázdný, daný cyklus zůstane mimo seznam cyklů. Názvy musí být jedinečné.\n\nStažený program je program na tomto zařízení, který spouští stažený cyklus. Rozpoznává se automaticky, ale před použitím jej zde potvrďte: výběrem staženého cyklu se do zařízení zapíše tento kód programu.",
"description": "Toto zařízení hlásí {total} stažených cyklů; {found} jich bylo dosud zjištěno.\n\nNastavení staženého cyklu jsou viditelná pouze tehdy, když je daný cyklus právě načtený, a zařízení nikdy nehlásí jejich názvy. Chcete-li doplnit chybějící ({pending}): v aplikaci SmartThings (ne na samotném zařízení) otevřete toto zařízení a klepněte na „Stažené cykly“ -- samostatný řádek odlišný od „Cyklus“, níže na obrazovce, a ten, na kterém zde záleží. Poté postupně projděte jednotlivé stažené programy, u každého se na pár sekund zastavte, aby jej LocalThings zaznamenal, a vraťte se sem.\n\nKaždému z nich zadejte název, který chcete vidět v Home Assistant. Necháte-li název prázdný, daný cyklus zůstane mimo seznam cyklů. Názvy musí být jedinečné.\n\nStažený program je program na tomto zařízení, který spouští stažený cyklus. Rozpoznává se automaticky, ale před použitím jej zde potvrďte: výběrem staženého cyklu se do zařízení zapíše tento kód programu.",
"data": {
"download_course": "Kód programu „Stažený program“"
}
},
"cloud_courses": {
"title": "Stažené cykly",
"description": "Průvodce nastavením vás provede zařízením: na zařízení vyberte stažený cyklus a jakmile je nalezen, pojmenujte ho – jeden po druhém.\n\nÚprava názvů zobrazí vše dosud nalezené najednou – použijte ji později k přejmenování nebo opravě.",
"description": "Průvodce nastavením vás provede: v aplikaci SmartThings (ne na samotném zařízení) otevřete toto zařízení a klepněte na „Stažené cykly“ -- samostatný řádek odlišný od „Cyklus“, níže na obrazovce, a ten, na kterém zde záleží. Vyberte tam cyklus, vraťte se sem a jakmile je nalezen, pojmenujte ho – jeden po druhém.\n\nÚprava názvů zobrazí vše dosud nalezené najednou – použijte ji později k přejmenování nebo opravě. Pojmenování funguje v obou případech, ale pojmenovaný cyklus se nabízí jako vybratelný cyklus jen tehdy, když je v nastavení zařízení zapnuto „Nabízet stažené cykly“.",
"menu_options": {
"cloud_guided": "Průvodce nastavením",
"cloud_manual": "Upravit názvy"
@@ -1533,7 +1576,7 @@
},
"cloud_name": {
"title": "Pojmenujte tento cyklus",
"description": "Zařízení přepnulo na stažený cyklus (pozice {slot}) a hlásí zbývající čas {remaining}.\n\nZadejte název, který chcete vidět v Home Assistant, a poté na zařízení vyberte další cyklus. Necháte-li pole prázdné, tento cyklus zůstane mimo seznam.\n\nDosud pojmenováno ({named} z {total}): {named_list}\n\nNázvy musí být jedinečné. Toto okno můžete kdykoli zavřít – názvy se ukládají průběžně.",
"description": "Pozice „Stažené cykly“ zařízení nyní obsahuje nový cyklus (pozice {slot}) a hlásí zbývající čas {remaining}.\n\nZadejte název, který chcete vidět v Home Assistant, a poté na obrazovce „Stažené cykly“ v aplikaci SmartThings vyberte další cyklus, který ještě není označen jako „Stažený“. Necháte-li pole prázdné, tento cyklus zůstane mimo seznam.\n\nDosud pojmenováno ({named} z {total}): {named_list}\n\nNázvy musí být jedinečné. Toto okno můžete kdykoli zavřít – názvy se ukládají průběžně.",
"data": {
"name": "Název",
"download_course": "Kód programu „Stažený program“"
@@ -1541,7 +1584,7 @@
},
"cloud_timeout": {
"title": "Nebyl vybrán žádný cyklus",
"description": "Na zařízení nebylo nic vybráno. Ujistěte se, že je nastaveno na Stažený program, a poté postupně procházejte stažené programy.\n\nDosud pojmenováno ({named} z {total}): {named_list}\n\nVše dosud pojmenované je již uloženo.",
"description": "Na zařízení se nic nezměnilo. Ujistěte se, že jste v aplikaci SmartThings klepli na „Stažené cykly“ -- samostatný řádek odlišný od „Cyklus“, níže na obrazovce -- a vybrali cyklus, který ještě nebyl označen jako „Stažený“, a poté klepli na Hotovo.\n\nDosud pojmenováno ({named} z {total}): {named_list}\n\nVše dosud pojmenované je již uloženo.",
"menu_options": {
"cloud_guided": "Počkat znovu",
"cloud_finish": "Dokončit"
@@ -1558,7 +1601,7 @@
"not_loaded": "Toto zařízení ještě není připojeno. Zkuste to znovu, až se načte."
},
"progress": {
"cloud_wait": "Nyní na zařízení vyberte stažený cyklus.\n\nDosud pojmenováno ({named} z {total}): {named_list}\n\nToto okno můžete kdykoli zavřít – názvy se ukládají průběžně."
"cloud_wait": "V aplikaci SmartThings (ne na samotném zařízení) otevřete toto zařízení a klepněte na „Stažené cykly“ -- samostatný řádek odlišný od „Cyklus“, níže na obrazovce, který uvádí pouze cykly rozpoznatelné tímto způsobem. Vyberte cyklus, který ještě není označen jako „Stažený“, a klepněte na Hotovo.\n\nZařízení nemusí být poblíž ani spuštěné -- stačí, když je zapnuté a připojené.\n\nDosud pojmenováno ({named} z {total}): {named_list}\n\nToto okno můžete kdykoli zavřít – názvy se ukládají průběžně."
}
},
"issues": {
@@ -1568,7 +1611,7 @@
},
"cloud_courses_undiscovered": {
"title": "Stažené cykly nejsou nastaveny pro {device_name}",
"description": "{device_name} má {pending} z {total} stažených cyklů, které Home Assistant zatím nemůže nabídnout. Stažený cyklus lze použít až poté, co byl na zařízení zjištěn načtený a co jste mu dali název.\n\nChcete-li je nastavit, přejděte do Nastavení > Zařízení a služby > LocalThings > {device_name} > Konfigurovat > Stažené cykly a postupujte podle pokynů."
"description": "{device_name} má {pending} z {total} stažených cyklů, které Home Assistant zatím nemůže nabídnout. Stažený cyklus lze použít až poté, co byl na zařízení zjištěn načtený -- na obrazovce „Stažené cykly“ v aplikaci SmartThings, ne na obrazovce „Cyklus“ -- a co jste mu dali název.\n\nChcete-li je nastavit, přejděte do Nastavení > Zařízení a služby > LocalThings > {device_name} > Konfigurovat > Stažené cykly a postupujte podle pokynů.\n\nStažené cykly nepoužíváte? Přejděte místo toho do Konfigurovat > Nastavení zařízení a vypněte „Nabízet stažené cykly“, čímž toto připomenutí natrvalo umlčíte."
}
},
"exceptions": {
@@ -376,7 +376,25 @@
"4e": "Selbsttrocknung",
"17": "Super Speed",
"26": "Frischluft",
"2a": "Hygienepflege+"
"2a": "Hygienepflege+",
"02": "KI-Trocknen",
"03": "Super Speed",
"05": "Bettwäsche",
"07": "Feinwäsche",
"09": "Hemden",
"0b": "Paddingpflege",
"0c": "Outdoor-Imprägnierpflege",
"0e": "Handtücher",
"0f": "Wolle",
"11": "Kaltluft",
"28": "Innenraum-Heißluftdesinfektion",
"37": "Blusen",
"38": "Bügeltrocken",
"39": "Raumentfeuchtung",
"3a": "Hygienepflege",
"3b": "Bettwäsche/Entstauben",
"3c": "Sportkleidung",
"3d": "Jeansstoff"
}
},
"favorite_capacity": {
@@ -610,10 +628,31 @@
"name": "Programm",
"state": {
"01": "Normal",
"02": "Super Intensiv",
"03": "Super Eco",
"04": "Schnellwäsche",
"06": "XXL-Wäsche",
"05": "Wolle/Dessous",
"06": "Bettwäsche",
"07": "Outdoor",
"08": "Spülen + Schleudern",
"09": "Trommelreinigung",
"0a": "Handtücher",
"0b": "Kochwäsche",
"0c": "Babypflege",
"0d": "Nur Schleudern",
"0e": "Bewölkter Tag",
"0f": "Reinwäsche",
"10": "Trockenschleudern",
"11": "Sommer-Bettwäsche",
"12": "Baumwolle",
"13": "Schwarze Baumwolle",
"14": "Feine Unterwäsche",
"15": "Sportkleidung",
"16": "Blusen",
"17": "Heruntergeladen",
"18": "Soft Bubble",
"19": "KI-Wäsche",
"1a": "Hemden",
"1b": "Baumwolle",
"1c": "Eco 40-60",
"1d": "Super Speed",
@@ -638,6 +677,7 @@
"32": "Hemden",
"33": "Handtücher",
"34": "Mischwäsche",
"35": "Eco Baumwolle",
"36": "Waschen + Trocknen",
"37": "Air Wash",
"38": "Trocknen Baumwolle",
@@ -652,17 +692,6 @@
"60": "Selbstreinigung+",
"65": "Buntwäsche",
"66": "Jeansstoff",
"7c": "Weißwäsche",
"7d": "Bettwäsche/Wasserdicht",
"7e": "Selbstreinigung",
"7f": "Wolle/Feinwäsche",
"86": "Tiefenreinigung",
"87": "Download",
"8f": "Kaltwäsche Intensiv",
"96": "Weniger Mikrofasern",
"a0": "Schnelle Wäsche 15'",
"b0": "Gemischte Beladung",
"17": "Heruntergeladen",
"69": "KI-Wäsche",
"6a": "Wolle",
"6b": "Jeansstoff",
@@ -680,8 +709,17 @@
"77": "Baumwolle",
"78": "Spülen + Schleudern",
"79": "Nur Schleudern",
"7c": "Weißwäsche",
"7d": "Bettwäsche/Wasserdicht",
"7e": "Selbstreinigung",
"7f": "Wolle/Feinwäsche",
"86": "Tiefenreinigung",
"87": "Download",
"88": "Tierpflege",
"35": "Eco Baumwolle"
"8f": "Kaltwäsche Intensiv",
"96": "Weniger Mikrofasern",
"a0": "Schnelle Wäsche 15'",
"b0": "Gemischte Beladung"
}
},
"washer_dry_level": {
@@ -952,7 +990,10 @@
"name": "Trockenzeit"
},
"dryer_type": {
"name": "Trocknertyp"
"name": "Trocknertyp",
"state": {
"electricity": "Strom"
}
},
"dust": {
"name": "Staub"
@@ -1092,6 +1133,7 @@
"steaming": "Dämpfen",
"airwashing": "Luftreinigung",
"drying": "Trocknen",
"dryingwithdooropen": "Lüften",
"cooling": "Abkühlen",
"predrain": "Abpumpen",
"prewash": "Vorwäsche"
@@ -1480,11 +1522,12 @@
},
"settings": {
"title": "Geräteeinstellungen",
"description": "Manche Geräte akzeptieren bestimmte Schreibvorgänge (z. B. die Standard-Dosierung von Waschmittel/Weichspüler bei einer Waschmaschine) auch dann, wenn sie melden, dass die Fernsteuerung ausgeschaltet ist. Standardmäßig blockiert LocalThings jeden Schreibvorgang mit einer klaren Fehlermeldung, sobald ein Gerät meldet, dass die Fernsteuerung ausgeschaltet ist, statt das Gerät den Vorgang stillschweigend ablehnen zu lassen. Aktivieren Sie dies nur, wenn Sie bestätigt haben, dass Schreibvorgänge bei diesem Gerät auch mit ausgeschalteter Fernsteuerung tatsächlich funktionieren -- andernfalls tauschen Sie die klare Fehlermeldung gegen ein stilles Fehlschlagen ein.\n\nDie voraussichtliche Fertigstellungszeit wird bei jeder Abfrage aus der verbleibenden Zeitschätzung des Geräts neu berechnet, die zwischen den Aktualisierungen um ein bis zwei Minuten schwanken oder korrigiert werden kann. Erhöhen Sie den unten stehenden Mindeständerungswert, um den Sensor bis zu einer Änderung der Schätzung um mindestens diese Anzahl an Minuten auf seinem zuletzt gemeldeten Wert zu halten und so das Rauschen in Verlauf/Logbuch zu verringern. Setzen Sie ihn auf 0, um jede berechnete Änderung zu melden.\n\nManche Modelle melden einen Modus, den sie nie als unterstützt angeben -- etwa eine Klimaanlage im Modus „Leise“, die nur Aus/Schlaf/Schnell anbietet. LocalThings merkt sich jeden solchen gemeldeten Modus und bietet ihn weiterhin an, sodass er auswählbar bleibt, sobald das Gerät mindestens einmal darin war. Schalten Sie dies aus, um nur das anzubieten, was das Gerät selbst angibt; verwenden Sie „Gemerkte Modi vergessen“ auf dem vorherigen Bildschirm, um bereits Gemerktes zu löschen.",
"description": "Manche Geräte akzeptieren bestimmte Schreibvorgänge (z. B. die Standard-Dosierung von Waschmittel/Weichspüler bei einer Waschmaschine) auch dann, wenn sie melden, dass die Fernsteuerung ausgeschaltet ist. Standardmäßig blockiert LocalThings jeden Schreibvorgang mit einer klaren Fehlermeldung, sobald ein Gerät meldet, dass die Fernsteuerung ausgeschaltet ist, statt das Gerät den Vorgang stillschweigend ablehnen zu lassen. Aktivieren Sie dies nur, wenn Sie bestätigt haben, dass Schreibvorgänge bei diesem Gerät auch mit ausgeschalteter Fernsteuerung tatsächlich funktionieren -- andernfalls tauschen Sie die klare Fehlermeldung gegen ein stilles Fehlschlagen ein.\n\nDie voraussichtliche Fertigstellungszeit wird bei jeder Abfrage aus der verbleibenden Zeitschätzung des Geräts neu berechnet, die zwischen den Aktualisierungen um ein bis zwei Minuten schwanken oder korrigiert werden kann. Erhöhen Sie den unten stehenden Mindeständerungswert, um den Sensor bis zu einer Änderung der Schätzung um mindestens diese Anzahl an Minuten auf seinem zuletzt gemeldeten Wert zu halten und so das Rauschen in Verlauf/Logbuch zu verringern. Setzen Sie ihn auf 0, um jede berechnete Änderung zu melden.\n\nManche Modelle melden einen Modus, den sie nie als unterstützt angeben -- etwa eine Klimaanlage im Modus „Leise“, die nur Aus/Schlaf/Schnell anbietet. LocalThings merkt sich jeden solchen gemeldeten Modus und bietet ihn weiterhin an, sodass er auswählbar bleibt, sobald das Gerät mindestens einmal darin war. Schalten Sie dies aus, um nur das anzubieten, was das Gerät selbst angibt; verwenden Sie „Gemerkte Modi vergessen“ auf dem vorherigen Bildschirm, um bereits Gemerktes zu löschen. Ein Wäschegerät mit aus der Cloud heruntergeladenen Programmen erhält hier eine Erinnerung, sobald es eines gemeldet hat, das Home Assistant noch nicht anbieten kann -- manche Geräte melden eines, auch wenn Sie den Bildschirm \"Download-Programme\" in der SmartThings-App nie selbst geöffnet haben. Schalten Sie dies aus, um diese Erinnerung zu stoppen und bereits benannte heruntergeladene Programme aus der Programmliste auszublenden; bereits Eingerichtetes geht dabei nicht verloren, und beim erneuten Einschalten erscheint es sofort wieder.",
"data": {
"bypass_remote_control_lock": "Schreibvorgänge auch bei ausgeschalteter Fernsteuerung zulassen",
"finish_time_hysteresis_minutes": "Voraussichtliches Ende -- Mindeständerung (Minuten)",
"learn_device_modes": "Vom Gerät gemeldete, aber nicht angegebene Modi merken"
"learn_device_modes": "Vom Gerät gemeldete, aber nicht angegebene Modi merken",
"cloud_courses_enabled": "Heruntergeladene Programme anbieten"
}
},
"debug_write": {
@@ -1515,14 +1558,14 @@
},
"cloud_manual": {
"title": "Download-Programme",
"description": "Dieses Gerät meldet {total} heruntergeladene Programme; {found} davon wurden bisher erkannt.\n\nDie Einstellungen eines heruntergeladenen Programms sind nur sichtbar, während dieses Programm geladen ist, und das Gerät meldet nie deren Namen. Um die fehlenden ({pending}) hinzuzufügen: Wählen Sie am Gerät das Download-Programm aus, gehen Sie dann nacheinander jedes heruntergeladene Programm durch, halten Sie bei jedem ein paar Sekunden inne, und kommen Sie danach hierher zurück.\n\nGeben Sie jedem den Namen, den Sie in Home Assistant sehen möchten. Lassen Sie einen Namen leer, um das Programm aus der Programmliste auszuschließen. Namen müssen eindeutig sein.\n\nDas Download-Programm ist das Programm auf diesem Gerät, das ein heruntergeladenes Programm ausführt. Es wird automatisch erkannt, bestätigen Sie es aber hier vor der Verwendung: Die Auswahl eines heruntergeladenen Programms schreibt diesen Programmcode auf das Gerät.",
"description": "Dieses Gerät meldet {total} heruntergeladene Programme; {found} davon wurden bisher erkannt.\n\nDie Einstellungen eines heruntergeladenen Programms sind nur sichtbar, während dieses Programm geladen ist, und das Gerät meldet nie deren Namen. Um die fehlenden ({pending}) hinzuzufügen: Öffnen Sie in der SmartThings-App (nicht am Gerät selbst) dieses Gerät und tippen Sie auf \"Download-Programme\" -- eine eigene Zeile, getrennt von \"Programm\", weiter unten im Bildschirm, und die, auf die es hier ankommt. Gehen Sie dann nacheinander jedes heruntergeladene Programm durch, halten Sie bei jedem ein paar Sekunden inne, damit LocalThings es erkennen kann, und kommen Sie danach hierher zurück.\n\nGeben Sie jedem den Namen, den Sie in Home Assistant sehen möchten. Lassen Sie einen Namen leer, um das Programm aus der Programmliste auszuschließen. Namen müssen eindeutig sein.\n\nDas Download-Programm ist das Programm auf diesem Gerät, das ein heruntergeladenes Programm ausführt. Es wird automatisch erkannt, bestätigen Sie es aber hier vor der Verwendung: Die Auswahl eines heruntergeladenen Programms schreibt diesen Programmcode auf das Gerät.",
"data": {
"download_course": "Programmcode des Download-Programms"
}
},
"cloud_courses": {
"title": "Download-Programme",
"description": "Die geführte Einrichtung begleitet Sie am Gerät: Wählen Sie am Gerät ein heruntergeladenes Programm aus und benennen Sie es, sobald es gefunden wird – eines nach dem anderen.\n\nNamen bearbeiten zeigt alles bisher Gefundene auf einmal an – nutzen Sie es später, um etwas umzubenennen oder zu korrigieren.",
"description": "Die geführte Einrichtung begleitet Sie dabei: Öffnen Sie in der SmartThings-App (nicht am Gerät selbst) dieses Gerät und tippen Sie auf \"Download-Programme\" -- eine eigene Zeile, getrennt von \"Programm\", weiter unten im Bildschirm, und die, auf die es hier ankommt. Wählen Sie dort ein Programm aus, kommen Sie dann zurück und benennen Sie es, sobald es gefunden wird – eines nach dem anderen.\n\nNamen bearbeiten zeigt alles bisher Gefundene auf einmal an – nutzen Sie es später, um etwas umzubenennen oder zu korrigieren. Benennen funktioniert so oder so, aber ein benanntes Programm wird nur dann als auswählbares Programm angeboten, wenn \"Heruntergeladene Programme anbieten\" in den Geräteeinstellungen aktiviert ist.",
"menu_options": {
"cloud_guided": "Geführte Einrichtung",
"cloud_manual": "Namen bearbeiten"
@@ -1533,7 +1576,7 @@
},
"cloud_name": {
"title": "Dieses Programm benennen",
"description": "Das Gerät ist zu einem heruntergeladenen Programm gewechselt (Platz {slot}) und meldet eine verbleibende Zeit von {remaining}.\n\nGeben Sie den Namen ein, den Sie in Home Assistant sehen möchten, und wählen Sie dann am Gerät das nächste aus. Lassen Sie das Feld leer, um dieses Programm von der Liste auszuschließen.\n\nBisher benannt ({named} von {total}): {named_list}\n\nNamen müssen eindeutig sein. Sie können dieses Fenster jederzeit schließen – die Namen werden fortlaufend gespeichert.",
"description": "Der Platz \"Download-Programme\" des Geräts enthält jetzt ein neues Programm (Platz {slot}) und meldet eine verbleibende Zeit von {remaining}.\n\nGeben Sie den Namen ein, den Sie in Home Assistant sehen möchten, und wählen Sie dann im Bildschirm \"Download-Programme\" der SmartThings-App das nächste Programm aus, das noch nicht als \"Heruntergeladen\" markiert ist. Lassen Sie das Feld leer, um dieses Programm von der Liste auszuschließen.\n\nBisher benannt ({named} von {total}): {named_list}\n\nNamen müssen eindeutig sein. Sie können dieses Fenster jederzeit schließen – die Namen werden fortlaufend gespeichert.",
"data": {
"name": "Name",
"download_course": "Programmcode des Download-Programms"
@@ -1541,7 +1584,7 @@
},
"cloud_timeout": {
"title": "Kein Programm ausgewählt",
"description": "Am Gerät wurde nichts ausgewählt. Stellen Sie sicher, dass es auf das Download-Programm eingestellt ist, und gehen Sie dann die heruntergeladenen Programme durch.\n\nBisher benannt ({named} von {total}): {named_list}\n\nAlles bisher Benannte ist bereits gespeichert.",
"description": "Am Gerät hat sich nichts geändert. Stellen Sie sicher, dass Sie in der SmartThings-App auf \"Download-Programme\" getippt haben -- eine eigene Zeile, getrennt von \"Programm\", weiter unten im Bildschirm -- und ein Programm ausgewählt haben, das noch nicht als \"Heruntergeladen\" markiert war, und dann auf Fertig getippt haben.\n\nBisher benannt ({named} von {total}): {named_list}\n\nAlles bisher Benannte ist bereits gespeichert.",
"menu_options": {
"cloud_guided": "Erneut warten",
"cloud_finish": "Fertig"
@@ -1558,7 +1601,7 @@
"not_loaded": "Dieses Gerät ist noch nicht verbunden. Versuchen Sie es erneut, sobald es geladen wurde."
},
"progress": {
"cloud_wait": "Wählen Sie jetzt ein heruntergeladenes Programm am Gerät aus.\n\nBisher benannt ({named} von {total}): {named_list}\n\nSie können dieses Fenster jederzeit schließen – die Namen werden fortlaufend gespeichert."
"cloud_wait": "Öffnen Sie in der SmartThings-App (nicht am Gerät selbst) dieses Gerät und tippen Sie auf \"Download-Programme\" -- eine eigene Zeile, getrennt von \"Programm\", weiter unten im Bildschirm, die nur die Programme auflistet, die auf diese Weise erkannt werden können. Wählen Sie eines aus, das noch nicht als \"Heruntergeladen\" markiert ist, und tippen Sie auf Fertig.\n\nDas Gerät muss dafür nicht in der Nähe sein oder das Programm ausführen -- es muss nur eingeschaltet und verbunden sein.\n\nBisher benannt ({named} von {total}): {named_list}\n\nSie können dieses Fenster jederzeit schließen – die Namen werden fortlaufend gespeichert."
}
},
"issues": {
@@ -1568,7 +1611,7 @@
},
"cloud_courses_undiscovered": {
"title": "Heruntergeladene Programme für {device_name} nicht eingerichtet",
"description": "{device_name} hat {pending} von {total} heruntergeladenen Programmen, die Home Assistant noch nicht anbieten kann. Ein heruntergeladenes Programm kann erst verwendet werden, sobald das Gerät damit geladen gesehen wurde und Sie ihm einen Namen gegeben haben.\n\nUm sie einzurichten, gehen Sie zu Einstellungen > Geräte & Dienste > LocalThings > {device_name} > Konfigurieren > Download-Programme und folgen Sie den dortigen Anweisungen."
"description": "{device_name} hat {pending} von {total} heruntergeladenen Programmen, die Home Assistant noch nicht anbieten kann. Ein heruntergeladenes Programm kann erst verwendet werden, sobald das Gerät damit geladen gesehen wurde -- im Bildschirm \"Download-Programme\" der SmartThings-App, nicht im Bildschirm \"Programm\" -- und Sie ihm einen Namen gegeben haben.\n\nUm sie einzurichten, gehen Sie zu Einstellungen > Geräte & Dienste > LocalThings > {device_name} > Konfigurieren > Download-Programme und folgen Sie den dortigen Anweisungen.\n\nNutzen Sie keine heruntergeladenen Programme? Gehen Sie stattdessen zu Konfigurieren > Geräteeinstellungen und schalten Sie \"Heruntergeladene Programme anbieten\" aus, um diese Erinnerung dauerhaft zu stoppen."
}
},
"exceptions": {
@@ -376,7 +376,25 @@
"4c": "Air Refresh",
"51": "Eco Cotton",
"53": "AI Dry+",
"4e": "Self Dry"
"4e": "Self Dry",
"02": "AI Dry",
"03": "Super Speed",
"05": "Bedding",
"07": "Delicates",
"09": "Shirts",
"0b": "Padding Care",
"0c": "Outdoor Water-Repellent Care",
"0e": "Towels",
"0f": "Wool",
"11": "Cool air",
"28": "Interior Hot Air Sanitize",
"37": "Blouses",
"38": "Iron dry",
"39": "Room Dehumidify",
"3a": "Hygiene Care",
"3b": "Bedding/Dust Off",
"3c": "Activewear",
"3d": "Denim"
}
},
"favorite_capacity": {
@@ -647,11 +665,31 @@
"name": "Cycle",
"state": {
"01": "Normal",
"02": "Extra Heavy Duty",
"03": "Super Eco Wash",
"04": "Quick Wash",
"06": "XXL Laundry",
"05": "Wool/Lingerie",
"06": "Bedding",
"07": "Outdoor",
"08": "Rinse+Spin",
"09": "Drum Clean",
"0a": "Towels",
"0b": "Boil Wash",
"0c": "Baby Care",
"0d": "Spin Only",
"0e": "Cloudy Day",
"0f": "Pure Wash",
"10": "Spin Dry",
"11": "Summer Bedding",
"12": "Cottons",
"13": "Black Cottons",
"14": "Delicate Underwear",
"15": "Activewear",
"16": "Blouses",
"17": "Downloaded",
"18": "Soft Bubble",
"19": "AI Wash",
"1a": "Shirts",
"1b": "Cotton",
"1c": "Eco 40-60",
"1d": "Super Speed",
@@ -958,7 +996,10 @@
"name": "Dry time"
},
"dryer_type": {
"name": "Dryer type"
"name": "Dryer type",
"state": {
"electricity": "Electricity"
}
},
"dust": {
"name": "Dust"
@@ -1098,6 +1139,7 @@
"steaming": "Steaming",
"airwashing": "Air washing",
"drying": "Drying",
"dryingwithdooropen": "Venting",
"cooling": "Cooling",
"predrain": "Pre-drain",
"prewash": "Pre-wash"
@@ -1480,11 +1522,12 @@
},
"settings": {
"title": "Device settings",
"description": "Some devices accept certain writes (e.g. default detergent/softener dosing on a washer) even while reporting remote control off. By default, LocalThings blocks every write with a clear error whenever a device reports remote control off, rather than letting the device silently reject it. Only enable this if you've confirmed writes actually work on this device with remote control off -- otherwise you'll trade that clear error for a silent failure.\n\nEstimated finish time is recomputed from the device's remaining-time estimate on every poll, which can drift or get revised by a minute or two between updates. Raise the minimum-change value below to hold the sensor at its last reported value until the estimate moves by at least that many minutes, cutting down on history/logbook noise. Set it to 0 to report every computed change.\n\nSome models report a mode they never list as supported -- an air conditioner sitting in Quiet that only offers Off/Sleep/Speed, for instance. LocalThings remembers any such mode it sees and keeps offering it, so it stays selectable once the device has been in it at least once. Turn this off to offer only what the device advertises; use \"Forget remembered modes\" on the previous screen to clear what has already been remembered.",
"description": "Some devices accept certain writes (e.g. default detergent/softener dosing on a washer) even while reporting remote control off. By default, LocalThings blocks every write with a clear error whenever a device reports remote control off, rather than letting the device silently reject it. Only enable this if you've confirmed writes actually work on this device with remote control off -- otherwise you'll trade that clear error for a silent failure.\n\nEstimated finish time is recomputed from the device's remaining-time estimate on every poll, which can drift or get revised by a minute or two between updates. Raise the minimum-change value below to hold the sensor at its last reported value until the estimate moves by at least that many minutes, cutting down on history/logbook noise. Set it to 0 to report every computed change.\n\nSome models report a mode they never list as supported -- an air conditioner sitting in Quiet that only offers Off/Sleep/Speed, for instance. LocalThings remembers any such mode it sees and keeps offering it, so it stays selectable once the device has been in it at least once. Turn this off to offer only what the device advertises; use \"Forget remembered modes\" on the previous screen to clear what has already been remembered.\n\nA laundry device with cloud-downloaded cycles gets a reminder here once it's reported one Home Assistant can't offer yet -- some devices report one even if you've never opened the SmartThings app's \"Download cycles\" screen yourself. Turn this off to stop that reminder and hide any already-named downloaded cycles from the cycle list; nothing already set up is lost, and turning it back on brings it right back.",
"data": {
"bypass_remote_control_lock": "Allow writes even when remote control is reported off",
"finish_time_hysteresis_minutes": "Estimated finish -- minimum change (minutes)",
"learn_device_modes": "Remember modes the device reports but doesn't advertise"
"learn_device_modes": "Remember modes the device reports but doesn't advertise",
"cloud_courses_enabled": "Offer downloaded cycles"
}
},
"forget_learned_modes": {
@@ -1515,14 +1558,14 @@
},
"cloud_manual": {
"title": "Download cycles",
"description": "This appliance reports {total} downloaded cycle(s); {found} have been seen so far.\n\nA downloaded cycle's settings are only visible while that cycle is loaded, and the appliance never reports their names. To add the missing ones ({pending}): on the appliance, select the Download cycle, then step through each downloaded program in turn, pausing a few seconds on each, and come back here.\n\nGive each one the name you want to see in Home Assistant. Leave a name blank to keep that cycle out of the cycle list. Names must be unique.\n\nThe Download cycle is the course on this appliance that runs a downloaded program. It is detected automatically, but confirm it here before use: selecting a downloaded cycle writes this course code to the appliance.",
"description": "This appliance reports {total} downloaded cycle(s); {found} have been seen so far.\n\nA downloaded cycle's settings are only visible while that cycle is loaded, and the appliance never reports their names. To add the missing ones ({pending}): in the SmartThings app (not on the appliance itself), open this appliance and tap \"Download cycles\" -- a separate row from \"Cycle\", further down the screen, and the one that matters here. Step through each downloaded program in turn, pausing a few seconds on each so LocalThings can see it, then come back here.\n\nGive each one the name you want to see in Home Assistant. Leave a name blank to keep that cycle out of the cycle list. Names must be unique.\n\nThe Download cycle is the course on this appliance that runs a downloaded program. It is detected automatically, but confirm it here before use: selecting a downloaded cycle writes this course code to the appliance.",
"data": {
"download_course": "Download cycle course code"
}
},
"cloud_courses": {
"title": "Download cycles",
"description": "Guided setup walks the appliance with you: select a downloaded cycle on the appliance and name it as it's found, one at a time.\n\nEdit names shows everything found so far at once — use it to rename or correct something later.",
"description": "Guided setup walks you through it: in the SmartThings app (not on the appliance itself), open this appliance and tap \"Download cycles\" -- a separate row from \"Cycle\", further down the screen, and the one that matters here. Pick a cycle there, then come back and name it as it's found, one at a time.\n\nEdit names shows everything found so far at once — use it to rename or correct something later. Naming works either way, but a named cycle is only offered as a selectable cycle while \"Offer downloaded cycles\" is on in Device settings.",
"menu_options": {
"cloud_guided": "Guided setup",
"cloud_manual": "Edit names"
@@ -1533,7 +1576,7 @@
},
"cloud_name": {
"title": "Name this cycle",
"description": "The appliance switched to a downloaded cycle (slot {slot}) and reports {remaining} remaining.\n\nGive it the name you want to see in Home Assistant, then select the next one on the appliance. Leave it blank to keep this cycle out of the list.\n\nNamed so far ({named} of {total}): {named_list}\n\nNames must be unique. You can close this dialog whenever you like — names are saved as you go.",
"description": "The appliance's \"Download cycles\" slot now holds a new cycle (slot {slot}) and reports {remaining} remaining.\n\nGive it the name you want to see in Home Assistant, then in the SmartThings app's \"Download cycles\" screen pick the next one that isn't already marked \"Downloaded\". Leave it blank to keep this cycle out of the list.\n\nNamed so far ({named} of {total}): {named_list}\n\nNames must be unique. You can close this dialog whenever you like — names are saved as you go.",
"data": {
"name": "Name",
"download_course": "Download cycle course code"
@@ -1541,7 +1584,7 @@
},
"cloud_timeout": {
"title": "No cycle selected",
"description": "Nothing was selected on the appliance. Make sure it's set to the Download cycle, then step through the downloaded programs.\n\nNamed so far ({named} of {total}): {named_list}\n\nEverything named so far is already saved.",
"description": "Nothing changed on the appliance. Make sure you tapped \"Download cycles\" in the SmartThings app -- a separate row from \"Cycle\", further down the screen -- and picked one that wasn't already marked \"Downloaded\", then tapped Done.\n\nNamed so far ({named} of {total}): {named_list}\n\nEverything named so far is already saved.",
"menu_options": {
"cloud_guided": "Wait again",
"cloud_finish": "Finish"
@@ -1558,7 +1601,7 @@
"not_loaded": "This device isn't connected yet. Try again once it has loaded."
},
"progress": {
"cloud_wait": "Select a downloaded cycle on the appliance now.\n\nNamed so far ({named} of {total}): {named_list}\n\nYou can close this dialog at any time — names are saved as you go."
"cloud_wait": "In the SmartThings app (not on the appliance itself), open this appliance and tap \"Download cycles\" -- a separate row from \"Cycle\", further down the screen, that lists only the cycles this can detect. Pick one that isn't already marked \"Downloaded\", then tap Done.\n\nThe appliance doesn't need to be nearby or running the cycle -- just powered on and connected.\n\nNamed so far ({named} of {total}): {named_list}\n\nYou can close this dialog at any time — names are saved as you go."
}
},
"issues": {
@@ -1568,7 +1611,7 @@
},
"cloud_courses_undiscovered": {
"title": "Downloaded cycles not set up for {device_name}",
"description": "{device_name} has {pending} of {total} downloaded cycle(s) that Home Assistant can't offer yet. A downloaded cycle can only be used once the appliance has been seen with it loaded and you've given it a name.\n\nTo set them up, go to Settings > Devices & Services > LocalThings > {device_name} > Configure > Download cycles and follow the instructions there."
"description": "{device_name} has {pending} of {total} downloaded cycle(s) that Home Assistant can't offer yet. A downloaded cycle can only be used once the appliance has been seen with it loaded -- in the SmartThings app's \"Download cycles\" screen, not the \"Cycle\" one -- and you've given it a name.\n\nTo set them up, go to Settings > Devices & Services > LocalThings > {device_name} > Configure > Download cycles and follow the instructions there.\n\nDon't use downloaded cycles? Go to Configure > Device settings instead and turn off \"Offer downloaded cycles\" to silence this reminder for good."
}
},
"exceptions": {
@@ -60,11 +60,12 @@
},
"settings": {
"title": "Ajustes del dispositivo",
"description": "Algunos dispositivos aceptan ciertas escrituras (p. ej. la dosificación por defecto de detergente/suavizante en una lavadora) incluso cuando informan de control remoto desactivado. Por defecto, LocalThings bloquea cada escritura con un error claro cuando un dispositivo informa de control remoto desactivado, en lugar de dejar que el dispositivo la rechace en silencio. Solo actívalo si has confirmado que las escrituras funcionan de verdad en este dispositivo con el control remoto desactivado; si no, cambiarás ese error claro por un fallo silencioso.\n\nLa hora estimada de finalización se recalcula a partir de la estimación de tiempo restante del dispositivo en cada sondeo, que puede variar o revisarse uno o dos minutos entre actualizaciones. Sube el valor de cambio mínimo siguiente para mantener el sensor en su último valor notificado hasta que la estimación varíe al menos esa cantidad de minutos, reduciendo el ruido en el historial/registro de actividad. Ponlo a 0 para notificar cada cambio calculado.\n\nAlgunos modelos notifican un modo que nunca incluyen entre los admitidos: por ejemplo, un aire acondicionado en modo Quiet que solo ofrece Off/Sleep/Speed. LocalThings recuerda cualquier modo así que detecte y lo sigue ofreciendo, de modo que quede seleccionable en cuanto el dispositivo haya estado en él al menos una vez. Desactívalo para ofrecer solo lo que el dispositivo anuncia; usa «Olvidar los modos recordados» en la pantalla anterior para borrar lo ya recordado.",
"description": "Algunos dispositivos aceptan ciertas escrituras (p. ej. la dosificación por defecto de detergente/suavizante en una lavadora) incluso cuando informan de control remoto desactivado. Por defecto, LocalThings bloquea cada escritura con un error claro cuando un dispositivo informa de control remoto desactivado, en lugar de dejar que el dispositivo la rechace en silencio. Solo actívalo si has confirmado que las escrituras funcionan de verdad en este dispositivo con el control remoto desactivado; si no, cambiarás ese error claro por un fallo silencioso.\n\nLa hora estimada de finalización se recalcula a partir de la estimación de tiempo restante del dispositivo en cada sondeo, que puede variar o revisarse uno o dos minutos entre actualizaciones. Sube el valor de cambio mínimo siguiente para mantener el sensor en su último valor notificado hasta que la estimación varíe al menos esa cantidad de minutos, reduciendo el ruido en el historial/registro de actividad. Ponlo a 0 para notificar cada cambio calculado.\n\nAlgunos modelos notifican un modo que nunca incluyen entre los admitidos: por ejemplo, un aire acondicionado en modo Quiet que solo ofrece Off/Sleep/Speed. LocalThings recuerda cualquier modo así que detecte y lo sigue ofreciendo, de modo que quede seleccionable en cuanto el dispositivo haya estado en él al menos una vez. Desactívalo para ofrecer solo lo que el dispositivo anuncia; usa «Olvidar los modos recordados» en la pantalla anterior para borrar lo ya recordado. Un dispositivo de lavandería con ciclos descargados de la nube recibe aquí un aviso en cuanto informa de uno que Home Assistant aún no puede ofrecer -- algunos dispositivos informan de uno incluso si nunca has abierto tú mismo la pantalla \"Descarga de Programas\" de la app SmartThings. Desactiva esto para detener ese aviso y ocultar los ciclos descargados ya nombrados de la lista de ciclos; no se pierde nada de lo ya configurado, y al volver a activarlo reaparece de inmediato.",
"data": {
"bypass_remote_control_lock": "Permitir escrituras incluso cuando el control remoto se notifica como desactivado",
"finish_time_hysteresis_minutes": "Finalización estimada -- cambio mínimo (minutos)",
"learn_device_modes": "Recordar los modos que el dispositivo notifica pero no anuncia"
"learn_device_modes": "Recordar los modos que el dispositivo notifica pero no anuncia",
"cloud_courses_enabled": "Ofrecer ciclos descargados"
}
},
"forget_learned_modes": {
@@ -95,14 +96,14 @@
},
"cloud_manual": {
"title": "Ciclos descargados",
"description": "Este dispositivo informa de {total} ciclos descargados; se han detectado {found} hasta ahora.\n\nLos ajustes de un ciclo descargado solo son visibles mientras ese ciclo está cargado, y el dispositivo nunca informa de sus nombres. Para añadir los que faltan ({pending}): en el dispositivo, selecciona Descarga de Programas y ve pasando por cada programa descargado uno a uno, deteniéndote unos segundos en cada uno, y vuelve aquí.\n\nDa a cada uno el nombre que quieras ver en Home Assistant. Deja un nombre en blanco para mantener ese ciclo fuera de la lista de ciclos. Los nombres deben ser únicos.\n\nDescarga de Programas es el programa de este dispositivo que ejecuta un ciclo descargado. Se detecta automáticamente, pero confírmalo aquí antes de usarlo: seleccionar un ciclo descargado escribe este código de programa en el dispositivo.",
"description": "Este dispositivo informa de {total} ciclos descargados; se han detectado {found} hasta ahora.\n\nLos ajustes de un ciclo descargado solo son visibles mientras ese ciclo está cargado, y el dispositivo nunca informa de sus nombres. Para añadir los que faltan ({pending}): en la app SmartThings (no en el dispositivo), abre este dispositivo y toca \"Descarga de Programas\" -- una fila aparte de \"Ciclo\", más abajo en la pantalla, y la que importa aquí. Ve pasando por cada programa descargado uno a uno, deteniéndote unos segundos en cada uno para que LocalThings pueda detectarlo, y vuelve aquí.\n\nDa a cada uno el nombre que quieras ver en Home Assistant. Deja un nombre en blanco para mantener ese ciclo fuera de la lista de ciclos. Los nombres deben ser únicos.\n\nDescarga de Programas es el programa de este dispositivo que ejecuta un ciclo descargado. Se detecta automáticamente, pero confírmalo aquí antes de usarlo: seleccionar un ciclo descargado escribe este código de programa en el dispositivo.",
"data": {
"download_course": "Código de programa de Descarga de Programas"
}
},
"cloud_courses": {
"title": "Ciclos descargados",
"description": "La configuración guiada te acompaña con el dispositivo: selecciona un ciclo descargado en el dispositivo y ponle nombre en cuanto se detecte, uno por uno.\n\nEditar nombres muestra todo lo encontrado hasta ahora de una vez — úsalo más adelante para renombrar o corregir algo.",
"description": "La configuración guiada te acompaña: en la app SmartThings (no en el dispositivo), abre este dispositivo y toca \"Descarga de Programas\" -- una fila aparte de \"Ciclo\", más abajo en la pantalla, y la que importa aquí. Elige un ciclo allí, vuelve aquí y ponle nombre en cuanto se detecte, uno por uno.\n\nEditar nombres muestra todo lo encontrado hasta ahora de una vez — úsalo más adelante para renombrar o corregir algo. Nombrar funciona en cualquier caso, pero un ciclo con nombre solo se ofrece como ciclo seleccionable mientras \"Ofrecer ciclos descargados\" esté activado en los ajustes del dispositivo.",
"menu_options": {
"cloud_guided": "Configuración guiada",
"cloud_manual": "Editar nombres"
@@ -113,7 +114,7 @@
},
"cloud_name": {
"title": "Nombra este ciclo",
"description": "El dispositivo cambió a un ciclo descargado (ranura {slot}) e informa de {remaining} restantes.\n\nDale el nombre que quieras ver en Home Assistant y luego selecciona el siguiente en el dispositivo. Déjalo en blanco para mantener este ciclo fuera de la lista.\n\nNombrados hasta ahora ({named} de {total}): {named_list}\n\nLos nombres deben ser únicos. Puedes cerrar este cuadro de diálogo cuando quieras — los nombres se guardan sobre la marcha.",
"description": "La ranura \"Descarga de Programas\" del dispositivo ahora tiene un ciclo nuevo (ranura {slot}) e informa de {remaining} restantes.\n\nDale el nombre que quieras ver en Home Assistant y luego, en la pantalla \"Descarga de Programas\" de la app SmartThings, elige el siguiente que no esté ya marcado como \"Descargado\". Déjalo en blanco para mantener este ciclo fuera de la lista.\n\nNombrados hasta ahora ({named} de {total}): {named_list}\n\nLos nombres deben ser únicos. Puedes cerrar este cuadro de diálogo cuando quieras — los nombres se guardan sobre la marcha.",
"data": {
"name": "Nombre",
"download_course": "Código de programa de Descarga de Programas"
@@ -121,7 +122,7 @@
},
"cloud_timeout": {
"title": "No se seleccionó ningún ciclo",
"description": "No se seleccionó nada en el dispositivo. Asegúrate de que esté puesto en Descarga de Programas y luego ve pasando por los programas descargados.\n\nNombrados hasta ahora ({named} de {total}): {named_list}\n\nTodo lo nombrado hasta ahora ya está guardado.",
"description": "No ha cambiado nada en el dispositivo. Asegúrate de haber tocado \"Descarga de Programas\" en la app SmartThings -- una fila aparte de \"Ciclo\", más abajo en la pantalla -- y de haber elegido uno que no estuviera ya marcado como \"Descargado\", y luego tocado Hecho.\n\nNombrados hasta ahora ({named} de {total}): {named_list}\n\nTodo lo nombrado hasta ahora ya está guardado.",
"menu_options": {
"cloud_guided": "Esperar de nuevo",
"cloud_finish": "Finalizar"
@@ -138,7 +139,7 @@
"not_loaded": "Este dispositivo aún no está conectado. Inténtalo de nuevo cuando se haya cargado."
},
"progress": {
"cloud_wait": "Selecciona ahora un ciclo descargado en el dispositivo.\n\nNombrados hasta ahora ({named} de {total}): {named_list}\n\nPuedes cerrar este cuadro de diálogo en cualquier momento — los nombres se guardan sobre la marcha."
"cloud_wait": "En la app SmartThings (no en el dispositivo), abre este dispositivo y toca \"Descarga de Programas\" -- una fila aparte de \"Ciclo\", más abajo en la pantalla, que solo lista los ciclos que se pueden detectar así. Elige uno que no esté ya marcado como \"Descargado\" y toca Hecho.\n\nEl dispositivo no necesita estar cerca ni ejecutando el ciclo -- solo encendido y conectado.\n\nNombrados hasta ahora ({named} de {total}): {named_list}\n\nPuedes cerrar este cuadro de diálogo en cualquier momento — los nombres se guardan sobre la marcha."
}
},
"issues": {
@@ -148,7 +149,7 @@
},
"cloud_courses_undiscovered": {
"title": "Ciclos descargados sin configurar para {device_name}",
"description": "{device_name} tiene {pending} de {total} ciclos descargados que Home Assistant todavía no puede ofrecer. Un ciclo descargado solo se puede usar una vez que el dispositivo se ha visto con él cargado y le has dado un nombre.\n\nPara configurarlos, ve a Ajustes > Dispositivos y servicios > LocalThings > {device_name} > Configurar > Ciclos descargados y sigue las instrucciones que aparecen allí."
"description": "{device_name} tiene {pending} de {total} ciclos descargados que Home Assistant todavía no puede ofrecer. Un ciclo descargado solo se puede usar una vez que el dispositivo se ha visto con él cargado -- en la pantalla \"Descarga de Programas\" de la app SmartThings, no en la de \"Ciclo\" -- y le has dado un nombre.\n\nPara configurarlos, ve a Ajustes > Dispositivos y servicios > LocalThings > {device_name} > Configurar > Ciclos descargados y sigue las instrucciones que aparecen allí.\n\n¿No usas ciclos descargados? Ve a Configurar > Ajustes del dispositivo y desactiva \"Ofrecer ciclos descargados\" para silenciar este aviso definitivamente."
}
},
"exceptions": {
@@ -572,7 +573,25 @@
"53": "Secado IA+",
"4e": "Autosecado",
"26": "Aireación",
"2a": "Cuidado higiénico+"
"2a": "Cuidado higiénico+",
"02": "Secado IA",
"03": "Súper velocidad",
"05": "Ropa de cama",
"07": "Delicados",
"09": "Camisas",
"0b": "Cuidado de acolchados",
"0c": "Cuidado hidrófugo para exterior",
"0e": "Toallas",
"0f": "Lana",
"11": "Aire frío",
"28": "Desinfección interior con aire caliente",
"37": "Blusas",
"38": "Planchado fácil",
"39": "Deshumidificación de habitación",
"3a": "Cuidado higiénico",
"3b": "Ropa de cama/Antipolvo",
"3c": "Ropa deportiva",
"3d": "Denim"
}
},
"favorite_capacity": {
@@ -843,11 +862,31 @@
"name": "Ciclo",
"state": {
"01": "Normal",
"02": "Servicio Extra Intensivo",
"03": "Super Eco",
"04": "Lavado rápido",
"06": "Colada XXL",
"05": "Lana/Lencería",
"06": "Ropa de cama",
"07": "Exterior",
"08": "Aclarar + Centrifugar",
"09": "Limpieza de Tambor",
"0a": "Toallas",
"0b": "Lavado a ebullición",
"0c": "Cuidado del bebé",
"0d": "Solo centrifugar",
"0e": "Día nublado",
"0f": "Lavado puro",
"10": "Secado-centrifugado",
"11": "Ropa de cama de verano",
"12": "Algodón",
"13": "Algodón negro",
"14": "Ropa interior delicada",
"15": "Ropa deportiva",
"16": "Blusas",
"17": "Descargado",
"18": "Burbuja suave",
"19": "Lavado IA",
"1a": "Camisas",
"1b": "Algodón",
"1c": "Eco 40-60",
"1d": "Súper velocidad",
@@ -872,6 +911,7 @@
"32": "Camisas",
"33": "Toallas",
"34": "Mezcla",
"35": "Algodón E",
"36": "Lavado + Secado",
"37": "Lavado con aire",
"38": "Secado de algodón",
@@ -886,16 +926,6 @@
"60": "Autolimpieza+",
"65": "Color",
"66": "Denim",
"7c": "Blancos",
"7d": "Ropa de cama / Impermeable",
"7e": "Autolimpieza",
"7f": "Lana / Delicados",
"86": "Lavado profundo",
"87": "Descarga de Programas",
"8f": "Lavado en frío",
"96": "Menos microfibras",
"a0": "Lavado rápido 15'",
"b0": "Carga mixta",
"69": "Lavado IA",
"6a": "Lana",
"6b": "Denim",
@@ -913,8 +943,17 @@
"77": "Algodón",
"78": "Aclarar + Centrifugar",
"79": "Solo centrifugar",
"7c": "Blancos",
"7d": "Ropa de cama / Impermeable",
"7e": "Autolimpieza",
"7f": "Lana / Delicados",
"86": "Lavado profundo",
"87": "Descarga de Programas",
"88": "Cuidado de mascotas",
"35": "Algodón E"
"8f": "Lavado en frío",
"96": "Menos microfibras",
"a0": "Lavado rápido 15'",
"b0": "Carga mixta"
}
},
"washer_dry_level": {
@@ -1151,7 +1190,10 @@
"name": "Tiempo de secado"
},
"dryer_type": {
"name": "Tipo de secadora"
"name": "Tipo de secadora",
"state": {
"electricity": "Electricidad"
}
},
"dust": {
"name": "Polvo"
@@ -1291,6 +1333,7 @@
"steaming": "Vaporización",
"airwashing": "Lavado con aire",
"drying": "Secado",
"dryingwithdooropen": "Ventilación",
"cooling": "Enfriamiento",
"predrain": "Drenaje previo",
"prewash": "Prelavado"
@@ -376,7 +376,25 @@
"53": "Asciugatura AI+",
"4e": "Autoasciugatura",
"26": "Arieggiatura",
"2a": "Cura igienica+"
"2a": "Cura igienica+",
"02": "Asciugatura AI",
"03": "Super Speed",
"05": "Biancheria da letto",
"07": "Delicati",
"09": "Camicie",
"0b": "Cura piumini",
"0c": "Cura idrorepellente outdoor",
"0e": "Asciugamani",
"0f": "Lana",
"11": "Aria fredda",
"28": "Sanificazione interna ad aria calda",
"37": "Camicette",
"38": "Pronto da stirare",
"39": "Deumidificazione ambiente",
"3a": "Cura igienica",
"3b": "Biancheria da letto/Antipolvere",
"3c": "Abbigliamento sportivo",
"3d": "Jeans"
}
},
"favorite_capacity": {
@@ -647,11 +665,31 @@
"name": "Ciclo",
"state": {
"01": "Normale",
"02": "Extra Intenso",
"03": "Super Eco",
"04": "Lavaggio rapido",
"06": "Bucato XXL",
"05": "Lana/Lingerie",
"06": "Biancheria da letto",
"07": "Capi outdoor",
"08": "Risciacquo+Centrifuga",
"09": "Pulizia cestello",
"0a": "Asciugamani",
"0b": "Lavaggio a ebollizione",
"0c": "Bambini",
"0d": "Solo centrifuga",
"0e": "Giornata nuvolosa",
"0f": "Lavaggio puro",
"10": "Asciugatura centrifuga",
"11": "Biancheria da letto estiva",
"12": "Cotone",
"13": "Cotone nero",
"14": "Biancheria intima delicata",
"15": "Abbigliamento sportivo",
"16": "Camicette",
"17": "Scaricato",
"18": "Soft Bubble",
"19": "Lavaggio AI",
"1a": "Camicie",
"1b": "Cotone",
"1c": "Eco 40-60",
"1d": "Super Speed",
@@ -676,6 +714,7 @@
"32": "Camicie",
"33": "Asciugamani",
"34": "Misti",
"35": "Cotone E",
"36": "Lavaggio+Asciugatura",
"37": "Lavaggio ad aria",
"38": "Asciugatura cotone",
@@ -690,14 +729,6 @@
"60": "Self Clean+",
"65": "Colorati",
"66": "Jeans",
"7c": "Bianchi",
"7d": "Biancheria da letto/Impermeabile",
"7e": "Pulizia cestello",
"7f": "Lana/Delicati",
"86": "Lavaggio profondo",
"87": "Scaricato",
"8f": "Intenso a freddo",
"96": "Riduci microfibre",
"69": "Lavaggio AI",
"6a": "Lana",
"6b": "Jeans",
@@ -715,8 +746,15 @@
"77": "Cotone",
"78": "Risciacquo+Centrifuga",
"79": "Solo centrifuga",
"7c": "Bianchi",
"7d": "Biancheria da letto/Impermeabile",
"7e": "Pulizia cestello",
"7f": "Lana/Delicati",
"86": "Lavaggio profondo",
"87": "Scaricato",
"88": "Cura animali",
"35": "Cotone E",
"8f": "Intenso a freddo",
"96": "Riduci microfibre",
"a0": "Rapido 15'",
"b0": "Carico misto"
}
@@ -958,7 +996,10 @@
"name": "Tempo di asciugatura"
},
"dryer_type": {
"name": "Tipo di asciugatrice"
"name": "Tipo di asciugatrice",
"state": {
"electricity": "Elettricità"
}
},
"dust": {
"name": "Polvere"
@@ -1098,6 +1139,7 @@
"steaming": "Vapore",
"airwashing": "Lavaggio ad aria",
"drying": "Asciugatura",
"dryingwithdooropen": "Ventilazione",
"cooling": "Raffreddamento",
"predrain": "Scarico preliminare",
"prewash": "Prelavaggio"
@@ -1480,11 +1522,12 @@
},
"settings": {
"title": "Impostazioni dispositivo",
"description": "Alcuni dispositivi accettano determinate scritture (ad esempio, il dosaggio predefinito di detersivo/ammorbidente su una lavatrice) anche quando segnalano che il controllo remoto è disattivato. Per impostazione predefinita, LocalThings blocca ogni scrittura con un errore chiaro ogni volta che un dispositivo segnala che il controllo remoto è disattivato, invece di consentire al dispositivo di rifiutarla silenziosamente. Abilitare questa opzione solo se si è verificato che le scritture funzionano effettivamente su questo dispositivo con il controllo remoto disattivato; in caso contrario, si sostituirà l'errore chiaro con un errore silenzioso.\n\nIl tempo di fine stimato viene ricalcolato dalla stima del tempo rimanente del dispositivo a ogni interrogazione, che può variare o essere rivista di uno o due minuti tra un aggiornamento e l'altro. Aumentare il valore di variazione minima riportato di seguito per mantenere il sensore al suo ultimo valore segnalato finché la stima non si sposta di almeno quel numero di minuti, riducendo il rumore nella cronologia/registro. Impostarlo su 0 per segnalare ogni variazione calcolata.\n\nAlcuni modelli segnalano una modalità che non elencano mai tra quelle supportate: ad esempio un condizionatore in modalità Quiet che offre solo Off/Sleep/Speed. LocalThings memorizza ogni modalità di questo tipo che rileva e continua a proporla, così resta selezionabile una volta che il dispositivo vi è stato almeno una volta. Disattivare questa opzione per proporre solo ciò che il dispositivo dichiara; usare «Dimentica le modalità memorizzate» nella schermata precedente per cancellare quanto già memorizzato.",
"description": "Alcuni dispositivi accettano determinate scritture (ad esempio, il dosaggio predefinito di detersivo/ammorbidente su una lavatrice) anche quando segnalano che il controllo remoto è disattivato. Per impostazione predefinita, LocalThings blocca ogni scrittura con un errore chiaro ogni volta che un dispositivo segnala che il controllo remoto è disattivato, invece di consentire al dispositivo di rifiutarla silenziosamente. Abilitare questa opzione solo se si è verificato che le scritture funzionano effettivamente su questo dispositivo con il controllo remoto disattivato; in caso contrario, si sostituirà l'errore chiaro con un errore silenzioso.\n\nIl tempo di fine stimato viene ricalcolato dalla stima del tempo rimanente del dispositivo a ogni interrogazione, che può variare o essere rivista di uno o due minuti tra un aggiornamento e l'altro. Aumentare il valore di variazione minima riportato di seguito per mantenere il sensore al suo ultimo valore segnalato finché la stima non si sposta di almeno quel numero di minuti, riducendo il rumore nella cronologia/registro. Impostarlo su 0 per segnalare ogni variazione calcolata.\n\nAlcuni modelli segnalano una modalità che non elencano mai tra quelle supportate: ad esempio un condizionatore in modalità Quiet che offre solo Off/Sleep/Speed. LocalThings memorizza ogni modalità di questo tipo che rileva e continua a proporla, così resta selezionabile una volta che il dispositivo vi è stato almeno una volta. Disattivare questa opzione per proporre solo ciò che il dispositivo dichiara; usare «Dimentica le modalità memorizzate» nella schermata precedente per cancellare quanto già memorizzato. Un dispositivo per il bucato con cicli scaricati dal cloud riceve qui un promemoria non appena ne segnala uno che Home Assistant non può ancora offrire -- alcuni dispositivi ne segnalano uno anche se non è mai stata aperta la schermata \"Cicli scaricati\" dell'app SmartThings. Disattivarlo per interrompere il promemoria e nascondere dall'elenco dei cicli quelli scaricati già nominati; nulla di quanto già configurato va perso, e riattivandolo riappare subito.",
"data": {
"bypass_remote_control_lock": "Consenti la scrittura anche quando il controllo remoto risulta disattivato.",
"finish_time_hysteresis_minutes": "Tempo finale stimato - variazione minima (minuti)",
"learn_device_modes": "Memorizza le modalità che il dispositivo segnala ma non dichiara supportate"
"learn_device_modes": "Memorizza le modalità che il dispositivo segnala ma non dichiara supportate",
"cloud_courses_enabled": "Offri cicli scaricati"
}
},
"forget_learned_modes": {
@@ -1515,14 +1558,14 @@
},
"cloud_manual": {
"title": "Cicli scaricati",
"description": "Questo dispositivo segnala {total} cicli scaricati; finora ne sono stati rilevati {found}.\n\nLe impostazioni di un ciclo scaricato sono visibili solo mentre quel ciclo è caricato, e il dispositivo non ne segnala mai il nome. Per aggiungere quelli mancanti ({pending}): sul dispositivo selezionare il ciclo Scaricato, quindi scorrere ciascun programma scaricato uno alla volta, sostando qualche secondo su ognuno, e tornare qui.\n\nAssegnare a ciascuno il nome che si desidera vedere in Home Assistant. Lasciare un nome vuoto per escludere quel ciclo dall'elenco dei cicli. I nomi devono essere univoci.\n\nIl ciclo Scaricato è il programma su questo dispositivo che esegue un programma scaricato. Viene rilevato automaticamente, ma confermarlo qui prima dell'uso: selezionare un ciclo scaricato scrive questo codice di programma sul dispositivo.",
"description": "Questo dispositivo segnala {total} cicli scaricati; finora ne sono stati rilevati {found}.\n\nLe impostazioni di un ciclo scaricato sono visibili solo mentre quel ciclo è caricato, e il dispositivo non ne segnala mai il nome. Per aggiungere quelli mancanti ({pending}): nell'app SmartThings (non sul dispositivo), aprire questo dispositivo e toccare \"Cicli scaricati\" -- una riga separata da \"Ciclo\", più in basso nella schermata, ed è quella che conta qui. Scorrere ciascun programma scaricato uno alla volta, sostando qualche secondo su ognuno perché LocalThings possa rilevarlo, e tornare qui.\n\nAssegnare a ciascuno il nome che si desidera vedere in Home Assistant. Lasciare un nome vuoto per escludere quel ciclo dall'elenco dei cicli. I nomi devono essere univoci.\n\nIl ciclo Scaricato è il programma su questo dispositivo che esegue un programma scaricato. Viene rilevato automaticamente, ma confermarlo qui prima dell'uso: selezionare un ciclo scaricato scrive questo codice di programma sul dispositivo.",
"data": {
"download_course": "Codice programma del ciclo Scaricato"
}
},
"cloud_courses": {
"title": "Cicli scaricati",
"description": "La configurazione guidata accompagna l'utente al dispositivo: selezionare un ciclo scaricato sul dispositivo e assegnargli un nome non appena viene rilevato, uno alla volta.\n\nModifica nomi mostra tutto ciò che è stato trovato finora in una sola volta — usarla in seguito per rinominare o correggere qualcosa.",
"description": "La configurazione guidata accompagna l'utente: nell'app SmartThings (non sul dispositivo), aprire questo dispositivo e toccare \"Cicli scaricati\" -- una riga separata da \"Ciclo\", più in basso nella schermata, ed è quella che conta qui. Scegliere un ciclo lì, poi tornare qui e assegnargli un nome non appena viene rilevato, uno alla volta.\n\nModifica nomi mostra tutto ciò che è stato trovato finora in una sola volta — usarla in seguito per rinominare o correggere qualcosa. Assegnare un nome funziona comunque, ma un ciclo con nome viene offerto come ciclo selezionabile solo quando \"Offri cicli scaricati\" è attivo nelle impostazioni del dispositivo.",
"menu_options": {
"cloud_guided": "Configurazione guidata",
"cloud_manual": "Modifica nomi"
@@ -1533,7 +1576,7 @@
},
"cloud_name": {
"title": "Assegna un nome a questo ciclo",
"description": "Il dispositivo è passato a un ciclo scaricato (slot {slot}) e segnala {remaining} rimanenti.\n\nAssegnargli il nome che si desidera vedere in Home Assistant, quindi selezionare il successivo sul dispositivo. Lasciare vuoto per escludere questo ciclo dall'elenco.\n\nAssegnati finora ({named} di {total}): {named_list}\n\nI nomi devono essere univoci. È possibile chiudere questa finestra in qualsiasi momento — i nomi vengono salvati man mano.",
"description": "Lo slot \"Cicli scaricati\" del dispositivo ora contiene un nuovo ciclo (slot {slot}) e segnala {remaining} rimanenti.\n\nAssegnargli il nome che si desidera vedere in Home Assistant, quindi nella schermata \"Cicli scaricati\" dell'app SmartThings scegliere il successivo non ancora contrassegnato come \"Scaricato\". Lasciare vuoto per escludere questo ciclo dall'elenco.\n\nAssegnati finora ({named} di {total}): {named_list}\n\nI nomi devono essere univoci. È possibile chiudere questa finestra in qualsiasi momento — i nomi vengono salvati man mano.",
"data": {
"name": "Nome",
"download_course": "Codice programma del ciclo Scaricato"
@@ -1541,7 +1584,7 @@
},
"cloud_timeout": {
"title": "Nessun ciclo selezionato",
"description": "Non è stato selezionato nulla sul dispositivo. Assicurarsi che sia impostato sul ciclo Scaricato, quindi scorrere i programmi scaricati.\n\nAssegnati finora ({named} di {total}): {named_list}\n\nTutto ciò che è stato assegnato finora è già salvato.",
"description": "Non è cambiato nulla sul dispositivo. Assicurarsi di aver toccato \"Cicli scaricati\" nell'app SmartThings -- una riga separata da \"Ciclo\", più in basso nella schermata -- e di aver scelto un ciclo non ancora contrassegnato come \"Scaricato\", quindi di aver toccato Fine.\n\nAssegnati finora ({named} di {total}): {named_list}\n\nTutto ciò che è stato assegnato finora è già salvato.",
"menu_options": {
"cloud_guided": "Attendi di nuovo",
"cloud_finish": "Fine"
@@ -1558,7 +1601,7 @@
"not_loaded": "Questo dispositivo non è ancora connesso. Riprova dopo il caricamento."
},
"progress": {
"cloud_wait": "Selezionare ora un ciclo scaricato sul dispositivo.\n\nAssegnati finora ({named} di {total}): {named_list}\n\nÈ possibile chiudere questa finestra in qualsiasi momento — i nomi vengono salvati man mano."
"cloud_wait": "Nell'app SmartThings (non sul dispositivo), aprire questo dispositivo e toccare \"Cicli scaricati\" -- una riga separata da \"Ciclo\", più in basso nella schermata, che elenca solo i cicli rilevabili in questo modo. Scegliere uno non ancora contrassegnato come \"Scaricato\", quindi toccare Fine.\n\nIl dispositivo non deve essere nelle vicinanze né in esecuzione -- deve solo essere acceso e connesso.\n\nAssegnati finora ({named} di {total}): {named_list}\n\nÈ possibile chiudere questa finestra in qualsiasi momento — i nomi vengono salvati man mano."
}
},
"issues": {
@@ -1568,7 +1611,7 @@
},
"cloud_courses_undiscovered": {
"title": "Cicli scaricati non configurati per {device_name}",
"description": "{device_name} ha {pending} cicli scaricati su {total} che Home Assistant non può ancora offrire. Un ciclo scaricato può essere usato solo dopo che il dispositivo è stato rilevato con quel ciclo caricato ed è stato assegnato un nome.\n\nPer configurarli, andare su Impostazioni > Dispositivi e servizi > LocalThings > {device_name} > Configura > Cicli scaricati e seguire le istruzioni presenti lì."
"description": "{device_name} ha {pending} cicli scaricati su {total} che Home Assistant non può ancora offrire. Un ciclo scaricato può essere usato solo dopo che il dispositivo è stato rilevato con quel ciclo caricato -- nella schermata \"Cicli scaricati\" dell'app SmartThings, non in quella \"Ciclo\" -- ed è stato assegnato un nome.\n\nPer configurarli, andare su Impostazioni > Dispositivi e servizi > LocalThings > {device_name} > Configura > Cicli scaricati e seguire le istruzioni presenti lì.\n\nNon si usano cicli scaricati? Andare invece su Configura > Impostazioni dispositivo e disattivare \"Offri cicli scaricati\" per silenziare questo promemoria definitivamente."
}
},
"exceptions": {
@@ -376,7 +376,25 @@
"53": "AI 맞춤건조+",
"4e": "자체 건조",
"26": "송풍",
"2a": "살균건조+"
"2a": "살균건조+",
"02": "AI 맞춤건조",
"03": "쾌속건조",
"05": "이불",
"07": "섬세의류",
"09": "셔츠",
"0b": "패딩케어",
"0c": "아웃도어발수케어",
"0e": "타월",
"0f": "울",
"11": "송풍건조",
"28": "열풍내부살균",
"37": "블라우스",
"38": "다림질건조",
"39": "공간제습",
"3a": "살균건조",
"3b": "이불/먼지털기",
"3c": "피트니스",
"3d": "데님"
}
},
"favorite_capacity": {
@@ -647,11 +665,31 @@
"name": "코스",
"state": {
"01": "표준세탁",
"02": "초강력세탁",
"03": "초절약세탁",
"04": "쾌속세탁",
"06": "XXL 세탁",
"05": "울/란제리",
"06": "이불",
"07": "아웃도어",
"08": "헹굼+탈수",
"09": "무세제통세척",
"0a": "타월",
"0b": "삶음세탁",
"0c": "아기옷",
"0d": "탈수단독",
"0e": "흐린날세탁",
"0f": "청정세탁",
"10": "건조탈수",
"11": "여름이불",
"12": "면의류",
"13": "검은면의류",
"14": "섬세속옷",
"15": "피트니스",
"16": "블라우스",
"17": "다운로드 코스",
"18": "소프트버블",
"19": "AI 맞춤세탁",
"1a": "셔츠",
"1b": "면",
"1c": "에코 40-60",
"1d": "쾌속세탁",
@@ -676,6 +714,7 @@
"32": "셔츠",
"33": "타월",
"34": "혼합",
"35": "에코 면",
"36": "세탁+건조",
"37": "에어워시",
"38": "면 건조",
@@ -690,16 +729,6 @@
"60": "통세척+",
"65": "컬러 의류",
"66": "데님",
"7c": "흰옷",
"7d": "이불/방수 의류",
"7e": "통세척",
"7f": "울/섬세",
"86": "찌든 때 세탁",
"87": "다운로드",
"8f": "강력 냉수 세탁",
"96": "미세플라스틱저감",
"a0": "15분 쾌속세탁",
"b0": "혼합 세탁",
"69": "AI 맞춤세탁",
"6a": "울",
"6b": "데님",
@@ -717,8 +746,17 @@
"77": "면",
"78": "헹굼+탈수",
"79": "탈수만",
"7c": "흰옷",
"7d": "이불/방수 의류",
"7e": "통세척",
"7f": "울/섬세",
"86": "찌든 때 세탁",
"87": "다운로드",
"88": "펫케어",
"35": "에코 면"
"8f": "강력 냉수 세탁",
"96": "미세플라스틱저감",
"a0": "15분 쾌속세탁",
"b0": "혼합 세탁"
}
},
"washer_dry_level": {
@@ -958,7 +996,10 @@
"name": "건조 시간"
},
"dryer_type": {
"name": "건조기 유형"
"name": "건조기 유형",
"state": {
"electricity": "전기"
}
},
"dust": {
"name": "미세먼지"
@@ -1098,6 +1139,7 @@
"steaming": "스팀",
"airwashing": "에어워시",
"drying": "건조",
"dryingwithdooropen": "환기",
"cooling": "냉각",
"predrain": "사전 배수",
"prewash": "애벌빨래"
@@ -1480,11 +1522,12 @@
},
"settings": {
"title": "기기 설정",
"description": "일부 기기는 스마트 컨트롤이 꺼져 있다고 보고하는 동안에도 특정 쓰기 요청(예: 세탁기의 기본 세제/섬유유연제 투입량)을 받아들입니다. 기본적으로 LocalThings는 기기가 스마트 컨트롤 꺼짐을 보고하면 기기가 쓰기 요청을 조용히 거부하도록 두지 않고, 명확한 오류와 함께 모든 쓰기를 차단합니다. 스마트 컨트롤이 꺼진 상태에서도 이 기기에 쓰기가 실제로 동작하는 것을 확인한 경우에만 이 옵션을 활성화하세요. 그렇지 않으면 명확한 오류 대신 아무 표시 없이 쓰기에 실패할 수 있습니다.\n\n예상 완료 시각은 기기가 보고하는 남은 시간 추정치를 사용하여 상태를 확인할 때마다 다시 계산합니다. 이 추정치는 업데이트 사이에 1~2분 정도 변하거나 수정될 수 있습니다. 아래의 최소 변경량을 늘리면 추정치가 설정한 시간(분) 이상 변할 때까지 센서가 마지막으로 보고된 값을 유지하므로 기록과 로그북에 불필요한 항목이 쌓이는 것을 줄일 수 있습니다. 계산된 모든 변경 사항을 보고하려면 0으로 설정하세요.\n\n일부 모델은 지원 목록에 없는 모드를 현재 모드로 보고합니다. 예를 들어 Off/Sleep/Speed만 제공하면서 Quiet 모드로 동작 중이라고 보고하는 에어컨이 그렇습니다. LocalThings는 이렇게 발견한 모드를 기억해 두고 계속 제공하므로, 기기가 한 번이라도 그 모드였다면 이후에도 선택할 수 있습니다. 이 옵션을 끄면 기기가 지원한다고 알린 모드만 제공합니다. 이미 기억된 항목을 지우려면 이전 화면의 '기억된 모드 지우기'를 사용하세요.",
"description": "일부 기기는 스마트 컨트롤이 꺼져 있다고 보고하는 동안에도 특정 쓰기 요청(예: 세탁기의 기본 세제/섬유유연제 투입량)을 받아들입니다. 기본적으로 LocalThings는 기기가 스마트 컨트롤 꺼짐을 보고하면 기기가 쓰기 요청을 조용히 거부하도록 두지 않고, 명확한 오류와 함께 모든 쓰기를 차단합니다. 스마트 컨트롤이 꺼진 상태에서도 이 기기에 쓰기가 실제로 동작하는 것을 확인한 경우에만 이 옵션을 활성화하세요. 그렇지 않으면 명확한 오류 대신 아무 표시 없이 쓰기에 실패할 수 있습니다.\n\n예상 완료 시각은 기기가 보고하는 남은 시간 추정치를 사용하여 상태를 확인할 때마다 다시 계산합니다. 이 추정치는 업데이트 사이에 1~2분 정도 변하거나 수정될 수 있습니다. 아래의 최소 변경량을 늘리면 추정치가 설정한 시간(분) 이상 변할 때까지 센서가 마지막으로 보고된 값을 유지하므로 기록과 로그북에 불필요한 항목이 쌓이는 것을 줄일 수 있습니다. 계산된 모든 변경 사항을 보고하려면 0으로 설정하세요.\n\n일부 모델은 지원 목록에 없는 모드를 현재 모드로 보고합니다. 예를 들어 Off/Sleep/Speed만 제공하면서 Quiet 모드로 동작 중이라고 보고하는 에어컨이 그렇습니다. LocalThings는 이렇게 발견한 모드를 기억해 두고 계속 제공하므로, 기기가 한 번이라도 그 모드였다면 이후에도 선택할 수 있습니다. 이 옵션을 끄면 기기가 지원한다고 알린 모드만 제공합니다. 이미 기억된 항목을 지우려면 이전 화면의 '기억된 모드 지우기'를 사용하세요. 클라우드에서 다운로드한 코스가 있는 세탁기는 Home Assistant가 아직 제공할 수 없는 코스를 보고하는 즉시 여기에서 알림을 받습니다. 일부 기기는 SmartThings 앱의 \"다운로드 코스\" 화면을 직접 연 적이 없어도 이런 코스를 보고합니다. 이 옵션을 끄면 이 알림이 멈추고 이미 이름을 지정한 다운로드 코스도 코스 목록에서 숨겨집니다. 이미 설정한 내용은 사라지지 않으며, 다시 켜면 즉시 원래대로 돌아옵니다.",
"data": {
"bypass_remote_control_lock": "스마트 컨트롤이 꺼진 것으로 보고되어도 쓰기 허용",
"finish_time_hysteresis_minutes": "예상 완료 시각 - 최소 변경량(분)",
"learn_device_modes": "기기가 보고하지만 지원 목록에 없는 모드 기억하기"
"learn_device_modes": "기기가 보고하지만 지원 목록에 없는 모드 기억하기",
"cloud_courses_enabled": "다운로드 코스 제공"
}
},
"forget_learned_modes": {
@@ -1515,14 +1558,14 @@
},
"cloud_manual": {
"title": "다운로드 코스",
"description": "이 기기는 다운로드한 코스를 {total}개 보고하며, 지금까지 {found}개를 확인했습니다.\n\n다운로드한 코스의 설정은 해당 코스가 로드되어 있는 동안에만 확인할 수 있고, 기기는 그 이름을 전달하지 않습니다. 누락된 항목({pending}개)을 추가하려면 기기에서 다운로드 코스를 선택한 다음, 다운로드된 프로그램을 하나씩 차례로 실행하면서 몇 초씩 머무른 뒤 이 화면으로 돌아오세요.\n\n각 코스에 Home Assistant에서 보고 싶은 이름을 입력하세요. 이름을 비워 두면 해당 코스는 코스 목록에서 제외됩니다. 이름은 서로 달라야 합니다.\n\n다운로드 코스는 이 기기에서 다운로드된 프로그램을 실행하는 코스입니다. 자동으로 감지되지만 사용하기 전에 여기서 확인하세요. 다운로드한 코스를 선택하면 이 코스 코드가 기기에 기록됩니다.",
"description": "이 기기는 다운로드한 코스를 {total}개 보고하며, 지금까지 {found}개를 확인했습니다.\n\n다운로드한 코스의 설정은 해당 코스가 로드되어 있는 동안에만 확인할 수 있고, 기기는 그 이름을 전달하지 않습니다. 누락된 항목({pending}개)을 추가하려면 기기가 아닌 SmartThings 앱에서 이 기기를 열고 \"다운로드 코스\"를 탭하세요. 이는 화면 아래쪽에 있는 \"코스\"와는 별도의 항목이며, 여기서 중요한 것은 이 항목입니다. 그런 다음 다운로드된 프로그램을 하나씩 차례로 실행하면서 LocalThings가 인식할 수 있도록 몇 초씩 머무른 뒤 이 화면으로 돌아오세요.\n\n각 코스에 Home Assistant에서 보고 싶은 이름을 입력하세요. 이름을 비워 두면 해당 코스는 코스 목록에서 제외됩니다. 이름은 서로 달라야 합니다.\n\n다운로드 코스는 이 기기에서 다운로드된 프로그램을 실행하는 코스입니다. 자동으로 감지되지만 사용하기 전에 여기서 확인하세요. 다운로드한 코스를 선택하면 이 코스 코드가 기기에 기록됩니다.",
"data": {
"download_course": "다운로드 코스 코드"
}
},
"cloud_courses": {
"title": "다운로드 코스",
"description": "안내 설정은 기기와 함께 진행됩니다. 기기에서 다운로드한 코스를 선택하고, 코스가 확인될 때마다 하나씩 이름을 지정하세요.\n\n이름 편집은 지금까지 찾은 모든 코스를 한 번에 보여줍니다. 나중에 이름을 바꾸거나 수정할 때 사용하세요.",
"description": "안내 설정은 다음과 같이 진행됩니다. 기기가 아닌 SmartThings 앱에서 이 기기를 열고 \"다운로드 코스\"를 탭하세요. 이는 화면 아래쪽에 있는 \"코스\"와는 별도의 항목이며, 여기서 중요한 것은 이 항목입니다. 그곳에서 코스를 선택한 다음 돌아와서, 코스가 확인될 때마다 하나씩 이름을 지정하세요.\n\n이름 편집은 지금까지 찾은 모든 코스를 한 번에 보여줍니다. 나중에 이름을 바꾸거나 수정할 때 사용하세요. 이름 지정은 어느 경우든 가능하지만, 이름을 지정한 코스는 기기 설정에서 \"다운로드 코스 제공\"이 켜져 있을 때만 선택 가능한 코스로 제공됩니다.",
"menu_options": {
"cloud_guided": "안내 설정",
"cloud_manual": "이름 편집"
@@ -1533,7 +1576,7 @@
},
"cloud_name": {
"title": "이 코스 이름 지정",
"description": "기기가 다운로드한 코스(슬롯 {slot})로 전환되었으며 {remaining} 남았다고 표시됩니다.\n\nHome Assistant에서 보고 싶은 이름을 입력한 다음, 기기에서 다음 코스를 선택하세요. 비워 두면 이 코스는 목록에서 제외됩니다.\n\n지금까지 이름 지정됨 ({named}/{total}개): {named_list}\n\n이름은 서로 달라야 합니다. 이 창은 언제든지 닫을 수 있습니다. 이름은 진행하는 대로 저장됩니다.",
"description": "기기의 \"다운로드 코스\" 슬롯에 이제 새 코스(슬롯 {slot})가 들어 있으며 {remaining} 남았다고 표시됩니다.\n\nHome Assistant에서 보고 싶은 이름을 입력한 다음, SmartThings 앱의 \"다운로드 코스\" 화면에서 아직 \"다운로드됨\"으로 표시되지 않은 다음 코스를 선택하세요. 비워 두면 이 코스는 목록에서 제외됩니다.\n\n지금까지 이름 지정됨 ({named}/{total}개): {named_list}\n\n이름은 서로 달라야 합니다. 이 창은 언제든지 닫을 수 있습니다. 이름은 진행하는 대로 저장됩니다.",
"data": {
"name": "이름",
"download_course": "다운로드 코스 코드"
@@ -1541,7 +1584,7 @@
},
"cloud_timeout": {
"title": "선택된 코스 없음",
"description": "기기에서 아무것도 선택되지 않았습니다. 다운로드 코스로 설정되어 있는지 확인한 다음, 다운로드된 프로그램을 하나씩 실행해 보세요.\n\n지금까지 이름 지정됨 ({named}/{total}개): {named_list}\n\n지금까지 이름을 지정한 항목은 이미 저장되었습니다.",
"description": "기기에서 변경된 내용이 없습니다. SmartThings 앱에서 \"다운로드 코스\"(화면 아래쪽에 있는 \"코스\"와는 별도의 항목)를 탭하고, 아직 \"다운로드됨\"으로 표시되지 않은 코스를 선택한 다음 완료를 탭했는지 확인하세요.\n\n지금까지 이름 지정됨 ({named}/{total}개): {named_list}\n\n지금까지 이름을 지정한 항목은 이미 저장되었습니다.",
"menu_options": {
"cloud_guided": "다시 대기",
"cloud_finish": "완료"
@@ -1558,7 +1601,7 @@
"not_loaded": "이 기기는 아직 연결되지 않았습니다. 기기를 불러온 후 다시 시도하세요."
},
"progress": {
"cloud_wait": "지금 기기에서 다운로드한 코스를 선택하세요.\n\n지금까지 이름 지정됨 ({named}/{total}개): {named_list}\n\n이 창은 언제든지 닫을 수 있습니다. 이름은 진행하는 대로 저장됩니다."
"cloud_wait": "기기가 아닌 SmartThings 앱에서 이 기기를 열고 \"다운로드 코스\"를 탭하세요. 이는 화면 아래쪽에 있는 \"코스\"와는 별도의 항목이며, 이 방식으로 인식할 수 있는 코스만 나열됩니다. 아직 \"다운로드됨\"으로 표시되지 않은 코스를 선택한 다음 완료를 탭하세요.\n\n기기가 근처에 있거나 코스를 실행 중일 필요는 없습니다. 전원이 켜져 있고 연결되어 있기만 하면 됩니다.\n\n지금까지 이름 지정됨 ({named}/{total}개): {named_list}\n\n이 창은 언제든지 닫을 수 있습니다. 이름은 진행하는 대로 저장됩니다."
}
},
"issues": {
@@ -1568,7 +1611,7 @@
},
"cloud_courses_undiscovered": {
"title": "{device_name}의 다운로드 코스가 설정되지 않음",
"description": "{device_name}에는 Home Assistant가 아직 제공할 수 없는 다운로드한 코스가 {total}개 중 {pending}개 있습니다. 다운로드한 코스는 기기에서 해당 코스가 로드된 상태로 확인되고 이름을 지정한 후에만 사용할 수 있습니다.\n\n설정하려면 설정 > 기기 및 서비스 > LocalThings > {device_name} > 구성 > 다운로드 코스로 이동하여 안내를 따르세요."
"description": "{device_name}에는 Home Assistant가 아직 제공할 수 없는 다운로드한 코스가 {total}개 중 {pending}개 있습니다. 다운로드한 코스는 SmartThings 앱의 \"다운로드 코스\" 화면(\"코스\" 화면이 아님)에서 기기가 해당 코스로 로드된 상태로 확인되고 이름을 지정한 후에만 사용할 수 있습니다.\n\n설정하려면 설정 > 기기 및 서비스 > LocalThings > {device_name} > 구성 > 다운로드 코스로 이동하여 안내를 따르세요.\n\n다운로드 코스를 사용하지 않나요? 대신 구성 > 기기 설정으로 이동하여 \"다운로드 코스 제공\"을 꺼서 이 알림을 완전히 중지하세요."
}
},
"exceptions": {
@@ -376,7 +376,25 @@
"53": "AI drogen+",
"4e": "Zelf drogen",
"26": "Luchtverfrissing",
"2a": "Hygiënische verzorging+"
"2a": "Hygiënische verzorging+",
"02": "AI drogen",
"03": "Super Speed",
"05": "Beddengoed",
"07": "Fijne was",
"09": "Overhemden",
"0b": "Paddingverzorging",
"0c": "Waterafstotende outdoorverzorging",
"0e": "Handdoeken",
"0f": "Wol",
"11": "Koude lucht",
"28": "Interne desinfectie met hete lucht",
"37": "Blouses",
"38": "Strijkdroog",
"39": "Ruimte ontvochtigen",
"3a": "Hygiënisch drogen",
"3b": "Beddengoed/Stof verwijderen",
"3c": "Sportkleding",
"3d": "Spijkergoed"
}
},
"favorite_capacity": {
@@ -647,11 +665,31 @@
"name": "Programma",
"state": {
"01": "Normaal",
"02": "Extra intensief",
"03": "Super Eco",
"04": "Snelle was",
"06": "XXL was",
"05": "Wol/Lingerie",
"06": "Beddengoed",
"07": "Outdoor",
"08": "Spoelen+centrifugeren",
"09": "Trommel reinigen",
"0a": "Handdoeken",
"0b": "Kookwas",
"0c": "Babyverzorging",
"0d": "Alleen centrifugeren",
"0e": "Bewolkte dag",
"0f": "Zuivere was",
"10": "Droogcentrifugeren",
"11": "Zomer beddengoed",
"12": "Katoen",
"13": "Zwart katoen",
"14": "Fijn ondergoed",
"15": "Sportkleding",
"16": "Blouses",
"17": "Gedownload",
"18": "Soft Bubble",
"19": "AI Wash",
"1a": "Overhemden",
"1b": "Katoen",
"1c": "Eco 40-60",
"1d": "Super Speed",
@@ -676,6 +714,7 @@
"32": "Overhemden",
"33": "Handdoeken",
"34": "Gemengd",
"35": "Eco katoen",
"36": "Wassen+drogen",
"37": "Air Wash",
"38": "Katoen drogen",
@@ -717,8 +756,7 @@
"8f": "Intensief koud",
"96": "Minder microvezels",
"a0": "15' Snelle was",
"b0": "Gemengde was",
"35": "Eco katoen"
"b0": "Gemengde was"
}
},
"washer_dry_level": {
@@ -958,7 +996,10 @@
"name": "Droogtijd"
},
"dryer_type": {
"name": "Type droger"
"name": "Type droger",
"state": {
"electricity": "Elektriciteit"
}
},
"dust": {
"name": "Stof"
@@ -1098,6 +1139,7 @@
"steaming": "Stomen",
"airwashing": "Luchtreiniging",
"drying": "Drogen",
"dryingwithdooropen": "Ventileren",
"cooling": "Koelen",
"predrain": "Vooraf afpompen",
"prewash": "Voorwas"
@@ -1480,11 +1522,12 @@
},
"settings": {
"title": "Apparaatinstellingen",
"description": "Sommige apparaten accepteren bepaalde schrijfbewerkingen (bijvoorbeeld de standaarddosering van wasmiddel of wasverzachter op een wasmachine), ook als ze melden dat de afstandsbediening is uitgeschakeld. LocalThings blokkeert standaard elke schrijfbewerking met een duidelijke foutmelding wanneer een apparaat meldt dat de afstandsbediening is uitgeschakeld, in plaats van het apparaat de opdracht stilzwijgend te laten weigeren. Schakel deze optie alleen in als je hebt bevestigd dat schrijfbewerkingen op dit apparaat echt werken wanneer de afstandsbediening is uitgeschakeld. Anders verruil je de duidelijke foutmelding voor een stille mislukking.\n\nDe geschatte eindtijd wordt bij elke poll opnieuw berekend op basis van de resterende tijd die het apparaat opgeeft, wat kan afwijken of met een minuut of wat worden bijgesteld tussen updates. Verhoog de minimale wijziging hieronder om de sensor op zijn laatst gerapporteerde waarde te houden totdat de schatting met minstens dat aantal minuten verandert, wat de ruis in geschiedenis/logboek vermindert. Zet op 0 om elke berekende wijziging te rapporteren.\n\nSommige modellen melden een modus die ze nooit als ondersteund opgeven: bijvoorbeeld een airco die in Quiet staat maar alleen Off/Sleep/Speed aanbiedt. LocalThings onthoudt elke zo waargenomen modus en blijft die aanbieden, zodat hij selecteerbaar blijft zodra het apparaat er minstens één keer in heeft gestaan. Schakel dit uit om alleen aan te bieden wat het apparaat opgeeft; gebruik \"Onthouden modi vergeten\" in het vorige scherm om te wissen wat al is onthouden.",
"description": "Sommige apparaten accepteren bepaalde schrijfbewerkingen (bijvoorbeeld de standaarddosering van wasmiddel of wasverzachter op een wasmachine), ook als ze melden dat de afstandsbediening is uitgeschakeld. LocalThings blokkeert standaard elke schrijfbewerking met een duidelijke foutmelding wanneer een apparaat meldt dat de afstandsbediening is uitgeschakeld, in plaats van het apparaat de opdracht stilzwijgend te laten weigeren. Schakel deze optie alleen in als je hebt bevestigd dat schrijfbewerkingen op dit apparaat echt werken wanneer de afstandsbediening is uitgeschakeld. Anders verruil je de duidelijke foutmelding voor een stille mislukking.\n\nDe geschatte eindtijd wordt bij elke poll opnieuw berekend op basis van de resterende tijd die het apparaat opgeeft, wat kan afwijken of met een minuut of wat worden bijgesteld tussen updates. Verhoog de minimale wijziging hieronder om de sensor op zijn laatst gerapporteerde waarde te houden totdat de schatting met minstens dat aantal minuten verandert, wat de ruis in geschiedenis/logboek vermindert. Zet op 0 om elke berekende wijziging te rapporteren.\n\nSommige modellen melden een modus die ze nooit als ondersteund opgeven: bijvoorbeeld een airco die in Quiet staat maar alleen Off/Sleep/Speed aanbiedt. LocalThings onthoudt elke zo waargenomen modus en blijft die aanbieden, zodat hij selecteerbaar blijft zodra het apparaat er minstens één keer in heeft gestaan. Schakel dit uit om alleen aan te bieden wat het apparaat opgeeft; gebruik \"Onthouden modi vergeten\" in het vorige scherm om te wissen wat al is onthouden. Een wasapparaat met uit de cloud gedownloade programma's krijgt hier een herinnering zodra het er een meldt die Home Assistant nog niet kan aanbieden -- sommige apparaten melden er een, ook als je het scherm \"Gedownloade programma's\" in de SmartThings-app zelf nooit hebt geopend. Zet dit uit om die herinnering te stoppen en al benoemde gedownloade programma's uit de programmalijst te verbergen; niets van wat al is ingesteld gaat verloren, en bij opnieuw inschakelen verschijnt het meteen weer.",
"data": {
"bypass_remote_control_lock": "Schrijfbewerkingen toestaan wanneer afstandsbediening als uitgeschakeld wordt gemeld",
"finish_time_hysteresis_minutes": "Geschatte eindtijd -- minimale wijziging (minuten)",
"learn_device_modes": "Modi onthouden die het apparaat meldt maar niet als ondersteund opgeeft"
"learn_device_modes": "Modi onthouden die het apparaat meldt maar niet als ondersteund opgeeft",
"cloud_courses_enabled": "Gedownloade programma's aanbieden"
}
},
"forget_learned_modes": {
@@ -1515,14 +1558,14 @@
},
"cloud_manual": {
"title": "Gedownloade programma's",
"description": "Dit apparaat meldt {total} gedownloade programma's; {found} daarvan zijn tot nu toe gezien.\n\nDe instellingen van een gedownload programma zijn alleen zichtbaar zolang dat programma geladen is, en het apparaat meldt nooit de naam ervan. Om de ontbrekende ({pending}) toe te voegen: selecteer op het apparaat het programma \"Gedownload\", doorloop dan elk gedownload programma na elkaar, pauzeer bij elk een paar seconden, en kom hierna terug.\n\nGeef elk programma de naam die je in Home Assistant wilt zien. Laat een naam leeg om dat programma buiten de programmalijst te houden. Namen moeten uniek zijn.\n\nHet programma \"Gedownload\" is het programma op dit apparaat dat een gedownload programma uitvoert. Het wordt automatisch gedetecteerd, maar bevestig dit hier voor gebruik: als je een gedownload programma selecteert, wordt deze programmacode naar het apparaat geschreven.",
"description": "Dit apparaat meldt {total} gedownloade programma's; {found} daarvan zijn tot nu toe gezien.\n\nDe instellingen van een gedownload programma zijn alleen zichtbaar zolang dat programma geladen is, en het apparaat meldt nooit de naam ervan. Om de ontbrekende ({pending}) toe te voegen: open in de SmartThings-app (niet op het apparaat zelf) dit apparaat en tik op \"Gedownloade programma's\" -- een aparte regel, los van \"Programma\", verderop op het scherm, en degene die hier telt. Doorloop dan elk gedownload programma na elkaar, pauzeer bij elk een paar seconden zodat LocalThings het kan zien, en kom hierna terug.\n\nGeef elk programma de naam die je in Home Assistant wilt zien. Laat een naam leeg om dat programma buiten de programmalijst te houden. Namen moeten uniek zijn.\n\nHet programma \"Gedownload\" is het programma op dit apparaat dat een gedownload programma uitvoert. Het wordt automatisch gedetecteerd, maar bevestig dit hier voor gebruik: als je een gedownload programma selecteert, wordt deze programmacode naar het apparaat geschreven.",
"data": {
"download_course": "Programmacode van \"Gedownload\""
}
},
"cloud_courses": {
"title": "Gedownloade programma's",
"description": "Begeleide installatie loodst je stap voor stap langs het apparaat: selecteer op het apparaat een gedownload programma en geef het een naam zodra het gevonden is, één voor één.\n\nNamen bewerken toont in één keer alles wat tot nu toe is gevonden — gebruik dit later om iets te hernoemen of te corrigeren.",
"description": "Begeleide installatie loodst je stap voor stap: open in de SmartThings-app (niet op het apparaat zelf) dit apparaat en tik op \"Gedownloade programma's\" -- een aparte regel, los van \"Programma\", verderop op het scherm, en degene die hier telt. Kies daar een programma, kom terug en geef het een naam zodra het gevonden is, één voor één.\n\nNamen bewerken toont in één keer alles wat tot nu toe is gevonden — gebruik dit later om iets te hernoemen of te corrigeren. Namen geven werkt sowieso, maar een benoemd programma wordt alleen als selecteerbaar programma aangeboden zolang \"Gedownloade programma's aanbieden\" aan staat in de apparaatinstellingen.",
"menu_options": {
"cloud_guided": "Begeleide installatie",
"cloud_manual": "Namen bewerken"
@@ -1533,7 +1576,7 @@
},
"cloud_name": {
"title": "Geef dit programma een naam",
"description": "Het apparaat is overgeschakeld naar een gedownload programma (slot {slot}) en meldt nog {remaining} resterend.\n\nGeef het de naam die je in Home Assistant wilt zien en selecteer daarna het volgende op het apparaat. Laat het veld leeg om dit programma buiten de lijst te houden.\n\nTot nu toe benoemd ({named} van {total}): {named_list}\n\nNamen moeten uniek zijn. Je kunt dit venster op elk moment sluiten — namen worden onderweg opgeslagen.",
"description": "Het vak \"Gedownloade programma's\" van het apparaat bevat nu een nieuw programma (slot {slot}) en meldt nog {remaining} resterend.\n\nGeef het de naam die je in Home Assistant wilt zien en kies daarna in het scherm \"Gedownloade programma's\" van de SmartThings-app het volgende programma dat nog niet als \"Gedownload\" is gemarkeerd. Laat het veld leeg om dit programma buiten de lijst te houden.\n\nTot nu toe benoemd ({named} van {total}): {named_list}\n\nNamen moeten uniek zijn. Je kunt dit venster op elk moment sluiten — namen worden onderweg opgeslagen.",
"data": {
"name": "Naam",
"download_course": "Programmacode van \"Gedownload\""
@@ -1541,7 +1584,7 @@
},
"cloud_timeout": {
"title": "Geen programma geselecteerd",
"description": "Er is niets geselecteerd op het apparaat. Zorg dat het is ingesteld op het programma \"Gedownload\" en doorloop dan de gedownloade programma's.\n\nTot nu toe benoemd ({named} van {total}): {named_list}\n\nAlles wat tot nu toe benoemd is, is al opgeslagen.",
"description": "Er is niets veranderd op het apparaat. Zorg dat je in de SmartThings-app op \"Gedownloade programma's\" hebt getikt -- een aparte regel, los van \"Programma\", verderop op het scherm -- en een programma hebt gekozen dat nog niet als \"Gedownload\" was gemarkeerd, en daarna op Klaar hebt getikt.\n\nTot nu toe benoemd ({named} van {total}): {named_list}\n\nAlles wat tot nu toe benoemd is, is al opgeslagen.",
"menu_options": {
"cloud_guided": "Opnieuw wachten",
"cloud_finish": "Voltooien"
@@ -1558,7 +1601,7 @@
"not_loaded": "Dit apparaat is nog niet verbonden. Probeer het opnieuw zodra het is geladen."
},
"progress": {
"cloud_wait": "Selecteer nu een gedownload programma op het apparaat.\n\nTot nu toe benoemd ({named} van {total}): {named_list}\n\nJe kunt dit venster op elk moment sluiten — namen worden onderweg opgeslagen."
"cloud_wait": "Open in de SmartThings-app (niet op het apparaat zelf) dit apparaat en tik op \"Gedownloade programma's\" -- een aparte regel, los van \"Programma\", verderop op het scherm, die alleen de programma's toont die op deze manier te herkennen zijn. Kies er een die nog niet als \"Gedownload\" is gemarkeerd en tik op Klaar.\n\nHet apparaat hoeft niet in de buurt te zijn of het programma uit te voeren -- het moet alleen aan staan en verbonden zijn.\n\nTot nu toe benoemd ({named} van {total}): {named_list}\n\nJe kunt dit venster op elk moment sluiten — namen worden onderweg opgeslagen."
}
},
"issues": {
@@ -1568,7 +1611,7 @@
},
"cloud_courses_undiscovered": {
"title": "Gedownloade programma's niet ingesteld voor {device_name}",
"description": "{device_name} heeft {pending} van de {total} gedownloade programma's die Home Assistant nog niet kan aanbieden. Een gedownload programma kan pas worden gebruikt zodra het apparaat ermee geladen is gezien en je het een naam hebt gegeven.\n\nGa om ze in te stellen naar Instellingen > Apparaten & diensten > LocalThings > {device_name} > Configureren > Gedownloade programma's en volg de instructies daar."
"description": "{device_name} heeft {pending} van de {total} gedownloade programma's die Home Assistant nog niet kan aanbieden. Een gedownload programma kan pas worden gebruikt zodra het apparaat ermee geladen is gezien -- in het scherm \"Gedownloade programma's\" van de SmartThings-app, niet in \"Programma\" -- en je het een naam hebt gegeven.\n\nGa om ze in te stellen naar Instellingen > Apparaten & diensten > LocalThings > {device_name} > Configureren > Gedownloade programma's en volg de instructies daar.\n\nGebruik je geen gedownloade programma's? Ga dan naar Configureren > Apparaatinstellingen en zet \"Gedownloade programma's aanbieden\" uit om deze herinnering voorgoed te stoppen."
}
},
"exceptions": {
+75
View File
@@ -15,6 +15,12 @@ that follows them explains why the reset looked cloud-only for as long as it
did — a genuine trap worth knowing about before the next reset-adjacent
mystery on this board family.
Scope: all of the above applies to boards that carry the counter as a
`FilterTime_<N>` option token on `/mode/vs/0`. Not every AC does. See
"Boards with no `FilterTime` token" at the end for an `ARTIK051_PRAC_20K`
that keeps the counter in its own resource, rejects both the token and a
direct write, and has no local reset at all.
## What the reset actually is
A **command**, not a value write. Samsung's cloud models it as capability
@@ -92,3 +98,72 @@ on every unit on record).
The entity key stays `filter_time` rather than `filter_time_elapsed`:
renaming it would change every existing unit's `entity_id`/`unique_id` for a
wording improvement only.
## Boards with no `FilterTime` token (`ARTIK051_PRAC_20K`)
Negative result, measured 2026-08-11 on integration v0.21.0 / HA 2026.8.1,
against one head of a three-head multi-split (`OptionCode_35880`,
`ExtendOptionCode_199181`). **There is no local reset on this board**, by
either route. Reset appears to be panel-only.
This generation does not put the counter in `/mode/vs/0` at all. Its
options blob carries no `FilterTime`, no `FilterAlarmTime` and no
`FilterCleanAlarm`:
```json
["Sleep_0", "ArtificialWorking_Off", "ComfortAICooling_Off",
"AiTempChanged_Off", "AiTemp_240", "OutdoorTemp_77", "CoolCapa_25",
"WarmCapa_32", "Light_Off", "Volume_100", "OptionCode_35880",
"ExtendOptionCode_199181", "RacInfo_None", "UpdateAllow_NotAllowed",
"DurationOn_0", "WelcomeCoolingState_Off"]
```
The counter lives in its own resource instead, as a **percentage** of a
500-hour interval rather than tenths of an hour —
`/filter/airdustfilter/vs/0`:
```json
{
"x.com.samsung.da.filterUsage": "96",
"x.com.samsung.da.filterUsageResolution": "1",
"x.com.samsung.da.filterDesiredUsage": "500",
"x.com.samsung.da.filterStatus": "normal",
"x.com.samsung.da.filterCapacity": "500",
"x.com.samsung.da.filterCapacityUnit": "Hour",
"x.com.samsung.da.filterResetType": ["replaceable", "washable"]
}
```
Three attempts, all against a live unit deliberately put in `fan_only`
first — writes to a powered-off head on this board are dropped silently
with no error, which would otherwise be indistinguishable from a rejected
write:
| Target | Payload | Result |
|---|---|---|
| `/mode/vs/0` | `{"x.com.samsung.da.options": ["FilterCleanAlarm_Clear"]}` | 4.00, options blob byte-identical |
| `/filter/airdustfilter/vs/0` | `{"x.com.samsung.da.filterUsage": "0"}` | 4.00 |
| `/filter/airdustfilter/vs/0` | `{"x.com.samsung.da.filterUsage": 0}` (integer) | 5.00 |
`filterUsage` stayed at `96` throughout, verified by a live re-read after
each write rather than by the integration's optimistic state.
The last two rows are the informative pair. They differ only in JSON type
and return *different* codes, which rules out both boring explanations: an
unresolved href or an unrecognised field name would fail identically. The
board parses the field, faults on the wrong type, and still refuses the
value when typed as the string its own rep uses. The resource is
**read-only**, not mis-addressed.
One trap worth stating plainly: `filterResetType:
["replaceable","washable"]` describes what the filter *is*, not a reset
command that exists. It reads like a hint that a reset write is available
somewhere. It is not.
Since the integration cannot perform the reset here, it can still observe
it. The counter only climbs in normal use, so a downward crossing is
unambiguous: a `numeric_state` trigger with `below: 10` on
`sensor.<name>_filter_usage`, stamping an `input_datetime`, keeps an
honest "last cleaned" date without pretending a reset entity exists. The
blind spot is a reset performed while HA is down or the entry is
unloaded — no state transition, so that stamp has to be set by hand.
+187
View File
@@ -0,0 +1,187 @@
# Loading a config entry while the appliance is offline
Issue #295 asks for faster recovery when a powered-off appliance comes back,
instead of waiting out HA's `ConfigEntryNotReady` backoff. PR #303 tried to
get there by catching the first-refresh failure in `async_setup_entry` and
loading the entry anyway.
That doesn't work here, and the reason is worth writing down: this
integration has no static entity list. Every entity comes from discovery,
and discovery only happens inside a successful poll.
(The issue's "up to 15 minutes" is out of date, incidentally. Current HA
retries on `2 ** min(tries, 4) * 5` seconds — capped at 80s, not 900. The
backoff was never the worst part; a device card reading "Retrying setup" with
no entities behind it is.)
## What PR #303 produces today
Measured on the PR's branch — set up with `_poll_once` raising, then advance
the clock four summary intervals:
| | |
| --- | --- |
| entry state | `LOADED` |
| `coordinator.bound` | 0 |
| entities in the state machine | 0 |
| registry entries | 1 (the disabled connection-mode sensor) |
| coordinator listeners | 0 |
| `_unsub_refresh` | `None` |
| poll attempts over the next 4 intervals | **0** |
The entry loads and then never polls again. `DataUpdateCoordinator._async_refresh`
reschedules only `if not auth_failed and self._listeners and not
self.hass.is_stopping`; with no bound entities the only unconditional entity
is `LocalThingsConnectionModeSensor`, which is
`entity_registry_enabled_default = False` and so never added and never
subscribes. Nothing reloads the entry either. The device comes back online to
an entry that is permanently empty until a manual reload — strictly worse
than the backoff it replaces, which did recover on its own within 15 minutes.
## Why entities can't just be created offline
Four independent gates, all of which need live device data:
1. `bound` is only ever assigned in `_run_discovery` (`coordinator.py:1081`),
which runs on a poll's `resources` dict.
2. All ten platforms enumerate `coordinator.bound` exactly once, at forward
time (`sensor.py:34` and siblings). Nothing adds entities later — the
invariant is already documented at `coordinator.py:1283-1286`.
3. `_is_included` (`entity.py:39`) returns False whenever `last_resources`
has no rep for the href. Even a fully reconstructed `bound` filters to
nothing while `StateCache` is empty.
4. `LocalThingsEntity` is a bare `CoordinatorEntity` with no `available`
override, no `RestoreEntity`, and no `Store` anywhere in the component. An
entity that did exist offline would be `unavailable` with no state.
The issue cites ESPHome, Shelly, LIFX and WLED as precedent for setup that
never fails. Those integrations can do it because each one has a *persisted
device description* to build entities from — ESPHome keeps its entity list in
`.storage`, Shelly caches device info. The pattern is portable; the mechanism
underneath it is the part PR #303 is missing.
## How the implemented version works
Three pieces, plus a gating rule.
### 1. A persisted discovery snapshot
After each successful first cycle, `_save_snapshot` banks exactly the
`resources` dict that cycle handed `_run_discovery`, along with the
pre-narrowing subdevice candidate list and the `DeviceIdentity` read from
`/oic/*`.
Storing the poll input rather than a rendered entity list is the decision
that keeps this honest. `BoundEntity` holds live
`Capability`/`SamsungEntityDescription` objects and isn't serializable, so
the alternative was a parallel format plus a re-resolution path — a second
implementation of discovery that could drift from the real one. Replaying the
input through `_run_discovery` means the same code, the same registry
resolution, and no second source of truth.
Three things ride along because `_run_discovery` reads them off `self`
rather than out of `resources`, and getting them wrong would silently resolve
a *different* registry offline than online — which reconciliation below would
then see as a real change and reload on every restart:
- `_identity.device_types` routes `resolve_registry`.
- `self.subdevices` is the candidate list `discover_partitioned` narrows;
replaying against the already-narrowed list finds no siblings at all.
- `_identity.manufacturer`/`model` feed `device_info`.
It lives in `.storage` (`Store`, keyed on entry_id) rather than on the config
entry: it's device state, not configuration, and runs to tens of kilobytes.
`async_remove_entry` deletes it with the entry.
The write is awaited, not `async_delay_save`d. A deferred write outlives
whatever queued it: it lands after `async_remove_entry` has deleted the file
and recreates it orphaned, and a reload scheduled by the reconcile below
would read the pre-reload snapshot back off disk. It runs once per entry
load, so there's nothing worth deferring. A write that fails is logged and
swallowed — a board reporting something the JSON encoder rejects must not
break polling.
### 2. Reconcile on reconnect
The snapshot is a claim about a device we haven't talked to yet. When the
first live poll lands, `_reconcile_rehydrated` compares the live entity set
against the rehydrated one — as `(subdevice key, _key(bound))` pairs, which
is the unique_id identity — and calls `async_schedule_reload` if they differ.
Gate 2 above is why this has to be a reload rather than an in-place fixup.
It's what makes the feature safe against a firmware update, a sibling
subdevice that starts answering, or a different appliance at the same IP.
### 3. Keep polling with no listeners
`async_setup_entry` holds one listener for the entry's lifetime:
```python
entry.async_on_unload(coordinator.async_add_listener(lambda: None))
```
Registered *before* the first refresh, so scheduling survives a refresh that
fails. This alone fixes the measured "never polls again" bug, and covers a
rehydrated set whose entities are all registry-disabled. Removing the last
listener unschedules the timer, and HA runs `async_on_unload` callbacks when
setup raises, so the setup-retry path doesn't leak a polling coordinator.
### Gating rule: only load offline when there's a snapshot
An entry that has never successfully polled has nothing to restore and keeps
raising `ConfigEntryNotReady`. This is what answers the objection in the PR
thread — with a snapshot we *do* have metadata to build a device from, and
without one HA's backoff is still the right behavior. It also leaves room for
the #168-style flows that need to interact with the device during setup: a
device that never completed setup still blocks.
It also means `async_remove_config_entry_device` is no longer reachable with
an empty `coordinator.subdevices`, so an offline load can't offer to delete a
real-but-unreachable subdevice.
### The coverage-gap Repair stays live-only
`_run_discovery(..., from_snapshot=True)` skips `_update_coverage_gap_issue`.
The Repair points the user at a diagnostics download, which is empty until
the appliance answers, and a device name that drifts between the snapshot and
the live poll would churn the issue for no reason.
Not a de-duplication measure — HA already handles that. `async_create_issue`
is keyed on `(domain, issue_id)`, `dataclasses.replace` in
`async_get_or_create` leaves `dismissed_version` alone, and the registry
reloads non-persistent issues with their dismissal intact, so one row per
entry survives restarts and an "Ignore" sticks.
## What this still won't do
Entities will be present and `unavailable` — not showing their last values.
Gate 4 means last-known values require either `RestoreEntity` per platform or
persisting `StateCache`, and both mean asserting state the integration cannot
verify: a washer unplugged for a week would read "Running". HA's convention
is that unreachable means unavailable, and the recorder keeps the history
either way, so long-term statistics and history graphs are unaffected by this
choice.
Worth being explicit about, because it is the gap between what PR #303
promises in the thread ("load their previously recorded states") and what any
correct version can deliver.
## Rejected: zeroconf
The issue's other suggestion — wire zeroconf so the device's own boot
announcement triggers a retry, which is the genuinely idiomatic HA answer —
is a non-starter as things stand: there is no `zeroconf` or `dhcp` key in
`manifest.json` and the config flow is user-driven only, so HA has no
discovery signal for this integration to hang a retry on. It would first need
a confirmed mDNS service on the appliance. Worth revisiting if one turns up;
it would make recovery near-instant instead of within one poll interval.
## Rejected: the cheap version
Keeping `ConfigEntryNotReady` and adding a probe that calls
`async_schedule_reload` on first success would have fixed the recovery *time*
in about twenty lines, with no persistence and no reconcile. It was rejected
because it leaves the device reading as broken for as long as the appliance
is off, which is the half of issue #295 that actually bites — an appliance
switched off at the wall is offline for days, not seconds, and a whole
integration that looks failed for that entire window is the complaint.
+1 -1
View File
@@ -6,7 +6,7 @@ pytest-homeassistant-custom-component>=0.13.316
# Integration runtime deps, needed to import the component under test
# (also declared in custom_components/localthings/manifest.json).
smartthings-local>=0.1.6
smartthings-local>=0.1.8
cbor2>=5.4.6
pyOpenSSL>=23.0
cryptography>=41.0
+1
View File
@@ -20,6 +20,7 @@
"fine_dust",
"humidity",
"odor",
"outdoor_temperature",
"power_watts",
"super_fine_dust",
"tropical_night_mode"
+1
View File
@@ -25,6 +25,7 @@
"mute_once",
"odor_controller_active",
"odor_controller_progress",
"outdoor_temperature",
"power_energy_kwh",
"power_watts",
"selfcheck_error",
@@ -18,6 +18,7 @@
"mute_once",
"odor_controller_active",
"odor_controller_progress",
"outdoor_temperature",
"power_watts",
"tropical_night_mode"
]
+1
View File
@@ -36,6 +36,7 @@
"mute_once",
"odor_controller_active",
"odor_controller_progress",
"outdoor_temperature",
"periodic_air_sensing",
"periodic_sensing_skip_status",
"power_watts",
+1
View File
@@ -17,6 +17,7 @@
"firmware_update",
"humidity",
"mute_once",
"outdoor_temperature",
"power_watts",
"tropical_night_mode"
]
@@ -23,6 +23,7 @@
"last_air_sensing_level",
"last_air_sensing_time",
"mute_once",
"outdoor_temperature",
"periodic_air_sensing",
"periodic_sensing_skip_status",
"selfcheck_error",
+1
View File
@@ -19,6 +19,7 @@
"energy_saved_kwh",
"firmware_update",
"mute_once",
"outdoor_temperature",
"selfcheck_error",
"selfcheck_result",
"selfcheck_status",
@@ -18,6 +18,7 @@
"mute_once",
"odor_controller_active",
"odor_controller_progress",
"outdoor_temperature",
"power_watts",
"tropical_night_mode"
]
@@ -16,6 +16,7 @@
"firmware_update",
"humidity",
"mute_once",
"outdoor_temperature",
"power_watts",
"tropical_night_mode"
]
@@ -20,6 +20,7 @@
"mute_once",
"odor_controller_active",
"odor_controller_progress",
"outdoor_temperature",
"selfcheck_error",
"selfcheck_result",
"selfcheck_status",
@@ -15,6 +15,7 @@
"firmware_update",
"humidity",
"mute_once",
"outdoor_temperature",
"power_watts",
"tropical_night_mode"
]
+1
View File
@@ -19,6 +19,7 @@
"fine_dust",
"humidity",
"odor",
"outdoor_temperature",
"power_watts",
"super_fine_dust",
"tropical_night_mode"
+1
View File
@@ -16,6 +16,7 @@
"firmware_update",
"humidity",
"mute_once",
"outdoor_temperature",
"power_watts",
"selfcheck_error",
"selfcheck_result",
+38
View File
@@ -15,6 +15,7 @@ from custom_components.localthings.const import (
CONF_BYPASS_REMOTE_CONTROL,
CONF_CA_CERT_PEM,
CONF_CA_KEY_PEM,
CONF_CLOUD_COURSES_ENABLED,
CONF_HOST,
CONF_LEAF_CERT_PEM,
CONF_LEARN_MODES,
@@ -1180,6 +1181,43 @@ async def test_learned_modes_option_can_be_turned_off(hass: HomeAssistant) -> No
assert entry.options[CONF_LEARN_MODES] is False
async def test_cloud_courses_enabled_option_defaults_to_on(hass: HomeAssistant) -> None:
"""Issue #364's toggle starts on -- devices that already have a working
downloaded-cycle setup keep it without having to find the option first."""
entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id=f"localthings_{MOCK_SERIAL}")
entry.add_to_hass(hass)
result = await hass.config_entries.options.async_init(entry.entry_id)
result = await hass.config_entries.options.async_configure(
result["flow_id"], user_input={"next_step_id": "settings"}
)
data_schema = result["data_schema"]
assert data_schema is not None
assert data_schema({})[CONF_CLOUD_COURSES_ENABLED] is True
async def test_cloud_courses_enabled_option_can_be_turned_off(hass: HomeAssistant) -> None:
entry = MockConfigEntry(domain=DOMAIN, data=ENTRY_DATA, unique_id=f"localthings_{MOCK_SERIAL}")
entry.add_to_hass(hass)
result = await hass.config_entries.options.async_init(entry.entry_id)
result = await hass.config_entries.options.async_configure(
result["flow_id"], user_input={"next_step_id": "settings"}
)
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={
CONF_BYPASS_REMOTE_CONTROL: False,
CONF_LEARN_MODES: True,
CONF_CLOUD_COURSES_ENABLED: False,
},
)
assert result["type"] == FlowResultType.CREATE_ENTRY
assert entry.options[CONF_CLOUD_COURSES_ENABLED] is False
@pytest.mark.parametrize(
("stored", "listed"),
[
+11 -5
View File
@@ -71,7 +71,12 @@ async def test_summary_interval(hass: HomeAssistant, mock_entry, mock_coordinato
async def test_update_failed_on_persistent_poll_error(hass: HomeAssistant, mock_entry) -> None:
"""ConfigEntryNotReady raised when poll fails even after reconnect."""
"""ConfigEntryNotReady raised when poll fails even after reconnect.
An entry with no stored discovery snapshot has never reached this device,
so there is nothing to load offline from (issue #295) -- it stays on HA's
backoff rather than loading empty.
"""
with (
patch("custom_components.localthings.coordinator.LocalThingsCoordinator._connect_session"),
@@ -1824,10 +1829,11 @@ async def test_first_refresh_timeout_recovers_via_reconnect(
async def test_first_refresh_persistent_timeout_fails_setup(
hass: HomeAssistant, mock_entry
) -> None:
"""When the reconnect times out too, the first refresh must fail so HA
retries on its backoff -- not load an entity-less entry. The session it
left open is closed on the way out (`_poll_once` keeps it up on a
`TimeoutError`, and the source port is fixed per device)."""
"""With no snapshot to load from, a reconnect that times out too must
fail the first refresh so HA retries on its backoff -- not load an
entity-less entry. The session it left open is closed on the way out
(`_poll_once` keeps it up on a `TimeoutError`, and the source port is
fixed per device)."""
with (
patch("custom_components.localthings.coordinator.LocalThingsCoordinator._connect_session"),
patch(
+418
View File
@@ -0,0 +1,418 @@
"""Loading a config entry while the appliance is unreachable (issue #295).
The device's entity set only exists as the output of a live poll, so coming
up offline means replaying the last successful discovery from a stored
snapshot. These tests pin the four things that makes load-bearing: the
snapshot gets written, it produces the same entity set offline, the
coordinator keeps polling until the device answers, and a live discovery that
disagrees with the snapshot reloads the entry rather than silently keeping a
stale set.
"""
from __future__ import annotations
from contextlib import contextmanager
from datetime import timedelta
from unittest.mock import patch
import pytest
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
from homeassistant.util import dt as dt_util
from pytest_homeassistant_custom_component.common import async_fire_time_changed
from custom_components.localthings.const import DOMAIN, SUMMARY_INTERVAL_S
from custom_components.localthings.coordinator import LocalThingsCoordinator
from custom_components.localthings.registry.identity import DeviceIdentity
from .conftest import _load_fridge_resources as _load_fridge
_COORD = "custom_components.localthings.coordinator.LocalThingsCoordinator"
@contextmanager
def _reachable(resources: dict, identity: DeviceIdentity | None = None):
"""A device that answers, optionally with an /oic/* identity -- which
`_connect_session` is what normally reads, so a test that patches it out
otherwise leaves `_identity` None."""
def _connect(self) -> None:
self._identity = identity
with (
patch(f"{_COORD}._connect_session", _connect),
patch(f"{_COORD}._poll_once", return_value=resources),
patch(f"{_COORD}._close_session"),
):
yield
@contextmanager
def _unreachable():
with (
patch(f"{_COORD}._connect_session"),
patch(f"{_COORD}._poll_once", side_effect=OSError("device offline")),
patch(f"{_COORD}._close_session"),
):
yield
def _store_key(entry) -> str:
return f"{DOMAIN}.{entry.entry_id}.discovery"
async def _tick(hass: HomeAssistant) -> None:
"""Advance past one summary interval so the coordinator polls again.
`wait_background_tasks` is load-bearing: DataUpdateCoordinator runs its
interval refresh as a background task, which a plain block_till_done
doesn't await -- the poll would still be in flight at the assertion.
"""
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=SUMMARY_INTERVAL_S + 1))
await hass.async_block_till_done(wait_background_tasks=True)
async def _setup_online_then_unload(hass: HomeAssistant, entry, resources: dict) -> set[str]:
"""Bring the entry up against a live device, bank the snapshot, and take
it back down. Returns the entity_ids that run produced."""
with _reachable(resources):
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
entity_ids = {s.entity_id for s in hass.states.async_all()}
await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
return entity_ids
# ---------------------------------------------------------------------------
# Writing the snapshot
# ---------------------------------------------------------------------------
async def test_snapshot_written_after_first_discovery(
hass: HomeAssistant, mock_entry, mock_coordinator_session, hass_storage
) -> None:
"""A successful first cycle banks what it handed _run_discovery."""
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
stored = hass_storage[_store_key(mock_entry)]["data"]
assert stored["resources"]
assert "/information/vs/0" in stored["resources"]
assert "subdevice_candidates" in stored
async def test_snapshot_not_written_when_device_never_answers(
hass: HomeAssistant, mock_entry, hass_storage
) -> None:
"""Nothing to bank, so nothing is -- this is what keeps the no-snapshot
gate meaningful on a brand-new entry."""
with _unreachable():
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
assert mock_entry.state is ConfigEntryState.SETUP_RETRY
assert _store_key(mock_entry) not in hass_storage
async def test_snapshot_removed_when_entry_removed(
hass: HomeAssistant, mock_entry, mock_coordinator_session, hass_storage
) -> None:
"""The store is keyed on entry_id, so re-adding the appliance mints a new
one -- the old file has to go with the entry that wrote it.
The clock is run on afterwards because a deferred write would land here:
with `async_delay_save` the removal was undone a few seconds later by the
save the last poll had queued, leaving the file orphaned for good.
"""
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
assert _store_key(mock_entry) in hass_storage
await hass.config_entries.async_remove(mock_entry.entry_id)
await hass.async_block_till_done()
assert hass_storage.get(_store_key(mock_entry), {}).get("data") is None
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=60))
await hass.async_block_till_done(wait_background_tasks=True)
assert hass_storage.get(_store_key(mock_entry), {}).get("data") is None
# ---------------------------------------------------------------------------
# Loading from it
# ---------------------------------------------------------------------------
async def test_offline_load_restores_the_same_entity_set(
hass: HomeAssistant, mock_entry, hass_storage
) -> None:
"""The whole point: a restart with the appliance powered off comes up on
the entity set the device last actually reported."""
resources = _load_fridge()
online_ids = await _setup_online_then_unload(hass, mock_entry, resources)
assert online_ids # guard: the online run must actually produce entities
with _unreachable():
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
assert mock_entry.state is ConfigEntryState.LOADED
assert {s.entity_id for s in hass.states.async_all()} == online_ids
async def test_offline_entities_are_unavailable_not_stale(hass: HomeAssistant, mock_entry) -> None:
"""Restored entities must not render the snapshot's values -- the
appliance is unreachable, so `unavailable` is the honest state and the
live cache stays empty to enforce it."""
resources = _load_fridge()
await _setup_online_then_unload(hass, mock_entry, resources)
with _unreachable():
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
states = hass.states.async_all()
assert states
assert all(s.state == "unavailable" for s in states)
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
assert coordinator.rehydrated
assert not coordinator.last_resources
async def test_offline_load_without_snapshot_still_fails(hass: HomeAssistant, mock_entry) -> None:
"""No snapshot means no device metadata to build anything from, so the
entry stays on HA's backoff rather than loading empty."""
with _unreachable():
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
assert mock_entry.state is ConfigEntryState.SETUP_RETRY
assert not hass.states.async_all()
async def test_malformed_snapshot_falls_back_to_setup_retry(
hass: HomeAssistant, mock_entry, hass_storage
) -> None:
"""A stored row missing a field the current dataclass declares must fail
the same way an unreachable device does.
Anything escaping async_rehydrate reaches async_setup_entry, which only
handles ConfigEntryNotReady -- so the entry would land in SETUP_ERROR,
which HA never retries, with its DTLS session left open on the fixed
source port the next attempt binds.
"""
key = _store_key(mock_entry)
hass_storage[key] = {
"version": 1,
"minor_version": 1,
"key": key,
"data": {
"resources": _load_fridge(),
"subdevice_candidates": [{"key": "1"}], # no "kind"
},
}
with (
_unreachable(),
patch.object(LocalThingsCoordinator, "async_close", autospec=True) as close,
):
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
assert mock_entry.state is ConfigEntryState.SETUP_RETRY
close.assert_awaited_once()
async def test_corrupt_snapshot_falls_back_to_setup_retry(
hass: HomeAssistant, mock_entry, hass_storage
) -> None:
"""A snapshot whose resources no longer replay cleanly must not take the
entry down with it."""
key = _store_key(mock_entry)
hass_storage[key] = {
"version": 1,
"minor_version": 1,
"key": key,
"data": {"resources": {"/information/vs/0": "not-a-rep"}, "subdevice_candidates": []},
}
with _unreachable():
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
assert mock_entry.state is ConfigEntryState.SETUP_RETRY
async def test_snapshot_restores_identity(hass: HomeAssistant, mock_entry) -> None:
"""`/oic/d`'s device types route the registry, so an offline load that
lost them could resolve a different one than the live poll did -- which
would show up as a spurious reconcile reload every restart."""
resources = _load_fridge()
identity = DeviceIdentity(
manufacturer="Samsung",
model="TEST-MODEL",
name="Fridge",
serial=None,
device_types=("oic.d.refrigerator",),
raw={"/oic/p": {}, "/oic/d": {}, "/oic/res": []},
)
with _reachable(resources, identity):
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
await hass.config_entries.async_unload(mock_entry.entry_id)
await hass.async_block_till_done()
with _unreachable():
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
assert coordinator._identity is not None
assert coordinator._identity.device_types == ("oic.d.refrigerator",)
# ---------------------------------------------------------------------------
# Recovery and reconciliation
# ---------------------------------------------------------------------------
async def test_offline_load_keeps_polling_and_recovers(hass: HomeAssistant, mock_entry) -> None:
"""The failure the PR this replaces actually shipped: with zero live
listeners the base coordinator stops rescheduling, and the entry never
polls again. Entities must go available on the next interval once the
appliance answers."""
resources = _load_fridge()
await _setup_online_then_unload(hass, mock_entry, resources)
with _unreachable():
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
assert coordinator._unsub_refresh is not None # a poll is actually queued
assert not coordinator.last_update_success
with _reachable(resources):
await _tick(hass)
assert coordinator.last_update_success
assert any(s.state != "unavailable" for s in hass.states.async_all())
async def test_reconcile_reloads_when_live_discovery_differs(
hass: HomeAssistant, mock_entry, hass_storage
) -> None:
"""Platforms enumerate `bound` once, so a live set that disagrees with the
snapshot can only be adopted by bringing the entry back up."""
resources = _load_fridge()
with _reachable(resources):
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
victim = coordinator.bound[0].href
await hass.config_entries.async_unload(mock_entry.entry_id)
await hass.async_block_till_done()
with _unreachable():
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
reduced = {href: rep for href, rep in resources.items() if href != victim}
with (
_reachable(reduced),
patch.object(hass.config_entries, "async_schedule_reload") as reload,
):
await _tick(hass)
reload.assert_called_once_with(mock_entry.entry_id)
# Banked before the reload is scheduled, so the entry that comes back up
# replays this discovery rather than the one it is replacing -- otherwise
# a device that goes quiet again mid-reload rehydrates the stale set and
# reconciles all over again.
assert victim not in hass_storage[_store_key(mock_entry)]["data"]["resources"]
async def test_reconcile_is_quiet_when_live_discovery_agrees(
hass: HomeAssistant, mock_entry
) -> None:
"""The common case -- same appliance, same firmware -- must not reload,
or every offline restart would cost a second setup cycle."""
resources = _load_fridge()
await _setup_online_then_unload(hass, mock_entry, resources)
with _unreachable():
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
with (
_reachable(resources),
patch.object(hass.config_entries, "async_schedule_reload") as reload,
):
await _tick(hass)
reload.assert_not_called()
def test_coverage_gap_repair_is_live_only(hass: HomeAssistant, mock_entry) -> None:
"""A coverage gap is a claim about what the device reports, so replaying
a snapshot must not raise the Repair -- it would restate last run's
conclusion while the diagnostics download it points at is still empty."""
gappy = {
"/information/vs/0": {
"x.com.samsung.da.modelNum": "TOTALLY_UNKNOWN_BOARD",
"x.com.samsung.da.serialNum": "TEST-SERIAL-0000",
},
"/nothing/maps/this/vs/0": {"someField": 1},
}
issue_id = f"device_gap_{mock_entry.entry_id}"
coordinator = LocalThingsCoordinator(hass, mock_entry)
coordinator._run_discovery(gappy, from_snapshot=True)
assert coordinator._unbound_hrefs # the gap is real, it just stays quiet
assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is None
coordinator._run_discovery(gappy)
assert ir.async_get(hass).async_get_issue(DOMAIN, issue_id) is not None
async def test_live_load_never_reconciles(
hass: HomeAssistant, mock_entry, mock_coordinator_session
) -> None:
"""An entry that came up against a live device has nothing to reconcile
against; the reload path must stay out of the normal startup entirely."""
with patch.object(hass.config_entries, "async_schedule_reload") as reload:
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
assert not coordinator.rehydrated
reload.assert_not_called()
@pytest.mark.parametrize("failures", [1, 3])
async def test_offline_load_survives_repeated_poll_failures(
hass: HomeAssistant, mock_entry, failures: int
) -> None:
"""Recovery isn't one-shot: the entry keeps its entities and keeps
retrying across however many intervals the appliance stays dark."""
resources = _load_fridge()
online_ids = await _setup_online_then_unload(hass, mock_entry, resources)
with _unreachable():
await hass.config_entries.async_setup(mock_entry.entry_id)
await hass.async_block_till_done()
for _ in range(failures):
await _tick(hass)
assert mock_entry.state is ConfigEntryState.LOADED
assert {s.entity_id for s in hass.states.async_all()} == online_ids
with _reachable(resources):
await _tick(hass)
coordinator: LocalThingsCoordinator = hass.data[DOMAIN][mock_entry.entry_id]
assert coordinator.last_update_success
@@ -36,6 +36,15 @@ class _FakeCoordinator:
def canonical_resources(self, subdevice):
return self.last_resources
# _is_included judges existence against the discovery view, which is the
# live cache for everything but an offline load (issue #295).
@property
def discovery_resources(self):
return self.last_resources
def discovery_canonical(self, subdevice):
return self.canonical_resources(subdevice)
def _resources():
return _load_device("airconditioner_ailp_fac")
+18 -5
View File
@@ -129,22 +129,35 @@ def test_token_entities_present_with_calibrated_values():
def test_token_entities_stay_off_newer_boards():
"""Newer families carry Volume/Sleep/OutdoorTemp/Autoclean tokens too,
while also exposing those settings as dedicated resources -- ungated, the
token entities would duplicate them (auto clean) or apply a scale
calibrated on another board generation (outdoor temperature)."""
"""Newer families carry Volume/Sleep/Autoclean tokens too, while also
exposing those settings as dedicated resources -- ungated, the token
entities would duplicate them."""
state = _state("airconditioner_tp1x_rac")
for key in (
"spi",
"auto_clean_legacy",
"air_monitoring",
"good_sleep",
"outdoor_temperature",
"filter_time",
):
assert key not in state, key
def test_outdoor_temperature_is_not_gated_to_legacy_boards():
"""issue #367: OutdoorTemp_ is not paired with any dedicated resource on
newer boards -- /temperatures/vs/0 carries indoor temperature only, no
outdoor equivalent exists anywhere in this fixture's 23 hrefs -- so unlike
auto_clean_legacy et al. above, gating it to is_legacy_board only dropped
a real reading. A 48h field capture correlated the token against
weather.forecast_home at r=0.92 across several non-legacy boards,
confirming it is live per-site data rather than a firmware constant."""
resources = _load_device("airconditioner_tp1x_rac")
assert is_legacy_board(resources) is False
bound, _ = _discover(resources)
state = flatten(bound, resources)
assert state["outdoor_temperature"] == 37.0 # OutdoorTemp_92, the fixture's own value
def test_climate_legacy_airflow_gate_agrees_with_is_legacy_board():
"""issue #161: climate.py's _legacy_airflow() delegates to
capabilities/airconditioner.py's is_legacy_board() instead of
+16
View File
@@ -654,6 +654,22 @@ def test_lnx_rac_heatpump_no_unbound_hrefs():
assert unbound == []
def test_lnx_rac_heatpump_outdoor_temperature_stays_off_fahrenheit_boards():
"""issue #367's -55 offset was field-validated on Celsius-locale boards
only; this fixture's own /temperatures/vs/0 declares Fahrenheit despite
carrying an OutdoorTemp_ token, so the sensor stays off rather than
apply an unvalidated offset/unit to it (see _reports_celsius)."""
reg, resources = _ac_lnx_rac_heatpump()
temps_item = resources["/temperatures/vs/0"]["x.com.samsung.da.items"][0]
assert temps_item["x.com.samsung.da.unit"] == "Fahrenheit"
options = resources["/mode/vs/0"]["x.com.samsung.da.options"]
assert any(o.startswith("OutdoorTemp_") for o in options)
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
state = flatten(bound, resources)
assert "outdoor_temperature" not in state
def test_lnx_rac_heatpump_absence_power_saving_state():
reg, resources = _ac_lnx_rac_heatpump()
bound = discover(resources, reg.capabilities, reg.pattern_capabilities)
+139 -2
View File
@@ -22,7 +22,11 @@ from homeassistant.helpers import issue_registry as ir
from pytest_homeassistant_custom_component.common import MockConfigEntry
from custom_components.localthings import cloudcourse
from custom_components.localthings.const import CONF_CLOUD_COURSES, DOMAIN
from custom_components.localthings.const import (
CONF_CLOUD_COURSES,
CONF_CLOUD_COURSES_ENABLED,
DOMAIN,
)
from custom_components.localthings.coordinator import LocalThingsCoordinator
from custom_components.localthings.registry.entities import SelectDesc
from custom_components.localthings.registry.subdevices import MAIN
@@ -55,10 +59,11 @@ async def _flush(hass: HomeAssistant) -> None:
await hass.async_block_till_done()
def _entry(hass: HomeAssistant, data=None) -> MockConfigEntry:
def _entry(hass: HomeAssistant, data=None, options=None) -> MockConfigEntry:
entry = MockConfigEntry(
domain=DOMAIN,
data={**ENTRY_DATA, **(data or {})},
options=options or {},
unique_id="localthings_CLOUD-COURSE-TEST",
)
entry.add_to_hass(hass)
@@ -240,6 +245,138 @@ async def test_a_malformed_entry_record_does_not_block_setup(hass: HomeAssistant
assert coordinator.cloud_courses.snapshot()["slots"]["55"]["blob"] == SPORTS
# ---------------------------------------------------------------------------
# cloud_courses_enabled toggle (issue #364) -- some devices report a
# downloaded program's payload before the owner has ever meant to use the
# feature, so the Repair and the entity offering need a way to be turned
# off entirely without discarding what was already learned.
# ---------------------------------------------------------------------------
def test_cloud_courses_enabled_defaults_to_true(hass: HomeAssistant):
coordinator = LocalThingsCoordinator(hass, _entry(hass))
assert coordinator.cloud_courses_enabled is True
async def test_disabling_suppresses_the_repair(hass: HomeAssistant):
entry = _entry(hass, options={CONF_CLOUD_COURSES_ENABLED: False})
coordinator = await _coordinator(hass, entry)
coordinator._refresh_cloud_course_issue()
await _flush(hass)
registry = ir.async_get(hass)
assert registry.async_get_issue(DOMAIN, f"cloud_courses_{entry.entry_id}") is None
async def test_disabling_clears_an_already_open_repair(hass: HomeAssistant):
"""Turning the option off has to reach a Repair that's already showing,
not just prevent a future one -- otherwise the toggle looks broken to
anyone who flips it after already seeing the nudge."""
entry = _entry(hass)
coordinator = await _coordinator(hass, entry)
coordinator._refresh_cloud_course_issue()
await _flush(hass)
registry = ir.async_get(hass)
issue_id = f"cloud_courses_{entry.entry_id}"
assert registry.async_get_issue(DOMAIN, issue_id) is not None
hass.config_entries.async_update_entry(entry, options={CONF_CLOUD_COURSES_ENABLED: False})
coordinator._refresh_cloud_course_issue()
await _flush(hass)
assert registry.async_get_issue(DOMAIN, issue_id) is None
async def test_disabling_hides_an_already_named_program_from_the_cycle_select(
hass: HomeAssistant,
):
"""`coordinator._on_cloud_courses_changed()` after the option write
stands in for __init__.py's options-update listener, which this
lightweight coordinator (no async_setup_entry) never registers --
see test_disabling_clears_an_already_open_repair for the same shape."""
entry = _entry(hass)
coordinator = await _coordinator(hass, entry)
coordinator.apply_cloud_courses({"55": "Sports"}, "87")
await _flush(hass)
assert "cloud:55" in _cycle_options(coordinator)
hass.config_entries.async_update_entry(entry, options={CONF_CLOUD_COURSES_ENABLED: False})
coordinator._on_cloud_courses_changed()
assert "cloud:55" not in _cycle_options(coordinator)
# Not discarded -- the store itself is untouched, only what
# entity_resources() hands the registry changes.
assert coordinator.cloud_courses.named() == {"55": "Sports"}
assert entry.data[CONF_CLOUD_COURSES]["slots"]["55"]["name"] == "Sports"
async def test_re_enabling_immediately_restores_the_offering(hass: HomeAssistant):
entry = _entry(hass, options={CONF_CLOUD_COURSES_ENABLED: False})
coordinator = await _coordinator(hass, entry)
coordinator.apply_cloud_courses({"55": "Sports"}, "87")
await _flush(hass)
assert "cloud:55" not in _cycle_options(coordinator)
hass.config_entries.async_update_entry(entry, options={CONF_CLOUD_COURSES_ENABLED: True})
coordinator._on_cloud_courses_changed()
assert "cloud:55" in _cycle_options(coordinator)
async def test_disabling_does_not_stop_passive_observation(hass: HomeAssistant):
"""Guided/manual setup depend on live observation to detect a newly
selected program at all -- see CONF_CLOUD_COURSES_ENABLED's own comment
for why the option leaves this running rather than gating it too."""
entry = _entry(hass, options={CONF_CLOUD_COURSES_ENABLED: False})
coordinator = await _coordinator(hass, entry)
assert coordinator.cloud_courses.snapshot()["slots"]["55"]["blob"] == SPORTS
assert entry.data[CONF_CLOUD_COURSES]["slots"]["55"]["blob"] == SPORTS
async def test_disabling_pushes_the_new_current_option_immediately(hass: HomeAssistant):
"""select.py's current_option reads coordinator.data, not
canonical_resources() -- clearing only the canonical-view cache left
it stale until whatever poll happened to run next. The fixture is
sitting on a one-time Jeans ('6B') override, so naming it is what
makes the live selection actually depend on the cloud store rather
than falling back to the raw course code."""
entry = _entry(hass)
coordinator = await _coordinator(hass, entry)
coordinator.apply_cloud_courses({"55": "Sports", "6B": "Jeans"}, "87")
await _flush(hass)
assert coordinator.data["cycle"] == "cloud:6B"
hass.config_entries.async_update_entry(entry, options={CONF_CLOUD_COURSES_ENABLED: False})
coordinator._on_cloud_courses_changed()
assert coordinator.data["cycle"] == "87"
async def test_an_unrelated_option_save_before_first_poll_does_not_clear_a_real_repair(
hass: HomeAssistant,
):
"""_on_cloud_courses_changed now runs from __init__.py's options-update
listener on *every* entry save, not just a cloud-course-specific one --
including one made before this device's first poll (a fresh restart,
still rehydrating). /course/vs/0 unpolled reads as an empty rep, which
must not be treated as evidence that nothing is pending: that would
delete a Repair a real poll had every reason to raise."""
entry = _entry(hass)
coordinator = await _coordinator(hass, entry)
coordinator._refresh_cloud_course_issue()
await _flush(hass)
registry = ir.async_get(hass)
issue_id = f"cloud_courses_{entry.entry_id}"
assert registry.async_get_issue(DOMAIN, issue_id) is not None
# A second coordinator against the same entry, standing in for a
# restart that hasn't polled yet -- its own resource cache is empty.
fresh = LocalThingsCoordinator(hass, entry)
fresh._on_cloud_courses_changed()
await _flush(hass)
assert registry.async_get_issue(DOMAIN, issue_id) is not None
# ---------------------------------------------------------------------------
# Options flow
# ---------------------------------------------------------------------------
+9
View File
@@ -29,6 +29,15 @@ class _FakeCoordinator:
# subdevices (issue #177).
return self.last_resources
# _is_included judges existence against the discovery view, which is the
# live cache for everything but an offline load (issue #295).
@property
def discovery_resources(self):
return self.last_resources
def discovery_canonical(self, subdevice):
return self.canonical_resources(subdevice)
def _coord(last_resources) -> LocalThingsCoordinator:
return cast(LocalThingsCoordinator, _FakeCoordinator(last_resources))
+167 -2
View File
@@ -180,10 +180,14 @@ def test_confirmed_washer_table_02_towels_bedding_are_not_swapped():
def test_confirmed_washer_table_02_missing_course_names():
"""Issue #342: 06/08/a0 had no translation and fell back to the raw
device code in the UI; 74 was already translated by the time this
landed and is pinned here only as a "didn't regress" anchor."""
landed and is pinned here only as a "didn't regress" anchor.
06's wording was corrected by issue #376 (originally 'XXL Laundry';
that device's own '이불' report -- the same text as the confirmed
Bedding codes 24/6f -- turned out to be the right one)."""
states = _load("en")["entity"]["select"]["washer_cycle_table_02"]["state"]
assert {code: states[code] for code in ("06", "08", "74", "a0")} == {
"06": "XXL Laundry",
"06": "Bedding",
"08": "Rinse+Spin",
"74": "Drum Clean",
"a0": "15' Quick Wash",
@@ -211,6 +215,167 @@ def test_confirmed_washer_table_02_ww90dg5g34able_course_names():
}
def test_confirmed_washer_table_02_wf21t6500kv_course_names():
"""Issue #376: WF21T6500KV (DA_WM_A51_20_COMMON) reported Korean labels
for 21 previously-untranslated Table_02 codes.
Several share their Korean text with an already-confirmed code
(cross-checked against translations/ko.json, not guessed), so those
reuse the established label rather than a fresh translation -- checked
in every locale per issue #363's precedent, since a locale that
translated the shared text differently would still pass the
key-topology test alone. That includes '06': its Korean text ('이불')
matches the confirmed Bedding codes 24/6f exactly, which superseded
the wrong 'XXL Laundry' wording #342 had originally given it (see
test_confirmed_washer_table_02_missing_course_names).
"""
english = _load("en")["entity"]["select"]["washer_cycle_table_02"]["state"]
assert {
code: english[code]
for code in (
"02",
"03",
"05",
"06",
"07",
"09",
"0b",
"0c",
"0d",
"0e",
"0f",
"10",
"11",
"12",
"13",
"14",
"15",
"16",
"18",
"19",
"1a",
)
} == {
"02": "Extra Heavy Duty",
"03": "Super Eco Wash",
"05": "Wool/Lingerie",
"06": "Bedding",
"07": "Outdoor",
"09": "Drum Clean",
"0b": "Boil Wash",
"0c": "Baby Care",
"0d": "Spin Only",
"0e": "Cloudy Day",
"0f": "Pure Wash",
"10": "Spin Dry",
"11": "Summer Bedding",
"12": "Cottons",
"13": "Black Cottons",
"14": "Delicate Underwear",
"15": "Activewear",
"16": "Blouses",
"18": "Soft Bubble",
"19": "AI Wash",
"1a": "Shirts",
}
# Anchors picked among the code's own duplicate-label siblings by
# whichever this catalog already has translated consistently -- '2b'
# over its "AI 맞춤세탁" twin '69', which nl.json alone translates
# differently ("AI wassen" vs "AI Wash"), an existing inconsistency
# unrelated to this issue and not one this PR resolves.
reused_pairs = (
("06", "24"),
("07", "75"),
("09", "3a"),
("0c", "2e"),
("15", "2f"),
("16", "6c"),
("19", "2b"),
("1a", "32"),
)
for language in _languages():
states = _load(language)["entity"]["select"]["washer_cycle_table_02"]["state"]
for new_code, anchor_code in reused_pairs:
assert states[new_code] == states[anchor_code], (language, new_code, anchor_code)
def test_confirmed_dryer_table_03_dv19t8745bv_course_names():
"""Issue #376: DV19T8745BV (DA_WM_TP1_21_COMMON) reported Korean labels
for 18 previously-untranslated Table_03 codes -- none conflict with an
existing entry. As with the washer table above, codes sharing Korean
text with an already-confirmed code (including two, '3c'/'3d', whose
text matches a washer_cycle_table_02 entry rather than one on this
table) reuse that label instead of a fresh translation, checked in
every locale.
"""
english = _load("en")["entity"]["select"]["dryer_cycle_table_03"]["state"]
assert {
code: english[code]
for code in (
"02",
"03",
"05",
"07",
"09",
"0b",
"0c",
"0e",
"0f",
"11",
"28",
"37",
"38",
"39",
"3a",
"3b",
"3c",
"3d",
)
} == {
"02": "AI Dry",
"03": "Super Speed",
"05": "Bedding",
"07": "Delicates",
"09": "Shirts",
"0b": "Padding Care",
"0c": "Outdoor Water-Repellent Care",
"0e": "Towels",
"0f": "Wool",
"11": "Cool air",
"28": "Interior Hot Air Sanitize",
"37": "Blouses",
"38": "Iron dry",
"39": "Room Dehumidify",
"3a": "Hygiene Care",
"3b": "Bedding/Dust Off",
"3c": "Activewear",
"3d": "Denim",
}
same_table_pairs = (
("02", "29"),
("03", "17"),
("05", "1b"),
("07", "19"),
("09", "1c"),
("0e", "1d"),
("0f", "1a"),
("11", "24"),
("38", "20"),
("3a", "21"),
)
for language in _languages():
d_states = _load(language)["entity"]["select"]["dryer_cycle_table_03"]["state"]
w_states = _load(language)["entity"]["select"]["washer_cycle_table_02"]["state"]
for new_code, anchor_code in same_table_pairs:
assert d_states[new_code] == d_states[anchor_code], (language, new_code, anchor_code)
# Cross-table reuse: same Korean text as a washer_cycle_table_02 code.
assert d_states["37"] == w_states["6c"], language # Blouses
assert d_states["3c"] == w_states["2f"], language # Activewear
assert d_states["3d"] == w_states["66"], language # Denim
def test_reported_washer_standard_courses_all_have_table_02_labels():
"""Every non-personal code in the reported washer's live course list
must resolve through the Table_02 catalog instead of appearing as raw
+9
View File
@@ -34,6 +34,15 @@ class _FakeCoordinator:
return canonical_view(subdevice, self.last_resources, self._subdevices)
# _is_included judges existence against the discovery view, which is the
# live cache for everything but an offline load (issue #295).
@property
def discovery_resources(self):
return self.last_resources
def discovery_canonical(self, subdevice):
return self.canonical_resources(subdevice)
@pytest.mark.parametrize("name", _FIXTURE_NAMES)
def test_key_is_unique_across_all_bound_entities(name):