boneIO 1.0.0.dev7__py3-none-any.whl → 1.0.0.dev8__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 (108) hide show
  1. boneio/components/output/basic.py +2 -172
  2. boneio/components/sensor/__init__.py +2 -0
  3. boneio/components/sensor/virtual_energy.py +292 -0
  4. boneio/const.py +1 -0
  5. boneio/core/config/config_helper.py +12 -0
  6. boneio/core/config/yaml_util.py +15 -0
  7. boneio/core/manager/inputs.py +36 -6
  8. boneio/core/manager/manager.py +19 -1
  9. boneio/core/manager/modbus.py +1 -1
  10. boneio/core/manager/outputs.py +25 -63
  11. boneio/core/manager/sensors.py +485 -2
  12. boneio/core/state/manager.py +63 -9
  13. boneio/hardware/gpio/expanders/mcp23017.py +115 -48
  14. boneio/hardware/i2c/bus.py +38 -33
  15. boneio/integration/__init__.py +2 -0
  16. boneio/integration/homeassistant.py +60 -8
  17. boneio/modbus/cli.py +2 -2
  18. boneio/modbus/coordinator.py +9 -14
  19. boneio/modbus/devices/energy_meters/socomec_e03.json +1 -0
  20. boneio/modbus/devices/energy_meters/socomec_e23.json +1 -0
  21. boneio/modbus/devices/inverters/sofar.json +195 -78
  22. boneio/modbus/devices/sensors/dts1964_3f.json +60 -54
  23. boneio/modbus/devices/sensors/pt100.json +3 -3
  24. boneio/modbus/entities/base.py +11 -19
  25. boneio/modbus/entities/derived/numeric.py +2 -2
  26. boneio/modbus/entities/derived/select.py +0 -1
  27. boneio/modbus/entities/derived/switch.py +0 -1
  28. boneio/modbus/entities/derived/text.py +0 -1
  29. boneio/modbus/entities/sensor/numeric.py +14 -15
  30. boneio/modbus/entities/sensor/text.py +2 -6
  31. boneio/modbus/utils.py +2 -69
  32. boneio/runner.py +5 -2
  33. boneio/schema/schema.yaml +84 -16
  34. boneio/version.py +1 -1
  35. boneio/webui/app.py +114 -151
  36. boneio/webui/frontend-dist/assets/{cssMode-ClVp1zRM.js → cssMode-BKR9s_Lh.js} +1 -1
  37. boneio/webui/frontend-dist/assets/{freemarker2-Dpc6FVoU.js → freemarker2-Culjpl1V.js} +1 -1
  38. boneio/webui/frontend-dist/assets/{handlebars-3T51HdyE.js → handlebars-DvFXDtCi.js} +1 -1
  39. boneio/webui/frontend-dist/assets/{html-Cqlv8Ey8.js → html-CRZebHSg.js} +1 -1
  40. boneio/webui/frontend-dist/assets/{htmlMode-kKBBldLZ.js → htmlMode-w52oBlKZ.js} +1 -1
  41. boneio/webui/frontend-dist/assets/{index-ClokVTBs.js → index-CrPqznir.js} +214 -214
  42. boneio/webui/frontend-dist/assets/index-DYGCuZw-.css +1 -0
  43. boneio/webui/frontend-dist/assets/{javascript-jIes5V6e.js → javascript-1_d307wM.js} +1 -1
  44. boneio/webui/frontend-dist/assets/{jsonMode-DUk1WXAK.js → jsonMode-SK3Ov5KI.js} +1 -1
  45. boneio/webui/frontend-dist/assets/{liquid-CklSeh78.js → liquid-BY2M9Ltg.js} +1 -1
  46. boneio/webui/frontend-dist/assets/{mdx-Q44kPz7v.js → mdx-CYoqZppc.js} +1 -1
  47. boneio/webui/frontend-dist/assets/{python-CX3wo97z.js → python-BRjt52qX.js} +1 -1
  48. boneio/webui/frontend-dist/assets/{razor-B3VfV1vo.js → razor-CM_wFXDY.js} +1 -1
  49. boneio/webui/frontend-dist/assets/{tsMode-BVdbrg1F.js → tsMode-DyZ1xUFQ.js} +1 -1
  50. boneio/webui/frontend-dist/assets/{typescript-C3sLeWbM.js → typescript-CeE-9TVF.js} +1 -1
  51. boneio/webui/frontend-dist/assets/{xml-DyAfOafQ.js → xml-DYrg3tVz.js} +1 -1
  52. boneio/webui/frontend-dist/assets/{yaml-DZVmC1BT.js → yaml-CKAkvRms.js} +1 -1
  53. boneio/webui/frontend-dist/index.html +2 -2
  54. boneio/webui/frontend-dist/schem/adc.schema.json +8 -8
  55. boneio/webui/frontend-dist/schem/areas.schema.json +4 -4
  56. boneio/webui/frontend-dist/schem/binary_sensor.schema.json +18 -18
  57. boneio/webui/frontend-dist/schem/boneio.schema.json +2 -2
  58. boneio/webui/frontend-dist/schem/config.schema.json +242 -170
  59. boneio/webui/frontend-dist/schem/cover.schema.json +4 -4
  60. boneio/webui/frontend-dist/schem/ds2482.schema.json +4 -4
  61. boneio/webui/frontend-dist/schem/event.schema.json +24 -24
  62. boneio/webui/frontend-dist/schem/ina219.schema.json +12 -12
  63. boneio/webui/frontend-dist/schem/lm75.schema.json +8 -8
  64. boneio/webui/frontend-dist/schem/logger.schema.json +2 -2
  65. boneio/webui/frontend-dist/schem/mcp23017.schema.json +4 -4
  66. boneio/webui/frontend-dist/schem/mcp9808.schema.json +8 -8
  67. boneio/webui/frontend-dist/schem/modbus.schema.json +2 -2
  68. boneio/webui/frontend-dist/schem/modbus_devices.schema.json +16 -16
  69. boneio/webui/frontend-dist/schem/mqtt.schema.json +4 -4
  70. boneio/webui/frontend-dist/schem/oled.schema.json +8 -8
  71. boneio/webui/frontend-dist/schem/output.schema.json +6 -16
  72. boneio/webui/frontend-dist/schem/output_group.schema.json +6 -6
  73. boneio/webui/frontend-dist/schem/pca9685.schema.json +4 -4
  74. boneio/webui/frontend-dist/schem/pcf8575.schema.json +4 -4
  75. boneio/webui/frontend-dist/schem/sensor.schema.json +8 -8
  76. boneio/webui/frontend-dist/schem/virtual_energy_sensor.schema.json +79 -0
  77. boneio/webui/frontend-dist/schem/web.schema.json +13 -4
  78. boneio/webui/schema/adc.schema.json +8 -8
  79. boneio/webui/schema/areas.schema.json +4 -4
  80. boneio/webui/schema/binary_sensor.schema.json +18 -18
  81. boneio/webui/schema/boneio.schema.json +2 -2
  82. boneio/webui/schema/config.schema.json +242 -170
  83. boneio/webui/schema/cover.schema.json +4 -4
  84. boneio/webui/schema/ds2482.schema.json +4 -4
  85. boneio/webui/schema/event.schema.json +24 -24
  86. boneio/webui/schema/ina219.schema.json +12 -12
  87. boneio/webui/schema/lm75.schema.json +8 -8
  88. boneio/webui/schema/logger.schema.json +2 -2
  89. boneio/webui/schema/mcp23017.schema.json +4 -4
  90. boneio/webui/schema/mcp9808.schema.json +8 -8
  91. boneio/webui/schema/modbus.schema.json +2 -2
  92. boneio/webui/schema/modbus_devices.schema.json +16 -16
  93. boneio/webui/schema/mqtt.schema.json +4 -4
  94. boneio/webui/schema/oled.schema.json +8 -8
  95. boneio/webui/schema/output.schema.json +6 -16
  96. boneio/webui/schema/output_group.schema.json +6 -6
  97. boneio/webui/schema/pca9685.schema.json +4 -4
  98. boneio/webui/schema/pcf8575.schema.json +4 -4
  99. boneio/webui/schema/sensor.schema.json +8 -8
  100. boneio/webui/schema/virtual_energy_sensor.schema.json +79 -0
  101. boneio/webui/schema/web.schema.json +13 -4
  102. {boneio-1.0.0.dev7.dist-info → boneio-1.0.0.dev8.dist-info}/METADATA +1 -1
  103. {boneio-1.0.0.dev7.dist-info → boneio-1.0.0.dev8.dist-info}/RECORD +107 -104
  104. boneio/webui/frontend-dist/assets/index-UMDOLcBJ.css +0 -1
  105. {boneio-1.0.0.dev7.dist-info → boneio-1.0.0.dev8.dist-info}/WHEEL +0 -0
  106. {boneio-1.0.0.dev7.dist-info → boneio-1.0.0.dev8.dist-info}/entry_points.txt +0 -0
  107. {boneio-1.0.0.dev7.dist-info → boneio-1.0.0.dev8.dist-info}/licenses/LICENSE +0 -0
  108. {boneio-1.0.0.dev7.dist-info → boneio-1.0.0.dev8.dist-info}/top_level.txt +0 -0
