lghorizon 0.9.0__py3-none-any.whl → 0.9.0b0__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.
@@ -1,10 +1,9 @@
1
1
  import asyncio
2
2
  import json
3
3
  import logging
4
- from typing import Any, Callable, Coroutine
5
4
 
6
5
  import paho.mqtt.client as mqtt
7
-
6
+ from typing import Any, Callable, Coroutine
8
7
  from .helpers import make_id
9
8
  from .lghorizon_models import LGHorizonAuth
10
9
 
@@ -12,46 +11,32 @@ _logger = logging.getLogger(__name__)
12
11
 
13
12
 
14
13
  class LGHorizonMqttClient:
15
- """Asynchronous-friendly wrapper around Paho MQTT."""
14
+ """LGHorizon MQTT client."""
15
+
16
+ _mqtt_broker_url: str = ""
17
+ _mqtt_client: mqtt.Client
18
+ _auth: LGHorizonAuth
19
+ _mqtt_token: str = ""
20
+ client_id: str = ""
21
+ _on_connected_callback: Callable[[], Coroutine[Any, Any, Any]]
22
+ _on_message_callback: Callable[[dict, str], Coroutine[Any, Any, Any]]
23
+
24
+ @property
25
+ def is_connected(self):
26
+ """Is client connected."""
27
+ return self._mqtt_client.is_connected
16
28
 
17
29
  def __init__(
18
30
  self,
19
31
  auth: LGHorizonAuth,
20
32
  on_connected_callback: Callable[[], Coroutine[Any, Any, Any]],
21
33
  on_message_callback: Callable[[dict, str], Coroutine[Any, Any, Any]],
22
- loop: asyncio.AbstractEventLoop,
23
- ) -> None:
34
+ ):
35
+ """Initialize the MQTT client."""
24
36
  self._auth = auth
25
- """Initialize the LGHorizonMqttClient.
26
-
27
- Args:
28
- auth: The authentication object for obtaining MQTT tokens.
29
- on_connected_callback: An async callback function for MQTT connection events.
30
- on_message_callback: An async callback function for MQTT message events.
31
- loop: The asyncio event loop.
32
- """
33
37
  self._on_connected_callback = on_connected_callback
34
38
  self._on_message_callback = on_message_callback
35
- self._loop = loop
36
-
37
- self._mqtt_client: mqtt.Client | None = None
38
- self._mqtt_broker_url: str = ""
39
- self._mqtt_token: str = ""
40
- self.client_id: str = ""
41
- self._reconnect_task: asyncio.Task | None = None
42
- self._disconnect_requested: bool = False
43
-
44
- # FIFO queues
45
- self._message_queue: asyncio.Queue = asyncio.Queue()
46
- self._publish_queue: asyncio.Queue = asyncio.Queue()
47
-
48
- # Worker tasks
49
- self._message_worker_task: asyncio.Task | None = None
50
- self._publish_worker_task: asyncio.Task | None = None
51
-
52
- @property
53
- def is_connected(self) -> bool:
54
- return self._mqtt_client is not None and self._mqtt_client.is_connected()
39
+ self._loop = asyncio.get_event_loop()
55
40
 
56
41
  @classmethod
57
42
  async def create(
@@ -59,277 +44,80 @@ class LGHorizonMqttClient:
59
44
  auth: LGHorizonAuth,
60
45
  on_connected_callback: Callable[[], Coroutine[Any, Any, Any]],
61
46
  on_message_callback: Callable[[dict, str], Coroutine[Any, Any, Any]],
62
- ) -> "LGHorizonMqttClient":
63
- """Asynchronously create and initialize an LGHorizonMqttClient instance."""
64
-
65
- loop = asyncio.get_running_loop()
66
- instance = cls(auth, on_connected_callback, on_message_callback, loop)
67
-
68
- # Service config ophalen
47
+ ):
48
+ """Create the MQTT client."""
49
+ instance = cls(auth, on_connected_callback, on_message_callback)
69
50
  service_config = await auth.get_service_config()
70
51
  mqtt_broker_url = await service_config.get_service_url("mqttBroker")
71
52
  instance._mqtt_broker_url = mqtt_broker_url.replace("wss://", "").replace(
72
53
  ":443/mqtt", ""
73
54
  )
74
-
75
55
  instance.client_id = await make_id()
76
-
77
- # Paho client
78
56
  instance._mqtt_client = mqtt.Client(
79
57
  client_id=instance.client_id,
80
58
  transport="websockets",
81
59
  )
