Skip to content

Commit 20164b9

Browse files
committed
update blocking calls and FW comparitor
1 parent b36f6bf commit 20164b9

13 files changed

Lines changed: 399 additions & 31 deletions

File tree

custom_components/monitormysolar/config_flow.py

Lines changed: 73 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,32 @@
1010

1111
_LOGGER = logging.getLogger(__name__)
1212

13+
14+
def _raw_entity_ids_in_use(hass, exclude_entry_id=None) -> bool:
15+
"""Whether an existing entry already owns un-prefixed (raw) entity_ids.
16+
17+
Raw entity_ids (sensor.soc instead of sensor.<dongle>_soc) are a singleton
18+
across the whole HA instance: a second entry using them would compute the
19+
same entity_ids, the registry would dedupe its entities to _2 suffixes, and
20+
those entities would never receive data. Mirrors the effective-prefix rules
21+
in coordinator.get_entity_prefix (explicit entity_prefix wins; legacy
22+
entries fall back to the drop_dongle_id flag).
23+
"""
24+
for entry in hass.config_entries.async_entries(DOMAIN):
25+
if entry.entry_id == exclude_entry_id:
26+
continue
27+
dongle_data = entry.data.get("dongle_data") or []
28+
for dongle in dongle_data:
29+
if "entity_prefix" in dongle:
30+
if not (dongle.get("entity_prefix") or "").strip():
31+
return True
32+
elif entry.data.get(CONF_DROP_DONGLE_ID) and len(dongle_data) == 1:
33+
return True
34+
if not dongle_data and entry.data.get(CONF_DROP_DONGLE_ID):
35+
return True
36+
return False
37+
38+
1339
class InverterMQTTFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
1440
"""Handle a config flow for Inverter MQTT."""
1541

@@ -110,17 +136,30 @@ async def async_step_single_inverter(self, user_input=None):
110136
# user can drop the dongle id later via the options flow, which
111137
# performs a proper history-preserving registry rename.
112138
install_kind = user_input.pop("install_kind", "fresh")
113-
user_input[CONF_DROP_DONGLE_ID] = install_kind == "fresh"
114139

115140
# Merge with initial data
116141
data = {**self.initial_data, **user_input}
117142