@@ -2,7 +2,6 @@
2
2
  from __future__ import annotations
3
3
 
4
4
  import asyncio
5
- import json
6
5
  import logging
7
6
  import time
8
7
 
@@ -12,147 +11,11 @@ from boneio.models.events import OutputEvent
12
11
  from boneio.core.events import EventBus, async_track_point_in_time, utcnow
13
12
  from boneio.integration.interlock import SoftwareInterlockManager
14
13
  from boneio.core.utils import callback
15
- from boneio.core.messaging.basic import MessageBus
16
14
  from boneio.models import OutputState
17
15
 
18
16
  _LOGGER = logging.getLogger(__name__)
19
17
 
20
18
 
21
- class VirtualEnergySensor:
22
-
23
- def __init__(self, message_bus: MessageBus, loop: asyncio.AbstractEventLoop, topic_prefix: str, parent: BasicOutput, virtual_power_usage: float | None = None, virtual_volume_flow_rate: float | None = None):
24
- self._loop = loop or asyncio.get_running_loop()
25
- self._message_bus = message_bus
26
- self._virtual_sensors_task = None
27
- self._parent = parent
28
- self._virtual_power_usage = virtual_power_usage
29
- self._virtual_volume_flow_rate = virtual_volume_flow_rate
30
- # --- Virtual energy counter ---
31
- self._energy_consumed_Wh = 0.0
32
- self._water_consumed_L = 0.0
33
- self._last_on_timestamp = time.time() if self._parent.state == ON else None
34
- self._virtual_energy_topic = f"{topic_prefix}/energy/{self._parent.id}"
35
- self._subscribe_restore_energy_state()
36
-
37
- def start_virtual_sensors_task(self):
38
- """Start periodic task to update and send virtual energy state every 30 seconds."""
39
- self._last_on_timestamp = time.time()
40
- if self._virtual_sensors_task is not None and not self._virtual_sensors_task.done():
41
- return # Already running
42
- self._virtual_sensors_task = self._loop.create_task(self._virtual_sensors_loop())
43
- _LOGGER.info(f"Started periodic virtual sensors task for {self._parent.id}")
44
-
45
- def stop_virtual_sensors_task(self):
46
- """Stop periodic virtual energy update task."""
47
- self._last_on_timestamp = None
48
- if self._virtual_sensors_task is not None:
49
- self._virtual_sensors_task.cancel()
50
- self._virtual_sensors_task = None
51
- self._update_virtual_sensors()
52
- _LOGGER.info(f"Stopped periodic virtual sensors task for {self._parent.id}")
53
-
54
- @property
55
- def last_on_timestamp(self) -> float | None:
56
- return self._last_on_timestamp
57
-
58
- @property
59
- def virtual_power_usage(self) -> float | None:
60
- return self._virtual_power_usage
61
-
62
- @property
63
- def virtual_volume_flow_rate(self) -> float | None:
64
- return self._virtual_volume_flow_rate
65
-
66
- async def _virtual_sensors_loop(self):
67
- """Periodically update and send virtual energy state every 30 seconds while relay is ON."""
68
- try:
69
- while self._parent.state == ON:
70
- self._update_virtual_sensors()
71
- await asyncio.sleep(30)
72
- except asyncio.CancelledError:
73
- pass
74
-
75
- def _update_virtual_sensors(self):
76
- """Update virtual sensors if virtual_power_usage is set."""
77
- if self.virtual_power_usage is not None or self.virtual_volume_flow_rate is not None:
78
- self._update_virtual_energy()
79
- self.send_virtual_energy_state()
80
-
81
-
82
- def _update_virtual_energy(self):
83
- """Update energy counter if virtual_power_usage is set."""
84
- now = time.time()
85
- if self._parent.state == ON and self._last_on_timestamp is not None:
86
- elapsed = now - self._last_on_timestamp
87
- if self.virtual_power_usage is not None:
88
- self._energy_consumed_Wh += (self.virtual_power_usage * elapsed) / 3600.0
89
- _LOGGER.debug(f"Energy updated for {self._parent.id}: {self._energy_consumed_Wh:.4f} Wh")
90
- if self.virtual_volume_flow_rate is not None:
91
- self._water_consumed_L += (self.virtual_volume_flow_rate * elapsed) / 3600.0
92
- _LOGGER.debug(f"Volume flow rate updated for {self._parent.id}: {self._water_consumed_L:.4f} L")
93
- self._last_on_timestamp = now
94
-
95
- def _subscribe_restore_energy_state(self):
96
- """
97
- Subscribe to the retained MQTT topic for energy and restore state if available.
98
- """
99
- async def on_energy_message(_topic, payload):
100
- try:
101
- payload = json.loads(payload)
102
- if isinstance(payload, dict):
103
- if "energy" in payload:
104
- retained_energy_wh = float(payload["energy"])
105
- self._energy_consumed_Wh = retained_energy_wh
106
- _LOGGER.info(f"Restored energy state for {self._parent.id} from MQTT: {self._energy_consumed_Wh:.4f} Wh")
107
- if "water" in payload:
108
- retained_water_consumption_L = float(payload["water"])
109
- self._water_consumed_L = retained_water_consumption_L
110
- _LOGGER.info(f"Restored water consumption state for {self._parent.id} from MQTT: {self._water_consumed_L:.4f} L")
111
- else:
112
- _LOGGER.warning(f"Invalid retained payload for {self._parent.id}: {payload}")
113
- except Exception as e:
114
- _LOGGER.warning(f"Failed to restore energy state for {self._parent.id} from MQTT: {e}")
115
- finally:
116
- await self._message_bus.unsubscribe_and_stop_listen(self._virtual_energy_topic)
117
- # Subscribe (works for both LocalMessageBus and MQTTClient)
118
- if hasattr(self, '_message_bus') and self._message_bus is not None:
119
- asyncio.create_task(self._message_bus.subscribe_and_listen(self._virtual_energy_topic, on_energy_message))
120
- else:
121
- _LOGGER.warning(f"Message bus not available for {self._parent.id}, cannot subscribe for retained energy.")
122
-
123
- def get_virtual_power(self) -> float:
124
- """Return current virtual power usage in W."""
125
- return (self.virtual_power_usage or 0.0) if self._parent.state == ON else 0.0
126
-
127
- def get_virtual_energy(self) -> float:
128
- """Return current virtual energy in Wh."""
129
- return round(self._energy_consumed_Wh, 3)
130
-
131
- def get_virtual_volume_flow_rate(self) -> float:
132
- """Return current virtual volume flow rate in L/h."""
133
- return (self.virtual_volume_flow_rate or 0.0) if self._parent.state == ON else 0.0
134
-
135
- def get_virtual_water_consumption(self) -> float:
136
- """Return current virtual water consumption in L."""
137
- return round(self._water_consumed_L, 3)
138
-
139
- def send_virtual_energy_state(self):
140
- """Send virtual power/energy state to MQTT for Home Assistant."""
141
- payload = {}
142
- if self.virtual_power_usage is not None:
143
- payload["power"] = self.get_virtual_power()
144
- payload["energy"] = self.get_virtual_energy()
145
- if self.virtual_volume_flow_rate is not None:
146
- payload["volume_flow_rate"] = self.get_virtual_volume_flow_rate()
147
- payload["water"] = self.get_virtual_water_consumption()
148
- self._message_bus.send_message(
149
- topic=f"{self._virtual_energy_topic}",
150
- payload=payload,
151
- retain=True,
152
- )
153
- _LOGGER.info(f"Sent virtual energy state for {self._parent.id}: {payload}")
154
-
155
-
156
19
  class BasicOutput(BasicMqtt):
