Skip to content

Commit 6e5a5c1

Browse files
authored
Merge pull request #50 from davidrecordon/master
Normalize "Undefined" variable values to None
2 parents b90745c + 98dc93f commit 6e5a5c1

10 files changed

Lines changed: 117 additions & 8 deletions

File tree

pyControl4/blind.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,51 +7,67 @@ class C4Blind(C4Entity):
77
async def getBatteryLevel(self):
88
"""Returns the battery of a blind. We currently don't know the range or meaning."""
99
value = await self.director.getItemVariableValue(self.item_id, "Battery Level")
10+
if value is None:
11+
return None
1012
return int(value)
1113

1214
async def getClosing(self):
1315
"""Returns an indication of whether the blind is moving in the closed direction as a boolean
1416
(True=closing, False=opening). If the blind is stopped, reports the direction it last moved.
1517
"""
1618
value = await self.director.getItemVariableValue(self.item_id, "Closing")
19+
if value is None:
20+
return None
1721
return bool(value)
1822

1923
async def getFullyClosed(self):
2024
"""Returns an indication of whether the blind is fully closed as a boolean
2125
(True=fully closed, False=at least partially open)."""
2226
value = await self.director.getItemVariableValue(self.item_id, "Fully Closed")
27+
if value is None:
28+
return None
2329
return bool(value)
2430

2531
async def getFullyOpen(self):
2632
"""Returns an indication of whether the blind is fully open as a boolean
2733
(True=fully open, False=at least partially closed)."""
2834
value = await self.director.getItemVariableValue(self.item_id, "Fully Open")
35+
if value is None:
36+
return None
2937
return bool(value)
3038

3139
async def getLevel(self):
3240
"""Returns the level (current position) of a blind as an int 0-100.
3341
0 is fully closed and 100 is fully open.
3442
"""
3543
value = await self.director.getItemVariableValue(self.item_id, "Level")
44+
if value is None:
45+
return None
3646
return int(value)
3747

3848
async def getOpen(self):
3949
"""Returns an indication of whether the blind is open as a boolean (True=open, False=closed).
4050
This is true even if the blind is only partially open."""
4151
value = await self.director.getItemVariableValue(self.item_id, "Open")
52+
if value is None:
53+
return None
4254
return bool(value)
4355

4456
async def getOpening(self):
4557
"""Returns an indication of whether the blind is moving in the open direction as a boolean
4658
(True=opening, False=closing). If the blind is stopped, reports the direction it last moved.
4759
"""
4860
value = await self.director.getItemVariableValue(self.item_id, "Opening")
61+
if value is None:
62+
return None
4963
return bool(value)
5064

5165
async def getStopped(self):
5266
"""Returns an indication of whether the blind is stopped as a boolean
5367
(True=stopped, False=moving)."""
5468
value = await self.director.getItemVariableValue(self.item_id, "Stopped")
69+
if value is None:
70+
return None
5571
return bool(value)
5672