118-
# Create dongle data structure for single inverter.
119-
# Single-dongle installs get NO entity_id prefix (clean names like
120-
# sensor.battery_soc) — we never ask. entity_prefix stays empty.
143+
# Entity naming. Raw (un-prefixed) entity_ids are a singleton across
144+
# the HA instance: if another entry already owns them, this entry's
145+
# entities would be _2-deduped by the registry and never get data —
146+
# so a second raw install is forced onto the dongle-id prefix.
147+
entity_prefix = ""
148+
if install_kind == "reconnect":
149+
entity_prefix = data["dongle_id"]
150+
elif _raw_entity_ids_in_use(self.hass):
151+
entity_prefix = data["dongle_id"]
152+
_LOGGER.warning(
153+
"Raw entity_ids already owned by another entry; forcing entity "
154+
"prefix %r for dongle %s", entity_prefix, data["dongle_id"],
155+
)
156+
# Keep the legacy flag consistent with the prefix actually in use
157+
# (it drives the entity-registry rename migration).
158+
data[CONF_DROP_DONGLE_ID] = entity_prefix == ""
159+
121160
dongle_data = [{
122161
"dongle_id": data["dongle_id"],
123-
"entity_prefix": "",
162+
"entity_prefix": entity_prefix,
124163
"is_master": True,
125164
"is_slave": False,
126165
"is_gridboss": False,
@@ -879,6 +918,7 @@ async def async_step_replace_dongle(self, user_input=None):
879918

880919
async def async_step_update_settings(self, user_input=None):
881920
"""Update general settings."""
921+
errors = {}
882922
if user_input is not None:
883923
new_data = dict(self.config_entry.data)
884924

@@ -902,20 +942,39 @@ async def async_step_update_settings(self, user_input=None):
902942
# Changing this triggers a history-preserving entity-registry rename on
903943
# reload (see migration.async_migrate_entity_ids).
904944
if CONF_DROP_DONGLE_ID in user_input:
905-
new_data[CONF_DROP_DONGLE_ID] = user_input[CONF_DROP_DONGLE_ID]
945+
want_raw = user_input[CONF_DROP_DONGLE_ID]
946+
# Raw entity_ids are a singleton across entries — refuse to
947+
# switch to them while another entry owns them (the registry
948+
# would _2-dedupe this entry's entities and they'd get no data).
949+
if want_raw and _raw_entity_ids_in_use(
950+
self.hass, exclude_entry_id=self.config_entry.entry_id
951+
):
952+
errors["base"] = "raw_ids_taken"
953+
else:
954+
new_data[CONF_DROP_DONGLE_ID] = want_raw
955+
# dongle_data.entity_prefix is the runtime source of truth
956+
# (it wins over the legacy flag) — keep it in sync so the
957+
# toggle takes effect on new-style entries too.
958+
dongle_data = [dict(d) for d in new_data.get("dongle_data", [])]
959+
if len(dongle_data) == 1 and "entity_prefix" in dongle_data[0]:
960+
dongle_data[0]["entity_prefix"] = (
961+
"" if want_raw else dongle_data[0]["dongle_id"]
962+
)
963+
new_data["dongle_data"] = dongle_data
906964

907965
# Update firmware track (prod/beta) if provided.
908966
if CONF_USE_BETA in user_input:
909967
new_data[CONF_USE_BETA] = user_input[CONF_USE_BETA]
910968

911-
self.hass.config_entries.async_update_entry(
912-
self.config_entry, data=new_data
913-
)
969+
if not errors:
970+
self.hass.config_entries.async_update_entry(
971+
self.config_entry, data=new_data
972+
)
914973

915-
# Reload the integration so device grouping / naming changes take effect
916-
await self.hass.config_entries.async_reload(self.config_entry.entry_id)
974+
# Reload the integration so device grouping / naming changes take effect
975+
await self.hass.config_entries.async_reload(self.config_entry.entry_id)
917976

918-
return self.async_create_entry(title="", data={})
977+
return self.async_create_entry(title="", data={})
919978

920979
current_update_interval = self.config_entry.data.get("update_interval", 60)
921980
current_has_gridboss = self.config_entry.data.get("has_gridboss", False)
@@ -951,7 +1010,8 @@ async def async_step_update_settings(self, user_input=None):
9511010

9521011
return self.async_show_form(
9531012
step_id="update_settings",
954-
data_schema=schema
1013+
data_schema=schema,
1014+
errors=errors,
9551015
)
9561016

9571017
async def async_step_check_status(self, user_input=None):

custom_components/monitormysolar/const.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,45 @@ def firmware_group(code: str) -> str:
106106
return "legacy"
107107

108108

109+
def version_tuple(version):
110+
"""Parse a dotted version into an int tuple ('4.3.2.2C6' -> (4, 3, 2, 2)).
111+
112+
Trailing non-digits per segment (chip suffixes like C6/S3) are stripped.
113+
Returns None if any segment has no leading digits or the string is empty.
114+
"""
115+
if not version:
116+
return None
117+
nums = []
118+
for part in str(version).strip().split("."):
119+
digits = ""
120+
for ch in part:
121+
if ch.isdigit():
122+
digits += ch
123+
else:
124+
break
125+
if not digits:
126+
return None
127+
nums.append(int(digits))
128+
return tuple(nums) or None
129+
130+
131+
def fw_version_is_newer(latest: str, installed: str) -> bool:
132+
"""Whether `latest` is strictly newer than `installed` (numeric compare).
133+
134+
Shorter tuples are zero-padded, so 4.3.2 == 4.3.2.0. Unparseable versions
135+
fall back to plain inequality (any difference counts as an update) so a
136+
malformed server version can hide behind equality but never crash.
137+
"""
138+
latest_t = version_tuple(latest)
139+
installed_t = version_tuple(installed)
140+
if latest_t is None or installed_t is None:
141+
return latest != installed
142+
width = max(len(latest_t), len(installed_t))
143+
return (latest_t + (0,) * (width - len(latest_t))) > (
144+
installed_t + (0,) * (width - len(installed_t))
145+
)
146+
147+
109148
def fw_code_get(mapping: dict, code: str, default=None):
110149
"""Case-insensitive lookup of a firmware code in a code-keyed table.
111150

custom_components/monitormysolar/coordinator.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,12 @@ def __init__(
102102
self._gridboss_dongle = entry.data.get("gridboss_dongle", "") # Track which dongle is GridBoss
103103
self._last_fault_warning_data = {} # Track last fault/warning data to prevent duplicate processing
104104
self._hass_startup_complete = False # Track if Home Assistant has finished starting up
105+
# Failsafe for the startup gate: if the 30s mark_startup_complete timer
106+
# never fires, force the gate open at this monotonic deadline instead of
107+
# silently dropping data messages forever. 60s > the 30s timer so the
108+
# timer always wins when it works.
109+
self._gate_opened_deadline = time.monotonic() + 60.0
110+
self._startup_dropped_count = 0 # Data messages dropped while gated (diagnostics)
105111
self._smart_soc_volt_bits = {} # Track SmartSOCVoltBits for each dongle
106112
self._smartload_bits = {} # Track SmartLoad Bits for each dongle
107113
self._port_modes = {} # Track Port Mode settings for each dongle
@@ -1281,8 +1287,24 @@ async def _async_handle_mqtt_message(self, msg) -> None:
12811287
self.async_set_updated_data(self.entities)
12821288
# Skip other message processing during startup to prevent excessive updates
12831289
elif not self._hass_startup_complete:
1284-
# Just store the message for later processing if needed
1285-
pass
1290+
# Failsafe: the gate is opened by a 30s timer in async_setup. If
1291+
# that timer ever fails to fire, every data message would be
1292+
# silently dropped forever (entities populate once from the
1293+
# snapshot, then flatline). Never trust the timer alone: force
1294+
# the gate open once we're clearly past the startup window.
1295+
# getattr: test coordinators are built via __new__ and skip __init__.
1296+
deadline = getattr(self, "_gate_opened_deadline", None)
1297+
if deadline is not None and time.monotonic() >= deadline:
1298+
LOGGER.warning(
1299+
"Startup gate still closed past its deadline (%d data "
1300+
"messages dropped so far) - forcing it open",
1301+
getattr(self, "_startup_dropped_count", 0),
1302+
)
1303+
self._hass_startup_complete = True
1304+
await self.process_message(dongle_id, topic, msg.payload)
1305+
self.async_set_updated_data(self.entities)
1306+
else:
1307+
self._startup_dropped_count = getattr(self, "_startup_dropped_count", 0) + 1
12861308
else:
12871309
# Process messages normally after startup is complete
12881310
if topic.endswith("/response"):
@@ -1542,7 +1564,11 @@ async def async_setup(self):
15421564
# Schedule startup completion after a delay to allow Home Assistant to finish starting up
15431565
async def mark_startup_complete(_):
15441566
self._hass_startup_complete = True
1545-
LOGGER.info("Home Assistant startup complete - MQTT message processing enabled")
1567+
LOGGER.info(
1568+
"Home Assistant startup complete - MQTT message processing enabled "
1569+
"(%d data messages were dropped during the startup window)",
1570+
getattr(self, "_startup_dropped_count", 0),
1571+
)
15461572

15471573
# Trigger entity availability updates for all dongles now that startup is complete
15481574
for dongle_id in self._dongle_ids:

custom_components/monitormysolar/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,6 @@
1010
"issue_tracker": "https://github.com/Monitor-My-Solar/monitormysolar",
1111
"loggers": [],
1212
"requirements": [],
13-
"version": "4.0.2"
13+
"version": "4.0.3.1"
1414
}
1515

custom_components/monitormysolar/migration.py

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
from __future__ import annotations
1313

1414
import logging
15-
from logging.handlers import RotatingFileHandler
15+
import queue
16+
from logging.handlers import QueueHandler, QueueListener, RotatingFileHandler
1617

1718
from homeassistant.core import HomeAssistant
1819
from homeassistant.helpers import entity_registry as er
@@ -29,29 +30,40 @@
2930
# audited after the fact — which the main HA log couldn't give us.
3031
_AUDIT_LOGGER_NAME = "monitormysolar.migration_audit"
3132
_audit_logger: logging.Logger | None = None
33+
_audit_listener: QueueListener | None = None # keep a ref so it isn't GC'd
3234

3335

3436
def _get_audit_logger(hass: HomeAssistant) -> logging.Logger:
35-
"""Lazily build a file-backed audit logger in the HA config directory."""
36-
global _audit_logger
37+
"""Lazily build a file-backed audit logger in the HA config directory.
38+
39+
audit() is called from the event loop, so no file I/O may happen there:
40+
the logger emits into a queue (non-blocking) and a QueueListener thread
41+
does the actual file writes. delay=True on the file handler defers even
42+
the open() to the first write — which runs on the listener thread.
43+
"""
44+
global _audit_logger, _audit_listener
3745
if _audit_logger is not None:
3846
return _audit_logger
3947

4048
logger = logging.getLogger(_AUDIT_LOGGER_NAME)
4149
logger.setLevel(logging.INFO)
4250
logger.propagate = False # don't spam the main HA log
4351

44-
# Only attach the file handler once.
52+
# Only attach the queue handler once.
4553
if not logger.handlers:
4654
try:
4755
path = hass.config.path("monitormysolar_migration.log")
48-
handler = RotatingFileHandler(
49-
path, maxBytes=1_000_000, backupCount=3, encoding="utf-8"
56+
file_handler = RotatingFileHandler(
57+
path, maxBytes=1_000_000, backupCount=3, encoding="utf-8",
58+
delay=True, # open on first write, on the listener thread
5059
)
51-
handler.setFormatter(
60+
file_handler.setFormatter(
5261
logging.Formatter("%(asctime)s %(message)s", "%Y-%m-%d %H:%M:%S")
5362
)
54-
logger.addHandler(handler)
63+
log_queue: queue.SimpleQueue = queue.SimpleQueue()
64+
logger.addHandler(QueueHandler(log_queue))
65+
_audit_listener = QueueListener(log_queue, file_handler)
66+
_audit_listener.start() # daemon thread; won't block HA shutdown
5567
except Exception as err: # pragma: no cover - fs edge cases
5668
LOGGER.warning("Could not open migration audit log: %s", err)
5769

custom_components/monitormysolar/sensor.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -369,7 +369,18 @@ def _handle_coordinator_update(self) -> None:
369369
)
370370
self.throttled_async_write_ha_state()
371371
else:
372-
LOGGER.warning(f"entity {self.entity_id} data key {self.data_key} not found")
372+
# A missing key is normal until this sensor's dongle has reported the
373+
# field (change-data firmware may never send static fields outside a
374+
# snapshot, and a quiet dongle in a multi-dongle entry has no data at
375+
# all). Every coordinator push runs this for every data-less sensor,
376+
# so a per-push WARNING floods the log and trips HA's
377+
# "logging too frequently" guard. Log once per entity, at DEBUG.
378+
if not getattr(self, "_missing_key_logged", False):
379+
self._missing_key_logged = True
380+
LOGGER.debug(
381+
"entity %s data key %s not found (logged once per entity)",
382+
self.entity_id, self.data_key,
383+
)
373384

374385
async def async_added_to_hass(self) -> None:
375386
"""When entity is added to hass."""

custom_components/monitormysolar/translations/en.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@
4141
"dongle_already_exists": "This dongle ID already exists",
4242
"dongle_not_responding": "Dongle is not responding. Please check the ID and ensure it's online.",
4343
"dongle_id_required": "A new dongle ID is required",
44-
"dongle_not_found": "The selected dongle was not found in this configuration"
44+
"dongle_not_found": "The selected dongle was not found in this configuration",
45+
"raw_ids_taken": "Another Monitor My Solar entry already uses the clean (no dongle ID) entity names. Only one entry can use them - keep the dongle ID in entity names for this one."
4546
},
4647
"abort": {
4748
"cannot_remove_last_dongle": "Cannot remove the last dongle",
@@ -111,4 +112,4 @@
111112
}
112113
}
113114
}
114-
}
115+
}

custom_components/monitormysolar/translations/es.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@
3636
},
3737
"abort": {
3838
"no_restorable_entities": "No hay entidades eliminadas o deshabilitadas para restaurar."
39+
},
40+
"error": {
41+
"raw_ids_taken": "Otra entrada de Monitor My Solar ya usa los nombres de entidad limpios (sin ID del dongle). Solo una entrada puede usarlos; mantenga el ID del dongle en los nombres de entidad de esta."
3942
}
4043
}
41-
}
44+
}

custom_components/monitormysolar/translations/strings.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,9 @@
135135
},
136136
"abort": {
137137
"no_restorable_entities": "There are no deleted or disabled entities to restore."
138+
},
139+
"error": {
140+
"raw_ids_taken": "Another Monitor My Solar entry already uses the clean (no dongle ID) entity names. Only one entry can use them - keep the dongle ID in entity names for this one."
138141
}
139142
}
140-
}
143+
}

custom_components/monitormysolar/update.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
from homeassistant.components.update import UpdateEntity, UpdateEntityFeature, UpdateDeviceClass
2626
from homeassistant.core import HomeAssistant, callback
2727
from homeassistant.helpers.event import async_track_time_interval
28-
from .const import DOMAIN, ENTITIES, LOGGER, CONF_USE_BETA, DEFAULT_USE_BETA
28+
from .const import DOMAIN, ENTITIES, LOGGER, CONF_USE_BETA, DEFAULT_USE_BETA, fw_version_is_newer
2929
from .coordinator import MonitorMySolarEntry
3030
from .entity import MonitorMySolarEntity
3131

@@ -183,6 +183,18 @@ def latest_version(self) -> str | None:
183183
def _use_beta(self) -> bool:
184184
"""Whether this install should track the beta firmware channel."""
185185
return self.coordinator.entry.data.get(CONF_USE_BETA, DEFAULT_USE_BETA)
186+
187+
def version_is_newer(self, latest_version: str, installed_version: str) -> bool:
188+
"""Offer an update only when the server version is strictly newer.
189+
190+
HA's default comparison is an inequality, so a unit running a build
191+
NEWER than the server's published version (dev/beta units) would be
192+
offered a 'downgrade'. Numeric tuple compare instead. Deliberate
193+
consequence: a server-side rollback to an older version is not shown
194+
in HA — rollbacks are pushed via the /admin OTA command, which does
195+
not consult this comparison.
196+
"""
197+
return fw_version_is_newer(latest_version, installed_version)
186198

187199
def release_notes(self) -> str | None:
188200
"""Return the release notes."""

0 commit comments

Comments
 (0)