boneIO 1.2.0.dev14__py3-none-any.whl → 1.2.0.dev17__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/cover/cover.py +10 -3
- boneio/core/config/migrations/v2_transition.py +23 -8
- boneio/core/config/yaml_util.py +20 -5
- boneio/core/events/bus.py +1 -0
- boneio/core/manager/inputs.py +6 -1
- boneio/core/manager/modbus.py +11 -1
- boneio/core/manager/templates/__init__.py +15 -2
- boneio/core/remote/esphome.py +40 -4
- boneio/modbus/client.py +41 -10
- boneio/modbus/coordinator.py +37 -13
- boneio/modbus/devices/sensors/boneio-edge-temp.json +50 -0
- boneio/modbus/devices/sensors/gdfs.json +35 -0
- boneio/modbus/devices/sensors/gdfx.json +41 -0
- boneio/schema/actions.yaml +2 -0
- boneio/schema/schema.yaml +14 -0
- boneio/version.py +1 -1
- boneio/webui/app.py +2 -0
- boneio/webui/frontend-dist/assets/{cssMode-DiRdRTjn.js → cssMode-DDcbtjMv.js} +1 -1
- boneio/webui/frontend-dist/assets/{freemarker2-My492RbD.js → freemarker2-DKdwGGQd.js} +1 -1
- boneio/webui/frontend-dist/assets/{handlebars-Bn2yjOuB.js → handlebars-CanQjBCY.js} +1 -1
- boneio/webui/frontend-dist/assets/{html-C3Dn1bP6.js → html-BIqxJPif.js} +1 -1
- boneio/webui/frontend-dist/assets/{htmlMode-C-CVJWpp.js → htmlMode-Da-qVQJp.js} +1 -1
- boneio/webui/frontend-dist/assets/index-BkP1mrhT.css +1 -0
- boneio/webui/frontend-dist/assets/{index-Cgrn-IcO.js → index-DUHOddeW.js} +227 -227
- boneio/webui/frontend-dist/assets/{javascript-Bt1XPvUb.js → javascript-D2sCfnfc.js} +1 -1
- boneio/webui/frontend-dist/assets/{jsonMode-D-kv5mDr.js → jsonMode-NoTCqaf3.js} +1 -1
- boneio/webui/frontend-dist/assets/{liquid-BAT5-2C3.js → liquid-CZ8xXWlF.js} +1 -1
- boneio/webui/frontend-dist/assets/{mdx-Dr8eK8pD.js → mdx-BMmV_pXf.js} +1 -1
- boneio/webui/frontend-dist/assets/{python-B-3qWZ1F.js → python-Cho18ADd.js} +1 -1
- boneio/webui/frontend-dist/assets/{razor-4_yKrual.js → razor-CB8kkVEZ.js} +1 -1
- boneio/webui/frontend-dist/assets/{tsMode-R_hQmMR5.js → tsMode-B6P_cxMO.js} +1 -1
- boneio/webui/frontend-dist/assets/{typescript-BUdJWLyj.js → typescript-DJ4c4LAD.js} +1 -1
- boneio/webui/frontend-dist/assets/{xml-Q501Nx8r.js → xml-DYlBjxVe.js} +1 -1
- boneio/webui/frontend-dist/assets/{yaml-C2ne9Q3j.js → yaml-BST1bCmo.js} +1 -1
- boneio/webui/frontend-dist/index.html +2 -2
- boneio/webui/frontend-dist/{schem → schema}/config.schema.json +22 -0
- boneio/webui/frontend-dist/{schem → schema}/modbus.schema.json +10 -0
- boneio/webui/frontend-dist/{schem → schema}/modbus_devices.schema.json +12 -0
- boneio/webui/frontend-dist/sw.js +1 -1
- boneio/webui/routes/config.py +75 -1
- boneio/webui/routes/modbus.py +50 -0
- boneio/webui/routes/sensors.py +17 -0
- boneio/webui/routes/tools.py +74 -2
- boneio/webui/schema/config.schema.json +22 -0
- boneio/webui/schema/modbus.schema.json +10 -0
- boneio/webui/schema/modbus_devices.schema.json +12 -0
- {boneio-1.2.0.dev14.dist-info → boneio-1.2.0.dev17.dist-info}/METADATA +1 -1
- {boneio-1.2.0.dev14.dist-info → boneio-1.2.0.dev17.dist-info}/RECORD +76 -73
- boneio/webui/frontend-dist/assets/index-CgzBI1Pg.css +0 -1
- /boneio/webui/frontend-dist/{schem → schema}/adc.schema.json +0 -0
- /boneio/webui/frontend-dist/{schem → schema}/areas.schema.json +0 -0
- /boneio/webui/frontend-dist/{schem → schema}/binary_sensor.schema.json +0 -0
- /boneio/webui/frontend-dist/{schem → schema}/boneio.schema.json +0 -0
- /boneio/webui/frontend-dist/{schem → schema}/cover.schema.json +0 -0
- /boneio/webui/frontend-dist/{schem → schema}/dallas.schema.json +0 -0
- /boneio/webui/frontend-dist/{schem → schema}/ds2482.schema.json +0 -0
- /boneio/webui/frontend-dist/{schem → schema}/event.schema.json +0 -0
- /boneio/webui/frontend-dist/{schem → schema}/ina219.schema.json +0 -0
- /boneio/webui/frontend-dist/{schem → schema}/lm75.schema.json +0 -0
- /boneio/webui/frontend-dist/{schem → schema}/logger.schema.json +0 -0
- /boneio/webui/frontend-dist/{schem → schema}/mcp23017.schema.json +0 -0
- /boneio/webui/frontend-dist/{schem → schema}/mcp9808.schema.json +0 -0
- /boneio/webui/frontend-dist/{schem → schema}/mqtt.schema.json +0 -0
- /boneio/webui/frontend-dist/{schem → schema}/oled.schema.json +0 -0
- /boneio/webui/frontend-dist/{schem → schema}/output.schema.json +0 -0
- /boneio/webui/frontend-dist/{schem → schema}/output_group.schema.json +0 -0
- /boneio/webui/frontend-dist/{schem → schema}/pca9685.schema.json +0 -0
- /boneio/webui/frontend-dist/{schem → schema}/pcf8575.schema.json +0 -0
- /boneio/webui/frontend-dist/{schem → schema}/remote_devices.schema.json +0 -0
- /boneio/webui/frontend-dist/{schem → schema}/sensor.schema.json +0 -0
- /boneio/webui/frontend-dist/{schem → schema}/template.schema.json +0 -0
- /boneio/webui/frontend-dist/{schem → schema}/virtual_energy_sensor.schema.json +0 -0
- /boneio/webui/frontend-dist/{schem → schema}/web.schema.json +0 -0
- {boneio-1.2.0.dev14.dist-info → boneio-1.2.0.dev17.dist-info}/WHEEL +0 -0
- {boneio-1.2.0.dev14.dist-info → boneio-1.2.0.dev17.dist-info}/entry_points.txt +0 -0
- {boneio-1.2.0.dev14.dist-info → boneio-1.2.0.dev17.dist-info}/licenses/LICENSE +0 -0
- {boneio-1.2.0.dev14.dist-info → boneio-1.2.0.dev17.dist-info}/top_level.txt +0 -0
boneio/components/cover/cover.py
CHANGED
|
@@ -171,6 +171,7 @@ class BaseCover(BaseCoverABC, BasicMqtt):
|
|
|
171
171
|
self._position = position
|
|
172
172
|
self._initial_position = None
|
|
173
173
|
self._current_operation = IDLE
|
|
174
|
+
self._last_operation = CLOSING
|
|
174
175
|
_LOGGER.debug(
|
|
175
176
|
"BaseCover %s initialized: open_time=%dms, close_time=%dms, initial_position=%d%%",
|
|
176
177
|
id, self._open_time, self._close_time, position
|
|
@@ -207,6 +208,8 @@ class BaseCover(BaseCoverABC, BasicMqtt):
|
|
|
207
208
|
# Send relay states to WebSocket (not MQTT - that's handled by output_type check)
|
|
208
209
|
asyncio.create_task(self._open_relay.async_send_state())
|
|
209
210
|
asyncio.create_task(self._close_relay.async_send_state())
|
|
211
|
+
if self._current_operation in (OPENING, CLOSING):
|
|
212
|
+
self._last_operation = self._current_operation
|
|
210
213
|
self._current_operation = IDLE
|
|
211
214
|
if not on_exit:
|
|
212
215
|
self.send_state(self.state, self.json_position)
|
|
@@ -219,6 +222,7 @@ class BaseCover(BaseCoverABC, BasicMqtt):
|
|
|
219
222
|
"Opening cover %s from position %d%%. Estimated time: %.1fs (open_time=%dms)",
|
|
220
223
|
self._id, self._position, estimated_time_s, self._open_time
|
|
221
224
|
)
|
|
225
|
+
self._last_operation = OPENING
|
|
222
226
|
await self.run_cover(current_operation=OPENING)
|
|
223
227
|
self._message_bus.send_message(topic=f"{self._send_topic}/state", payload=OPENING)
|
|
224
228
|
|
|
@@ -230,6 +234,7 @@ class BaseCover(BaseCoverABC, BasicMqtt):
|
|
|
230
234
|
"Closing cover %s from position %d%%. Estimated time: %.1fs (close_time=%dms)",
|
|
231
235
|
self._id, self._position, estimated_time_s, self._close_time
|
|
232
236
|
)
|
|
237
|
+
self._last_operation = CLOSING
|
|
233
238
|
await self.run_cover(current_operation=CLOSING)
|
|
234
239
|
self._message_bus.send_message(topic=f"{self._send_topic}/state", payload=CLOSING)
|
|
235
240
|
|
|
@@ -258,10 +263,12 @@ class BaseCover(BaseCoverABC, BasicMqtt):
|
|
|
258
263
|
|
|
259
264
|
async def toggle(self) -> None:
|
|
260
265
|
_LOGGER.debug("Toggle cover %s from input.", self._id)
|
|
261
|
-
if self.
|
|
262
|
-
await self.
|
|
263
|
-
|
|
266
|
+
if self._current_operation != IDLE:
|
|
267
|
+
await self.stop()
|
|
268
|
+
elif self._last_operation == CLOSING:
|
|
264
269
|
await self.open()
|
|
270
|
+
else:
|
|
271
|
+
await self.close()
|
|
265
272
|
|
|
266
273
|
async def toggle_open(self) -> None:
|
|
267
274
|
_LOGGER.debug("Toggle open cover %s from input.", self._id)
|
|
@@ -32,6 +32,14 @@ def _persist_transition(config_file: str) -> None:
|
|
|
32
32
|
for line in lines:
|
|
33
33
|
m = transition_re.match(line)
|
|
34
34
|
if m:
|
|
35
|
+
num_val = float(m.group(2))
|
|
36
|
+
if num_val == 0:
|
|
37
|
+
# Remove transition: 0 entirely — not meaningful
|
|
38
|
+
_LOGGER.info(
|
|
39
|
+
"Removing from config file: %s", line.rstrip(),
|
|
40
|
+
)
|
|
41
|
+
updated = True
|
|
42
|
+
continue # skip this line
|
|
35
43
|
new_line = f"{m.group(1)}{m.group(2)}s\n"
|
|
36
44
|
updated_lines.append(new_line)
|
|
37
45
|
updated = True
|
|
@@ -82,13 +90,20 @@ def migrate(doc: dict) -> dict:
|
|
|
82
90
|
if "transition" in action_def:
|
|
83
91
|
val = action_def["transition"]
|
|
84
92
|
if isinstance(val, (int, float)):
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
+
if val == 0:
|
|
94
|
+
_LOGGER.info(
|
|
95
|
+
"Removing transition: 0 from %s action (not meaningful)",
|
|
96
|
+
section_name,
|
|
97
|
+
)
|
|
98
|
+
del action_def["transition"]
|
|
99
|
+
else:
|
|
100
|
+
new_val = f"{val}s"
|
|
101
|
+
_LOGGER.info(
|
|
102
|
+
"Migrating transition: %s -> '%s' in %s action",
|
|
103
|
+
val,
|
|
104
|
+
new_val,
|
|
105
|
+
section_name,
|
|
106
|
+
)
|
|
107
|
+
action_def["transition"] = new_val
|
|
93
108
|
|
|
94
109
|
return doc
|
boneio/core/config/yaml_util.py
CHANGED
|
@@ -6,7 +6,7 @@ from collections import OrderedDict
|
|
|
6
6
|
from typing import Any
|
|
7
7
|
|
|
8
8
|
from cerberus import TypeDefinition, Validator
|
|
9
|
-
from yaml import MarkedYAMLError, SafeLoader, YAMLError, dump, load
|
|
9
|
+
from yaml import MarkedYAMLError, SafeLoader, SafeDumper, YAMLError, dump, load
|
|
10
10
|
|
|
11
11
|
from boneio.const import OUTPUT
|
|
12
12
|
from boneio.core.utils import TimePeriod
|
|
@@ -56,6 +56,15 @@ def clear_config_cache():
|
|
|
56
56
|
_LOGGER.info("Config cache cleared")
|
|
57
57
|
|
|
58
58
|
|
|
59
|
+
class TimePeriodDumper(SafeDumper):
|
|
60
|
+
"""Custom dumper that cleanly serializes TimePeriod objects as strings."""
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
def represent_time_period(dumper, data):
|
|
64
|
+
return dumper.represent_scalar('tag:yaml.org,2002:str', str(data))
|
|
65
|
+
|
|
66
|
+
TimePeriodDumper.add_representer(TimePeriod, represent_time_period)
|
|
67
|
+
|
|
59
68
|
class BoneIOLoader(SafeLoader):
|
|
60
69
|
"""Loader which support for include in yaml files."""
|
|
61
70
|
|
|
@@ -518,6 +527,8 @@ class CustomValidator(Validator):
|
|
|
518
527
|
def _normalize_coerce_positive_time_period(self, value) -> TimePeriod:
|
|
519
528
|
"""Validate and transform time period with time unit and integer value."""
|
|
520
529
|
if isinstance(value, int):
|
|
530
|
+
if value == 0:
|
|
531
|
+
return TimePeriod(seconds=0)
|
|
521
532
|
raise ConfigurationException(
|
|
522
533
|
f"Don't know what '{value}' means as it has no time *unit*! Did you mean '{value}s'?"
|
|
523
534
|
)
|
|
@@ -876,8 +887,12 @@ def strip_default_values(data: Any, schema: dict | None = None, section: str | N
|
|
|
876
887
|
action_defaults = {
|
|
877
888
|
'action_cover': 'TOGGLE',
|
|
878
889
|
'action_output': 'TOGGLE',
|
|
879
|
-
'data': {}
|
|
890
|
+
'data': {},
|
|
880
891
|
}
|
|
892
|
+
# Remove transition if 0 or '0s' — not meaningful
|
|
893
|
+
t_val = action_item.get('transition')
|
|
894
|
+
if t_val is not None and (t_val == 0 or t_val == 0.0 or str(t_val) in ('0s', '0ms', '0')):
|
|
895
|
+
action_item = {k: v for k, v in action_item.items() if k != 'transition'}
|
|
881
896
|
cleaned_action = clean_dict(action_item, action_defaults)
|
|
882
897
|
# Only add if action field exists (required)
|
|
883
898
|
if 'action' in cleaned_action or cleaned_action:
|
|
@@ -1006,7 +1021,7 @@ def update_config_section(config_file: str, section: str, data: dict | list) ->
|
|
|
1006
1021
|
_LOGGER.info(f"Section '{section}' uses !include '{include_filename}', updating {include_file_path}")
|
|
1007
1022
|
|
|
1008
1023
|
# Save cleaned data to the included file
|
|
1009
|
-
content = dump(cleaned_data, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
|
1024
|
+
content = dump(cleaned_data, Dumper=TimePeriodDumper, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
|
1010
1025
|
with open(include_file_path, 'w', encoding='utf-8') as f:
|
|
1011
1026
|
f.write(content)
|
|
1012
1027
|
|
|
@@ -1035,7 +1050,7 @@ def update_config_section(config_file: str, section: str, data: dict | list) ->
|
|
|
1035
1050
|
in_section = True
|
|
1036
1051
|
section_indent = len(line) - len(line.lstrip())
|
|
1037
1052
|
# Add the complete new section (header + data)
|
|
1038
|
-
section_yaml = dump({section: cleaned_data}, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
|
1053
|
+
section_yaml = dump({section: cleaned_data}, Dumper=TimePeriodDumper, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
|
1039
1054
|
# Add proper indentation if section was indented
|
|
1040
1055
|
if section_indent > 0:
|
|
1041
1056
|
indented_lines = []
|
|
@@ -1065,7 +1080,7 @@ def update_config_section(config_file: str, section: str, data: dict | list) ->
|
|
|
1065
1080
|
_LOGGER.info(f"Section '{section}' doesn't exist, adding to config.yaml")
|
|
1066
1081
|
|
|
1067
1082
|
# Append new section to the end of the file
|
|
1068
|
-
section_yaml = dump({section: cleaned_data}, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
|
1083
|
+
section_yaml = dump({section: cleaned_data}, Dumper=TimePeriodDumper, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
|
1069
1084
|
with open(config_file, 'a', encoding='utf-8') as f:
|
|
1070
1085
|
f.write('\n' + section_yaml)
|
|
1071
1086
|
|
boneio/core/events/bus.py
CHANGED
boneio/core/manager/inputs.py
CHANGED
|
@@ -695,7 +695,12 @@ class InputManager:
|
|
|
695
695
|
"""
|
|
696
696
|
|
|
697
697
|
if not event.entity_id or not event.click_type:
|
|
698
|
-
|
|
698
|
+
# State-sync events (e.g. from _broadcast_all_input_states after reload)
|
|
699
|
+
# have click_type=None — this is expected, not an error.
|
|
700
|
+
_LOGGER.debug(
|
|
701
|
+
"Ignoring input event without click_type (state sync): entity_id=%s",
|
|
702
|
+
event.entity_id,
|
|
703
|
+
)
|
|
699
704
|
return
|
|
700
705
|
# Get the input instance and retrieve actions for this click type
|
|
701
706
|
input_instance = self._inputs.get(event.entity_id)
|
boneio/core/manager/modbus.py
CHANGED
|
@@ -10,7 +10,7 @@ import logging
|
|
|
10
10
|
from typing import TYPE_CHECKING, Any
|
|
11
11
|
|
|
12
12
|
from boneio.const import ADDRESS, ID, MODEL, NAME, UART, UARTS, UPDATE_INTERVAL
|
|
13
|
-
from boneio.core.utils.timeperiod import TimePeriod
|
|
13
|
+
from boneio.core.utils.timeperiod import TimePeriod, parse_time_to_seconds, parse_time_to_ms
|
|
14
14
|
from boneio.exceptions import ModbusUartException
|
|
15
15
|
|
|
16
16
|
if TYPE_CHECKING:
|
|
@@ -83,10 +83,20 @@ class ModbusManager:
|
|
|
83
83
|
f"UART {uart} is not available. Available UARTs: {UARTS}"
|
|
84
84
|
)
|
|
85
85
|
|
|
86
|
+
# Convert TimePeriod values to numeric before passing to client
|
|
87
|
+
timeout = parse_time_to_seconds(
|
|
88
|
+
config.pop("timeout", None), default=1.5
|
|
89
|
+
)
|
|
90
|
+
inter_device_delay = parse_time_to_ms(
|
|
91
|
+
config.pop("inter_device_delay", None), default=5
|
|
92
|
+
) or 5
|
|
93
|
+
|
|
86
94
|
# Initialize Modbus client
|
|
87
95
|
self._modbus = Modbus(
|
|
88
96
|
uart=UARTS[uart],
|
|
89
97
|
baudrate=config.pop("baudrate", 9600),
|
|
98
|
+
timeout=timeout,
|
|
99
|
+
inter_device_delay=inter_device_delay,
|
|
90
100
|
**config
|
|
91
101
|
)
|
|
92
102
|
|
|
@@ -93,7 +93,15 @@ class TemplateManager:
|
|
|
93
93
|
except (ValueError, TypeError, AttributeError):
|
|
94
94
|
return
|
|
95
95
|
|
|
96
|
-
|
|
96
|
+
matched = self._thermostats.sensor_map.get(sensor_id, [])
|
|
97
|
+
if not matched:
|
|
98
|
+
_LOGGER.debug(
|
|
99
|
+
"SensorEvent '%s' (%.1f°C) has no matching thermostat. "
|
|
100
|
+
"Registered sensor_ids: %s",
|
|
101
|
+
sensor_id, temperature,
|
|
102
|
+
list(self._thermostats.sensor_map.keys()),
|
|
103
|
+
)
|
|
104
|
+
for thermostat in matched:
|
|
97
105
|
thermostat.update_sensor_temperature(sensor_id, temperature)
|
|
98
106
|
|
|
99
107
|
def on_input_event(self, input_id: str, event_type: str) -> None:
|
|
@@ -260,7 +268,7 @@ class TemplateManager:
|
|
|
260
268
|
listener_id=f"template_mgr_{sensor_id}",
|
|
261
269
|
)
|
|
262
270
|
|
|
263
|
-
# Re-register for current mappings
|
|
271
|
+
# Re-register for current mappings (native SensorEvent sources)
|
|
264
272
|
for sensor_id in self._thermostats.sensor_map:
|
|
265
273
|
event_bus.add_event_listener(
|
|
266
274
|
event_type="sensor",
|
|
@@ -268,3 +276,8 @@ class TemplateManager:
|
|
|
268
276
|
listener_id=f"template_mgr_{sensor_id}",
|
|
269
277
|
target=self._on_sensor_event,
|
|
270
278
|
)
|
|
279
|
+
|
|
280
|
+
_LOGGER.info(
|
|
281
|
+
"Registered sensor listeners for thermostat sensor_ids: %s",
|
|
282
|
+
list(self._thermostats.sensor_map.keys()),
|
|
283
|
+
)
|
boneio/core/remote/esphome.py
CHANGED
|
@@ -351,10 +351,18 @@ class ESPHomeRemoteDevice(RemoteDevice):
|
|
|
351
351
|
for cover in self._covers_list:
|
|
352
352
|
if cover.get("key") == state.key:
|
|
353
353
|
cover_id = cover.get("id", "")
|
|
354
|
+
|
|
355
|
+
# Store last known operation if moving
|
|
356
|
+
current_op = getattr(state, 'current_operation', 0)
|
|
357
|
+
last_op = self._cover_states.get(cover_id, {}).get("last_known_operation", 2) # Default to CLOSING (2)
|
|
358
|
+
if current_op != 0:
|
|
359
|
+
last_op = current_op
|
|
360
|
+
|
|
354
361
|
self._cover_states[cover_id] = {
|
|
355
362
|
"position": state.position,
|
|
356
363
|
"tilt": getattr(state, 'tilt', None),
|
|
357
|
-
"current_operation":
|
|
364
|
+
"current_operation": current_op,
|
|
365
|
+
"last_known_operation": last_op,
|
|
358
366
|
}
|
|
359
367
|
_LOGGER.debug("Cover '%s' position: %.2f", cover_id, state.position)
|
|
360
368
|
break
|
|
@@ -847,12 +855,40 @@ class ESPHomeRemoteDevice(RemoteDevice):
|
|
|
847
855
|
elif action_upper == "STOP":
|
|
848
856
|
self._client.cover_command(cover_key, stop=True)
|
|
849
857
|
elif action_upper == "TOGGLE":
|
|
850
|
-
#
|
|
851
|
-
|
|
852
|
-
|
|
858
|
+
# Toggle based on current operation or last known operation
|
|
859
|
+
state = self._cover_states.get(cover_id, {})
|
|
860
|
+
current_op = state.get("current_operation", 0) # 0=IDLE, 1=OPENING, 2=CLOSING
|
|
861
|
+
last_op = state.get("last_known_operation", 2)
|
|
862
|
+
cover_pos = state.get("position")
|
|
863
|
+
_LOGGER.debug(
|
|
864
|
+
"TOGGLE cover '%s': key=%s, state=%s, current_op=%s, last_op=%s, position=%s, all_states=%s",
|
|
865
|
+
cover_id, cover_key, state, current_op, last_op, cover_pos,
|
|
866
|
+
{k: v for k, v in self._cover_states.items()},
|
|
867
|
+
)
|
|
868
|
+
|
|
869
|
+
if current_op != 0: # If moving, stop it
|
|
870
|
+
_LOGGER.debug("TOGGLE -> STOP (cover is moving, current_op=%s)", current_op)
|
|
871
|
+
self._client.cover_command(cover_key, stop=True)
|
|
872
|
+
elif last_op == 2: # If last operation was CLOSING, open it
|
|
873
|
+
_LOGGER.debug("TOGGLE -> OPEN (last_op=CLOSING)")
|
|
874
|
+
self._client.cover_command(cover_key, position=1.0)
|
|
875
|
+
else: # Otherwise close it
|
|
876
|
+
_LOGGER.debug("TOGGLE -> CLOSE (last_op=%s)", last_op)
|
|
853
877
|
self._client.cover_command(cover_key, position=0.0)
|
|
878
|
+
elif action_upper == "TOGGLE_OPEN":
|
|
879
|
+
state = self._cover_states.get(cover_id, {})
|
|
880
|
+
current_op = state.get("current_operation", 0)
|
|
881
|
+
if current_op != 0:
|
|
882
|
+
self._client.cover_command(cover_key, stop=True)
|
|
854
883
|
else:
|
|
855
884
|
self._client.cover_command(cover_key, position=1.0)
|
|
885
|
+
elif action_upper == "TOGGLE_CLOSE":
|
|
886
|
+
state = self._cover_states.get(cover_id, {})
|
|
887
|
+
current_op = state.get("current_operation", 0)
|
|
888
|
+
if current_op != 0:
|
|
889
|
+
self._client.cover_command(cover_key, stop=True)
|
|
890
|
+
else:
|
|
891
|
+
self._client.cover_command(cover_key, position=0.0)
|
|
856
892
|
else:
|
|
857
893
|
_LOGGER.error("Invalid cover action: %s", action)
|
|
858
894
|
return False
|
boneio/modbus/client.py
CHANGED
|
@@ -103,8 +103,21 @@ class Modbus:
|
|
|
103
103
|
bytesize: int = 8,
|
|
104
104
|
parity: str = "N",
|
|
105
105
|
timeout: float = 1.5,
|
|
106
|
+
inter_device_delay: int = 5,
|
|
106
107
|
) -> None:
|
|
107
|
-
"""Initialize the Modbus hub.
|
|
108
|
+
"""Initialize the Modbus hub.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
uart: UART configuration dict with RX/TX pins.
|
|
112
|
+
baudrate: Serial baudrate (default 9600).
|
|
113
|
+
stopbits: Number of stop bits (default 1).
|
|
114
|
+
bytesize: Number of data bits (default 8).
|
|
115
|
+
parity: Parity mode N/E/O (default N).
|
|
116
|
+
timeout: Response timeout in seconds (default 1.5).
|
|
117
|
+
inter_device_delay: Delay in ms between transactions (default 5).
|
|
118
|
+
Helps with star topology wiring where signal reflections
|
|
119
|
+
on branch stubs need time to settle.
|
|
120
|
+
"""
|
|
108
121
|
rx = uart.get(RX)
|
|
109
122
|
tx = uart.get(TX)
|
|
110
123
|
if not tx or not rx:
|
|
@@ -115,15 +128,24 @@ class Modbus:
|
|
|
115
128
|
self._uart = uart
|
|
116
129
|
self._loop = asyncio.get_event_loop()
|
|
117
130
|
self._lock = asyncio.Lock()
|
|
131
|
+
# Clamp inter_device_delay to 0-200ms, timeout to 0.1-10s
|
|
132
|
+
clamped_delay = max(0, min(200, inter_device_delay))
|
|
133
|
+
if clamped_delay != inter_device_delay:
|
|
134
|
+
_LOGGER.warning(
|
|
135
|
+
"inter_device_delay %dms out of range (0-200), clamped to %dms",
|
|
136
|
+
inter_device_delay, clamped_delay,
|
|
137
|
+
)
|
|
138
|
+
self._inter_device_delay = clamped_delay / 1000.0 # ms -> seconds
|
|
139
|
+
clamped_timeout = max(0.1, min(10.0, timeout))
|
|
140
|
+
if clamped_timeout != timeout:
|
|
141
|
+
_LOGGER.warning(
|
|
142
|
+
"timeout %.2fs out of range (0.1-10), clamped to %.2fs",
|
|
143
|
+
timeout, clamped_timeout,
|
|
144
|
+
)
|
|
145
|
+
timeout = clamped_timeout
|
|
118
146
|
self._executor = ThreadPoolExecutor(max_workers=MAX_WORKERS, thread_name_prefix="modbus_worker")
|
|
119
147
|
|
|
120
148
|
_LOGGER.debug(f"Creating ModbusSerialClient for port: {self._uart[ID]}")
|
|
121
|
-
# Calculate inter-character timeout based on baudrate (3.5 characters)
|
|
122
|
-
# At 9600 baud: 1 char = 11 bits (start + 8 data + parity + stop) = ~1.15ms
|
|
123
|
-
# 3.5 chars = ~4ms, we use slightly more for safety
|
|
124
|
-
char_time_ms = (11 * 1000) / baudrate # Time for 1 character in ms
|
|
125
|
-
inter_char_timeout = (char_time_ms * 3.5) / 1000 # Convert to seconds
|
|
126
|
-
|
|
127
149
|
self._client = ModbusSerialClient(
|
|
128
150
|
port=self._uart[ID],
|
|
129
151
|
framer=FramerType.RTU,
|
|
@@ -134,7 +156,10 @@ class Modbus:
|
|
|
134
156
|
timeout=timeout,
|
|
135
157
|
retries=2, # Reduced from 3 to speed up detection of offline devices
|
|
136
158
|
)
|
|
137
|
-
_LOGGER.debug(
|
|
159
|
+
_LOGGER.debug(
|
|
160
|
+
"ModbusSerialClient created successfully with timeout=%.2fs, retries=2, inter_device_delay=%dms",
|
|
161
|
+
timeout, inter_device_delay,
|
|
162
|
+
)
|
|
138
163
|
|
|
139
164
|
@property
|
|
140
165
|
def client(self) -> ModbusSerialClient | None:
|
|
@@ -341,7 +366,10 @@ class Modbus:
|
|
|
341
366
|
):
|
|
342
367
|
"""Call async pymodbus."""
|
|
343
368
|
async with self._lock:
|
|
344
|
-
|
|
369
|
+
result = await self._loop.run_in_executor(self._executor, self.read_registers_blocking, unit, address, count, method)
|
|
370
|
+
if self._inter_device_delay > 0:
|
|
371
|
+
await asyncio.sleep(self._inter_device_delay)
|
|
372
|
+
return result
|
|
345
373
|
|
|
346
374
|
def scan_device_blocking(self, unit: int, address: int = 1, method: str = "input", timeout: float = 0.3) -> bool:
|
|
347
375
|
"""Quick scan to check if device exists at address.
|
|
@@ -426,4 +454,7 @@ class Modbus:
|
|
|
426
454
|
async def write_register(self, unit: int | str, address: int, value: int | float):
|
|
427
455
|
"""Write register async."""
|
|
428
456
|
async with self._lock:
|
|
429
|
-
|
|
457
|
+
result = await self._loop.run_in_executor(self._executor, self.write_register_blocking, unit, address, value)
|
|
458
|
+
if self._inter_device_delay > 0:
|
|
459
|
+
await asyncio.sleep(self._inter_device_delay)
|
|
460
|
+
return result
|
boneio/modbus/coordinator.py
CHANGED
|
@@ -49,7 +49,8 @@ from boneio.modbus.entities.writeable.numeric import (
|
|
|
49
49
|
ModbusNumericWriteableEntity,
|
|
50
50
|
ModbusNumericWriteableEntityDiscrete,
|
|
51
51
|
)
|
|
52
|
-
from boneio.models.events import ModbusDeviceEvent
|
|
52
|
+
from boneio.models.events import ModbusDeviceEvent, SensorEvent
|
|
53
|
+
from boneio.models.state import SensorState
|
|
53
54
|
|
|
54
55
|
from .client import VALUE_TYPES, Modbus
|
|
55
56
|
|
|
@@ -686,27 +687,50 @@ class ModbusCoordinator(BasicMqtt, AsyncUpdater, Filter):
|
|
|
686
687
|
) -> None:
|
|
687
688
|
"""Trigger events for entity updates.
|
|
688
689
|
|
|
690
|
+
Emits ModbusDeviceEvent for all entities. Additionally emits
|
|
691
|
+
SensorEvent for any entity that acts as a sensor, so standard
|
|
692
|
+
components (like thermostats) can listen to sensor updates natively.
|
|
693
|
+
|
|
689
694
|
Args:
|
|
690
695
|
entity: Entity that was updated
|
|
691
|
-
output: Output dictionary with states
|
|
692
696
|
"""
|
|
693
697
|
type_info = self._get_entity_type_info(entity)
|
|
694
698
|
|
|
699
|
+
device_state = ModbusDeviceState(
|
|
700
|
+
id=entity.id,
|
|
701
|
+
name=entity.name,
|
|
702
|
+
state=entity.state or 0.0,
|
|
703
|
+
unit=getattr(entity, 'unit_of_measurement', None),
|
|
704
|
+
timestamp=entity.last_timestamp,
|
|
705
|
+
device_group=self.name,
|
|
706
|
+
coordinator_id=self._id,
|
|
707
|
+
entity_type=entity.entity_type,
|
|
708
|
+
**type_info
|
|
709
|
+
)
|
|
710
|
+
|
|
695
711
|
self._event_bus.trigger_event(ModbusDeviceEvent(
|
|
696
712
|
entity_id=entity.id,
|
|
697
|
-
state=
|
|
698
|
-
id=entity.id,
|
|
699
|
-
name=entity.name,
|
|
700
|
-
state=entity.state or 0.0,
|
|
701
|
-
unit=getattr(entity, 'unit_of_measurement', None),
|
|
702
|
-
timestamp=entity.last_timestamp,
|
|
703
|
-
device_group=self.name,
|
|
704
|
-
coordinator_id=self._id,
|
|
705
|
-
entity_type=entity.entity_type,
|
|
706
|
-
**type_info
|
|
707
|
-
),
|
|
713
|
+
state=device_state,
|
|
708
714
|
))
|
|
709
715
|
|
|
716
|
+
# Also emit dedicated SensorEvent for generic sensor integration
|
|
717
|
+
# (thermostats, rules, etc. shouldn't care about transport type)
|
|
718
|
+
if entity.entity_type in (SENSOR, TEXT_SENSOR):
|
|
719
|
+
_LOGGER.debug(
|
|
720
|
+
"Emitting SensorEvent: entity_id='%s', name='%s', state=%s",
|
|
721
|
+
entity.id, entity.name, entity.state,
|
|
722
|
+
)
|
|
723
|
+
self._event_bus.trigger_event(SensorEvent(
|
|
724
|
+
entity_id=entity.id,
|
|
725
|
+
state=SensorState(
|
|
726
|
+
id=entity.id,
|
|
727
|
+
name=entity.name,
|
|
728
|
+
state=entity.state or 0.0,
|
|
729
|
+
unit=getattr(entity, 'unit_of_measurement', None),
|
|
730
|
+
timestamp=entity.last_timestamp,
|
|
731
|
+
)
|
|
732
|
+
))
|
|
733
|
+
|
|
710
734
|
async def write_register(
|
|
711
735
|
self,
|
|
712
736
|
value: str | float | int,
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"model": "boneIO Edge Temp and Humidity sensor",
|
|
3
|
+
"set_base": {
|
|
4
|
+
"set_address_address": 100,
|
|
5
|
+
"set_baudrate": {
|
|
6
|
+
"address": 101,
|
|
7
|
+
"possible_baudrates": {
|
|
8
|
+
"2400": 0,
|
|
9
|
+
"4800": 1,
|
|
10
|
+
"9600": 2,
|
|
11
|
+
"19200": 3
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"registers_base": [
|
|
16
|
+
{
|
|
17
|
+
"base": 0,
|
|
18
|
+
"length": 2,
|
|
19
|
+
"register_type": "holding",
|
|
20
|
+
"registers": [
|
|
21
|
+
{
|
|
22
|
+
"name": "Humidity",
|
|
23
|
+
"address": 0,
|
|
24
|
+
"unit_of_measurement": "%",
|
|
25
|
+
"state_class": "measurement",
|
|
26
|
+
"device_class": "humidity",
|
|
27
|
+
"value_type": "S_WORD",
|
|
28
|
+
"filters": [
|
|
29
|
+
{
|
|
30
|
+
"multiply": 0.1
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"name": "Temperature",
|
|
36
|
+
"address": 1,
|
|
37
|
+
"unit_of_measurement": "°C",
|
|
38
|
+
"state_class": "measurement",
|
|
39
|
+
"device_class": "temperature",
|
|
40
|
+
"value_type": "S_WORD",
|
|
41
|
+
"filters": [
|
|
42
|
+
{
|
|
43
|
+
"multiply": 0.1
|
|
44
|
+
}
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
]
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"model": "WIND_SPEED_GD_FS",
|
|
3
|
+
"registers_base": [
|
|
4
|
+
{
|
|
5
|
+
"base": 0,
|
|
6
|
+
"length": 1,
|
|
7
|
+
"register_type": "holding",
|
|
8
|
+
"registers": [
|
|
9
|
+
{
|
|
10
|
+
"name": "wind_speed",
|
|
11
|
+
"address": 0,
|
|
12
|
+
"unit_of_measurement": "m/s",
|
|
13
|
+
"state_class": "measurement",
|
|
14
|
+
"value_type": "U_WORD",
|
|
15
|
+
"device_class": "speed"
|
|
16
|
+
}
|
|
17
|
+
]
|
|
18
|
+
}
|
|
19
|
+
],
|
|
20
|
+
"set_base": {
|
|
21
|
+
"set_address_address": 10,
|
|
22
|
+
"set_baudrate": {
|
|
23
|
+
"address": 100,
|
|
24
|
+
"possible_baudrates": {
|
|
25
|
+
"2400": 0,
|
|
26
|
+
"4800": 1,
|
|
27
|
+
"9600": 2,
|
|
28
|
+
"19200": 3,
|
|
29
|
+
"38400": 4,
|
|
30
|
+
"57600": 5,
|
|
31
|
+
"115200": 6
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"model": "WIND_DIRECTION_GD_FX",
|
|
3
|
+
"registers_base": [
|
|
4
|
+
{
|
|
5
|
+
"base": 0,
|
|
6
|
+
"length": 2,
|
|
7
|
+
"register_type": "holding",
|
|
8
|
+
"registers": [
|
|
9
|
+
{
|
|
10
|
+
"name": "wind_direction_gear",
|
|
11
|
+
"address": 0,
|
|
12
|
+
"unit_of_measurement": "sector",
|
|
13
|
+
"state_class": "measurement",
|
|
14
|
+
"value_type": "U_WORD"
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"name": "wind_direction_degree",
|
|
18
|
+
"address": 1,
|
|
19
|
+
"unit_of_measurement": "°",
|
|
20
|
+
"state_class": "measurement",
|
|
21
|
+
"value_type": "U_WORD"
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
25
|
+
],
|
|
26
|
+
"set_base": {
|
|
27
|
+
"set_address_address": 10,
|
|
28
|
+
"set_baudrate": {
|
|
29
|
+
"address": 100,
|
|
30
|
+
"possible_baudrates": {
|
|
31
|
+
"2400": 0,
|
|
32
|
+
"4800": 1,
|
|
33
|
+
"9600": 2,
|
|
34
|
+
"19200": 3,
|
|
35
|
+
"38400": 4,
|
|
36
|
+
"57600": 5,
|
|
37
|
+
"115200": 6
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
boneio/schema/actions.yaml
CHANGED
|
@@ -166,6 +166,8 @@ schema:
|
|
|
166
166
|
type: timeperiod
|
|
167
167
|
coerce: positive_time_period
|
|
168
168
|
required: False
|
|
169
|
+
forbidden_if:
|
|
170
|
+
action: ['output', 'cover', 'mqtt', 'output_over_mqtt', 'cover_over_mqtt', 'remote_cover']
|
|
169
171
|
meta:
|
|
170
172
|
label: Transition Time
|
|
171
173
|
description: "Time to transition to the new state. Only for ESPHome/WLED lights. Example: 500ms, 1s"
|
boneio/schema/schema.yaml
CHANGED
|
@@ -291,6 +291,16 @@ modbus:
|
|
|
291
291
|
type: string
|
|
292
292
|
allowed: ['N', 'E', 'O']
|
|
293
293
|
default: 'N'
|
|
294
|
+
timeout:
|
|
295
|
+
coerce: timeperiod
|
|
296
|
+
default: 1500ms
|
|
297
|
+
meta:
|
|
298
|
+
label: Response timeout. Increase for slow devices or long cables.
|
|
299
|
+
inter_device_delay:
|
|
300
|
+
coerce: timeperiod
|
|
301
|
+
default: 5ms
|
|
302
|
+
meta:
|
|
303
|
+
label: Delay between Modbus transactions. Helps with star topology wiring where signal reflections need time to settle. Recommended 5-50ms for star, 0 for daisy-chain.
|
|
294
304
|
|
|
295
305
|
modbus_devices:
|
|
296
306
|
type: list
|
|
@@ -340,6 +350,10 @@ modbus_devices:
|
|
|
340
350
|
[
|
|
341
351
|
'cwt',
|
|
342
352
|
'dts1964_3f',
|
|
353
|
+
'dyp-a12-ultrasonic',
|
|
354
|
+
'gdfs',
|
|
355
|
+
'gdfx',
|
|
356
|
+
'boneio-edge-temp',
|
|
343
357
|
'liquid-sensor',
|
|
344
358
|
'orno-or-we-517',
|
|
345
359
|
'pt100',
|
boneio/version.py
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
# flake8: noqa
|
|
2
|
-
__version__ = "1.2.
|
|
2
|
+
__version__ = "1.2.0dev17"
|