From ac9ab37344d6a08271accda0860ad324a779c7e9 Mon Sep 17 00:00:00 2001
From: Ravaners
Date: Thu, 30 Jul 2026 10:13:41 +0200
Subject: [PATCH] Fix stale refreshes in 0.5.5
---
Bundle/Info.plist | 4 +-
CHANGELOG.md | 13 +++
README.md | 8 +-
RELEASE_NOTES_0.5.5.md | 27 +++++
Sources/SolixBar/SolixDataProvider.swift | 18 +++-
Sources/SolixBar/StatusController.swift | 87 +++++++++++++--
.../RefreshReliabilityPolicy.swift | 21 ++++
Sources/SolixBarCoreChecks/main.swift | 22 ++++
VERSION | 2 +-
docs/index.html | 8 +-
scripts/render_docs_assets.py | 2 +-
scripts/solix_snapshot.py | 100 ++++++++++++++++--
scripts/solix_snapshot_checks.py | 16 +++
13 files changed, 299 insertions(+), 29 deletions(-)
create mode 100644 RELEASE_NOTES_0.5.5.md
create mode 100644 Sources/SolixBarCore/RefreshReliabilityPolicy.swift
diff --git a/Bundle/Info.plist b/Bundle/Info.plist
index a53282b..1d5a33d 100644
--- a/Bundle/Info.plist
+++ b/Bundle/Info.plist
@@ -19,9 +19,9 @@
CFBundlePackageTypeAPPLCFBundleShortVersionString
- 0.5.4
+ 0.5.5CFBundleVersion
- 53
+ 54LSMinimumSystemVersion14.0LSUIElement
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 405ab39..aaecf7e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,19 @@
## Unreleased
+## 0.5.5 - 2026-07-30
+
+- DE: Der direkte SOLIX-Abruf fordert jetzt fuer alle unterstuetzten Geraete der Anlage Echtzeitdaten an und liest anschliessend die gemeinsame Anlagenansicht erneut. Dadurch werden Akku, PV, Hauslast, Netz und Akku-Fluss zusammen aktualisiert, auch wenn die normale Anker-Cloud-Uebersicht zuvor alte Werte geliefert hat.
+- EN: Direct SOLIX refreshes now request real-time data from every supported system device and then reload the combined system view. Battery, solar, home load, grid, and battery flow therefore update together even when the regular Anker cloud overview previously returned stale values.
+- DE: Der direkt empfangene Echtzeit-Akkustand hat Vorrang vor einem aelteren Cloudwert; bei Geraeten oder Konten ohne Echtzeitunterstuetzung bleibt der bisherige Cloud-Abruf als Rueckfall erhalten.
+- EN: A directly received real-time battery level takes precedence over an older cloud value; the existing cloud retrieval remains available as a fallback for devices or accounts without real-time support.
+- DE: Eine unabhaengige Aktualisierungsueberwachung erkennt ausgebliebene Zeitgeber und Abrufe, die laenger als 60 Sekunden festhaengen. Manuelles Aktualisieren bricht einen solchen Abruf jetzt wirklich ab und startet mit einer frischen Verbindung neu.
+- EN: An independent refresh watchdog detects missed timers and fetches stuck for more than 60 seconds. Manual refresh now actually cancels such a fetch and restarts it with a fresh connection.
+- DE: Neben dem vollstaendigen Aufwachen werden auch Bildschirm-Aufwachen und eine wieder aktive macOS-Sitzung erkannt, damit nach Ruhephasen sofort wieder aktuelle Daten abgerufen werden.
+- EN: In addition to a full system wake, screen wake and a reactivated macOS session are detected so current data is fetched immediately after idle periods.
+- DE: Layout, Farben, Symbole, Reihenfolge und bestehende Screenshots bleiben unveraendert; 0.5.5 korrigiert ausschliesslich die Zuverlaessigkeit und Aktualitaet aller Messwerte.
+- EN: Layout, colors, symbols, ordering, and existing screenshots remain unchanged; 0.5.5 only corrects the reliability and freshness of all readings.
+
## 0.5.4 - 2026-07-22
- DE: Das App-Symbol der normalen macOS-Menueleiste laesst sich jetzt zuverlaessig ausblenden, auch wenn die abgedockte Leiste geoeffnet ist. Die abgedockte Leiste behaelt weiterhin ihre eigene, unabhaengige Symbol-Einstellung.
diff --git a/README.md b/README.md
index 25967d3..83aedb0 100644
--- a/README.md
+++ b/README.md
@@ -41,7 +41,7 @@ English: Project homepage files are in [`docs/`](docs/) and can be published wit
## Version / Version
-Aktuelle Version / Current version: `0.5.4`
+Aktuelle Version / Current version: `0.5.5`
Versionshinweise stehen in [CHANGELOG.md](CHANGELOG.md). / See [CHANGELOG.md](CHANGELOG.md) for release notes.
@@ -63,7 +63,7 @@ App-Bundle zum Doppelklicken erstellen. / Create a double-clickable app bundle:
```bash
sh scripts/package_app.sh
-unzip outputs/SolixBar-0.5.4-macOS-arm64.zip -d outputs
+unzip outputs/SolixBar-0.5.5-macOS-arm64.zip -d outputs
open outputs/SolixBar.app
```
@@ -97,9 +97,9 @@ Das JSON sollte so aussehen. / The JSON should look like this:
## Live SOLIX Daten / Live SOLIX Data
-Anker stellt keine stabile oeffentliche SOLIX API bereit. SolixBar 0.5.4 liefert den benoetigten Python-Helper und seine Laufzeit im App-Bundle mit. Fuer den normalen SOLIX-Login sind keine Projektordner, Terminalbefehle oder persoenlichen Dateipfade mehr erforderlich. Tagesertrag und PV-Gesamtertrag werden ueber die passenden SOLIX-Energieendpunkte gelesen; bei mehreren Wechselrichtern werden deren PV-Gesamtwerte addiert. Falls kein Gesamtwert verfuegbar ist, wird die reale Messung lokal und datenquellenuebergreifend fortgefuehrt.
+Anker stellt keine stabile oeffentliche SOLIX API bereit. SolixBar 0.5.5 liefert den benoetigten Python-Helper und seine Laufzeit im App-Bundle mit. Fuer den normalen SOLIX-Login sind keine Projektordner, Terminalbefehle oder persoenlichen Dateipfade mehr erforderlich. Der direkte Abruf fordert Echtzeitdaten fuer alle unterstuetzten Anlagengeraete an und liest danach Akku, PV, Hauslast, Netz und Akku-Fluss gemeinsam neu. Eine unabhaengige Ueberwachung stellt ausgebliebene oder festhaengende Aktualisierungen wieder her. Tagesertrag und PV-Gesamtertrag werden ueber die passenden SOLIX-Energieendpunkte gelesen; bei mehreren Wechselrichtern werden deren PV-Gesamtwerte addiert. Falls kein Gesamtwert verfuegbar ist, wird die reale Messung lokal und datenquellenuebergreifend fortgefuehrt.
-English: Anker does not provide a stable public SOLIX API. SolixBar 0.5.4 bundles the required Python helper and runtime inside the app. Normal SOLIX login no longer requires a project checkout, Terminal commands, or personal file paths. Daily yield and PV lifetime total use the matching SOLIX energy endpoints; PV lifetime totals from multiple inverters are added together. If no lifetime total is available, the real measurement continues locally across live data sources.
+English: Anker does not provide a stable public SOLIX API. SolixBar 0.5.5 bundles the required Python helper and runtime inside the app. Normal SOLIX login no longer requires a project checkout, Terminal commands, or personal file paths. Direct refreshes request real-time data from every supported system device and then reload battery, solar, home load, grid, and battery flow together. An independent watchdog recovers missed or stuck refreshes. Daily yield and PV lifetime total use the matching SOLIX energy endpoints; PV lifetime totals from multiple inverters are added together. If no lifetime total is available, the real measurement continues locally across live data sources.
Oeffne `Einstellungen` -> `Datenquelle`, waehle `SOLIX Login`, trage Mail,
Passwort und Land ein und druecke `Speichern`. Mail und Passwort werden ohne
diff --git a/RELEASE_NOTES_0.5.5.md b/RELEASE_NOTES_0.5.5.md
new file mode 100644
index 0000000..4280547
--- /dev/null
+++ b/RELEASE_NOTES_0.5.5.md
@@ -0,0 +1,27 @@
+# SolixBar 0.5.5
+
+## Deutsch
+
+SolixBar 0.5.5 sorgt dafuer, dass die gesamte Anzeige dauerhaft aktuell bleibt.
+
+- Der direkte SOLIX-Abruf fordert jetzt fuer alle unterstuetzten Geraete der Anlage Echtzeitdaten an und liest danach die gemeinsame Anlagenansicht erneut.
+- Akku, PV, Hauslast, Netz und Akku-Fluss werden dadurch zusammen aktualisiert, auch wenn die normale Anker-Cloud-Uebersicht zuvor alte Werte geliefert hat.
+- Der direkt empfangene Echtzeit-Akkustand hat Vorrang vor einem aelteren Cloudwert. Ohne Echtzeitunterstuetzung bleibt der bisherige Cloud-Abruf als Rueckfall erhalten.
+- Eine unabhaengige Ueberwachung erkennt ausgebliebene Zeitgeber und Abrufe, die laenger als 60 Sekunden festhaengen.
+- Manuelles Aktualisieren bricht einen festhaengenden Abruf jetzt wirklich ab und startet mit einer frischen Verbindung neu.
+- SolixBar reagiert auf vollstaendiges Aufwachen, Bildschirm-Aufwachen und eine wieder aktive macOS-Sitzung.
+- Layout, Farben, Symbole, Reihenfolge und Screenshots wurden nicht veraendert.
+
+## English
+
+SolixBar 0.5.5 keeps the complete display current over long-running sessions.
+
+- Direct SOLIX refreshes now request real-time data from every supported system device and then reload the combined system view.
+- Battery, solar, home load, grid, and battery flow therefore update together even when the regular Anker cloud overview previously returned stale values.
+- A directly received real-time battery level takes precedence over an older cloud value. The existing cloud retrieval remains available as a fallback without real-time support.
+- An independent watchdog detects missed timers and fetches stuck for more than 60 seconds.
+- Manual refresh now actually cancels a stuck fetch and restarts it with a fresh connection.
+- SolixBar reacts to full system wake, screen wake, and a reactivated macOS session.
+- Layout, colors, symbols, ordering, and screenshots are unchanged.
+
+Download `SolixBar-0.5.5-macOS-arm64.zip`, unpack it, and move `SolixBar.app` to `Applications`.
diff --git a/Sources/SolixBar/SolixDataProvider.swift b/Sources/SolixBar/SolixDataProvider.swift
index b48376e..457a048 100644
--- a/Sources/SolixBar/SolixDataProvider.swift
+++ b/Sources/SolixBar/SolixDataProvider.swift
@@ -66,7 +66,7 @@ final class BundledSolixDataProvider: SolixDataProvider {
let inputData = try JSONEncoder().encode(request) + Data([0x0A])
let timeout = timeout
- return try await Task.detached(priority: .utility) {
+ let worker = Task.detached(priority: .utility) {
let process = Process()
process.executableURL = runtime.python
process.arguments = [runtime.script.path, "--stdin-config"]
@@ -107,7 +107,12 @@ final class BundledSolixDataProvider: SolixDataProvider {
throw SolixProviderError.commandFailed(message.trimmingCharacters(in: .whitespacesAndNewlines))
}
return try SnapshotDecoder.decode(data)
- }.value
+ }
+ return try await withTaskCancellationHandler {
+ try await worker.value
+ } onCancel: {
+ worker.cancel()
+ }
}
private static func runtimeConfiguration() -> HelperRuntime? {
@@ -189,7 +194,7 @@ final class CommandSolixDataProvider: SolixDataProvider {
let command = command
let timeout = timeout
- return try await Task.detached(priority: .utility) {
+ let worker = Task.detached(priority: .utility) {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/zsh")
process.arguments = ["-lc", command]
@@ -212,7 +217,12 @@ final class CommandSolixDataProvider: SolixDataProvider {
}
return try SnapshotDecoder.decode(data)
- }.value
+ }
+ return try await withTaskCancellationHandler {
+ try await worker.value
+ } onCancel: {
+ worker.cancel()
+ }
}
}
diff --git a/Sources/SolixBar/StatusController.swift b/Sources/SolixBar/StatusController.swift
index 817f9d8..8f5c509 100644
--- a/Sources/SolixBar/StatusController.swift
+++ b/Sources/SolixBar/StatusController.swift
@@ -50,6 +50,10 @@ final class StatusController: NSObject {
private var isTerminating = false
private var isRefreshing = false
private var refreshRequestedWhileBusy = false
+ private var refreshTask: Task?
+ private var refreshStartedAt: Date?
+ private var nextRefreshDueAt: Date?
+ private var refreshWatchdog: DispatchSourceTimer?
private var refreshAnimationTimer: Timer?
private var wakeRefreshTimer: Timer?
private var wakeRefreshAttemptsRemaining = 0
@@ -60,10 +64,23 @@ final class StatusController: NSObject {
func start() {
NSWorkspace.shared.notificationCenter.addObserver(
self,
- selector: #selector(workspaceDidWake(_:)),
+ selector: #selector(workspaceBecameUsable(_:)),
name: NSWorkspace.didWakeNotification,
object: nil
)
+ NSWorkspace.shared.notificationCenter.addObserver(
+ self,
+ selector: #selector(workspaceBecameUsable(_:)),
+ name: NSWorkspace.screensDidWakeNotification,
+ object: nil
+ )
+ NSWorkspace.shared.notificationCenter.addObserver(
+ self,
+ selector: #selector(workspaceBecameUsable(_:)),
+ name: NSWorkspace.sessionDidBecomeActiveNotification,
+ object: nil
+ )
+ startRefreshWatchdog()
settings.migrateMenuBarGridMetricIfNeeded()
settings.migrateDetachedBarSettingsIfNeeded()
applyAppearance()
@@ -86,6 +103,9 @@ final class StatusController: NSObject {
timer?.invalidate()
wakeRefreshTimer?.invalidate()
refreshAnimationTimer?.invalidate()
+ refreshTask?.cancel()
+ refreshWatchdog?.cancel()
+ refreshWatchdog = nil
settings.isDetachedMenuBarActive = isMenuBarDetached
AppLogger.info("Persisted detached slim bar state: \(isMenuBarDetached ? "active" : "inactive").")
}
@@ -107,20 +127,31 @@ final class StatusController: NSObject {
}
}
- private func refresh() {
+ private func refresh(force: Bool = false) {
guard !isTerminating else { return }
guard !isRefreshing else {
refreshRequestedWhileBusy = true
- AppLogger.info("Refresh skipped because a previous refresh is still running.")
+ if force {
+ AppLogger.info("Active refresh cancelled so the requested refresh can start with a fresh connection.")
+ refreshTask?.cancel()
+ } else {
+ AppLogger.info("Refresh queued because a previous refresh is still running.")
+ }
return
}
+ timer?.invalidate()
+ timer = nil
+ nextRefreshDueAt = nil
isRefreshing = true
+ refreshStartedAt = Date()
startRefreshAnimation()
AppLogger.info("Refreshing data source: \(settings.dataSourceMode.rawValue).")
updateTitle()
- Task {
+ refreshTask = Task {
defer {
isRefreshing = false
+ refreshStartedAt = nil
+ refreshTask = nil
stopRefreshAnimation()
if refreshRequestedWhileBusy {
refreshRequestedWhileBusy = false
@@ -162,6 +193,8 @@ final class StatusController: NSObject {
+ "today=\(snapshot.todayKWh.map { String(format: "%.3f", $0) } ?? "-")kWh, "
+ "total=\(snapshot.totalKWh.map { String(format: "%.3f", $0) } ?? "-")kWh."
)
+ } catch is CancellationError {
+ AppLogger.info("Refresh cancelled; a fresh retrieval is already scheduled.")
} catch {
let keepsLastSnapshot = currentSnapshot() != nil
&& (wakeRefreshAttemptsRemaining > 0 || consecutiveRefreshFailures < 2)
@@ -190,16 +223,21 @@ final class StatusController: NSObject {
}
}
- @objc private func workspaceDidWake(_ notification: Notification) {
- AppLogger.info("Mac woke from sleep; scheduling network-aware refresh retries.")
+ @objc private func workspaceBecameUsable(_ notification: Notification) {
+ AppLogger.info("Mac or screen became active; scheduling a fresh network-aware refresh.")
consecutiveRefreshFailures = 0
wakeRefreshAttemptsRemaining = 2
+ if isRefreshing {
+ refreshRequestedWhileBusy = false
+ refreshTask?.cancel()
+ }
scheduleWakeRefresh(after: 3, reason: "macOS wake notification")
}
private func scheduleWakeRefresh(after delay: TimeInterval, reason: String) {
timer?.invalidate()
timer = nil
+ nextRefreshDueAt = nil
wakeRefreshTimer?.invalidate()
let wakeTimer = Timer(timeInterval: delay, repeats: false) { [weak self] _ in
Task { @MainActor in
@@ -213,6 +251,40 @@ final class StatusController: NSObject {
AppLogger.info("Wake refresh scheduled in \(Int(delay)) seconds (\(reason)).")
}
+ private func startRefreshWatchdog() {
+ refreshWatchdog?.cancel()
+ let watchdog = DispatchSource.makeTimerSource(queue: .main)
+ watchdog.schedule(deadline: .now() + 15, repeating: 15, leeway: .seconds(2))
+ watchdog.setEventHandler { [weak self] in
+ Task { @MainActor in
+ self?.recoverOverdueRefreshIfNeeded()
+ }
+ }
+ refreshWatchdog = watchdog
+ watchdog.resume()
+ }
+
+ private func recoverOverdueRefreshIfNeeded() {
+ guard !isTerminating else { return }
+ let now = Date()
+ if isRefreshing,
+ RefreshReliabilityPolicy.refreshIsStuck(now: now, startedAt: refreshStartedAt) {
+ AppLogger.error("Refresh watchdog found a fetch running for more than 60 seconds; restarting it.")
+ refresh(force: true)
+ return
+ }
+ guard !isRefreshing,
+ wakeRefreshTimer == nil,
+ RefreshReliabilityPolicy.timerIsOverdue(now: now, scheduledFor: nextRefreshDueAt) else {
+ return
+ }
+ AppLogger.info("Refresh watchdog recovered an overdue automatic refresh.")
+ timer?.invalidate()
+ timer = nil
+ nextRefreshDueAt = nil
+ refresh()
+ }
+
private func startRefreshAnimation() {
refreshAnimationFrame = 0
refreshAnimationTimer?.invalidate()
@@ -249,6 +321,7 @@ final class StatusController: NSObject {
newTimer.tolerance = min(5, interval * 0.1)
RunLoop.main.add(newTimer, forMode: .common)
timer = newTimer
+ nextRefreshDueAt = Date().addingTimeInterval(interval)
AppLogger.info("Next refresh scheduled in \(Int(interval)) seconds (failures=\(consecutiveRefreshFailures)).")
}
@@ -1003,7 +1076,7 @@ final class StatusController: NSObject {
@objc private func refreshMenuAction() {
AppLogger.info("Manual refresh requested from menu.")
- refresh()
+ refresh(force: true)
}
@objc private func toggleDetachedMenuBar() {
diff --git a/Sources/SolixBarCore/RefreshReliabilityPolicy.swift b/Sources/SolixBarCore/RefreshReliabilityPolicy.swift
new file mode 100644
index 0000000..fa534bf
--- /dev/null
+++ b/Sources/SolixBarCore/RefreshReliabilityPolicy.swift
@@ -0,0 +1,21 @@
+import Foundation
+
+public enum RefreshReliabilityPolicy {
+ public static func timerIsOverdue(
+ now: Date,
+ scheduledFor: Date?,
+ grace: TimeInterval = 15
+ ) -> Bool {
+ guard let scheduledFor else { return false }
+ return now.timeIntervalSince(scheduledFor) >= max(0, grace)
+ }
+
+ public static func refreshIsStuck(
+ now: Date,
+ startedAt: Date?,
+ timeout: TimeInterval = 60
+ ) -> Bool {
+ guard let startedAt else { return false }
+ return now.timeIntervalSince(startedAt) >= max(1, timeout)
+ }
+}
diff --git a/Sources/SolixBarCoreChecks/main.swift b/Sources/SolixBarCoreChecks/main.swift
index 96f4c98..8baceaa 100644
--- a/Sources/SolixBarCoreChecks/main.swift
+++ b/Sources/SolixBarCoreChecks/main.swift
@@ -74,4 +74,26 @@ require(MenuBarIconPolicy.shouldShowAppIcon(settingIsEnabled: true, detachedBarI
require(MenuBarIconPolicy.shouldShowAppIcon(settingIsEnabled: true, detachedBarIsOpen: true), "enabled menu bar icon while detached")
require(!MenuBarIconPolicy.shouldShowAppIcon(settingIsEnabled: false, detachedBarIsOpen: false), "disabled menu bar icon")
require(!MenuBarIconPolicy.shouldShowAppIcon(settingIsEnabled: false, detachedBarIsOpen: true), "detached bar must not force menu bar icon")
+let refreshDue = start.addingTimeInterval(120)
+require(
+ !RefreshReliabilityPolicy.timerIsOverdue(
+ now: refreshDue.addingTimeInterval(14),
+ scheduledFor: refreshDue
+ ),
+ "refresh watchdog observes grace period"
+)
+require(
+ RefreshReliabilityPolicy.timerIsOverdue(
+ now: refreshDue.addingTimeInterval(15),
+ scheduledFor: refreshDue
+ ),
+ "refresh watchdog detects overdue timer"
+)
+require(
+ RefreshReliabilityPolicy.refreshIsStuck(
+ now: start.addingTimeInterval(60),
+ startedAt: start
+ ),
+ "refresh watchdog detects stuck fetch"
+)
print("SolixBar core checks passed.")
diff --git a/VERSION b/VERSION
index 7d85683..d1d899f 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.5.4
+0.5.5
diff --git a/docs/index.html b/docs/index.html
index e9c3900..29134e9 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -37,7 +37,7 @@
SolixBar 0.5.4 bringt den direkten SOLIX-Helper und seine Laufzeit selbst mit. Tages- und PV-Gesamtwerte verwenden die passenden Energieendpunkte, mehrere Wechselrichter werden zusammengezaehlt, und voruebergehende Abruffehler lassen die letzten gueltigen Werte sichtbar.
-
English: SolixBar 0.5.4 bundles the direct SOLIX helper and runtime. Daily and PV lifetime values use the matching energy endpoints, multiple inverters are added together, and temporary fetch failures keep the latest valid readings visible.
+
SolixBar 0.5.5 bringt den direkten SOLIX-Helper und seine Laufzeit selbst mit. Echtzeitanforderungen aktualisieren Akku, PV, Hauslast, Netz und Akku-Fluss gemeinsam; eine unabhaengige Ueberwachung stellt ausgebliebene oder festhaengende Abrufe wieder her. Tages- und PV-Gesamtwerte verwenden die passenden Energieendpunkte, mehrere Wechselrichter werden zusammengezaehlt.
+
English: SolixBar 0.5.5 bundles the direct SOLIX helper and runtime. Real-time requests refresh battery, solar, home load, grid, and battery flow together; an independent watchdog recovers missed or stuck fetches. Daily and PV lifetime values use the matching energy endpoints, and multiple inverters are added together.
Privat gebaut
@@ -171,7 +171,7 @@
Installieren und Live-Daten aktivieren
Release laden
Aktuelle Version herunterladen und SolixBar starten.
English: Download the latest release and start SolixBar.
- SolixBar-0.5.4-macOS-arm64.zip
+ SolixBar-0.5.5-macOS-arm64.zip2
diff --git a/scripts/render_docs_assets.py b/scripts/render_docs_assets.py
index 8ee0a62..307a746 100644
--- a/scripts/render_docs_assets.py
+++ b/scripts/render_docs_assets.py
@@ -301,7 +301,7 @@ def render_settings():
text(d, (92, 148), "Menueleiste und abgedockte Leiste unabhaengig und direkt sortierbar.", MUTED, F["body"])
shadow(base, (360, 230, 1240, 900), radius=34, blur=34)
rounded(d, (360, 230, 1240, 900), 28, PANEL, (160, 170, 164), 3)
- text(d, (410, 278), "SOLIX Bar 0.5.4", INK, F["h3"])
+ text(d, (410, 278), "SOLIX Bar 0.5.5", INK, F["h3"])
tabs = ["Menueleiste", "Abgedockt", "Warnungen", "Datenquelle", "App", "Start"]
xx = 405
for index, label in enumerate(tabs):
diff --git a/scripts/solix_snapshot.py b/scripts/solix_snapshot.py
index 9303e40..ab99152 100644
--- a/scripts/solix_snapshot.py
+++ b/scripts/solix_snapshot.py
@@ -115,6 +115,89 @@ def _first_solarbank(devices):
)
+def _mqtt_snapshot(device):
+ mqtt_data = device.get("mqtt_data")
+ return dict(mqtt_data) if isinstance(mqtt_data, dict) else {}
+
+
+def _battery_percent(mqtt_solarbank, solarbank, first_solarbank, site):
+ return _as_int(
+ mqtt_solarbank.get("battery_soc"),
+ first_solarbank.get("battery_power"),
+ solarbank.get("battery_soc"),
+ solarbank.get("battery_percentage"),
+ site.get("battery_soc"),
+ site.get("soc"),
+ )
+
+
+async def _trigger_realtime_devices(client):
+ """Ask every supported site device for fresh telemetry."""
+ devices = [
+ device
+ for device in client.devices.values()
+ if device.get("mqtt_supported")
+ and not device.get("is_passive")
+ ]
+ if not devices:
+ return {}
+
+ mqtt_session = None
+ try:
+ mqtt_session = await asyncio.wait_for(client.startMqttSession(), timeout=6)
+ if not mqtt_session or not mqtt_session.is_connected():
+ return {}
+
+ subscribed = []
+ for device in devices:
+ topic_prefix = mqtt_session.get_topic_prefix(deviceDict=device)
+ if not topic_prefix:
+ continue
+ response = mqtt_session.subscribe(f"{topic_prefix}#")
+ if response is None or not response.is_failure:
+ subscribed.append(device)
+ if not subscribed:
+ return {}
+
+ await asyncio.sleep(0.25)
+ for device in subscribed:
+ mqtt_session.realtime_trigger(
+ deviceDict=device,
+ timeout=300,
+ wait_for_publish=2,
+ )
+ if device.get("mqtt_status_request"):
+ mqtt_session.status_request(
+ deviceDict=device,
+ wait_for_publish=2,
+ )
+
+ earliest_cloud_refresh = time.monotonic() + 3
+ deadline = time.monotonic() + 5
+ freshest_solarbank = {}
+ while time.monotonic() < deadline:
+ await asyncio.sleep(0.25)
+ for device in subscribed:
+ candidate = _mqtt_snapshot(device)
+ device_type = str(device.get("type", "")).lower()
+ is_solarbank = "solarbank" in device_type or "battery" in device_type
+ if is_solarbank and candidate.get("last_update"):
+ freshest_solarbank = candidate
+ if (
+ candidate.get("battery_soc") is not None
+ and time.monotonic() >= earliest_cloud_refresh
+ ):
+ return candidate
+ return freshest_solarbank
+ except Exception:
+ # MQTT is an optional freshness path. The regular cloud response remains
+ # available when a device, account, or network does not support it.
+ return {}
+ finally:
+ if mqtt_session is not None:
+ client.stopMqttSession()
+
+
def _energy_total(statistics, stat_type="1"):
if not isinstance(statistics, list):
return None
@@ -298,6 +381,12 @@ async def main():
client = api.AnkerSolixApi(user, password, country, session)
await client.update_sites()
await client.update_device_details()
+ mqtt_solarbank = await _trigger_realtime_devices(client)
+ # Real-time triggers refresh the cloud scene used by the app. Query it
+ # once more so battery, solar, load, grid, and flow all come from the
+ # same current system measurement. This also works when direct MQTT
+ # values are not available for a particular device model.
+ await client.update_sites()
cache = _load_cache()
site = next(iter(client.sites.values()), {})
@@ -393,12 +482,11 @@ async def main():
site.get("site_name"),
site.get("siteName"),
) or "Anker SOLIX",
- "batteryPercent": _as_int(
- solarbank.get("battery_soc"),
- first_solarbank.get("battery_power"),
- solarbank.get("battery_percentage"),
- site.get("battery_soc"),
- site.get("soc"),
+ "batteryPercent": _battery_percent(
+ mqtt_solarbank,
+ solarbank,
+ first_solarbank,
+ site,
),
"solarWatts": solar_watts,
"homeWatts": _as_int(
diff --git a/scripts/solix_snapshot_checks.py b/scripts/solix_snapshot_checks.py
index 4da5653..223c0d0 100644
--- a/scripts/solix_snapshot_checks.py
+++ b/scripts/solix_snapshot_checks.py
@@ -21,6 +21,22 @@
"solar_production": "4.2"
}
assert helper._first_text(None, "", 7) == "7"
+assert helper._mqtt_snapshot({"mqtt_data": {"battery_soc": "62"}}) == {
+ "battery_soc": "62"
+}
+assert helper._mqtt_snapshot({"mqtt_data": "invalid"}) == {}
+assert helper._battery_percent(
+ {"battery_soc": "62"},
+ {"battery_soc": "70"},
+ {"battery_power": "69"},
+ {},
+) == 62
+assert helper._battery_percent(
+ {},
+ {"battery_soc": "70"},
+ {"battery_power": "69"},
+ {},
+) == 69
assert helper._inverter_serials(
{"solar_list": [{"device_sn": "PV-1"}, {"device_sn": "PV-2"}]},
[{"type": "inverter", "device_sn": "PV-2"}, {"type": "inverter", "device_sn": "PV-3"}],