Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"python.testing.pytestArgs": ["tests"],
"python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true
}
96 changes: 68 additions & 28 deletions lghorizon/lghorizon_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,14 @@ def _redact_sensitive(data):
if not isinstance(data, dict):
return data
redacted = dict(data)
for key in ("accessToken", "access_token", "refreshToken", "refresh_token", "token", "password"):
for key in (
"accessToken",
"access_token",
"refreshToken",
"refresh_token",
"token",
"password",
):
if key in redacted:
redacted[key] = "***REDACTED***"
return redacted
Expand Down Expand Up @@ -264,13 +271,15 @@ def ad_manifest(self) -> List[LGHorizonAdBreak]:
raw_manifest = self._raw_json.get("adManifest", [])
breaks = []
for entry in raw_manifest:
breaks.append(LGHorizonAdBreak(
start_ms=entry.get("dStart", 0),
end_ms=entry.get("dEnd", 0),
ad_type=entry.get("adType", "UNKNOWN"),
is_skippable=entry.get("isSkippable", False),
has_counter=entry.get("adCounter", False),
))
breaks.append(
LGHorizonAdBreak(
start_ms=entry.get("dStart", 0),
end_ms=entry.get("dEnd", 0),
ad_type=entry.get("adType", "UNKNOWN"),
is_skippable=entry.get("isSkippable", False),
has_counter=entry.get("adCounter", False),
)
)
return breaks

