Skip to content

Commit abb107e

Browse files
committed
add advanced audio controls and channel trim support
- add bass and treble trim controls (-12dB to +12dB in 0.1dB steps) - add loudness on/off switch - add lipsync delay adjustment - add channel trim controls (center, lfe, surrounds, height) - implement control classes in pymcintosh library (sync and async) - create switch platform for loudness control - create number platform for trim and lipsync controls - add proper device_info grouping for all entities - update documentation with new features - bump version to 0.2.0 all controls based on mcintosh mx160/mx170/mx180 rs232 protocol extracted from pyavcontrol library
1 parent 048a7eb commit abb107e

File tree

6 files changed

+788
-9
lines changed

6 files changed

+788
-9
lines changed

README.md

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ Control your McIntosh MX160, MX170, or MX180 audio/video processor from Home Ass
2323
- Full media player control (power, volume, mute, source selection)
2424
- Support for all 26 source inputs (HDMI, SPDIF, USB, Analog, Balanced, Phono)
2525
- Custom source naming via UI configuration
26+
- Advanced audio controls:
27+
- Bass and treble trim (-12dB to +12dB)
28+
- Loudness on/off switch
29+
- Lipsync delay adjustment
30+
- Channel trim controls (center, LFE, surrounds, height)
2631
- Zone 2 support (via pymcintosh library)
2732
- RS232 serial and IP/socket connection support
2833
- Config flow UI for easy setup
@@ -71,13 +76,21 @@ You can customize source names through the **Options** menu after setup.
7176

7277
## Supported Controls
7378

74-
| Feature | Main Zone | Zone 2 |
75-
|---------|-----------|--------|
76-
| Power On/Off || ✅ (via library) |
77-
| Volume Control || ✅ (via library) |
78-
| Mute || ✅ (via library) |
79-
| Source Selection || ✅ (via library) |
80-
| Volume Range | 0-99 | 0-99 |
79+
| Feature | Main Zone | Zone 2 | Entity Type |
80+
|---------|-----------|--------|-------------|
81+
| Power On/Off || ✅ (via library) | media_player |
82+
| Volume Control || ✅ (via library) | media_player |
83+
| Mute || ✅ (via library) | media_player |
84+
| Source Selection || ✅ (via library) | media_player |
85+
| Bass Trim || N/A | number |
86+
| Treble Trim || N/A | number |
87+
| Loudness || N/A | switch |
88+
| Lipsync Delay || N/A | number |
89+
| Center Channel Trim || N/A | number |
90+
| LFE Channel Trim || N/A | number |
91+
| Surround Channels Trim || N/A | number |
92+
| Height Channels Trim || N/A | number |
93+
| Volume Range | 0-99 | 0-99 | - |
8194

8295
**Note:** Zone 2 controls are available through the pymcintosh library but not yet exposed in the HA UI.
8396

custom_components/mcintosh/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919

2020
LOG = logging.getLogger(__name__)
2121

22-
PLATFORMS = [Platform.MEDIA_PLAYER]
22+
PLATFORMS = [Platform.MEDIA_PLAYER, Platform.SWITCH, Platform.NUMBER]
2323

2424

2525
@dataclass

