Skip to content

Commit b514906

Browse files
committed
Adds ALL-Link Group support as controlled state (nugget#114)
* This is only the sending half of the Issue nugget#114 implementation * Discovers PLM ALL-Link group controllers and exposes as states so each group is a "switch" in Home Assistant * Sends ALL-Link group cleanups to each responder * Does not flood the network with unnecessary status polls * Correctly reports device state for ALL-Link group recall (0x11) from responder's ALDB stored recall level. Requires device aldb scan to get devices updated correctly; will work OK without the scan but individual devices should be hidden on the UI. * Requires an update to the Home Assistant Insteon platform
1 parent 8b9be57 commit b514906

File tree

6 files changed

+380
-0
lines changed

6 files changed

+380
-0
lines changed

insteonplm/devices/__init__.py

+89
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
MESSAGE_ACK,
2525
MESSAGE_TYPE_BROADCAST_MESSAGE,
2626
MESSAGE_TYPE_DIRECT_MESSAGE,
27+
MESSAGE_TYPE_ALL_LINK_CLEANUP,
2728
MESSAGE_FLAG_DIRECT_MESSAGE_NAK_0XA0,
2829
MESSAGE_STANDARD_MESSAGE_RECEIVED_0X50,
2930
MESSAGE_EXTENDED_MESSAGE_RECEIVED_0X51
@@ -203,6 +204,77 @@ def product_data_request(self):
203204
COMMAND_PRODUCT_DATA_REQUEST_0X03_0X00)
204205
self._send_msg(msg)
205206

