From bbe3095c0b38ae1ec174788a723ccbebadd23ec9 Mon Sep 17 00:00:00 2001 From: Rudolf Offereins Date: Sat, 24 Jan 2026 00:20:46 +0000 Subject: [PATCH 01/16] Start with async and refactoring. - Proper auth handling - cleaning up integration auth differences retrieve settings, customer info, and mqtt token --- lghorizon/__init__.py | 13 +-- lghorizon/const.py | 8 +- lghorizon/helpers.py | 2 +- lghorizon/{ => legacy}/lghorizon_api.py | 70 ---------------- lghorizon/{ => legacy}/models.py | 0 lghorizon/lghorizonapi.py | 58 +++++++++++++ lghorizon/models/__init__.py | 6 ++ lghorizon/{ => models}/exceptions.py | 0 lghorizon/models/lghorizon_auth.py | 106 ++++++++++++++++++++++++ lghorizon/models/lghorizon_config.py | 65 +++++++++++++++ lghorizon/models/lghorizon_customer.py | 24 ++++++ lghorizon/models/lghorizon_profile.py | 11 +++ main.py | 26 ++++++ test.py | 84 ------------------- 14 files changed, 305 insertions(+), 168 deletions(-) rename lghorizon/{ => legacy}/lghorizon_api.py (86%) rename lghorizon/{ => legacy}/models.py (100%) create mode 100644 lghorizon/lghorizonapi.py create mode 100644 lghorizon/models/__init__.py rename lghorizon/{ => models}/exceptions.py (100%) create mode 100644 lghorizon/models/lghorizon_auth.py create mode 100644 lghorizon/models/lghorizon_config.py create mode 100644 lghorizon/models/lghorizon_customer.py create mode 100644 lghorizon/models/lghorizon_profile.py create mode 100644 main.py delete mode 100644 test.py diff --git a/lghorizon/__init__.py b/lghorizon/__init__.py index 2636e8b..be1c977 100644 --- a/lghorizon/__init__.py +++ b/lghorizon/__init__.py @@ -1,15 +1,10 @@ """Python client for LG Horizon.""" -from .lghorizon_api import LGHorizonApi -from .models import ( - LGHorizonBox, - LGHorizonRecordingListSeasonShow, - LGHorizonRecordingSingle, - LGHorizonRecordingShow, - LGHorizonRecordingEpisode, - LGHorizonCustomer, +from .lghorizonapi import LGHorizonApi +from .models.lghorizon_auth import ( + LGHorizonAuth, ) -from .exceptions import ( +from .models.exceptions import ( LGHorizonApiUnauthorizedError, LGHorizonApiConnectionError, LGHorizonApiLockedError, diff --git a/lghorizon/const.py b/lghorizon/const.py index c78e4aa..8098b76 100644 --- a/lghorizon/const.py +++ b/lghorizon/const.py @@ -42,7 +42,7 @@ "nl": { "api_url": "https://spark-prod-nl.gnp.cloud.ziggogo.tv", "mqtt_url": "obomsg.prod.nl.horizon.tv", - "use_oauth": False, + "use_refreshtoken": False, "channels": [ { "channelId": "NL_000073_019506", @@ -106,7 +106,7 @@ }, "be-nl-preprod": { "api_url": "https://spark-preprod-be.gnp.cloud.telenet.tv", - "use_oauth": True, + "use_refreshtoken": True, "oauth_username_fieldname": "j_username", "oauth_password_fieldname": "j_password", "oauth_add_accept_header": False, @@ -131,13 +131,13 @@ }, "ie": { "api_url": "https://spark-prod-ie.gnp.cloud.virginmediatv.ie", - "use_oauth": False, + "use_refreshtoken": False, "channels": [], "language": "en", }, "pl": { "api_url": "https://spark-prod-pl.gnp.cloud.upctv.pl", - "use_oauth": False, + "use_refreshtoken": False, "channels": [], "language": "pl", "platform_types": { diff --git a/lghorizon/helpers.py b/lghorizon/helpers.py index c7baab3..32f0f12 100644 --- a/lghorizon/helpers.py +++ b/lghorizon/helpers.py @@ -3,7 +3,7 @@ import random -def make_id(string_length=10): +async def make_id(string_length=10): """Create an id with given length.""" letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" return "".join(random.choice(letters) for i in range(string_length)) diff --git a/lghorizon/lghorizon_api.py b/lghorizon/legacy/lghorizon_api.py similarity index 86% rename from lghorizon/lghorizon_api.py rename to lghorizon/legacy/lghorizon_api.py index 695ce03..1e144eb 100644 --- a/lghorizon/lghorizon_api.py +++ b/lghorizon/legacy/lghorizon_api.py @@ -167,76 +167,6 @@ def set_callback(self, refresh_callback: Callable) -> None: """Set the refresh callback.""" self._refresh_callback = refresh_callback - def _authorize_telenet(self): - """Authorize telenet users.""" - try: - login_session = Session() - # Step 1 - Get Authorization data - _logger.debug("Step 1 - Get Authorization data") - auth_url = ( - f"{self._country_settings['api_url']}/auth-service/v1/sso/authorization" - ) - auth_response = login_session.get(auth_url) - if not auth_response.ok: - raise LGHorizonApiConnectionError("Can't connect to authorization URL") - auth_response_json = auth_response.json() - authorization_uri = auth_response_json["authorizationUri"] - authorization_validity_token = auth_response_json["validityToken"] - - # Step 2 - Get Authorization cookie - _logger.debug("Step 2 - Get Authorization cookie") - - auth_cookie_response = login_session.get(authorization_uri) - if not auth_cookie_response.ok: - raise LGHorizonApiConnectionError("Can't connect to authorization URL") - - _logger.debug("Step 3 - Login") - - username_fieldname = self._country_settings["oauth_username_fieldname"] - pasword_fieldname = self._country_settings["oauth_password_fieldname"] - - payload = { - username_fieldname: self.username, - pasword_fieldname: self.password, - "rememberme": "true", - } - - login_response = login_session.post( - self._country_settings["oauth_url"], payload, allow_redirects=False - ) - if not login_response.ok: - raise LGHorizonApiConnectionError("Can't connect to authorization URL") - redirect_url = login_response.headers[ - self._country_settings["oauth_redirect_header"] - ] - - if self._identifier is not None: - redirect_url += f"&dtv_identifier={self._identifier}" - redirect_response = login_session.get(redirect_url, allow_redirects=False) - success_url = redirect_response.headers[ - self._country_settings["oauth_redirect_header"] - ] - code_matches = re.findall(r"code=(.*)&", success_url) - - authorization_code = code_matches[0] - - new_payload = { - "authorizationGrant": { - "authorizationCode": authorization_code, - "validityToken": authorization_validity_token, - } - } - headers = { - "content-type": "application/json", - } - post_result = login_session.post( - auth_url, json.dumps(new_payload), headers=headers - ) - self._auth.fill(post_result.json()) - self._session.cookies["ACCESSTOKEN"] = self._auth.access_token - except Exception: - pass - def _obtain_mqtt_token(self): _logger.debug("Obtain mqtt token...") mqtt_auth_url = self._config["authorizationService"]["URL"] diff --git a/lghorizon/models.py b/lghorizon/legacy/models.py similarity index 100% rename from lghorizon/models.py rename to lghorizon/legacy/models.py diff --git a/lghorizon/lghorizonapi.py b/lghorizon/lghorizonapi.py new file mode 100644 index 0000000..aeaf1cd --- /dev/null +++ b/lghorizon/lghorizonapi.py @@ -0,0 +1,58 @@ +"""LG Horizon API client.""" + +from typing import Any +from .models.lghorizon_auth import LGHorizonAuth +from .models.lghorizon_config import LGHorizonServicesConfig +from .models.lghorizon_customer import LGHorizonCustomer + + +class LGHorizonApi: + """LG Horizon API client.""" + + def __init__(self, auth: LGHorizonAuth) -> None: + """Initialize LG Horizon API client.""" + self.auth = auth + self._service_config: LGHorizonServicesConfig | None = None + + @property + def service_config(self) -> LGHorizonServicesConfig: + """Return the service config, or raise if not initialized.""" + if self._service_config is None: + raise RuntimeError("Service configuration not initialized") + return self._service_config + + async def initialize(self) -> None: + """Initialize the API client.""" + await self._get_config() + await self._get_mqtt_token() + await self._get_customer_info() + + async def _get_config(self): + base_country_code = self.auth.country_code[0:2] + result = await self.auth.request( + self.auth.host, + f"/{base_country_code}/en/config-service/conf/web/backoffice.json", + ) + self._service_config = LGHorizonServicesConfig(result) + + async def _get_mqtt_token(self) -> Any: + """Get the MQTT token.""" + service_url = await self.service_config.get_service_url("authorizationService") + result = await self.auth.request( + service_url, + "/v1/mqtt/token", + ) + return result["token"] + + async def _get_customer_info(self) -> Any: + service_url = await self.service_config.get_service_url( + "personalizationService" + ) + result = await self.auth.request( + service_url, + f"/v1/customer/{self.auth.household_id}?with=profiles%2Cdevices", + ) + return LGHorizonCustomer(result) + + +__all__ = ["LGHorizonApi", "LGHorizonAuth"] diff --git a/lghorizon/models/__init__.py b/lghorizon/models/__init__.py new file mode 100644 index 0000000..49c78b5 --- /dev/null +++ b/lghorizon/models/__init__.py @@ -0,0 +1,6 @@ +"""Models for LG Horizon.""" + +from .lghorizon_auth import LGHorizonAuth +from .lghorizon_config import LGHorizonServicesConfig + +__all__ = ["LGHorizonAuth", "LGHorizonServicesConfig"] diff --git a/lghorizon/exceptions.py b/lghorizon/models/exceptions.py similarity index 100% rename from lghorizon/exceptions.py rename to lghorizon/models/exceptions.py diff --git a/lghorizon/models/lghorizon_auth.py b/lghorizon/models/lghorizon_auth.py new file mode 100644 index 0000000..e9d09f0 --- /dev/null +++ b/lghorizon/models/lghorizon_auth.py @@ -0,0 +1,106 @@ +import time +import backoff + +from typing import Any +from aiohttp import ClientSession, ClientResponseError +from requests import exceptions as request_exceptions +from ..const import COUNTRY_SETTINGS +from .exceptions import LGHorizonApiConnectionError, LGHorizonApiUnauthorizedError + + +class LGHorizonAuth: + """Class to make authenticated requests.""" + + def __init__( + self, + websession: ClientSession, + country_code: str, + refresh_token: str = "", + username: str = "", + password: str = "", + ) -> None: + """Initialize the auth with refresh token.""" + self.websession = websession + self.refresh_token = refresh_token + self.access_token = None + self.username = username + self.password = password + self.household_id = None + 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"] + + async def is_token_expiring(self) -> bool: + """Check if the token is expiring within one day.""" + if not self.access_token or not self.token_expiry: + return True + current_unix_time = int(time.time()) + return current_unix_time >= (self.token_expiry - 86400) + + async def fetch_access_token(self) -> None: + """Fetch the access token.""" + + headers = dict() + headers["content-type"] = "application/json" + headers["charset"] = "utf-8" + + if not self.use_refresh_token and self.access_token is None: + payload = {"password": self.password, "username": self.username} + headers["x-device-code"] = "web" + auth_url_path = "/auth-service/v1/authorization" + else: + payload = {"refreshToken": self.refresh_token} + auth_url_path = "/auth-service/v1/authorization/refresh" + try: + auth_response = await self.websession.post( + f"{self.host}{auth_url_path}", + json=payload, + headers=headers, + ) + except Exception as ex: + raise LGHorizonApiConnectionError from ex + auth_json = await auth_response.json() + if not auth_response.ok: + error = None + if "error" in auth_json: + error = auth_json["error"] + if error and error["statusCode"] == 97401: + raise LGHorizonApiUnauthorizedError("Invalid credentials") + elif error: + raise LGHorizonApiConnectionError(error["message"]) + else: + raise LGHorizonApiConnectionError("Unknown connection error") + + self.household_id = auth_json["householdId"] + self.access_token = auth_json["accessToken"] + self.refresh_token = auth_json["refreshToken"] + self.username = auth_json["username"] + self.token_expiry = auth_json["refreshTokenExpiry"] + + @backoff.on_exception(backoff.expo, LGHorizonApiConnectionError, max_tries=3) + async def request(self, host: str, path: str, **kwargs) -> Any: + """Make a request.""" + if headers := kwargs.pop("headers", {}): + headers = dict(headers) + request_url = f"{host}{path}" + if await self.is_token_expiring(): + await self.fetch_access_token() + try: + clear_cookie = False + if clear_cookie: + self.websession.cookie_jar.clear() + web_response = await self.websession.request( + "GET", + request_url, + **kwargs, + headers=headers, + ) + web_response.raise_for_status() + return await web_response.json() + except ClientResponseError as cre: + if cre.status == 401: + await self.fetch_access_token() + raise LGHorizonApiConnectionError( + f"Unable to call {request_url}. Error:{str(cre)}" + ) from cre diff --git a/lghorizon/models/lghorizon_config.py b/lghorizon/models/lghorizon_config.py new file mode 100644 index 0000000..ee15e64 --- /dev/null +++ b/lghorizon/models/lghorizon_config.py @@ -0,0 +1,65 @@ +"""Configuration handler for LG Horizon services.""" + +from typing import Any, Optional + + +class LGHorizonServicesConfig: + """Handle LG Horizon configuration and service URLs.""" + + def __init__(self, config_data: dict[str, Any]) -> None: + """Initialize LG Horizon config. + + Args: + config_data: Configuration dictionary with service endpoints + """ + self._config = config_data + + async def get_service_url(self, service_name: str) -> str: + """Get the URL for a specific service. + + Args: + service_name: Name of the service (e.g., 'authService', 'recordingService') + + Returns: + URL for the service + + Raises: + 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"] + raise ValueError(f"Service URL for '{service_name}' not found in configuration") + + async def get_all_services(self) -> dict[str, str]: + """Get all available services and their URLs. + + Returns: + Dictionary mapping service names to URLs + """ + return { + name: url + for name, service in self._config.items() + if isinstance(service, dict) and (url := service.get("URL")) + } + + async def __getattr__(self, name: str) -> Optional[str]: + """Access service URLs as attributes. + + Example: config.authService returns the auth service URL + + Args: + name: Service name + + Returns: + URL for the service or None if not found + """ + if name.startswith("_"): + raise AttributeError( + f"'{type(self).__name__}' object has no attribute '{name}'" + ) + return await self.get_service_url(name) + + def __repr__(self) -> str: + """Return string representation.""" + services = list(self._config.keys()) + return f"LGHorizonConfig({len(services)} services)" diff --git a/lghorizon/models/lghorizon_customer.py b/lghorizon/models/lghorizon_customer.py new file mode 100644 index 0000000..c984ea8 --- /dev/null +++ b/lghorizon/models/lghorizon_customer.py @@ -0,0 +1,24 @@ +from typing import Optional, Dict +from .lghorizon_profile import LGHorizonProfile + + +class LGHorizonCustomer: + """LGHorizon customer""" + + customer_id: Optional[str] = None + hashed_customer_id: Optional[str] = None + country_id: Optional[str] = None + city_id: int = 0 + settop_boxes: Optional[list[str]] = None + profiles: Dict[str, LGHorizonProfile] = {} + + def __init__(self, json_payload): + self.customer_id = json_payload["customerId"] + self.hashed_customer_id = json_payload["hashedCustomerId"] + self.country_id = json_payload["countryId"] + self.city_id = json_payload["cityId"] + if "assignedDevices" in json_payload: + self.settop_boxes = json_payload["assignedDevices"] + if "profiles" in json_payload: + for profile in json_payload["profiles"]: + self.profiles[profile["profileId"]] = LGHorizonProfile(profile) diff --git a/lghorizon/models/lghorizon_profile.py b/lghorizon/models/lghorizon_profile.py new file mode 100644 index 0000000..6fddf50 --- /dev/null +++ b/lghorizon/models/lghorizon_profile.py @@ -0,0 +1,11 @@ +class LGHorizonProfile: + """LGHorizon profile.""" + + profile_id: str | None = None + name: str | None = None + favorite_channels: list[str] | None = None + + def __init__(self, json_payload): + self.profile_id = json_payload["profileId"] + self.name = json_payload["name"] + self.favorite_channels = json_payload["favoriteChannels"] diff --git a/main.py b/main.py new file mode 100644 index 0000000..07dd1f8 --- /dev/null +++ b/main.py @@ -0,0 +1,26 @@ +"""Main class to test working of LG Horizon API""" + +import asyncio +import json + +import aiohttp + +from lghorizon import LGHorizonApi +from lghorizon.models import LGHorizonAuth + + +async def main(): + """main loop""" + with open("secrets.json", encoding="utf-8") as f: + secrets = json.load(f) + username = secrets.get("username") + password = secrets.get("password") + country = secrets.get("country", "nl") + + async with aiohttp.ClientSession() as session: + auth = LGHorizonAuth(session, country, username=username, password=password) + api = LGHorizonApi(auth) + await api.initialize() + + +asyncio.run(main()) diff --git a/test.py b/test.py deleted file mode 100644 index 88548fe..0000000 --- a/test.py +++ /dev/null @@ -1,84 +0,0 @@ -""" "Test the component.""" - -import json -import logging -import time -from lghorizon import LGHorizonApi - -api: LGHorizonApi - -logging.basicConfig( - level=logging.DEBUG, - format="%(asctime)s - %(levelname)s - %(message)s", -) - -_Logger = logging.getLogger() - -file_handler = logging.FileHandler("logfile.log", mode="w") -file_handler.setLevel(logging.DEBUG) -_Logger.addHandler(file_handler) - -console_handler = logging.StreamHandler() -console_handler.setLevel(logging.DEBUG) -_Logger.addHandler(console_handler) - -secrets: dict[str, str] = None - - -def read_secrets(file_path): - """Read secrets from file.""" - try: - with open(file_path, "r", encoding="UTF-8") as file: - return json.load(file) - except FileNotFoundError: - print(f"Error: Secrets file not found at {file_path}") - return {} - except json.JSONDecodeError: - print(f"Error: Unable to decode JSON in {file_path}") - return {} - - -def event_loop(): - """Default event loop.""" - while True: - time.sleep(1) # Simulate some work - - # Check for a breaking condition - if break_condition(): - break - - -def break_condition(): - """Break event loop on conditions.""" - # Implement your breaking condition logic here - return False # Change this condition based on your requirements - - -if __name__ == "__main__": - try: - secrets = read_secrets("secrets.json") - - refresh_token: str = None - if "refresh_token" in secrets: - refresh_token = secrets["refresh_token"] - - profile_id: str = None - if "profile_id" in secrets: - profile_id = secrets["profile_id"] - - api = LGHorizonApi( - secrets["username"], - secrets["password"], - secrets["country"], - # identifier="DTV3907048", - refresh_token=refresh_token, - profile_id=profile_id, - ) - api.connect() - event_loop() - except KeyboardInterrupt: - print("\nScript interrupted by user.") - finally: - print("Script is exiting.") - if api: - api.disconnect() From 62d25a806e745e4f3831f547fe666d44ab7784b7 Mon Sep 17 00:00:00 2001 From: Rudolf Offereins Date: Sat, 24 Jan 2026 08:44:01 +0000 Subject: [PATCH 02/16] Making the mqtt client connect --- .vscode/launch.json | 15 +++ lghorizon/lghorizonapi.py | 58 ++++----- lghorizon/models/lghorizon_auth.py | 34 +++++- lghorizon/models/lghorizon_mqtt_client.py | 140 ++++++++++++++++++++++ main.py | 8 ++ 5 files changed, 223 insertions(+), 32 deletions(-) create mode 100644 .vscode/launch.json create mode 100644 lghorizon/models/lghorizon_mqtt_client.py diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..3ff6055 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,15 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python Debugger: Current File", + "type": "debugpy", + "request": "launch", + "program": "${file}", + "console": "integratedTerminal" + } + ] +} diff --git a/lghorizon/lghorizonapi.py b/lghorizon/lghorizonapi.py index aeaf1cd..73bf2f3 100644 --- a/lghorizon/lghorizonapi.py +++ b/lghorizon/lghorizonapi.py @@ -2,50 +2,48 @@ from typing import Any from .models.lghorizon_auth import LGHorizonAuth -from .models.lghorizon_config import LGHorizonServicesConfig from .models.lghorizon_customer import LGHorizonCustomer +from .models.lghorizon_mqtt_client import LGHorizonMqttClient +from .models.lghorizon_config import LGHorizonServicesConfig class LGHorizonApi: """LG Horizon API client.""" + _mqtt_client: LGHorizonMqttClient + auth: LGHorizonAuth + _service_config: LGHorizonServicesConfig + _customer: LGHorizonCustomer + def __init__(self, auth: LGHorizonAuth) -> None: """Initialize LG Horizon API client.""" self.auth = auth - self._service_config: LGHorizonServicesConfig | None = None - - @property - def service_config(self) -> LGHorizonServicesConfig: - """Return the service config, or raise if not initialized.""" - if self._service_config is None: - raise RuntimeError("Service configuration not initialized") - return self._service_config async def initialize(self) -> None: """Initialize the API client.""" - await self._get_config() - await self._get_mqtt_token() - await self._get_customer_info() - - async def _get_config(self): - base_country_code = self.auth.country_code[0:2] - result = await self.auth.request( - self.auth.host, - f"/{base_country_code}/en/config-service/conf/web/backoffice.json", + self._service_config = await self.auth.get_service_config() + self._customer = await self._get_customer_info() + self._mqtt_client = await self._create_mqtt_client() + self._mqtt_client.connect() + + async def _create_mqtt_client(self) -> LGHorizonMqttClient: + mqtt_client = await LGHorizonMqttClient.create( + self.auth, + self._on_mqtt_connected, + self._on_mqtt_message, ) - self._service_config = LGHorizonServicesConfig(result) + return mqtt_client - async def _get_mqtt_token(self) -> Any: - """Get the MQTT token.""" - service_url = await self.service_config.get_service_url("authorizationService") - result = await self.auth.request( - service_url, - "/v1/mqtt/token", - ) - return result["token"] + async def _on_mqtt_connected(self) -> None: + """MQTT connected callback.""" + pass + + async def _on_mqtt_message(self, message: str, topic: str) -> None: + """MQTT message callback.""" + pass async def _get_customer_info(self) -> Any: - service_url = await self.service_config.get_service_url( + service_url = await self._service_config.get_service_url( "personalizationService" ) result = await self.auth.request( @@ -54,5 +52,9 @@ async def _get_customer_info(self) -> Any: ) return LGHorizonCustomer(result) + async def disconnect(self) -> None: + """Disconnect the client.""" + await self._mqtt_client.disconnect() + __all__ = ["LGHorizonApi", "LGHorizonAuth"] diff --git a/lghorizon/models/lghorizon_auth.py b/lghorizon/models/lghorizon_auth.py index e9d09f0..8cf240d 100644 --- a/lghorizon/models/lghorizon_auth.py +++ b/lghorizon/models/lghorizon_auth.py @@ -1,11 +1,14 @@ +"""LG Horizon Auth Model.""" + import time +from typing import Any + import backoff +from aiohttp import ClientResponseError, ClientSession -from typing import Any -from aiohttp import ClientSession, ClientResponseError -from requests import exceptions as request_exceptions from ..const import COUNTRY_SETTINGS from .exceptions import LGHorizonApiConnectionError, LGHorizonApiUnauthorizedError +from .lghorizon_config import LGHorizonServicesConfig class LGHorizonAuth: @@ -25,11 +28,12 @@ def __init__( self.access_token = None self.username = username self.password = password - self.household_id = None + self.household_id = "" 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"] + self._service_config = None async def is_token_expiring(self) -> bool: """Check if the token is expiring within one day.""" @@ -104,3 +108,25 @@ async def request(self, host: str, path: str, **kwargs) -> Any: raise LGHorizonApiConnectionError( f"Unable to call {request_url}. Error:{str(cre)}" ) from cre + + async def get_mqtt_token(self) -> Any: + """Get the MQTT token.""" + config = await self.get_service_config() + service_url = await config.get_service_url("authorizationService") + result = await self.request( + service_url, + "/v1/mqtt/token", + ) + return result["token"] + + async def get_service_config(self): + """Get the service configuration.""" + if self._service_config is None: + base_country_code = self.country_code[0:2] + result = await self.request( + self.host, + f"/{base_country_code}/en/config-service/conf/web/backoffice.json", + ) + self._service_config = LGHorizonServicesConfig(result) + + return self._service_config diff --git a/lghorizon/models/lghorizon_mqtt_client.py b/lghorizon/models/lghorizon_mqtt_client.py new file mode 100644 index 0000000..5eb4760 --- /dev/null +++ b/lghorizon/models/lghorizon_mqtt_client.py @@ -0,0 +1,140 @@ +"""MQTT client for LGHorizon.""" + +import json +import logging +import asyncio +from typing import Callable + +import paho.mqtt.client as mqtt + +from ..helpers import make_id +from .lghorizon_auth import LGHorizonAuth + +_logger = logging.getLogger(__name__) + + +class LGHorizonMqttClient: + """LGHorizon MQTT client.""" + + _mqtt_broker_url: str = "" + _mqtt_client: mqtt.Client + _auth: LGHorizonAuth + _mqtt_token: str = "" + client_id: str = "" + _on_connected_callback: Callable + _on_message_callback: Callable + + @property + def is_connected(self): + """Is client connected.""" + return self._mqtt_client.is_connected + + def __init__( + self, + auth: LGHorizonAuth, + on_connected_callback: Callable, + on_message_callback: Callable, + ): + """Initialize the MQTT client.""" + self._auth = auth + self._on_connected_callback = on_connected_callback + self._on_message_callback = on_message_callback + self._loop = asyncio.get_event_loop() + + @classmethod + async def create( + cls, + auth: LGHorizonAuth, + on_connected_callback: Callable, + on_message_callback: Callable, + ): + """Create the MQTT client.""" + instance = cls(auth, on_connected_callback, on_message_callback) + service_config = await auth.get_service_config() + mqtt_broker_url = await service_config.get_service_url("mqttBroker") + instance._mqtt_broker_url = mqtt_broker_url.replace("wss://", "").replace( + ":443/mqtt", "" + ) + instance.client_id = await make_id() + instance._mqtt_client = mqtt.Client( + client_id=instance.client_id, + transport="websockets", + ) + + instance._mqtt_client.ws_set_options( + headers={"Sec-WebSocket-Protocol": "mqtt, mqttv3.1, mqttv3.11"} + ) + instance._mqtt_token = await auth.get_mqtt_token() + instance._mqtt_client.username_pw_set(auth.household_id, instance._mqtt_token) + instance._mqtt_client.tls_set() + instance._mqtt_client.enable_logger(_logger) + instance._mqtt_client.on_connect = instance._on_mqtt_connect + instance._on_connected_callback = on_connected_callback + instance._on_message_callback = on_message_callback + return instance + + def _on_mqtt_connect(self, client, userdata, flags, result_code): # pylint: disable=unused-argument + if result_code == 0: + self._mqtt_client.on_message = self._on_message_wrapper + self._mqtt_client.subscribe(self._auth.household_id) + self._mqtt_client.subscribe(self._auth.household_id + "/#") + self._mqtt_client.subscribe(self._auth.household_id + "/" + self.client_id) + self._mqtt_client.subscribe(self._auth.household_id + "/+/status") + self._mqtt_client.subscribe( + self._auth.household_id + "/+/networkRecordings" + ) + self._mqtt_client.subscribe( + self._auth.household_id + "/+/networkRecordings/capacity" + ) + self._mqtt_client.subscribe(self._auth.household_id + "/+/localRecordings") + self._mqtt_client.subscribe( + self._auth.household_id + "/+/localRecordings/capacity" + ) + self._mqtt_client.subscribe(self._auth.household_id + "/watchlistService") + self._mqtt_client.subscribe(self._auth.household_id + "/purchaseService") + self._mqtt_client.subscribe( + self._auth.household_id + "/personalizationService" + ) + self._mqtt_client.subscribe(self._auth.household_id + "/recordingStatus") + self._mqtt_client.subscribe( + self._auth.household_id + "/recordingStatus/lastUserAction" + ) + if self._on_connected_callback: + asyncio.run_coroutine_threadsafe( + self._on_connected_callback(), self._loop + ) + elif result_code == 5: + self._mqtt_client.username_pw_set(self._auth.household_id, self._mqtt_token) + self.connect() + else: + _logger.error( + "Cannot connect to MQTT server with resultCode: %s", result_code + ) + + def connect(self) -> None: + """Connect the client.""" + self._mqtt_client.connect(self._mqtt_broker_url, 443) + self._mqtt_client.loop_start() + + def _on_message_wrapper(self, client, userdata, message): # pylint: disable=unused-argument + """Wrapper for handling MQTT messages in a thread-safe manner.""" + asyncio.run_coroutine_threadsafe( + self._on_client_message(client, userdata, message), self._loop + ) + + async def _on_client_message(self, client, userdata, message): # pylint: disable=unused-argument + """Handle messages received by mqtt client.""" + _logger.debug("Received MQTT message. Topic: %s", message.topic) + json_payload = json.loads(message.payload) + _logger.debug("Message: %s", json_payload) + if self._on_message_callback: + await self._on_message_callback(json_payload, message.topic) + + def publish_message(self, topic: str, json_payload: str) -> None: + """Publish a MQTT message.""" + self._mqtt_client.publish(topic, json_payload, qos=2) + + async def disconnect(self) -> None: + """Disconnect the client.""" + if self._mqtt_client.is_connected(): + self._mqtt_client.disconnect() diff --git a/main.py b/main.py index 07dd1f8..6aff77c 100644 --- a/main.py +++ b/main.py @@ -22,5 +22,13 @@ async def main(): api = LGHorizonApi(auth) await api.initialize() + try: + print("Listening to MQTT broker... Press Ctrl+C to exit") + while True: + await asyncio.sleep(1) + except KeyboardInterrupt: + print("\nShutting down...") + await api.disconnect() + asyncio.run(main()) From a32873ae09103d5cedfa90510108d9f80157bcd3 Mon Sep 17 00:00:00 2001 From: Rudolf Offereins Date: Mon, 26 Jan 2026 00:37:59 +0000 Subject: [PATCH 03/16] First try implementing status updates --- .gitignore | 1 + lghorizon/const.py | 7 + lghorizon/lghorizonapi.py | 167 ++++++++- lghorizon/message_factory.py | 39 ++ lghorizon/models/__init__.py | 8 +- lghorizon/models/lghorizon_auth.py | 21 +- lghorizon/models/lghorizon_channel.py | 53 +++ lghorizon/models/lghorizon_customer.py | 61 ++- lghorizon/models/lghorizon_device.py | 416 +++++++++++++++++++++ lghorizon/models/lghorizon_device_state.py | 136 +++++++ lghorizon/models/lghorizon_entitlements.py | 21 ++ lghorizon/models/lghorizon_message.py | 112 ++++++ lghorizon/models/lghorizon_mqtt_client.py | 68 ++-- lghorizon/models/lghorizon_profile.py | 27 +- lghorizon/models/lghorizon_sources.py | 50 +++ lghorizon/models/lghorizon_ui_status.py | 71 ++++ main.py | 18 +- 17 files changed, 1190 insertions(+), 86 deletions(-) create mode 100644 lghorizon/message_factory.py create mode 100644 lghorizon/models/lghorizon_channel.py create mode 100644 lghorizon/models/lghorizon_device.py create mode 100644 lghorizon/models/lghorizon_device_state.py create mode 100644 lghorizon/models/lghorizon_entitlements.py create mode 100644 lghorizon/models/lghorizon_message.py create mode 100644 lghorizon/models/lghorizon_sources.py create mode 100644 lghorizon/models/lghorizon_ui_status.py diff --git a/.gitignore b/.gitignore index c1c7385..1053beb 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ __pycache__/* test/nl.py secrets.json logfile.log +lghorizon.log diff --git a/lghorizon/const.py b/lghorizon/const.py index 8098b76..94caa71 100644 --- a/lghorizon/const.py +++ b/lghorizon/const.py @@ -38,6 +38,13 @@ BE_AUTH_URL = "https://login.prd.telenet.be/openid/login.do" +PLATFORM_TYPES = { + "EOS": {"manufacturer": "Arris", "model": "DCX960"}, + "EOS2": {"manufacturer": "HUMAX", "model": "2008C-STB-TN"}, + "HORIZON": {"manufacturer": "Arris", "model": "DCX960"}, + "APOLLO": {"manufacturer": "Arris", "model": "VIP5002W"}, +} + COUNTRY_SETTINGS = { "nl": { "api_url": "https://spark-prod-nl.gnp.cloud.ziggogo.tv", diff --git a/lghorizon/lghorizonapi.py b/lghorizon/lghorizonapi.py index 73bf2f3..40f8eb1 100644 --- a/lghorizon/lghorizonapi.py +++ b/lghorizon/lghorizonapi.py @@ -1,10 +1,21 @@ """LG Horizon API client.""" -from typing import Any +import logging +from typing import Any, Dict, cast + +from .models.lghorizon_device import LGHorizonDevice +from .models.lghorizon_channel import LGHorizonChannel from .models.lghorizon_auth import LGHorizonAuth from .models.lghorizon_customer import LGHorizonCustomer from .models.lghorizon_mqtt_client import LGHorizonMqttClient from .models.lghorizon_config import LGHorizonServicesConfig +from .models.lghorizon_entitlements import LGHorizonEntitlements +from .models.lghorizon_profile import LGHorizonProfile +from .models.lghorizon_message import LGHorizonMessageType +from .message_factory import LGHorizonMessageFactory +from .models.lghorizon_message import LGHorizonStatusMessage, LGHorizonUIStatusMessage + +_LOGGER = logging.getLogger(__name__) class LGHorizonApi: @@ -14,17 +25,92 @@ class LGHorizonApi: auth: LGHorizonAuth _service_config: LGHorizonServicesConfig _customer: LGHorizonCustomer + _channels: Dict[str, LGHorizonChannel] + _entitlements: LGHorizonEntitlements + _profile_id: str + _initialized: bool = False + _devices: Dict[str, LGHorizonDevice] = {} + _message_factory: LGHorizonMessageFactory = LGHorizonMessageFactory() - def __init__(self, auth: LGHorizonAuth) -> None: + def __init__(self, auth: LGHorizonAuth, profile_id: str = "") -> None: """Initialize LG Horizon API client.""" self.auth = auth + self._profile_id = profile_id + self._channels = {} async def initialize(self) -> None: """Initialize the API client.""" self._service_config = await self.auth.get_service_config() self._customer = await self._get_customer_info() + await self._refresh_entitlements() + await self._refresh_channels() self._mqtt_client = await self._create_mqtt_client() - self._mqtt_client.connect() + await self._mqtt_client.connect() + await self._register_devices() + self._initialized = True + + async def get_devices(self) -> Dict[str, LGHorizonDevice]: + """Get devices.""" + if not self._initialized: + raise RuntimeError("LGHorizonApi not initialized") + + return self._devices + + async def get_profiles(self) -> Dict[str, LGHorizonProfile]: + """Get profile IDs.""" + if not self._initialized: + raise RuntimeError("LGHorizonApi not initialized") + + return self._customer.profiles + + async def get_profile_channels( + self, profile_id: str + ) -> Dict[str, LGHorizonChannel]: + """Returns channels to display baed on profile.""" + # Attempt to retrieve the profile by the given profile_id + profile = self._customer.profiles.get(profile_id) + + # If the specified profile is not found, and there are other profiles available, + # default to the first profile in the customer's list. + if not profile and self._customer.profiles: + _LOGGER.debug( + "Profile with ID '%s' not found. Defaulting to first available profile.", + profile_id, + ) + profile = list(self._customer.profiles.values())[0] + + # If a profile is found and it has favorite channels, filter the main channels list. + if profile and profile.favorite_channels: + _LOGGER.debug("Returning favorite channels for profile '%s'.", profile.name) + # Use a set for faster lookup of favorite channel IDs + profile_channel_ids = set(profile.favorite_channels) + return { + channel.id: channel + for channel in self._channels.values() + if channel.id in profile_channel_ids + } + + # If no profile is found (even after defaulting) or the profile has no favorite channels, + # return all available channels. + _LOGGER.debug("No specific profile channels found, returning all channels.") + return self._channels + + async def _register_devices(self) -> None: + """Register devices.""" + _LOGGER.debug("Registering devices...") + self._devices = {} + channels = await self.get_profile_channels(self._profile_id) + for raw_box in self._customer.assigned_devices: + _LOGGER.debug("Creating box for device: %s", raw_box) + device = LGHorizonDevice(raw_box, self._mqtt_client, self.auth, channels) + await device.register_mqtt() + self._devices[device.device_id] = device + + async def disconnect(self) -> None: + """Disconnect the client.""" + if self._mqtt_client: + await self._mqtt_client.disconnect() + self._initialized = False async def _create_mqtt_client(self) -> LGHorizonMqttClient: mqtt_client = await LGHorizonMqttClient.create( @@ -36,11 +122,48 @@ async def _create_mqtt_client(self) -> LGHorizonMqttClient: async def _on_mqtt_connected(self) -> None: """MQTT connected callback.""" - pass + await self._mqtt_client.subscribe(self.auth.household_id) + await self._mqtt_client.subscribe(self.auth.household_id + "/#") + await self._mqtt_client.subscribe( + self.auth.household_id + "/" + self._mqtt_client.client_id + ) + await self._mqtt_client.subscribe(self.auth.household_id + "/+/status") + await self._mqtt_client.subscribe( + self.auth.household_id + "/+/networkRecordings" + ) + await self._mqtt_client.subscribe( + self.auth.household_id + "/+/networkRecordings/capacity" + ) + await self._mqtt_client.subscribe(self.auth.household_id + "/+/localRecordings") + await self._mqtt_client.subscribe( + self.auth.household_id + "/+/localRecordings/capacity" + ) + await self._mqtt_client.subscribe(self.auth.household_id + "/watchlistService") + await self._mqtt_client.subscribe(self.auth.household_id + "/purchaseService") + await self._mqtt_client.subscribe( + self.auth.household_id + "/personalizationService" + ) + await self._mqtt_client.subscribe(self.auth.household_id + "/recordingStatus") + await self._mqtt_client.subscribe( + self.auth.household_id + "/recordingStatus/lastUserAction" + ) - async def _on_mqtt_message(self, message: str, topic: str) -> None: + async def _on_mqtt_message(self, mqtt_message: dict, mqtt_topic: str) -> None: """MQTT message callback.""" - pass + message = await self._message_factory.create_message(mqtt_topic, mqtt_message) + match message.message_type: + case LGHorizonMessageType.STATUS: + message.__class__ = LGHorizonStatusMessage + status_message = cast(LGHorizonStatusMessage, message) + await self._devices[status_message.source].handle_status_message( + status_message + ) + case LGHorizonMessageType.UI_STATUS: + message.__class__ = LGHorizonUIStatusMessage + status_message = cast(LGHorizonUIStatusMessage, message) + await self._devices[status_message.source].handle_ui_status_message( + status_message + ) async def _get_customer_info(self) -> Any: service_url = await self._service_config.get_service_url( @@ -52,9 +175,35 @@ async def _get_customer_info(self) -> Any: ) return LGHorizonCustomer(result) - async def disconnect(self) -> None: - """Disconnect the client.""" - await self._mqtt_client.disconnect() + async def _refresh_entitlements(self) -> Any: + """Retrieve entitlements.""" + _LOGGER.debug("Retrieving entitlements...") + service_url = await self._service_config.get_service_url("purchaseService") + result = await self.auth.request( + service_url, + f"/v2/customers/{self.auth.household_id}/entitlements?enableDaypass=true", + ) + self._entitlements = LGHorizonEntitlements(result) + + async def _refresh_channels(self): + """Retrieve channels.""" + _LOGGER.debug("Retrieving channels...") + service_url = await self._service_config.get_service_url("linearService") + + channels_json = await self.auth.request( + service_url, + f"/v2/channels?cityId={self._customer.city_id}&language={self._customer.country_id}&productClass=Orion-DASH", + ) + for channel_json in channels_json: + channel = LGHorizonChannel(channel_json) + common_entitlements = list( + set(self._entitlements.entitlement_ids) & set(channel.linear_products) + ) + + if len(common_entitlements) == 0: + continue + + self._channels[channel.id] = channel __all__ = ["LGHorizonApi", "LGHorizonAuth"] diff --git a/lghorizon/message_factory.py b/lghorizon/message_factory.py new file mode 100644 index 0000000..8a3c18e --- /dev/null +++ b/lghorizon/message_factory.py @@ -0,0 +1,39 @@ +"LG Horizon Message Factory." + +from .models.lghorizon_message import ( + LGHorizonMessage, + LGHorizonStatusMessage, + LGHorizonUnknownMessage, + LGHorizonUIStatusMessage, + LGHorizonMessageType, # Import LGHorizonMessageType from here +) + + +class LGHorizonMessageFactory: + """Handle incoming MQTT messages for LG Horizon devices.""" + + def __init__(self): + """Initialize the LG Horizon Message Factory.""" + + async def create_message(self, topic: str, payload: dict) -> LGHorizonMessage: + """Create an LG Horizon message based on the topic and payload.""" + message_type = await self._get_message_type(topic, payload) + match message_type: + case LGHorizonMessageType.STATUS: + return LGHorizonStatusMessage(payload, topic) + case LGHorizonMessageType.UI_STATUS: + # Placeholder for UI_STATUS message handling + return LGHorizonUIStatusMessage(payload, topic) + case LGHorizonMessageType.UNKNOWN: + return LGHorizonUnknownMessage(payload, topic) + + async def _get_message_type( + self, topic: str, payload: dict + ) -> LGHorizonMessageType: + """Determine the message type based on topic and payload.""" + if "status" in topic: + return LGHorizonMessageType.STATUS + if "type" in payload: + if payload["type"] == "CPE.uiStatus": + return LGHorizonMessageType.UI_STATUS + return LGHorizonMessageType.UNKNOWN diff --git a/lghorizon/models/__init__.py b/lghorizon/models/__init__.py index 49c78b5..23d0949 100644 --- a/lghorizon/models/__init__.py +++ b/lghorizon/models/__init__.py @@ -2,5 +2,11 @@ from .lghorizon_auth import LGHorizonAuth from .lghorizon_config import LGHorizonServicesConfig +from .lghorizon_message import LGHorizonMessage, LGHorizonStatusMessage -__all__ = ["LGHorizonAuth", "LGHorizonServicesConfig"] +__all__ = [ + "LGHorizonAuth", + "LGHorizonServicesConfig", + "LGHorizonMessage", + "LGHorizonStatusMessage", +] diff --git a/lghorizon/models/lghorizon_auth.py b/lghorizon/models/lghorizon_auth.py index 8cf240d..0ac012d 100644 --- a/lghorizon/models/lghorizon_auth.py +++ b/lghorizon/models/lghorizon_auth.py @@ -1,6 +1,8 @@ """LG Horizon Auth Model.""" import time +import logging +import json from typing import Any import backoff @@ -10,6 +12,8 @@ from .exceptions import LGHorizonApiConnectionError, LGHorizonApiUnauthorizedError from .lghorizon_config import LGHorizonServicesConfig +_LOGGER = logging.getLogger(__name__) + class LGHorizonAuth: """Class to make authenticated requests.""" @@ -44,7 +48,7 @@ async def is_token_expiring(self) -> bool: async def fetch_access_token(self) -> None: """Fetch the access token.""" - + _LOGGER.debug("Fetching access token") headers = dict() headers["content-type"] = "application/json" headers["charset"] = "utf-8" @@ -89,11 +93,9 @@ async def request(self, host: str, path: str, **kwargs) -> Any: headers = dict(headers) request_url = f"{host}{path}" if await self.is_token_expiring(): + _LOGGER.debug("Access token is expiring, fetching a new one") await self.fetch_access_token() try: - clear_cookie = False - if clear_cookie: - self.websession.cookie_jar.clear() web_response = await self.websession.request( "GET", request_url, @@ -101,8 +103,15 @@ async def request(self, host: str, path: str, **kwargs) -> Any: headers=headers, ) web_response.raise_for_status() - return await web_response.json() + json_response = await web_response.json() + _LOGGER.debug( + "Response from %s:\n %s", + request_url, + json.dumps(json_response, indent=2), + ) + return json_response except ClientResponseError as cre: + _LOGGER.error("Error response from %s: %s", request_url, str(cre)) if cre.status == 401: await self.fetch_access_token() raise LGHorizonApiConnectionError( @@ -111,6 +120,7 @@ async def request(self, host: str, path: str, **kwargs) -> Any: async def get_mqtt_token(self) -> Any: """Get the MQTT token.""" + _LOGGER.debug("Fetching MQTT token") config = await self.get_service_config() service_url = await config.get_service_url("authorizationService") result = await self.request( @@ -121,6 +131,7 @@ async def get_mqtt_token(self) -> Any: async def get_service_config(self): """Get the service configuration.""" + _LOGGER.debug("Fetching service configuration") if self._service_config is None: base_country_code = self.country_code[0:2] result = await self.request( diff --git a/lghorizon/models/lghorizon_channel.py b/lghorizon/models/lghorizon_channel.py new file mode 100644 index 0000000..fc8a772 --- /dev/null +++ b/lghorizon/models/lghorizon_channel.py @@ -0,0 +1,53 @@ +"""LG Horizon Channel model.""" + + +class LGHorizonChannel: + """Class to represent a channel.""" + + def __init__(self, channel_json): + """Initialize a channel.""" + self.channel_json = channel_json + + @property + def id(self) -> str: + """Returns the id.""" + return self.channel_json["id"] + + @property + def channel_number(self) -> str: + """Returns the channel number.""" + return self.channel_json["logicalChannelNumber"] + + @property + def is_radio(self) -> bool: + """Returns if the channel is a radio channel.""" + return self.channel_json.get("isRadio", False) + + @property + def title(self) -> str: + """Returns the title.""" + return self.channel_json["name"] + + @property + def logo_image(self) -> str: + """Returns the logo image.""" + if "logo" in self.channel_json and "focused" in self.channel_json["logo"]: + return self.channel_json["logo"]["focused"] + return "" + + @property + def linear_products(self) -> list[str]: + """Returns the linear products.""" + return self.channel_json.get("linearProducts", []) + + @property + def stream_image(self) -> str: + """Returns the stream image.""" + image_stream = self.channel_json["imageStream"] + if "full" in image_stream: + return image_stream["full"] + if "small" in image_stream: + return image_stream["small"] + if "logo" in self.channel_json and "focused" in self.channel_json["logo"]: + return self.channel_json["logo"]["focused"] + return "" diff --git a/lghorizon/models/lghorizon_customer.py b/lghorizon/models/lghorizon_customer.py index c984ea8..3a65fef 100644 --- a/lghorizon/models/lghorizon_customer.py +++ b/lghorizon/models/lghorizon_customer.py @@ -1,24 +1,45 @@ -from typing import Optional, Dict +"""LGHorizon customer model.""" + +from typing import Dict from .lghorizon_profile import LGHorizonProfile class LGHorizonCustomer: - """LGHorizon customer""" - - customer_id: Optional[str] = None - hashed_customer_id: Optional[str] = None - country_id: Optional[str] = None - city_id: int = 0 - settop_boxes: Optional[list[str]] = None - profiles: Dict[str, LGHorizonProfile] = {} - - def __init__(self, json_payload): - self.customer_id = json_payload["customerId"] - self.hashed_customer_id = json_payload["hashedCustomerId"] - self.country_id = json_payload["countryId"] - self.city_id = json_payload["cityId"] - if "assignedDevices" in json_payload: - self.settop_boxes = json_payload["assignedDevices"] - if "profiles" in json_payload: - for profile in json_payload["profiles"]: - self.profiles[profile["profileId"]] = LGHorizonProfile(profile) + """LGHorizon customer.""" + + def __init__(self, json_payload: dict): + """Initialize a customer.""" + self._json_payload = json_payload + + @property + def customer_id(self) -> str: + """Return the customer id.""" + return self._json_payload["customerId"] + + @property + def hashed_customer_id(self) -> str: + """Return the hashed customer id.""" + return self._json_payload["hashedCustomerId"] + + @property + def country_id(self) -> str: + """Return the country id.""" + return self._json_payload["countryId"] + + @property + def city_id(self) -> int: + """Return the city id.""" + return self._json_payload["cityId"] + + @property + def assigned_devices(self) -> list[str]: + """Return the assigned set-top boxes.""" + return self._json_payload.get("assignedDevices", []) + + @property + def profiles(self) -> Dict[str, LGHorizonProfile]: + """Return the profiles.""" + return { + p["profileId"]: LGHorizonProfile(p) + for p in self._json_payload.get("profiles", []) + } diff --git a/lghorizon/models/lghorizon_device.py b/lghorizon/models/lghorizon_device.py new file mode 100644 index 0000000..de65597 --- /dev/null +++ b/lghorizon/models/lghorizon_device.py @@ -0,0 +1,416 @@ +"""LG Horizon device (set-top box) model.""" + +import json +import logging +from typing import Callable, Dict, Optional + +from lghorizon.models.lghorizon_sources import LGHorizonSourceType + +from ..const import ( + BOX_PLAY_STATE_CHANNEL, + ONLINE_STANDBY, + ONLINE_RUNNING, + MEDIA_KEY_POWER, + MEDIA_KEY_PLAY_PAUSE, + MEDIA_KEY_STOP, + MEDIA_KEY_CHANNEL_UP, + MEDIA_KEY_CHANNEL_DOWN, + MEDIA_KEY_ENTER, + MEDIA_KEY_REWIND, + MEDIA_KEY_FAST_FORWARD, + MEDIA_KEY_RECORD, + PLATFORM_TYPES, +) +from ..helpers import make_id +from .lghorizon_auth import LGHorizonAuth +from .lghorizon_channel import LGHorizonChannel +from .lghorizon_mqtt_client import LGHorizonMqttClient # Added import for type checking +from .lghorizon_device_state import LGHorizonDeviceState +from .exceptions import LGHorizonApiConnectionError +from .lghorizon_message import LGHorizonStatusMessage, LGHorizonUIStatusMessage + +# Assuming these models are available from legacy or will be moved to models/ +# from ..legacy.models import ( +# # LGHorizonPlayingInfo, +# # LGHorizonPlayerState, # This is now in lghorizon_ui_status.py +# # LGHorizonReplayEvent, +# # LGHorizonRecordingSingle, +# # LGHorizonVod, +# # LGHorizonApp, +# ) + +_logger = logging.getLogger(__name__) + + +class LGHorizonDevice: + """The LG Horizon device (set-top box).""" + + _device_id: str + _hashed_cpe_id: str + _device_friendly_name: str + _platform_type: str + _state: Optional[str] + _device_state: LGHorizonDeviceState + _manufacturer: Optional[str] + _model: Optional[str] + _recording_capacity: Optional[int] + + _mqtt_client: LGHorizonMqttClient + _change_callback: Callable + _auth: LGHorizonAuth + _channels: Dict[str, LGHorizonChannel] + + def __init__( + self, + device_json, + mqtt_client: LGHorizonMqttClient, + auth: LGHorizonAuth, + channels: Dict[str, LGHorizonChannel], + ): + """Initialize the LG Horizon device.""" + self._device_id = device_json["deviceId"] + self._hashed_cpe_id = device_json["hashedCPEId"] + self._device_friendly_name = device_json["settings"]["deviceFriendlyName"] + self._platform_type = device_json.get("platformType") + self._mqtt_client = mqtt_client + self._auth = auth + self._channels = channels + self._device_state = LGHorizonDeviceState() + self._state = None # Initialize state + self._manufacturer = None + self._model = None + self._recording_capacity = None + + @property + def device_id(self) -> str: + """Return the device ID.""" + return self._device_id + + @property + def platform_type(self) -> str: + """Return the device ID.""" + return self._platform_type + + @property + def manufacturer(self) -> str: + """Return the manufacturer of the settop box.""" + platform_info = PLATFORM_TYPES.get(self._platform_type, dict()) + return platform_info.get("manufacturer", "unknown") + + @property + def model(self) -> str: + """Return the model of the settop box.""" + platform_info = PLATFORM_TYPES.get(self._platform_type, dict()) + return platform_info.get("model", "unknown") + + @property + def is_available(self) -> bool: + """Return the availability of the settop box.""" + return self.state == ONLINE_RUNNING or self.state == ONLINE_STANDBY + + @property + def hashed_cpe_id(self) -> str: + """Return the hashed CPE ID.""" + return self._hashed_cpe_id + + @property + def device_friendly_name(self) -> str: + """Return the device friendly name.""" + return self._device_friendly_name + + @property + def state(self) -> Optional[str]: + """Return the current state of the device.""" + return self._state + + @state.setter + def state(self, value: str) -> None: + """Set the current state of the device.""" + self._state = value + + @property + def device_state(self) -> LGHorizonDeviceState: + """Return the current playing information.""" + return self._device_state + + @property + def recording_capacity(self) -> Optional[int]: + """Return the recording capacity used.""" + return self._recording_capacity + + @recording_capacity.setter + def recording_capacity(self, value: int) -> None: + """Set the recording capacity used.""" + self._recording_capacity = value + + async def update_channels(self, channels: Dict[str, LGHorizonChannel]): + """Update the channels list.""" + self._channels = channels + + async def register_mqtt(self) -> None: + """Register the mqtt connection.""" + if not self._mqtt_client.is_connected: + raise LGHorizonApiConnectionError("MQTT client not connected.") + topic = f"{self._auth.household_id}/{self._mqtt_client.client_id}/status" + payload = { + "source": self._mqtt_client.client_id, + "state": ONLINE_RUNNING, + "deviceType": "HGO", + } + await self._mqtt_client.publish_message(topic, json.dumps(payload)) + + async def set_callback(self, change_callback: Callable) -> None: + """Set a callback function.""" + self._change_callback = change_callback # type: ignore [assignment] # Callback can be None + + async def handle_status_message( + self, status_message: LGHorizonStatusMessage + ) -> None: + """Register a new settop box.""" + state = status_message.state + if self._state == state: # Access backing field for comparison + return + self.state = state # Use the setter + if self._state == ONLINE_STANDBY: # Access backing field for comparison + self._device_state.reset() + if self._change_callback: + self._change_callback(self._device_id) + else: + await self._request_settop_box_state() + await self._request_settop_box_recording_capacity() + + async def handle_ui_status_message( + self, status_message: LGHorizonUIStatusMessage + ) -> None: + """Handle UI status message.""" + if ( + status_message.ui_state is None + or status_message.ui_state.player_state is None + or status_message.ui_state.player_state.source is None + ): + return + match status_message.ui_state.player_state.source.source_type: + case LGHorizonSourceType.LINEAR: + await self.handle_linear_message(status_message) + + await self._trigger_callback() + + async def handle_linear_message( + self, status_message: LGHorizonUIStatusMessage + ) -> None: + """Handle linear UI status message.""" + pass + + async def update_recording_capacity(self, payload) -> None: + """Updates the recording capacity.""" + if "CPE.capacity" not in payload or "used" not in payload: + return + self.recording_capacity = payload["used"] # Use the setter + + # async def update_with_replay_event( + # self, source_type: str, event: LGHorizonReplayEvent, channel: LGHorizonChannel + # ) -> None: + # """Update box with replay event.""" + # self._device_state.source_type = source_type + # self._device_state.channel_id = channel.id + # self._device_state.channel_title = channel.title + # title = event.title + # if event.episode_name: + # title += f": {event.episode_name}" + # self._device_state.title = title + # self._device_state.image = channel.stream_image + # self._device_state.reset_progress() + # await self._trigger_callback() + + # async def update_with_recording( + # self, + # source_type: str, + # recording: LGHorizonRecordingSingle, + # channel: LGHorizonChannel, # type: ignore [valid-type] # channel can be None + # start: float, + # end: float, + # last_speed_change: float, + # relative_position: float, + # ) -> None: + # """Update box with recording.""" + # self._device_state.source_type = source_type + # self._device_state.channel_id = channel.id + # self._device_state.channel_title = channel.title + # self._device_state.title = f"{recording.title}" + # self._device_state.image = recording.image + # start_dt = datetime.fromtimestamp(start / 1000.0) + # end_dt = datetime.fromtimestamp(end / 1000.0) + # duration = (end_dt - start_dt).total_seconds() + # self._device_state.duration = duration + # self._device_state.position = relative_position / 1000.0 + # last_update_dt = datetime.fromtimestamp(last_speed_change / 1000.0) + # self._device_state.last_position_update = last_update_dt + # await self._trigger_callback() + + # async def update_with_vod( + # self, + # source_type: str, + # vod: LGHorizonVod, + # last_speed_change: float, + # relative_position: float, + # ) -> None: + # """Update box with vod.""" + # self._device_state.source_type = source_type + # self._device_state.channel_id = None + # self._device_state.channel_title = None + # self._device_state.title = vod.title + # self._device_state.image = None + # self._device_state.duration = vod.duration + # self._device_state.position = relative_position / 1000.0 + # last_update_dt = datetime.fromtimestamp(last_speed_change / 1000.0) + # self._device_state.last_position_update = last_update_dt + # await self._trigger_callback() + + # async def update_with_app(self, source_type: str, app: LGHorizonApp) -> None: + # """Update box with app.""" + # self._device_state.source_type = source_type + # self._device_state.channel_id = None + # self._device_state.channel_title = app.title + # self._device_state.title = app.title + # self._device_state.image = app.image + # self._device_state.reset_progress() + # await self._trigger_callback() + + async def _trigger_callback(self): + if self._change_callback: + _logger.debug("Callback called from box %s", self.device_id) + self._change_callback(self.device_id) + + async def turn_on(self) -> None: + """Turn the settop box on.""" + + if self.state == ONLINE_STANDBY: + await self.send_key_to_box(MEDIA_KEY_POWER) + + async def turn_off(self) -> None: + """Turn the settop box off.""" + if self.state == ONLINE_RUNNING: + await self.send_key_to_box(MEDIA_KEY_POWER) + self._device_state.reset() + + async def pause(self) -> None: + """Pause the given settopbox.""" + if self.state == ONLINE_RUNNING and not self._device_state.paused: + await self.send_key_to_box(MEDIA_KEY_PLAY_PAUSE) + + async def play(self) -> None: + """Resume the settopbox.""" + if self.state == ONLINE_RUNNING and self._device_state.paused: + await self.send_key_to_box(MEDIA_KEY_PLAY_PAUSE) + + async def stop(self) -> None: + """Stop the settopbox.""" + if self.state == ONLINE_RUNNING: + await self.send_key_to_box(MEDIA_KEY_STOP) + + async def next_channel(self): + """Select the next channel for given settop box.""" + if self.state == ONLINE_RUNNING: + await self.send_key_to_box(MEDIA_KEY_CHANNEL_UP) + + async def previous_channel(self) -> None: + """Select the previous channel for given settop box.""" + if self.state == ONLINE_RUNNING: + await self.send_key_to_box(MEDIA_KEY_CHANNEL_DOWN) + + async def press_enter(self) -> None: + """Press enter on the settop box.""" + if self.state == ONLINE_RUNNING: + await self.send_key_to_box(MEDIA_KEY_ENTER) + + async def rewind(self) -> None: + """Rewind the settop box.""" + if self.state == ONLINE_RUNNING: + await self.send_key_to_box(MEDIA_KEY_REWIND) + + async def fast_forward(self) -> None: + """Fast forward the settop box.""" + if self.state == ONLINE_RUNNING: + await self.send_key_to_box(MEDIA_KEY_FAST_FORWARD) + + async def record(self): + """Record on the settop box.""" + if self.state == ONLINE_RUNNING: + await self.send_key_to_box(MEDIA_KEY_RECORD) + + async def set_channel(self, source: str) -> None: + """Change te channel from the settopbox.""" + channel = [src for src in self._channels.values() if src.title == source][0] + payload = ( + '{"id":"' + + await make_id(8) + + '","type":"CPE.pushToTV","source":{"clientId":"' + + self._mqtt_client.client_id + + '","friendlyDeviceName":"Home Assistant"},' + + '"status":{"sourceType":"linear","source":{"channelId":"' + + channel.id + + '"},"relativePosition":0,"speed":1}}' + ) + + await self._mqtt_client.publish_message( + f"{self._auth.household_id}/{self.device_id}", payload + ) + + async def play_recording(self, recording_id): + """Play recording.""" + payload = ( + '{"id":"' + + await make_id(8) + + '","type":"CPE.pushToTV","source":{"clientId":"' + + self._mqtt_client.client_id + + '","friendlyDeviceName":"Home Assistant"},' + + '"status":{"sourceType":"nDVR","source":{"recordingId":"' + + recording_id + + '"},"relativePosition":0}}' + ) + await self._mqtt_client.publish_message( + f"{self._auth.household_id}/{self.device_id}", payload + ) + + async def send_key_to_box(self, key: str) -> None: + """Send emulated (remote) key press to settopbox.""" + payload_dict = { + "type": "CPE.KeyEvent", + "runtimeType": "key", + "id": "ha", + "source": self.device_id.lower(), + "status": {"w3cKey": key, "eventType": "keyDownUp"}, + } + payload = json.dumps(payload_dict) + await self._mqtt_client.publish_message( + f"{self._auth.household_id}/{self.device_id}", payload + ) + + async def _set_unknown_channel_info(self) -> None: + """Set unknown channel info.""" + _logger.warning("Couldn't set channel. Channel info set to unknown...") + self._device_state.source_type = BOX_PLAY_STATE_CHANNEL + self._device_state.channel_id = None + self._device_state.title = "No information available" + self._device_state.image = None + self._device_state.paused = False + + async def _request_settop_box_state(self) -> None: + """Send mqtt message to receive state from settop box.""" + topic = f"{self._auth.household_id}/{self.device_id}" + payload = { + "id": await make_id(8), + "type": "CPE.getUiStatus", + "source": self._mqtt_client.client_id, + } + await self._mqtt_client.publish_message(topic, json.dumps(payload)) + + async def _request_settop_box_recording_capacity(self) -> None: + """Send mqtt message to receive state from settop box.""" + topic = f"{self._auth.household_id}/{self.device_id}" + payload = { + "id": await make_id(8), + "type": "CPE.capacity", + "source": self._mqtt_client.client_id, + } + await self._mqtt_client.publish_message(topic, json.dumps(payload)) diff --git a/lghorizon/models/lghorizon_device_state.py b/lghorizon/models/lghorizon_device_state.py new file mode 100644 index 0000000..b80036e --- /dev/null +++ b/lghorizon/models/lghorizon_device_state.py @@ -0,0 +1,136 @@ +"""LG Horizon device state model.""" + +from datetime import datetime +from typing import Optional + + +class LGHorizonDeviceState: + """Represent current state of a box.""" + + _channel_id: Optional[str] + _title: Optional[str] + _image: Optional[str] + _source_type: Optional[str] + _paused: bool + _channel_title: Optional[str] + _duration: Optional[float] + _position: Optional[float] + _last_position_update: Optional[datetime] + + def __init__(self) -> None: + """Initialize the playing info.""" + self._channel_id = None + self._title = None + self._image = None + self._source_type = None + self._paused = False + self._channel_title = None + self._duration = None + self._position = None + self._last_position_update = None + + @property + def channel_id(self) -> Optional[str]: + """Return the channel ID.""" + return self._channel_id + + @channel_id.setter + def channel_id(self, value: Optional[str]) -> None: + """Set the channel ID.""" + self._channel_id = value + + @property + def title(self) -> Optional[str]: + """Return the title.""" + return self._title + + @title.setter + def title(self, value: Optional[str]) -> None: + """Set the title.""" + self._title = value + + @property + def image(self) -> Optional[str]: + """Return the image URL.""" + return self._image + + @image.setter + def image(self, value: Optional[str]) -> None: + """Set the image URL.""" + self._image = value + + @property + def source_type(self) -> Optional[str]: + """Return the source type.""" + return self._source_type + + @source_type.setter + def source_type(self, value: Optional[str]) -> None: + """Set the source type.""" + self._source_type = value + + @property + def paused(self) -> bool: + """Return if the media is paused.""" + return self._paused + + @paused.setter + def paused(self, value: bool) -> None: + """Set the paused state.""" + self._paused = value + + @property + def channel_title(self) -> Optional[str]: + """Return the channel title.""" + return self._channel_title + + @channel_title.setter + def channel_title(self, value: Optional[str]) -> None: + """Set the channel title.""" + self._channel_title = value + + @property + def duration(self) -> Optional[float]: + """Return the duration of the media.""" + return self._duration + + @duration.setter + def duration(self, value: Optional[float]) -> None: + """Set the duration of the media.""" + self._duration = value + + @property + def position(self) -> Optional[float]: + """Return the current position in the media.""" + return self._position + + @position.setter + def position(self, value: Optional[float]) -> None: + """Set the current position in the media.""" + self._position = value + + @property + def last_position_update(self) -> Optional[datetime]: + """Return the last time the position was updated.""" + return self._last_position_update + + @last_position_update.setter + def last_position_update(self, value: Optional[datetime]) -> None: + """Set the last position update time.""" + self._last_position_update = value + + def reset_progress(self) -> None: + """Reset the progress-related attributes.""" + self.last_position_update = None + self.duration = None + self.position = None + + def reset(self) -> None: + """Reset all playing information.""" + self.channel_id = None + self.title = None + self.image = None + self.source_type = None + self.paused = False + self.channel_title = None + self.reset_progress() diff --git a/lghorizon/models/lghorizon_entitlements.py b/lghorizon/models/lghorizon_entitlements.py new file mode 100644 index 0000000..ebea100 --- /dev/null +++ b/lghorizon/models/lghorizon_entitlements.py @@ -0,0 +1,21 @@ +"""LG Horizon Entitlements model.""" + +from __future__ import annotations + + +class LGHorizonEntitlements: + """Class to represent entitlements.""" + + def __init__(self, entitlements_json): + """Initialize entitlements.""" + self.entitlements_json = entitlements_json + + @property + def entitlements(self): + """Returns the entitlements.""" + return self.entitlements_json.get("entitlements", []) + + @property + def entitlement_ids(self) -> list[str]: + """Returns a list of entitlement IDs.""" + return [e["id"] for e in self.entitlements if "id" in e] diff --git a/lghorizon/models/lghorizon_message.py b/lghorizon/models/lghorizon_message.py new file mode 100644 index 0000000..a2eebf5 --- /dev/null +++ b/lghorizon/models/lghorizon_message.py @@ -0,0 +1,112 @@ +"""LG Horizon message models.""" + +from abc import ABC, abstractmethod +import json + +from enum import Enum +from .lghorizon_ui_status import LGHorizonUIState + + +class LGHorizonMessageType(Enum): + """Enumeration of LG Horizon message types.""" + + UNKNOWN = 0 + STATUS = 1 + UI_STATUS = 2 + + +class LGHorizonMessage(ABC): + """Abstract base class for LG Horizon messages.""" + + @property + def topic(self) -> str: + """Return the topic of the message.""" + return self._topic + + @property + def payload(self) -> dict: + """Return the payload of the message.""" + return self._payload + + @property + @abstractmethod + def message_type(self) -> LGHorizonMessageType | None: + """Return the message type.""" + + @abstractmethod + def __init__(self, topic: str, payload: dict) -> None: + """Abstract base class for LG Horizon messages.""" + self._topic = topic + self._payload = payload + + def __repr__(self) -> str: + """Return a string representation of the message.""" + return f"LGHorizonStatusMessage(topic='{self._topic}', payload={json.dumps(self._payload, indent=2)})" + + +class LGHorizonStatusMessage(LGHorizonMessage): + """Represents an LG Horizon status message received via MQTT.""" + + def __init__(self, payload: dict, topic: str) -> None: + """Initialize an LG Horizon status message.""" + super().__init__(topic, payload) + + @property + def message_type(self) -> LGHorizonMessageType: + """Return the message type from the payload, if available.""" + return LGHorizonMessageType.STATUS + + @property + def source(self) -> str: + """Return the device ID from the payload, if available.""" + return self._payload.get("source", "unknown") + + @property + def state(self) -> str: + """Return the device ID from the payload, if available.""" + return self._payload.get("state", "unknown") + + +class LGHorizonUIStatusMessage(LGHorizonMessage): + """Represents an LG Horizon UI status message received via MQTT.""" + + _status: LGHorizonUIState | None = None + + def __init__(self, payload: dict, topic: str) -> None: + """Initialize an LG Horizon UI status message.""" + super().__init__(topic, payload) + + @property + def message_type(self) -> LGHorizonMessageType: + """Return the message type from the payload, if available.""" + return LGHorizonMessageType.UI_STATUS + + @property + def source(self) -> str: + """Return the device ID from the payload, if available.""" + return self._payload.get("source", "unknown") + + @property + def message_timestamp(self) -> int: + """Return the device ID from the payload, if available.""" + return self._payload.get("messageTimeStamp", 0) + + @property + def ui_state(self) -> LGHorizonUIState | None: + """Return the device ID from the payload, if available.""" + if not self._status and "status" in self._payload: + self._status = LGHorizonUIState(self._payload["status"]) + return self._status + + +class LGHorizonUnknownMessage(LGHorizonMessage): + """Represents an unknown LG Horizon message received via MQTT.""" + + def __init__(self, payload: dict, topic: str) -> None: + """Initialize an LG Horizon unknown message.""" + super().__init__(topic, payload) + + @property + def message_type(self) -> LGHorizonMessageType: + """Return the message type from the payload, if available.""" + return LGHorizonMessageType.UNKNOWN diff --git a/lghorizon/models/lghorizon_mqtt_client.py b/lghorizon/models/lghorizon_mqtt_client.py index 5eb4760..c4105cf 100644 --- a/lghorizon/models/lghorizon_mqtt_client.py +++ b/lghorizon/models/lghorizon_mqtt_client.py @@ -68,69 +68,42 @@ async def create( instance._mqtt_client.username_pw_set(auth.household_id, instance._mqtt_token) instance._mqtt_client.tls_set() instance._mqtt_client.enable_logger(_logger) - instance._mqtt_client.on_connect = instance._on_mqtt_connect + instance._mqtt_client.on_connect = instance._on_connect instance._on_connected_callback = on_connected_callback instance._on_message_callback = on_message_callback return instance - def _on_mqtt_connect(self, client, userdata, flags, result_code): # pylint: disable=unused-argument + def _on_connect(self, client, userdata, flags, result_code): # pylint: disable=unused-argument if result_code == 0: - self._mqtt_client.on_message = self._on_message_wrapper - self._mqtt_client.subscribe(self._auth.household_id) - self._mqtt_client.subscribe(self._auth.household_id + "/#") - self._mqtt_client.subscribe(self._auth.household_id + "/" + self.client_id) - self._mqtt_client.subscribe(self._auth.household_id + "/+/status") - self._mqtt_client.subscribe( - self._auth.household_id + "/+/networkRecordings" - ) - self._mqtt_client.subscribe( - self._auth.household_id + "/+/networkRecordings/capacity" - ) - self._mqtt_client.subscribe(self._auth.household_id + "/+/localRecordings") - self._mqtt_client.subscribe( - self._auth.household_id + "/+/localRecordings/capacity" - ) - self._mqtt_client.subscribe(self._auth.household_id + "/watchlistService") - self._mqtt_client.subscribe(self._auth.household_id + "/purchaseService") - self._mqtt_client.subscribe( - self._auth.household_id + "/personalizationService" - ) - self._mqtt_client.subscribe(self._auth.household_id + "/recordingStatus") - self._mqtt_client.subscribe( - self._auth.household_id + "/recordingStatus/lastUserAction" - ) + self._mqtt_client.on_message = self._on_message if self._on_connected_callback: asyncio.run_coroutine_threadsafe( self._on_connected_callback(), self._loop ) elif result_code == 5: self._mqtt_client.username_pw_set(self._auth.household_id, self._mqtt_token) - self.connect() + asyncio.run_coroutine_threadsafe(self.connect(), self._loop) else: _logger.error( "Cannot connect to MQTT server with resultCode: %s", result_code ) - def connect(self) -> None: - """Connect the client.""" - self._mqtt_client.connect(self._mqtt_broker_url, 443) - self._mqtt_client.loop_start() - - def _on_message_wrapper(self, client, userdata, message): # pylint: disable=unused-argument + def _on_message(self, client, userdata, message): # pylint: disable=unused-argument """Wrapper for handling MQTT messages in a thread-safe manner.""" asyncio.run_coroutine_threadsafe( self._on_client_message(client, userdata, message), self._loop ) - async def _on_client_message(self, client, userdata, message): # pylint: disable=unused-argument - """Handle messages received by mqtt client.""" - _logger.debug("Received MQTT message. Topic: %s", message.topic) - json_payload = json.loads(message.payload) - _logger.debug("Message: %s", json_payload) - if self._on_message_callback: - await self._on_message_callback(json_payload, message.topic) + async def connect(self) -> None: + """Connect the client.""" + self._mqtt_client.connect(self._mqtt_broker_url, 443) + self._mqtt_client.loop_start() + + async def subscribe(self, topic: str) -> None: + """Subscribe to a MQTT topic.""" + self._mqtt_client.subscribe(topic) - def publish_message(self, topic: str, json_payload: str) -> None: + async def publish_message(self, topic: str, json_payload: str) -> None: """Publish a MQTT message.""" self._mqtt_client.publish(topic, json_payload, qos=2) @@ -138,3 +111,16 @@ async def disconnect(self) -> None: """Disconnect the client.""" if self._mqtt_client.is_connected(): self._mqtt_client.disconnect() + + async def _on_client_message(self, client, userdata, message): # pylint: disable=unused-argument + """Handle messages received by mqtt client.""" + json_payload = await self._loop.run_in_executor( + None, json.loads, message.payload + ) + _logger.debug( + "Received MQTT message \n\ntopic: %s\npayload:\n\n%s\n", + message.topic, + json.dumps(json_payload, indent=2), + ) + if self._on_message_callback: + await self._on_message_callback(json_payload, message.topic) diff --git a/lghorizon/models/lghorizon_profile.py b/lghorizon/models/lghorizon_profile.py index 6fddf50..346c420 100644 --- a/lghorizon/models/lghorizon_profile.py +++ b/lghorizon/models/lghorizon_profile.py @@ -1,11 +1,24 @@ +"""LG Horizon Profile model.""" + + class LGHorizonProfile: """LGHorizon profile.""" - profile_id: str | None = None - name: str | None = None - favorite_channels: list[str] | None = None + def __init__(self, json_payload: dict): + """Initialize a profile.""" + self._json_payload = json_payload + + @property + def id(self) -> str: + """Return the profile id.""" + return self._json_payload["profileId"] + + @property + def name(self) -> str: + """Return the profile name.""" + return self._json_payload["name"] - def __init__(self, json_payload): - self.profile_id = json_payload["profileId"] - self.name = json_payload["name"] - self.favorite_channels = json_payload["favoriteChannels"] + @property + def favorite_channels(self) -> list[str]: + """Return the favorite channels.""" + return self._json_payload.get("favoriteChannels", []) diff --git a/lghorizon/models/lghorizon_sources.py b/lghorizon/models/lghorizon_sources.py new file mode 100644 index 0000000..7fa59dc --- /dev/null +++ b/lghorizon/models/lghorizon_sources.py @@ -0,0 +1,50 @@ +"LG Horizon Sources Model." + +from abc import ABC, abstractmethod +from enum import Enum + + +class LGHorizonSourceType(Enum): + """Enumeration of LG Horizon message types.""" + + LINEAR = "linear" + UNKNOWN = "unknown" + + +class LGHorizonSource(ABC): + """Abstract base class for LG Horizon sources.""" + + def __init__(self, raw_json: dict) -> None: + """Initialize the LG Horizon source.""" + self._raw_json = raw_json + + @property + @abstractmethod + def source_type(self) -> LGHorizonSourceType: + """Return the message type.""" + + +class LGHorizonLinearSource(LGHorizonSource): + """Represent the Linear Source of an LG Horizon device.""" + + @property + def channel_id(self) -> str: + """Return the source type.""" + return self._raw_json.get("channelId", "") + + @property + def event_id(self) -> str: + """Return the event ID.""" + return self._raw_json.get("eventId", "") + + @property + def source_type(self) -> LGHorizonSourceType: + return LGHorizonSourceType.LINEAR + + +class LGHorizonUnknownSource(LGHorizonSource): + """Represent the Linear Source of an LG Horizon device.""" + + @property + def source_type(self) -> LGHorizonSourceType: + return LGHorizonSourceType.UNKNOWN diff --git a/lghorizon/models/lghorizon_ui_status.py b/lghorizon/models/lghorizon_ui_status.py new file mode 100644 index 0000000..b879342 --- /dev/null +++ b/lghorizon/models/lghorizon_ui_status.py @@ -0,0 +1,71 @@ +"""LG Horizon UI Status Model.""" + +from .lghorizon_sources import ( + LGHorizonSource, + LGHorizonLinearSource, + LGHorizonUnknownSource, + LGHorizonSourceType, +) + + +class LGHorizonPlayerState: + """Represent the Player State of an LG Horizon device.""" + + def __init__(self, raw_json: dict) -> None: + """Initialize the Player State.""" + self._raw_json = raw_json + + @property + def source_type(self) -> str: + """Return the source type.""" + return self._raw_json.get("sourceType", "") + + @property + def speed(self) -> int: + """Return the Player State dictionary.""" + return self._raw_json.get("speed", 0) + + @property + def last_speed_change_time( + self, + ) -> int: + """Return the last speed change time.""" + return self._raw_json.get("lastSpeedChangeTime", 0.0) + + @property + def source(self) -> LGHorizonSource | None: # Added None to the return type + """Return the last speed change time.""" + if "source" in self._raw_json: + source_type = LGHorizonSourceType[self.source_type.upper()] + match source_type: + case LGHorizonSourceType.LINEAR: + return LGHorizonLinearSource(self._raw_json["source"]) + + return LGHorizonUnknownSource(self._raw_json["source"]) + + +class LGHorizonUIState: + """Represent the State of an LG Horizon device.""" + + _player_state: LGHorizonPlayerState | None = None + + def __init__(self, raw_json: dict) -> None: + """Initialize the State.""" + self._raw_json = raw_json + + @property + def ui_status(self) -> str: + """Return the UI status dictionary.""" + return self._raw_json.get("uiStatus", "") + + @property + def player_state( + self, + ) -> LGHorizonPlayerState | None: # Added None to the return type + """Return the UI status dictionary.""" + # Check if _player_state is None and if "playerState" key exists in raw_json + if self._player_state is None and "playerState" in self._raw_json: + self._player_state = LGHorizonPlayerState( + self._raw_json["playerState"] + ) # Access directly as existence is checked + return self._player_state diff --git a/main.py b/main.py index 6aff77c..3280235 100644 --- a/main.py +++ b/main.py @@ -2,6 +2,7 @@ import asyncio import json +import logging import aiohttp @@ -11,6 +12,13 @@ async def main(): """main loop""" + logging.basicConfig( + level=logging.DEBUG, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + filename="lghorizon.log", + filemode="w", + ) + logging.info("Starting LG Horizon test script") with open("secrets.json", encoding="utf-8") as f: secrets = json.load(f) username = secrets.get("username") @@ -20,15 +28,19 @@ async def main(): async with aiohttp.ClientSession() as session: auth = LGHorizonAuth(session, country, username=username, password=password) api = LGHorizonApi(auth) - await api.initialize() - try: - print("Listening to MQTT broker... Press Ctrl+C to exit") + await api.initialize() while True: await asyncio.sleep(1) except KeyboardInterrupt: print("\nShutting down...") + except Exception as e: + print(f"An error occurred: {e}") + finally: await api.disconnect() + def device_callback(self, device_id: str): + print(f"Device {device_id} state changed.") + asyncio.run(main()) From 42f8ed58d29b87ce192e4ec33199dd07d7c0c264 Mon Sep 17 00:00:00 2001 From: Rudolf Offereins Date: Wed, 28 Jan 2026 00:23:52 +0000 Subject: [PATCH 04/16] Added linear, replay, nDvr, reviewbuffer and VOD(series) support --- lghorizon/device_state_processor.py | 194 +++++++++++++++++++++ lghorizon/legacy/models.py | 16 +- lghorizon/lghorizonapi.py | 45 +++-- lghorizon/models/lghorizon_auth.py | 122 ++++++++++--- lghorizon/models/lghorizon_device.py | 136 +++++++-------- lghorizon/models/lghorizon_device_state.py | 61 +++++-- lghorizon/models/lghorizon_events.py | 86 +++++++++ lghorizon/models/lghorizon_message.py | 5 +- lghorizon/models/lghorizon_mqtt_client.py | 14 +- lghorizon/models/lghorizon_sources.py | 58 ++++++ lghorizon/models/lghorizon_ui_status.py | 61 ++++++- main.py | 17 +- 12 files changed, 665 insertions(+), 150 deletions(-) create mode 100644 lghorizon/device_state_processor.py create mode 100644 lghorizon/models/lghorizon_events.py diff --git a/lghorizon/device_state_processor.py b/lghorizon/device_state_processor.py new file mode 100644 index 0000000..62c2e70 --- /dev/null +++ b/lghorizon/device_state_processor.py @@ -0,0 +1,194 @@ +"""LG Horizon device (set-top box) model.""" + +import random +import json +import urllib.parse + +from typing import cast, Dict + +from .models.lghorizon_device_state import LGHorizonDeviceState, LGHorizonRunningState +from .models.lghorizon_message import LGHorizonStatusMessage, LGHorizonUIStatusMessage +from .models.lghorizon_sources import ( + LGHorizonSourceType, + LGHorizonLinearSource, + LGHorizonVODSource, + LGHorizonReviewBufferSource, + LGHorizonNDVRSource, +) +from .models.lghorizon_auth import LGHorizonAuth +from .models.lghorizon_events import LGHorizonReplayEvent, LGHorizonVOD +from .models.lghorizon_channel import LGHorizonChannel +from .models.lghorizon_ui_status import ( + LGHorizonUIStateType, + LGHorizonAppsState, + LGHorizonPlayerState, +) +from .models.lghorizon_customer import LGHorizonCustomer + + +class LGHorizonDeviceStateProcessor: + """Process incoming device state messages""" + + def __init__( + self, + auth: LGHorizonAuth, + channels: Dict[str, LGHorizonChannel], + customer: LGHorizonCustomer, + profile_id: str, + ): + self._auth = auth + self._channels = channels + self._customer = customer + self._profile_id = profile_id + + async def process_state( + self, device_state: LGHorizonDeviceState, status_message: LGHorizonStatusMessage + ) -> None: + """Process the device state based on the status message.""" + await device_state.reset() + device_state.state = status_message.running_state + + async def process_ui_state( + self, + device_state: LGHorizonDeviceState, + ui_status_message: LGHorizonUIStatusMessage, + ) -> None: + """Process the device state based on the UI status message.""" + await device_state.reset() + if ( + ui_status_message.ui_state is None + or device_state.state == LGHorizonRunningState.ONLINE_STANDBY + ): + return + + if ui_status_message.ui_state is None: + return + match ui_status_message.ui_state.ui_status: + case LGHorizonUIStateType.MAINUI: + if ui_status_message.ui_state.player_state is None: + return + await self._process_main_ui_state( + device_state, ui_status_message.ui_state.player_state + ) + case LGHorizonUIStateType.APPS: + if ui_status_message.ui_state.apps_state is None: + return + await self._process_apps_state( + device_state, ui_status_message.ui_state.apps_state + ) + + if ui_status_message.ui_state.ui_status == LGHorizonUIStateType.APPS: + return + + if ui_status_message.ui_state.player_state is None: + return + + async def _process_main_ui_state( + self, + device_state: LGHorizonDeviceState, + player_state: LGHorizonPlayerState, + ) -> None: + if player_state is None: + return + + device_state.source_type = player_state.source_type + match player_state.source_type: + case ( + LGHorizonSourceType.LINEAR + | LGHorizonSourceType.REVIEWBUFFER + | LGHorizonSourceType.REPLAY + | LGHorizonSourceType.NDVR + ): + await self._process_channel_state(device_state, player_state) + case LGHorizonSourceType.VOD: + await self._process_vod_state(device_state, player_state) + + async def _process_apps_state( + self, + device_state: LGHorizonDeviceState, + apps_state: LGHorizonAppsState, + ) -> None: + device_state.channel_id = apps_state.id + device_state.channel_title = apps_state.app_name + device_state.image = apps_state.logo_path + + async def _process_channel_state( + self, + device_state: LGHorizonDeviceState, + player_state: LGHorizonPlayerState, + ) -> None: + """Process the device state based on the UI status message.""" + if player_state.source is None: + return + player_state.source.__class__ = LGHorizonLinearSource + source = cast(LGHorizonLinearSource, player_state.source) + service_config = await self._auth.get_service_config() + service_url = await service_config.get_service_url("linearService") + service_path = f"/v2/replayEvent/{source.event_id}?returnLinearContent=true&language={self._auth.country_code}" + + event_json = await self._auth.request( + service_url, + service_path, + ) + replay_event = LGHorizonReplayEvent(event_json) + channel = self._channels[replay_event.channel_id] + device_state.source_type = source.source_type + device_state.channel_id = channel.channel_number + device_state.channel_title = channel.title + device_state.title = replay_event.title + if replay_event.episode_name: + device_state.title += f": {replay_event.episode_name}" + + # Add random number to url to force refresh + join_param = "?" + if join_param in channel.stream_image: + join_param = "&" + image_url = ( + f"{channel.stream_image}{join_param}{str(random.randrange(1000000))}" + ) + device_state.image = image_url + await device_state.reset_progress() + + async def _process_vod_state( + self, + device_state: LGHorizonDeviceState, + player_state: LGHorizonPlayerState, + ) -> None: + """Process the device state based on the UI status message.""" + if player_state.source is None: + return + player_state.source.__class__ = LGHorizonVODSource + source = cast(LGHorizonVODSource, player_state.source) + service_config = await self._auth.get_service_config() + service_url = await service_config.get_service_url("vodService") + service_path = f"/v2/detailscreen/{source.title_id}?language={self._customer.country_id}&profileId={self._profile_id}&cityId={self._customer.city_id}" + + vod_json = await self._auth.request( + service_url, + service_path, + ) + vod = LGHorizonVOD(vod_json) + device_state.channel_title = vod.title + device_state.duration = vod.duration + intents_url = await service_config.get_service_url("imageService") + intents_path = "/intent" + body_json = [ + { + "id": vod.id, + "intents": ["detailedBackground", "posterTile"], + } + ] + intents_body = urllib.parse.quote( + json.dumps(body_json, separators=(",", ":"), indent=None), safe="~" + ) + + # Construct the full path with the URL-encoded JSON as a query parameter + full_intents_path = f"{intents_path}?jsonBody={intents_body}" + intents_result = await self._auth.request(intents_url, full_intents_path) + if ( + "intents" in intents_result[0] + and len(intents_result[0]["intents"]) > 0 + and intents_result[0]["intents"][0]["url"] + ): + device_state.image = intents_result[0]["intents"][0]["url"] + await device_state.reset_progress() diff --git a/lghorizon/legacy/models.py b/lghorizon/legacy/models.py index 36f2ab8..8afebb6 100644 --- a/lghorizon/legacy/models.py +++ b/lghorizon/legacy/models.py @@ -703,14 +703,14 @@ def send_key_to_box(self, key: str) -> None: f"{self._auth.household_id}/{self.device_id}", payload ) - def _set_unknown_channel_info(self) -> None: - """Set unknown channel info.""" - _logger.warning("Couldn't set channel. Channel info set to unknown...") - self.playing_info.set_source_type(BOX_PLAY_STATE_CHANNEL) - self.playing_info.set_channel(None) - self.playing_info.set_title("No information available") - self.playing_info.set_image(None) - self.playing_info.set_paused(False) + # def _set_unknown_channel_info(self) -> None: + # """Set unknown channel info.""" + # _logger.warning("Couldn't set channel. Channel info set to unknown...") + # self.playing_info.set_source_type(BOX_PLAY_STATE_CHANNEL) + # self.playing_info.set_channel(None) + # self.playing_info.set_title("No information available") + # self.playing_info.set_image(None) + # self.playing_info.set_paused(False) def _request_settop_box_state(self) -> None: """Send mqtt message to receive state from settop box.""" diff --git a/lghorizon/lghorizonapi.py b/lghorizon/lghorizonapi.py index 40f8eb1..a7e82cd 100644 --- a/lghorizon/lghorizonapi.py +++ b/lghorizon/lghorizonapi.py @@ -14,6 +14,9 @@ from .models.lghorizon_message import LGHorizonMessageType from .message_factory import LGHorizonMessageFactory from .models.lghorizon_message import LGHorizonStatusMessage, LGHorizonUIStatusMessage +from .models.lghorizon_device_state import LGHorizonRunningState +from .device_state_processor import LGHorizonDeviceStateProcessor + _LOGGER = logging.getLogger(__name__) @@ -31,22 +34,29 @@ class LGHorizonApi: _initialized: bool = False _devices: Dict[str, LGHorizonDevice] = {} _message_factory: LGHorizonMessageFactory = LGHorizonMessageFactory() + _device_state_processor: LGHorizonDeviceStateProcessor | None def __init__(self, auth: LGHorizonAuth, profile_id: str = "") -> None: """Initialize LG Horizon API client.""" self.auth = auth self._profile_id = profile_id self._channels = {} + self._device_state_processor = None async def initialize(self) -> None: """Initialize the API client.""" self._service_config = await self.auth.get_service_config() self._customer = await self._get_customer_info() + if self._profile_id == "": + self._profile_id = list(self._customer.profiles.keys())[0] await self._refresh_entitlements() await self._refresh_channels() self._mqtt_client = await self._create_mqtt_client() await self._mqtt_client.connect() await self._register_devices() + self._device_state_processor = LGHorizonDeviceStateProcessor( + self.auth, self._channels, self._customer, self._profile_id + ) self._initialized = True async def get_devices(self) -> Dict[str, LGHorizonDevice]: @@ -102,7 +112,17 @@ async def _register_devices(self) -> None: channels = await self.get_profile_channels(self._profile_id) for raw_box in self._customer.assigned_devices: _LOGGER.debug("Creating box for device: %s", raw_box) - device = LGHorizonDevice(raw_box, self._mqtt_client, self.auth, channels) + if self._device_state_processor is None: + self._device_state_processor = LGHorizonDeviceStateProcessor( + self.auth, self._channels, self._customer, self._profile_id + ) + device = LGHorizonDevice( + raw_box, + self._mqtt_client, + self._device_state_processor, + self.auth, + channels, + ) await device.register_mqtt() self._devices[device.device_id] = device @@ -120,10 +140,10 @@ async def _create_mqtt_client(self) -> LGHorizonMqttClient: ) return mqtt_client - async def _on_mqtt_connected(self) -> None: + async def _on_mqtt_connected(self): """MQTT connected callback.""" await self._mqtt_client.subscribe(self.auth.household_id) - await self._mqtt_client.subscribe(self.auth.household_id + "/#") + # await self._mqtt_client.subscribe(self.auth.household_id + "/#") await self._mqtt_client.subscribe( self.auth.household_id + "/" + self._mqtt_client.client_id ) @@ -148,22 +168,25 @@ async def _on_mqtt_connected(self) -> None: self.auth.household_id + "/recordingStatus/lastUserAction" ) - async def _on_mqtt_message(self, mqtt_message: dict, mqtt_topic: str) -> None: + async def _on_mqtt_message(self, mqtt_message: dict, mqtt_topic: str): """MQTT message callback.""" message = await self._message_factory.create_message(mqtt_topic, mqtt_message) match message.message_type: case LGHorizonMessageType.STATUS: message.__class__ = LGHorizonStatusMessage status_message = cast(LGHorizonStatusMessage, message) - await self._devices[status_message.source].handle_status_message( - status_message - ) + device = self._devices[status_message.source] + await device.handle_status_message(status_message) case LGHorizonMessageType.UI_STATUS: message.__class__ = LGHorizonUIStatusMessage - status_message = cast(LGHorizonUIStatusMessage, message) - await self._devices[status_message.source].handle_ui_status_message( - status_message - ) + ui_status_message = cast(LGHorizonUIStatusMessage, message) + device = self._devices[ui_status_message.source] + if ( + not device.device_state.state + == LGHorizonRunningState.ONLINE_RUNNING + ): + return + await device.handle_ui_status_message(ui_status_message) async def _get_customer_info(self) -> Any: service_url = await self._service_config.get_service_url( diff --git a/lghorizon/models/lghorizon_auth.py b/lghorizon/models/lghorizon_auth.py index 0ac012d..017335c 100644 --- a/lghorizon/models/lghorizon_auth.py +++ b/lghorizon/models/lghorizon_auth.py @@ -3,7 +3,7 @@ import time import logging import json -from typing import Any +from typing import Any, Optional import backoff from aiohttp import ClientResponseError, ClientSession @@ -18,6 +18,17 @@ class LGHorizonAuth: """Class to make authenticated requests.""" + _websession: ClientSession + _refresh_token: str + _access_token: Optional[str] + _username: str + _password: str + _household_id: str + _token_expiry: Optional[int] + _country_code: str + _host: str + _use_refresh_token: bool + def __init__( self, websession: ClientSession, @@ -27,18 +38,88 @@ def __init__( password: str = "", ) -> None: """Initialize the auth with refresh token.""" - self.websession = websession - self.refresh_token = refresh_token - self.access_token = None - self.username = username - self.password = password - self.household_id = "" - 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"] + self._websession = websession + self._refresh_token = refresh_token + self._access_token = None + self._username = username + self._password = password + self._household_id = "" + 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"] self._service_config = None + @property + def websession(self) -> ClientSession: + """Return the aiohttp client session.""" + return self._websession + + @property + def refresh_token(self) -> str: + """Return the refresh token.""" + return self._refresh_token + + @refresh_token.setter + def refresh_token(self, value: str) -> None: + """Set the refresh token.""" + self._refresh_token = value + + @property + def access_token(self) -> Optional[str]: + """Return the access token.""" + return self._access_token + + @access_token.setter + def access_token(self, value: Optional[str]) -> None: + """Set the access token.""" + self._access_token = value + + @property + def username(self) -> str: + """Return the username.""" + return self._username + + @username.setter + def username(self, value: str) -> None: + """Set the username.""" + self._username = value + + @property + def password(self) -> str: + """Return the password.""" + return self._password + + @password.setter + def password(self, value: str) -> None: + """Set the password.""" + self._password = value + + @property + def household_id(self) -> str: + """Return the household ID.""" + return self._household_id + + @household_id.setter + def household_id(self, value: str) -> None: + """Set the household ID.""" + self._household_id = value + + @property + def token_expiry(self) -> Optional[int]: + """Return the token expiry timestamp.""" + return self._token_expiry + + @token_expiry.setter + def token_expiry(self, value: Optional[int]) -> None: + """Set the token expiry timestamp.""" + self._token_expiry = value + + @property + def country_code(self) -> str: + """Return the country code.""" + return self._country_code + async def is_token_expiring(self) -> bool: """Check if the token is expiring within one day.""" if not self.access_token or not self.token_expiry: @@ -53,16 +134,16 @@ async def fetch_access_token(self) -> None: headers["content-type"] = "application/json" headers["charset"] = "utf-8" - if not self.use_refresh_token and self.access_token is None: + if not self._use_refresh_token and self.access_token is None: payload = {"password": self.password, "username": self.username} headers["x-device-code"] = "web" auth_url_path = "/auth-service/v1/authorization" else: payload = {"refreshToken": self.refresh_token} auth_url_path = "/auth-service/v1/authorization/refresh" - try: + try: # Use properties and backing fields auth_response = await self.websession.post( - f"{self.host}{auth_url_path}", + f"{self._host}{auth_url_path}", json=payload, headers=headers, ) @@ -87,20 +168,17 @@ async def fetch_access_token(self) -> None: self.token_expiry = auth_json["refreshTokenExpiry"] @backoff.on_exception(backoff.expo, LGHorizonApiConnectionError, max_tries=3) - async def request(self, host: str, path: str, **kwargs) -> Any: + async def request(self, host: str, path: str, params=None, **kwargs) -> Any: """Make a request.""" if headers := kwargs.pop("headers", {}): headers = dict(headers) request_url = f"{host}{path}" - if await self.is_token_expiring(): + if await self.is_token_expiring(): # Use property _LOGGER.debug("Access token is expiring, fetching a new one") await self.fetch_access_token() try: web_response = await self.websession.request( - "GET", - request_url, - **kwargs, - headers=headers, + "GET", request_url, **kwargs, headers=headers, params=params ) web_response.raise_for_status() json_response = await web_response.json() @@ -132,10 +210,10 @@ async def get_mqtt_token(self) -> Any: async def get_service_config(self): """Get the service configuration.""" _LOGGER.debug("Fetching service configuration") - if self._service_config is None: + if self._service_config is None: # Use property and backing field base_country_code = self.country_code[0:2] result = await self.request( - self.host, + self._host, f"/{base_country_code}/en/config-service/conf/web/backoffice.json", ) self._service_config = LGHorizonServicesConfig(result) diff --git a/lghorizon/models/lghorizon_device.py b/lghorizon/models/lghorizon_device.py index de65597..5bf380e 100644 --- a/lghorizon/models/lghorizon_device.py +++ b/lghorizon/models/lghorizon_device.py @@ -2,13 +2,10 @@ import json import logging -from typing import Callable, Dict, Optional +from typing import Callable, Dict, Optional, Any, Coroutine -from lghorizon.models.lghorizon_sources import LGHorizonSourceType from ..const import ( - BOX_PLAY_STATE_CHANNEL, - ONLINE_STANDBY, ONLINE_RUNNING, MEDIA_KEY_POWER, MEDIA_KEY_PLAY_PAUSE, @@ -25,10 +22,12 @@ from .lghorizon_auth import LGHorizonAuth from .lghorizon_channel import LGHorizonChannel from .lghorizon_mqtt_client import LGHorizonMqttClient # Added import for type checking -from .lghorizon_device_state import LGHorizonDeviceState +from .lghorizon_device_state import LGHorizonDeviceState, LGHorizonRunningState from .exceptions import LGHorizonApiConnectionError from .lghorizon_message import LGHorizonStatusMessage, LGHorizonUIStatusMessage +from ..device_state_processor import LGHorizonDeviceStateProcessor + # Assuming these models are available from legacy or will be moved to models/ # from ..legacy.models import ( # # LGHorizonPlayingInfo, @@ -49,21 +48,22 @@ class LGHorizonDevice: _hashed_cpe_id: str _device_friendly_name: str _platform_type: str - _state: Optional[str] _device_state: LGHorizonDeviceState _manufacturer: Optional[str] _model: Optional[str] _recording_capacity: Optional[int] - + _device_state_processor: LGHorizonDeviceStateProcessor _mqtt_client: LGHorizonMqttClient - _change_callback: Callable + _change_callback: Callable[[str], Coroutine[Any, Any, Any]] _auth: LGHorizonAuth _channels: Dict[str, LGHorizonChannel] + _last_ui_message_timestamp: int = 0 def __init__( self, device_json, mqtt_client: LGHorizonMqttClient, + device_state_processor: LGHorizonDeviceStateProcessor, auth: LGHorizonAuth, channels: Dict[str, LGHorizonChannel], ): @@ -75,11 +75,11 @@ def __init__( self._mqtt_client = mqtt_client self._auth = auth self._channels = channels - self._device_state = LGHorizonDeviceState() - self._state = None # Initialize state + self._device_state = LGHorizonDeviceState() # Initialize state self._manufacturer = None self._model = None self._recording_capacity = None + self._device_state_processor = device_state_processor @property def device_id(self) -> str: @@ -106,7 +106,10 @@ def model(self) -> str: @property def is_available(self) -> bool: """Return the availability of the settop box.""" - return self.state == ONLINE_RUNNING or self.state == ONLINE_STANDBY + return self._device_state.state in ( + LGHorizonRunningState.ONLINE_RUNNING, + LGHorizonRunningState.ONLINE_STANDBY, + ) @property def hashed_cpe_id(self) -> str: @@ -118,16 +121,6 @@ def device_friendly_name(self) -> str: """Return the device friendly name.""" return self._device_friendly_name - @property - def state(self) -> Optional[str]: - """Return the current state of the device.""" - return self._state - - @state.setter - def state(self, value: str) -> None: - """Set the current state of the device.""" - self._state = value - @property def device_state(self) -> LGHorizonDeviceState: """Return the current playing information.""" @@ -143,6 +136,16 @@ def recording_capacity(self, value: int) -> None: """Set the recording capacity used.""" self._recording_capacity = value + @property + def last_ui_message_timestamp(self) -> int: + """Return the last ui message timestamp.""" + return self._last_ui_message_timestamp + + @last_ui_message_timestamp.setter + def last_ui_message_timestamp(self, value: int) -> None: + """Set the last ui message timestamp.""" + self._last_ui_message_timestamp = value + async def update_channels(self, channels: Dict[str, LGHorizonChannel]): """Update the channels list.""" self._channels = channels @@ -159,7 +162,9 @@ async def register_mqtt(self) -> None: } await self._mqtt_client.publish_message(topic, json.dumps(payload)) - async def set_callback(self, change_callback: Callable) -> None: + async def set_callback( + self, change_callback: Callable[[str], Coroutine[Any, Any, Any]] + ) -> None: """Set a callback function.""" self._change_callback = change_callback # type: ignore [assignment] # Callback can be None @@ -167,14 +172,17 @@ async def handle_status_message( self, status_message: LGHorizonStatusMessage ) -> None: """Register a new settop box.""" - state = status_message.state - if self._state == state: # Access backing field for comparison + old_running_state = self.device_state.state + new_running_state = status_message.running_state + if ( + old_running_state == new_running_state + ): # Access backing field for comparison return - self.state = state # Use the setter - if self._state == ONLINE_STANDBY: # Access backing field for comparison - self._device_state.reset() - if self._change_callback: - self._change_callback(self._device_id) + await self._device_state_processor.process_state( + self.device_state, status_message + ) # Use the setter + if self._device_state.state == LGHorizonRunningState.ONLINE_STANDBY: + await self._trigger_callback() else: await self._request_settop_box_state() await self._request_settop_box_recording_capacity() @@ -183,24 +191,13 @@ async def handle_ui_status_message( self, status_message: LGHorizonUIStatusMessage ) -> None: """Handle UI status message.""" - if ( - status_message.ui_state is None - or status_message.ui_state.player_state is None - or status_message.ui_state.player_state.source is None - ): - return - match status_message.ui_state.player_state.source.source_type: - case LGHorizonSourceType.LINEAR: - await self.handle_linear_message(status_message) + await self._device_state_processor.process_ui_state( + self.device_state, status_message + ) + self.last_ui_message_timestamp = status_message.message_timestamp await self._trigger_callback() - async def handle_linear_message( - self, status_message: LGHorizonUIStatusMessage - ) -> None: - """Handle linear UI status message.""" - pass - async def update_recording_capacity(self, payload) -> None: """Updates the recording capacity.""" if "CPE.capacity" not in payload or "used" not in payload: @@ -266,76 +263,72 @@ async def update_recording_capacity(self, payload) -> None: # self._device_state.last_position_update = last_update_dt # await self._trigger_callback() - # async def update_with_app(self, source_type: str, app: LGHorizonApp) -> None: - # """Update box with app.""" - # self._device_state.source_type = source_type - # self._device_state.channel_id = None - # self._device_state.channel_title = app.title - # self._device_state.title = app.title - # self._device_state.image = app.image - # self._device_state.reset_progress() - # await self._trigger_callback() - async def _trigger_callback(self): if self._change_callback: _logger.debug("Callback called from box %s", self.device_id) - self._change_callback(self.device_id) + await self._change_callback(self.device_id) async def turn_on(self) -> None: """Turn the settop box on.""" - if self.state == ONLINE_STANDBY: + if self._device_state.state == LGHorizonRunningState.ONLINE_STANDBY: await self.send_key_to_box(MEDIA_KEY_POWER) async def turn_off(self) -> None: """Turn the settop box off.""" - if self.state == ONLINE_RUNNING: + if self._device_state.state == LGHorizonRunningState.ONLINE_RUNNING: await self.send_key_to_box(MEDIA_KEY_POWER) - self._device_state.reset() + await self._device_state.reset() async def pause(self) -> None: """Pause the given settopbox.""" - if self.state == ONLINE_RUNNING and not self._device_state.paused: + if ( + self._device_state.state == LGHorizonRunningState.ONLINE_RUNNING + and not self._device_state.paused + ): await self.send_key_to_box(MEDIA_KEY_PLAY_PAUSE) async def play(self) -> None: """Resume the settopbox.""" - if self.state == ONLINE_RUNNING and self._device_state.paused: + if ( + self._device_state.state == LGHorizonRunningState.ONLINE_RUNNING + and self._device_state.paused + ): await self.send_key_to_box(MEDIA_KEY_PLAY_PAUSE) async def stop(self) -> None: """Stop the settopbox.""" - if self.state == ONLINE_RUNNING: + if self._device_state.state == LGHorizonRunningState.ONLINE_RUNNING: await self.send_key_to_box(MEDIA_KEY_STOP) async def next_channel(self): """Select the next channel for given settop box.""" - if self.state == ONLINE_RUNNING: + if self._device_state.state == LGHorizonRunningState.ONLINE_RUNNING: await self.send_key_to_box(MEDIA_KEY_CHANNEL_UP) async def previous_channel(self) -> None: """Select the previous channel for given settop box.""" - if self.state == ONLINE_RUNNING: + if self._device_state.state == LGHorizonRunningState.ONLINE_RUNNING: await self.send_key_to_box(MEDIA_KEY_CHANNEL_DOWN) async def press_enter(self) -> None: """Press enter on the settop box.""" - if self.state == ONLINE_RUNNING: + if self._device_state.state == LGHorizonRunningState.ONLINE_RUNNING: await self.send_key_to_box(MEDIA_KEY_ENTER) async def rewind(self) -> None: """Rewind the settop box.""" - if self.state == ONLINE_RUNNING: + if self._device_state.state == LGHorizonRunningState.ONLINE_RUNNING: await self.send_key_to_box(MEDIA_KEY_REWIND) async def fast_forward(self) -> None: """Fast forward the settop box.""" - if self.state == ONLINE_RUNNING: + if self._device_state.state == LGHorizonRunningState.ONLINE_RUNNING: await self.send_key_to_box(MEDIA_KEY_FAST_FORWARD) async def record(self): """Record on the settop box.""" - if self.state == ONLINE_RUNNING: + if self._device_state.state == LGHorizonRunningState.ONLINE_RUNNING: await self.send_key_to_box(MEDIA_KEY_RECORD) async def set_channel(self, source: str) -> None: @@ -386,15 +379,6 @@ async def send_key_to_box(self, key: str) -> None: f"{self._auth.household_id}/{self.device_id}", payload ) - async def _set_unknown_channel_info(self) -> None: - """Set unknown channel info.""" - _logger.warning("Couldn't set channel. Channel info set to unknown...") - self._device_state.source_type = BOX_PLAY_STATE_CHANNEL - self._device_state.channel_id = None - self._device_state.title = "No information available" - self._device_state.image = None - self._device_state.paused = False - async def _request_settop_box_state(self) -> None: """Send mqtt message to receive state from settop box.""" topic = f"{self._auth.household_id}/{self.device_id}" diff --git a/lghorizon/models/lghorizon_device_state.py b/lghorizon/models/lghorizon_device_state.py index b80036e..64752fd 100644 --- a/lghorizon/models/lghorizon_device_state.py +++ b/lghorizon/models/lghorizon_device_state.py @@ -2,6 +2,16 @@ from datetime import datetime from typing import Optional +from enum import Enum +from .lghorizon_sources import LGHorizonSourceType + + +class LGHorizonRunningState(Enum): + """Running state of horizon box.""" + + UNKNOWN = "UNKNOWN" + ONLINE_RUNNING = "ONLINE_RUNNING" + ONLINE_STANDBY = "ONLINE_STANDBY" class LGHorizonDeviceState: @@ -10,24 +20,38 @@ class LGHorizonDeviceState: _channel_id: Optional[str] _title: Optional[str] _image: Optional[str] - _source_type: Optional[str] + _source_type: LGHorizonSourceType _paused: bool _channel_title: Optional[str] _duration: Optional[float] _position: Optional[float] _last_position_update: Optional[datetime] + _state: LGHorizonRunningState + _speed: Optional[int] def __init__(self) -> None: """Initialize the playing info.""" self._channel_id = None self._title = None self._image = None - self._source_type = None + self._source_type = LGHorizonSourceType.UNKNOWN self._paused = False self._channel_title = None self._duration = None self._position = None self._last_position_update = None + self._state = LGHorizonRunningState.UNKNOWN + self._speed = None + + @property + def state(self) -> LGHorizonRunningState: + """Return the channel ID.""" + return self._state + + @state.setter + def state(self, value: LGHorizonRunningState) -> None: + """Set the channel ID.""" + self._state = value @property def channel_id(self) -> Optional[str]: @@ -60,24 +84,21 @@ def image(self, value: Optional[str]) -> None: self._image = value @property - def source_type(self) -> Optional[str]: + def source_type(self) -> LGHorizonSourceType: """Return the source type.""" return self._source_type @source_type.setter - def source_type(self, value: Optional[str]) -> None: + def source_type(self, value: LGHorizonSourceType) -> None: """Set the source type.""" self._source_type = value @property def paused(self) -> bool: """Return if the media is paused.""" - return self._paused - - @paused.setter - def paused(self, value: bool) -> None: - """Set the paused state.""" - self._paused = value + if self.speed is None: + return False + return self.speed == 0 @property def channel_title(self) -> Optional[str]: @@ -119,18 +140,28 @@ def last_position_update(self, value: Optional[datetime]) -> None: """Set the last position update time.""" self._last_position_update = value - def reset_progress(self) -> None: + async def reset_progress(self) -> None: """Reset the progress-related attributes.""" self.last_position_update = None self.duration = None self.position = None - def reset(self) -> None: + @property + def speed(self) -> Optional[int]: + """Return the speed.""" + return self._speed + + @speed.setter + def speed(self, value: int | None) -> None: + """Set the channel ID.""" + self._speed = value + + async def reset(self) -> None: """Reset all playing information.""" self.channel_id = None self.title = None self.image = None - self.source_type = None - self.paused = False + self.source_type = LGHorizonSourceType.UNKNOWN + self.speed = None self.channel_title = None - self.reset_progress() + await self.reset_progress() diff --git a/lghorizon/models/lghorizon_events.py b/lghorizon/models/lghorizon_events.py new file mode 100644 index 0000000..412380c --- /dev/null +++ b/lghorizon/models/lghorizon_events.py @@ -0,0 +1,86 @@ +"""LG Horizon message models.""" + +from typing import Optional +from enum import Enum + + +class LGHorizonReplayEvent: + """LGhorizon replay event.""" + + def __init__(self, raw_json: dict): + """Initialize an LG Horizon replay event.""" + self._raw_json = raw_json + + @property + def episode_number(self) -> Optional[int]: + """Return the episode number.""" + return self._raw_json.get("episodeNumber") + + @property + def channel_id(self) -> str: + """Return the channel ID.""" + return self._raw_json["channelId"] + + @property + def event_id(self) -> str: + """Return the event ID.""" + return self._raw_json["eventId"] + + @property + def season_number(self) -> Optional[int]: + """Return the season number.""" + return self._raw_json.get("seasonNumber") + + @property + def title(self) -> str: + """Return the title of the event.""" + return self._raw_json["title"] + + @property + def episode_name(self) -> Optional[str]: + """Return the episode name.""" + return self._raw_json.get("episodeName") + + def __repr__(self) -> str: + """Return a string representation of the replay event.""" + return f"LGHorizonReplayEvent(title='{self.title}', channel_id='{self.channel_id}', event_id='{self.event_id}')" + + +class LGHorizonVODType(Enum): + """Enumeration of LG Horizon VOD types.""" + + MOVIE = "MOVIE" + EPISODE = "EPISODE" + UNKNOWN = "UNKNOWN" + + +class LGHorizonVOD: + """LGHorizon video on demand.""" + + def __init__(self, vod_json) -> None: + self._vod_json = vod_json + + @property + def vod_type(self) -> LGHorizonVODType: + """Return the ID of the VOD.""" + return LGHorizonVODType[self._vod_json.get("type", "unknown").upper()] + + @property + def id(self) -> str: + """Return the ID of the VOD.""" + return self._vod_json["id"] + + @property + def title(self) -> str: + """Return the title of the VOD.""" + match self.vod_type: + case LGHorizonVODType.MOVIE: + return self._vod_json["seriesTitle"] + case LGHorizonVODType.EPISODE: + return self._vod_json["seriesTitle"] + return "unknown" + + @property + def duration(self) -> float: + """Return the duration of the VOD.""" + return self._vod_json["duration"] diff --git a/lghorizon/models/lghorizon_message.py b/lghorizon/models/lghorizon_message.py index a2eebf5..03e8120 100644 --- a/lghorizon/models/lghorizon_message.py +++ b/lghorizon/models/lghorizon_message.py @@ -5,6 +5,7 @@ from enum import Enum from .lghorizon_ui_status import LGHorizonUIState +from .lghorizon_device_state import LGHorizonRunningState class LGHorizonMessageType(Enum): @@ -62,9 +63,9 @@ def source(self) -> str: return self._payload.get("source", "unknown") @property - def state(self) -> str: + def running_state(self) -> LGHorizonRunningState: """Return the device ID from the payload, if available.""" - return self._payload.get("state", "unknown") + return LGHorizonRunningState[self._payload.get("state", "unknown").upper()] class LGHorizonUIStatusMessage(LGHorizonMessage): diff --git a/lghorizon/models/lghorizon_mqtt_client.py b/lghorizon/models/lghorizon_mqtt_client.py index c4105cf..657f396 100644 --- a/lghorizon/models/lghorizon_mqtt_client.py +++ b/lghorizon/models/lghorizon_mqtt_client.py @@ -3,7 +3,7 @@ import json import logging import asyncio -from typing import Callable +from typing import Callable, Any, Coroutine import paho.mqtt.client as mqtt @@ -21,8 +21,8 @@ class LGHorizonMqttClient: _auth: LGHorizonAuth _mqtt_token: str = "" client_id: str = "" - _on_connected_callback: Callable - _on_message_callback: Callable + _on_connected_callback: Callable[[], Coroutine[Any, Any, Any]] + _on_message_callback: Callable[[dict, str], Coroutine[Any, Any, Any]] @property def is_connected(self): @@ -32,8 +32,8 @@ def is_connected(self): def __init__( self, auth: LGHorizonAuth, - on_connected_callback: Callable, - on_message_callback: Callable, + on_connected_callback: Callable[[], Coroutine[Any, Any, Any]], + on_message_callback: Callable[[dict, str], Coroutine[Any, Any, Any]], ): """Initialize the MQTT client.""" self._auth = auth @@ -45,8 +45,8 @@ def __init__( async def create( cls, auth: LGHorizonAuth, - on_connected_callback: Callable, - on_message_callback: Callable, + on_connected_callback: Callable[[], Coroutine[Any, Any, Any]], + on_message_callback: Callable[[dict, str], Coroutine[Any, Any, Any]], ): """Create the MQTT client.""" instance = cls(auth, on_connected_callback, on_message_callback) diff --git a/lghorizon/models/lghorizon_sources.py b/lghorizon/models/lghorizon_sources.py index 7fa59dc..8daf813 100644 --- a/lghorizon/models/lghorizon_sources.py +++ b/lghorizon/models/lghorizon_sources.py @@ -8,6 +8,10 @@ class LGHorizonSourceType(Enum): """Enumeration of LG Horizon message types.""" LINEAR = "linear" + REVIEWBUFFER = "reviewBuffer" + NDVR = "nDVR" + REPLAY = "replay" + VOD = "VOD" UNKNOWN = "unknown" @@ -42,6 +46,60 @@ def source_type(self) -> LGHorizonSourceType: return LGHorizonSourceType.LINEAR +class LGHorizonReviewBufferSource(LGHorizonSource): + """Represent the ReviewBuffer Source of an LG Horizon device.""" + + @property + def channel_id(self) -> str: + """Return the source type.""" + return self._raw_json.get("channelId", "") + + @property + def event_id(self) -> str: + """Return the event ID.""" + return self._raw_json.get("eventId", "") + + @property + def source_type(self) -> LGHorizonSourceType: + return LGHorizonSourceType.REVIEWBUFFER + + +class LGHorizonNDVRSource(LGHorizonSource): + """Represent the ReviewBuffer Source of an LG Horizon device.""" + + @property + def event_id(self) -> str: + """Return the event ID.""" + return self._raw_json.get("eventId", "") + + @property + def source_type(self) -> LGHorizonSourceType: + return LGHorizonSourceType.NDVR + + +class LGHorizonVODSource(LGHorizonSource): + """Represent the VOD Source of an LG Horizon device.""" + + @property + def title_id(self) -> str: + """Return the title ID.""" + return self._raw_json.get("titleId", "") + + @property + def start_intro_time(self) -> int: + """Return the start intro time.""" + return self._raw_json.get("startIntroTime", 0) + + @property + def end_intro_time(self) -> int: + """Return the end intro time.""" + return self._raw_json.get("endIntroTime", 0) + + @property + def source_type(self) -> LGHorizonSourceType: + return LGHorizonSourceType.VOD + + class LGHorizonUnknownSource(LGHorizonSource): """Represent the Linear Source of an LG Horizon device.""" diff --git a/lghorizon/models/lghorizon_ui_status.py b/lghorizon/models/lghorizon_ui_status.py index b879342..5775ea8 100644 --- a/lghorizon/models/lghorizon_ui_status.py +++ b/lghorizon/models/lghorizon_ui_status.py @@ -1,13 +1,25 @@ """LG Horizon UI Status Model.""" +from enum import Enum from .lghorizon_sources import ( LGHorizonSource, LGHorizonLinearSource, + LGHorizonVODSource, + LGHorizonNDVRSource, + LGHorizonReviewBufferSource, LGHorizonUnknownSource, LGHorizonSourceType, ) +class LGHorizonUIStateType(Enum): + """Enumeration of LG Horizon UI State types.""" + + MAINUI = "mainUI" + APPS = "apps" + UNKNOWN = "unknown" + + class LGHorizonPlayerState: """Represent the Player State of an LG Horizon device.""" @@ -16,9 +28,9 @@ def __init__(self, raw_json: dict) -> None: self._raw_json = raw_json @property - def source_type(self) -> str: + def source_type(self) -> LGHorizonSourceType: """Return the source type.""" - return self._raw_json.get("sourceType", "") + return LGHorizonSourceType[self._raw_json.get("sourceType", "unknown").upper()] @property def speed(self) -> int: @@ -36,27 +48,52 @@ def last_speed_change_time( def source(self) -> LGHorizonSource | None: # Added None to the return type """Return the last speed change time.""" if "source" in self._raw_json: - source_type = LGHorizonSourceType[self.source_type.upper()] - match source_type: + match self.source_type: case LGHorizonSourceType.LINEAR: return LGHorizonLinearSource(self._raw_json["source"]) + case LGHorizonSourceType.VOD: + return LGHorizonVODSource(self._raw_json["source"]) return LGHorizonUnknownSource(self._raw_json["source"]) +class LGHorizonAppsState: + """Represent the State of an LG Horizon device.""" + + def __init__(self, raw_json: dict) -> None: + """Initialize the Apps state.""" + self._raw_json = raw_json + + @property + def id(self) -> str: + """Return the id.""" + return self._raw_json.get("id", "") + + @property + def app_name(self) -> str: + """Return the app name.""" + return self._raw_json.get("appName", "") + + @property + def logo_path(self) -> str: + """Return the logo path.""" + return self._raw_json.get("logoPath", "") + + class LGHorizonUIState: """Represent the State of an LG Horizon device.""" _player_state: LGHorizonPlayerState | None = None + _apps_state: LGHorizonAppsState | None = None def __init__(self, raw_json: dict) -> None: """Initialize the State.""" self._raw_json = raw_json @property - def ui_status(self) -> str: + def ui_status(self) -> LGHorizonUIStateType: """Return the UI status dictionary.""" - return self._raw_json.get("uiStatus", "") + return LGHorizonUIStateType[self._raw_json.get("uiStatus", "unknown").upper()] @property def player_state( @@ -69,3 +106,15 @@ def player_state( self._raw_json["playerState"] ) # Access directly as existence is checked return self._player_state + + @property + def apps_state( + self, + ) -> LGHorizonAppsState | None: # Added None to the return type + """Return the UI status dictionary.""" + # Check if _player_state is None and if "playerState" key exists in raw_json + if self._apps_state is None and "appsState" in self._raw_json: + self._apps_state = LGHorizonAppsState( + self._raw_json["appsState"] + ) # Access directly as existence is checked + return self._apps_state diff --git a/main.py b/main.py index 3280235..10bd5bf 100644 --- a/main.py +++ b/main.py @@ -9,6 +9,8 @@ from lghorizon import LGHorizonApi from lghorizon.models import LGHorizonAuth +_LOGGER = logging.getLogger(__name__) + async def main(): """main loop""" @@ -18,6 +20,7 @@ async def main(): filename="lghorizon.log", filemode="w", ) + logging.info("Starting LG Horizon test script") with open("secrets.json", encoding="utf-8") as f: secrets = json.load(f) @@ -28,8 +31,19 @@ async def main(): async with aiohttp.ClientSession() as session: auth = LGHorizonAuth(session, country, username=username, password=password) api = LGHorizonApi(auth) + try: await api.initialize() + devices = await api.get_devices() + + async def device_callback(device_id: str): + device = devices[device_id] + print( + f"Device {device.device_id} state changed. Status:\n\nName: {device.device_friendly_name}\nState: {device.device_state.state.value}\nChannel: {device.device_state.channel_title}\nTitle: {device.device_state.title}\n\n", + ) + + for device in devices.values(): + await device.set_callback(device_callback) while True: await asyncio.sleep(1) except KeyboardInterrupt: @@ -39,8 +53,5 @@ async def main(): finally: await api.disconnect() - def device_callback(self, device_id: str): - print(f"Device {device_id} state changed.") - asyncio.run(main()) From 5a307a6cbd1b93e3d3bd4d695a5947d81a07bcdb Mon Sep 17 00:00:00 2001 From: Rudolf Offereins Date: Thu, 29 Jan 2026 21:38:18 +0000 Subject: [PATCH 05/16] Small refactor, seperate source for replayTV --- .vscode/launch.json | 4 +- lghorizon/device_state_processor.py | 65 ++++++++++++++++++---- lghorizon/models/lghorizon_device.py | 6 +- lghorizon/models/lghorizon_device_state.py | 16 +++--- lghorizon/models/lghorizon_events.py | 29 ++++++++-- lghorizon/models/lghorizon_sources.py | 14 +++++ lghorizon/models/lghorizon_ui_status.py | 3 + main.py | 36 ++++++++++-- 8 files changed, 139 insertions(+), 34 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 3ff6055..8ec29e5 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -5,10 +5,10 @@ "version": "0.2.0", "configurations": [ { - "name": "Python Debugger: Current File", + "name": "Python Debugger: Debug LGHorizon", "type": "debugpy", "request": "launch", - "program": "${file}", + "program": "main.py", "console": "integratedTerminal" } ] diff --git a/lghorizon/device_state_processor.py b/lghorizon/device_state_processor.py index 62c2e70..e969fa0 100644 --- a/lghorizon/device_state_processor.py +++ b/lghorizon/device_state_processor.py @@ -4,7 +4,7 @@ import json import urllib.parse -from typing import cast, Dict +from typing import cast, Dict, Optional from .models.lghorizon_device_state import LGHorizonDeviceState, LGHorizonRunningState from .models.lghorizon_message import LGHorizonStatusMessage, LGHorizonUIStatusMessage @@ -12,6 +12,7 @@ LGHorizonSourceType, LGHorizonLinearSource, LGHorizonVODSource, + LGHorizonReplaySource, LGHorizonReviewBufferSource, LGHorizonNDVRSource, ) @@ -96,10 +97,14 @@ async def _process_main_ui_state( case ( LGHorizonSourceType.LINEAR | LGHorizonSourceType.REVIEWBUFFER - | LGHorizonSourceType.REPLAY | LGHorizonSourceType.NDVR ): - await self._process_channel_state(device_state, player_state) + await self._process_linear_or_reviewbuffer_state( + device_state, player_state + ) + case LGHorizonSourceType.REPLAY: + await self._process_replay_state(device_state, player_state) + case LGHorizonSourceType.VOD: await self._process_vod_state(device_state, player_state) @@ -109,10 +114,10 @@ async def _process_apps_state( apps_state: LGHorizonAppsState, ) -> None: device_state.channel_id = apps_state.id - device_state.channel_title = apps_state.app_name + device_state.title = apps_state.app_name device_state.image = apps_state.logo_path - async def _process_channel_state( + async def _process_linear_or_reviewbuffer_state( self, device_state: LGHorizonDeviceState, player_state: LGHorizonPlayerState, @@ -134,10 +139,10 @@ async def _process_channel_state( channel = self._channels[replay_event.channel_id] device_state.source_type = source.source_type device_state.channel_id = channel.channel_number - device_state.channel_title = channel.title - device_state.title = replay_event.title + device_state.title = channel.title + device_state.sub_title = replay_event.title if replay_event.episode_name: - device_state.title += f": {replay_event.episode_name}" + device_state.sub_title += f": {replay_event.episode_name}" # Add random number to url to force refresh join_param = "?" @@ -149,6 +154,35 @@ async def _process_channel_state( device_state.image = image_url await device_state.reset_progress() + async def _process_replay_state( + self, + device_state: LGHorizonDeviceState, + player_state: LGHorizonPlayerState, + ) -> None: + """Process the device state based on the UI status message.""" + if player_state.source is None: + return + player_state.source.__class__ = LGHorizonReplaySource + source = cast(LGHorizonReplaySource, player_state.source) + service_config = await self._auth.get_service_config() + service_url = await service_config.get_service_url("linearService") + service_path = f"/v2/replayEvent/{source.event_id}?returnLinearContent=true&language={self._auth.country_code}" + + event_json = await self._auth.request( + service_url, + service_path, + ) + replay_event = LGHorizonReplayEvent(event_json) + device_state.source_type = source.source_type + device_state.channel_id = None + device_state.title = replay_event.title + if replay_event.full_episode_title: + device_state.sub_title = replay_event.full_episode_title + + # Add random number to url to force refresh + device_state.image = await self._get_intent_image_url(replay_event.event_id) + await device_state.reset_progress() + async def _process_vod_state( self, device_state: LGHorizonDeviceState, @@ -168,13 +202,20 @@ async def _process_vod_state( service_path, ) vod = LGHorizonVOD(vod_json) - device_state.channel_title = vod.title + device_state.title = vod.title + device_state.title = vod.episode_title device_state.duration = vod.duration + device_state.image = await self._get_intent_image_url(vod.id) + await device_state.reset_progress() + + async def _get_intent_image_url(self, id: str) -> Optional[str]: + """Get intent image url.""" + service_config = await self._auth.get_service_config() intents_url = await service_config.get_service_url("imageService") intents_path = "/intent" body_json = [ { - "id": vod.id, + "id": id, "intents": ["detailedBackground", "posterTile"], } ] @@ -190,5 +231,5 @@ async def _process_vod_state( and len(intents_result[0]["intents"]) > 0 and intents_result[0]["intents"][0]["url"] ): - device_state.image = intents_result[0]["intents"][0]["url"] - await device_state.reset_progress() + return intents_result[0]["intents"][0]["url"] + return None diff --git a/lghorizon/models/lghorizon_device.py b/lghorizon/models/lghorizon_device.py index 5bf380e..dd0e828 100644 --- a/lghorizon/models/lghorizon_device.py +++ b/lghorizon/models/lghorizon_device.py @@ -181,10 +181,10 @@ async def handle_status_message( await self._device_state_processor.process_state( self.device_state, status_message ) # Use the setter - if self._device_state.state == LGHorizonRunningState.ONLINE_STANDBY: - await self._trigger_callback() - else: + if self._device_state.state == LGHorizonRunningState.ONLINE_RUNNING: await self._request_settop_box_state() + + await self._trigger_callback() await self._request_settop_box_recording_capacity() async def handle_ui_status_message( diff --git a/lghorizon/models/lghorizon_device_state.py b/lghorizon/models/lghorizon_device_state.py index 64752fd..5e7fa33 100644 --- a/lghorizon/models/lghorizon_device_state.py +++ b/lghorizon/models/lghorizon_device_state.py @@ -22,7 +22,7 @@ class LGHorizonDeviceState: _image: Optional[str] _source_type: LGHorizonSourceType _paused: bool - _channel_title: Optional[str] + _sub_title: Optional[str] _duration: Optional[float] _position: Optional[float] _last_position_update: Optional[datetime] @@ -36,7 +36,7 @@ def __init__(self) -> None: self._image = None self._source_type = LGHorizonSourceType.UNKNOWN self._paused = False - self._channel_title = None + self.sub_title = None self._duration = None self._position = None self._last_position_update = None @@ -101,14 +101,14 @@ def paused(self) -> bool: return self.speed == 0 @property - def channel_title(self) -> Optional[str]: + def sub_title(self) -> Optional[str]: """Return the channel title.""" - return self._channel_title + return self._sub_title - @channel_title.setter - def channel_title(self, value: Optional[str]) -> None: + @sub_title.setter + def sub_title(self, value: Optional[str]) -> None: """Set the channel title.""" - self._channel_title = value + self._sub_title = value @property def duration(self) -> Optional[float]: @@ -160,8 +160,8 @@ async def reset(self) -> None: """Reset all playing information.""" self.channel_id = None self.title = None + self.sub_title = None self.image = None self.source_type = LGHorizonSourceType.UNKNOWN self.speed = None - self.channel_title = None await self.reset_progress() diff --git a/lghorizon/models/lghorizon_events.py b/lghorizon/models/lghorizon_events.py index 412380c..31de2db 100644 --- a/lghorizon/models/lghorizon_events.py +++ b/lghorizon/models/lghorizon_events.py @@ -39,7 +39,18 @@ def title(self) -> str: @property def episode_name(self) -> Optional[str]: """Return the episode name.""" - return self._raw_json.get("episodeName") + return self._raw_json.get("episodeName", None) + + @property + def full_episode_title(self) -> Optional[str]: + """Return the full episode title.""" + + if not self.season_number and not self.episode_number: + return None + full_title = f"""S{self.season_number:02d}E{self.episode_number:02d}""" + if self.episode_name: + full_title += f": {self.episode_name}" + return full_title def __repr__(self) -> str: """Return a string representation of the replay event.""" @@ -49,7 +60,7 @@ def __repr__(self) -> str: class LGHorizonVODType(Enum): """Enumeration of LG Horizon VOD types.""" - MOVIE = "MOVIE" + ASSET = "ASSET" EPISODE = "EPISODE" UNKNOWN = "UNKNOWN" @@ -70,12 +81,22 @@ def id(self) -> str: """Return the ID of the VOD.""" return self._vod_json["id"] + @property + def episode_title(self) -> str: + """Return the ID of the VOD.""" + match self.vod_type: + case LGHorizonVODType.ASSET: + return "" + case LGHorizonVODType.EPISODE: + return f"S{self._vod_json['season'].zfill(2)}E{self._vod_json['episode'].zfill(2)}: {self._vod_json['title']}" + return "" + @property def title(self) -> str: """Return the title of the VOD.""" match self.vod_type: - case LGHorizonVODType.MOVIE: - return self._vod_json["seriesTitle"] + case LGHorizonVODType.ASSET: + return self._vod_json["title"] case LGHorizonVODType.EPISODE: return self._vod_json["seriesTitle"] return "unknown" diff --git a/lghorizon/models/lghorizon_sources.py b/lghorizon/models/lghorizon_sources.py index 8daf813..5f9c900 100644 --- a/lghorizon/models/lghorizon_sources.py +++ b/lghorizon/models/lghorizon_sources.py @@ -100,6 +100,20 @@ def source_type(self) -> LGHorizonSourceType: return LGHorizonSourceType.VOD +class LGHorizonReplaySource(LGHorizonSource): + """Represent the VOD Source of an LG Horizon device.""" + + @property + def event_id(self) -> str: + """Return the title ID.""" + return self._raw_json.get("eventId", "") + + @property + def source_type(self) -> LGHorizonSourceType: + """Return the source type.""" + return LGHorizonSourceType.REPLAY + + class LGHorizonUnknownSource(LGHorizonSource): """Represent the Linear Source of an LG Horizon device.""" diff --git a/lghorizon/models/lghorizon_ui_status.py b/lghorizon/models/lghorizon_ui_status.py index 5775ea8..164228f 100644 --- a/lghorizon/models/lghorizon_ui_status.py +++ b/lghorizon/models/lghorizon_ui_status.py @@ -5,6 +5,7 @@ LGHorizonSource, LGHorizonLinearSource, LGHorizonVODSource, + LGHorizonReplaySource, LGHorizonNDVRSource, LGHorizonReviewBufferSource, LGHorizonUnknownSource, @@ -53,6 +54,8 @@ def source(self) -> LGHorizonSource | None: # Added None to the return type return LGHorizonLinearSource(self._raw_json["source"]) case LGHorizonSourceType.VOD: return LGHorizonVODSource(self._raw_json["source"]) + case LGHorizonSourceType.REPLAY: + return LGHorizonReplaySource(self._raw_json["source"]) return LGHorizonUnknownSource(self._raw_json["source"]) diff --git a/main.py b/main.py index 10bd5bf..82c314c 100644 --- a/main.py +++ b/main.py @@ -3,12 +3,27 @@ import asyncio import json import logging +import sys # Import sys for stdin import aiohttp from lghorizon import LGHorizonApi from lghorizon.models import LGHorizonAuth +# Define an asyncio Event to signal shutdown +shutdown_event = asyncio.Event() + + +async def read_input_and_signal_shutdown(): + """Reads a line from stdin and sets the shutdown event.""" + _LOGGER.info("Press Enter to gracefully shut down...") + # run_in_executor is used to run blocking I/O operations in a separate thread + # so it doesn't block the asyncio event loop. + await asyncio.get_event_loop().run_in_executor(None, sys.stdin.readline) + _LOGGER.info("Enter pressed, signaling shutdown.") + shutdown_event.set() + + _LOGGER = logging.getLogger(__name__) @@ -32,6 +47,9 @@ async def main(): auth = LGHorizonAuth(session, country, username=username, password=password) api = LGHorizonApi(auth) + # Start the input reader task + input_task = asyncio.create_task(read_input_and_signal_shutdown()) + try: await api.initialize() devices = await api.get_devices() @@ -39,19 +57,27 @@ async def main(): async def device_callback(device_id: str): device = devices[device_id] print( - f"Device {device.device_id} state changed. Status:\n\nName: {device.device_friendly_name}\nState: {device.device_state.state.value}\nChannel: {device.device_state.channel_title}\nTitle: {device.device_state.title}\n\n", + f"Device {device.device_id} state changed. Status:\n\nName: {device.device_friendly_name}\nState: {device.device_state.state.value}\nTitle: {device.device_state.title}\nSubtitle: {device.device_state.sub_title}\nSource type: {device.device_state.source_type.value}\n\n", ) for device in devices.values(): await device.set_callback(device_callback) - while True: - await asyncio.sleep(1) - except KeyboardInterrupt: - print("\nShutting down...") + + # Wait until the shutdown event is set + await shutdown_event.wait() + except Exception as e: print(f"An error occurred: {e}") + _LOGGER.error("An error occurred: %s", e, exc_info=True) finally: + _LOGGER.info("Shutting down API and cancelling input task.") + input_task.cancel() + try: + await input_task # Await to let it clean up if it was cancelled + except asyncio.CancelledError: + pass # Expected if cancelled await api.disconnect() + _LOGGER.info("Shutdown complete.") asyncio.run(main()) From 5628d9ea7f2b9db54d4888d261f9064e12d2324f Mon Sep 17 00:00:00 2001 From: Rudolf Offereins Date: Fri, 30 Jan 2026 23:13:36 +0000 Subject: [PATCH 06/16] Add recordings --- lghorizon/device_state_processor.py | 112 ++++++++-- lghorizon/lghorizonapi.py | 44 +++- lghorizon/models/lghorizon_auth.py | 6 + lghorizon/models/lghorizon_customer.py | 18 +- lghorizon/models/lghorizon_device.py | 62 +----- lghorizon/models/lghorizon_device_state.py | 13 ++ lghorizon/models/lghorizon_events.py | 40 ++-- lghorizon/models/lghorizon_profile.py | 32 ++- lghorizon/models/lghorizon_recordings.py | 243 +++++++++++++++++++++ lghorizon/models/lghorizon_sources.py | 11 +- lghorizon/models/lghorizon_ui_status.py | 4 + lghorizon/recording_factory.py | 41 ++++ main.py | 25 ++- 13 files changed, 532 insertions(+), 119 deletions(-) create mode 100644 lghorizon/models/lghorizon_recordings.py create mode 100644 lghorizon/recording_factory.py diff --git a/lghorizon/device_state_processor.py b/lghorizon/device_state_processor.py index e969fa0..b76dbea 100644 --- a/lghorizon/device_state_processor.py +++ b/lghorizon/device_state_processor.py @@ -13,11 +13,16 @@ LGHorizonLinearSource, LGHorizonVODSource, LGHorizonReplaySource, - LGHorizonReviewBufferSource, LGHorizonNDVRSource, + LGHorizonReviewBufferSource, ) from .models.lghorizon_auth import LGHorizonAuth -from .models.lghorizon_events import LGHorizonReplayEvent, LGHorizonVOD +from .models.lghorizon_events import ( + LGHorizonReplayEvent, + LGHorizonVOD, +) + +from .models.lghorizon_recordings import LGHorizonRecordingSingle from .models.lghorizon_channel import LGHorizonChannel from .models.lghorizon_ui_status import ( LGHorizonUIStateType, @@ -60,6 +65,7 @@ async def process_ui_state( ui_status_message.ui_state is None or device_state.state == LGHorizonRunningState.ONLINE_STANDBY ): + await device_state.reset() return if ui_status_message.ui_state is None: @@ -91,22 +97,19 @@ async def _process_main_ui_state( ) -> None: if player_state is None: return - + await device_state.reset() device_state.source_type = player_state.source_type match player_state.source_type: - case ( - LGHorizonSourceType.LINEAR - | LGHorizonSourceType.REVIEWBUFFER - | LGHorizonSourceType.NDVR - ): - await self._process_linear_or_reviewbuffer_state( - device_state, player_state - ) + case LGHorizonSourceType.LINEAR: + await self._process_linear_state(device_state, player_state) + case LGHorizonSourceType.REVIEWBUFFER: + await self._process_reviewbuffer_state(device_state, player_state) case LGHorizonSourceType.REPLAY: await self._process_replay_state(device_state, player_state) - case LGHorizonSourceType.VOD: await self._process_vod_state(device_state, player_state) + case LGHorizonSourceType.NDVR: + await self._process_ndvr_state(device_state, player_state) async def _process_apps_state( self, @@ -117,7 +120,7 @@ async def _process_apps_state( device_state.title = apps_state.app_name device_state.image = apps_state.logo_path - async def _process_linear_or_reviewbuffer_state( + async def _process_linear_state( self, device_state: LGHorizonDeviceState, player_state: LGHorizonPlayerState, @@ -129,7 +132,45 @@ async def _process_linear_or_reviewbuffer_state( source = cast(LGHorizonLinearSource, player_state.source) service_config = await self._auth.get_service_config() service_url = await service_config.get_service_url("linearService") - service_path = f"/v2/replayEvent/{source.event_id}?returnLinearContent=true&language={self._auth.country_code}" + lang = await self._customer.get_profile_lang(self._profile_id) + service_path = f"/v2/replayEvent/{source.event_id}?returnLinearContent=true&language={lang}" + + event_json = await self._auth.request( + service_url, + service_path, + ) + replay_event = LGHorizonReplayEvent(event_json) + channel = self._channels[replay_event.channel_id] + device_state.source_type = source.source_type + device_state.channel_id = channel.channel_number + device_state.channel_name = channel.title + device_state.title = replay_event.title + device_state.sub_title = replay_event.full_episode_title + + # Add random number to url to force refresh + join_param = "?" + if join_param in channel.stream_image: + join_param = "&" + image_url = ( + f"{channel.stream_image}{join_param}{str(random.randrange(1000000))}" + ) + device_state.image = image_url + await device_state.reset_progress() + + async def _process_reviewbuffer_state( + self, + device_state: LGHorizonDeviceState, + player_state: LGHorizonPlayerState, + ) -> None: + """Process the device state based on the UI status message.""" + if player_state.source is None: + return + player_state.source.__class__ = LGHorizonReviewBufferSource + source = cast(LGHorizonReviewBufferSource, player_state.source) + service_config = await self._auth.get_service_config() + service_url = await service_config.get_service_url("linearService") + lang = await self._customer.get_profile_lang(self._profile_id) + service_path = f"/v2/replayEvent/{source.event_id}?returnLinearContent=true&language={lang}" event_json = await self._auth.request( service_url, @@ -139,10 +180,9 @@ async def _process_linear_or_reviewbuffer_state( channel = self._channels[replay_event.channel_id] device_state.source_type = source.source_type device_state.channel_id = channel.channel_number - device_state.title = channel.title - device_state.sub_title = replay_event.title - if replay_event.episode_name: - device_state.sub_title += f": {replay_event.episode_name}" + device_state.channel_name = channel.title + device_state.title = replay_event.title + device_state.sub_title = replay_event.full_episode_title # Add random number to url to force refresh join_param = "?" @@ -166,7 +206,8 @@ async def _process_replay_state( source = cast(LGHorizonReplaySource, player_state.source) service_config = await self._auth.get_service_config() service_url = await service_config.get_service_url("linearService") - service_path = f"/v2/replayEvent/{source.event_id}?returnLinearContent=true&language={self._auth.country_code}" + lang = await self._customer.get_profile_lang(self._profile_id) + service_path = f"/v2/replayEvent/{source.event_id}?returnLinearContent=true&language={lang}" event_json = await self._auth.request( service_url, @@ -195,7 +236,8 @@ async def _process_vod_state( source = cast(LGHorizonVODSource, player_state.source) service_config = await self._auth.get_service_config() service_url = await service_config.get_service_url("vodService") - service_path = f"/v2/detailscreen/{source.title_id}?language={self._customer.country_id}&profileId={self._profile_id}&cityId={self._customer.city_id}" + lang = await self._customer.get_profile_lang(self._profile_id) + service_path = f"/v2/detailscreen/{source.title_id}?language={lang}&profileId={self._profile_id}&cityId={self._customer.city_id}" vod_json = await self._auth.request( service_url, @@ -203,19 +245,43 @@ async def _process_vod_state( ) vod = LGHorizonVOD(vod_json) device_state.title = vod.title - device_state.title = vod.episode_title + device_state.sub_title = vod.full_episode_title device_state.duration = vod.duration device_state.image = await self._get_intent_image_url(vod.id) await device_state.reset_progress() - async def _get_intent_image_url(self, id: str) -> Optional[str]: + async def _process_ndvr_state( + self, device_state: LGHorizonDeviceState, player_state: LGHorizonPlayerState + ) -> None: + """Process the device state based on the UI status message.""" + if player_state.source is None: + return + player_state.source.__class__ = LGHorizonNDVRSource + source = cast(LGHorizonNDVRSource, player_state.source) + service_config = await self._auth.get_service_config() + service_url = await service_config.get_service_url("recordingService") + lang = await self._customer.get_profile_lang(self._profile_id) + service_path = f"/customers/{self._customer.customer_id}/details/single/{source.recording_id}?profileId={self._profile_id}&language={lang}" + recording_json = await self._auth.request( + service_url, + service_path, + ) + recording = LGHorizonRecordingSingle(recording_json) + device_state.title = recording.title + device_state.sub_title = recording.full_episode_title + device_state.channel_id = recording.channel_id + if recording.channel_id: + channel = self._channels[recording.channel_id] + device_state.channel_name = channel.title + + async def _get_intent_image_url(self, intent_id: str) -> Optional[str]: """Get intent image url.""" service_config = await self._auth.get_service_config() intents_url = await service_config.get_service_url("imageService") intents_path = "/intent" body_json = [ { - "id": id, + "id": intent_id, "intents": ["detailedBackground", "posterTile"], } ] diff --git a/lghorizon/lghorizonapi.py b/lghorizon/lghorizonapi.py index a7e82cd..15e38c6 100644 --- a/lghorizon/lghorizonapi.py +++ b/lghorizon/lghorizonapi.py @@ -15,6 +15,8 @@ from .message_factory import LGHorizonMessageFactory from .models.lghorizon_message import LGHorizonStatusMessage, LGHorizonUIStatusMessage from .models.lghorizon_device_state import LGHorizonRunningState +from .models.lghorizon_recordings import LGHorizonRecordingList, LGHorizonRecordingQuota +from .recording_factory import LGHorizonRecordingFactory from .device_state_processor import LGHorizonDeviceStateProcessor @@ -35,6 +37,7 @@ class LGHorizonApi: _devices: Dict[str, LGHorizonDevice] = {} _message_factory: LGHorizonMessageFactory = LGHorizonMessageFactory() _device_state_processor: LGHorizonDeviceStateProcessor | None + _recording_factory: LGHorizonRecordingFactory = LGHorizonRecordingFactory() def __init__(self, auth: LGHorizonAuth, profile_id: str = "") -> None: """Initialize LG Horizon API client.""" @@ -123,7 +126,6 @@ async def _register_devices(self) -> None: self.auth, channels, ) - await device.register_mqtt() self._devices[device.device_id] = device async def disconnect(self) -> None: @@ -212,10 +214,10 @@ async def _refresh_channels(self): """Retrieve channels.""" _LOGGER.debug("Retrieving channels...") service_url = await self._service_config.get_service_url("linearService") - + lang = await self._customer.get_profile_lang(self._profile_id) channels_json = await self.auth.request( service_url, - f"/v2/channels?cityId={self._customer.city_id}&language={self._customer.country_id}&productClass=Orion-DASH", + f"/v2/channels?cityId={self._customer.city_id}&language={lang}&productClass=Orion-DASH", ) for channel_json in channels_json: channel = LGHorizonChannel(channel_json) @@ -228,5 +230,41 @@ async def _refresh_channels(self): self._channels[channel.id] = channel + async def get_all_recordings(self) -> LGHorizonRecordingList: + """Retrieve all recordings.""" + _LOGGER.debug("Retrieving recordings...") + service_url = await self._service_config.get_service_url("recordingService") + lang = await self._customer.get_profile_lang(self._profile_id) + recordings_json = await self.auth.request( + service_url, + f"/customers/{self.auth.household_id}/recordings?isAdult=false&offset=0&limit=100&sort=time&sortOrder=desc&profileId={self._profile_id}&language={lang}", + ) + recordings = await self._recording_factory.create_recordings(recordings_json) + return recordings + + async def get_show_recordings( + self, show_id: str, channel_id: str + ) -> LGHorizonRecordingList: + """Retrieve all recordings.""" + _LOGGER.debug("Retrieving recordings fro show...") + service_url = await self._service_config.get_service_url("recordingService") + lang = await self._customer.get_profile_lang(self._profile_id) + episodes_json = await self.auth.request( + service_url, + f"/customers/8436830_nl/episodes/shows/{show_id}?source=recording&isAdult=false&offset=0&limit=100&profileId={self._profile_id}&language={lang}&channelId={channel_id}&sort=time&sortOrder=asc", + ) + recordings = await self._recording_factory.create_episodes(episodes_json) + return recordings + + async def get_recording_quota(self) -> LGHorizonRecordingQuota: + """Refresh recording quota.""" + _LOGGER.debug("Refreshing recording quota...") + service_url = await self._service_config.get_service_url("recordingService") + quota_json = await self.auth.request( + service_url, + f"/customers/{self.auth.household_id}/quota", + ) + return LGHorizonRecordingQuota(quota_json) + __all__ = ["LGHorizonApi", "LGHorizonAuth"] diff --git a/lghorizon/models/lghorizon_auth.py b/lghorizon/models/lghorizon_auth.py index 017335c..ee700d0 100644 --- a/lghorizon/models/lghorizon_auth.py +++ b/lghorizon/models/lghorizon_auth.py @@ -196,6 +196,12 @@ async def request(self, host: str, path: str, params=None, **kwargs) -> Any: f"Unable to call {request_url}. Error:{str(cre)}" ) from cre + except Exception as ex: + _LOGGER.error("Error calling %s: %s", request_url, str(ex)) + raise LGHorizonApiConnectionError( + f"Unable to call {request_url}. Error:{str(ex)}" + ) from ex + async def get_mqtt_token(self) -> Any: """Get the MQTT token.""" _LOGGER.debug("Fetching MQTT token") diff --git a/lghorizon/models/lghorizon_customer.py b/lghorizon/models/lghorizon_customer.py index 3a65fef..8f8c82c 100644 --- a/lghorizon/models/lghorizon_customer.py +++ b/lghorizon/models/lghorizon_customer.py @@ -7,6 +7,8 @@ class LGHorizonCustomer: """LGHorizon customer.""" + _profiles: Dict[str, LGHorizonProfile] = {} + def __init__(self, json_payload: dict): """Initialize a customer.""" self._json_payload = json_payload @@ -39,7 +41,15 @@ def assigned_devices(self) -> list[str]: @property def profiles(self) -> Dict[str, LGHorizonProfile]: """Return the profiles.""" - return { - p["profileId"]: LGHorizonProfile(p) - for p in self._json_payload.get("profiles", []) - } + if not self._profiles or self._profiles == {}: + self._profiles = { + p["profileId"]: LGHorizonProfile(p) + for p in self._json_payload.get("profiles", []) + } + return self._profiles + + async def get_profile_lang(self, profile_id: str) -> str: + """Return the profile language.""" + if profile_id not in self.profiles: + return "nl" + return self.profiles[profile_id].options.lang diff --git a/lghorizon/models/lghorizon_device.py b/lghorizon/models/lghorizon_device.py index dd0e828..e9b852e 100644 --- a/lghorizon/models/lghorizon_device.py +++ b/lghorizon/models/lghorizon_device.py @@ -166,7 +166,8 @@ async def set_callback( self, change_callback: Callable[[str], Coroutine[Any, Any, Any]] ) -> None: """Set a callback function.""" - self._change_callback = change_callback # type: ignore [assignment] # Callback can be None + self._change_callback = change_callback + await self.register_mqtt() # type: ignore [assignment] # Callback can be None async def handle_status_message( self, status_message: LGHorizonStatusMessage @@ -204,65 +205,6 @@ async def update_recording_capacity(self, payload) -> None: return self.recording_capacity = payload["used"] # Use the setter - # async def update_with_replay_event( - # self, source_type: str, event: LGHorizonReplayEvent, channel: LGHorizonChannel - # ) -> None: - # """Update box with replay event.""" - # self._device_state.source_type = source_type - # self._device_state.channel_id = channel.id - # self._device_state.channel_title = channel.title - # title = event.title - # if event.episode_name: - # title += f": {event.episode_name}" - # self._device_state.title = title - # self._device_state.image = channel.stream_image - # self._device_state.reset_progress() - # await self._trigger_callback() - - # async def update_with_recording( - # self, - # source_type: str, - # recording: LGHorizonRecordingSingle, - # channel: LGHorizonChannel, # type: ignore [valid-type] # channel can be None - # start: float, - # end: float, - # last_speed_change: float, - # relative_position: float, - # ) -> None: - # """Update box with recording.""" - # self._device_state.source_type = source_type - # self._device_state.channel_id = channel.id - # self._device_state.channel_title = channel.title - # self._device_state.title = f"{recording.title}" - # self._device_state.image = recording.image - # start_dt = datetime.fromtimestamp(start / 1000.0) - # end_dt = datetime.fromtimestamp(end / 1000.0) - # duration = (end_dt - start_dt).total_seconds() - # self._device_state.duration = duration - # self._device_state.position = relative_position / 1000.0 - # last_update_dt = datetime.fromtimestamp(last_speed_change / 1000.0) - # self._device_state.last_position_update = last_update_dt - # await self._trigger_callback() - - # async def update_with_vod( - # self, - # source_type: str, - # vod: LGHorizonVod, - # last_speed_change: float, - # relative_position: float, - # ) -> None: - # """Update box with vod.""" - # self._device_state.source_type = source_type - # self._device_state.channel_id = None - # self._device_state.channel_title = None - # self._device_state.title = vod.title - # self._device_state.image = None - # self._device_state.duration = vod.duration - # self._device_state.position = relative_position / 1000.0 - # last_update_dt = datetime.fromtimestamp(last_speed_change / 1000.0) - # self._device_state.last_position_update = last_update_dt - # await self._trigger_callback() - async def _trigger_callback(self): if self._change_callback: _logger.debug("Callback called from box %s", self.device_id) diff --git a/lghorizon/models/lghorizon_device_state.py b/lghorizon/models/lghorizon_device_state.py index 5e7fa33..ee4b18b 100644 --- a/lghorizon/models/lghorizon_device_state.py +++ b/lghorizon/models/lghorizon_device_state.py @@ -18,6 +18,7 @@ class LGHorizonDeviceState: """Represent current state of a box.""" _channel_id: Optional[str] + _channel_name: Optional[str] _title: Optional[str] _image: Optional[str] _source_type: LGHorizonSourceType @@ -42,6 +43,7 @@ def __init__(self) -> None: self._last_position_update = None self._state = LGHorizonRunningState.UNKNOWN self._speed = None + self._channel_name = None @property def state(self) -> LGHorizonRunningState: @@ -63,6 +65,16 @@ def channel_id(self, value: Optional[str]) -> None: """Set the channel ID.""" self._channel_id = value + @property + def channel_name(self) -> Optional[str]: + """Return the channel ID.""" + return self._channel_name + + @channel_name.setter + def channel_name(self, value: Optional[str]) -> None: + """Set the channel ID.""" + self._channel_name = value + @property def title(self) -> Optional[str]: """Return the title.""" @@ -164,4 +176,5 @@ async def reset(self) -> None: self.image = None self.source_type = LGHorizonSourceType.UNKNOWN self.speed = None + self.channel_name = None await self.reset_progress() diff --git a/lghorizon/models/lghorizon_events.py b/lghorizon/models/lghorizon_events.py index 31de2db..0493a42 100644 --- a/lghorizon/models/lghorizon_events.py +++ b/lghorizon/models/lghorizon_events.py @@ -82,24 +82,36 @@ def id(self) -> str: return self._vod_json["id"] @property - def episode_title(self) -> str: + def season_number(self) -> Optional[int]: + """Return the season number of the recording.""" + return self._vod_json.get("seasonNumber", None) + + @property + def episode_number(self) -> Optional[int]: + """Return the episode number of the recording.""" + return self._vod_json.get("episodeNumber", None) + + @property + def full_episode_title(self) -> Optional[str]: """Return the ID of the VOD.""" - match self.vod_type: - case LGHorizonVODType.ASSET: - return "" - case LGHorizonVODType.EPISODE: - return f"S{self._vod_json['season'].zfill(2)}E{self._vod_json['episode'].zfill(2)}: {self._vod_json['title']}" - return "" + if self.vod_type != LGHorizonVODType.EPISODE: + return None + if not self.season_number and not self.episode_number: + return None + full_title = f"""S{self.season_number:02d}E{self.episode_number:02d}""" + if self.title: + full_title += f": {self.title}" + return full_title @property def title(self) -> str: - """Return the title of the VOD.""" - match self.vod_type: - case LGHorizonVODType.ASSET: - return self._vod_json["title"] - case LGHorizonVODType.EPISODE: - return self._vod_json["seriesTitle"] - return "unknown" + """Return the ID of the VOD.""" + return self._vod_json["title"] + + @property + def series_title(self) -> Optional[str]: + """Return the series title of the VOD.""" + return self._vod_json.get("seriesTitle", None) @property def duration(self) -> float: diff --git a/lghorizon/models/lghorizon_profile.py b/lghorizon/models/lghorizon_profile.py index 346c420..1da59d5 100644 --- a/lghorizon/models/lghorizon_profile.py +++ b/lghorizon/models/lghorizon_profile.py @@ -1,24 +1,46 @@ """LG Horizon Profile model.""" +class LGHorizonProfileOptions: + """LGHorizon profile options.""" + + def __init__(self, options_payload: dict): + """Initialize a profile options.""" + self._options_payload = options_payload + + @property + def lang(self) -> str: + """Return the language.""" + return self._options_payload["lang"] + + class LGHorizonProfile: """LGHorizon profile.""" - def __init__(self, json_payload: dict): + _options: LGHorizonProfileOptions + _profile_payload: dict + + def __init__(self, profile_payload: dict): """Initialize a profile.""" - self._json_payload = json_payload + self._profile_payload = profile_payload + self._options = LGHorizonProfileOptions(self._profile_payload["options"]) @property def id(self) -> str: """Return the profile id.""" - return self._json_payload["profileId"] + return self._profile_payload["profileId"] @property def name(self) -> str: """Return the profile name.""" - return self._json_payload["name"] + return self._profile_payload["name"] @property def favorite_channels(self) -> list[str]: """Return the favorite channels.""" - return self._json_payload.get("favoriteChannels", []) + return self._profile_payload.get("favoriteChannels", []) + + @property + def options(self) -> LGHorizonProfileOptions: + """Return the profile options.""" + return self._options diff --git a/lghorizon/models/lghorizon_recordings.py b/lghorizon/models/lghorizon_recordings.py new file mode 100644 index 0000000..e683060 --- /dev/null +++ b/lghorizon/models/lghorizon_recordings.py @@ -0,0 +1,243 @@ +"""LG Horizon recoring models.""" + +from enum import Enum +from typing import Optional, List +from abc import ABC + + +class LGHorizonRecordingSource(Enum): + """LGHorizon recording.""" + + SHOW = "show" + UNKNOWN = "unknown" + + +class LGHorizonRecordingState(Enum): + """Enumeration of LG Horizon recording states.""" + + RECORDED = "recorded" + UNKNOWN = "unknown" + + +class LGHorizonRecordingType(Enum): + """Enumeration of LG Horizon recording states.""" + + SINGLE = "single" + SEASON = "season" + SHOW = "show" + UNKNOWN = "unknown" + + +class LGHOrizonRelevantEpisode: + """LGHorizon recording.""" + + def __init__(self, episode_json: dict) -> None: + """Abstract base class for LG Horizon recordings.""" + self._episode_json = episode_json + + @property + def recording_state(self) -> LGHorizonRecordingState: + """Return the recording state.""" + return LGHorizonRecordingState[ + self._episode_json.get("recordingState", "unknown").upper() + ] + + @property + def season_number(self) -> Optional[int]: + """Return the season number of the recording.""" + return self._episode_json.get("seasonNumber", None) + + @property + def episode_number(self) -> Optional[int]: + """Return the episode number of the recording.""" + return self._episode_json.get("episodeNumber", None) + + +class LGHorizonRecording(ABC): + """Abstract base class for LG Horizon recordings.""" + + @property + def recording_payload(self) -> dict: + """Return the payload of the message.""" + return self._recording_payload + + @property + def recording_state(self) -> LGHorizonRecordingState: + """Return the recording state.""" + return LGHorizonRecordingState[ + self._recording_payload.get("recordingState", "unknown").upper() + ] + + @property + def source(self) -> LGHorizonRecordingSource: + """Return the recording source.""" + return LGHorizonRecordingSource[ + self._recording_payload.get("source", "unknown").upper() + ] + + @property + def type(self) -> LGHorizonRecordingType: + """Return the recording source.""" + return LGHorizonRecordingType[ + self._recording_payload.get("type", "unknown").upper() + ] + + @property + def id(self) -> str: + """Return the ID of the recording.""" + return self._recording_payload["id"] + + @property + def title(self) -> str: + """Return the title of the recording.""" + return self._recording_payload["title"] + + @property + def channel_id(self) -> str: + """Return the channel ID of the recording.""" + return self._recording_payload["channelId"] + + @property + def poster_url(self) -> Optional[str]: + """Return the title of the recording.""" + poster = self._recording_payload.get("poster") + if poster: + return poster.get("url") + return None + + def __init__(self, recording_payload: dict) -> None: + """Abstract base class for LG Horizon recordings.""" + self._recording_payload = recording_payload + + +class LGHorizonRecordingSingle(LGHorizonRecording): + """LGHorizon recording.""" + + @property + def episode_title(self) -> Optional[str]: + """Return the episode title of the recording.""" + return self._recording_payload.get("episodeTitle", None) + + @property + def season_number(self) -> Optional[int]: + """Return the season number of the recording.""" + return self._recording_payload.get("seasonNumber", None) + + @property + def episode_number(self) -> Optional[int]: + """Return the episode number of the recording.""" + return self._recording_payload.get("episodeNumber", None) + + @property + def show_id(self) -> Optional[str]: + """Return the show ID of the recording.""" + return self._recording_payload.get("showId", None) + + @property + def season_id(self) -> Optional[str]: + """Return the season ID of the recording.""" + return self._recording_payload.get("seasonId", None) + + @property + def full_episode_title(self) -> Optional[str]: + """Return the full episode title of the recording.""" + if not self.season_number and not self.episode_number: + return None + full_title = f"""S{self.season_number:02d}E{self.episode_number:02d}""" + if self.episode_title: + full_title += f": {self.episode_title}" + return full_title + + @property + def channel_id(self) -> Optional[str]: + """Return the channel ID of the recording.""" + return self._recording_payload.get("channelId", None) + + +class LGHorizonRecordingSeason(LGHorizonRecording): + """LGHorizon recording.""" + + _most_relevant_epsode: Optional[LGHOrizonRelevantEpisode] + + def __init__(self, payload: dict) -> None: + """Abstract base class for LG Horizon recordings.""" + super().__init__(payload) + episode_payload = payload.get("mostRelevantEpisode") + if episode_payload: + self._most_relevant_epsode = LGHOrizonRelevantEpisode(episode_payload) + + @property + def no_of_episodes(self) -> int: + """Return the number of episodes in the season.""" + return self._recording_payload.get("noOfEpisodes", 0) + + @property + def season_title(self) -> str: + """Return the season title of the recording.""" + return self._recording_payload.get("seasonTitle", "") + + @property + def most_relevant_episode(self) -> Optional[LGHOrizonRelevantEpisode]: + """Return the most relevant episode of the season.""" + return self._most_relevant_epsode + + +class LGHorizonRecordingShow(LGHorizonRecording): + """LGHorizon recording.""" + + _most_relevant_epsode: Optional[LGHOrizonRelevantEpisode] + + def __init__(self, payload: dict) -> None: + """Abstract base class for LG Horizon recordings.""" + super().__init__(payload) + episode_payload = payload.get("mostRelevantEpisode") + if episode_payload: + self._most_relevant_epsode = LGHOrizonRelevantEpisode(episode_payload) + + @property + def no_of_episodes(self) -> int: + """Return the number of episodes in the season.""" + return self._recording_payload.get("noOfEpisodes", 0) + + @property + def most_relevant_episode(self) -> Optional[LGHOrizonRelevantEpisode]: + """Return the most relevant episode of the season.""" + return self._most_relevant_epsode + + +class LGHorizonRecordingList: + """LGHorizon recording.""" + + @property + def total(self) -> int: + """Return the total number of recordings.""" + return len(self._recordings) + + def __init__(self, recordings: List[LGHorizonRecording]) -> None: + """Abstract base class for LG Horizon recordings.""" + self._recordings = recordings + + +class LGHorizonRecordingQuota: + """LGHorizon recording quota.""" + + def __init__(self, quota_json: dict) -> None: + """Initialize the recording quota.""" + self._quota_json = quota_json + + @property + def quota(self) -> int: + """Return the total space in MB.""" + return self._quota_json.get("quota", 0) + + @property + def occupied(self) -> int: + """Return the used space in MB.""" + return self._quota_json.get("occupied", 0) + + @property + def percentage_used(self) -> float: + """Return the percentage of space used.""" + if self.quota == 0: + return 0.0 + return (self.occupied / self.quota) * 100 diff --git a/lghorizon/models/lghorizon_sources.py b/lghorizon/models/lghorizon_sources.py index 5f9c900..799418a 100644 --- a/lghorizon/models/lghorizon_sources.py +++ b/lghorizon/models/lghorizon_sources.py @@ -68,9 +68,14 @@ class LGHorizonNDVRSource(LGHorizonSource): """Represent the ReviewBuffer Source of an LG Horizon device.""" @property - def event_id(self) -> str: - """Return the event ID.""" - return self._raw_json.get("eventId", "") + def recording_id(self) -> str: + """Return the recording ID.""" + return self._raw_json.get("recordingId", "") + + @property + def channel_id(self) -> str: + """Return the channel ID.""" + return self._raw_json.get("channelId", "") @property def source_type(self) -> LGHorizonSourceType: diff --git a/lghorizon/models/lghorizon_ui_status.py b/lghorizon/models/lghorizon_ui_status.py index 164228f..4ad60bd 100644 --- a/lghorizon/models/lghorizon_ui_status.py +++ b/lghorizon/models/lghorizon_ui_status.py @@ -56,6 +56,10 @@ def source(self) -> LGHorizonSource | None: # Added None to the return type return LGHorizonVODSource(self._raw_json["source"]) case LGHorizonSourceType.REPLAY: return LGHorizonReplaySource(self._raw_json["source"]) + case LGHorizonSourceType.NDVR: + return LGHorizonNDVRSource(self._raw_json["source"]) + case LGHorizonSourceType.REVIEWBUFFER: + return LGHorizonReviewBufferSource(self._raw_json["source"]) return LGHorizonUnknownSource(self._raw_json["source"]) diff --git a/lghorizon/recording_factory.py b/lghorizon/recording_factory.py new file mode 100644 index 0000000..f92e129 --- /dev/null +++ b/lghorizon/recording_factory.py @@ -0,0 +1,41 @@ +from .models.lghorizon_recordings import ( + LGHorizonRecordingList, + LGHorizonRecordingSingle, + LGHorizonRecordingSeason, + LGHorizonRecordingShow, + LGHorizonRecordingType, +) + + +class LGHorizonRecordingFactory: + """Factory to create LGHorizonRecording objects.""" + + async def create_recordings(self, recording_json: dict) -> LGHorizonRecordingList: + """Create a LGHorizonRecording object based on the recording type.""" + recording_list = [] + for recording in recording_json["data"]: + recording_type = LGHorizonRecordingType[ + recording.get("type", "unknown").upper() + ] + match recording_type: + case LGHorizonRecordingType.SINGLE: + recording_single = LGHorizonRecordingSingle(recording) + recording_list.append(recording_single) + case LGHorizonRecordingType.SEASON: + recording_season = LGHorizonRecordingSeason(recording) + recording_list.append(recording_season) + case LGHorizonRecordingType.SHOW: + recording_show = LGHorizonRecordingShow(recording) + recording_list.append(recording_show) + case LGHorizonRecordingType.UNKNOWN: + pass + + return LGHorizonRecordingList(recording_list) + + async def create_episodes(self, episode_json: dict) -> LGHorizonRecordingList: + """Create a LGHorizonRecording list based for episodes.""" + recording_list = [] + for recording in episode_json["data"]: + recording_single = LGHorizonRecordingSingle(recording) + recording_list.append(recording_single) + return LGHorizonRecordingList(recording_list) diff --git a/main.py b/main.py index 82c314c..499d70c 100644 --- a/main.py +++ b/main.py @@ -50,18 +50,29 @@ async def main(): # Start the input reader task input_task = asyncio.create_task(read_input_and_signal_shutdown()) + async def device_callback(device_id: str): + device = devices[device_id] + print( + f"Device {device.device_id} state changed. Status:\n\nName: {device.device_friendly_name}\nState: {device.device_state.state.value}\nChannel: {device.device_state.channel_name}\nTitle: {device.device_state.title}\nSubtitle: {device.device_state.sub_title}\nSource type: {device.device_state.source_type.value}\n\n", + ) + try: await api.initialize() devices = await api.get_devices() - - async def device_callback(device_id: str): - device = devices[device_id] - print( - f"Device {device.device_id} state changed. Status:\n\nName: {device.device_friendly_name}\nState: {device.device_state.state.value}\nTitle: {device.device_state.title}\nSubtitle: {device.device_state.sub_title}\nSource type: {device.device_state.source_type.value}\n\n", - ) - for device in devices.values(): await device.set_callback(device_callback) + quota = await api.get_recording_quota() + print(f"Recording occupancy: {quota.percentage_used}") + try: + recordings = await api.get_all_recordings() + print(f"Total recordings: {recordings.total}") + + show_recordings = await api.get_show_recordings( + "crid:~~2F~~2Fbds.tv~~2F272418335", "NL_000006_019130" + ) + print(f"recordings: {show_recordings.total}") + except Exception as ex: + print(ex) # Wait until the shutdown event is set await shutdown_event.wait() From 19a14a2230467d379433c11b6ae8731ff634f348 Mon Sep 17 00:00:00 2001 From: Rudolf Offereins Date: Sat, 31 Jan 2026 00:36:32 +0000 Subject: [PATCH 07/16] restructuring files --- lghorizon/__init__.py | 35 +- lghorizon/{models => }/exceptions.py | 0 .../{lghorizonapi.py => lghorizon_api.py} | 30 +- lghorizon/{models => }/lghorizon_device.py | 56 +- ...py => lghorizon_device_state_processor.py} | 18 +- ...actory.py => lghorizon_message_factory.py} | 2 +- lghorizon/lghorizon_models.py | 1331 +++++++++++++++++ .../{models => }/lghorizon_mqtt_client.py | 11 +- ...tory.py => lghorizon_recording_factory.py} | 2 +- lghorizon/models/__init__.py | 12 - lghorizon/models/lghorizon_auth.py | 227 --- lghorizon/models/lghorizon_channel.py | 53 - lghorizon/models/lghorizon_config.py | 65 - lghorizon/models/lghorizon_customer.py | 55 - lghorizon/models/lghorizon_device_state.py | 180 --- lghorizon/models/lghorizon_entitlements.py | 21 - lghorizon/models/lghorizon_events.py | 119 -- lghorizon/models/lghorizon_message.py | 113 -- lghorizon/models/lghorizon_profile.py | 46 - lghorizon/models/lghorizon_recordings.py | 243 --- lghorizon/models/lghorizon_sources.py | 127 -- lghorizon/models/lghorizon_ui_status.py | 127 -- main.py | 4 +- 23 files changed, 1389 insertions(+), 1488 deletions(-) rename lghorizon/{models => }/exceptions.py (100%) rename lghorizon/{lghorizonapi.py => lghorizon_api.py} (92%) rename lghorizon/{models => }/lghorizon_device.py (92%) rename lghorizon/{device_state_processor.py => lghorizon_device_state_processor.py} (95%) rename lghorizon/{message_factory.py => lghorizon_message_factory.py} (97%) create mode 100644 lghorizon/lghorizon_models.py rename lghorizon/{models => }/lghorizon_mqtt_client.py (96%) rename lghorizon/{recording_factory.py => lghorizon_recording_factory.py} (97%) delete mode 100644 lghorizon/models/__init__.py delete mode 100644 lghorizon/models/lghorizon_auth.py delete mode 100644 lghorizon/models/lghorizon_channel.py delete mode 100644 lghorizon/models/lghorizon_config.py delete mode 100644 lghorizon/models/lghorizon_customer.py delete mode 100644 lghorizon/models/lghorizon_device_state.py delete mode 100644 lghorizon/models/lghorizon_entitlements.py delete mode 100644 lghorizon/models/lghorizon_events.py delete mode 100644 lghorizon/models/lghorizon_message.py delete mode 100644 lghorizon/models/lghorizon_profile.py delete mode 100644 lghorizon/models/lghorizon_recordings.py delete mode 100644 lghorizon/models/lghorizon_sources.py delete mode 100644 lghorizon/models/lghorizon_ui_status.py diff --git a/lghorizon/__init__.py b/lghorizon/__init__.py index be1c977..b80c366 100644 --- a/lghorizon/__init__.py +++ b/lghorizon/__init__.py @@ -1,36 +1,3 @@ """Python client for LG Horizon.""" -from .lghorizonapi import LGHorizonApi -from .models.lghorizon_auth import ( - LGHorizonAuth, -) -from .models.exceptions import ( - LGHorizonApiUnauthorizedError, - LGHorizonApiConnectionError, - LGHorizonApiLockedError, -) -from .const import ( - ONLINE_RUNNING, - ONLINE_STANDBY, - RECORDING_TYPE_SHOW, - RECORDING_TYPE_SEASON, - RECORDING_TYPE_SINGLE, -) - -__all__ = [ - "LGHorizonApi", - "LGHorizonBox", - "LGHorizonRecordingListSeasonShow", - "LGHorizonRecordingSingle", - "LGHorizonRecordingShow", - "LGHorizonRecordingEpisode", - "LGHorizonCustomer", - "LGHorizonApiUnauthorizedError", - "LGHorizonApiConnectionError", - "LGHorizonApiLockedError", - "ONLINE_RUNNING", - "ONLINE_STANDBY", - "RECORDING_TYPE_SHOW", - "RECORDING_TYPE_SEASON", - "RECORDING_TYPE_SINGLE", -] # noqa +pass diff --git a/lghorizon/models/exceptions.py b/lghorizon/exceptions.py similarity index 100% rename from lghorizon/models/exceptions.py rename to lghorizon/exceptions.py diff --git a/lghorizon/lghorizonapi.py b/lghorizon/lghorizon_api.py similarity index 92% rename from lghorizon/lghorizonapi.py rename to lghorizon/lghorizon_api.py index 15e38c6..c43ca15 100644 --- a/lghorizon/lghorizonapi.py +++ b/lghorizon/lghorizon_api.py @@ -3,21 +3,21 @@ import logging from typing import Any, Dict, cast -from .models.lghorizon_device import LGHorizonDevice -from .models.lghorizon_channel import LGHorizonChannel -from .models.lghorizon_auth import LGHorizonAuth -from .models.lghorizon_customer import LGHorizonCustomer -from .models.lghorizon_mqtt_client import LGHorizonMqttClient -from .models.lghorizon_config import LGHorizonServicesConfig -from .models.lghorizon_entitlements import LGHorizonEntitlements -from .models.lghorizon_profile import LGHorizonProfile -from .models.lghorizon_message import LGHorizonMessageType -from .message_factory import LGHorizonMessageFactory -from .models.lghorizon_message import LGHorizonStatusMessage, LGHorizonUIStatusMessage -from .models.lghorizon_device_state import LGHorizonRunningState -from .models.lghorizon_recordings import LGHorizonRecordingList, LGHorizonRecordingQuota -from .recording_factory import LGHorizonRecordingFactory -from .device_state_processor import LGHorizonDeviceStateProcessor +from .lghorizon_device import LGHorizonDevice +from .lghorizon_models import LGHorizonChannel +from .lghorizon_models import LGHorizonAuth +from .lghorizon_models import LGHorizonCustomer +from .lghorizon_mqtt_client import LGHorizonMqttClient +from .lghorizon_models import LGHorizonServicesConfig +from .lghorizon_models import LGHorizonEntitlements +from .lghorizon_models import LGHorizonProfile +from .lghorizon_models import LGHorizonMessageType +from .lghorizon_message_factory import LGHorizonMessageFactory +from .lghorizon_models import LGHorizonStatusMessage, LGHorizonUIStatusMessage +from .lghorizon_models import LGHorizonRunningState +from .lghorizon_models import LGHorizonRecordingList, LGHorizonRecordingQuota +from .lghorizon_recording_factory import LGHorizonRecordingFactory +from .lghorizon_device_state_processor import LGHorizonDeviceStateProcessor _LOGGER = logging.getLogger(__name__) diff --git a/lghorizon/models/lghorizon_device.py b/lghorizon/lghorizon_device.py similarity index 92% rename from lghorizon/models/lghorizon_device.py rename to lghorizon/lghorizon_device.py index e9b852e..e7c271d 100644 --- a/lghorizon/models/lghorizon_device.py +++ b/lghorizon/lghorizon_device.py @@ -1,44 +1,38 @@ -"""LG Horizon device (set-top box) model.""" +"""LG Horizon Device.""" + +from __future__ import annotations import json import logging -from typing import Callable, Dict, Optional, Any, Coroutine - +from typing import Any, Callable, Coroutine, Dict, Optional +from .lghorizon_models import ( + LGHorizonRunningState, + LGHorizonStatusMessage, + LGHorizonUIStatusMessage, + LGHorizonDeviceState, + LGHorizonAuth, + LGHorizonChannel, +) -from ..const import ( - ONLINE_RUNNING, - MEDIA_KEY_POWER, - MEDIA_KEY_PLAY_PAUSE, - MEDIA_KEY_STOP, - MEDIA_KEY_CHANNEL_UP, +from .exceptions import LGHorizonApiConnectionError +from .helpers import make_id +from .lghorizon_device_state_processor import LGHorizonDeviceStateProcessor +from .lghorizon_mqtt_client import LGHorizonMqttClient +from .const import ( MEDIA_KEY_CHANNEL_DOWN, + MEDIA_KEY_CHANNEL_UP, MEDIA_KEY_ENTER, - MEDIA_KEY_REWIND, MEDIA_KEY_FAST_FORWARD, + MEDIA_KEY_PLAY_PAUSE, + MEDIA_KEY_POWER, MEDIA_KEY_RECORD, + MEDIA_KEY_REWIND, + MEDIA_KEY_STOP, + ONLINE_RUNNING, PLATFORM_TYPES, ) -from ..helpers import make_id -from .lghorizon_auth import LGHorizonAuth -from .lghorizon_channel import LGHorizonChannel -from .lghorizon_mqtt_client import LGHorizonMqttClient # Added import for type checking -from .lghorizon_device_state import LGHorizonDeviceState, LGHorizonRunningState -from .exceptions import LGHorizonApiConnectionError -from .lghorizon_message import LGHorizonStatusMessage, LGHorizonUIStatusMessage - -from ..device_state_processor import LGHorizonDeviceStateProcessor - -# Assuming these models are available from legacy or will be moved to models/ -# from ..legacy.models import ( -# # LGHorizonPlayingInfo, -# # LGHorizonPlayerState, # This is now in lghorizon_ui_status.py -# # LGHorizonReplayEvent, -# # LGHorizonRecordingSingle, -# # LGHorizonVod, -# # LGHorizonApp, -# ) -_logger = logging.getLogger(__name__) +_LOGGER = logging.getLogger(__name__) class LGHorizonDevice: @@ -207,7 +201,7 @@ async def update_recording_capacity(self, payload) -> None: async def _trigger_callback(self): if self._change_callback: - _logger.debug("Callback called from box %s", self.device_id) + _LOGGER.debug("Callback called from box %s", self.device_id) await self._change_callback(self.device_id) async def turn_on(self) -> None: diff --git a/lghorizon/device_state_processor.py b/lghorizon/lghorizon_device_state_processor.py similarity index 95% rename from lghorizon/device_state_processor.py rename to lghorizon/lghorizon_device_state_processor.py index b76dbea..d32f473 100644 --- a/lghorizon/device_state_processor.py +++ b/lghorizon/lghorizon_device_state_processor.py @@ -6,9 +6,9 @@ from typing import cast, Dict, Optional -from .models.lghorizon_device_state import LGHorizonDeviceState, LGHorizonRunningState -from .models.lghorizon_message import LGHorizonStatusMessage, LGHorizonUIStatusMessage -from .models.lghorizon_sources import ( +from .lghorizon_models import LGHorizonDeviceState, LGHorizonRunningState +from .lghorizon_models import LGHorizonStatusMessage, LGHorizonUIStatusMessage +from .lghorizon_models import ( LGHorizonSourceType, LGHorizonLinearSource, LGHorizonVODSource, @@ -16,20 +16,20 @@ LGHorizonNDVRSource, LGHorizonReviewBufferSource, ) -from .models.lghorizon_auth import LGHorizonAuth -from .models.lghorizon_events import ( +from .lghorizon_models import LGHorizonAuth +from .lghorizon_models import ( LGHorizonReplayEvent, LGHorizonVOD, ) -from .models.lghorizon_recordings import LGHorizonRecordingSingle -from .models.lghorizon_channel import LGHorizonChannel -from .models.lghorizon_ui_status import ( +from .lghorizon_models import LGHorizonRecordingSingle +from .lghorizon_models import LGHorizonChannel +from .lghorizon_models import ( LGHorizonUIStateType, LGHorizonAppsState, LGHorizonPlayerState, ) -from .models.lghorizon_customer import LGHorizonCustomer +from .lghorizon_models import LGHorizonCustomer class LGHorizonDeviceStateProcessor: diff --git a/lghorizon/message_factory.py b/lghorizon/lghorizon_message_factory.py similarity index 97% rename from lghorizon/message_factory.py rename to lghorizon/lghorizon_message_factory.py index 8a3c18e..498697a 100644 --- a/lghorizon/message_factory.py +++ b/lghorizon/lghorizon_message_factory.py @@ -1,6 +1,6 @@ "LG Horizon Message Factory." -from .models.lghorizon_message import ( +from .lghorizon_models import ( LGHorizonMessage, LGHorizonStatusMessage, LGHorizonUnknownMessage, diff --git a/lghorizon/lghorizon_models.py b/lghorizon/lghorizon_models.py new file mode 100644 index 0000000..8206b76 --- /dev/null +++ b/lghorizon/lghorizon_models.py @@ -0,0 +1,1331 @@ +"""LG Horizon Model.""" + +from __future__ import annotations + +import json +import logging +import time +from abc import ABC, abstractmethod +from datetime import datetime +from enum import Enum +from typing import Any, Dict, List, Optional + +import backoff +from aiohttp import ClientResponseError, ClientSession + +from .const import ( + COUNTRY_SETTINGS, +) +from .exceptions import LGHorizonApiConnectionError, LGHorizonApiUnauthorizedError + + +_LOGGER = logging.getLogger(__name__) + + +class LGHorizonRunningState(Enum): + """Running state of horizon box.""" + + UNKNOWN = "UNKNOWN" + ONLINE_RUNNING = "ONLINE_RUNNING" + ONLINE_STANDBY = "ONLINE_STANDBY" + + +class LGHorizonMessageType(Enum): + """Enumeration of LG Horizon message types.""" + + UNKNOWN = 0 + STATUS = 1 + UI_STATUS = 2 + + +class LGHorizonRecordingSource(Enum): + """LGHorizon recording.""" + + SHOW = "show" + UNKNOWN = "unknown" + + +class LGHorizonRecordingState(Enum): + """Enumeration of LG Horizon recording states.""" + + RECORDED = "recorded" + UNKNOWN = "unknown" + + +class LGHorizonRecordingType(Enum): + """Enumeration of LG Horizon recording states.""" + + SINGLE = "single" + SEASON = "season" + SHOW = "show" + UNKNOWN = "unknown" + + +class LGHorizonUIStateType(Enum): + """Enumeration of LG Horizon UI State types.""" + + MAINUI = "mainUI" + APPS = "apps" + UNKNOWN = "unknown" + + +class LGHorizonMessage(ABC): + """Abstract base class for LG Horizon messages.""" + + @property + def topic(self) -> str: + """Return the topic of the message.""" + return self._topic + + @property + def payload(self) -> dict: + """Return the payload of the message.""" + return self._payload + + @property + @abstractmethod + def message_type(self) -> LGHorizonMessageType | None: + """Return the message type.""" + + @abstractmethod + def __init__(self, topic: str, payload: dict) -> None: + """Abstract base class for LG Horizon messages.""" + self._topic = topic + self._payload = payload + + def __repr__(self) -> str: + """Return a string representation of the message.""" + return f"LGHorizonStatusMessage(topic='{self._topic}', payload={json.dumps(self._payload, indent=2)})" + + +class LGHorizonStatusMessage(LGHorizonMessage): + """Represents an LG Horizon status message received via MQTT.""" + + def __init__(self, payload: dict, topic: str) -> None: + """Initialize an LG Horizon status message.""" + super().__init__(topic, payload) + + @property + def message_type(self) -> LGHorizonMessageType: + """Return the message type from the payload, if available.""" + return LGHorizonMessageType.STATUS + + @property + def source(self) -> str: + """Return the device ID from the payload, if available.""" + return self._payload.get("source", "unknown") + + @property + def running_state(self) -> LGHorizonRunningState: + """Return the device ID from the payload, if available.""" + return LGHorizonRunningState[self._payload.get("state", "unknown").upper()] + + +class LGHorizonSourceType(Enum): + """Enumeration of LG Horizon message types.""" + + LINEAR = "linear" + REVIEWBUFFER = "reviewBuffer" + NDVR = "nDVR" + REPLAY = "replay" + VOD = "VOD" + UNKNOWN = "unknown" + + +class LGHorizonSource(ABC): + """Abstract base class for LG Horizon sources.""" + + def __init__(self, raw_json: dict) -> None: + """Initialize the LG Horizon source.""" + self._raw_json = raw_json + + @property + @abstractmethod + def source_type(self) -> LGHorizonSourceType: + """Return the message type.""" + + +class LGHorizonLinearSource(LGHorizonSource): + """Represent the Linear Source of an LG Horizon device.""" + + @property + def channel_id(self) -> str: + """Return the source type.""" + return self._raw_json.get("channelId", "") + + @property + def event_id(self) -> str: + """Return the event ID.""" + return self._raw_json.get("eventId", "") + + @property + def source_type(self) -> LGHorizonSourceType: + return LGHorizonSourceType.LINEAR + + +class LGHorizonReviewBufferSource(LGHorizonSource): + """Represent the ReviewBuffer Source of an LG Horizon device.""" + + @property + def channel_id(self) -> str: + """Return the source type.""" + return self._raw_json.get("channelId", "") + + @property + def event_id(self) -> str: + """Return the event ID.""" + return self._raw_json.get("eventId", "") + + @property + def source_type(self) -> LGHorizonSourceType: + return LGHorizonSourceType.REVIEWBUFFER + + +class LGHorizonNDVRSource(LGHorizonSource): + """Represent the ReviewBuffer Source of an LG Horizon device.""" + + @property + def recording_id(self) -> str: + """Return the recording ID.""" + return self._raw_json.get("recordingId", "") + + @property + def channel_id(self) -> str: + """Return the channel ID.""" + return self._raw_json.get("channelId", "") + + @property + def source_type(self) -> LGHorizonSourceType: + return LGHorizonSourceType.NDVR + + +class LGHorizonVODSource(LGHorizonSource): + """Represent the VOD Source of an LG Horizon device.""" + + @property + def title_id(self) -> str: + """Return the title ID.""" + return self._raw_json.get("titleId", "") + + @property + def start_intro_time(self) -> int: + """Return the start intro time.""" + return self._raw_json.get("startIntroTime", 0) + + @property + def end_intro_time(self) -> int: + """Return the end intro time.""" + return self._raw_json.get("endIntroTime", 0) + + @property + def source_type(self) -> LGHorizonSourceType: + return LGHorizonSourceType.VOD + + +class LGHorizonReplaySource(LGHorizonSource): + """Represent the VOD Source of an LG Horizon device.""" + + @property + def event_id(self) -> str: + """Return the title ID.""" + return self._raw_json.get("eventId", "") + + @property + def source_type(self) -> LGHorizonSourceType: + """Return the source type.""" + return LGHorizonSourceType.REPLAY + + +class LGHorizonUnknownSource(LGHorizonSource): + """Represent the Linear Source of an LG Horizon device.""" + + @property + def source_type(self) -> LGHorizonSourceType: + return LGHorizonSourceType.UNKNOWN + + +class LGHorizonPlayerState: + """Represent the Player State of an LG Horizon device.""" + + def __init__(self, raw_json: dict) -> None: + """Initialize the Player State.""" + self._raw_json = raw_json + + @property + def source_type(self) -> LGHorizonSourceType: + """Return the source type.""" + return LGHorizonSourceType[self._raw_json.get("sourceType", "unknown").upper()] + + @property + def speed(self) -> int: + """Return the Player State dictionary.""" + return self._raw_json.get("speed", 0) + + @property + def last_speed_change_time( + self, + ) -> int: + """Return the last speed change time.""" + return self._raw_json.get("lastSpeedChangeTime", 0.0) + + @property + def source(self) -> LGHorizonSource | None: # Added None to the return type + """Return the last speed change time.""" + if "source" in self._raw_json: + match self.source_type: + case LGHorizonSourceType.LINEAR: + return LGHorizonLinearSource(self._raw_json["source"]) + case LGHorizonSourceType.VOD: + return LGHorizonVODSource(self._raw_json["source"]) + case LGHorizonSourceType.REPLAY: + return LGHorizonReplaySource(self._raw_json["source"]) + case LGHorizonSourceType.NDVR: + return LGHorizonNDVRSource(self._raw_json["source"]) + case LGHorizonSourceType.REVIEWBUFFER: + return LGHorizonReviewBufferSource(self._raw_json["source"]) + + return LGHorizonUnknownSource(self._raw_json["source"]) + + +class LGHorizonAppsState: + """Represent the State of an LG Horizon device.""" + + def __init__(self, raw_json: dict) -> None: + """Initialize the Apps state.""" + self._raw_json = raw_json + + @property + def id(self) -> str: + """Return the id.""" + return self._raw_json.get("id", "") + + @property + def app_name(self) -> str: + """Return the app name.""" + return self._raw_json.get("appName", "") + + @property + def logo_path(self) -> str: + """Return the logo path.""" + return self._raw_json.get("logoPath", "") + + +class LGHorizonUIState: + """Represent the State of an LG Horizon device.""" + + _player_state: LGHorizonPlayerState | None = None + _apps_state: LGHorizonAppsState | None = None + + def __init__(self, raw_json: dict) -> None: + """Initialize the State.""" + self._raw_json = raw_json + + @property + def ui_status(self) -> LGHorizonUIStateType: + """Return the UI status dictionary.""" + return LGHorizonUIStateType[self._raw_json.get("uiStatus", "unknown").upper()] + + @property + def player_state( + self, + ) -> LGHorizonPlayerState | None: # Added None to the return type + """Return the UI status dictionary.""" + # Check if _player_state is None and if "playerState" key exists in raw_json + if self._player_state is None and "playerState" in self._raw_json: + self._player_state = LGHorizonPlayerState( + self._raw_json["playerState"] + ) # Access directly as existence is checked + return self._player_state + + @property + def apps_state( + self, + ) -> LGHorizonAppsState | None: # Added None to the return type + """Return the UI status dictionary.""" + # Check if _player_state is None and if "playerState" key exists in raw_json + if self._apps_state is None and "appsState" in self._raw_json: + self._apps_state = LGHorizonAppsState( + self._raw_json["appsState"] + ) # Access directly as existence is checked + return self._apps_state + + +class LGHorizonUIStatusMessage(LGHorizonMessage): + """Represents an LG Horizon UI status message received via MQTT.""" + + _status: LGHorizonUIState | None = None + + def __init__(self, payload: dict, topic: str) -> None: + """Initialize an LG Horizon UI status message.""" + super().__init__(topic, payload) + + @property + def message_type(self) -> LGHorizonMessageType: + """Return the message type from the payload, if available.""" + return LGHorizonMessageType.UI_STATUS + + @property + def source(self) -> str: + """Return the device ID from the payload, if available.""" + return self._payload.get("source", "unknown") + + @property + def message_timestamp(self) -> int: + """Return the device ID from the payload, if available.""" + return self._payload.get("messageTimeStamp", 0) + + @property + def ui_state(self) -> LGHorizonUIState | None: + """Return the device ID from the payload, if available.""" + if not self._status and "status" in self._payload: + self._status = LGHorizonUIState(self._payload["status"]) + return self._status + + +class LGHorizonUnknownMessage(LGHorizonMessage): + """Represents an unknown LG Horizon message received via MQTT.""" + + def __init__(self, payload: dict, topic: str) -> None: + """Initialize an LG Horizon unknown message.""" + super().__init__(topic, payload) + + @property + def message_type(self) -> LGHorizonMessageType: + """Return the message type from the payload, if available.""" + return LGHorizonMessageType.UNKNOWN + + +class LGHorizonProfileOptions: + """LGHorizon profile options.""" + + def __init__(self, options_payload: dict): + """Initialize a profile options.""" + self._options_payload = options_payload + + @property + def lang(self) -> str: + """Return the language.""" + return self._options_payload["lang"] + + +class LGHorizonProfile: + """LGHorizon profile.""" + + _options: LGHorizonProfileOptions + _profile_payload: dict + + def __init__(self, profile_payload: dict): + """Initialize a profile.""" + self._profile_payload = profile_payload + self._options = LGHorizonProfileOptions(self._profile_payload["options"]) + + @property + def id(self) -> str: + """Return the profile id.""" + return self._profile_payload["profileId"] + + @property + def name(self) -> str: + """Return the profile name.""" + return self._profile_payload["name"] + + @property + def favorite_channels(self) -> list[str]: + """Return the favorite channels.""" + return self._profile_payload.get("favoriteChannels", []) + + @property + def options(self) -> LGHorizonProfileOptions: + """Return the profile options.""" + return self._options + + +class LGHorizonAuth: + """Class to make authenticated requests.""" + + _websession: ClientSession + _refresh_token: str + _access_token: Optional[str] + _username: str + _password: str + _household_id: str + _token_expiry: Optional[int] + _country_code: str + _host: str + _use_refresh_token: bool + + def __init__( + self, + websession: ClientSession, + country_code: str, + refresh_token: str = "", + username: str = "", + password: str = "", + ) -> None: + """Initialize the auth with refresh token.""" + self._websession = websession + self._refresh_token = refresh_token + self._access_token = None + self._username = username + self._password = password + self._household_id = "" + 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"] + self._service_config = None + + @property + def websession(self) -> ClientSession: + """Return the aiohttp client session.""" + return self._websession + + @property + def refresh_token(self) -> str: + """Return the refresh token.""" + return self._refresh_token + + @refresh_token.setter + def refresh_token(self, value: str) -> None: + """Set the refresh token.""" + self._refresh_token = value + + @property + def access_token(self) -> Optional[str]: + """Return the access token.""" + return self._access_token + + @access_token.setter + def access_token(self, value: Optional[str]) -> None: + """Set the access token.""" + self._access_token = value + + @property + def username(self) -> str: + """Return the username.""" + return self._username + + @username.setter + def username(self, value: str) -> None: + """Set the username.""" + self._username = value + + @property + def password(self) -> str: + """Return the password.""" + return self._password + + @password.setter + def password(self, value: str) -> None: + """Set the password.""" + self._password = value + + @property + def household_id(self) -> str: + """Return the household ID.""" + return self._household_id + + @household_id.setter + def household_id(self, value: str) -> None: + """Set the household ID.""" + self._household_id = value + + @property + def token_expiry(self) -> Optional[int]: + """Return the token expiry timestamp.""" + return self._token_expiry + + @token_expiry.setter + def token_expiry(self, value: Optional[int]) -> None: + """Set the token expiry timestamp.""" + self._token_expiry = value + + @property + def country_code(self) -> str: + """Return the country code.""" + return self._country_code + + async def is_token_expiring(self) -> bool: + """Check if the token is expiring within one day.""" + if not self.access_token or not self.token_expiry: + return True + current_unix_time = int(time.time()) + return current_unix_time >= (self.token_expiry - 86400) + + async def fetch_access_token(self) -> None: + """Fetch the access token.""" + _LOGGER.debug("Fetching access token") + headers = dict() + headers["content-type"] = "application/json" + headers["charset"] = "utf-8" + + if not self._use_refresh_token and self.access_token is None: + payload = {"password": self.password, "username": self.username} + headers["x-device-code"] = "web" + auth_url_path = "/auth-service/v1/authorization" + else: + payload = {"refreshToken": self.refresh_token} + auth_url_path = "/auth-service/v1/authorization/refresh" + try: # Use properties and backing fields + auth_response = await self.websession.post( + f"{self._host}{auth_url_path}", + json=payload, + headers=headers, + ) + except Exception as ex: + raise LGHorizonApiConnectionError from ex + auth_json = await auth_response.json() + if not auth_response.ok: + error = None + if "error" in auth_json: + error = auth_json["error"] + if error and error["statusCode"] == 97401: + raise LGHorizonApiUnauthorizedError("Invalid credentials") + elif error: + raise LGHorizonApiConnectionError(error["message"]) + else: + raise LGHorizonApiConnectionError("Unknown connection error") + + self.household_id = auth_json["householdId"] + self.access_token = auth_json["accessToken"] + self.refresh_token = auth_json["refreshToken"] + self.username = auth_json["username"] + self.token_expiry = auth_json["refreshTokenExpiry"] + + @backoff.on_exception(backoff.expo, LGHorizonApiConnectionError, max_tries=3) + async def request(self, host: str, path: str, params=None, **kwargs) -> Any: + """Make a request.""" + if headers := kwargs.pop("headers", {}): + headers = dict(headers) + request_url = f"{host}{path}" + if await self.is_token_expiring(): # Use property + _LOGGER.debug("Access token is expiring, fetching a new one") + await self.fetch_access_token() + try: + web_response = await self.websession.request( + "GET", request_url, **kwargs, headers=headers, params=params + ) + web_response.raise_for_status() + json_response = await web_response.json() + _LOGGER.debug( + "Response from %s:\n %s", + request_url, + json.dumps(json_response, indent=2), + ) + return json_response + except ClientResponseError as cre: + _LOGGER.error("Error response from %s: %s", request_url, str(cre)) + if cre.status == 401: + await self.fetch_access_token() + raise LGHorizonApiConnectionError( + f"Unable to call {request_url}. Error:{str(cre)}" + ) from cre + + except Exception as ex: + _LOGGER.error("Error calling %s: %s", request_url, str(ex)) + raise LGHorizonApiConnectionError( + f"Unable to call {request_url}. Error:{str(ex)}" + ) from ex + + async def get_mqtt_token(self) -> Any: + """Get the MQTT token.""" + _LOGGER.debug("Fetching MQTT token") + config = await self.get_service_config() + service_url = await config.get_service_url("authorizationService") + result = await self.request( + service_url, + "/v1/mqtt/token", + ) + return result["token"] + + async def get_service_config(self): + """Get the service configuration.""" + _LOGGER.debug("Fetching service configuration") + if self._service_config is None: # Use property and backing field + base_country_code = self.country_code[0:2] + result = await self.request( + self._host, + f"/{base_country_code}/en/config-service/conf/web/backoffice.json", + ) + self._service_config = LGHorizonServicesConfig(result) + + return self._service_config + + +class LGHorizonChannel: + """Class to represent a channel.""" + + def __init__(self, channel_json): + """Initialize a channel.""" + self.channel_json = channel_json + + @property + def id(self) -> str: + """Returns the id.""" + return self.channel_json["id"] + + @property + def channel_number(self) -> str: + """Returns the channel number.""" + return self.channel_json["logicalChannelNumber"] + + @property + def is_radio(self) -> bool: + """Returns if the channel is a radio channel.""" + return self.channel_json.get("isRadio", False) + + @property + def title(self) -> str: + """Returns the title.""" + return self.channel_json["name"] + + @property + def logo_image(self) -> str: + """Returns the logo image.""" + if "logo" in self.channel_json and "focused" in self.channel_json["logo"]: + return self.channel_json["logo"]["focused"] + return "" + + @property + def linear_products(self) -> list[str]: + """Returns the linear products.""" + return self.channel_json.get("linearProducts", []) + + @property + def stream_image(self) -> str: + """Returns the stream image.""" + image_stream = self.channel_json["imageStream"] + if "full" in image_stream: + return image_stream["full"] + if "small" in image_stream: + return image_stream["small"] + if "logo" in self.channel_json and "focused" in self.channel_json["logo"]: + return self.channel_json["logo"]["focused"] + return "" + + +class LGHorizonServicesConfig: + """Handle LG Horizon configuration and service URLs.""" + + def __init__(self, config_data: dict[str, Any]) -> None: + """Initialize LG Horizon config. + + Args: + config_data: Configuration dictionary with service endpoints + """ + self._config = config_data + + async def get_service_url(self, service_name: str) -> str: + """Get the URL for a specific service. + + Args: + service_name: Name of the service (e.g., 'authService', 'recordingService') + + Returns: + URL for the service + + Raises: + 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"] + raise ValueError(f"Service URL for '{service_name}' not found in configuration") + + async def get_all_services(self) -> dict[str, str]: + """Get all available services and their URLs. + + Returns: + Dictionary mapping service names to URLs + """ + return { + name: url + for name, service in self._config.items() + if isinstance(service, dict) and (url := service.get("URL")) + } + + async def __getattr__(self, name: str) -> Optional[str]: + """Access service URLs as attributes. + + Example: config.authService returns the auth service URL + + Args: + name: Service name + + Returns: + URL for the service or None if not found + """ + if name.startswith("_"): + raise AttributeError( + f"'{type(self).__name__}' object has no attribute '{name}'" + ) + return await self.get_service_url(name) + + def __repr__(self) -> str: + """Return string representation.""" + services = list(self._config.keys()) + return f"LGHorizonConfig({len(services)} services)" + + +class LGHorizonCustomer: + """LGHorizon customer.""" + + _profiles: Dict[str, LGHorizonProfile] = {} + + def __init__(self, json_payload: dict): + """Initialize a customer.""" + self._json_payload = json_payload + + @property + def customer_id(self) -> str: + """Return the customer id.""" + return self._json_payload["customerId"] + + @property + def hashed_customer_id(self) -> str: + """Return the hashed customer id.""" + return self._json_payload["hashedCustomerId"] + + @property + def country_id(self) -> str: + """Return the country id.""" + return self._json_payload["countryId"] + + @property + def city_id(self) -> int: + """Return the city id.""" + return self._json_payload["cityId"] + + @property + def assigned_devices(self) -> list[str]: + """Return the assigned set-top boxes.""" + return self._json_payload.get("assignedDevices", []) + + @property + def profiles(self) -> Dict[str, LGHorizonProfile]: + """Return the profiles.""" + if not self._profiles or self._profiles == {}: + self._profiles = { + p["profileId"]: LGHorizonProfile(p) + for p in self._json_payload.get("profiles", []) + } + return self._profiles + + async def get_profile_lang(self, profile_id: str) -> str: + """Return the profile language.""" + if profile_id not in self.profiles: + return "nl" + return self.profiles[profile_id].options.lang + + +class LGHorizonDeviceState: + """Represent current state of a box.""" + + _channel_id: Optional[str] + _channel_name: Optional[str] + _title: Optional[str] + _image: Optional[str] + _source_type: LGHorizonSourceType + _paused: bool + _sub_title: Optional[str] + _duration: Optional[float] + _position: Optional[float] + _last_position_update: Optional[datetime] + _state: LGHorizonRunningState + _speed: Optional[int] + + def __init__(self) -> None: + """Initialize the playing info.""" + self._channel_id = None + self._title = None + self._image = None + self._source_type = LGHorizonSourceType.UNKNOWN + self._paused = False + self.sub_title = None + self._duration = None + self._position = None + self._last_position_update = None + self._state = LGHorizonRunningState.UNKNOWN + self._speed = None + self._channel_name = None + + @property + def state(self) -> LGHorizonRunningState: + """Return the channel ID.""" + return self._state + + @state.setter + def state(self, value: LGHorizonRunningState) -> None: + """Set the channel ID.""" + self._state = value + + @property + def channel_id(self) -> Optional[str]: + """Return the channel ID.""" + return self._channel_id + + @channel_id.setter + def channel_id(self, value: Optional[str]) -> None: + """Set the channel ID.""" + self._channel_id = value + + @property + def channel_name(self) -> Optional[str]: + """Return the channel ID.""" + return self._channel_name + + @channel_name.setter + def channel_name(self, value: Optional[str]) -> None: + """Set the channel ID.""" + self._channel_name = value + + @property + def title(self) -> Optional[str]: + """Return the title.""" + return self._title + + @title.setter + def title(self, value: Optional[str]) -> None: + """Set the title.""" + self._title = value + + @property + def image(self) -> Optional[str]: + """Return the image URL.""" + return self._image + + @image.setter + def image(self, value: Optional[str]) -> None: + """Set the image URL.""" + self._image = value + + @property + def source_type(self) -> LGHorizonSourceType: + """Return the source type.""" + return self._source_type + + @source_type.setter + def source_type(self, value: LGHorizonSourceType) -> None: + """Set the source type.""" + self._source_type = value + + @property + def paused(self) -> bool: + """Return if the media is paused.""" + if self.speed is None: + return False + return self.speed == 0 + + @property + def sub_title(self) -> Optional[str]: + """Return the channel title.""" + return self._sub_title + + @sub_title.setter + def sub_title(self, value: Optional[str]) -> None: + """Set the channel title.""" + self._sub_title = value + + @property + def duration(self) -> Optional[float]: + """Return the duration of the media.""" + return self._duration + + @duration.setter + def duration(self, value: Optional[float]) -> None: + """Set the duration of the media.""" + self._duration = value + + @property + def position(self) -> Optional[float]: + """Return the current position in the media.""" + return self._position + + @position.setter + def position(self, value: Optional[float]) -> None: + """Set the current position in the media.""" + self._position = value + + @property + def last_position_update(self) -> Optional[datetime]: + """Return the last time the position was updated.""" + return self._last_position_update + + @last_position_update.setter + def last_position_update(self, value: Optional[datetime]) -> None: + """Set the last position update time.""" + self._last_position_update = value + + async def reset_progress(self) -> None: + """Reset the progress-related attributes.""" + self.last_position_update = None + self.duration = None + self.position = None + + @property + def speed(self) -> Optional[int]: + """Return the speed.""" + return self._speed + + @speed.setter + def speed(self, value: int | None) -> None: + """Set the channel ID.""" + self._speed = value + + async def reset(self) -> None: + """Reset all playing information.""" + self.channel_id = None + self.title = None + self.sub_title = None + self.image = None + self.source_type = LGHorizonSourceType.UNKNOWN + self.speed = None + self.channel_name = None + await self.reset_progress() + + +class LGHorizonEntitlements: + """Class to represent entitlements.""" + + def __init__(self, entitlements_json): + """Initialize entitlements.""" + self.entitlements_json = entitlements_json + + @property + def entitlements(self): + """Returns the entitlements.""" + return self.entitlements_json.get("entitlements", []) + + @property + def entitlement_ids(self) -> list[str]: + """Returns a list of entitlement IDs.""" + return [e["id"] for e in self.entitlements if "id" in e] + + +class LGHorizonReplayEvent: + """LGhorizon replay event.""" + + def __init__(self, raw_json: dict): + """Initialize an LG Horizon replay event.""" + self._raw_json = raw_json + + @property + def episode_number(self) -> Optional[int]: + """Return the episode number.""" + return self._raw_json.get("episodeNumber") + + @property + def channel_id(self) -> str: + """Return the channel ID.""" + return self._raw_json["channelId"] + + @property + def event_id(self) -> str: + """Return the event ID.""" + return self._raw_json["eventId"] + + @property + def season_number(self) -> Optional[int]: + """Return the season number.""" + return self._raw_json.get("seasonNumber") + + @property + def title(self) -> str: + """Return the title of the event.""" + return self._raw_json["title"] + + @property + def episode_name(self) -> Optional[str]: + """Return the episode name.""" + return self._raw_json.get("episodeName", None) + + @property + def full_episode_title(self) -> Optional[str]: + """Return the full episode title.""" + + if not self.season_number and not self.episode_number: + return None + full_title = f"""S{self.season_number:02d}E{self.episode_number:02d}""" + if self.episode_name: + full_title += f": {self.episode_name}" + return full_title + + def __repr__(self) -> str: + """Return a string representation of the replay event.""" + return f"LGHorizonReplayEvent(title='{self.title}', channel_id='{self.channel_id}', event_id='{self.event_id}')" + + +class LGHorizonVODType(Enum): + """Enumeration of LG Horizon VOD types.""" + + ASSET = "ASSET" + EPISODE = "EPISODE" + UNKNOWN = "UNKNOWN" + + +class LGHorizonVOD: + """LGHorizon video on demand.""" + + def __init__(self, vod_json) -> None: + self._vod_json = vod_json + + @property + def vod_type(self) -> LGHorizonVODType: + """Return the ID of the VOD.""" + return LGHorizonVODType[self._vod_json.get("type", "unknown").upper()] + + @property + def id(self) -> str: + """Return the ID of the VOD.""" + return self._vod_json["id"] + + @property + def season_number(self) -> Optional[int]: + """Return the season number of the recording.""" + return self._vod_json.get("seasonNumber", None) + + @property + def episode_number(self) -> Optional[int]: + """Return the episode number of the recording.""" + return self._vod_json.get("episodeNumber", None) + + @property + def full_episode_title(self) -> Optional[str]: + """Return the ID of the VOD.""" + if self.vod_type != LGHorizonVODType.EPISODE: + return None + if not self.season_number and not self.episode_number: + return None + full_title = f"""S{self.season_number:02d}E{self.episode_number:02d}""" + if self.title: + full_title += f": {self.title}" + return full_title + + @property + def title(self) -> str: + """Return the ID of the VOD.""" + return self._vod_json["title"] + + @property + def series_title(self) -> Optional[str]: + """Return the series title of the VOD.""" + return self._vod_json.get("seriesTitle", None) + + @property + def duration(self) -> float: + """Return the duration of the VOD.""" + return self._vod_json["duration"] + + +class LGHOrizonRelevantEpisode: + """LGHorizon recording.""" + + def __init__(self, episode_json: dict) -> None: + """Abstract base class for LG Horizon recordings.""" + self._episode_json = episode_json + + @property + def recording_state(self) -> LGHorizonRecordingState: + """Return the recording state.""" + return LGHorizonRecordingState[ + self._episode_json.get("recordingState", "unknown").upper() + ] + + @property + def season_number(self) -> Optional[int]: + """Return the season number of the recording.""" + return self._episode_json.get("seasonNumber", None) + + @property + def episode_number(self) -> Optional[int]: + """Return the episode number of the recording.""" + return self._episode_json.get("episodeNumber", None) + + +class LGHorizonRecording(ABC): + """Abstract base class for LG Horizon recordings.""" + + @property + def recording_payload(self) -> dict: + """Return the payload of the message.""" + return self._recording_payload + + @property + def recording_state(self) -> LGHorizonRecordingState: + """Return the recording state.""" + return LGHorizonRecordingState[ + self._recording_payload.get("recordingState", "unknown").upper() + ] + + @property + def source(self) -> LGHorizonRecordingSource: + """Return the recording source.""" + return LGHorizonRecordingSource[ + self._recording_payload.get("source", "unknown").upper() + ] + + @property + def type(self) -> LGHorizonRecordingType: + """Return the recording source.""" + return LGHorizonRecordingType[ + self._recording_payload.get("type", "unknown").upper() + ] + + @property + def id(self) -> str: + """Return the ID of the recording.""" + return self._recording_payload["id"] + + @property + def title(self) -> str: + """Return the title of the recording.""" + return self._recording_payload["title"] + + @property + def channel_id(self) -> str: + """Return the channel ID of the recording.""" + return self._recording_payload["channelId"] + + @property + def poster_url(self) -> Optional[str]: + """Return the title of the recording.""" + poster = self._recording_payload.get("poster") + if poster: + return poster.get("url") + return None + + def __init__(self, recording_payload: dict) -> None: + """Abstract base class for LG Horizon recordings.""" + self._recording_payload = recording_payload + + +class LGHorizonRecordingSingle(LGHorizonRecording): + """LGHorizon recording.""" + + @property + def episode_title(self) -> Optional[str]: + """Return the episode title of the recording.""" + return self._recording_payload.get("episodeTitle", None) + + @property + def season_number(self) -> Optional[int]: + """Return the season number of the recording.""" + return self._recording_payload.get("seasonNumber", None) + + @property + def episode_number(self) -> Optional[int]: + """Return the episode number of the recording.""" + return self._recording_payload.get("episodeNumber", None) + + @property + def show_id(self) -> Optional[str]: + """Return the show ID of the recording.""" + return self._recording_payload.get("showId", None) + + @property + def season_id(self) -> Optional[str]: + """Return the season ID of the recording.""" + return self._recording_payload.get("seasonId", None) + + @property + def full_episode_title(self) -> Optional[str]: + """Return the full episode title of the recording.""" + if not self.season_number and not self.episode_number: + return None + full_title = f"""S{self.season_number:02d}E{self.episode_number:02d}""" + if self.episode_title: + full_title += f": {self.episode_title}" + return full_title + + @property + def channel_id(self) -> Optional[str]: + """Return the channel ID of the recording.""" + return self._recording_payload.get("channelId", None) + + +class LGHorizonRecordingSeason(LGHorizonRecording): + """LGHorizon recording.""" + + _most_relevant_epsode: Optional[LGHOrizonRelevantEpisode] + + def __init__(self, payload: dict) -> None: + """Abstract base class for LG Horizon recordings.""" + super().__init__(payload) + episode_payload = payload.get("mostRelevantEpisode") + if episode_payload: + self._most_relevant_epsode = LGHOrizonRelevantEpisode(episode_payload) + + @property + def no_of_episodes(self) -> int: + """Return the number of episodes in the season.""" + return self._recording_payload.get("noOfEpisodes", 0) + + @property + def season_title(self) -> str: + """Return the season title of the recording.""" + return self._recording_payload.get("seasonTitle", "") + + @property + def most_relevant_episode(self) -> Optional[LGHOrizonRelevantEpisode]: + """Return the most relevant episode of the season.""" + return self._most_relevant_epsode + + +class LGHorizonRecordingShow(LGHorizonRecording): + """LGHorizon recording.""" + + _most_relevant_epsode: Optional[LGHOrizonRelevantEpisode] + + def __init__(self, payload: dict) -> None: + """Abstract base class for LG Horizon recordings.""" + super().__init__(payload) + episode_payload = payload.get("mostRelevantEpisode") + if episode_payload: + self._most_relevant_epsode = LGHOrizonRelevantEpisode(episode_payload) + + @property + def no_of_episodes(self) -> int: + """Return the number of episodes in the season.""" + return self._recording_payload.get("noOfEpisodes", 0) + + @property + def most_relevant_episode(self) -> Optional[LGHOrizonRelevantEpisode]: + """Return the most relevant episode of the season.""" + return self._most_relevant_epsode + + +class LGHorizonRecordingList: + """LGHorizon recording.""" + + @property + def total(self) -> int: + """Return the total number of recordings.""" + return len(self._recordings) + + def __init__(self, recordings: List[LGHorizonRecording]) -> None: + """Abstract base class for LG Horizon recordings.""" + self._recordings = recordings + + +class LGHorizonRecordingQuota: + """LGHorizon recording quota.""" + + def __init__(self, quota_json: dict) -> None: + """Initialize the recording quota.""" + self._quota_json = quota_json + + @property + def quota(self) -> int: + """Return the total space in MB.""" + return self._quota_json.get("quota", 0) + + @property + def occupied(self) -> int: + """Return the used space in MB.""" + return self._quota_json.get("occupied", 0) + + @property + def percentage_used(self) -> float: + """Return the percentage of space used.""" + if self.quota == 0: + return 0.0 + return (self.occupied / self.quota) * 100 diff --git a/lghorizon/models/lghorizon_mqtt_client.py b/lghorizon/lghorizon_mqtt_client.py similarity index 96% rename from lghorizon/models/lghorizon_mqtt_client.py rename to lghorizon/lghorizon_mqtt_client.py index 657f396..28ceefb 100644 --- a/lghorizon/models/lghorizon_mqtt_client.py +++ b/lghorizon/lghorizon_mqtt_client.py @@ -1,14 +1,11 @@ -"""MQTT client for LGHorizon.""" - +import asyncio import json import logging -import asyncio -from typing import Callable, Any, Coroutine import paho.mqtt.client as mqtt - -from ..helpers import make_id -from .lghorizon_auth import LGHorizonAuth +from typing import Any, Callable, Coroutine +from .helpers import make_id +from .lghorizon_models import LGHorizonAuth _logger = logging.getLogger(__name__) diff --git a/lghorizon/recording_factory.py b/lghorizon/lghorizon_recording_factory.py similarity index 97% rename from lghorizon/recording_factory.py rename to lghorizon/lghorizon_recording_factory.py index f92e129..4ef4044 100644 --- a/lghorizon/recording_factory.py +++ b/lghorizon/lghorizon_recording_factory.py @@ -1,4 +1,4 @@ -from .models.lghorizon_recordings import ( +from .lghorizon_models import ( LGHorizonRecordingList, LGHorizonRecordingSingle, LGHorizonRecordingSeason, diff --git a/lghorizon/models/__init__.py b/lghorizon/models/__init__.py deleted file mode 100644 index 23d0949..0000000 --- a/lghorizon/models/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Models for LG Horizon.""" - -from .lghorizon_auth import LGHorizonAuth -from .lghorizon_config import LGHorizonServicesConfig -from .lghorizon_message import LGHorizonMessage, LGHorizonStatusMessage - -__all__ = [ - "LGHorizonAuth", - "LGHorizonServicesConfig", - "LGHorizonMessage", - "LGHorizonStatusMessage", -] diff --git a/lghorizon/models/lghorizon_auth.py b/lghorizon/models/lghorizon_auth.py deleted file mode 100644 index ee700d0..0000000 --- a/lghorizon/models/lghorizon_auth.py +++ /dev/null @@ -1,227 +0,0 @@ -"""LG Horizon Auth Model.""" - -import time -import logging -import json -from typing import Any, Optional - -import backoff -from aiohttp import ClientResponseError, ClientSession - -from ..const import COUNTRY_SETTINGS -from .exceptions import LGHorizonApiConnectionError, LGHorizonApiUnauthorizedError -from .lghorizon_config import LGHorizonServicesConfig - -_LOGGER = logging.getLogger(__name__) - - -class LGHorizonAuth: - """Class to make authenticated requests.""" - - _websession: ClientSession - _refresh_token: str - _access_token: Optional[str] - _username: str - _password: str - _household_id: str - _token_expiry: Optional[int] - _country_code: str - _host: str - _use_refresh_token: bool - - def __init__( - self, - websession: ClientSession, - country_code: str, - refresh_token: str = "", - username: str = "", - password: str = "", - ) -> None: - """Initialize the auth with refresh token.""" - self._websession = websession - self._refresh_token = refresh_token - self._access_token = None - self._username = username - self._password = password - self._household_id = "" - 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"] - self._service_config = None - - @property - def websession(self) -> ClientSession: - """Return the aiohttp client session.""" - return self._websession - - @property - def refresh_token(self) -> str: - """Return the refresh token.""" - return self._refresh_token - - @refresh_token.setter - def refresh_token(self, value: str) -> None: - """Set the refresh token.""" - self._refresh_token = value - - @property - def access_token(self) -> Optional[str]: - """Return the access token.""" - return self._access_token - - @access_token.setter - def access_token(self, value: Optional[str]) -> None: - """Set the access token.""" - self._access_token = value - - @property - def username(self) -> str: - """Return the username.""" - return self._username - - @username.setter - def username(self, value: str) -> None: - """Set the username.""" - self._username = value - - @property - def password(self) -> str: - """Return the password.""" - return self._password - - @password.setter - def password(self, value: str) -> None: - """Set the password.""" - self._password = value - - @property - def household_id(self) -> str: - """Return the household ID.""" - return self._household_id - - @household_id.setter - def household_id(self, value: str) -> None: - """Set the household ID.""" - self._household_id = value - - @property - def token_expiry(self) -> Optional[int]: - """Return the token expiry timestamp.""" - return self._token_expiry - - @token_expiry.setter - def token_expiry(self, value: Optional[int]) -> None: - """Set the token expiry timestamp.""" - self._token_expiry = value - - @property - def country_code(self) -> str: - """Return the country code.""" - return self._country_code - - async def is_token_expiring(self) -> bool: - """Check if the token is expiring within one day.""" - if not self.access_token or not self.token_expiry: - return True - current_unix_time = int(time.time()) - return current_unix_time >= (self.token_expiry - 86400) - - async def fetch_access_token(self) -> None: - """Fetch the access token.""" - _LOGGER.debug("Fetching access token") - headers = dict() - headers["content-type"] = "application/json" - headers["charset"] = "utf-8" - - if not self._use_refresh_token and self.access_token is None: - payload = {"password": self.password, "username": self.username} - headers["x-device-code"] = "web" - auth_url_path = "/auth-service/v1/authorization" - else: - payload = {"refreshToken": self.refresh_token} - auth_url_path = "/auth-service/v1/authorization/refresh" - try: # Use properties and backing fields - auth_response = await self.websession.post( - f"{self._host}{auth_url_path}", - json=payload, - headers=headers, - ) - except Exception as ex: - raise LGHorizonApiConnectionError from ex - auth_json = await auth_response.json() - if not auth_response.ok: - error = None - if "error" in auth_json: - error = auth_json["error"] - if error and error["statusCode"] == 97401: - raise LGHorizonApiUnauthorizedError("Invalid credentials") - elif error: - raise LGHorizonApiConnectionError(error["message"]) - else: - raise LGHorizonApiConnectionError("Unknown connection error") - - self.household_id = auth_json["householdId"] - self.access_token = auth_json["accessToken"] - self.refresh_token = auth_json["refreshToken"] - self.username = auth_json["username"] - self.token_expiry = auth_json["refreshTokenExpiry"] - - @backoff.on_exception(backoff.expo, LGHorizonApiConnectionError, max_tries=3) - async def request(self, host: str, path: str, params=None, **kwargs) -> Any: - """Make a request.""" - if headers := kwargs.pop("headers", {}): - headers = dict(headers) - request_url = f"{host}{path}" - if await self.is_token_expiring(): # Use property - _LOGGER.debug("Access token is expiring, fetching a new one") - await self.fetch_access_token() - try: - web_response = await self.websession.request( - "GET", request_url, **kwargs, headers=headers, params=params - ) - web_response.raise_for_status() - json_response = await web_response.json() - _LOGGER.debug( - "Response from %s:\n %s", - request_url, - json.dumps(json_response, indent=2), - ) - return json_response - except ClientResponseError as cre: - _LOGGER.error("Error response from %s: %s", request_url, str(cre)) - if cre.status == 401: - await self.fetch_access_token() - raise LGHorizonApiConnectionError( - f"Unable to call {request_url}. Error:{str(cre)}" - ) from cre - - except Exception as ex: - _LOGGER.error("Error calling %s: %s", request_url, str(ex)) - raise LGHorizonApiConnectionError( - f"Unable to call {request_url}. Error:{str(ex)}" - ) from ex - - async def get_mqtt_token(self) -> Any: - """Get the MQTT token.""" - _LOGGER.debug("Fetching MQTT token") - config = await self.get_service_config() - service_url = await config.get_service_url("authorizationService") - result = await self.request( - service_url, - "/v1/mqtt/token", - ) - return result["token"] - - async def get_service_config(self): - """Get the service configuration.""" - _LOGGER.debug("Fetching service configuration") - if self._service_config is None: # Use property and backing field - base_country_code = self.country_code[0:2] - result = await self.request( - self._host, - f"/{base_country_code}/en/config-service/conf/web/backoffice.json", - ) - self._service_config = LGHorizonServicesConfig(result) - - return self._service_config diff --git a/lghorizon/models/lghorizon_channel.py b/lghorizon/models/lghorizon_channel.py deleted file mode 100644 index fc8a772..0000000 --- a/lghorizon/models/lghorizon_channel.py +++ /dev/null @@ -1,53 +0,0 @@ -"""LG Horizon Channel model.""" - - -class LGHorizonChannel: - """Class to represent a channel.""" - - def __init__(self, channel_json): - """Initialize a channel.""" - self.channel_json = channel_json - - @property - def id(self) -> str: - """Returns the id.""" - return self.channel_json["id"] - - @property - def channel_number(self) -> str: - """Returns the channel number.""" - return self.channel_json["logicalChannelNumber"] - - @property - def is_radio(self) -> bool: - """Returns if the channel is a radio channel.""" - return self.channel_json.get("isRadio", False) - - @property - def title(self) -> str: - """Returns the title.""" - return self.channel_json["name"] - - @property - def logo_image(self) -> str: - """Returns the logo image.""" - if "logo" in self.channel_json and "focused" in self.channel_json["logo"]: - return self.channel_json["logo"]["focused"] - return "" - - @property - def linear_products(self) -> list[str]: - """Returns the linear products.""" - return self.channel_json.get("linearProducts", []) - - @property - def stream_image(self) -> str: - """Returns the stream image.""" - image_stream = self.channel_json["imageStream"] - if "full" in image_stream: - return image_stream["full"] - if "small" in image_stream: - return image_stream["small"] - if "logo" in self.channel_json and "focused" in self.channel_json["logo"]: - return self.channel_json["logo"]["focused"] - return "" diff --git a/lghorizon/models/lghorizon_config.py b/lghorizon/models/lghorizon_config.py deleted file mode 100644 index ee15e64..0000000 --- a/lghorizon/models/lghorizon_config.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Configuration handler for LG Horizon services.""" - -from typing import Any, Optional - - -class LGHorizonServicesConfig: - """Handle LG Horizon configuration and service URLs.""" - - def __init__(self, config_data: dict[str, Any]) -> None: - """Initialize LG Horizon config. - - Args: - config_data: Configuration dictionary with service endpoints - """ - self._config = config_data - - async def get_service_url(self, service_name: str) -> str: - """Get the URL for a specific service. - - Args: - service_name: Name of the service (e.g., 'authService', 'recordingService') - - Returns: - URL for the service - - Raises: - 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"] - raise ValueError(f"Service URL for '{service_name}' not found in configuration") - - async def get_all_services(self) -> dict[str, str]: - """Get all available services and their URLs. - - Returns: - Dictionary mapping service names to URLs - """ - return { - name: url - for name, service in self._config.items() - if isinstance(service, dict) and (url := service.get("URL")) - } - - async def __getattr__(self, name: str) -> Optional[str]: - """Access service URLs as attributes. - - Example: config.authService returns the auth service URL - - Args: - name: Service name - - Returns: - URL for the service or None if not found - """ - if name.startswith("_"): - raise AttributeError( - f"'{type(self).__name__}' object has no attribute '{name}'" - ) - return await self.get_service_url(name) - - def __repr__(self) -> str: - """Return string representation.""" - services = list(self._config.keys()) - return f"LGHorizonConfig({len(services)} services)" diff --git a/lghorizon/models/lghorizon_customer.py b/lghorizon/models/lghorizon_customer.py deleted file mode 100644 index 8f8c82c..0000000 --- a/lghorizon/models/lghorizon_customer.py +++ /dev/null @@ -1,55 +0,0 @@ -"""LGHorizon customer model.""" - -from typing import Dict -from .lghorizon_profile import LGHorizonProfile - - -class LGHorizonCustomer: - """LGHorizon customer.""" - - _profiles: Dict[str, LGHorizonProfile] = {} - - def __init__(self, json_payload: dict): - """Initialize a customer.""" - self._json_payload = json_payload - - @property - def customer_id(self) -> str: - """Return the customer id.""" - return self._json_payload["customerId"] - - @property - def hashed_customer_id(self) -> str: - """Return the hashed customer id.""" - return self._json_payload["hashedCustomerId"] - - @property - def country_id(self) -> str: - """Return the country id.""" - return self._json_payload["countryId"] - - @property - def city_id(self) -> int: - """Return the city id.""" - return self._json_payload["cityId"] - - @property - def assigned_devices(self) -> list[str]: - """Return the assigned set-top boxes.""" - return self._json_payload.get("assignedDevices", []) - - @property - def profiles(self) -> Dict[str, LGHorizonProfile]: - """Return the profiles.""" - if not self._profiles or self._profiles == {}: - self._profiles = { - p["profileId"]: LGHorizonProfile(p) - for p in self._json_payload.get("profiles", []) - } - return self._profiles - - async def get_profile_lang(self, profile_id: str) -> str: - """Return the profile language.""" - if profile_id not in self.profiles: - return "nl" - return self.profiles[profile_id].options.lang diff --git a/lghorizon/models/lghorizon_device_state.py b/lghorizon/models/lghorizon_device_state.py deleted file mode 100644 index ee4b18b..0000000 --- a/lghorizon/models/lghorizon_device_state.py +++ /dev/null @@ -1,180 +0,0 @@ -"""LG Horizon device state model.""" - -from datetime import datetime -from typing import Optional -from enum import Enum -from .lghorizon_sources import LGHorizonSourceType - - -class LGHorizonRunningState(Enum): - """Running state of horizon box.""" - - UNKNOWN = "UNKNOWN" - ONLINE_RUNNING = "ONLINE_RUNNING" - ONLINE_STANDBY = "ONLINE_STANDBY" - - -class LGHorizonDeviceState: - """Represent current state of a box.""" - - _channel_id: Optional[str] - _channel_name: Optional[str] - _title: Optional[str] - _image: Optional[str] - _source_type: LGHorizonSourceType - _paused: bool - _sub_title: Optional[str] - _duration: Optional[float] - _position: Optional[float] - _last_position_update: Optional[datetime] - _state: LGHorizonRunningState - _speed: Optional[int] - - def __init__(self) -> None: - """Initialize the playing info.""" - self._channel_id = None - self._title = None - self._image = None - self._source_type = LGHorizonSourceType.UNKNOWN - self._paused = False - self.sub_title = None - self._duration = None - self._position = None - self._last_position_update = None - self._state = LGHorizonRunningState.UNKNOWN - self._speed = None - self._channel_name = None - - @property - def state(self) -> LGHorizonRunningState: - """Return the channel ID.""" - return self._state - - @state.setter - def state(self, value: LGHorizonRunningState) -> None: - """Set the channel ID.""" - self._state = value - - @property - def channel_id(self) -> Optional[str]: - """Return the channel ID.""" - return self._channel_id - - @channel_id.setter - def channel_id(self, value: Optional[str]) -> None: - """Set the channel ID.""" - self._channel_id = value - - @property - def channel_name(self) -> Optional[str]: - """Return the channel ID.""" - return self._channel_name - - @channel_name.setter - def channel_name(self, value: Optional[str]) -> None: - """Set the channel ID.""" - self._channel_name = value - - @property - def title(self) -> Optional[str]: - """Return the title.""" - return self._title - - @title.setter - def title(self, value: Optional[str]) -> None: - """Set the title.""" - self._title = value - - @property - def image(self) -> Optional[str]: - """Return the image URL.""" - return self._image - - @image.setter - def image(self, value: Optional[str]) -> None: - """Set the image URL.""" - self._image = value - - @property - def source_type(self) -> LGHorizonSourceType: - """Return the source type.""" - return self._source_type - - @source_type.setter - def source_type(self, value: LGHorizonSourceType) -> None: - """Set the source type.""" - self._source_type = value - - @property - def paused(self) -> bool: - """Return if the media is paused.""" - if self.speed is None: - return False - return self.speed == 0 - - @property - def sub_title(self) -> Optional[str]: - """Return the channel title.""" - return self._sub_title - - @sub_title.setter - def sub_title(self, value: Optional[str]) -> None: - """Set the channel title.""" - self._sub_title = value - - @property - def duration(self) -> Optional[float]: - """Return the duration of the media.""" - return self._duration - - @duration.setter - def duration(self, value: Optional[float]) -> None: - """Set the duration of the media.""" - self._duration = value - - @property - def position(self) -> Optional[float]: - """Return the current position in the media.""" - return self._position - - @position.setter - def position(self, value: Optional[float]) -> None: - """Set the current position in the media.""" - self._position = value - - @property - def last_position_update(self) -> Optional[datetime]: - """Return the last time the position was updated.""" - return self._last_position_update - - @last_position_update.setter - def last_position_update(self, value: Optional[datetime]) -> None: - """Set the last position update time.""" - self._last_position_update = value - - async def reset_progress(self) -> None: - """Reset the progress-related attributes.""" - self.last_position_update = None - self.duration = None - self.position = None - - @property - def speed(self) -> Optional[int]: - """Return the speed.""" - return self._speed - - @speed.setter - def speed(self, value: int | None) -> None: - """Set the channel ID.""" - self._speed = value - - async def reset(self) -> None: - """Reset all playing information.""" - self.channel_id = None - self.title = None - self.sub_title = None - self.image = None - self.source_type = LGHorizonSourceType.UNKNOWN - self.speed = None - self.channel_name = None - await self.reset_progress() diff --git a/lghorizon/models/lghorizon_entitlements.py b/lghorizon/models/lghorizon_entitlements.py deleted file mode 100644 index ebea100..0000000 --- a/lghorizon/models/lghorizon_entitlements.py +++ /dev/null @@ -1,21 +0,0 @@ -"""LG Horizon Entitlements model.""" - -from __future__ import annotations - - -class LGHorizonEntitlements: - """Class to represent entitlements.""" - - def __init__(self, entitlements_json): - """Initialize entitlements.""" - self.entitlements_json = entitlements_json - - @property - def entitlements(self): - """Returns the entitlements.""" - return self.entitlements_json.get("entitlements", []) - - @property - def entitlement_ids(self) -> list[str]: - """Returns a list of entitlement IDs.""" - return [e["id"] for e in self.entitlements if "id" in e] diff --git a/lghorizon/models/lghorizon_events.py b/lghorizon/models/lghorizon_events.py deleted file mode 100644 index 0493a42..0000000 --- a/lghorizon/models/lghorizon_events.py +++ /dev/null @@ -1,119 +0,0 @@ -"""LG Horizon message models.""" - -from typing import Optional -from enum import Enum - - -class LGHorizonReplayEvent: - """LGhorizon replay event.""" - - def __init__(self, raw_json: dict): - """Initialize an LG Horizon replay event.""" - self._raw_json = raw_json - - @property - def episode_number(self) -> Optional[int]: - """Return the episode number.""" - return self._raw_json.get("episodeNumber") - - @property - def channel_id(self) -> str: - """Return the channel ID.""" - return self._raw_json["channelId"] - - @property - def event_id(self) -> str: - """Return the event ID.""" - return self._raw_json["eventId"] - - @property - def season_number(self) -> Optional[int]: - """Return the season number.""" - return self._raw_json.get("seasonNumber") - - @property - def title(self) -> str: - """Return the title of the event.""" - return self._raw_json["title"] - - @property - def episode_name(self) -> Optional[str]: - """Return the episode name.""" - return self._raw_json.get("episodeName", None) - - @property - def full_episode_title(self) -> Optional[str]: - """Return the full episode title.""" - - if not self.season_number and not self.episode_number: - return None - full_title = f"""S{self.season_number:02d}E{self.episode_number:02d}""" - if self.episode_name: - full_title += f": {self.episode_name}" - return full_title - - def __repr__(self) -> str: - """Return a string representation of the replay event.""" - return f"LGHorizonReplayEvent(title='{self.title}', channel_id='{self.channel_id}', event_id='{self.event_id}')" - - -class LGHorizonVODType(Enum): - """Enumeration of LG Horizon VOD types.""" - - ASSET = "ASSET" - EPISODE = "EPISODE" - UNKNOWN = "UNKNOWN" - - -class LGHorizonVOD: - """LGHorizon video on demand.""" - - def __init__(self, vod_json) -> None: - self._vod_json = vod_json - - @property - def vod_type(self) -> LGHorizonVODType: - """Return the ID of the VOD.""" - return LGHorizonVODType[self._vod_json.get("type", "unknown").upper()] - - @property - def id(self) -> str: - """Return the ID of the VOD.""" - return self._vod_json["id"] - - @property - def season_number(self) -> Optional[int]: - """Return the season number of the recording.""" - return self._vod_json.get("seasonNumber", None) - - @property - def episode_number(self) -> Optional[int]: - """Return the episode number of the recording.""" - return self._vod_json.get("episodeNumber", None) - - @property - def full_episode_title(self) -> Optional[str]: - """Return the ID of the VOD.""" - if self.vod_type != LGHorizonVODType.EPISODE: - return None - if not self.season_number and not self.episode_number: - return None - full_title = f"""S{self.season_number:02d}E{self.episode_number:02d}""" - if self.title: - full_title += f": {self.title}" - return full_title - - @property - def title(self) -> str: - """Return the ID of the VOD.""" - return self._vod_json["title"] - - @property - def series_title(self) -> Optional[str]: - """Return the series title of the VOD.""" - return self._vod_json.get("seriesTitle", None) - - @property - def duration(self) -> float: - """Return the duration of the VOD.""" - return self._vod_json["duration"] diff --git a/lghorizon/models/lghorizon_message.py b/lghorizon/models/lghorizon_message.py deleted file mode 100644 index 03e8120..0000000 --- a/lghorizon/models/lghorizon_message.py +++ /dev/null @@ -1,113 +0,0 @@ -"""LG Horizon message models.""" - -from abc import ABC, abstractmethod -import json - -from enum import Enum -from .lghorizon_ui_status import LGHorizonUIState -from .lghorizon_device_state import LGHorizonRunningState - - -class LGHorizonMessageType(Enum): - """Enumeration of LG Horizon message types.""" - - UNKNOWN = 0 - STATUS = 1 - UI_STATUS = 2 - - -class LGHorizonMessage(ABC): - """Abstract base class for LG Horizon messages.""" - - @property - def topic(self) -> str: - """Return the topic of the message.""" - return self._topic - - @property - def payload(self) -> dict: - """Return the payload of the message.""" - return self._payload - - @property - @abstractmethod - def message_type(self) -> LGHorizonMessageType | None: - """Return the message type.""" - - @abstractmethod - def __init__(self, topic: str, payload: dict) -> None: - """Abstract base class for LG Horizon messages.""" - self._topic = topic - self._payload = payload - - def __repr__(self) -> str: - """Return a string representation of the message.""" - return f"LGHorizonStatusMessage(topic='{self._topic}', payload={json.dumps(self._payload, indent=2)})" - - -class LGHorizonStatusMessage(LGHorizonMessage): - """Represents an LG Horizon status message received via MQTT.""" - - def __init__(self, payload: dict, topic: str) -> None: - """Initialize an LG Horizon status message.""" - super().__init__(topic, payload) - - @property - def message_type(self) -> LGHorizonMessageType: - """Return the message type from the payload, if available.""" - return LGHorizonMessageType.STATUS - - @property - def source(self) -> str: - """Return the device ID from the payload, if available.""" - return self._payload.get("source", "unknown") - - @property - def running_state(self) -> LGHorizonRunningState: - """Return the device ID from the payload, if available.""" - return LGHorizonRunningState[self._payload.get("state", "unknown").upper()] - - -class LGHorizonUIStatusMessage(LGHorizonMessage): - """Represents an LG Horizon UI status message received via MQTT.""" - - _status: LGHorizonUIState | None = None - - def __init__(self, payload: dict, topic: str) -> None: - """Initialize an LG Horizon UI status message.""" - super().__init__(topic, payload) - - @property - def message_type(self) -> LGHorizonMessageType: - """Return the message type from the payload, if available.""" - return LGHorizonMessageType.UI_STATUS - - @property - def source(self) -> str: - """Return the device ID from the payload, if available.""" - return self._payload.get("source", "unknown") - - @property - def message_timestamp(self) -> int: - """Return the device ID from the payload, if available.""" - return self._payload.get("messageTimeStamp", 0) - - @property - def ui_state(self) -> LGHorizonUIState | None: - """Return the device ID from the payload, if available.""" - if not self._status and "status" in self._payload: - self._status = LGHorizonUIState(self._payload["status"]) - return self._status - - -class LGHorizonUnknownMessage(LGHorizonMessage): - """Represents an unknown LG Horizon message received via MQTT.""" - - def __init__(self, payload: dict, topic: str) -> None: - """Initialize an LG Horizon unknown message.""" - super().__init__(topic, payload) - - @property - def message_type(self) -> LGHorizonMessageType: - """Return the message type from the payload, if available.""" - return LGHorizonMessageType.UNKNOWN diff --git a/lghorizon/models/lghorizon_profile.py b/lghorizon/models/lghorizon_profile.py deleted file mode 100644 index 1da59d5..0000000 --- a/lghorizon/models/lghorizon_profile.py +++ /dev/null @@ -1,46 +0,0 @@ -"""LG Horizon Profile model.""" - - -class LGHorizonProfileOptions: - """LGHorizon profile options.""" - - def __init__(self, options_payload: dict): - """Initialize a profile options.""" - self._options_payload = options_payload - - @property - def lang(self) -> str: - """Return the language.""" - return self._options_payload["lang"] - - -class LGHorizonProfile: - """LGHorizon profile.""" - - _options: LGHorizonProfileOptions - _profile_payload: dict - - def __init__(self, profile_payload: dict): - """Initialize a profile.""" - self._profile_payload = profile_payload - self._options = LGHorizonProfileOptions(self._profile_payload["options"]) - - @property - def id(self) -> str: - """Return the profile id.""" - return self._profile_payload["profileId"] - - @property - def name(self) -> str: - """Return the profile name.""" - return self._profile_payload["name"] - - @property - def favorite_channels(self) -> list[str]: - """Return the favorite channels.""" - return self._profile_payload.get("favoriteChannels", []) - - @property - def options(self) -> LGHorizonProfileOptions: - """Return the profile options.""" - return self._options diff --git a/lghorizon/models/lghorizon_recordings.py b/lghorizon/models/lghorizon_recordings.py deleted file mode 100644 index e683060..0000000 --- a/lghorizon/models/lghorizon_recordings.py +++ /dev/null @@ -1,243 +0,0 @@ -"""LG Horizon recoring models.""" - -from enum import Enum -from typing import Optional, List -from abc import ABC - - -class LGHorizonRecordingSource(Enum): - """LGHorizon recording.""" - - SHOW = "show" - UNKNOWN = "unknown" - - -class LGHorizonRecordingState(Enum): - """Enumeration of LG Horizon recording states.""" - - RECORDED = "recorded" - UNKNOWN = "unknown" - - -class LGHorizonRecordingType(Enum): - """Enumeration of LG Horizon recording states.""" - - SINGLE = "single" - SEASON = "season" - SHOW = "show" - UNKNOWN = "unknown" - - -class LGHOrizonRelevantEpisode: - """LGHorizon recording.""" - - def __init__(self, episode_json: dict) -> None: - """Abstract base class for LG Horizon recordings.""" - self._episode_json = episode_json - - @property - def recording_state(self) -> LGHorizonRecordingState: - """Return the recording state.""" - return LGHorizonRecordingState[ - self._episode_json.get("recordingState", "unknown").upper() - ] - - @property - def season_number(self) -> Optional[int]: - """Return the season number of the recording.""" - return self._episode_json.get("seasonNumber", None) - - @property - def episode_number(self) -> Optional[int]: - """Return the episode number of the recording.""" - return self._episode_json.get("episodeNumber", None) - - -class LGHorizonRecording(ABC): - """Abstract base class for LG Horizon recordings.""" - - @property - def recording_payload(self) -> dict: - """Return the payload of the message.""" - return self._recording_payload - - @property - def recording_state(self) -> LGHorizonRecordingState: - """Return the recording state.""" - return LGHorizonRecordingState[ - self._recording_payload.get("recordingState", "unknown").upper() - ] - - @property - def source(self) -> LGHorizonRecordingSource: - """Return the recording source.""" - return LGHorizonRecordingSource[ - self._recording_payload.get("source", "unknown").upper() - ] - - @property - def type(self) -> LGHorizonRecordingType: - """Return the recording source.""" - return LGHorizonRecordingType[ - self._recording_payload.get("type", "unknown").upper() - ] - - @property - def id(self) -> str: - """Return the ID of the recording.""" - return self._recording_payload["id"] - - @property - def title(self) -> str: - """Return the title of the recording.""" - return self._recording_payload["title"] - - @property - def channel_id(self) -> str: - """Return the channel ID of the recording.""" - return self._recording_payload["channelId"] - - @property - def poster_url(self) -> Optional[str]: - """Return the title of the recording.""" - poster = self._recording_payload.get("poster") - if poster: - return poster.get("url") - return None - - def __init__(self, recording_payload: dict) -> None: - """Abstract base class for LG Horizon recordings.""" - self._recording_payload = recording_payload - - -class LGHorizonRecordingSingle(LGHorizonRecording): - """LGHorizon recording.""" - - @property - def episode_title(self) -> Optional[str]: - """Return the episode title of the recording.""" - return self._recording_payload.get("episodeTitle", None) - - @property - def season_number(self) -> Optional[int]: - """Return the season number of the recording.""" - return self._recording_payload.get("seasonNumber", None) - - @property - def episode_number(self) -> Optional[int]: - """Return the episode number of the recording.""" - return self._recording_payload.get("episodeNumber", None) - - @property - def show_id(self) -> Optional[str]: - """Return the show ID of the recording.""" - return self._recording_payload.get("showId", None) - - @property - def season_id(self) -> Optional[str]: - """Return the season ID of the recording.""" - return self._recording_payload.get("seasonId", None) - - @property - def full_episode_title(self) -> Optional[str]: - """Return the full episode title of the recording.""" - if not self.season_number and not self.episode_number: - return None - full_title = f"""S{self.season_number:02d}E{self.episode_number:02d}""" - if self.episode_title: - full_title += f": {self.episode_title}" - return full_title - - @property - def channel_id(self) -> Optional[str]: - """Return the channel ID of the recording.""" - return self._recording_payload.get("channelId", None) - - -class LGHorizonRecordingSeason(LGHorizonRecording): - """LGHorizon recording.""" - - _most_relevant_epsode: Optional[LGHOrizonRelevantEpisode] - - def __init__(self, payload: dict) -> None: - """Abstract base class for LG Horizon recordings.""" - super().__init__(payload) - episode_payload = payload.get("mostRelevantEpisode") - if episode_payload: - self._most_relevant_epsode = LGHOrizonRelevantEpisode(episode_payload) - - @property - def no_of_episodes(self) -> int: - """Return the number of episodes in the season.""" - return self._recording_payload.get("noOfEpisodes", 0) - - @property - def season_title(self) -> str: - """Return the season title of the recording.""" - return self._recording_payload.get("seasonTitle", "") - - @property - def most_relevant_episode(self) -> Optional[LGHOrizonRelevantEpisode]: - """Return the most relevant episode of the season.""" - return self._most_relevant_epsode - - -class LGHorizonRecordingShow(LGHorizonRecording): - """LGHorizon recording.""" - - _most_relevant_epsode: Optional[LGHOrizonRelevantEpisode] - - def __init__(self, payload: dict) -> None: - """Abstract base class for LG Horizon recordings.""" - super().__init__(payload) - episode_payload = payload.get("mostRelevantEpisode") - if episode_payload: - self._most_relevant_epsode = LGHOrizonRelevantEpisode(episode_payload) - - @property - def no_of_episodes(self) -> int: - """Return the number of episodes in the season.""" - return self._recording_payload.get("noOfEpisodes", 0) - - @property - def most_relevant_episode(self) -> Optional[LGHOrizonRelevantEpisode]: - """Return the most relevant episode of the season.""" - return self._most_relevant_epsode - - -class LGHorizonRecordingList: - """LGHorizon recording.""" - - @property - def total(self) -> int: - """Return the total number of recordings.""" - return len(self._recordings) - - def __init__(self, recordings: List[LGHorizonRecording]) -> None: - """Abstract base class for LG Horizon recordings.""" - self._recordings = recordings - - -class LGHorizonRecordingQuota: - """LGHorizon recording quota.""" - - def __init__(self, quota_json: dict) -> None: - """Initialize the recording quota.""" - self._quota_json = quota_json - - @property - def quota(self) -> int: - """Return the total space in MB.""" - return self._quota_json.get("quota", 0) - - @property - def occupied(self) -> int: - """Return the used space in MB.""" - return self._quota_json.get("occupied", 0) - - @property - def percentage_used(self) -> float: - """Return the percentage of space used.""" - if self.quota == 0: - return 0.0 - return (self.occupied / self.quota) * 100 diff --git a/lghorizon/models/lghorizon_sources.py b/lghorizon/models/lghorizon_sources.py deleted file mode 100644 index 799418a..0000000 --- a/lghorizon/models/lghorizon_sources.py +++ /dev/null @@ -1,127 +0,0 @@ -"LG Horizon Sources Model." - -from abc import ABC, abstractmethod -from enum import Enum - - -class LGHorizonSourceType(Enum): - """Enumeration of LG Horizon message types.""" - - LINEAR = "linear" - REVIEWBUFFER = "reviewBuffer" - NDVR = "nDVR" - REPLAY = "replay" - VOD = "VOD" - UNKNOWN = "unknown" - - -class LGHorizonSource(ABC): - """Abstract base class for LG Horizon sources.""" - - def __init__(self, raw_json: dict) -> None: - """Initialize the LG Horizon source.""" - self._raw_json = raw_json - - @property - @abstractmethod - def source_type(self) -> LGHorizonSourceType: - """Return the message type.""" - - -class LGHorizonLinearSource(LGHorizonSource): - """Represent the Linear Source of an LG Horizon device.""" - - @property - def channel_id(self) -> str: - """Return the source type.""" - return self._raw_json.get("channelId", "") - - @property - def event_id(self) -> str: - """Return the event ID.""" - return self._raw_json.get("eventId", "") - - @property - def source_type(self) -> LGHorizonSourceType: - return LGHorizonSourceType.LINEAR - - -class LGHorizonReviewBufferSource(LGHorizonSource): - """Represent the ReviewBuffer Source of an LG Horizon device.""" - - @property - def channel_id(self) -> str: - """Return the source type.""" - return self._raw_json.get("channelId", "") - - @property - def event_id(self) -> str: - """Return the event ID.""" - return self._raw_json.get("eventId", "") - - @property - def source_type(self) -> LGHorizonSourceType: - return LGHorizonSourceType.REVIEWBUFFER - - -class LGHorizonNDVRSource(LGHorizonSource): - """Represent the ReviewBuffer Source of an LG Horizon device.""" - - @property - def recording_id(self) -> str: - """Return the recording ID.""" - return self._raw_json.get("recordingId", "") - - @property - def channel_id(self) -> str: - """Return the channel ID.""" - return self._raw_json.get("channelId", "") - - @property - def source_type(self) -> LGHorizonSourceType: - return LGHorizonSourceType.NDVR - - -class LGHorizonVODSource(LGHorizonSource): - """Represent the VOD Source of an LG Horizon device.""" - - @property - def title_id(self) -> str: - """Return the title ID.""" - return self._raw_json.get("titleId", "") - - @property - def start_intro_time(self) -> int: - """Return the start intro time.""" - return self._raw_json.get("startIntroTime", 0) - - @property - def end_intro_time(self) -> int: - """Return the end intro time.""" - return self._raw_json.get("endIntroTime", 0) - - @property - def source_type(self) -> LGHorizonSourceType: - return LGHorizonSourceType.VOD - - -class LGHorizonReplaySource(LGHorizonSource): - """Represent the VOD Source of an LG Horizon device.""" - - @property - def event_id(self) -> str: - """Return the title ID.""" - return self._raw_json.get("eventId", "") - - @property - def source_type(self) -> LGHorizonSourceType: - """Return the source type.""" - return LGHorizonSourceType.REPLAY - - -class LGHorizonUnknownSource(LGHorizonSource): - """Represent the Linear Source of an LG Horizon device.""" - - @property - def source_type(self) -> LGHorizonSourceType: - return LGHorizonSourceType.UNKNOWN diff --git a/lghorizon/models/lghorizon_ui_status.py b/lghorizon/models/lghorizon_ui_status.py deleted file mode 100644 index 4ad60bd..0000000 --- a/lghorizon/models/lghorizon_ui_status.py +++ /dev/null @@ -1,127 +0,0 @@ -"""LG Horizon UI Status Model.""" - -from enum import Enum -from .lghorizon_sources import ( - LGHorizonSource, - LGHorizonLinearSource, - LGHorizonVODSource, - LGHorizonReplaySource, - LGHorizonNDVRSource, - LGHorizonReviewBufferSource, - LGHorizonUnknownSource, - LGHorizonSourceType, -) - - -class LGHorizonUIStateType(Enum): - """Enumeration of LG Horizon UI State types.""" - - MAINUI = "mainUI" - APPS = "apps" - UNKNOWN = "unknown" - - -class LGHorizonPlayerState: - """Represent the Player State of an LG Horizon device.""" - - def __init__(self, raw_json: dict) -> None: - """Initialize the Player State.""" - self._raw_json = raw_json - - @property - def source_type(self) -> LGHorizonSourceType: - """Return the source type.""" - return LGHorizonSourceType[self._raw_json.get("sourceType", "unknown").upper()] - - @property - def speed(self) -> int: - """Return the Player State dictionary.""" - return self._raw_json.get("speed", 0) - - @property - def last_speed_change_time( - self, - ) -> int: - """Return the last speed change time.""" - return self._raw_json.get("lastSpeedChangeTime", 0.0) - - @property - def source(self) -> LGHorizonSource | None: # Added None to the return type - """Return the last speed change time.""" - if "source" in self._raw_json: - match self.source_type: - case LGHorizonSourceType.LINEAR: - return LGHorizonLinearSource(self._raw_json["source"]) - case LGHorizonSourceType.VOD: - return LGHorizonVODSource(self._raw_json["source"]) - case LGHorizonSourceType.REPLAY: - return LGHorizonReplaySource(self._raw_json["source"]) - case LGHorizonSourceType.NDVR: - return LGHorizonNDVRSource(self._raw_json["source"]) - case LGHorizonSourceType.REVIEWBUFFER: - return LGHorizonReviewBufferSource(self._raw_json["source"]) - - return LGHorizonUnknownSource(self._raw_json["source"]) - - -class LGHorizonAppsState: - """Represent the State of an LG Horizon device.""" - - def __init__(self, raw_json: dict) -> None: - """Initialize the Apps state.""" - self._raw_json = raw_json - - @property - def id(self) -> str: - """Return the id.""" - return self._raw_json.get("id", "") - - @property - def app_name(self) -> str: - """Return the app name.""" - return self._raw_json.get("appName", "") - - @property - def logo_path(self) -> str: - """Return the logo path.""" - return self._raw_json.get("logoPath", "") - - -class LGHorizonUIState: - """Represent the State of an LG Horizon device.""" - - _player_state: LGHorizonPlayerState | None = None - _apps_state: LGHorizonAppsState | None = None - - def __init__(self, raw_json: dict) -> None: - """Initialize the State.""" - self._raw_json = raw_json - - @property - def ui_status(self) -> LGHorizonUIStateType: - """Return the UI status dictionary.""" - return LGHorizonUIStateType[self._raw_json.get("uiStatus", "unknown").upper()] - - @property - def player_state( - self, - ) -> LGHorizonPlayerState | None: # Added None to the return type - """Return the UI status dictionary.""" - # Check if _player_state is None and if "playerState" key exists in raw_json - if self._player_state is None and "playerState" in self._raw_json: - self._player_state = LGHorizonPlayerState( - self._raw_json["playerState"] - ) # Access directly as existence is checked - return self._player_state - - @property - def apps_state( - self, - ) -> LGHorizonAppsState | None: # Added None to the return type - """Return the UI status dictionary.""" - # Check if _player_state is None and if "playerState" key exists in raw_json - if self._apps_state is None and "appsState" in self._raw_json: - self._apps_state = LGHorizonAppsState( - self._raw_json["appsState"] - ) # Access directly as existence is checked - return self._apps_state diff --git a/main.py b/main.py index 499d70c..74fc5c8 100644 --- a/main.py +++ b/main.py @@ -7,8 +7,8 @@ import aiohttp -from lghorizon import LGHorizonApi -from lghorizon.models import LGHorizonAuth +from lghorizon.lghorizon_api import LGHorizonApi +from lghorizon.lghorizon_models import LGHorizonAuth # Define an asyncio Event to signal shutdown shutdown_event = asyncio.Event() From 05206f35292f00e8651ebd0cbe200e71cd1de049 Mon Sep 17 00:00:00 2001 From: Rudolf Offereins Date: Sat, 31 Jan 2026 00:48:20 +0000 Subject: [PATCH 08/16] fix init --- lghorizon/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lghorizon/__init__.py b/lghorizon/__init__.py index b80c366..7e405f9 100644 --- a/lghorizon/__init__.py +++ b/lghorizon/__init__.py @@ -1,3 +1,5 @@ """Python client for LG Horizon.""" -pass +from .lghorizon_api import LGHorizonApi +from .lghorizon_models import * +from .exceptions import * From c3cfd7ae94d3fabaae47a7c23b52a63465ada18d Mon Sep 17 00:00:00 2001 From: Rudolf Offereins Date: Sat, 31 Jan 2026 01:43:24 +0000 Subject: [PATCH 09/16] Import extra models --- lghorizon/__init__.py | 1 + lghorizon/lghorizon_api.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/lghorizon/__init__.py b/lghorizon/__init__.py index 7e405f9..9eea8d0 100644 --- a/lghorizon/__init__.py +++ b/lghorizon/__init__.py @@ -1,5 +1,6 @@ """Python client for LG Horizon.""" from .lghorizon_api import LGHorizonApi +from .lghorizon_device import LGHorizonDevice from .lghorizon_models import * from .exceptions import * diff --git a/lghorizon/lghorizon_api.py b/lghorizon/lghorizon_api.py index c43ca15..4c6ba1d 100644 --- a/lghorizon/lghorizon_api.py +++ b/lghorizon/lghorizon_api.py @@ -190,7 +190,7 @@ async def _on_mqtt_message(self, mqtt_message: dict, mqtt_topic: str): return await device.handle_ui_status_message(ui_status_message) - async def _get_customer_info(self) -> Any: + async def _get_customer_info(self) -> LGHorizonCustomer: service_url = await self._service_config.get_service_url( "personalizationService" ) From fe753a762bed20aaa50abcb64524d62f12a8a213 Mon Sep 17 00:00:00 2001 From: Rudolf Offereins Date: Sat, 31 Jan 2026 20:25:38 +0000 Subject: [PATCH 10/16] Small changes --- lghorizon/__init__.py | 69 ++++++++++++++++++- lghorizon/lghorizon_api.py | 30 +++++--- lghorizon/lghorizon_device_state_processor.py | 4 ++ lghorizon/lghorizon_models.py | 52 +++++++++++++- lghorizon/lghorizon_recording_factory.py | 18 ++++- main.py | 5 +- 6 files changed, 163 insertions(+), 15 deletions(-) diff --git a/lghorizon/__init__.py b/lghorizon/__init__.py index 9eea8d0..1156804 100644 --- a/lghorizon/__init__.py +++ b/lghorizon/__init__.py @@ -2,5 +2,70 @@ from .lghorizon_api import LGHorizonApi from .lghorizon_device import LGHorizonDevice -from .lghorizon_models import * -from .exceptions import * +from .lghorizon_models import ( + LGHorizonAuth, + LGHorizonChannel, + LGHorizonCustomer, + LGHorizonDeviceState, + LGHorizonProfile, + LGHorizonRecording, + LGHorizonRecordingList, + LGHorizonShowRecordingList, + LGHorizonRecordingSeason, + LGHorizonRecordingSingle, + LGHorizonRecordingShow, + LGHorizonRecordingQuota, + LGHorizonRecordingType, + LGHorizonUIStateType, + LGHorizonMessageType, + LGHorizonRunningState, + LGHorizonRecordingSource, + LGHorizonRecordingState, + LGHorizonSourceType, + LGHorizonPlayerState, + LGHorizonAppsState, + LGHorizonUIState, + LGHorizonProfileOptions, + LGHorizonServicesConfig, +) +from .exceptions import ( + LGHorizonApiError, + LGHorizonApiConnectionError, + LGHorizonApiUnauthorizedError, + LGHorizonApiLockedError, +) + +__all__ = [ + "LGHorizonApi", + "LGHorizonDevice", + "LGHorizonAuth", + "LGHorizonChannel", + "LGHorizonCustomer", + "LGHorizonDeviceState", + "LGHorizonProfile", + "LGHorizonApiError", + "LGHorizonApiConnectionError", + "LGHorizonApiUnauthorizedError", + "LGHorizonApiLockedError", + "LGHorizonRecordingList", + "LGHorizonRecordingSeason", + "LGHorizonRecordingSingle", + "LGHorizonRecordingShow", + "LGHorizonRecordingQuota", + "LGHorizonRecordingType", + "LGHorizonUIStateType", + "LGHorizonMessageType", + "LGHorizonRunningState", + "LGHorizonRecordingSource", + "LGHorizonRecordingState", + "LGHorizonSourceType", + "LGHorizonPlayerState", + "LGHorizonAppsState", + "LGHorizonUIState", + "LGHorizonProfileOptions", + "LGHorizonProfile", + "LGHorizonAuth", + "LGHorizonServicesConfig", + "LGHorizonRecording", + "LGHorizonShowRecordingList", +] diff --git a/lghorizon/lghorizon_api.py b/lghorizon/lghorizon_api.py index 4c6ba1d..aa622c5 100644 --- a/lghorizon/lghorizon_api.py +++ b/lghorizon/lghorizon_api.py @@ -1,7 +1,7 @@ """LG Horizon API client.""" import logging -from typing import Any, Dict, cast +from typing import Any, Dict, cast, Callable, Optional from .lghorizon_device import LGHorizonDevice from .lghorizon_models import LGHorizonChannel @@ -15,7 +15,11 @@ from .lghorizon_message_factory import LGHorizonMessageFactory from .lghorizon_models import LGHorizonStatusMessage, LGHorizonUIStatusMessage from .lghorizon_models import LGHorizonRunningState -from .lghorizon_models import LGHorizonRecordingList, LGHorizonRecordingQuota +from .lghorizon_models import ( + LGHorizonRecordingList, + LGHorizonRecordingQuota, + LGHorizonShowRecordingList, +) from .lghorizon_recording_factory import LGHorizonRecordingFactory from .lghorizon_device_state_processor import LGHorizonDeviceStateProcessor @@ -26,7 +30,7 @@ class LGHorizonApi: """LG Horizon API client.""" - _mqtt_client: LGHorizonMqttClient + _mqtt_client: LGHorizonMqttClient | None auth: LGHorizonAuth _service_config: LGHorizonServicesConfig _customer: LGHorizonCustomer @@ -45,6 +49,8 @@ def __init__(self, auth: LGHorizonAuth, profile_id: str = "") -> None: self._profile_id = profile_id self._channels = {} self._device_state_processor = None + self._mqtt_client = None + self._initialized = False async def initialize(self) -> None: """Initialize the API client.""" @@ -62,14 +68,20 @@ async def initialize(self) -> None: ) self._initialized = True - async def get_devices(self) -> Dict[str, LGHorizonDevice]: + async def set_token_refresh_callback( + self, token_refresh_callback: Callable[str, None] + ) -> None: + """Set the token refresh callback.""" + self.auth.token_refresh_callback = token_refresh_callback + + async def get_devices(self) -> dict[str, LGHorizonDevice]: """Get devices.""" if not self._initialized: raise RuntimeError("LGHorizonApi not initialized") return self._devices - async def get_profiles(self) -> Dict[str, LGHorizonProfile]: + async def get_profiles(self) -> dict[str, LGHorizonProfile]: """Get profile IDs.""" if not self._initialized: raise RuntimeError("LGHorizonApi not initialized") @@ -77,10 +89,12 @@ async def get_profiles(self) -> Dict[str, LGHorizonProfile]: return self._customer.profiles async def get_profile_channels( - self, profile_id: str - ) -> Dict[str, LGHorizonChannel]: + self, profile_id: Optional[str] = None + ) -> dict[str, LGHorizonChannel]: """Returns channels to display baed on profile.""" # Attempt to retrieve the profile by the given profile_id + if not profile_id: + profile_id = self._profile_id profile = self._customer.profiles.get(profile_id) # If the specified profile is not found, and there are other profiles available, @@ -244,7 +258,7 @@ async def get_all_recordings(self) -> LGHorizonRecordingList: async def get_show_recordings( self, show_id: str, channel_id: str - ) -> LGHorizonRecordingList: + ) -> LGHorizonShowRecordingList: """Retrieve all recordings.""" _LOGGER.debug("Retrieving recordings fro show...") service_url = await self._service_config.get_service_url("recordingService") diff --git a/lghorizon/lghorizon_device_state_processor.py b/lghorizon/lghorizon_device_state_processor.py index d32f473..b405c10 100644 --- a/lghorizon/lghorizon_device_state_processor.py +++ b/lghorizon/lghorizon_device_state_processor.py @@ -99,6 +99,9 @@ async def _process_main_ui_state( return await device_state.reset() device_state.source_type = player_state.source_type + device_state.ui_state_type = LGHorizonUIStateType.MAINUI + device_state.speed = player_state.speed + match player_state.source_type: case LGHorizonSourceType.LINEAR: await self._process_linear_state(device_state, player_state) @@ -130,6 +133,7 @@ async def _process_linear_state( return player_state.source.__class__ = LGHorizonLinearSource source = cast(LGHorizonLinearSource, player_state.source) + device_state.ui_state_type = LGHorizonUIStateType.APPS service_config = await self._auth.get_service_config() service_url = await service_config.get_service_url("linearService") lang = await self._customer.get_profile_lang(self._profile_id) diff --git a/lghorizon/lghorizon_models.py b/lghorizon/lghorizon_models.py index 8206b76..70a88c5 100644 --- a/lghorizon/lghorizon_models.py +++ b/lghorizon/lghorizon_models.py @@ -8,7 +8,7 @@ from abc import ABC, abstractmethod from datetime import datetime from enum import Enum -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Callable import backoff from aiohttp import ClientResponseError, ClientSession @@ -453,6 +453,7 @@ class LGHorizonAuth: _country_code: str _host: str _use_refresh_token: bool + _token_refresh_callback: Callable[str, None] | None def __init__( self, @@ -474,6 +475,7 @@ def __init__( self._host = COUNTRY_SETTINGS[country_code]["api_url"] self._use_refresh_token = COUNTRY_SETTINGS[country_code]["use_refreshtoken"] self._service_config = None + self._token_refresh_callback = None @property def websession(self) -> ClientSession: @@ -589,6 +591,8 @@ async def fetch_access_token(self) -> None: self.household_id = auth_json["householdId"] self.access_token = auth_json["accessToken"] self.refresh_token = auth_json["refreshToken"] + if self._token_refresh_callback: + self._token_refresh_callback(self.refresh_token) self.username = auth_json["username"] self.token_expiry = auth_json["refreshTokenExpiry"] @@ -839,6 +843,7 @@ def __init__(self) -> None: self._title = None self._image = None self._source_type = LGHorizonSourceType.UNKNOWN + self._ui_state_type = LGHorizonUIStateType.UNKNOWN self._paused = False self.sub_title = None self._duration = None @@ -908,6 +913,16 @@ def source_type(self, value: LGHorizonSourceType) -> None: """Set the source type.""" self._source_type = value + @property + def ui_state_type(self) -> LGHorizonUIStateType: + """Return the source type.""" + return self._ui_state_type + + @ui_state_type.setter + def ui_state_type(self, value: LGHorizonUIStateType) -> None: + """Set the source type.""" + self._ui_state_type = value + @property def paused(self) -> bool: """Return if the media is paused.""" @@ -1221,6 +1236,11 @@ def show_id(self) -> Optional[str]: """Return the show ID of the recording.""" return self._recording_payload.get("showId", None) + @property + def show_title(self) -> Optional[str]: + """Return the show ID of the recording.""" + return self._recording_payload.get("showTitle", None) + @property def season_id(self) -> Optional[str]: """Return the season ID of the recording.""" @@ -1305,6 +1325,36 @@ def __init__(self, recordings: List[LGHorizonRecording]) -> None: """Abstract base class for LG Horizon recordings.""" self._recordings = recordings + @property + def recordings(self) -> int: + """Return the total number of recordings.""" + return self._recordings + + +class LGHorizonShowRecordingList(LGHorizonRecordingList): + """LGHorizon recording.""" + + def __init__( + self, + show_title: Optional[str], + show_image, + recordings: List[LGHorizonRecording], + ) -> None: + """Abstract base class for LG Horizon recordings.""" + super().__init__(recordings) + self._show_title = show_title + self._show_image = show_image + + @property + def show_title(self) -> str: + """Title of the show.""" + return self._show_title + + @property + def show_image(self) -> Optional[str]: + """Image of the show.""" + return self._show_image + class LGHorizonRecordingQuota: """LGHorizon recording quota.""" diff --git a/lghorizon/lghorizon_recording_factory.py b/lghorizon/lghorizon_recording_factory.py index 4ef4044..1129ecf 100644 --- a/lghorizon/lghorizon_recording_factory.py +++ b/lghorizon/lghorizon_recording_factory.py @@ -1,9 +1,11 @@ +from typing import Optional from .lghorizon_models import ( LGHorizonRecordingList, LGHorizonRecordingSingle, LGHorizonRecordingSeason, LGHorizonRecordingShow, LGHorizonRecordingType, + LGHorizonShowRecordingList, ) @@ -32,10 +34,22 @@ async def create_recordings(self, recording_json: dict) -> LGHorizonRecordingLis return LGHorizonRecordingList(recording_list) - async def create_episodes(self, episode_json: dict) -> LGHorizonRecordingList: + async def create_episodes(self, episode_json: dict) -> LGHorizonShowRecordingList: """Create a LGHorizonRecording list based for episodes.""" recording_list = [] + show_title: Optional[str] = None + if "images" in episode_json: + images = episode_json["images"] + show_image = next( + (img["url"] for img in images if img.get("type") == "titleTreatment"), + images[0]["url"] if images else None, + ) + else: + show_image = None + for recording in episode_json["data"]: recording_single = LGHorizonRecordingSingle(recording) + if show_title is None: + show_title = recording_single.show_title recording_list.append(recording_single) - return LGHorizonRecordingList(recording_list) + return LGHorizonShowRecordingList(show_title, show_image, recording_list) diff --git a/main.py b/main.py index 74fc5c8..70e9b51 100644 --- a/main.py +++ b/main.py @@ -6,6 +6,7 @@ import sys # Import sys for stdin import aiohttp +import traceback from lghorizon.lghorizon_api import LGHorizonApi from lghorizon.lghorizon_models import LGHorizonAuth @@ -71,8 +72,8 @@ async def device_callback(device_id: str): "crid:~~2F~~2Fbds.tv~~2F272418335", "NL_000006_019130" ) print(f"recordings: {show_recordings.total}") - except Exception as ex: - print(ex) + except Exception: + traceback.print_exc() # Wait until the shutdown event is set await shutdown_event.wait() From bbdbfe4cf36c9dc376bd6b45e96a400311122534 Mon Sep 17 00:00:00 2001 From: Rudolf Offereins Date: Sun, 1 Feb 2026 19:09:53 +0000 Subject: [PATCH 11/16] paho wrapped for async, add data to device state --- lghorizon/legacy/lghorizon_api.py | 469 ----------- lghorizon/legacy/models.py | 768 ------------------ lghorizon/lghorizon_api.py | 10 +- lghorizon/lghorizon_device.py | 4 +- lghorizon/lghorizon_device_state_processor.py | 44 +- lghorizon/lghorizon_models.py | 103 ++- lghorizon/lghorizon_mqtt_client.py | 212 +++-- lghorizon/lghorizon_recording_factory.py | 2 +- main.py | 2 +- 9 files changed, 260 insertions(+), 1354 deletions(-) delete mode 100644 lghorizon/legacy/lghorizon_api.py delete mode 100644 lghorizon/legacy/models.py diff --git a/lghorizon/legacy/lghorizon_api.py b/lghorizon/legacy/lghorizon_api.py deleted file mode 100644 index 1e144eb..0000000 --- a/lghorizon/legacy/lghorizon_api.py +++ /dev/null @@ -1,469 +0,0 @@ -"""Python client for LGHorizon.""" -# pylint: disable=broad-exception-caught -# pylint: disable=line-too-long - -import logging -import json -import re - -from typing import Any, Callable, Dict, List -import backoff - -from requests import Session, exceptions as request_exceptions - -from .exceptions import ( - LGHorizonApiUnauthorizedError, - LGHorizonApiConnectionError, - LGHorizonApiLockedError, -) - -from .models import ( - LGHorizonAuth, - LGHorizonBox, - LGHorizonMqttClient, - LGHorizonCustomer, - LGHorizonChannel, - LGHorizonReplayEvent, - LGHorizonRecordingSingle, - LGHorizonVod, - LGHorizonApp, - LGHorizonBaseRecording, - LGHorizonRecordingListSeasonShow, - LGHorizonRecordingEpisode, - LGHorizonRecordingShow, -) - -from .const import ( - COUNTRY_SETTINGS, - BOX_PLAY_STATE_BUFFER, - BOX_PLAY_STATE_CHANNEL, - BOX_PLAY_STATE_DVR, - BOX_PLAY_STATE_REPLAY, - BOX_PLAY_STATE_VOD, - RECORDING_TYPE_SINGLE, - RECORDING_TYPE_SEASON, - RECORDING_TYPE_SHOW, -) - - -_logger = logging.getLogger(__name__) -_supported_platforms = ["EOS", "EOS2", "HORIZON", "APOLLO"] - - -class LGHorizonApi: - """Main class for handling connections with LGHorizon Settop boxes.""" - - _auth: LGHorizonAuth = None - _session: Session = None - settop_boxes: Dict[str, LGHorizonBox] = None - customer: LGHorizonCustomer = None - _mqtt_client: LGHorizonMqttClient = None - _channels: Dict[str, LGHorizonChannel] = None - _country_settings = None - _country_code: str = None - recording_capacity: int = None - _entitlements: List[str] = None - _identifier: str = None - _config: str = None - _refresh_callback: Callable = None - _profile_id: str = None - - def __init__( - self, - username: str, - password: str, - country_code: str = "nl", - identifier: str = None, - refresh_token=None, - profile_id=None, - ) -> None: - """Create LGHorizon API.""" - self.username = username - self.password = password - self.refresh_token = refresh_token - self._session = Session() - self._country_settings = COUNTRY_SETTINGS[country_code] - self._country_code = country_code - self._auth = LGHorizonAuth() - self.settop_boxes = {} - self._channels = {} - self._entitlements = [] - self._identifier = identifier - self._profile_id = profile_id - - def _authorize(self) -> None: - ctry_code = self._country_code[0:2] - if ctry_code in ("gb", "ch", "be"): - self._authorize_with_refresh_token() - else: - self._authorize_default() - - def _authorize_default(self) -> None: - _logger.debug("Authorizing") - auth_url = f"{self._country_settings['api_url']}/auth-service/v1/authorization" - auth_headers = {"x-device-code": "web"} - auth_payload = {"password": self.password, "username": self.username} - try: - auth_response = self._session.post( - auth_url, headers=auth_headers, json=auth_payload - ) - except Exception as ex: - raise LGHorizonApiConnectionError("Unknown connection failure") from ex - - if not auth_response.ok: - error_json = auth_response.json() - error = error_json["error"] - if error and error["statusCode"] == 97401: - raise LGHorizonApiUnauthorizedError("Invalid credentials") - elif error and error["statusCode"] == 97117: - raise LGHorizonApiLockedError("Account locked") - elif error: - raise LGHorizonApiConnectionError(error["message"]) - else: - raise LGHorizonApiConnectionError("Unknown connection error") - - self._auth.fill(auth_response.json()) - _logger.debug("Authorization succeeded") - - def _authorize_with_refresh_token(self) -> None: - """Handle authorizzationg using request token.""" - _logger.debug("Authorizing via refresh") - refresh_url = ( - f"{self._country_settings['api_url']}/auth-service/v1/authorization/refresh" - ) - headers = {"content-type": "application/json", "charset": "utf-8"} - payload = '{"refreshToken":"' + self.refresh_token + '"}' - - try: - auth_response = self._session.post( - refresh_url, headers=headers, data=payload - ) - except Exception as ex: - raise LGHorizonApiConnectionError("Unknown connection failure") from ex - - if not auth_response.ok: - _logger.debug("response %s", auth_response) - error_json = auth_response.json() - error = None - if "error" in error_json: - error = error_json["error"] - if error and error["statusCode"] == 97401: - raise LGHorizonApiUnauthorizedError("Invalid credentials") - elif error: - raise LGHorizonApiConnectionError(error["message"]) - else: - raise LGHorizonApiConnectionError("Unknown connection error") - - self._auth.fill(auth_response.json()) - self.refresh_token = self._auth.refresh_token - self._session.cookies["ACCESSTOKEN"] = self._auth.access_token - - if self._refresh_callback: - self._refresh_callback() - - _logger.debug("Authorization succeeded") - - def set_callback(self, refresh_callback: Callable) -> None: - """Set the refresh callback.""" - self._refresh_callback = refresh_callback - - def _obtain_mqtt_token(self): - _logger.debug("Obtain mqtt token...") - mqtt_auth_url = self._config["authorizationService"]["URL"] - mqtt_response = self._do_api_call(f"{mqtt_auth_url}/v1/mqtt/token") - self._auth.mqttToken = mqtt_response["token"] - _logger.debug("MQTT token: %s", self._auth.mqttToken) - - @backoff.on_exception( - backoff.expo, - BaseException, - jitter=None, - max_tries=3, - logger=_logger, - giveup=lambda e: isinstance( - e, (LGHorizonApiLockedError, LGHorizonApiUnauthorizedError) - ), - ) - def connect(self) -> None: - """Start connection process.""" - self._config = self._get_config(self._country_code) - _logger.debug("Connect to API") - self._authorize() - self._obtain_mqtt_token() - self._mqtt_client = LGHorizonMqttClient( - self._auth, - self._config["mqttBroker"]["URL"], - self._on_mqtt_connected, - self._on_mqtt_message, - ) - - self._register_customer_and_boxes() - self._mqtt_client.connect() - - def disconnect(self): - """Disconnect.""" - _logger.debug("Disconnect from API") - if not self._mqtt_client or not self._mqtt_client.is_connected: - return - self._mqtt_client.disconnect() - - def _on_mqtt_connected(self) -> None: - _logger.debug("Connected to MQTT server. Registering all boxes...") - box: LGHorizonBox - for box in self.settop_boxes.values(): - box.register_mqtt() - - def _on_mqtt_message(self, message: str, topic: str) -> None: - if "action" in message and message["action"] == "OPS.getProfilesUpdate": - self._update_customer() - elif "source" in message: - device_id = message["source"] - if not isinstance(device_id, str): - _logger.debug("ignoring message - not a string") - return - if device_id not in self.settop_boxes: - return - try: - if "deviceType" in message and message["deviceType"] == "STB": - self.settop_boxes[device_id].update_state(message) - if "status" in message: - self._handle_box_update(device_id, message) - - except Exception: - _logger.exception("Could not handle status message") - _logger.warning("Full message: %s", str(message)) - self.settop_boxes[device_id].playing_info.reset() - self.settop_boxes[device_id].playing_info.set_paused(False) - elif "CPE.capacity" in message: - splitted_topic = topic.split("/") - if len(splitted_topic) != 4: - return - device_id = splitted_topic[1] - if device_id not in self.settop_boxes: - return - self.settop_boxes[device_id].update_recording_capacity(message) - - def _handle_box_update(self, device_id: str, raw_message: Any) -> None: - status_payload = raw_message["status"] - if "uiStatus" not in status_payload: - return - ui_status = status_payload["uiStatus"] - if ui_status == "mainUI": - player_state = status_payload["playerState"] - if "sourceType" not in player_state or "source" not in player_state: - return - source_type = player_state["sourceType"] - state_source = player_state["source"] - self.settop_boxes[device_id].playing_info.set_paused( - player_state["speed"] == 0 - ) - if ( - source_type - in ( - BOX_PLAY_STATE_CHANNEL, - BOX_PLAY_STATE_BUFFER, - BOX_PLAY_STATE_REPLAY, - ) - and "eventId" in state_source - ): - event_id = state_source["eventId"] - raw_replay_event = self._do_api_call( - f"{self._config['linearService']['URL']}/v2/replayEvent/{event_id}?returnLinearContent=true&language={self._country_settings['language']}" - ) - replay_event = LGHorizonReplayEvent(raw_replay_event) - channel = self._channels[replay_event.channel_id] - self.settop_boxes[device_id].update_with_replay_event( - source_type, replay_event, channel - ) - elif source_type == BOX_PLAY_STATE_DVR: - recording_id = state_source["recordingId"] - session_start_time = state_source["sessionStartTime"] - session_end_time = state_source["sessionEndTime"] - last_speed_change_time = player_state["lastSpeedChangeTime"] - relative_position = player_state["relativePosition"] - raw_recording = self._do_api_call( - f"{self._config['recordingService']['URL']}/customers/{self._auth.household_id}/details/single/{recording_id}?profileId=4504e28d-c1cb-4284-810b-f5eaab06f034&language={self._country_settings['language']}" - ) - recording = LGHorizonRecordingSingle(raw_recording) - channel = self._channels[recording.channel_id] - self.settop_boxes[device_id].update_with_recording( - source_type, - recording, - channel, - session_start_time, - session_end_time, - last_speed_change_time, - relative_position, - ) - elif source_type == BOX_PLAY_STATE_VOD: - title_id = state_source["titleId"] - last_speed_change_time = player_state["lastSpeedChangeTime"] - relative_position = player_state["relativePosition"] - raw_vod = self._do_api_call( - f"{self._config['vodService']['URL']}/v2/detailscreen/{title_id}?language={self._country_settings['language']}&profileId=4504e28d-c1cb-4284-810b-f5eaab06f034&cityId={self.customer.city_id}" - ) - vod = LGHorizonVod(raw_vod) - self.settop_boxes[device_id].update_with_vod( - source_type, vod, last_speed_change_time, relative_position - ) - elif ui_status == "apps": - app = LGHorizonApp(status_payload["appsState"]) - self.settop_boxes[device_id].update_with_app("app", app) - - @backoff.on_exception( - backoff.expo, LGHorizonApiConnectionError, max_tries=3, logger=_logger - ) - def _do_api_call(self, url: str) -> str: - _logger.info("Executing API call to %s", url) - try: - api_response = self._session.get(url) - api_response.raise_for_status() - json_response = api_response.json() - except request_exceptions.HTTPError as http_ex: - self._authorize() - raise LGHorizonApiConnectionError( - f"Unable to call {url}. Error:{str(http_ex)}" - ) from http_ex - _logger.debug("Result API call: %s", json_response) - return json_response - - def _register_customer_and_boxes(self): - self._update_customer() - self._get_channels() - if len(self.customer.settop_boxes) == 0: - _logger.warning("No boxes found.") - return - _logger.info("Registering boxes") - for device in self.customer.settop_boxes: - platform_type = device["platformType"] - if platform_type not in _supported_platforms: - continue - if ( - "platform_types" in self._country_settings - and platform_type in self._country_settings["platform_types"] - ): - platform_type = self._country_settings["platform_types"][platform_type] - else: - platform_type = None - box = LGHorizonBox( - device, platform_type, self._mqtt_client, self._auth, self._channels - ) - self.settop_boxes[box.device_id] = box - _logger.info("Box %s registered...", box.device_id) - - def _update_customer(self): - _logger.info("Get customer data") - personalisation_result = self._do_api_call( - f"{self._config['personalizationService']['URL']}/v1/customer/{self._auth.household_id}?with=profiles%2Cdevices" - ) - _logger.debug("Personalisation result: %s ", personalisation_result) - self.customer = LGHorizonCustomer(personalisation_result) - - def _get_channels(self): - self._update_entitlements() - _logger.info("Retrieving channels...") - channels_result = self._do_api_call( - f"{self._config['linearService']['URL']}/v2/channels?cityId={self.customer.city_id}&language={self._country_settings['language']}&productClass=Orion-DASH" - ) - for channel in channels_result: - if "isRadio" in channel and channel["isRadio"]: - continue - common_entitlements = list( - set(self._entitlements) & set(channel["linearProducts"]) - ) - if len(common_entitlements) == 0: - continue - channel_id = channel["id"] - self._channels[channel_id] = LGHorizonChannel(channel) - _logger.info("%s retrieved.", len(self._channels)) - - def get_display_channels(self): - """Returns channels to display baed on profile.""" - all_channels = self._channels.values() - if not self._profile_id or self._profile_id not in self.customer.profiles: - return all_channels - profile_channel_ids = self.customer.profiles[self._profile_id].favorite_channels - if len(profile_channel_ids) == 0: - return all_channels - - return [ - channel for channel in all_channels if channel.id in profile_channel_ids - ] - - def _get_replay_event(self, listing_id) -> Any: - """Get listing.""" - _logger.info("Retrieving replay event details...") - response = self._do_api_call( - f"{self._config['linearService']['URL']}/v2/replayEvent/{listing_id}?returnLinearContent=true&language={self._country_settings['language']}" - ) - _logger.info("Replay event details retrieved") - return response - - def get_recording_capacity(self) -> int: - """Returns remaining recording capacity""" - ctry_code = self._country_code[0:2] - if ctry_code == "gb": - _logger.debug("GB: not supported") - return None - try: - _logger.info("Retrieving recordingcapacity...") - quota_content = self._do_api_call( - f"{self._config['recordingService']['URL']}/customers/{self._auth.household_id}/quota" - ) - if "quota" not in quota_content and "occupied" not in quota_content: - _logger.error("Unable to fetch recording capacity...") - return None - capacity = (quota_content["occupied"] / quota_content["quota"]) * 100 - self.recording_capacity = round(capacity) - _logger.debug("Remaining recordingcapacity %s %%", self.recording_capacity) - return self.recording_capacity - except Exception: - _logger.error("Unable to fetch recording capacity...") - return None - - def get_recordings(self) -> List[LGHorizonBaseRecording]: - """Returns recordings.""" - _logger.info("Retrieving recordings...") - recording_content = self._do_api_call( - f"{self._config['recordingService']['URL']}/customers/{self._auth.household_id}/recordings?sort=time&sortOrder=desc&language={self._country_settings['language']}" - ) - recordings = [] - for recording_data_item in recording_content["data"]: - recording_type = recording_data_item["type"] - if recording_type == RECORDING_TYPE_SINGLE: - recordings.append(LGHorizonRecordingSingle(recording_data_item)) - elif recording_type in (RECORDING_TYPE_SEASON, RECORDING_TYPE_SHOW): - recordings.append(LGHorizonRecordingListSeasonShow(recording_data_item)) - _logger.info("%s recordings retrieved...", len(recordings)) - return recordings - - def get_recording_show(self, show_id: str) -> list[LGHorizonRecordingSingle]: - """Returns show recording""" - _logger.info("Retrieving show recordings...") - show_recording_content = self._do_api_call( - f"{self._config['recordingService']['URL']}/customers/{self._auth.household_id}/episodes/shows/{show_id}?source=recording&language=nl&sort=time&sortOrder=asc" - ) - recordings = [] - for item in show_recording_content["data"]: - if item["source"] == "show": - recordings.append(LGHorizonRecordingShow(item)) - else: - recordings.append(LGHorizonRecordingEpisode(item)) - _logger.info("%s showrecordings retrieved...", len(recordings)) - return recordings - - def _update_entitlements(self) -> None: - _logger.info("Retrieving entitlements...") - entitlements_json = self._do_api_call( - f"{self._config['purchaseService']['URL']}/v2/customers/{self._auth.household_id}/entitlements?enableDaypass=true" - ) - self._entitlements.clear() - for entitlement in entitlements_json["entitlements"]: - self._entitlements.append(entitlement["id"]) - - def _get_config(self, country_code: str): - base_country_code = country_code[0:2] - config_url = f"{self._country_settings['api_url']}/{base_country_code}/en/config-service/conf/web/backoffice.json" - result = self._do_api_call(config_url) - _logger.debug(result) - return result diff --git a/lghorizon/legacy/models.py b/lghorizon/legacy/models.py deleted file mode 100644 index 8afebb6..0000000 --- a/lghorizon/legacy/models.py +++ /dev/null @@ -1,768 +0,0 @@ -"""Models for LGHorizon API.""" - -# pylint: disable=broad-exception-caught -# pylint: disable=broad-exception-raised -from datetime import datetime -from typing import Callable, Dict -import json -import logging -import paho.mqtt.client as mqtt - -from .const import ( - BOX_PLAY_STATE_CHANNEL, - ONLINE_STANDBY, - ONLINE_RUNNING, - MEDIA_KEY_POWER, - MEDIA_KEY_PLAY_PAUSE, - MEDIA_KEY_STOP, - MEDIA_KEY_CHANNEL_UP, - MEDIA_KEY_CHANNEL_DOWN, - MEDIA_KEY_ENTER, - MEDIA_KEY_REWIND, - MEDIA_KEY_FAST_FORWARD, - MEDIA_KEY_RECORD, - RECORDING_TYPE_SEASON, -) - -from .helpers import make_id - -_logger = logging.getLogger(__name__) - - -class LGHorizonAuth: - """Class to hold LGHorizon authentication.""" - - household_id: str - access_token: str - refresh_token: str - refresh_token_expiry: datetime - username: str - mqtt_token: str = None - access_token: str = None - - def __init__(self): - """Initialize a session.""" - - def fill(self, auth_json) -> None: - """Fill the object.""" - self.household_id = auth_json["householdId"] - self.access_token = auth_json["accessToken"] - self.refresh_token = auth_json["refreshToken"] - self.username = auth_json["username"] - try: - self.refresh_token_expiry = datetime.fromtimestamp( - auth_json["refreshTokenExpiry"] - ) - except ValueError: - # VM uses milliseconds for the expiry time. - # If the year is too high to be valid, it assumes it's milliseconds and divides it - self.refresh_token_expiry = datetime.fromtimestamp( - auth_json["refreshTokenExpiry"] // 1000 - ) - - def is_expired(self) -> bool: - """Check if refresh token is expired.""" - return self.refresh_token_expiry - - -class LGHorizonPlayingInfo: - """Represent current state of a box.""" - - channel_id: str = None - title: str = None - image: str = None - source_type: str = None - paused: bool = False - channel_title: str = None - duration: float = None - position: float = None - last_position_update: datetime = None - - def __init__(self): - """Initialize the playing info.""" - - def set_paused(self, paused: bool): - """Set pause state.""" - self.paused = paused - - def set_channel(self, channel_id): - """Set channel.""" - self.channel_id = channel_id - - def set_title(self, title): - """Set title.""" - self.title = title - - def set_channel_title(self, title): - """Set channel title.""" - self.channel_title = title - - def set_image(self, image): - """Set image.""" - self.image = image - - def set_source_type(self, source_type): - """Set source type.""" - self.source_type = source_type - - def set_duration(self, duration: float): - """Set duration.""" - self.duration = duration - - def set_position(self, position: float): - """Set position.""" - self.position = position - - def set_last_position_update(self, last_position_update: datetime): - """Set last position update.""" - self.last_position_update = last_position_update - - def reset_progress(self): - """Reset the progress.""" - self.last_position_update = None - self.duration = None - self.position = None - - def reset(self): - """Reset the channel""" - self.channel_id = None - self.title = None - self.image = None - self.source_type = None - self.paused = False - self.channel_title = None - self.reset_progress() - - -class LGHorizonChannel: - """Represent a channel.""" - - id: str - title: str - stream_image: str - logo_image: str - channel_number: str - - def __init__(self, channel_json): - """Initialize a channel.""" - self.id = channel_json["id"] - self.title = channel_json["name"] - self.stream_image = self.get_stream_image(channel_json) - if "logo" in channel_json and "focused" in channel_json["logo"]: - self.logo_image = channel_json["logo"]["focused"] - else: - self.logo_image = "" - self.channel_number = channel_json["logicalChannelNumber"] - - def get_stream_image(self, channel_json) -> str: - """Returns the stream image.""" - image_stream = channel_json["imageStream"] - if "full" in image_stream: - return image_stream["full"] - if "small" in image_stream: - return image_stream["small"] - if "logo" in channel_json and "focused" in channel_json["logo"]: - return channel_json["logo"]["focused"] - return "" - - -class LGHorizonReplayEvent: - """LGhorizon replay event.""" - - episode_number: int = None - channel_id: str = None - event_id: str = None - season_number: int = None - title: str = None - episode_name: str = None - - def __init__(self, raw_json: str): - self.channel_id = raw_json["channelId"] - self.event_id = raw_json["eventId"] - self.title = raw_json["title"] - if "episodeName" in raw_json: - self.episode_name = raw_json["episodeName"] - if "episodeNumber" in raw_json: - self.episode_number = raw_json["episodeNumber"] - if "seasonNumber" in raw_json: - self.season_number = raw_json["seasonNumber"] - - -class LGHorizonBaseRecording: - """LgHorizon base recording.""" - - recording_id: str = None - title: str = None - image: str = None - recording_type: str = None - channel_id: str = None - - def __init__( - self, - recording_id: str, - title: str, - image: str, - channel_id: str, - recording_type: str, - ) -> None: - self.recording_id = recording_id - self.title = title - self.image = image - self.channel_id = channel_id - self.recording_type = recording_type - - -class LGHorizonRecordingSingle(LGHorizonBaseRecording): - """Represents a single recording.""" - - season_number: int = None - episode_number: int = None - - def __init__(self, recording_json): - """Init the single recording.""" - poster_url = None - if "poster" in recording_json and "url" in recording_json["poster"]: - poster_url = recording_json["poster"]["url"] - LGHorizonBaseRecording.__init__( - self, - recording_json["id"], - recording_json["title"], - poster_url, - recording_json["channelId"], - recording_json["type"], - ) - if "seasonNumber" in recording_json: - self.season_number = recording_json["seasonNumber"] - if "episodeNumber" in recording_json: - self.episode_number = recording_json["episodeNumber"] - - -class LGHorizonRecordingEpisode: - """Represents a single recording.""" - - episode_id: str = None - episode_title: str = None - season_number: int = None - episode_number: int = None - show_title: str = None - recording_state: str = None - image: str = None - - def __init__(self, recording_json): - """Init the single recording.""" - self.episode_id = recording_json["episodeId"] - self.episode_title = recording_json["episodeTitle"] - self.show_title = recording_json["showTitle"] - self.recording_state = recording_json["recordingState"] - if "seasonNumber" in recording_json: - self.season_number = recording_json["seasonNumber"] - if "episodeNumber" in recording_json: - self.episode_number = recording_json["episodeNumber"] - if "poster" in recording_json and "url" in recording_json["poster"]: - self.image = recording_json["poster"]["url"] - - -class LGHorizonRecordingShow: - """Represents a single recording.""" - - episode_id: str = None - show_title: str = None - season_number: int = None - episode_number: int = None - recording_state: str = None - image: str = None - - def __init__(self, recording_json): - """Init the single recording.""" - self.episode_id = recording_json["episodeId"] - self.show_title = recording_json["showTitle"] - self.recording_state = recording_json["recordingState"] - if "seasonNumber" in recording_json: - self.season_number = recording_json["seasonNumber"] - if "episodeNumber" in recording_json: - self.episode_number = recording_json["episodeNumber"] - if "poster" in recording_json and "url" in recording_json["poster"]: - self.image = recording_json["poster"]["url"] - - -class LGHorizonRecordingListSeasonShow(LGHorizonBaseRecording): - """LGHorizon Season show list.""" - - show_id: str = None - - def __init__(self, recording_season_json): - """Init the single recording.""" - - poster_url = None - if ( - "poster" in recording_season_json - and "url" in recording_season_json["poster"] - ): - poster_url = recording_season_json["poster"]["url"] - LGHorizonBaseRecording.__init__( - self, - recording_season_json["id"], - recording_season_json["title"], - poster_url, - recording_season_json["channelId"], - recording_season_json["type"], - ) - if self.recording_type == RECORDING_TYPE_SEASON: - self.show_id = recording_season_json["showId"] - else: - self.show_id = recording_season_json["id"] - - -class LGHorizonVod: - """LGHorizon video on demand.""" - - title: str = None - image: str = None - duration: float = None - - def __init__(self, vod_json) -> None: - self.title = vod_json["title"] - self.duration = vod_json["duration"] - - -class LGHorizonApp: - """LGHorizon App.""" - - title: str = None - image: str = None - - def __init__(self, app_state_json: str) -> None: - self.title = app_state_json["appName"] - self.image = app_state_json["logoPath"] - if not self.image.startswith("http:"): - self.image = "https:" + self.image - - -class LGHorizonMqttClient: - """LGHorizon MQTT client.""" - - _broker_url: str = None - _mqtt_client: mqtt.Client - _auth: LGHorizonAuth - client_id: str = None - _on_connected_callback: Callable = None - _on_message_callback: Callable[[str, str], None] = None - - @property - def is_connected(self): - """Is client connected.""" - return self._mqtt_client.is_connected - - def __init__( - self, - auth: LGHorizonAuth, - mqtt_broker_url: str, - on_connected_callback: Callable = None, - on_message_callback: Callable[[str], None] = None, - ): - self._auth = auth - self._broker_url = mqtt_broker_url.replace("wss://", "").replace( - ":443/mqtt", "" - ) - self.client_id = make_id() - self._mqtt_client = mqtt.Client( - client_id=self.client_id, - transport="websockets", - ) - - self._mqtt_client.ws_set_options( - headers={"Sec-WebSocket-Protocol": "mqtt, mqttv3.1, mqttv3.11"} - ) - self._mqtt_client.username_pw_set( - self._auth.household_id, self._auth.mqtt_token - ) - self._mqtt_client.tls_set() - self._mqtt_client.enable_logger(_logger) - self._mqtt_client.on_connect = self._on_mqtt_connect - self._on_connected_callback = on_connected_callback - self._on_message_callback = on_message_callback - - def _on_mqtt_connect(self, client, userdata, flags, result_code): # pylint: disable=unused-argument - if result_code == 0: - self._mqtt_client.on_message = self._on_client_message - self._mqtt_client.subscribe(self._auth.household_id) - self._mqtt_client.subscribe(self._auth.household_id + "/#") - self._mqtt_client.subscribe(self._auth.household_id + "/" + self.client_id) - self._mqtt_client.subscribe(self._auth.household_id + "/+/status") - self._mqtt_client.subscribe( - self._auth.household_id + "/+/networkRecordings" - ) - self._mqtt_client.subscribe( - self._auth.household_id + "/+/networkRecordings/capacity" - ) - self._mqtt_client.subscribe(self._auth.household_id + "/+/localRecordings") - self._mqtt_client.subscribe( - self._auth.household_id + "/+/localRecordings/capacity" - ) - self._mqtt_client.subscribe(self._auth.household_id + "/watchlistService") - self._mqtt_client.subscribe(self._auth.household_id + "/purchaseService") - self._mqtt_client.subscribe( - self._auth.household_id + "/personalizationService" - ) - self._mqtt_client.subscribe(self._auth.household_id + "/recordingStatus") - self._mqtt_client.subscribe( - self._auth.household_id + "/recordingStatus/lastUserAction" - ) - if self._on_connected_callback: - self._on_connected_callback() - elif result_code == 5: - self._mqtt_client.username_pw_set( - self._auth.household_id, self._auth.mqtt_token - ) - self.connect() - else: - _logger.error( - "Cannot connect to MQTT server with resultCode: %s", result_code - ) - - def connect(self) -> None: - """Connect the client.""" - self._mqtt_client.connect(self._broker_url, 443) - self._mqtt_client.loop_start() - - def _on_client_message(self, client, userdata, message): # pylint: disable=unused-argument - """Handle messages received by mqtt client.""" - _logger.debug("Received MQTT message. Topic: %s", message.topic) - json_payload = json.loads(message.payload) - _logger.debug("Message: %s", json_payload) - if self._on_message_callback: - self._on_message_callback(json_payload, message.topic) - - def publish_message(self, topic: str, json_payload: str) -> None: - """Publish a MQTT message.""" - self._mqtt_client.publish(topic, json_payload, qos=2) - - def disconnect(self) -> None: - """Disconnect the client.""" - if self._mqtt_client.is_connected(): - self._mqtt_client.disconnect() - - -class LGHorizonBox: - """The LGHorizon box.""" - - device_id: str = None - hashed_cpe_id: str = None - device_friendly_name: str = None - state: str = None - playing_info: LGHorizonPlayingInfo = None - manufacturer: str = None - model: str = None - recording_capacity: int = None - - _mqtt_client: LGHorizonMqttClient - _change_callback: Callable = None - _auth: LGHorizonAuth = None - _channels: Dict[str, LGHorizonChannel] = None - _message_stamp = None - - def __init__( - self, - box_json: str, - platform_type: Dict[str, str], - mqtt_client: LGHorizonMqttClient, - auth: LGHorizonAuth, - channels: Dict[str, LGHorizonChannel], - ): - self.device_id = box_json["deviceId"] - self.hashed_cpe_id = box_json["hashedCPEId"] - self.device_friendly_name = box_json["settings"]["deviceFriendlyName"] - self._mqtt_client = mqtt_client - self._auth = auth - self._channels = channels - self.playing_info = LGHorizonPlayingInfo() - if platform_type: - self.manufacturer = platform_type["manufacturer"] - self.model = platform_type["model"] - - def update_channels(self, channels: Dict[str, LGHorizonChannel]): - """Update the channels list.""" - self._channels = channels - - def register_mqtt(self) -> None: - """Register the mqtt connection.""" - if not self._mqtt_client.is_connected: - raise Exception("MQTT client not connected.") - topic = f"{self._auth.household_id}/{self._mqtt_client.client_id}/status" - payload = { - "source": self._mqtt_client.client_id, - "state": ONLINE_RUNNING, - "deviceType": "HGO", - } - self._mqtt_client.publish_message(topic, json.dumps(payload)) - - def set_callback(self, change_callback: Callable) -> None: - """Set a callback function.""" - self._change_callback = change_callback - - def update_state(self, payload): - """Register a new settop box.""" - state = payload["state"] - if self.state == state: - return - self.state = state - if state == ONLINE_STANDBY: - self.playing_info.reset() - if self._change_callback: - self._change_callback(self.device_id) - else: - self._request_settop_box_state() - self._request_settop_box_recording_capacity() - - def update_recording_capacity(self, payload) -> None: - """Updates the recording capacity.""" - if "CPE.capacity" not in payload or "used" not in payload: - return - self.recording_capacity = payload["used"] - - def update_with_replay_event( - self, source_type: str, event: LGHorizonReplayEvent, channel: LGHorizonChannel - ) -> None: - """Update box with replay event.""" - self.playing_info.set_source_type(source_type) - self.playing_info.set_channel(channel.id) - self.playing_info.set_channel_title(channel.title) - title = event.title - if event.episode_name: - title += f": {event.episode_name}" - self.playing_info.set_title(title) - self.playing_info.set_image(channel.stream_image) - self.playing_info.reset_progress() - self._trigger_callback() - - def update_with_recording( - self, - source_type: str, - recording: LGHorizonRecordingSingle, - channel: LGHorizonChannel, - start: float, - end: float, - last_speed_change: float, - relative_position: float, - ) -> None: - """Update box with recording.""" - self.playing_info.set_source_type(source_type) - self.playing_info.set_channel(channel.id) - self.playing_info.set_channel_title(channel.title) - self.playing_info.set_title(f"{recording.title}") - self.playing_info.set_image(recording.image) - start_dt = datetime.fromtimestamp(start / 1000.0) - end_dt = datetime.fromtimestamp(end / 1000.0) - duration = (end_dt - start_dt).total_seconds() - self.playing_info.set_duration(duration) - self.playing_info.set_position(relative_position / 1000.0) - last_update_dt = datetime.fromtimestamp(last_speed_change / 1000.0) - self.playing_info.set_last_position_update(last_update_dt) - self._trigger_callback() - - def update_with_vod( - self, - source_type: str, - vod: LGHorizonVod, - last_speed_change: float, - relative_position: float, - ) -> None: - """Update box with vod.""" - self.playing_info.set_source_type(source_type) - self.playing_info.set_channel(None) - self.playing_info.set_channel_title(None) - self.playing_info.set_title(vod.title) - self.playing_info.set_image(None) - self.playing_info.set_duration(vod.duration) - self.playing_info.set_position(relative_position / 1000.0) - last_update_dt = datetime.fromtimestamp(last_speed_change / 1000.0) - self.playing_info.set_last_position_update(last_update_dt) - self._trigger_callback() - - def update_with_app(self, source_type: str, app: LGHorizonApp) -> None: - """Update box with app.""" - self.playing_info.set_source_type(source_type) - self.playing_info.set_channel(None) - self.playing_info.set_channel_title(app.title) - self.playing_info.set_title(app.title) - self.playing_info.set_image(app.image) - self.playing_info.reset_progress() - self._trigger_callback() - - def _trigger_callback(self): - if self._change_callback: - _logger.debug("Callback called from box %s", self.device_id) - self._change_callback(self.device_id) - - def turn_on(self) -> None: - """Turn the settop box on.""" - - if self.state == ONLINE_STANDBY: - self.send_key_to_box(MEDIA_KEY_POWER) - - def turn_off(self) -> None: - """Turn the settop box off.""" - if self.state == ONLINE_RUNNING: - self.send_key_to_box(MEDIA_KEY_POWER) - self.playing_info.reset() - - def pause(self) -> None: - """Pause the given settopbox.""" - if self.state == ONLINE_RUNNING and not self.playing_info.paused: - self.send_key_to_box(MEDIA_KEY_PLAY_PAUSE) - - def play(self) -> None: - """Resume the settopbox.""" - if self.state == ONLINE_RUNNING and self.playing_info.paused: - self.send_key_to_box(MEDIA_KEY_PLAY_PAUSE) - - def stop(self) -> None: - """Stop the settopbox.""" - if self.state == ONLINE_RUNNING: - self.send_key_to_box(MEDIA_KEY_STOP) - - def next_channel(self): - """Select the next channel for given settop box.""" - if self.state == ONLINE_RUNNING: - self.send_key_to_box(MEDIA_KEY_CHANNEL_UP) - - def previous_channel(self) -> None: - """Select the previous channel for given settop box.""" - if self.state == ONLINE_RUNNING: - self.send_key_to_box(MEDIA_KEY_CHANNEL_DOWN) - - def press_enter(self) -> None: - """Press enter on the settop box.""" - if self.state == ONLINE_RUNNING: - self.send_key_to_box(MEDIA_KEY_ENTER) - - def rewind(self) -> None: - """Rewind the settop box.""" - if self.state == ONLINE_RUNNING: - self.send_key_to_box(MEDIA_KEY_REWIND) - - def fast_forward(self) -> None: - """Fast forward the settop box.""" - if self.state == ONLINE_RUNNING: - self.send_key_to_box(MEDIA_KEY_FAST_FORWARD) - - def record(self): - """Record on the settop box.""" - if self.state == ONLINE_RUNNING: - self.send_key_to_box(MEDIA_KEY_RECORD) - - def is_available(self) -> bool: - """Return the availability of the settop box.""" - return self.state == ONLINE_RUNNING or self.state == ONLINE_STANDBY - - def set_channel(self, source: str) -> None: - """Change te channel from the settopbox.""" - channel = [src for src in self._channels.values() if src.title == source][0] - payload = ( - '{"id":"' - + make_id(8) - + '","type":"CPE.pushToTV","source":{"clientId":"' - + self._mqtt_client.client_id - + '","friendlyDeviceName":"Home Assistant"},' - + '"status":{"sourceType":"linear","source":{"channelId":"' - + channel.id - + '"},"relativePosition":0,"speed":1}}' - ) - - self._mqtt_client.publish_message( - f"{self._auth.household_id}/{self.device_id}", payload - ) - - def play_recording(self, recording_id): - """Play recording.""" - payload = ( - '{"id":"' - + make_id(8) - + '","type":"CPE.pushToTV","source":{"clientId":"' - + self._mqtt_client.client_id - + '","friendlyDeviceName":"Home Assistant"},' - + '"status":{"sourceType":"nDVR","source":{"recordingId":"' - + recording_id - + '"},"relativePosition":0}}' - ) - self._mqtt_client.publish_message( - f"{self._auth.household_id}/{self.device_id}", payload - ) - - def send_key_to_box(self, key: str) -> None: - """Send emulated (remote) key press to settopbox.""" - payload_dict = { - "type": "CPE.KeyEvent", - "runtimeType": "key", - "id": "ha", - "source": self.device_id.lower(), - "status": {"w3cKey": key, "eventType": "keyDownUp"}, - } - payload = json.dumps(payload_dict) - self._mqtt_client.publish_message( - f"{self._auth.household_id}/{self.device_id}", payload - ) - - # def _set_unknown_channel_info(self) -> None: - # """Set unknown channel info.""" - # _logger.warning("Couldn't set channel. Channel info set to unknown...") - # self.playing_info.set_source_type(BOX_PLAY_STATE_CHANNEL) - # self.playing_info.set_channel(None) - # self.playing_info.set_title("No information available") - # self.playing_info.set_image(None) - # self.playing_info.set_paused(False) - - def _request_settop_box_state(self) -> None: - """Send mqtt message to receive state from settop box.""" - topic = f"{self._auth.household_id}/{self.device_id}" - payload = { - "id": make_id(8), - "type": "CPE.getUiStatus", - "source": self._mqtt_client.client_id, - } - self._mqtt_client.publish_message(topic, json.dumps(payload)) - - def _request_settop_box_recording_capacity(self) -> None: - """Send mqtt message to receive state from settop box.""" - topic = f"{self._auth.household_id}/{self.device_id}" - payload = { - "id": make_id(8), - "type": "CPE.capacity", - "source": self._mqtt_client.client_id, - } - self._mqtt_client.publish_message(topic, json.dumps(payload)) - - -class LGHorizonProfile: - """LGHorizon profile.""" - - profile_id: str = None - name: str = None - favorite_channels: list[str] = None - - def __init__(self, json_payload): - self.profile_id = json_payload["profileId"] - self.name = json_payload["name"] - self.favorite_channels = json_payload["favoriteChannels"] - - -class LGHorizonCustomer: - """LGHorizon customer""" - - customer_id: str = None - hashed_customer_id: str = None - country_id: str = None - city_id: int = 0 - settop_boxes: list[str] = None - profiles: Dict[str, LGHorizonProfile] = {} - - def __init__(self, json_payload): - self.customer_id = json_payload["customerId"] - self.hashed_customer_id = json_payload["hashedCustomerId"] - self.country_id = json_payload["countryId"] - self.city_id = json_payload["cityId"] - if "assignedDevices" in json_payload: - self.settop_boxes = json_payload["assignedDevices"] - if "profiles" in json_payload: - for profile in json_payload["profiles"]: - self.profiles[profile["profileId"]] = LGHorizonProfile(profile) diff --git a/lghorizon/lghorizon_api.py b/lghorizon/lghorizon_api.py index aa622c5..f008bb9 100644 --- a/lghorizon/lghorizon_api.py +++ b/lghorizon/lghorizon_api.py @@ -191,12 +191,16 @@ async def _on_mqtt_message(self, mqtt_message: dict, mqtt_topic: str): case LGHorizonMessageType.STATUS: message.__class__ = LGHorizonStatusMessage status_message = cast(LGHorizonStatusMessage, message) - device = self._devices[status_message.source] + device = self._devices.get(status_message.source, None) + if not device: + return await device.handle_status_message(status_message) case LGHorizonMessageType.UI_STATUS: message.__class__ = LGHorizonUIStatusMessage ui_status_message = cast(LGHorizonUIStatusMessage, message) - device = self._devices[ui_status_message.source] + device = self._devices.get(ui_status_message.source, None) + if not device: + return if ( not device.device_state.state == LGHorizonRunningState.ONLINE_RUNNING @@ -265,7 +269,7 @@ async def get_show_recordings( lang = await self._customer.get_profile_lang(self._profile_id) episodes_json = await self.auth.request( service_url, - f"/customers/8436830_nl/episodes/shows/{show_id}?source=recording&isAdult=false&offset=0&limit=100&profileId={self._profile_id}&language={lang}&channelId={channel_id}&sort=time&sortOrder=asc", + f"/customers/{self.auth.household_id}/episodes/shows/{show_id}?source=recording&isAdult=false&offset=0&limit=100&profileId={self._profile_id}&language={lang}&channelId={channel_id}&sort=time&sortOrder=asc", ) recordings = await self._recording_factory.create_episodes(episodes_json) return recordings diff --git a/lghorizon/lghorizon_device.py b/lghorizon/lghorizon_device.py index e7c271d..9d864e8 100644 --- a/lghorizon/lghorizon_device.py +++ b/lghorizon/lghorizon_device.py @@ -146,8 +146,6 @@ async def update_channels(self, channels: Dict[str, LGHorizonChannel]): async def register_mqtt(self) -> None: """Register the mqtt connection.""" - if not self._mqtt_client.is_connected: - raise LGHorizonApiConnectionError("MQTT client not connected.") topic = f"{self._auth.household_id}/{self._mqtt_client.client_id}/status" payload = { "source": self._mqtt_client.client_id, @@ -200,7 +198,7 @@ async def update_recording_capacity(self, payload) -> None: self.recording_capacity = payload["used"] # Use the setter async def _trigger_callback(self): - if self._change_callback: + if self._change_callback is not None: _LOGGER.debug("Callback called from box %s", self.device_id) await self._change_callback(self.device_id) diff --git a/lghorizon/lghorizon_device_state_processor.py b/lghorizon/lghorizon_device_state_processor.py index b405c10..47c42e0 100644 --- a/lghorizon/lghorizon_device_state_processor.py +++ b/lghorizon/lghorizon_device_state_processor.py @@ -15,6 +15,7 @@ LGHorizonReplaySource, LGHorizonNDVRSource, LGHorizonReviewBufferSource, + LGHorizonRecordingSource, ) from .lghorizon_models import LGHorizonAuth from .lghorizon_models import ( @@ -119,9 +120,10 @@ async def _process_apps_state( device_state: LGHorizonDeviceState, apps_state: LGHorizonAppsState, ) -> None: - device_state.channel_id = apps_state.id + device_state.id = apps_state.id device_state.title = apps_state.app_name device_state.image = apps_state.logo_path + device_state.ui_state_type = LGHorizonUIStateType.APPS async def _process_linear_state( self, @@ -133,7 +135,6 @@ async def _process_linear_state( return player_state.source.__class__ = LGHorizonLinearSource source = cast(LGHorizonLinearSource, player_state.source) - device_state.ui_state_type = LGHorizonUIStateType.APPS service_config = await self._auth.get_service_config() service_url = await service_config.get_service_url("linearService") lang = await self._customer.get_profile_lang(self._profile_id) @@ -144,12 +145,15 @@ async def _process_linear_state( service_path, ) replay_event = LGHorizonReplayEvent(event_json) + device_state.id = replay_event.event_id channel = self._channels[replay_event.channel_id] device_state.source_type = source.source_type device_state.channel_id = channel.channel_number device_state.channel_name = channel.title - device_state.title = replay_event.title - device_state.sub_title = replay_event.full_episode_title + device_state.episode_title = replay_event.episode_name + device_state.season_number = replay_event.season_number + device_state.episode_number = replay_event.episode_number + device_state.show_title = replay_event.title # Add random number to url to force refresh join_param = "?" @@ -181,12 +185,15 @@ async def _process_reviewbuffer_state( service_path, ) replay_event = LGHorizonReplayEvent(event_json) + device_state.id = replay_event.event_id channel = self._channels[replay_event.channel_id] device_state.source_type = source.source_type device_state.channel_id = channel.channel_number device_state.channel_name = channel.title - device_state.title = replay_event.title - device_state.sub_title = replay_event.full_episode_title + device_state.episode_title = replay_event.episode_name + device_state.season_number = replay_event.season_number + device_state.episode_number = replay_event.episode_number + device_state.show_title = replay_event.title # Add random number to url to force refresh join_param = "?" @@ -218,11 +225,13 @@ async def _process_replay_state( service_path, ) replay_event = LGHorizonReplayEvent(event_json) + device_state.id = replay_event.event_id device_state.source_type = source.source_type device_state.channel_id = None - device_state.title = replay_event.title - if replay_event.full_episode_title: - device_state.sub_title = replay_event.full_episode_title + device_state.episode_title = replay_event.episode_name + device_state.season_number = replay_event.season_number + device_state.episode_number = replay_event.episode_number + device_state.show_title = replay_event.title # Add random number to url to force refresh device_state.image = await self._get_intent_image_url(replay_event.event_id) @@ -248,9 +257,14 @@ async def _process_vod_state( service_path, ) vod = LGHorizonVOD(vod_json) + device_state.id = vod.id device_state.title = vod.title device_state.sub_title = vod.full_episode_title device_state.duration = vod.duration + device_state.episode_title = vod.title + device_state.season_number = vod.season_number + device_state.episode_number = vod.episode_number + device_state.show_title = vod.series_title device_state.image = await self._get_intent_image_url(vod.id) await device_state.reset_progress() @@ -271,13 +285,21 @@ async def _process_ndvr_state( service_path, ) recording = LGHorizonRecordingSingle(recording_json) - device_state.title = recording.title - device_state.sub_title = recording.full_episode_title + device_state.id = recording.id device_state.channel_id = recording.channel_id if recording.channel_id: channel = self._channels[recording.channel_id] device_state.channel_name = channel.title + device_state.episode_title = recording.episode_title + device_state.season_number = recording.season_number + device_state.episode_number = recording.episode_number + if recording.source == LGHorizonRecordingSource.SHOW: + device_state.show_title = recording.title + else: + device_state.show_title = recording.show_title + device_state.image = await self._get_intent_image_url(recording.id) + async def _get_intent_image_url(self, intent_id: str) -> Optional[str]: """Get intent image url.""" service_config = await self._auth.get_service_config() diff --git a/lghorizon/lghorizon_models.py b/lghorizon/lghorizon_models.py index 70a88c5..ae0c8a7 100644 --- a/lghorizon/lghorizon_models.py +++ b/lghorizon/lghorizon_models.py @@ -824,13 +824,16 @@ async def get_profile_lang(self, profile_id: str) -> str: class LGHorizonDeviceState: """Represent current state of a box.""" + _id: Optional[str] _channel_id: Optional[str] _channel_name: Optional[str] - _title: Optional[str] + _show_title: Optional[str] + _episode_title: Optional[str] + _season_number: Optional[int] + _episode_number: Optional[int] _image: Optional[str] _source_type: LGHorizonSourceType _paused: bool - _sub_title: Optional[str] _duration: Optional[float] _position: Optional[float] _last_position_update: Optional[datetime] @@ -840,18 +843,21 @@ class LGHorizonDeviceState: def __init__(self) -> None: """Initialize the playing info.""" self._channel_id = None - self._title = None + self._show_title = None + self._episode_title = None + self._season_number = None + self._episode_number = None self._image = None self._source_type = LGHorizonSourceType.UNKNOWN self._ui_state_type = LGHorizonUIStateType.UNKNOWN self._paused = False - self.sub_title = None self._duration = None self._position = None self._last_position_update = None self._state = LGHorizonRunningState.UNKNOWN self._speed = None self._channel_name = None + self._id = None @property def state(self) -> LGHorizonRunningState: @@ -873,6 +879,16 @@ def channel_id(self, value: Optional[str]) -> None: """Set the channel ID.""" self._channel_id = value + @property + def id(self) -> Optional[str]: + """Return the channel ID.""" + return self._id + + @id.setter + def id(self, value: Optional[str]) -> None: + """Set the channel ID.""" + self._id = value + @property def channel_name(self) -> Optional[str]: """Return the channel ID.""" @@ -884,14 +900,44 @@ def channel_name(self, value: Optional[str]) -> None: self._channel_name = value @property - def title(self) -> Optional[str]: + def show_title(self) -> Optional[str]: + """Return the title.""" + return self._show_title + + @show_title.setter + def show_title(self, value: Optional[str]) -> None: + """Set the title.""" + self._show_title = value + + @property + def episode_title(self) -> Optional[str]: + """Return the title.""" + return self._episode_title + + @episode_title.setter + def episode_title(self, value: Optional[str]) -> None: + """Set the title.""" + self._episode_title = value + + @property + def episode_number(self) -> Optional[int]: + """Return the title.""" + return self._episode_number + + @episode_number.setter + def episode_number(self, value: Optional[int]) -> None: + """Set the title.""" + self._episode_number = value + + @property + def season_number(self) -> Optional[int]: """Return the title.""" - return self._title + return self._season_number - @title.setter - def title(self, value: Optional[str]) -> None: + @season_number.setter + def season_number(self, value: Optional[int]) -> None: """Set the title.""" - self._title = value + self._season_number = value @property def image(self) -> Optional[str]: @@ -930,16 +976,6 @@ def paused(self) -> bool: return False return self.speed == 0 - @property - def sub_title(self) -> Optional[str]: - """Return the channel title.""" - return self._sub_title - - @sub_title.setter - def sub_title(self, value: Optional[str]) -> None: - """Set the channel title.""" - self._sub_title = value - @property def duration(self) -> Optional[float]: """Return the duration of the media.""" @@ -989,7 +1025,10 @@ def speed(self, value: int | None) -> None: async def reset(self) -> None: """Reset all playing information.""" self.channel_id = None - self.title = None + self.episode_number = None + self.season_number = None + self.episode_title = None + self.show_title = None self.sub_title = None self.image = None self.source_type = LGHorizonSourceType.UNKNOWN @@ -1193,7 +1232,7 @@ def id(self) -> str: @property def title(self) -> str: """Return the title of the recording.""" - return self._recording_payload["title"] + return self._recording_payload.get("title", "unknown") @property def channel_id(self) -> str: @@ -1221,6 +1260,11 @@ def episode_title(self) -> Optional[str]: """Return the episode title of the recording.""" return self._recording_payload.get("episodeTitle", None) + @property + def episode_id(self) -> Optional[str]: + """Return the episode title of the recording.""" + return self._recording_payload.get("episodeId", None) + @property def season_number(self) -> Optional[int]: """Return the season number of the recording.""" @@ -1246,16 +1290,6 @@ def season_id(self) -> Optional[str]: """Return the season ID of the recording.""" return self._recording_payload.get("seasonId", None) - @property - def full_episode_title(self) -> Optional[str]: - """Return the full episode title of the recording.""" - if not self.season_number and not self.episode_number: - return None - full_title = f"""S{self.season_number:02d}E{self.episode_number:02d}""" - if self.episode_title: - full_title += f": {self.episode_title}" - return full_title - @property def channel_id(self) -> Optional[str]: """Return the channel ID of the recording.""" @@ -1284,6 +1318,11 @@ def season_title(self) -> str: """Return the season title of the recording.""" return self._recording_payload.get("seasonTitle", "") + @property + def show_id(self) -> str: + """Return the season title of the recording.""" + return self._recording_payload.get("showId", "") + @property def most_relevant_episode(self) -> Optional[LGHOrizonRelevantEpisode]: """Return the most relevant episode of the season.""" @@ -1326,7 +1365,7 @@ def __init__(self, recordings: List[LGHorizonRecording]) -> None: self._recordings = recordings @property - def recordings(self) -> int: + def recordings(self) -> List[LGHorizonRecording]: """Return the total number of recordings.""" return self._recordings diff --git a/lghorizon/lghorizon_mqtt_client.py b/lghorizon/lghorizon_mqtt_client.py index 28ceefb..30ff6fe 100644 --- a/lghorizon/lghorizon_mqtt_client.py +++ b/lghorizon/lghorizon_mqtt_client.py @@ -1,9 +1,10 @@ import asyncio import json import logging +from typing import Any, Callable, Coroutine import paho.mqtt.client as mqtt -from typing import Any, Callable, Coroutine + from .helpers import make_id from .lghorizon_models import LGHorizonAuth @@ -11,32 +12,36 @@ class LGHorizonMqttClient: - """LGHorizon MQTT client.""" - - _mqtt_broker_url: str = "" - _mqtt_client: mqtt.Client - _auth: LGHorizonAuth - _mqtt_token: str = "" - client_id: str = "" - _on_connected_callback: Callable[[], Coroutine[Any, Any, Any]] - _on_message_callback: Callable[[dict, str], Coroutine[Any, Any, Any]] - - @property - def is_connected(self): - """Is client connected.""" - return self._mqtt_client.is_connected + """Async‑vriendelijke wrapper rond Paho MQTT.""" def __init__( self, auth: LGHorizonAuth, on_connected_callback: Callable[[], Coroutine[Any, Any, Any]], on_message_callback: Callable[[dict, str], Coroutine[Any, Any, Any]], - ): - """Initialize the MQTT client.""" + loop: asyncio.AbstractEventLoop, + ) -> None: self._auth = auth self._on_connected_callback = on_connected_callback self._on_message_callback = on_message_callback - self._loop = asyncio.get_event_loop() + self._loop = loop + + self._mqtt_client: mqtt.Client | None = None + self._mqtt_broker_url: str = "" + self._mqtt_token: str = "" + self.client_id: str = "" + + # FIFO queues + self._message_queue: asyncio.Queue = asyncio.Queue() + self._publish_queue: asyncio.Queue = asyncio.Queue() + + # Worker tasks + self._message_worker_task: asyncio.Task | None = None + self._publish_worker_task: asyncio.Task | None = None + + @property + def is_connected(self) -> bool: + return self._mqtt_client is not None and self._mqtt_client.is_connected() @classmethod async def create( @@ -44,80 +49,155 @@ async def create( auth: LGHorizonAuth, on_connected_callback: Callable[[], Coroutine[Any, Any, Any]], on_message_callback: Callable[[dict, str], Coroutine[Any, Any, Any]], - ): - """Create the MQTT client.""" - instance = cls(auth, on_connected_callback, on_message_callback) + ) -> "LGHorizonMqttClient": + loop = asyncio.get_running_loop() + instance = cls(auth, on_connected_callback, on_message_callback, loop) + + # Service config ophalen service_config = await auth.get_service_config() mqtt_broker_url = await service_config.get_service_url("mqttBroker") instance._mqtt_broker_url = mqtt_broker_url.replace("wss://", "").replace( ":443/mqtt", "" ) + instance.client_id = await make_id() + + # Paho client instance._mqtt_client = mqtt.Client( client_id=instance.client_id, transport="websockets", ) - instance._mqtt_client.ws_set_options( headers={"Sec-WebSocket-Protocol": "mqtt, mqttv3.1, mqttv3.11"} ) + + # Token ophalen instance._mqtt_token = await auth.get_mqtt_token() - instance._mqtt_client.username_pw_set(auth.household_id, instance._mqtt_token) - instance._mqtt_client.tls_set() + instance._mqtt_client.username_pw_set( + auth.household_id, + instance._mqtt_token, + ) + + # TLS instellen (blocking → executor) + await loop.run_in_executor(None, instance._mqtt_client.tls_set) + instance._mqtt_client.enable_logger(_logger) instance._mqtt_client.on_connect = instance._on_connect - instance._on_connected_callback = on_connected_callback - instance._on_message_callback = on_message_callback + instance._mqtt_client.on_message = instance._on_message + return instance - def _on_connect(self, client, userdata, flags, result_code): # pylint: disable=unused-argument + async def connect(self) -> None: + """Async‑veilige connect.""" + if not self._mqtt_client: + raise RuntimeError("MQTT client not initialized") + + # Blocking connect → executor + await self._loop.run_in_executor( + None, + self._mqtt_client.connect, + self._mqtt_broker_url, + 443, + ) + + # Start Paho thread + self._mqtt_client.loop_start() + + # Start workers + self._message_worker_task = asyncio.create_task(self._message_worker()) + self._publish_worker_task = asyncio.create_task(self._publish_worker()) + + async def disconnect(self) -> None: + """Async‑veilige disconnect.""" + if not self._mqtt_client: + return + + # Stop workers + if self._message_worker_task: + self._message_worker_task.cancel() + self._message_worker_task = None + + if self._publish_worker_task: + self._publish_worker_task.cancel() + self._publish_worker_task = None + + # Blocking disconnect → executor + await self._loop.run_in_executor(None, self._mqtt_client.disconnect) + self._mqtt_client.loop_stop() + + async def subscribe(self, topic: str) -> None: + """Subscribe op een topic (Paho doet dit sync in eigen thread).""" + if not self._mqtt_client: + raise RuntimeError("MQTT client not initialized") + + self._mqtt_client.subscribe(topic) + + async def publish_message(self, topic: str, json_payload: str) -> None: + """Queue een publish-opdracht.""" + await self._publish_queue.put((topic, json_payload)) + + # ------------------------- + # INTERNAL CALLBACKS + # ------------------------- + + def _on_connect(self, client, userdata, flags, result_code): if result_code == 0: - self._mqtt_client.on_message = self._on_message - if self._on_connected_callback: - asyncio.run_coroutine_threadsafe( - self._on_connected_callback(), self._loop - ) + asyncio.run_coroutine_threadsafe( + self._on_connected_callback(), + self._loop, + ) elif result_code == 5: - self._mqtt_client.username_pw_set(self._auth.household_id, self._mqtt_token) + # Token verlopen → opnieuw proberen + self._mqtt_client.username_pw_set( + self._auth.household_id, + self._mqtt_token, + ) asyncio.run_coroutine_threadsafe(self.connect(), self._loop) else: - _logger.error( - "Cannot connect to MQTT server with resultCode: %s", result_code - ) + _logger.error("MQTT connect error: %s", result_code) - def _on_message(self, client, userdata, message): # pylint: disable=unused-argument - """Wrapper for handling MQTT messages in a thread-safe manner.""" + def _on_message(self, client, userdata, message): + """Ontvangen bericht → FIFO queue.""" asyncio.run_coroutine_threadsafe( - self._on_client_message(client, userdata, message), self._loop + self._message_queue.put((message.topic, message.payload)), + self._loop, ) - async def connect(self) -> None: - """Connect the client.""" - self._mqtt_client.connect(self._mqtt_broker_url, 443) - self._mqtt_client.loop_start() + # ------------------------- + # MESSAGE WORKER (FIFO) + # ------------------------- - async def subscribe(self, topic: str) -> None: - """Subscribe to a MQTT topic.""" - self._mqtt_client.subscribe(topic) + async def _message_worker(self): + """Verwerkt berichten in volgorde van binnenkomst.""" + while True: + topic, payload = await self._message_queue.get() - async def publish_message(self, topic: str, json_payload: str) -> None: - """Publish a MQTT message.""" - self._mqtt_client.publish(topic, json_payload, qos=2) + try: + json_payload = json.loads(payload) + await self._on_message_callback(json_payload, topic) + except Exception: + _logger.exception("Error processing MQTT message") - async def disconnect(self) -> None: - """Disconnect the client.""" - if self._mqtt_client.is_connected(): - self._mqtt_client.disconnect() - - async def _on_client_message(self, client, userdata, message): # pylint: disable=unused-argument - """Handle messages received by mqtt client.""" - json_payload = await self._loop.run_in_executor( - None, json.loads, message.payload - ) - _logger.debug( - "Received MQTT message \n\ntopic: %s\npayload:\n\n%s\n", - message.topic, - json.dumps(json_payload, indent=2), - ) - if self._on_message_callback: - await self._on_message_callback(json_payload, message.topic) + self._message_queue.task_done() + + # ------------------------- + # PUBLISH WORKER (FIFO) + # ------------------------- + + async def _publish_worker(self): + """Verwerkt publish-opdrachten in volgorde, maar alleen als connected.""" + while True: + topic, payload = await self._publish_queue.get() + + try: + # Wacht tot MQTT echt connected is + while not self.is_connected: + await asyncio.sleep(0.1) + + # Publish is non-blocking + self._mqtt_client.publish(topic, payload, qos=2) + + except Exception: + _logger.exception("Error publishing MQTT message") + + self._publish_queue.task_done() diff --git a/lghorizon/lghorizon_recording_factory.py b/lghorizon/lghorizon_recording_factory.py index 1129ecf..d08d811 100644 --- a/lghorizon/lghorizon_recording_factory.py +++ b/lghorizon/lghorizon_recording_factory.py @@ -50,6 +50,6 @@ async def create_episodes(self, episode_json: dict) -> LGHorizonShowRecordingLis for recording in episode_json["data"]: recording_single = LGHorizonRecordingSingle(recording) if show_title is None: - show_title = recording_single.show_title + show_title = recording_single.show_title or recording_single.title recording_list.append(recording_single) return LGHorizonShowRecordingList(show_title, show_image, recording_list) diff --git a/main.py b/main.py index 70e9b51..03a3acb 100644 --- a/main.py +++ b/main.py @@ -54,7 +54,7 @@ async def main(): async def device_callback(device_id: str): device = devices[device_id] print( - f"Device {device.device_id} state changed. Status:\n\nName: {device.device_friendly_name}\nState: {device.device_state.state.value}\nChannel: {device.device_state.channel_name}\nTitle: {device.device_state.title}\nSubtitle: {device.device_state.sub_title}\nSource type: {device.device_state.source_type.value}\n\n", + f"Device {device.device_id} state changed. Status:\n\nName: {device.device_friendly_name}\nState: {device.device_state.state.value}\nChannel: {device.device_state.channel_name}\nShow: {device.device_state.show_title}\nEpisode: {device.device_state.episode_title}\nSource type: {device.device_state.source_type.value}\n\n", ) try: From c8a72ad30cfcfae97d28ffeb973f31414d827283 Mon Sep 17 00:00:00 2001 From: Rudolf Offereins Date: Mon, 2 Feb 2026 23:03:48 +0000 Subject: [PATCH 12/16] Add support for seek and sending messages --- lghorizon/lghorizon_api.py | 2 + lghorizon/lghorizon_device.py | 73 +++++++++++-- lghorizon/lghorizon_device_state_processor.py | 78 +++++++++---- lghorizon/lghorizon_message_factory.py | 1 - lghorizon/lghorizon_models.py | 103 ++++++++++++++---- main.py | 14 +-- 6 files changed, 207 insertions(+), 64 deletions(-) diff --git a/lghorizon/lghorizon_api.py b/lghorizon/lghorizon_api.py index f008bb9..e82520a 100644 --- a/lghorizon/lghorizon_api.py +++ b/lghorizon/lghorizon_api.py @@ -158,8 +158,10 @@ async def _create_mqtt_client(self) -> LGHorizonMqttClient: async def _on_mqtt_connected(self): """MQTT connected callback.""" + await self._mqtt_client.subscribe("#") await self._mqtt_client.subscribe(self.auth.household_id) # await self._mqtt_client.subscribe(self.auth.household_id + "/#") + # await self._mqtt_client.subscribe(self.auth.household_id + "/+/#") await self._mqtt_client.subscribe( self.auth.household_id + "/" + self._mqtt_client.client_id ) diff --git a/lghorizon/lghorizon_device.py b/lghorizon/lghorizon_device.py index 9d864e8..dc76b4c 100644 --- a/lghorizon/lghorizon_device.py +++ b/lghorizon/lghorizon_device.py @@ -1,7 +1,7 @@ """LG Horizon Device.""" from __future__ import annotations - +import asyncio import json import logging from typing import Any, Callable, Coroutine, Dict, Optional @@ -265,22 +265,71 @@ async def record(self): if self._device_state.state == LGHorizonRunningState.ONLINE_RUNNING: await self.send_key_to_box(MEDIA_KEY_RECORD) + async def set_player_position(self, position: int) -> None: + """Set the player position on the settop box.""" + payload = { + "source": self.device_id, + "type": "CPE.setPlayerPosition", + "runtimeType": "setPlayerposition", + "id": await make_id(), + "version": "1.3.11", + "status": {"relativePosition": position}, + } + payload_str = json.dumps(payload) + await self._mqtt_client.publish_message( + f"{self._auth.household_id}/{self.device_id}", payload_str + ) + + async def display_message(self, sourceType: str, message: str) -> None: + """Toon een bericht op de settopbox en herhaal dit voor langere zichtbaarheid.""" + + # We sturen de payload 3 keer met een kortere tussentijd + for i in range(3): + payload = { + "id": await make_id(8), + "type": "CPE.pushToTV", + "source": { + "clientId": self._mqtt_client.client_id, + "friendlyDeviceName": f"\n\n{message}", + }, + "status": { + "sourceType": sourceType, + "source": {"channelId": "1234"}, + "title": "Nieuwe melding", + "relativePosition": 0, + "speed": 1, + }, + } + + await self._mqtt_client.publish_message( + f"{self._auth.household_id}/{self.device_id}", json.dumps(payload) + ) + + # Omdat de melding 3 seconden blijft staan, wachten we 3 seconden + # voor de volgende 'refresh'. + if i < 2: + await asyncio.sleep(3) + async def set_channel(self, source: str) -> None: """Change te channel from the settopbox.""" channel = [src for src in self._channels.values() if src.title == source][0] - payload = ( - '{"id":"' - + await make_id(8) - + '","type":"CPE.pushToTV","source":{"clientId":"' - + self._mqtt_client.client_id - + '","friendlyDeviceName":"Home Assistant"},' - + '"status":{"sourceType":"linear","source":{"channelId":"' - + channel.id - + '"},"relativePosition":0,"speed":1}}' - ) + payload = { + "id": await make_id(8), + "type": "CPE.pushToTV", + "source": { + "clientId": self._mqtt_client.client_id, + "friendlyDeviceName": "Home Assistant", + }, + "status": { + "sourceType": "linear", + "source": {"channelId": channel.id}, + "relativePosition": 0, + "speed": 1, + }, + } await self._mqtt_client.publish_message( - f"{self._auth.household_id}/{self.device_id}", payload + f"{self._auth.household_id}/{self.device_id}", json.dumps(payload) ) async def play_recording(self, recording_id): diff --git a/lghorizon/lghorizon_device_state_processor.py b/lghorizon/lghorizon_device_state_processor.py index 47c42e0..e9d9bc3 100644 --- a/lghorizon/lghorizon_device_state_processor.py +++ b/lghorizon/lghorizon_device_state_processor.py @@ -3,6 +3,9 @@ import random import json import urllib.parse +from datetime import datetime as dt, timezone + +import time from typing import cast, Dict, Optional @@ -18,10 +21,7 @@ LGHorizonRecordingSource, ) from .lghorizon_models import LGHorizonAuth -from .lghorizon_models import ( - LGHorizonReplayEvent, - LGHorizonVOD, -) +from .lghorizon_models import LGHorizonReplayEvent, LGHorizonVOD, LGHorizonVODType from .lghorizon_models import LGHorizonRecordingSingle from .lghorizon_models import LGHorizonChannel @@ -121,7 +121,7 @@ async def _process_apps_state( apps_state: LGHorizonAppsState, ) -> None: device_state.id = apps_state.id - device_state.title = apps_state.app_name + device_state.show_title = apps_state.app_name device_state.image = apps_state.logo_path device_state.ui_state_type = LGHorizonUIStateType.APPS @@ -148,12 +148,19 @@ async def _process_linear_state( device_state.id = replay_event.event_id channel = self._channels[replay_event.channel_id] device_state.source_type = source.source_type - device_state.channel_id = channel.channel_number + device_state.channel_id = channel.id device_state.channel_name = channel.title device_state.episode_title = replay_event.episode_name device_state.season_number = replay_event.season_number device_state.episode_number = replay_event.episode_number device_state.show_title = replay_event.title + now_in_ms = int(time.time() * 1000) + + device_state.last_position_update = int(time.time() * 1000) + device_state.start_time = replay_event.start_time + device_state.end_time = replay_event.end_time + device_state.duration = replay_event.end_time - replay_event.start_time + device_state.position = now_in_ms - int(replay_event.start_time * 1000) # Add random number to url to force refresh join_param = "?" @@ -163,7 +170,6 @@ async def _process_linear_state( f"{channel.stream_image}{join_param}{str(random.randrange(1000000))}" ) device_state.image = image_url - await device_state.reset_progress() async def _process_reviewbuffer_state( self, @@ -188,13 +194,17 @@ async def _process_reviewbuffer_state( device_state.id = replay_event.event_id channel = self._channels[replay_event.channel_id] device_state.source_type = source.source_type - device_state.channel_id = channel.channel_number + device_state.channel_id = channel.id device_state.channel_name = channel.title device_state.episode_title = replay_event.episode_name device_state.season_number = replay_event.season_number device_state.episode_number = replay_event.episode_number device_state.show_title = replay_event.title - + device_state.last_position_update = player_state.last_speed_change_time + device_state.position = player_state.relative_position + device_state.start_time = replay_event.start_time + device_state.end_time = replay_event.end_time + device_state.duration = replay_event.end_time - replay_event.start_time # Add random number to url to force refresh join_param = "?" if join_param in channel.stream_image: @@ -203,7 +213,6 @@ async def _process_reviewbuffer_state( f"{channel.stream_image}{join_param}{str(random.randrange(1000000))}" ) device_state.image = image_url - await device_state.reset_progress() async def _process_replay_state( self, @@ -226,16 +235,26 @@ async def _process_replay_state( ) replay_event = LGHorizonReplayEvent(event_json) device_state.id = replay_event.event_id + # Iets met buffer doen + channel = self._channels[replay_event.channel_id] + padding = channel.replay_pre_padding + channel.replay_post_padding device_state.source_type = source.source_type - device_state.channel_id = None + device_state.channel_id = channel.id device_state.episode_title = replay_event.episode_name device_state.season_number = replay_event.season_number device_state.episode_number = replay_event.episode_number device_state.show_title = replay_event.title - + device_state.last_position_update = int(time.time() * 1000) + device_state.start_time = replay_event.start_time + device_state.end_time = replay_event.end_time + device_state.duration = ( + replay_event.end_time - replay_event.start_time + padding + ) + device_state.position = ( + player_state.relative_position + channel.replay_pre_padding + ) # Add random number to url to force refresh device_state.image = await self._get_intent_image_url(replay_event.event_id) - await device_state.reset_progress() async def _process_vod_state( self, @@ -258,15 +277,19 @@ async def _process_vod_state( ) vod = LGHorizonVOD(vod_json) device_state.id = vod.id - device_state.title = vod.title - device_state.sub_title = vod.full_episode_title + if vod.vod_type == LGHorizonVODType.EPISODE: + device_state.show_title = vod.series_title + device_state.episode_title = vod.title + device_state.season_number = vod.season + device_state.episode_number = vod.episode + else: + device_state.show_title = vod.title + device_state.duration = vod.duration - device_state.episode_title = vod.title - device_state.season_number = vod.season_number - device_state.episode_number = vod.episode_number - device_state.show_title = vod.series_title + device_state.last_position_update = int(time.time() * 1000) + device_state.position = player_state.relative_position + device_state.image = await self._get_intent_image_url(vod.id) - await device_state.reset_progress() async def _process_ndvr_state( self, device_state: LGHorizonDeviceState, player_state: LGHorizonPlayerState @@ -294,10 +317,25 @@ async def _process_ndvr_state( device_state.episode_title = recording.episode_title device_state.season_number = recording.season_number device_state.episode_number = recording.episode_number + device_state.last_position_update = player_state.last_speed_change_time + device_state.position = player_state.relative_position + if recording.start_time: + device_state.start_time = int( + dt.fromisoformat( + recording.start_time.replace("Z", "+00:00") + ).timestamp() + ) + if recording.end_time: + device_state.end_time = int( + dt.fromisoformat(recording.end_time.replace("Z", "+00:00")).timestamp() + ) + if recording.start_time and recording.end_time: + device_state.duration = device_state.end_time - device_state.start_time if recording.source == LGHorizonRecordingSource.SHOW: device_state.show_title = recording.title else: device_state.show_title = recording.show_title + device_state.image = await self._get_intent_image_url(recording.id) async def _get_intent_image_url(self, intent_id: str) -> Optional[str]: diff --git a/lghorizon/lghorizon_message_factory.py b/lghorizon/lghorizon_message_factory.py index 498697a..007995d 100644 --- a/lghorizon/lghorizon_message_factory.py +++ b/lghorizon/lghorizon_message_factory.py @@ -22,7 +22,6 @@ async def create_message(self, topic: str, payload: dict) -> LGHorizonMessage: case LGHorizonMessageType.STATUS: return LGHorizonStatusMessage(payload, topic) case LGHorizonMessageType.UI_STATUS: - # Placeholder for UI_STATUS message handling return LGHorizonUIStatusMessage(payload, topic) case LGHorizonMessageType.UNKNOWN: return LGHorizonUnknownMessage(payload, topic) diff --git a/lghorizon/lghorizon_models.py b/lghorizon/lghorizon_models.py index ae0c8a7..ec208c5 100644 --- a/lghorizon/lghorizon_models.py +++ b/lghorizon/lghorizon_models.py @@ -268,6 +268,13 @@ def last_speed_change_time( """Return the last speed change time.""" return self._raw_json.get("lastSpeedChangeTime", 0.0) + @property + def relative_position( + self, + ) -> int: + """Return the last speed change time.""" + return self._raw_json.get("relativePosition", 0.0) + @property def source(self) -> LGHorizonSource | None: # Added None to the return type """Return the last speed change time.""" @@ -673,6 +680,16 @@ def channel_number(self) -> str: """Returns the channel number.""" return self.channel_json["logicalChannelNumber"] + @property + def replay_pre_padding(self) -> int: + """Returns the channel number.""" + return self.channel_json.get("replayPrePadding", 0) + + @property + def replay_post_padding(self) -> int: + """Returns the channel number.""" + return self.channel_json.get("replayPostPadding", 0) + @property def is_radio(self) -> bool: """Returns if the channel is a radio channel.""" @@ -839,6 +856,8 @@ class LGHorizonDeviceState: _last_position_update: Optional[datetime] _state: LGHorizonRunningState _speed: Optional[int] + _start_time: Optional[int] + _end_time: Optional[int] def __init__(self) -> None: """Initialize the playing info.""" @@ -858,6 +877,8 @@ def __init__(self) -> None: self._speed = None self._channel_name = None self._id = None + self._start_time = None + self._end_time = None @property def state(self) -> LGHorizonRunningState: @@ -909,6 +930,16 @@ def show_title(self, value: Optional[str]) -> None: """Set the title.""" self._show_title = value + @property + def app_name(self) -> Optional[str]: + """Return the title.""" + return self._app_name + + @app_name.setter + def app_name(self, value: Optional[str]) -> None: + """Set the title.""" + self._app_name = value + @property def episode_title(self) -> Optional[str]: """Return the title.""" @@ -939,6 +970,26 @@ def season_number(self, value: Optional[int]) -> None: """Set the title.""" self._season_number = value + @property + def start_time(self) -> Optional[int]: + """Return the title.""" + return self._start_time + + @start_time.setter + def start_time(self, value: Optional[int]) -> None: + """Set the title.""" + self._start_time = value + + @property + def end_time(self) -> Optional[int]: + """Return the title.""" + return self._end_time + + @end_time.setter + def end_time(self, value: Optional[int]) -> None: + """Set the title.""" + self._end_time = value + @property def image(self) -> Optional[str]: """Return the image URL.""" @@ -997,12 +1048,12 @@ def position(self, value: Optional[float]) -> None: self._position = value @property - def last_position_update(self) -> Optional[datetime]: + def last_position_update(self) -> Optional[int]: """Return the last time the position was updated.""" return self._last_position_update @last_position_update.setter - def last_position_update(self, value: Optional[datetime]) -> None: + def last_position_update(self, value: Optional[int]) -> None: """Set the last position update time.""" self._last_position_update = value @@ -1029,11 +1080,12 @@ async def reset(self) -> None: self.season_number = None self.episode_title = None self.show_title = None - self.sub_title = None + self.app_name = None self.image = None self.source_type = LGHorizonSourceType.UNKNOWN self.speed = None self.channel_name = None + self.id = None await self.reset_progress() @@ -1082,6 +1134,16 @@ def season_number(self) -> Optional[int]: """Return the season number.""" return self._raw_json.get("seasonNumber") + @property + def start_time(self) -> Optional[int]: + """Return the season number.""" + return self._raw_json.get("startTime", None) + + @property + def end_time(self) -> Optional[int]: + """Return the season number.""" + return self._raw_json.get("endTime", None) + @property def title(self) -> str: """Return the title of the event.""" @@ -1133,26 +1195,14 @@ def id(self) -> str: return self._vod_json["id"] @property - def season_number(self) -> Optional[int]: + def season(self) -> Optional[int]: """Return the season number of the recording.""" - return self._vod_json.get("seasonNumber", None) + return self._vod_json.get("season", None) @property - def episode_number(self) -> Optional[int]: + def episode(self) -> Optional[int]: """Return the episode number of the recording.""" - return self._vod_json.get("episodeNumber", None) - - @property - def full_episode_title(self) -> Optional[str]: - """Return the ID of the VOD.""" - if self.vod_type != LGHorizonVODType.EPISODE: - return None - if not self.season_number and not self.episode_number: - return None - full_title = f"""S{self.season_number:02d}E{self.episode_number:02d}""" - if self.title: - full_title += f": {self.title}" - return full_title + return self._vod_json.get("episode", None) @property def title(self) -> str: @@ -1295,6 +1345,21 @@ def channel_id(self) -> Optional[str]: """Return the channel ID of the recording.""" return self._recording_payload.get("channelId", None) + @property + def duration(self) -> Optional[int]: + """Return the title.""" + return self.recording_payload.get("duration", None) + + @property + def start_time(self) -> Optional[int]: + """Return the title.""" + return self.recording_payload.get("startTime", None) + + @property + def end_time(self) -> Optional[int]: + """Return the title.""" + return self.recording_payload.get("endTime", None) + class LGHorizonRecordingSeason(LGHorizonRecording): """LGHorizon recording.""" diff --git a/main.py b/main.py index 03a3acb..3bbe9e4 100644 --- a/main.py +++ b/main.py @@ -54,7 +54,7 @@ async def main(): async def device_callback(device_id: str): device = devices[device_id] print( - f"Device {device.device_id} state changed. Status:\n\nName: {device.device_friendly_name}\nState: {device.device_state.state.value}\nChannel: {device.device_state.channel_name}\nShow: {device.device_state.show_title}\nEpisode: {device.device_state.episode_title}\nSource type: {device.device_state.source_type.value}\n\n", + f"Device {device.device_id} state changed. Status:\n\nName: {device.device_friendly_name}\nState: {device.device_state.state.value}\nChannel: {device.device_state.channel_name} ({device.device_state.channel_id})\nShow: {device.device_state.show_title}\nEpisode: {device.device_state.episode_title}\nSource type: {device.device_state.source_type.value}\nlast pos update: {device.device_state.last_position_update}\npos: {device.device_state.position}\nstart time: {device.device_state.start_time}\nend time: {device.device_state.end_time}\n\n", ) try: @@ -62,19 +62,9 @@ async def device_callback(device_id: str): devices = await api.get_devices() for device in devices.values(): await device.set_callback(device_callback) + quota = await api.get_recording_quota() print(f"Recording occupancy: {quota.percentage_used}") - try: - recordings = await api.get_all_recordings() - print(f"Total recordings: {recordings.total}") - - show_recordings = await api.get_show_recordings( - "crid:~~2F~~2Fbds.tv~~2F272418335", "NL_000006_019130" - ) - print(f"recordings: {show_recordings.total}") - except Exception: - traceback.print_exc() - # Wait until the shutdown event is set await shutdown_event.wait() From 2a493478f874fda045c1672f4e72a3dc88ca769b Mon Sep 17 00:00:00 2001 From: Rudolf Offereins Date: Tue, 3 Feb 2026 17:46:40 +0000 Subject: [PATCH 13/16] Set version to max 3.13.* for renovate --- renovate.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/renovate.json b/renovate.json index 9a3152d..1cc015e 100644 --- a/renovate.json +++ b/renovate.json @@ -1,7 +1,7 @@ { "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": [ - "config:recommended", - ":dependencyDashboard" - ] + "extends": ["config:recommended", ":dependencyDashboard"], + "constraints": { + "python": "==3.13.*" + } } From 31b8d1ac5912c24f93435aa24838961d04f31717 Mon Sep 17 00:00:00 2001 From: Rudolf Offereins Date: Tue, 3 Feb 2026 18:06:15 +0000 Subject: [PATCH 14/16] Docstrings fix --- lghorizon/exceptions.py | 6 +-- lghorizon/lghorizon_api.py | 16 ++++++-- lghorizon/lghorizon_device.py | 54 ++++++++++++++++++------- lghorizon/lghorizon_models.py | 65 +++++++++++++++++++++--------- lghorizon/lghorizon_mqtt_client.py | 48 ++++++++++++++++++---- main.py | 2 +- 6 files changed, 143 insertions(+), 48 deletions(-) diff --git a/lghorizon/exceptions.py b/lghorizon/exceptions.py index 740d341..012de05 100644 --- a/lghorizon/exceptions.py +++ b/lghorizon/exceptions.py @@ -6,12 +6,12 @@ class LGHorizonApiError(Exception): class LGHorizonApiConnectionError(LGHorizonApiError): - """Generic LGHorizon exception.""" + """Exception for connection-related errors with the LG Horizon API.""" class LGHorizonApiUnauthorizedError(Exception): - """Generic LGHorizon exception.""" + """Exception for unauthorized access to the LG Horizon API.""" class LGHorizonApiLockedError(LGHorizonApiUnauthorizedError): - """Generic LGHorizon exception.""" + """Exception for locked account errors with the LG Horizon API.""" diff --git a/lghorizon/lghorizon_api.py b/lghorizon/lghorizon_api.py index e82520a..31462d7 100644 --- a/lghorizon/lghorizon_api.py +++ b/lghorizon/lghorizon_api.py @@ -45,6 +45,12 @@ class LGHorizonApi: def __init__(self, auth: LGHorizonAuth, profile_id: str = "") -> None: """Initialize LG Horizon API client.""" + """Initialize LG Horizon API client. + + Args: + auth: The authentication object for API requests. + profile_id: The ID of the user profile to use (optional). + """ self.auth = auth self._profile_id = profile_id self._channels = {} @@ -90,7 +96,7 @@ async def get_profiles(self) -> dict[str, LGHorizonProfile]: async def get_profile_channels( self, profile_id: Optional[str] = None - ) -> dict[str, LGHorizonChannel]: + ) -> Dict[str, LGHorizonChannel]: """Returns channels to display baed on profile.""" # Attempt to retrieve the profile by the given profile_id if not profile_id: @@ -98,7 +104,7 @@ async def get_profile_channels( profile = self._customer.profiles.get(profile_id) # If the specified profile is not found, and there are other profiles available, - # default to the first profile in the customer's list. + # default to the first profile in the customer's list if available. if not profile and self._customer.profiles: _LOGGER.debug( "Profile with ID '%s' not found. Defaulting to first available profile.", @@ -149,6 +155,10 @@ async def disconnect(self) -> None: self._initialized = False async def _create_mqtt_client(self) -> LGHorizonMqttClient: + """Create and configure the MQTT client. + + Returns: An initialized LGHorizonMqttClient instance. + """ mqtt_client = await LGHorizonMqttClient.create( self.auth, self._on_mqtt_connected, @@ -264,7 +274,7 @@ async def get_all_recordings(self) -> LGHorizonRecordingList: async def get_show_recordings( self, show_id: str, channel_id: str - ) -> LGHorizonShowRecordingList: + ) -> LGHorizonShowRecordingList: # type: ignore[valid-type] """Retrieve all recordings.""" _LOGGER.debug("Retrieving recordings fro show...") service_url = await self._service_config.get_service_url("recordingService") diff --git a/lghorizon/lghorizon_device.py b/lghorizon/lghorizon_device.py index dc76b4c..2c20d7f 100644 --- a/lghorizon/lghorizon_device.py +++ b/lghorizon/lghorizon_device.py @@ -157,14 +157,23 @@ async def register_mqtt(self) -> None: async def set_callback( self, change_callback: Callable[[str], Coroutine[Any, Any, Any]] ) -> None: - """Set a callback function.""" + """Set a callback function to be called when the device state changes. + + Args: + change_callback: An asynchronous callable that takes the device ID + as an argument. + """ self._change_callback = change_callback await self.register_mqtt() # type: ignore [assignment] # Callback can be None async def handle_status_message( self, status_message: LGHorizonStatusMessage ) -> None: - """Register a new settop box.""" + """Handle an incoming status message from the set-top box. + + Args: + status_message: The status message received from the device. + """ old_running_state = self.device_state.state new_running_state = status_message.running_state if ( @@ -198,6 +207,10 @@ async def update_recording_capacity(self, payload) -> None: self.recording_capacity = payload["used"] # Use the setter async def _trigger_callback(self): + """Trigger the registered callback function. + + This method is called when the device's state changes and a callback is set. + """ if self._change_callback is not None: _LOGGER.debug("Callback called from box %s", self.device_id) await self._change_callback(self.device_id) @@ -281,9 +294,18 @@ async def set_player_position(self, position: int) -> None: ) async def display_message(self, sourceType: str, message: str) -> None: - """Toon een bericht op de settopbox en herhaal dit voor langere zichtbaarheid.""" + """Display a message on the set-top box and repeat it for longer visibility. # We sturen de payload 3 keer met een kortere tussentijd + + This method sends the message payload multiple times to ensure it stays + visible on the screen for a longer duration, as the display time for + such messages is typically short. + + Args: + sourceType: The type of source for the message (e.g., "linear"). + message: The message string to display. + """ for i in range(3): payload = { "id": await make_id(8), @@ -334,18 +356,22 @@ async def set_channel(self, source: str) -> None: async def play_recording(self, recording_id): """Play recording.""" - payload = ( - '{"id":"' - + await make_id(8) - + '","type":"CPE.pushToTV","source":{"clientId":"' - + self._mqtt_client.client_id - + '","friendlyDeviceName":"Home Assistant"},' - + '"status":{"sourceType":"nDVR","source":{"recordingId":"' - + recording_id - + '"},"relativePosition":0}}' - ) + payload = { + "id": await make_id(8), + "type": "CPE.pushToTV", + "source": { + "clientId": self._mqtt_client.client_id, + "friendlyDeviceName": "Home Assistant", + }, + "status": { + "sourceType": "nDVR", + "source": {"recordingId": recording_id}, + "relativePosition": 0, + }, + } + await self._mqtt_client.publish_message( - f"{self._auth.household_id}/{self.device_id}", payload + f"{self._auth.household_id}/{self.device_id}", json.dumps(payload) ) async def send_key_to_box(self, key: str) -> None: diff --git a/lghorizon/lghorizon_models.py b/lghorizon/lghorizon_models.py index ec208c5..c82231e 100644 --- a/lghorizon/lghorizon_models.py +++ b/lghorizon/lghorizon_models.py @@ -52,7 +52,7 @@ class LGHorizonRecordingState(Enum): UNKNOWN = "unknown" -class LGHorizonRecordingType(Enum): +class LGHorizonRecordingType(Enum): # type: ignore[no-redef] """Enumeration of LG Horizon recording states.""" SINGLE = "single" @@ -91,6 +91,12 @@ def message_type(self) -> LGHorizonMessageType | None: def __init__(self, topic: str, payload: dict) -> None: """Abstract base class for LG Horizon messages.""" self._topic = topic + """Initialize the abstract base class for LG Horizon messages. + + Args: + topic: The MQTT topic of the message. + payload: The dictionary payload of the message. + """ self._payload = payload def __repr__(self) -> str: @@ -122,7 +128,7 @@ def running_state(self) -> LGHorizonRunningState: class LGHorizonSourceType(Enum): - """Enumeration of LG Horizon message types.""" + """Enumeration of LG Horizon source types.""" LINEAR = "linear" REVIEWBUFFER = "reviewBuffer" @@ -141,7 +147,7 @@ def __init__(self, raw_json: dict) -> None: @property @abstractmethod - def source_type(self) -> LGHorizonSourceType: + def source_type(self) -> LGHorizonSourceType: # type: ignore[no-redef] """Return the message type.""" @@ -182,7 +188,7 @@ def source_type(self) -> LGHorizonSourceType: class LGHorizonNDVRSource(LGHorizonSource): - """Represent the ReviewBuffer Source of an LG Horizon device.""" + """Represent the Network Digital Video Recorder (NDVR) Source of an LG Horizon device.""" @property def recording_id(self) -> str: @@ -223,7 +229,7 @@ def source_type(self) -> LGHorizonSourceType: class LGHorizonReplaySource(LGHorizonSource): - """Represent the VOD Source of an LG Horizon device.""" + """Represent the Replay Source of an LG Horizon device.""" @property def event_id(self) -> str: @@ -237,7 +243,7 @@ def source_type(self) -> LGHorizonSourceType: class LGHorizonUnknownSource(LGHorizonSource): - """Represent the Linear Source of an LG Horizon device.""" + """Represent an unknown source type of an LG Horizon device.""" @property def source_type(self) -> LGHorizonSourceType: @@ -295,7 +301,7 @@ def source(self) -> LGHorizonSource | None: # Added None to the return type class LGHorizonAppsState: - """Represent the State of an LG Horizon device.""" + """Represent the Apps State of an LG Horizon device.""" def __init__(self, raw_json: dict) -> None: """Initialize the Apps state.""" @@ -318,7 +324,7 @@ def logo_path(self) -> str: class LGHorizonUIState: - """Represent the State of an LG Horizon device.""" + """Represent the UI State of an LG Horizon device.""" _player_state: LGHorizonPlayerState | None = None _apps_state: LGHorizonAppsState | None = None @@ -326,6 +332,11 @@ class LGHorizonUIState: def __init__(self, raw_json: dict) -> None: """Initialize the State.""" self._raw_json = raw_json + """Initialize the UI State. + + Args: + raw_json: The raw JSON dictionary containing UI state information. + """ @property def ui_status(self) -> LGHorizonUIStateType: @@ -460,7 +471,7 @@ class LGHorizonAuth: _country_code: str _host: str _use_refresh_token: bool - _token_refresh_callback: Callable[str, None] | None + _token_refresh_callback: Callable[str, None] | None # pyright: ignore[reportInvalidTypeForm] def __init__( self, @@ -1165,10 +1176,6 @@ def full_episode_title(self) -> Optional[str]: full_title += f": {self.episode_name}" return full_title - def __repr__(self) -> str: - """Return a string representation of the replay event.""" - return f"LGHorizonReplayEvent(title='{self.title}', channel_id='{self.channel_id}', event_id='{self.event_id}')" - class LGHorizonVODType(Enum): """Enumeration of LG Horizon VOD types.""" @@ -1182,6 +1189,11 @@ class LGHorizonVOD: """LGHorizon video on demand.""" def __init__(self, vod_json) -> None: + """Initialize an LG Horizon VOD object. + + Args: + vod_json: The raw JSON dictionary containing VOD information. + """ self._vod_json = vod_json @property @@ -1221,7 +1233,7 @@ def duration(self) -> float: class LGHOrizonRelevantEpisode: - """LGHorizon recording.""" + """Represents a relevant episode within a recording season or show.""" def __init__(self, episode_json: dict) -> None: """Abstract base class for LG Horizon recordings.""" @@ -1298,7 +1310,10 @@ def poster_url(self) -> Optional[str]: return None def __init__(self, recording_payload: dict) -> None: - """Abstract base class for LG Horizon recordings.""" + """Abstract base class for LG Horizon recordings. + Args: + recording_payload: The raw JSON dictionary containing recording information. + """ self._recording_payload = recording_payload @@ -1362,7 +1377,7 @@ def end_time(self) -> Optional[int]: class LGHorizonRecordingSeason(LGHorizonRecording): - """LGHorizon recording.""" + """Represents an LG Horizon recording season.""" _most_relevant_epsode: Optional[LGHOrizonRelevantEpisode] @@ -1395,7 +1410,7 @@ def most_relevant_episode(self) -> Optional[LGHOrizonRelevantEpisode]: class LGHorizonRecordingShow(LGHorizonRecording): - """LGHorizon recording.""" + """Represents an LG Horizon recording show.""" _most_relevant_epsode: Optional[LGHOrizonRelevantEpisode] @@ -1418,7 +1433,7 @@ def most_relevant_episode(self) -> Optional[LGHOrizonRelevantEpisode]: class LGHorizonRecordingList: - """LGHorizon recording.""" + """Represents a list of LG Horizon recordings.""" @property def total(self) -> int: @@ -1426,7 +1441,11 @@ def total(self) -> int: return len(self._recordings) def __init__(self, recordings: List[LGHorizonRecording]) -> None: - """Abstract base class for LG Horizon recordings.""" + """Initialize an LG Horizon recording list. + + Args: + recordings: A list of LGHorizonRecording objects. + """ self._recordings = recordings @property @@ -1444,7 +1463,13 @@ def __init__( show_image, recordings: List[LGHorizonRecording], ) -> None: - """Abstract base class for LG Horizon recordings.""" + """Initialize an LG Horizon show recording list. + + Args: + show_title: The title of the show. + show_image: The image URL for the show. + recordings: A list of LGHorizonRecording objects belonging to the show. + """ super().__init__(recordings) self._show_title = show_title self._show_image = show_image diff --git a/lghorizon/lghorizon_mqtt_client.py b/lghorizon/lghorizon_mqtt_client.py index 30ff6fe..1b5b1b2 100644 --- a/lghorizon/lghorizon_mqtt_client.py +++ b/lghorizon/lghorizon_mqtt_client.py @@ -12,7 +12,7 @@ class LGHorizonMqttClient: - """Async‑vriendelijke wrapper rond Paho MQTT.""" + """Asynchronous-friendly wrapper around Paho MQTT.""" def __init__( self, @@ -22,6 +22,14 @@ def __init__( loop: asyncio.AbstractEventLoop, ) -> None: self._auth = auth + """Initialize the LGHorizonMqttClient. + + Args: + auth: The authentication object for obtaining MQTT tokens. + on_connected_callback: An async callback function for MQTT connection events. + on_message_callback: An async callback function for MQTT message events. + loop: The asyncio event loop. + """ self._on_connected_callback = on_connected_callback self._on_message_callback = on_message_callback self._loop = loop @@ -50,6 +58,8 @@ async def create( on_connected_callback: Callable[[], Coroutine[Any, Any, Any]], on_message_callback: Callable[[dict, str], Coroutine[Any, Any, Any]], ) -> "LGHorizonMqttClient": + """Asynchronously create and initialize an LGHorizonMqttClient instance.""" + loop = asyncio.get_running_loop() instance = cls(auth, on_connected_callback, on_message_callback, loop) @@ -88,7 +98,7 @@ async def create( return instance async def connect(self) -> None: - """Async‑veilige connect.""" + """Connect the MQTT client to the broker asynchronously.""" if not self._mqtt_client: raise RuntimeError("MQTT client not initialized") @@ -108,7 +118,7 @@ async def connect(self) -> None: self._publish_worker_task = asyncio.create_task(self._publish_worker()) async def disconnect(self) -> None: - """Async‑veilige disconnect.""" + """Disconnect the MQTT client from the broker asynchronously.""" if not self._mqtt_client: return @@ -126,14 +136,23 @@ async def disconnect(self) -> None: self._mqtt_client.loop_stop() async def subscribe(self, topic: str) -> None: - """Subscribe op een topic (Paho doet dit sync in eigen thread).""" + """Subscribe to an MQTT topic. + + Args: + topic: The MQTT topic to subscribe to. + """ if not self._mqtt_client: raise RuntimeError("MQTT client not initialized") self._mqtt_client.subscribe(topic) async def publish_message(self, topic: str, json_payload: str) -> None: - """Queue een publish-opdracht.""" + """Queue an MQTT message for publishing. + + Args: + topic: The MQTT topic to publish to. + json_payload: The JSON payload as a string. + """ await self._publish_queue.put((topic, json_payload)) # ------------------------- @@ -141,6 +160,14 @@ async def publish_message(self, topic: str, json_payload: str) -> None: # ------------------------- def _on_connect(self, client, userdata, flags, result_code): + """Callback for when the MQTT client connects to the broker. + + Args: + client: The Paho MQTT client instance. + userdata: User data passed to the client. + flags: Response flags from the broker. + result_code: The connection result code. + """ if result_code == 0: asyncio.run_coroutine_threadsafe( self._on_connected_callback(), @@ -157,7 +184,13 @@ def _on_connect(self, client, userdata, flags, result_code): _logger.error("MQTT connect error: %s", result_code) def _on_message(self, client, userdata, message): - """Ontvangen bericht → FIFO queue.""" + """Callback for when an MQTT message is received. + + Args: + client: The Paho MQTT client instance. + userdata: User data passed to the client. + message: The MQTTMessage object containing topic and payload. + """ asyncio.run_coroutine_threadsafe( self._message_queue.put((message.topic, message.payload)), self._loop, @@ -168,7 +201,7 @@ def _on_message(self, client, userdata, message): # ------------------------- async def _message_worker(self): - """Verwerkt berichten in volgorde van binnenkomst.""" + """Worker task to process incoming MQTT messages from the queue.""" while True: topic, payload = await self._message_queue.get() @@ -186,6 +219,7 @@ async def _message_worker(self): async def _publish_worker(self): """Verwerkt publish-opdrachten in volgorde, maar alleen als connected.""" + """Worker task to process outgoing MQTT publish commands from the queue.""" while True: topic, payload = await self._publish_queue.get() diff --git a/main.py b/main.py index 3bbe9e4..90aa922 100644 --- a/main.py +++ b/main.py @@ -29,7 +29,7 @@ async def read_input_and_signal_shutdown(): async def main(): - """main loop""" + """Main function to run the LG Horizon API test script.""" logging.basicConfig( level=logging.DEBUG, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", From d1e9975aaf6afa0bcb21ecf29175dea6e0a41e60 Mon Sep 17 00:00:00 2001 From: Rudolf Offereins Date: Tue, 3 Feb 2026 18:20:55 +0000 Subject: [PATCH 15/16] Add reconnection logic, small reset fix --- lghorizon/lghorizon_models.py | 2 + lghorizon/lghorizon_mqtt_client.py | 108 +++++++++++++++++++++++++++-- 2 files changed, 105 insertions(+), 5 deletions(-) diff --git a/lghorizon/lghorizon_models.py b/lghorizon/lghorizon_models.py index c82231e..4c85ac1 100644 --- a/lghorizon/lghorizon_models.py +++ b/lghorizon/lghorizon_models.py @@ -1097,6 +1097,8 @@ async def reset(self) -> None: self.speed = None self.channel_name = None self.id = None + self.start_time = None + self.end_time = None await self.reset_progress() diff --git a/lghorizon/lghorizon_mqtt_client.py b/lghorizon/lghorizon_mqtt_client.py index 1b5b1b2..1d8e695 100644 --- a/lghorizon/lghorizon_mqtt_client.py +++ b/lghorizon/lghorizon_mqtt_client.py @@ -38,6 +38,8 @@ def __init__( self._mqtt_broker_url: str = "" self._mqtt_token: str = "" self.client_id: str = "" + self._reconnect_task: asyncio.Task | None = None + self._disconnect_requested: bool = False # FIFO queues self._message_queue: asyncio.Queue = asyncio.Queue() @@ -94,6 +96,7 @@ async def create( instance._mqtt_client.enable_logger(_logger) instance._mqtt_client.on_connect = instance._on_connect instance._mqtt_client.on_message = instance._on_message + instance._mqtt_client.on_disconnect = instance._on_disconnect return instance @@ -102,6 +105,19 @@ async def connect(self) -> None: if not self._mqtt_client: raise RuntimeError("MQTT client not initialized") + if self.is_connected: + _logger.debug("MQTT client is already connected.") + return + + self._disconnect_requested = False # Reset flag for new connection attempt + + # Cancel any ongoing reconnect task if connect() is called manually + if self._reconnect_task and not self._reconnect_task.done(): + _logger.debug("Cancelling existing reconnect task before manual connect.") + self._reconnect_task.cancel() + self._reconnect_task = None + + _logger.info("Attempting initial MQTT connection...") # Blocking connect → executor await self._loop.run_in_executor( None, @@ -169,19 +185,53 @@ def _on_connect(self, client, userdata, flags, result_code): result_code: The connection result code. """ if result_code == 0: + _logger.info("MQTT client connected successfully.") + # If a reconnect task was running, it means we successfully reconnected. + # Cancel it as we are now connected. + if self._reconnect_task: + self._reconnect_task.cancel() + self._reconnect_task = None # Clear the reference asyncio.run_coroutine_threadsafe( self._on_connected_callback(), self._loop, ) elif result_code == 5: - # Token verlopen → opnieuw proberen + _logger.warning( + "MQTT connection failed: Token expired. Attempting to refresh token and reconnect." + ) + # Schedule the token refresh and reconnect in the main event loop + asyncio.run_coroutine_threadsafe( + self._handle_token_refresh_and_reconnect(), self._loop + ) + else: + _logger.error("MQTT connect error: %s", result_code) + # For other errors, Paho's _on_disconnect will typically be called, + # which will then trigger the general reconnect loop. + + async def _handle_token_refresh_and_reconnect(self): + """Refreshes the MQTT token and attempts to reconnect the client.""" + try: + # Get new token + self._mqtt_token = await self._auth.get_mqtt_token() self._mqtt_client.username_pw_set( self._auth.household_id, self._mqtt_token, ) - asyncio.run_coroutine_threadsafe(self.connect(), self._loop) - else: - _logger.error("MQTT connect error: %s", result_code) + _logger.info("MQTT token refreshed. Attempting to reconnect.") + # Call connect. If it fails, _on_disconnect will be triggered, + # and the _reconnect_loop will take over. + await self.connect() + except Exception as e: + _logger.error("Failed to refresh MQTT token or initiate reconnect: %s", e) + # If token refresh itself fails, or connect() raises an exception + # before _on_disconnect can be called, ensure reconnect loop starts. + if not self._disconnect_requested and ( + not self._reconnect_task or self._reconnect_task.done() + ): + _logger.info( + "Scheduling MQTT reconnect after token refresh/connect failure." + ) + self._reconnect_task = asyncio.create_task(self._reconnect_loop()) def _on_message(self, client, userdata, message): """Callback for when an MQTT message is received. @@ -196,6 +246,55 @@ def _on_message(self, client, userdata, message): self._loop, ) + def _on_disconnect(self, client, userdata, result_code): + """Callback for when the MQTT client disconnects from the broker. + + Args: + client: The Paho MQTT client instance. + userdata: User data passed to the client. + result_code: The disconnection result code. + """ + _logger.warning("MQTT disconnected with result code: %s", result_code) + if not self._disconnect_requested: + _logger.info("Unexpected MQTT disconnection. Initiating reconnect loop.") + if not self._reconnect_task or self._reconnect_task.done(): + self._reconnect_task = asyncio.run_coroutine_threadsafe( + self._reconnect_loop(), self._loop + ) + else: + _logger.debug("Reconnect loop already active.") + else: + _logger.info("MQTT disconnected as requested.") + + async def _reconnect_loop(self): + """Manages the MQTT reconnection process with exponential backoff.""" + retries = 0 + while not self._disconnect_requested: + if self.is_connected: + _logger.debug( + "MQTT client reconnected within loop, stopping reconnect attempts." + ) + break # Already connected, stop trying + + delay = min(2**retries, 60) # Exponential backoff, max 60 seconds + _logger.debug( + "Waiting %s seconds before MQTT reconnect attempt %s", + delay, + retries + 1, + ) + await asyncio.sleep(delay) + + try: + _logger.info("Attempting MQTT reconnect...") + await self.connect() + # If connect() succeeds, _on_connect will be called, which will cancel this task. + # If connect() fails, _on_disconnect will be called again, and this loop continues. + break # If connect() doesn't raise, assume it's handled by _on_connect + except Exception as e: + _logger.error("MQTT reconnect attempt failed: %s", e) + retries += 1 + self._reconnect_task = None # Clear task when loop finishes or is cancelled. + # ------------------------- # MESSAGE WORKER (FIFO) # ------------------------- @@ -218,7 +317,6 @@ async def _message_worker(self): # ------------------------- async def _publish_worker(self): - """Verwerkt publish-opdrachten in volgorde, maar alleen als connected.""" """Worker task to process outgoing MQTT publish commands from the queue.""" while True: topic, payload = await self._publish_queue.get() From a8e5893d722d5fcf7bae15b7ea763baa5dbd3659 Mon Sep 17 00:00:00 2001 From: Rudolf Offereins Date: Tue, 3 Feb 2026 19:10:40 +0000 Subject: [PATCH 16/16] Updated the readme --- README.md | 152 +++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 151 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 150de8d..f6d6111 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,153 @@ # LG Horizon Api -Python library to control multiple LG Horizon boxes +# LG Horizon API Python Library + +A Python library to interact with and control LG Horizon set-top boxes. This library provides functionalities for authentication, real-time device status monitoring via MQTT, and various control commands for your Horizon devices. + +## Features + +- **Authentication**: Supports authentication using username/password or a refresh token. The library automatically handles access token refreshing. +- **Device Management**: Discover and manage multiple LG Horizon set-top boxes associated with your account. +- **Real-time Status**: Monitor device status (online/running/standby) and current playback information (channel, show, VOD, recording, app) through MQTT. +- **Channel Information**: Retrieve a list of available channels and profile-specific favorite channels. +- **Recording Management**: + - Get a list of all recordings. + - Retrieve recordings for specific shows. + - Check recording quota and usage. +- **Device Control**: Send various commands to your set-top box: + - Power on/off. + - Play, pause, stop, rewind, fast forward. + - Change channels (up/down, direct channel selection). + - Record current program. + - Set player position for VOD/recordings. + - Display custom messages on the TV screen. + - Send emulated remote control key presses. +- **Robustness**: Includes automatic MQTT reconnection with exponential backoff and token refresh logic to maintain a stable connection. + +## Installation + +```bash +pip install lghorizon-python # (Replace with actual package name if different) +``` + +## Usage + +Here's a basic example of how to use the library to connect to your LG Horizon devices and monitor their state: + +First, create a `secrets.json` file in the root of your project with your LG Horizon credentials: + +```json +{ + "username": "your_username", + "password": "your_password", + "country": "nl" // e.g., "nl" for Netherlands, "be" for Belgium +} +``` + +Then, you can use the library as follows: + +```python +import asyncio +import json +import logging +import aiohttp + +from lghorizon.lghorizon_api import LGHorizonApi +from lghorizon.lghorizon_models import LGHorizonAuth + +_LOGGER = logging.getLogger(__name__) + +async def main(): + logging.basicConfig(level=logging.INFO) # Set to DEBUG for more verbose output + + with open("secrets.json", encoding="utf-8") as f: + secrets = json.load(f) + username = secrets.get("username") + password = secrets.get("password") + country = secrets.get("country", "nl") + + async with aiohttp.ClientSession() as session: + auth = LGHorizonAuth(session, country, username=username, password=password) + api = LGHorizonApi(auth) + + async def device_state_changed_callback(device_id: str): + device = devices[device_id] + _LOGGER.info( + f"Device {device.device_friendly_name} ({device.device_id}) state changed:\n" + f" State: {device.device_state.state.value}\n" + f" UI State: {device.device_state.ui_state_type.value}\n" + f" Source Type: {device.device_state.source_type.value}\n" + f" Channel: {device.device_state.channel_name or 'N/A'} ({device.device_state.channel_id or 'N/A'})\n" + f" Show: {device.device_state.show_title or 'N/A'}\n" + f" Episode: {device.device_state.episode_title or 'N/A'}\n" + f" Position: {device.device_state.position or 'N/A'} / {device.device_state.duration or 'N/A'}\n" + ) + + try: + _LOGGER.info("Initializing LG Horizon API...") + await api.initialize() + devices = await api.get_devices() + + for device in devices.values(): + _LOGGER.info(f"Registering callback for device: {device.device_friendly_name}") + await device.set_callback(device_state_changed_callback) + + _LOGGER.info("API initialized. Monitoring device states. Press Ctrl+C to exit.") + # Keep the script running to receive MQTT updates + while True: + await asyncio.sleep(3600) # Sleep for a long time, MQTT callbacks will still fire + + except Exception as e: + _LOGGER.error(f"An error occurred: {e}", exc_info=True) + finally: + _LOGGER.info("Disconnecting from LG Horizon API.") + await api.disconnect() + _LOGGER.info("Disconnected.") + +if __name__ == "__main__": + asyncio.run(main()) +``` + +## Authentication + +The `LGHorizonAuth` class handles authentication. You can initialize it with a username and password, or directly with a refresh token if you have one. The library automatically refreshes access tokens as needed. + +```python +# Using username and password +auth = LGHorizonAuth(session, "nl", username="your_username", password="your_password") + +# Using a refresh token (e.g., if you've saved it from a previous session) +# auth = LGHorizonAuth(session, "nl", refresh_token="your_refresh_token") +``` + +You can also set a callback to receive the updated refresh token when it's refreshed, allowing you to persist it for future sessions: + +```python +def token_updated_callback(new_refresh_token: str): + print(f"New refresh token received: {new_refresh_token}") + # Here you would typically save this new_refresh_token + # to your secrets.json or other persistent storage. + +# After initializing LGHorizonApi: +# api.set_token_refresh_callback(token_updated_callback) +``` + +## Error Handling + +The library defines custom exceptions for common error scenarios: + +- `LGHorizonApiError`: Base exception for all API-related errors. +- `LGHorizonApiConnectionError`: Raised for network or connection issues. +- `LGHorizonApiUnauthorizedError`: Raised when authentication fails (e.g., invalid credentials). +- `LGHorizonApiLockedError`: A specific type of `LGHorizonApiUnauthorizedError` indicating a locked account. + +These exceptions allow for more granular error handling in your application. + +## Development + +To run the example script (`main.py`) from the repository: + +1. Clone this repository. +2. Install dependencies: `pip install -r requirements.txt` (ensure `requirements.txt` is up-to-date). +3. Create a `secrets.json` file as described in the Usage section. +4. Run `python main.py`.