lghorizon 0.9.0.dev4__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.
@@ -0,0 +1,409 @@
1
+ """LG Horizon Device."""
2
+
3
+ from __future__ import annotations
4
+ import asyncio
5
+ import json
6
+ import logging
7
+ from typing import Any, Callable, Coroutine, Dict, Optional
8
+ from .lghorizon_models import (
9
+ LGHorizonRunningState,
10
+ LGHorizonStatusMessage,
11
+ LGHorizonUIStatusMessage,
12
+ LGHorizonDeviceState,
13
+ LGHorizonAuth,
14
+ LGHorizonChannel,
15
+ )
16
+
17
+ from .exceptions import LGHorizonApiConnectionError
18
+ from .helpers import make_id
19
+ from .lghorizon_device_state_processor import LGHorizonDeviceStateProcessor
20
+ from .lghorizon_mqtt_client import LGHorizonMqttClient
21
+ from .const import (
22
+ MEDIA_KEY_CHANNEL_DOWN,
23
+ MEDIA_KEY_CHANNEL_UP,
24
+ MEDIA_KEY_ENTER,
25
+ MEDIA_KEY_FAST_FORWARD,
26
+ MEDIA_KEY_PLAY_PAUSE,
27
+ MEDIA_KEY_POWER,
28
+ MEDIA_KEY_RECORD,
29
+ MEDIA_KEY_REWIND,
30
+ MEDIA_KEY_STOP,
31
+ ONLINE_RUNNING,
32
+ PLATFORM_TYPES,
33
+ )
34
+
35
+ _LOGGER = logging.getLogger(__name__)
36
+
37
+
38
+ class LGHorizonDevice:
39
+ """The LG Horizon device (set-top box)."""
40
+
41
+ _device_id: str
42
+ _hashed_cpe_id: str
43
+ _device_friendly_name: str
44
+ _platform_type: str
45
+ _device_state: LGHorizonDeviceState
46
+ _manufacturer: Optional[str]
47
+ _model: Optional[str]
48
+ _recording_capacity: Optional[int]
49
+ _device_state_processor: LGHorizonDeviceStateProcessor
50
+ _mqtt_client: LGHorizonMqttClient
51
+ _change_callback: Callable[[str], Coroutine[Any, Any, Any]]
52
+ _auth: LGHorizonAuth
53
+ _channels: Dict[str, LGHorizonChannel]
54
+ _last_ui_message_timestamp: int = 0
55
+
56
+ def __init__(
57
+ self,
58
+ device_json,
59
+ mqtt_client: LGHorizonMqttClient,
60
+ device_state_processor: LGHorizonDeviceStateProcessor,
61
+ auth: LGHorizonAuth,
62
+ channels: Dict[str, LGHorizonChannel],
63
+ ):
64
+ """Initialize the LG Horizon device."""
65
+ self._device_id = device_json["deviceId"]
66
+ self._hashed_cpe_id = device_json["hashedCPEId"]
67
+ self._device_friendly_name = device_json["settings"]["deviceFriendlyName"]
68
+ self._platform_type = device_json.get("platformType")
69
+ self._mqtt_client = mqtt_client
70
+ self._auth = auth
71
+ self._channels = channels
72
+ self._device_state = LGHorizonDeviceState() # Initialize state
73
+ self._manufacturer = None
74
+ self._model = None
75
+ self._recording_capacity = None
76
+ self._device_state_processor = device_state_processor
77
+
78
+ @property
79
+ def device_id(self) -> str:
80
+ """Return the device ID."""
81
+ return self._device_id
82
+
83
+ @property
84
+ def platform_type(self) -> str:
85
+ """Return the device ID."""
86
+ return self._platform_type
87
+
88
+ @property
89
+ def manufacturer(self) -> str:
90
+ """Return the manufacturer of the settop box."""
91
+ platform_info = PLATFORM_TYPES.get(self._platform_type, dict())
92
+ return platform_info.get("manufacturer", "unknown")
93
+
94
+ @property
95
+ def model(self) -> str:
96
+ """Return the model of the settop box."""
97
+ platform_info = PLATFORM_TYPES.get(self._platform_type, dict())
98
+ return platform_info.get("model", "unknown")
99
+
100
+ @property
101
+ def is_available(self) -> bool:
102
+ """Return the availability of the settop box."""
103
+ return self._device_state.state in (
104
+ LGHorizonRunningState.ONLINE_RUNNING,
105
+ LGHorizonRunningState.ONLINE_STANDBY,
106
+ )
107
+
108
+ @property
109
+ def hashed_cpe_id(self) -> str:
110
+ """Return the hashed CPE ID."""
111
+ return self._hashed_cpe_id
112
+
113
+ @property
114
+ def device_friendly_name(self) -> str:
115
+ """Return the device friendly name."""
116
+ return self._device_friendly_name
117
+
118
+ @property
119
+ def device_state(self) -> LGHorizonDeviceState:
120
+ """Return the current playing information."""
121
+ return self._device_state
122
+
123
+ @property
124
+ def recording_capacity(self) -> Optional[int]:
125
+ """Return the recording capacity used."""
126
+ return self._recording_capacity
127
+
128
+ @recording_capacity.setter
129
+ def recording_capacity(self, value: int) -> None:
130
+ """Set the recording capacity used."""
131
+ self._recording_capacity = value
132
+
133
+ @property
134
+ def last_ui_message_timestamp(self) -> int:
135
+ """Return the last ui message timestamp."""
136
+ return self._last_ui_message_timestamp
137
+
138
+ @last_ui_message_timestamp.setter
139
+ def last_ui_message_timestamp(self, value: int) -> None:
140
+ """Set the last ui message timestamp."""
141
+ self._last_ui_message_timestamp = value
142
+
143
+ async def update_channels(self, channels: Dict[str, LGHorizonChannel]):
144
+ """Update the channels list."""
145
+ self._channels = channels
146
+
147
+ async def register_mqtt(self) -> None:
148
+ """Register the mqtt connection."""
149
+ topic = f"{self._auth.household_id}/{self._mqtt_client.client_id}/status"
150
+ payload = {
151
+ "source": self._mqtt_client.client_id,
152
+ "state": ONLINE_RUNNING,
153
+ "deviceType": "HGO",
154
+ }
155
+ await self._mqtt_client.publish_message(topic, json.dumps(payload))
156
+
157
+ async def set_callback(
158
+ self, change_callback: Callable[[str], Coroutine[Any, Any, Any]]
159
+ ) -> None:
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
+ """
166
+ self._change_callback = change_callback
167
+ await self.register_mqtt() # type: ignore [assignment] # Callback can be None
168
+
169
+ async def handle_status_message(
170
+ self, status_message: LGHorizonStatusMessage
171
+ ) -> None:
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
+ """
177
+ old_running_state = self.device_state.state
178
+ new_running_state = status_message.running_state
179
+ if (
180
+ old_running_state == new_running_state
181
+ ): # Access backing field for comparison
182
+ return
183
+ await self._device_state_processor.process_state(
184
+ self.device_state, status_message
185
+ ) # Use the setter
186
+ if self._device_state.state == LGHorizonRunningState.ONLINE_RUNNING:
187
+ await self._request_settop_box_state()
188
+
189
+ await self._trigger_callback()
190
+ await self._request_settop_box_recording_capacity()
191
+
192
+ async def handle_ui_status_message(
193
+ self, status_message: LGHorizonUIStatusMessage
194
+ ) -> None:
195
+ """Handle UI status message."""
196
+
197
+ await self._device_state_processor.process_ui_state(
198
+ self.device_state, status_message
199
+ )
200
+ self.last_ui_message_timestamp = status_message.message_timestamp
201
+ await self._trigger_callback()
202
+
203
+ async def update_recording_capacity(self, payload) -> None:
204
+ """Updates the recording capacity."""
205
+ if "CPE.capacity" not in payload or "used" not in payload:
206
+ return
207
+ self.recording_capacity = payload["used"] # Use the setter
208
+
209
+ async def _trigger_callback(self):
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:
215
+ _LOGGER.debug("Callback called from box %s", self.device_id)
216
+ await self._change_callback(self.device_id)
217
+
218
+ async def turn_on(self) -> None:
219
+ """Turn the settop box on."""
220
+
221
+ if self._device_state.state == LGHorizonRunningState.ONLINE_STANDBY:
222
+ await self.send_key_to_box(MEDIA_KEY_POWER)
223
+
224
+ async def turn_off(self) -> None:
225
+ """Turn the settop box off."""
226
+ if self._device_state.state == LGHorizonRunningState.ONLINE_RUNNING:
227
+ await self.send_key_to_box(MEDIA_KEY_POWER)
228
+ await self._device_state.reset()
229
+
230
+ async def pause(self) -> None:
231
+ """Pause the given settopbox."""
232
+ if (
233
+ self._device_state.state == LGHorizonRunningState.ONLINE_RUNNING
234
+ and not self._device_state.paused
235
+ ):
236
+ await self.send_key_to_box(MEDIA_KEY_PLAY_PAUSE)
237
+
238
+ async def play(self) -> None:
239
+ """Resume the settopbox."""
240
+ if (
241
+ self._device_state.state == LGHorizonRunningState.ONLINE_RUNNING
242
+ and self._device_state.paused
243
+ ):
244
+ await self.send_key_to_box(MEDIA_KEY_PLAY_PAUSE)
245
+
246
+ async def stop(self) -> None:
247
+ """Stop the settopbox."""
248
+ if self._device_state.state == LGHorizonRunningState.ONLINE_RUNNING:
249
+ await self.send_key_to_box(MEDIA_KEY_STOP)
250
+
251
+ async def next_channel(self):
252
+ """Select the next channel for given settop box."""
253
+ if self._device_state.state == LGHorizonRunningState.ONLINE_RUNNING:
254
+ await self.send_key_to_box(MEDIA_KEY_CHANNEL_UP)
255
+
256
+ async def previous_channel(self) -> None:
257
+ """Select the previous channel for given settop box."""
258
+ if self._device_state.state == LGHorizonRunningState.ONLINE_RUNNING:
259
+ await self.send_key_to_box(MEDIA_KEY_CHANNEL_DOWN)
260
+
261
+ async def press_enter(self) -> None:
262
+ """Press enter on the settop box."""
263
+ if self._device_state.state == LGHorizonRunningState.ONLINE_RUNNING:
264
+ await self.send_key_to_box(MEDIA_KEY_ENTER)
265
+
266
+ async def rewind(self) -> None:
267
+ """Rewind the settop box."""
268
+ if self._device_state.state == LGHorizonRunningState.ONLINE_RUNNING:
269
+ await self.send_key_to_box(MEDIA_KEY_REWIND)
270
+
271
+ async def fast_forward(self) -> None:
272
+ """Fast forward the settop box."""
273
+ if self._device_state.state == LGHorizonRunningState.ONLINE_RUNNING:
274
+ await self.send_key_to_box(MEDIA_KEY_FAST_FORWARD)
275
+
276
+ async def record(self):
277
+ """Record on the settop box."""
278
+ if self._device_state.state == LGHorizonRunningState.ONLINE_RUNNING:
279
+ await self.send_key_to_box(MEDIA_KEY_RECORD)
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
+
335
+ async def set_channel(self, source: str) -> None:
336
+ """Change te channel from the settopbox."""
337
+ channel = [src for src in self._channels.values() if src.title == source][0]
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
+ }
352
+
353
+ await self._mqtt_client.publish_message(
354
+ f"{self._auth.household_id}/{self.device_id}", json.dumps(payload)
355
+ )
356
+
357
+ async def play_recording(self, recording_id):
358
+ """Play recording."""
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
+
373
+ await self._mqtt_client.publish_message(
374
+ f"{self._auth.household_id}/{self.device_id}", json.dumps(payload)
375
+ )
376
+
377
+ async def send_key_to_box(self, key: str) -> None:
378
+ """Send emulated (remote) key press to settopbox."""
379
+ payload_dict = {
380
+ "type": "CPE.KeyEvent",
381
+ "runtimeType": "key",
382
+ "id": "ha",
383
+ "source": self.device_id.lower(),
384
+ "status": {"w3cKey": key, "eventType": "keyDownUp"},
385
+ }
386
+ payload = json.dumps(payload_dict)
387
+ await self._mqtt_client.publish_message(
388
+ f"{self._auth.household_id}/{self.device_id}", payload
389
+ )
390
+
391
+ async def _request_settop_box_state(self) -> None:
392
+ """Send mqtt message to receive state from settop box."""
393
+ topic = f"{self._auth.household_id}/{self.device_id}"
394
+ payload = {
395
+ "id": await make_id(8),
396
+ "type": "CPE.getUiStatus",
397
+ "source": self._mqtt_client.client_id,
398
+ }
399
+ await self._mqtt_client.publish_message(topic, json.dumps(payload))
400
+
401
+ async def _request_settop_box_recording_capacity(self) -> None:
402
+ """Send mqtt message to receive state from settop box."""
403
+ topic = f"{self._auth.household_id}/{self.device_id}"
404
+ payload = {
405
+ "id": await make_id(8),
406
+ "type": "CPE.capacity",
407
+ "source": self._mqtt_client.client_id,
408
+ }
409
+ await self._mqtt_client.publish_message(topic, json.dumps(payload))