207+
def ALL_Link_cleanup(self, group, cmd_tuple):
208+
"""Send an ALL-Link cleanup message to this device.
209+
210+
The device object for the device being cleaned up must send this
211+
message so the messaging lock will correctly sequence the cleanup with
212+
other device activities (e.g. reading ALDB)
213+
"""
214+
_LOGGER.debug('Sending ALL-Link Cleanup to %s for ALL-Link group 0x%x',
215+
self.address.human, group)
216+
flags = MessageFlags.template(MESSAGE_TYPE_ALL_LINK_CLEANUP, 0, 3, 3)
217+
msg = StandardSend(self._address, cmd_tuple, cmd2=group, flags=flags)
218+
self._send_msg(msg, self._handle_ALL_Link_cleanup_ack)
219+
220+
def _handle_ALL_Link_cleanup_ack(self, msg):
221+
_LOGGER.debug('Received ALL-Link Cleanup ACK from %s; ALL-Link group '
222+
'0x%x; looking for responder states',
223+
msg.address.human, msg.cmd2)
224+
# Search Device's ALDB for ID: target and group: cmd2
225+
# Get responder group list and recall_level
226+
responders = self._find_group_responder_states(msg.target, msg.cmd2)
227+
228+
for responder in responders:
229+
_LOGGER.debug('Calling %s:0x%x:handle_ALL_Link_cleanup_ack',
230+
self._address.human, responder)
231+
if hasattr(self.states[responder], 'handle_ALL_Link_cleanup_ack'):
232+
self.states[responder].handle_ALL_Link_cleanup_ack(
233+
msg, responders[responder])
234+
else:
235+
_LOGGER.warning('Device %s:0x%x has no '
236+
'handle_ALL_Link_cleanup_ack() method. '
237+
'Cannot perform ALL-Link cleanup',
238+
self._address.human, responder)
239+
240+
def _find_group_responder_states(self, ctl, ctl_group):
241+
_LOGGER.debug('Looking for responder to controller %s:0x%x',
242+
ctl.human, ctl_group)
243+
responders = {}
244+
if self._aldb:
245+
for rec_num in self._aldb:
246+
rec = self._aldb[rec_num]
247+
# Need to match either group 0 or 1 for ctl group 0 or 1
248+
# compare rec.group using 0x01 if rec.group is 0x00
249+
rec_group = rec.group if rec.group != 0x00 else 0x01
250+
# compare ctl_group using 0x01 if ctl_group
251+
# is 0x00
252+
ctl_group = ctl_group if ctl_group != 0x00 else 0x01
253+
if (rec.control_flags.is_responder and
254+
rec.address == ctl and
255+
rec_group == ctl_group):
256+
# responder group should be 0x01 even if recorded
257+
# as 0x00
258+
group = rec.data3 if rec.data3 != 0x00 else 0x01
259+
_LOGGER.debug('Found responder state %s:0x%x recall '
260+
'level 0x%x for controller %s:0x%x. Actual '
261+
'responder state 0x%x', self.address.human,
262+
rec.data3, rec.data1, ctl.human,
263+
rec.group, group)
264+
responders[group] = rec.data1
265+
if not responders:
266+
_LOGGER.warning('No responders found in ALDB %s',
267+
self.address.human)
268+
else:
269+
if self._aldb.status == ALDBStatus.LOADED:
270+
_LOGGER.warning('No responders found; ALDB is empty %s',
271+
self.address.human)
272+
else:
273+
_LOGGER.warning('No responders found; ALDB is not loaded %s',
274+
self.address.human)
275+
276+
return responders
277+
206278
def assign_to_all_link_group(self, group=0x01):
207279
"""Assign a device to an All-Link Group.
208280
@@ -685,13 +757,25 @@ def receive_message(self, msg):
685757
self._directACK_received_queue.put_nowait(msg)
686758
else:
687759
_LOGGER.debug('But Direct ACK not expected')
760+
elif (hasattr(msg, 'flags') and
761+
hasattr(msg.flags, 'isAllLinkCleanupACK') and
762+
msg.flags.isAllLinkCleanupACK):
763+
_LOGGER.debug('Got ALL-Link Cleanup ACK message. Already in '
764+
'queue: %d, Queueing %s:%s',
765+
self._directACK_received_queue.qsize(),
766+
id(msg), msg)
767+
if self._send_msg_lock.locked():
768+
self._directACK_received_queue.put_nowait(msg)
769+
else:
770+
_LOGGER.debug('But ALL-Link Cleanup ACK not expected')
688771

689772
callbacks = self._message_callbacks.get_callbacks_from_message(msg)
690773
for callback in callbacks:
691774
_LOGGER.debug('Scheduling msg callback: %s', callback)
692775
self._plm.loop.call_soon(callback, msg)
693776
else:
694777
_LOGGER.debug('msg is duplicate: %s', id(msg))
778+
695779
self._last_communication_received = datetime.datetime.now()
696780
_LOGGER.debug('Ending Device.receive_message')
697781

@@ -1348,6 +1432,11 @@ def find_matching_link(self, mode, group, addr):
13481432
reverse direction.
13491433
group: All-Link group number
13501434
addr: Inteon address of the linked device
1435+
1436+
NOTE: This function is flawed because it assumes there is only one
1437+
matching link. There could be multiple responders on a single device
1438+
that are tied to the ALL-Link group (e.g. turn on two relays). Should
1439+
return a list of matching records.
13511440
"""
13521441
found_rec = None
13531442
mode_test = None

insteonplm/plm.py

+68
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
byte_to_unitcode,
3232
rawX10_to_bytes,
3333
x10_command_type)
34+
from insteonplm.states.allLinkGroup import AllLinkGroup
3435

3536
__all__ = ('PLM, Hub')
3637
_LOGGER = logging.getLogger(__name__)
@@ -395,6 +396,42 @@ def _find_scene(self, group):
395396
device_list.append(device.address)
396397
return device_list
397398

