Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
555280e
Test a restore cut off mid-way and a value the device adjusts
Varitras Nov 1, 2026
c0b10f3
Test end of stream and non-object lines from the speaker
Varitras Nov 1, 2026
f2dfd3f
Test that a setup whose platforms fail closes the connection
Varitras Nov 1, 2026
316572b
Guard that every error key in the code has a text
Varitras Nov 1, 2026
a7c575c
Test the timeout and failure exits of backup, discovery and EQ reset
Varitras Nov 1, 2026
5374b5e
Test that every gate check.sh owns counts as a direct call
Varitras Nov 1, 2026
989fd56
Count mypy, compileall and the gitleaks pin as script-owned gates
Varitras Nov 1, 2026
089faf8
Test that the standby minutes are durations
Varitras Nov 1, 2026
cf87cb4
Mark the standby minutes as durations
Varitras Nov 1, 2026
0766bae
Type the restore's coordinator and key backups by unique_id_base
Varitras Nov 1, 2026
1947f20
Drop the switch pass-through and a default the select repeated
Varitras Nov 1, 2026
bb9905b
Map SSC write errors in one place
Varitras Nov 1, 2026
988b5ca
Name magic numbers and the EQ leaves, use the restore kind constant
Varitras Nov 1, 2026
831f425
Spell out abbreviated names
Varitras Nov 1, 2026
76be5e5
Test that the discovery card asks again for an empty name
Varitras Nov 1, 2026
351763f
Flatten the discovery card step and share the name field
Varitras Nov 1, 2026
10b4301
Test that a write during a poll waits for one query at most
Varitras Nov 1, 2026
0f6d9e2
Drop the priority flag the FIFO lock made redundant
Varitras Nov 1, 2026
112dd97
Test that announced serials spare the relocate connections
Varitras Nov 1, 2026
17b1789
Take the serial a relocate candidate announces
Varitras Nov 1, 2026
9fe990b
Drop the logo note from the READMEs
Varitras Nov 1, 2026
b4f7f4b
Test that the announced serial reaches the candidate, cleaned
Varitras Nov 1, 2026
0f85c08
Clean the announced serial like the zeroconf step does
Varitras Nov 1, 2026
8dae96c
Test the remembered name, the write error text and translated exceptions
Varitras Nov 1, 2026
f6fe204
Say why relocate trusts an announced serial
Varitras Nov 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 72 additions & 2 deletions .github/mutations/plan.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@
{
"name": "a factory reset with a lost reply keeps the stale cache",
"path": "custom_components/neumann_kh/button.py",
"find": " await self.coordinator.async_invalidate_and_refresh()\n raise HomeAssistantError(",
"replace": " raise HomeAssistantError(",
"find": " on_unreachable=self.coordinator.async_invalidate_and_refresh,",
"replace": " on_unreachable=None,",
"expect": "tests/test_factory_reset.py::test_a_reset_whose_reply_is_lost_still_drops_the_cache"
},
{
Expand Down Expand Up @@ -166,5 +166,75 @@
"find": " if \"osc\" in payload or json_tokens(payload) + _XID_TOKENS > _MAX_REQUEST_TOKENS:",
"replace": " if \"osc\" in payload:",
"expect": "tests/test_ssc_client.py::test_a_request_over_the_token_limit_goes_without_an_xid"
},
{
"name": "a restore cut off mid-way does not re-read the value in flight",
"path": "custom_components/neumann_kh/export_actions.py",
"find": " coordinator.request_reread(path)\n raise _stop(\"restore_interrupted\", error=str(err)) from err",
"replace": " raise _stop(\"restore_interrupted\", error=str(err)) from err",
"expect": "tests/test_export_actions.py::test_a_restore_cut_off_mid_way_keeps_what_landed_and_rereads_the_rest"
},
{
"name": "a value the device adjusted counts as written",
"path": "custom_components/neumann_kh/export_actions.py",
"find": " if confirmed != value:",
"replace": " if False:",
"expect": "tests/test_export_actions.py::test_a_value_the_device_adjusts_is_counted_as_adjusted"
},
{
"name": "a clean end of stream before any answer is a silent None",
"path": "custom_components/neumann_kh/ssc_client.py",
"find": " else:\n raise SSCConnectionError(\n f\"Connection to {mask_host(self._host)} interrupted\"\n ) from err",
"replace": " else:\n break",
"expect": "tests/test_ssc_client.py::test_a_speaker_closing_without_an_answer_is_a_connection_error"
},
{
"name": "a JSON line that is not an object reaches deep_merge",
"path": "custom_components/neumann_kh/ssc_client.py",
"find": " if not isinstance(parsed, dict) or nesting_exceeds(parsed, _MAX_NESTING):",
"replace": " if isinstance(parsed, dict) and nesting_exceeds(parsed, _MAX_NESTING):",
"expect": "tests/test_ssc_client.py::test_a_json_line_that_is_not_an_object_is_skipped"
},
{
"name": "a setup whose platforms fail keeps its connection",
"path": "custom_components/neumann_kh/__init__.py",
"find": " # any more: it lives on the entry, and the next attempt overwrites it.\n await client.close()",
"replace": " # any more: it lives on the entry, and the next attempt overwrites it.\n pass",
"expect": "tests/test_setup_cleanup.py::test_a_setup_whose_platforms_fail_closes_the_connection"
},
{
"name": "an error key in the code has no text",
"path": "custom_components/neumann_kh/export_actions.py",
"find": "translation_key=\"restore_no_backup\"",
"replace": "translation_key=\"restore_no_backups\"",
"expect": "tests/test_translations.py::test_every_error_key_in_the_code_has_a_text"
},
{
"name": "a backup that ran out of time reports a generic failure",
"path": "custom_components/neumann_kh/export_actions.py",
"find": " except BackupTimeoutError as err:",
"replace": " except KeyError as err:",
"expect": "tests/test_export_actions.py::test_a_backup_that_fails_to_read_says_why"
},
{
"name": "a discovery that ran out of time reports a generic failure",
"path": "custom_components/neumann_kh/export_actions.py",
"find": " except DiscoveryTimeoutError as err:",
"replace": " except KeyError as err:",
"expect": "tests/test_export_actions.py::test_a_discovery_that_fails_to_read_says_why"
},
{
"name": "the name field forgets the name the speaker had before",
"path": "custom_components/neumann_kh/config_flow.py",
"find": " return self.add_suggested_values_to_schema(schema, {CONF_NAME: remembered_name})",
"replace": " return schema",
"expect": "tests/test_config_flow.py::test_the_name_field_offers_the_name_the_speaker_had_before"
},
{
"name": "a refused write drops the device's reason from the message",
"path": "custom_components/neumann_kh/entity.py",
"find": " translation_key=rejected_key,\n translation_placeholders={\"error\": str(err)},",
"replace": " translation_key=rejected_key,",
"expect": "tests/test_entity_writes.py::test_a_write_error_tells_the_user_what_the_device_said"
}
]
3 changes: 0 additions & 3 deletions README.de.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,6 @@ Protokoll- und Gerätedetails (SSC, gemessenes Lautsprecher-Verhalten): siehe

