lghorizon 0.9.0.dev3__py3-none-any.whl → 0.9.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
lghorizon/__init__.py CHANGED
@@ -1,5 +1,74 @@
1
1
  """Python client for LG Horizon."""
2
2
 
3
3
  from .lghorizon_api import LGHorizonApi
4
- from .lghorizon_models import *
5
- from .exceptions import *
4
+ from .lghorizon_device import LGHorizonDevice
5
+ from .lghorizon_models import (
6
+ LGHorizonAuth,
7
+ LGHorizonChannel,
8
+ LGHorizonCustomer,
9
+ LGHorizonDeviceState,
10
+ LGHorizonProfile,
11
+ LGHorizonRecording,
12
+ LGHorizonRecordingList,
13
+ LGHorizonShowRecordingList,
14
+ LGHorizonRecordingSeason,
15
+ LGHorizonRecordingSingle,
16
+ LGHorizonRecordingShow,
17
+ LGHorizonRecordingQuota,
18
+ LGHorizonRecordingType,
19
+ LGHorizonUIStateType,
20
+ LGHorizonMessageType,
21
+ LGHorizonRunningState,
22
+ LGHorizonRecordingSource,
23
+ LGHorizonRecordingState,
24
+ LGHorizonSourceType,
25
+ LGHorizonPlayerState,
26
+ LGHorizonAppsState,
27
+ LGHorizonUIState,
28
+ LGHorizonProfileOptions,
29
+ LGHorizonServicesConfig,
30
+ )
31
+ from .exceptions import (
32
+ LGHorizonApiError,
33
+ LGHorizonApiConnectionError,
34
+ LGHorizonApiUnauthorizedError,
35
+ LGHorizonApiLockedError,
36
+ )
37
+
38
+ from .const import COUNTRY_SETTINGS
39
+
40
+ __all__ = [
41
+ "LGHorizonApi",
42
+ "LGHorizonDevice",
43
+ "LGHorizonAuth",
44
+ "LGHorizonChannel",
45
+ "LGHorizonCustomer",
46
+ "LGHorizonDeviceState",
47
+ "LGHorizonProfile",
48
+ "LGHorizonApiError",
49
+ "LGHorizonApiConnectionError",
50
+ "LGHorizonApiUnauthorizedError",
51
+ "LGHorizonApiLockedError",
52
+ "LGHorizonRecordingList",
53
+ "LGHorizonRecordingSeason",
54
+ "LGHorizonRecordingSingle",
55
+ "LGHorizonRecordingShow",
56
+ "LGHorizonRecordingQuota",
57
+ "LGHorizonRecordingType",
58
+ "LGHorizonUIStateType",
59
+ "LGHorizonMessageType",
60
+ "LGHorizonRunningState",
61
+ "LGHorizonRecordingSource",
62
+ "LGHorizonRecordingState",
63
+ "LGHorizonSourceType",
64
+ "LGHorizonPlayerState",
65
+ "LGHorizonAppsState",
66
+ "LGHorizonUIState",
67
+ "LGHorizonProfileOptions",
68
+ "LGHorizonProfile",
69
+ "LGHorizonAuth",
70
+ "LGHorizonServicesConfig",
71
+ "LGHorizonRecording",
72
+ "LGHorizonShowRecordingList",
73
+ "COUNTRY_SETTINGS",
74
+ ]
lghorizon/const.py CHANGED
@@ -50,106 +50,41 @@ COUNTRY_SETTINGS = {
50
50
  "api_url": "https://spark-prod-nl.gnp.cloud.ziggogo.tv",
51
51
  "mqtt_url": "obomsg.prod.nl.horizon.tv",
52
52
  "use_refreshtoken": False,
53
- "channels": [
54
- {
55
- "channelId": "NL_000073_019506",
56
- "channelName": "Netflix",
57
- "channelNumber": "150",
58
- },
59
- {
60
- "channelId": "NL_000074_019507",
61
- "channelName": "Videoland",
62
- "channelNumber": "151",
63
- },
64
- {
65
- "channelId": "NL_000194_019352",
66
- "channelName": "NPO",
67
- "channelNumber": "152",
68
- },
69
- {
70
- "channelId": "NL_000199_019356",
71
- "channelName": "Prime Video",
72
- "channelNumber": "153",
73
- },
74
- ],
75
- "platform_types": {
76
- "EOS": {"manufacturer": "Arris", "model": "DCX960"},
77
- "APOLLO": {"manufacturer": "Arris", "model": "VIP5002W"},
78
- },
79
- "language": "nl",
53
+ "name": "Ziggo",
80
54
  },
