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.
- lghorizon/__init__.py +58 -25
- lghorizon/const.py +23 -81
- lghorizon/exceptions.py +3 -3
- lghorizon/helpers.py +1 -1
- lghorizon/lghorizon_api.py +263 -502
- lghorizon/lghorizon_device.py +409 -0
- lghorizon/lghorizon_device_state_processor.py +365 -0
- lghorizon/lghorizon_message_factory.py +38 -0
- lghorizon/lghorizon_models.py +1512 -0
- lghorizon/lghorizon_mqtt_client.py +335 -0
- lghorizon/lghorizon_recording_factory.py +55 -0
- lghorizon-0.9.1.dist-info/METADATA +189 -0
- lghorizon-0.9.1.dist-info/RECORD +17 -0
- lghorizon/models.py +0 -768
- lghorizon-0.9.0.dev4.dist-info/METADATA +0 -41
- lghorizon-0.9.0.dev4.dist-info/RECORD +0 -12
- {lghorizon-0.9.0.dev4.dist-info → lghorizon-0.9.1.dist-info}/WHEEL +0 -0
- {lghorizon-0.9.0.dev4.dist-info → lghorizon-0.9.1.dist-info}/licenses/LICENSE +0 -0
- {lghorizon-0.9.0.dev4.dist-info → lghorizon-0.9.1.dist-info}/top_level.txt +0 -0
|
@@ -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))
|