Skip to content

Commit 5dd7ccf

Browse files
committed
Bump version to 0.2.6 with UI improvements and options flow
- Fix sonance amp type key to sonance6 (matches pyxantech) - Add descriptive amp type labels showing all supported models (MRC88, MX88, MRAUDIO8X8, DAX66, WS66i, etc.) - Switch amp type selector from dropdown to radio buttons - Fix DAX88 default sources to show all 8 inputs - Add options flow to edit zones, sources, connection, and polling interval after initial setup - Implement optimistic updates for instant UI feedback on power, volume, mute, and source changes
1 parent a8d1410 commit 5dd7ccf

File tree

6 files changed

+356
-27
lines changed

6 files changed

+356
-27
lines changed

custom_components/xantech/config_flow.py

Lines changed: 192 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ async def async_step_user(
9494
): SelectSelector(
9595
SelectSelectorConfig(
9696
options=SUPPORTED_AMP_TYPES,
97-
mode=SelectSelectorMode.DROPDOWN,
97+
mode=SelectSelectorMode.LIST,
9898
translation_key='amp_type',
9999
)
100100
),
@@ -165,7 +165,9 @@ async def async_step_sources(
165165
data=self._data,
166166
)
167167

168-
default_sources = '1: TV\n2: Streaming\n3: Turntable\n4: CD Player'
168+
default_sources = self._get_default_sources_text(
169+
self._data.get(CONF_AMP_TYPE, DEFAULT_AMP_TYPE)
170+
)
169171

170172
return self.async_show_form(
171173
step_id='sources',
@@ -241,6 +243,17 @@ def _get_default_zones_text(self, amp_type: str) -> str:
241243
'14: Office\n15: Patio\n16: Dining Room'
242244
)
243245

246+
def _get_default_sources_text(self, amp_type: str) -> str:
247+
"""Get default sources text for an amplifier type."""
248+
# 8-source amps: xantech8, dax88
249+
if amp_type in ('xantech8', 'dax88'):
250+
return (
251+
'1: TV\n2: Streaming\n3: Turntable\n4: CD Player\n'
252+
'5: Auxiliary\n6: Tuner\n7: Phono\n8: Media Server'
253+
)
254+
# 6-source amps: monoprice6, zpr68-10, sonance6
255+
return '1: TV\n2: Streaming\n3: Turntable\n4: CD Player\n5: Auxiliary\n6: Tuner'
256+
244257
@staticmethod
245258
@callback
246259
def async_get_options_flow(config_entry: ConfigEntry) -> OptionsFlow:
@@ -256,10 +269,53 @@ def __init__(self, config_entry: ConfigEntry) -> None:
256269
self.config_entry = config_entry
257270

258271
async def async_step_init(
272+
self,
273+
user_input: dict[str, Any] | None = None, # noqa: ARG002
274+
) -> ConfigFlowResult:
275+
"""Show menu of configuration options."""
276+
return self.async_show_menu(
277+
step_id='init',
278+
menu_options=['polling', 'connection', 'zones', 'sources'],
279+
)
280+
281+
async def async_step_connection(
282+
self,
283+
user_input: dict[str, Any] | None = None,
284+
) -> ConfigFlowResult:
285+
"""Configure connection settings."""
286+
errors: dict[str, str] = {}
287+
288+
if user_input is not None:
289+
new_port = user_input.get(CONF_PORT, '')
290+
if not new_port:
291+
errors['base'] = 'cannot_connect'
292+
else:
293+
# update config entry data with new port
294+
new_data = {**self.config_entry.data, CONF_PORT: new_port}
295+
self.hass.config_entries.async_update_entry(
296+
self.config_entry, data=new_data
297+
)
298+
return self.async_create_entry(title='', data=self.config_entry.options)
299+
300+
current_port = self.config_entry.data.get(CONF_PORT, '/dev/ttyUSB0')
301+
302+
return self.async_show_form(
303+
step_id='connection',
304+
data_schema=vol.Schema(
305+
{
306+
vol.Required(CONF_PORT, default=current_port): TextSelector(
307+
TextSelectorConfig(type=TextSelectorType.TEXT)
308+
),
309+
}
310+
),
311+
errors=errors,
312+
)
313+
314+
async def async_step_polling(
259315
self,
260316
user_input: dict[str, Any] | None = None,
261317
) -> ConfigFlowResult:
262-
"""Manage the options."""
318+
"""Configure polling interval."""
263319
if user_input is not None:
264320
return self.async_create_entry(title='', data=user_input)
265321