81
55
  "ch": {
82
56
  "api_url": "https://spark-prod-ch.gnp.cloud.sunrisetv.ch",
83
- "channels": [],
84
- "language": "de",
57
+ "use_refreshtoken": True,
58
+ "name": "UPC Switzerland",
85
59
  },
86
60
  "be-basetv": {
87
61
  "api_url": "https://spark-prod-be.gnp.cloud.base.tv",
88
- "channels": [],
89
- "language": "nl",
90
- "platform_types": {
91
- "EOS": {"manufacturer": "Arris", "model": "DCX960"},
92
- "HORIZON": {"manufacturer": "Arris", "model": "VIP5002W"},
93
- },
62
+ "use_refreshtoken": True,
63
+ "name": "BASE TV (BE)",
94
64
  },
95
65
  "be-nl": {
96
66
  "api_url": "https://spark-prod-be.gnp.cloud.telenet.tv",
97
- "oauth_username_fieldname": "j_username",
98
- "oauth_password_fieldname": "j_password",
99
- "oauth_add_accept_header": False,
100
- "oauth_url": "https://login.prd.telenet.be/openid/login.do",
101
- "oauth_quote_login": False,
102
- "oauth_redirect_header": "Location",
103
- "channels": [
104
- {"channelId": "netflix", "channelName": "Netflix", "channelNumber": "600"},
105
- {"channelId": "youtube", "channelName": "Youtube", "channelNumber": "-1"},
106
- ],
107
- "platform_types": {
108
- "EOS": {"manufacturer": "Arris", "model": "DCX960"},
109
- "HORIZON": {"manufacturer": "Arris", "model": "DCX960"},
110
- "EOS2": {"manufacturer": "HUMAX", "model": "2008C-STB-TN"},
111
- },
112
- "language": "nl",
67
+ "use_refreshtoken": True,
68
+ "name": "Telenet (BE)",
113
69
  },
114
70
  "be-nl-preprod": {
115
71
  "api_url": "https://spark-preprod-be.gnp.cloud.telenet.tv",
116
72
  "use_refreshtoken": True,
117
- "oauth_username_fieldname": "j_username",
118
- "oauth_password_fieldname": "j_password",
119
- "oauth_add_accept_header": False,
120
- "oauth_url": "https://login.prd.telenet.be/openid/login.do",
121
- "oauth_quote_login": False,
122
- "oauth_redirect_header": "Location",
123
- "channels": [
124
- {"channelId": "netflix", "channelName": "Netflix", "channelNumber": "600"},
125
- {"channelId": "youtube", "channelName": "Youtube", "channelNumber": "-1"},
126
- ],
127
- "platform_types": {
128
- "EOS": {"manufacturer": "Arris", "model": "DCX960"},
129
- "HORIZON": {"manufacturer": "Arris", "model": "DCX960"},
130
- "EOS2": {"manufacturer": "HUMAX", "model": "2008C-STB-TN"},
131
- },
132
- "language": "nl",
73
+ "name": "Telenet (BE, PREPROD)",
133
74
  },
134
75
  "gb": {
135
76
  "api_url": "https://spark-prod-gb.gnp.cloud.virgintvgo.virginmedia.com",
136
- "channels": [],
137
- "language": "en",
77
+ "use_refreshtoken": True,
78
+ "name": "Virgin Media (GB)",
138
79
  },
139
80
  "ie": {
140
81
  "api_url": "https://spark-prod-ie.gnp.cloud.virginmediatv.ie",
141
82
  "use_refreshtoken": False,
142
- "channels": [],
143
- "language": "en",
83
+ "name": "Virgin Media (IE)",
144
84
  },