157
20
  """Basic output class (relay, switch, light, PWM).
158
21
 
@@ -166,7 +29,6 @@ class BasicOutput(BasicMqtt):
166
29
 
167
30
  def __init__(
168
31
  self,
169
- # callback: Callable[[OutputState], Awaitable[None]],
170
32
  id: str,
171
33
  event_bus: EventBus,
172
34
  topic_prefix: str,
@@ -178,14 +40,9 @@ class BasicOutput(BasicMqtt):
178
40
  interlock_groups: list[str] = [],
179
41
  **kwargs,
180
42
  ) -> None:
181
- """Initialize Basic relay.
182
- Supports virtual_power_usage for energy monitoring.
183
- """
43
+ """Initialize Basic output."""
184
44
  self._momentary_turn_on = kwargs.pop("momentary_turn_on", None)
185
45
  self._momentary_turn_off = kwargs.pop("momentary_turn_off", None)
186
- virtual_power_usage = kwargs.pop("virtual_power_usage", None)
187
- virtual_volume_flow_rate = kwargs.pop("virtual_volume_flow_rate", None)
188
- # No parsing needed, Cerberus coerce handles conversion to watts.
189
46
  super().__init__(id=id, name=name or id, topic_type=topic_type, topic_prefix=topic_prefix, **kwargs)
190
47
  self._output_type: str = output_type
191
48
  self._event_bus: EventBus = event_bus
@@ -195,45 +52,23 @@ class BasicOutput(BasicMqtt):
195
52
  self._momentary_turn_on = None
196
53
  self._momentary_turn_off = None
197
54
  self._state = ON if restored_state else OFF
198
- # self._callback = callback
199
55
  self._momentary_action = None
200
56
  self._last_timestamp = 0.0
201
57
  self._loop = asyncio.get_running_loop()
202
58
 
203
- # Subscribe to retained MQTT energy value
204
- self._virtual_energy_sensor = None
205
- if virtual_power_usage is not None or virtual_volume_flow_rate is not None:
206
- self._virtual_energy_sensor = VirtualEnergySensor(
207
- message_bus=self._message_bus,
208
- loop=self._loop,
209
- topic_prefix=topic_prefix,
210
- parent=self,
211
- virtual_power_usage=virtual_power_usage,
212
- virtual_volume_flow_rate=virtual_volume_flow_rate,
213
- )
214
- self._virtual_sensors_task = None
215
-
216
59
  # HA area/room assignment (set by OutputManager)
217
60
  self.area: str | None = None
218
61
 
219
62
  def set_interlock(self, interlock_manager: SoftwareInterlockManager, interlock_groups: list[str]):
63
+ """Set interlock manager and groups."""
220
64
  self._interlock_manager = interlock_manager
221
65
  self._interlock_groups = interlock_groups
222
66
 
223
-
224
67
  @property
225
68
  def is_mcp_type(self) -> bool:
226
69
  """Check if relay is mcp type."""
227
70
  return False
228
71
 
229
- @property
230
- def is_virtual_power(self) -> bool:
231
- return self._virtual_energy_sensor is not None and self._virtual_energy_sensor.virtual_power_usage is not None
232
-
233
- @property
234
- def is_virtual_volume_flow_rate(self) -> bool:
235
- return self._virtual_energy_sensor is not None and self._virtual_energy_sensor.virtual_volume_flow_rate is not None
236
-
237
72
  @property
238
73
  def output_type(self) -> str:
239
74
  """HA type."""
@@ -291,11 +126,6 @@ class BasicOutput(BasicMqtt):
291
126
  payload={STATE: state},
292
127
  retain=True,
293
128
  )
294
- if self._virtual_energy_sensor and not optimized_value:
295
- if state == ON:
296
- self._virtual_energy_sensor.start_virtual_sensors_task()
297
- elif self._virtual_energy_sensor.last_on_timestamp is not None:
298
- self._virtual_energy_sensor.stop_virtual_sensors_task()
299
129
  if optimized_value:
300
130
  return
301
131
  self._last_timestamp = time.time()
@@ -5,7 +5,9 @@ hardware drivers with business logic.
5
5
  """