60
+
82
61
  instance._mqtt_client.ws_set_options(
83
62
  headers={"Sec-WebSocket-Protocol": "mqtt, mqttv3.1, mqttv3.11"}
84
63
  )
85
-
86
- # Token ophalen
87
64
  instance._mqtt_token = await auth.get_mqtt_token()
88
- instance._mqtt_client.username_pw_set(
89
- auth.household_id,
90
- instance._mqtt_token,
91
- )
92
-
93
- # TLS instellen (blocking → executor)
94
- await loop.run_in_executor(None, instance._mqtt_client.tls_set)
95
-
65
+ instance._mqtt_client.username_pw_set(auth.household_id, instance._mqtt_token)
66
+ instance._mqtt_client.tls_set()
96
67
  instance._mqtt_client.enable_logger(_logger)
97
68
  instance._mqtt_client.on_connect = instance._on_connect
98
- instance._mqtt_client.on_message = instance._on_message
99
- instance._mqtt_client.on_disconnect = instance._on_disconnect
100
-
69
+ instance._on_connected_callback = on_connected_callback
70
+ instance._on_message_callback = on_message_callback
101
71
  return instance
102
72
 
103
- async def connect(self) -> None:
104
- """Connect the MQTT client to the broker asynchronously."""
105
- if not self._mqtt_client:
106
- raise RuntimeError("MQTT client not initialized")
107
-
108
- if self.is_connected:
109
- _logger.debug("MQTT client is already connected.")
110
- return
111
-
112
- self._disconnect_requested = False # Reset flag for new connection attempt
113
-
114
- # Cancel any ongoing reconnect task if connect() is called manually
115
- if self._reconnect_task and not self._reconnect_task.done():
116
- _logger.debug("Cancelling existing reconnect task before manual connect.")
117
- self._reconnect_task.cancel()
118
- self._reconnect_task = None
119
-
120
- _logger.info("Attempting initial MQTT connection...")
121
- # Blocking connect → executor
122
- await self._loop.run_in_executor(
123
- None,
124
- self._mqtt_client.connect,
125
- self._mqtt_broker_url,
126
- 443,
127
- )
128
-
129
- # Start Paho thread
130
- self._mqtt_client.loop_start()
131
-
132
- # Start workers
133
- self._message_worker_task = asyncio.create_task(self._message_worker())
134
- self._publish_worker_task = asyncio.create_task(self._publish_worker())
135
-
136
- async def disconnect(self) -> None:
137
- """Disconnect the MQTT client from the broker asynchronously."""
138
- if not self._mqtt_client:
139
- return
140
-
141
- # Stop workers
142
- if self._message_worker_task:
143
- self._message_worker_task.cancel()
144
- self._message_worker_task = None
145
-
146
- if self._publish_worker_task:
147
- self._publish_worker_task.cancel()
148
- self._publish_worker_task = None
149
-
150
- # Blocking disconnect → executor
151
- await self._loop.run_in_executor(None, self._mqtt_client.disconnect)
152
- self._mqtt_client.loop_stop()
153
-
154
- async def subscribe(self, topic: str) -> None:
155
- """Subscribe to an MQTT topic.
156
-
157
- Args:
158
- topic: The MQTT topic to subscribe to.
159
- """
160
- if not self._mqtt_client:
161
- raise RuntimeError("MQTT client not initialized")
162
-
163
- self._mqtt_client.subscribe(topic)
164
-
165
- async def publish_message(self, topic: str, json_payload: str) -> None:
166
- """Queue an MQTT message for publishing.
167
-
168
- Args:
169
- topic: The MQTT topic to publish to.
170
- json_payload: The JSON payload as a string.
171
- """
172
- await self._publish_queue.put((topic, json_payload))
173
-
174
- # -------------------------
175
- # INTERNAL CALLBACKS
176
- # -------------------------
177
-
178
- def _on_connect(self, client, userdata, flags, result_code):
179
- """Callback for when the MQTT client connects to the broker.
180
-
181
- Args:
182
- client: The Paho MQTT client instance.
183
- userdata: User data passed to the client.
184
- flags: Response flags from the broker.
185
- result_code: The connection result code.
186
- """
73
+ def _on_connect(self, client, userdata, flags, result_code): # pylint: disable=unused-argument
187
74
  if result_code == 0:
188
- _logger.info("MQTT client connected successfully.")
189
- # If a reconnect task was running, it means we successfully reconnected.
190
- # Cancel it as we are now connected.
191
- if self._reconnect_task:
192
- self._reconnect_task.cancel()
193
- self._reconnect_task = None # Clear the reference
194
- asyncio.run_coroutine_threadsafe(
195
- self._on_connected_callback(),
196
- self._loop,
197
- )
75
+ self._mqtt_client.on_message = self._on_message
76
+ if self._on_connected_callback:
77
+ asyncio.run_coroutine_threadsafe(
78
+ self._on_connected_callback(), self._loop
79
+ )
198
80
  elif result_code == 5:
199
- _logger.warning(
200
- "MQTT connection failed: Token expired. Attempting to refresh token and reconnect."
201
- )
202
- # Schedule the token refresh and reconnect in the main event loop
203
- asyncio.run_coroutine_threadsafe(
204
- self._handle_token_refresh_and_reconnect(), self._loop
205
- )
81
+ self._mqtt_client.username_pw_set(self._auth.household_id, self._mqtt_token)
82
+ asyncio.run_coroutine_threadsafe(self.connect(), self._loop)
206
83
  else:
207
- _logger.error("MQTT connect error: %s", result_code)
208
- # For other errors, Paho's _on_disconnect will typically be called,
209
- # which will then trigger the general reconnect loop.
210
-
211
- async def _handle_token_refresh_and_reconnect(self):
212
- """Refreshes the MQTT token and attempts to reconnect the client."""
213
- try:
214
- # Get new token
215
- self._mqtt_token = await self._auth.get_mqtt_token()
216
- self._mqtt_client.username_pw_set(
217
- self._auth.household_id,
218
- self._mqtt_token,
84
+ _logger.error(
85
+ "Cannot connect to MQTT server with resultCode: %s", result_code
219
86
  )
220
- _logger.info("MQTT token refreshed. Attempting to reconnect.")
221
- # Call connect. If it fails, _on_disconnect will be triggered,
222
- # and the _reconnect_loop will take over.
223
- await self.connect()
224
- except Exception as e:
225
- _logger.error("Failed to refresh MQTT token or initiate reconnect: %s", e)
226
- # If token refresh itself fails, or connect() raises an exception
227
- # before _on_disconnect can be called, ensure reconnect loop starts.
228
- if not self._disconnect_requested and (
229
- not self._reconnect_task or self._reconnect_task.done()
230
- ):
231
- _logger.info(
232
- "Scheduling MQTT reconnect after token refresh/connect failure."
233
- )
234
- self._reconnect_task = asyncio.create_task(self._reconnect_loop())
235
-
236
- def _on_message(self, client, userdata, message):
237
- """Callback for when an MQTT message is received.
238
87
 
239
- Args:
240
- client: The Paho MQTT client instance.
241
- userdata: User data passed to the client.
242
- message: The MQTTMessage object containing topic and payload.
243
- """
88
+ def _on_message(self, client, userdata, message): # pylint: disable=unused-argument
89
+ """Wrapper for handling MQTT messages in a thread-safe manner."""
244
90
  asyncio.run_coroutine_threadsafe(
245
- self._message_queue.put((message.topic, message.payload)),
246
- self._loop,
91
+ self._on_client_message(client, userdata, message), self._loop
247
92
  )
248
93
 
249
- def _on_disconnect(self, client, userdata, result_code):
250
- """Callback for when the MQTT client disconnects from the broker.
251
-
252
- Args:
253
- client: The Paho MQTT client instance.
254
- userdata: User data passed to the client.
255
- result_code: The disconnection result code.
256
- """
257
- _logger.warning("MQTT disconnected with result code: %s", result_code)
258
- if not self._disconnect_requested:
259
- _logger.info("Unexpected MQTT disconnection. Initiating reconnect loop.")
260
- if not self._reconnect_task or self._reconnect_task.done():
261
- self._reconnect_task = asyncio.run_coroutine_threadsafe(
262
- self._reconnect_loop(), self._loop
263
- )
264
- else:
265
- _logger.debug("Reconnect loop already active.")
266
- else:
267
- _logger.info("MQTT disconnected as requested.")
268
-
269
- async def _reconnect_loop(self):
270
- """Manages the MQTT reconnection process with exponential backoff."""
271
- retries = 0
272
- while not self._disconnect_requested:
273
- if self.is_connected:
274
- _logger.debug(
275
- "MQTT client reconnected within loop, stopping reconnect attempts."
276
- )
277
- break # Already connected, stop trying
278
-
279
- delay = min(2**retries, 60) # Exponential backoff, max 60 seconds
280
- _logger.debug(
281
- "Waiting %s seconds before MQTT reconnect attempt %s",
282
- delay,
283
- retries + 1,
284
- )
285
- await asyncio.sleep(delay)
286
-
287
- try:
288
- _logger.info("Attempting MQTT reconnect...")
289
- await self.connect()
290
- # If connect() succeeds, _on_connect will be called, which will cancel this task.
291
- # If connect() fails, _on_disconnect will be called again, and this loop continues.
292
- break # If connect() doesn't raise, assume it's handled by _on_connect
293
- except Exception as e:
294
- _logger.error("MQTT reconnect attempt failed: %s", e)
295
- retries += 1
296
- self._reconnect_task = None # Clear task when loop finishes or is cancelled.
297
-
298
- # -------------------------
299
- # MESSAGE WORKER (FIFO)
300
- # -------------------------
301
-
302
- async def _message_worker(self):
303
- """Worker task to process incoming MQTT messages from the queue."""
304
- while True:
305
- topic, payload = await self._message_queue.get()
306
-
307
- try:
308
- json_payload = json.loads(payload)
309
- await self._on_message_callback(json_payload, topic)
310
- except Exception:
311
- _logger.exception("Error processing MQTT message")
312
-
313
- self._message_queue.task_done()
314
-
315
- # -------------------------
316
- # PUBLISH WORKER (FIFO)
317
- # -------------------------
318
-
319
- async def _publish_worker(self):
320
- """Worker task to process outgoing MQTT publish commands from the queue."""
321
- while True:
322
- topic, payload = await self._publish_queue.get()
323
-
324
- try:
325
- # Wacht tot MQTT echt connected is
326
- while not self.is_connected:
327
- await asyncio.sleep(0.1)
94
+ async def connect(self) -> None:
95
+ """Connect the client."""
96
+ self._mqtt_client.connect(self._mqtt_broker_url, 443)
97
+ self._mqtt_client.loop_start()
328
98
 
329
- # Publish is non-blocking
330
- self._mqtt_client.publish(topic, payload, qos=2)
99
+ async def subscribe(self, topic: str) -> None:
100
+ """Subscribe to a MQTT topic."""
101
+ self._mqtt_client.subscribe(topic)
331
102
 
332
- except Exception:
333
- _logger.exception("Error publishing MQTT message")
103
+ async def publish_message(self, topic: str, json_payload: str) -> None:
104
+ """Publish a MQTT message."""
105
+ self._mqtt_client.publish(topic, json_payload, qos=2)
334
106
 
335
- self._publish_queue.task_done()
107
+ async def disconnect(self) -> None:
108
+ """Disconnect the client."""
109
+ if self._mqtt_client.is_connected():
110
+ self._mqtt_client.disconnect()
111
+
112
+ async def _on_client_message(self, client, userdata, message): # pylint: disable=unused-argument
113
+ """Handle messages received by mqtt client."""
114
+ json_payload = await self._loop.run_in_executor(
115
+ None, json.loads, message.payload
116
+ )
117
+ _logger.debug(
118
+ "Received MQTT message \n\ntopic: %s\npayload:\n\n%s\n",
119
+ message.topic,
120
+ json.dumps(json_payload, indent=2),
121
+ )
122
+ if self._on_message_callback:
123
+ await self._on_message_callback(json_payload, message.topic)
@@ -1,11 +1,9 @@
1
- from typing import Optional
2
1
  from .lghorizon_models import (
3
2
  LGHorizonRecordingList,
4
3
  LGHorizonRecordingSingle,
5
4
  LGHorizonRecordingSeason,
6
5
  LGHorizonRecordingShow,
7
6
  LGHorizonRecordingType,
8
- LGHorizonShowRecordingList,
9
7
  )
10
8
 
11
9
 
@@ -34,22 +32,10 @@ class LGHorizonRecordingFactory:
34
32
 
35
33
  return LGHorizonRecordingList(recording_list)
36
34
 
37
- async def create_episodes(self, episode_json: dict) -> LGHorizonShowRecordingList:
35
+ async def create_episodes(self, episode_json: dict) -> LGHorizonRecordingList:
38
36
  """Create a LGHorizonRecording list based for episodes."""
39
37
  recording_list = []
40
- show_title: Optional[str] = None
41
- if "images" in episode_json:
42
- images = episode_json["images"]
43
- show_image = next(
44
- (img["url"] for img in images if img.get("type") == "titleTreatment"),
45
- images[0]["url"] if images else None,
46
- )
47
- else:
48
- show_image = None
49
-
50
38
  for recording in episode_json["data"]:
51
39
  recording_single = LGHorizonRecordingSingle(recording)
52
- if show_title is None:
53
- show_title = recording_single.show_title or recording_single.title
54
40
  recording_list.append(recording_single)
55
- return LGHorizonShowRecordingList(show_title, show_image, recording_list)
41
+ return LGHorizonRecordingList(recording_list)
@@ -0,0 +1,41 @@
1
+ Metadata-Version: 2.4
2
+ Name: lghorizon
3
+ Version: 0.9.0b0
4
+ Summary: Python client for Liberty Global Horizon settop boxes
5
+ Home-page: https://github.com/sholofly/LGHorizon-python
6
+ Author: Rudolf Offereins
7
+ Author-email: r.offereins@gmail.com
8
+ License: MIT license
9
+ Keywords: LG,Horizon,API,Settop box
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Natural Language :: English
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Programming Language :: Python :: 3.6
17
+ Classifier: Programming Language :: Python :: 3.7
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Requires-Python: >=3.9
21
+ Description-Content-Type: text/markdown
22
+ License-File: LICENSE
23
+ Requires-Dist: paho-mqtt
24
+ Requires-Dist: requests>=2.22.0
25
+ Requires-Dist: backoff>=1.9.0
26
+ Dynamic: author
27
+ Dynamic: author-email
28
+ Dynamic: classifier
29
+ Dynamic: description
30
+ Dynamic: description-content-type
31
+ Dynamic: home-page
32
+ Dynamic: keywords
33
+ Dynamic: license
34
+ Dynamic: license-file
35
+ Dynamic: requires-dist
36
+ Dynamic: requires-python
37
+ Dynamic: summary
38
+
39
+ # LG Horizon Api
40
+
41
+ Python library to control multiple LG Horizon boxes
@@ -0,0 +1,17 @@
1
+ lghorizon/__init__.py,sha256=kUPbxGDOnoMwR3TdAeuzt8-vDB2bwRQCX_6yPSH1Rf8,181
2
+ lghorizon/const.py,sha256=HINlbyevEN9ZRnfIBbSGNc6i9J8WkIgpqkLZrwyqpGQ,5307
3
+ lghorizon/exceptions.py,sha256=-6v55KDTogBldGAg1wV9Mrxm5L5BsaVguhBgVMOeJHk,404
4
+ lghorizon/helpers.py,sha256=SGlEN6V0kh2vqw1qCKmM1KhfeO-UvPyyQmnThgFLFhs,272
5
+ lghorizon/lghorizon_api.py,sha256=r3Pc6ju4uHdSZOPZLtu3wxPFDMaoCGFt_3gZ4RNCg-Q,11781
6
+ lghorizon/lghorizon_device.py,sha256=jaxEjTjIx1zI9rBsv9gUwScRsVVNZrJMeM4ygxllBIo,12198
7
+ lghorizon/lghorizon_device_state_processor.py,sha256=R-aLtmS50nqJ3CZ7R5Qdg9w4lvZ3xIWiMskh4ls2sTA,12062
8
+ lghorizon/lghorizon_message_factory.py,sha256=1ZqgoGCERI-fhFh9ralemeHjPcpaPJS2AAkoGIbp9YI,1496
9
+ lghorizon/lghorizon_models.py,sha256=bncfG_gfJaG604xceg-svg8_EUDsplRXSSknqgOqh6k,41141
10
+ lghorizon/lghorizon_mqtt_client.py,sha256=bUWhTM8BJ1V5CbgP8ueJrm-pmopxBB7v_hLHKSwLxUU,4704
11
+ lghorizon/lghorizon_recording_factory.py,sha256=qIVfnfIvXliUv-Gy1LJXYmE6rFg90WhOQn_3P4ji2T0,1755
12
+ lghorizon/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
+ lghorizon-0.9.0b0.dist-info/licenses/LICENSE,sha256=6Dh2tur1gMX3r3rITjVwUONBEJxyyPZDY8p6DZXtimE,1059
14
+ lghorizon-0.9.0b0.dist-info/METADATA,sha256=aGK1F_OuZ-TZJkBMSO58RPWpKKnFylwIscKOorPSOf8,1284
15
+ lghorizon-0.9.0b0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
16
+ lghorizon-0.9.0b0.dist-info/top_level.txt,sha256=usii76_AxGfPI6gjrrh-NyZxcQQuF1B8_Q9kd7sID8Q,10
17
+ lghorizon-0.9.0b0.dist-info/RECORD,,