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.
- boneio/components/output/basic.py +2 -172
- boneio/components/sensor/__init__.py +2 -0
- boneio/components/sensor/virtual_energy.py +292 -0
- boneio/const.py +1 -0
- boneio/core/config/config_helper.py +12 -0
- boneio/core/config/yaml_util.py +15 -0
- boneio/core/manager/inputs.py +36 -6
- boneio/core/manager/manager.py +19 -1
- boneio/core/manager/modbus.py +1 -1
- boneio/core/manager/outputs.py +25 -63
- boneio/core/manager/sensors.py +485 -2
- boneio/core/state/manager.py +63 -9
- boneio/hardware/gpio/expanders/mcp23017.py +115 -48
- boneio/hardware/i2c/bus.py +38 -33
- boneio/integration/__init__.py +2 -0
- boneio/integration/homeassistant.py +60 -8
- boneio/modbus/cli.py +2 -2
- boneio/modbus/coordinator.py +9 -14
- boneio/modbus/devices/energy_meters/socomec_e03.json +1 -0
- boneio/modbus/devices/energy_meters/socomec_e23.json +1 -0
- boneio/modbus/devices/inverters/sofar.json +195 -78
- boneio/modbus/devices/sensors/dts1964_3f.json +60 -54
- boneio/modbus/devices/sensors/pt100.json +3 -3
- boneio/modbus/entities/base.py +11 -19
- boneio/modbus/entities/derived/numeric.py +2 -2
- boneio/modbus/entities/derived/select.py +0 -1
- boneio/modbus/entities/derived/switch.py +0 -1
- boneio/modbus/entities/derived/text.py +0 -1
- boneio/modbus/entities/sensor/numeric.py +14 -15
- boneio/modbus/entities/sensor/text.py +2 -6
- boneio/modbus/utils.py +2 -69
- boneio/runner.py +5 -2
- boneio/schema/schema.yaml +84 -16
- boneio/version.py +1 -1
- boneio/webui/app.py +114 -151
- boneio/webui/frontend-dist/assets/{cssMode-ClVp1zRM.js → cssMode-BKR9s_Lh.js} +1 -1
- boneio/webui/frontend-dist/assets/{freemarker2-Dpc6FVoU.js → freemarker2-Culjpl1V.js} +1 -1
- boneio/webui/frontend-dist/assets/{handlebars-3T51HdyE.js → handlebars-DvFXDtCi.js} +1 -1
- boneio/webui/frontend-dist/assets/{html-Cqlv8Ey8.js → html-CRZebHSg.js} +1 -1
- boneio/webui/frontend-dist/assets/{htmlMode-kKBBldLZ.js → htmlMode-w52oBlKZ.js} +1 -1
- boneio/webui/frontend-dist/assets/{index-ClokVTBs.js → index-CrPqznir.js} +214 -214
- boneio/webui/frontend-dist/assets/index-DYGCuZw-.css +1 -0
- boneio/webui/frontend-dist/assets/{javascript-jIes5V6e.js → javascript-1_d307wM.js} +1 -1
- boneio/webui/frontend-dist/assets/{jsonMode-DUk1WXAK.js → jsonMode-SK3Ov5KI.js} +1 -1
- boneio/webui/frontend-dist/assets/{liquid-CklSeh78.js → liquid-BY2M9Ltg.js} +1 -1
- boneio/webui/frontend-dist/assets/{mdx-Q44kPz7v.js → mdx-CYoqZppc.js} +1 -1
- boneio/webui/frontend-dist/assets/{python-CX3wo97z.js → python-BRjt52qX.js} +1 -1
- boneio/webui/frontend-dist/assets/{razor-B3VfV1vo.js → razor-CM_wFXDY.js} +1 -1
- boneio/webui/frontend-dist/assets/{tsMode-BVdbrg1F.js → tsMode-DyZ1xUFQ.js} +1 -1
- boneio/webui/frontend-dist/assets/{typescript-C3sLeWbM.js → typescript-CeE-9TVF.js} +1 -1
- boneio/webui/frontend-dist/assets/{xml-DyAfOafQ.js → xml-DYrg3tVz.js} +1 -1
- boneio/webui/frontend-dist/assets/{yaml-DZVmC1BT.js → yaml-CKAkvRms.js} +1 -1
- boneio/webui/frontend-dist/index.html +2 -2
- boneio/webui/frontend-dist/schem/adc.schema.json +8 -8
- boneio/webui/frontend-dist/schem/areas.schema.json +4 -4
- boneio/webui/frontend-dist/schem/binary_sensor.schema.json +18 -18
- boneio/webui/frontend-dist/schem/boneio.schema.json +2 -2
- boneio/webui/frontend-dist/schem/config.schema.json +242 -170
- boneio/webui/frontend-dist/schem/cover.schema.json +4 -4
- boneio/webui/frontend-dist/schem/ds2482.schema.json +4 -4
- boneio/webui/frontend-dist/schem/event.schema.json +24 -24
- boneio/webui/frontend-dist/schem/ina219.schema.json +12 -12
- boneio/webui/frontend-dist/schem/lm75.schema.json +8 -8
- boneio/webui/frontend-dist/schem/logger.schema.json +2 -2
- boneio/webui/frontend-dist/schem/mcp23017.schema.json +4 -4
- boneio/webui/frontend-dist/schem/mcp9808.schema.json +8 -8
- boneio/webui/frontend-dist/schem/modbus.schema.json +2 -2
- boneio/webui/frontend-dist/schem/modbus_devices.schema.json +16 -16
- boneio/webui/frontend-dist/schem/mqtt.schema.json +4 -4
- boneio/webui/frontend-dist/schem/oled.schema.json +8 -8
- boneio/webui/frontend-dist/schem/output.schema.json +6 -16
- boneio/webui/frontend-dist/schem/output_group.schema.json +6 -6
- boneio/webui/frontend-dist/schem/pca9685.schema.json +4 -4
- boneio/webui/frontend-dist/schem/pcf8575.schema.json +4 -4
- boneio/webui/frontend-dist/schem/sensor.schema.json +8 -8
- boneio/webui/frontend-dist/schem/virtual_energy_sensor.schema.json +79 -0
- boneio/webui/frontend-dist/schem/web.schema.json +13 -4
- boneio/webui/schema/adc.schema.json +8 -8
- boneio/webui/schema/areas.schema.json +4 -4
- boneio/webui/schema/binary_sensor.schema.json +18 -18
- boneio/webui/schema/boneio.schema.json +2 -2
- boneio/webui/schema/config.schema.json +242 -170
- boneio/webui/schema/cover.schema.json +4 -4
- boneio/webui/schema/ds2482.schema.json +4 -4
- boneio/webui/schema/event.schema.json +24 -24
- boneio/webui/schema/ina219.schema.json +12 -12
- boneio/webui/schema/lm75.schema.json +8 -8
- boneio/webui/schema/logger.schema.json +2 -2
- boneio/webui/schema/mcp23017.schema.json +4 -4
- boneio/webui/schema/mcp9808.schema.json +8 -8
- boneio/webui/schema/modbus.schema.json +2 -2
- boneio/webui/schema/modbus_devices.schema.json +16 -16
- boneio/webui/schema/mqtt.schema.json +4 -4
- boneio/webui/schema/oled.schema.json +8 -8
- boneio/webui/schema/output.schema.json +6 -16
- boneio/webui/schema/output_group.schema.json +6 -6
- boneio/webui/schema/pca9685.schema.json +4 -4
- boneio/webui/schema/pcf8575.schema.json +4 -4
- boneio/webui/schema/sensor.schema.json +8 -8
- boneio/webui/schema/virtual_energy_sensor.schema.json +79 -0
- boneio/webui/schema/web.schema.json +13 -4
- {boneio-1.0.0.dev7.dist-info → boneio-1.0.0.dev8.dist-info}/METADATA +1 -1
- {boneio-1.0.0.dev7.dist-info → boneio-1.0.0.dev8.dist-info}/RECORD +107 -104
- boneio/webui/frontend-dist/assets/index-UMDOLcBJ.css +0 -1
- {boneio-1.0.0.dev7.dist-info → boneio-1.0.0.dev8.dist-info}/WHEEL +0 -0
- {boneio-1.0.0.dev7.dist-info → boneio-1.0.0.dev8.dist-info}/entry_points.txt +0 -0
- {boneio-1.0.0.dev7.dist-info → boneio-1.0.0.dev8.dist-info}/licenses/LICENSE +0 -0
- {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
|
|
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()
|
|
@@ -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
|
@@ -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
|
boneio/core/config/yaml_util.py
CHANGED
|
@@ -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}")
|
boneio/core/manager/inputs.py
CHANGED
|
@@ -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
|
-
#
|
|
495
|
-
|
|
496
|
-
self.
|
|
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
|
|