Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
106 changes: 103 additions & 3 deletions pylutron/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@

import asyncio
import telnetlib3
from typing import Any, Callable, Dict, Type, List, Optional, Tuple, Union, TYPE_CHECKING, cast
from typing import Any, Callable, Dict, Set, Type, List, Optional, Tuple, Union, TYPE_CHECKING, cast

_LOGGER = logging.getLogger(__name__)

Expand Down Expand Up @@ -276,11 +276,70 @@ def __init__(self, lutron: Lutron, xml_db_str: Union[str, bytes]) -> None:
self.areas: List[Area] = []
self._occupancy_groups: Dict[str, OccupancyGroup] = {}
self.project_name: Optional[str] = None
self._scene_levels: Dict[str, Dict[str, Dict[int, float]]] = {}

# Area integration id -> scene number -> {output integration id: level}.
# Buttons reference scenes by (area, number), and the area owning the scene
# is not necessarily the one owning the button, so this is built up front.
def _build_scene_table(self, root: ET.Element) -> None:
for area_xml in root.iter('Area'):
area_id = area_xml.get('IntegrationID')
if area_id is None:
continue
for scene_xml in area_xml.findall('./Scenes/Scene'):
number = scene_xml.get('Number')
if number is None:
continue
levels, _ = self._preset_assignments(scene_xml)
self._scene_levels.setdefault(area_id, {})[number] = levels

@staticmethod
def _preset_assignments(
node: ET.Element) -> Tuple[Dict[int, float], List[Tuple[str, str]]]:
"""Splits a node's PresetAssignments into direct levels and scene refs."""
levels: Dict[int, float] = {}
scene_refs: List[Tuple[str, str]] = []
for pa_xml in node.iter('PresetAssignment'):
integration_id = pa_xml.findtext('IntegrationID')
if not integration_id:
continue
kind = pa_xml.get('AssignmentName')
if kind == 'GOTO_LEVEL':
try:
levels[int(integration_id)] = float(pa_xml.findtext('Level') or 0)
except ValueError:
continue
elif kind == 'GOTO_SCENE':
number = pa_xml.findtext('Number')
if number:
scene_refs.append((integration_id, number))
return levels, scene_refs

def _button_affected_outputs(
self, button_xml: ET.Element) -> Dict[str, Dict[int, float]]:
"""Resolves what each of a button's actions assigns, scene refs included.

A button is programmed per action: Press, Release, Hold and MultiTap can
each drive a different set of outputs to different levels, so they are kept
apart rather than merged into one mapping.
"""
by_action: Dict[str, Dict[int, float]] = {}
for action_xml in button_xml.findall('./Actions/Action'):
action = action_xml.get('Name') or action_xml.get('ActionType') or ''
levels_for_action = by_action.setdefault(action, {})
for preset_xml in action_xml.findall('./Presets/Preset'):
levels, scene_refs = self._preset_assignments(preset_xml)
levels_for_action.update(levels)
for area_id, number in scene_refs:
levels_for_action.update(
self._scene_levels.get(area_id, {}).get(number, {}))
return {action: levels for action, levels in by_action.items() if levels}

def parse(self) -> bool:
"""Main entrypoint into the parser. It interprets and creates all the
relevant Lutron objects and stuffs them into the appropriate hierarchy."""
root = ET.fromstring(self._xml_db_str)
self._build_scene_table(root)
# The structure is something like this:
# <Areas>
# <Area ...>
Expand Down Expand Up @@ -437,7 +496,8 @@ def _parse_button(self, keypad: Keypad, component_xml: ET.Element) -> Button:
num=int(component_xml.get('ComponentNumber') or 0),
button_type=button_type,
direction=direction,
uuid=button_xml.get('UUID') or "")
uuid=button_xml.get('UUID') or "",
affected_outputs_by_action=self._button_affected_outputs(button_xml))
return button

def _parse_led(self, keypad: Keypad, component_xml: ET.Element) -> Led:
Expand Down Expand Up @@ -1001,11 +1061,14 @@ class Event(LutronEvent):
RELEASED = 2
DOUBLE_CLICKED = 3

def __init__(self, lutron: Lutron, keypad: Keypad, name: str, num: int, button_type: str, direction: Optional[str], uuid: str) -> None:
def __init__(self, lutron: Lutron, keypad: Keypad, name: str, num: int, button_type: str, direction: Optional[str], uuid: str, affected_outputs_by_action: Optional[Dict[str, Dict[int, float]]] = None) -> None:
"""Initializes the Button class."""
super(Button, self).__init__(lutron, keypad, name, num, num, uuid)
self._button_type = button_type
self._direction = direction
self._affected_outputs_by_action = {
action: dict(levels)
for action, levels in (affected_outputs_by_action or {}).items()}