**Benötigt Home Assistant 2026.10 oder neuer.**

Eigenes Icon/Logo unter `custom_components/neumann_kh/brand/` (eigenständiges
Design, keine Kopie des offiziellen Neumann-Firmenlogos).

Die SSC-Adresspfade stammen ursprünglich aus dem
[khtool-Projekt](https://github.com/schwinn/khtool); die von der Integration
genutzten Pfade und ihr Verhalten sind inzwischen an KH 120 II und KH 750 DSP
Expand Down
3 changes: 0 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,6 @@ Protocol and device details (SSC, measured speaker behaviour): see the

**Requires Home Assistant 2026.10 or newer.**

Custom icon/logo in `custom_components/neumann_kh/brand/` (an independent
design, not a copy of the official Neumann company logo).

The SSC address paths were first taken from the
[khtool project](https://github.com/schwinn/khtool); the paths the integration
uses and how they behave have since been checked on KH 120 II and KH 750 DSP
Expand Down
15 changes: 12 additions & 3 deletions custom_components/neumann_kh/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ async def _async_relocate(hass: HomeAssistant, entry: ConfigEntry) -> bool:
# address but moved to another port is precisely the case this repair
# exists for.
current = (entry.data.get(CONF_HOST), entry.data.get(CONF_PORT, DEFAULT_PORT))
candidates = [s for s in speakers if (s.host, s.port) != current]
candidates = [speaker for speaker in speakers if (speaker.host, speaker.port) != current]

# Asked concurrently, a few at a time. One after another this cost the
# connection timeout per silent candidate, and this runs on every failed
Expand All @@ -118,9 +118,18 @@ async def _serial_of(speaker: DiscoveredSpeaker) -> Any:
finally:
await client.close()

found_serials = await asyncio.gather(*(_serial_of(s) for s in candidates))
# The announcement names its serial: a candidate naming another speaker
# needs no connection, and one naming this speaker is taken as it is.
# Unlike async_step_zeroconf, which asks the device before repointing a
# working entry, this runs only after a failed setup, and the retry's
# serial check (_async_verify_identity) is that confirmation.
announced = [speaker for speaker in candidates if speaker.serial]
unannounced = [speaker for speaker in candidates if not speaker.serial]
found_serials = [speaker.serial for speaker in announced] + await asyncio.gather(
*(_serial_of(speaker) for speaker in unannounced)
)

for speaker, found in zip(candidates, found_serials, strict=True):
for speaker, found in zip(announced + unannounced, found_serials, strict=True):
if not serial_matches(serial, found):
continue

Expand Down
42 changes: 13 additions & 29 deletions custom_components/neumann_kh/button.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,26 +31,23 @@
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddEntitiesCallback

from ._util import localized
from .const import (
CONF_MODEL,
DOMAIN,
PATH_RESTORE,
RESTORE_FACTORY_DEFAULTS_VALUE,
)
from .coordinator import NeumannKHConfigEntry, NeumannKHCoordinator
from .entity import NeumannKHEntity
from .entity import NeumannKHEntity, device_errors
from .eq import build_eq_reset_buttons
from .export_actions import (
async_check_restorable,
async_run_backup,
async_run_discovery,
async_run_restore,
)
from .ssc_client import SSCConnectionError, SSCDeviceError, SSCLocalModeError, SSCTimeoutError

# Upper bound of the confirmation window shared by factory reset and restore
# backup. After it elapses, the button must be armed again.
Expand Down Expand Up @@ -134,31 +131,18 @@ async def async_press(self) -> None:
# Second press after the minimum gap, within the window -> reset.
self._armed_at = None
async_dismiss_notification(self.hass, self._notification_id)
try:
async with self.coordinator.claim_device():
await self.coordinator.client.set(PATH_RESTORE, RESTORE_FACTORY_DEFAULTS_VALUE)
except SSCLocalModeError as err:
raise HomeAssistantError(
translation_domain=DOMAIN, translation_key="local_mode"
) from err
except SSCDeviceError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="factory_reset_rejected",
translation_placeholders={"error": str(err)},
) from err
except (SSCConnectionError, SSCTimeoutError) as err:
# The command may already have reached the speaker: a reset
# reboots it, so a missing reply is what success looks like
# from here too. Reading that as "nothing happened" kept every
# value from before a reset that did in fact run - forever,
# for values that are only ever written once.
await self.coordinator.async_invalidate_and_refresh()
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="device_unreachable",
translation_placeholders={"error": str(err)},
) from err
# A lost reply drops the cache too: a reset reboots the speaker,
# so a missing reply is what success looks like from here as well.
# Reading that as "nothing happened" kept every value from before
# a reset that did in fact run - forever, for values written once.
async with (
device_errors(
rejected_key="factory_reset_rejected",
on_unreachable=self.coordinator.async_invalidate_and_refresh,
),
self.coordinator.claim_device(),
):
await self.coordinator.client.set(PATH_RESTORE, RESTORE_FACTORY_DEFAULTS_VALUE)
# A reset rewrites everything at once, so every cached value is
# stale - including the slow-polled ones, which would otherwise
# keep showing pre-reset settings for up to five minutes.
Expand Down
64 changes: 27 additions & 37 deletions custom_components/neumann_kh/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,8 @@ async def _async_get_interface_options(hass: HomeAssistant) -> list[selector.Sel
name = adapter.get("name")
if not name:
continue
addresses = [ip["address"] for ip in adapter.get("ipv6", []) if ip.get("address")]
addresses += [ip["address"] for ip in adapter.get("ipv4", []) if ip.get("address")]
entries = [*adapter.get("ipv6", []), *adapter.get("ipv4", [])]
addresses = [entry["address"] for entry in entries if entry.get("address")]
label = f"{name} ({', '.join(addresses)})" if addresses else name
options.append(selector.SelectOptionDict(value=name, label=label))

Expand Down Expand Up @@ -595,38 +595,26 @@ async def async_step_zeroconf_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Name a speaker that mDNS turned up and that is not configured yet."""
errors: dict[str, str] = {}
entry_data = self._pending_entry
identity = self._pending_identity
if entry_data is None or identity is None:
return self.async_abort(reason="discovery_expired")

if user_input is not None:
name = user_input.get(CONF_NAME, "").strip()
if not name:
errors["base"] = "name_required"
else:
entry_data = {**entry_data, CONF_NAME: name}
if identity.serial:
await storage.async_remember_name(self.hass, identity.serial, name)
return await self._async_finish(entry_data, identity)

suggested = None
if identity.serial:
suggested = await storage.async_get_remembered_name(self.hass, identity.serial)

schema = vol.Schema({vol.Required(CONF_NAME): str})
if suggested:
schema = self.add_suggested_values_to_schema(schema, {CONF_NAME: suggested})
name = (user_input or {}).get(CONF_NAME, "").strip()
if not name:
# First visit, or the name was left empty.
return self.async_show_form(
step_id="zeroconf_confirm",
data_schema=await self._name_schema(identity),
errors={"base": "name_required"} if user_input is not None else {},
description_placeholders={
"device": f"{identity.product or FALLBACK_MODEL} – {entry_data[CONF_HOST]}"
},
)

return self.async_show_form(
step_id="zeroconf_confirm",
data_schema=schema,
errors=errors,
description_placeholders={
"device": f"{identity.product or FALLBACK_MODEL} – {entry_data[CONF_HOST]}"
},
)
if identity.serial:
await storage.async_remember_name(self.hass, identity.serial, name)
return await self._async_finish({**entry_data, CONF_NAME: name}, identity)

# --- Path 2: Active mDNS scan, then naming ------------------------------

Expand Down Expand Up @@ -672,21 +660,23 @@ async def async_step_scan(self, user_input: dict[str, Any] | None = None) -> Con

return self.async_show_form(step_id="scan", data_schema=self._build_scan_schema())

async def _name_schema(self, identity: DeviceIdentity) -> vol.Schema:
"""A name field, pre-filled if this speaker carried a name before."""
schema = vol.Schema({vol.Required(CONF_NAME): str})
if not identity.serial:
return schema
remembered_name = await storage.async_get_remembered_name(self.hass, identity.serial)
if not remembered_name:
return schema
return self.add_suggested_values_to_schema(schema, {CONF_NAME: remembered_name})

async def _show_scan_confirm_form(
self, identity: DeviceIdentity, candidate: DiscoveredSpeaker, error: str | None
) -> ConfigFlowResult:
"""Ask for a name, pre-filled if this speaker carried one before."""
remembered_name = None
if identity.serial:
remembered_name = await storage.async_get_remembered_name(self.hass, identity.serial)

schema = vol.Schema({vol.Required(CONF_NAME): str})
if remembered_name:
schema = self.add_suggested_values_to_schema(schema, {CONF_NAME: remembered_name})

return self.async_show_form(
step_id="scan_confirm",
data_schema=schema,
data_schema=await self._name_schema(identity),
errors={"base": error} if error else {},
description_placeholders={
"device": f"{identity.product or FALLBACK_MODEL} – {candidate.host}"
Expand Down
18 changes: 4 additions & 14 deletions custom_components/neumann_kh/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@
SUBWOOFER_SLOW_POLL_PATHS,
UPDATE_INTERVAL_SECONDS,
)
from .eq_containers import eq_containers_for_model
from .eq_containers import EQ_ENABLED, eq_containers_for_model
from .ssc_client import (
ERROR_NOT_FOUND,
SSCClient,
Expand Down Expand Up @@ -147,7 +147,7 @@ def __init__(
# Change only through user action (which applies the confirmed value
# immediately) - therefore in the slow poll.
for container in eq_containers_for_model(model):
self._slow_poll_paths.append(container.path + ("enabled",))
self._slow_poll_paths.append(container.leaf(EQ_ENABLED))
# Counts the poll cycles to include the slow paths only every Nth round.
# Starts at 0, so that the slow paths are queried right on the very first
# cycle (values available immediately).
Expand Down Expand Up @@ -274,10 +274,6 @@ async def _poll_all_paths(self, paths: list[tuple[str, ...]]) -> dict[str, Any]:
answered = False
try:
for path in paths:
# Priority path: briefly yield to a waiting user action.
if self.client.priority_waiting.is_set():
await asyncio.sleep(0.05)

try:
value = await self.client.get(path)
except SSCDeviceError:
Expand Down Expand Up @@ -403,8 +399,8 @@ async def async_persist(self) -> None:
return
try:
await self.client.set(PATH_SAVE_SETTINGS, True)
except SSCDeviceError as err:
if err.code == ERROR_NOT_FOUND:
except (SSCDeviceError, SSCConnectionError, SSCTimeoutError) as err:
if isinstance(err, SSCDeviceError) and err.code == ERROR_NOT_FOUND:
# The KH 80 need is reported, not measured: a firmware without
# the path has nothing to save, and must not fail every change.
_LOGGER.debug("%s has no save command - nothing to save", self.name)
Expand All @@ -414,12 +410,6 @@ async def async_persist(self) -> None:
translation_key="settings_not_saved",
translation_placeholders={"error": str(err)},
) from err
except (SSCConnectionError, SSCTimeoutError) as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="settings_not_saved",
translation_placeholders={"error": str(err)},
) from err

def value(self, path: tuple[str, ...]) -> Any:
"""Convenient access to a value from the last polled data."""
Expand Down
Loading
Loading