145
85
  "pl": {
146
86
  "api_url": "https://spark-prod-pl.gnp.cloud.upctv.pl",
147
87
  "use_refreshtoken": False,
148
- "channels": [],
149
- "language": "pl",
150
- "platform_types": {
151
- "EOS": {"manufacturer": "Arris", "model": "DCX960"},
152
- "APOLLO": {"manufacturer": "Arris", "model": "VIP5002W"},
153
- },
88
+ "name": "UPC (PL)",
154
89
  },
155
90
  }
lghorizon/exceptions.py CHANGED
@@ -6,12 +6,12 @@ class LGHorizonApiError(Exception):
6
6
 
7
7
 
8
8
  class LGHorizonApiConnectionError(LGHorizonApiError):
9
- """Generic LGHorizon exception."""
9
+ """Exception for connection-related errors with the LG Horizon API."""
10
10
 
11
11
 
12
12
  class LGHorizonApiUnauthorizedError(Exception):
13
- """Generic LGHorizon exception."""
13
+ """Exception for unauthorized access to the LG Horizon API."""
14
14
 
15
15
 
16
16
  class LGHorizonApiLockedError(LGHorizonApiUnauthorizedError):
17
- """Generic LGHorizon exception."""
17
+ """Exception for locked account errors with the LG Horizon API."""
@@ -1,7 +1,7 @@
1
1
  """LG Horizon API client."""
2
2
 
3
3
  import logging
4
- from typing import Any, Dict, cast
4
+ from typing import Any, Dict, cast, Callable, Optional
5
5
 
6
6
  from .lghorizon_device import LGHorizonDevice
7
7
  from .lghorizon_models import LGHorizonChannel
@@ -15,7 +15,11 @@ from .lghorizon_models import LGHorizonMessageType
15
15
  from .lghorizon_message_factory import LGHorizonMessageFactory
16
16
  from .lghorizon_models import LGHorizonStatusMessage, LGHorizonUIStatusMessage
17
17
  from .lghorizon_models import LGHorizonRunningState
18
- from .lghorizon_models import LGHorizonRecordingList, LGHorizonRecordingQuota
18
+ from .lghorizon_models import (
19
+ LGHorizonRecordingList,
20
+ LGHorizonRecordingQuota,
21
+ LGHorizonShowRecordingList,
22
+ )
19
23
  from .lghorizon_recording_factory import LGHorizonRecordingFactory
20
24
  from .lghorizon_device_state_processor import LGHorizonDeviceStateProcessor
21
25
 
@@ -26,7 +30,7 @@ _LOGGER = logging.getLogger(__name__)
26
30
  class LGHorizonApi:
27
31
  """LG Horizon API client."""
28
32
 
29
- _mqtt_client: LGHorizonMqttClient
33
+ _mqtt_client: LGHorizonMqttClient | None
30
34
  auth: LGHorizonAuth
31
35
  _service_config: LGHorizonServicesConfig
32
36
  _customer: LGHorizonCustomer
@@ -41,10 +45,18 @@ class LGHorizonApi:
41
45
 
42
46
  def __init__(self, auth: LGHorizonAuth, profile_id: str = "") -> None:
43
47
  """Initialize LG Horizon API client."""
48
+ """Initialize LG Horizon API client.
49
+
50
+ Args:
51
+ auth: The authentication object for API requests.
52
+ profile_id: The ID of the user profile to use (optional).
53
+ """
44
54
  self.auth = auth
45
55
  self._profile_id = profile_id
46
56
  self._channels = {}
47
57
  self._device_state_processor = None
58
+ self._mqtt_client = None
59
+ self._initialized = False
48
60
 
49
61
  async def initialize(self) -> None:
50
62
  """Initialize the API client."""
@@ -62,14 +74,20 @@ class LGHorizonApi:
62
74
  )
63
75
  self._initialized = True
64
76
 