@property
Expand Down Expand Up @@ -574,7 +583,9 @@ def __init__(
self._token_expiry = None
self._country_code = country_code
self._host = COUNTRY_SETTINGS[country_code]["api_url"]
self._use_refresh_token = COUNTRY_SETTINGS[country_code]["use_refreshtoken"] or bool(refresh_token)
self._use_refresh_token = COUNTRY_SETTINGS[country_code][
"use_refreshtoken"
] or bool(refresh_token)
self._service_config = None
self._token_refresh_callback = token_refresh_callback

Expand Down Expand Up @@ -726,7 +737,9 @@ async def request(self, host: str, path: str, params=None, **kwargs) -> Any:
return json_response
except ClientResponseError as cre:
if cre.status == 401:
_LOGGER.debug("Got 401 from %s, refreshing token and retrying", request_url)
_LOGGER.debug(
"Got 401 from %s, refreshing token and retrying", request_url
)
await self.fetch_access_token()
try:
web_response = await self.websession.request(
Expand All @@ -741,7 +754,9 @@ async def request(self, host: str, path: str, params=None, **kwargs) -> Any:
)
return json_response
except ClientResponseError as retry_cre:
_LOGGER.error("Retry failed for %s: %s", request_url, str(retry_cre))
_LOGGER.error(
"Retry failed for %s: %s", request_url, str(retry_cre)
)
raise LGHorizonApiConnectionError(
f"Unable to call {request_url}. Error:{str(retry_cre)}"
) from retry_cre
Expand Down Expand Up @@ -867,7 +882,16 @@ def get_service_url(self, service_name: str) -> str:
ValueError: If the service or its URL is not found
"""
if service_name in self._config and "URL" in self._config[service_name]:
return self._config[service_name]["URL"]
url = self._config[service_name]["URL"]

# Temporary override for broken Ziggo NL EPG server
if "static.spark.ziggogo.tv" in url:
url = url.replace(
"static.spark.ziggogo.tv", "staticqbr-prod-nl.gnp.cloud.ziggogo.tv"
)

return url

raise ValueError(f"Service URL for '{service_name}' not found in configuration")

def get_all_services(self) -> dict[str, str]:
Expand Down Expand Up @@ -924,7 +948,9 @@ def recording_retention_period(self) -> Optional[int]:
@property
def has_cloud_recording(self) -> bool:
"""Return whether the customer has cloud recording."""
return bool(self.recording_retention_period and self.recording_retention_period > 0)
return bool(
self.recording_retention_period and self.recording_retention_period > 0
)

@property
def assigned_devices(self) -> list[str]:
Expand All @@ -948,15 +974,22 @@ def get_profile_lang(self, profile_id: str) -> str:
return self.profiles[profile_id].options.lang



@dataclass
class LGHorizonDeviceState:
"""Represent current state of a box."""

state: LGHorizonRunningState = field(default_factory=lambda: LGHorizonRunningState.UNKNOWN)
source_type: LGHorizonSourceType = field(default_factory=lambda: LGHorizonSourceType.UNKNOWN)
ui_state_type: LGHorizonUIStateType = field(default_factory=lambda: LGHorizonUIStateType.UNKNOWN)
media_type: LGHorizonMediaType = field(default_factory=lambda: LGHorizonMediaType.UNKNOWN)
state: LGHorizonRunningState = field(
default_factory=lambda: LGHorizonRunningState.UNKNOWN
)
source_type: LGHorizonSourceType = field(
default_factory=lambda: LGHorizonSourceType.UNKNOWN
)
ui_state_type: LGHorizonUIStateType = field(
default_factory=lambda: LGHorizonUIStateType.UNKNOWN
)
media_type: LGHorizonMediaType = field(
default_factory=lambda: LGHorizonMediaType.UNKNOWN
)
id: Optional[str] = None
channel_id: Optional[str] = None
channel_name: Optional[str] = None
Expand Down Expand Up @@ -1027,7 +1060,6 @@ def reset(self) -> None:
self.ad_breaks = []
self.reset_progress()


def cache_linear_metadata(self) -> None:
"""Cache current linear metadata for fallback when app overlays appear."""
if not self.channel_name or not self.show_title:
Expand Down Expand Up @@ -1064,9 +1096,15 @@ def restore_linear_metadata(self) -> bool:
self.end_time = self._last_good_linear_metadata.get("end_time")
self.duration = self._last_good_linear_metadata.get("duration")
self.position = self._last_good_linear_metadata.get("position")
self.last_position_update = self._last_good_linear_metadata.get("last_position_update")
self.source_type = self._last_good_linear_metadata.get("source_type", LGHorizonSourceType.LINEAR)
self.media_type = self._last_good_linear_metadata.get("media_type", LGHorizonMediaType.CHANNEL)
self.last_position_update = self._last_good_linear_metadata.get(
"last_position_update"
)
self.source_type = self._last_good_linear_metadata.get(
"source_type", LGHorizonSourceType.LINEAR
)
self.media_type = self._last_good_linear_metadata.get(
"media_type", LGHorizonMediaType.CHANNEL
)
return True

def clear_linear_metadata_cache(self) -> None:
Expand Down Expand Up @@ -1620,8 +1658,7 @@ def __init__(self, entry_json: dict) -> None:
self._entry_json = entry_json
channel_id = entry_json.get("channelId", "")
self._events = [
LGHorizonEpgEvent(ev, channel_id)
for ev in entry_json.get("events", [])
LGHorizonEpgEvent(ev, channel_id) for ev in entry_json.get("events", [])
]

@property
Expand Down Expand Up @@ -1911,12 +1948,16 @@ def duration(self) -> Optional[int]:
@property
def start_time(self) -> Optional[str]:
"""Return the display start time (ISO 8601)."""
return self._recording_json.get("displayStartTime") or self._recording_json.get("startTime")
return self._recording_json.get("displayStartTime") or self._recording_json.get(
"startTime"
)

@property
def end_time(self) -> Optional[str]:
"""Return the display end time (ISO 8601)."""
return self._recording_json.get("displayEndTime") or self._recording_json.get("endTime")
return self._recording_json.get("displayEndTime") or self._recording_json.get(
"endTime"
)

@property
def rec_start_time(self) -> Optional[str]:
Expand Down Expand Up @@ -1987,8 +2028,7 @@ def __init__(self, response_json: dict) -> None:
self._limit = response_json.get("limit", 0)
self._offset = response_json.get("offset", 0)
self._recordings = [
LGHorizonManagedRecording(item)
for item in response_json.get("data", [])
LGHorizonManagedRecording(item) for item in response_json.get("data", [])
]

@property
Expand Down
1 change: 1 addition & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
[pytest]
asyncio_mode = auto
testpaths = tests
asyncio_default_fixture_loop_scope = function
98 changes: 77 additions & 21 deletions tests/test_mqtt_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,14 @@ async def _create_client(paho_instance=None):
if paho_instance is None:
paho_instance = _make_paho_mock()

with patch("lghorizon.lghorizon_mqtt_client.mqtt.Client", return_value=paho_instance):
with patch(
"lghorizon.lghorizon_mqtt_client.mqtt.Client", return_value=paho_instance
):
# tls_set is called via run_in_executor; patch executor to call it synchronously
with patch("asyncio.AbstractEventLoop.run_in_executor", new=AsyncMock(return_value=None)):
with patch(
"asyncio.AbstractEventLoop.run_in_executor",
new=AsyncMock(return_value=None),
):
client = await LGHorizonMqttClient.create(auth, on_connected, on_message)

return client, auth, on_connected, on_message, paho_instance
Expand All @@ -68,11 +73,21 @@ async def _create_client(paho_instance=None):
def _make_direct_client(loop=None):
"""Directly instantiate LGHorizonMqttClient (bypasses create())."""
if loop is None:
loop = asyncio.get_event_loop()
try:
loop = asyncio.get_running_loop()
except RuntimeError:
# Als er geen loop draait (zoals in jouw synchrone test), maak er een aan
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
auth = _make_mock_auth()
on_connected = AsyncMock()
on_message = AsyncMock()
return LGHorizonMqttClient(auth, on_connected, on_message, loop), auth, on_connected, on_message
return (
LGHorizonMqttClient(auth, on_connected, on_message, loop),
auth,
on_connected,
on_message,
)


# ===========================================================================
Expand All @@ -83,47 +98,80 @@ def _make_direct_client(loop=None):
class TestCreate:
async def test_returns_instance(self):
paho_mock = _make_paho_mock()
with patch("lghorizon.lghorizon_mqtt_client.mqtt.Client", return_value=paho_mock):
with patch("asyncio.AbstractEventLoop.run_in_executor", new=AsyncMock(return_value=None)):
with patch(
"lghorizon.lghorizon_mqtt_client.mqtt.Client", return_value=paho_mock
):
with patch(
"asyncio.AbstractEventLoop.run_in_executor",
new=AsyncMock(return_value=None),
):
auth = _make_mock_auth()
client = await LGHorizonMqttClient.create(auth, AsyncMock(), AsyncMock())
client = await LGHorizonMqttClient.create(
auth, AsyncMock(), AsyncMock()
)

assert isinstance(client, LGHorizonMqttClient)

async def test_client_id_is_non_empty_string(self):
paho_mock = _make_paho_mock()
with patch("lghorizon.lghorizon_mqtt_client.mqtt.Client", return_value=paho_mock):
with patch("asyncio.AbstractEventLoop.run_in_executor", new=AsyncMock(return_value=None)):
with patch(
"lghorizon.lghorizon_mqtt_client.mqtt.Client", return_value=paho_mock
):
with patch(
"asyncio.AbstractEventLoop.run_in_executor",
new=AsyncMock(return_value=None),
):
auth = _make_mock_auth()
client = await LGHorizonMqttClient.create(auth, AsyncMock(), AsyncMock())
client = await LGHorizonMqttClient.create(
auth, AsyncMock(), AsyncMock()
)

assert isinstance(client.client_id, str)
assert len(client.client_id) > 0

async def test_broker_url_strips_wss_and_port(self):
paho_mock = _make_paho_mock()
with patch("lghorizon.lghorizon_mqtt_client.mqtt.Client", return_value=paho_mock):
with patch("asyncio.AbstractEventLoop.run_in_executor", new=AsyncMock(return_value=None)):
with patch(
"lghorizon.lghorizon_mqtt_client.mqtt.Client", return_value=paho_mock
):
with patch(
"asyncio.AbstractEventLoop.run_in_executor",
new=AsyncMock(return_value=None),
):
auth = _make_mock_auth()
client = await LGHorizonMqttClient.create(auth, AsyncMock(), AsyncMock())
client = await LGHorizonMqttClient.create(
auth, AsyncMock(), AsyncMock()
)

assert client._mqtt_broker_url == BROKER_URL_STRIPPED

async def test_username_password_set_from_auth(self):
paho_mock = _make_paho_mock()
with patch("lghorizon.lghorizon_mqtt_client.mqtt.Client", return_value=paho_mock):
with patch("asyncio.AbstractEventLoop.run_in_executor", new=AsyncMock(return_value=None)):
with patch(
"lghorizon.lghorizon_mqtt_client.mqtt.Client", return_value=paho_mock
):
with patch(
"asyncio.AbstractEventLoop.run_in_executor",
new=AsyncMock(return_value=None),
):
auth = _make_mock_auth()
await LGHorizonMqttClient.create(auth, AsyncMock(), AsyncMock())

paho_mock.username_pw_set.assert_called_once_with(HOUSEHOLD_ID, MQTT_TOKEN)

async def test_paho_client_constructed_with_client_id(self):
paho_mock = _make_paho_mock()
with patch("lghorizon.lghorizon_mqtt_client.mqtt.Client", return_value=paho_mock) as mock_cls:
with patch("asyncio.AbstractEventLoop.run_in_executor", new=AsyncMock(return_value=None)):
with patch(
"lghorizon.lghorizon_mqtt_client.mqtt.Client", return_value=paho_mock
) as mock_cls:
with patch(
"asyncio.AbstractEventLoop.run_in_executor",
new=AsyncMock(return_value=None),
):
auth = _make_mock_auth()
client = await LGHorizonMqttClient.create(auth, AsyncMock(), AsyncMock())
client = await LGHorizonMqttClient.create(
auth, AsyncMock(), AsyncMock()
)

call_kwargs = mock_cls.call_args
# client_id should be set and match the instance's client_id
Expand Down Expand Up @@ -151,7 +199,9 @@ async def test_calls_connect_with_broker_url_and_port_443(self):
client._mqtt_client = paho_mock
client._mqtt_broker_url = BROKER_URL_STRIPPED

with patch.object(loop, "run_in_executor", new=AsyncMock(return_value=None)) as mock_exec:
with patch.object(
loop, "run_in_executor", new=AsyncMock(return_value=None)
) as mock_exec:
await client.connect()

# run_in_executor should have been called with connect, broker url, port 443
Expand Down Expand Up @@ -199,11 +249,17 @@ async def test_skips_if_already_connected(self):
client._mqtt_client = paho_mock
client._mqtt_broker_url = BROKER_URL_STRIPPED

with patch.object(loop, "run_in_executor", new=AsyncMock(return_value=None)) as mock_exec:
with patch.object(
loop, "run_in_executor", new=AsyncMock(return_value=None)
) as mock_exec:
await client.connect()

# connect should NOT have been called via executor
connect_calls = [c for c in mock_exec.call_args_list if len(c.args) > 1 and c.args[1] == paho_mock.connect]
connect_calls = [
c
for c in mock_exec.call_args_list
if len(c.args) > 1 and c.args[1] == paho_mock.connect
]
assert len(connect_calls) == 0


Expand Down