def __str__(self) -> str:
"""Pretty printed string value of the Button object."""
Expand All @@ -1022,6 +1085,43 @@ def button_type(self) -> str:
"""Returns the button type (Toggle, MasterRaiseLower, etc.)."""
return self._button_type

@property
def affected_outputs(self) -> Set[int]:
"""Integration ids of the outputs this button's programming can change.

Read from the button's programming in the XML database: the presets of
every action it defines (Press, Release, Hold, MultiTap), with GOTO_SCENE
assignments resolved through the target area's scene table. Empty for
buttons that drive nothing (or whose programming the database does not
describe).

This is the union across actions, which is what a consumer needs to refresh
the right subset of outputs after a button event instead of every output in
the system. Which of them actually moved depends on the action the button
reported; see `affected_outputs_by_action` for the per-action programming.
"""
outputs: Set[int] = set()
for levels in self._affected_outputs_by_action.values():
outputs.update(levels)
return outputs

@property
def affected_outputs_by_action(self) -> Dict[str, Dict[int, float]]:
"""Per-action programming, as {action name: {output id: level}}.

Keyed by the action name used in the XML database ('Press', 'Release',
'Hold', 'MultiTap'); actions with no assignments are omitted. A button can
program the same output to different levels on different actions, so these
are deliberately not merged.

The levels are what the programming assigns, which is not always what the
outputs end up at: a toggle button's result depends on the current state,
and a raise/lower button's depends on how long it is held. Treat them as a
prediction to be confirmed, not as fact.
"""
return {action: dict(levels)
for action, levels in self._affected_outputs_by_action.items()}