6
6
 
7
7
  from boneio.components.sensor.system import SerialNumberSensor
8
+ from boneio.components.sensor.virtual_energy import VirtualEnergySensor
8
9
 
9
10
  __all__ = [
10
11
  "SerialNumberSensor",
12
+ "VirtualEnergySensor",
11
13
  ]
@@ -0,0 +1,292 @@
1
+ """Virtual Energy Sensor module.
2
+
3
+ Creates virtual power/energy or water flow sensors linked to outputs.
4
+ When the linked output is ON, the sensor calculates consumption based on
5
+ configured power_usage or flow_rate.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import json
11
+ import logging
12
+ import time
13
+ from typing import TYPE_CHECKING
14
+
15
+ from boneio.const import ON
16
+ from boneio.models import SensorState
17
+ from boneio.models.events import SensorEvent
18
+
19
+ if TYPE_CHECKING:
20
+ from boneio.core.messaging.basic import MessageBus
21
+ from boneio.core.events.bus import EventBus
22
+ from boneio.components.output.basic import BasicOutput
23
+
24
+ _LOGGER = logging.getLogger(__name__)
25
+
26
+
27
+ class VirtualEnergySensor:
28
+ """Virtual energy/water sensor linked to an output.
29
+
30
+ Tracks energy consumption (Wh) or water consumption (L) based on
31
+ configured power_usage (W) or flow_rate (L/h) when the linked output is ON.
32
+
33
+ Args:
34
+ id: Unique sensor identifier
35
+ name: Display name for Home Assistant
36
+ output: The output to track
37
+ message_bus: MessageBus for MQTT communication
38
+ loop: Event loop
39
+ topic_prefix: MQTT topic prefix
40
+ sensor_type: 'power' or 'water'
41
+ power_usage: Power consumption in Watts (for sensor_type='power')
42
+ flow_rate: Flow rate in L/h (for sensor_type='water')
43
+ area: Optional area ID for HA sub-device
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ id: str,
49
+ name: str,
50
+ output: BasicOutput,
51
+ message_bus: MessageBus,
52
+ event_bus: "EventBus",
53
+ loop: asyncio.AbstractEventLoop,
54
+ topic_prefix: str,
55
+ sensor_type: str,
56
+ power_usage: float | None = None,
57
+ flow_rate: float | None = None,
58
+ area: str | None = None,
59
+ ):
60
+ self._id = id
61
+ self._name = name
62
+ self._output = output
63
+ self._loop = loop or asyncio.get_running_loop()
64
+ self._message_bus = message_bus
65
+ self._event_bus = event_bus
66
+ self._topic_prefix = topic_prefix
67
+ self._sensor_type = sensor_type
68
+ self._power_usage = power_usage
69
+ self._flow_rate = flow_rate
70
+ self._area = area
71
+
72
+ self._virtual_sensors_task = None
73
+
74
+ # Counters
75
+ self._energy_consumed_Wh = 0.0
76
+ self._water_consumed_L = 0.0
77
+ self._last_on_timestamp = time.time() if self._output.state == ON else None
78
+
79
+ # MQTT topic for this sensor
80
+ self._sensor_topic = f"{topic_prefix}/energy/{id}"
81
+
82
+ # Subscribe to restore state from MQTT
83
+ self._subscribe_restore_state()
84
+
85
+ _LOGGER.info(
86
+ "Initialized VirtualEnergySensor: id=%s, name=%s, output=%s, type=%s",
87
+ id, name, output.id, sensor_type
88
+ )
89
+
90
+ @property
91
+ def id(self) -> str:
92
+ """Get sensor ID."""
93
+ return self._id
94
+
95
+ @property
96
+ def name(self) -> str:
97
+ """Get sensor name."""
98
+ return self._name
99
+
100
+ @property
101
+ def output_id(self) -> str:
102
+ """Get linked output ID."""
103
+ return self._output.id
104
+
105
+ @property
106
+ def sensor_type(self) -> str:
107
+ """Get sensor type ('power' or 'water')."""
108
+ return self._sensor_type
109
+
110
+ @property
111
+ def area(self) -> str | None:
112
+ """Get area ID."""
113
+ return self._area
114
+
115
+ @property
116
+ def power_usage(self) -> float | None:
117
+ """Get power usage in Watts."""
118
+ return self._power_usage
119
+
120
+ @property
121
+ def flow_rate(self) -> float | None:
122
+ """Get flow rate in L/h."""
123
+ return self._flow_rate
124
+
125
+ @property
126
+ def last_on_timestamp(self) -> float | None:
127
+ """Get timestamp when output was last turned ON."""
128
+ return self._last_on_timestamp
129
+
130
+ def start_tracking(self):
131
+ """Start tracking consumption (called when output turns ON)."""
132
+ self._last_on_timestamp = time.time()
133
+ if self._virtual_sensors_task is not None and not self._virtual_sensors_task.done():
134
+ return # Already running
135
+ self._virtual_sensors_task = self._loop.create_task(self._tracking_loop())
136
+ _LOGGER.debug("Started tracking for virtual sensor %s", self._id)
137
+
138
+ def stop_tracking(self):
139
+ """Stop tracking consumption (called when output turns OFF)."""
140
+ # Update one last time before stopping
141
+ self._update_consumption()
142
+ self._last_on_timestamp = None
143
+
144
+ if self._virtual_sensors_task is not None:
145
+ self._virtual_sensors_task.cancel()
146
+ self._virtual_sensors_task = None
147
+
148
+ # Send final state
149
+ self._send_state()
150
+ _LOGGER.debug("Stopped tracking for virtual sensor %s", self._id)
151
+
152
+ async def _tracking_loop(self):
153
+ """Periodically update and send state every 30 seconds while output is ON."""
154
+ try:
155
+ while self._output.state == ON:
156
+ self._update_consumption()
157
+ self._send_state()
158
+ await asyncio.sleep(30)
159
+ except asyncio.CancelledError:
160
+ pass
161
+
162
+ def _update_consumption(self):
163
+ """Update consumption counters based on elapsed time."""
164
+ now = time.time()
165
+ if self._output.state == ON and self._last_on_timestamp is not None:
166
+ elapsed = now - self._last_on_timestamp
167
+
168
+ if self._sensor_type == "power" and self._power_usage is not None:
169
+ self._energy_consumed_Wh += (self._power_usage * elapsed) / 3600.0
170
+ _LOGGER.debug(
171
+ "Energy updated for %s: %.4f Wh",
172
+ self._id, self._energy_consumed_Wh
173
+ )
174
+ elif self._sensor_type == "water" and self._flow_rate is not None:
175
+ self._water_consumed_L += (self._flow_rate * elapsed) / 3600.0
176
+ _LOGGER.debug(
177
+ "Water updated for %s: %.4f L",
178
+ self._id, self._water_consumed_L
179
+ )
180
+
181
+ self._last_on_timestamp = now
182
+
183
+ def _subscribe_restore_state(self):
184
+ """Subscribe to retained MQTT topic to restore state on startup."""
185
+ async def on_message(_topic, payload):
186
+ try:
187
+ data = json.loads(payload)
188
+ if isinstance(data, dict):
189
+ if "energy" in data:
190
+ self._energy_consumed_Wh = float(data["energy"])
191
+ _LOGGER.info(
192
+ "Restored energy state for %s: %.4f Wh",
193
+ self._id, self._energy_consumed_Wh
194
+ )
195
+ if "water" in data:
196
+ self._water_consumed_L = float(data["water"])
197
+ _LOGGER.info(
198
+ "Restored water state for %s: %.4f L",
199
+ self._id, self._water_consumed_L
200
+ )
201
+ except Exception as e:
202
+ _LOGGER.warning("Failed to restore state for %s: %s", self._id, e)
203
+ finally:
204
+ await self._message_bus.unsubscribe_and_stop_listen(self._sensor_topic)
205
+
206
+ if self._message_bus is not None:
207
+ asyncio.create_task(
208
+ self._message_bus.subscribe_and_listen(self._sensor_topic, on_message)
209
+ )
210
+
211
+ def get_current_power(self) -> float:
212
+ """Get current power usage in W (0 if output is OFF)."""
213
+ if self._sensor_type != "power":
214
+ return 0.0
215
+ return (self._power_usage or 0.0) if self._output.state == ON else 0.0
216
+
217
+ def get_total_energy(self) -> float:
218
+ """Get total energy consumed in Wh."""
219
+ return round(self._energy_consumed_Wh, 3)
220
+
221
+ def get_current_flow_rate(self) -> float:
222
+ """Get current flow rate in L/h (0 if output is OFF)."""
223
+ if self._sensor_type != "water":
224
+ return 0.0
225
+ return (self._flow_rate or 0.0) if self._output.state == ON else 0.0
226
+
227
+ def get_total_water(self) -> float:
228
+ """Get total water consumed in L."""
229
+ return round(self._water_consumed_L, 3)
230
+
231
+ def _send_state(self):
232
+ """Send current state to MQTT and EventBus (for WebSocket/frontend)."""
233
+ payload = {}
234
+ timestamp = int(time.time())
235
+
236
+ if self._sensor_type == "power":
237
+ payload["power"] = self.get_current_power()
238
+ payload["energy"] = self.get_total_energy()
239
+
240
+ # Send SensorEvents to frontend via EventBus
241
+ self._event_bus.trigger_event(SensorEvent(
242
+ entity_id=f"{self._id}_power",
243
+ state=SensorState(
244
+ id=f"{self._id}_power",
245
+ name=f"{self._name} Power",
246
+ state=self.get_current_power(),
247
+ unit="W",
248
+ timestamp=timestamp,
249
+ )
250
+ ))
251
+ self._event_bus.trigger_event(SensorEvent(
252
+ entity_id=f"{self._id}_energy",
253
+ state=SensorState(
254
+ id=f"{self._id}_energy",
255
+ name=f"{self._name} Energy",
256
+ state=self.get_total_energy(),
257
+ unit="Wh",
258
+ timestamp=timestamp,
259
+ )
260
+ ))
261
+ elif self._sensor_type == "water":
262
+ payload["volume_flow_rate"] = self.get_current_flow_rate()
263
+ payload["water"] = self.get_total_water()
264
+
265
+ # Send SensorEvents to frontend via EventBus
266
+ self._event_bus.trigger_event(SensorEvent(
267
+ entity_id=f"{self._id}_flow",
268
+ state=SensorState(
269
+ id=f"{self._id}_flow",
270
+ name=f"{self._name} Flow Rate",
271
+ state=self.get_current_flow_rate(),
272
+ unit="L/h",
273
+ timestamp=timestamp,
274
+ )
275
+ ))
276
+ self._event_bus.trigger_event(SensorEvent(
277
+ entity_id=f"{self._id}_water",
278
+ state=SensorState(
279
+ id=f"{self._id}_water",
280
+ name=f"{self._name} Water",
281
+ state=self.get_total_water(),
282
+ unit="L",
283
+ timestamp=timestamp,
284
+ )
285
+ ))
286
+
287
+ self._message_bus.send_message(
288
+ topic=self._sensor_topic,
289
+ payload=payload,
290
+ retain=True,
291
+ )
292
+ _LOGGER.debug("Sent state for %s: %s", self._id, payload)
boneio/const.py CHANGED
@@ -166,6 +166,7 @@ cover_actions = {
166
166
  }