65
- async def get_devices(self) -> Dict[str, LGHorizonDevice]:
77
+ async def set_token_refresh_callback(
78
+ self, token_refresh_callback: Callable[str, None]
79
+ ) -> None:
80
+ """Set the token refresh callback."""
81
+ self.auth.token_refresh_callback = token_refresh_callback
82
+
83
+ async def get_devices(self) -> dict[str, LGHorizonDevice]:
66
84
  """Get devices."""
67
85
  if not self._initialized:
68
86
  raise RuntimeError("LGHorizonApi not initialized")
69
87
 
70
88
  return self._devices
71
89
 
72
- async def get_profiles(self) -> Dict[str, LGHorizonProfile]:
90
+ async def get_profiles(self) -> dict[str, LGHorizonProfile]:
73
91
  """Get profile IDs."""
74
92
  if not self._initialized:
75
93
  raise RuntimeError("LGHorizonApi not initialized")
@@ -77,14 +95,16 @@ class LGHorizonApi:
77
95
  return self._customer.profiles
78
96
 
79
97
  async def get_profile_channels(
80
- self, profile_id: str
98
+ self, profile_id: Optional[str] = None
81
99
  ) -> Dict[str, LGHorizonChannel]:
82
100
  """Returns channels to display baed on profile."""
83
101
  # Attempt to retrieve the profile by the given profile_id
102
+ if not profile_id:
103
+ profile_id = self._profile_id
84
104
  profile = self._customer.profiles.get(profile_id)
85
105
 
86
106
  # If the specified profile is not found, and there are other profiles available,
87
- # default to the first profile in the customer's list.
107
+ # default to the first profile in the customer's list if available.
88
108
  if not profile and self._customer.profiles:
89
109
  _LOGGER.debug(
90
110
  "Profile with ID '%s' not found. Defaulting to first available profile.",
@@ -135,6 +155,10 @@ class LGHorizonApi:
135
155
  self._initialized = False
136
156
 
137
157
  async def _create_mqtt_client(self) -> LGHorizonMqttClient:
158
+ """Create and configure the MQTT client.
159
+
160
+ Returns: An initialized LGHorizonMqttClient instance.
161
+ """
138
162
  mqtt_client = await LGHorizonMqttClient.create(
139
163
  self.auth,
140
164
  self._on_mqtt_connected,
@@ -144,8 +168,10 @@ class LGHorizonApi:
144
168
 
145
169
  async def _on_mqtt_connected(self):
146
170
  """MQTT connected callback."""
171
+ await self._mqtt_client.subscribe("#")
147
172
  await self._mqtt_client.subscribe(self.auth.household_id)
148
173
  # await self._mqtt_client.subscribe(self.auth.household_id + "/#")
174
+ # await self._mqtt_client.subscribe(self.auth.household_id + "/+/#")
149
175
  await self._mqtt_client.subscribe(
150
176
  self.auth.household_id + "/" + self._mqtt_client.client_id
151
177
  )
@@ -177,12 +203,16 @@ class LGHorizonApi:
177
203
  case LGHorizonMessageType.STATUS:
178
204
  message.__class__ = LGHorizonStatusMessage
179
205
  status_message = cast(LGHorizonStatusMessage, message)
180
- device = self._devices[status_message.source]
206
+ device = self._devices.get(status_message.source, None)
207
+ if not device:
208
+ return
181
209
  await device.handle_status_message(status_message)
182
210
  case LGHorizonMessageType.UI_STATUS:
183
211
  message.__class__ = LGHorizonUIStatusMessage
184
212
  ui_status_message = cast(LGHorizonUIStatusMessage, message)
185
- device = self._devices[ui_status_message.source]
213
+ device = self._devices.get(ui_status_message.source, None)
214
+ if not device:
215
+ return
186
216
  if (
187
217
  not device.device_state.state
188
218
  == LGHorizonRunningState.ONLINE_RUNNING
@@ -190,7 +220,7 @@ class LGHorizonApi:
190
220
  return
191
221
  await device.handle_ui_status_message(ui_status_message)
192
222
 
193
- async def _get_customer_info(self) -> Any:
223
+ async def _get_customer_info(self) -> LGHorizonCustomer:
194
224
  service_url = await self._service_config.get_service_url(
195
225
  "personalizationService"
196
226
  )
@@ -244,14 +274,14 @@ class LGHorizonApi:
244
274
 
245
275
  async def get_show_recordings(
246
276
  self, show_id: str, channel_id: str
247
- ) -> LGHorizonRecordingList:
277
+ ) -> LGHorizonShowRecordingList: # type: ignore[valid-type]
248
278
  """Retrieve all recordings."""
249
279
  _LOGGER.debug("Retrieving recordings fro show...")
250
280
  service_url = await self._service_config.get_service_url("recordingService")
251
281
  lang = await self._customer.get_profile_lang(self._profile_id)
252
282
  episodes_json = await self.auth.request(
253
283
  service_url,
254
- 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",
284
+ 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",
255
285
  )
256
286
  recordings = await self._recording_factory.create_episodes(episodes_json)
257
287
  return recordings
@@ -1,7 +1,7 @@
1
1
  """LG Horizon Device."""
2
2
 
3
3
  from __future__ import annotations
4
-
4
+ import asyncio
5
5
  import json
6
6
  import logging
7
7
  from typing import Any, Callable, Coroutine, Dict, Optional
@@ -146,8 +146,6 @@ class LGHorizonDevice:
146
146
 
147
147
  async def register_mqtt(self) -> None:
148
148
  """Register the mqtt connection."""
149
- if not self._mqtt_client.is_connected:
150
- raise LGHorizonApiConnectionError("MQTT client not connected.")
151
149
  topic = f"{self._auth.household_id}/{self._mqtt_client.client_id}/status"
152
150
  payload = {
153
151
  "source": self._mqtt_client.client_id,
@@ -159,14 +157,23 @@ class LGHorizonDevice:
159
157
  async def set_callback(
160
158
  self, change_callback: Callable[[str], Coroutine[Any, Any, Any]]
161
159
  ) -> None:
162
- """Set a callback function."""
160
+ """Set a callback function to be called when the device state changes.
161
+
162
+ Args:
163
+ change_callback: An asynchronous callable that takes the device ID
164
+ as an argument.
165
+ """
163
166
  self._change_callback = change_callback
164
167
  await self.register_mqtt() # type: ignore [assignment] # Callback can be None
165
168
 
166
169
  async def handle_status_message(
167
170
  self, status_message: LGHorizonStatusMessage
168
171
  ) -> None:
169
- """Register a new settop box."""
172
+ """Handle an incoming status message from the set-top box.
173
+
174
+ Args:
175
+ status_message: The status message received from the device.
176
+ """
170
177
  old_running_state = self.device_state.state
171
178
  new_running_state = status_message.running_state
172
179
  if (
@@ -200,7 +207,11 @@ class LGHorizonDevice:
200
207
  self.recording_capacity = payload["used"] # Use the setter
201
208
 
202
209
  async def _trigger_callback(self):
203
- if self._change_callback:
210
+ """Trigger the registered callback function.
211
+
212
+ This method is called when the device's state changes and a callback is set.
213
+ """
214
+ if self._change_callback is not None:
204
215
  _LOGGER.debug("Callback called from box %s", self.device_id)
205
216
  await self._change_callback(self.device_id)
206
217
 
@@ -267,38 +278,100 @@ class LGHorizonDevice:
267
278
  if self._device_state.state == LGHorizonRunningState.ONLINE_RUNNING:
268
279
  await self.send_key_to_box(MEDIA_KEY_RECORD)
269
280
 
281
+ async def set_player_position(self, position: int) -> None:
282
+ """Set the player position on the settop box."""
283
+ payload = {
284
+ "source": self.device_id,
285
+ "type": "CPE.setPlayerPosition",
286
+ "runtimeType": "setPlayerposition",
287
+ "id": await make_id(),
288
+ "version": "1.3.11",
289
+ "status": {"relativePosition": position},
290
+ }
291
+ payload_str = json.dumps(payload)
292
+ await self._mqtt_client.publish_message(
293
+ f"{self._auth.household_id}/{self.device_id}", payload_str
294
+ )
295
+
296
+ async def display_message(self, sourceType: str, message: str) -> None:
297
+ """Display a message on the set-top box and repeat it for longer visibility.
298
+
299
+ # We sturen de payload 3 keer met een kortere tussentijd
300
+
301
+ This method sends the message payload multiple times to ensure it stays
302
+ visible on the screen for a longer duration, as the display time for
303
+ such messages is typically short.
304
+
305
+ Args:
306
+ sourceType: The type of source for the message (e.g., "linear").
307
+ message: The message string to display.
308
+ """
309
+ for i in range(3):
310
+ payload = {
311
+ "id": await make_id(8),
312
+ "type": "CPE.pushToTV",
313
+ "source": {
314
+ "clientId": self._mqtt_client.client_id,
315
+ "friendlyDeviceName": f"\n\n{message}",
316
+ },
317
+ "status": {
318
+ "sourceType": sourceType,
319
+ "source": {"channelId": "1234"},
320
+ "title": "Nieuwe melding",
321
+ "relativePosition": 0,
322
+ "speed": 1,
323
+ },
324
+ }
325
+
326
+ await self._mqtt_client.publish_message(
327
+ f"{self._auth.household_id}/{self.device_id}", json.dumps(payload)
328
+ )
329
+
330
+ # Omdat de melding 3 seconden blijft staan, wachten we 3 seconden
331
+ # voor de volgende 'refresh'.
332
+ if i < 2:
333
+ await asyncio.sleep(3)
334
+
270
335
  async def set_channel(self, source: str) -> None:
271
336
  """Change te channel from the settopbox."""
272
337
  channel = [src for src in self._channels.values() if src.title == source][0]
273
- payload = (
274
- '{"id":"'
275
- + await make_id(8)
276
- + '","type":"CPE.pushToTV","source":{"clientId":"'
277
- + self._mqtt_client.client_id
278
- + '","friendlyDeviceName":"Home Assistant"},'
279
- + '"status":{"sourceType":"linear","source":{"channelId":"'
280
- + channel.id
281
- + '"},"relativePosition":0,"speed":1}}'
282
- )
338
+ payload = {
339
+ "id": await make_id(8),
340
+ "type": "CPE.pushToTV",
341
+ "source": {
342
+ "clientId": self._mqtt_client.client_id,
343
+ "friendlyDeviceName": "Home Assistant",
344
+ },
345
+ "status": {
346
+ "sourceType": "linear",
347
+ "source": {"channelId": channel.id},
348
+ "relativePosition": 0,
349
+ "speed": 1,
350
+ },
351
+ }
283
352
 
284
353
  await self._mqtt_client.publish_message(
285
- f"{self._auth.household_id}/{self.device_id}", payload
354
+ f"{self._auth.household_id}/{self.device_id}", json.dumps(payload)
286
355
  )
287
356
 
288
357
  async def play_recording(self, recording_id):
289
358
  """Play recording."""
290
- payload = (
291
- '{"id":"'
292
- + await make_id(8)
293
- + '","type":"CPE.pushToTV","source":{"clientId":"'
294
- + self._mqtt_client.client_id
295
- + '","friendlyDeviceName":"Home Assistant"},'
296
- + '"status":{"sourceType":"nDVR","source":{"recordingId":"'
297
- + recording_id
298
- + '"},"relativePosition":0}}'
299
- )
359
+ payload = {
360
+ "id": await make_id(8),
361
+ "type": "CPE.pushToTV",
362
+ "source": {
363
+ "clientId": self._mqtt_client.client_id,
364
+ "friendlyDeviceName": "Home Assistant",
365
+ },
366
+ "status": {
367
+ "sourceType": "nDVR",
368
+ "source": {"recordingId": recording_id},
369
+ "relativePosition": 0,
370
+ },
371
+ }
372
+
300
373
  await self._mqtt_client.publish_message(
301
- f"{self._auth.household_id}/{self.device_id}", payload
374
+ f"{self._auth.household_id}/{self.device_id}", json.dumps(payload)
302
375
  )
303
376
 
304
377
  async def send_key_to_box(self, key: str) -> None: