Skip to content

Commit c1e210b

Browse files
author
Maksym Shtembuliak
committed
fix: preserve learned Modbus write commands
1 parent 9984dc4 commit c1e210b

6 files changed

Lines changed: 751 additions & 8 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,13 @@ the GitHub release body should be rendered from the matching version section her
8181

8282
### Fixed
8383

84+
- Newly learned Modbus controls retain the captured FC06 or FC16 write command
85+
through profile generation and runtime dispatch. Incomplete, conflicting or
86+
unrepresentable write shapes remain support evidence instead of guessed
87+
controls. The SMG driver also honors explicit FC06 declarations in existing
88+
profiles; FC16 remains the default when no override is declared. Older learned
89+
profiles need a new learning run to regenerate their command metadata.
90+
8491
- Collector connection settings remain reachable before the inverter is
8592
identified (#49). Unknown collector capabilities no longer imply a local-only
8693
ESP collector or hide callback diagnostics. Endpoint changes still require

‎custom_components/eybond_local/drivers/smg.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -680,6 +680,9 @@ async def async_write_capability(
680680
encoded_word=int(raw_words[0]),
681681
)
682682
await session.write_holding(capability.register, [merged])
683+
elif capability.write_function == 6:
684+
for offset, word in enumerate(raw_words):
685+
await session.write_single_holding(capability.register + offset, int(word))
683686
else:
684687
await session.write_holding(capability.register, raw_words)
685688

‎custom_components/eybond_local/support/shadow_learning/overlay_generator.py‎

Lines changed: 74 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ def generate_shadow_learning_overlay_drafts(
205205
"read_enum_bindings": read_enum_bindings,
206206
"learned_capabilities": list(learned_summary["generated"]),
207207
"skipped_duplicates": list(learned_summary["skipped"]),
208+
"skipped_controls": list(learned_summary["rejected"]),
208209
# Read sensors materialized from unique value/enum correlations.
209210
"learned_read_sensors": list(learned_read["generated"]),
210211
"skipped_read_sensors": list(learned_read["skipped"]),
@@ -930,6 +931,7 @@ def _build_learned_capabilities(
930931
generated: list[dict[str, Any]] = []
931932
generated_summary: list[dict[str, Any]] = []
932933
skipped_summary: list[dict[str, Any]] = []
934+
rejected_summary: list[dict[str, Any]] = []
933935

934936
next_order = 9000
935937
for group in grouped:
@@ -976,6 +978,37 @@ def _build_learned_capabilities(
976978
)
977979
continue
978980

981+
write_contract = _observed_modbus_write_contract(group) if register >= 0 else None
982+
if register >= 0 and write_contract is None:
983+
rejected_summary.append(
984+
{
985+
"field_id": field_id,
986+
"field_name": field_name,
987+
"register": register,
988+
"reason": "unproven_modbus_write_contract",
989+
}
990+
)
991+
continue
992+
993+
classification = _classify_learned_control(group)
994+
if write_contract is not None and (
995+
write_contract[1] != 1
996+
or classification["word_count"] != write_contract[1]
997+
):
998+
# A multi-register cloud command must never become a truncated
999+
# single-register switch/action merely because its labels look familiar.
1000+
# Numeric multiword captures also need a proven encoding before
1001+
# they can be represented by the runtime capability codec.
1002+
rejected_summary.append(
1003+
{
1004+
"field_id": field_id,
1005+
"field_name": field_name,
1006+
"register": register,
1007+
"reason": "unsupported_modbus_write_shape",
1008+
}
1009+
)
1010+
continue
1011+
9791012
base_key = (
9801013
_capability_key(field_id=field_id, field_name=field_name, register=register)
9811014
if register >= 0
@@ -985,7 +1018,6 @@ def _build_learned_capabilities(
9851018
existing_keys.add(capability_key)
9861019

9871020
read_key = _g_ascii_read_key_for_group(group)
988-
classification = _classify_learned_control(group)
9891021
classification, command_map = _normalize_g_ascii_feature_flag_capability(
9901022
group=group,
9911023
classification=classification,
@@ -1024,6 +1056,8 @@ def _build_learned_capabilities(
10241056
"order": next_order,
10251057
"learned_provenance": provenance,
10261058
}
1059+
if write_contract is not None:
1060+
capability["write_function"] = write_contract[0]
10271061
next_order += 1
10281062

10291063
minimum = classification.get("minimum")
@@ -1058,6 +1092,7 @@ def _build_learned_capabilities(
10581092
"field_id": field_id,
10591093
"field_name": field_name,
10601094
"register": register,
1095+
"write_function": capability.get("write_function"),
10611096
"command": command,
10621097
"command_map": capability.get("command_map", {}),
10631098
"read_key": read_key,
@@ -1068,7 +1103,40 @@ def _build_learned_capabilities(
10681103
}
10691104
)
10701105

1071-
return generated, {"generated": generated_summary, "skipped": skipped_summary}
1106+
return generated, {
1107+
"generated": generated_summary,
1108+
"skipped": skipped_summary,
1109+
"rejected": rejected_summary,
1110+
}
1111+
1112+
1113+
def _observed_modbus_write_contract(group: dict[str, Any]) -> tuple[int, int] | None:
1114+
"""Require one observed, representable function/width; never guess FC16.
1115+
1116+
Cloud metadata describes a setting, not the wire operation. Every correlated
1117+
sample must agree before the generated control is allowed to replay it.
1118+
Older/incomplete or conflicting evidence stays in the support artifacts.
1119+
"""
1120+
1121+
contract: tuple[int, int] | None = None
1122+
for sample in group.get("samples") or []:
1123+
observation = sample["observation"]
1124+
function = observation.get("function_code")
1125+
values = observation.get("values")
1126+
if (
1127+
type(function) is not int
1128+
or function not in (6, 16)
1129+
or type(values) is not list
1130+
or len(values) not in (1, 2)
1131+
or any(type(value) is not int or not 0 <= value <= 0xFFFF for value in values)
1132+
or (function == 6 and len(values) != 1)
1133+
):
1134+
return None
1135+
observed = (function, len(values))
1136+
if contract is not None and observed != contract:
1137+
return None
1138+
contract = observed
1139+
return contract
10721140

10731141

10741142
def _group_matched_records(correlation: dict[str, Any]) -> list[dict[str, Any]]:
@@ -1114,12 +1182,10 @@ def _group_matched_records(correlation: dict[str, Any]) -> list[dict[str, Any]]:
11141182
"requested_at": str(item.get("requested_at") or "").strip(),
11151183
"observation": {
11161184
"register": register,
1117-
"function_code": _to_int(observation.get("function_code")) or 16,
1118-
"values": [
1119-
int(value)
1120-
for value in list(observation.get("values") or [])
1121-
if _to_int(value) is not None
1122-
],
1185+
# Preserve malformed/missing fields for fail-closed contract
1186+
# validation rather than defaulting or dropping bad words.
1187+
"function_code": observation.get("function_code"),
1188+
"values": observation.get("values"),
11231189
"devcode": _to_int(observation.get("devcode")),
11241190
"devaddr": _to_int(observation.get("devaddr")),
11251191
"unit": _to_int(observation.get("unit")),

‎docs/user/DEVICE_LEARNING.md‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,33 @@ If you are helping add support for a model:
228228

229229
The Support Archive includes the relevant learning evidence.
230230

231+
### Learning controls from SmartValue / ValueCloud
232+
233+
For a supported Modbus inverter such as MUST PV3300, choose **Verify additional
234+
local controls** and **ValueCloud API**, using the account you use in SmartValue.
235+
The same endpoint-change and interception precautions above apply. The result
236+
can include controls missing from the built-in profile; it does not duplicate
237+
settings already provided by that profile or guarantee that every cloud control
238+
can be added.
239+
240+
Select and apply the controls you want to try. Selected learned controls are
241+
available in Auto or Full Control; Read Only still blocks all writes. Learning
242+
identifies the local commands but blocks the test writes, so it does **not** prove
243+
that the real inverter accepted them. Tell the maintainer which controls you
244+
actually tested and share a fresh Support Archive afterward. You can also share
245+
the archive without applying any controls.
246+
247+
An **Ongrid Switch** label does not by itself mean a complete inverter power
248+
switch. Check its actual effect separately, only when an interruption is safe
249+
and manual recovery is available. Do not assume that remote turn-on will remain
250+
possible after turning it off.
251+
252+
New learned Modbus controls preserve the captured write-command type rather
253+
than substituting another one. Incomplete or conflicting captures stay support
254+
evidence instead of becoming writable controls. Previously generated local
255+
profiles are not rewritten by an update; rerun learning if you need to regenerate
256+
an older learned control with the corrected command type.
257+
231258
## If learning fails
232259

233260
Common causes:

0 commit comments

Comments
 (0)