Skip to content

Commit 089f499

Browse files
committed
Support local binary sensors in mapping
1 parent 5929453 commit 089f499

File tree

6 files changed

+157
-32
lines changed

6 files changed

+157
-32
lines changed

custom_components/oig_cloud/data_source.py

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -170,19 +170,22 @@ def _get_latest_local_entity_update(
170170
"""Return the most recent update timestamp among local telemetry entities for a box."""
171171
if not (isinstance(box_id, str) and box_id.isdigit()):
172172
return None
173-
prefix = f"sensor.oig_local_{box_id}_"
174173
try:
175174
latest: Optional[dt_util.dt.datetime] = None
176-
for st in hass.states.async_all("sensor"):
177-
if not st.entity_id.startswith(prefix):
178-
continue
179-
if st.state in (None, "", "unknown", "unavailable"):
180-
continue
181-
dt = st.last_updated or st.last_changed
182-
if dt is None:
183-
continue
184-
dt_utc = dt_util.as_utc(dt) if dt.tzinfo else dt.replace(tzinfo=dt_util.UTC)
185-
latest = dt_utc if latest is None else max(latest, dt_utc)
175+
for domain in ("sensor", "binary_sensor"):
176+
prefix = f"{domain}.oig_local_{box_id}_"
177+
for st in hass.states.async_all(domain):
178+
if not st.entity_id.startswith(prefix):
179+
continue
180+
if st.state in (None, "", "unknown", "unavailable"):
181+
continue
182+
dt = st.last_updated or st.last_changed
183+
if dt is None:
184+
continue
185+
dt_utc = (
186+
dt_util.as_utc(dt) if dt.tzinfo else dt.replace(tzinfo=dt_util.UTC)
187+
)
188+
latest = dt_utc if latest is None else max(latest, dt_utc)
186189
return latest
187190
except Exception:
188191
return None
@@ -282,7 +285,7 @@ def init_data_source_state(hass: HomeAssistant, entry: ConfigEntry) -> DataSourc
282285
class DataSourceController:
283286
"""Controls effective data source mode based on local proxy health."""
284287

285-
_LOCAL_ENTITY_RE = re.compile(r"^sensor\.oig_local_(\d+)_")
288+
_LOCAL_ENTITY_RE = re.compile(r"^(?:sensor|binary_sensor)\.oig_local_(\d+)_")
286289

287290
def __init__(
288291
self,
@@ -399,7 +402,10 @@ def _on_any_state_change(self, event: Any) -> None:
399402
entity_id = event.data.get("entity_id")
400403
if not isinstance(entity_id, str):
401404
return
402-
if not entity_id.startswith("sensor.oig_local_"):
405+
if not (
406+
entity_id.startswith("sensor.oig_local_")
407+
or entity_id.startswith("binary_sensor.oig_local_")
408+
):
403409
return
404410

405411
# Ensure the local update belongs to this entry's box_id (prevents cross-device wiring).

custom_components/oig_cloud/local_mapper.py

Lines changed: 95 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,46 @@ def _normalize_box_mode(value: Any) -> Optional[int]:
7272
return None
7373

7474

75+
def _normalize_domains(value: Any) -> Tuple[str, ...]:
76+
if isinstance(value, str):
77+
raw = [value]
78+
elif isinstance(value, (list, tuple, set)):
79+
raw = list(value)
80+
else:
81+
raw = []
82+
83+
domains: List[str] = []
84+
for item in raw:
85+
if not isinstance(item, str):
86+
continue
87+
domain = item.strip()
88+
if domain in {"sensor", "binary_sensor"} and domain not in domains:
89+
domains.append(domain)
90+
91+
if not domains:
92+
domains = ["sensor"]
93+
return tuple(domains)
94+
95+
96+
def _normalize_value_map(value: Any) -> Optional[Dict[str, Any]]:
97+
if not isinstance(value, dict):
98+
return None
99+
out: Dict[str, Any] = {}
100+
for key, mapped in value.items():
101+
if not isinstance(key, str):
102+
continue
103+
out[key.strip().lower()] = mapped
104+
return out or None
105+
106+
107+
def _apply_value_map(value: Any, value_map: Optional[Dict[str, Any]]) -> Any:
108+
if isinstance(value, str) and value_map:
109+
key = value.strip().lower()
110+
if key in value_map:
111+
return value_map[key]
112+
return _coerce_number(value)
113+
114+
75115
# Extended "values" layout used by OigCloudDataSensor._get_extended_value()
76116
_EXTENDED_INDEX_BY_SENSOR_TYPE: Dict[str, Tuple[str, int]] = {
77117
# battery -> extended_batt
@@ -118,14 +158,40 @@ class _ExtendedUpdate:
118158
LocalUpdate = _NodeUpdate | _ExtendedUpdate
119159

120160

121-
def _build_suffix_updates() -> Dict[str, List[LocalUpdate]]:
122-
out: Dict[str, List[LocalUpdate]] = {}
161+
@dataclass(frozen=True, slots=True)
162+
class _SuffixConfig:
163+
updates: Tuple[LocalUpdate, ...]
164+
domains: Tuple[str, ...]
165+
value_map: Optional[Dict[str, Any]]
166+
167+
168+
def _build_suffix_updates() -> Dict[str, _SuffixConfig]:
169+
raw: Dict[str, Dict[str, Any]] = {}
123170
for sensor_type, cfg in SENSOR_TYPES.items():
124171
suffix = cfg.get("local_entity_suffix")
125172
if not isinstance(suffix, str) or not suffix:
126173
continue
127-
128-
updates: List[LocalUpdate] = out.setdefault(suffix, [])
174+
entry = raw.setdefault(
175+
suffix,
176+
{
177+
"updates": [],
178+
"domains": [],
179+
"value_map": None,
180+
},
181+
)
182+
183+
domains = _normalize_domains(cfg.get("local_entity_domains"))
184+
for domain in domains:
185+
if domain not in entry["domains"]:
186+
entry["domains"].append(domain)
187+
188+
value_map = _normalize_value_map(cfg.get("local_value_map"))
189+
if value_map:
190+
if entry["value_map"] is None:
191+
entry["value_map"] = {}
192+
entry["value_map"].update(value_map)
193+
194+
updates: List[LocalUpdate] = entry["updates"]
129195

130196
node_id = cfg.get("node_id")
131197
node_key = cfg.get("node_key")
@@ -142,10 +208,18 @@ def _build_suffix_updates() -> Dict[str, List[LocalUpdate]]:
142208
group, index = ext
143209
updates.append(_ExtendedUpdate(group=group, index=index))
144210

211+
out: Dict[str, _SuffixConfig] = {}
212+
for suffix, entry in raw.items():
213+
domains = tuple(entry["domains"]) if entry["domains"] else ("sensor",)
214+
out[suffix] = _SuffixConfig(
215+
updates=tuple(entry["updates"]),
216+
domains=domains,
217+
value_map=entry["value_map"],
218+
)
145219
return out
146220

147221

148-
_SUFFIX_UPDATES: Dict[str, List[LocalUpdate]] = _build_suffix_updates()
222+
_SUFFIX_UPDATES: Dict[str, _SuffixConfig] = _build_suffix_updates()
149223

150224

151225
class LocalUpdateApplier:
@@ -162,15 +236,24 @@ def apply_state(
162236
last_updated: Optional[datetime],
163237
) -> bool:
164238
"""Return True if payload changed."""
165-
prefix = f"sensor.oig_local_{self.box_id}_"
166-
if not (isinstance(entity_id, str) and entity_id.startswith(prefix)):
239+
if not isinstance(entity_id, str):
167240
return False
168-
suffix = entity_id[len(prefix) :]
169-
updates = _SUFFIX_UPDATES.get(suffix)
170-
if not updates:
241+
domain = None
242+
suffix = None
243+
for candidate_domain in ("sensor", "binary_sensor"):
244+
prefix = f"{candidate_domain}.oig_local_{self.box_id}_"
245+
if entity_id.startswith(prefix):
246+
domain = candidate_domain
247+
suffix = entity_id[len(prefix) :]
248+
break
249+
if domain is None or suffix is None:
250+
return False
251+
252+
suffix_cfg = _SUFFIX_UPDATES.get(suffix)
253+
if not suffix_cfg or domain not in suffix_cfg.domains:
171254
return False
172255

173-
value = _coerce_number(state)
256+
value = _apply_value_map(state, suffix_cfg.value_map)
174257
if value is None:
175258
return False
176259

@@ -183,7 +266,7 @@ def apply_state(
183266
payload[self.box_id] = {}
184267
box = payload[self.box_id]
185268

186-
for upd in updates:
269+
for upd in suffix_cfg.updates:
187270
if isinstance(upd, _NodeUpdate):
188271
node = box.setdefault(upd.node_id, {})
189272
if not isinstance(node, dict):

custom_components/oig_cloud/oig_cloud_data_sensor.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -551,7 +551,20 @@ def _get_local_entity_id_for_config(
551551

552552
suffix = sensor_config.get("local_entity_suffix")
553553
if suffix and self._box_id and self._box_id != "unknown":
554-
return f"sensor.oig_local_{self._box_id}_{suffix}"
554+
domains = sensor_config.get("local_entity_domains")
555+
if isinstance(domains, str):
556+
domain_list = [domains]
557+
elif isinstance(domains, (list, tuple, set)):
558+
domain_list = [d for d in domains if isinstance(d, str)]
559+
else:
560+
domain_list = ["sensor"]
561+
if not domain_list:
562+
domain_list = ["sensor"]
563+
for domain in domain_list:
564+
candidate = f"{domain}.oig_local_{self._box_id}_{suffix}"
565+
if self.hass.states.get(candidate):
566+
return candidate
567+
return f"{domain_list[0]}.oig_local_{self._box_id}_{suffix}"
555568
return None
556569

557570
def _coerce_number(self, value: Any) -> Any:
@@ -562,6 +575,16 @@ def _coerce_number(self, value: Any) -> Any:
562575
except ValueError:
563576
return value
564577

578+
def _apply_local_value_map(self, value: Any, sensor_config: Dict[str, Any]) -> Any:
579+
if value is None:
580+
return None
581+
value_map = sensor_config.get("local_value_map")
582+
if isinstance(value, str) and isinstance(value_map, dict):
583+
key = value.strip().lower()
584+
if key in value_map:
585+
return value_map[key]
586+
return self._coerce_number(value)
587+
565588
def _fallback_value(self) -> Optional[Any]:
566589
if self._last_state is not None:
567590
return self._last_state
@@ -579,7 +602,7 @@ def _get_local_value(self) -> Optional[Any]:
579602
st = self.hass.states.get(local_entity_id)
580603
if not st or st.state in (None, "unknown", "unavailable"):
581604
return None
582-
return self._coerce_number(st.state)
605+
return self._apply_local_value_map(st.state, self._sensor_config)
583606

584607
def _get_local_value_for_sensor_type(self, sensor_type: str) -> Optional[Any]:
585608
try:
@@ -594,7 +617,7 @@ def _get_local_value_for_sensor_type(self, sensor_type: str) -> Optional[Any]:
594617
st = self.hass.states.get(local_entity_id)
595618
if not st or st.state in (None, "unknown", "unavailable"):
596619
return None
597-
return self._coerce_number(st.state)
620+
return self._apply_local_value_map(st.state, cfg)
598621
except Exception:
599622
return None
600623

custom_components/oig_cloud/sensors/SENSOR_TYPES_BOILER.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@
5050
"unit_of_measurement": None,
5151
"node_id": "boiler_prms",
5252
"node_key": "manual",
53+
"local_entity_domains": ["sensor", "binary_sensor"],
54+
"local_value_map": {"on": 1, "off": 0},
5355
"local_entity_suffix": "tbl_boiler_prms_manual",
5456
"entity_category": EntityCategory.DIAGNOSTIC,
5557
"state_class": None,
@@ -64,6 +66,8 @@
6466
"unit_of_measurement": None,
6567
"node_id": "boiler_prms",
6668
"node_key": "ssr0",
69+
"local_entity_domains": ["sensor", "binary_sensor"],
70+
"local_value_map": {"on": 1, "off": 0},
6771
"local_entity_suffix": "tbl_boiler_prms_ssr0",
6872
"entity_category": EntityCategory.DIAGNOSTIC,
6973
"state_class": None,
@@ -78,6 +82,8 @@
7882
"unit_of_measurement": None,
7983
"node_id": "boiler_prms",
8084
"node_key": "ssr1",
85+
"local_entity_domains": ["sensor", "binary_sensor"],
86+
"local_value_map": {"on": 1, "off": 0},
8187
"local_entity_suffix": "tbl_boiler_prms_ssr1",
8288
"entity_category": EntityCategory.DIAGNOSTIC,
8389
"state_class": None,
@@ -92,6 +98,8 @@
9298
"unit_of_measurement": None,
9399
"node_id": "boiler_prms",
94100
"node_key": "ssr2",
101+
"local_entity_domains": ["sensor", "binary_sensor"],
102+
"local_value_map": {"on": 1, "off": 0},
95103
"local_entity_suffix": "tbl_boiler_prms_ssr2",
96104
"entity_category": EntityCategory.DIAGNOSTIC,
97105
"state_class": None,
@@ -106,6 +114,8 @@
106114
"unit_of_measurement": None,
107115
"node_id": "boiler_prms",
108116
"node_key": "ison",
117+
"local_entity_domains": ["sensor", "binary_sensor"],
118+
"local_value_map": {"on": 1, "off": 0},
109119
"local_entity_suffix": "tbl_boiler_prms_ison",
110120
"entity_category": EntityCategory.DIAGNOSTIC,
111121
"state_class": None,

custom_components/oig_cloud/sensors/SENSOR_TYPES_MISC.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@
5353
"options": ["Vypnuto / Off", "Zapnuto / On", "S omezením / Limited"],
5454
"sensor_type_category": "data",
5555
"device_mapping": "main",
56+
"local_entity_domains": ["sensor", "binary_sensor"],
57+
"local_value_map": {"on": 1, "off": 0},
5658
"local_entity_suffix": "tbl_invertor_prms_to_grid",
5759
},
5860
"installed_battery_capacity_kwh": {

custom_components/oig_cloud/telemetry_store.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,12 @@ def apply_local_events(self, entity_ids: Iterable[str]) -> bool:
7777

7878
def seed_from_existing_local_states(self) -> bool:
7979
"""Seed payload from all currently-known local entity states for this box."""
80-
prefix = f"sensor.oig_local_{self.box_id}_"
8180
entity_ids = []
82-
for st in self.hass.states.async_all("sensor"):
83-
if st.entity_id.startswith(prefix):
84-
entity_ids.append(st.entity_id)
81+
for domain in ("sensor", "binary_sensor"):
82+
prefix = f"{domain}.oig_local_{self.box_id}_"
83+
for st in self.hass.states.async_all(domain):
84+
if st.entity_id.startswith(prefix):
85+
entity_ids.append(st.entity_id)
8586
return self.apply_local_events(entity_ids)
8687

8788
def get_snapshot(self) -> TelemetrySnapshot:

0 commit comments

Comments
 (0)