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.
Files changed (56) hide show
  1. boneio/core/config/config_helper.py +8 -0
  2. boneio/core/manager/inputs.py +8 -0
  3. boneio/core/manager/manager.py +32 -3
  4. boneio/core/manager/remote.py +113 -4
  5. boneio/core/manager/update.py +341 -0
  6. boneio/core/remote/__init__.py +9 -0
  7. boneio/core/remote/base.py +2 -0
  8. boneio/core/remote/wled.py +572 -0
  9. boneio/integration/homeassistant.py +45 -2
  10. boneio/runner.py +3 -2
  11. boneio/schema/actions.yaml +18 -0
  12. boneio/schema/remote_devices.yaml +106 -3
  13. boneio/schema/schema.yaml +12 -0
  14. boneio/version.py +1 -1
  15. boneio/webui/app.py +2 -0
  16. boneio/webui/frontend-dist/assets/{cssMode-Ca1DAf9-.js → cssMode-CfBnHhQI.js} +1 -1
  17. boneio/webui/frontend-dist/assets/{freemarker2-C5IgNKwL.js → freemarker2-Bh2BQpSG.js} +1 -1
  18. boneio/webui/frontend-dist/assets/{handlebars-coN2SbcA.js → handlebars-D68RL95z.js} +1 -1
  19. boneio/webui/frontend-dist/assets/{html-DauOMz0u.js → html-XKVYovyY.js} +1 -1
  20. boneio/webui/frontend-dist/assets/{htmlMode-CmodurAE.js → htmlMode-QT0rbnhL.js} +1 -1
  21. boneio/webui/frontend-dist/assets/index-D0XGmf2O.css +1 -0
  22. boneio/webui/frontend-dist/assets/{index-CeFOvpPM.js → index-NKuu8fvk.js} +201 -201
  23. boneio/webui/frontend-dist/assets/{javascript-N6k9oxRy.js → javascript-CxT2_Xcl.js} +1 -1
  24. boneio/webui/frontend-dist/assets/{jsonMode-DCkYHItT.js → jsonMode-mhlSbJro.js} +1 -1
  25. boneio/webui/frontend-dist/assets/{liquid-CuYEfgul.js → liquid-D62PitRX.js} +1 -1
  26. boneio/webui/frontend-dist/assets/{mdx-DEiUIP8w.js → mdx-C5xjvE7q.js} +1 -1
  27. boneio/webui/frontend-dist/assets/{python-DBpf3_HZ.js → python-B5fpI3od.js} +1 -1
  28. boneio/webui/frontend-dist/assets/{razor-S1P72cEP.js → razor-DaPtFLXE.js} +1 -1
  29. boneio/webui/frontend-dist/assets/{tsMode-A2L2Jxa9.js → tsMode-ltYtI_us.js} +1 -1
  30. boneio/webui/frontend-dist/assets/{typescript-CupJHRfY.js → typescript-DwdFtOSo.js} +1 -1
  31. boneio/webui/frontend-dist/assets/{xml-DDPTy55c.js → xml-CiaIsyq7.js} +1 -1
  32. boneio/webui/frontend-dist/assets/{yaml-CuImUjrc.js → yaml-DWYHE4pc.js} +1 -1
  33. boneio/webui/frontend-dist/index.html +2 -2
  34. boneio/webui/frontend-dist/schem/binary_sensor.schema.json +32 -0
  35. boneio/webui/frontend-dist/schem/config.schema.json +338 -3
  36. boneio/webui/frontend-dist/schem/event.schema.json +112 -0
  37. boneio/webui/frontend-dist/schem/mqtt.schema.json +13 -0
  38. boneio/webui/frontend-dist/schem/remote_devices.schema.json +179 -1
  39. boneio/webui/frontend-dist/schem/web.schema.json +2 -2
  40. boneio/webui/routes/config.py +3 -0
  41. boneio/webui/routes/remote_devices.py +84 -0
  42. boneio/webui/routes/update.py +42 -1
  43. boneio/webui/schema/binary_sensor.schema.json +32 -0
  44. boneio/webui/schema/config.schema.json +338 -3
  45. boneio/webui/schema/event.schema.json +112 -0
  46. boneio/webui/schema/mqtt.schema.json +13 -0
  47. boneio/webui/schema/remote_devices.schema.json +179 -1
  48. boneio/webui/schema/web.schema.json +2 -2
  49. boneio/webui/web_server.py +34 -29
  50. {boneio-1.1.0.dev2.dist-info → boneio-1.1.0.dev4.dist-info}/METADATA +2 -1
  51. {boneio-1.1.0.dev2.dist-info → boneio-1.1.0.dev4.dist-info}/RECORD +55 -53
  52. boneio/webui/frontend-dist/assets/index-BGHXdp7U.css +0 -1
  53. {boneio-1.1.0.dev2.dist-info → boneio-1.1.0.dev4.dist-info}/WHEEL +0 -0
  54. {boneio-1.1.0.dev2.dist-info → boneio-1.1.0.dev4.dist-info}/entry_points.txt +0 -0
  55. {boneio-1.1.0.dev2.dist-info → boneio-1.1.0.dev4.dist-info}/licenses/LICENSE +0 -0
  56. {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
@@ -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
@@ -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:
@@ -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 or ESPHome API).
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) - only for ESPHome lights
347
- transition: Transition time in seconds - only for ESPHome lights
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)