5773
async def getTargetLevel(self):
@@ -60,6 +76,8 @@ async def getTargetLevel(self):
6076
0 is fully closed and 100 is fully open.
6177
"""
6278
value = await self.director.getItemVariableValue(self.item_id, "Target Level")
79+
if value is None:
80+
return None
6381
return int(value)
6482

6583
async def open(self):

pyControl4/director.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,10 @@ async def getItemVariableValue(self, item_id, var_name):
165165
raise ValueError("Empty response recieved from Director! The variable {} \
166166
doesn't seem to exist for item {}.".format(var_name, item_id))
167167
jsonDictionary = json.loads(data)
168-
return jsonDictionary[0]["value"]
168+
value = jsonDictionary[0]["value"]
169+
if value == "Undefined":
170+
return None
171+
return value
169172

170173
async def getAllItemVariableValue(self, var_name):
171174
"""Returns a dictionary with the values of the specified variable
@@ -184,6 +187,9 @@ async def getAllItemVariableValue(self, var_name):
184187
raise ValueError("Empty response recieved from Director! The variable {} \
185188
doesn't seem to exist for any items.".format(var_name))
186189
jsonDictionary = json.loads(data)
190+
for item in jsonDictionary:
191+
if item.get("value") == "Undefined":
192+
item["value"] = None
187193
return jsonDictionary
188194

189195
async def getItemCommands(self, item_id):

pyControl4/fan.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ async def getState(self):
1616
bool: True if the fan is on, False otherwise.
1717
"""
1818
value = await self.director.getItemVariableValue(self.item_id, "IS_ON")
19+
if value is None:
20+
return None
1921
return bool(value)
2022

2123
async def getSpeed(self):
@@ -37,6 +39,8 @@ async def getSpeed(self):
3739
int: Current fan speed (0–4).
3840
"""
3941
value = await self.director.getItemVariableValue(self.item_id, "CURRENT_SPEED")
42+
if value is None:
43+
return None
4044
return int(value)
4145

4246
# ------------------------

pyControl4/light.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,15 @@ async def getLevel(self):
99
Will cause an error if called on a non-dimmer switch. Use `getState()` instead.
1010
"""
1111
value = await self.director.getItemVariableValue(self.item_id, "LIGHT_LEVEL")
12+
if value is None:
13+
return None
1214
return int(value)
1315

1416
async def getState(self):
1517
"""Returns the power state of a dimmer or switch as a boolean (True=on, False=off)."""
1618
value = await self.director.getItemVariableValue(self.item_id, "LIGHT_STATE")
19+
if value is None:
20+
return None
1721
return bool(value)
1822

1923
async def setLevel(self, level):

pyControl4/relay.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,10 @@ async def getRelayStateVerified(self):
2020
I think this is just used to verify that the relay is functional,
2121
not 100% sure though.
2222
"""
23-
return bool(
24-
await self.director.getItemVariableValue(self.item_id, "StateVerified")
25-
)
23+
value = await self.director.getItemVariableValue(self.item_id, "StateVerified")
24+
if value is None:
25+
return None
26+
return bool(value)
2627

2728
async def open(self):
2829
"""Set the relay to its open state.

pyControl4/room.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"""Controls Control4 Room devices."""
22

3+
from typing import Optional
4+
35
from pyControl4 import C4Entity
46

57

@@ -8,14 +10,18 @@ class C4Room(C4Entity):
810
A media-oriented view of a Control4 Room, supporting items of type="room"
911
"""
1012

11-
async def isRoomHidden(self) -> bool:
13+
async def isRoomHidden(self) -> Optional[bool]:
1214
"""Returns True if the room is hidden from the end-user"""
1315
value = await self.director.getItemVariableValue(self.item_id, "ROOM_HIDDEN")
16+
if value is None:
17+
return None
1418
return int(value) != 0
1519

16-
async def isOn(self) -> bool:
20+
async def isOn(self) -> Optional[bool]:
1721
"""Returns True/False if the room is "ON" from the director's perspective"""
1822
value = await self.director.getItemVariableValue(self.item_id, "POWER_STATE")
23+
if value is None:
24+
return None
1925
return int(value) != 0
2026

2127
async def setRoomOff(self):
@@ -45,14 +51,18 @@ async def setVideoAndAudioSource(self, source_id: int):
4551
"""Sets the current audio and video source for the room"""
4652
await self._setSource(source_id, audio_only=False)
4753

48-
async def getVolume(self) -> int:
54+
async def getVolume(self) -> Optional[int]:
4955
"""Returns the current volume for the room from 0-100"""
5056
value = await self.director.getItemVariableValue(self.item_id, "CURRENT_VOLUME")
57+
if value is None:
58+
return None
5159
return int(value)
5260

53-
async def isMuted(self) -> bool:
61+
async def isMuted(self) -> Optional[bool]:
5462
"""Returns True if the room is muted"""
5563
value = await self.director.getItemVariableValue(self.item_id, "IS_MUTED")
64+
if value is None:
65+
return None
5666
return int(value) != 0
5767

5868
async def setMuteOn(self):

pytest.ini

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[pytest]
2+
markers =
3+
asyncio: marks tests as asyncio tests, requires the pytest-asyncio plugin.

requirements-dev.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ xmltodict
33
python-socketio-v4
44
websocket-client
55
pdoc3
6+
pytest-asyncio

tests/__init__.py

Whitespace-only changes.

tests/test_undefined_handling.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""Tests for handling of 'Undefined' variable values from the Control4 Director."""
2+
3+
import json
4+
from unittest.mock import AsyncMock, patch
5+
6+
import pytest
7+
8+
from pyControl4.director import C4Director
9+
from pyControl4.light import C4Light
10+
from pyControl4.blind import C4Blind
11+
12+
13+
@pytest.fixture
14+
def director():
15+
"""Create a C4Director with a mocked session."""
16+
return C4Director("192.168.1.1", "test-token")
17+
18+
19+
@pytest.mark.asyncio
20+
async def test_get_item_variable_value_undefined(director):
21+
"""Test that getItemVariableValue normalizes 'Undefined' to None."""
22+
response = json.dumps([{"id": 123, "varName": "HUMIDITY", "value": "Undefined"}])
23+
with patch.object(director, "sendGetRequest", new=AsyncMock(return_value=response)):
24+
result = await director.getItemVariableValue(123, "HUMIDITY")
25+
assert result is None
26+
27+
28+
@pytest.mark.asyncio
29+
async def test_get_all_item_variable_value_undefined(director):
30+
"""Test that getAllItemVariableValue normalizes 'Undefined' to None in items."""
31+
response = json.dumps(
32+
[
33+
{"id": 100, "varName": "HUMIDITY", "value": "Undefined"},
34+
{"id": 100, "varName": "TEMPERATURE_F", "value": 72.5},
35+
{"id": 200, "varName": "HUMIDITY", "value": 45},
36+
]
37+
)
38+
with patch.object(director, "sendGetRequest", new=AsyncMock(return_value=response)):
39+
result = await director.getAllItemVariableValue("HUMIDITY,TEMPERATURE_F")
40+
assert result[0]["value"] is None
41+
assert result[1]["value"] == 72.5
42+
assert result[2]["value"] == 45
43+
44+
45+
@pytest.mark.asyncio
46+
async def test_light_get_level_undefined(director):
47+
"""Test that int callers propagate None instead of crashing."""
48+
light = C4Light(director, 100)
49+
response = json.dumps([{"id": 100, "varName": "LIGHT_LEVEL", "value": "Undefined"}])
50+
with patch.object(director, "sendGetRequest", new=AsyncMock(return_value=response)):
51+
result = await light.getLevel()
52+
assert result is None
53+
54+
55+
@pytest.mark.asyncio
56+
async def test_blind_get_fully_open_undefined(director):
57+
"""Test that bool callers propagate None instead of a misleading value."""
58+
blind = C4Blind(director, 200)
59+
response = json.dumps([{"id": 200, "varName": "Fully Open", "value": "Undefined"}])
60+
with patch.object(director, "sendGetRequest", new=AsyncMock(return_value=response)):
61+
result = await blind.getFullyOpen()
62+
assert result is None

0 commit comments

Comments
 (0)