boneIO 1.1.0.dev2__py3-none-any.whl → 1.1.0.dev4__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.
- boneio/core/config/config_helper.py +8 -0
- boneio/core/manager/inputs.py +8 -0
- boneio/core/manager/manager.py +32 -3
- boneio/core/manager/remote.py +113 -4
- boneio/core/manager/update.py +341 -0
- boneio/core/remote/__init__.py +9 -0
- boneio/core/remote/base.py +2 -0
- boneio/core/remote/wled.py +572 -0
- boneio/integration/homeassistant.py +45 -2
- boneio/runner.py +3 -2
- boneio/schema/actions.yaml +18 -0
- boneio/schema/remote_devices.yaml +106 -3
- boneio/schema/schema.yaml +12 -0
- boneio/version.py +1 -1
- boneio/webui/app.py +2 -0
- boneio/webui/frontend-dist/assets/{cssMode-Ca1DAf9-.js → cssMode-CfBnHhQI.js} +1 -1
- boneio/webui/frontend-dist/assets/{freemarker2-C5IgNKwL.js → freemarker2-Bh2BQpSG.js} +1 -1
- boneio/webui/frontend-dist/assets/{handlebars-coN2SbcA.js → handlebars-D68RL95z.js} +1 -1
- boneio/webui/frontend-dist/assets/{html-DauOMz0u.js → html-XKVYovyY.js} +1 -1
- boneio/webui/frontend-dist/assets/{htmlMode-CmodurAE.js → htmlMode-QT0rbnhL.js} +1 -1
- boneio/webui/frontend-dist/assets/index-D0XGmf2O.css +1 -0
- boneio/webui/frontend-dist/assets/{index-CeFOvpPM.js → index-NKuu8fvk.js} +201 -201
- boneio/webui/frontend-dist/assets/{javascript-N6k9oxRy.js → javascript-CxT2_Xcl.js} +1 -1
- boneio/webui/frontend-dist/assets/{jsonMode-DCkYHItT.js → jsonMode-mhlSbJro.js} +1 -1
- boneio/webui/frontend-dist/assets/{liquid-CuYEfgul.js → liquid-D62PitRX.js} +1 -1
- boneio/webui/frontend-dist/assets/{mdx-DEiUIP8w.js → mdx-C5xjvE7q.js} +1 -1
- boneio/webui/frontend-dist/assets/{python-DBpf3_HZ.js → python-B5fpI3od.js} +1 -1
- boneio/webui/frontend-dist/assets/{razor-S1P72cEP.js → razor-DaPtFLXE.js} +1 -1
- boneio/webui/frontend-dist/assets/{tsMode-A2L2Jxa9.js → tsMode-ltYtI_us.js} +1 -1
- boneio/webui/frontend-dist/assets/{typescript-CupJHRfY.js → typescript-DwdFtOSo.js} +1 -1
- boneio/webui/frontend-dist/assets/{xml-DDPTy55c.js → xml-CiaIsyq7.js} +1 -1
- boneio/webui/frontend-dist/assets/{yaml-CuImUjrc.js → yaml-DWYHE4pc.js} +1 -1
- boneio/webui/frontend-dist/index.html +2 -2
- boneio/webui/frontend-dist/schem/binary_sensor.schema.json +32 -0
- boneio/webui/frontend-dist/schem/config.schema.json +338 -3
- boneio/webui/frontend-dist/schem/event.schema.json +112 -0
- boneio/webui/frontend-dist/schem/mqtt.schema.json +13 -0
- boneio/webui/frontend-dist/schem/remote_devices.schema.json +179 -1
- boneio/webui/frontend-dist/schem/web.schema.json +2 -2
- boneio/webui/routes/config.py +3 -0
- boneio/webui/routes/remote_devices.py +84 -0
- boneio/webui/routes/update.py +42 -1
- boneio/webui/schema/binary_sensor.schema.json +32 -0
- boneio/webui/schema/config.schema.json +338 -3
- boneio/webui/schema/event.schema.json +112 -0
- boneio/webui/schema/mqtt.schema.json +13 -0
- boneio/webui/schema/remote_devices.schema.json +179 -1
- boneio/webui/schema/web.schema.json +2 -2
- boneio/webui/web_server.py +34 -29
- {boneio-1.1.0.dev2.dist-info → boneio-1.1.0.dev4.dist-info}/METADATA +2 -1
- {boneio-1.1.0.dev2.dist-info → boneio-1.1.0.dev4.dist-info}/RECORD +55 -53
- boneio/webui/frontend-dist/assets/index-BGHXdp7U.css +0 -1
- {boneio-1.1.0.dev2.dist-info → boneio-1.1.0.dev4.dist-info}/WHEEL +0 -0
- {boneio-1.1.0.dev2.dist-info → boneio-1.1.0.dev4.dist-info}/entry_points.txt +0 -0
- {boneio-1.1.0.dev2.dist-info → boneio-1.1.0.dev4.dist-info}/licenses/LICENSE +0 -0
- {boneio-1.1.0.dev2.dist-info → boneio-1.1.0.dev4.dist-info}/top_level.txt +0 -0
|
@@ -45,6 +45,7 @@ class ConfigHelper:
|
|
|
45
45
|
config_file_path: str | None = None,
|
|
46
46
|
send_boneio_autodiscovery: bool = True,
|
|
47
47
|
receive_boneio_autodiscovery: bool = True,
|
|
48
|
+
update_channel: str = "stable",
|
|
48
49
|
):
|
|
49
50
|
self._name = name
|
|
50
51
|
|
|
@@ -63,6 +64,7 @@ class ConfigHelper:
|
|
|
63
64
|
self._ha_discovery_prefix = ha_discovery_prefix
|
|
64
65
|
self._send_boneio_autodiscovery = send_boneio_autodiscovery
|
|
65
66
|
self._receive_boneio_autodiscovery = receive_boneio_autodiscovery
|
|
67
|
+
self._update_channel = update_channel
|
|
66
68
|
self._device_type = device_type
|
|
67
69
|
self._web_port = web_port
|
|
68
70
|
self._proxy_port = proxy_port
|
|
@@ -79,6 +81,7 @@ class ConfigHelper:
|
|
|
79
81
|
TEXT_SENSOR: {},
|
|
80
82
|
SELECT: {},
|
|
81
83
|
NUMERIC: {},
|
|
84
|
+
"update": {}
|
|
82
85
|
}
|
|
83
86
|
self.manager_ready: bool = False
|
|
84
87
|
self._network_info = network_info
|
|
@@ -181,6 +184,11 @@ class ConfigHelper:
|
|
|
181
184
|
"""Check if BoneIO autodiscovery receiving is enabled."""
|
|
182
185
|
return self._receive_boneio_autodiscovery
|
|
183
186
|
|
|
187
|
+
@property
|
|
188
|
+
def update_channel(self) -> str:
|
|
189
|
+
"""Get update channel (stable or dev)."""
|
|
190
|
+
return self._update_channel
|
|
191
|
+
|
|
184
192
|
@property
|
|
185
193
|
def device_type(self) -> str:
|
|
186
194
|
return self._device_type
|
boneio/core/manager/inputs.py
CHANGED
|
@@ -687,6 +687,14 @@ class InputManager:
|
|
|
687
687
|
# Send event to MQTT for Home Assistant
|
|
688
688
|
self._publish_input_event_to_mqtt(input_instance, event)
|
|
689
689
|
|
|
690
|
+
# If publish_only is set, skip action execution (used for initial state sync)
|
|
691
|
+
if event.publish_only:
|
|
692
|
+
_LOGGER.debug(
|
|
693
|
+
"Skipping action execution for %s (publish_only=True)",
|
|
694
|
+
event.entity_id
|
|
695
|
+
)
|
|
696
|
+
return
|
|
697
|
+
|
|
690
698
|
# Execute actions with duration threshold support
|
|
691
699
|
if actions and event.click_type == LONG:
|
|
692
700
|
# Get executed_actions from detector state
|
boneio/core/manager/manager.py
CHANGED
|
@@ -39,6 +39,7 @@ from boneio.core.manager.modbus import ModbusManager
|
|
|
39
39
|
from boneio.core.manager.outputs import OutputManager
|
|
40
40
|
from boneio.core.manager.sensors import SensorManager
|
|
41
41
|
from boneio.core.manager.remote import RemoteDeviceManager
|
|
42
|
+
from boneio.core.manager.update import UpdateManager
|
|
42
43
|
from boneio.core.discovery import BlackDiscoveryPublisher
|
|
43
44
|
from boneio.core.messaging import MessageBus
|
|
44
45
|
from boneio.core.state import StateManager
|
|
@@ -204,6 +205,11 @@ class Manager:
|
|
|
204
205
|
message_bus=message_bus,
|
|
205
206
|
)
|
|
206
207
|
|
|
208
|
+
# 9. UpdateManager (checks for software updates and publishes to HA)
|
|
209
|
+
self.update_manager = UpdateManager(
|
|
210
|
+
manager=self,
|
|
211
|
+
)
|
|
212
|
+
|
|
207
213
|
# Configure virtual energy sensors (must be after outputs are initialized)
|
|
208
214
|
self.sensors.configure_virtual_energy_sensors()
|
|
209
215
|
|
|
@@ -323,6 +329,7 @@ class Manager:
|
|
|
323
329
|
await self.sensors.send_ha_autodiscovery()
|
|
324
330
|
await self.modbus.send_ha_autodiscovery()
|
|
325
331
|
await self.display.send_ha_autodiscovery()
|
|
332
|
+
await self.update_manager.send_ha_autodiscovery()
|
|
326
333
|
|
|
327
334
|
def send_ha_autodiscovery(
|
|
328
335
|
self,
|
|
@@ -380,6 +387,9 @@ class Manager:
|
|
|
380
387
|
availability_msg_func = ha_cover_availabilty_message
|
|
381
388
|
elif ha_type == BUTTON:
|
|
382
389
|
availability_msg_func = ha_button_availabilty_message
|
|
390
|
+
elif ha_type == "update":
|
|
391
|
+
from boneio.integration.homeassistant import ha_update_availability_message
|
|
392
|
+
availability_msg_func = ha_update_availability_message
|
|
383
393
|
|
|
384
394
|
# Use availability_msg_func from kwargs if provided (for sensors)
|
|
385
395
|
if 'availability_msg_func' in kwargs:
|
|
@@ -400,6 +410,16 @@ class Manager:
|
|
|
400
410
|
)
|
|
401
411
|
self.send_message(topic=topic, payload=payload, retain=True)
|
|
402
412
|
|
|
413
|
+
async def _handle_update_install_command(self, topic: str, payload: str) -> None:
|
|
414
|
+
"""Handle update install command from Home Assistant.
|
|
415
|
+
|
|
416
|
+
Args:
|
|
417
|
+
topic: MQTT topic
|
|
418
|
+
payload: MQTT payload (should be "INSTALL")
|
|
419
|
+
"""
|
|
420
|
+
_LOGGER.info("Received update install command: %s", payload)
|
|
421
|
+
await self.update_manager.handle_install_command(payload)
|
|
422
|
+
|
|
403
423
|
def parse_actions(self, pin: str, actions: dict) -> dict:
|
|
404
424
|
"""Parse actions configuration.
|
|
405
425
|
|
|
@@ -698,7 +718,7 @@ class Manager:
|
|
|
698
718
|
)
|
|
699
719
|
|
|
700
720
|
elif action == REMOTE_OUTPUT:
|
|
701
|
-
# Control output on remote device
|
|
721
|
+
# Control output on remote device (supports ESPHome lights with brightness/color and WLED effects)
|
|
702
722
|
remote_device_id = action_definition.get("remote_device")
|
|
703
723
|
output_id = action_definition.get("output_id")
|
|
704
724
|
action_output = action_definition.get("action_output", "TOGGLE")
|
|
@@ -706,6 +726,14 @@ class Manager:
|
|
|
706
726
|
device_id=remote_device_id,
|
|
707
727
|
output_id=output_id,
|
|
708
728
|
action=action_output,
|
|
729
|
+
brightness=action_definition.get("brightness"),
|
|
730
|
+
color_temp=action_definition.get("color_temp"),
|
|
731
|
+
rgb=action_definition.get("rgb"),
|
|
732
|
+
transition=action_definition.get("transition"),
|
|
733
|
+
effect=action_definition.get("effect"),
|
|
734
|
+
palette=action_definition.get("palette"),
|
|
735
|
+
effect_speed=action_definition.get("effect_speed"),
|
|
736
|
+
effect_intensity=action_definition.get("effect_intensity"),
|
|
709
737
|
)
|
|
710
738
|
|
|
711
739
|
elif action == REMOTE_COVER:
|
|
@@ -740,16 +768,17 @@ class Manager:
|
|
|
740
768
|
configure_logger(log_config, debug=0)
|
|
741
769
|
_LOGGER.info("Logger configuration reloaded successfully")
|
|
742
770
|
|
|
743
|
-
def _reload_remote_devices(self) -> None:
|
|
771
|
+
async def _reload_remote_devices(self) -> None:
|
|
744
772
|
"""Reload remote devices configuration from config file.
|
|
745
773
|
|
|
746
774
|
This allows hot-reloading of remote devices without restarting the application.
|
|
775
|
+
Handles ESPHome connections properly (stops old, starts new).
|
|
747
776
|
"""
|
|
748
777
|
config = self._config_helper.get_config()
|
|
749
778
|
remote_devices_config = config.get("remote_devices", [])
|
|
750
779
|
|
|
751
780
|
_LOGGER.info("Reloading remote devices configuration")
|
|
752
|
-
self.remote_devices.reload(remote_devices_config)
|
|
781
|
+
await self.remote_devices.reload(remote_devices_config)
|
|
753
782
|
_LOGGER.info("Remote devices configuration reloaded successfully")
|
|
754
783
|
|
|
755
784
|
async def publish_discovery(self) -> None:
|
boneio/core/manager/remote.py
CHANGED
|
@@ -18,6 +18,7 @@ from boneio.core.remote.base import (
|
|
|
18
18
|
)
|
|
19
19
|
from boneio.core.remote.mqtt import MQTTRemoteDevice
|
|
20
20
|
from boneio.core.remote.esphome import ESPHomeRemoteDevice, ESPHOME_API_AVAILABLE
|
|
21
|
+
from boneio.core.remote.wled import WLEDRemoteDevice
|
|
21
22
|
|
|
22
23
|
if TYPE_CHECKING:
|
|
23
24
|
from boneio.core.messaging import MessageBus
|
|
@@ -176,6 +177,8 @@ class RemoteDeviceManager:
|
|
|
176
177
|
return None
|
|
177
178
|
elif protocol == RemoteDeviceProtocol.ESPHOME_API:
|
|
178
179
|
return self._create_esphome_device(device_id, name, config)
|
|
180
|
+
elif protocol == RemoteDeviceProtocol.WLED:
|
|
181
|
+
return self._create_wled_device(device_id, name, config)
|
|
179
182
|
else:
|
|
180
183
|
_LOGGER.error("Unsupported protocol '%s' for device '%s'", protocol, device_id)
|
|
181
184
|
return None
|
|
@@ -259,6 +262,40 @@ class RemoteDeviceManager:
|
|
|
259
262
|
covers=covers,
|
|
260
263
|
)
|
|
261
264
|
|
|
265
|
+
def _create_wled_device(
|
|
266
|
+
self,
|
|
267
|
+
device_id: str,
|
|
268
|
+
name: str,
|
|
269
|
+
config: dict[str, Any],
|
|
270
|
+
) -> WLEDRemoteDevice | None:
|
|
271
|
+
"""Create WLED remote device.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
device_id: Device ID
|
|
275
|
+
name: Device name
|
|
276
|
+
config: Full device configuration
|
|
277
|
+
|
|
278
|
+
Returns:
|
|
279
|
+
WLEDRemoteDevice instance or None if creation failed
|
|
280
|
+
"""
|
|
281
|
+
wled_config = config.get("wled", {})
|
|
282
|
+
host = wled_config.get("host")
|
|
283
|
+
|
|
284
|
+
if not host:
|
|
285
|
+
_LOGGER.error("WLED device '%s' missing 'host' in wled config", device_id)
|
|
286
|
+
return None
|
|
287
|
+
|
|
288
|
+
port = wled_config.get("port", 80)
|
|
289
|
+
segments = wled_config.get("segments", [])
|
|
290
|
+
|
|
291
|
+
return WLEDRemoteDevice(
|
|
292
|
+
id=device_id,
|
|
293
|
+
name=name,
|
|
294
|
+
host=host,
|
|
295
|
+
port=port,
|
|
296
|
+
segments=segments,
|
|
297
|
+
)
|
|
298
|
+
|
|
262
299
|
async def start_all_connections(self, delay_seconds: float = 10.0) -> None:
|
|
263
300
|
"""Start persistent connections for all ESPHome devices.
|
|
264
301
|
|
|
@@ -332,19 +369,32 @@ class RemoteDeviceManager:
|
|
|
332
369
|
output_id: str,
|
|
333
370
|
action: str,
|
|
334
371
|
brightness: int | None = None,
|
|
372
|
+
color_temp: int | None = None,
|
|
373
|
+
rgb: list[int] | tuple[int, int, int] | None = None,
|
|
335
374
|
transition: float | None = None,
|
|
375
|
+
effect: int | None = None,
|
|
376
|
+
palette: int | None = None,
|
|
377
|
+
effect_speed: int | None = None,
|
|
378
|
+
effect_intensity: int | None = None,
|
|
336
379
|
) -> bool:
|
|
337
|
-
"""Control output on remote device (BoneIO MQTT
|
|
380
|
+
"""Control output on remote device (BoneIO MQTT, ESPHome API, or WLED).
|
|
338
381
|
|
|
339
382
|
Automatically detects device protocol and routes to appropriate method.
|
|
340
383
|
For ESPHome devices, output_id can be a switch or light entity.
|
|
384
|
+
For WLED devices, output_id can be "main" or segment ID.
|
|
341
385
|
|
|
342
386
|
Args:
|
|
343
387
|
device_id: ID of the remote device
|
|
344
388
|
output_id: ID of the output/switch/light to control
|
|
345
389
|
action: Action to perform (ON, OFF, TOGGLE, BRIGHTNESS_UP, BRIGHTNESS_DOWN, SET_BRIGHTNESS)
|
|
346
|
-
brightness: Brightness level (0-255) -
|
|
347
|
-
|
|
390
|
+
brightness: Brightness level (0-255) - for ESPHome/WLED lights
|
|
391
|
+
color_temp: Color temperature in mireds - only for ESPHome lights
|
|
392
|
+
rgb: RGB color as [R, G, B] list or tuple (0-255 each) - for ESPHome/WLED lights
|
|
393
|
+
transition: Transition time in seconds - for ESPHome/WLED lights
|
|
394
|
+
effect: WLED effect ID
|
|
395
|
+
palette: WLED color palette ID
|
|
396
|
+
effect_speed: WLED effect speed (0-255)
|
|
397
|
+
effect_intensity: WLED effect intensity (0-255)
|
|
348
398
|
|
|
349
399
|
Returns:
|
|
350
400
|
True if command was sent successfully
|
|
@@ -359,10 +409,16 @@ class RemoteDeviceManager:
|
|
|
359
409
|
# Check if output_id is a light
|
|
360
410
|
if device.has_light(output_id):
|
|
361
411
|
_LOGGER.debug("Controlling ESPHome light '%s' on device '%s'", output_id, device_id)
|
|
412
|
+
# Convert rgb list to tuple[int, int, int] if needed
|
|
413
|
+
rgb_tuple: tuple[int, int, int] | None = None
|
|
414
|
+
if rgb and len(rgb) >= 3:
|
|
415
|
+
rgb_tuple = (int(rgb[0]), int(rgb[1]), int(rgb[2]))
|
|
362
416
|
return await device.control_light(
|
|
363
417
|
light_id=output_id,
|
|
364
418
|
action=action,
|
|
365
419
|
brightness=brightness,
|
|
420
|
+
color_temp=color_temp,
|
|
421
|
+
rgb=rgb_tuple,
|
|
366
422
|
transition=transition if transition is not None else 0.0,
|
|
367
423
|
)
|
|
368
424
|
# Otherwise treat as switch
|
|
@@ -372,6 +428,27 @@ class RemoteDeviceManager:
|
|
|
372
428
|
action=action,
|
|
373
429
|
)
|
|
374
430
|
|
|
431
|
+
# For WLED devices, use HTTP JSON API
|
|
432
|
+
if isinstance(device, WLEDRemoteDevice):
|
|
433
|
+
_LOGGER.debug("Controlling WLED '%s' segment '%s' on device '%s'", output_id, action, device_id)
|
|
434
|
+
# Parse segment_id - "main" means whole device, otherwise it's segment ID
|
|
435
|
+
segment_id = None if output_id == "main" else int(output_id)
|
|
436
|
+
# Convert rgb list to tuple if needed
|
|
437
|
+
wled_rgb: tuple[int, int, int] | None = None
|
|
438
|
+
if rgb and len(rgb) >= 3:
|
|
439
|
+
wled_rgb = (int(rgb[0]), int(rgb[1]), int(rgb[2]))
|
|
440
|
+
return await device.control_light(
|
|
441
|
+
segment_id=segment_id,
|
|
442
|
+
action=action,
|
|
443
|
+
brightness=brightness,
|
|
444
|
+
rgb=wled_rgb,
|
|
445
|
+
transition=transition if transition is not None else 0.0,
|
|
446
|
+
effect=effect,
|
|
447
|
+
palette=palette,
|
|
448
|
+
effect_speed=effect_speed,
|
|
449
|
+
effect_intensity=effect_intensity,
|
|
450
|
+
)
|
|
451
|
+
|
|
375
452
|
# For MQTT devices, use standard control_output
|
|
376
453
|
return await device.control_output(
|
|
377
454
|
output_id=output_id,
|
|
@@ -420,20 +497,52 @@ class RemoteDeviceManager:
|
|
|
420
497
|
**kwargs,
|
|
421
498
|
)
|
|
422
499
|
|
|
423
|
-
def reload(self, remote_devices_config: list[dict[str, Any]] | None = None) -> None:
|
|
500
|
+
async def reload(self, remote_devices_config: list[dict[str, Any]] | None = None) -> None:
|
|
424
501
|
"""Reload remote devices from config.
|
|
425
502
|
|
|
503
|
+
Stops existing ESPHome connections, reconfigures devices,
|
|
504
|
+
and starts new ESPHome connections.
|
|
505
|
+
|
|
426
506
|
Args:
|
|
427
507
|
remote_devices_config: New list of remote device configurations
|
|
428
508
|
"""
|
|
429
509
|
_LOGGER.info("Reloading remote devices configuration")
|
|
510
|
+
|
|
511
|
+
# Stop existing ESPHome connections first
|
|
512
|
+
await self.stop_all_connections()
|
|
513
|
+
|
|
430
514
|
self._devices.clear()
|
|
431
515
|
|
|
432
516
|
if remote_devices_config:
|
|
433
517
|
self._configure_devices(remote_devices_config)
|
|
434
518
|
|
|
519
|
+
# Start ESPHome connections immediately (no delay for reload)
|
|
520
|
+
await self._start_esphome_connections_immediate()
|
|
521
|
+
|
|
435
522
|
_LOGGER.info("Reloaded %d remote devices", len(self._devices))
|
|
436
523
|
|
|
524
|
+
async def _start_esphome_connections_immediate(self) -> None:
|
|
525
|
+
"""Start ESPHome connections immediately without delay.
|
|
526
|
+
|
|
527
|
+
Used during reload when we want connections to start right away.
|
|
528
|
+
"""
|
|
529
|
+
esphome_devices = [
|
|
530
|
+
(device_id, device)
|
|
531
|
+
for device_id, device in self._devices.items()
|
|
532
|
+
if isinstance(device, ESPHomeRemoteDevice)
|
|
533
|
+
]
|
|
534
|
+
|
|
535
|
+
if not esphome_devices:
|
|
536
|
+
return
|
|
537
|
+
|
|
538
|
+
_LOGGER.info("Starting %d ESPHome connection(s)...", len(esphome_devices))
|
|
539
|
+
for device_id, device in esphome_devices:
|
|
540
|
+
try:
|
|
541
|
+
await device.start_connection()
|
|
542
|
+
_LOGGER.info("Started connection for ESPHome device '%s'", device_id)
|
|
543
|
+
except Exception as e:
|
|
544
|
+
_LOGGER.error("Failed to start connection for ESPHome device '%s': %s", device_id, e)
|
|
545
|
+
|
|
437
546
|
def to_dict(self) -> dict[str, Any]:
|
|
438
547
|
"""Convert to dictionary representation.
|
|
439
548
|
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""Update manager for checking and publishing software updates to Home Assistant.
|
|
2
|
+
|
|
3
|
+
This module manages periodic checking of software updates from GitHub
|
|
4
|
+
and publishes the update status to Home Assistant via MQTT.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from boneio.core.utils.async_updater import AsyncUpdater
|
|
14
|
+
from boneio.core.utils.timeperiod import TimePeriod
|
|
15
|
+
from boneio.version import __version__
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from boneio.core.manager import Manager
|
|
19
|
+
|
|
20
|
+
_LOGGER = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class UpdateManager(AsyncUpdater):
|
|
24
|
+
"""Manages software update checking and MQTT publishing.
|
|
25
|
+
|
|
26
|
+
This manager:
|
|
27
|
+
- Checks for updates from GitHub periodically (default: 4 hours)
|
|
28
|
+
- Caches results to avoid rate limiting
|
|
29
|
+
- Publishes update state to MQTT for Home Assistant
|
|
30
|
+
- Supports on-demand update checks from frontend
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
manager: Manager,
|
|
36
|
+
update_interval: TimePeriod | None = None,
|
|
37
|
+
):
|
|
38
|
+
"""Initialize UpdateManager.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
manager: Parent Manager instance
|
|
42
|
+
update_interval: Time between update checks (default: 4 hours)
|
|
43
|
+
"""
|
|
44
|
+
self.id = "update_manager"
|
|
45
|
+
self._manager = manager
|
|
46
|
+
|
|
47
|
+
# Update check interval (default: 4 hours)
|
|
48
|
+
if update_interval is None:
|
|
49
|
+
update_interval = TimePeriod(hours=4)
|
|
50
|
+
|
|
51
|
+
# Cache for update info to avoid excessive GitHub API calls
|
|
52
|
+
self._last_check_result: dict | None = None
|
|
53
|
+
self._last_published_state: str | None = None
|
|
54
|
+
self._ha_discovery_sent: bool = False
|
|
55
|
+
|
|
56
|
+
# Initialize AsyncUpdater (starts periodic task)
|
|
57
|
+
super().__init__(manager=manager, update_interval=update_interval)
|
|
58
|
+
|
|
59
|
+
_LOGGER.info(
|
|
60
|
+
"UpdateManager initialized (check interval: %s)",
|
|
61
|
+
update_interval
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
async def async_update(self, timestamp: float) -> float | None:
|
|
65
|
+
"""Perform periodic update check.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
timestamp: Current timestamp
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
Optional custom update interval in seconds
|
|
72
|
+
"""
|
|
73
|
+
# Send HA discovery on first update (MQTT is connected by then)
|
|
74
|
+
if not self._ha_discovery_sent:
|
|
75
|
+
await self.send_ha_autodiscovery()
|
|
76
|
+
self._ha_discovery_sent = True
|
|
77
|
+
|
|
78
|
+
_LOGGER.debug("Checking for software updates...")
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
# Check for updates from GitHub
|
|
82
|
+
update_info = await self._check_update_from_github()
|
|
83
|
+
|
|
84
|
+
if update_info and update_info.get("status") == "success":
|
|
85
|
+
# Publish state to MQTT
|
|
86
|
+
await self._publish_state_to_mqtt(update_info)
|
|
87
|
+
else:
|
|
88
|
+
_LOGGER.warning(
|
|
89
|
+
"Update check failed: %s",
|
|
90
|
+
update_info.get("message", "Unknown error") if update_info else "No response"
|
|
91
|
+
)
|
|
92
|
+
except Exception as e:
|
|
93
|
+
_LOGGER.error("Error during update check: %s", e, exc_info=True)
|
|
94
|
+
|
|
95
|
+
# Return None to use default interval
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
async def _check_update_from_github(self) -> dict | None:
|
|
99
|
+
"""Check for updates from GitHub releases.
|
|
100
|
+
|
|
101
|
+
This method reuses the logic from /api/check_update endpoint.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Update information dict or None on error
|
|
105
|
+
"""
|
|
106
|
+
current_version = __version__
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
import requests
|
|
110
|
+
except ImportError:
|
|
111
|
+
_LOGGER.error("Package 'requests' is not installed")
|
|
112
|
+
return {
|
|
113
|
+
"status": "error",
|
|
114
|
+
"message": "Package 'requests' is not installed",
|
|
115
|
+
"current_version": current_version
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
from packaging import version
|
|
120
|
+
except ImportError:
|
|
121
|
+
_LOGGER.error("Package 'packaging' is not installed")
|
|
122
|
+
return {
|
|
123
|
+
"status": "error",
|
|
124
|
+
"message": "Package 'packaging' is not installed",
|
|
125
|
+
"current_version": current_version
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
repo = "boneIO-eu/app_black"
|
|
130
|
+
api_url = f'https://api.github.com/repos/{repo}/releases'
|
|
131
|
+
response = requests.get(api_url, timeout=10)
|
|
132
|
+
|
|
133
|
+
if response.status_code != 200:
|
|
134
|
+
return {
|
|
135
|
+
"status": "error",
|
|
136
|
+
"message": f"GitHub API error: {response.status_code}",
|
|
137
|
+
"current_version": current_version
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
releases = response.json()
|
|
141
|
+
|
|
142
|
+
if not releases:
|
|
143
|
+
return {
|
|
144
|
+
"status": "error",
|
|
145
|
+
"message": "No releases found on GitHub",
|
|
146
|
+
"current_version": current_version
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
# Find latest stable and prerelease versions
|
|
150
|
+
latest_stable = None
|
|
151
|
+
latest_prerelease = None
|
|
152
|
+
|
|
153
|
+
for release in releases:
|
|
154
|
+
tag = release['tag_name']
|
|
155
|
+
ver_str = tag[1:] if tag.startswith('v') else tag
|
|
156
|
+
|
|
157
|
+
# Skip v0.x versions (Debian 10, incompatible)
|
|
158
|
+
if tag.startswith("v0."):
|
|
159
|
+
continue
|
|
160
|
+
|
|
161
|
+
is_prerelease = release.get('prerelease', False)
|
|
162
|
+
|
|
163
|
+
if not is_prerelease:
|
|
164
|
+
ver_lower = ver_str.lower()
|
|
165
|
+
is_prerelease = any(x in ver_lower for x in ['dev', 'alpha', 'beta', 'rc'])
|
|
166
|
+
|
|
167
|
+
ver_info = {
|
|
168
|
+
"version": ver_str,
|
|
169
|
+
"is_prerelease": is_prerelease,
|
|
170
|
+
"release_url": release['html_url'],
|
|
171
|
+
"published_at": release['published_at'],
|
|
172
|
+
"release_notes": release.get('body', '')[:255], # Max 255 chars for HA
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if not is_prerelease and latest_stable is None:
|
|
176
|
+
latest_stable = ver_info
|
|
177
|
+
if is_prerelease and latest_prerelease is None:
|
|
178
|
+
latest_prerelease = ver_info
|
|
179
|
+
|
|
180
|
+
# Stop after finding both
|
|
181
|
+
if latest_stable and latest_prerelease:
|
|
182
|
+
break
|
|
183
|
+
|
|
184
|
+
# Determine which version to recommend based on update_channel setting
|
|
185
|
+
update_channel = self._manager._config_helper.update_channel
|
|
186
|
+
|
|
187
|
+
if update_channel == "dev":
|
|
188
|
+
# Dev channel: prefer prerelease, fallback to stable
|
|
189
|
+
recommended = latest_prerelease or latest_stable
|
|
190
|
+
else:
|
|
191
|
+
# Stable channel (default): only stable releases
|
|
192
|
+
recommended = latest_stable
|
|
193
|
+
|
|
194
|
+
if not recommended:
|
|
195
|
+
return {
|
|
196
|
+
"status": "error",
|
|
197
|
+
"message": "No suitable release found",
|
|
198
|
+
"current_version": current_version
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
# Check if update is available
|
|
202
|
+
is_update_available = False
|
|
203
|
+
try:
|
|
204
|
+
current_parsed = version.parse(current_version)
|
|
205
|
+
recommended_parsed = version.parse(recommended["version"])
|
|
206
|
+
is_update_available = recommended_parsed > current_parsed
|
|
207
|
+
except Exception as e:
|
|
208
|
+
_LOGGER.warning("Error parsing versions: %s", e)
|
|
209
|
+
|
|
210
|
+
result = {
|
|
211
|
+
"status": "success",
|
|
212
|
+
"current_version": current_version,
|
|
213
|
+
"latest_version": recommended["version"],
|
|
214
|
+
"update_available": is_update_available,
|
|
215
|
+
"release_url": recommended["release_url"],
|
|
216
|
+
"release_notes": recommended.get("release_notes", ""),
|
|
217
|
+
"is_prerelease": recommended["is_prerelease"],
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
# Cache result
|
|
221
|
+
self._last_check_result = result
|
|
222
|
+
|
|
223
|
+
return result
|
|
224
|
+
|
|
225
|
+
except Exception as e:
|
|
226
|
+
_LOGGER.error("Error checking for updates: %s", e, exc_info=True)
|
|
227
|
+
return {
|
|
228
|
+
"status": "error",
|
|
229
|
+
"message": f"Error: {str(e)}",
|
|
230
|
+
"current_version": current_version
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async def _publish_state_to_mqtt(self, update_info: dict) -> None:
|
|
234
|
+
"""Publish update state to MQTT for Home Assistant.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
update_info: Update information from GitHub check
|
|
238
|
+
"""
|
|
239
|
+
if update_info.get("status") != "success":
|
|
240
|
+
return
|
|
241
|
+
|
|
242
|
+
current_version = update_info.get("current_version", __version__)
|
|
243
|
+
latest_version = update_info.get("latest_version", current_version)
|
|
244
|
+
|
|
245
|
+
# Build state payload (JSON format for HA Update entity)
|
|
246
|
+
state_payload = {
|
|
247
|
+
"installed_version": current_version,
|
|
248
|
+
"latest_version": latest_version,
|
|
249
|
+
"title": "boneIO Black Firmware",
|
|
250
|
+
"release_url": update_info.get("release_url", ""),
|
|
251
|
+
"release_summary": update_info.get("release_notes", "")[:255], # HA limit
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
# Add entity_picture (boneIO logo from HA brands)
|
|
255
|
+
state_payload["entity_picture"] = "http://boneio.eu/logo_fb_circle.png"
|
|
256
|
+
|
|
257
|
+
# Convert to JSON
|
|
258
|
+
payload_json = json.dumps(state_payload)
|
|
259
|
+
|
|
260
|
+
# Only publish if state changed (avoid unnecessary MQTT traffic)
|
|
261
|
+
if payload_json == self._last_published_state:
|
|
262
|
+
_LOGGER.debug("Update state unchanged, skipping MQTT publish")
|
|
263
|
+
return
|
|
264
|
+
|
|
265
|
+
# Publish to MQTT
|
|
266
|
+
topic_prefix = self._manager._config_helper.topic_prefix
|
|
267
|
+
state_topic = f"{topic_prefix}/update/state"
|
|
268
|
+
|
|
269
|
+
self._manager.send_message(
|
|
270
|
+
topic=state_topic,
|
|
271
|
+
payload=payload_json,
|
|
272
|
+
retain=True,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
self._last_published_state = payload_json
|
|
276
|
+
|
|
277
|
+
_LOGGER.info(
|
|
278
|
+
"Published update state to MQTT: %s -> %s (update available: %s)",
|
|
279
|
+
current_version,
|
|
280
|
+
latest_version,
|
|
281
|
+
update_info.get("update_available", False)
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
def get_last_check_result(self) -> dict | None:
|
|
285
|
+
"""Get the last update check result.
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
Last check result dict or None if no check performed yet
|
|
289
|
+
"""
|
|
290
|
+
return self._last_check_result
|
|
291
|
+
|
|
292
|
+
async def handle_install_command(self, payload: str) -> None:
|
|
293
|
+
"""Handle install command from Home Assistant.
|
|
294
|
+
|
|
295
|
+
This is called when HA sends a message to the command topic.
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
payload: MQTT payload (should be "INSTALL")
|
|
299
|
+
"""
|
|
300
|
+
if payload != "INSTALL":
|
|
301
|
+
_LOGGER.warning("Invalid install command payload: %s", payload)
|
|
302
|
+
return
|
|
303
|
+
|
|
304
|
+
_LOGGER.info("Received install command from Home Assistant")
|
|
305
|
+
|
|
306
|
+
# Check if update is available
|
|
307
|
+
if not self._last_check_result:
|
|
308
|
+
_LOGGER.warning("No update check result available, checking now...")
|
|
309
|
+
await self.async_update(timestamp=0)
|
|
310
|
+
|
|
311
|
+
if not self._last_check_result or not self._last_check_result.get("update_available"):
|
|
312
|
+
_LOGGER.warning("No update available to install")
|
|
313
|
+
return
|
|
314
|
+
|
|
315
|
+
# Trigger update via existing update endpoint
|
|
316
|
+
# Note: This requires the update API to be accessible
|
|
317
|
+
# The actual installation is handled by /api/update endpoint
|
|
318
|
+
_LOGGER.info(
|
|
319
|
+
"Update installation should be triggered via WebUI or API endpoint /api/update"
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
# TODO: Consider adding direct integration with update logic
|
|
323
|
+
# For now, users should use the WebUI to trigger updates
|
|
324
|
+
|
|
325
|
+
async def send_ha_autodiscovery(self) -> None:
|
|
326
|
+
"""Send Home Assistant autodiscovery for Update entity."""
|
|
327
|
+
_LOGGER.debug("Sending HA autodiscovery for Update entity")
|
|
328
|
+
|
|
329
|
+
self._manager.send_ha_autodiscovery(
|
|
330
|
+
id="firmware",
|
|
331
|
+
name="Update",
|
|
332
|
+
ha_type="update",
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
# Subscribe to command topic for install commands
|
|
336
|
+
command_topic = f"{self._manager._topic_prefix}/update/install"
|
|
337
|
+
await self._manager._message_bus.subscribe_and_listen(
|
|
338
|
+
topic=command_topic,
|
|
339
|
+
callback=self._manager._handle_update_install_command
|
|
340
|
+
)
|
|
341
|
+
_LOGGER.info("Subscribed to update command topic: %s", command_topic)
|