def press(self) -> None:
"""Triggers a simulated button press to the Keypad."""
self._lutron.send(Lutron.OP_EXECUTE, Keypad._CMD_TYPE, self._keypad.id,
Expand Down
158 changes: 158 additions & 0 deletions tests/test_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,113 @@
"""


# A button driving outputs directly (GOTO_LEVEL) next to one going through a
# scene owned by a *different* area (GOTO_SCENE). That split is the common
# shape in HomeWorks QS projects: wall keypads sit in their own area and recall
# scenes belonging to the rooms they control.
BUTTON_PROGRAMMING_XML = """
<Lutron>
<GUID>12345678-ABCD-1234-ABCD-1234567890AB</GUID>
<Areas>
<Area Name="Project">
<Areas>
<Area Name="Living Room" IntegrationID="1">
<Scenes>
<Scene Number="3" Name="Evening">
<Presets>
<Preset UUID="900">
<PresetAssignments>
<PresetAssignment AssignmentName="GOTO_LEVEL" AssignmentType="2">
<Level>40.00</Level><IntegrationID>10</IntegrationID>
</PresetAssignment>
<PresetAssignment AssignmentName="GOTO_LEVEL" AssignmentType="2">
<Level>0.00</Level><IntegrationID>11</IntegrationID>
</PresetAssignment>
</PresetAssignments>
</Preset>
</Presets>
</Scene>
</Scenes>
<Outputs>
<Output Name="Downlights" IntegrationID="10" OutputType="DALI" Wattage="0" UUID="601" />
<Output Name="Cove" IntegrationID="11" OutputType="DALI" Wattage="0" UUID="602" />
<Output Name="Lamp" IntegrationID="12" OutputType="DALI" Wattage="0" UUID="603" />
</Outputs>
</Area>
<Area Name="Hall" IntegrationID="2">
<DeviceGroups>
<DeviceGroup Name="Wall Keypad">
<Devices>
<Device Name="Entry" IntegrationID="20" DeviceType="SEETOUCH_KEYPAD" UUID="700">
<Components>
<Component ComponentNumber="1" ComponentType="BUTTON">
<Button Engraving="Evening" ButtonType="SingleAction" UUID="701">
<Actions><Action Name="Press" ActionType="3"><Presets>
<Preset Name="Press On" UUID="702"><PresetAssignments>
<PresetAssignment AssignmentName="GOTO_SCENE" AssignmentType="5">
<Number>3</Number><IntegrationID>1</IntegrationID>
</PresetAssignment>
</PresetAssignments></Preset>
</Presets></Action></Actions>
</Button>
</Component>
<Component ComponentNumber="2" ComponentType="BUTTON">
<Button Engraving="Lamp" ButtonType="SingleAction" UUID="703">
<Actions><Action Name="Press" ActionType="3"><Presets>
<Preset Name="Press On" UUID="704"><PresetAssignments>
<PresetAssignment AssignmentName="GOTO_LEVEL" AssignmentType="2">
<Level>75.00</Level><IntegrationID>12</IntegrationID>
</PresetAssignment>
</PresetAssignments></Preset>
</Presets></Action></Actions>
</Button>
</Component>
<Component ComponentNumber="3" ComponentType="BUTTON">
<Button Engraving="Unprogrammed" ButtonType="SingleAction" UUID="705" />
</Component>
<Component ComponentNumber="4" ComponentType="BUTTON">
<Button Engraving="Multi" ButtonType="SingleAction" UUID="706">
<Actions>
<Action Name="Press" ActionType="3"><Presets>
<Preset Name="On" UUID="707"><PresetAssignments>
<PresetAssignment AssignmentName="GOTO_LEVEL" AssignmentType="2">
<Level>100.00</Level><IntegrationID>13</IntegrationID>
</PresetAssignment>
</PresetAssignments></Preset>
</Presets></Action>
<Action Name="Release" ActionType="4"><Presets>
<Preset Name="Off" UUID="708"><PresetAssignments>
<PresetAssignment AssignmentName="GOTO_LEVEL" AssignmentType="2">
<Level>0.00</Level><IntegrationID>13</IntegrationID>
</PresetAssignment>
<PresetAssignment AssignmentName="GOTO_LEVEL" AssignmentType="2">
<Level>50.00</Level><IntegrationID>14</IntegrationID>
</PresetAssignment>
</PresetAssignments></Preset>
</Presets></Action>
<Action Name="Hold" ActionType="5"><Presets>
<Preset Name="Dim" UUID="709"><PresetAssignments>
<PresetAssignment AssignmentName="GOTO_LEVEL" AssignmentType="2">
<Level>25.00</Level><IntegrationID>15</IntegrationID>
</PresetAssignment>
</PresetAssignments></Preset>
</Presets></Action>
</Actions>
</Button>
</Component>
</Components>
</Device>
</Devices>
</DeviceGroup>
</DeviceGroups>
</Area>
</Areas>
</Area>
</Areas>
</Lutron>
"""


class TestLutronXmlDbParser(unittest.TestCase):
def setUp(self) -> None:
self.lutron = Lutron('localhost', 'user', 'pass')
Expand Down Expand Up @@ -159,3 +266,54 @@ def test_parse_keypad(self) -> None:

if __name__ == '__main__':
unittest.main()


class TestButtonAffectedOutputs(unittest.TestCase):
"""Buttons expose which outputs their programming can change."""

def setUp(self) -> None:
self.lutron = Lutron("1.1.1.1", "user", "pass")
parser = LutronXmlDbParser(self.lutron, BUTTON_PROGRAMMING_XML)
self.assertTrue(parser.parse())
self.buttons = {
b.name: b
for area in parser.areas for kp in area.keypads for b in kp.buttons
}

def test_scene_reference_is_resolved_across_areas(self) -> None:
# The scene belongs to "Living Room"; the keypad sits in "Hall".
button = self.buttons["Evening"]
self.assertEqual(button.affected_outputs, {10, 11})
self.assertEqual(button.affected_outputs_by_action,
{"Press": {10: 40.0, 11: 0.0}})

def test_direct_level_assignment(self) -> None:
button = self.buttons["Lamp"]
self.assertEqual(button.affected_outputs, {12})
self.assertEqual(button.affected_outputs_by_action,
{"Press": {12: 75.0}})

def test_button_without_programming(self) -> None:
button = self.buttons["Unprogrammed"]
self.assertEqual(button.affected_outputs, set())
self.assertEqual(button.affected_outputs_by_action, {})

def test_actions_are_not_merged(self) -> None:
# Press and Release drive output 13 to conflicting levels; keeping them
# apart is the only way either value means anything.
button = self.buttons["Multi"]
self.assertEqual(button.affected_outputs_by_action,
{"Press": {13: 100.0},
"Release": {13: 0.0, 14: 50.0},
"Hold": {15: 25.0}})

def test_affected_outputs_is_the_union_across_actions(self) -> None:
self.assertEqual(self.buttons["Multi"].affected_outputs, {13, 14, 15})

def test_returned_mapping_is_a_copy(self) -> None:
button = self.buttons["Lamp"]
button.affected_outputs.add(99)
button.affected_outputs_by_action["Press"][12] = 0.0
self.assertEqual(button.affected_outputs, {12})
self.assertEqual(button.affected_outputs_by_action,
{"Press": {12: 75.0}})