Skip to content

Commit 0cb03b1

Browse files
committed
Fix zone validation and improve zone ID guidance in config flow
Add zone ID validation against pyxantech device config in both config flow and options flow, rejecting IDs outside the device's valid set. Update UX text to clarify both zone formats: two-digit (11-18) for most Xantech models (MRC88, MX88) and single-digit (1-8) for standalone MRAUDIO8x8 units. Require pyxantech>=0.10.7 which accepts alternative_zones. Bump version to 0.3.2.
1 parent dccc201 commit 0cb03b1

File tree

6 files changed

+98
-22
lines changed

6 files changed

+98
-22
lines changed

custom_components/xantech/config_flow.py

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
TextSelectorConfig,
2525
TextSelectorType,
2626
)
27-
from pyxantech import async_get_amp_controller
27+
from pyxantech import async_get_amp_controller, get_device_config
2828
from serial import SerialException
2929
import voluptuous as vol
3030

@@ -44,6 +44,9 @@
4444

4545
LOG = logging.getLogger(__name__)
4646

47+
# type alias to reduce line length in signatures
48+
ZonesDict = dict[int, dict[str, str]]
49+
4750

4851
class XantechConfigFlow(ConfigFlow, domain=DOMAIN):
4952
"""Handle a config flow for Xantech Multi-Zone Amplifier."""
@@ -115,7 +118,9 @@ async def async_step_zones(
115118
if user_input is not None:
116119
# parse zones configuration
117120
zones_text = user_input.get('zones_config', '')
118-
zones = self._parse_zones_config(zones_text)
121+
zones = self._parse_zones_config(
122+
zones_text, self._data.get(CONF_AMP_TYPE)
123+
)
119124

120125
if not zones:
121126
errors['base'] = 'invalid_zones'
@@ -197,12 +202,15 @@ async def async_step_sources(
197202
},
198203
)
199204

200-
def _parse_zones_config(self, text: str) -> dict[int, dict[str, str]]:
205+
def _parse_zones_config(
206+
self, text: str, amp_type: str | None = None,
207+
) -> ZonesDict:
201208
"""Parse zone configuration text into structured dict.
202209
203210
Format: 'zone_id: zone_name' per line
211+
Validates zone IDs against device config when amp_type provided.
204212
"""
205-
zones: dict[int, dict[str, str]] = {}
213+
zones: ZonesDict = {}
206214
for line in text.strip().split('\n'):
207215
line = line.strip()
208216
if not line or ':' not in line:
@@ -213,6 +221,18 @@ def _parse_zones_config(self, text: str) -> dict[int, dict[str, str]]:
213221
zones[zone_id] = {'name': name.strip()}
214222
except ValueError:
215223
continue
224+
225+
if amp_type and zones:
226+
valid_zones = (
227+
get_device_config(amp_type, 'zones') or {}
228+
)
229+
alt_zones = get_device_config(
230+
amp_type, 'alternative_zones', log_missing=False,
231+
) or {}
232+
all_valid = {**valid_zones, **alt_zones}
233+
if any(z not in all_valid for z in zones):
234+
return {}
235+
216236
return zones
217237

