Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Bundle/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.5.4</string>
<string>0.5.5</string>
<key>CFBundleVersion</key>
<string>53</string>
<string>54</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>LSUIElement</key>
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
```

Expand Down Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions RELEASE_NOTES_0.5.5.md
Original file line number Diff line number Diff line change
@@ -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`.
18 changes: 14 additions & 4 deletions Sources/SolixBar/SolixDataProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -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? {
Expand Down Expand Up @@ -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]
Expand All @@ -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()
}
}
}

Expand Down
87 changes: 80 additions & 7 deletions Sources/SolixBar/StatusController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ final class StatusController: NSObject {
private var isTerminating = false
private var isRefreshing = false
private var refreshRequestedWhileBusy = false
private var refreshTask: Task<Void, Never>?
private var refreshStartedAt: Date?
private var nextRefreshDueAt: Date?
private var refreshWatchdog: DispatchSourceTimer?
private var refreshAnimationTimer: Timer?
private var wakeRefreshTimer: Timer?
private var wakeRefreshAttemptsRemaining = 0
Expand All @@ -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()
Expand All @@ -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").")
}
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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()
Expand Down Expand Up @@ -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)).")
}

Expand Down Expand Up @@ -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() {
Expand Down
21 changes: 21 additions & 0 deletions Sources/SolixBarCore/RefreshReliabilityPolicy.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
22 changes: 22 additions & 0 deletions Sources/SolixBarCoreChecks/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.")
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.5.4
0.5.5
Loading
Loading