Skip to content

Commit 7e8cbf1

Browse files
author
Maksym Shtembuliak
committed
feat: learn numeric cloud controls from intercepted command evidence
1 parent a29cb36 commit 7e8cbf1

22 files changed

Lines changed: 448 additions & 53 deletions

‎CHANGELOG.md‎

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

1010
### Added
1111

12+
- Active control learning shares numeric command evidence across SmartESS,
13+
DESSMonitor and generic ValueCloud controls. Missing cloud ranges no longer
14+
exclude numeric discovery; two intercepted values must agree on the supported
15+
Modbus encoding. Provider hint ranges remain device-scoped, writes remain
16+
untested, and incomplete captures receive explicit review explanations.
17+
Existing G-ASCII command codecs and read-only cloud analysis are unchanged.
18+
1219
- PI30 devices without model-specific charge-current controls can expose
1320
**Max Total Charge Current** and **Max Utility Charge Current** in Full
1421
Control, using only the selectable values read from that inverter (#58).
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""Provider-neutral numeric evidence for protected cloud command learning.
2+
3+
Probe values are NOT recommended settings or inferred operating limits. They
4+
may only be dispatched by an already protected shadow-learning workflow. Cloud
5+
rejections are evidence, not a reason to bypass interception or try every value.
6+
"""
7+
8+
from decimal import Decimal, InvalidOperation
9+
import re
10+
11+
12+
_NUMBER = r"-?\d+(?:\.\d+)?"
13+
_RANGE = re.compile(rf"({_NUMBER})\s*(?:~|~|–|\bto\b|-)\s*({_NUMBER})", re.I)
14+
15+
16+
def decimal_value(value: object) -> Decimal | None:
17+
text = str(value).strip()
18+
if len(text) > 32 or not re.fullmatch(_NUMBER, text):
19+
return None
20+
try:
21+
number = Decimal(text)
22+
except InvalidOperation:
23+
return None
24+
return number if number.is_finite() and abs(number) <= 1_000_000_000 else None
25+
26+
27+
def numeric_learning_candidates(*, hint="", unit="", current="", fallback="1") -> list[dict]:
28+
"""Return at most two distinct probes, with device-scoped range evidence.
29+
30+
Only an unambiguous single range is accepted from the provider hint. Voltage
31+
family, field name and cloud field ID never supply a range or register address.
32+
"""
33+
matches = list(_RANGE.finditer(str(hint)))
34+
minimum = maximum = None
35+
if len(matches) == 1:
36+
minimum, maximum = (decimal_value(v) for v in matches[0].groups())
37+
if minimum is None or maximum is None or minimum > maximum:
38+
minimum = maximum = None
39+
start = decimal_value(current)
40+
source = "current"
41+
if start is None or (minimum is not None and not minimum <= start <= maximum):
42+
start = minimum if minimum is not None else decimal_value(fallback)
43+
source = "provider_hint" if minimum is not None else "probe_only"
44+
if start is None:
45+
return []
46+
# Decimal precision can select a second probe in a sub-unit interval;
47+
# otherwise use a whole-unit separation, not a claimed device step.
48+
precision = min(start.as_tuple().exponent, 0)
49+
if minimum is not None:
50+
precision = min(precision, minimum.as_tuple().exponent, maximum.as_tuple().exponent)
51+
step = Decimal(1)
52+
if minimum is not None and maximum - minimum < step:
53+
step = Decimal(10) ** max(precision, -3)
54+
second = start + step
55+
if maximum is not None and second > maximum:
56+
second = start - step
57+
candidates = [start]
58+
if second != start and (minimum is None or minimum <= second <= maximum):
59+
candidates.append(second)
60+
evidence = {"kind": "number", "unit": str(unit or ""), "sample_source": source}
61+
if minimum is not None:
62+
evidence.update(minimum=str(minimum), maximum=str(maximum), range_source="provider_hint")
63+
return [dict(value=format(v, "f"), label="", source="numeric_probe",
64+
numeric_evidence=dict(evidence)) for v in candidates]
65+
66+
67+
def classify_numeric_encoding(samples: list[dict]) -> dict:
68+
"""Prove the supported unsigned, zero-offset encoding across all samples."""
69+
evidence = [s.get("numeric_evidence") for s in samples]
70+
if not evidence or any(not isinstance(e, dict) or e.get("kind") != "number" for e in evidence):
71+
return {"rejection_reason": "numeric_metadata_inconsistent"}
72+
if any(e != evidence[0] for e in evidence[1:]):
73+
return {"rejection_reason": "numeric_metadata_inconsistent"}
74+
pairs = []
75+
routes = set()
76+
for sample in samples:
77+
observation = sample.get("observation", {})
78+
routes.add(tuple(observation.get(k) for k in ("register", "function_code", "unit", "devcode", "devaddr")))
79+
value = decimal_value(sample.get("requested_value"))
80+
words = sample.get("observation", {}).get("values")
81+
if value is None or not isinstance(words, (list, tuple)) or len(words) != 1:
82+
return {"rejection_reason": "unsupported_numeric_encoding"}
83+
if type(words[0]) is not int or not 0 <= words[0] <= 65535 or value < 0:
84+
return {"rejection_reason": "unsupported_numeric_encoding"}
85+
pairs.append((value, words[0]))
86+
if len(routes) != 1:
87+
return {"rejection_reason": "numeric_route_inconsistent"}
88+
if len({v for v, _ in pairs}) < 2:
89+
return {"rejection_reason": "numeric_needs_distinct_samples"}
90+
divisors = [d for d in (1, 10, 100, 1000) if all(v * d == raw for v, raw in pairs)]
91+
if len(divisors) != 1:
92+
return {"rejection_reason": "numeric_encoding_inconsistent"}
93+
divisor = divisors[0]
94+
result = dict(value_kind="scaled_u16" if divisor > 1 else "u16", word_count=1,
95+
combine="u16", advanced=False, requires_confirm=True,
96+
unsafe_while_running=False, safety_class="setting", confidence="high")
97+
if divisor > 1:
98+
result["divisor"] = divisor
99+
if evidence[0].get("unit"):
100+
result["unit"] = evidence[0]["unit"]
101+
for key in ("minimum", "maximum"):
102+
if key not in evidence[0]:
103+
continue
104+
bound = decimal_value(evidence[0][key])
105+
if bound is None or bound * divisor != (bound * divisor).to_integral_value():
106+
return {"rejection_reason": "numeric_range_not_representable"}
107+
raw_bound = int(bound * divisor)
108+
if not 0 <= raw_bound <= 65535:
109+
return {"rejection_reason": "numeric_range_not_representable"}
110+
result[key] = raw_bound
111+
if result.get("minimum", 0) > result.get("maximum", 65535):
112+
return {"rejection_reason": "numeric_range_not_representable"}
113+
return result

‎custom_components/eybond_local/dessmonitor_cloud.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -821,6 +821,7 @@ def _normalize_control_fields(
821821
unit=_text(row.get("unit")),
822822
hint=_text(row.get("hint")),
823823
choices=tuple(choices),
824+
current_value=_text(row.get("val")),
824825
)
825826
)
826827
return tuple(output)
@@ -841,7 +842,7 @@ def _with_control_values(
841842
transport_unavailable = False
842843
planned_queries = min(len(fields), max_values)
843844
for index, item in enumerate(fields):
844-
current = ""
845+
current = item.current_value
845846
if index < max_values and not transport_unavailable:
846847
try:
847848
envelope = fetch(

‎custom_components/eybond_local/flow_translations/en.json‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
"none": "None",
1616
"not_run_yet": "Not run yet",
1717
"cloud_learning_method_intro": "Choose what you want Home Assistant to learn. Read-only analysis does not change the device. Active verification can discover additional local controls.",
18-
"cloud_learning_method_read_only": "Analyze device data (recommended)",
18+
"cloud_learning_method_read_only": "Analyze device data (diagnostics)",
1919
"cloud_learning_method_active": "Verify additional local controls (advanced)",
2020
"cloud_learning_source_intro": "Choose the compatible cloud service Home Assistant should use for this check.",
2121
"cloud_learning_source_smartess": "SmartESS API",
@@ -76,6 +76,11 @@
7676
"control_discovery_result_action_done": "Return to the menu",
7777
"control_discovery_overview_intro": "Found {control_discovery_count} supported items: {control_discovery_new_count} new controls, {control_discovery_existing_count} existing controls, {control_discovery_new_read_count} new sensors, and {control_discovery_existing_read_count} existing sensors. Another {control_discovery_inconclusive_read_count} cloud fields were visible but could not be linked safely in this run.\n\n{control_discovery_overview}",
7878
"control_discovery_overview_new_heading": "New controls found ({count})",
79+
"control_learning_unproven_heading": "Controls not added in this run",
80+
"control_learning_unproven_samples": "Not enough distinct commands were captured to confirm value encoding.",
81+
"control_learning_unproven_capture": "The cloud command was rejected or no matching local command was captured.",
82+
"control_learning_unproven_metadata": "The cloud did not provide usable setting metadata.",
83+
"control_learning_unproven_encoding": "The captured command format or value encoding could not be confirmed.",
7984
"control_discovery_overview_existing_heading": "Already in Home Assistant ({count})",
8085
"control_discovery_overview_new_reads_heading": "New sensors found ({count})",
8186
"control_discovery_overview_existing_reads_heading": "Sensors already in Home Assistant ({count})",

‎custom_components/eybond_local/flow_translations/ru.json‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
"none": "Нет",
1616
"not_run_yet": "Ещё не запускалось",
1717
"cloud_learning_method_intro": "Выберите, что должен узнать Home Assistant. Анализ только для чтения не изменяет устройство. Активная проверка может найти дополнительные локальные элементы управления.",
18-
"cloud_learning_method_read_only": "Анализировать данные устройства (рекомендуется)",
18+
"cloud_learning_method_read_only": "Анализировать данные устройства (диагностика)",
1919
"cloud_learning_method_active": "Проверить дополнительные локальные элементы управления (расширенный режим)",
2020
"cloud_learning_source_intro": "Выберите совместимый облачный сервис для этой проверки.",
2121
"cloud_learning_source_smartess": "SmartESS API",
@@ -76,6 +76,11 @@
7676
"control_discovery_result_action_done": "Вернуться в меню",
7777
"control_discovery_overview_intro": "Найдено поддерживаемых возможностей: {control_discovery_count}. Новых элементов управления: {control_discovery_new_count}, уже доступных: {control_discovery_existing_count}; новых датчиков: {control_discovery_new_read_count}, уже доступных: {control_discovery_existing_read_count}. Ещё {control_discovery_inconclusive_read_count} облачных полей были видны, но в этом запуске их нельзя было безопасно связать с регистрами.\n\n{control_discovery_overview}",
7878
"control_discovery_overview_new_heading": "Найдены новые элементы ({count})",
79+
"control_learning_unproven_heading": "Управление не добавлено в этом запуске",
80+
"control_learning_unproven_samples": "Недостаточно разных перехваченных команд для подтверждения кодирования значения.",
81+
"control_learning_unproven_capture": "Облако отклонило команду или соответствующая локальная команда не была перехвачена.",
82+
"control_learning_unproven_metadata": "Облако не предоставило пригодное описание настройки.",
83+
"control_learning_unproven_encoding": "Не удалось подтвердить формат команды или кодирование значения.",
7984
"control_discovery_overview_existing_heading": "Уже в Home Assistant ({count})",
8085
"control_discovery_overview_new_reads_heading": "Найдены новые датчики ({count})",
8186
"control_discovery_overview_existing_reads_heading": "Датчики уже в Home Assistant ({count})",

‎custom_components/eybond_local/flow_translations/uk.json‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
"none": "Немає",
1616
"not_run_yet": "Ще не запускалося",
1717
"cloud_learning_method_intro": "Виберіть, що має дізнатися Home Assistant. Аналіз лише для читання не змінює пристрій. Активна перевірка може знайти додаткові локальні елементи керування.",
18-
"cloud_learning_method_read_only": "Аналізувати дані пристрою (рекомендовано)",
18+
"cloud_learning_method_read_only": "Аналізувати дані пристрою (діагностика)",
1919
"cloud_learning_method_active": "Перевірити додаткові локальні елементи керування (розширений режим)",
2020
"cloud_learning_source_intro": "Виберіть сумісний хмарний сервіс для цієї перевірки.",
2121
"cloud_learning_source_smartess": "SmartESS API",
@@ -76,6 +76,11 @@
7676
"control_discovery_result_action_done": "Повернутися до меню",
7777
"control_discovery_overview_intro": "Знайдено підтримуваних можливостей: {control_discovery_count}. Нових елементів керування: {control_discovery_new_count}, уже доступних: {control_discovery_existing_count}; нових сенсорів: {control_discovery_new_read_count}, уже доступних: {control_discovery_existing_read_count}. Ще {control_discovery_inconclusive_read_count} хмарних полів були видимі, але цього разу їх не вдалося безпечно пов’язати з регістрами.\n\n{control_discovery_overview}",
7878
"control_discovery_overview_new_heading": "Знайдено нові елементи ({count})",
79+
"control_learning_unproven_heading": "Керування не додано в цьому запуску",
80+
"control_learning_unproven_samples": "Недостатньо різних перехоплених команд для підтвердження кодування значення.",
81+
"control_learning_unproven_capture": "Хмара відхилила команду або відповідну локальну команду не було перехоплено.",
82+
"control_learning_unproven_metadata": "Хмара не надала придатний опис налаштування.",
83+
"control_learning_unproven_encoding": "Не вдалося підтвердити формат команди або кодування значення.",
7984
"control_discovery_overview_existing_heading": "Уже в Home Assistant ({count})",
8085
"control_discovery_overview_new_reads_heading": "Знайдено нові сенсори ({count})",
8186
"control_discovery_overview_existing_reads_heading": "Сенсори вже в Home Assistant ({count})",

‎custom_components/eybond_local/flows/options/shadow_review.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
from ...support.shadow_learning.review_model import (
2222
build_activation_selection,
2323
default_learned_control_label,
24+
unpromoted_control_rows,
25+
unpromoted_controls_markdown,
2426
)
2527
from ...support.cloud_local_coverage import (
2628
CLOUD_LOCAL_STATUS_AVAILABLE_CARRIED,
@@ -121,6 +123,7 @@ async def async_step_shadow_learning_review(
121123
already_reads = self._control_discovery_already_supported_read_sensors()
122124
inconclusive_reads = self._control_discovery_inconclusive_read_sensors()
123125
metadata_fields = self._control_discovery_metadata_fields()
126+
unpromoted = unpromoted_control_rows(self._shadow_learning_state)
124127

125128
if (
126129
metadata_fields
@@ -129,6 +132,7 @@ async def async_step_shadow_learning_review(
129132
and not new_reads
130133
and not already_reads
131134
and not inconclusive_reads
135+
and not unpromoted
132136
):
133137
learning_engine = self._control_discovery_learning_engine(coordinator)
134138
collection_supported = bool(
@@ -237,6 +241,7 @@ async def async_step_shadow_learning_review(
237241
and not new_reads
238242
and not already_reads
239243
and not inconclusive_reads
244+
and not unpromoted
240245
):
241246
return await self.async_step_shadow_learning_result()
242247

@@ -279,7 +284,8 @@ async def async_step_shadow_learning_review(
279284
new_reads,
280285
already_reads,
281286
inconclusive_reads,
282-
),
287+
) + ("\n\n" + unpromoted_controls_markdown(unpromoted, self._tr)
288+
if unpromoted else ""),
283289
}
284290
return self.async_show_form(
285291
step_id="shadow_learning_review",

‎custom_components/eybond_local/flows/options/shadow_run.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -320,7 +320,7 @@ def _control_discovery_method_option_label(self, method_id: str) -> str:
320320
labels = {
321321
LEARNING_METHOD_READ_ONLY_EVIDENCE: self._tr(
322322
"common.dynamic.cloud_learning_method_read_only",
323-
"Analyze device data (recommended)",
323+
"Analyze device data (diagnostics)",
324324
),
325325
LEARNING_METHOD_ACTIVE_CORRELATION: self._tr(
326326
"common.dynamic.cloud_learning_method_active",

‎custom_components/eybond_local/smartess_cloud.py‎

Lines changed: 11 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from urllib.parse import quote
1414
from urllib.request import Request, urlopen
1515

16+
from .cloud_numeric_learning import numeric_learning_candidates
1617
from .cloud_signing import password_signature, session_signature
1718
from .metadata.smartess_semantic_catalog_loader import (
1819
resolve_smartess_cloud_classification,
@@ -35,9 +36,6 @@
3536
logger = logging.getLogger(__name__)
3637

3738

38-
_HINT_RANGE_RE = re.compile(r"(-?\d+(?:\.\d+)?)\s*(?:~|-|to)\s*(-?\d+(?:\.\d+)?)", re.IGNORECASE)
39-
40-
4139
_SMARTESS_0925_EXACT = "exact_0925"
4240
_SMARTESS_0925_PROBABLE = "probable_0925"
4341
_SMARTESS_CLOUD_ONLY = "cloud_only"
@@ -662,7 +660,7 @@ def build_learn_settings_plan(
662660
numeric_value: str = DEFAULT_LEARN_NUMERIC_VALUE,
663661
all_choice_values: bool = False,
664662
max_fields: int = 0,
665-
) -> list[dict[str, str]]:
663+
) -> list[dict[str, Any]]:
666664
"""Return deterministic plan items for SmartESS learn-settings orchestration."""
667665

668666
if not isinstance(dat, dict):
@@ -674,7 +672,7 @@ def build_learn_settings_plan(
674672
if not isinstance(fields, list):
675673
return []
676674

677-
plan: list[dict[str, str]] = []
675+
plan: list[dict[str, Any]] = []
678676
visited_fields = 0
679677
for raw_field in fields:
680678
if not isinstance(raw_field, dict):
@@ -706,6 +704,8 @@ def build_learn_settings_plan(
706704
"value": candidate["value"],
707705
"value_label": candidate.get("label", ""),
708706
"value_source": candidate["source"],
707+
**({"numeric_evidence": candidate["numeric_evidence"]}
708+
if "numeric_evidence" in candidate else {}),
709709
}
710710
)
711711
return plan
@@ -717,7 +717,7 @@ def _candidate_learn_setting_values(
717717
include_numeric: bool,
718718
numeric_value: str,
719719
all_choice_values: bool,
720-
) -> list[dict[str, str]]:
720+
) -> list[dict[str, Any]]:
721721
choices = field.get("item")
722722
if isinstance(choices, list) and choices:
723723
candidates: list[dict[str, str]] = []
@@ -740,21 +740,11 @@ def _candidate_learn_setting_values(
740740

741741
if not include_numeric:
742742
return []
743-
hint_value = _first_numeric_hint_value(str(field.get("hint") or ""))
744-
return [
745-
{
746-
"value": hint_value or str(numeric_value),
747-
"label": "",
748-
"source": "hint_min" if hint_value else "numeric_default",
749-
}
750-
]
751-
752-
753-
def _first_numeric_hint_value(hint: str) -> str:
754-
match = _HINT_RANGE_RE.search(hint)
755-
if not match:
756-
return ""
757-
return match.group(1)
743+
current, _ = _extract_current_value(field)
744+
return numeric_learning_candidates(
745+
hint=field.get("hint", ""), unit=field.get("unit", ""),
746+
current=current, fallback=numeric_value,
747+
)
758748

759749

760750
def _resolve_smartess_0925_setting_classification(normalized_title: str) -> dict[str, Any]:

0 commit comments

Comments
 (0)