218238
def _parse_sources_config(self, text: str) -> dict[int, dict[str, str]]:
@@ -243,9 +263,11 @@ def _get_default_zones_text(self, amp_type: str) -> str:
243263
if amp_type == 'dax88':
244264
return (
245265
'11: Living Room\n12: Kitchen\n13: Master Bedroom\n'
246-
'14: Office\n15: Patio\n16: Dining Room\n17: Garage\n18: Basement'
266+
'14: Office\n15: Patio\n16: Dining Room\n'
267+
'17: Garage\n18: Basement'
247268
)
248-
# default to xantech8
269+
# xantech8: most models (MRC88, MX88) use 11-18 addressing;
270+
# older MRAUDIO8x8 standalone units also accept 1-8
249271
return (
250272
'11: Living Room\n12: Kitchen\n13: Master Bedroom\n'
251273
'14: Office\n15: Patio\n16: Dining Room'
@@ -360,7 +382,8 @@ async def async_step_zones(
360382

361383
if user_input is not None:
362384
zones_text = user_input.get('zones_config', '')
363-
zones = self._parse_zones_config(zones_text)
385+
amp_type = self.config_entry.data.get(CONF_AMP_TYPE)
386+
zones = self._parse_zones_config(zones_text, amp_type)
364387

365388
if not zones:
366389
errors['base'] = 'invalid_zones'
@@ -437,9 +460,11 @@ async def async_step_sources(
437460
},
438461
)
439462

440-
def _parse_zones_config(self, text: str) -> dict[int, dict[str, str]]:
463+
def _parse_zones_config(
464+
self, text: str, amp_type: str | None = None,
465+
) -> ZonesDict:
441466
"""Parse zone configuration text into structured dict."""
442-
zones: dict[int, dict[str, str]] = {}
467+
zones: ZonesDict = {}
443468
for line in text.strip().split('\n'):
444469
line = line.strip()
445470
if not line or ':' not in line:
@@ -450,6 +475,18 @@ def _parse_zones_config(self, text: str) -> dict[int, dict[str, str]]:
450475
zones[zone_id] = {'name': name.strip()}
451476
except ValueError:
452477
continue
478+
479+
if amp_type and zones:
480+
valid_zones = (
481+
get_device_config(amp_type, 'zones') or {}
482+
)
483+
alt_zones = get_device_config(
484+
amp_type, 'alternative_zones', log_missing=False,
485+
) or {}
486+
all_valid = {**valid_zones, **alt_zones}
487+
if any(z not in all_valid for z in zones):
488+
return {}
489+
453490
return zones
454491

455492
def _parse_sources_config(self, text: str) -> dict[int, dict[str, str]]:

custom_components/xantech/manifest.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
{
22
"domain": "xantech",
33
"name": "Xantech Multi-Zone Audio",
4-
"version": "0.3.1",
4+
"version": "0.3.2",
55
"documentation": "https://github.com/rsnodgrass/hass-xantech",
66
"issue_tracker": "https://github.com/rsnodgrass/hass-xantech/issues",
7-
"requirements": ["pyxantech>=0.10.5"],
7+
"requirements": ["pyxantech>=0.10.7"],
88
"homeassistant": "2025.2.0",
99
"codeowners": ["@rsnodgrass"],
1010
"config_flow": true,

custom_components/xantech/strings.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
},
1616
"zones": {
1717
"title": "Name Your Zones",
18-
"description": "Give each zone a friendly name. Zone IDs use standard addressing: first digit is the controller unit (1-3), second digit is the zone output (1-8). For example, 11-18 are zones on controller 1.",
18+
"description": "Give each zone a friendly name.\n\nFor most Xantech models (MRC88, MX88), use two-digit zone IDs: first digit is the controller unit (1-3), second digit is the zone (1-8). For example, 11-18 for controller 1.\n\nStandalone MRAUDIO8x8 units also accept single-digit zones (1-8).",
1919
"data": {
2020
"zones_config": "Zone Configuration",
2121
"enable_audio_controls": "Advanced audio controls"
@@ -39,7 +39,7 @@
3939
"error": {
4040
"cannot_connect": "Could not connect. Verify the serial port path and cable connection.",
4141
"unknown": "An unexpected error occurred.",
42-
"invalid_zones": "Could not parse zones. Use format: 11: Living Room",
42+
"invalid_zones": "Invalid zone IDs for this amplifier type. Check your model's documentation for valid zone numbers.",
4343
"invalid_sources": "Could not parse sources. Use format: 1: TV"
4444
},
4545
"abort": {
@@ -81,7 +81,7 @@
8181
},
8282
"zones": {
8383
"title": "Edit Zones",
84-
"description": "Update zone names. Zone IDs use standard addressing: first digit is the controller unit (1-3), second digit is the zone output (1-8).",
84+
"description": "Update zone names. Most Xantech models use two-digit zone IDs (11-18 for controller 1). Standalone MRAUDIO8x8 units also accept 1-8.",
8585
"data": {
8686
"zones_config": "Zone Configuration"
8787
},

custom_components/xantech/translations/en.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
},
1616
"zones": {
1717
"title": "Name Your Zones",
18-
"description": "Give each zone a friendly name. Zone IDs use standard addressing: first digit is the controller unit (1-3), second digit is the zone output (1-8). For example, 11-18 are zones on controller 1.",
18+
"description": "Give each zone a friendly name.\n\nFor most Xantech models (MRC88, MX88), use two-digit zone IDs: first digit is the controller unit (1-3), second digit is the zone (1-8). For example, 11-18 for controller 1.\n\nStandalone MRAUDIO8x8 units also accept single-digit zones (1-8).",
1919
"data": {
2020
"zones_config": "Zone Configuration"
2121
},
@@ -37,7 +37,7 @@
3737
"error": {
3838
"cannot_connect": "Could not connect. Verify the serial port path and cable connection.",
3939
"unknown": "An unexpected error occurred.",
40-
"invalid_zones": "Could not parse zones. Use format: 11: Living Room",
40+
"invalid_zones": "Invalid zone IDs for this amplifier type. Check your model's documentation for valid zone numbers.",
4141
"invalid_sources": "Could not parse sources. Use format: 1: TV"
4242
},
4343
"abort": {
@@ -78,7 +78,7 @@
7878
},
7979
"zones": {
8080
"title": "Edit Zones",
81-
"description": "Update zone names. Zone IDs use standard addressing: first digit is the controller unit (1-3), second digit is the zone output (1-8).",
81+
"description": "Update zone names. Most Xantech models use two-digit zone IDs (11-18 for controller 1). Standalone MRAUDIO8x8 units also accept 1-8.",
8282
"data": {
8383
"zones_config": "Zone Configuration"
8484
},

tests/test_config_flow.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
from custom_components.xantech.const import (
1313
CONF_AMP_TYPE,
1414
CONF_PORT,
15-
CONF_SCAN_INTERVAL,
1615
DOMAIN,
1716
)
1817

@@ -220,3 +219,43 @@ def test_options_flow_stores_config_entry_correctly() -> None:
220219
assert hasattr(flow, '_config_entry'), 'Flow must store entry in _config_entry'
221220
assert flow._config_entry is mock_entry
222221
assert flow._config_entry.data['port'] == '/dev/ttyUSB0'
222+
223+
224+
def test_xantech8_default_zones_use_double_digit() -> None:
225+
"""Test that xantech8 config flow defaults use double-digit zone IDs.
226+
227+
Most xantech8 models (MRC88, MX88) use two-digit addressing (11-18).
228+
Standalone MRAUDIO8x8 also accepts 1-8, but defaults target the common case.
229+
"""
230+
from custom_components.xantech.config_flow import XantechConfigFlow
231+
232+
flow = XantechConfigFlow()
233+
defaults = flow._get_default_zones_text('xantech8')
234+
235+
for line in defaults.strip().split('\n'):
236+
zone_id = int(line.split(':')[0].strip())
237+
assert 11 <= zone_id <= 18, f'xantech8 default zone {zone_id} should be 11-18'
238+
239+
240+
def test_monoprice6_default_zones_use_double_digit() -> None:
241+
"""Test that monoprice6 config flow defaults use double-digit zone IDs."""
242+
from custom_components.xantech.config_flow import XantechConfigFlow
243+
244+
flow = XantechConfigFlow()
245+
defaults = flow._get_default_zones_text('monoprice6')
246+
247+
for line in defaults.strip().split('\n'):
248+
zone_id = int(line.split(':')[0].strip())
249+
assert zone_id >= 11, f'monoprice6 default zone {zone_id} should be >= 11'
250+
251+
252+
def test_dax88_default_zones_use_double_digit() -> None:
253+
"""Test that dax88 config flow defaults use double-digit zone IDs."""
254+
from custom_components.xantech.config_flow import XantechConfigFlow
255+
256+
flow = XantechConfigFlow()
257+
defaults = flow._get_default_zones_text('dax88')
258+
259+
for line in defaults.strip().split('\n'):
260+
zone_id = int(line.split(':')[0].strip())
261+
assert zone_id >= 11, f'dax88 default zone {zone_id} should be >= 11'

uv.lock

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)