@@ -268,7 +324,7 @@ async def async_step_init(
268324
)
269325

270326
return self.async_show_form(
271-
step_id='init',
327+
step_id='polling',
272328
data_schema=vol.Schema(
273329
{
274330
vol.Optional(
@@ -286,3 +342,135 @@ async def async_step_init(
286342
}
287343
),
288344
)
345+
346+
async def async_step_zones(
347+
self,
348+
user_input: dict[str, Any] | None = None,
349+
) -> ConfigFlowResult:
350+
"""Configure zones."""
351+
errors: dict[str, str] = {}
352+
353+
if user_input is not None:
354+
zones_text = user_input.get('zones_config', '')
355+
zones = self._parse_zones_config(zones_text)
356+
357+
if not zones:
358+
errors['base'] = 'invalid_zones'
359+
else:
360+
# update config entry data with new zones
361+
new_data = {**self.config_entry.data, CONF_ZONES: zones}
362+
self.hass.config_entries.async_update_entry(
363+
self.config_entry, data=new_data
364+
)
365+
return self.async_create_entry(title='', data=self.config_entry.options)
366+
367+
# convert current zones dict back to text
368+
current_zones = self.config_entry.data.get(CONF_ZONES, {})
369+
zones_text = self._zones_to_text(current_zones)
370+
371+
return self.async_show_form(
372+
step_id='zones',
373+
data_schema=vol.Schema(
374+
{
375+
vol.Required('zones_config', default=zones_text): TextSelector(
376+
TextSelectorConfig(
377+
type=TextSelectorType.TEXT,
378+
multiline=True,
379+
)
380+
),
381+
}
382+
),
383+
errors=errors,
384+
description_placeholders={
385+
'example': '11: Living Room\n12: Kitchen\n13: Bedroom',
386+
},
387+
)
388+
389+
async def async_step_sources(
390+
self,
391+
user_input: dict[str, Any] | None = None,
392+
) -> ConfigFlowResult:
393+
"""Configure sources."""
394+
errors: dict[str, str] = {}
395+
396+
if user_input is not None:
397+
sources_text = user_input.get('sources_config', '')
398+
sources = self._parse_sources_config(sources_text)
399+
400+
if not sources:
401+
errors['base'] = 'invalid_sources'
402+
else:
403+
# update config entry data with new sources
404+
new_data = {**self.config_entry.data, CONF_SOURCES: sources}
405+
self.hass.config_entries.async_update_entry(
406+
self.config_entry, data=new_data
407+
)
408+
return self.async_create_entry(title='', data=self.config_entry.options)
409+
410+
# convert current sources dict back to text
411+
current_sources = self.config_entry.data.get(CONF_SOURCES, {})
412+
sources_text = self._sources_to_text(current_sources)
413+
414+
return self.async_show_form(
415+
step_id='sources',
416+
data_schema=vol.Schema(
417+
{
418+
vol.Required('sources_config', default=sources_text): TextSelector(
419+
TextSelectorConfig(
420+
type=TextSelectorType.TEXT,
421+
multiline=True,
422+
)
423+
),
424+
}
425+
),
426+
errors=errors,
427+
description_placeholders={
428+
'example': '1: Sonos\n2: Turntable\n3: TV',
429+
},
430+
)
431+
432+
def _parse_zones_config(self, text: str) -> dict[int, dict[str, str]]:
433+
"""Parse zone configuration text into structured dict."""
434+
zones: dict[int, dict[str, str]] = {}
435+
for line in text.strip().split('\n'):
436+
line = line.strip()
437+
if not line or ':' not in line:
438+
continue
439+
try:
440+
zone_id_str, name = line.split(':', 1)
441+
zone_id = int(zone_id_str.strip())
442+
zones[zone_id] = {'name': name.strip()}
443+
except ValueError:
444+
continue
445+
return zones
446+
447+
def _parse_sources_config(self, text: str) -> dict[int, dict[str, str]]:
448+
"""Parse source configuration text into structured dict."""
449+
sources: dict[int, dict[str, str]] = {}
450+
for line in text.strip().split('\n'):
451+
line = line.strip()
452+
if not line or ':' not in line:
453+
continue
454+
try:
455+
source_id_str, name = line.split(':', 1)
456+
source_id = int(source_id_str.strip())
457+
sources[source_id] = {'name': name.strip()}
458+
except ValueError:
459+
continue
460+
return sources
461+
462+
def _zones_to_text(self, zones: dict[int, dict[str, str]]) -> str:
463+
"""Convert zones dict back to text format."""
464+
lines = []
465+
for zone_id in sorted(zones.keys()):
466+
name = zones[zone_id].get('name', f'Zone {zone_id}')
467+
lines.append(f'{zone_id}: {name}')
468+
return '\n'.join(lines)
469+
470+
def _sources_to_text(self, sources: dict[int, dict[str, str]]) -> str:
471+
"""Convert sources dict back to text format."""
472+
lines = []
473+
for source_id in sorted(sources.keys()):
474+
name = sources[source_id].get('name', f'Source {source_id}')
475+
lines.append(f'{source_id}: {name}')
476+
return '\n'.join(lines)

custom_components/xantech/const.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,18 +24,23 @@
2424
DEFAULT_SCAN_INTERVAL: Final = 30
2525

2626
# Amplifier types supported by pyxantech
27+
# xantech8: MX88, MX88ai, MRC88, MRC88m, MRAUDIO8X8, MRAUDIO8X8m
2728
AMP_TYPE_XANTECH8: Final = 'xantech8'
29+
# monoprice6: Monoprice MPR-SG6Z, Dayton Audio DAX66, Soundavo WS66i
2830
AMP_TYPE_MONOPRICE6: Final = 'monoprice6'
31+
# dax88: Dayton Audio DAX88 (6+2 zone)
2932
AMP_TYPE_DAX88: Final = 'dax88'
33+
# zpr68-10: Xantech ZPR68-10 controller
3034
AMP_TYPE_ZPR68: Final = 'zpr68-10'
31-
AMP_TYPE_SONANCE: Final = 'sonance'
35+
# sonance6: Sonance C4630 SE (6-zone), 875D MKII (4-zone)
36+
AMP_TYPE_SONANCE6: Final = 'sonance6'
3237

3338
SUPPORTED_AMP_TYPES: Final[list[str]] = [
3439
AMP_TYPE_XANTECH8,
3540
AMP_TYPE_MONOPRICE6,
3641
AMP_TYPE_DAX88,
3742
AMP_TYPE_ZPR68,
38-
AMP_TYPE_SONANCE,
43+
AMP_TYPE_SONANCE6,
3944
]
4045

4146
# Max volume level for amps

custom_components/xantech/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"domain": "xantech",
33
"name": "Xantech Multi-Zone Audio",
4-
"version": "0.2.5",
4+
"version": "0.2.6",
55
"documentation": "https://github.com/rsnodgrass/hass-xantech",
66
"issue_tracker": "https://github.com/rsnodgrass/hass-xantech/issues",
77
"requirements": ["pyxantech>=0.10.3"],

0 commit comments

Comments
 (0)