custom_components/mcintosh/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,5 @@
77
"iot_class": "local_polling",
88
"issue_tracker": "https://github.com/homeassistant-community/hass-mcintosh/issues",
99
"requirements": ["pyserial>=3.5", "pyserial-asyncio>=0.6"],
10-
"version": "0.1.0"
10+
"version": "0.2.0"
1111
}
Lines changed: 339 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,339 @@
1+
"""Home Assistant McIntosh Number Platform"""
2+
3+
import logging
4+
from typing import Optional
5+
6+
from homeassistant.components.number import NumberEntity, NumberMode
7+
from homeassistant.config_entries import ConfigEntry
8+
from homeassistant.core import HomeAssistant
9+
from homeassistant.helpers.device_registry import DeviceInfo
10+
from homeassistant.helpers.entity_platform import AddEntitiesCallback
11+
12+
from . import DeviceClientDetails
13+
from .const import CONF_MODEL, DOMAIN
14+
from .pymcintosh.models import get_model_config
15+
16+
LOG = logging.getLogger(__name__)
17+
18+
19+
def _get_device_info(config_entry: ConfigEntry) -> DeviceInfo:
20+
"""Get device info for grouping entities."""
21+
model_id = config_entry.data[CONF_MODEL]
22+
model_config = get_model_config(model_id)
23+
manufacturer = 'McIntosh'
24+
model_name = model_config['name']
25+
device_unique_id = f'{DOMAIN}_{model_id}'.lower().replace(' ', '_')
26+
27+
return DeviceInfo(
28+
identifiers={(DOMAIN, device_unique_id)},
29+
manufacturer=manufacturer,
30+
model=model_name,
31+
name=f'{manufacturer} {model_name}',
32+
)
33+
34+
35+
async def async_setup_entry(
36+
hass: HomeAssistant,
37+
config_entry: ConfigEntry,
38+
async_add_entities: AddEntitiesCallback,
39+
) -> None:
40+
if data := hass.data[DOMAIN][config_entry.entry_id]:
41+
entities = [
42+
McIntoshBassNumber(config_entry, data),
43+
McIntoshTrebleNumber(config_entry, data),
44+
McIntoshLipsyncNumber(config_entry, data),
45+
McIntoshCenterTrimNumber(config_entry, data),
46+
McIntoshLFETrimNumber(config_entry, data),
47+
McIntoshSurroundsTrimNumber(config_entry, data),
48+
McIntoshHeightTrimNumber(config_entry, data),
49+
]
50+
async_add_entities(new_entities=entities, update_before_add=True)
51+
else:
52+
LOG.error(
53+
f'missing pre-connected client for {config_entry}, cannot create number entities'
54+
)
55+
56+
57+
class McIntoshBassNumber(NumberEntity):
58+
_attr_has_entity_name = True
59+
_attr_native_min_value = -120
60+
_attr_native_max_value = 120
61+
_attr_native_step = 10
62+
_attr_mode = NumberMode.SLIDER
63+
_attr_icon = 'mdi:music-clef-bass'
64+
65+
def __init__(self, config_entry: ConfigEntry, details: DeviceClientDetails) -> None:
66+
self._config_entry = config_entry
67+
self._details = details
68+
self._client = details.client
69+
70+
model_id = config_entry.data[CONF_MODEL]
71+
self._attr_unique_id = f'{DOMAIN}_{model_id}_bass'.lower().replace(' ', '_')
72+
self._attr_name = 'Bass Trim'
73+
self._attr_native_unit_of_measurement = 'x0.1dB'
74+
self._attr_device_info = _get_device_info(config_entry)
75+
76+
async def async_added_to_hass(self) -> None:
77+
await self.async_update()
78+
79+
async def async_update(self):
80+
"""Retrieve the latest state."""
81+
LOG.debug(f'updating {self.unique_id}')
82+
83+
try:
84+
value = await self._client.bass_treble.get_bass()
85+
if value is not None:
86+
self._attr_native_value = value
87+
except Exception as e:
88+
LOG.exception(f'could not update {self.unique_id}: {e}')
89+
90+
async def async_set_native_value(self, value: float) -> None:
91+
"""Set bass trim level."""
92+
await self._client.bass_treble.set_bass(int(value))
93+
self.async_schedule_update_ha_state(force_refresh=True)
94+
95+
96+
class McIntoshTrebleNumber(NumberEntity):
97+
_attr_has_entity_name = True
98+
_attr_native_min_value = -120
99+
_attr_native_max_value = 120
100+
_attr_native_step = 10
101+
_attr_mode = NumberMode.SLIDER
102+
_attr_icon = 'mdi:music-clef-treble'
103+
104+
def __init__(self, config_entry: ConfigEntry, details: DeviceClientDetails) -> None:
105+
self._config_entry = config_entry
106+
self._details = details
107+
self._client = details.client
108+
109+
model_id = config_entry.data[CONF_MODEL]
110+
self._attr_unique_id = f'{DOMAIN}_{model_id}_treble'.lower().replace(' ', '_')
111+
self._attr_name = 'Treble Trim'
112+
self._attr_native_unit_of_measurement = 'x0.1dB'
113+
self._attr_device_info = _get_device_info(config_entry)
114+
115+
async def async_added_to_hass(self) -> None:
116+
await self.async_update()
117+
118+
async def async_update(self):
119+
"""Retrieve the latest state."""
120+
LOG.debug(f'updating {self.unique_id}')
121+
122+
try:
123+
value = await self._client.bass_treble.get_treble()
124+
if value is not None:
125+
self._attr_native_value = value
126+
except Exception as e:
127+
LOG.exception(f'could not update {self.unique_id}: {e}')
128+
129+
async def async_set_native_value(self, value: float) -> None:
130+
"""Set treble trim level."""
131+
await self._client.bass_treble.set_treble(int(value))
132+
self.async_schedule_update_ha_state(force_refresh=True)
133+
134+
135+
class McIntoshLipsyncNumber(NumberEntity):
136+
_attr_has_entity_name = True
137+
_attr_mode = NumberMode.BOX
138+
_attr_icon = 'mdi:timer-outline'
139+
140+
def __init__(self, config_entry: ConfigEntry, details: DeviceClientDetails) -> None:
141+
self._config_entry = config_entry
142+
self._details = details
143+
self._client = details.client
144+
145+
model_id = config_entry.data[CONF_MODEL]
146+
self._attr_unique_id = f'{DOMAIN}_{model_id}_lipsync'.lower().replace(' ', '_')
147+
self._attr_name = 'Lipsync Delay'
148+
self._attr_native_unit_of_measurement = 'ms'
149+
self._attr_device_info = _get_device_info(config_entry)
150+
151+
# will be set dynamically from device
152+
self._attr_native_min_value = 0
153+
self._attr_native_max_value = 100
154+
self._attr_native_step = 1
155+
156+
async def async_added_to_hass(self) -> None:
157+
# query device for actual lipsync range
158+
try:
159+
range_info = await self._client.lipsync.get_range()
160+
if range_info:
161+
self._attr_native_min_value = range_info['min']
162+
self._attr_native_max_value = range_info['max']
163+
LOG.debug(f'lipsync range: {range_info}')
164+
except Exception as e:
165+
LOG.warning(f'could not get lipsync range, using defaults: {e}')
166+
167+
await self.async_update()
168+
169+
async def async_update(self):
170+
"""Retrieve the latest state."""
171+
LOG.debug(f'updating {self.unique_id}')
172+
173+
try:
174+
value = await self._client.lipsync.get()
175+
if value is not None:
176+
self._attr_native_value = value
177+
except Exception as e:
178+
LOG.exception(f'could not update {self.unique_id}: {e}')
179+
180+
async def async_set_native_value(self, value: float) -> None:
181+
"""Set lipsync delay."""
182+
await self._client.lipsync.set(int(value))
183+
self.async_schedule_update_ha_state(force_refresh=True)
184+
185+
186+
class McIntoshCenterTrimNumber(NumberEntity):
187+
_attr_has_entity_name = True
188+
_attr_native_min_value = -120
189+
_attr_native_max_value = 120
190+
_attr_native_step = 10
191+
_attr_mode = NumberMode.SLIDER
192+
_attr_icon = 'mdi:speaker'
193+
194+
def __init__(self, config_entry: ConfigEntry, details: DeviceClientDetails) -> None:
195+
self._config_entry = config_entry
196+
self._details = details
197+
self._client = details.client
198+
199+
model_id = config_entry.data[CONF_MODEL]
200+
self._attr_unique_id = f'{DOMAIN}_{model_id}_center_trim'.lower().replace(' ', '_')
201+
self._attr_name = 'Center Channel Trim'
202+
self._attr_native_unit_of_measurement = 'x0.1dB'
203+
self._attr_device_info = _get_device_info(config_entry)
204+
205+
async def async_added_to_hass(self) -> None:
206+
await self.async_update()
207+
208+
async def async_update(self):
209+
"""Retrieve the latest state."""
210+
LOG.debug(f'updating {self.unique_id}')
211+
212+
try:
213+
value = await self._client.channel_trim.get_center()
214+
if value is not None:
215+
self._attr_native_value = value
216+
except Exception as e:
217+
LOG.exception(f'could not update {self.unique_id}: {e}')
218+
219+
async def async_set_native_value(self, value: float) -> None:
220+
"""Set center channel trim level."""
221+
await self._client.channel_trim.set_center(int(value))
222+
self.async_schedule_update_ha_state(force_refresh=True)
223+
224+
225+
class McIntoshLFETrimNumber(NumberEntity):
226+
_attr_has_entity_name = True
227+
_attr_native_min_value = -120
228+
_attr_native_max_value = 120
229+
_attr_native_step = 10
230+
_attr_mode = NumberMode.SLIDER
231+
_attr_icon = 'mdi:waveform'
232+
233+
def __init__(self, config_entry: ConfigEntry, details: DeviceClientDetails) -> None:
234+
self._config_entry = config_entry
235+
self._details = details
236+
self._client = details.client
237+
238+
model_id = config_entry.data[CONF_MODEL]
239+
self._attr_unique_id = f'{DOMAIN}_{model_id}_lfe_trim'.lower().replace(' ', '_')
240+
self._attr_name = 'LFE Channel Trim'
241+
self._attr_native_unit_of_measurement = 'x0.1dB'
242+
self._attr_device_info = _get_device_info(config_entry)
243+
244+
async def async_added_to_hass(self) -> None:
245+
await self.async_update()
246+
247+
async def async_update(self):
248+
"""Retrieve the latest state."""
249+
LOG.debug(f'updating {self.unique_id}')
250+
251+
try:
252+
value = await self._client.channel_trim.get_lfe()
253+
if value is not None:
254+
self._attr_native_value = value
255+
except Exception as e:
256+
LOG.exception(f'could not update {self.unique_id}: {e}')
257+
258+
async def async_set_native_value(self, value: float) -> None:
259+
"""Set LFE channel trim level."""
260+
await self._client.channel_trim.set_lfe(int(value))
261+
self.async_schedule_update_ha_state(force_refresh=True)
262+
263+
264+
class McIntoshSurroundsTrimNumber(NumberEntity):
265+
_attr_has_entity_name = True
266+
_attr_native_min_value = -120
267+
_attr_native_max_value = 120
268+
_attr_native_step = 10
269+
_attr_mode = NumberMode.SLIDER
270+
_attr_icon = 'mdi:surround-sound'
271+
272+
def __init__(self, config_entry: ConfigEntry, details: DeviceClientDetails) -> None:
273+
self._config_entry = config_entry
274+
self._details = details
275+
self._client = details.client
276+
277+
model_id = config_entry.data[CONF_MODEL]
278+
self._attr_unique_id = f'{DOMAIN}_{model_id}_surrounds_trim'.lower().replace(' ', '_')
279+
self._attr_name = 'Surround Channels Trim'
280+
self._attr_native_unit_of_measurement = 'x0.1dB'
281+
self._attr_device_info = _get_device_info(config_entry)
282+
283+
async def async_added_to_hass(self) -> None:
284+
await self.async_update()
285+
286+
async def async_update(self):
287+
"""Retrieve the latest state."""
288+
LOG.debug(f'updating {self.unique_id}')
289+
290+
try:
291+
value = await self._client.channel_trim.get_surrounds()
292+
if value is not None:
293+
self._attr_native_value = value
294+
except Exception as e:
295+
LOG.exception(f'could not update {self.unique_id}: {e}')
296+
297+
async def async_set_native_value(self, value: float) -> None:
298+
"""Set surround channels trim level."""
299+
await self._client.channel_trim.set_surrounds(int(value))
300+
self.async_schedule_update_ha_state(force_refresh=True)
301+
302+
303+
class McIntoshHeightTrimNumber(NumberEntity):
304+
_attr_has_entity_name = True
305+
_attr_native_min_value = -120
306+
_attr_native_max_value = 120
307+
_attr_native_step = 10
308+
_attr_mode = NumberMode.SLIDER
309+
_attr_icon = 'mdi:arrow-up-bold'
310+
311+
def __init__(self, config_entry: ConfigEntry, details: DeviceClientDetails) -> None:
312+
self._config_entry = config_entry
313+
self._details = details
314+
self._client = details.client
315+
316+
model_id = config_entry.data[CONF_MODEL]
317+
self._attr_unique_id = f'{DOMAIN}_{model_id}_height_trim'.lower().replace(' ', '_')
318+
self._attr_name = 'Height Channels Trim'
319+
self._attr_native_unit_of_measurement = 'x0.1dB'
320+
self._attr_device_info = _get_device_info(config_entry)
321+
322+
async def async_added_to_hass(self) -> None:
323+
await self.async_update()
324+
325+
async def async_update(self):
326+
"""Retrieve the latest state."""
327+
LOG.debug(f'updating {self.unique_id}')
328+
329+
try:
330+
value = await self._client.channel_trim.get_height()
331+
if value is not None:
332+
self._attr_native_value = value
333+
except Exception as e:
334+
LOG.exception(f'could not update {self.unique_id}: {e}')
335+
336+
async def async_set_native_value(self, value: float) -> None:
337+
"""Set height channels trim level."""
338+
await self._client.channel_trim.set_height(int(value))
339+
self.async_schedule_update_ha_state(force_refresh=True)

0 commit comments

Comments
 (0)