167
167
 
168
168
  INA219 = "ina219"
169
+ VIRTUAL_ENERGY_SENSOR = "virtual_energy_sensor"
169
170
  PINS = {
170
171
  # Based on boneio/boards/0.8/input.yaml
171
172
  "P8_37": {"chip": 1, "line": 14},
@@ -38,6 +38,7 @@ class ConfigHelper:
38
38
  network_info: dict = {},
39
39
  is_web_active: bool = False,
40
40
  web_port: int = 8090,
41
+ nginx_proxy_port: int | None = None,
41
42
  config_file_path: str | None = None,
42
43
  ):
43
44
  self._name = name
@@ -47,6 +48,7 @@ class ConfigHelper:
47
48
  self._ha_discovery_prefix = ha_discovery_prefix
48
49
  self._device_type = device_type
49
50
  self._web_port = web_port
51
+ self._nginx_proxy_port = nginx_proxy_port
50
52
  self._fetch_old_discovery = None
51
53
  self._autodiscovery_messages = {
52
54
  SWITCH: {},
@@ -113,6 +115,16 @@ class ConfigHelper:
113
115
  def web_port(self) -> int:
114
116
  return self._web_port
115
117
 
118
+ @property
119
+ def nginx_proxy_port(self) -> int | None:
120
+ """Get nginx proxy port if configured."""
121
+ return self._nginx_proxy_port
122
+
123
+ @property
124
+ def ha_configuration_port(self) -> int:
125
+ """Get port for HA discovery URL. Uses nginx_proxy_port if set, otherwise web_port."""
126
+ return self._nginx_proxy_port if self._nginx_proxy_port else self._web_port
127
+
116
128
  @property
117
129
  def topic_prefix(self) -> str:
118
130
  return self._topic_prefix
@@ -892,6 +892,21 @@ def update_config_section(config_file: str, section: str, data: dict | list) ->
892
892
 
893
893
  _LOGGER.info(f"Updating section '{section}' with data: {data}")
894
894
 
895
+ # Special handling for mcp23017 - convert hex strings to integers
896
+ # This ensures YAML writes them as integers which are then read back as hex
897
+ if section == 'mcp23017' and isinstance(data, list):
898
+ for entry in data:
899
+ if isinstance(entry, dict) and 'address' in entry:
900
+ addr = entry['address']
901
+ if isinstance(addr, str):
902
+ if addr.startswith('0x') or addr.startswith('0X'):
903
+ entry['address'] = int(addr, 16)
904
+ else:
905
+ try:
906
+ entry['address'] = int(addr, 10)
907
+ except ValueError:
908
+ pass # Keep as string if conversion fails
909
+
895
910
  # Strip default values to keep YAML clean
896
911
  cleaned_data = strip_default_values(data, {}, section)
897
912
  _LOGGER.info(f"Cleaned data (defaults removed): {cleaned_data}")
@@ -422,8 +422,6 @@ class InputManager:
422
422
  - Removing deleted inputs (from internal state and HA Discovery)
423
423
  - Adding inputs that use already-registered GPIO pins (e.g., moving from event to binary_sensor)
424
424
  """
425
- import asyncio
426
-
427
425
  _LOGGER.info("Reloading input configuration")
428
426
 
429
427
  # Get new config
@@ -491,9 +489,42 @@ class InputManager:
491
489
  # - Add new inputs if their GPIO pin is already registered
492
490
  self._configure_inputs(reload_config=True)
493
491
 
494
- # Signal WebSocket handlers to re-send all input states to clients
495
- from boneio.models.events import InputsReloadedEvent
496
- self._manager._event_bus.trigger_event(InputsReloadedEvent())
492
+ # Broadcast all input states to WebSocket clients
493
+ # Global listeners will receive these events for all inputs (including new ones)
494
+ self._broadcast_all_input_states()
495
+
496
+ def _broadcast_all_input_states(self) -> None:
497
+ """Broadcast current state of all inputs via WebSocket.
498
+
499
+ This is called after reload to ensure frontend receives
500
+ the updated input list immediately.
501
+ """
502
+ import time
503
+ from boneio.models import InputState
504
+ from boneio.models.events import InputEvent
505
+
506
+ timestamp = time.time()
507
+
508
+ for input_ in self._inputs.values():
509
+ try:
510
+ input_state = InputState(
511
+ name=input_.name,
512
+ state=input_.last_state,
513
+ type=input_.input_type,
514
+ pin=input_.pin,
515
+ timestamp=timestamp,
516
+ boneio_input=input_.boneio_input,
517
+ area=input_.area
518
+ )
519
+ event = InputEvent(
520
+ entity_id=input_.id,
521
+ state=input_state,
522
+ click_type=None,
523
+ duration=None
524
+ )
525
+ self._manager._event_bus.trigger_event(event)
526
+ except Exception as e:
527
+ _LOGGER.debug("Error broadcasting input state %s: %s", input_.id, e)
497
528
 
498
529
  def _remove_input_ha_discovery(self, input_id: str, old_area: str | None = None) -> None:
499
530
  """Remove HA Discovery entries for an input.
@@ -591,7 +622,6 @@ class InputManager:
591
622
  # Get the input instance and retrieve actions for this click type
592
623
  input_instance = self._inputs.get(event.entity_id)
593
624
  if not input_instance:
594
- print(self._inputs)
595
625
  _LOGGER.warning("Input %s not found for event handling", event.entity_id)
596
626
  return
597
627