399+
def trigger_All_Link_group(self, group, cmd_tuple):
400+
"""Trigger an All-Link Group using command in cmd_tupl."""
401+
from .messages.standardSend import StandardSend
402+
from .messages.messageFlags import MessageFlags
403+
from .constants import MESSAGE_TYPE_ALL_LINK_BROADCAST
404+
405+
target = Address(bytearray([0x00, 0x00, group]))
406+
flags = MessageFlags.template(MESSAGE_TYPE_ALL_LINK_BROADCAST, 0, 3, 3)
407+
# ALL-Link broadcast cmd2 is always 0x00
408+
msg = StandardSend(target, cmd_tuple, cmd2=0x00, flags=flags)
409+
self.send_msg(msg)
410+
_LOGGER.debug('Broadcast ALL-Link Group 0x%x cmd1: 0x%x cmd2: 0x%x',
411+
group, cmd_tuple['cmd1'], 0x00)
412+
413+
# Find all the responders to this PLM ALL-Link Group from PLM ALDB
414+
device_list = self._find_group_controlled_devices(group)
415+
416+
for device in device_list:
417+
_LOGGER.debug('Sending Cleanup to %s for ALL-Link group 0x%x',
418+
device.address.human, group)
419+
# find responder entries in the device's ALDB
420+
device.ALL_Link_cleanup(group, cmd_tuple)
421+
422+
def _find_group_controlled_devices(self, group):
423+
"""Identify all devices where PLM is controller for ALL-Link group."""
424+
device_list = []
425+
426+
for rec_num in self._aldb:
427+
rec = self._aldb[rec_num]
428+
if (rec.control_flags.is_controller and rec.group == group):
429+
if rec.address.id not in device_list:
430+
device = self._devices[rec.address.id]
431+
device_list.append(device)
432+
433+
return device_list
434+
398435
async def _setup_devices(self):
399436
await self.devices.load_saved_device_info()
400437
_LOGGER.info('Found %d saved devices',
@@ -689,6 +726,11 @@ def _handle_all_link_record_response(self, msg):
689726
_LOGGER.debug('Device %s already loaded from saved data or '
690727
'overrides', msg.address.hex)
691728

729+
# If this is a controller entry, create a allLinkGroup state. These
730+
# entries are used for PLM controlled scenes.
731+
if msg.isController:
732+
self._add_controller(msg.group)
733+
692734
self._next_all_link_rec_nak_retries = 0
693735
self._get_next_all_link_record()
694736

@@ -706,8 +748,34 @@ def _handle_get_next_all_link_record_nak(self, msg):
706748
callback = self._cb_load_all_link_db_done.pop()
707749
callback()
708750

751+
# If PLM has ALL-Link group states add PLM to the linkedDevices class
752+
# iterator This will trigger the client call back for a new device
753+
if self._stateList:
754+
self.devices[self._address.id] = self
755+
709756
self._get_device_info()
710757

758+
def _add_controller(self, group):
759+
"""Add ALL-Link Group state object to the IM device.
760+
761+
State object represents controller entries in the PLM ALDB. This
762+
exposes PLM ALL-Link groups as controllable states.
763+
764+
Parameters:
765+
group: ALL-Link group number
766+
767+
Returns:
768+
None
769+
770+
"""
771+
if group not in self._stateList:
772+
# Only add group state on the first occurance in the ALDB
773+
_LOGGER.info('Creating IM state for ALL-Link group 0x%x', group)
774+
self._stateList[group] = AllLinkGroup(
775+
self._address, "ALL-LinkGroup{}".format(group), group,
776+
self._send_msg, self._message_callbacks, 0x00,
777+
self.trigger_All_Link_group)
778+
711779
def _get_device_info(self):
712780
_LOGGER.debug('Starting _get_device_info')
713781
# Remove saved records for devices found in the ALDB

insteonplm/states/__init__.py

+4
Original file line numberDiff line numberDiff line change
@@ -120,4 +120,8 @@ def _update_subscribers(self, val):
120120
"""Save state value and notify listeners of the change."""
121121
self._value = val
122122
for callback in self._observer_callbacks:
123+
_LOGGER.debug('_update_subscribers state %s:0x%x updating '
124+
'subscribers with level 0x%x', self.address.human,
125+
self.group, val)
126+
123127
callback(self._address, self._group, val)

insteonplm/states/allLinkGroup.py

+163
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
"""Dimmable light states."""
2+
import logging
3+
4+
from insteonplm.constants import (
5+
COMMAND_LIGHT_ON_0X11_NONE,
6+
COMMAND_LIGHT_ON_FAST_0X12_NONE,
7+
COMMAND_LIGHT_OFF_0X13_0X00,
8+
COMMAND_LIGHT_OFF_FAST_0X14_0X00,
9+
COMMAND_LIGHT_BRIGHTEN_ONE_STEP_0X15_0X00,
10+
COMMAND_LIGHT_DIM_ONE_STEP_0X16_0X00,
11+
COMMAND_LIGHT_START_MANUAL_CHANGEDOWN_0X17_0X00,
12+
COMMAND_LIGHT_START_MANUAL_CHANGEUP_0X17_0X01,
13+
COMMAND_LIGHT_STOP_MANUAL_CHANGE_0X18_0X00,
14+
COMMAND_LIGHT_STATUS_REQUEST_0X19_0X00,
15+
COMMAND_LIGHT_INSTANT_CHANGE_0X21_NONE,
16+
MESSAGE_TYPE_ALL_LINK_CLEANUP)
17+
from insteonplm.messages.standardSend import StandardSend
18+
from insteonplm.messages.standardReceive import StandardReceive
19+
from insteonplm.messages.messageFlags import MessageFlags
20+
from insteonplm.states import State
21+
22+
_LOGGER = logging.getLogger(__name__)
23+
24+
25+
class AllLinkGroup(State):
26+
"""Device state representing an IM ALL-Link Group.
27+
28+
Available methods are:
29+
on()
30+
off()
31+
brighten()
32+
dim()
33+
"""
34+
35+
def __init__(self, address, statename, group, send_message_method,
36+
message_callbacks, defaultvalue, ALL_Link_cleanup_method):
37+
"""Init the State Class."""
38+
super().__init__(address, statename, group, send_message_method,
39+
message_callbacks, defaultvalue)
40+
41+
self._updatemethod = None
42+
self._ALL_Link_cleanup_method = ALL_Link_cleanup_method
43+
self._register_messages()
44+
45+
# pylint: disable=too-many-locals
46+
def _register_messages(self):
47+
_LOGGER.debug('Registering callbacks for allLinkGroup PLM: %s '
48+
'Group: 0x%x', self._address.human, self._group)
49+
template_on_cleanup = StandardReceive.template(
50+
commandtuple=COMMAND_LIGHT_ON_0X11_NONE,
51+
address=self._address,
52+
flags=MessageFlags.template(MESSAGE_TYPE_ALL_LINK_CLEANUP, None),
53+
cmd2=self._group)
54+
template_on_fast_cleanup = StandardReceive.template(
55+
commandtuple=COMMAND_LIGHT_ON_FAST_0X12_NONE,
56+
address=self._address,
57+
flags=MessageFlags.template(MESSAGE_TYPE_ALL_LINK_CLEANUP, None),
58+
cmd2=self._group)
59+
template_off_cleanup = StandardReceive.template(
60+
commandtuple=COMMAND_LIGHT_OFF_0X13_0X00,
61+
address=self._address,
62+
flags=MessageFlags.template(MESSAGE_TYPE_ALL_LINK_CLEANUP, None),
63+
cmd2=self._group)
64+
template_off_fast_cleanup = StandardReceive.template(
65+
commandtuple=COMMAND_LIGHT_OFF_FAST_0X14_0X00,
66+
address=self._address,
67+
flags=MessageFlags.template(MESSAGE_TYPE_ALL_LINK_CLEANUP, None),
68+
cmd2=self._group)
69+
template_brighten_cleanup = StandardReceive.template(
70+
commandtuple=COMMAND_LIGHT_BRIGHTEN_ONE_STEP_0X15_0X00,
71+
address=self._address,
72+
flags=MessageFlags.template(MESSAGE_TYPE_ALL_LINK_CLEANUP, None),
73+
cmd2=self._group)
74+
template_dim_cleanup = StandardReceive.template(
75+
commandtuple=COMMAND_LIGHT_DIM_ONE_STEP_0X16_0X00,
76+
address=self._address,
77+
flags=MessageFlags.template(MESSAGE_TYPE_ALL_LINK_CLEANUP, None),
78+
cmd2=self._group)
79+
template_manual_start_down_cleanup = StandardReceive.template(
80+
commandtuple=COMMAND_LIGHT_START_MANUAL_CHANGEDOWN_0X17_0X00,
81+
address=self._address,
82+
flags=MessageFlags.template(MESSAGE_TYPE_ALL_LINK_CLEANUP, None),
83+
cmd2=self._group)
84+
template_manual_start_up_cleanup = StandardReceive.template(
85+
commandtuple=COMMAND_LIGHT_START_MANUAL_CHANGEUP_0X17_0X01,
86+
address=self._address,
87+
flags=MessageFlags.template(MESSAGE_TYPE_ALL_LINK_CLEANUP, None),
88+
cmd2=self._group)
89+
template_manual_cleanup = StandardReceive.template(
90+
commandtuple=COMMAND_LIGHT_STOP_MANUAL_CHANGE_0X18_0X00,
91+
address=self._address,
92+
flags=MessageFlags.template(MESSAGE_TYPE_ALL_LINK_CLEANUP, None),
93+
cmd2=self._group)
94+
template_instant_cleanup = StandardReceive.template(
95+
commandtuple=COMMAND_LIGHT_INSTANT_CHANGE_0X21_NONE,
96+
address=self._address,
97+
flags=MessageFlags.template(MESSAGE_TYPE_ALL_LINK_CLEANUP, None),
98+
cmd2=self._group)
99+
100+
self._message_callbacks.add(template_on_cleanup,
101+
self._on_message_received)
102+
self._message_callbacks.add(template_on_fast_cleanup,
103+
self._on_message_received)
104+
self._message_callbacks.add(template_off_cleanup,
105+
self._manual_change_received)
106+
self._message_callbacks.add(template_off_fast_cleanup,
107+
self._manual_change_received)
108+
self._message_callbacks.add(template_brighten_cleanup,
109+
self._manual_change_received)
110+
self._message_callbacks.add(template_dim_cleanup,
111+
self._manual_change_received)
112+
self._message_callbacks.add(template_manual_start_down_cleanup,
113+
self._manual_change_received)
114+
self._message_callbacks.add(template_manual_start_up_cleanup,
115+
self._manual_change_received)
116+
self._message_callbacks.add(template_manual_cleanup,
117+
self._manual_change_received)
118+
self._message_callbacks.add(template_instant_cleanup,
119+
self._manual_change_received)
120+
121+
def on(self):
122+
"""Braodcast All-Link Recall for this state's group."""
123+
self._ALL_Link_cleanup_method(self.group, COMMAND_LIGHT_ON_0X11_NONE)
124+
self._update_subscribers(0xff)
125+
126+
def off(self):
127+
"""Braodcast All-Link off for this state's group."""
128+
self._ALL_Link_cleanup_method(self.group, COMMAND_LIGHT_OFF_0X13_0X00)
129+
self._update_subscribers(0x00)
130+
131+
def brighten(self):
132+
"""Braodcast All-Link Brighten for this state's group."""
133+
self._ALL_Link_cleanup_method(
134+
self.group,
135+
COMMAND_LIGHT_BRIGHTEN_ONE_STEP_0X15_0X00)
136+
137+
def dim(self):
138+
"""Braodcast All-Link Dim for this state's group."""
139+
self._ALL_Link_cleanup_method(self.group,
140+
COMMAND_LIGHT_DIM_ONE_STEP_0X16_0X00)
141+
142+
def _on_message_received(self, msg):
143+
cmd2 = msg.cmd2 if msg.cmd2 else 255
144+
self._update_subscribers(cmd2)
145+
146+
# pylint: disable=unused-argument
147+
def _off_message_received(self, msg):
148+
self._update_subscribers(0x00)
149+
150+
# pylint: disable=unused-argument
151+
def _manual_change_received(self, msg):
152+
self._send_status_request()
153+
154+
def _send_status_request(self):
155+
"""Send a status request message to the device."""
156+
status_command = StandardSend(self._address,
157+
COMMAND_LIGHT_STATUS_REQUEST_0X19_0X00)
158+
self._send_method(status_command,
159+
self._status_message_received)
160+
161+
def _status_message_received(self, msg):
162+
_LOGGER.debug("DimmableSwitch status message received called")
163+
self._update_subscribers(msg.cmd2)

0 commit comments

Comments
 (0)