diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..ae5f6d5
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,90 @@
+name: Build & Test
+
+on:
+ push:
+ branches: ['**']
+ tags: ['v*']
+ pull_request:
+ workflow_dispatch:
+
+permissions:
+ contents: write
+
+jobs:
+ build:
+ name: Build, test, and package
+ runs-on: macos-15
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ # Volle Historie: der Release-Changelog listet Commits seit dem
+ # vorherigen Tag.
+ fetch-depth: 0
+
+ - name: Swift version
+ run: swift --version
+
+ # Schutz gegen vergessene VERSION-Datei: Tag und VERSION müssen
+ # übereinstimmen, sonst trägt das Release die falsche Nummer.
+ - name: Verify tag matches VERSION (tags only)
+ if: startsWith(github.ref, 'refs/tags/v')
+ run: |
+ FILE_VERSION="$(tr -d '[:space:]' < VERSION)"
+ TAG_VERSION="${GITHUB_REF_NAME#v}"
+ if [ "$FILE_VERSION" != "$TAG_VERSION" ]; then
+ echo "Tag ${GITHUB_REF_NAME} passt nicht zur VERSION-Datei (${FILE_VERSION})." >&2
+ exit 1
+ fi
+
+ - name: Run tests
+ run: swift test
+
+ - name: Build release binary (universal)
+ run: swift build -c release --arch arm64 --arch x86_64
+
+ # Portable Python-Laufzeit für den direkten SOLIX-Modus. Die Laufzeit
+ # ist arm64-only (wie die Runner); auf Intel-Macs bleiben Demo-,
+ # Befehl- und URL-Modus voll nutzbar.
+ - name: Prepare SOLIX runtime
+ run: sh scripts/prepare_solix_runtime.sh
+
+ - name: Package app bundle
+ run: |
+ SOLIXBAR_BIN=".build/apple/Products/Release/SolixBar" sh scripts/package_app.sh
+ sh scripts/verify_release.sh outputs/SolixBar.app
+ ditto -c -k --sequesterRsrc --keepParent outputs/SolixBar.app SolixBar-macOS.zip
+
+ - name: Upload render snapshots
+ uses: actions/upload-artifact@v4
+ with:
+ name: ui-renders
+ path: .build/renders/
+ if-no-files-found: ignore
+
+ - name: Upload app bundle artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: SolixBar-macOS
+ path: SolixBar-macOS.zip
+
+ - name: Attach bundle to release with changelog (tags only)
+ if: startsWith(github.ref, 'refs/tags/v')
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ PREV_TAG=$(git describe --tags --abbrev=0 "${GITHUB_REF_NAME}^" 2>/dev/null || true)
+ {
+ echo "Hinweis: Das Bundle ist ad-hoc signiert. Beim ersten Start: Rechtsklick -> \"Oeffnen\"."
+ echo "Note: The bundle is ad-hoc signed. First launch: right-click -> \"Open\"."
+ echo
+ echo "## Changelog"
+ if [ -n "$PREV_TAG" ]; then
+ git log --no-merges --pretty='- %s' "${PREV_TAG}..${GITHUB_REF_NAME}"
+ echo
+ echo "Kompletter Diff: https://github.com/${GITHUB_REPOSITORY}/compare/${PREV_TAG}...${GITHUB_REF_NAME}"
+ else
+ git log --no-merges --pretty='- %s' "${GITHUB_REF_NAME}"
+ fi
+ } > release-notes.md
+ gh release create "${GITHUB_REF_NAME}" --title "SolixBar ${GITHUB_REF_NAME}" --notes-file release-notes.md || true
+ gh release upload "${GITHUB_REF_NAME}" SolixBar-macOS.zip --clobber
diff --git a/.gitignore b/.gitignore
index 1b969dc..8e61566 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,16 +1,9 @@
-.DS_Store
.build/
-*.pyc
-__pycache__/
-
-# Local/generated runtime assets
-work/
outputs/
-bin/
-obj/
-.vs/
-*.user
-*.suo
-
-# Local secrets or generated command snippets
-*.env
+work/
+.claude/
+.DS_Store
+*.xcodeproj
+renders/
+__pycache__/
+*.pyc
diff --git a/Bundle/Info.plist b/Bundle/Info.plist
index 6a9063f..3f76ee5 100644
--- a/Bundle/Info.plist
+++ b/Bundle/Info.plist
@@ -19,9 +19,9 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 0.3.20
+ 0.4.0
CFBundleVersion
- 45
+ 46
LSMinimumSystemVersion
14.0
LSUIElement
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 09961b1..9ec024c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,68 @@
# Changelog
+## 0.4.3 - 2026-07-13
+
+- DE: Verlaufsgraph: Kurven lassen sich optional glätten (weiche, fliessende Kurven, die durch alle Messpunkte laufen und auf die Plotfläche geklemmt sind, damit ein Bogen nie scheinbar negative Watt unter die Nulllinie zieht), und die dezente Flächenfüllung unter den Kurven ist jetzt je Kurve (Akku, Solar, Netzbezug) einzeln wählbar — beides unter App → Dashboard. Die Demo-Datenquelle zeigt den Akkustand jetzt als langsame, realistische Lade-/Entladekurve statt als Minuten-Sägezahn.
+- EN: History graph: curves can optionally be smoothed (soft, flowing curves that run through every measurement and are clamped to the plot area so a bend never draws seemingly negative watts below the zero line), and the subtle area fill under the curves is now selectable per curve (battery, solar, grid import) — both under App → Dashboard. The demo data source now shows the battery level as a slow, realistic charge/discharge curve instead of a per-minute sawtooth.
+- DE: Die Fragezeichen-Knöpfe in den Einstellungen zeigen beim Klick jetzt ausführliche, verständliche Hilfetexte in einem lesbaren Popover (bisher blieb die Sprechblase leer); der Hover-Tooltip bleibt der kurze Hinweis.
+- EN: The question-mark buttons in the settings now show extensive, easy-to-understand help texts in a readable popover on click (previously the bubble stayed empty); the hover tooltip remains the short hint.
+- DE: Dashboard-Kacheln (Akku/Solar): Der Wert passt seine Größe an die verfügbare Breite an, statt den Trend-Pfeil abzuschneiden; bei drei oder vier PV-Eingängen werden die Einzelwerte als 2×2-Raster auf zwei Zeilen gezeigt (der zweizeilige Wert wächst nach unten, die Titel bleiben auf gleicher Höhe).
+- EN: Dashboard tiles (battery/solar): the value scales to the available width instead of cutting off the trend arrow; with three or four PV inputs the individual values are shown as a 2×2 grid on two lines (the two-line value grows downward, the titles stay aligned).
+
+- DE: Neue Datenquelle "SOLIX-Konto (direkt)": SolixBar fragt Anker direkt mit Mail und Passwort ab — ohne konfigurierten Befehl und ohne lokale Python-Installation. Das Release-Bundle enthält eine portable Python-Laufzeit samt SOLIX-Modulen (arm64; auf Intel-Macs bleiben Demo-, Befehl- und URL-Modus nutzbar). Die Zugangsdaten gehen als stdin-JSON an den Helper statt als Umgebungsvariablen, das Passwort liegt weiterhin nur im macOS-Schlüsselbund. Wer bisher den vorbereiteten SOLIX-Befehl nutzte, wird automatisch auf den direkten Modus umgestellt.
+- EN: New data source "SOLIX account (direct)": SolixBar queries Anker directly with email and password — no configured command and no local Python installation required. The release bundle ships a portable Python runtime with the SOLIX modules (arm64; on Intel Macs the demo, command, and URL modes remain available). Credentials are passed to the helper as stdin JSON instead of environment variables; the password still lives only in the macOS Keychain. Users of the prepared SOLIX command are migrated to the direct mode automatically.
+- DE: Weniger API-Aufrufe: Der Helper cached die teure Tagesstatistik 10 und die Gesamtstatistik 15 Minuten (atomar geschriebene 0600-Dateien im Application-Support-Ordner); die Live-Leistungswerte kommen weiterhin bei jedem Abruf frisch. Ertragszustand und Cache liegen nie mehr im (signierten) App-Bundle.
+- EN: Fewer API calls: the helper caches the expensive daily statistics for 10 and the total statistics for 15 minutes (atomically written 0600 files in Application Support); live power values stay fresh on every refresh. Energy state and cache never live inside the (signed) app bundle anymore.
+- DE: Fehler-Backoff: Nach fehlgeschlagenen Abrufen verdoppelt sich der Abstand bis zum nächsten Versuch (maximal 30 Minuten) und springt beim ersten Erfolg auf das eingestellte Intervall zurück — bei toter API hämmert die App nicht mehr die ganze Nacht durch. Der nächste Abruf wird erst nach Abschluss des laufenden geplant.
+- EN: Failure backoff: after failed refreshes the delay to the next attempt doubles (capped at 30 minutes) and resets to the configured interval on the first success — a dead API no longer gets hammered all night. The next refresh is scheduled only after the current one finishes.
+- DE: Unsichtbare Fenster (geschlossenes Dashboard-/Verlaufsfenster) werden nach einem Abruf nicht mehr unnötig neu aufgebaut; beim Öffnen wird ohnehin frisch gerendert.
+- EN: Hidden windows (closed dashboard/history window) are no longer rebuilt unnecessarily after a refresh; opening them renders fresh anyway.
+- DE: Release-Prozess: `scripts/prepare_solix_runtime.sh` lädt die portable Laufzeit reproduzierbar (python-build-standalone, gepinnt), `package_app.sh` bettet sie ein und entfernt die Debug-Map aus dem Binary (keine absoluten Quellpfade mehr im Bundle), `verify_release.sh` prüft Version, Signatur, private Daten und die Importierbarkeit der SOLIX-Module; die CI macht das alles automatisch.
+- EN: Release process: `scripts/prepare_solix_runtime.sh` downloads the portable runtime reproducibly (python-build-standalone, pinned), `package_app.sh` embeds it and strips the debug map from the binary (no more absolute source paths in the bundle), `verify_release.sh` checks version, signature, private data, and that the SOLIX modules import; CI does all of this automatically.
+
+## 0.4.2 - 2026-07-11
+
+- DE: Update-Hinweis: SolixBar prüft täglich die GitHub-Releases und meldet eine neuere Version einmalig per macOS-Mitteilung plus dauerhaftem Menüpunkt ("Update verfügbar"). Abschaltbar unter App → "Automatisch nach Updates suchen". Installiert wird nichts automatisch.
+- EN: Update notice: SolixBar checks the GitHub releases daily and reports a newer version once via macOS notification plus a persistent menu entry ("Update available"). Can be disabled under App → "Check for updates automatically". Nothing is installed automatically.
+- DE: Verlaufsfenster abdockbar: neuer Menüpunkt "Verlauf abdocken" öffnet den großen Graphen als eigenes Fenster; Position und Offen-Zustand überleben einen Neustart, die Fensterebene (vorn/normal/hinten) ist wie bei Dashboard und Slim-Bar wählbar.
+- EN: Detachable history window: a new "Detach history graph" menu entry opens the large graph as its own window; position and open state survive a restart, and the window level (front/normal/behind) is selectable like for the dashboard and slim bar.
+- DE: Datenexport: "Daten exportieren ..." schreibt die gespeicherte History der aktiven Datenquelle als CSV (für Excel/Numbers) oder JSON — Formatwahl direkt im Speichern-Dialog. Die History erfasst jetzt zusätzlich Hauslast und Akku-Fluss; ältere Dateien bleiben lesbar.
+- EN: Data export: "Export data ..." writes the stored history of the active data source as CSV (for Excel/Numbers) or JSON — format selectable right in the save dialog. History now also records home load and battery flow; older files stay readable.
+- DE: Warnungen (alle standardmäßig aus, Tab "Warnungen"): Akku unter Schwelle (einmalig, mit Hysterese), PV-Einbruch (0 W obwohl kürzlich erzeugt wurde — nachts still), optionales Tagesfenster und Überwachung einzelner PV-Eingänge — wahlweise im Vergleich zu den anderen Eingängen und/oder als Einbruch gegen die eigene jüngste Erzeugung (erkennt defekte Module auch, wenn mehrere Eingänge gleichzeitig betroffen sind). Zustellung als macOS-Mitteilung plus ⚠-Eintrag im Menü, solange die Bedingung anhält.
+- EN: Warnings (all off by default, "Warnings" tab): battery below threshold (fires once, with hysteresis), PV collapse (0 W despite recent production — silent at night), optional daytime window, and monitoring of individual PV inputs — either compared to the other inputs and/or as a dip against the input's own recent production (catches defective panels even when several inputs are affected at once). Delivered as macOS notifications plus a ⚠ menu entry while the condition persists.
+- DE: Neue Datenquelle "Demo (Warnungs-Test)": spielt ein gerafftes Szenario ab (normale Erzeugung → Akku fällt unter die Schwelle → PV-Eingang 2 stirbt → kompletter PV-Einbruch), mit 30-Sekunden-Abruf und 10-fach geraffter Zeit — aktivierte Warnungen feuern so innerhalb weniger Minuten wirklich als Mitteilung.
+- EN: New data source "Demo (warning test)": plays an accelerated scenario (normal production → battery drops below threshold → PV input 2 dies → full PV collapse) with a 30-second refresh and 10× compressed time — enabled warnings actually fire as notifications within a few minutes.
+- DE: Leistung je PV-Eingang: `solix_snapshot.py` reicht die MPPT-Kanäle (`solar_power_1..4`) als `pvWatts` durch. Die "PV-Anzeige" ist je Fläche wählbar — Gesamtwert, Einzelwerte ("438 · 204 W") oder Gesamt + Einzelwerte — getrennt für das Dashboard im Menü, das abgedockte Dashboard-Fenster, die Menüleiste und die abgedockte Leiste (bei den Leisten jeweils für einzeilige und Kompaktansicht). Hinweis: Feldnamen laut anker-solix-api, mangels Hardware unverifiziert — Solarbank 2 Pro/3 melden 4 Kanäle, Solarbank 2 Plus/AC 2, die erste Generation keine.
+- EN: Per-PV-input power: `solix_snapshot.py` forwards the MPPT channels (`solar_power_1..4`) as `pvWatts`. The "PV display" is selectable per surface — total, individual inputs ("438 · 204 W"), or total + individual — separately for the menu dashboard, the detached dashboard window, the menu bar, and the detached bar (bars apply it to single-line and compact views). Note: field names per anker-solix-api, unverified without hardware — Solarbank 2 Pro/3 report 4 channels, Solarbank 2 Plus/AC 2, the first generation none.
+- DE: Behoben: Nach einer Einstellungsänderung zeigte der Graph im Dropdown beim ersten Öffnen keine Linien — die Einblende-Animation lief im Standard-Runloop-Modus, der während der Menü-Anzeige pausiert; die Animation blieb bei 0 stehen. Der Timer läuft jetzt im common-Modus; zusätzlich gleicht der Appearance-Neuaufbau des Dashboards Layout und Höhe ab.
+- EN: Fixed: after a settings change the dropdown graph showed no lines on first open — the reveal animation ran in the default run-loop mode, which pauses while a menu is showing, so it stalled at 0. The timer now runs in common modes; additionally the dashboard's appearance rebuild re-runs layout and height.
+- DE: Werte-Auswahl mit Reihenfolge: Statt fester Häkchen-Raster gibt es sortierbare Listen (Häkchen wählt aus, Ziehen ordnet) — getrennt für Menüleiste und abgedockte Leiste sowie jeweils für einzeilige und Kompaktansicht. Die Kompakt-Listen folgen der einzeiligen, bis man sie entkoppelt.
+- EN: Value selection with ordering: the fixed checkbox grids are now sortable lists (checkbox selects, dragging reorders) — separate for the menu bar and the detached bar, and for the single-line and compact views. Compact lists follow the single-line list until decoupled.
+- DE: Version wird beim Packen automatisch aus der `VERSION`-Datei in die App geschrieben (Anzeige in Einstellungen/Menü stimmt ab jetzt immer); die CI bricht ab, wenn ein Release-Tag nicht zur `VERSION`-Datei passt. Lokal gepackte Bundles werden ad-hoc signiert, damit Mitteilungen funktionieren.
+- EN: The version is injected from the `VERSION` file at packaging time (the settings/menu display is now always correct); CI fails when a release tag does not match the `VERSION` file. Locally packaged bundles are ad-hoc signed so notifications work.
+
+## 0.4.1 - 2026-07-11
+
+- DE: Nutzer-Feedback-Runde: Fensterebene wählbar (Slim-Bar und Dashboard getrennt), Schließen-Kreuz der Slim-Bar nur bei Hover, "Farbige Werte" und "Flussrichtung" getrennt schaltbar je Leiste, Sammel-Metrik "Energiefluss" entfernt (Migration aktiviert die Pfeil-Option), Kompaktansicht mit Richtungspfeilen und Status-Metrik, Slim-Bar-Breite aus echten Layout-Maßen. Releases erhalten automatisch einen Commit-Changelog.
+- EN: User-feedback round: selectable window level (slim bar and dashboard separately), slim-bar close button only on hover, "colored values" and "flow direction" toggle separately per bar, composite "energy flow" metric removed (migration enables the arrows option), compact view with direction arrows and status metric, slim-bar width from real layout metrics. Releases automatically get a commit changelog.
+
+## 0.4.0 - 2026-07-11
+
+- DE: Statusitem weicht der Notch aus: Auf MacBooks mit Notch verdichtet sich die Menüleisten-Anzeige automatisch stufenweise (ohne Bezeichnungen -> ohne Symbole -> kompakt), bis sie vollständig sichtbar ist. Vorher konnte das Item komplett hinter der Notch verschwinden.
+- EN: The status item now avoids the notch: on notched MacBooks the menu bar display degrades stepwise (no labels -> no symbols -> compact) until it is fully visible. Previously the item could disappear behind the notch entirely.
+- DE: SOLIX-Zugangsdaten: Passwort liegt jetzt im macOS-Schlüsselbund; die Env-Datei (neu unter `~/Library/Application Support/SolixBar/solixbar.env`, Rechte 0600) enthält keine Secrets mehr. Die früher fest verdrahteten Pfade ins Home-Verzeichnis des Autors sind entfernt - der eingebaute SOLIX-Login funktioniert damit auf jedem Rechner.
+- EN: SOLIX credentials: the password now lives in the macOS Keychain; the env file (new location `~/Library/Application Support/SolixBar/solixbar.env`, mode 0600) no longer contains secrets. The previously hardcoded paths into the original author's home directory are gone - the built-in SOLIX login now works on any machine.
+- DE: Fehlertoleranz: Ein fehlgeschlagener Abruf leert die Anzeige nicht mehr, sondern behält die letzten Werte mit Warnhinweis. HTTP-Fehler werden klar gemeldet; grosse Befehlsausgaben blockieren nicht mehr (Pipe-Deadlock); haengende Befehle werden hart beendet.
+- EN: Resilience: a failed refresh keeps the last values with a warning instead of blanking the display. HTTP errors are reported clearly; large command output no longer blocks (pipe deadlock); stuck commands are force-killed.
+- DE: Verlauf: Samples werden pro Datenquelle getrennt gespeichert (Demo-Daten verfälschen den Live-Graphen nicht mehr) und als Datei in Application Support. Die Speichergrenze richtet sich nach dem Intervall, sodass die 30-Tage-Ansicht gefüllt werden kann.
+- EN: History: samples are stored per data source (demo data no longer pollutes the live graph) in a file in Application Support. The cap follows the refresh interval so the 30-day view can actually fill.
+- DE: Design: zentrales semantisches Farbsystem (Netzbezug rot = Kosten, Einspeisung violett), praezisere Grafen (runde Stundenticks, klare Achsenzuordnung, Legendenpunkte an den Checkboxen), Demo-Badge im Dashboard, korrekter Dark Mode, Template-Glyph in der Menüleiste.
+- EN: Design: central semantic color system (grid import red = cost, export violet), more precise graphs (round hour ticks, clear axis mapping, legend dots on checkboxes), demo badge in the dashboard, correct dark mode, template glyph in the menu bar.
+- DE: Autostart nutzt SMAppService statt eines LaunchAgent-Plists. Logging zusätzlich ins Unified Logging (Console.app), mit mehr Kontext und optionalem Debug-Modus (`defaults write local.codex.SolixBar verboseLogging -bool true`).
+- EN: Autostart uses SMAppService instead of a LaunchAgent plist. Logging additionally goes to unified logging (Console.app) with more context and an optional debug mode.
+- DE: Neu: Tests (Unit + Offscreen-Render-Snapshots + HTTP-Integrationstests) und GitHub-Actions-Workflow, der fertige App-Bundles baut.
+- EN: New: tests (unit + offscreen render snapshots + HTTP integration tests) and a GitHub Actions workflow producing ready-to-run app bundles.
+
## Unreleased
## 0.3.20 - 2026-07-10
diff --git a/PLAN.md b/PLAN.md
new file mode 100644
index 0000000..f5b1a5f
--- /dev/null
+++ b/PLAN.md
@@ -0,0 +1,113 @@
+# SolixBar — Implementierungsplan (Review-Fixes & Design)
+
+Basis: Code-Review + Live-UI-Review vom 2026-07-11 (Fork `itsab1989/SolixBar`, Branch `review-fixes`).
+Jede Phase endet mit: `swift build` grün, Tests grün, Offscreen-Renders aktualisiert, Commit.
+
+Stand: Phasen 0–4 umgesetzt (25 Tests grün, CI grün). Offen: Phase 5 nach
+visueller Abnahme.
+
+## Phase 0 — Infrastruktur ✅
+
+- [x] Package-Umbau: Library `SolixBarKit` + dünnes Executable (Tests brauchen ein Library-Target).
+- [x] Test-Target `SolixBarTests` (swift-testing; läuft mit reinen Command Line Tools).
+- [x] Render-Snapshots als Tests: Dashboard, Graph, Slim-Bar, alle Settings-Tabs als PNG (hell/dunkel) nach `.build/renders` bzw. `$SOLIXBAR_RENDER_DIR` — visuelle Verifikation ohne Screen-Recording-Berechtigung.
+- [x] `.gitignore`.
+
+## Phase 1 — P0 ✅
+
+1. [x] **Notch-Ausweichen:** Anzeige verdichtet sich stufenweise (ohne Labels → ohne Symbole → 2 Metriken → minimal), bis das Item nicht mehr mit der Notch-Zone kollidiert; Messung über Button→Screen-Konvertierung nach Layout-Delay; Start-Diagnose im Log. Live verifiziert (Level 0→3, landet rechts der Notch).
+2. [x] **Pfade:** Env-Datei unter `~/Library/Application Support/SolixBar/` (0600); Helper-Script aus Repo/Bundle mit `SOLIXBAR_ENV_FILE`; README korrigiert.
+3. [x] **Snapshot bei Fehlern behalten** + ⚠-Indikator.
+
+## Phase 2 — P1 ✅
+
+- [x] Passwort im Schlüsselbund, Injektion als Env-Variable (Env-Datei ohne Secrets).
+- [x] HTTP-Statusprüfung (Integrationstests gegen lokalen HTTP-Server, inkl. 404).
+- [x] Decoder tolerant (`siteName`/`updatedAt` optional mit Defaults).
+- [x] Pipe-Drain-Threads (kein 64-KB-Deadlock, per Test mit 200-KB-Ausgabe belegt), SIGTERM→SIGKILL-Eskalation (Test mit `trap '' TERM`).
+- [x] History pro Datenquelle als Datei; Cap folgt dem Intervall (30-Tage-Ansicht füllbar); Migration des alten UserDefaults-Blobs.
+
+## Phase 3 — Design ✅
+
+- [x] `Theme.swift` (semantische Rollen, hell/dunkel inkl. Menüleisten-Luminanz; Netzbezug rot = Kosten, Einspeisung violett).
+- [x] Slim-Bar: Farben über `.solixRole`-Attribut statt ~180 Zeilen Text-Parsing; Zwei-Stopp-Akzent.
+- [x] Graph: Header über Plotfläche, runde Zeitticks mit Overlap-Schutz vor "Jetzt", nur Solar gefüllt, %-Achse in Akku-Farbe, Legende/Ticks lokalisiert.
+- [x] Dashboard: Radius-Skala 16/12/8, Demo-Badge, Tage-Feld nur bei "Eig.", Checkboxen als farbige Legende, kein unbelegtes "Online" mehr.
+- [x] Dark-Mode-Fix: dynamische Panel-Farben + Layer-Refresh bei Appearance-Wechsel.
+- [x] Menüleiste: Template-Glyph statt 1,5-MB-PNG-Downscale.
+
+## Phase 4 — Wartbarkeit ✅ (Teilumfang)
+
+- [x] Logging: os.Logger (Subsystem `local.codex.SolixBar`) + Datei-Spiegel, offenes FileHandle, gecachter Formatter, `#function`-Kontext, DecodingError/URLError-Details, DEBUG via `defaults write local.codex.SolixBar verboseLogging -bool true`.
+- [x] Settings: Tabs "App"+"Start" zusammengelegt; Text-Preview entprellt (0,4 s); Popups lokalisiert.
+- [x] `SMAppService` statt LaunchAgent-Plist (inkl. Aufräumen des Alt-Plists).
+- [x] CI: GitHub Actions (macos-15) — Tests, Universal-Build, ad-hoc-signiertes App-Bundle als Artifact, Release-Upload bei `v*`-Tags, Render-PNGs als Artifact.
+- [x] CHANGELOG/VERSION → 0.4.0.
+- [x] Nachgezogen (2026-07-11, Runde 2): `MenuBarFormatter` aus dem StatusController extrahiert (~470 Zeilen; Controller jetzt ~770); Metrik-Namen zentral in `MetricLocalization.swift` (echter String-Katalog folgt der System-Locale und kollidiert mit dem In-App-Sprachschalter — bewusst dagegen entschieden); Live-Vorschau der Menüleiste in den Einstellungen (hell+dunkel, echte Engine); Trend-Pfeile (▲/▼) im Dashboard; Hover-Inspektor im großen Graphen; Doppelstart-Schutz; Speichern-Nachfrage beim Schließen ungespeicherter Einstellungen; NumberFormatter-Validierung (Intervall, eig. Zeitraum); toter Code entfernt (energyFlowArrow-Kette, CenteredTextFieldCell).
+
+## Unterwegs gefunden (Selbst-Checks)
+
+- Graph-Container im Dropdown hatte keine Höhen-Constraint → Layout kollabierte, Plot überdeckte den eigenen Header (Ursache der "Header-Kollision"; gefixt).
+- Erste Notch-Messung über `window.frame` war stale → Button→Screen-Konvertierung + 100 ms Layout-Delay (gefixt, live belegt).
+- Zeittick konnte mit "Jetzt"-Label überlappen → 60%-Schrittweiten-Puffer (gefixt).
+- Demo-Daten waren nicht als Demo erkennbar → Demo-Badge (gefixt).
+- `swift test` funktioniert mit reinen CLT (kein Xcode nötig) — gut für Contributor.
+- Schema-Vertrag `solix_snapshot.py` ↔ Decoder verifiziert (alle 10 Felder). End-to-End mit echter Anker-Cloud bleibt ohne Gerät unverifizierbar → Bitte an Maintainer.
+
+## Design-Runde 2 (Nutzer-Feedback vom 2026-07-11, umgesetzt)
+
+- [x] Abgedocktes Dashboard: eigener Fensterrahmen (Panel = Fenster, transparente Titelleiste, schwebende Ampeln) statt doppelter Ecken; keine Konturlinie.
+- [x] Zweizeilige Kompaktanzeige (Menüleiste, Default an; abgedockte Leiste separat schaltbar), Bright-Palette + dynamische Höhe für die dunkle Leiste, Glyphen mit korrektem Seitenverhältnis.
+- [x] Eigener Settings-Tab "Abgedockte Leiste" mit unabhängiger Werte-Auswahl; klickbare Hilfe-Popovers statt toter "?"-Labels.
+- [x] Notch-Regression behoben (Preview-Reset + unbrauchbare Frames versteckter Items) — per App-Log diagnostiziert.
+- [x] Graph: Innen-Box aufgelöst (nur Grundlinie), kräftigere Rasterlinien in beiden Modi; kartenweite Konturen entfernt (Tiefe über Flächen).
+- [x] Slim-Bar: einheitliche Schriftgröße, dezentes Bolt-Glyph statt App-Icon-PNG, Symbol-Close-Button, magnetisches Kanten-Einrasten, lockere Positions-Klemmung.
+- [x] Dashboard: Zeitstempel färbt sich orange bei überfälligen Daten.
+- [x] App-Icon-Vorschlag (weißer Blitz auf Bernstein→Grün-Verlauf) als Bilder generiert — Entscheidung offen (Original behalten / ersetzen / beides ins Issue).
+
+## Optionale visuelle Ideen (für Maintainer-Issue, nicht umgesetzt)
+
+- Live-Vorschau der Menüleisten-Anzeige in den Einstellungen (braucht Extraktion des Formatters aus StatusController).
+- Trend-Indikatoren (▲▼) in den Dashboard-Karten.
+- Hover-Tooltip mit exakten Werten im großen Graphen.
+
+## Phase 5 — Abschluss ✅ (2026-07-11)
+
+- [x] Nutzer hat App + Renders visuell abgenommen (mehrere Iterationsrunden, siehe Design-Runde 2/3).
+- [x] PR im Fork mit Vorher/Nachher-Bildern: https://github.com/itsab1989/SolixBar/pull/1
+- [x] Upstream-Issue: https://github.com/Ravaners/SolixBar/issues/3 (Arbeitspakete mit Datei:Zeile, Repro, Akzeptanzkriterien, Referenz-Commits, Demo-Renders, Icon-Vorschlag, Hardware-Test-Bitte, Optionen-Frage).
+
+## Phase 6 — Nutzer-Feedback-Runde & Veröffentlichung ✅ (2026-07-11, v0.4.1)
+
+- [x] Fensterebene wählbar (vorn/normal/hinten), getrennt für Slim-Bar und abgedocktes Dashboard; wirkt live bei Vorschau/Speichern.
+- [x] Slim-Bar: Schließen-Kreuz nur bei Hover (Platz bleibt reserviert, kein Umbruch); Breite aus echten Layout-Maßen statt Schätzung (kein Leerraum rechts, Mindestbreite 200 pt).
+- [x] "Farbige Werte" und "Flussrichtung" getrennt schaltbar, je Leiste unabhängig; ohne Farben keine .solixRole-Tags (Slim-Bar koloriert sonst selbst).
+- [x] Sammel-Metrik "Energiefluss" entfernt — zeigte dieselben Pfeile/Begriffe wie die Pfeil-Option doppelt; Migration schaltet Betroffenen die Pfeil-Option ein. Vorzeichen bei Netz/Akku-Fluss nur noch ohne Flussrichtung (dann einzige Richtungsinfo).
+- [x] Kompaktanzeige respektiert Flussrichtung (↓/↑/←/→ vor den Werten) und zeigt die Status-Metrik.
+- [x] Render-Fidelity: echtes Verlaufsfenster (Chip-Kopfzeile) statt nackter Graph-View; Slim-Bar pro Appearance neu aufgebaut über Desktop-Verlauf (Offscreen rendert keine Vibrancy); Trend-Pfeile im Dashboard-Render.
+- [x] Neue App-Icon-Entwürfe "Panel als Diagramm" (Variante A geneigt/ansteigend, B ruhig), Generator in `scripts/make_icon_proposal.swift`.
+- [x] CI: Releases bekommen automatisch einen Commit-Changelog seit dem vorherigen Tag.
+- [x] Veröffentlichung: PR gegen Upstream (Ravaners/SolixBar#4), Issue #3 zu einem konsolidierten Beitrag umgeschrieben (natürliches Deutsch, aktuelle Bilder, Optionen-Prompt für die Maintainer-KI), Release v0.4.1 mit Changelog.
+
+## Phase 7 — Funktionsrunde v0.4.2 (2026-07-11)
+
+- [x] Update-Check: täglicher GitHub-Releases-Abgleich, einmalige Mitteilung pro Version + dauerhafter Menüpunkt; abschaltbar. `UpdateChecker.isNewer` mit Test-Matrix.
+- [x] Benachrichtigungs-Infrastruktur: `NotificationManager` (Bundle-Guard — unbundled crasht UserNotifications; Lazy-Autorisierung, Klick öffnet URL). `package_app.sh` signiert ad-hoc wie die CI.
+- [x] Verlaufsfenster abdockbar: Menüpunkt-Toggle, onClose-Muster wie Slim-Bar, Frame-Autosave, Offen-Zustand überlebt Neustart, Fensterebene wählbar.
+- [x] History + Export: Samples erfassen zusätzlich Hauslast/Akku-Fluss (abwärtskompatibel); "Daten exportieren ..." mit CSV/JSON-Format-Popup im Speichern-Dialog.
+- [x] Pro-PV: `pvWatts` durch die ganze Pipeline (Python → Decoder → Dashboard-Zeile, abschaltbar); Demo simuliert 2 Kanäle inkl. Ausfallfenster Minute 40–45. Ohne Hardware unverifiziert (Hinweis an Maintainer).
+- [x] Warnungen (opt-in, eigener Tab): Akku-Schwelle mit Hysterese, PV-Einbruch-Heuristik (nachts still), optionales Tagesfenster, tote MPPT-Kanäle; Mitteilung + ⚠-Menüeinträge. Reine `WarningEngine` mit 8 Szenario-Tests (intervallunabhängig).
+- [x] Vier Metrik-Listen mit Drag & Drop: Auswahl + Reihenfolge je Leiste und je Ansicht (einzeilig/kompakt); Kompakt folgt einzeilig bis zur Entkopplung; keine Reihenfolge-Normalisierung mehr beim Speichern; Leisten-Tabs zweispaltig.
+- [x] Version automatisch: `VERSION`-Datei → Info.plist beim Packen (PlistBuddy, Build-Nummer aus Commit-Zähler); CI verweigert Tags, die nicht zur `VERSION` passen.
+- [x] Nachträge aus der Live-Abnahme: "PV-Anzeige" (Gesamt/Einzeln/Beides) getrennt für Menü-Dashboard, abgedocktes Fenster, Menüleiste und abgedockte Leiste; Graph-Linien fehlten beim ersten Menü-Öffnen (Animations-Timer lief nicht im Menü-Tracking-Modus → .common); Demo simuliert den PV-Kanal-Ausfall nur noch bei aktiver Pro-PV-Warnung; Refresh-Log enthält pvWatts.
+- [x] Live-Abnahme durch Nutzer erfolgt; PR #4 + Issue #3 aktualisiert, Tag v0.4.2.
+- [x] Nachtrag 2 (in v0.4.2 aufgenommen, Release neu getaggt): "Einbruch je PV-Eingang melden" (eigene Kanal-Historie statt Geschwister-Vergleich — Defekt-Erkennung, auch wenn alle Eingänge betroffen sind); Datenquelle "Demo (Warnungs-Test)" mit gerafftem Szenario (30-s-Abruf, Zeit ×10), damit aktivierte Warnungen in Minuten real feuern; Fork-main per Merge auf den Release-Stand (README-Startseite).
+
+## Teststrategie (Querschnitt)
+
+| Ebene | Werkzeug | Deckt ab |
+|---|---|---|
+| Unit | swift-testing | Decoder, History (Energie, Cap, Migration, Quellentrennung), Env-Datei (Quoting, 0600), Display-Stufen, Notch-Prädikat |
+| Integration | Provider gegen echte Prozesse/lokalen HTTP-Server | Pipe-Deadlock, SIGKILL, stderr, Env-Injektion, HTTP 2xx/404 |
+| Visuell | Offscreen-Render-PNGs (hell/dunkel) | Dashboard, Graph, Slim-Bar, Settings |
+| On-Screen | AX/AppleScript (Freigabe erteilt) | Statusitem sichtbar & notch-frei, Menü öffnet |
diff --git a/Package.swift b/Package.swift
index 115bc19..35497a2 100644
--- a/Package.swift
+++ b/Package.swift
@@ -10,9 +10,19 @@ let package = Package(
.executable(name: "SolixBar", targets: ["SolixBar"])
],
targets: [
+ .target(
+ name: "SolixBarKit",
+ path: "Sources/SolixBarKit"
+ ),
.executableTarget(
name: "SolixBar",
+ dependencies: ["SolixBarKit"],
path: "Sources/SolixBar"
+ ),
+ .testTarget(
+ name: "SolixBarTests",
+ dependencies: ["SolixBarKit"],
+ path: "Tests/SolixBarTests"
)
]
)
diff --git a/README.md b/README.md
index c5db984..9ade890 100644
--- a/README.md
+++ b/README.md
@@ -12,17 +12,29 @@ English: Project homepage files are in [`docs/`](docs/) and can be published wit
## Screenshots / Screenshots
-
+Alle Bilder zeigen Demo-Daten. / All images show demo data.
-
+| Dashboard | Verlaufsfenster / History window |
+|---|---|
+|  |  |
-
+Zweizeilige Kompaktanzeige in der Menüleiste und abgedockte schmale Leiste: / Two-line compact menu bar display and detached slim bar:
+
+
+
+
+Einstellungen mit sortierbarer Werte-Auswahl und optionalen Warnungen: / Settings with sortable value lists and optional warnings:
+
+| Menüleiste / Menu bar | Warnungen / Warnings |
+|---|---|
+|  |  |
## Funktionen / Features
- Native AppKit-Menueleisten-App. / Native AppKit menu bar app.
+- Direkter SOLIX-Abruf mit Mail und Passwort — die mitgelieferte Python-Laufzeit macht jede lokale Installation überflüssig. / Direct SOLIX access with email and password — the bundled Python runtime removes any local installation.
- Demo-Modus zum Testen ohne Zugangsdaten. / Demo data mode for testing without credentials.
-- Live-Daten ueber lokalen JSON-Befehl oder JSON-URL. / Live data via local JSON command or JSON URL.
+- Live-Daten alternativ über lokalen JSON-Befehl oder JSON-URL. / Live data alternatively via local JSON command or JSON URL.
- Frei waehlbare Menueleistenwerte, Bezeichnungen, Symbole, App-Symbol und Skalierung. / Configurable menu bar values, labels, symbols, app icon visibility, and scaling.
- Optionale farbige Energiefluss-Pfeile in der Menueleiste. / Optional colored energy-flow arrows in the menu bar.
- Abgedockte schmale Leiste mit Andocken-Funktion, Fixieren gegen versehentliches Verschieben und gespeichertem Zustand. / Detachable slim bar with dock action, optional movement lock, and restored state.
@@ -34,10 +46,17 @@ English: Project homepage files are in [`docs/`](docs/) and can be published wit
- Zeitraeume: Aktuell, 24 Stunden, 7 Tage, 30 Tage und individuell. / Graph ranges: current, 24 hours, 7 days, 30 days, and custom.
- Sichtbare Fragezeichen-Hilfen in den Einstellungen. / Visible question-mark help controls in settings.
- Lokale Logdatei fuer Fehleranalyse: `~/Library/Application Support/SolixBar/SolixBar.log`. / Local log file for troubleshooting.
+- Werte-Auswahl als sortierbare Listen: Häkchen wählt, Ziehen ordnet — je Leiste und je Ansicht (einzeilig/kompakt) unabhängig. / Value selection as sortable lists: checkboxes select, dragging reorders — independent per bar and per view (single-line/compact).
+- Verlaufsgraph als eigenes Fenster abdockbar; Fensterebene (vorn/normal/hinten) für Leiste, Dashboard und Verlaufsfenster getrennt wählbar. / History graph detachable as its own window; window level (front/normal/behind) selectable separately for bar, dashboard, and history window.
+- Datenexport als CSV oder JSON direkt aus dem Menü. / Data export as CSV or JSON right from the menu.
+- Optionale Warnungen: Akku niedrig, PV-Einbruch (nachts still), Zeitfenster, tote oder eingebrochene einzelne PV-Eingänge (Defekt-Erkennung) — als macOS-Mitteilung plus Menü-Hinweis. / Optional warnings: battery low, PV collapse (silent at night), time window, dead or dipped individual PV inputs (defect detection) — as macOS notifications plus a menu indicator.
+- Datenquelle "Demo (Warnungs-Test)": gerafftes Szenario, mit dem alle aktivierten Warnungen in wenigen Minuten wirklich feuern. / "Demo (warning test)" data source: an accelerated scenario that makes all enabled warnings actually fire within minutes.
+- Leistung je PV-Eingang (Solarbank 2/3): "PV-Anzeige" wählbar als Gesamtwert, Einzelwerte oder beides — getrennt für Dashboard im Menü, abgedocktes Fenster, Menüleiste und abgedockte Leiste. / Per-PV-input power (Solarbank 2/3): "PV display" selectable as total, individual inputs, or both — separately per surface.
+- Täglicher Update-Hinweis auf neue GitHub-Releases (abschaltbar, installiert nichts). / Daily update notice for new GitHub releases (can be disabled, installs nothing).
## Version / Version
-Aktuelle Version / Current version: `0.3.20`
+Aktuelle Version / Current version: `0.4.2`
Versionshinweise stehen in [CHANGELOG.md](CHANGELOG.md). / See [CHANGELOG.md](CHANGELOG.md) for release notes.
@@ -64,11 +83,13 @@ open outputs/SolixBar.app
## Datenquellen / Data Source Modes
-SolixBar unterstuetzt drei Modi. / SolixBar supports three modes:
+SolixBar unterstützt fünf Modi. / SolixBar supports five modes:
-- `Demo`: erzeugte Beispieldaten zum Testen der Oberflaeche. / Generated sample data for testing the UI.
-- `Lokaler JSON-Befehl`: fuehrt einen lokalen Befehl aus und liest JSON aus stdout. / Runs a local command and reads JSON from stdout.
-- `JSON-URL`: laedt JSON von einer lokalen oder entfernten HTTP-Adresse. / Fetches JSON from a local or remote HTTP endpoint.
+- `SOLIX-Konto (direkt)`: fragt Anker direkt mit Mail und Passwort ab — empfohlener Modus, keine Installation nötig (Release-Bundle für Apple Silicon). / Queries Anker directly with email and password — recommended mode, no installation needed (release bundle for Apple Silicon).
+- `Demo`: erzeugte Beispieldaten zum Testen der Oberfläche. / Generated sample data for testing the UI.
+- `Demo (Warnungs-Test)`: gerafftes Szenario, mit dem aktivierte Warnungen in Minuten feuern. / Accelerated scenario that makes enabled warnings fire within minutes.
+- `Lokaler JSON-Befehl`: führt einen lokalen Befehl aus und liest JSON aus stdout. / Runs a local command and reads JSON from stdout.
+- `JSON-URL`: lädt JSON von einer lokalen oder entfernten HTTP-Adresse. / Fetches JSON from a local or remote HTTP endpoint.
Das JSON sollte so aussehen. / The JSON should look like this:
@@ -93,52 +114,67 @@ Anker stellt keine stabile oeffentliche SOLIX API bereit. Dieses Projekt enthael
English: Anker does not provide a stable public SOLIX API. This project includes a helper script prepared for the unofficial community library `thomluther/anker-solix-api`.
-Empfohlener lokaler Befehl fuer SolixBar. / Recommended local command for SolixBar:
+Empfohlener Weg: Öffne `Einstellungen` -> `Datenquelle`, wähle
+`SOLIX-Konto (direkt)`, trage Mail, Passwort und Land unter `SOLIX Login` ein
+und drücke `Speichern`. Das Release-Bundle bringt die nötige Python-Laufzeit
+samt SOLIX-Modulen mit — es ist keine Installation nötig. Die Zugangsdaten
+gehen als stdin-JSON an den Helper, teure Statistik-Abfragen werden 10 bzw.
+15 Minuten zwischengespeichert, und nach Fehlern verlängert sich das
+Abrufintervall automatisch (maximal 30 Minuten).
+
+English: Recommended path: open `Einstellungen` -> `Datenquelle`, choose
+`SOLIX-Konto (direkt)`, enter email, password, and country under `SOLIX
+Login`, then press `Speichern`. The release bundle ships the required Python
+runtime with the SOLIX modules — no installation needed. Credentials are
+passed to the helper as stdin JSON, expensive statistics calls are cached for
+10 and 15 minutes, and after failures the refresh interval backs off
+automatically (capped at 30 minutes).
+
+Wer selbst baut, bereitet die portable Laufzeit einmalig vor (lädt
+python-build-standalone und installiert `requirements-solix.txt`); danach
+bettet `package_app.sh` sie automatisch ein. / If you build from source,
+prepare the portable runtime once (downloads python-build-standalone and
+installs `requirements-solix.txt`); `package_app.sh` then embeds it
+automatically:
```bash
-/Users/holger/Documents/Codex/2026-07-06/bi/scripts/run_solix_snapshot.sh
+sh scripts/prepare_solix_runtime.sh
```
-Die App kann die lokale ignorierte Zugangsdaten-Datei fuer dich erstellen. Oeffne
-`Einstellungen` -> `Datenquelle`, waehle `Lokaler JSON-Befehl`, trage Mail,
-Passwort und Land unter `SOLIX Login` ein und druecke `Speichern`.
+Alternativ bleibt der Befehls-Modus erhalten (Script liegt im Repo und in der
+gepackten App). / Alternatively the command mode remains available (the
+script ships in the repo and the packaged app):
-English: The app can create the local ignored credentials file for you. Open
-`Einstellungen` -> `Datenquelle`, choose `Lokaler JSON-Befehl`, enter email,
-password, and country under `SOLIX Login`, then press `Speichern`.
+```bash
+scripts/run_solix_snapshot.sh
+```
Du kannst jederzeit zu `Demo` oder `JSON-URL` wechseln; SolixBar zeigt nur die
-Felder an, die fuer den gewaehlten Modus notwendig sind.
+Felder an, die für den gewählten Modus notwendig sind.
English: You can still switch back to `Demo` or `JSON-URL`; SolixBar only shows
the fields needed for the selected mode.
-SolixBar schreibt diese lokale Datei. / SolixBar writes this local file:
+Das Passwort liegt im macOS-Schlüsselbund. SolixBar schreibt zusätzlich diese lokale Datei (`~/Library/Application Support/SolixBar/solixbar.env`, Rechte 0600, ohne Secrets). / The password lives in the macOS Keychain. SolixBar additionally writes this local file (mode 0600, no secrets):
```bash
ANKER_SOLIX_USER='you@example.com'
-ANKER_SOLIX_PASSWORD='your-password'
ANKER_SOLIX_COUNTRY='DE'
```
-Manueller Beispielbefehl nach Ersetzen der Zugangsdaten. / Manual example command after replacing credentials:
+Manueller Beispielbefehl (Passwort dann selbst als Umgebungsvariable setzen). / Manual example command (set the password env var yourself):
```bash
ANKER_SOLIX_USER="you@example.com" \
ANKER_SOLIX_PASSWORD="..." \
ANKER_SOLIX_COUNTRY="DE" \
-/path/to/python \
-scripts/solix_snapshot.py
+scripts/run_solix_snapshot.sh
```
Trage den Befehl nur dann unter `Lokaler JSON-Befehl` ein, wenn du die eingebauten SOLIX-Login-Felder nicht nutzt.
English: Put the command into SolixBar settings under `Lokaler JSON-Befehl` only if you do not use the built-in SOLIX login fields.
-Aus Sicherheitsgruenden keine Zugangsdaten committen. Eine spaetere Verbesserung sollte Zugangsdaten im macOS-Schluesselbund speichern.
-
-English: For security, avoid committing credentials. A future improvement should store credentials in the macOS Keychain.
-
## Repository-Hinweise / Repository Notes
Das Repository schliesst lokale Build-Produkte, gepackte Apps, Python-Laufzeiten und heruntergeladene API-Checkouts bewusst aus.
diff --git a/Sources/SolixBar/AnimatedPanelView.swift b/Sources/SolixBar/AnimatedPanelView.swift
deleted file mode 100644
index 8bdf4ea..0000000
--- a/Sources/SolixBar/AnimatedPanelView.swift
+++ /dev/null
@@ -1,43 +0,0 @@
-import AppKit
-import QuartzCore
-
-final class AnimatedPanelView: NSView {
- var baseColor: NSColor = .controlBackgroundColor {
- didSet {
- layer?.backgroundColor = baseColor.cgColor
- }
- }
-
- var highlightColor: NSColor = .controlBackgroundColor
-
- init() {
- super.init(frame: .zero)
- wantsLayer = true
- }
-
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-
- override func viewDidMoveToWindow() {
- super.viewDidMoveToWindow()
- if window == nil {
- layer?.removeAnimation(forKey: "softPanelGlow")
- } else {
- startSoftAnimation()
- }
- }
-
- private func startSoftAnimation() {
- guard let layer else { return }
- layer.backgroundColor = baseColor.cgColor
- let animation = CABasicAnimation(keyPath: "backgroundColor")
- animation.fromValue = baseColor.cgColor
- animation.toValue = highlightColor.cgColor
- animation.duration = 3.8
- animation.autoreverses = true
- animation.repeatCount = .infinity
- animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
- layer.add(animation, forKey: "softPanelGlow")
- }
-}
diff --git a/Sources/SolixBar/AppDelegate.swift b/Sources/SolixBar/AppDelegate.swift
deleted file mode 100644
index 7705396..0000000
--- a/Sources/SolixBar/AppDelegate.swift
+++ /dev/null
@@ -1,17 +0,0 @@
-import AppKit
-
-final class AppDelegate: NSObject, NSApplicationDelegate {
- private var statusController: StatusController?
-
- func applicationDidFinishLaunching(_ notification: Notification) {
- UserDefaults.standard.set(0.1, forKey: "NSInitialToolTipDelay")
- AppLogger.info("SolixBar \(AppVersion.display) started. Log: \(AppLogger.logURL.path)")
- statusController = StatusController()
- statusController?.start()
- }
-
- func applicationWillTerminate(_ notification: Notification) {
- statusController?.prepareForTermination()
- AppLogger.info("SolixBar terminated.")
- }
-}
diff --git a/Sources/SolixBar/AppLogger.swift b/Sources/SolixBar/AppLogger.swift
deleted file mode 100644
index 85512ea..0000000
--- a/Sources/SolixBar/AppLogger.swift
+++ /dev/null
@@ -1,61 +0,0 @@
-import Foundation
-
-enum AppLogger {
- private static let lock = NSLock()
- private static let maxLogSize = 512 * 1024
-
- static var logURL: URL {
- let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
- ?? URL(fileURLWithPath: NSTemporaryDirectory())
- return base.appendingPathComponent("SolixBar", isDirectory: true).appendingPathComponent("SolixBar.log")
- }
-
- static func info(_ message: String) {
- write("INFO", message)
- }
-
- static func error(_ message: String) {
- write("ERROR", message)
- }
-
- private static func write(_ level: String, _ message: String) {
- lock.lock()
- defer { lock.unlock() }
-
- do {
- let directory = logURL.deletingLastPathComponent()
- try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
- rotateIfNeeded()
-
- let line = "\(timestamp()) [\(level)] \(message)\n"
- let data = Data(line.utf8)
- if FileManager.default.fileExists(atPath: logURL.path) {
- let handle = try FileHandle(forWritingTo: logURL)
- try handle.seekToEnd()
- try handle.write(contentsOf: data)
- try handle.close()
- } else {
- try data.write(to: logURL, options: .atomic)
- }
- } catch {
- NSLog("SolixBar logging failed: \(error.localizedDescription)")
- }
- }
-
- private static func rotateIfNeeded() {
- guard let size = try? FileManager.default.attributesOfItem(atPath: logURL.path)[.size] as? NSNumber,
- size.intValue > maxLogSize else {
- return
- }
-
- let oldURL = logURL.deletingLastPathComponent().appendingPathComponent("SolixBar.old.log")
- try? FileManager.default.removeItem(at: oldURL)
- try? FileManager.default.moveItem(at: logURL, to: oldURL)
- }
-
- private static func timestamp() -> String {
- let formatter = ISO8601DateFormatter()
- formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
- return formatter.string(from: Date())
- }
-}
diff --git a/Sources/SolixBar/AppSettings.swift b/Sources/SolixBar/AppSettings.swift
deleted file mode 100644
index 90d9174..0000000
--- a/Sources/SolixBar/AppSettings.swift
+++ /dev/null
@@ -1,395 +0,0 @@
-import Foundation
-
-enum DataSourceMode: String {
- case demo
- case command
- case url
-}
-
-enum AppAppearanceMode: String {
- case system
- case light
- case dark
-}
-
-enum AppLanguage: String {
- case german
- case english
-}
-
-enum BarMetric: String, CaseIterable {
- case battery
- case solar
- case home
- case grid
- case batteryFlow
- case flow
- case today
- case total
- case status
-
- var title: String {
- switch self {
- case .battery:
- "Batterie"
- case .solar:
- "PV"
- case .home:
- "Hauslast"
- case .grid:
- "Netzbezug"
- case .batteryFlow:
- "Akku-Fluss"
- case .flow:
- "Energiefluss"
- case .today:
- "Heutiger Ertrag"
- case .total:
- "Gesamtertrag"
- case .status:
- "Status"
- }
- }
-
- var shortTitle: String {
- switch self {
- case .battery:
- "Akku"
- case .solar:
- "PV"
- case .home:
- "Last"
- case .grid:
- "Netz"
- case .batteryFlow:
- "Fluss"
- case .flow:
- "Flow"
- case .today:
- "Ertrag"
- case .total:
- "Gesamt"
- case .status:
- "Status"
- }
- }
-
- var symbolName: String {
- switch self {
- case .battery:
- "battery.75percent"
- case .solar:
- "sun.max.fill"
- case .home:
- "house.fill"
- case .grid:
- "powerplug.fill"
- case .batteryFlow:
- "bolt.fill"
- case .flow:
- "arrow.up.arrow.down.circle.fill"
- case .today:
- "chart.bar.fill"
- case .total:
- "sum"
- case .status:
- "checkmark.circle.fill"
- }
- }
-}
-
-enum HistoryRange: String, CaseIterable {
- case current
- case day
- case week
- case month
- case custom
-
- var title: String {
- switch self {
- case .current:
- "Aktuell"
- case .day:
- "24 Stunden"
- case .week:
- "7 Tage"
- case .month:
- "30 Tage"
- case .custom:
- "Individuell"
- }
- }
-
- var shortTitle: String {
- switch self {
- case .current:
- "Akt."
- case .day:
- "24h"
- case .week:
- "7T"
- case .month:
- "30T"
- case .custom:
- "Eig."
- }
- }
-
- func duration(customDays: Double) -> TimeInterval {
- switch self {
- case .current:
- 3 * 60 * 60
- case .day:
- 24 * 60 * 60
- case .week:
- 7 * 24 * 60 * 60
- case .month:
- 30 * 24 * 60 * 60
- case .custom:
- max(1, customDays) * 24 * 60 * 60
- }
- }
-}
-
-enum GraphMetric: String, CaseIterable {
- case battery
- case solar
- case grid
-
- var title: String {
- switch self {
- case .battery:
- "Akku"
- case .solar:
- "Solar"
- case .grid:
- "Netzbezug"
- }
- }
-}
-
-struct AppSettingsSnapshot {
- var dataSourceMode: DataSourceMode
- var command: String
- var urlString: String
- var refreshInterval: TimeInterval
- var barMetrics: [BarMetric]
- var showMenuBarIcon: Bool
- var showMetricLabels: Bool
- var showMenuBarMetricSymbols: Bool
- var showEnergyFlowArrows: Bool
- var menuBarScale: Double
- var detachedMenuBarScale: Double
- var lockDetachedMenuBar: Bool
- var appearanceMode: AppAppearanceMode
- var appLanguage: AppLanguage
- var historyRange: HistoryRange
- var customHistoryDays: Double
- var graphMetrics: [GraphMetric]
- var isDetachedMenuBarActive: Bool
- var detachedMenuBarFrame: String
-}
-
-@MainActor
-final class AppSettings {
- static let shared = AppSettings()
-
- private let defaults = UserDefaults.standard
- private let defaultBarMetrics: [BarMetric] = [.battery, .solar, .grid]
-
- var dataSourceMode: DataSourceMode {
- get { DataSourceMode(rawValue: defaults.string(forKey: "dataSourceMode") ?? "") ?? .demo }
- set { defaults.set(newValue.rawValue, forKey: "dataSourceMode") }
- }
-
- var command: String {
- get { defaults.string(forKey: "command") ?? "" }
- set { defaults.set(newValue, forKey: "command") }
- }
-
- var urlString: String {
- get { defaults.string(forKey: "urlString") ?? "" }
- set { defaults.set(newValue, forKey: "urlString") }
- }
-
- var refreshInterval: TimeInterval {
- get {
- let value = defaults.double(forKey: "refreshInterval")
- return value > 0 ? max(60, value) : 300
- }
- set { defaults.set(max(60, newValue), forKey: "refreshInterval") }
- }
-
- var barMetrics: [BarMetric] {
- get {
- guard let values = defaults.array(forKey: "barMetrics") as? [String] else {
- return defaultBarMetrics
- }
- let metrics = values.compactMap(BarMetric.init(rawValue:))
- return metrics.isEmpty ? defaultBarMetrics : metrics
- }
- set {
- let metrics = newValue.isEmpty ? defaultBarMetrics : newValue
- defaults.set(metrics.map(\.rawValue), forKey: "barMetrics")
- }
- }
-
- func migrateMenuBarGridMetricIfNeeded() {
- let key = "didMigrateGridMetric033"
- guard defaults.bool(forKey: key) == false else { return }
- if !barMetrics.contains(.grid) {
- barMetrics.append(.grid)
- }
- defaults.set(true, forKey: key)
- }
-
- var showMenuBarIcon: Bool {
- get {
- guard defaults.object(forKey: "showMenuBarIcon") != nil else { return true }
- return defaults.bool(forKey: "showMenuBarIcon")
- }
- set { defaults.set(newValue, forKey: "showMenuBarIcon") }
- }
-
- var showMetricLabels: Bool {
- get {
- guard defaults.object(forKey: "showMetricLabels") != nil else { return true }
- return defaults.bool(forKey: "showMetricLabels")
- }
- set { defaults.set(newValue, forKey: "showMetricLabels") }
- }
-
- var showMenuBarMetricSymbols: Bool {
- get {
- guard defaults.object(forKey: "showMenuBarMetricSymbols") != nil else { return false }
- return defaults.bool(forKey: "showMenuBarMetricSymbols")
- }
- set { defaults.set(newValue, forKey: "showMenuBarMetricSymbols") }
- }
-
- var showEnergyFlowArrows: Bool {
- get {
- guard defaults.object(forKey: "showEnergyFlowArrows") != nil else { return false }
- return defaults.bool(forKey: "showEnergyFlowArrows")
- }
- set { defaults.set(newValue, forKey: "showEnergyFlowArrows") }
- }
-
- var menuBarScale: Double {
- get {
- let value = defaults.double(forKey: "menuBarScale")
- return value > 0 ? min(1.6, max(0.75, value)) : 1.0
- }
- set { defaults.set(min(1.6, max(0.75, newValue)), forKey: "menuBarScale") }
- }
-
- var detachedMenuBarScale: Double {
- get {
- let value = defaults.double(forKey: "detachedMenuBarScale")
- return value > 0 ? min(1.9, max(0.75, value)) : 1.0
- }
- set { defaults.set(min(1.9, max(0.75, newValue)), forKey: "detachedMenuBarScale") }
- }
-
- var lockDetachedMenuBar: Bool {
- get { defaults.bool(forKey: "lockDetachedMenuBar") }
- set { defaults.set(newValue, forKey: "lockDetachedMenuBar") }
- }
-
- var appearanceMode: AppAppearanceMode {
- get { AppAppearanceMode(rawValue: defaults.string(forKey: "appearanceMode") ?? "") ?? .system }
- set { defaults.set(newValue.rawValue, forKey: "appearanceMode") }
- }
-
- var appLanguage: AppLanguage {
- get { AppLanguage(rawValue: defaults.string(forKey: "appLanguage") ?? "") ?? .german }
- set { defaults.set(newValue.rawValue, forKey: "appLanguage") }
- }
-
- var historyRange: HistoryRange {
- get { HistoryRange(rawValue: defaults.string(forKey: "historyRange") ?? "") ?? .day }
- set { defaults.set(newValue.rawValue, forKey: "historyRange") }
- }
-
- var customHistoryDays: Double {
- get {
- let value = defaults.double(forKey: "customHistoryDays")
- return value > 0 ? min(365, max(1, value)) : 14
- }
- set { defaults.set(min(365, max(1, newValue)), forKey: "customHistoryDays") }
- }
-
- var historyDuration: TimeInterval {
- historyRange.duration(customDays: customHistoryDays)
- }
-
- var graphMetrics: [GraphMetric] {
- get {
- guard let values = defaults.array(forKey: "graphMetrics") as? [String] else {
- return GraphMetric.allCases
- }
- let metrics = values.compactMap(GraphMetric.init(rawValue:))
- return metrics.isEmpty ? GraphMetric.allCases : metrics
- }
- set {
- let metrics = newValue.isEmpty ? GraphMetric.allCases : newValue
- defaults.set(metrics.map(\.rawValue), forKey: "graphMetrics")
- }
- }
-
- var isDetachedMenuBarActive: Bool {
- get { defaults.bool(forKey: "isDetachedMenuBarActive") }
- set { defaults.set(newValue, forKey: "isDetachedMenuBarActive") }
- }
-
- var detachedMenuBarFrame: String {
- get { defaults.string(forKey: "detachedMenuBarFrame") ?? "" }
- set { defaults.set(newValue, forKey: "detachedMenuBarFrame") }
- }
-
- func snapshot() -> AppSettingsSnapshot {
- AppSettingsSnapshot(
- dataSourceMode: dataSourceMode,
- command: command,
- urlString: urlString,
- refreshInterval: refreshInterval,
- barMetrics: barMetrics,
- showMenuBarIcon: showMenuBarIcon,
- showMetricLabels: showMetricLabels,
- showMenuBarMetricSymbols: showMenuBarMetricSymbols,
- showEnergyFlowArrows: showEnergyFlowArrows,
- menuBarScale: menuBarScale,
- detachedMenuBarScale: detachedMenuBarScale,
- lockDetachedMenuBar: lockDetachedMenuBar,
- appearanceMode: appearanceMode,
- appLanguage: appLanguage,
- historyRange: historyRange,
- customHistoryDays: customHistoryDays,
- graphMetrics: graphMetrics,
- isDetachedMenuBarActive: isDetachedMenuBarActive,
- detachedMenuBarFrame: detachedMenuBarFrame
- )
- }
-
- func apply(_ snapshot: AppSettingsSnapshot) {
- dataSourceMode = snapshot.dataSourceMode
- command = snapshot.command
- urlString = snapshot.urlString
- refreshInterval = snapshot.refreshInterval
- barMetrics = snapshot.barMetrics
- showMenuBarIcon = snapshot.showMenuBarIcon
- showMetricLabels = snapshot.showMetricLabels
- showMenuBarMetricSymbols = snapshot.showMenuBarMetricSymbols
- showEnergyFlowArrows = snapshot.showEnergyFlowArrows
- menuBarScale = snapshot.menuBarScale
- detachedMenuBarScale = snapshot.detachedMenuBarScale
- lockDetachedMenuBar = snapshot.lockDetachedMenuBar
- appearanceMode = snapshot.appearanceMode
- appLanguage = snapshot.appLanguage
- historyRange = snapshot.historyRange
- customHistoryDays = snapshot.customHistoryDays
- graphMetrics = snapshot.graphMetrics
- isDetachedMenuBarActive = snapshot.isDetachedMenuBarActive
- detachedMenuBarFrame = snapshot.detachedMenuBarFrame
- }
-}
diff --git a/Sources/SolixBar/AutostartManager.swift b/Sources/SolixBar/AutostartManager.swift
deleted file mode 100644
index 16441ab..0000000
--- a/Sources/SolixBar/AutostartManager.swift
+++ /dev/null
@@ -1,57 +0,0 @@
-import Foundation
-
-enum AutostartManager {
- private static let label = "local.codex.SolixBar"
-
- static var launchAgentURL: URL {
- FileManager.default.homeDirectoryForCurrentUser
- .appendingPathComponent("Library/LaunchAgents/\(label).plist")
- }
-
- static var isEnabled: Bool {
- FileManager.default.fileExists(atPath: launchAgentURL.path)
- }
-
- static func setEnabled(_ enabled: Bool) throws {
- if enabled {
- try install()
- } else {
- try remove()
- }
- }
-
- private static func install() throws {
- let executableURL = Bundle.main.executableURL ?? URL(fileURLWithPath: CommandLine.arguments[0])
- let appURL = appBundleURL(from: executableURL)
- let launchPath = appURL?.path ?? executableURL.path
- let programArguments = appURL == nil ? [launchPath] : ["/usr/bin/open", "-a", launchPath]
-
- let plist: [String: Any] = [
- "Label": label,
- "ProgramArguments": programArguments,
- "RunAtLoad": true,
- "KeepAlive": false
- ]
-
- let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
- let directory = launchAgentURL.deletingLastPathComponent()
- try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
- try data.write(to: launchAgentURL, options: .atomic)
- }
-
- private static func remove() throws {
- guard isEnabled else { return }
- try FileManager.default.removeItem(at: launchAgentURL)
- }
-
- private static func appBundleURL(from executableURL: URL) -> URL? {
- var url = executableURL
- while url.path != "/" {
- if url.pathExtension == "app" {
- return url
- }
- url.deleteLastPathComponent()
- }
- return nil
- }
-}
diff --git a/Sources/SolixBar/CenteredTextFieldCell.swift b/Sources/SolixBar/CenteredTextFieldCell.swift
deleted file mode 100644
index 4926bc7..0000000
--- a/Sources/SolixBar/CenteredTextFieldCell.swift
+++ /dev/null
@@ -1,11 +0,0 @@
-import AppKit
-
-final class CenteredTextFieldCell: NSTextFieldCell {
- override func drawingRect(forBounds rect: NSRect) -> NSRect {
- var drawingRect = super.drawingRect(forBounds: rect)
- let textSize = cellSize(forBounds: rect)
- drawingRect.origin.y += max(0, (rect.height - textSize.height) / 2)
- drawingRect.size.height = min(textSize.height, rect.height)
- return drawingRect
- }
-}
diff --git a/Sources/SolixBar/HistoryGraphMenuView.swift b/Sources/SolixBar/HistoryGraphMenuView.swift
deleted file mode 100644
index 115005f..0000000
--- a/Sources/SolixBar/HistoryGraphMenuView.swift
+++ /dev/null
@@ -1,184 +0,0 @@
-import AppKit
-
-@MainActor
-final class HistoryGraphMenuView: NSView {
- private let settings = AppSettings.shared
- private let graphProvider: () -> [SolixHistorySample]
- private let onRangeChange: () -> Void
- private let onOpenLarge: () -> Void
- private let segmented = NSSegmentedControl(labels: HistoryRange.allCases.map(\.shortTitle), trackingMode: .selectOne, target: nil, action: nil)
- private let customDaysField = NSTextField()
- private let graphContainer = NSView()
- private var graphMetricButtons: [GraphMetric: NSButton] = [:]
-
- init(graphProvider: @escaping () -> [SolixHistorySample], onRangeChange: @escaping () -> Void, onOpenLarge: @escaping () -> Void) {
- self.graphProvider = graphProvider
- self.onRangeChange = onRangeChange
- self.onOpenLarge = onOpenLarge
- super.init(frame: NSRect(x: 0, y: 0, width: 396, height: 282))
- wantsLayer = true
- layer?.cornerRadius = 14
- layer?.backgroundColor = menuBackground.cgColor
- layer?.borderWidth = 1
- layer?.borderColor = NSColor.separatorColor.withAlphaComponent(0.6).cgColor
- buildView()
- reload()
- }
-
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-
- private func buildView() {
- let title = NSTextField(labelWithString: "Verlauf")
- title.font = .boldSystemFont(ofSize: 13)
- title.textColor = .labelColor
- title.toolTip = "Zeigt den zeitlichen Verlauf von Akku, Solar und Netzbezug."
-
- segmented.target = self
- segmented.action = #selector(changeRange)
- segmented.segmentStyle = .rounded
- segmented.controlSize = .small
- segmented.font = .systemFont(ofSize: 11, weight: .semibold)
- segmented.toolTip = "Wählt den Zeitraum für den Graphen."
-
- customDaysField.placeholderString = "Tage"
- customDaysField.target = self
- customDaysField.action = #selector(changeCustomDays)
- customDaysField.cell = CenteredTextFieldCell(textCell: "")
- customDaysField.alignment = .center
- customDaysField.font = .monospacedDigitSystemFont(ofSize: 12, weight: .semibold)
- customDaysField.backgroundColor = fieldBackground
- customDaysField.textColor = .labelColor
- customDaysField.toolTip = "Anzahl der Tage für den individuellen Zeitraum."
-
- let metricControls = graphMetricControls()
-
- for view in [title, segmented, customDaysField, metricControls, graphContainer] {
- view.translatesAutoresizingMaskIntoConstraints = false
- addSubview(view)
- }
-
- NSLayoutConstraint.activate([
- title.topAnchor.constraint(equalTo: topAnchor, constant: 12),
- title.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 13),
-
- segmented.centerYAnchor.constraint(equalTo: title.centerYAnchor),
- segmented.leadingAnchor.constraint(equalTo: title.trailingAnchor, constant: 12),
- segmented.widthAnchor.constraint(equalToConstant: 220),
- segmented.heightAnchor.constraint(equalToConstant: 25),
-
- customDaysField.centerYAnchor.constraint(equalTo: title.centerYAnchor),
- customDaysField.leadingAnchor.constraint(equalTo: segmented.trailingAnchor, constant: 8),
- customDaysField.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -13),
- customDaysField.widthAnchor.constraint(equalToConstant: 54),
- customDaysField.heightAnchor.constraint(equalToConstant: 25),
-
- metricControls.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 10),
- metricControls.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 13),
- metricControls.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -13),
- metricControls.heightAnchor.constraint(equalToConstant: 24),
-
- graphContainer.topAnchor.constraint(equalTo: metricControls.bottomAnchor, constant: 8),
- graphContainer.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 13),
- graphContainer.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -13),
- graphContainer.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -12)
- ])
- }
-
- private func graphMetricControls() -> NSStackView {
- let stack = NSStackView()
- stack.orientation = .horizontal
- stack.spacing = 8
-
- for metric in GraphMetric.allCases {
- let button = NSButton(checkboxWithTitle: metric.title, target: self, action: #selector(changeGraphMetrics))
- button.font = .systemFont(ofSize: 11, weight: .semibold)
- button.controlSize = .small
- button.toolTip = "Blendet \(metric.title) im Graphen ein oder aus."
- graphMetricButtons[metric] = button
- stack.addArrangedSubview(button)
- }
- return stack
- }
-
- private func reload() {
- segmented.selectedSegment = HistoryRange.allCases.firstIndex(of: settings.historyRange) ?? 0
- customDaysField.stringValue = String(Int(settings.customHistoryDays))
- customDaysField.isEnabled = settings.historyRange == .custom
- let selectedMetrics = Set(settings.graphMetrics)
- for metric in GraphMetric.allCases {
- graphMetricButtons[metric]?.state = selectedMetrics.contains(metric) ? .on : .off
- }
-
- graphContainer.subviews.forEach { $0.removeFromSuperview() }
- let graph = HistoryGraphView(
- samples: graphProvider(),
- rangeTitle: settings.historyRange.title,
- range: settings.historyRange,
- rangeDuration: settings.historyDuration,
- visibleMetrics: settings.graphMetrics,
- size: NSSize(width: 370, height: 191)
- )
- graph.onClick = onOpenLarge
- graph.translatesAutoresizingMaskIntoConstraints = false
- graphContainer.addSubview(graph)
-
- NSLayoutConstraint.activate([
- graph.topAnchor.constraint(equalTo: graphContainer.topAnchor),
- graph.leadingAnchor.constraint(equalTo: graphContainer.leadingAnchor),
- graph.trailingAnchor.constraint(equalTo: graphContainer.trailingAnchor),
- graph.bottomAnchor.constraint(equalTo: graphContainer.bottomAnchor)
- ])
- }
-
- @objc private func changeRange() {
- settings.historyRange = HistoryRange.allCases[safe: segmented.selectedSegment] ?? .day
- AppLogger.info("Dashboard graph range changed to \(settings.historyRange.rawValue).")
- reload()
- onRangeChange()
- }
-
- @objc private func changeCustomDays() {
- settings.customHistoryDays = Double(max(1, customDaysField.integerValue))
- AppLogger.info("Dashboard graph custom days changed to \(Int(settings.customHistoryDays)).")
- reload()
- onRangeChange()
- }
-
- @objc private func changeGraphMetrics() {
- let selected = GraphMetric.allCases.filter { graphMetricButtons[$0]?.state == .on }
- settings.graphMetrics = selected.isEmpty ? GraphMetric.allCases : selected
- let metricNames = settings.graphMetrics.map(\.rawValue).joined(separator: ",")
- AppLogger.info("Dashboard graph metrics changed to \(metricNames).")
- reload()
- onRangeChange()
- }
-
- override func draw(_ dirtyRect: NSRect) {
- menuBackground.setFill()
- bounds.fill()
- }
-
- private var menuBackground: NSColor {
- NSColor(name: nil) { appearance in
- appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
- ? NSColor(calibratedRed: 0.105, green: 0.115, blue: 0.125, alpha: 1)
- : NSColor(calibratedRed: 1, green: 1, blue: 1, alpha: 1)
- }
- }
-
- private var fieldBackground: NSColor {
- NSColor(name: nil) { appearance in
- appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
- ? NSColor(calibratedRed: 0.16, green: 0.17, blue: 0.18, alpha: 1)
- : NSColor.white
- }
- }
-}
-
-private extension Array {
- subscript(safe index: Int) -> Element? {
- indices.contains(index) ? self[index] : nil
- }
-}
diff --git a/Sources/SolixBar/HistoryGraphView.swift b/Sources/SolixBar/HistoryGraphView.swift
deleted file mode 100644
index 7cc58f6..0000000
--- a/Sources/SolixBar/HistoryGraphView.swift
+++ /dev/null
@@ -1,461 +0,0 @@
-import AppKit
-
-final class HistoryGraphView: NSView {
- private let samples: [SolixHistorySample]
- private let rangeTitle: String
- private let range: HistoryRange
- private let rangeDuration: TimeInterval
- private let visibleMetrics: [GraphMetric]
- private var animationProgress: CGFloat = 0
- private var animationTimer: Timer?
- private let animationStart = Date()
- var onClick: (() -> Void)?
-
- private let batteryColor = NSColor(calibratedRed: 0.05, green: 0.70, blue: 0.35, alpha: 1)
- private let solarColor = NSColor(calibratedRed: 1.00, green: 0.62, blue: 0.03, alpha: 1)
- private let gridColor = NSColor(calibratedRed: 0.92, green: 0.10, blue: 0.16, alpha: 1)
-
- init(
- samples: [SolixHistorySample],
- rangeTitle: String,
- range: HistoryRange = AppSettings.shared.historyRange,
- rangeDuration: TimeInterval = AppSettings.shared.historyDuration,
- visibleMetrics: [GraphMetric] = AppSettings.shared.graphMetrics,
- size: NSSize = NSSize(width: 320, height: 170)
- ) {
- self.samples = samples.sorted { $0.date < $1.date }
- self.rangeTitle = rangeTitle
- self.range = range
- self.rangeDuration = rangeDuration
- self.visibleMetrics = visibleMetrics.isEmpty ? GraphMetric.allCases : visibleMetrics
- super.init(frame: NSRect(origin: .zero, size: size))
- wantsLayer = true
- layer?.cornerRadius = 12
- layer?.backgroundColor = graphBackground.cgColor
- layer?.borderWidth = 1
- layer?.borderColor = NSColor.separatorColor.withAlphaComponent(0.45).cgColor
- toolTip = LocalizedText.text(
- "Zeigt die aktivierten Werte im gewählten Zeitraum. Klick öffnet die große Ansicht.",
- "Shows the enabled values for the selected period. Click to open the large view."
- )
- startLineAnimation()
- }
-
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-
- override func mouseDown(with event: NSEvent) {
- onClick?()
- }
-
- override func draw(_ dirtyRect: NSRect) {
- super.draw(dirtyRect)
- drawBackground()
- drawHeader()
- let isCompact = bounds.height < 220
- if !isCompact {
- drawLegend()
- }
-
- let topInset: CGFloat = isCompact ? 58 : 78
- let bottomInset: CGFloat = isCompact ? 42 : 48
- let leftInset: CGFloat = isCompact ? 64 : 70
- let rightInset: CGFloat = isCompact ? 70 : 82
- let plot = NSRect(
- x: leftInset,
- y: bottomInset,
- width: max(80, bounds.width - leftInset - rightInset),
- height: max(48, bounds.height - topInset - bottomInset)
- )
- let maxPower = maxPowerValue()
- drawPlotSurface(in: plot)
- drawGrid(in: plot, maxPower: maxPower)
- drawAxes(in: plot)
- drawTimeLabels(in: plot)
-
- guard samples.count >= 2 else {
- drawEmptyState()
- return
- }
-
- if visibleMetrics.contains(.battery) {
- drawLine(values: animatedPoints(batteryPoints(in: plot)), color: batteryColor, width: 3.1, baseline: plot.minY)
- }
- if visibleMetrics.contains(.solar) {
- drawLine(values: animatedPoints(solarPoints(in: plot, maxPower: maxPower)), color: solarColor, width: 3.1, baseline: plot.minY)
- }
- if visibleMetrics.contains(.grid) {
- drawLine(values: animatedPoints(gridPoints(in: plot, maxPower: maxPower)), color: gridColor, width: 3.1, baseline: plot.minY)
- }
- }
-
- private func startLineAnimation() {
- animationTimer = Timer.scheduledTimer(
- timeInterval: 1.0 / 30.0,
- target: self,
- selector: #selector(tickLineAnimation(_:)),
- userInfo: nil,
- repeats: true
- )
- }
-
- @objc private func tickLineAnimation(_ timer: Timer) {
- let elapsed = Date().timeIntervalSince(animationStart)
- let progress = min(1, elapsed / 0.9)
- animationProgress = easeOutCubic(CGFloat(progress))
- needsDisplay = true
- if progress >= 1 {
- timer.invalidate()
- animationTimer = nil
- }
- }
-
- private func drawBackground() {
- let path = NSBezierPath(roundedRect: bounds.insetBy(dx: 0.5, dy: 0.5), xRadius: 12, yRadius: 12)
- if let gradient = NSGradient(colors: [graphBackgroundTop, graphBackground]) {
- gradient.draw(in: path, angle: -90)
- } else {
- graphBackground.setFill()
- path.fill()
- }
- }
-
- private func drawPlotSurface(in rect: NSRect) {
- let plot = NSBezierPath(roundedRect: rect.insetBy(dx: -5, dy: -5), xRadius: 10, yRadius: 10)
- plotSurfaceColor.setFill()
- plot.fill()
-
- NSColor.separatorColor.withAlphaComponent(0.30).setStroke()
- plot.lineWidth = 1
- plot.stroke()
- }
-
- private func drawHeader() {
- drawText(
- "\(LocalizedText.text("Verlauf", "History")) \(localizedRangeTitle(rangeTitle))",
- at: NSPoint(x: 16, y: bounds.maxY - 25),
- font: .boldSystemFont(ofSize: bounds.height < 180 ? 12 : 13),
- color: .labelColor
- )
- }
-
- private func drawEmptyState() {
- drawText(
- LocalizedText.text("Noch nicht genug Messpunkte", "Not enough measurements yet"),
- at: NSPoint(x: 18, y: bounds.midY - 6),
- font: .systemFont(ofSize: 13, weight: .medium),
- color: .secondaryLabelColor
- )
- }
-
- private func localizedRangeTitle(_ title: String) -> String {
- guard AppSettings.shared.appLanguage == .english else { return title }
- switch title {
- case "Aktuell":
- return "Current"
- case "24 Stunden":
- return "24 Hours"
- case "7 Tage":
- return "7 Days"
- case "30 Tage":
- return "30 Days"
- case "Individuell":
- return "Custom"
- default:
- return title
- }
- }
-
- private func drawGrid(in rect: NSRect, maxPower: Int) {
- let gridPath = NSBezierPath()
- for index in 0...4 {
- let y = rect.minY + (rect.height / 4) * CGFloat(index)
- gridPath.move(to: NSPoint(x: rect.minX, y: y))
- gridPath.line(to: NSPoint(x: rect.maxX, y: y))
-
- let percent = index * 25
- drawText(
- "\(percent)%",
- at: NSPoint(x: rect.minX - 50, y: y - 7),
- font: .monospacedDigitSystemFont(ofSize: 10, weight: .medium),
- color: .secondaryLabelColor
- )
-
- let watts = Int(round(Double(maxPower) * Double(index) / 4.0))
- drawText(
- "\(watts)W",
- at: NSPoint(x: rect.maxX + 10, y: y - 7),
- font: .monospacedDigitSystemFont(ofSize: 10, weight: .medium),
- color: .secondaryLabelColor
- )
- }
- gridLineColor.setStroke()
- gridPath.lineWidth = 0.8
- gridPath.stroke()
- }
-
- private func drawAxes(in rect: NSRect) {
- let path = NSBezierPath()
- path.move(to: NSPoint(x: rect.minX, y: rect.minY))
- path.line(to: NSPoint(x: rect.minX, y: rect.maxY))
- path.move(to: NSPoint(x: rect.maxX, y: rect.minY))
- path.line(to: NSPoint(x: rect.maxX, y: rect.maxY))
- path.move(to: NSPoint(x: rect.minX, y: rect.minY))
- path.line(to: NSPoint(x: rect.maxX, y: rect.minY))
- axisColor.setStroke()
- path.lineWidth = 1.1
- path.stroke()
- }
-
- private func drawTimeLabels(in rect: NSRect) {
- let domain = timeDomain()
- for tick in timeTicks(for: domain) {
- let progress = domain.end.timeIntervalSince(domain.start) > 0
- ? tick.date.timeIntervalSince(domain.start) / domain.end.timeIntervalSince(domain.start)
- : 0
- let date = domain.start.addingTimeInterval(domain.end.timeIntervalSince(domain.start) * progress)
- let text = tick.label ?? timeLabel(for: date, isLast: tick.isLast)
- let font = NSFont.systemFont(ofSize: 10, weight: .medium)
- let width = (text as NSString).size(withAttributes: [.font: font]).width
- let x = rect.minX + rect.width * CGFloat(progress)
- let clampedX = min(rect.maxX - width, max(rect.minX, x - width / 2))
- drawText(text, at: NSPoint(x: clampedX, y: max(13, rect.minY - 28)), font: font, color: .secondaryLabelColor)
-
- let tick = NSBezierPath()
- tick.move(to: NSPoint(x: x, y: rect.minY))
- tick.line(to: NSPoint(x: x, y: rect.minY - 4))
- axisColor.setStroke()
- tick.lineWidth = 1
- tick.stroke()
- }
- }
-
- private func batteryPoints(in rect: NSRect) -> [NSPoint] {
- normalizedPoints(in: rect) { sample in
- guard let percent = sample.batteryPercent else { return nil }
- return min(1, max(0, Double(percent) / 100))
- }
- }
-
- private func solarPoints(in rect: NSRect, maxPower: Int) -> [NSPoint] {
- normalizedPoints(in: rect) { sample in
- guard let watts = sample.solarWatts else { return nil }
- return min(1, max(0, Double(watts) / Double(maxPower)))
- }
- }
-
- private func gridPoints(in rect: NSRect, maxPower: Int) -> [NSPoint] {
- normalizedPoints(in: rect) { sample in
- guard let watts = sample.gridWatts else { return nil }
- return min(1, max(0, Double(max(0, watts)) / Double(maxPower)))
- }
- }
-
- private func normalizedPoints(in rect: NSRect, value: (SolixHistorySample) -> Double?) -> [NSPoint] {
- let domain = timeDomain()
- let first = domain.start.timeIntervalSince1970
- let last = domain.end.timeIntervalSince1970
- guard last > first else { return [] }
-
- return samples.compactMap { sample in
- guard let normalized = value(sample) else { return nil }
- let timestamp = sample.date.timeIntervalSince1970
- guard timestamp >= first, timestamp <= last else { return nil }
- let x = rect.minX + rect.width * CGFloat((timestamp - first) / (last - first))
- let y = rect.minY + rect.height * CGFloat(normalized)
- return NSPoint(x: x, y: y)
- }
- }
-
- private func timeDomain() -> (start: Date, end: Date) {
- let now = Date()
- let end = max(samples.last?.date ?? now, now)
- let duration = max(60 * 60, rangeDuration)
- return (end.addingTimeInterval(-duration), end)
- }
-
- private func timeTicks(for domain: (start: Date, end: Date)) -> [(date: Date, label: String?, isLast: Bool)] {
- let count: Int
- switch range {
- case .current:
- count = bounds.width < 430 ? 4 : 5
- case .day:
- count = bounds.width < 430 ? 5 : 7
- case .week:
- count = 8
- case .month:
- count = bounds.width < 430 ? 7 : 11
- case .custom:
- count = rangeDuration <= 10 * 24 * 60 * 60 ? 8 : (bounds.width < 430 ? 7 : 11)
- }
-
- return (0.. String {
- if isLast { return "Jetzt" }
- let formatter = DateFormatter()
- formatter.locale = Locale(identifier: "de_DE")
- switch range {
- case .current, .day:
- formatter.dateFormat = "HH:mm"
- case .week:
- formatter.dateFormat = "dd.MM"
- case .month:
- formatter.dateFormat = "dd.MM"
- case .custom:
- formatter.dateFormat = rangeDuration <= 35 * 24 * 60 * 60 ? "dd.MM" : "MM/yy"
- }
- return formatter.string(from: date)
- }
-
- private func drawLine(values points: [NSPoint], color: NSColor, width: CGFloat, baseline: CGFloat) {
- guard points.count >= 2 else { return }
- let fillPath = NSBezierPath()
- fillPath.move(to: NSPoint(x: points[0].x, y: baseline))
- for point in points {
- fillPath.line(to: point)
- }
- if let last = points.last {
- fillPath.line(to: NSPoint(x: last.x, y: baseline))
- }
- fillPath.close()
- color.withAlphaComponent(0.10).setFill()
- fillPath.fill()
-
- let path = NSBezierPath()
- path.move(to: points[0])
- for point in points.dropFirst() {
- path.line(to: point)
- }
- let shadow = NSShadow()
- shadow.shadowColor = color.withAlphaComponent(0.24)
- shadow.shadowBlurRadius = 5
- shadow.shadowOffset = NSSize(width: 0, height: -1)
-
- NSGraphicsContext.saveGraphicsState()
- shadow.set()
- color.setStroke()
- path.lineWidth = width
- path.lineJoinStyle = .round
- path.lineCapStyle = .round
- path.stroke()
- NSGraphicsContext.restoreGraphicsState()
-
- if let point = points.last {
- color.setFill()
- NSBezierPath(ovalIn: NSRect(x: point.x - 4.5, y: point.y - 4.5, width: 9, height: 9)).fill()
- graphBackground.setFill()
- NSBezierPath(ovalIn: NSRect(x: point.x - 2.0, y: point.y - 2.0, width: 4, height: 4)).fill()
- }
- }
-
- private func animatedPoints(_ points: [NSPoint]) -> [NSPoint] {
- guard points.count >= 2, animationProgress < 1 else { return points }
- let target = CGFloat(points.count - 1) * animationProgress
- let fullIndex = max(0, min(points.count - 1, Int(target.rounded(.down))))
- var visible = Array(points.prefix(fullIndex + 1))
- guard fullIndex < points.count - 1 else { return visible }
- let fraction = target - CGFloat(fullIndex)
- let start = points[fullIndex]
- let end = points[fullIndex + 1]
- visible.append(NSPoint(
- x: start.x + (end.x - start.x) * fraction,
- y: start.y + (end.y - start.y) * fraction
- ))
- return visible
- }
-
- private func drawLegend() {
- let y = bounds.maxY - (bounds.height < 180 ? 46 : 52)
- var x: CGFloat = 18
- let maxWidth = bounds.width - 36
- let compact = maxWidth < 310
- if visibleMetrics.contains(.battery) {
- drawLegendItem(title: "Akku", color: batteryColor, x: x, y: y, width: compact ? 50 : 58)
- x += compact ? 56 : 66
- }
- if visibleMetrics.contains(.solar) {
- drawLegendItem(title: "Solar", color: solarColor, x: x, y: y, width: compact ? 54 : 64)
- x += compact ? 60 : 72
- }
- if visibleMetrics.contains(.grid) {
- drawLegendItem(title: "Netz", color: gridColor, x: x, y: y, width: compact ? 50 : 58)
- }
- }
-
- private func drawLegendItem(title: String, color: NSColor, x: CGFloat, y: CGFloat, width: CGFloat) {
- let badgeRect = NSRect(x: x - 7, y: y - 5, width: width, height: 20)
- (color.withAlphaComponent(0.18).blended(withFraction: 0.20, of: graphBackground) ?? color.withAlphaComponent(0.18)).setFill()
- NSBezierPath(roundedRect: badgeRect, xRadius: 7, yRadius: 7).fill()
-
- color.withAlphaComponent(0.45).setStroke()
- let border = NSBezierPath(roundedRect: badgeRect, xRadius: 7, yRadius: 7)
- border.lineWidth = 1
- border.stroke()
-
- color.setFill()
- NSBezierPath(ovalIn: NSRect(x: x, y: y + 1, width: 8, height: 8)).fill()
- drawText(title, at: NSPoint(x: x + 12, y: y - 2), font: .systemFont(ofSize: 11, weight: .bold), color: color)
- }
-
- private func maxPowerValue() -> Int {
- let values = samples.flatMap { sample -> [Int] in
- [
- visibleMetrics.contains(.solar) ? (sample.solarWatts ?? 0) : 0,
- visibleMetrics.contains(.grid) ? max(0, sample.gridWatts ?? 0) : 0
- ]
- }
- let rawMax = max(2000, values.max() ?? 2000)
- return Int(ceil(Double(rawMax) / 500.0) * 500)
- }
-
- private func easeOutCubic(_ value: CGFloat) -> CGFloat {
- 1 - pow(1 - value, 3)
- }
-
- private func drawText(_ text: String, at point: NSPoint, font: NSFont, color: NSColor) {
- let attributes: [NSAttributedString.Key: Any] = [
- .font: font,
- .foregroundColor: color
- ]
- text.draw(at: point, withAttributes: attributes)
- }
-
- private var graphBackground: NSColor {
- NSColor(name: nil) { appearance in
- appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
- ? NSColor(calibratedRed: 0.105, green: 0.115, blue: 0.125, alpha: 1)
- : NSColor(calibratedRed: 1, green: 1, blue: 1, alpha: 1)
- }
- }
-
- private var graphBackgroundTop: NSColor {
- NSColor(name: nil) { appearance in
- appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
- ? NSColor(calibratedRed: 0.13, green: 0.15, blue: 0.16, alpha: 1)
- : NSColor(calibratedRed: 0.96, green: 0.985, blue: 0.98, alpha: 1)
- }
- }
-
- private var plotSurfaceColor: NSColor {
- NSColor(name: nil) { appearance in
- appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
- ? NSColor(calibratedRed: 0.07, green: 0.08, blue: 0.09, alpha: 0.72)
- : NSColor(calibratedRed: 0.985, green: 0.995, blue: 1.0, alpha: 0.92)
- }
- }
-
- private var gridLineColor: NSColor {
- NSColor.separatorColor.withAlphaComponent(0.45)
- }
-
- private var axisColor: NSColor {
- NSColor.separatorColor.withAlphaComponent(0.8)
- }
-}
diff --git a/Sources/SolixBar/LargeGraphWindowController.swift b/Sources/SolixBar/LargeGraphWindowController.swift
deleted file mode 100644
index 2c7e2a3..0000000
--- a/Sources/SolixBar/LargeGraphWindowController.swift
+++ /dev/null
@@ -1,156 +0,0 @@
-import AppKit
-
-@MainActor
-final class LargeGraphWindowController: NSWindowController {
- private let settings = AppSettings.shared
- private let graphProvider: () -> [SolixHistorySample]
- private let segmented = NSSegmentedControl(labels: HistoryRange.allCases.map(\.shortTitle), trackingMode: .selectOne, target: nil, action: nil)
- private let customDaysField = NSTextField()
- private let graphContainer = NSView()
- private var graphMetricButtons: [GraphMetric: NSButton] = [:]
-
- init(graphProvider: @escaping () -> [SolixHistorySample]) {
- self.graphProvider = graphProvider
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 760, height: 480),
- styleMask: [.titled, .closable, .miniaturizable, .resizable],
- backing: .buffered,
- defer: false
- )
- window.title = "SOLIX Verlauf"
- window.minSize = NSSize(width: 620, height: 400)
- window.center()
- super.init(window: window)
- window.contentView = buildView()
- rebuild()
- }
-
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-
- func rebuild() {
- segmented.selectedSegment = HistoryRange.allCases.firstIndex(of: settings.historyRange) ?? 0
- customDaysField.stringValue = String(Int(settings.customHistoryDays))
- customDaysField.isEnabled = settings.historyRange == .custom
-
- let selectedMetrics = Set(settings.graphMetrics)
- for metric in GraphMetric.allCases {
- graphMetricButtons[metric]?.state = selectedMetrics.contains(metric) ? .on : .off
- }
-
- graphContainer.subviews.forEach { $0.removeFromSuperview() }
- let graph = HistoryGraphView(
- samples: graphProvider(),
- rangeTitle: settings.historyRange.title,
- range: settings.historyRange,
- rangeDuration: settings.historyDuration,
- visibleMetrics: settings.graphMetrics,
- size: NSSize(width: 680, height: 360)
- )
- graph.translatesAutoresizingMaskIntoConstraints = false
- graphContainer.addSubview(graph)
-
- NSLayoutConstraint.activate([
- graph.topAnchor.constraint(equalTo: graphContainer.topAnchor),
- graph.leadingAnchor.constraint(equalTo: graphContainer.leadingAnchor),
- graph.trailingAnchor.constraint(equalTo: graphContainer.trailingAnchor),
- graph.bottomAnchor.constraint(equalTo: graphContainer.bottomAnchor)
- ])
- }
-
- private func buildView() -> NSView {
- let container = NSView()
-
- let title = NSTextField(labelWithString: "Verlauf")
- title.font = .boldSystemFont(ofSize: 16)
- title.textColor = .labelColor
-
- segmented.target = self
- segmented.action = #selector(changeRange)
- segmented.segmentStyle = .rounded
- segmented.controlSize = .regular
- segmented.toolTip = "Wählt den Zeitraum für den Graphen."
-
- customDaysField.placeholderString = "Tage"
- customDaysField.target = self
- customDaysField.action = #selector(changeCustomDays)
- customDaysField.cell = CenteredTextFieldCell(textCell: "")
- customDaysField.alignment = .center
- customDaysField.font = .monospacedDigitSystemFont(ofSize: 12, weight: .semibold)
- customDaysField.toolTip = "Anzahl der Tage für den individuellen Zeitraum."
-
- let metricControls = graphMetricControls()
-
- for view in [title, segmented, customDaysField, metricControls, graphContainer] {
- view.translatesAutoresizingMaskIntoConstraints = false
- container.addSubview(view)
- }
-
- NSLayoutConstraint.activate([
- title.topAnchor.constraint(equalTo: container.topAnchor, constant: 18),
- title.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 20),
-
- segmented.centerYAnchor.constraint(equalTo: title.centerYAnchor),
- segmented.leadingAnchor.constraint(equalTo: title.trailingAnchor, constant: 18),
- segmented.widthAnchor.constraint(equalToConstant: 250),
- segmented.heightAnchor.constraint(equalToConstant: 28),
-
- customDaysField.centerYAnchor.constraint(equalTo: title.centerYAnchor),
- customDaysField.leadingAnchor.constraint(equalTo: segmented.trailingAnchor, constant: 10),
- customDaysField.widthAnchor.constraint(equalToConstant: 64),
- customDaysField.heightAnchor.constraint(equalToConstant: 28),
-
- metricControls.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 14),
- metricControls.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 20),
- metricControls.trailingAnchor.constraint(lessThanOrEqualTo: container.trailingAnchor, constant: -20),
- metricControls.heightAnchor.constraint(equalToConstant: 28),
-
- graphContainer.topAnchor.constraint(equalTo: metricControls.bottomAnchor, constant: 12),
- graphContainer.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 20),
- graphContainer.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -20),
- graphContainer.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -20)
- ])
-
- return container
- }
-
- private func graphMetricControls() -> NSStackView {
- let stack = NSStackView()
- stack.orientation = .horizontal
- stack.spacing = 12
-
- for metric in GraphMetric.allCases {
- let button = NSButton(checkboxWithTitle: metric.title, target: self, action: #selector(changeGraphMetrics))
- button.font = .systemFont(ofSize: 12, weight: .semibold)
- button.toolTip = "Blendet \(metric.title) im Graphen ein oder aus."
- graphMetricButtons[metric] = button
- stack.addArrangedSubview(button)
- }
- return stack
- }
-
- @objc private func changeRange() {
- settings.historyRange = historyRange(at: segmented.selectedSegment) ?? .day
- AppLogger.info("Large graph range changed to \(settings.historyRange.rawValue).")
- rebuild()
- }
-
- @objc private func changeCustomDays() {
- settings.customHistoryDays = Double(max(1, customDaysField.integerValue))
- AppLogger.info("Large graph custom days changed to \(Int(settings.customHistoryDays)).")
- rebuild()
- }
-
- @objc private func changeGraphMetrics() {
- let selected = GraphMetric.allCases.filter { graphMetricButtons[$0]?.state == .on }
- settings.graphMetrics = selected.isEmpty ? GraphMetric.allCases : selected
- let metricNames = settings.graphMetrics.map(\.rawValue).joined(separator: ",")
- AppLogger.info("Large graph metrics changed to \(metricNames).")
- rebuild()
- }
-
- private func historyRange(at index: Int) -> HistoryRange? {
- HistoryRange.allCases.indices.contains(index) ? HistoryRange.allCases[index] : nil
- }
-}
diff --git a/Sources/SolixBar/SettingsWindowController.swift b/Sources/SolixBar/SettingsWindowController.swift
deleted file mode 100644
index b67b812..0000000
--- a/Sources/SolixBar/SettingsWindowController.swift
+++ /dev/null
@@ -1,818 +0,0 @@
-import AppKit
-
-@MainActor
-final class SettingsWindowController: NSWindowController, NSWindowDelegate, NSTextFieldDelegate {
- private let settings = AppSettings.shared
- private let onPreview: () -> Void
- private let onSave: () -> Void
- private let onReset: () -> Void
-
- private let modePopup = NSPopUpButton()
- private let commandField = NSTextField()
- private let urlField = NSTextField()
- private let intervalField = NSTextField()
- private let solixEmailField = NSTextField()
- private let solixPasswordField = NSSecureTextField()
- private let solixCountryField = NSTextField()
- private let solixTodayBaseField = NSTextField()
- private let solixTotalBaseField = NSTextField()
- private let commandRow = NSStackView()
- private let urlRow = NSStackView()
- private let solixTitle = NSTextField(labelWithString: "SOLIX Login")
- private let solixHint = NSTextField(wrappingLabelWithString: "Nur fuer den vorbereiteten SOLIX-Befehl. Mail und Passwort werden lokal gespeichert.")
- private let solixEmailRow = NSStackView()
- private let solixPasswordRow = NSStackView()
- private let solixCountryRow = NSStackView()
- private let solixTodayBaseRow = NSStackView()
- private let solixTotalBaseRow = NSStackView()
- private let autostartButton = NSButton(checkboxWithTitle: "Beim Login automatisch starten", target: nil, action: nil)
- private let autostartStatus = NSTextField(labelWithString: "")
- private let showIconButton = NSButton(checkboxWithTitle: "App-Symbol in der Menüleiste anzeigen", target: nil, action: nil)
- private let showLabelsButton = NSButton(checkboxWithTitle: "Werte mit Bezeichnung anzeigen", target: nil, action: nil)
- private let showMetricSymbolsButton = NSButton(checkboxWithTitle: "Symbole vor den Werten anzeigen", target: nil, action: nil)
- private let showEnergyFlowArrowsButton = NSButton(checkboxWithTitle: "Farbige Pfeile beim Energiefluss anzeigen", target: nil, action: nil)
- private let lockDetachedMenuBarButton = NSButton(checkboxWithTitle: "Abgedockte Leiste fixieren", target: nil, action: nil)
- private let scaleSlider = NSSlider(value: 1.0, minValue: 0.75, maxValue: 1.6, target: nil, action: nil)
- private let scaleValue = NSTextField(labelWithString: "100 %")
- private let detachedScaleSlider = NSSlider(value: 1.0, minValue: 0.75, maxValue: 1.9, target: nil, action: nil)
- private let detachedScaleValue = NSTextField(labelWithString: "100 %")
- private let appearancePopup = NSPopUpButton()
- private let languagePopup = NSPopUpButton()
- private var metricButtons: [BarMetric: NSButton] = [:]
- private var originalSettings: AppSettingsSnapshot?
- private var originalAutostart = false
- private var isSaving = false
- private var isLoading = false
-
- init(onPreview: @escaping () -> Void, onSave: @escaping () -> Void, onReset: @escaping () -> Void) {
- self.onPreview = onPreview
- self.onSave = onSave
- self.onReset = onReset
- let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 720, height: 660),
- styleMask: [.titled, .closable, .miniaturizable],
- backing: .buffered,
- defer: false
- )
- window.title = LocalizedText.text("SOLIX Bar Einstellungen", "SOLIX Bar Settings")
- window.center()
- super.init(window: window)
- window.delegate = self
- window.contentView = buildView()
- }
-
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
-
- override func showWindow(_ sender: Any?) {
- originalSettings = settings.snapshot()
- originalAutostart = AutostartManager.isEnabled
- isSaving = false
- loadSettings()
- super.showWindow(sender)
- }
-
- private func buildView() -> NSView {
- let container = NSView()
-
- modePopup.addItems(withTitles: ["Demo", "Lokaler JSON-Befehl", "JSON-URL"])
- appearancePopup.addItems(withTitles: ["Automatisch", "Hell", "Dunkel"])
- languagePopup.addItems(withTitles: ["Deutsch", "English"])
- applyLocalizedControlTitles()
- modePopup.toolTip = "Legt fest, woher SolixBar die Werte lädt."
- appearancePopup.toolTip = "Waehlt helle Darstellung, dunkle Darstellung oder automatisch passend zum macOS-System."
- languagePopup.toolTip = "Waehlt die Sprache fuer sichtbare App-Texte."
- commandField.placeholderString = solixHelperCommand
- commandField.toolTip = "Führt einen lokalen Befehl aus und liest dessen JSON-Ausgabe."
- urlField.placeholderString = "http://127.0.0.1:8787/solix.json"
- urlField.toolTip = "Lädt die Werte von einer JSON-Adresse."
- intervalField.placeholderString = "300"
- intervalField.toolTip = "Zeit zwischen zwei Aktualisierungen in Sekunden."
- solixEmailField.placeholderString = "mail@example.com"
- solixEmailField.toolTip = "E-Mail-Adresse deines Anker/SOLIX-Kontos."
- solixPasswordField.placeholderString = "Passwort"
- solixPasswordField.toolTip = "Passwort deines Anker/SOLIX-Kontos. Wird lokal in work/solixbar.env gespeichert und nicht hochgeladen."
- solixCountryField.placeholderString = "DE"
- solixCountryField.toolTip = "Land deines Anker-Kontos, normalerweise DE."
- solixTodayBaseField.placeholderString = "z.B. 7.2"
- solixTodayBaseField.toolTip = "Optionaler Korrekturwert fuer den heutigen Ertrag in kWh, falls Anker heute 0 kWh meldet. SolixBar zaehlt ab diesem Wert weiter."
- solixTotalBaseField.placeholderString = "z.B. 427.8"
- solixTotalBaseField.toolTip = LocalizedText.text(
- "Optionaler Startwert fuer den Gesamtertrag. Ohne API-Gesamtwert kumuliert SolixBar alle fortlaufenden Solarmessungen lokal.",
- "Optional starting value for total yield. Without an API total, SolixBar locally accumulates all continuous solar measurements."
- )
-
- for textField in [commandField, urlField, intervalField, solixEmailField, solixPasswordField, solixCountryField, solixTodayBaseField, solixTotalBaseField] {
- textField.delegate = self
- }
-
- for control in [modePopup, appearancePopup, languagePopup, showIconButton, showLabelsButton, showMetricSymbolsButton, showEnergyFlowArrowsButton, lockDetachedMenuBarButton, scaleSlider, detachedScaleSlider] {
- control.target = self
- control.action = #selector(applyPreview)
- }
- autostartButton.target = self
- autostartButton.action = #selector(toggleAutostart)
- autostartButton.toolTip = "Startet SolixBar automatisch nach dem Anmelden."
- autostartStatus.textColor = .secondaryLabelColor
- autostartStatus.lineBreakMode = .byTruncatingMiddle
- showIconButton.toolTip = "Zeigt oder versteckt das SolixBar-Symbol in der Menüleiste."
- showLabelsButton.toolTip = "Zeigt kurze Namen wie Akku oder Solar vor den Zahlen."
- showMetricSymbolsButton.toolTip = "Zeigt farbige Symbole direkt vor den Menüleistenwerten."
- showEnergyFlowArrowsButton.toolTip = "Schaltet kontrastreiche Flussfarben, Richtungspfeile und Begriffe wie Laden, Entladen, Bezug und Einspeisen gemeinsam ein oder aus."
- lockDetachedMenuBarButton.toolTip = "Fixiert die abgedockte Leiste, damit sie nicht versehentlich verschoben wird."
- scaleSlider.toolTip = "Vergrößert oder verkleinert Text und Symbole in der Menüleiste."
- scaleValue.toolTip = "Aktuell eingestellte Größe der Menüleistenanzeige."
- detachedScaleSlider.toolTip = "Vergrößert oder verkleinert nur die abgedockte Menüleistenanzeige."
- detachedScaleValue.toolTip = "Aktuell eingestellte Größe der abgedockten Leiste."
-
- let title = NSTextField(labelWithString: "SOLIX Bar \(AppVersion.short)")
- title.font = .boldSystemFont(ofSize: 20)
-
- let tabs = NSTabView()
- tabs.tabViewType = .topTabsBezelBorder
- tabs.addTabViewItem(tab(title: LocalizedText.text("Menüleiste", "Menu Bar"), view: menuBarPane()))
- tabs.addTabViewItem(tab(title: LocalizedText.text("Datenquelle", "Data Source"), view: dataSourcePane()))
- tabs.addTabViewItem(tab(title: LocalizedText.text("App", "App"), view: appPane()))
- tabs.addTabViewItem(tab(title: LocalizedText.text("Start", "Startup"), view: startupPane()))
-
- let cancel = NSButton(title: LocalizedText.text("Abbrechen", "Cancel"), target: self, action: #selector(cancelSettings))
- cancel.bezelStyle = .rounded
- cancel.toolTip = "Verwirft die Vorschau und stellt die alten Einstellungen wieder her."
-
- let save = NSButton(title: LocalizedText.text("Speichern", "Save"), target: self, action: #selector(saveSettings))
- save.bezelStyle = .rounded
- save.keyEquivalent = "\r"
- save.toolTip = "Speichert die aktuellen Einstellungen dauerhaft."
-
- for view in [title, tabs, cancel, save] {
- view.translatesAutoresizingMaskIntoConstraints = false
- container.addSubview(view)
- }
-
- NSLayoutConstraint.activate([
- title.topAnchor.constraint(equalTo: container.topAnchor, constant: 20),
- title.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
-
- tabs.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 16),
- tabs.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 20),
- tabs.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -20),
- tabs.bottomAnchor.constraint(equalTo: save.topAnchor, constant: -18),
-
- cancel.trailingAnchor.constraint(equalTo: save.leadingAnchor, constant: -10),
- cancel.centerYAnchor.constraint(equalTo: save.centerYAnchor),
-
- save.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -24),
- save.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -24)
- ])
-
- return container
- }
-
- private func tab(title: String, view: NSView) -> NSTabViewItem {
- let item = NSTabViewItem(identifier: title)
- item.label = title
- item.view = view
- return item
- }
-
- private func applyLocalizedControlTitles() {
- showIconButton.title = LocalizedText.text("App-Symbol in der Menüleiste anzeigen", "Show app icon in the menu bar")
- showLabelsButton.title = LocalizedText.text("Werte mit Bezeichnung anzeigen", "Show labels next to values")
- showMetricSymbolsButton.title = LocalizedText.text("Symbole vor den Werten anzeigen", "Show symbols before values")
- showEnergyFlowArrowsButton.title = LocalizedText.text("Farben und Flussrichtung anzeigen", "Show colors and flow direction")
- lockDetachedMenuBarButton.title = LocalizedText.text("Abgedockte Leiste fixieren", "Lock detached slim bar")
- autostartButton.title = LocalizedText.text("Beim Login automatisch starten", "Start automatically at login")
- }
-
- private func menuBarPane() -> NSView {
- let container = NSView()
- let metricTitle = sectionTitle(LocalizedText.text("Angezeigte Werte", "Visible Values"))
- let metricGrid = buildMetricGrid()
- let displayTitle = sectionTitle(LocalizedText.text("Darstellung", "Display"))
- let showIconRow = settingRow(showIconButton, help: showIconButton.toolTip ?? "")
- let showLabelsRow = settingRow(showLabelsButton, help: showLabelsButton.toolTip ?? "")
- let showMetricSymbolsRow = settingRow(showMetricSymbolsButton, help: showMetricSymbolsButton.toolTip ?? "")
- let showEnergyFlowArrowsRow = settingRow(showEnergyFlowArrowsButton, help: showEnergyFlowArrowsButton.toolTip ?? "")
- let lockDetachedMenuBarRow = settingRow(lockDetachedMenuBarButton, help: lockDetachedMenuBarButton.toolTip ?? "")
- let scaleRow = NSStackView(views: [label(LocalizedText.text("Skalierung", "Scale")), scaleSlider, scaleValue, helpButton(labelTooltip("Skalierung"))])
- scaleRow.orientation = .horizontal
- scaleRow.spacing = 12
- scaleRow.alignment = .centerY
- scaleValue.alignment = .right
- scaleValue.widthAnchor.constraint(equalToConstant: 56).isActive = true
- let detachedScaleRow = NSStackView(views: [label(LocalizedText.text("Abgedockt", "Detached")), detachedScaleSlider, detachedScaleValue, helpButton(labelTooltip("Abgedockt"))])
- detachedScaleRow.orientation = .horizontal
- detachedScaleRow.spacing = 12
- detachedScaleRow.alignment = .centerY
- detachedScaleValue.alignment = .right
- detachedScaleValue.widthAnchor.constraint(equalToConstant: 56).isActive = true
-
- for view in [metricTitle, metricGrid, displayTitle, showIconRow, showLabelsRow, showMetricSymbolsRow, showEnergyFlowArrowsRow, lockDetachedMenuBarRow, scaleRow, detachedScaleRow] {
- view.translatesAutoresizingMaskIntoConstraints = false
- container.addSubview(view)
- }
-
- NSLayoutConstraint.activate([
- metricTitle.topAnchor.constraint(equalTo: container.topAnchor, constant: 22),
- metricTitle.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
-
- metricGrid.topAnchor.constraint(equalTo: metricTitle.bottomAnchor, constant: 10),
- metricGrid.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
- metricGrid.trailingAnchor.constraint(lessThanOrEqualTo: container.trailingAnchor, constant: -24),
-
- displayTitle.topAnchor.constraint(equalTo: metricGrid.bottomAnchor, constant: 24),
- displayTitle.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
-
- showIconRow.topAnchor.constraint(equalTo: displayTitle.bottomAnchor, constant: 10),
- showIconRow.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
-
- showLabelsRow.topAnchor.constraint(equalTo: showIconRow.bottomAnchor, constant: 8),
- showLabelsRow.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
-
- showMetricSymbolsRow.topAnchor.constraint(equalTo: showLabelsRow.bottomAnchor, constant: 8),
- showMetricSymbolsRow.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
-
- showEnergyFlowArrowsRow.topAnchor.constraint(equalTo: showMetricSymbolsRow.bottomAnchor, constant: 8),
- showEnergyFlowArrowsRow.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
-
- lockDetachedMenuBarRow.topAnchor.constraint(equalTo: showEnergyFlowArrowsRow.bottomAnchor, constant: 8),
- lockDetachedMenuBarRow.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
-
- scaleRow.topAnchor.constraint(equalTo: lockDetachedMenuBarRow.bottomAnchor, constant: 16),
- scaleRow.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
- scaleRow.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -24),
-
- detachedScaleRow.topAnchor.constraint(equalTo: scaleRow.bottomAnchor, constant: 10),
- detachedScaleRow.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
- detachedScaleRow.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -24)
- ])
-
- return container
- }
-
- private func dataSourcePane() -> NSView {
- let container = NSView()
- let title = sectionTitle(LocalizedText.text("Datenquelle", "Data Source"))
- let hint = NSTextField(wrappingLabelWithString: "Die gewählte Datenquelle muss ein JSON-Objekt mit Feldern wie batteryPercent, solarWatts, homeWatts und updatedAt liefern. Mindestintervall: 60 Sekunden.")
- hint.textColor = .secondaryLabelColor
-
- let rows = NSStackView()
- rows.orientation = .vertical
- rows.spacing = 12
-
- solixTitle.font = .boldSystemFont(ofSize: 13)
- solixHint.textColor = .secondaryLabelColor
-
- rows.addArrangedSubview(formRow(labelText: LocalizedText.text("Modus", "Mode"), control: modePopup))
- rows.addArrangedSubview(solixTitle)
- configure(row: solixEmailRow, labelText: LocalizedText.text("Mail", "Email"), control: solixEmailField)
- configure(row: solixPasswordRow, labelText: LocalizedText.text("Passwort", "Password"), control: solixPasswordField)
- configure(row: solixCountryRow, labelText: LocalizedText.text("Land", "Country"), control: solixCountryField)
- configure(row: solixTodayBaseRow, labelText: LocalizedText.text("Ertrag heute", "Yield today"), control: solixTodayBaseField)
- configure(row: solixTotalBaseRow, labelText: LocalizedText.text("Gesamtertrag", "Total yield"), control: solixTotalBaseField)
- rows.addArrangedSubview(solixEmailRow)
- rows.addArrangedSubview(solixPasswordRow)
- rows.addArrangedSubview(solixCountryRow)
- rows.addArrangedSubview(solixTodayBaseRow)
- rows.addArrangedSubview(solixTotalBaseRow)
- rows.addArrangedSubview(solixHint)
- configure(row: commandRow, labelText: LocalizedText.text("Befehl", "Command"), control: commandField)
- configure(row: urlRow, labelText: "URL", control: urlField)
- rows.addArrangedSubview(commandRow)
- rows.addArrangedSubview(urlRow)
- rows.addArrangedSubview(formRow(labelText: LocalizedText.text("Intervall", "Interval"), control: intervalField))
-
- for view in [title, rows, hint] {
- view.translatesAutoresizingMaskIntoConstraints = false
- container.addSubview(view)
- }
-
- NSLayoutConstraint.activate([
- title.topAnchor.constraint(equalTo: container.topAnchor, constant: 22),
- title.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
-
- rows.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 16),
- rows.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
- rows.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -24),
-
- hint.topAnchor.constraint(equalTo: rows.bottomAnchor, constant: 18),
- hint.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
- hint.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -24)
- ])
-
- return container
- }
-
- private func appPane() -> NSView {
- let container = NSView()
- let title = sectionTitle(LocalizedText.text("App-Darstellung", "App Appearance"))
- let appearanceRow = formRow(labelText: LocalizedText.text("Design", "Theme"), control: appearancePopup)
- let languageRow = formRow(labelText: LocalizedText.text("Sprache", "Language"), control: languagePopup)
- let hint = NSTextField(wrappingLabelWithString: LocalizedText.text(
- "Aenderungen wirken sofort als Vorschau. Erst Speichern macht sie dauerhaft.",
- "Changes apply immediately as a preview. Press Save to keep them."
- ))
- hint.textColor = .secondaryLabelColor
-
- for view in [title, appearanceRow, languageRow, hint] {
- view.translatesAutoresizingMaskIntoConstraints = false
- container.addSubview(view)
- }
-
- NSLayoutConstraint.activate([
- title.topAnchor.constraint(equalTo: container.topAnchor, constant: 22),
- title.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
-
- appearanceRow.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 16),
- appearanceRow.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
-
- languageRow.topAnchor.constraint(equalTo: appearanceRow.bottomAnchor, constant: 12),
- languageRow.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
-
- hint.topAnchor.constraint(equalTo: languageRow.bottomAnchor, constant: 18),
- hint.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
- hint.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -24)
- ])
-
- return container
- }
-
- private func formRow(labelText: String, control: NSView) -> NSStackView {
- let row = NSStackView()
- configure(row: row, labelText: labelText, control: control)
- return row
- }
-
- private func configure(row: NSStackView, labelText: String, control: NSView) {
- row.orientation = .horizontal
- row.spacing = 12
- row.alignment = .centerY
-
- let rowLabel = label(labelText)
- rowLabel.alignment = .right
- rowLabel.translatesAutoresizingMaskIntoConstraints = false
- control.translatesAutoresizingMaskIntoConstraints = false
- row.addArrangedSubview(rowLabel)
- row.addArrangedSubview(control)
- row.addArrangedSubview(helpButton(control.toolTip ?? labelTooltip(labelText)))
-
- NSLayoutConstraint.activate([
- rowLabel.widthAnchor.constraint(equalToConstant: 88),
- control.widthAnchor.constraint(equalToConstant: 420)
- ])
- }
-
- private func startupPane() -> NSView {
- let container = NSView()
- let title = sectionTitle(LocalizedText.text("Startverhalten", "Startup"))
-
- let autostartRow = settingRow(autostartButton, help: autostartButton.toolTip ?? "")
-
- for view in [title, autostartRow, autostartStatus] {
- view.translatesAutoresizingMaskIntoConstraints = false
- container.addSubview(view)
- }
-
- NSLayoutConstraint.activate([
- title.topAnchor.constraint(equalTo: container.topAnchor, constant: 22),
- title.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
-
- autostartRow.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 16),
- autostartRow.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
- autostartRow.trailingAnchor.constraint(lessThanOrEqualTo: container.trailingAnchor, constant: -24),
-
- autostartStatus.topAnchor.constraint(equalTo: autostartRow.bottomAnchor, constant: 8),
- autostartStatus.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
- autostartStatus.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -24)
- ])
-
- return container
- }
-
- private func buildMetricGrid() -> NSGridView {
- let rows = [
- [BarMetric.battery, .solar, .home],
- [BarMetric.grid, .batteryFlow, .flow],
- [BarMetric.today, .total, .status]
- ].map { metrics in
- metrics.map { metric in
- let button = NSButton(checkboxWithTitle: localizedMetricTitle(metric), target: self, action: #selector(applyPreview))
- button.toolTip = metricTooltip(metric)
- metricButtons[metric] = button
- return settingRow(button, help: metricTooltip(metric))
- }
- }
-
- let grid = NSGridView(views: rows)
- grid.rowSpacing = 6
- grid.columnSpacing = 16
- return grid
- }
-
- private func sectionTitle(_ text: String) -> NSTextField {
- let label = NSTextField(labelWithString: text)
- label.font = .boldSystemFont(ofSize: 13)
- return label
- }
-
- private func label(_ text: String) -> NSTextField {
- let label = NSTextField(labelWithString: text)
- label.textColor = .secondaryLabelColor
- label.toolTip = labelTooltip(text)
- return label
- }
-
- private func settingRow(_ control: NSView, help: String) -> NSStackView {
- let row = NSStackView(views: [control, helpButton(help)])
- row.orientation = .horizontal
- row.alignment = .centerY
- row.spacing = 6
- return row
- }
-
- private func helpButton(_ tooltip: String) -> NSButton {
- let button = NSButton(title: "?", target: nil, action: nil)
- button.isBordered = false
- button.font = .systemFont(ofSize: 12, weight: .bold)
- button.contentTintColor = .secondaryLabelColor
- button.toolTip = tooltip
- button.setButtonType(.momentaryChange)
- button.translatesAutoresizingMaskIntoConstraints = false
- button.widthAnchor.constraint(equalToConstant: 22).isActive = true
- button.heightAnchor.constraint(equalToConstant: 22).isActive = true
- return button
- }
-
- private func metricTooltip(_ metric: BarMetric) -> String {
- if settings.appLanguage == .english {
- switch metric {
- case .battery:
- return "Shows the current battery level in percent in the menu bar."
- case .solar:
- return "Shows the current solar output in watts in the menu bar."
- case .home:
- return "Shows the current real home load in watts in the menu bar."
- case .grid:
- return "Shows current grid import or export in watts."
- case .batteryFlow:
- return "Shows whether the battery is charging or discharging."
- case .flow:
- return "Shows the energy-flow field in the menu bar. Arrows appear when the arrow option is enabled."
- case .today:
- return "Shows today's solar yield in kWh."
- case .total:
- return "Shows the total measured solar yield in kWh."
- case .status:
- return "Shows the current data-source status."
- }
- }
- switch metric {
- case .battery:
- return "Zeigt den aktuellen Akkustand in Prozent in der Menüleiste."
- case .solar:
- return "Zeigt die aktuelle Solarleistung in Watt in der Menüleiste."
- case .home:
- return "Zeigt die aktuelle echte Hauslast in Watt in der Menüleiste."
- case .grid:
- return "Zeigt den aktuellen Netzbezug oder die Einspeisung in Watt."
- case .batteryFlow:
- return "Zeigt, ob der Akku gerade lädt oder entlädt."
- case .flow:
- return "Zeigt das Energiefluss-Feld in der Menüleiste. Die Pfeile erscheinen, wenn die Pfeil-Option aktiviert ist."
- case .today:
- return "Zeigt den heutigen Solarertrag in kWh."
- case .total:
- return "Zeigt den gesamten bisher gemessenen Solarertrag in kWh."
- case .status:
- return "Zeigt den aktuellen Status der Datenquelle."
- }
- }
-
- private func localizedMetricTitle(_ metric: BarMetric) -> String {
- guard settings.appLanguage == .english else { return metric.title }
- switch metric {
- case .battery:
- return "Battery"
- case .solar:
- return "PV"
- case .home:
- return "Home"
- case .grid:
- return "Grid Import"
- case .batteryFlow:
- return "Battery Flow"
- case .flow:
- return "Energy Flow"
- case .today:
- return "Today's Yield"
- case .total:
- return "Total Yield"
- case .status:
- return "Status"
- }
- }
-
- private func labelTooltip(_ text: String) -> String {
- switch text {
- case "Skalierung", "Scale":
- return "Passt die Größe der Menüleistenanzeige an."
- case "Abgedockt", "Detached":
- return "Passt nur die Größe der abgedockten Menüleistenleiste an."
- case "Modus", "Mode":
- return "Wählt Demo, lokalen JSON-Befehl oder JSON-URL."
- case "Mail", "Email":
- return "E-Mail-Adresse deines Anker/SOLIX-Kontos."
- case "Passwort", "Password":
- return "Passwort deines Anker/SOLIX-Kontos."
- case "Land", "Country":
- return "Land des Anker-Kontos, meistens DE."
- case "Ertrag heute", "Yield today":
- return "Korrigiert den heutigen Ertrag in kWh, wenn Anker fuer heute 0 kWh liefert."
- case "Gesamtertrag", "Total yield":
- return LocalizedText.text(
- "Setzt optional den Gesamtertrag aus der Anker-App als Startwert. Ohne API-Gesamtwert zaehlt SolixBar alle fortlaufenden Messungen zusammen.",
- "Optionally sets the Anker app total as a starting value. Without an API total, SolixBar adds up all continuous measurements."
- )
- case "Befehl", "Command":
- return "Der lokale Befehl muss ein JSON-Objekt ausgeben."
- case "URL":
- return "Die Adresse muss ein JSON-Objekt liefern."
- case "Intervall", "Interval":
- return "Legt fest, wie oft neue Daten geholt werden."
- case "Design", "Theme":
- return "Waehlt Hell, Dunkel oder Automatisch passend zum System."
- case "Sprache", "Language":
- return "Waehlt Deutsch oder Englisch fuer sichtbare App-Texte."
- default:
- return text
- }
- }
-
- private func loadSettings() {
- isLoading = true
- switch settings.dataSourceMode {
- case .demo:
- modePopup.selectItem(at: 0)
- case .command:
- modePopup.selectItem(at: 1)
- case .url:
- modePopup.selectItem(at: 2)
- }
- switch settings.appearanceMode {
- case .system:
- appearancePopup.selectItem(at: 0)
- case .light:
- appearancePopup.selectItem(at: 1)
- case .dark:
- appearancePopup.selectItem(at: 2)
- }
- languagePopup.selectItem(at: settings.appLanguage == .english ? 1 : 0)
- commandField.stringValue = settings.command
- urlField.stringValue = settings.urlString
- intervalField.stringValue = String(Int(settings.refreshInterval))
- loadSolixCredentials()
- showIconButton.state = settings.showMenuBarIcon ? .on : .off
- showLabelsButton.state = settings.showMetricLabels ? .on : .off
- showMetricSymbolsButton.state = settings.showMenuBarMetricSymbols ? .on : .off
- showEnergyFlowArrowsButton.state = settings.showEnergyFlowArrows ? .on : .off
- lockDetachedMenuBarButton.state = settings.lockDetachedMenuBar ? .on : .off
- scaleSlider.doubleValue = settings.menuBarScale
- scaleValue.stringValue = "\(Int(round(scaleSlider.doubleValue * 100))) %"
- detachedScaleSlider.doubleValue = settings.detachedMenuBarScale
- detachedScaleValue.stringValue = "\(Int(round(detachedScaleSlider.doubleValue * 100))) %"
-
- let selected = Set(settings.barMetrics)
- for metric in BarMetric.allCases {
- metricButtons[metric]?.state = selected.contains(metric) ? .on : .off
- }
- refreshAutostartState()
- updateDataSourceFieldVisibility()
- isLoading = false
- }
-
- private func refreshAutostartState(message: String? = nil) {
- autostartButton.state = AutostartManager.isEnabled ? .on : .off
- if let message {
- autostartStatus.stringValue = message
- } else {
- autostartStatus.stringValue = AutostartManager.isEnabled
- ? "Autostart ist aktiv."
- : "Autostart ist deaktiviert."
- }
- }
-
- private func applyControlsToSettings() {
- switch modePopup.indexOfSelectedItem {
- case 1:
- settings.dataSourceMode = .command
- case 2:
- settings.dataSourceMode = .url
- default:
- settings.dataSourceMode = .demo
- }
- switch appearancePopup.indexOfSelectedItem {
- case 1:
- settings.appearanceMode = .light
- case 2:
- settings.appearanceMode = .dark
- default:
- settings.appearanceMode = .system
- }
- settings.appLanguage = languagePopup.indexOfSelectedItem == 1 ? .english : .german
- settings.command = commandField.stringValue
- settings.urlString = urlField.stringValue
- settings.refreshInterval = TimeInterval(max(60, intervalField.integerValue))
- settings.barMetrics = BarMetric.allCases.filter { metricButtons[$0]?.state == .on }
- settings.showMenuBarIcon = showIconButton.state == .on
- settings.showMetricLabels = showLabelsButton.state == .on
- settings.showMenuBarMetricSymbols = showMetricSymbolsButton.state == .on
- settings.showEnergyFlowArrows = showEnergyFlowArrowsButton.state == .on
- settings.lockDetachedMenuBar = lockDetachedMenuBarButton.state == .on
- settings.menuBarScale = scaleSlider.doubleValue
- settings.detachedMenuBarScale = detachedScaleSlider.doubleValue
- if settings.dataSourceMode == .command && shouldUseSolixHelper {
- settings.dataSourceMode = .command
- settings.command = solixHelperCommand
- commandField.stringValue = solixHelperCommand
- modePopup.selectItem(at: 1)
- }
- updateDataSourceFieldVisibility()
- }
-
- private func updateDataSourceFieldVisibility() {
- switch modePopup.indexOfSelectedItem {
- case 1:
- commandRow.isHidden = false
- urlRow.isHidden = true
- setSolixRowsHidden(false)
- case 2:
- commandRow.isHidden = true
- urlRow.isHidden = false
- setSolixRowsHidden(true)
- default:
- commandRow.isHidden = true
- urlRow.isHidden = true
- setSolixRowsHidden(true)
- }
- }
-
- private func setSolixRowsHidden(_ hidden: Bool) {
- solixTitle.isHidden = hidden
- solixHint.isHidden = hidden
- solixEmailRow.isHidden = hidden
- solixPasswordRow.isHidden = hidden
- solixCountryRow.isHidden = hidden
- solixTodayBaseRow.isHidden = hidden
- solixTotalBaseRow.isHidden = hidden
- }
-
- private func restoreOriginalSettings() {
- if let originalSettings {
- settings.apply(originalSettings)
- }
- if AutostartManager.isEnabled != originalAutostart {
- try? AutostartManager.setEnabled(originalAutostart)
- }
- refreshAutostartState()
- onReset()
- }
-
- @objc private func toggleAutostart() {
- do {
- try AutostartManager.setEnabled(autostartButton.state == .on)
- refreshAutostartState()
- } catch {
- refreshAutostartState(message: "Autostart konnte nicht geändert werden: \(error.localizedDescription)")
- }
- }
-
- @objc private func applyPreview() {
- guard !isLoading else { return }
- scaleValue.stringValue = "\(Int(round(scaleSlider.doubleValue * 100))) %"
- detachedScaleValue.stringValue = "\(Int(round(detachedScaleSlider.doubleValue * 100))) %"
- applyControlsToSettings()
- onPreview()
- }
-
- @objc private func cancelSettings() {
- window?.close()
- }
-
- @objc private func saveSettings() {
- isSaving = true
- applyControlsToSettings()
- if settings.dataSourceMode == .command {
- saveSolixCredentialsIfNeeded()
- if shouldUseSolixHelper {
- settings.command = solixHelperCommand
- commandField.stringValue = solixHelperCommand
- }
- }
- onSave()
- window?.close()
- }
-
- func controlTextDidChange(_ obj: Notification) {
- applyPreview()
- }
-
- func windowWillClose(_ notification: Notification) {
- guard !isSaving else { return }
- restoreOriginalSettings()
- }
-
- private var shouldUseSolixHelper: Bool {
- !solixEmailField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
- || !solixPasswordField.stringValue.isEmpty
- || !solixCountryField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
- }
-
- private var solixHelperCommand: String {
- "/Users/holger/Documents/Codex/2026-07-06/bi/scripts/run_solix_snapshot.sh"
- }
-
- private var solixEnvURL: URL {
- URL(fileURLWithPath: "/Users/holger/Documents/Codex/2026-07-06/bi/work/solixbar.env")
- }
-
- private func loadSolixCredentials() {
- let values = readSolixEnv()
- solixEmailField.stringValue = values["ANKER_SOLIX_USER"] ?? ""
- solixPasswordField.stringValue = values["ANKER_SOLIX_PASSWORD"] ?? ""
- solixCountryField.stringValue = values["ANKER_SOLIX_COUNTRY"] ?? "DE"
- solixTodayBaseField.stringValue = values["SOLIXBAR_TODAY_KWH_BASE"] ?? ""
- solixTotalBaseField.stringValue = values["SOLIXBAR_TOTAL_KWH_BASE"] ?? ""
- }
-
- private func saveSolixCredentialsIfNeeded() {
- guard shouldUseSolixHelper else { return }
- let email = solixEmailField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
- let password = solixPasswordField.stringValue
- let country = solixCountryField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
- ? "DE"
- : solixCountryField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
- let todayBase = solixTodayBaseField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
- let totalBase = solixTotalBaseField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
-
- var lines = [
- "ANKER_SOLIX_USER=\(shellQuoted(email))",
- "ANKER_SOLIX_PASSWORD=\(shellQuoted(password))",
- "ANKER_SOLIX_COUNTRY=\(shellQuoted(country))"
- ]
- if !todayBase.isEmpty {
- lines.append("SOLIXBAR_TODAY_KWH_BASE=\(shellQuoted(todayBase.replacingOccurrences(of: ",", with: ".")))")
- lines.append("SOLIXBAR_TODAY_KWH_DATE=\(shellQuoted(Self.todayKey()))")
- }
- if !totalBase.isEmpty {
- lines.append("SOLIXBAR_TOTAL_KWH_BASE=\(shellQuoted(totalBase.replacingOccurrences(of: ",", with: ".")))")
- }
- let text = lines.joined(separator: "\n") + "\n"
-
- do {
- try FileManager.default.createDirectory(at: solixEnvURL.deletingLastPathComponent(), withIntermediateDirectories: true)
- try text.write(to: solixEnvURL, atomically: true, encoding: .utf8)
- } catch {
- NSAlert(error: error).runModal()
- }
- }
-
- private func readSolixEnv() -> [String: String] {
- guard let text = try? String(contentsOf: solixEnvURL, encoding: .utf8) else { return [:] }
- var values: [String: String] = [:]
- for rawLine in text.components(separatedBy: .newlines) {
- let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !line.isEmpty, !line.hasPrefix("#"), let equals = line.firstIndex(of: "=") else { continue }
- let key = String(line[.. String {
- "'\(value.replacingOccurrences(of: "'", with: "'\\''"))'"
- }
-
- private static func todayKey() -> String {
- let formatter = DateFormatter()
- formatter.calendar = Calendar(identifier: .gregorian)
- formatter.locale = Locale(identifier: "en_US_POSIX")
- formatter.dateFormat = "yyyy-MM-dd"
- return formatter.string(from: Date())
- }
-
- private func unquoteShellValue(_ value: String) -> String {
- var value = value.trimmingCharacters(in: .whitespacesAndNewlines)
- if value.hasPrefix("'"), value.hasSuffix("'"), value.count >= 2 {
- value.removeFirst()
- value.removeLast()
- return value.replacingOccurrences(of: "'\\''", with: "'")
- }
- if value.hasPrefix("\""), value.hasSuffix("\""), value.count >= 2 {
- value.removeFirst()
- value.removeLast()
- }
- return value
- }
-}
diff --git a/Sources/SolixBar/SolixDataProvider.swift b/Sources/SolixBar/SolixDataProvider.swift
deleted file mode 100644
index 477603c..0000000
--- a/Sources/SolixBar/SolixDataProvider.swift
+++ /dev/null
@@ -1,116 +0,0 @@
-import Foundation
-
-protocol SolixDataProvider: Sendable {
- func fetchSnapshot() async throws -> SolixSnapshot
-}
-
-enum SolixProviderError: LocalizedError, Sendable {
- case missingCommand
- case missingURL
- case commandFailed(String)
- case commandTimedOut(TimeInterval)
-
- var errorDescription: String? {
- switch self {
- case .missingCommand:
- "Kein JSON-Befehl konfiguriert."
- case .missingURL:
- "Keine JSON-URL konfiguriert."
- case .commandFailed(let message):
- message
- case .commandTimedOut(let seconds):
- "JSON-Befehl nach \(Int(seconds)) Sekunden abgebrochen."
- }
- }
-}
-
-final class DemoSolixDataProvider: SolixDataProvider {
- func fetchSnapshot() async throws -> SolixSnapshot {
- var snapshot = SolixSnapshot.demo
- let minute = Calendar.current.component(.minute, from: Date())
- snapshot.solarWatts = 400 + ((minute * 37) % 420)
- snapshot.homeWatts = 220 + ((minute * 19) % 260)
- snapshot.batteryPercent = 65 + (minute % 25)
- snapshot.batteryWatts = (snapshot.solarWatts ?? 0) - (snapshot.homeWatts ?? 0)
- snapshot.updatedAt = Date()
- return snapshot
- }
-}
-
-final class CommandSolixDataProvider: SolixDataProvider {
- private let command: String
- private let timeout: TimeInterval = 45
-
- init(command: String) {
- self.command = command
- }
-
- func fetchSnapshot() async throws -> SolixSnapshot {
- guard !command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
- throw SolixProviderError.missingCommand
- }
-
- let command = command
- let timeout = timeout
- return try await Task.detached(priority: .utility) {
- let process = Process()
- process.executableURL = URL(fileURLWithPath: "/bin/zsh")
- process.arguments = ["-lc", command]
-
- let output = Pipe()
- let error = Pipe()
- process.standardOutput = output
- process.standardError = error
-
- try process.run()
- let deadline = Date().addingTimeInterval(timeout)
- while process.isRunning && Date() < deadline {
- try await Task.sleep(nanoseconds: 100_000_000)
- }
-
- if process.isRunning {
- process.terminate()
- process.waitUntilExit()
- throw SolixProviderError.commandTimedOut(timeout)
- }
-
- let data = output.fileHandleForReading.readDataToEndOfFile()
- let errorData = error.fileHandleForReading.readDataToEndOfFile()
-
- guard process.terminationStatus == 0 else {
- let message = String(data: errorData, encoding: .utf8) ?? "Befehl fehlgeschlagen."
- throw SolixProviderError.commandFailed(message.trimmingCharacters(in: .whitespacesAndNewlines))
- }
-
- return try SnapshotDecoder.decode(data)
- }.value
- }
-}
-
-final class URLSolixDataProvider: SolixDataProvider {
- private let urlString: String
- private let timeout: TimeInterval = 45
-
- init(urlString: String) {
- self.urlString = urlString
- }
-
- func fetchSnapshot() async throws -> SolixSnapshot {
- guard let url = URL(string: urlString), !urlString.isEmpty else {
- throw SolixProviderError.missingURL
- }
-
- var request = URLRequest(url: url)
- request.timeoutInterval = timeout
- let (data, _) = try await URLSession.shared.data(for: request)
- return try SnapshotDecoder.decode(data)
- }
-}
-
-enum SnapshotDecoder {
- static func decode(_ data: Data) throws -> SolixSnapshot {
- let decoder = JSONDecoder()
- decoder.dateDecodingStrategy = .iso8601
- return try decoder.decode(SolixSnapshot.self, from: data)
- }
-}
diff --git a/Sources/SolixBar/SolixHistoryStore.swift b/Sources/SolixBar/SolixHistoryStore.swift
deleted file mode 100644
index 59e0255..0000000
--- a/Sources/SolixBar/SolixHistoryStore.swift
+++ /dev/null
@@ -1,113 +0,0 @@
-import Foundation
-
-struct SolixHistorySample: Codable, Sendable {
- var date: Date
- var batteryPercent: Int?
- var solarWatts: Int?
- var gridWatts: Int?
-}
-
-private struct SolixEnergyAccumulator: Codable {
- var totalKWh: Double = 0
- var lastDate: Date?
- var lastSolarWatts: Int?
-}
-
-@MainActor
-final class SolixHistoryStore {
- static let shared = SolixHistoryStore()
-
- private let defaults = UserDefaults.standard
- private let key = "solixHistorySamples"
- private let accumulatorKey = "solixEnergyAccumulators"
- private let maxAge: TimeInterval = 31 * 24 * 60 * 60
-
- func cumulativeSolarKWh(recording snapshot: SolixSnapshot, sourceKey: String) -> Double {
- var accumulators = loadAccumulators()
- var accumulator = accumulators[sourceKey] ?? SolixEnergyAccumulator()
-
- if let lastDate = accumulator.lastDate,
- let lastSolarWatts = accumulator.lastSolarWatts,
- let solarWatts = snapshot.solarWatts {
- let seconds = snapshot.updatedAt.timeIntervalSince(lastDate)
- if seconds > 0, seconds <= 30 * 60 {
- let measuredKWh = Double(lastSolarWatts + solarWatts) / 2 * seconds / 3_600_000
- accumulator.totalKWh += max(0, measuredKWh)
- }
- }
-
- if let providerTotal = snapshot.totalKWh, providerTotal > accumulator.totalKWh {
- accumulator.totalKWh = providerTotal
- }
-
- accumulator.lastDate = snapshot.updatedAt
- accumulator.lastSolarWatts = snapshot.solarWatts
- accumulators[sourceKey] = accumulator
- saveAccumulators(accumulators)
- return accumulator.totalKWh
- }
-
- func record(_ snapshot: SolixSnapshot) {
- var values = allSamples()
- values.append(
- SolixHistorySample(
- date: snapshot.updatedAt,
- batteryPercent: snapshot.batteryPercent,
- solarWatts: snapshot.solarWatts,
- gridWatts: snapshot.gridWatts
- )
- )
- save(pruned(values, from: Date()))
- }
-
- func samples(duration: TimeInterval) -> [SolixHistorySample] {
- let cutoff = Date().addingTimeInterval(-duration)
- return allSamples().filter { $0.date >= cutoff }.sorted { $0.date < $1.date }
- }
-
- func estimatedSolarKWh(since startDate: Date? = nil) -> Double {
- let samples = allSamples()
- .filter { sample in
- guard let startDate else { return true }
- return sample.date >= startDate
- }
- .sorted { $0.date < $1.date }
- guard samples.count >= 2 else { return 0 }
-
- return zip(samples, samples.dropFirst()).reduce(0) { total, pair in
- let first = pair.0
- let second = pair.1
- guard let firstWatts = first.solarWatts, let secondWatts = second.solarWatts else { return total }
- let seconds = second.date.timeIntervalSince(first.date)
- guard seconds > 0, seconds <= 30 * 60 else { return total }
- return total + (Double(firstWatts + secondWatts) / 2) * seconds / 3_600_000
- }
- }
-
- private func allSamples() -> [SolixHistorySample] {
- guard let data = defaults.data(forKey: key) else { return [] }
- return (try? JSONDecoder().decode([SolixHistorySample].self, from: data)) ?? []
- }
-
- private func save(_ samples: [SolixHistorySample]) {
- guard let data = try? JSONEncoder().encode(samples) else { return }
- defaults.set(data, forKey: key)
- }
-
- private func loadAccumulators() -> [String: SolixEnergyAccumulator] {
- guard let data = defaults.data(forKey: accumulatorKey) else { return [:] }
- return (try? JSONDecoder().decode([String: SolixEnergyAccumulator].self, from: data)) ?? [:]
- }
-
- private func saveAccumulators(_ accumulators: [String: SolixEnergyAccumulator]) {
- guard let data = try? JSONEncoder().encode(accumulators) else { return }
- defaults.set(data, forKey: accumulatorKey)
- }
-
- private func pruned(_ samples: [SolixHistorySample], from date: Date) -> [SolixHistorySample] {
- let cutoff = date.addingTimeInterval(-maxAge)
- let filtered = samples.filter { $0.date >= cutoff }
- guard filtered.count > 2000 else { return filtered }
- return Array(filtered.suffix(2000))
- }
-}
diff --git a/Sources/SolixBar/SolixSnapshot.swift b/Sources/SolixBar/SolixSnapshot.swift
deleted file mode 100644
index 181fd87..0000000
--- a/Sources/SolixBar/SolixSnapshot.swift
+++ /dev/null
@@ -1,27 +0,0 @@
-import Foundation
-
-struct SolixSnapshot: Codable, Sendable {
- var siteName: String
- var batteryPercent: Int?
- var solarWatts: Int?
- var homeWatts: Int?
- var gridWatts: Int?
- var batteryWatts: Int?
- var todayKWh: Double?
- var totalKWh: Double?
- var status: String?
- var updatedAt: Date
-
- static let demo = SolixSnapshot(
- siteName: "Anker SOLIX",
- batteryPercent: 82,
- solarWatts: 642,
- homeWatts: 318,
- gridWatts: -86,
- batteryWatts: 238,
- todayKWh: 3.74,
- totalKWh: 427.8,
- status: "Online",
- updatedAt: Date()
- )
-}
diff --git a/Sources/SolixBar/StatusController.swift b/Sources/SolixBar/StatusController.swift
deleted file mode 100644
index c5ea9a4..0000000
--- a/Sources/SolixBar/StatusController.swift
+++ /dev/null
@@ -1,1090 +0,0 @@
-import AppKit
-
-private func menuBarUsesDarkBackground(_ appearance: NSAppearance) -> Bool {
- var label: NSColor?
- appearance.performAsCurrentDrawingAppearance {
- label = NSColor.labelColor.usingColorSpace(.deviceRGB)
- }
- if let label {
- let luminance = 0.2126 * label.redComponent
- + 0.7152 * label.greenComponent
- + 0.0722 * label.blueComponent
- return luminance > 0.55
- }
-
- let match = appearance.bestMatch(from: [.vibrantDark, .darkAqua, .vibrantLight, .aqua])
- return match == .vibrantDark || match == .darkAqua
-}
-
-@MainActor
-final class StatusController: NSObject {
- private let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
- private let settings = AppSettings.shared
- private let historyStore = SolixHistoryStore.shared
- private var timer: Timer?
- private var lastSnapshot: SolixSnapshot?
- private var lastSnapshotMode: DataSourceMode?
- private var lastError: String?
- private var settingsWindow: SettingsWindowController?
- private var largeGraphWindow: LargeGraphWindowController?
- private var detachedDashboardWindow: DetachedDashboardWindowController?
- private var detachedMenuBarWindow: DetachedMenuBarWindowController?
- private var isMenuBarDetached = false
- private var isTerminating = false
- private var isRefreshing = false
- private var refreshAnimationTimer: Timer?
- private var refreshAnimationFrame = 0
- private let refreshFrames = ["↻", "↺"]
-
- func start() {
- settings.migrateMenuBarGridMetricIfNeeded()
- applyAppearance()
- updateMenuBarIcon()
- setStatusTitle("SOLIX")
- rebuildMenu()
- refresh()
- if settings.isDetachedMenuBarActive {
- AppLogger.info("Restoring detached slim bar from previous session.")
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
- self?.openDetachedMenuBar()
- }
- }
- scheduleRefreshTimer()
- }
-
- func prepareForTermination() {
- isTerminating = true
- settings.isDetachedMenuBarActive = isMenuBarDetached
- AppLogger.info("Persisted detached slim bar state: \(isMenuBarDetached ? "active" : "inactive").")
- }
-
- private func provider() -> SolixDataProvider {
- switch settings.dataSourceMode {
- case .demo:
- DemoSolixDataProvider()
- case .command:
- CommandSolixDataProvider(command: settings.command)
- case .url:
- URLSolixDataProvider(urlString: settings.urlString)
- }
- }
-
- private func refresh() {
- guard !isRefreshing else {
- AppLogger.info("Refresh skipped because a previous refresh is still running.")
- return
- }
- isRefreshing = true
- startRefreshAnimation()
- AppLogger.info("Refreshing data source: \(settings.dataSourceMode.rawValue).")
- updateTitle()
- Task {
- defer {
- isRefreshing = false
- stopRefreshAnimation()
- }
- do {
- var snapshot = try await provider().fetchSnapshot()
- snapshot.updatedAt = Date()
- snapshot.totalKWh = historyStore.cumulativeSolarKWh(
- recording: snapshot,
- sourceKey: settings.dataSourceMode.rawValue
- )
- lastSnapshot = snapshot
- lastSnapshotMode = settings.dataSourceMode
- lastError = nil
- historyStore.record(snapshot)
- AppLogger.info("Refresh succeeded: battery=\(snapshot.batteryPercent.map(String.init) ?? "-")%, solar=\(snapshot.solarWatts.map(String.init) ?? "-")W, grid=\(snapshot.gridWatts.map(String.init) ?? "-")W.")
- } catch {
- lastSnapshot = nil
- lastSnapshotMode = nil
- lastError = error.localizedDescription
- AppLogger.error("Refresh failed: \(error.localizedDescription)")
- }
- updateTitle()
- rebuildMenu()
- detachedDashboardWindow?.rebuild()
- detachedMenuBarWindow?.rebuild()
- largeGraphWindow?.rebuild()
- }
- }
-
- private func startRefreshAnimation() {
- refreshAnimationFrame = 0
- refreshAnimationTimer?.invalidate()
- let timer = Timer(timeInterval: 0.16, repeats: true) { [weak self] _ in
- Task { @MainActor in
- guard let self else { return }
- self.refreshAnimationFrame = (self.refreshAnimationFrame + 1) % self.refreshFrames.count
- self.updateTitle()
- }
- }
- RunLoop.main.add(timer, forMode: .common)
- refreshAnimationTimer = timer
- }
-
- private func stopRefreshAnimation() {
- refreshAnimationTimer?.invalidate()
- refreshAnimationTimer = nil
- refreshAnimationFrame = 0
- updateTitle()
- }
-
- private func scheduleRefreshTimer() {
- timer?.invalidate()
- let interval = max(60, settings.refreshInterval)
- let newTimer = Timer(timeInterval: interval, repeats: true) { [weak self] _ in
- Task { @MainActor in
- self?.refresh()
- }
- }
- newTimer.tolerance = min(5, interval * 0.1)
- RunLoop.main.add(newTimer, forMode: .common)
- timer = newTimer
- AppLogger.info("Refresh timer scheduled every \(Int(interval)) seconds in common run loop modes.")
- }
-
- private func updateTitle() {
- if isRefreshing {
- setStatusAttributedTitle(refreshStatusAttributedTitle(scale: settings.menuBarScale))
- return
- }
-
- if isMenuBarDetached {
- setStatusAttributedTitle(detachedMenuBarStatusAttributedTitle())
- return
- }
-
- guard let snapshot = currentSnapshot() else {
- let title = lastError == nil ? "SOLIX" : "SOLIX !"
- setStatusTitle(title)
- return
- }
-
- let battery = snapshot.batteryPercent.map { "\($0)%" } ?? "--%"
- if settings.showMenuBarMetricSymbols || settings.showEnergyFlowArrows || settings.barMetrics.contains(.flow) {
- setStatusAttributedTitle(barAttributedText(for: snapshot, scale: settings.menuBarScale))
- } else {
- let parts = visibleBarMetrics(for: snapshot).map { metric in
- barText(for: metric, snapshot: snapshot)
- }
- let title = parts.isEmpty ? battery : parts.joined(separator: separator())
- setStatusTitle(title)
- }
- }
-
- private func rebuildMenu() {
- let menu = NSMenu()
-
- if let snapshot = currentSnapshot() {
- menu.addItem(dashboardItem(snapshot))
- } else {
- menu.addItem(header("Anker SOLIX"))
- menu.addItem(value(LocalizedText.text("Status", "Status"), lastError ?? LocalizedText.text("Warte auf Daten ...", "Waiting for data ..."), symbol: "hourglass", color: .systemGray))
- }
-
- if let lastError {
- menu.addItem(NSMenuItem.separator())
- menu.addItem(value(LocalizedText.text("Fehler", "Error"), lastError, symbol: "exclamationmark.triangle.fill", color: .systemRed))
- }
-
- menu.addItem(NSMenuItem.separator())
- menu.addItem(action(LocalizedText.text("Aktualisieren", "Refresh"), #selector(refreshMenuAction), "arrow.clockwise"))
- menu.addItem(action(
- isMenuBarDetached
- ? LocalizedText.text("Menüleiste andocken", "Dock menu bar")
- : LocalizedText.text("Menüleiste abdocken", "Detach menu bar"),
- #selector(toggleDetachedMenuBar),
- "menubar.rectangle"
- ))
- menu.addItem(action(LocalizedText.text("Dashboard abdocken", "Detach dashboard"), #selector(openDetachedDashboard), "macwindow.on.rectangle"))
- menu.addItem(action(LocalizedText.text("Einstellungen ...", "Settings ..."), #selector(openSettings), "gearshape"))
- menu.addItem(action(LocalizedText.text("Logdatei anzeigen", "Show log file"), #selector(showLogFile), "doc.text.magnifyingglass"))
- menu.addItem(NSMenuItem.separator())
- menu.addItem(versionItem())
- menu.addItem(action(LocalizedText.text("Beenden", "Quit"), #selector(quit), "power"))
- item.menu = menu
- }
-
- private func dashboardItem(_ snapshot: SolixSnapshot) -> NSMenuItem {
- let item = NSMenuItem()
- item.view = SolixMenuDashboardView(
- snapshot: snapshot,
- graphProvider: { [weak self] in self?.graphSamples() ?? [] },
- onRangeChange: { [weak self] in
- self?.largeGraphWindow?.rebuild()
- },
- onOpenLarge: { [weak self] in
- self?.openLargeGraph()
- }
- )
- return item
- }
-
- private func historyGraphItem() -> NSMenuItem {
- let item = NSMenuItem()
- item.view = HistoryGraphMenuView(
- graphProvider: { [weak self] in self?.graphSamples() ?? [] },
- onRangeChange: { [weak self] in
- self?.largeGraphWindow?.rebuild()
- },
- onOpenLarge: { [weak self] in
- self?.openLargeGraph()
- }
- )
- return item
- }
-
- private func graphSamples() -> [SolixHistorySample] {
- let samples = historyStore.samples(duration: settings.historyDuration)
- guard samples.count < 3, settings.dataSourceMode == .demo else { return samples }
- return demoGraphSamples(duration: settings.historyDuration)
- }
-
- private func demoGraphSamples(duration: TimeInterval) -> [SolixHistorySample] {
- let now = Date()
- let count = 32
- return (0.. NSMenuItem {
- let item = NSMenuItem(title: title, action: nil, keyEquivalent: "")
- item.isEnabled = false
- item.attributedTitle = NSAttributedString(
- string: title,
- attributes: [
- .font: NSFont.boldSystemFont(ofSize: 13),
- .foregroundColor: NSColor.labelColor
- ]
- )
- return item
- }
-
- private func value(_ label: String, _ text: String?, symbol: String, color: NSColor) -> NSMenuItem {
- let title = "\(label): \(text ?? "-")"
- let item = NSMenuItem(title: title, action: nil, keyEquivalent: "")
- item.isEnabled = false
- item.attributedTitle = NSAttributedString(
- string: title,
- attributes: [
- .font: NSFont.systemFont(ofSize: 13, weight: .medium),
- .foregroundColor: NSColor.labelColor
- ]
- )
- item.image = coloredSymbol(symbol, color: color, accessibilityDescription: label)
- return item
- }
-
- private func metricValue(_ metric: BarMetric, _ text: String?, snapshot: SolixSnapshot, label: String? = nil) -> NSMenuItem {
- value(label ?? metricTitle(metric), text, symbol: symbol(for: metric, snapshot: snapshot), color: color(for: metric, snapshot: snapshot))
- }
-
- private func action(_ title: String, _ selector: Selector, _ symbol: String) -> NSMenuItem {
- let item = NSMenuItem(title: title, action: selector, keyEquivalent: "")
- item.target = self
- item.image = NSImage(systemSymbolName: symbol, accessibilityDescription: title)
- return item
- }
-
- private func versionItem() -> NSMenuItem {
- let item = NSMenuItem(title: AppVersion.display, action: nil, keyEquivalent: "")
- item.isEnabled = false
- item.image = NSImage(systemSymbolName: "info.circle", accessibilityDescription: "Version")
- item.attributedTitle = NSAttributedString(
- string: AppVersion.display,
- attributes: [
- .font: NSFont.systemFont(ofSize: 12, weight: .medium),
- .foregroundColor: NSColor.secondaryLabelColor
- ]
- )
- return item
- }
-
- private func coloredSymbol(_ symbol: String, color: NSColor, accessibilityDescription: String) -> NSImage? {
- guard let image = NSImage(systemSymbolName: symbol, accessibilityDescription: accessibilityDescription) else {
- return nil
- }
- let configuration = NSImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
- let configured = image.withSymbolConfiguration(configuration) ?? image
- let copy = configured.copy() as? NSImage ?? configured
- copy.isTemplate = false
- copy.lockFocus()
- color.set()
- NSRect(origin: .zero, size: copy.size).fill(using: .sourceAtop)
- copy.unlockFocus()
- return copy
- }
-
- private func updateMenuBarIcon() {
- guard shouldShowMenuBarIcon else {
- item.button?.image = nil
- item.button?.imagePosition = .noImage
- return
- }
-
- item.button?.image = menuBarIcon()
- item.button?.imagePosition = .imageLeading
- }
-
- private var shouldShowMenuBarIcon: Bool {
- settings.showMenuBarIcon || isMenuBarDetached
- }
-
- private func menuBarIcon() -> NSImage? {
- let appIcon = Bundle.main.url(forResource: "SolixBar", withExtension: "png")
- .flatMap { NSImage(contentsOf: $0) }
-
- guard let image = appIcon ?? coloredSymbol("bolt.fill", color: .systemYellow, accessibilityDescription: "SOLIX") else {
- return nil
- }
-
- let size = round(18 * settings.menuBarScale)
- return roundedIconImage(image, size: size)
- }
-
- private func setStatusTitle(_ title: String) {
- let prefix = shouldShowMenuBarIcon ? " " : ""
- let fontSize = round(13 * settings.menuBarScale)
- item.button?.attributedTitle = NSAttributedString(
- string: prefix + title,
- attributes: [
- .font: NSFont.monospacedDigitSystemFont(ofSize: fontSize, weight: .medium),
- .foregroundColor: NSColor.labelColor
- ]
- )
- }
-
- private func setStatusAttributedTitle(_ value: NSAttributedString) {
- let title = NSMutableAttributedString()
- if shouldShowMenuBarIcon {
- title.append(NSAttributedString(string: " "))
- }
- title.append(value)
- item.button?.attributedTitle = title
- }
-
- private func refreshIndicator() -> String {
- refreshFrames[refreshAnimationFrame % refreshFrames.count]
- }
-
- private func refreshIndicatorAttributedText(scale: Double) -> NSAttributedString {
- NSAttributedString(
- string: refreshIndicator(),
- attributes: [
- .font: NSFont.monospacedDigitSystemFont(ofSize: round(14 * scale), weight: .bold),
- .foregroundColor: refreshColor,
- .shadow: menuBarTextShadow
- ]
- )
- }
-
- private func refreshStatusAttributedTitle(scale: Double) -> NSAttributedString {
- NSAttributedString(
- string: "\(refreshIndicator()) \(LocalizedText.text("Aktualisiert ...", "Refreshing ..."))",
- attributes: [
- .font: NSFont.monospacedDigitSystemFont(ofSize: round(13 * scale), weight: .bold),
- .foregroundColor: refreshColor,
- .shadow: menuBarTextShadow
- ]
- )
- }
-
- private func separator(scale: Double? = nil) -> String {
- (scale ?? settings.menuBarScale) < 0.95 ? " " : " "
- }
-
- private func symbol(for metric: BarMetric, snapshot: SolixSnapshot) -> String {
- switch metric {
- case .battery:
- batterySymbol(snapshot.batteryPercent)
- case .batteryFlow:
- batteryFlowSymbol(snapshot.batteryWatts)
- case .flow:
- metric.symbolName
- default:
- metric.symbolName
- }
- }
-
- private func color(for metric: BarMetric, snapshot: SolixSnapshot) -> NSColor {
- switch metric {
- case .battery:
- batteryColor(snapshot.batteryPercent)
- case .solar:
- solarColor
- case .home:
- homeConsumptionColor
- case .grid:
- gridColor(snapshot.gridWatts)
- case .batteryFlow:
- batteryFlowColor(snapshot.batteryWatts)
- case .flow:
- .systemGreen
- case .today:
- .systemGreen
- case .total:
- .systemPurple
- case .status:
- .systemGreen
- }
- }
-
- private func batterySymbol(_ percent: Int?) -> String {
- guard let percent else { return "battery.75percent" }
- if percent <= 20 { return "battery.25percent" }
- if percent <= 60 { return "battery.50percent" }
- return "battery.100percent"
- }
-
- private func batteryColor(_ percent: Int?) -> NSColor {
- guard let percent else { return .systemGray }
- if percent <= 20 { return batteryLowColor }
- if percent <= 60 { return batteryMediumColor }
- return batteryHighColor
- }
-
- private func gridColor(_ watts: Int?) -> NSColor {
- guard let watts else { return .systemGray }
- if watts > 0 { return gridImportColor }
- if watts < 0 { return gridExportColor }
- return .systemGray
- }
-
- private var solarColor: NSColor {
- solarFlowColor
- }
-
- private func batteryFlowSymbol(_ watts: Int?) -> String {
- guard let watts else { return "bolt.fill" }
- return watts >= 0 ? "arrow.down.circle.fill" : "arrow.up.circle.fill"
- }
-
- private func batteryFlowColor(_ watts: Int?) -> NSColor {
- guard let watts else { return .systemGray }
- if watts > 0 { return batteryChargingColor }
- if watts < 0 { return batteryDischargingColor }
- return .systemGray
- }
-
- private func formatSignedWatts(_ value: Int?) -> String? {
- guard let value else { return nil }
- return value > 0 ? "+\(value) W" : "\(value) W"
- }
-
- private func formatFlowWatts(_ value: Int?) -> String? {
- guard let value else { return nil }
- return settings.showEnergyFlowArrows ? "\(abs(value)) W" : formatSignedWatts(value)
- }
-
- private func barText(for metric: BarMetric, snapshot: SolixSnapshot) -> String {
- switch metric {
- case .battery:
- formatBarMetric(metric, value: snapshot.batteryPercent.map { "\($0)%" } ?? "--%")
- case .solar:
- formatBarMetric(metric, value: snapshot.solarWatts.map { "\($0)W" } ?? "--W")
- case .home:
- formatBarMetric(metric, value: snapshot.homeWatts.map { "\($0)W" } ?? "--W")
- case .grid:
- formatBarMetric(metric, value: formatFlowWatts(snapshot.gridWatts) ?? "--W")
- case .batteryFlow:
- formatBarMetric(metric, value: formatFlowWatts(snapshot.batteryWatts) ?? "--W")
- case .flow:
- settings.showMetricLabels ? "\(metricShortTitle(metric))" : "Flow"
- case .today:
- formatBarMetric(metric, value: snapshot.todayKWh.map { String(format: "%.2fkWh", $0) } ?? "--kWh")
- case .total:
- formatBarMetric(metric, value: snapshot.totalKWh.map { String(format: "%.1fkWh", $0) } ?? "--kWh")
- case .status:
- formatBarMetric(metric, value: snapshot.status ?? "-")
- }
- }
-
- private func formatBarMetric(_ metric: BarMetric, value: String) -> String {
- settings.showMetricLabels ? "\(metricShortTitle(metric)) \(value)" : value
- }
-
- private func metricTitle(_ metric: BarMetric) -> String {
- guard settings.appLanguage == .english else { return metric.title }
- switch metric {
- case .battery:
- return "Battery"
- case .solar:
- return "PV"
- case .home:
- return "Home Load"
- case .grid:
- return "Grid Import"
- case .batteryFlow:
- return "Battery Flow"
- case .flow:
- return "Energy Flow"
- case .today:
- return "Today's Yield"
- case .total:
- return "Total Yield"
- case .status:
- return "Status"
- }
- }
-
- private func metricShortTitle(_ metric: BarMetric) -> String {
- guard settings.appLanguage == .english else { return metric.shortTitle }
- switch metric {
- case .battery:
- return "Batt"
- case .solar:
- return "PV"
- case .home:
- return "Load"
- case .grid:
- return "Grid"
- case .batteryFlow:
- return "Flow"
- case .flow:
- return "Flow"
- case .today:
- return "Yield"
- case .total:
- return "Total"
- case .status:
- return "Status"
- }
- }
-
- private func barAttributedText(for snapshot: SolixSnapshot, scale: Double) -> NSAttributedString {
- let result = NSMutableAttributedString()
- let metrics = visibleBarMetrics(for: snapshot)
- for (index, metric) in metrics.enumerated() {
- if index > 0 {
- result.append(textAttachment(separator(scale: scale), scale: scale))
- }
- if metric == .flow {
- appendFlowField(to: result, snapshot: snapshot, scale: scale)
- continue
- }
- if settings.showEnergyFlowArrows,
- let flow = energyFlowText(for: metric, snapshot: snapshot) {
- result.append(textAttachment(flow.text, color: flow.color, weight: .bold, scale: scale))
- result.append(textAttachment(" ", scale: scale))
- }
- if settings.showMenuBarMetricSymbols,
- let image = coloredSymbol(
- symbol(for: metric, snapshot: snapshot),
- color: (metric == .battery || settings.showEnergyFlowArrows)
- ? color(for: metric, snapshot: snapshot)
- : .labelColor,
- accessibilityDescription: metricTitle(metric)
- ) {
- result.append(imageAttachment(image, scale: scale))
- result.append(textAttachment(" ", scale: scale))
- }
- result.append(textAttachment(barText(for: metric, snapshot: snapshot), color: valueColor(for: metric, snapshot: snapshot), scale: scale))
- }
- return result
- }
-
- private func visibleBarMetrics(for snapshot: SolixSnapshot) -> [BarMetric] {
- let metrics = settings.barMetrics.isEmpty ? [BarMetric.battery, .solar, .grid] : settings.barMetrics
- return metrics.filter { metric in
- metric != .total || snapshot.totalKWh != nil
- }
- }
-
- private func appendFlowField(to result: NSMutableAttributedString, snapshot: SolixSnapshot, scale: Double) {
- if settings.showMetricLabels {
- result.append(textAttachment("\(metricShortTitle(.flow)) ", color: .secondaryLabelColor, scale: scale))
- }
-
- guard settings.showEnergyFlowArrows else {
- result.append(textAttachment("aus", color: .secondaryLabelColor, scale: scale))
- return
- }
-
- let flows: [BarMetric] = [.solar, .batteryFlow, .grid]
- var didAppend = false
- for metric in flows {
- guard let flow = energyFlowText(for: metric, snapshot: snapshot) else {
- continue
- }
- if didAppend {
- result.append(textAttachment(" ", scale: scale))
- }
- result.append(textAttachment(flow.text, color: flow.color, weight: .bold, scale: scale))
- didAppend = true
- }
-
- if !didAppend {
- result.append(textAttachment("-", color: .secondaryLabelColor, scale: scale))
- }
- }
-
- private func energyFlowText(for metric: BarMetric, snapshot: SolixSnapshot) -> (text: String, color: NSColor)? {
- switch metric {
- case .solar:
- guard let watts = snapshot.solarWatts else { return nil }
- return watts > 0
- ? (LocalizedText.text("↓ Erzeugt", "↓ Producing"), solarFlowColor)
- : ("•", .systemGray)
- case .grid:
- guard let watts = snapshot.gridWatts else { return nil }
- if watts > 0 {
- return (LocalizedText.text("← Bezug", "← Import"), gridImportColor)
- }
- if watts < 0 {
- return (LocalizedText.text("→ Einspeisen", "→ Export"), gridExportColor)
- }
- return ("•", .systemGray)
- case .batteryFlow:
- guard let watts = snapshot.batteryWatts else { return nil }
- if watts > 0 {
- return (LocalizedText.text("↓ Laden", "↓ Charging"), batteryChargingColor)
- }
- if watts < 0 {
- return (LocalizedText.text("↑ Entladen", "↑ Discharging"), batteryDischargingColor)
- }
- return ("•", .systemGray)
- default:
- return nil
- }
- }
-
- private func energyFlowArrow(for metric: BarMetric, snapshot: SolixSnapshot) -> (symbol: String, color: NSColor, description: String)? {
- switch metric {
- case .solar:
- guard let watts = snapshot.solarWatts else { return nil }
- return watts > 0
- ? ("arrow.down.circle.fill", productionColor(watts), "Solar erzeugt Energie")
- : ("minus.circle.fill", .systemGray, "Keine Solarleistung")
- case .grid:
- guard let watts = snapshot.gridWatts else { return nil }
- if watts > 0 {
- return ("arrow.up.circle.fill", consumptionColor(watts), "Strom wird aus dem Netz bezogen")
- }
- if watts < 0 {
- return ("arrow.down.circle.fill", storageColor(abs(watts)), "Strom wird ins Netz eingespeist")
- }
- return ("minus.circle.fill", .systemGray, "Kein Netzfluss")
- case .batteryFlow:
- guard let watts = snapshot.batteryWatts else { return nil }
- if watts > 0 {
- return ("arrow.down.circle.fill", storageColor(watts), "Akku wird geladen")
- }
- if watts < 0 {
- return ("arrow.up.circle.fill", consumptionColor(abs(watts)), "Akku gibt Strom ab")
- }
- return ("minus.circle.fill", .systemGray, "Kein Akku-Fluss")
- default:
- return nil
- }
- }
-
- private func imageAttachment(_ image: NSImage, scale: Double) -> NSAttributedString {
- let attachment = NSTextAttachment()
- let size = round(13 * scale)
- image.size = NSSize(width: size, height: size)
- attachment.image = image
- attachment.bounds = NSRect(x: 0, y: -2, width: size, height: size)
- return NSAttributedString(attachment: attachment)
- }
-
- private func textAttachment(_ string: String, color: NSColor = .labelColor, weight: NSFont.Weight = .medium, scale: Double) -> NSAttributedString {
- NSAttributedString(
- string: string,
- attributes: [
- .font: NSFont.monospacedDigitSystemFont(
- ofSize: round((string.contains("↓") || string.contains("↑") || string.contains("←") || string.contains("→")) ? 13.5 * scale : 13 * scale),
- weight: weight
- ),
- .foregroundColor: color,
- .shadow: menuBarTextShadow
- ]
- )
- }
-
- private func valueColor(for metric: BarMetric, snapshot: SolixSnapshot) -> NSColor {
- if metric == .battery {
- return snapshot.batteryPercent.map(batteryColor) ?? .secondaryLabelColor
- }
- guard settings.showEnergyFlowArrows else { return .labelColor }
- switch metric {
- case .solar:
- return snapshot.solarWatts == nil ? .secondaryLabelColor : solarFlowColor
- case .home:
- return snapshot.homeWatts == nil ? .secondaryLabelColor : homeConsumptionColor
- case .grid:
- guard let watts = snapshot.gridWatts else { return .secondaryLabelColor }
- if watts > 0 { return gridImportColor }
- if watts < 0 { return gridExportColor }
- return .secondaryLabelColor
- case .batteryFlow:
- guard let watts = snapshot.batteryWatts else { return .secondaryLabelColor }
- if watts > 0 { return batteryChargingColor }
- if watts < 0 { return batteryDischargingColor }
- return .secondaryLabelColor
- default:
- return .labelColor
- }
- }
-
- private func productionColor(_ watts: Int) -> NSColor {
- if watts <= 0 { return .secondaryLabelColor }
- return readableSolarColor(watts)
- }
-
- private func readableSolarColor(_ watts: Int) -> NSColor {
- let fraction = min(1, max(0, Double(watts) / 2000.0))
- return interpolateColor(
- from: NSColor(calibratedRed: 0.82, green: 0.45, blue: 0.00, alpha: 1),
- to: NSColor(calibratedRed: 0.00, green: 0.54, blue: 0.25, alpha: 1),
- fraction: fraction
- )
- }
-
- private var solarFlowColor: NSColor {
- adaptiveFlowColor(
- light: (0.48, 0.22, 0.00),
- dark: (1.00, 0.82, 0.30)
- )
- }
-
- private var homeConsumptionColor: NSColor {
- adaptiveFlowColor(
- light: (0.00, 0.20, 0.80),
- dark: (0.40, 0.88, 1.00)
- )
- }
-
- private var batteryChargingColor: NSColor {
- adaptiveFlowColor(
- light: (0.00, 0.36, 0.12),
- dark: (0.42, 1.00, 0.58)
- )
- }
-
- private var batteryDischargingColor: NSColor {
- adaptiveFlowColor(
- light: (0.55, 0.12, 0.00),
- dark: (1.00, 0.55, 0.32)
- )
- }
-
- private var gridImportColor: NSColor {
- adaptiveFlowColor(
- light: (0.00, 0.20, 0.80),
- dark: (0.40, 0.88, 1.00)
- )
- }
-
- private var gridExportColor: NSColor {
- adaptiveFlowColor(
- light: (0.34, 0.10, 0.58),
- dark: (0.82, 0.65, 1.00)
- )
- }
-
- private var refreshColor: NSColor {
- adaptiveFlowColor(
- light: (0.00, 0.20, 0.80),
- dark: (0.40, 0.88, 1.00)
- )
- }
-
- private var batteryLowColor: NSColor {
- adaptiveFlowColor(
- light: (0.69, 0.00, 0.13),
- dark: (1.00, 0.42, 0.46)
- )
- }
-
- private var batteryMediumColor: NSColor {
- adaptiveFlowColor(
- light: (0.54, 0.35, 0.00),
- dark: (1.00, 0.85, 0.30)
- )
- }
-
- private var batteryHighColor: NSColor {
- adaptiveFlowColor(
- light: (0.00, 0.36, 0.12),
- dark: (0.42, 1.00, 0.58)
- )
- }
-
- private var menuBarTextShadow: NSShadow {
- let shadow = NSShadow()
- shadow.shadowColor = NSColor(name: nil) { appearance in
- menuBarUsesDarkBackground(appearance)
- ? NSColor.black.withAlphaComponent(0.85)
- : NSColor.white.withAlphaComponent(0.90)
- }
- shadow.shadowBlurRadius = 1.5
- shadow.shadowOffset = NSSize(width: 0, height: -0.5)
- return shadow
- }
-
- private func adaptiveFlowColor(light: (CGFloat, CGFloat, CGFloat), dark: (CGFloat, CGFloat, CGFloat)) -> NSColor {
- NSColor(name: nil) { appearance in
- let components = menuBarUsesDarkBackground(appearance) ? dark : light
- return NSColor(
- calibratedRed: components.0,
- green: components.1,
- blue: components.2,
- alpha: 1
- )
- }
- }
-
- private func storageColor(_ watts: Int) -> NSColor {
- let fraction = min(1, max(0, Double(watts) / 2000.0))
- return interpolateColor(
- from: NSColor(calibratedRed: 1.00, green: 0.65, blue: 0.03, alpha: 1),
- to: NSColor(calibratedRed: 0.00, green: 0.68, blue: 0.32, alpha: 1),
- fraction: fraction
- )
- }
-
- private func consumptionColor(_ watts: Int) -> NSColor {
- let fraction = min(1, max(0, Double(watts) / 2000.0))
- return interpolateColor(
- from: NSColor(calibratedRed: 1.00, green: 0.65, blue: 0.03, alpha: 1),
- to: NSColor(calibratedRed: 0.92, green: 0.08, blue: 0.12, alpha: 1),
- fraction: fraction
- )
- }
-
- private func interpolateColor(from start: NSColor, to end: NSColor, fraction: Double) -> NSColor {
- guard let start = start.usingColorSpace(.deviceRGB),
- let end = end.usingColorSpace(.deviceRGB) else {
- return fraction > 0.5 ? end : start
- }
- let t = CGFloat(fraction)
- return NSColor(
- calibratedRed: start.redComponent + (end.redComponent - start.redComponent) * t,
- green: start.greenComponent + (end.greenComponent - start.greenComponent) * t,
- blue: start.blueComponent + (end.blueComponent - start.blueComponent) * t,
- alpha: 1
- )
- }
-
- @objc private func refreshMenuAction() {
- AppLogger.info("Manual refresh requested from menu.")
- refresh()
- }
-
- @objc private func toggleDetachedMenuBar() {
- AppLogger.info(isMenuBarDetached ? "Dock slim menu bar requested." : "Detach slim menu bar requested.")
- if isMenuBarDetached {
- dockDetachedMenuBar()
- } else {
- openDetachedMenuBar()
- }
- }
-
- @objc private func openSettings() {
- AppLogger.info("Settings window requested.")
- if settingsWindow == nil {
- settingsWindow = SettingsWindowController(
- onPreview: { [weak self] in
- AppLogger.info("Settings preview applied.")
- self?.applyCurrentSettings(refreshNow: false)
- },
- onSave: { [weak self] in
- AppLogger.info("Settings saved.")
- self?.applyCurrentSettings(refreshNow: true)
- },
- onReset: { [weak self] in
- AppLogger.info("Settings reset or cancelled.")
- self?.applyCurrentSettings(refreshNow: true)
- }
- )
- }
- settingsWindow?.showWindow(nil)
- NSApp.activate(ignoringOtherApps: true)
- }
-
- private func openLargeGraph() {
- AppLogger.info("Large graph window requested.")
- if largeGraphWindow == nil {
- largeGraphWindow = LargeGraphWindowController(graphProvider: { [weak self] in self?.graphSamples() ?? [] })
- }
- largeGraphWindow?.rebuild()
- largeGraphWindow?.showWindow(nil)
- NSApp.activate(ignoringOtherApps: true)
- }
-
- @objc private func openDetachedDashboard() {
- AppLogger.info("Detached dashboard requested.")
- if detachedDashboardWindow == nil {
- detachedDashboardWindow = DetachedDashboardWindowController(
- snapshotProvider: { [weak self] in self?.currentSnapshot() },
- graphProvider: { [weak self] in self?.graphSamples() ?? [] },
- onRangeChange: { [weak self] in
- self?.detachedDashboardWindow?.rebuild()
- self?.largeGraphWindow?.rebuild()
- },
- onOpenLarge: { [weak self] in
- self?.openLargeGraph()
- }
- )
- }
- detachedDashboardWindow?.showBelowMenuBar(anchor: statusButtonFrameOnScreen())
- }
-
- @objc private func openDetachedMenuBar() {
- if detachedMenuBarWindow == nil {
- detachedMenuBarWindow = DetachedMenuBarWindowController(
- attributedBarProvider: { [weak self] in
- guard let self, let snapshot = self.currentSnapshot() else { return nil }
- return self.barAttributedText(for: snapshot, scale: self.settings.detachedMenuBarScale)
- },
- onClose: { [weak self] in
- self?.isMenuBarDetached = false
- if self?.isTerminating != true {
- self?.settings.isDetachedMenuBarActive = false
- AppLogger.info("Detached slim bar closed by user.")
- }
- self?.detachedMenuBarWindow = nil
- self?.updateMenuBarIcon()
- self?.updateTitle()
- self?.rebuildMenu()
- }
- )
- }
- isMenuBarDetached = true
- settings.isDetachedMenuBarActive = true
- AppLogger.info("Detached slim bar opened.")
- updateMenuBarIcon()
- updateTitle()
- rebuildMenu()
- detachedMenuBarWindow?.showBelowMenuBar(anchor: statusButtonFrameOnScreen())
- }
-
- private func dockDetachedMenuBar() {
- if let detachedMenuBarWindow {
- detachedMenuBarWindow.closeFromOwner()
- return
- }
- isMenuBarDetached = false
- settings.isDetachedMenuBarActive = false
- updateMenuBarIcon()
- updateTitle()
- rebuildMenu()
- }
-
- private func detachedMenuBarStatusAttributedTitle() -> NSAttributedString {
- let isOnline: Bool
- if let snapshot = currentSnapshot() {
- isOnline = snapshot.status?.localizedCaseInsensitiveContains("offline") != true
- } else {
- isOnline = lastError == nil
- }
-
- let result = NSMutableAttributedString()
- result.append(NSAttributedString(
- string: isRefreshing ? "\(refreshIndicator()) " : "● ",
- attributes: [
- .font: NSFont.systemFont(ofSize: round(12 * settings.menuBarScale), weight: .bold),
- .foregroundColor: isRefreshing ? refreshColor : (isOnline ? NSColor.systemGreen : NSColor.systemRed),
- .shadow: menuBarTextShadow
- ]
- ))
- result.append(NSAttributedString(
- string: isOnline ? "Online" : "Offline",
- attributes: [
- .font: NSFont.monospacedDigitSystemFont(ofSize: round(13 * settings.menuBarScale), weight: .semibold),
- .foregroundColor: NSColor.labelColor
- ]
- ))
- return result
- }
-
- private func statusButtonFrameOnScreen() -> NSRect? {
- guard let button = item.button, let window = button.window else { return nil }
- return window.convertToScreen(button.convert(button.bounds, to: nil))
- }
-
- private func applyCurrentSettings(refreshNow: Bool) {
- scheduleRefreshTimer()
- applyAppearance()
- updateMenuBarIcon()
- clearStaleSnapshotIfNeeded()
- updateTitle()
- rebuildMenu()
- detachedMenuBarWindow?.rebuild()
- if refreshNow {
- refresh()
- }
- }
-
- private func applyAppearance() {
- switch settings.appearanceMode {
- case .system:
- NSApp.appearance = nil
- case .light:
- NSApp.appearance = NSAppearance(named: .aqua)
- case .dark:
- NSApp.appearance = NSAppearance(named: .darkAqua)
- }
- }
-
- private func currentSnapshot() -> SolixSnapshot? {
- guard lastSnapshotMode == settings.dataSourceMode else { return nil }
- return lastSnapshot
- }
-
- private func clearStaleSnapshotIfNeeded() {
- guard lastSnapshotMode == settings.dataSourceMode else {
- lastSnapshot = nil
- lastSnapshotMode = nil
- lastError = configurationMessage()
- return
- }
-
- if !isCurrentDataSourceConfigured {
- lastSnapshot = nil
- lastSnapshotMode = nil
- lastError = configurationMessage()
- }
- }
-
- private var isCurrentDataSourceConfigured: Bool {
- switch settings.dataSourceMode {
- case .demo:
- true
- case .command:
- !settings.command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
- case .url:
- !settings.urlString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
- }
- }
-
- private func configurationMessage() -> String? {
- switch settings.dataSourceMode {
- case .demo:
- nil
- case .command:
- isCurrentDataSourceConfigured ? "Noch keine Daten vom JSON-Befehl geladen." : "Kein JSON-Befehl konfiguriert."
- case .url:
- isCurrentDataSourceConfigured ? "Noch keine Daten von der JSON-URL geladen." : "Keine JSON-URL konfiguriert."
- }
- }
-
- @objc private func quit() {
- NSApp.terminate(nil)
- }
-
- @objc private func showLogFile() {
- AppLogger.info("Log file requested from menu.")
- NSWorkspace.shared.activateFileViewerSelecting([AppLogger.logURL])
- }
-}
diff --git a/Sources/SolixBar/main.swift b/Sources/SolixBar/main.swift
index c39deb8..8cfb2fa 100644
--- a/Sources/SolixBar/main.swift
+++ b/Sources/SolixBar/main.swift
@@ -1,4 +1,5 @@
import AppKit
+import SolixBarKit
let app = NSApplication.shared
let delegate = AppDelegate()
diff --git a/Sources/SolixBarKit/AnimatedPanelView.swift b/Sources/SolixBarKit/AnimatedPanelView.swift
new file mode 100644
index 0000000..2c230c3
--- /dev/null
+++ b/Sources/SolixBarKit/AnimatedPanelView.swift
@@ -0,0 +1,62 @@
+import AppKit
+import QuartzCore
+
+final class AnimatedPanelView: NSView {
+ var baseColor: NSColor = .controlBackgroundColor {
+ didSet {
+ applyLayerColor()
+ }
+ }
+
+ var highlightColor: NSColor = .controlBackgroundColor
+
+ init() {
+ super.init(frame: .zero)
+ wantsLayer = true
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func viewDidMoveToWindow() {
+ super.viewDidMoveToWindow()
+ if window == nil {
+ layer?.removeAnimation(forKey: "softPanelGlow")
+ } else {
+ startSoftAnimation()
+ }
+ }
+
+ /// Dynamische NSColor werden beim Setzen als cgColor eingefroren; bei einem
+ /// Theme-Wechsel müssen Layer-Farben deshalb neu aufgelöst werden.
+ override func viewDidChangeEffectiveAppearance() {
+ super.viewDidChangeEffectiveAppearance()
+ applyLayerColor()
+ if window != nil {
+ startSoftAnimation()
+ }
+ }
+
+ private func applyLayerColor() {
+ effectiveAppearance.performAsCurrentDrawingAppearance { [self] in
+ layer?.backgroundColor = baseColor.cgColor
+ }
+ }
+
+ private func startSoftAnimation() {
+ guard let layer else { return }
+ layer.removeAnimation(forKey: "softPanelGlow")
+ effectiveAppearance.performAsCurrentDrawingAppearance {
+ layer.backgroundColor = baseColor.cgColor
+ let animation = CABasicAnimation(keyPath: "backgroundColor")
+ animation.fromValue = baseColor.cgColor
+ animation.toValue = highlightColor.cgColor
+ animation.duration = 3.8
+ animation.autoreverses = true
+ animation.repeatCount = .infinity
+ animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
+ layer.add(animation, forKey: "softPanelGlow")
+ }
+ }
+}
diff --git a/Sources/SolixBarKit/AppDelegate.swift b/Sources/SolixBarKit/AppDelegate.swift
new file mode 100644
index 0000000..fdcf599
--- /dev/null
+++ b/Sources/SolixBarKit/AppDelegate.swift
@@ -0,0 +1,33 @@
+import AppKit
+
+public final class AppDelegate: NSObject, NSApplicationDelegate {
+ private var statusController: StatusController?
+
+ public override init() {
+ super.init()
+ }
+
+ public func applicationDidFinishLaunching(_ notification: Notification) {
+ // Doppelstart-Schutz: zweite Instanz aktiviert die erste und beendet
+ // sich (zwei Instanzen ergaeben zwei Statusitems und Schreibkonflikte).
+ if let bundleID = Bundle.main.bundleIdentifier {
+ let others = NSRunningApplication.runningApplications(withBundleIdentifier: bundleID)
+ .filter { $0.processIdentifier != ProcessInfo.processInfo.processIdentifier }
+ if let existing = others.first {
+ existing.activate()
+ AppLogger.info("Second instance detected; terminating in favor of PID \(existing.processIdentifier).")
+ NSApp.terminate(nil)
+ return
+ }
+ }
+ UserDefaults.standard.set(0.1, forKey: "NSInitialToolTipDelay")
+ AppLogger.info("SolixBar \(AppVersion.display) started. Log: \(AppLogger.logURL.path)")
+ statusController = StatusController()
+ statusController?.start()
+ }
+
+ public func applicationWillTerminate(_ notification: Notification) {
+ statusController?.prepareForTermination()
+ AppLogger.info("SolixBar terminated.")
+ }
+}
diff --git a/Sources/SolixBarKit/AppLogger.swift b/Sources/SolixBarKit/AppLogger.swift
new file mode 100644
index 0000000..d779a46
--- /dev/null
+++ b/Sources/SolixBarKit/AppLogger.swift
@@ -0,0 +1,84 @@
+import Foundation
+import os
+
+/// Loggt doppelt: ins Unified Logging (Console.app, Filterung per Subsystem)
+/// und in eine Datei für Support-Fälle. DEBUG-Zeilen erscheinen nur, wenn
+/// `defaults write local.codex.SolixBar verboseLogging -bool true` gesetzt ist.
+enum AppLogger {
+ private static let subsystem = "local.codex.SolixBar"
+ private static let osLogger = Logger(subsystem: subsystem, category: "app")
+ private static let lock = NSLock()
+ private static let maxLogSize = 512 * 1024
+ nonisolated(unsafe) private static var handle: FileHandle?
+ nonisolated(unsafe) private static var cachedFormatter: ISO8601DateFormatter?
+
+ static let isVerbose = UserDefaults.standard.bool(forKey: "verboseLogging")
+
+ static var logURL: URL {
+ let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
+ ?? URL(fileURLWithPath: NSTemporaryDirectory())
+ return base.appendingPathComponent("SolixBar", isDirectory: true).appendingPathComponent("SolixBar.log")
+ }
+
+ static func debug(_ message: String, function: String = #function) {
+ guard isVerbose else { return }
+ osLogger.debug("\(function, privacy: .public): \(message, privacy: .public)")
+ write("DEBUG", "\(function): \(message)")
+ }
+
+ static func info(_ message: String, function: String = #function) {
+ osLogger.info("\(message, privacy: .public)")
+ write("INFO", isVerbose ? "\(function): \(message)" : message)
+ }
+
+ static func error(_ message: String, function: String = #function) {
+ osLogger.error("\(function, privacy: .public): \(message, privacy: .public)")
+ write("ERROR", "\(function): \(message)")
+ }
+
+ private static func write(_ level: String, _ message: String) {
+ lock.lock()
+ defer { lock.unlock() }
+
+ do {
+ let line = "\(timestamp()) [\(level)] \(message)\n"
+ let data = Data(line.utf8)
+ try ensureHandle()
+ try handle?.write(contentsOf: data)
+ } catch {
+ NSLog("SolixBar logging failed: \(error.localizedDescription)")
+ }
+ }
+
+ /// Hält die Datei offen statt sie pro Zeile neu zu öffnen; rotiert bei 512 KB.
+ private static func ensureHandle() throws {
+ if let size = try? FileManager.default.attributesOfItem(atPath: logURL.path)[.size] as? NSNumber,
+ size.intValue > maxLogSize {
+ try? handle?.close()
+ handle = nil
+ let oldURL = logURL.deletingLastPathComponent().appendingPathComponent("SolixBar.old.log")
+ try? FileManager.default.removeItem(at: oldURL)
+ try? FileManager.default.moveItem(at: logURL, to: oldURL)
+ }
+ if handle == nil {
+ let directory = logURL.deletingLastPathComponent()
+ try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
+ if !FileManager.default.fileExists(atPath: logURL.path) {
+ FileManager.default.createFile(atPath: logURL.path, contents: nil)
+ }
+ let newHandle = try FileHandle(forWritingTo: logURL)
+ try newHandle.seekToEnd()
+ handle = newHandle
+ }
+ }
+
+ private static func timestamp() -> String {
+ if let cachedFormatter {
+ return cachedFormatter.string(from: Date())
+ }
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
+ cachedFormatter = formatter
+ return formatter.string(from: Date())
+ }
+}
diff --git a/Sources/SolixBarKit/AppSettings.swift b/Sources/SolixBarKit/AppSettings.swift
new file mode 100644
index 0000000..99c4ec5
--- /dev/null
+++ b/Sources/SolixBarKit/AppSettings.swift
@@ -0,0 +1,860 @@
+import Foundation
+
+enum DataSourceMode: String {
+ /// Direkter SOLIX-Abruf über die mitgelieferte Python-Laufzeit —
+ /// funktioniert ohne konfigurierten Befehl und ohne lokale Installation.
+ case solix
+ case demo
+ /// Gerafftes Test-Szenario, damit aktivierte Warnungen innerhalb
+ /// weniger Minuten wirklich feuern.
+ case demoWarnings = "demo-warnings"
+ case command
+ case url
+}
+
+enum AppAppearanceMode: String {
+ case system
+ case light
+ case dark
+}
+
+enum AppLanguage: String {
+ case german
+ case english
+}
+
+enum BarMetric: String, CaseIterable {
+ case battery
+ case solar
+ case home
+ case grid
+ case batteryFlow
+ case today
+ case total
+ case status
+
+ var title: String {
+ switch self {
+ case .battery:
+ "Batterie"
+ case .solar:
+ "PV"
+ case .home:
+ "Hauslast"
+ case .grid:
+ "Netzbezug"
+ case .batteryFlow:
+ "Akku-Fluss"
+ case .today:
+ "Heutiger Ertrag"
+ case .total:
+ "Gesamtertrag"
+ case .status:
+ "Status"
+ }
+ }
+
+ var shortTitle: String {
+ switch self {
+ case .battery:
+ "Akku"
+ case .solar:
+ "PV"
+ case .home:
+ "Last"
+ case .grid:
+ "Netz"
+ case .batteryFlow:
+ "Fluss"
+ case .today:
+ "Ertrag"
+ case .total:
+ "Gesamt"
+ case .status:
+ "Status"
+ }
+ }
+
+ var symbolName: String {
+ switch self {
+ case .battery:
+ "battery.75percent"
+ case .solar:
+ "sun.max.fill"
+ case .home:
+ "house.fill"
+ case .grid:
+ "powerplug.fill"
+ case .batteryFlow:
+ "bolt.fill"
+ case .today:
+ "chart.bar.fill"
+ case .total:
+ "sum"
+ case .status:
+ "checkmark.circle.fill"
+ }
+ }
+}
+
+enum HistoryRange: String, CaseIterable {
+ case current
+ case day
+ case week
+ case month
+ case custom
+
+ var title: String {
+ switch self {
+ case .current:
+ "Aktuell"
+ case .day:
+ "24 Stunden"
+ case .week:
+ "7 Tage"
+ case .month:
+ "30 Tage"
+ case .custom:
+ "Individuell"
+ }
+ }
+
+ var shortTitle: String {
+ switch self {
+ case .current:
+ "Akt."
+ case .day:
+ "24h"
+ case .week:
+ "7T"
+ case .month:
+ "30T"
+ case .custom:
+ "Eig."
+ }
+ }
+
+ func duration(customDays: Double) -> TimeInterval {
+ switch self {
+ case .current:
+ 3 * 60 * 60
+ case .day:
+ 24 * 60 * 60
+ case .week:
+ 7 * 24 * 60 * 60
+ case .month:
+ 30 * 24 * 60 * 60
+ case .custom:
+ max(1, customDays) * 24 * 60 * 60
+ }
+ }
+}
+
+/// Fensterebene der frei platzierbaren Fenster (Slim-Bar, abgedocktes
+/// Dashboard): immer vorn, normal im Fensterstapel oder immer hinten.
+/// Darstellung des PV-Werts, wenn die Solarbank ihre MPPT-Kanäle einzeln
+/// meldet: nur Summe, nur Einzelwerte oder beides.
+enum PVDisplayMode: String, CaseIterable, Sendable {
+ case total
+ case perInput
+ case both
+
+ @MainActor var title: String {
+ switch self {
+ case .total:
+ return LocalizedText.text("Gesamtwert", "Total")
+ case .perInput:
+ return LocalizedText.text("Einzelwerte", "Individual inputs")
+ case .both:
+ return LocalizedText.text("Gesamt + Einzelwerte", "Total + individual")
+ }
+ }
+}
+
+enum WindowLevelMode: String, CaseIterable {
+ case alwaysOnTop
+ case normal
+ case alwaysBehind
+
+ @MainActor var title: String {
+ switch self {
+ case .alwaysOnTop:
+ return LocalizedText.text("Immer im Vordergrund", "Always on top")
+ case .normal:
+ return LocalizedText.text("Normal (wie andere Fenster)", "Normal (like other windows)")
+ case .alwaysBehind:
+ return LocalizedText.text("Immer im Hintergrund", "Always behind")
+ }
+ }
+}
+
+enum GraphMetric: String, CaseIterable {
+ case battery
+ case solar
+ case grid
+
+ var title: String {
+ switch self {
+ case .battery:
+ "Akku"
+ case .solar:
+ "Solar"
+ case .grid:
+ "Netzbezug"
+ }
+ }
+}
+
+struct AppSettingsSnapshot: Equatable {
+ var dataSourceMode: DataSourceMode
+ var command: String
+ var urlString: String
+ var refreshInterval: TimeInterval
+ var barMetrics: [BarMetric]
+ var detachedBarMetrics: [BarMetric]
+ var stackedBarMetrics: [BarMetric]
+ var detachedStackedBarMetrics: [BarMetric]
+ var detachedShowLabels: Bool
+ var detachedShowSymbols: Bool
+ var detachedShowArrows: Bool
+ var detachedShowIcon: Bool
+ var menuBarStacked: Bool
+ var detachedBarStacked: Bool
+ var showMenuBarIcon: Bool
+ var showMetricLabels: Bool
+ var showMenuBarMetricSymbols: Bool
+ var showEnergyFlowArrows: Bool
+ var showFlowColors: Bool
+ var detachedShowFlowColors: Bool
+ var menuBarScale: Double
+ var detachedMenuBarScale: Double
+ var lockDetachedMenuBar: Bool
+ var appearanceMode: AppAppearanceMode
+ var appLanguage: AppLanguage
+ var historyRange: HistoryRange
+ var customHistoryDays: Double
+ var customHistoryUnit: String
+ var graphMetrics: [GraphMetric]
+ var graphFitsData: Bool
+ var isDetachedMenuBarActive: Bool
+ var detachedMenuBarFrame: String
+ var detachedBarLevel: WindowLevelMode
+ var dashboardWindowLevel: WindowLevelMode
+ var graphWindowLevel: WindowLevelMode
+ var updateCheckEnabled: Bool
+ var dashboardPVDisplay: PVDisplayMode
+ var detachedDashboardPVDisplay: PVDisplayMode
+ var menuBarPVDisplay: PVDisplayMode
+ var detachedPVDisplay: PVDisplayMode
+ var warnBatteryLowEnabled: Bool
+ var warnBatteryLowThreshold: Int
+ var warnPVStallEnabled: Bool
+ var warnPVStallMinutes: Int
+ var warnPVStallMinRecentWatts: Int
+ var warnPVWindowEnabled: Bool
+ var warnPVWindowStart: Int
+ var warnPVWindowEnd: Int
+ var warnPerPVEnabled: Bool
+ var warnPerPVDipEnabled: Bool
+ var graphSmoothing: Bool
+ var graphFilledMetrics: [GraphMetric]
+}
+
+@MainActor
+final class AppSettings {
+ static let shared = AppSettings()
+
+ private let defaults = UserDefaults.standard
+ private let defaultBarMetrics: [BarMetric] = [.battery, .solar, .grid]
+
+ var dataSourceMode: DataSourceMode {
+ get { DataSourceMode(rawValue: defaults.string(forKey: "dataSourceMode") ?? "") ?? .demo }
+ set { defaults.set(newValue.rawValue, forKey: "dataSourceMode") }
+ }
+
+ var command: String {
+ get { defaults.string(forKey: "command") ?? "" }
+ set { defaults.set(newValue, forKey: "command") }
+ }
+
+ var urlString: String {
+ get { defaults.string(forKey: "urlString") ?? "" }
+ set { defaults.set(newValue, forKey: "urlString") }
+ }
+
+ var refreshInterval: TimeInterval {
+ get {
+ let value = defaults.double(forKey: "refreshInterval")
+ return value > 0 ? max(60, value) : 300
+ }
+ set { defaults.set(max(60, newValue), forKey: "refreshInterval") }
+ }
+
+ var barMetrics: [BarMetric] {
+ get {
+ guard let values = defaults.array(forKey: "barMetrics") as? [String] else {
+ return defaultBarMetrics
+ }
+ let metrics = values.compactMap(BarMetric.init(rawValue:))
+ return metrics.isEmpty ? defaultBarMetrics : metrics
+ }
+ set {
+ let metrics = newValue.isEmpty ? defaultBarMetrics : newValue
+ defaults.set(metrics.map(\.rawValue), forKey: "barMetrics")
+ }
+ }
+
+ /// Werte der abgedockten Leiste; folgt der Menüleisten-Auswahl, bis der
+ /// Nutzer sie explizit anpasst.
+ var detachedBarMetrics: [BarMetric] {
+ get {
+ guard let values = defaults.array(forKey: "detachedBarMetrics") as? [String] else {
+ return barMetrics
+ }
+ let metrics = values.compactMap(BarMetric.init(rawValue:))
+ return metrics.isEmpty ? barMetrics : metrics
+ }
+ set {
+ defaults.set(newValue.map(\.rawValue), forKey: "detachedBarMetrics")
+ }
+ }
+
+ /// Werte der Kompaktansicht (Menüleiste); leer = folgt der einzeiligen
+ /// Liste, bis der Nutzer sie in den Einstellungen entkoppelt.
+ var stackedBarMetrics: [BarMetric] {
+ get {
+ guard let values = defaults.array(forKey: "stackedBarMetrics") as? [String] else { return [] }
+ return values.compactMap(BarMetric.init(rawValue:))
+ }
+ set { defaults.set(newValue.map(\.rawValue), forKey: "stackedBarMetrics") }
+ }
+
+ var effectiveStackedBarMetrics: [BarMetric] {
+ let stacked = stackedBarMetrics
+ return stacked.isEmpty ? barMetrics : stacked
+ }
+
+ /// Werte der Kompaktansicht der abgedockten Leiste; leer = folgt deren
+ /// einzeiliger Liste (die ihrerseits der Menüleiste folgen kann).
+ var detachedStackedBarMetrics: [BarMetric] {
+ get {
+ guard let values = defaults.array(forKey: "detachedStackedBarMetrics") as? [String] else { return [] }
+ return values.compactMap(BarMetric.init(rawValue:))
+ }
+ set { defaults.set(newValue.map(\.rawValue), forKey: "detachedStackedBarMetrics") }
+ }
+
+ var effectiveDetachedStackedBarMetrics: [BarMetric] {
+ let stacked = detachedStackedBarMetrics
+ return stacked.isEmpty ? detachedBarMetrics : stacked
+ }
+
+ private func followBool(_ key: String, fallback: Bool) -> Bool {
+ guard defaults.object(forKey: key) != nil else { return fallback }
+ return defaults.bool(forKey: key)
+ }
+
+ var detachedShowLabels: Bool {
+ get { followBool("detachedShowLabels", fallback: showMetricLabels) }
+ set { defaults.set(newValue, forKey: "detachedShowLabels") }
+ }
+
+ var detachedShowSymbols: Bool {
+ get { followBool("detachedShowSymbols", fallback: showMenuBarMetricSymbols) }
+ set { defaults.set(newValue, forKey: "detachedShowSymbols") }
+ }
+
+ var detachedShowArrows: Bool {
+ get { followBool("detachedShowArrows", fallback: showEnergyFlowArrows) }
+ set { defaults.set(newValue, forKey: "detachedShowArrows") }
+ }
+
+ var detachedShowIcon: Bool {
+ get { followBool("detachedShowIcon", fallback: showMenuBarIcon) }
+ set { defaults.set(newValue, forKey: "detachedShowIcon") }
+ }
+
+ func migrateMenuBarGridMetricIfNeeded() {
+ let key = "didMigrateGridMetric033"
+ guard defaults.bool(forKey: key) == false else { return }
+ if !barMetrics.contains(.grid) {
+ barMetrics.append(.grid)
+ }
+ defaults.set(true, forKey: key)
+ }
+
+ /// Der Sammelwert "Energiefluss" zeigte dieselben Pfeile und Begriffe wie
+ /// die Option "Farben und Flussrichtung" ein zweites Mal — er entfällt.
+ /// Wer ihn aktiv hatte, behält die Information über die Pfeil-Option.
+ func migrateFlowMetricIfNeeded() {
+ let key = "didMigrateFlowMetric040"
+ guard defaults.bool(forKey: key) == false else { return }
+ defaults.set(true, forKey: key)
+ if let stored = defaults.array(forKey: "barMetrics") as? [String], stored.contains("flow") {
+ defaults.set(stored.filter { $0 != "flow" }, forKey: "barMetrics")
+ showEnergyFlowArrows = true
+ }
+ if let stored = defaults.array(forKey: "detachedBarMetrics") as? [String], stored.contains("flow") {
+ defaults.set(stored.filter { $0 != "flow" }, forKey: "detachedBarMetrics")
+ detachedShowArrows = true
+ }
+ }
+
+ /// Wer bisher den vorbereiteten SOLIX-Befehl im Modus "Lokaler
+ /// JSON-Befehl" nutzte, wechselt auf den direkten SOLIX-Modus — die
+ /// Zugangsdaten (Env-Datei + Schlüsselbund) bleiben dieselben.
+ func migrateSolixCommandIfNeeded() {
+ let key = "didMigrateSolixCommand043"
+ guard defaults.bool(forKey: key) == false else { return }
+ defaults.set(true, forKey: key)
+ if dataSourceMode == .command, command.contains("run_solix_snapshot.sh") {
+ dataSourceMode = .solix
+ }
+ }
+
+ /// Kompaktanzeige ist Standard: informationsdicht und notch-sicher.
+ var menuBarStacked: Bool {
+ get {
+ guard defaults.object(forKey: "menuBarStacked") != nil else { return true }
+ return defaults.bool(forKey: "menuBarStacked")
+ }
+ set { defaults.set(newValue, forKey: "menuBarStacked") }
+ }
+
+ var detachedBarStacked: Bool {
+ get { defaults.bool(forKey: "detachedBarStacked") }
+ set { defaults.set(newValue, forKey: "detachedBarStacked") }
+ }
+
+ var showMenuBarIcon: Bool {
+ get {
+ guard defaults.object(forKey: "showMenuBarIcon") != nil else { return true }
+ return defaults.bool(forKey: "showMenuBarIcon")
+ }
+ set { defaults.set(newValue, forKey: "showMenuBarIcon") }
+ }
+
+ var showMetricLabels: Bool {
+ get {
+ guard defaults.object(forKey: "showMetricLabels") != nil else { return true }
+ return defaults.bool(forKey: "showMetricLabels")
+ }
+ set { defaults.set(newValue, forKey: "showMetricLabels") }
+ }
+
+ var showMenuBarMetricSymbols: Bool {
+ get {
+ guard defaults.object(forKey: "showMenuBarMetricSymbols") != nil else { return false }
+ return defaults.bool(forKey: "showMenuBarMetricSymbols")
+ }
+ set { defaults.set(newValue, forKey: "showMenuBarMetricSymbols") }
+ }
+
+ var showEnergyFlowArrows: Bool {
+ get {
+ guard defaults.object(forKey: "showEnergyFlowArrows") != nil else { return false }
+ return defaults.bool(forKey: "showEnergyFlowArrows")
+ }
+ set { defaults.set(newValue, forKey: "showEnergyFlowArrows") }
+ }
+
+ /// Rollenfarben getrennt von der Flussrichtung — die frühere Option
+ /// schaltete beides gemeinsam. Standard an: entspricht der farbigen
+ /// Kompaktanzeige.
+ var showFlowColors: Bool {
+ get { followBool("showFlowColors", fallback: true) }
+ set { defaults.set(newValue, forKey: "showFlowColors") }
+ }
+
+ var detachedShowFlowColors: Bool {
+ get { followBool("detachedShowFlowColors", fallback: showFlowColors) }
+ set { defaults.set(newValue, forKey: "detachedShowFlowColors") }
+ }
+
+ var menuBarScale: Double {
+ get {
+ let value = defaults.double(forKey: "menuBarScale")
+ return value > 0 ? min(1.6, max(0.75, value)) : 1.0
+ }
+ set { defaults.set(min(1.6, max(0.75, newValue)), forKey: "menuBarScale") }
+ }
+
+ var detachedMenuBarScale: Double {
+ get {
+ let value = defaults.double(forKey: "detachedMenuBarScale")
+ return value > 0 ? min(1.9, max(0.75, value)) : 1.0
+ }
+ set { defaults.set(min(1.9, max(0.75, newValue)), forKey: "detachedMenuBarScale") }
+ }
+
+ var lockDetachedMenuBar: Bool {
+ get { defaults.bool(forKey: "lockDetachedMenuBar") }
+ set { defaults.set(newValue, forKey: "lockDetachedMenuBar") }
+ }
+
+ var appearanceMode: AppAppearanceMode {
+ get { AppAppearanceMode(rawValue: defaults.string(forKey: "appearanceMode") ?? "") ?? .system }
+ set { defaults.set(newValue.rawValue, forKey: "appearanceMode") }
+ }
+
+ var appLanguage: AppLanguage {
+ get { AppLanguage(rawValue: defaults.string(forKey: "appLanguage") ?? "") ?? .german }
+ set { defaults.set(newValue.rawValue, forKey: "appLanguage") }
+ }
+
+ var historyRange: HistoryRange {
+ get { HistoryRange(rawValue: defaults.string(forKey: "historyRange") ?? "") ?? .day }
+ set { defaults.set(newValue.rawValue, forKey: "historyRange") }
+ }
+
+ var customHistoryDays: Double {
+ get {
+ let value = defaults.double(forKey: "customHistoryDays")
+ return value > 0 ? min(365, max(1, value)) : 14
+ }
+ set { defaults.set(min(365, max(1, newValue)), forKey: "customHistoryDays") }
+ }
+
+ /// Anzeigeeinheit des eigenen Zeitraums (hours/days/weeks); gespeichert
+ /// wird immer in Tagen (customHistoryDays).
+ var customHistoryUnit: String {
+ get { defaults.string(forKey: "customHistoryUnit") ?? "days" }
+ set { defaults.set(newValue, forKey: "customHistoryUnit") }
+ }
+
+ var historyDuration: TimeInterval {
+ historyRange.duration(customDays: customHistoryDays)
+ }
+
+ /// Passt die Zeitachse an vorhandene Daten an, statt leere Zeiträume zu
+ /// zeigen. Abschaltbar für den festen Kalenderblick.
+ var graphFitsData: Bool {
+ get {
+ guard defaults.object(forKey: "graphFitsData") != nil else { return true }
+ return defaults.bool(forKey: "graphFitsData")
+ }
+ set { defaults.set(newValue, forKey: "graphFitsData") }
+ }
+
+ var graphMetrics: [GraphMetric] {
+ get {
+ guard let values = defaults.array(forKey: "graphMetrics") as? [String] else {
+ return GraphMetric.allCases
+ }
+ let metrics = values.compactMap(GraphMetric.init(rawValue:))
+ return metrics.isEmpty ? GraphMetric.allCases : metrics
+ }
+ set {
+ let metrics = newValue.isEmpty ? GraphMetric.allCases : newValue
+ defaults.set(metrics.map(\.rawValue), forKey: "graphMetrics")
+ }
+ }
+
+ /// Zeichnet die Verlaufskurven als weiche Kurven (monotone Interpolation
+ /// ohne Überschwingen) statt als Geradenzüge. Reine Darstellung — die
+ /// gespeicherten Messwerte bleiben unverändert.
+ var graphSmoothing: Bool {
+ get { defaults.bool(forKey: "graphSmoothing") }
+ set { defaults.set(newValue, forKey: "graphSmoothing") }
+ }
+
+ /// Welche Kurven eine Flächenfüllung unter der Linie bekommen.
+ /// Standard: nur Solar (bisheriges Verhalten).
+ var graphFilledMetrics: [GraphMetric] {
+ get {
+ guard let values = defaults.array(forKey: "graphFilledMetrics") as? [String] else {
+ return [.solar]
+ }
+ return values.compactMap(GraphMetric.init(rawValue:))
+ }
+ set { defaults.set(newValue.map(\.rawValue), forKey: "graphFilledMetrics") }
+ }
+
+ var isDetachedMenuBarActive: Bool {
+ get { defaults.bool(forKey: "isDetachedMenuBarActive") }
+ set { defaults.set(newValue, forKey: "isDetachedMenuBarActive") }
+ }
+
+ var detachedMenuBarFrame: String {
+ get { defaults.string(forKey: "detachedMenuBarFrame") ?? "" }
+ set { defaults.set(newValue, forKey: "detachedMenuBarFrame") }
+ }
+
+ /// Standardwerte entsprechen dem bisherigen festen Verhalten:
+ /// Slim-Bar hinten (Schreibtisch-Ebene), Dashboard immer vorn.
+ var detachedBarLevel: WindowLevelMode {
+ get { WindowLevelMode(rawValue: defaults.string(forKey: "detachedBarLevel") ?? "") ?? .alwaysBehind }
+ set { defaults.set(newValue.rawValue, forKey: "detachedBarLevel") }
+ }
+
+ var dashboardWindowLevel: WindowLevelMode {
+ get { WindowLevelMode(rawValue: defaults.string(forKey: "dashboardWindowLevel") ?? "") ?? .alwaysOnTop }
+ set { defaults.set(newValue.rawValue, forKey: "dashboardWindowLevel") }
+ }
+
+ /// Abgedocktes Verlaufsfenster: verhält sich standardmäßig wie ein
+ /// normales Fenster (vorn wenn aktiv, sonst dahinter).
+ var graphWindowLevel: WindowLevelMode {
+ get { WindowLevelMode(rawValue: defaults.string(forKey: "graphWindowLevel") ?? "") ?? .normal }
+ set { defaults.set(newValue.rawValue, forKey: "graphWindowLevel") }
+ }
+
+ var isLargeGraphActive: Bool {
+ get { defaults.bool(forKey: "isLargeGraphActive") }
+ set { defaults.set(newValue, forKey: "isLargeGraphActive") }
+ }
+
+ var updateCheckEnabled: Bool {
+ get { followBool("updateCheckEnabled", fallback: true) }
+ set { defaults.set(newValue, forKey: "updateCheckEnabled") }
+ }
+
+ /// PV-Anzeige je Fläche (Summe/Einzelwerte/beides). Die kurzlebigen
+ /// Bool-Vorgänger aus der 0.4.2-Testphase werden als Fallback gelesen.
+ var dashboardPVDisplay: PVDisplayMode {
+ get { pvDisplayMode(forKey: "dashboardPVDisplay", legacyBoolKey: "showPerPVValues") }
+ set { defaults.set(newValue.rawValue, forKey: "dashboardPVDisplay") }
+ }
+
+ /// Abgedocktes Dashboard-Fenster; folgt dem Menü-Dashboard, bis explizit gesetzt.
+ var detachedDashboardPVDisplay: PVDisplayMode {
+ get {
+ if let raw = defaults.string(forKey: "detachedDashboardPVDisplay"),
+ let mode = PVDisplayMode(rawValue: raw) {
+ return mode
+ }
+ return dashboardPVDisplay
+ }
+ set { defaults.set(newValue.rawValue, forKey: "detachedDashboardPVDisplay") }
+ }
+
+ var menuBarPVDisplay: PVDisplayMode {
+ get { pvDisplayMode(forKey: "menuBarPVDisplay", legacyBoolKey: "menuBarPerPVWatts") }
+ set { defaults.set(newValue.rawValue, forKey: "menuBarPVDisplay") }
+ }
+
+ /// Abgedockte Leiste; folgt der Menüleisten-Option, bis explizit gesetzt.
+ var detachedPVDisplay: PVDisplayMode {
+ get {
+ if let raw = defaults.string(forKey: "detachedPVDisplay"),
+ let mode = PVDisplayMode(rawValue: raw) {
+ return mode
+ }
+ if defaults.object(forKey: "detachedPerPVWatts") != nil {
+ return defaults.bool(forKey: "detachedPerPVWatts") ? .perInput : .total
+ }
+ return menuBarPVDisplay
+ }
+ set { defaults.set(newValue.rawValue, forKey: "detachedPVDisplay") }
+ }
+
+ private func pvDisplayMode(forKey key: String, legacyBoolKey: String) -> PVDisplayMode {
+ if let raw = defaults.string(forKey: key), let mode = PVDisplayMode(rawValue: raw) {
+ return mode
+ }
+ return defaults.bool(forKey: legacyBoolKey) ? .perInput : .total
+ }
+
+ // MARK: Warnungen (alle opt-in — ein Update darf nicht unaufgefordert
+ // Mitteilungen schicken)
+
+ var warnBatteryLowEnabled: Bool {
+ get { followBool("warnBatteryLowEnabled", fallback: false) }
+ set { defaults.set(newValue, forKey: "warnBatteryLowEnabled") }
+ }
+
+ var warnBatteryLowThreshold: Int {
+ get {
+ let value = defaults.integer(forKey: "warnBatteryLowThreshold")
+ return value > 0 ? min(95, max(5, value)) : 20
+ }
+ set { defaults.set(min(95, max(5, newValue)), forKey: "warnBatteryLowThreshold") }
+ }
+
+ var warnPVStallEnabled: Bool {
+ get { followBool("warnPVStallEnabled", fallback: false) }
+ set { defaults.set(newValue, forKey: "warnPVStallEnabled") }
+ }
+
+ var warnPVStallMinutes: Int {
+ get {
+ let value = defaults.integer(forKey: "warnPVStallMinutes")
+ return value > 0 ? min(120, max(5, value)) : 15
+ }
+ set { defaults.set(min(120, max(5, newValue)), forKey: "warnPVStallMinutes") }
+ }
+
+ var warnPVStallMinRecentWatts: Int {
+ get {
+ let value = defaults.integer(forKey: "warnPVStallMinRecentWatts")
+ return value > 0 ? min(2000, max(10, value)) : 50
+ }
+ set { defaults.set(min(2000, max(10, newValue)), forKey: "warnPVStallMinRecentWatts") }
+ }
+
+ var warnPVWindowEnabled: Bool {
+ get { followBool("warnPVWindowEnabled", fallback: false) }
+ set { defaults.set(newValue, forKey: "warnPVWindowEnabled") }
+ }
+
+ var warnPVWindowStart: Int {
+ get {
+ guard defaults.object(forKey: "warnPVWindowStart") != nil else { return 9 }
+ return min(23, max(0, defaults.integer(forKey: "warnPVWindowStart")))
+ }
+ set { defaults.set(min(23, max(0, newValue)), forKey: "warnPVWindowStart") }
+ }
+
+ var warnPVWindowEnd: Int {
+ get {
+ guard defaults.object(forKey: "warnPVWindowEnd") != nil else { return 17 }
+ return min(24, max(1, defaults.integer(forKey: "warnPVWindowEnd")))
+ }
+ set { defaults.set(min(24, max(1, newValue)), forKey: "warnPVWindowEnd") }
+ }
+
+ var warnPerPVEnabled: Bool {
+ get { followBool("warnPerPVEnabled", fallback: false) }
+ set { defaults.set(newValue, forKey: "warnPerPVEnabled") }
+ }
+
+ var warnPerPVDipEnabled: Bool {
+ get { followBool("warnPerPVDipEnabled", fallback: false) }
+ set { defaults.set(newValue, forKey: "warnPerPVDipEnabled") }
+ }
+
+ func warningConfig() -> WarningEngine.Config {
+ WarningEngine.Config(
+ batteryLowEnabled: warnBatteryLowEnabled,
+ batteryLowThreshold: warnBatteryLowThreshold,
+ pvStallEnabled: warnPVStallEnabled,
+ pvStallMinutes: warnPVStallMinutes,
+ pvStallMinRecentWatts: warnPVStallMinRecentWatts,
+ pvWindowEnabled: warnPVWindowEnabled,
+ pvWindowStartHour: warnPVWindowStart,
+ pvWindowEndHour: warnPVWindowEnd,
+ perPVEnabled: warnPerPVEnabled,
+ perPVDipEnabled: warnPerPVDipEnabled
+ )
+ }
+
+ /// Zuletzt gemeldete Update-Version — Zustand, keine Einstellung: bewusst
+ /// nicht im Snapshot, damit "Abbrechen" im Einstellungsfenster keine
+ /// bereits gezeigte Benachrichtigung wieder scharf schaltet.
+ var lastNotifiedUpdateVersion: String? {
+ get { defaults.string(forKey: "lastNotifiedUpdateVersion") }
+ set { defaults.set(newValue, forKey: "lastNotifiedUpdateVersion") }
+ }
+
+ func snapshot() -> AppSettingsSnapshot {
+ AppSettingsSnapshot(
+ dataSourceMode: dataSourceMode,
+ command: command,
+ urlString: urlString,
+ refreshInterval: refreshInterval,
+ barMetrics: barMetrics,
+ detachedBarMetrics: detachedBarMetrics,
+ stackedBarMetrics: stackedBarMetrics,
+ detachedStackedBarMetrics: detachedStackedBarMetrics,
+ detachedShowLabels: detachedShowLabels,
+ detachedShowSymbols: detachedShowSymbols,
+ detachedShowArrows: detachedShowArrows,
+ detachedShowIcon: detachedShowIcon,
+ menuBarStacked: menuBarStacked,
+ detachedBarStacked: detachedBarStacked,
+ showMenuBarIcon: showMenuBarIcon,
+ showMetricLabels: showMetricLabels,
+ showMenuBarMetricSymbols: showMenuBarMetricSymbols,
+ showEnergyFlowArrows: showEnergyFlowArrows,
+ showFlowColors: showFlowColors,
+ detachedShowFlowColors: detachedShowFlowColors,
+ menuBarScale: menuBarScale,
+ detachedMenuBarScale: detachedMenuBarScale,
+ lockDetachedMenuBar: lockDetachedMenuBar,
+ appearanceMode: appearanceMode,
+ appLanguage: appLanguage,
+ historyRange: historyRange,
+ customHistoryDays: customHistoryDays,
+ customHistoryUnit: customHistoryUnit,
+ graphMetrics: graphMetrics,
+ graphFitsData: graphFitsData,
+ isDetachedMenuBarActive: isDetachedMenuBarActive,
+ detachedMenuBarFrame: detachedMenuBarFrame,
+ detachedBarLevel: detachedBarLevel,
+ dashboardWindowLevel: dashboardWindowLevel,
+ graphWindowLevel: graphWindowLevel,
+ updateCheckEnabled: updateCheckEnabled,
+ dashboardPVDisplay: dashboardPVDisplay,
+ detachedDashboardPVDisplay: detachedDashboardPVDisplay,
+ menuBarPVDisplay: menuBarPVDisplay,
+ detachedPVDisplay: detachedPVDisplay,
+ warnBatteryLowEnabled: warnBatteryLowEnabled,
+ warnBatteryLowThreshold: warnBatteryLowThreshold,
+ warnPVStallEnabled: warnPVStallEnabled,
+ warnPVStallMinutes: warnPVStallMinutes,
+ warnPVStallMinRecentWatts: warnPVStallMinRecentWatts,
+ warnPVWindowEnabled: warnPVWindowEnabled,
+ warnPVWindowStart: warnPVWindowStart,
+ warnPVWindowEnd: warnPVWindowEnd,
+ warnPerPVEnabled: warnPerPVEnabled,
+ warnPerPVDipEnabled: warnPerPVDipEnabled,
+ graphSmoothing: graphSmoothing,
+ graphFilledMetrics: graphFilledMetrics
+ )
+ }
+
+ func apply(_ snapshot: AppSettingsSnapshot) {
+ dataSourceMode = snapshot.dataSourceMode
+ command = snapshot.command
+ urlString = snapshot.urlString
+ refreshInterval = snapshot.refreshInterval
+ barMetrics = snapshot.barMetrics
+ detachedBarMetrics = snapshot.detachedBarMetrics
+ stackedBarMetrics = snapshot.stackedBarMetrics
+ detachedStackedBarMetrics = snapshot.detachedStackedBarMetrics
+ detachedShowLabels = snapshot.detachedShowLabels
+ detachedShowSymbols = snapshot.detachedShowSymbols
+ detachedShowArrows = snapshot.detachedShowArrows
+ detachedShowIcon = snapshot.detachedShowIcon
+ menuBarStacked = snapshot.menuBarStacked
+ detachedBarStacked = snapshot.detachedBarStacked
+ showMenuBarIcon = snapshot.showMenuBarIcon
+ showMetricLabels = snapshot.showMetricLabels
+ showMenuBarMetricSymbols = snapshot.showMenuBarMetricSymbols
+ showEnergyFlowArrows = snapshot.showEnergyFlowArrows
+ showFlowColors = snapshot.showFlowColors
+ detachedShowFlowColors = snapshot.detachedShowFlowColors
+ menuBarScale = snapshot.menuBarScale
+ detachedMenuBarScale = snapshot.detachedMenuBarScale
+ lockDetachedMenuBar = snapshot.lockDetachedMenuBar
+ appearanceMode = snapshot.appearanceMode
+ appLanguage = snapshot.appLanguage
+ historyRange = snapshot.historyRange
+ customHistoryDays = snapshot.customHistoryDays
+ customHistoryUnit = snapshot.customHistoryUnit
+ graphMetrics = snapshot.graphMetrics
+ graphFitsData = snapshot.graphFitsData
+ isDetachedMenuBarActive = snapshot.isDetachedMenuBarActive
+ detachedMenuBarFrame = snapshot.detachedMenuBarFrame
+ detachedBarLevel = snapshot.detachedBarLevel
+ dashboardWindowLevel = snapshot.dashboardWindowLevel
+ graphWindowLevel = snapshot.graphWindowLevel
+ updateCheckEnabled = snapshot.updateCheckEnabled
+ dashboardPVDisplay = snapshot.dashboardPVDisplay
+ detachedDashboardPVDisplay = snapshot.detachedDashboardPVDisplay
+ menuBarPVDisplay = snapshot.menuBarPVDisplay
+ detachedPVDisplay = snapshot.detachedPVDisplay
+ warnBatteryLowEnabled = snapshot.warnBatteryLowEnabled
+ warnBatteryLowThreshold = snapshot.warnBatteryLowThreshold
+ warnPVStallEnabled = snapshot.warnPVStallEnabled
+ warnPVStallMinutes = snapshot.warnPVStallMinutes
+ warnPVStallMinRecentWatts = snapshot.warnPVStallMinRecentWatts
+ warnPVWindowEnabled = snapshot.warnPVWindowEnabled
+ warnPVWindowStart = snapshot.warnPVWindowStart
+ warnPVWindowEnd = snapshot.warnPVWindowEnd
+ warnPerPVEnabled = snapshot.warnPerPVEnabled
+ warnPerPVDipEnabled = snapshot.warnPerPVDipEnabled
+ graphSmoothing = snapshot.graphSmoothing
+ graphFilledMetrics = snapshot.graphFilledMetrics
+ }
+}
diff --git a/Sources/SolixBar/AppVersion.swift b/Sources/SolixBarKit/AppVersion.swift
similarity index 100%
rename from Sources/SolixBar/AppVersion.swift
rename to Sources/SolixBarKit/AppVersion.swift
diff --git a/Sources/SolixBarKit/AutostartManager.swift b/Sources/SolixBarKit/AutostartManager.swift
new file mode 100644
index 0000000..fce72c8
--- /dev/null
+++ b/Sources/SolixBarKit/AutostartManager.swift
@@ -0,0 +1,30 @@
+import Foundation
+import ServiceManagement
+
+/// Autostart über die moderne SMAppService-API. Ein früher installiertes
+/// LaunchAgent-Plist wird beim Umschalten entfernt (Migration).
+enum AutostartManager {
+ private static let label = "local.codex.SolixBar"
+
+ static var legacyLaunchAgentURL: URL {
+ FileManager.default.homeDirectoryForCurrentUser
+ .appendingPathComponent("Library/LaunchAgents/\(label).plist")
+ }
+
+ static var isEnabled: Bool {
+ SMAppService.mainApp.status == .enabled
+ || FileManager.default.fileExists(atPath: legacyLaunchAgentURL.path)
+ }
+
+ static func setEnabled(_ enabled: Bool) throws {
+ // Altes LaunchAgent-Plist immer aufräumen, sonst startet die App doppelt.
+ if FileManager.default.fileExists(atPath: legacyLaunchAgentURL.path) {
+ try? FileManager.default.removeItem(at: legacyLaunchAgentURL)
+ }
+ if enabled {
+ try SMAppService.mainApp.register()
+ } else {
+ try SMAppService.mainApp.unregister()
+ }
+ }
+}
diff --git a/Sources/SolixBar/DetachedDashboardWindowController.swift b/Sources/SolixBarKit/DetachedDashboardWindowController.swift
similarity index 77%
rename from Sources/SolixBar/DetachedDashboardWindowController.swift
rename to Sources/SolixBarKit/DetachedDashboardWindowController.swift
index 87a9e9d..f5b053c 100644
--- a/Sources/SolixBar/DetachedDashboardWindowController.swift
+++ b/Sources/SolixBarKit/DetachedDashboardWindowController.swift
@@ -3,31 +3,44 @@ import AppKit
@MainActor
final class DetachedDashboardWindowController: NSWindowController {
private let snapshotProvider: () -> SolixSnapshot?
+ private let previousProvider: () -> SolixSnapshot?
private let graphProvider: () -> [SolixHistorySample]
private let onRangeChange: () -> Void
private let onOpenLarge: () -> Void
init(
snapshotProvider: @escaping () -> SolixSnapshot?,
+ previousProvider: @escaping () -> SolixSnapshot? = { nil },
graphProvider: @escaping () -> [SolixHistorySample],
onRangeChange: @escaping () -> Void,
onOpenLarge: @escaping () -> Void
) {
self.snapshotProvider = snapshotProvider
+ self.previousProvider = previousProvider
self.graphProvider = graphProvider
self.onRangeChange = onRangeChange
self.onOpenLarge = onOpenLarge
+ // Eigener Fensterrahmen: Das Panel mit seinem 16-pt-Radius IST das
+ // Fenster (transparenter Hintergrund, ausgeblendete Titelleiste,
+ // schwebende Ampel-Buttons). Ein normaler Fensterrahmen erzeugte
+ // sichtbare "doppelte Ecken" um das randbündige Panel.
let window = NSWindow(
- contentRect: NSRect(x: 0, y: 0, width: 430, height: 622),
- styleMask: [.titled, .closable, .miniaturizable, .resizable],
+ contentRect: NSRect(x: 0, y: 0, width: 430, height: 672),
+ styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
backing: .buffered,
defer: false
)
window.title = "SOLIX Dashboard"
- window.minSize = NSSize(width: 430, height: 560)
- window.contentMinSize = NSSize(width: 430, height: 560)
+ window.titleVisibility = .hidden
+ window.titlebarAppearsTransparent = true
+ window.isOpaque = false
+ window.backgroundColor = .clear
+ window.isMovableByWindowBackground = true
+ window.hasShadow = true
+ window.minSize = NSSize(width: 430, height: 580)
+ window.contentMinSize = NSSize(width: 430, height: 580)
window.maxSize = NSSize(width: 760, height: 980)
- window.level = .floating
+ window.level = AppSettings.shared.dashboardWindowLevel.nsWindowLevel
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
window.setFrameAutosaveName("SolixBarDetachedDashboard")
super.init(window: window)
@@ -49,6 +62,7 @@ final class DetachedDashboardWindowController: NSWindowController {
func rebuild() {
guard let window else { return }
+ window.level = AppSettings.shared.dashboardWindowLevel.nsWindowLevel
let oldFrame = window.frame
let contentSize = window.contentView?.bounds.size ?? window.contentLayoutRect.size
let view: NSView
@@ -56,6 +70,8 @@ final class DetachedDashboardWindowController: NSWindowController {
if let snapshot = snapshotProvider() {
view = SolixMenuDashboardView(
snapshot: snapshot,
+ previous: previousProvider(),
+ style: .window,
graphProvider: graphProvider,
onRangeChange: onRangeChange,
onOpenLarge: onOpenLarge
@@ -93,6 +109,8 @@ private final class DetachedDashboardPlaceholderView: NSView {
super.init(frame: frameRect)
wantsLayer = true
layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor
+ layer?.cornerRadius = Theme.radiusPanel
+ layer?.masksToBounds = true
let label = NSTextField(wrappingLabelWithString: "Noch keine SOLIX-Daten geladen.")
label.alignment = .center
diff --git a/Sources/SolixBar/DetachedMenuBarWindowController.swift b/Sources/SolixBarKit/DetachedMenuBarWindowController.swift
similarity index 50%
rename from Sources/SolixBar/DetachedMenuBarWindowController.swift
rename to Sources/SolixBarKit/DetachedMenuBarWindowController.swift
index e67e073..7d97a9d 100644
--- a/Sources/SolixBar/DetachedMenuBarWindowController.swift
+++ b/Sources/SolixBarKit/DetachedMenuBarWindowController.swift
@@ -1,19 +1,34 @@
import AppKit
+extension WindowLevelMode {
+ /// "Immer hinten" nutzt die Schreibtischsymbol-Ebene: sichtbar auf dem
+ /// Desktop, aber unter allen normalen Fenstern.
+ var nsWindowLevel: NSWindow.Level {
+ switch self {
+ case .alwaysOnTop: return .floating
+ case .normal: return .normal
+ case .alwaysBehind: return NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.desktopIconWindow)))
+ }
+ }
+}
+
@MainActor
final class DetachedMenuBarWindowController: NSWindowController, NSWindowDelegate {
- private static let desktopAccessoryLevel = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.desktopIconWindow)))
private let settings = AppSettings.shared
private let attributedBarProvider: () -> NSAttributedString?
+ private let stackedImageProvider: () -> NSImage?
private let onClose: () -> Void
private var didNotifyClose = false
private var wantsVisible = false
+ private var snapDebounce: Timer?
init(
attributedBarProvider: @escaping () -> NSAttributedString?,
+ stackedImageProvider: @escaping () -> NSImage? = { nil },
onClose: @escaping () -> Void
) {
self.attributedBarProvider = attributedBarProvider
+ self.stackedImageProvider = stackedImageProvider
self.onClose = onClose
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 640, height: 44),
@@ -22,7 +37,7 @@ final class DetachedMenuBarWindowController: NSWindowController, NSWindowDelegat
defer: false
)
window.title = "SOLIX Leiste"
- window.level = Self.desktopAccessoryLevel
+ window.level = settings.detachedBarLevel.nsWindowLevel
window.isMovableByWindowBackground = !settings.lockDetachedMenuBar
window.collectionBehavior = [.canJoinAllSpaces]
window.hasShadow = true
@@ -56,10 +71,12 @@ final class DetachedMenuBarWindowController: NSWindowController, NSWindowDelegat
func rebuild() {
guard let window else { return }
window.isMovableByWindowBackground = !settings.lockDetachedMenuBar
+ window.level = settings.detachedBarLevel.nsWindowLevel
let attributedText = attributedBarProvider()
+ let stackedImage = stackedImageProvider()
let oldFrame = window.frame
- let targetSize = targetSize(for: attributedText, screen: window.screen)
- let view = DetachedMenuBarView(attributedText: attributedText, onClose: { [weak self] in
+ let targetSize = targetSize(for: attributedText, stackedImage: stackedImage, screen: window.screen)
+ let view = DetachedMenuBarView(attributedText: attributedText, stackedImage: stackedImage, onClose: { [weak self] in
self?.closeFromButton()
})
view.frame = NSRect(origin: .zero, size: targetSize)
@@ -68,8 +85,12 @@ final class DetachedMenuBarWindowController: NSWindowController, NSWindowDelegat
var frame = oldFrame
frame.size = targetSize
if let screen = window.screen {
- frame.origin.x = min(screen.visibleFrame.maxX - targetSize.width - 8, max(screen.visibleFrame.minX + 8, frame.origin.x))
- frame.origin.y = min(screen.visibleFrame.maxY - targetSize.height - 6, max(screen.visibleFrame.minY + 8, frame.origin.y))
+ // Position des Nutzers respektieren: nur sicherstellen, dass ein
+ // greifbarer Teil (60 pt) sichtbar bleibt, statt die Leiste hart
+ // an den Rand zu klemmen.
+ let visible = screen.visibleFrame
+ frame.origin.x = min(visible.maxX - 60, max(visible.minX + 60 - targetSize.width, frame.origin.x))
+ frame.origin.y = min(visible.maxY - targetSize.height, max(visible.minY, frame.origin.y))
}
window.setFrame(frame, display: true)
}
@@ -81,6 +102,46 @@ final class DetachedMenuBarWindowController: NSWindowController, NSWindowDelegat
func windowDidMove(_ notification: Notification) {
saveCurrentPosition()
+ scheduleEdgeSnap()
+ }
+
+ /// Magnetisches Einrasten an Bildschirmkanten (8 pt Abstand), entprellt,
+ /// damit es erst nach dem Loslassen greift und nicht am Cursor klebt.
+ private func scheduleEdgeSnap() {
+ guard !settings.lockDetachedMenuBar else { return }
+ snapDebounce?.invalidate()
+ snapDebounce = Timer.scheduledTimer(withTimeInterval: 0.30, repeats: false) { [weak self] _ in
+ Task { @MainActor in
+ self?.snapToNearbyEdge()
+ }
+ }
+ }
+
+ private func snapToNearbyEdge() {
+ guard let window, let screen = window.screen else { return }
+ let threshold: CGFloat = 18
+ let margin: CGFloat = 8
+ let visible = screen.visibleFrame
+ var frame = window.frame
+ var snapped = false
+
+ if abs(frame.minX - visible.minX) < threshold {
+ frame.origin.x = visible.minX + margin
+ snapped = true
+ } else if abs(frame.maxX - visible.maxX) < threshold {
+ frame.origin.x = visible.maxX - frame.width - margin
+ snapped = true
+ }
+ if abs(frame.minY - visible.minY) < threshold {
+ frame.origin.y = visible.minY + margin
+ snapped = true
+ } else if abs(frame.maxY - visible.maxY) < threshold {
+ frame.origin.y = visible.maxY - frame.height - margin
+ snapped = true
+ }
+ guard snapped, frame != window.frame else { return }
+ window.setFrame(frame, display: true, animate: true)
+ saveCurrentPosition()
}
func windowDidResize(_ notification: Notification) {
@@ -114,23 +175,32 @@ final class DetachedMenuBarWindowController: NSWindowController, NSWindowDelegat
guard saved.width > 0, saved.height > 0 else { return false }
guard let screen = NSScreen.screens.first(where: { $0.visibleFrame.intersects(saved) }) ?? NSScreen.main else { return false }
var frame = saved
- frame.size = targetSize(for: attributedBarProvider(), screen: screen)
- frame.origin.x = min(screen.visibleFrame.maxX - frame.width - 8, max(screen.visibleFrame.minX + 8, frame.origin.x))
- frame.origin.y = min(screen.visibleFrame.maxY - frame.height - 6, max(screen.visibleFrame.minY + 8, frame.origin.y))
+ frame.size = targetSize(for: attributedBarProvider(), stackedImage: stackedImageProvider(), screen: screen)
+ let visible = screen.visibleFrame
+ frame.origin.x = min(visible.maxX - 60, max(visible.minX + 60 - frame.width, frame.origin.x))
+ frame.origin.y = min(visible.maxY - frame.height, max(visible.minY, frame.origin.y))
window.setFrame(frame, display: true)
return true
}
- private func targetSize(for attributedText: NSAttributedString?, screen: NSScreen?) -> NSSize {
- let textWidth = ceil(attributedText?.size().width ?? 152)
+ /// Breite aus den echten Layout-Maßen von DetachedMenuBarView ableiten —
+ /// die frühere Schätzung (plus 260-pt-Mindestbreite) ließ rechts sichtbar
+ /// Leerraum stehen.
+ private func targetSize(for attributedText: NSAttributedString?, stackedImage: NSImage?, screen: NSScreen?) -> NSSize {
+ let textWidth = ceil(stackedImage?.size.width ?? attributedText?.size().width ?? 152)
let scale = AppSettings.shared.detachedMenuBarScale
- let iconWidth: CGFloat = AppSettings.shared.showMenuBarIcon ? round(34 * scale) : 0
- let closeWidth: CGFloat = AppSettings.shared.lockDetachedMenuBar ? 0 : round(44 * scale)
- let horizontalPadding: CGFloat = round(34 * scale)
- let width = textWidth + iconWidth + closeWidth + horizontalPadding
+ let iconWidth: CGFloat = AppSettings.shared.detachedShowIcon
+ ? round(24 * scale) + round(10 * scale) // Icon + Stack-Abstand
+ : 0
+ let leading = round(14 * scale)
+ // Rechts: fixiert nur Randabstand; sonst Lücke + Hover-Schließknopf + Rand.
+ let trailing: CGFloat = AppSettings.shared.lockDetachedMenuBar
+ ? round(14 * scale)
+ : 10 + round(28 * scale) + round(8 * scale)
+ let width = leading + iconWidth + textWidth + trailing
let visibleWidth = screen?.visibleFrame.width ?? 900
let height = min(68, max(44, round(44 * scale)))
- return NSSize(width: min(max(width, 260), visibleWidth - 24), height: height)
+ return NSSize(width: min(max(width, 200), visibleWidth - 24), height: height)
}
private func positionBelowMenuBar(anchor: NSRect?) {
@@ -214,17 +284,22 @@ final class DetachedMenuBarWindowController: NSWindowController, NSWindowDelegat
private final class DetachedMenuBarView: NSView {
private let attributedText: NSAttributedString?
+ private let stackedImage: NSImage?
private let onClose: () -> Void
private let settings = AppSettings.shared
+ private weak var closeButton: NSButton?
- init(attributedText: NSAttributedString?, onClose: @escaping () -> Void) {
+ init(attributedText: NSAttributedString?, stackedImage: NSImage?, onClose: @escaping () -> Void) {
self.attributedText = attributedText
+ self.stackedImage = stackedImage
self.onClose = onClose
super.init(frame: NSRect(x: 0, y: 0, width: 640, height: 44))
wantsLayer = true
layer?.cornerRadius = 16
layer?.masksToBounds = true
- buildView()
+ effectiveAppearance.performAsCurrentDrawingAppearance {
+ buildView()
+ }
}
required init?(coder: NSCoder) {
@@ -233,7 +308,10 @@ private final class DetachedMenuBarView: NSView {
private func buildView() {
let glass = NSVisualEffectView()
- glass.material = .hudWindow
+ // .hudWindow erzwingt dunkle Vibrancy für alle Subviews — dadurch
+ // wurden im Light Mode die Dunkelmodus-Farben aufgelöst. .popover
+ // ist adaptiv und folgt der echten Appearance.
+ glass.material = .popover
glass.blendingMode = .behindWindow
glass.state = .active
glass.wantsLayer = true
@@ -254,21 +332,12 @@ private final class DetachedMenuBarView: NSView {
accent.translatesAutoresizingMaskIntoConstraints = false
addSubview(accent)
- let border = NSView()
- border.wantsLayer = true
- border.layer?.cornerRadius = 16
- border.layer?.borderWidth = 1
- border.layer?.borderColor = NSColor.separatorColor.withAlphaComponent(0.62).cgColor
- border.layer?.backgroundColor = NSColor.clear.cgColor
- border.translatesAutoresizingMaskIntoConstraints = false
- addSubview(border)
-
let stack = NSStackView()
stack.orientation = .horizontal
stack.alignment = .centerY
stack.spacing = round(10 * settings.detachedMenuBarScale)
- if settings.showMenuBarIcon, let image = appIcon() {
+ if settings.detachedShowIcon, let image = appIcon() {
let imageView = NSImageView(image: image)
let iconSize = round(24 * settings.detachedMenuBarScale)
imageView.widthAnchor.constraint(equalToConstant: iconSize).isActive = true
@@ -276,7 +345,13 @@ private final class DetachedMenuBarView: NSView {
stack.addArrangedSubview(imageView)
}
- if let attributedText, attributedText.length > 0 {
+ if let stackedImage {
+ // Kompaktanzeige: identisches zweizeiliges Bild wie in der
+ // Menüleiste — halbiert die Leistenlänge.
+ let imageView = NSImageView(image: stackedImage)
+ imageView.imageScaling = .scaleNone
+ stack.addArrangedSubview(imageView)
+ } else if let attributedText, attributedText.length > 0 {
let label = NSTextField(labelWithString: "")
label.attributedStringValue = readableDetachedText(attributedText)
label.lineBreakMode = .byTruncatingTail
@@ -284,9 +359,9 @@ private final class DetachedMenuBarView: NSView {
label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
stack.addArrangedSubview(label)
} else {
- let label = NSTextField(labelWithString: "SOLIX wartet auf Daten")
+ let label = NSTextField(labelWithString: LocalizedText.text("SOLIX wartet auf Daten", "SOLIX waiting for data"))
label.font = .systemFont(ofSize: 13, weight: .semibold)
- label.textColor = .white
+ label.textColor = .labelColor
stack.addArrangedSubview(label)
}
@@ -309,11 +384,6 @@ private final class DetachedMenuBarView: NSView {
accent.topAnchor.constraint(equalTo: topAnchor),
accent.bottomAnchor.constraint(equalTo: bottomAnchor),
- border.leadingAnchor.constraint(equalTo: leadingAnchor),
- border.trailingAnchor.constraint(equalTo: trailingAnchor),
- border.topAnchor.constraint(equalTo: topAnchor),
- border.bottomAnchor.constraint(equalTo: bottomAnchor),
-
stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: round(14 * settings.detachedMenuBarScale)),
stack.centerYAnchor.constraint(equalTo: centerYAnchor)
])
@@ -324,10 +394,13 @@ private final class DetachedMenuBarView: NSView {
constant: -round(14 * settings.detachedMenuBarScale)
).isActive = true
} else {
- let closeButton = NSButton(title: "×", target: self, action: #selector(close))
+ let closeButton = NSButton(title: "", target: self, action: #selector(close))
closeButton.isBordered = false
- closeButton.font = .systemFont(ofSize: round(18 * settings.detachedMenuBarScale), weight: .bold)
- closeButton.contentTintColor = .white
+ closeButton.image = NSImage(
+ systemSymbolName: "xmark.circle.fill",
+ accessibilityDescription: LocalizedText.text("Schließen", "Close")
+ )?.withSymbolConfiguration(.init(pointSize: round(13 * settings.detachedMenuBarScale), weight: .semibold))
+ closeButton.contentTintColor = NSColor.secondaryLabelColor
closeButton.toolTip = LocalizedText.text(
"Abgedockte Leiste schließen.",
"Close detached slim bar."
@@ -343,242 +416,135 @@ private final class DetachedMenuBarView: NSView {
closeButton.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -round(8 * settings.detachedMenuBarScale)),
closeButton.centerYAnchor.constraint(equalTo: centerYAnchor)
])
+
+ // Erst bei Maus über der Leiste einblenden: im Ruhezustand soll
+ // die Leiste nur Werte zeigen, kein Bedien-Element. Der Platz
+ // bleibt reserviert, damit beim Hover nichts umbricht.
+ closeButton.alphaValue = 0
+ self.closeButton = closeButton
}
}
- private func appIcon() -> NSImage? {
- let image = Bundle.main.url(forResource: "SolixBar", withExtension: "png")
- .flatMap { NSImage(contentsOf: $0) }
- let size = round(24 * settings.detachedMenuBarScale)
- return image.map { roundedIconImage($0, size: size) }
+ override func updateTrackingAreas() {
+ super.updateTrackingAreas()
+ trackingAreas.forEach(removeTrackingArea)
+ addTrackingArea(NSTrackingArea(
+ rect: .zero,
+ options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect],
+ owner: self,
+ userInfo: nil
+ ))
}
- private func readableDetachedText(_ attributedText: NSAttributedString) -> NSAttributedString {
- let result = NSMutableAttributedString(attributedString: attributedText)
- let brightText = NSColor.white
- let mutedText = NSColor(calibratedWhite: 0.86, alpha: 1)
- let shadow = NSShadow()
- shadow.shadowColor = NSColor.black.withAlphaComponent(0.92)
- shadow.shadowBlurRadius = 3
- shadow.shadowOffset = NSSize(width: 0, height: -1)
- let fullRange = NSRange(location: 0, length: result.length)
- result.addAttributes(
- [
- .shadow: shadow,
- .strokeColor: NSColor.black.withAlphaComponent(0.70),
- .strokeWidth: -1.5
- ],
- range: fullRange
- )
- result.enumerateAttribute(.foregroundColor, in: NSRange(location: 0, length: result.length)) { value, range, _ in
- let substring = (result.string as NSString).substring(with: range)
- guard let color = value as? NSColor else {
- result.addAttribute(.foregroundColor, value: brightText, range: range)
- return
- }
- if color == NSColor.labelColor || color == NSColor.secondaryLabelColor || color.isNearlyWhite {
- result.addAttribute(.foregroundColor, value: substring.trimmingCharacters(in: .whitespaces) == "•" ? mutedText : brightText, range: range)
- } else {
- result.addAttribute(.foregroundColor, value: brightDetachedColor(for: color, text: substring), range: range)
- }
- }
- makeAttachmentsReadable(in: result)
- return result
+ override func viewDidMoveToWindow() {
+ super.viewDidMoveToWindow()
+ // Die Leiste wird bei jedem Refresh neu aufgebaut; steht der Zeiger
+ // dabei bereits über ihr, käme sonst kein mouseEntered mehr.
+ guard let window else { return }
+ closeButton?.alphaValue = window.frame.contains(NSEvent.mouseLocation) ? 1 : 0
}
- private func brightDetachedColor(for color: NSColor, text: String) -> NSColor {
- let normalized = text.lowercased()
- if normalized.contains("einspeis") || normalized.contains("export") {
- return NSColor(calibratedRed: 0.84, green: 0.69, blue: 1.00, alpha: 1)
- }
- if normalized.contains("bezug") || normalized.contains("import") || normalized.contains("last") || normalized.contains("home") {
- return NSColor(calibratedRed: 0.46, green: 0.86, blue: 1.00, alpha: 1)
- }
- if normalized.contains("entladen") || normalized.contains("discharging") {
- return NSColor(calibratedRed: 1.00, green: 0.58, blue: 0.36, alpha: 1)
- }
- if normalized.contains("laden") || normalized.contains("charging") {
- return NSColor(calibratedRed: 0.49, green: 1.00, blue: 0.60, alpha: 1)
- }
- if normalized.contains("erzeugt") || normalized.contains("producing") || normalized.contains("pv") || normalized.contains("solar") {
- return NSColor(calibratedRed: 1.00, green: 0.85, blue: 0.30, alpha: 1)
- }
- if normalized.contains("akku"), let percent = percentage(in: normalized) {
- if percent <= 20 {
- return NSColor(calibratedRed: 1.00, green: 0.42, blue: 0.46, alpha: 1)
- }
- if percent <= 60 {
- return NSColor(calibratedRed: 1.00, green: 0.85, blue: 0.30, alpha: 1)
- }
- return NSColor(calibratedRed: 0.49, green: 1.00, blue: 0.60, alpha: 1)
- }
+ override func mouseEntered(with event: NSEvent) {
+ setCloseButtonVisible(true)
+ }
- guard let rgb = color.usingColorSpace(.deviceRGB) else { return NSColor.white }
- var hue: CGFloat = 0
- var saturation: CGFloat = 0
- var brightness: CGFloat = 0
- var alpha: CGFloat = 0
- rgb.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: &alpha)
- if saturation < 0.16 { return NSColor.white }
- switch hue {
- case 0..<0.05, 0.95...1:
- return NSColor(calibratedRed: 1.00, green: 0.42, blue: 0.46, alpha: 1)
- case 0.05..<0.12:
- return NSColor(calibratedRed: 1.00, green: 0.58, blue: 0.36, alpha: 1)
- case 0.12..<0.20:
- return NSColor(calibratedRed: 1.00, green: 0.85, blue: 0.30, alpha: 1)
- case 0.20..<0.48:
- return NSColor(calibratedRed: 0.49, green: 1.00, blue: 0.60, alpha: 1)
- case 0.48..<0.72:
- return NSColor(calibratedRed: 0.46, green: 0.86, blue: 1.00, alpha: 1)
- default:
- return NSColor(calibratedRed: 0.84, green: 0.69, blue: 1.00, alpha: 1)
- }
+ override func mouseExited(with event: NSEvent) {
+ setCloseButtonVisible(false)
}
- private func percentage(in text: String) -> Int? {
- let digits = text.split { !$0.isNumber }
- return digits.compactMap { Int($0) }.first
- }
-
- private func makeAttachmentsReadable(in text: NSMutableAttributedString) {
- var replacements: [(NSRange, NSTextAttachment)] = []
- text.enumerateAttribute(.attachment, in: NSRange(location: 0, length: text.length)) { value, range, _ in
- guard let attachment = value as? NSTextAttachment, let image = attachment.image else { return }
- let readableImage = image.copy() as? NSImage ?? image
- readableImage.isTemplate = false
- let beforeStart = max(0, range.location - 32)
- let beforeRange = NSRange(location: beforeStart, length: range.location - beforeStart)
- let afterEnd = min(text.length, NSMaxRange(range) + 32)
- let afterRange = NSRange(location: NSMaxRange(range), length: afterEnd - NSMaxRange(range))
- let before = (text.string as NSString).substring(with: beforeRange)
- let after = (text.string as NSString).substring(with: afterRange)
- let tint = brightAttachmentColor(
- description: image.accessibilityDescription ?? "",
- before: before,
- after: after
- )
- readableImage.lockFocus()
- tint.set()
- NSRect(origin: .zero, size: readableImage.size).fill(using: .sourceAtop)
- readableImage.unlockFocus()
- let replacement = NSTextAttachment()
- replacement.image = readableImage
- replacement.bounds = attachment.bounds
- replacements.append((range, replacement))
- }
- for (range, attachment) in replacements {
- text.addAttribute(.attachment, value: attachment, range: range)
+ private func setCloseButtonVisible(_ visible: Bool) {
+ guard let closeButton else { return }
+ NSAnimationContext.runAnimationGroup { context in
+ context.duration = 0.18
+ closeButton.animator().alphaValue = visible ? 1 : 0
}
}
- private func brightAttachmentColor(description: String, before: String, after: String) -> NSColor {
- let description = description.lowercased()
- let before = before.lowercased()
- let after = after.lowercased()
- let context = before + " " + after
- if after.contains("akku") || after.contains("battery") || after.contains("batt") {
- if let percent = percentage(in: after) {
- if percent <= 20 {
- return NSColor(calibratedRed: 1.00, green: 0.42, blue: 0.46, alpha: 1)
- }
- if percent <= 60 {
- return NSColor(calibratedRed: 1.00, green: 0.85, blue: 0.30, alpha: 1)
- }
- }
- return NSColor(calibratedRed: 0.49, green: 1.00, blue: 0.60, alpha: 1)
- }
- if after.contains("pv") || after.contains("solar") {
- return NSColor(calibratedRed: 1.00, green: 0.85, blue: 0.30, alpha: 1)
- }
- if after.contains("last") || after.contains("home") || after.contains("haus") {
- return NSColor(calibratedRed: 0.46, green: 0.86, blue: 1.00, alpha: 1)
- }
- if after.contains("netz") || after.contains("grid") {
- return before.contains("einspeis") || before.contains("export")
- ? NSColor(calibratedRed: 0.84, green: 0.69, blue: 1.00, alpha: 1)
- : NSColor(calibratedRed: 0.46, green: 0.86, blue: 1.00, alpha: 1)
- }
- if after.contains("fluss") || after.contains("flow") {
- return before.contains("entladen") || before.contains("discharging")
- ? NSColor(calibratedRed: 1.00, green: 0.58, blue: 0.36, alpha: 1)
- : NSColor(calibratedRed: 0.49, green: 1.00, blue: 0.60, alpha: 1)
- }
- if after.contains("ertrag") || after.contains("yield") || after.contains("gesamt") || after.contains("total") {
- return NSColor(calibratedRed: 0.84, green: 0.69, blue: 1.00, alpha: 1)
- }
- if description.contains("battery") || description.contains("batterie") || description.contains("akku") {
- if description.contains("flow") || description.contains("fluss") {
- return context.contains("entladen") || context.contains("discharging")
- ? NSColor(calibratedRed: 1.00, green: 0.58, blue: 0.36, alpha: 1)
- : NSColor(calibratedRed: 0.49, green: 1.00, blue: 0.60, alpha: 1)
+ /// Dezentes Bolt-Glyph wie in der Menüleiste — das bunte App-Icon-PNG
+ /// wirkte auf dem dunklen HUD grell und fremd.
+ private func appIcon() -> NSImage? {
+ let image = NSImage(systemSymbolName: "bolt.fill", accessibilityDescription: "SOLIX")?
+ .withSymbolConfiguration(.init(pointSize: round(14 * settings.detachedMenuBarScale), weight: .semibold))
+ guard let image else { return nil }
+ return image.tinted(NSColor.labelColor.withAlphaComponent(0.85))
+ }
+
+ /// Löst Farben über das semantische Rollen-Attribut (.solixRole) auf,
+ /// das die Menüleisten-Formatierung an jeden Lauf hängt — früher wurde
+ /// hier der gerenderte Text nach Schlüsselwörtern durchsucht, was bei
+ /// jeder Textänderung brach.
+ private func readableDetachedText(_ attributedText: NSAttributedString) -> NSAttributedString {
+ let result = NSMutableAttributedString(attributedString: attributedText)
+ let fullRange = NSRange(location: 0, length: result.length)
+ // Kontur- und Schatteneffekte entfernen: Auf dem satten dunklen
+ // Hintergrund der Leiste erzeugten sie Doppelkanten an den Glyphen
+ // (zulaufende "e", doppelt gerändertes "P"); der geerbte
+ // Menüleisten-Schatten fliegt ebenfalls raus.
+ result.removeAttribute(.shadow, range: fullRange)
+ result.removeAttribute(.strokeColor, range: fullRange)
+ result.removeAttribute(.strokeWidth, range: fullRange)
+
+ result.enumerateAttributes(in: fullRange) { attributes, range, _ in
+ let role = (attributes[.solixRole] as? String).flatMap(ColorRole.init(rawValue:))
+ if let attachment = attributes[.attachment] as? NSTextAttachment, let image = attachment.image {
+ let tinted = image.tinted(Theme.hud(role ?? .neutral))
+ let replacement = NSTextAttachment()
+ replacement.image = tinted
+ replacement.bounds = attachment.bounds
+ result.addAttribute(.attachment, value: replacement, range: range)
+ return
}
- if let percent = percentage(in: context) {
- if percent <= 20 {
- return NSColor(calibratedRed: 1.00, green: 0.42, blue: 0.46, alpha: 1)
- }
- if percent <= 60 {
- return NSColor(calibratedRed: 1.00, green: 0.85, blue: 0.30, alpha: 1)
- }
+ let substring = (result.string as NSString).substring(with: range)
+ let color: NSColor
+ if let role {
+ color = Theme.hud(role)
+ } else if substring.trimmingCharacters(in: .whitespaces) == "•" {
+ color = .secondaryLabelColor
+ } else {
+ color = .labelColor
}
- return NSColor(calibratedRed: 0.49, green: 1.00, blue: 0.60, alpha: 1)
- }
- if description.contains("pv") || description.contains("solar") {
- return NSColor(calibratedRed: 1.00, green: 0.85, blue: 0.30, alpha: 1)
+ result.addAttribute(.foregroundColor, value: color, range: range)
}
- if description.contains("home") || description.contains("haus") || description.contains("last") {
- return NSColor(calibratedRed: 0.46, green: 0.86, blue: 1.00, alpha: 1)
- }
- if description.contains("grid") || description.contains("netz") {
- return context.contains("einspeis") || context.contains("export")
- ? NSColor(calibratedRed: 0.84, green: 0.69, blue: 1.00, alpha: 1)
- : NSColor(calibratedRed: 0.46, green: 0.86, blue: 1.00, alpha: 1)
- }
- if description.contains("flow") || description.contains("fluss") {
- return context.contains("entladen") || context.contains("discharging")
- ? NSColor(calibratedRed: 1.00, green: 0.58, blue: 0.36, alpha: 1)
- : NSColor(calibratedRed: 0.49, green: 1.00, blue: 0.60, alpha: 1)
- }
- if description.contains("yield") || description.contains("ertrag") {
- return NSColor(calibratedRed: 0.84, green: 0.69, blue: 1.00, alpha: 1)
- }
- return NSColor.white
+ return result
}
@objc private func close() {
onClose()
}
+ /// Dunkles HUD im Dark Mode, helles Milchglas im Light Mode — vorher
+ /// war die Leiste in beiden Modi dunkel.
private var readableBackground: NSColor {
NSColor(name: nil) { appearance in
appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
? NSColor(calibratedRed: 0.08, green: 0.10, blue: 0.11, alpha: 0.90)
- : NSColor(calibratedRed: 0.10, green: 0.13, blue: 0.13, alpha: 0.82)
+ : NSColor(calibratedRed: 0.97, green: 0.98, blue: 0.985, alpha: 0.88)
}
}
+ /// Dezenter Zwei-Stopp-Akzent aus den ersten beiden Metrikfarben — die
+ /// frühere Überlagerung von bis zu fünf Farben ergab ein schlammiges Braun.
private var accentColors: [NSColor] {
let metrics = settings.barMetrics.isEmpty ? [BarMetric.battery, .solar, .grid] : settings.barMetrics
- let colors = metrics.map(accentColor)
- return Array(colors.prefix(5))
+ return metrics.prefix(2).map { Theme.accent(accentRole(for: $0)) }
}
- private func accentColor(for metric: BarMetric) -> NSColor {
+ private func accentRole(for metric: BarMetric) -> ColorRole {
switch metric {
case .battery:
- return NSColor(calibratedRed: 0.17, green: 0.78, blue: 0.36, alpha: 1)
+ .batteryHigh
case .solar, .today:
- return NSColor(calibratedRed: 1.00, green: 0.68, blue: 0.03, alpha: 1)
+ .solar
case .home:
- return NSColor(calibratedRed: 0.16, green: 0.50, blue: 0.96, alpha: 1)
+ .load
case .grid:
- return NSColor(calibratedRed: 0.95, green: 0.18, blue: 0.22, alpha: 1)
- case .batteryFlow, .flow:
- return NSColor(calibratedRed: 0.00, green: 0.70, blue: 0.46, alpha: 1)
+ .gridImport
+ case .batteryFlow:
+ .batteryCharging
case .total:
- return NSColor(calibratedRed: 0.48, green: 0.35, blue: 0.95, alpha: 1)
+ .yieldTotal
case .status:
- return NSColor(calibratedRed: 0.12, green: 0.72, blue: 0.38, alpha: 1)
+ .status
}
}
}
@@ -610,13 +576,13 @@ private final class AccentGradientView: NSView {
}
private var gradientColors: [CGColor] {
- let base = colors.isEmpty
- ? [
+ let base = colors.count >= 2
+ ? colors
+ : [
NSColor(calibratedRed: 0.17, green: 0.78, blue: 0.36, alpha: 1),
NSColor(calibratedRed: 1.00, green: 0.68, blue: 0.03, alpha: 1)
]
- : colors
- return base.map { $0.withAlphaComponent(0.18).cgColor }
+ return base.map { $0.withAlphaComponent(0.12).cgColor }
}
private var gradientLocations: [NSNumber] {
@@ -624,10 +590,3 @@ private final class AccentGradientView: NSView {
return (0.. 0.82 && rgb.greenComponent > 0.82 && rgb.blueComponent > 0.82
- }
-}
diff --git a/Sources/SolixBarKit/FittingValueLabel.swift b/Sources/SolixBarKit/FittingValueLabel.swift
new file mode 100644
index 0000000..a720313
--- /dev/null
+++ b/Sources/SolixBarKit/FittingValueLabel.swift
@@ -0,0 +1,59 @@
+import AppKit
+
+/// Wertelabel für die großen Dashboard-Kacheln, das seine Schriftgröße an die
+/// verfügbare Breite anpasst und mehrzeilige Werte (PV-Eingänge) unterstützt.
+///
+/// Die Kacheln stehen paarweise nebeneinander und werden im abgedockten
+/// Fenster mitgezogen; die Breite steht daher erst zur Layout-Zeit fest.
+/// Darum wird die Schrift in `layout()` gewählt: die größte Größe zwischen
+/// `minFontSize` und `baseFontSize`, bei der die breiteste Zeile noch passt.
+/// Reicht selbst `minFontSize` nicht, kürzt macOS die Zeile am Ende (…).
+final class FittingValueLabel: NSTextField {
+ var baseFontSize: CGFloat = 23
+ var minFontSize: CGFloat = 14
+ var weight: NSFont.Weight = .bold
+
+ private var lines: [String] = []
+
+ convenience init() {
+ self.init(labelWithString: "")
+ isBordered = false
+ isEditable = false
+ isSelectable = false
+ drawsBackground = false
+ lineBreakMode = .byTruncatingTail
+ cell?.truncatesLastVisibleLine = true
+ // Zeilen liegen als \n im String vor; zwei Zeilen genügen (2×2-Raster
+ // für bis zu vier PV-Eingänge).
+ maximumNumberOfLines = 2
+ }
+
+ /// Setzt eine oder zwei Zeilen. Leere Eingabe zeigt "-".
+ func setLines(_ newLines: [String]) {
+ lines = newLines.isEmpty ? ["-"] : newLines
+ stringValue = lines.joined(separator: "\n")
+ maximumNumberOfLines = max(1, lines.count)
+ needsLayout = true
+ }
+
+ override func layout() {
+ super.layout()
+ let available = bounds.width
+ guard available > 1, !lines.isEmpty else { return }
+
+ var size = baseFontSize
+ while size > minFontSize {
+ let candidate = NSFont.monospacedDigitSystemFont(ofSize: size, weight: weight)
+ let widest = lines
+ .map { ($0 as NSString).size(withAttributes: [.font: candidate]).width }
+ .max() ?? 0
+ if widest <= available { break }
+ size -= 1
+ }
+ // Nur bei echter Änderung setzen — sonst löst jedes layout() eine neue
+ // Layout-Runde aus (Endlosschleife).
+ if abs((font?.pointSize ?? 0) - size) > 0.1 {
+ font = NSFont.monospacedDigitSystemFont(ofSize: size, weight: weight)
+ }
+ }
+}
diff --git a/Sources/SolixBarKit/GraphControlHeader.swift b/Sources/SolixBarKit/GraphControlHeader.swift
new file mode 100644
index 0000000..f22112f
--- /dev/null
+++ b/Sources/SolixBarKit/GraphControlHeader.swift
@@ -0,0 +1,202 @@
+import AppKit
+
+/// Borderless Button mit symmetrischem Innenabstand — Leerzeichen-Padding
+/// zentriert nicht (Trailing-Spaces werden beim Rendern verschluckt).
+@MainActor
+final class ChipButton: NSButton {
+ override var intrinsicContentSize: NSSize {
+ var size = super.intrinsicContentSize
+ size.width += 20
+ size.height = 22
+ return size
+ }
+}
+
+/// Gemeinsame Kopfzeile aller Verlaufs-Ansichten: Titel, Zeitraum-Chips und
+/// Legend-Chips — Dashboard-Dropdown, abgedocktes Dashboard und großes
+/// Verlaufsfenster nutzen exakt dieselben Bedienelemente.
+@MainActor
+final class GraphControlHeader: NSView {
+ private let settings = AppSettings.shared
+ private let onChange: () -> Void
+ private var rangeChips: [HistoryRange: NSButton] = [:]
+ private var legendChips: [GraphMetric: NSButton] = [:]
+
+ init(onChange: @escaping () -> Void) {
+ self.onChange = onChange
+ super.init(frame: .zero)
+ buildView()
+ reload()
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ private func buildView() {
+ let title = NSTextField(labelWithString: LocalizedText.text("Verlauf", "History"))
+ title.font = .boldSystemFont(ofSize: 13)
+ title.textColor = .labelColor
+
+ let rangeRow = NSStackView()
+ rangeRow.orientation = .horizontal
+ rangeRow.spacing = 4
+ for range in HistoryRange.allCases {
+ let chip = makeChip(action: #selector(changeRange(_:)))
+ chip.tag = HistoryRange.allCases.firstIndex(of: range) ?? 0
+ chip.toolTip = range == .custom
+ ? LocalizedText.text(
+ "Eigener Zeitraum — Dauer in den Einstellungen (App) festlegen.",
+ "Custom range — set the duration in Settings (App)."
+ )
+ : LocalizedText.text("Zeitraum: \(range.title)", "Range: \(range.title)")
+ rangeChips[range] = chip
+ rangeRow.addArrangedSubview(chip)
+ }
+
+ let legendRow = NSStackView()
+ legendRow.orientation = .horizontal
+ legendRow.spacing = 6
+ for metric in GraphMetric.allCases {
+ let chip = makeChip(action: #selector(toggleMetric(_:)))
+ chip.tag = GraphMetric.allCases.firstIndex(of: metric) ?? 0
+ chip.toolTip = LocalizedText.text(
+ "Blendet \(metric.title) im Graphen ein oder aus.",
+ "Shows or hides \(metric.title) in the graph."
+ )
+ legendChips[metric] = chip
+ legendRow.addArrangedSubview(chip)
+ }
+
+ for view in [title, rangeRow, legendRow] {
+ view.translatesAutoresizingMaskIntoConstraints = false
+ addSubview(view)
+ }
+
+ NSLayoutConstraint.activate([
+ title.leadingAnchor.constraint(equalTo: leadingAnchor),
+ title.centerYAnchor.constraint(equalTo: rangeRow.centerYAnchor),
+
+ rangeRow.topAnchor.constraint(equalTo: topAnchor),
+ rangeRow.leadingAnchor.constraint(equalTo: title.trailingAnchor, constant: 14),
+ rangeRow.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor),
+
+ legendRow.topAnchor.constraint(equalTo: rangeRow.bottomAnchor, constant: 8),
+ legendRow.leadingAnchor.constraint(equalTo: leadingAnchor),
+ legendRow.bottomAnchor.constraint(equalTo: bottomAnchor)
+ ])
+ }
+
+ private func makeChip(action: Selector) -> NSButton {
+ let chip = ChipButton(title: "", target: self, action: action)
+ chip.isBordered = false
+ chip.wantsLayer = true
+ chip.layer?.cornerRadius = 11
+ chip.layer?.masksToBounds = true
+ chip.heightAnchor.constraint(equalToConstant: 22).isActive = true
+ chip.setContentHuggingPriority(.required, for: .horizontal)
+ return chip
+ }
+
+ private func styleRangeChip(_ chip: NSButton, title: String, selected: Bool) {
+ chip.attributedTitle = NSAttributedString(
+ string: title,
+ attributes: [
+ .font: NSFont.systemFont(ofSize: 11, weight: selected ? .bold : .medium),
+ .foregroundColor: selected ? NSColor.labelColor : NSColor.secondaryLabelColor
+ ]
+ )
+ chip.layer?.backgroundColor = selected
+ ? NSColor.labelColor.withAlphaComponent(0.12).cgColor
+ : NSColor.clear.cgColor
+ }
+
+ private func styleLegendChip(_ chip: NSButton, metric: GraphMetric, active: Bool) {
+ let accent: NSColor = switch metric {
+ case .battery: Theme.accent(.batteryHigh)
+ case .solar: Theme.accent(.solar)
+ case .grid: Theme.accent(.gridImport)
+ }
+ let name = switch metric {
+ case .battery: LocalizedText.text("Akku", "Battery")
+ case .solar: "Solar"
+ case .grid: LocalizedText.text("Netz", "Grid")
+ }
+ let text = NSMutableAttributedString(
+ string: "● ",
+ attributes: [
+ .font: NSFont.systemFont(ofSize: 9, weight: .bold),
+ .baselineOffset: 0.5,
+ .foregroundColor: active ? accent : NSColor.tertiaryLabelColor
+ ]
+ )
+ text.append(NSAttributedString(
+ string: name,
+ attributes: [
+ .font: NSFont.systemFont(ofSize: 11, weight: .semibold),
+ .foregroundColor: active ? NSColor.labelColor : NSColor.tertiaryLabelColor
+ ]
+ ))
+ chip.attributedTitle = text
+ chip.layer?.backgroundColor = active
+ ? accent.withAlphaComponent(0.14).cgColor
+ : NSColor.labelColor.withAlphaComponent(0.05).cgColor
+ }
+
+ func reload() {
+ effectiveAppearance.performAsCurrentDrawingAppearance {
+ reloadChips()
+ }
+ }
+
+ private func reloadChips() {
+ for range in HistoryRange.allCases {
+ guard let chip = rangeChips[range] else { continue }
+ // Der Eig.-Chip zeigt den in den Einstellungen gewählten Wert.
+ let title: String
+ if range == .custom, settings.historyRange == .custom {
+ let value = HistoryGraphMenuView.customValue(
+ days: settings.customHistoryDays,
+ unit: settings.customHistoryUnit
+ )
+ let unit = switch settings.customHistoryUnit {
+ case "hours": LocalizedText.text("Std.", "h")
+ case "weeks": LocalizedText.text("Wo.", "wk")
+ case "months": LocalizedText.text("Mon.", "mo")
+ default: LocalizedText.text("Tage", "d")
+ }
+ title = "\(value) \(unit)"
+ } else {
+ title = range.shortTitle
+ }
+ styleRangeChip(chip, title: title, selected: settings.historyRange == range)
+ }
+ let selectedMetrics = Set(settings.graphMetrics)
+ for metric in GraphMetric.allCases {
+ guard let chip = legendChips[metric] else { continue }
+ styleLegendChip(chip, metric: metric, active: selectedMetrics.contains(metric))
+ }
+ }
+
+ @objc private func changeRange(_ sender: NSButton) {
+ settings.historyRange = HistoryRange.allCases[safe: sender.tag] ?? .day
+ AppLogger.info("Graph range changed to \(settings.historyRange.rawValue).")
+ reload()
+ onChange()
+ }
+
+ @objc private func toggleMetric(_ sender: NSButton) {
+ guard let metric = GraphMetric.allCases[safe: sender.tag] else { return }
+ var selected = Set(settings.graphMetrics)
+ if selected.contains(metric) {
+ selected.remove(metric)
+ } else {
+ selected.insert(metric)
+ }
+ let ordered = GraphMetric.allCases.filter { selected.contains($0) }
+ settings.graphMetrics = ordered.isEmpty ? GraphMetric.allCases : ordered
+ AppLogger.info("Graph metrics changed to \(settings.graphMetrics.map(\.rawValue).joined(separator: ","))")
+ reload()
+ onChange()
+ }
+}
diff --git a/Sources/SolixBarKit/HistoryExporter.swift b/Sources/SolixBarKit/HistoryExporter.swift
new file mode 100644
index 0000000..be16dc8
--- /dev/null
+++ b/Sources/SolixBarKit/HistoryExporter.swift
@@ -0,0 +1,84 @@
+import Foundation
+
+/// Serialisiert die gespeicherte History für den Export — CSV für
+/// Tabellenkalkulationen, JSON für Weiterverarbeitung/Backup.
+enum HistoryExporter {
+ static let csvHeader = "timestamp,batteryPercent,solarWatts,gridWatts,homeWatts,batteryWatts"
+
+ private static func makeTimestampFormatter() -> ISO8601DateFormatter {
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime]
+ formatter.timeZone = .current
+ return formatter
+ }
+
+ /// Eine Zeile pro Sample, älteste zuerst; fehlende Werte bleiben leere
+ /// Zellen. Ist der aktuelle Snapshot neuer als das letzte Sample, kommt
+ /// er als Abschlusszeile dazu.
+ static func csv(samples: [SolixHistorySample], current: SolixSnapshot? = nil) -> String {
+ let formatter = makeTimestampFormatter()
+ var rows = [csvHeader]
+ for sample in exportSamples(samples: samples, current: current) {
+ var cells: [String] = []
+ cells.append(formatter.string(from: sample.date))
+ cells.append(sample.batteryPercent.map(String.init) ?? "")
+ cells.append(sample.solarWatts.map(String.init) ?? "")
+ cells.append(sample.gridWatts.map(String.init) ?? "")
+ cells.append(sample.homeWatts.map(String.init) ?? "")
+ cells.append(sample.batteryWatts.map(String.init) ?? "")
+ rows.append(cells.joined(separator: ","))
+ }
+ return rows.joined(separator: "\n") + "\n"
+ }
+
+ static func json(
+ samples: [SolixHistorySample],
+ current: SolixSnapshot? = nil,
+ sourceKey: String,
+ exportedAt: Date = Date()
+ ) throws -> Data {
+ struct Export: Encodable {
+ let exportedAt: String
+ let source: String
+ let currentSnapshot: SolixSnapshot?
+ let samples: [SolixHistorySample]
+ }
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+ encoder.dateEncodingStrategy = .iso8601
+ return try encoder.encode(Export(
+ exportedAt: makeTimestampFormatter().string(from: exportedAt),
+ source: sourceKey,
+ currentSnapshot: current,
+ samples: samples
+ ))
+ }
+
+ @MainActor
+ static func defaultFilename(ext: String, date: Date = Date()) -> String {
+ let formatter = DateFormatter()
+ formatter.calendar = Calendar(identifier: .gregorian)
+ formatter.locale = Locale(identifier: "en_US_POSIX")
+ formatter.dateFormat = "yyyy-MM-dd"
+ let word = LocalizedText.text("Verlauf", "History")
+ return "SolixBar-\(word)-\(formatter.string(from: date)).\(ext)"
+ }
+
+ private static func exportSamples(
+ samples: [SolixHistorySample],
+ current: SolixSnapshot?
+ ) -> [SolixHistorySample] {
+ var result = samples.sorted { $0.date < $1.date }
+ if let current, current.updatedAt > (result.last?.date ?? .distantPast) {
+ result.append(SolixHistorySample(
+ date: current.updatedAt,
+ batteryPercent: current.batteryPercent,
+ solarWatts: current.solarWatts,
+ gridWatts: current.gridWatts,
+ homeWatts: current.homeWatts,
+ batteryWatts: current.batteryWatts
+ ))
+ }
+ return result
+ }
+}
diff --git a/Sources/SolixBarKit/HistoryGraphMenuView.swift b/Sources/SolixBarKit/HistoryGraphMenuView.swift
new file mode 100644
index 0000000..ab5c725
--- /dev/null
+++ b/Sources/SolixBarKit/HistoryGraphMenuView.swift
@@ -0,0 +1,131 @@
+import AppKit
+
+/// Verlaufs-Sektion im Dashboard: gemeinsame Chip-Kopfzeile
+/// (GraphControlHeader) über dem Graphen.
+@MainActor
+final class HistoryGraphMenuView: NSView {
+ private let settings = AppSettings.shared
+ private let graphProvider: () -> [SolixHistorySample]
+ private let onRangeChange: () -> Void
+ private let onOpenLarge: () -> Void
+ private let graphContainer = NSView()
+ private var header: GraphControlHeader?
+
+ init(graphProvider: @escaping () -> [SolixHistorySample], onRangeChange: @escaping () -> Void, onOpenLarge: @escaping () -> Void) {
+ self.graphProvider = graphProvider
+ self.onRangeChange = onRangeChange
+ self.onOpenLarge = onOpenLarge
+ super.init(frame: NSRect(x: 0, y: 0, width: 396, height: 300))
+ wantsLayer = true
+ layer?.cornerRadius = Theme.radiusCard
+ layer?.masksToBounds = true
+ effectiveAppearance.performAsCurrentDrawingAppearance { [self] in
+ layer?.backgroundColor = menuBackground.cgColor
+ buildView()
+ reload()
+ }
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ private func buildView() {
+ let header = GraphControlHeader(onChange: { [weak self] in
+ self?.reload()
+ self?.onRangeChange()
+ })
+ self.header = header
+
+ for view in [header, graphContainer] {
+ view.translatesAutoresizingMaskIntoConstraints = false
+ addSubview(view)
+ }
+
+ NSLayoutConstraint.activate([
+ header.topAnchor.constraint(equalTo: topAnchor, constant: 12),
+ header.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 14),
+ header.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -14),
+
+ graphContainer.topAnchor.constraint(equalTo: header.bottomAnchor, constant: 14),
+ graphContainer.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 14),
+ graphContainer.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -14),
+ graphContainer.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -14),
+ // Ohne explizite Höhe kollabiert der Container (der Graph hat keine
+ // intrinsische Größe).
+ graphContainer.heightAnchor.constraint(greaterThanOrEqualToConstant: 190)
+ ])
+ }
+
+ /// Umrechnung Tage <-> Anzeigeeinheit des eigenen Zeitraums
+ /// (Speicherformat bleibt Tage). Auch von den Einstellungen genutzt.
+ static func customValue(days: Double, unit: String) -> Int {
+ switch unit {
+ case "hours": max(1, Int((days * 24).rounded()))
+ case "weeks": max(1, Int((days / 7).rounded()))
+ case "months": max(1, Int((days / 30).rounded()))
+ default: max(1, Int(days.rounded()))
+ }
+ }
+
+ static func days(fromValue value: Int, unit: String) -> Double {
+ switch unit {
+ case "hours": max(1.0 / 24, Double(value) / 24)
+ case "weeks": Double(value) * 7
+ case "months": Double(value) * 30
+ default: Double(value)
+ }
+ }
+
+ private func reload() {
+ header?.reload()
+ graphContainer.subviews.forEach { $0.removeFromSuperview() }
+ let graph = HistoryGraphView(
+ samples: graphProvider(),
+ rangeTitle: settings.historyRange.title,
+ range: settings.historyRange,
+ rangeDuration: settings.historyDuration,
+ visibleMetrics: settings.graphMetrics,
+ showsHeader: false,
+ size: NSSize(width: 368, height: 191)
+ )
+ graph.onClick = onOpenLarge
+ graph.translatesAutoresizingMaskIntoConstraints = false
+ graphContainer.addSubview(graph)
+
+ NSLayoutConstraint.activate([
+ graph.topAnchor.constraint(equalTo: graphContainer.topAnchor),
+ graph.leadingAnchor.constraint(equalTo: graphContainer.leadingAnchor),
+ graph.trailingAnchor.constraint(equalTo: graphContainer.trailingAnchor),
+ graph.bottomAnchor.constraint(equalTo: graphContainer.bottomAnchor)
+ ])
+ }
+
+ override func draw(_ dirtyRect: NSRect) {
+ menuBackground.setFill()
+ bounds.fill()
+ }
+
+ override func viewDidChangeEffectiveAppearance() {
+ super.viewDidChangeEffectiveAppearance()
+ effectiveAppearance.performAsCurrentDrawingAppearance { [self] in
+ layer?.backgroundColor = menuBackground.cgColor
+ }
+ reload()
+ needsDisplay = true
+ }
+
+ private var menuBackground: NSColor {
+ NSColor(name: nil) { appearance in
+ appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
+ ? NSColor(calibratedRed: 0.105, green: 0.115, blue: 0.125, alpha: 1)
+ : NSColor(calibratedRed: 1, green: 1, blue: 1, alpha: 1)
+ }
+ }
+}
+
+extension Array {
+ subscript(safe index: Int) -> Element? {
+ indices.contains(index) ? self[index] : nil
+ }
+}
diff --git a/Sources/SolixBarKit/HistoryGraphView.swift b/Sources/SolixBarKit/HistoryGraphView.swift
new file mode 100644
index 0000000..a23837c
--- /dev/null
+++ b/Sources/SolixBarKit/HistoryGraphView.swift
@@ -0,0 +1,716 @@
+import AppKit
+
+final class HistoryGraphView: NSView {
+ private let samples: [SolixHistorySample]
+ private let rangeTitle: String
+ private let range: HistoryRange
+ private let rangeDuration: TimeInterval
+ private let visibleMetrics: [GraphMetric]
+ private let showsHeader: Bool
+ private let fitsData: Bool
+ private let smoothing: Bool
+ private let filledMetrics: Set
+ /// Y-Grenzen der Plotfläche, auf die die Glättung geklemmt wird (während
+ /// des Zeichnens gesetzt). Verhindert Überschwingen aus dem Chart heraus.
+ private var curveClampBounds: ClosedRange?
+ private var animationProgress: CGFloat = 0
+ private var animationTimer: Timer?
+ private let animationStart = Date()
+ var onClick: (() -> Void)?
+ /// Hover-Inspektion (vertikale Linie + Wertebox); nur im großen Fenster aktiv.
+ var isInteractive = false {
+ didSet { updateTrackingAreas() }
+ }
+ private var hoverX: CGFloat?
+ private var lastPlotRect: NSRect = .zero
+
+ @MainActor private var batteryColor: NSColor { Theme.accent(.batteryHigh) }
+ @MainActor private var solarColor: NSColor { Theme.accent(.solar) }
+ @MainActor private var gridColor: NSColor { Theme.accent(.gridImport) }
+
+ init(
+ samples: [SolixHistorySample],
+ rangeTitle: String,
+ range: HistoryRange = AppSettings.shared.historyRange,
+ rangeDuration: TimeInterval = AppSettings.shared.historyDuration,
+ visibleMetrics: [GraphMetric] = AppSettings.shared.graphMetrics,
+ showsHeader: Bool = true,
+ fitsData: Bool = AppSettings.shared.graphFitsData,
+ smoothing: Bool = AppSettings.shared.graphSmoothing,
+ filledMetrics: Set = Set(AppSettings.shared.graphFilledMetrics),
+ size: NSSize = NSSize(width: 320, height: 170)
+ ) {
+ self.samples = samples.sorted { $0.date < $1.date }
+ self.rangeTitle = rangeTitle
+ self.range = range
+ self.rangeDuration = rangeDuration
+ self.showsHeader = showsHeader
+ self.fitsData = fitsData
+ self.smoothing = smoothing
+ self.filledMetrics = filledMetrics
+ self.visibleMetrics = visibleMetrics.isEmpty ? GraphMetric.allCases : visibleMetrics
+ super.init(frame: NSRect(origin: .zero, size: size))
+ wantsLayer = true
+ layer?.cornerRadius = 12
+ layer?.masksToBounds = true
+ toolTip = LocalizedText.text(
+ "Zeigt die aktivierten Werte im gewählten Zeitraum. Klick öffnet die große Ansicht.",
+ "Shows the enabled values for the selected period. Click to open the large view."
+ )
+ startLineAnimation()
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func mouseDown(with event: NSEvent) {
+ onClick?()
+ }
+
+ override func updateTrackingAreas() {
+ super.updateTrackingAreas()
+ trackingAreas.forEach(removeTrackingArea)
+ guard isInteractive else { return }
+ addTrackingArea(NSTrackingArea(
+ rect: bounds,
+ options: [.mouseMoved, .mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect],
+ owner: self,
+ userInfo: nil
+ ))
+ }
+
+ override func mouseMoved(with event: NSEvent) {
+ guard isInteractive else { return }
+ let point = convert(event.locationInWindow, from: nil)
+ hoverX = lastPlotRect.contains(NSPoint(x: point.x, y: lastPlotRect.midY)) ? point.x : nil
+ needsDisplay = true
+ }
+
+ override func mouseExited(with event: NSEvent) {
+ hoverX = nil
+ needsDisplay = true
+ }
+
+ override func draw(_ dirtyRect: NSRect) {
+ super.draw(dirtyRect)
+ drawBackground()
+ let isCompact = bounds.height < 220
+
+ // Ohne Innen-Header (Dashboard: Chips übernehmen Titel + Legende)
+ // braucht der Plot oben nur minimalen Abstand.
+ let topInset: CGFloat = showsHeader ? (isCompact ? 58 : 78) : 18
+ let bottomInset: CGFloat = isCompact ? 42 : 48
+ let leftInset: CGFloat = isCompact ? 64 : 70
+ let rightInset: CGFloat = isCompact ? 70 : 82
+ let plot = NSRect(
+ x: leftInset,
+ y: bottomInset,
+ width: max(80, bounds.width - leftInset - rightInset),
+ height: max(48, bounds.height - topInset - bottomInset)
+ )
+ let maxPower = maxPowerValue()
+ drawPlotSurface(in: plot)
+ drawGrid(in: plot, maxPower: maxPower)
+ drawTimeLabels(in: plot)
+ // Header/Legende nach der Plotfläche zeichnen, damit sie bei knapper
+ // Höhe niemals unter der Fläche verschwinden.
+ drawHeader()
+ if !isCompact && showsHeader {
+ drawLegend()
+ }
+
+ guard samples.count >= 2 else {
+ drawEmptyState()
+ return
+ }
+
+ // Flächenfüllung je Kurve wählbar (Standard: nur Solar). Alle
+ // Füllungen zuerst, dann alle Linien — so bleiben die Linien auch
+ // bei überlappenden (bewusst blassen) Füllungen knackig.
+ var curves: [(points: [NSPoint], color: NSColor, metric: GraphMetric)] = []
+ if visibleMetrics.contains(.battery) {
+ curves.append((animatedPoints(batteryPoints(in: plot)), batteryColor, .battery))
+ }
+ if visibleMetrics.contains(.solar) {
+ curves.append((animatedPoints(solarPoints(in: plot, maxPower: maxPower)), solarColor, .solar))
+ }
+ if visibleMetrics.contains(.grid) {
+ curves.append((animatedPoints(gridPoints(in: plot, maxPower: maxPower)), gridColor, .grid))
+ }
+ // Glättung darf die Kurve nicht aus der Plotfläche schieben — sonst
+ // zeigte ein weicher Bogen z. B. negative Watt unter der Nulllinie.
+ curveClampBounds = plot.minY...plot.maxY
+ for curve in curves where filledMetrics.contains(curve.metric) {
+ drawFill(values: curve.points, color: curve.color, baseline: plot.minY)
+ }
+ for curve in curves {
+ drawLine(values: curve.points, color: curve.color, width: 3.1)
+ }
+ // Grundlinie zuletzt: Kurven auf 0 (z. B. Netz nachts) sollen die
+ // Achse nicht verdecken.
+ drawAxes(in: plot)
+ lastPlotRect = plot
+ drawHoverInspector(in: plot)
+ }
+
+ /// Vertikale Inspektionslinie mit Wertebox am Mauszeiger.
+ private func drawHoverInspector(in rect: NSRect) {
+ guard isInteractive, let hoverX, samples.count >= 2 else { return }
+ let domain = timeDomain()
+ let span = domain.end.timeIntervalSince(domain.start)
+ guard span > 0 else { return }
+ let progress = Double((hoverX - rect.minX) / rect.width)
+ let date = domain.start.addingTimeInterval(span * min(1, max(0, progress)))
+ guard let sample = samples.min(by: {
+ abs($0.date.timeIntervalSince(date)) < abs($1.date.timeIntervalSince(date))
+ }) else { return }
+
+ let line = NSBezierPath()
+ line.move(to: NSPoint(x: hoverX, y: rect.minY))
+ line.line(to: NSPoint(x: hoverX, y: rect.maxY))
+ NSColor.secondaryLabelColor.withAlphaComponent(0.55).setStroke()
+ line.lineWidth = 1
+ line.stroke()
+
+ let formatter = DateFormatter()
+ formatter.locale = Locale(identifier: AppSettings.shared.appLanguage == .english ? "en_US" : "de_DE")
+ formatter.dateFormat = span > 26 * 3600 ? "dd.MM. HH:mm" : "HH:mm"
+
+ var linesOut: [(String, NSColor)] = [(formatter.string(from: sample.date), .secondaryLabelColor)]
+ if visibleMetrics.contains(.battery), let percent = sample.batteryPercent {
+ linesOut.append((LocalizedText.text("Akku \(percent)%", "Battery \(percent)%"), batteryColor))
+ }
+ if visibleMetrics.contains(.solar), let watts = sample.solarWatts {
+ linesOut.append(("Solar \(watts)W", solarColor))
+ }
+ if visibleMetrics.contains(.grid), let watts = sample.gridWatts {
+ linesOut.append((LocalizedText.text("Netz \(watts)W", "Grid \(watts)W"), gridColor))
+ }
+
+ let font = NSFont.monospacedDigitSystemFont(ofSize: 11, weight: .semibold)
+ let lineHeight: CGFloat = 15
+ let boxWidth = linesOut.map { (($0.0 as NSString).size(withAttributes: [.font: font]).width) }.max().map { $0 + 20 } ?? 80
+ let boxHeight = CGFloat(linesOut.count) * lineHeight + 12
+ var boxX = hoverX + 10
+ if boxX + boxWidth > rect.maxX { boxX = hoverX - boxWidth - 10 }
+ let box = NSRect(x: boxX, y: rect.maxY - boxHeight - 4, width: boxWidth, height: boxHeight)
+
+ let boxPath = NSBezierPath(roundedRect: box, xRadius: 8, yRadius: 8)
+ (NSColor(name: nil) { appearance in
+ appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
+ ? NSColor(calibratedWhite: 0.08, alpha: 0.94)
+ : NSColor(calibratedWhite: 1.0, alpha: 0.96)
+ }).setFill()
+ boxPath.fill()
+
+ for (index, entry) in linesOut.enumerated() {
+ drawText(
+ entry.0,
+ at: NSPoint(x: box.minX + 10, y: box.maxY - CGFloat(index + 1) * lineHeight - 4),
+ font: font,
+ color: entry.1
+ )
+ }
+ }
+
+ private func startLineAnimation() {
+ // .common statt Default-Modus: Während ein Menü offen ist, läuft der
+ // Runloop im Tracking-Modus — dort feuerte der Timer nicht, der
+ // Fortschritt blieb bei 0 und die Linien waren beim ersten Öffnen
+ // nach einem Neuaufbau (z. B. Einstellungsänderung) unsichtbar.
+ let timer = Timer(
+ timeInterval: 1.0 / 30.0,
+ target: self,
+ selector: #selector(tickLineAnimation(_:)),
+ userInfo: nil,
+ repeats: true
+ )
+ RunLoop.main.add(timer, forMode: .common)
+ animationTimer = timer
+ }
+
+ @objc private func tickLineAnimation(_ timer: Timer) {
+ let elapsed = Date().timeIntervalSince(animationStart)
+ let progress = min(1, elapsed / 0.9)
+ animationProgress = easeOutCubic(CGFloat(progress))
+ needsDisplay = true
+ if progress >= 1 {
+ timer.invalidate()
+ animationTimer = nil
+ }
+ }
+
+ private func drawBackground() {
+ let path = NSBezierPath(roundedRect: bounds, xRadius: 12, yRadius: 12)
+ if let gradient = NSGradient(colors: [graphBackgroundTop, graphBackground]) {
+ gradient.draw(in: path, angle: -90)
+ } else {
+ graphBackground.setFill()
+ path.fill()
+ }
+ }
+
+ /// Dezente Toenung der Plotflaeche ohne eigenen Rahmen — die fruehere
+ /// eckige Box kollidierte optisch mit den runden Ecken der Aussenkarte.
+ private func drawPlotSurface(in rect: NSRect) {
+ let plot = NSBezierPath(roundedRect: rect.insetBy(dx: -6, dy: -6), xRadius: 8, yRadius: 8)
+ plotSurfaceColor.withAlphaComponent(0.4).setFill()
+ plot.fill()
+ }
+
+ private func drawHeader() {
+ guard showsHeader else { return }
+ drawText(
+ "\(LocalizedText.text("Verlauf", "History")) \(localizedRangeTitle(rangeTitle))",
+ at: NSPoint(x: 16, y: bounds.maxY - 25),
+ font: .boldSystemFont(ofSize: bounds.height < 180 ? 12 : 13),
+ color: .labelColor
+ )
+ }
+
+ private func drawEmptyState() {
+ drawText(
+ LocalizedText.text("Noch nicht genug Messpunkte", "Not enough measurements yet"),
+ at: NSPoint(x: 18, y: bounds.midY - 6),
+ font: .systemFont(ofSize: 13, weight: .medium),
+ color: .secondaryLabelColor
+ )
+ }
+
+ private func localizedRangeTitle(_ title: String) -> String {
+ guard AppSettings.shared.appLanguage == .english else { return title }
+ switch title {
+ case "Aktuell":
+ return "Current"
+ case "24 Stunden":
+ return "24 Hours"
+ case "7 Tage":
+ return "7 Days"
+ case "30 Tage":
+ return "30 Days"
+ case "Individuell":
+ return "Custom"
+ default:
+ return title
+ }
+ }
+
+ private func drawGrid(in rect: NSRect, maxPower: Int) {
+ let gridPath = NSBezierPath()
+ for index in 0...4 {
+ let y = rect.minY + (rect.height / 4) * CGFloat(index)
+ gridPath.move(to: NSPoint(x: rect.minX, y: y))
+ gridPath.line(to: NSPoint(x: rect.maxX, y: y))
+
+ // %-Achse gehört zur Akku-Linie: gleiche Farbe stellt die Zuordnung
+ // der beiden Y-Achsen klar (links %, rechts Watt).
+ let percent = index * 25
+ drawText(
+ "\(percent)%",
+ at: NSPoint(x: rect.minX - 50, y: y - 7),
+ font: .monospacedDigitSystemFont(ofSize: 10, weight: .medium),
+ color: visibleMetrics.contains(.battery)
+ ? batteryColor.withAlphaComponent(0.85)
+ : .secondaryLabelColor
+ )
+
+ let watts = Int(round(Double(maxPower) * Double(index) / 4.0))
+ drawText(
+ "\(watts)W",
+ at: NSPoint(x: rect.maxX + 10, y: y - 7),
+ font: .monospacedDigitSystemFont(ofSize: 10, weight: .medium),
+ color: .secondaryLabelColor
+ )
+ }
+ gridLineColor.setStroke()
+ gridPath.lineWidth = 0.8
+ gridPath.stroke()
+ }
+
+ /// Nur die Grundlinie als Achse — der komplette Kasten wirkte wie ein
+ /// zweiter Rahmen im Rahmen.
+ private func drawAxes(in rect: NSRect) {
+ let path = NSBezierPath()
+ path.move(to: NSPoint(x: rect.minX, y: rect.minY))
+ path.line(to: NSPoint(x: rect.maxX, y: rect.minY))
+ axisColor.setStroke()
+ path.lineWidth = 1.1
+ path.stroke()
+ }
+
+ private func drawTimeLabels(in rect: NSRect) {
+ let domain = timeDomain()
+ let font = NSFont.systemFont(ofSize: 10, weight: .medium)
+ let ticks = timeTicks(for: domain)
+ let span = domain.end.timeIntervalSince(domain.start)
+
+ // Pixelposition des "Jetzt"-Labels vorab bestimmen: reguläre Labels,
+ // die damit kollidieren würden, werden ausgelassen (Zeit-Puffer allein
+ // reicht nicht, weil das Jetzt-Label am Rand nach links geclampt wird).
+ var nowLabelMinX = CGFloat.greatestFiniteMagnitude
+ if let last = ticks.last(where: { $0.isLast }) {
+ let text = last.label ?? timeLabel(for: last.date, isLast: true)
+ let width = (text as NSString).size(withAttributes: [.font: font]).width
+ let progress = span > 0 ? last.date.timeIntervalSince(domain.start) / span : 1
+ let x = rect.minX + rect.width * CGFloat(progress)
+ nowLabelMinX = min(rect.maxX - width, max(rect.minX, x - width / 2))
+ }
+
+ for tick in ticks {
+ let progress = span > 0
+ ? tick.date.timeIntervalSince(domain.start) / span
+ : 0
+ let text = tick.label ?? timeLabel(for: tick.date, isLast: tick.isLast)
+ let width = (text as NSString).size(withAttributes: [.font: font]).width
+ let x = rect.minX + rect.width * CGFloat(progress)
+ let clampedX = min(rect.maxX - width, max(rect.minX, x - width / 2))
+ if !tick.isLast && clampedX + width > nowLabelMinX - 8 {
+ continue
+ }
+ drawText(text, at: NSPoint(x: clampedX, y: max(13, rect.minY - 28)), font: font, color: .secondaryLabelColor)
+
+ let mark = NSBezierPath()
+ mark.move(to: NSPoint(x: x, y: rect.minY))
+ mark.line(to: NSPoint(x: x, y: rect.minY - 4))
+ axisColor.setStroke()
+ mark.lineWidth = 1
+ mark.stroke()
+ }
+ }
+
+ private func batteryPoints(in rect: NSRect) -> [NSPoint] {
+ normalizedPoints(in: rect) { sample in
+ guard let percent = sample.batteryPercent else { return nil }
+ return min(1, max(0, Double(percent) / 100))
+ }
+ }
+
+ private func solarPoints(in rect: NSRect, maxPower: Int) -> [NSPoint] {
+ normalizedPoints(in: rect) { sample in
+ guard let watts = sample.solarWatts else { return nil }
+ return min(1, max(0, Double(watts) / Double(maxPower)))
+ }
+ }
+
+ private func gridPoints(in rect: NSRect, maxPower: Int) -> [NSPoint] {
+ normalizedPoints(in: rect) { sample in
+ guard let watts = sample.gridWatts else { return nil }
+ return min(1, max(0, Double(max(0, watts)) / Double(maxPower)))
+ }
+ }
+
+ private func normalizedPoints(in rect: NSRect, value: (SolixHistorySample) -> Double?) -> [NSPoint] {
+ let domain = timeDomain()
+ let first = domain.start.timeIntervalSince1970
+ let last = domain.end.timeIntervalSince1970
+ guard last > first else { return [] }
+
+ return samples.compactMap { sample in
+ guard let normalized = value(sample) else { return nil }
+ let timestamp = sample.date.timeIntervalSince1970
+ guard timestamp >= first, timestamp <= last else { return nil }
+ let x = rect.minX + rect.width * CGFloat((timestamp - first) / (last - first))
+ let y = rect.minY + rect.height * CGFloat(normalized)
+ return NSPoint(x: x, y: y)
+ }
+ }
+
+ private func timeDomain() -> (start: Date, end: Date) {
+ let now = Date()
+ let end = max(samples.last?.date ?? now, now)
+ let duration = max(60 * 60, rangeDuration)
+ var start = end.addingTimeInterval(-duration)
+ // Fit-to-Data: leere Zeiträume nicht anzeigen, wenn die Daten erst
+ // deutlich später beginnen (mindestens 1 h Spannweite behalten).
+ if fitsData, let first = samples.first?.date, first > start {
+ let padded = first.addingTimeInterval(-duration * 0.03)
+ start = min(max(padded, start), end.addingTimeInterval(-60 * 60))
+ }
+ return (start, end)
+ }
+
+ /// Ticks an runden Uhrzeit-/Tagesgrenzen statt an krummen Bruchteilen der
+ /// Domäne (früher: 00:33, 06:33 ...). Der letzte Tick ist immer "Jetzt".
+ private func timeTicks(for domain: (start: Date, end: Date)) -> [(date: Date, label: String?, isLast: Bool)] {
+ let duration = domain.end.timeIntervalSince(domain.start)
+ let step: TimeInterval
+ if fitsData && duration < rangeDuration * 0.75 {
+ // Gefittete (kürzere) Domäne: Schrittweite aus der echten Dauer.
+ switch duration {
+ case ..<(4 * 3600): step = 30 * 60
+ case ..<(26 * 3600): step = bounds.width < 430 ? 6 * 3600 : 4 * 3600
+ case ..<(8 * 24 * 3600): step = 24 * 3600
+ default: step = max(1, ((duration / (24 * 3600)) / 6).rounded()) * 24 * 3600
+ }
+ } else {
+ switch range {
+ case .current:
+ step = 30 * 60
+ case .day:
+ step = bounds.width < 430 ? 6 * 3600 : 4 * 3600
+ case .week:
+ step = 24 * 3600
+ case .month:
+ step = bounds.width < 430 ? 7 * 24 * 3600 : 5 * 24 * 3600
+ case .custom:
+ let days = max(1, (duration / (24 * 3600)).rounded())
+ step = max(1, (days / 6).rounded()) * 24 * 3600
+ }
+ }
+
+ var ticks: [(date: Date, label: String?, isLast: Bool)] = []
+ let calendar = Calendar.current
+ var boundary: Date
+ if step < 24 * 3600 {
+ let anchor = calendar.dateInterval(of: .hour, for: domain.start)?.end ?? domain.start
+ boundary = anchor
+ // auf ein Vielfaches der Schrittweite ab Mitternacht ausrichten
+ while boundary.timeIntervalSince(calendar.startOfDay(for: boundary))
+ .truncatingRemainder(dividingBy: step) != 0 {
+ boundary.addTimeInterval(3600)
+ }
+ } else {
+ boundary = calendar.startOfDay(for: domain.start.addingTimeInterval(24 * 3600))
+ }
+ // Puffer von 60% der Schrittweite vor "Jetzt", damit sich das letzte
+ // Zeitlabel nie mit dem Jetzt-Label überlagert.
+ while boundary < domain.end.addingTimeInterval(-step * 0.6) {
+ ticks.append((date: boundary, label: nil, isLast: false))
+ boundary.addTimeInterval(step)
+ }
+ ticks.append((date: domain.end, label: LocalizedText.text("Jetzt", "Now"), isLast: true))
+ return ticks
+ }
+
+ private func timeLabel(for date: Date, isLast: Bool) -> String {
+ if isLast { return LocalizedText.text("Jetzt", "Now") }
+ let formatter = DateFormatter()
+ formatter.locale = Locale(
+ identifier: AppSettings.shared.appLanguage == .english ? "en_US" : "de_DE"
+ )
+ let effectiveDuration = timeDomain().end.timeIntervalSince(timeDomain().start)
+ if fitsData && effectiveDuration < 26 * 3600 {
+ formatter.dateFormat = "HH:mm"
+ } else {
+ switch range {
+ case .current, .day:
+ formatter.dateFormat = "HH:mm"
+ case .week:
+ formatter.dateFormat = "dd.MM"
+ case .month:
+ formatter.dateFormat = "dd.MM"
+ case .custom:
+ formatter.dateFormat = rangeDuration <= 35 * 24 * 60 * 60 ? "dd.MM" : "MM/yy"
+ }
+ }
+ return formatter.string(from: date)
+ }
+
+ private func drawFill(values points: [NSPoint], color: NSColor, baseline: CGFloat) {
+ guard points.count >= 2, let first = points.first, let last = points.last else { return }
+ let fillPath = linePath(through: points)
+ fillPath.line(to: NSPoint(x: last.x, y: baseline))
+ fillPath.line(to: NSPoint(x: first.x, y: baseline))
+ fillPath.close()
+ color.withAlphaComponent(0.12).setFill()
+ fillPath.fill()
+ }
+
+ /// Pfad durch die Messpunkte: Geradenzug oder — bei aktivierter
+ /// Glättung — eine weiche Kurve (auf die Plotfläche geklemmt).
+ private func linePath(through points: [NSPoint]) -> NSBezierPath {
+ smoothing
+ ? Self.smoothPath(through: points, clampingY: curveClampBounds)
+ : Self.straightPath(through: points)
+ }
+
+ static func straightPath(through points: [NSPoint]) -> NSBezierPath {
+ let path = NSBezierPath()
+ guard let first = points.first else { return path }
+ path.move(to: first)
+ for point in points.dropFirst() {
+ path.line(to: point)
+ }
+ return path
+ }
+
+ /// Weiche Kurve durch alle Messpunkte (Catmull-Rom, in kubische Béziers
+ /// überführt). Fliesst sichtbar runder durch die Punkte als eine
+ /// monotone Interpolation. Damit ein Bogen die Kurve nicht aus dem Chart
+ /// schiebt (z. B. scheinbar negative Watt unter der Nulllinie), werden
+ /// die Kontrollpunkte auf die Plotfläche geklemmt: Da eine Bézier-Kurve
+ /// stets in der konvexen Hülle ihrer Kontrollpunkte liegt, bleibt die
+ /// ganze Kurve dann garantiert innerhalb dieser Grenzen.
+ static func smoothPath(through points: [NSPoint], clampingY yBounds: ClosedRange?) -> NSBezierPath {
+ let path = NSBezierPath()
+ guard let first = points.first else { return path }
+ path.move(to: first)
+ guard points.count > 2 else {
+ for point in points.dropFirst() {
+ path.line(to: point)
+ }
+ return path
+ }
+
+ func clampY(_ y: CGFloat) -> CGFloat {
+ guard let yBounds else { return y }
+ return min(max(y, yBounds.lowerBound), yBounds.upperBound)
+ }
+
+ let count = points.count
+ for index in 0..<(count - 1) {
+ let p0 = points[index == 0 ? 0 : index - 1]
+ let p1 = points[index]
+ let p2 = points[index + 1]
+ let p3 = points[index + 2 < count ? index + 2 : count - 1]
+
+ // Catmull-Rom → Bézier-Kontrollpunkte (Standard-Tension 1/6).
+ var c1 = NSPoint(x: p1.x + (p2.x - p0.x) / 6, y: p1.y + (p2.y - p0.y) / 6)
+ var c2 = NSPoint(x: p2.x - (p3.x - p1.x) / 6, y: p2.y - (p3.y - p1.y) / 6)
+ // X im Segment halten (kein Rückwärtsschlaufen bei ungleichen
+ // Abständen), Y auf die Plotfläche klemmen.
+ c1.x = min(max(c1.x, p1.x), p2.x)
+ c2.x = min(max(c2.x, p1.x), p2.x)
+ c1.y = clampY(c1.y)
+ c2.y = clampY(c2.y)
+ path.curve(to: p2, controlPoint1: c1, controlPoint2: c2)
+ }
+ return path
+ }
+
+ private func drawLine(values points: [NSPoint], color: NSColor, width: CGFloat) {
+ guard points.count >= 2 else { return }
+ let path = linePath(through: points)
+ let shadow = NSShadow()
+ shadow.shadowColor = color.withAlphaComponent(0.24)
+ shadow.shadowBlurRadius = 5
+ shadow.shadowOffset = NSSize(width: 0, height: -1)
+
+ NSGraphicsContext.saveGraphicsState()
+ shadow.set()
+ color.setStroke()
+ path.lineWidth = width
+ path.lineJoinStyle = .round
+ path.lineCapStyle = .round
+ path.stroke()
+ NSGraphicsContext.restoreGraphicsState()
+
+ if let point = points.last {
+ color.setFill()
+ NSBezierPath(ovalIn: NSRect(x: point.x - 3.5, y: point.y - 3.5, width: 7, height: 7)).fill()
+ graphBackground.setFill()
+ NSBezierPath(ovalIn: NSRect(x: point.x - 1.5, y: point.y - 1.5, width: 3, height: 3)).fill()
+ }
+ }
+
+ private func animatedPoints(_ points: [NSPoint]) -> [NSPoint] {
+ guard points.count >= 2, animationProgress < 1 else { return points }
+ let target = CGFloat(points.count - 1) * animationProgress
+ let fullIndex = max(0, min(points.count - 1, Int(target.rounded(.down))))
+ var visible = Array(points.prefix(fullIndex + 1))
+ guard fullIndex < points.count - 1 else { return visible }
+ let fraction = target - CGFloat(fullIndex)
+ let start = points[fullIndex]
+ let end = points[fullIndex + 1]
+ visible.append(NSPoint(
+ x: start.x + (end.x - start.x) * fraction,
+ y: start.y + (end.y - start.y) * fraction
+ ))
+ return visible
+ }
+
+ private func drawLegend() {
+ let y = bounds.maxY - (bounds.height < 180 ? 46 : 52)
+ var x: CGFloat = 18
+ let maxWidth = bounds.width - 36
+ let compact = maxWidth < 310
+ if visibleMetrics.contains(.battery) {
+ drawLegendItem(title: LocalizedText.text("Akku", "Battery"), color: batteryColor, x: x, y: y, width: compact ? 50 : 58)
+ x += compact ? 56 : 66
+ }
+ if visibleMetrics.contains(.solar) {
+ drawLegendItem(title: "Solar", color: solarColor, x: x, y: y, width: compact ? 54 : 64)
+ x += compact ? 60 : 72
+ }
+ if visibleMetrics.contains(.grid) {
+ drawLegendItem(title: LocalizedText.text("Netz", "Grid"), color: gridColor, x: x, y: y, width: compact ? 50 : 58)
+ }
+ }
+
+ private func drawLegendItem(title: String, color: NSColor, x: CGFloat, y: CGFloat, width: CGFloat) {
+ let badgeRect = NSRect(x: x - 7, y: y - 5, width: width, height: 20)
+ (color.withAlphaComponent(0.18).blended(withFraction: 0.20, of: graphBackground) ?? color.withAlphaComponent(0.18)).setFill()
+ NSBezierPath(roundedRect: badgeRect, xRadius: 7, yRadius: 7).fill()
+
+ color.withAlphaComponent(0.45).setStroke()
+ let border = NSBezierPath(roundedRect: badgeRect, xRadius: 7, yRadius: 7)
+ border.lineWidth = 1
+ border.stroke()
+
+ color.setFill()
+ NSBezierPath(ovalIn: NSRect(x: x, y: y + 1, width: 8, height: 8)).fill()
+ drawText(title, at: NSPoint(x: x + 12, y: y - 2), font: .systemFont(ofSize: 11, weight: .bold), color: color)
+ }
+
+ private func maxPowerValue() -> Int {
+ let values = samples.flatMap { sample -> [Int] in
+ [
+ visibleMetrics.contains(.solar) ? (sample.solarWatts ?? 0) : 0,
+ visibleMetrics.contains(.grid) ? max(0, sample.gridWatts ?? 0) : 0
+ ]
+ }
+ let rawMax = max(2000, values.max() ?? 2000)
+ return Int(ceil(Double(rawMax) / 500.0) * 500)
+ }
+
+ private func easeOutCubic(_ value: CGFloat) -> CGFloat {
+ 1 - pow(1 - value, 3)
+ }
+
+ private func drawText(_ text: String, at point: NSPoint, font: NSFont, color: NSColor) {
+ let attributes: [NSAttributedString.Key: Any] = [
+ .font: font,
+ .foregroundColor: color
+ ]
+ text.draw(at: point, withAttributes: attributes)
+ }
+
+ private var graphBackground: NSColor {
+ NSColor(name: nil) { appearance in
+ appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
+ ? NSColor(calibratedRed: 0.105, green: 0.115, blue: 0.125, alpha: 1)
+ : NSColor(calibratedRed: 1, green: 1, blue: 1, alpha: 1)
+ }
+ }
+
+ private var graphBackgroundTop: NSColor {
+ NSColor(name: nil) { appearance in
+ appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
+ ? NSColor(calibratedRed: 0.13, green: 0.15, blue: 0.16, alpha: 1)
+ : NSColor(calibratedRed: 0.96, green: 0.985, blue: 0.98, alpha: 1)
+ }
+ }
+
+ private var plotSurfaceColor: NSColor {
+ NSColor(name: nil) { appearance in
+ appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
+ ? NSColor(calibratedRed: 0.07, green: 0.08, blue: 0.09, alpha: 0.72)
+ : NSColor(calibratedRed: 0.985, green: 0.995, blue: 1.0, alpha: 0.92)
+ }
+ }
+
+ /// Etwas kräftiger als der Systemtrenner, damit die Rasterlinien in beiden
+ /// Modi als Ablesehilfe taugen, ohne die Kurven zu stören.
+ private var gridLineColor: NSColor {
+ NSColor(name: nil) { appearance in
+ appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
+ ? NSColor(calibratedWhite: 1.0, alpha: 0.18)
+ : NSColor(calibratedWhite: 0.0, alpha: 0.16)
+ }
+ }
+
+ private var axisColor: NSColor {
+ NSColor.separatorColor.withAlphaComponent(0.8)
+ }
+}
diff --git a/Sources/SolixBarKit/KeychainStore.swift b/Sources/SolixBarKit/KeychainStore.swift
new file mode 100644
index 0000000..48980e2
--- /dev/null
+++ b/Sources/SolixBarKit/KeychainStore.swift
@@ -0,0 +1,63 @@
+import Foundation
+import Security
+
+/// Speichert das SOLIX-Passwort im macOS-Schlüsselbund statt im Klartext.
+enum KeychainStore {
+ private static let service = "local.codex.SolixBar"
+
+ static func setPassword(_ password: String, account: String) throws {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: account
+ ]
+ let attributes: [String: Any] = [
+ kSecValueData as String: Data(password.utf8)
+ ]
+ let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
+ if status == errSecItemNotFound {
+ var add = query
+ add[kSecValueData as String] = Data(password.utf8)
+ let addStatus = SecItemAdd(add as CFDictionary, nil)
+ guard addStatus == errSecSuccess else {
+ throw KeychainError.status(addStatus)
+ }
+ } else if status != errSecSuccess {
+ throw KeychainError.status(status)
+ }
+ }
+
+ static func password(account: String) -> String? {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: account,
+ kSecReturnData as String: true,
+ kSecMatchLimit as String: kSecMatchLimitOne
+ ]
+ var result: AnyObject?
+ let status = SecItemCopyMatching(query as CFDictionary, &result)
+ guard status == errSecSuccess, let data = result as? Data else { return nil }
+ return String(data: data, encoding: .utf8)
+ }
+
+ static func deletePassword(account: String) {
+ let query: [String: Any] = [
+ kSecClass as String: kSecClassGenericPassword,
+ kSecAttrService as String: service,
+ kSecAttrAccount as String: account
+ ]
+ SecItemDelete(query as CFDictionary)
+ }
+
+ enum KeychainError: LocalizedError {
+ case status(OSStatus)
+
+ var errorDescription: String? {
+ switch self {
+ case .status(let code):
+ "Schlüsselbund-Fehler \(code)."
+ }
+ }
+ }
+}
diff --git a/Sources/SolixBarKit/LargeGraphWindowController.swift b/Sources/SolixBarKit/LargeGraphWindowController.swift
new file mode 100644
index 0000000..dc348fe
--- /dev/null
+++ b/Sources/SolixBarKit/LargeGraphWindowController.swift
@@ -0,0 +1,93 @@
+import AppKit
+
+/// Großes Verlaufsfenster: nutzt dieselbe Chip-Kopfzeile wie das Dashboard.
+@MainActor
+final class LargeGraphWindowController: NSWindowController, NSWindowDelegate {
+ private let settings = AppSettings.shared
+ private let graphProvider: () -> [SolixHistorySample]
+ private let graphContainer = NSView()
+ private var header: GraphControlHeader?
+ private let onClose: (() -> Void)?
+
+ init(graphProvider: @escaping () -> [SolixHistorySample], onClose: (() -> Void)? = nil) {
+ self.graphProvider = graphProvider
+ self.onClose = onClose
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 760, height: 480),
+ styleMask: [.titled, .closable, .miniaturizable, .resizable],
+ backing: .buffered,
+ defer: false
+ )
+ window.title = LocalizedText.text("SOLIX Verlauf", "SOLIX History")
+ window.minSize = NSSize(width: 620, height: 400)
+ window.center()
+ window.setFrameAutosaveName("SolixBar.LargeGraphWindow")
+ super.init(window: window)
+ window.delegate = self
+ window.level = settings.graphWindowLevel.nsWindowLevel
+ window.contentView = buildView()
+ rebuild()
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ func applyWindowLevel() {
+ window?.level = settings.graphWindowLevel.nsWindowLevel
+ }
+
+ func windowWillClose(_ notification: Notification) {
+ onClose?()
+ }
+
+ func rebuild() {
+ header?.reload()
+ graphContainer.subviews.forEach { $0.removeFromSuperview() }
+ let graph = HistoryGraphView(
+ samples: graphProvider(),
+ rangeTitle: settings.historyRange.title,
+ range: settings.historyRange,
+ rangeDuration: settings.historyDuration,
+ visibleMetrics: settings.graphMetrics,
+ showsHeader: false,
+ size: NSSize(width: 680, height: 360)
+ )
+ graph.isInteractive = true
+ graph.translatesAutoresizingMaskIntoConstraints = false
+ graphContainer.addSubview(graph)
+
+ NSLayoutConstraint.activate([
+ graph.topAnchor.constraint(equalTo: graphContainer.topAnchor),
+ graph.leadingAnchor.constraint(equalTo: graphContainer.leadingAnchor),
+ graph.trailingAnchor.constraint(equalTo: graphContainer.trailingAnchor),
+ graph.bottomAnchor.constraint(equalTo: graphContainer.bottomAnchor)
+ ])
+ }
+
+ private func buildView() -> NSView {
+ let container = NSView()
+ let header = GraphControlHeader(onChange: { [weak self] in
+ self?.rebuild()
+ })
+ self.header = header
+
+ for view in [header, graphContainer] {
+ view.translatesAutoresizingMaskIntoConstraints = false
+ container.addSubview(view)
+ }
+
+ NSLayoutConstraint.activate([
+ header.topAnchor.constraint(equalTo: container.topAnchor, constant: 18),
+ header.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 20),
+ header.trailingAnchor.constraint(lessThanOrEqualTo: container.trailingAnchor, constant: -20),
+
+ graphContainer.topAnchor.constraint(equalTo: header.bottomAnchor, constant: 14),
+ graphContainer.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 20),
+ graphContainer.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -20),
+ graphContainer.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -20)
+ ])
+
+ return container
+ }
+}
diff --git a/Sources/SolixBar/LocalizedText.swift b/Sources/SolixBarKit/LocalizedText.swift
similarity index 100%
rename from Sources/SolixBar/LocalizedText.swift
rename to Sources/SolixBarKit/LocalizedText.swift
diff --git a/Sources/SolixBarKit/MenuBarDisplay.swift b/Sources/SolixBarKit/MenuBarDisplay.swift
new file mode 100644
index 0000000..ac149b2
--- /dev/null
+++ b/Sources/SolixBarKit/MenuBarDisplay.swift
@@ -0,0 +1,87 @@
+import AppKit
+
+/// Stufenweise Verdichtung der Menüleisten-Anzeige, damit das Statusitem auf
+/// MacBooks mit Notch nie in die Notch-Zone ragt (macOS blendet solche Items
+/// komplett aus — das Item wäre unsichtbar).
+enum MenuBarDisplayLevel: Int, CaseIterable, Sendable {
+ /// Alle vom Nutzer gewählten Optionen.
+ case full = 0
+ /// Ohne Text-Bezeichnungen ("Akku", "PV", ...).
+ case noLabels = 1
+ /// Zusätzlich ohne Symbole und Flusspfeile — nur Werte.
+ case valuesOnly = 2
+ /// Nur die ersten zwei Metriken als Werte.
+ case compact = 3
+ /// Nur Icon bzw. "SOLIX".
+ case minimal = 4
+
+ var next: MenuBarDisplayLevel? {
+ MenuBarDisplayLevel(rawValue: rawValue + 1)
+ }
+}
+
+/// Anzeigeoptionen der Menüleiste, abgeleitet aus den Settings und pro
+/// Verdichtungsstufe reduziert. Reine Wertstruktur -> unit-testbar.
+struct MenuBarDisplayOptions: Sendable {
+ var metrics: [BarMetric]
+ var showLabels: Bool
+ var showSymbols: Bool
+ var showArrows: Bool
+ /// Rollenfarben auf Werten/Symbolen — unabhängig von der Flussrichtung;
+ /// Farben kosten keine Breite und bleiben daher auf allen Stufen erhalten.
+ var showColors: Bool = true
+ /// PV-Wert als Summe, Einzelwerte je Eingang ("438·204W") oder beides
+ /// ("642W (438·204)") — Einzelwerte nur, wenn der Snapshot Kanalwerte
+ /// enthält (Solarbank 2/3), sonst automatisch die Summe.
+ var pvDisplay: PVDisplayMode = .total
+
+ func applying(_ level: MenuBarDisplayLevel) -> MenuBarDisplayOptions {
+ var result = self
+ switch level {
+ case .full:
+ break
+ case .noLabels:
+ result.showLabels = false
+ case .valuesOnly:
+ result.showLabels = false
+ result.showSymbols = false
+ result.showArrows = false
+ case .compact:
+ result.showLabels = false
+ result.showSymbols = false
+ result.showArrows = false
+ result.metrics = Array(metrics.prefix(2))
+ case .minimal:
+ result.showLabels = false
+ result.showSymbols = false
+ result.showArrows = false
+ result.metrics = []
+ }
+ return result
+ }
+}
+
+enum NotchGeometry {
+ /// Die Notch-Zone eines Bildschirms in Bildschirmkoordinaten (nil ohne Notch).
+ @MainActor
+ static func notchRect(on screen: NSScreen?) -> NSRect? {
+ guard let screen,
+ let left = screen.auxiliaryTopLeftArea,
+ let right = screen.auxiliaryTopRightArea else {
+ return nil
+ }
+ let height = screen.safeAreaInsets.top
+ guard height > 0, right.minX > left.maxX else { return nil }
+ return NSRect(
+ x: left.maxX,
+ y: screen.frame.maxY - height,
+ width: right.minX - left.maxX,
+ height: height
+ )
+ }
+
+ /// Ragt das Item horizontal in die Notch-Zone?
+ static func overlaps(itemMinX: CGFloat, itemMaxX: CGFloat, notchMinX: CGFloat, notchMaxX: CGFloat) -> Bool {
+ itemMaxX > notchMinX && itemMinX < notchMaxX
+ }
+}
diff --git a/Sources/SolixBarKit/MenuBarFormatter.swift b/Sources/SolixBarKit/MenuBarFormatter.swift
new file mode 100644
index 0000000..da04e52
--- /dev/null
+++ b/Sources/SolixBarKit/MenuBarFormatter.swift
@@ -0,0 +1,351 @@
+import AppKit
+
+/// Formatierungs-Engine der Menüleisten-Anzeige: baut aus einem Snapshot und
+/// Anzeigeoptionen die einzeilige (attributed) bzw. zweizeilige (gestapelte)
+/// Darstellung. Aus dem StatusController herausgelöst, damit u. a. die
+/// Live-Vorschau in den Einstellungen dieselbe Engine nutzen kann.
+@MainActor
+final class MenuBarFormatter {
+ // MARK: Öffentliche API
+
+ /// Einzeilige Darstellung mit Symbolen/Pfeilen/Rollen-Tags.
+ func attributedTitle(
+ for snapshot: SolixSnapshot,
+ scale: Double,
+ options: MenuBarDisplayOptions,
+ showWarning: Bool = false
+ ) -> NSAttributedString {
+ let result = NSMutableAttributedString(attributedString: barAttributedText(for: snapshot, scale: scale, options: options))
+ if showWarning {
+ result.append(textAttachment(" ⚠", color: Theme.color(.batteryMedium), scale: scale))
+ }
+ return result
+ }
+
+ /// Reine Text-Darstellung (ohne Symbole/Pfeile); nil, wenn keine Metriken
+ /// sichtbar sind.
+ func plainTitle(for snapshot: SolixSnapshot, options: MenuBarDisplayOptions) -> String? {
+ let parts = visibleBarMetrics(for: snapshot, options: options).map { metric in
+ barText(for: metric, snapshot: snapshot, options: options)
+ }
+ return parts.isEmpty ? nil : parts.joined(separator: separator())
+ }
+
+ /// Einträge für die zweizeilige Kompaktanzeige. Auch Energiefluss und
+ /// Status erscheinen hier — früher wurden beide stillschweigend
+ /// herausgefiltert, ihre Häkchen wirkten in der Kompaktanzeige also nicht.
+ func stackedEntries(for snapshot: SolixSnapshot, options: MenuBarDisplayOptions) -> [StackedMenuBarRenderer.Entry] {
+ visibleBarMetrics(for: snapshot, options: options)
+ .compactMap { metric -> StackedMenuBarRenderer.Entry? in
+ guard let text = stackedText(for: metric, snapshot: snapshot, options: options) else { return nil }
+ return StackedMenuBarRenderer.Entry(
+ symbolName: symbol(for: metric, snapshot: snapshot),
+ text: text,
+ role: options.showColors ? roleTag(for: metric, snapshot: snapshot) : .neutral
+ )
+ }
+ }
+
+ func symbol(for metric: BarMetric, snapshot: SolixSnapshot) -> String {
+ switch metric {
+ case .battery:
+ batterySymbol(snapshot.batteryPercent)
+ case .batteryFlow:
+ batteryFlowSymbol(snapshot.batteryWatts)
+ default:
+ metric.symbolName
+ }
+ }
+
+ func color(for metric: BarMetric, snapshot: SolixSnapshot) -> NSColor {
+ Theme.color(roleTag(for: metric, snapshot: snapshot))
+ }
+
+ func roleTag(for metric: BarMetric, snapshot: SolixSnapshot) -> ColorRole {
+ switch metric {
+ case .battery:
+ Theme.battery(percent: snapshot.batteryPercent)
+ case .solar:
+ .solar
+ case .home:
+ .load
+ case .grid:
+ Theme.grid(watts: snapshot.gridWatts)
+ case .batteryFlow:
+ Theme.batteryFlow(watts: snapshot.batteryWatts)
+ case .today:
+ .yieldToday
+ case .total:
+ .yieldTotal
+ case .status:
+ .status
+ }
+ }
+
+ func coloredSymbol(_ symbol: String, color: NSColor, accessibilityDescription: String) -> NSImage? {
+ guard let image = NSImage(systemSymbolName: symbol, accessibilityDescription: accessibilityDescription) else {
+ return nil
+ }
+ let configuration = NSImage.SymbolConfiguration(pointSize: 14, weight: .semibold)
+ let configured = image.withSymbolConfiguration(configuration) ?? image
+ return configured.tinted(color)
+ }
+
+ var textShadow: NSShadow {
+ let shadow = NSShadow()
+ shadow.shadowColor = NSColor(name: nil) { appearance in
+ Theme.usesDarkBackground(appearance)
+ ? NSColor.black.withAlphaComponent(0.85)
+ : NSColor.white.withAlphaComponent(0.90)
+ }
+ shadow.shadowBlurRadius = 1.5
+ shadow.shadowOffset = NSSize(width: 0, height: -0.5)
+ return shadow
+ }
+
+ // MARK: Aufbau der einzeiligen Darstellung
+
+ private func barAttributedText(for snapshot: SolixSnapshot, scale: Double, options: MenuBarDisplayOptions) -> NSAttributedString {
+ let result = NSMutableAttributedString()
+ let metrics = visibleBarMetrics(for: snapshot, options: options)
+ for (index, metric) in metrics.enumerated() {
+ // Ohne Farb-Option keine Rollen-Tags anhängen: die Slim-Bar löst
+ // .solixRole eigenständig in Farben auf und würde sonst doch
+ // wieder kolorieren.
+ let role: ColorRole? = options.showColors ? roleTag(for: metric, snapshot: snapshot) : nil
+ if index > 0 {
+ result.append(textAttachment(separator(scale: scale), scale: scale))
+ }
+ if options.showArrows,
+ let flow = energyFlowText(for: metric, snapshot: snapshot) {
+ result.append(textAttachment(
+ flow.text,
+ color: options.showColors ? Theme.color(flow.role) : .labelColor,
+ weight: .bold,
+ scale: scale,
+ role: options.showColors ? flow.role : nil
+ ))
+ result.append(textAttachment(" ", scale: scale))
+ }
+ if options.showSymbols,
+ let image = coloredSymbol(
+ symbol(for: metric, snapshot: snapshot),
+ color: options.showColors
+ ? color(for: metric, snapshot: snapshot)
+ : .labelColor,
+ accessibilityDescription: metric.localizedTitle
+ ) {
+ result.append(imageAttachment(image, scale: scale, role: role))
+ result.append(textAttachment(" ", scale: scale))
+ }
+ result.append(textAttachment(
+ barText(for: metric, snapshot: snapshot, options: options),
+ color: valueColor(for: metric, snapshot: snapshot, options: options),
+ scale: scale,
+ role: role
+ ))
+ }
+ return result
+ }
+
+ private func visibleBarMetrics(for snapshot: SolixSnapshot, options: MenuBarDisplayOptions) -> [BarMetric] {
+ let metrics = options.metrics.isEmpty ? [BarMetric.battery, .solar, .grid] : options.metrics
+ return metrics.filter { metric in
+ metric != .total || snapshot.totalKWh != nil
+ }
+ }
+
+ private func energyFlowText(for metric: BarMetric, snapshot: SolixSnapshot) -> (text: String, role: ColorRole)? {
+ switch metric {
+ case .solar:
+ guard let watts = snapshot.solarWatts else { return nil }
+ return watts > 0
+ ? (LocalizedText.text("↓ Erzeugt", "↓ Producing"), .solar)
+ : ("•", .neutral)
+ case .grid:
+ guard let watts = snapshot.gridWatts else { return nil }
+ if watts > 0 {
+ return (LocalizedText.text("← Bezug", "← Import"), .gridImport)
+ }
+ if watts < 0 {
+ return (LocalizedText.text("→ Einspeisen", "→ Export"), .gridExport)
+ }
+ return ("•", .neutral)
+ case .batteryFlow:
+ guard let watts = snapshot.batteryWatts else { return nil }
+ if watts > 0 {
+ return (LocalizedText.text("↓ Laden", "↓ Charging"), .batteryCharging)
+ }
+ if watts < 0 {
+ return (LocalizedText.text("↑ Entladen", "↑ Discharging"), .batteryDischarging)
+ }
+ return ("•", .neutral)
+ default:
+ return nil
+ }
+ }
+
+ // MARK: Texte und Werte
+
+ private func barText(for metric: BarMetric, snapshot: SolixSnapshot, options: MenuBarDisplayOptions) -> String {
+ switch metric {
+ case .battery:
+ formatBarMetric(metric, value: snapshot.batteryPercent.map { "\($0)%" } ?? "--%", options: options)
+ case .solar:
+ formatBarMetric(metric, value: solarValueText(for: snapshot, options: options) ?? "--W", options: options)
+ case .home:
+ formatBarMetric(metric, value: snapshot.homeWatts.map { "\($0)W" } ?? "--W", options: options)
+ case .grid:
+ formatBarMetric(metric, value: formatFlowWatts(snapshot.gridWatts, options: options) ?? "--W", options: options)
+ case .batteryFlow:
+ formatBarMetric(metric, value: formatFlowWatts(snapshot.batteryWatts, options: options) ?? "--W", options: options)
+ case .today:
+ formatBarMetric(metric, value: snapshot.todayKWh.map { String(format: "%.2fkWh", $0) } ?? "--kWh", options: options)
+ case .total:
+ formatBarMetric(metric, value: snapshot.totalKWh.map { String(format: "%.1fkWh", $0) } ?? "--kWh", options: options)
+ case .status:
+ formatBarMetric(metric, value: snapshot.status ?? "-", options: options)
+ }
+ }
+
+ private func formatBarMetric(_ metric: BarMetric, value: String, options: MenuBarDisplayOptions) -> String {
+ options.showLabels ? "\(metric.localizedShortTitle) \(value)" : value
+ }
+
+ /// PV-Wert nach Anzeige-Modus: Summe ("642W"), Einzelwerte je Eingang
+ /// ("438·204W") oder beides ("642W (438·204)"). Ohne Kanalwerte im
+ /// Snapshot bleibt es bei der Summe.
+ private func solarValueText(for snapshot: SolixSnapshot, options: MenuBarDisplayOptions) -> String? {
+ let sum = snapshot.solarWatts.map { "\($0)W" }
+ guard options.pvDisplay != .total,
+ let channels = snapshot.pvWatts, channels.count > 1 else {
+ return sum
+ }
+ let individual = channels.map(String.init).joined(separator: "·")
+ switch options.pvDisplay {
+ case .perInput:
+ return individual + "W"
+ case .both:
+ guard let sum else { return individual + "W" }
+ return "\(sum) (\(individual))"
+ case .total:
+ return sum
+ }
+ }
+
+ /// Kompaktwert für die zweizeilige Anzeige: nur Zahl + Einheit, die
+ /// Metrik-Identität trägt das Glyph. Mit Pfeil-Option zeigt der Wert
+ /// zusätzlich seine Flussrichtung — dieselbe Semantik wie einzeilig
+ /// (Solar ↓ erzeugt, Akku ↓ lädt / ↑ entlädt, Netz ← Bezug / → Einspeisen).
+ private func stackedText(for metric: BarMetric, snapshot: SolixSnapshot, options: MenuBarDisplayOptions) -> String? {
+ switch metric {
+ case .battery:
+ return snapshot.batteryPercent.map { "\($0)%" } ?? "--%"
+ case .solar:
+ guard let value = solarValueText(for: snapshot, options: options) else { return "--W" }
+ return options.showArrows && (snapshot.solarWatts ?? 0) > 0 ? "↓\(value)" : value
+ case .home:
+ return snapshot.homeWatts.map { "\($0)W" } ?? "--W"
+ case .grid:
+ guard let watts = snapshot.gridWatts else { return "--W" }
+ if options.showArrows, watts != 0 {
+ return watts > 0 ? "←\(watts)W" : "→\(abs(watts))W"
+ }
+ return "\(watts)W"
+ case .batteryFlow:
+ guard let watts = snapshot.batteryWatts else { return "--W" }
+ if options.showArrows, watts != 0 {
+ return watts > 0 ? "↓\(watts)W" : "↑\(abs(watts))W"
+ }
+ return "\(watts > 0 ? "+" : "")\(watts)W"
+ case .today:
+ return snapshot.todayKWh.map { String(format: "%.1fk", $0) }
+ case .total:
+ return snapshot.totalKWh.map { String(format: "%.0fk", $0) }
+ case .status:
+ return snapshot.status ?? "-"
+ }
+ }
+
+ private func formatSignedWatts(_ value: Int?) -> String? {
+ guard let value else { return nil }
+ return value > 0 ? "+\(value) W" : "\(value) W"
+ }
+
+ private func formatFlowWatts(_ value: Int?, options: MenuBarDisplayOptions) -> String? {
+ guard let value else { return nil }
+ return options.showArrows ? "\(abs(value)) W" : formatSignedWatts(value)
+ }
+
+ // MARK: Symbole und Farben
+
+ private func batterySymbol(_ percent: Int?) -> String {
+ guard let percent else { return "battery.75percent" }
+ if percent <= 20 { return "battery.25percent" }
+ if percent <= 60 { return "battery.50percent" }
+ return "battery.100percent"
+ }
+
+ private func batteryFlowSymbol(_ watts: Int?) -> String {
+ guard let watts else { return "bolt.fill" }
+ return watts >= 0 ? "arrow.down.circle.fill" : "arrow.up.circle.fill"
+ }
+
+ private func valueColor(for metric: BarMetric, snapshot: SolixSnapshot, options: MenuBarDisplayOptions) -> NSColor {
+ guard options.showColors else { return .labelColor }
+ if metric == .battery {
+ guard snapshot.batteryPercent != nil else { return .secondaryLabelColor }
+ return Theme.color(Theme.battery(percent: snapshot.batteryPercent))
+ }
+ switch metric {
+ case .solar:
+ return snapshot.solarWatts == nil ? .secondaryLabelColor : Theme.color(.solar)
+ case .home:
+ return snapshot.homeWatts == nil ? .secondaryLabelColor : Theme.color(.load)
+ case .grid:
+ guard let watts = snapshot.gridWatts, watts != 0 else { return .secondaryLabelColor }
+ return Theme.color(Theme.grid(watts: watts))
+ case .batteryFlow:
+ guard let watts = snapshot.batteryWatts, watts != 0 else { return .secondaryLabelColor }
+ return Theme.color(Theme.batteryFlow(watts: watts))
+ default:
+ return .labelColor
+ }
+ }
+
+ // MARK: Bausteine
+
+ private func separator(scale: Double? = nil) -> String {
+ (scale ?? AppSettings.shared.menuBarScale) < 0.95 ? " " : " "
+ }
+
+ private func imageAttachment(_ image: NSImage, scale: Double, role: ColorRole? = nil) -> NSAttributedString {
+ let attachment = NSTextAttachment()
+ let height = round(13 * scale)
+ // Seitenverhältnis erhalten: breite Symbole (Batterie) nicht ins
+ // Quadrat stauchen.
+ let aspect = image.size.height > 0 ? image.size.width / image.size.height : 1
+ let width = round(height * aspect)
+ image.size = NSSize(width: width, height: height)
+ attachment.image = image
+ attachment.bounds = NSRect(x: 0, y: -2, width: width, height: height)
+ let result = NSMutableAttributedString(attributedString: NSAttributedString(attachment: attachment))
+ if let role {
+ result.addAttribute(.solixRole, value: role.rawValue, range: NSRange(location: 0, length: result.length))
+ }
+ return result
+ }
+
+ private func textAttachment(_ string: String, color: NSColor = .labelColor, weight: NSFont.Weight = .medium, scale: Double, role: ColorRole? = nil) -> NSAttributedString {
+ // Einheitliche Schriftgröße für alle Läufe.
+ var attributes: [NSAttributedString.Key: Any] = [
+ .font: NSFont.monospacedDigitSystemFont(ofSize: round(13 * scale), weight: weight),
+ .foregroundColor: color,
+ .shadow: textShadow
+ ]
+ if let role {
+ attributes[.solixRole] = role.rawValue
+ }
+ return NSAttributedString(string: string, attributes: attributes)
+ }
+}
diff --git a/Sources/SolixBarKit/MetricLocalization.swift b/Sources/SolixBarKit/MetricLocalization.swift
new file mode 100644
index 0000000..fa0e67a
--- /dev/null
+++ b/Sources/SolixBarKit/MetricLocalization.swift
@@ -0,0 +1,83 @@
+import Foundation
+
+/// Zentrale, sprachbewusste Namen für Metriken — ersetzt vier verstreute
+/// switch-Blöcke (StatusController, Settings, Header, Tooltips), die bei
+/// jeder Textänderung einzeln nachgezogen werden mussten.
+/// Hinweis: Ein Apple String Catalog folgt der System-Locale; SolixBar hat
+/// einen eigenen In-App-Sprachschalter, daher bleibt dieses Modell.
+@MainActor
+extension BarMetric {
+ var localizedTitle: String {
+ switch self {
+ case .battery: LocalizedText.text("Batterie", "Battery")
+ case .solar: "PV"
+ case .home: LocalizedText.text("Hauslast", "Home Load")
+ case .grid: LocalizedText.text("Netzbezug", "Grid Import")
+ case .batteryFlow: LocalizedText.text("Akku-Fluss", "Battery Flow")
+ case .today: LocalizedText.text("Heutiger Ertrag", "Today's Yield")
+ case .total: LocalizedText.text("Gesamtertrag", "Total Yield")
+ case .status: "Status"
+ }
+ }
+
+ var localizedShortTitle: String {
+ switch self {
+ case .battery: LocalizedText.text("Akku", "Batt")
+ case .solar: "PV"
+ case .home: LocalizedText.text("Last", "Load")
+ case .grid: LocalizedText.text("Netz", "Grid")
+ case .batteryFlow: LocalizedText.text("Fluss", "Flow")
+ case .today: LocalizedText.text("Ertrag", "Yield")
+ case .total: LocalizedText.text("Gesamt", "Total")
+ case .status: "Status"
+ }
+ }
+
+ var localizedTooltip: String {
+ switch self {
+ case .battery: LocalizedText.text(
+ "Zeigt den aktuellen Akkustand in Prozent.",
+ "Shows the current battery level in percent.")
+ case .solar: LocalizedText.text(
+ "Zeigt die aktuelle Solarleistung in Watt.",
+ "Shows the current solar output in watts.")
+ case .home: LocalizedText.text(
+ "Zeigt die aktuelle echte Hauslast in Watt.",
+ "Shows the current real home load in watts.")
+ case .grid: LocalizedText.text(
+ "Zeigt den aktuellen Netzbezug oder die Einspeisung in Watt.",
+ "Shows current grid import or export in watts.")
+ case .batteryFlow: LocalizedText.text(
+ "Zeigt, ob der Akku gerade lädt oder entlädt.",
+ "Shows whether the battery is charging or discharging.")
+ case .today: LocalizedText.text(
+ "Zeigt den heutigen Solarertrag in kWh.",
+ "Shows today's solar yield in kWh.")
+ case .total: LocalizedText.text(
+ "Zeigt den gesamten bisher gemessenen Solarertrag in kWh.",
+ "Shows the total measured solar yield in kWh.")
+ case .status: LocalizedText.text(
+ "Zeigt den aktuellen Status der Datenquelle.",
+ "Shows the current data-source status.")
+ }
+ }
+}
+
+@MainActor
+extension GraphMetric {
+ var localizedTitle: String {
+ switch self {
+ case .battery: LocalizedText.text("Akku", "Battery")
+ case .solar: "Solar"
+ case .grid: LocalizedText.text("Netzbezug", "Grid import")
+ }
+ }
+
+ var localizedShortTitle: String {
+ switch self {
+ case .battery: LocalizedText.text("Akku", "Battery")
+ case .solar: "Solar"
+ case .grid: LocalizedText.text("Netz", "Grid")
+ }
+ }
+}
diff --git a/Sources/SolixBarKit/MetricOrderListView.swift b/Sources/SolixBarKit/MetricOrderListView.swift
new file mode 100644
index 0000000..a1fa526
--- /dev/null
+++ b/Sources/SolixBarKit/MetricOrderListView.swift
@@ -0,0 +1,166 @@
+import AppKit
+
+/// Sortierbare Metrik-Liste für die Einstellungen: Häkchen wählt die Werte
+/// aus, Ziehen einer Zeile ändert die Reihenfolge. Auswahl UND Reihenfolge
+/// ergeben zusammen `result` — die Arrays in AppSettings sind geordnet.
+@MainActor
+final class MetricOrderListView: NSView {
+ var onChange: (() -> Void)?
+
+ private(set) var orderedMetrics: [BarMetric] = BarMetric.allCases
+ private(set) var selected: Set = []
+
+ private let tableView = NSTableView()
+ private let scrollView = NSScrollView()
+ private static let dragType = NSPasteboard.PasteboardType("local.codex.SolixBar.metricRow")
+
+ /// Ausgegraut, solange die Liste einer anderen folgt ("Folgt ..."-Modus).
+ var isListEnabled = true {
+ didSet {
+ tableView.isEnabled = isListEnabled
+ alphaValue = isListEnabled ? 1 : 0.5
+ }
+ }
+
+ override init(frame frameRect: NSRect) {
+ super.init(frame: frameRect)
+ buildView()
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ /// Setzt Zustand ohne onChange auszulösen: ausgewählte Metriken in
+ /// gespeicherter Reihenfolge zuerst, alle übrigen (abgewählt) dahinter.
+ func load(order: [BarMetric], selected: Set) {
+ var full = order.filter { BarMetric.allCases.contains($0) }
+ for metric in BarMetric.allCases where !full.contains(metric) {
+ full.append(metric)
+ }
+ orderedMetrics = full
+ self.selected = selected
+ tableView.reloadData()
+ }
+
+ /// Geordnete Auswahl — das, was in AppSettings gespeichert wird.
+ var result: [BarMetric] {
+ orderedMetrics.filter(selected.contains)
+ }
+
+ func reloadTitles() {
+ tableView.reloadData()
+ }
+
+ private func buildView() {
+ tableView.dataSource = self
+ tableView.delegate = self
+ tableView.headerView = nil
+ tableView.rowHeight = 24
+ tableView.allowsMultipleSelection = false
+ tableView.selectionHighlightStyle = .none
+ tableView.backgroundColor = .clear
+ tableView.intercellSpacing = NSSize(width: 0, height: 2)
+ let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("metric"))
+ column.resizingMask = .autoresizingMask
+ tableView.addTableColumn(column)
+ tableView.registerForDraggedTypes([Self.dragType])
+
+ scrollView.documentView = tableView
+ scrollView.hasVerticalScroller = false
+ scrollView.borderType = .bezelBorder
+ scrollView.drawsBackground = false
+ scrollView.translatesAutoresizingMaskIntoConstraints = false
+ addSubview(scrollView)
+
+ let height = CGFloat(BarMetric.allCases.count) * (tableView.rowHeight + 2) + 6
+ NSLayoutConstraint.activate([
+ scrollView.topAnchor.constraint(equalTo: topAnchor),
+ scrollView.leadingAnchor.constraint(equalTo: leadingAnchor),
+ scrollView.trailingAnchor.constraint(equalTo: trailingAnchor),
+ scrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
+ scrollView.heightAnchor.constraint(equalToConstant: height),
+ scrollView.widthAnchor.constraint(equalToConstant: 240)
+ ])
+ }
+
+ @objc private func checkboxToggled(_ sender: NSButton) {
+ // Zeile über die aktuelle Position auflösen — nach einem Drag stimmen
+ // gemerkte Indizes nicht mehr.
+ let row = tableView.row(for: sender)
+ guard row >= 0, row < orderedMetrics.count else { return }
+ let metric = orderedMetrics[row]
+ if sender.state == .on {
+ selected.insert(metric)
+ } else {
+ selected.remove(metric)
+ }
+ onChange?()
+ }
+}
+
+extension MetricOrderListView: NSTableViewDataSource, NSTableViewDelegate {
+ func numberOfRows(in tableView: NSTableView) -> Int {
+ orderedMetrics.count
+ }
+
+ func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
+ let metric = orderedMetrics[row]
+ // Zellen bewusst nicht wiederverwenden (8 Zeilen): target/action und
+ // Zustand werden so bei jedem Aufbau frisch gesetzt.
+ let checkbox = NSButton(
+ checkboxWithTitle: metric.localizedTitle,
+ target: self,
+ action: #selector(checkboxToggled(_:))
+ )
+ checkbox.state = selected.contains(metric) ? .on : .off
+ checkbox.isEnabled = isListEnabled
+ checkbox.toolTip = metric.localizedTooltip
+
+ let grip = NSImageView()
+ grip.image = NSImage(systemSymbolName: "line.3.horizontal", accessibilityDescription: nil)
+ grip.contentTintColor = .tertiaryLabelColor
+
+ let cell = NSStackView(views: [checkbox, NSView(), grip])
+ cell.orientation = .horizontal
+ cell.spacing = 4
+ cell.edgeInsets = NSEdgeInsets(top: 0, left: 6, bottom: 0, right: 8)
+ return cell
+ }
+
+ func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> NSPasteboardWriting? {
+ guard isListEnabled else { return nil }
+ let item = NSPasteboardItem()
+ item.setString(String(row), forType: Self.dragType)
+ return item
+ }
+
+ func tableView(
+ _ tableView: NSTableView,
+ validateDrop info: NSDraggingInfo,
+ proposedRow row: Int,
+ proposedDropOperation dropOperation: NSTableView.DropOperation
+ ) -> NSDragOperation {
+ guard isListEnabled, dropOperation == .above else { return [] }
+ return .move
+ }
+
+ func tableView(
+ _ tableView: NSTableView,
+ acceptDrop info: NSDraggingInfo,
+ row: Int,
+ dropOperation: NSTableView.DropOperation
+ ) -> Bool {
+ guard let raw = info.draggingPasteboard.string(forType: Self.dragType),
+ let sourceRow = Int(raw),
+ sourceRow >= 0, sourceRow < orderedMetrics.count else { return false }
+ var target = row
+ if sourceRow < target { target -= 1 }
+ guard target != sourceRow else { return true }
+ let metric = orderedMetrics.remove(at: sourceRow)
+ orderedMetrics.insert(metric, at: target)
+ tableView.reloadData()
+ onChange?()
+ return true
+ }
+}
diff --git a/Sources/SolixBarKit/NSImageTint.swift b/Sources/SolixBarKit/NSImageTint.swift
new file mode 100644
index 0000000..46dd4e8
--- /dev/null
+++ b/Sources/SolixBarKit/NSImageTint.swift
@@ -0,0 +1,19 @@
+import AppKit
+
+extension NSImage {
+ /// Auflösungsunabhängiges Tinting: zeichnet per DrawingHandler im
+ /// Zielkontext (Retina-scharf). Das frühere lockFocus-Tinting rasterte
+ /// mit 1x und wirkte skaliert unscharf.
+ func tinted(_ color: NSColor) -> NSImage {
+ let base = self
+ let result = NSImage(size: size, flipped: false) { rect in
+ base.draw(in: rect, from: .zero, operation: .sourceOver, fraction: 1)
+ color.set()
+ rect.fill(using: .sourceAtop)
+ return true
+ }
+ result.isTemplate = false
+ result.accessibilityDescription = accessibilityDescription
+ return result
+ }
+}
diff --git a/Sources/SolixBarKit/NotificationManager.swift b/Sources/SolixBarKit/NotificationManager.swift
new file mode 100644
index 0000000..3d302e9
--- /dev/null
+++ b/Sources/SolixBarKit/NotificationManager.swift
@@ -0,0 +1,103 @@
+import AppKit
+import UserNotifications
+
+/// Dünne Hülle um UNUserNotificationCenter für Update-Hinweise und Warnungen.
+///
+/// UserNotifications setzt ein (mindestens ad-hoc-)signiertes App-Bundle
+/// voraus; aus einem nackten Executable (`swift run`, Tests) crasht schon der
+/// Zugriff auf `UNUserNotificationCenter.current()`. Deshalb kapselt diese
+/// Klasse jeden Zugriff hinter `isAvailable` und degradiert still — die
+/// Menü-Einträge der Aufrufer bleiben der immer funktionierende Rückfallweg.
+@MainActor
+final class NotificationManager: NSObject {
+ static let shared = NotificationManager()
+
+ private let openURLActionIdentifier = "solixbar.open-url"
+ private let categoryIdentifier = "solixbar.general"
+ private var didConfigure = false
+
+ var isAvailable: Bool {
+ Bundle.main.bundleIdentifier != nil
+ }
+
+ /// Zeigt eine Benachrichtigung; gleiche `id` ersetzt eine noch sichtbare.
+ /// `url` wird beim Klick auf Benachrichtigung oder Aktion geöffnet.
+ func post(id: String, title: String, body: String, url: URL? = nil) {
+ guard isAvailable else {
+ AppLogger.info("Notification suppressed (unbundled): \(title)")
+ return
+ }
+ configureIfNeeded()
+ let center = UNUserNotificationCenter.current()
+ center.requestAuthorization(options: [.alert, .sound]) { granted, error in
+ if let error {
+ AppLogger.error("Notification authorization failed: \(error.localizedDescription)")
+ return
+ }
+ guard granted else {
+ AppLogger.info("Notifications not granted; relying on menu fallback.")
+ return
+ }
+ let content = UNMutableNotificationContent()
+ content.title = title
+ content.body = body
+ content.sound = .default
+ content.categoryIdentifier = "solixbar.general"
+ if let url {
+ content.userInfo = ["url": url.absoluteString]
+ }
+ center.add(UNNotificationRequest(identifier: id, content: content, trigger: nil)) { error in
+ if let error {
+ AppLogger.error("Notification delivery failed: \(error.localizedDescription)")
+ }
+ }
+ }
+ }
+
+ /// Entfernt eine zugestellte Benachrichtigung wieder (z. B. wenn sich eine
+ /// Warnbedingung von selbst erledigt hat).
+ func withdraw(id: String) {
+ guard isAvailable else { return }
+ UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: [id])
+ }
+
+ private func configureIfNeeded() {
+ guard !didConfigure else { return }
+ didConfigure = true
+ let center = UNUserNotificationCenter.current()
+ center.delegate = self
+ let open = UNNotificationAction(
+ identifier: openURLActionIdentifier,
+ title: LocalizedText.text("Öffnen", "Open"),
+ options: [.foreground]
+ )
+ center.setNotificationCategories([
+ UNNotificationCategory(identifier: categoryIdentifier, actions: [open], intentIdentifiers: [])
+ ])
+ }
+}
+
+extension NotificationManager: UNUserNotificationCenterDelegate {
+ /// Auch anzeigen, wenn die App "im Vordergrund" ist (Menüleisten-App).
+ nonisolated func userNotificationCenter(
+ _ center: UNUserNotificationCenter,
+ willPresent notification: UNNotification,
+ withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
+ ) {
+ completionHandler([.banner, .sound])
+ }
+
+ nonisolated func userNotificationCenter(
+ _ center: UNUserNotificationCenter,
+ didReceive response: UNNotificationResponse,
+ withCompletionHandler completionHandler: @escaping () -> Void
+ ) {
+ if let urlString = response.notification.request.content.userInfo["url"] as? String,
+ let url = URL(string: urlString) {
+ DispatchQueue.main.async {
+ NSWorkspace.shared.open(url)
+ }
+ }
+ completionHandler()
+ }
+}
diff --git a/Sources/SolixBar/RoundedIcon.swift b/Sources/SolixBarKit/RoundedIcon.swift
similarity index 100%
rename from Sources/SolixBar/RoundedIcon.swift
rename to Sources/SolixBarKit/RoundedIcon.swift
diff --git a/Sources/SolixBarKit/SettingsHelp.swift b/Sources/SolixBarKit/SettingsHelp.swift
new file mode 100644
index 0000000..900a25f
--- /dev/null
+++ b/Sources/SolixBarKit/SettingsHelp.swift
@@ -0,0 +1,355 @@
+import Foundation
+
+/// Ausführliche Hilfetexte für die ?-Knöpfe der Einstellungen.
+///
+/// Der Hover-Tooltip bleibt der kurze Hinweis; ein Klick auf das ?
+/// zeigt den ausführlichen Text aus diesem Katalog. Nachgeschlagen wird
+/// über den kurzen Text (deutsch oder englisch) — ändert sich ein
+/// Tooltip, fällt der Knopf still auf den kurzen Text zurück, es geht
+/// also nie Information verloren.
+enum SettingsHelp {
+ @MainActor
+ static func extended(for shortText: String) -> String? {
+ for entry in entries where entry.keys.contains(shortText) {
+ return LocalizedText.text(entry.de, entry.en)
+ }
+ return nil
+ }
+
+ private struct Entry {
+ let keys: [String]
+ let de: String
+ let en: String
+ }
+
+ private static let entries: [Entry] = [
+ // MARK: Datenquelle
+ Entry(
+ keys: [
+ "Legt fest, woher SolixBar die Werte lädt.",
+ "Legt fest, woher SolixBar die Werte lädt. \"SOLIX-Konto (direkt)\" fragt Anker mit Mail und Passwort direkt ab. \"Demo (Warnungs-Test)\" spielt ein gerafftes Szenario ab (Akku fällt, PV-Eingang stirbt, kompletter Einbruch), damit aktivierte Warnungen innerhalb weniger Minuten wirklich feuern.",
+ "Controls where SolixBar loads its values from. \"SOLIX account (direct)\" queries Anker directly with email and password. \"Demo (warning test)\" plays an accelerated scenario (battery drops, one PV input dies, full collapse) so enabled warnings actually fire within a few minutes."
+ ],
+ de: """
+ Hier wählst du, woher SolixBar seine Werte bekommt:
+
+ • SOLIX-Konto (direkt) — der empfohlene Weg. SolixBar fragt Anker direkt mit deiner Mail und deinem Passwort ab. Es muss nichts installiert werden.
+ • Demo — erfundene Beispielwerte, um die Anzeige gefahrlos auszuprobieren.
+ • Demo (Warnungs-Test) — spielt in wenigen Minuten ein Szenario durch (Akku fällt, ein PV-Eingang stirbt, kompletter Einbruch), damit du aktivierte Warnungen wirklich einmal feuern siehst.
+ • Lokaler JSON-Befehl — für Fortgeschrittene: ein eigenes Programm liefert die Werte.
+ • JSON-URL — die Werte kommen von einer Web-Adresse, z. B. aus deiner Hausautomation.
+ """,
+ en: """
+ This selects where SolixBar gets its values from:
+
+ • SOLIX account (direct) — the recommended way. SolixBar queries Anker directly with your email and password. Nothing needs to be installed.
+ • Demo — made-up sample values to safely explore the display.
+ • Demo (warning test) — plays through a scenario within minutes (battery drops, one PV input dies, full collapse) so you can watch enabled warnings actually fire.
+ • Local JSON command — for advanced users: your own program supplies the values.
+ • JSON URL — values come from a web address, e.g. your home automation.
+ """
+ ),
+ Entry(
+ keys: ["E-Mail-Adresse deines Anker/SOLIX-Kontos."],
+ de: "Die Mail-Adresse, mit der du dich auch in der Anker-App anmeldest. Sie wird nur auf deinem Mac gespeichert (in einer lokalen Datei mit strengen Dateirechten) und ausschliesslich an Anker übertragen, um deine Werte abzurufen.",
+ en: "The email address you also use to sign in to the Anker app. It is stored only on your Mac (in a local file with strict permissions) and transmitted exclusively to Anker to fetch your values."
+ ),
+ Entry(
+ keys: ["Passwort deines Anker/SOLIX-Kontos. Wird sicher im macOS-Schlüsselbund gespeichert und nie hochgeladen."],
+ de: "Das Passwort deines Anker-Kontos. Es landet ausschliesslich im macOS-Schlüsselbund — dort, wo macOS auch deine anderen Passwörter schützt — und wird nur zur Anmeldung an Anker geschickt, nie an andere Server. Wenn du das Passwort in der Anker-App änderst, trage es hier neu ein.",
+ en: "Your Anker account password. It is kept exclusively in the macOS Keychain — where macOS protects your other passwords too — and is only sent to Anker to sign in, never to any other server. If you change the password in the Anker app, re-enter it here."
+ ),
+ Entry(
+ keys: ["Land deines Anker-Kontos, normalerweise DE."],
+ de: "Der Zwei-Buchstaben-Ländercode deines Anker-Kontos, z. B. DE für Deutschland oder AT für Österreich. Anker betreibt je Region eigene Server — mit dem falschen Land schlägt die Anmeldung fehl. Im Zweifel: das Land, in dem du das Konto angelegt hast.",
+ en: "The two-letter country code of your Anker account, e.g. DE for Germany or AT for Austria. Anker runs separate servers per region — the wrong country makes the sign-in fail. When in doubt: the country where you created the account."
+ ),
+ Entry(
+ keys: ["Optionaler Korrekturwert für den heutigen Ertrag in kWh, falls Anker heute 0 kWh meldet. SolixBar zählt ab diesem Wert weiter."],
+ de: "Normalerweise leer lassen. Nur wenn Anker für heute fälschlich 0 kWh meldet (das passiert bei manchen Anlagen), kannst du hier den echten heutigen Ertrag aus der Anker-App eintragen. SolixBar zählt dann ab diesem Wert weiter. Der Wert gilt nur für den heutigen Tag und läuft danach automatisch aus.",
+ en: "Normally leave this empty. Only if Anker wrongly reports 0 kWh for today (this happens with some systems), enter today's real yield from the Anker app here. SolixBar then keeps counting from that value. It only applies to today and expires automatically afterwards."
+ ),
+ Entry(
+ keys: [
+ "Optionaler Startwert für den Gesamtertrag. Ohne API-Gesamtwert kumuliert SolixBar alle fortlaufenden Solarmessungen lokal.",
+ "Optional starting value for total yield. Without an API total, SolixBar locally accumulates all continuous solar measurements.",
+ "Setzt optional den Gesamtertrag aus der Anker-App als Startwert. Ohne API-Gesamtwert zählt SolixBar alle fortlaufenden Messungen zusammen.",
+ "Optionally sets the Anker app total as a starting value. Without an API total, SolixBar adds up all continuous measurements."
+ ],
+ de: "Normalerweise leer lassen. Liefert Anker keinen Gesamtertrag über die Schnittstelle, summiert SolixBar deine Solarmessungen selbst auf — beginnend bei 0. Damit die Zählung nicht bei 0 startet, kannst du hier einmalig den Gesamtertrag aus der Anker-App als Startwert eintragen; SolixBar zählt dann dauerhaft ab diesem Wert weiter.",
+ en: "Normally leave this empty. If Anker does not provide a total yield via its interface, SolixBar accumulates your solar measurements itself — starting at 0. To avoid starting at 0, you can enter the total from the Anker app once as a starting value; SolixBar then keeps counting from it permanently."
+ ),
+ Entry(
+ keys: ["Führt einen lokalen Befehl aus und liest dessen JSON-Ausgabe."],
+ de: "Für Fortgeschrittene: SolixBar führt bei jeder Aktualisierung diesen Shell-Befehl aus und liest die Werte aus seiner JSON-Ausgabe (Felder wie batteryPercent, solarWatts, homeWatts, updatedAt — das vollständige Format steht im README). So kannst du eigene Skripte oder andere Datenquellen anbinden. Für den normalen SOLIX-Abruf brauchst du das nicht — nimm dafür \"SOLIX-Konto (direkt)\".",
+ en: "For advanced users: on every refresh SolixBar runs this shell command and reads the values from its JSON output (fields like batteryPercent, solarWatts, homeWatts, updatedAt — the full format is in the README). This lets you hook up your own scripts or other data sources. For the normal SOLIX access you don't need this — use \"SOLIX account (direct)\" instead.",
+ ),
+ Entry(
+ keys: ["Lädt die Werte von einer JSON-Adresse."],
+ de: "SolixBar lädt die Werte bei jeder Aktualisierung von dieser HTTP-Adresse, z. B. von einer Bridge in deiner Hausautomation (Home Assistant, ioBroker …) oder einem eigenen kleinen Server. Die Antwort muss ein JSON-Objekt mit Feldern wie batteryPercent, solarWatts und updatedAt sein — das vollständige Format steht im README.",
+ en: "SolixBar fetches the values from this HTTP address on every refresh, e.g. from a bridge in your home automation (Home Assistant, ioBroker …) or a small server of your own. The response must be a JSON object with fields like batteryPercent, solarWatts, and updatedAt — the full format is in the README."
+ ),
+ Entry(
+ keys: ["Zeit zwischen zwei Aktualisierungen in Sekunden.", "Legt fest, wie oft neue Daten geholt werden."],
+ de: "So viele Sekunden wartet SolixBar zwischen zwei Abrufen (mindestens 60). Kürzer heisst aktuellere Werte, aber mehr Anfragen an Anker. 300 Sekunden (5 Minuten) sind ein guter Alltagswert. Schlagen Abrufe fehl, verdoppelt SolixBar den Abstand automatisch bis maximal 30 Minuten und kehrt beim ersten Erfolg zu deinem Intervall zurück.",
+ en: "How many seconds SolixBar waits between two refreshes (at least 60). Shorter means fresher values but more requests to Anker. 300 seconds (5 minutes) is a good everyday value. If refreshes fail, SolixBar automatically doubles the delay up to 30 minutes and returns to your interval on the first success."
+ ),
+
+ // MARK: Menüleiste
+ Entry(
+ keys: ["Zeigt oder versteckt das SolixBar-Symbol in der Menüleiste."],
+ de: "Zeigt links neben den Werten das kleine Blitz-Symbol von SolixBar. Ausgeschaltet sparst du ein paar Pixel Platz in der Menüleiste — die Werte bleiben natürlich sichtbar.",
+ en: "Shows SolixBar's small bolt icon to the left of the values. Turning it off saves a few pixels of menu bar space — the values of course stay visible."
+ ),
+ Entry(
+ keys: ["Zeigt die Werte in zwei kompakten Zeilen übereinander — halbe Breite bei gleicher Information, praktisch auf MacBooks mit Notch."],
+ de: "Ordnet die Werte in zwei kleinen Zeilen übereinander statt nebeneinander. Die Anzeige wird dadurch nur halb so breit — bei gleicher Information. Besonders praktisch auf MacBooks mit Notch, wo Platz in der Menüleiste knapp ist. Welche Werte in der Kompaktansicht erscheinen, stellst du in der Liste darunter ein.",
+ en: "Arranges the values in two small stacked lines instead of side by side. The display becomes only half as wide — with the same information. Especially handy on MacBooks with a notch, where menu bar space is tight. Which values appear in the compact view is configured in the list below."
+ ),
+ Entry(
+ keys: ["Zeigt kurze Namen wie Akku oder Solar vor den Zahlen.", "Shows short names like Battery or Solar before the numbers."],
+ de: "Stellt kurze Namen wie \"Akku\" oder \"PV\" vor die Zahlen, damit sofort klar ist, was welcher Wert bedeutet. Ausgeschaltet stehen nur die Zahlen da — schmaler, aber du musst die Reihenfolge im Kopf haben.",
+ en: "Puts short names like \"Akku\" or \"PV\" in front of the numbers so it's immediately clear what each value means. Turned off, only the numbers remain — narrower, but you need to remember the order."
+ ),
+ Entry(
+ keys: ["Zeigt farbige Symbole direkt vor den Menüleistenwerten."],
+ de: "Zeigt vor jedem Wert ein kleines farbiges Symbol (Batterie, Sonne, Haus, Netz). Die Werte lassen sich damit auf einen Blick zuordnen, ohne Text lesen zu müssen — eine gute platzsparende Alternative zu den Bezeichnungen.",
+ en: "Shows a small colored symbol (battery, sun, house, grid) before each value. You can identify the values at a glance without reading text — a good space-saving alternative to the labels."
+ ),
+ Entry(
+ keys: ["Färbt Werte und Symbole nach ihrer Bedeutung (Akku grün, Solar gelb, Netzbezug rot, Einspeisung violett). Ohne Farben bleibt die Anzeige einfarbig."],
+ de: "Färbt die Werte nach ihrer Bedeutung: Akku grün, Solar gelb, Netzbezug rot, Einspeisung violett. So erkennst du z. B. teuren Netzbezug sofort an der Farbe. Ausgeschaltet bleibt alles dezent einfarbig wie die übrige Menüleiste.",
+ en: "Colors the values by meaning: battery green, solar yellow, grid import red, feed-in purple. That way you spot e.g. expensive grid import instantly by color. Turned off, everything stays subtly monochrome like the rest of the menu bar."
+ ),
+ Entry(
+ keys: ["Zeigt Richtungspfeile und Begriffe wie Laden, Entladen, Bezug und Einspeisen; Wattwerte erscheinen dann ohne Vorzeichen."],
+ de: "Zeigt zu Akku und Netz die Richtung des Energieflusses: Pfeile und Begriffe wie \"Laden\", \"Entladen\", \"Bezug\" oder \"Einspeisen\". Die Wattwerte erscheinen dann ohne Plus/Minus — die Richtung steckt ja schon im Begriff. Ausgeschaltet zeigen Vorzeichen die Richtung an (z. B. -86 W = Einspeisung).",
+ en: "Shows the direction of energy flow for battery and grid: arrows and terms like \"charging\", \"discharging\", \"import\", or \"feed-in\". Watt values then appear without plus/minus — the direction is already in the term. Turned off, signs indicate the direction (e.g. -86 W = feed-in)."
+ ),
+ Entry(
+ keys: [
+ "PV-Wert in der Menüleiste: Summe (\"642W\"), Einzelwerte (\"438·204W\") oder beides (\"642W (438·204)\") — gilt für einzeilige und Kompaktansicht. Einzelwerte brauchen Kanal-Reporting (Solarbank 2/3).",
+ "PV value in the menu bar: total (\"642W\"), individual inputs (\"438·204W\"), or both (\"642W (438·204)\") — applies to single-line and compact views. Individual values require channel reporting (Solarbank 2/3)."
+ ],
+ de: "Bestimmt, wie der Solarwert in der Menüleiste erscheint:\n\n• Gesamt — nur die Summe, z. B. \"642W\".\n• Einzeln — jeder Modul-Eingang für sich, z. B. \"438·204W\". So siehst du sofort, wenn ein Modul schwächelt.\n• Beides — Summe plus Einzelwerte, z. B. \"642W (438·204)\".\n\nEinzelwerte gibt es nur, wenn deine Solarbank die Eingänge einzeln meldet (Solarbank 2 und 3); ältere Modelle zeigen immer die Summe.",
+ en: "Determines how the solar value appears in the menu bar:\n\n• Total — just the sum, e.g. \"642W\".\n• Individual — each panel input separately, e.g. \"438·204W\". You instantly see when one panel underperforms.\n• Both — sum plus individual values, e.g. \"642W (438·204)\".\n\nIndividual values are only available if your Solarbank reports its inputs separately (Solarbank 2 and 3); older models always show the total."
+ ),
+ Entry(
+ keys: ["Passt die Größe der Menüleistenanzeige an."],
+ de: "Vergrössert oder verkleinert Text und Symbole der Menüleistenanzeige stufenlos. Kleiner spart Platz neben der Notch, grösser liest sich leichter — probiere es einfach aus, die Vorschau unten zeigt sofort das Ergebnis.",
+ en: "Scales the menu bar display's text and symbols up or down continuously. Smaller saves space next to the notch, larger is easier to read — just try it, the preview below shows the result immediately."
+ ),
+
+ // MARK: Abgedockte Leiste
+ Entry(
+ keys: ["Nutzt die zweizeilige Kompaktanzeige auch in der abgedockten Leiste — macht sie etwa halb so lang."],
+ de: "Ordnet die Werte auch in der abgedockten Leiste in zwei kompakten Zeilen übereinander — die Leiste wird dadurch etwa halb so lang. Welche Werte dort erscheinen, stellst du in der Liste darunter ein.",
+ en: "Arranges the values in the detached bar in two compact stacked lines as well — making the bar roughly half as long. Which values appear there is configured in the list below."
+ ),
+ Entry(
+ keys: ["Zeigt das Blitz-Symbol links in der Leiste.", "Shows the bolt glyph at the left of the bar."],
+ de: "Zeigt das kleine Blitz-Symbol von SolixBar am linken Rand der abgedockten Leiste. Ausgeschaltet beginnt die Leiste direkt mit den Werten.",
+ en: "Shows SolixBar's small bolt glyph at the left edge of the detached bar. Turned off, the bar starts directly with the values."
+ ),
+ Entry(
+ keys: ["Zeigt farbige Symbole direkt vor den Werten.", "Shows colored symbols right before the values."],
+ de: "Zeigt vor jedem Wert der abgedockten Leiste ein kleines farbiges Symbol (Batterie, Sonne, Haus, Netz) — so ordnest du die Werte auf einen Blick zu.",
+ en: "Shows a small colored symbol (battery, sun, house, grid) before each value in the detached bar — so you can identify the values at a glance."
+ ),
+ Entry(
+ keys: ["Richtungspfeile und Begriffe wie Laden oder Bezug in der Leiste; Wattwerte ohne Vorzeichen.", "Direction arrows and terms like charging or import in the bar; watt values without sign."],
+ de: "Zeigt in der abgedockten Leiste die Richtung des Energieflusses mit Pfeilen und Begriffen wie \"Laden\" oder \"Bezug\". Die Wattwerte erscheinen dann ohne Plus/Minus. Ausgeschaltet zeigen Vorzeichen die Richtung an.",
+ en: "Shows the direction of energy flow in the detached bar with arrows and terms like \"charging\" or \"import\". Watt values then appear without plus/minus. Turned off, signs indicate the direction."
+ ),
+ Entry(
+ keys: ["Färbt Werte und Symbole der Leiste nach ihrer Bedeutung.", "Colors the bar's values and symbols by meaning."],
+ de: "Färbt die Werte der abgedockten Leiste nach ihrer Bedeutung: Akku grün, Solar gelb, Netzbezug rot, Einspeisung violett. Ausgeschaltet bleibt die Leiste dezent einfarbig.",
+ en: "Colors the detached bar's values by meaning: battery green, solar yellow, grid import red, feed-in purple. Turned off, the bar stays subtly monochrome."
+ ),
+ Entry(
+ keys: ["Fixiert die abgedockte Leiste, damit sie nicht versehentlich verschoben wird."],
+ de: "Verankert die abgedockte Leiste an ihrer Position, damit du sie beim Klicken nicht versehentlich verschiebst. Solange die Leiste fixiert ist, wird auch ihr Schliesskreuz ausgeblendet; zum Verschieben oder Schliessen einfach kurz wieder entfixieren.",
+ en: "Anchors the detached bar in place so you don't accidentally drag it while clicking. While the bar is locked its close button is hidden as well; to move or close it, simply unlock it again briefly."
+ ),
+ Entry(
+ keys: [
+ "PV-Wert der abgedockten Leiste: Summe, Einzelwerte oder beides — gilt für einzeilige und Kompaktansicht. Einzelwerte brauchen Kanal-Reporting (Solarbank 2/3).",
+ "PV value in the detached bar: total, individual inputs, or both — applies to single-line and compact views. Individual values require channel reporting (Solarbank 2/3)."
+ ],
+ de: "Bestimmt, wie der Solarwert in der abgedockten Leiste erscheint: nur die Summe, jeder Modul-Eingang einzeln (z. B. \"438·204W\") oder beides zusammen. Einzelwerte gibt es nur, wenn deine Solarbank die Eingänge einzeln meldet (Solarbank 2 und 3). Die Einstellung ist unabhängig von der Menüleiste.",
+ en: "Determines how the solar value appears in the detached bar: just the total, each panel input separately (e.g. \"438·204W\"), or both together. Individual values are only available if your Solarbank reports its inputs separately (Solarbank 2 and 3). The setting is independent of the menu bar."
+ ),
+ Entry(
+ keys: [
+ "Legt fest, wo die Leiste im Fensterstapel liegt: über allen Fenstern, normal eingereiht oder auf dem Schreibtisch hinter allen Fenstern.",
+ "Controls where the bar sits in the window stack: above all windows, ordered like a normal window, or on the desktop behind everything."
+ ],
+ de: "Bestimmt, wo die abgedockte Leiste im Fensterstapel liegt:\n\n• Immer vorn — schwebt über allen Fenstern und bleibt ständig sichtbar.\n• Normal — verhält sich wie ein gewöhnliches Fenster und kann verdeckt werden.\n• Hinten — liegt auf dem Schreibtisch hinter allen Fenstern, wie ein Widget.",
+ en: "Controls where the detached bar sits in the window stack:\n\n• Always front — floats above all windows and stays permanently visible.\n• Normal — behaves like an ordinary window and can be covered.\n• Behind — sits on the desktop behind all windows, like a widget."
+ ),
+ Entry(
+ keys: ["Passt nur die Größe der abgedockten Menüleistenleiste an."],
+ de: "Vergrössert oder verkleinert nur die abgedockte Leiste — unabhängig von der Anzeige in der Menüleiste. Praktisch, wenn die Leiste z. B. auf dem Schreibtisch grösser lesbar sein soll.",
+ en: "Scales only the detached bar — independent of the menu bar display. Handy if you want the bar to be more readable e.g. on the desktop."
+ ),
+
+ // MARK: App / Dashboard
+ Entry(
+ keys: ["Wählt helle Darstellung, dunkle Darstellung oder automatisch passend zum macOS-System."],
+ de: "Bestimmt das Erscheinungsbild der SolixBar-Fenster (Dashboard, Verlauf, Einstellungen): hell, dunkel oder automatisch passend zur macOS-Systemeinstellung. \"Automatisch\" wechselt mit, wenn macOS abends in den Dunkelmodus geht.",
+ en: "Determines the appearance of SolixBar's windows (dashboard, history, settings): light, dark, or automatically matching the macOS system setting. \"Automatic\" follows along when macOS switches to dark mode in the evening."
+ ),
+ Entry(
+ keys: ["Wählt die Sprache für sichtbare App-Texte."],
+ de: "Stellt die Sprache aller sichtbaren SolixBar-Texte um: Deutsch oder Englisch. Einzelne Fenster übernehmen die neue Sprache spätestens nach einem Neustart der App.",
+ en: "Switches the language of all visible SolixBar texts: German or English. Individual windows pick up the new language at the latest after restarting the app."
+ ),
+ Entry(
+ keys: ["Startet SolixBar automatisch nach dem Anmelden."],
+ de: "Startet SolixBar automatisch, sobald du dich an deinem Mac anmeldest — die Werte sind dann immer da, ohne dass du an die App denken musst. Der Eintrag erscheint auch unter Systemeinstellungen → Allgemein → Anmeldeobjekte und lässt sich dort ebenfalls verwalten.",
+ en: "Starts SolixBar automatically as soon as you sign in to your Mac — the values are always there without you having to think about the app. The entry also appears under System Settings → General → Login Items and can be managed there as well."
+ ),
+ Entry(
+ keys: [
+ "Fragt einmal täglich die GitHub-Releases ab. Bei einer neueren Version erscheint eine Mitteilung und ein Eintrag im Menü — installiert wird nichts automatisch.",
+ "Checks the GitHub releases once a day. A newer version shows a notification and a menu entry — nothing is installed automatically."
+ ],
+ de: "SolixBar schaut einmal täglich auf GitHub nach, ob es eine neuere Version gibt. Falls ja, bekommst du einmalig eine macOS-Mitteilung und im Menü erscheint der Eintrag \"Update verfügbar\" mit Link zum Download. Es wird nichts automatisch installiert und ausser der Versionsabfrage nichts übertragen.",
+ en: "Once a day SolixBar checks GitHub for a newer version. If there is one, you get a single macOS notification and the menu shows an \"Update available\" entry linking to the download. Nothing is installed automatically and nothing is transmitted beyond the version query."
+ ),
+ Entry(
+ keys: ["Blendet leere Zeiträume im Verlaufsgraphen aus: Die Zeitachse beginnt bei der ersten vorhandenen Messung statt beim Kalenderanfang des Zeitraums."],
+ de: "Passt die Zeitachse des Verlaufsgraphen an deine tatsächlichen Daten an: Sie beginnt bei der ersten vorhandenen Messung statt beim Kalenderanfang. Nutzt du SolixBar z. B. erst seit drei Tagen, zeigt die Wochenansicht keine vier leeren Tage davor.",
+ en: "Fits the history graph's time axis to your actual data: it starts at the first recorded measurement instead of the calendar start. If you've only used SolixBar for three days, the week view won't show four empty days before that."
+ ),
+ Entry(
+ keys: [
+ "Zeichnet die Verlaufskurven weich statt eckig — die Kurve läuft weiter exakt durch alle Messpunkte.",
+ "Draws the history curves smoothly instead of angular — the curve still passes exactly through every measurement."
+ ],
+ de: "Zeichnet die Linien im Verlaufsgraphen als weiche, fliessende Kurven statt als eckige Geradenzüge — das Auge folgt dem Verlauf dann leichter. Die Kurve läuft weiterhin durch jeden Messpunkt; zwischen zwei Punkten darf sie für den weichen Schwung leicht ausholen, bleibt dabei aber immer innerhalb des Diagramms (also z. B. nie unter der Nulllinie mit scheinbar negativen Watt). Deine gespeicherten Daten ändern sich nicht; ausgeschaltet siehst du wieder die rohen Geradenzüge.",
+ en: "Draws the lines in the history graph as soft, flowing curves instead of angular straight segments — the eye follows the trend more easily. The curve still runs through every measurement; between two points it may bow slightly for the smooth flow, but always stays within the chart (so e.g. never below the zero line with seemingly negative watts). Your stored data does not change; turned off you see the raw straight segments again."
+ ),
+ Entry(
+ keys: [
+ "Füllt die Fläche unter der jeweiligen Kurve dezent ein — je Kurve wählbar.",
+ "Subtly fills the area under the respective curve — selectable per curve."
+ ],
+ de: "Hinterlegt die Fläche unter der jeweiligen Kurve mit einem dezenten Farbschimmer — das betont den Verlauf und lässt den Graphen ruhiger wirken. Du kannst die Füllung für Akku, Solar und Netzbezug einzeln an- und abschalten; ab Werk ist nur Solar gefüllt. Die Füllungen sind bewusst blass, damit sie sich bei mehreren aktiven Kurven nicht zu einem Farbbrei mischen — bei allen dreien wird es trotzdem lebhaft, probiere einfach aus, was dir gefällt.",
+ en: "Backs the area under the respective curve with a subtle color tint — emphasizing the trend and making the graph feel calmer. You can toggle the fill for battery, solar, and grid import individually; by default only solar is filled. The fills are deliberately pale so several active curves don't blend into a color mush — with all three it still gets lively, so just try what you like."
+ ),
+ Entry(
+ keys: [
+ "Dauer des eigenen Zeitraums (Chip \"Eig.\" im Verlauf).",
+ "Duration of the custom range (chip \"Eig.\" in the history)."
+ ],
+ de: "Legt die Dauer des eigenen Zeitraums fest, den du im Verlaufsgraphen über den Chip \"Eig.\" auswählst — zusätzlich zu den festen Zeiträumen wie Tag, Woche oder Monat. Beispiel: 3 Tage oder 2 Wochen, ganz wie es zu deinem Blick auf die Anlage passt.",
+ en: "Sets the duration of the custom range you select in the history graph via the \"Eig.\" chip — in addition to the fixed ranges like day, week, or month. Example: 3 days or 2 weeks, whatever fits how you look at your system."
+ ),
+ Entry(
+ keys: [
+ "Gilt für das abgedockte Dashboard-Fenster: über allen Fenstern schwebend, normal eingereiht oder hinter allen Fenstern auf dem Schreibtisch.",
+ "Applies to the detached dashboard window: floating above all windows, ordered like a normal window, or on the desktop behind everything."
+ ],
+ de: "Bestimmt, wo das abgedockte Dashboard-Fenster im Fensterstapel liegt: immer vorn über allen Fenstern schwebend, normal eingereiht wie ein gewöhnliches Fenster, oder hinten auf dem Schreibtisch wie ein Widget.",
+ en: "Controls where the detached dashboard window sits in the window stack: always in front floating above all windows, ordered normally like an ordinary window, or at the back on the desktop like a widget."
+ ),
+ Entry(
+ keys: [
+ "Gilt für das abgedockte Verlaufsfenster: über allen Fenstern schwebend, normal eingereiht oder hinter allen Fenstern auf dem Schreibtisch.",
+ "Applies to the detached history window: floating above all windows, ordered like a normal window, or on the desktop behind everything."
+ ],
+ de: "Bestimmt, wo das abgedockte Verlaufsfenster im Fensterstapel liegt: immer vorn über allen Fenstern schwebend, normal eingereiht wie ein gewöhnliches Fenster, oder hinten auf dem Schreibtisch wie ein Widget.",
+ en: "Controls where the detached history window sits in the window stack: always in front floating above all windows, ordered normally like an ordinary window, or at the back on the desktop like a widget."
+ ),
+ Entry(
+ keys: [
+ "PV-Wert im Dashboard des Menüs: nur die Summe, nur die Eingänge einzeln (\"438 · 204 W\" in der Kachel) oder Summe in der Kachel plus eigene Einzelwerte-Zeile. Einzelwerte brauchen eine Solarbank mit Kanal-Reporting (Solarbank 2/3), sonst bleibt es bei der Summe.",
+ "PV value in the menu's dashboard: total only, individual inputs only (\"438 · 204 W\" in the tile), or the total in the tile plus a separate per-input row. Individual values require a Solarbank with channel reporting (Solarbank 2/3); otherwise the total is shown."
+ ],
+ de: "Bestimmt, wie der Solarwert im Dashboard (im Menü) erscheint: nur die Summe in der Kachel, nur die Modul-Eingänge einzeln (\"438 · 204 W\"), oder die Summe plus eine eigene Zeile mit den Einzelwerten. Mit den Einzelwerten erkennst du sofort, wenn ein Modul schwächelt. Sie brauchen eine Solarbank, die ihre Eingänge einzeln meldet (Solarbank 2 und 3) — sonst bleibt es automatisch bei der Summe.",
+ en: "Determines how the solar value appears in the dashboard (in the menu): just the total in the tile, only the panel inputs individually (\"438 · 204 W\"), or the total plus a separate row with the individual values. With individual values you instantly spot an underperforming panel. They require a Solarbank that reports its inputs separately (Solarbank 2 and 3) — otherwise the total is shown automatically."
+ ),
+ Entry(
+ keys: [
+ "PV-Wert im abgedockten Dashboard-Fenster: Gesamtwert, Einzelwerte oder beides — unabhängig vom Menü-Dashboard einstellbar.",
+ "PV value in the detached dashboard window: total, individual inputs, or both — configurable independently of the menu dashboard."
+ ],
+ de: "Bestimmt, wie der Solarwert im abgedockten Dashboard-Fenster erscheint: Gesamtwert, Einzelwerte je Modul-Eingang oder beides. Die Einstellung ist unabhängig vom Dashboard im Menü — du kannst also z. B. im Fenster die Einzelwerte beobachten und im Menü bei der Summe bleiben.",
+ en: "Determines how the solar value appears in the detached dashboard window: total, individual values per panel input, or both. The setting is independent of the menu dashboard — so you can e.g. watch the individual values in the window while keeping the total in the menu."
+ ),
+
+ // MARK: Warnungen
+ Entry(
+ keys: [
+ "Meldet sich einmal, wenn der Akku unter die Schwelle fällt. Erst wenn er wieder 5 Punkte darüber liegt, wird die Warnung neu scharf geschaltet.",
+ "Fires once when the battery drops below the threshold. It re-arms only after climbing 5 points above it again."
+ ],
+ de: "Schickt dir eine macOS-Mitteilung, sobald der Akku unter die eingestellte Schwelle fällt — zusätzlich erscheint ein ⚠-Eintrag im Menü, solange der Stand niedrig bleibt. Die Warnung kommt bewusst nur einmal: Erst wenn der Akku wieder 5 Punkte über der Schwelle war, wird sie neu scharf geschaltet. So gibt es kein Mitteilungs-Gewitter, wenn der Stand um die Schwelle pendelt.",
+ en: "Sends you a macOS notification as soon as the battery drops below the set threshold — plus a ⚠ entry in the menu while the level stays low. The warning deliberately fires only once: it re-arms only after the battery has been 5 points above the threshold again. That way there is no notification storm when the level hovers around the threshold."
+ ),
+ Entry(
+ keys: ["Warnschwelle in Prozent (5–95).", "Warning threshold in percent (5–95)."],
+ de: "Die Akkustand-Schwelle in Prozent (5–95), unter der die Warnung ausgelöst wird. Ein typischer Wert ist 20 %: tief genug, um nicht ständig zu warnen, früh genug, um noch reagieren zu können.",
+ en: "The battery level threshold in percent (5–95) below which the warning fires. A typical value is 20%: low enough not to warn constantly, early enough to still react."
+ ),
+ Entry(
+ keys: [
+ "Warnt, wenn die Solarmodule auf 0 W fallen, obwohl sie in der letzten Stunde noch nennenswert erzeugt haben. Nachts bleibt es dadurch still.",
+ "Warns when the panels drop to 0 W although they produced meaningfully within the last hour. Stays silent at night as a result."
+ ],
+ de: "Warnt dich, wenn deine Solarmodule plötzlich 0 W liefern, obwohl sie in der letzten Stunde noch nennenswert erzeugt haben — das deutet auf ein echtes Problem hin (Stecker, Kabel, Wechselrichter) statt auf eine Wolke. Weil die Warnung vorherige Erzeugung voraussetzt, bleibt sie nachts automatisch still. Wie lange 0 W anliegen müssen und was als \"nennenswert\" gilt, stellst du daneben ein.",
+ en: "Warns you when your panels suddenly deliver 0 W although they produced meaningfully within the last hour — pointing to a real problem (connector, cable, inverter) rather than a cloud. Because the warning requires prior production, it automatically stays silent at night. How long 0 W must persist and what counts as \"meaningful\" is configured next to it."
+ ),
+ Entry(
+ keys: [
+ "So viele Minuten muss die PV durchgehend 0 W liefern, bevor gewarnt wird (5–120).",
+ "How many minutes PV must stay at 0 W before warning (5–120)."
+ ],
+ de: "So viele Minuten muss die Anlage durchgehend 0 W liefern, bevor die Warnung ausgelöst wird (5–120). Kürzere Zeiten melden schneller, können aber bei kurzen Aussetzern falschen Alarm geben; 15 Minuten sind ein guter Kompromiss.",
+ en: "How many minutes the system must stay at 0 W before the warning fires (5–120). Shorter times report faster but can false-alarm on brief dropouts; 15 minutes is a good compromise."
+ ),
+ Entry(
+ keys: [
+ "Ab dieser Leistung gilt die Anlage als \"hat kürzlich erzeugt\" (10–2000 W).",
+ "Output at or above this counts as \"was recently producing\" (10–2000 W)."
+ ],
+ de: "Erst ab dieser Leistung (10–2000 W) zählt die Anlage als \"hat kürzlich erzeugt\" — und nur dann kann die Einbruch-Warnung auslösen. Ein höherer Wert verhindert Fehlalarme in der Dämmerung, wenn ohnehin fast nichts erzeugt wird.",
+ en: "Only at or above this output (10–2000 W) does the system count as \"was recently producing\" — and only then can the collapse warning fire. A higher value prevents false alarms at dusk when hardly anything is produced anyway."
+ ),
+ Entry(
+ keys: [
+ "Meldet 0 W auch ohne vorherige Erzeugung, solange die Uhrzeit im angegebenen Fenster liegt — z. B. wenn die Anlage schon morgens nicht anläuft.",
+ "Also reports 0 W without prior production while the time of day is inside the window — e.g. when the system never starts up in the morning."
+ ],
+ de: "Ergänzt die Einbruch-Warnung um ein festes Tagesfenster: Innerhalb dieser Stunden wird 0 W auch dann gemeldet, wenn vorher gar nichts erzeugt wurde — zum Beispiel wenn die Anlage morgens gar nicht erst anläuft. Wähle das Fenster so, dass deine Anlage darin normalerweise sicher erzeugt (z. B. 10 bis 16 Uhr), sonst gibt es an trüben Wintertagen Fehlalarme.",
+ en: "Extends the collapse warning with a fixed daytime window: within these hours, 0 W is reported even without any prior production — for example when the system never starts up in the morning. Choose the window so your system normally produces reliably within it (e.g. 10:00 to 16:00), otherwise gloomy winter days will cause false alarms."
+ ),
+ Entry(
+ keys: ["Beginn des Zeitfensters (Stunde, 0–23).", "Window start (hour, 0–23)."],
+ de: "Die Stunde (0–23), ab der das Tagesfenster gilt. Beispiel: 10 bedeutet ab 10:00 Uhr.",
+ en: "The hour (0–23) from which the daytime window applies. Example: 10 means from 10:00."
+ ),
+ Entry(
+ keys: ["Ende des Zeitfensters (Stunde, 1–24).", "Window end (hour, 1–24)."],
+ de: "Die Stunde (1–24), bis zu der das Tagesfenster gilt. Beispiel: 16 bedeutet bis 16:00 Uhr.",
+ en: "The hour (1–24) until which the daytime window applies. Example: 16 means until 16:00."
+ ),
+ Entry(
+ keys: [
+ "Warnt, wenn ein PV-Eingang dauerhaft 0 W liefert, während die anderen Eingänge erzeugen. Braucht eine Solarbank, die ihre MPPT-Kanäle einzeln meldet (Solarbank 2/3).",
+ "Warns when one PV input stays at 0 W while the other inputs are producing. Requires a Solarbank that reports its MPPT channels individually (Solarbank 2/3)."
+ ],
+ de: "Überwacht jeden Modul-Eingang einzeln: Liefert ein Eingang dauerhaft 0 W, während die anderen munter erzeugen, bekommst du eine Mitteilung — typisch für einen gezogenen Stecker oder ein defektes Modul, das im Gesamtwert sonst kaum auffällt. Braucht eine Solarbank, die ihre Eingänge einzeln meldet (Solarbank 2 und 3).",
+ en: "Monitors each panel input individually: if one input stays at 0 W while the others are happily producing, you get a notification — typical for a pulled connector or a defective panel that would barely show in the total. Requires a Solarbank that reports its inputs individually (Solarbank 2 and 3)."
+ ),
+ Entry(
+ keys: [
+ "Warnt, wenn ein Eingang einbricht, der kurz zuvor selbst noch erzeugt hat — auch ohne Vergleich mit den anderen Eingängen. So fällt ein defektes Modul oder Kabel auf, selbst wenn mehrere Eingänge gleichzeitig betroffen sind. Nachts still.",
+ "Warns when an input collapses after it was recently producing itself — without comparing to the other inputs. Catches a defective panel or cable even when several inputs are affected at once. Silent at night."
+ ],
+ de: "Vergleicht jeden Modul-Eingang mit seiner eigenen jüngsten Erzeugung: Bricht ein Eingang auf 0 W ein, der kurz zuvor selbst noch erzeugt hat, wird gewarnt — unabhängig davon, was die anderen Eingänge tun. Das erkennt defekte Module oder Kabel auch dann, wenn mehrere Eingänge gleichzeitig ausfallen (wo der reine Vergleich mit den Nachbarn stumm bliebe). Nachts bleibt die Warnung automatisch still.",
+ en: "Compares each panel input with its own recent production: if an input collapses to 0 W after it was recently producing itself, you get warned — regardless of what the other inputs do. This catches defective panels or cables even when several inputs fail at once (where the pure neighbor comparison would stay silent). At night the warning automatically stays silent."
+ )
+ ]
+}
diff --git a/Sources/SolixBarKit/SettingsWindowController.swift b/Sources/SolixBarKit/SettingsWindowController.swift
new file mode 100644
index 0000000..1d72204
--- /dev/null
+++ b/Sources/SolixBarKit/SettingsWindowController.swift
@@ -0,0 +1,1621 @@
+import AppKit
+
+@MainActor
+final class SettingsWindowController: NSWindowController, NSWindowDelegate, NSTextFieldDelegate {
+ private let settings = AppSettings.shared
+ private let onPreview: () -> Void
+ private let onSave: () -> Void
+ private let onReset: () -> Void
+
+ private let modePopup = NSPopUpButton()
+ private let commandField = NSTextField()
+ private let urlField = NSTextField()
+ private let intervalField = NSTextField()
+ private let solixEmailField = NSTextField()
+ private let solixPasswordField = NSSecureTextField()
+ private let solixCountryField = NSTextField()
+ private let solixTodayBaseField = NSTextField()
+ private let solixTotalBaseField = NSTextField()
+ private let commandRow = NSStackView()
+ private let urlRow = NSStackView()
+ private let solixTitle = NSTextField(labelWithString: "SOLIX Login")
+ private let solixHint = NSTextField(wrappingLabelWithString: "SolixBar fragt die Werte direkt mit der mitgelieferten Laufzeit ab. Das Passwort liegt im macOS-Schlüsselbund, Mail und Land in einer lokalen Datei.")
+ private let solixEmailRow = NSStackView()
+ private let solixPasswordRow = NSStackView()
+ private let solixCountryRow = NSStackView()
+ private let solixTodayBaseRow = NSStackView()
+ private let solixTotalBaseRow = NSStackView()
+ private let graphFitButton = NSButton(checkboxWithTitle: "Graph an vorhandene Daten anpassen", target: nil, action: nil)
+ private let graphSmoothButton = NSButton(checkboxWithTitle: "Kurven glätten", target: nil, action: nil)
+ private let fillBatteryButton = NSButton(checkboxWithTitle: "Akku", target: nil, action: nil)
+ private let fillSolarButton = NSButton(checkboxWithTitle: "Solar", target: nil, action: nil)
+ private let fillGridButton = NSButton(checkboxWithTitle: "Netzbezug", target: nil, action: nil)
+ private let customRangeField = NSTextField()
+ private let customRangeUnitPopup = NSPopUpButton()
+ private let autostartButton = NSButton(checkboxWithTitle: "Beim Login automatisch starten", target: nil, action: nil)
+ private let autostartStatus = NSTextField(labelWithString: "")
+ private let updateCheckButton = NSButton(checkboxWithTitle: "Automatisch nach Updates suchen", target: nil, action: nil)
+ private let dashboardPVPopup = NSPopUpButton()
+ private let detachedDashboardPVPopup = NSPopUpButton()
+ private let menuBarPVPopup = NSPopUpButton()
+ private let detachedPVPopup = NSPopUpButton()
+ private let warnBatteryButton = NSButton(checkboxWithTitle: "Bei niedrigem Akkustand warnen", target: nil, action: nil)
+ private let warnBatteryThresholdField = NSTextField()
+ private let warnPVStallButton = NSButton(checkboxWithTitle: "Bei PV-Einbruch warnen", target: nil, action: nil)
+ private let warnPVMinutesField = NSTextField()
+ private let warnPVWattsField = NSTextField()
+ private let warnPVWindowButton = NSButton(checkboxWithTitle: "Zusätzlich im Zeitfenster warnen", target: nil, action: nil)
+ private let warnPVWindowStartField = NSTextField()
+ private let warnPVWindowEndField = NSTextField()
+ private let warnPerPVButton = NSButton(checkboxWithTitle: "Einzelne PV-Eingänge überwachen", target: nil, action: nil)
+ private let warnPerPVDipButton = NSButton(checkboxWithTitle: "Einbruch je PV-Eingang melden", target: nil, action: nil)
+
+ // Vier unabhängige Metrik-Listen (Leiste × Ansicht). Die Kompakt-Listen
+ // folgen der einzeiligen Liste, bis der Nutzer sie entkoppelt.
+ private enum MetricListKind: CaseIterable {
+ case dockedNormal, dockedStacked, detachedNormal, detachedStacked
+ }
+ private struct MetricListState {
+ var order: [BarMetric]
+ var selected: Set
+ }
+ private var metricListStates: [MetricListKind: MetricListState] = [:]
+ private var dockedStackedFollows = true
+ private var detachedStackedFollows = true
+ private let menuMetricSegment = NSSegmentedControl()
+ private let menuMetricList = MetricOrderListView()
+ private let menuFollowButton = NSButton(checkboxWithTitle: "Folgt der einzeiligen Liste", target: nil, action: nil)
+ private let detachedMetricSegment = NSSegmentedControl()
+ private let detachedMetricList = MetricOrderListView()
+ private let detachedFollowButton = NSButton(checkboxWithTitle: "Folgt der einzeiligen Liste", target: nil, action: nil)
+ private let showIconButton = NSButton(checkboxWithTitle: "App-Symbol in der Menüleiste anzeigen", target: nil, action: nil)
+ private let stackedButton = NSButton(checkboxWithTitle: "Zweizeilige Kompaktanzeige", target: nil, action: nil)
+ private let stackedDetachedButton = NSButton(checkboxWithTitle: "Abgedockte Leiste: Kompaktanzeige", target: nil, action: nil)
+ private let detachedIconButton = NSButton(checkboxWithTitle: "", target: nil, action: nil)
+ private let detachedLabelsButton = NSButton(checkboxWithTitle: "", target: nil, action: nil)
+ private let detachedSymbolsButton = NSButton(checkboxWithTitle: "", target: nil, action: nil)
+ private let detachedArrowsButton = NSButton(checkboxWithTitle: "", target: nil, action: nil)
+ private let showLabelsButton = NSButton(checkboxWithTitle: "Werte mit Bezeichnung anzeigen", target: nil, action: nil)
+ private let showMetricSymbolsButton = NSButton(checkboxWithTitle: "Symbole vor den Werten anzeigen", target: nil, action: nil)
+ private let showEnergyFlowArrowsButton = NSButton(checkboxWithTitle: "Flussrichtung anzeigen", target: nil, action: nil)
+ private let showFlowColorsButton = NSButton(checkboxWithTitle: "Farbige Werte anzeigen", target: nil, action: nil)
+ private let detachedFlowColorsButton = NSButton(checkboxWithTitle: "", target: nil, action: nil)
+ private let lockDetachedMenuBarButton = NSButton(checkboxWithTitle: "Abgedockte Leiste fixieren", target: nil, action: nil)
+ private let scaleSlider = NSSlider(value: 1.0, minValue: 0.75, maxValue: 1.6, target: nil, action: nil)
+ private let scaleValue = NSTextField(labelWithString: "100 %")
+ private let detachedScaleSlider = NSSlider(value: 1.0, minValue: 0.75, maxValue: 1.9, target: nil, action: nil)
+ private let detachedScaleValue = NSTextField(labelWithString: "100 %")
+ private let appearancePopup = NSPopUpButton()
+ private let languagePopup = NSPopUpButton()
+ private let detachedLevelPopup = NSPopUpButton()
+ private let dashboardLevelPopup = NSPopUpButton()
+ private let graphLevelPopup = NSPopUpButton()
+ private var originalSettings: AppSettingsSnapshot?
+ private var originalAutostart = false
+ private var isSaving = false
+ private var isLoading = false
+ private var previewDebounce: Timer?
+ private let menuBarPreview = NSImageView()
+ private let previewFormatter = MenuBarFormatter()
+
+ init(onPreview: @escaping () -> Void, onSave: @escaping () -> Void, onReset: @escaping () -> Void) {
+ self.onPreview = onPreview
+ self.onSave = onSave
+ self.onReset = onReset
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 720, height: 660),
+ styleMask: [.titled, .closable, .miniaturizable],
+ backing: .buffered,
+ defer: false
+ )
+ window.title = LocalizedText.text("SOLIX Bar Einstellungen", "SOLIX Bar Settings")
+ window.center()
+ super.init(window: window)
+ window.delegate = self
+ window.contentView = buildView()
+ }
+
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func showWindow(_ sender: Any?) {
+ originalSettings = settings.snapshot()
+ originalAutostart = AutostartManager.isEnabled
+ isSaving = false
+ loadSettings()
+ super.showWindow(sender)
+ }
+
+ private func buildView() -> NSView {
+ let container = NSView()
+
+ modePopup.addItems(withTitles: ["SOLIX-Konto (direkt)", "Demo", "Demo (Warnungs-Test)", "Lokaler JSON-Befehl", "JSON-URL"])
+ appearancePopup.addItems(withTitles: ["Automatisch", "Hell", "Dunkel"])
+ languagePopup.addItems(withTitles: ["Deutsch", "English"])
+ applyLocalizedControlTitles()
+ modePopup.toolTip = "Legt fest, woher SolixBar die Werte lädt."
+ appearancePopup.toolTip = "Wählt helle Darstellung, dunkle Darstellung oder automatisch passend zum macOS-System."
+ languagePopup.toolTip = "Wählt die Sprache für sichtbare App-Texte."
+ commandField.placeholderString = solixHelperCommand
+ commandField.toolTip = "Führt einen lokalen Befehl aus und liest dessen JSON-Ausgabe."
+ urlField.placeholderString = "http://127.0.0.1:8787/solix.json"
+ urlField.toolTip = "Lädt die Werte von einer JSON-Adresse."
+ intervalField.placeholderString = "300"
+ let intervalNumbers = NumberFormatter()
+ intervalNumbers.allowsFloats = false
+ intervalNumbers.minimum = 60
+ intervalNumbers.maximum = 86_400
+ intervalField.formatter = intervalNumbers
+ let rangeNumbers = NumberFormatter()
+ rangeNumbers.allowsFloats = false
+ rangeNumbers.minimum = 1
+ rangeNumbers.maximum = 365
+ customRangeField.formatter = rangeNumbers
+ intervalField.toolTip = "Zeit zwischen zwei Aktualisierungen in Sekunden."
+ solixEmailField.placeholderString = "mail@example.com"
+ solixEmailField.toolTip = "E-Mail-Adresse deines Anker/SOLIX-Kontos."
+ solixPasswordField.placeholderString = "Passwort"
+ solixPasswordField.toolTip = "Passwort deines Anker/SOLIX-Kontos. Wird sicher im macOS-Schlüsselbund gespeichert und nie hochgeladen."
+ solixCountryField.placeholderString = "DE"
+ solixCountryField.toolTip = "Land deines Anker-Kontos, normalerweise DE."
+ solixTodayBaseField.placeholderString = "z.B. 7.2"
+ solixTodayBaseField.toolTip = "Optionaler Korrekturwert für den heutigen Ertrag in kWh, falls Anker heute 0 kWh meldet. SolixBar zählt ab diesem Wert weiter."
+ solixTotalBaseField.placeholderString = "z.B. 427.8"
+ solixTotalBaseField.toolTip = LocalizedText.text(
+ "Optionaler Startwert für den Gesamtertrag. Ohne API-Gesamtwert kumuliert SolixBar alle fortlaufenden Solarmessungen lokal.",
+ "Optional starting value for total yield. Without an API total, SolixBar locally accumulates all continuous solar measurements."
+ )
+
+ let batteryNumbers = NumberFormatter()
+ batteryNumbers.allowsFloats = false
+ batteryNumbers.minimum = 5
+ batteryNumbers.maximum = 95
+ warnBatteryThresholdField.formatter = batteryNumbers
+ let minutesNumbers = NumberFormatter()
+ minutesNumbers.allowsFloats = false
+ minutesNumbers.minimum = 5
+ minutesNumbers.maximum = 120
+ warnPVMinutesField.formatter = minutesNumbers
+ let wattsNumbers = NumberFormatter()
+ wattsNumbers.allowsFloats = false
+ wattsNumbers.minimum = 10
+ wattsNumbers.maximum = 2000
+ warnPVWattsField.formatter = wattsNumbers
+ let startHourNumbers = NumberFormatter()
+ startHourNumbers.allowsFloats = false
+ startHourNumbers.minimum = 0
+ startHourNumbers.maximum = 23
+ warnPVWindowStartField.formatter = startHourNumbers
+ let endHourNumbers = NumberFormatter()
+ endHourNumbers.allowsFloats = false
+ endHourNumbers.minimum = 1
+ endHourNumbers.maximum = 24
+ warnPVWindowEndField.formatter = endHourNumbers
+ for field in [warnBatteryThresholdField, warnPVMinutesField, warnPVWattsField, warnPVWindowStartField, warnPVWindowEndField] {
+ field.alignment = .center
+ field.font = .monospacedDigitSystemFont(ofSize: 12, weight: .semibold)
+ field.target = self
+ field.action = #selector(applyPreview)
+ }
+
+ for textField in [commandField, urlField, intervalField, solixEmailField, solixPasswordField, solixCountryField, solixTodayBaseField, solixTotalBaseField, warnBatteryThresholdField, warnPVMinutesField, warnPVWattsField, warnPVWindowStartField, warnPVWindowEndField] {
+ textField.delegate = self
+ }
+
+ for control in [modePopup, appearancePopup, languagePopup, detachedLevelPopup, dashboardLevelPopup, graphLevelPopup, updateCheckButton, dashboardPVPopup, detachedDashboardPVPopup, menuBarPVPopup, detachedPVPopup, warnBatteryButton, warnPVStallButton, warnPVWindowButton, warnPerPVButton, warnPerPVDipButton, showIconButton, stackedButton, stackedDetachedButton, detachedIconButton, detachedLabelsButton, detachedSymbolsButton, detachedArrowsButton, detachedFlowColorsButton, graphFitButton, graphSmoothButton, fillBatteryButton, fillSolarButton, fillGridButton, showLabelsButton, showMetricSymbolsButton, showEnergyFlowArrowsButton, showFlowColorsButton, lockDetachedMenuBarButton, scaleSlider, detachedScaleSlider] {
+ control.target = self
+ control.action = #selector(applyPreview)
+ }
+ autostartButton.target = self
+ autostartButton.action = #selector(toggleAutostart)
+ autostartButton.toolTip = "Startet SolixBar automatisch nach dem Anmelden."
+ autostartStatus.textColor = .secondaryLabelColor
+ autostartStatus.lineBreakMode = .byTruncatingMiddle
+ showIconButton.toolTip = "Zeigt oder versteckt das SolixBar-Symbol in der Menüleiste."
+ stackedButton.toolTip = "Zeigt die Werte in zwei kompakten Zeilen übereinander — halbe Breite bei gleicher Information, praktisch auf MacBooks mit Notch."
+ stackedDetachedButton.toolTip = "Nutzt die zweizeilige Kompaktanzeige auch in der abgedockten Leiste — macht sie etwa halb so lang."
+ graphFitButton.toolTip = "Blendet leere Zeiträume im Verlaufsgraphen aus: Die Zeitachse beginnt bei der ersten vorhandenen Messung statt beim Kalenderanfang des Zeitraums."
+ graphSmoothButton.toolTip = LocalizedText.text(
+ "Zeichnet die Verlaufskurven weich statt eckig — die Kurve läuft weiter exakt durch alle Messpunkte.",
+ "Draws the history curves smoothly instead of angular — the curve still passes exactly through every measurement."
+ )
+ let fillTooltip = LocalizedText.text(
+ "Füllt die Fläche unter der jeweiligen Kurve dezent ein — je Kurve wählbar.",
+ "Subtly fills the area under the respective curve — selectable per curve."
+ )
+ fillBatteryButton.toolTip = fillTooltip
+ fillSolarButton.toolTip = fillTooltip
+ fillGridButton.toolTip = fillTooltip
+ showLabelsButton.toolTip = "Zeigt kurze Namen wie Akku oder Solar vor den Zahlen."
+ showMetricSymbolsButton.toolTip = "Zeigt farbige Symbole direkt vor den Menüleistenwerten."
+ showEnergyFlowArrowsButton.toolTip = "Zeigt Richtungspfeile und Begriffe wie Laden, Entladen, Bezug und Einspeisen; Wattwerte erscheinen dann ohne Vorzeichen."
+ showFlowColorsButton.toolTip = "Färbt Werte und Symbole nach ihrer Bedeutung (Akku grün, Solar gelb, Netzbezug rot, Einspeisung violett). Ohne Farben bleibt die Anzeige einfarbig."
+ lockDetachedMenuBarButton.toolTip = "Fixiert die abgedockte Leiste, damit sie nicht versehentlich verschoben wird."
+ scaleSlider.toolTip = "Vergrößert oder verkleinert Text und Symbole in der Menüleiste."
+ scaleValue.toolTip = "Aktuell eingestellte Größe der Menüleistenanzeige."
+ detachedScaleSlider.toolTip = "Vergrößert oder verkleinert nur die abgedockte Menüleistenanzeige."
+ detachedScaleValue.toolTip = "Aktuell eingestellte Größe der abgedockten Leiste."
+
+ let title = NSTextField(labelWithString: "SOLIX Bar \(AppVersion.short)")
+ title.font = .boldSystemFont(ofSize: 20)
+
+ let tabs = NSTabView()
+ tabs.tabViewType = .topTabsBezelBorder
+ tabs.addTabViewItem(tab(title: LocalizedText.text("Menüleiste", "Menu Bar"), view: menuBarPane()))
+ tabs.addTabViewItem(tab(title: LocalizedText.text("Abgedockte Leiste", "Detached Bar"), view: detachedPane()))
+ tabs.addTabViewItem(tab(title: LocalizedText.text("Datenquelle", "Data Source"), view: dataSourcePane()))
+ tabs.addTabViewItem(tab(title: LocalizedText.text("Warnungen", "Warnings"), view: warningsPane()))
+ tabs.addTabViewItem(tab(title: LocalizedText.text("App", "App"), view: appPane()))
+
+ let cancel = NSButton(title: LocalizedText.text("Abbrechen", "Cancel"), target: self, action: #selector(cancelSettings))
+ cancel.bezelStyle = .rounded
+ cancel.toolTip = "Verwirft die Vorschau und stellt die alten Einstellungen wieder her."
+
+ let save = NSButton(title: LocalizedText.text("Speichern", "Save"), target: self, action: #selector(saveSettings))
+ save.bezelStyle = .rounded
+ save.keyEquivalent = "\r"
+ save.toolTip = "Speichert die aktuellen Einstellungen dauerhaft."
+
+ for view in [title, tabs, cancel, save] {
+ view.translatesAutoresizingMaskIntoConstraints = false
+ container.addSubview(view)
+ }
+
+ NSLayoutConstraint.activate([
+ title.topAnchor.constraint(equalTo: container.topAnchor, constant: 20),
+ title.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
+
+ tabs.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 16),
+ tabs.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 20),
+ tabs.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -20),
+ tabs.bottomAnchor.constraint(equalTo: save.topAnchor, constant: -18),
+
+ cancel.trailingAnchor.constraint(equalTo: save.leadingAnchor, constant: -10),
+ cancel.centerYAnchor.constraint(equalTo: save.centerYAnchor),
+
+ save.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -24),
+ save.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -24)
+ ])
+
+ return container
+ }
+
+ private func tab(title: String, view: NSView) -> NSTabViewItem {
+ let item = NSTabViewItem(identifier: title)
+ item.label = title
+ item.view = view
+ return item
+ }
+
+ private func applyLocalizedControlTitles() {
+ let modeIndex = modePopup.indexOfSelectedItem
+ modePopup.removeAllItems()
+ modePopup.addItems(withTitles: [
+ LocalizedText.text("SOLIX-Konto (direkt)", "SOLIX account (direct)"),
+ "Demo",
+ LocalizedText.text("Demo (Warnungs-Test)", "Demo (warning test)"),
+ LocalizedText.text("Lokaler JSON-Befehl", "Local JSON command"),
+ "JSON-URL"
+ ])
+ if modeIndex >= 0 { modePopup.selectItem(at: modeIndex) }
+ modePopup.toolTip = LocalizedText.text(
+ "Legt fest, woher SolixBar die Werte lädt. \"SOLIX-Konto (direkt)\" fragt Anker mit Mail und Passwort direkt ab. \"Demo (Warnungs-Test)\" spielt ein gerafftes Szenario ab (Akku fällt, PV-Eingang stirbt, kompletter Einbruch), damit aktivierte Warnungen innerhalb weniger Minuten wirklich feuern.",
+ "Controls where SolixBar loads its values from. \"SOLIX account (direct)\" queries Anker directly with email and password. \"Demo (warning test)\" plays an accelerated scenario (battery drops, one PV input dies, full collapse) so enabled warnings actually fire within a few minutes."
+ )
+ solixTitle.stringValue = LocalizedText.text("SOLIX Login", "SOLIX login")
+ solixHint.stringValue = LocalizedText.text(
+ "SolixBar fragt die Werte direkt mit der mitgelieferten Laufzeit ab. Das Passwort liegt im macOS-Schlüsselbund, Mail und Land in einer lokalen Datei.",
+ "SolixBar queries the values directly using the bundled runtime. The password lives in the macOS Keychain; email and country in a local file."
+ )
+ let appearanceIndex = appearancePopup.indexOfSelectedItem
+ appearancePopup.removeAllItems()
+ appearancePopup.addItems(withTitles: [
+ LocalizedText.text("Automatisch", "Automatic"),
+ LocalizedText.text("Hell", "Light"),
+ LocalizedText.text("Dunkel", "Dark")
+ ])
+ if appearanceIndex >= 0 { appearancePopup.selectItem(at: appearanceIndex) }
+ showIconButton.title = LocalizedText.text("App-Symbol in der Menüleiste anzeigen", "Show app icon in the menu bar")
+ stackedButton.title = LocalizedText.text("Zweizeilige Kompaktanzeige", "Two-line compact display")
+ stackedDetachedButton.title = LocalizedText.text("Kompaktanzeige (zweizeilig)", "Compact display (two lines)")
+ detachedIconButton.title = LocalizedText.text("App-Symbol anzeigen", "Show app glyph")
+ detachedIconButton.toolTip = LocalizedText.text("Zeigt das Blitz-Symbol links in der Leiste.", "Shows the bolt glyph at the left of the bar.")
+ detachedLabelsButton.title = LocalizedText.text("Werte mit Bezeichnung anzeigen", "Show labels next to values")
+ detachedLabelsButton.toolTip = LocalizedText.text("Zeigt kurze Namen wie Akku oder Solar vor den Zahlen.", "Shows short names like Battery or Solar before the numbers.")
+ detachedSymbolsButton.title = LocalizedText.text("Symbole vor den Werten anzeigen", "Show symbols before values")
+ detachedSymbolsButton.toolTip = LocalizedText.text("Zeigt farbige Symbole direkt vor den Werten.", "Shows colored symbols right before the values.")
+ detachedArrowsButton.title = LocalizedText.text("Flussrichtung anzeigen", "Show flow direction")
+ detachedArrowsButton.toolTip = LocalizedText.text("Richtungspfeile und Begriffe wie Laden oder Bezug in der Leiste; Wattwerte ohne Vorzeichen.", "Direction arrows and terms like charging or import in the bar; watt values without sign.")
+ detachedFlowColorsButton.title = LocalizedText.text("Farbige Werte anzeigen", "Show colored values")
+ detachedFlowColorsButton.toolTip = LocalizedText.text("Färbt Werte und Symbole der Leiste nach ihrer Bedeutung.", "Colors the bar's values and symbols by meaning.")
+ graphFitButton.title = LocalizedText.text("Graph an vorhandene Daten anpassen", "Fit graph to available data")
+ graphSmoothButton.title = LocalizedText.text("Kurven glätten", "Smooth curves")
+ fillBatteryButton.title = LocalizedText.text("Akku", "Battery")
+ fillSolarButton.title = LocalizedText.text("Solar", "Solar")
+ fillGridButton.title = LocalizedText.text("Netzbezug", "Grid import")
+ showLabelsButton.title = LocalizedText.text("Werte mit Bezeichnung anzeigen", "Show labels next to values")
+ showMetricSymbolsButton.title = LocalizedText.text("Symbole vor den Werten anzeigen", "Show symbols before values")
+ showEnergyFlowArrowsButton.title = LocalizedText.text("Flussrichtung anzeigen", "Show flow direction")
+ showFlowColorsButton.title = LocalizedText.text("Farbige Werte anzeigen", "Show colored values")
+ lockDetachedMenuBarButton.title = LocalizedText.text("Abgedockte Leiste fixieren", "Lock detached slim bar")
+ autostartButton.title = LocalizedText.text("Beim Login automatisch starten", "Start automatically at login")
+ updateCheckButton.title = LocalizedText.text("Automatisch nach Updates suchen", "Check for updates automatically")
+ warnBatteryButton.title = LocalizedText.text("Bei niedrigem Akkustand warnen", "Warn when the battery is low")
+ warnBatteryButton.toolTip = LocalizedText.text(
+ "Meldet sich einmal, wenn der Akku unter die Schwelle fällt. Erst wenn er wieder 5 Punkte darüber liegt, wird die Warnung neu scharf geschaltet.",
+ "Fires once when the battery drops below the threshold. It re-arms only after climbing 5 points above it again."
+ )
+ warnPVStallButton.title = LocalizedText.text("Bei PV-Einbruch warnen", "Warn when PV output collapses")
+ warnPVStallButton.toolTip = LocalizedText.text(
+ "Warnt, wenn die Solarmodule auf 0 W fallen, obwohl sie in der letzten Stunde noch nennenswert erzeugt haben. Nachts bleibt es dadurch still.",
+ "Warns when the panels drop to 0 W although they produced meaningfully within the last hour. Stays silent at night as a result."
+ )
+ warnPVWindowButton.title = LocalizedText.text("Zusätzlich im Zeitfenster warnen", "Also warn within a time window")
+ warnPVWindowButton.toolTip = LocalizedText.text(
+ "Meldet 0 W auch ohne vorherige Erzeugung, solange die Uhrzeit im angegebenen Fenster liegt — z. B. wenn die Anlage schon morgens nicht anläuft.",
+ "Also reports 0 W without prior production while the time of day is inside the window — e.g. when the system never starts up in the morning."
+ )
+ warnPerPVButton.title = LocalizedText.text("Einzelne PV-Eingänge überwachen", "Monitor individual PV inputs")
+ warnPerPVButton.toolTip = LocalizedText.text(
+ "Warnt, wenn ein PV-Eingang dauerhaft 0 W liefert, während die anderen Eingänge erzeugen. Braucht eine Solarbank, die ihre MPPT-Kanäle einzeln meldet (Solarbank 2/3).",
+ "Warns when one PV input stays at 0 W while the other inputs are producing. Requires a Solarbank that reports its MPPT channels individually (Solarbank 2/3)."
+ )
+ warnPerPVDipButton.title = LocalizedText.text("Einbruch je PV-Eingang melden", "Report a dip per PV input")
+ warnPerPVDipButton.toolTip = LocalizedText.text(
+ "Warnt, wenn ein Eingang einbricht, der kurz zuvor selbst noch erzeugt hat — auch ohne Vergleich mit den anderen Eingängen. So fällt ein defektes Modul oder Kabel auf, selbst wenn mehrere Eingänge gleichzeitig betroffen sind. Nachts still.",
+ "Warns when an input collapses after it was recently producing itself — without comparing to the other inputs. Catches a defective panel or cable even when several inputs are affected at once. Silent at night."
+ )
+ warnBatteryThresholdField.toolTip = LocalizedText.text("Warnschwelle in Prozent (5–95).", "Warning threshold in percent (5–95).")
+ warnPVMinutesField.toolTip = LocalizedText.text(
+ "So viele Minuten muss die PV durchgehend 0 W liefern, bevor gewarnt wird (5–120).",
+ "How many minutes PV must stay at 0 W before warning (5–120)."
+ )
+ warnPVWattsField.toolTip = LocalizedText.text(
+ "Ab dieser Leistung gilt die Anlage als \"hat kürzlich erzeugt\" (10–2000 W).",
+ "Output at or above this counts as \"was recently producing\" (10–2000 W)."
+ )
+ warnPVWindowStartField.toolTip = LocalizedText.text("Beginn des Zeitfensters (Stunde, 0–23).", "Window start (hour, 0–23).")
+ warnPVWindowEndField.toolTip = LocalizedText.text("Ende des Zeitfensters (Stunde, 1–24).", "Window end (hour, 1–24).")
+ for popup in [dashboardPVPopup, detachedDashboardPVPopup, menuBarPVPopup, detachedPVPopup] {
+ let index = popup.indexOfSelectedItem
+ popup.removeAllItems()
+ popup.addItems(withTitles: PVDisplayMode.allCases.map(\.title))
+ if index >= 0 { popup.selectItem(at: index) }
+ }
+ dashboardPVPopup.toolTip = LocalizedText.text(
+ "PV-Wert im Dashboard des Menüs: nur die Summe, nur die Eingänge einzeln (\"438 · 204 W\" in der Kachel) oder Summe in der Kachel plus eigene Einzelwerte-Zeile. Einzelwerte brauchen eine Solarbank mit Kanal-Reporting (Solarbank 2/3), sonst bleibt es bei der Summe.",
+ "PV value in the menu's dashboard: total only, individual inputs only (\"438 · 204 W\" in the tile), or the total in the tile plus a separate per-input row. Individual values require a Solarbank with channel reporting (Solarbank 2/3); otherwise the total is shown."
+ )
+ detachedDashboardPVPopup.toolTip = LocalizedText.text(
+ "PV-Wert im abgedockten Dashboard-Fenster: Gesamtwert, Einzelwerte oder beides — unabhängig vom Menü-Dashboard einstellbar.",
+ "PV value in the detached dashboard window: total, individual inputs, or both — configurable independently of the menu dashboard."
+ )
+ menuBarPVPopup.toolTip = LocalizedText.text(
+ "PV-Wert in der Menüleiste: Summe (\"642W\"), Einzelwerte (\"438·204W\") oder beides (\"642W (438·204)\") — gilt für einzeilige und Kompaktansicht. Einzelwerte brauchen Kanal-Reporting (Solarbank 2/3).",
+ "PV value in the menu bar: total (\"642W\"), individual inputs (\"438·204W\"), or both (\"642W (438·204)\") — applies to single-line and compact views. Individual values require channel reporting (Solarbank 2/3)."
+ )
+ detachedPVPopup.toolTip = LocalizedText.text(
+ "PV-Wert der abgedockten Leiste: Summe, Einzelwerte oder beides — gilt für einzeilige und Kompaktansicht. Einzelwerte brauchen Kanal-Reporting (Solarbank 2/3).",
+ "PV value in the detached bar: total, individual inputs, or both — applies to single-line and compact views. Individual values require channel reporting (Solarbank 2/3)."
+ )
+ updateCheckButton.toolTip = LocalizedText.text(
+ "Fragt einmal täglich die GitHub-Releases ab. Bei einer neueren Version erscheint eine Mitteilung und ein Eintrag im Menü — installiert wird nichts automatisch.",
+ "Checks the GitHub releases once a day. A newer version shows a notification and a menu entry — nothing is installed automatically."
+ )
+ menuFollowButton.title = LocalizedText.text("Folgt der einzeiligen Liste", "Follows the single-line list")
+ detachedFollowButton.title = menuFollowButton.title
+ for follow in [menuFollowButton, detachedFollowButton] {
+ follow.toolTip = LocalizedText.text(
+ "Angehakt übernimmt die Kompaktansicht Auswahl und Reihenfolge der einzeiligen Ansicht. Abwählen macht sie unabhängig (eigene Häkchen, eigene Reihenfolge).",
+ "When checked, the compact view mirrors the single-line selection and order. Uncheck to make it independent (own checkboxes, own order)."
+ )
+ }
+ for segment in [menuMetricSegment, detachedMetricSegment] where segment.segmentCount == 2 {
+ segment.setLabel(LocalizedText.text("Einzeilig", "Single line"), forSegment: 0)
+ segment.setLabel(LocalizedText.text("Kompakt", "Compact"), forSegment: 1)
+ }
+ menuMetricList.reloadTitles()
+ detachedMetricList.reloadTitles()
+ for popup in [detachedLevelPopup, dashboardLevelPopup, graphLevelPopup] {
+ let index = popup.indexOfSelectedItem
+ popup.removeAllItems()
+ popup.addItems(withTitles: WindowLevelMode.allCases.map(\.title))
+ if index >= 0 { popup.selectItem(at: index) }
+ }
+ detachedLevelPopup.toolTip = LocalizedText.text(
+ "Legt fest, wo die Leiste im Fensterstapel liegt: über allen Fenstern, normal eingereiht oder auf dem Schreibtisch hinter allen Fenstern.",
+ "Controls where the bar sits in the window stack: above all windows, ordered like a normal window, or on the desktop behind everything."
+ )
+ dashboardLevelPopup.toolTip = LocalizedText.text(
+ "Gilt für das abgedockte Dashboard-Fenster: über allen Fenstern schwebend, normal eingereiht oder hinter allen Fenstern auf dem Schreibtisch.",
+ "Applies to the detached dashboard window: floating above all windows, ordered like a normal window, or on the desktop behind everything."
+ )
+ graphLevelPopup.toolTip = LocalizedText.text(
+ "Gilt für das abgedockte Verlaufsfenster: über allen Fenstern schwebend, normal eingereiht oder hinter allen Fenstern auf dem Schreibtisch.",
+ "Applies to the detached history window: floating above all windows, ordered like a normal window, or on the desktop behind everything."
+ )
+ }
+
+ private func menuBarPane() -> NSView {
+ let container = NSView()
+ let previewTitle = sectionTitle(LocalizedText.text("Vorschau", "Preview"))
+ menuBarPreview.imageScaling = .scaleNone
+ menuBarPreview.wantsLayer = true
+ menuBarPreview.layer?.cornerRadius = Theme.radiusChip
+ menuBarPreview.layer?.masksToBounds = true
+
+ let metricTitle = sectionTitle(LocalizedText.text("Angezeigte Werte", "Visible Values"))
+ let metricGrid = buildMetricListSection(detached: false)
+ let displayTitle = sectionTitle(LocalizedText.text("Darstellung", "Display"))
+ let showIconRow = settingRow(showIconButton, help: showIconButton.toolTip ?? "")
+ let stackedRow = settingRow(stackedButton, help: stackedButton.toolTip ?? "")
+ let showLabelsRow = settingRow(showLabelsButton, help: showLabelsButton.toolTip ?? "")
+ let showMetricSymbolsRow = settingRow(showMetricSymbolsButton, help: showMetricSymbolsButton.toolTip ?? "")
+ let showFlowColorsRow = settingRow(showFlowColorsButton, help: showFlowColorsButton.toolTip ?? "")
+ let showEnergyFlowArrowsRow = settingRow(showEnergyFlowArrowsButton, help: showEnergyFlowArrowsButton.toolTip ?? "")
+ let menuBarPerPVRow = NSStackView(views: [
+ label(LocalizedText.text("PV-Anzeige", "PV display")),
+ menuBarPVPopup,
+ helpButton(menuBarPVPopup.toolTip ?? "")
+ ])
+ menuBarPerPVRow.orientation = .horizontal
+ menuBarPerPVRow.spacing = 12
+ menuBarPerPVRow.alignment = .centerY
+ let scaleRow = NSStackView(views: [label(LocalizedText.text("Skalierung", "Scale")), scaleSlider, scaleValue, helpButton(labelTooltip("Skalierung"))])
+ scaleRow.orientation = .horizontal
+ scaleRow.spacing = 12
+ scaleRow.alignment = .centerY
+ scaleValue.alignment = .right
+ scaleValue.widthAnchor.constraint(equalToConstant: 56).isActive = true
+
+ for view in [previewTitle, menuBarPreview, metricTitle, metricGrid, displayTitle, showIconRow, stackedRow, showLabelsRow, showMetricSymbolsRow, showFlowColorsRow, showEnergyFlowArrowsRow, menuBarPerPVRow, scaleRow] {
+ view.translatesAutoresizingMaskIntoConstraints = false
+ container.addSubview(view)
+ }
+
+ NSLayoutConstraint.activate([
+ previewTitle.topAnchor.constraint(equalTo: container.topAnchor, constant: 18),
+ previewTitle.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
+
+ menuBarPreview.topAnchor.constraint(equalTo: previewTitle.bottomAnchor, constant: 8),
+ menuBarPreview.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
+ menuBarPreview.heightAnchor.constraint(equalToConstant: 62),
+
+ // Zwei Spalten: links die (hohe) Werte-Liste, rechts Darstellung.
+ metricTitle.topAnchor.constraint(equalTo: menuBarPreview.bottomAnchor, constant: 18),
+ metricTitle.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
+
+ metricGrid.topAnchor.constraint(equalTo: metricTitle.bottomAnchor, constant: 10),
+ metricGrid.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
+
+ displayTitle.topAnchor.constraint(equalTo: metricTitle.topAnchor),
+ displayTitle.leadingAnchor.constraint(equalTo: metricGrid.trailingAnchor, constant: 32),
+
+ showIconRow.topAnchor.constraint(equalTo: displayTitle.bottomAnchor, constant: 10),
+ showIconRow.leadingAnchor.constraint(equalTo: displayTitle.leadingAnchor),
+
+ stackedRow.topAnchor.constraint(equalTo: showIconRow.bottomAnchor, constant: 8),
+ stackedRow.leadingAnchor.constraint(equalTo: displayTitle.leadingAnchor),
+
+ showLabelsRow.topAnchor.constraint(equalTo: stackedRow.bottomAnchor, constant: 8),
+ showLabelsRow.leadingAnchor.constraint(equalTo: displayTitle.leadingAnchor),
+
+ showMetricSymbolsRow.topAnchor.constraint(equalTo: showLabelsRow.bottomAnchor, constant: 8),
+ showMetricSymbolsRow.leadingAnchor.constraint(equalTo: displayTitle.leadingAnchor),
+
+ showFlowColorsRow.topAnchor.constraint(equalTo: showMetricSymbolsRow.bottomAnchor, constant: 8),
+ showFlowColorsRow.leadingAnchor.constraint(equalTo: displayTitle.leadingAnchor),
+
+ showEnergyFlowArrowsRow.topAnchor.constraint(equalTo: showFlowColorsRow.bottomAnchor, constant: 8),
+ showEnergyFlowArrowsRow.leadingAnchor.constraint(equalTo: displayTitle.leadingAnchor),
+
+ menuBarPerPVRow.topAnchor.constraint(equalTo: showEnergyFlowArrowsRow.bottomAnchor, constant: 8),
+ menuBarPerPVRow.leadingAnchor.constraint(equalTo: displayTitle.leadingAnchor),
+
+ scaleRow.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
+ scaleRow.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -24)
+ ])
+ // Skalierung unter der jeweils tieferen Spalte.
+ let scaleBelowList = scaleRow.topAnchor.constraint(greaterThanOrEqualTo: metricGrid.bottomAnchor, constant: 16)
+ let scaleBelowRows = scaleRow.topAnchor.constraint(equalTo: menuBarPerPVRow.bottomAnchor, constant: 16)
+ scaleBelowRows.priority = .defaultHigh
+ NSLayoutConstraint.activate([scaleBelowList, scaleBelowRows])
+
+ return container
+ }
+
+ /// Eigener Tab für die abgedockte Leiste: eigene Werte-Auswahl,
+ /// Kompaktanzeige, Fixieren und Skalierung.
+ private func detachedPane() -> NSView {
+ let container = NSView()
+ let metricTitle = sectionTitle(LocalizedText.text("Angezeigte Werte", "Visible Values"))
+ let metricGrid = buildMetricListSection(detached: true)
+ let displayTitle = sectionTitle(LocalizedText.text("Darstellung", "Display"))
+ let stackedDetachedRow = settingRow(stackedDetachedButton, help: stackedDetachedButton.toolTip ?? "")
+ let iconRow = settingRow(detachedIconButton, help: detachedIconButton.toolTip ?? "")
+ let labelsRow = settingRow(detachedLabelsButton, help: detachedLabelsButton.toolTip ?? "")
+ let symbolsRow = settingRow(detachedSymbolsButton, help: detachedSymbolsButton.toolTip ?? "")
+ let colorsRow = settingRow(detachedFlowColorsButton, help: detachedFlowColorsButton.toolTip ?? "")
+ let arrowsRow = settingRow(detachedArrowsButton, help: detachedArrowsButton.toolTip ?? "")
+ let detachedPerPVRow = NSStackView(views: [
+ label(LocalizedText.text("PV-Anzeige", "PV display")),
+ detachedPVPopup,
+ helpButton(detachedPVPopup.toolTip ?? "")
+ ])
+ detachedPerPVRow.orientation = .horizontal
+ detachedPerPVRow.spacing = 12
+ detachedPerPVRow.alignment = .centerY
+ let lockRow = settingRow(lockDetachedMenuBarButton, help: lockDetachedMenuBarButton.toolTip ?? "")
+ let levelRow = NSStackView(views: [
+ label(LocalizedText.text("Fensterebene", "Window level")),
+ detachedLevelPopup,
+ helpButton(detachedLevelPopup.toolTip ?? "")
+ ])
+ levelRow.orientation = .horizontal
+ levelRow.spacing = 12
+ levelRow.alignment = .centerY
+ let detachedScaleRow = NSStackView(views: [label(LocalizedText.text("Skalierung", "Scale")), detachedScaleSlider, detachedScaleValue, helpButton(labelTooltip("Abgedockt"))])
+ detachedScaleRow.orientation = .horizontal
+ detachedScaleRow.spacing = 12
+ detachedScaleRow.alignment = .centerY
+ detachedScaleValue.alignment = .right
+ detachedScaleValue.widthAnchor.constraint(equalToConstant: 56).isActive = true
+ let hint = NSTextField(wrappingLabelWithString: LocalizedText.text(
+ "Die Leiste lässt sich frei verschieben: einfach am Hintergrund ziehen. Fixieren verhindert versehentliches Verschieben.",
+ "Move the bar anywhere by dragging its background. Locking prevents accidental moves."
+ ))
+ hint.textColor = .secondaryLabelColor
+
+ for view in [metricTitle, metricGrid, displayTitle, stackedDetachedRow, iconRow, labelsRow, symbolsRow, colorsRow, arrowsRow, detachedPerPVRow, lockRow, levelRow, detachedScaleRow, hint] {
+ view.translatesAutoresizingMaskIntoConstraints = false
+ container.addSubview(view)
+ }
+
+ NSLayoutConstraint.activate([
+ // Zwei Spalten wie im Menüleisten-Tab.
+ metricTitle.topAnchor.constraint(equalTo: container.topAnchor, constant: 22),
+ metricTitle.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
+
+ metricGrid.topAnchor.constraint(equalTo: metricTitle.bottomAnchor, constant: 10),
+ metricGrid.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
+
+ displayTitle.topAnchor.constraint(equalTo: metricTitle.topAnchor),
+ displayTitle.leadingAnchor.constraint(equalTo: metricGrid.trailingAnchor, constant: 32),
+
+ stackedDetachedRow.topAnchor.constraint(equalTo: displayTitle.bottomAnchor, constant: 10),
+ stackedDetachedRow.leadingAnchor.constraint(equalTo: displayTitle.leadingAnchor),
+
+ iconRow.topAnchor.constraint(equalTo: stackedDetachedRow.bottomAnchor, constant: 8),
+ iconRow.leadingAnchor.constraint(equalTo: displayTitle.leadingAnchor),
+
+ labelsRow.topAnchor.constraint(equalTo: iconRow.bottomAnchor, constant: 8),
+ labelsRow.leadingAnchor.constraint(equalTo: displayTitle.leadingAnchor),
+
+ symbolsRow.topAnchor.constraint(equalTo: labelsRow.bottomAnchor, constant: 8),
+ symbolsRow.leadingAnchor.constraint(equalTo: displayTitle.leadingAnchor),
+
+ colorsRow.topAnchor.constraint(equalTo: symbolsRow.bottomAnchor, constant: 8),
+ colorsRow.leadingAnchor.constraint(equalTo: displayTitle.leadingAnchor),
+
+ arrowsRow.topAnchor.constraint(equalTo: colorsRow.bottomAnchor, constant: 8),
+ arrowsRow.leadingAnchor.constraint(equalTo: displayTitle.leadingAnchor),
+
+ detachedPerPVRow.topAnchor.constraint(equalTo: arrowsRow.bottomAnchor, constant: 8),
+ detachedPerPVRow.leadingAnchor.constraint(equalTo: displayTitle.leadingAnchor),
+
+ lockRow.topAnchor.constraint(equalTo: detachedPerPVRow.bottomAnchor, constant: 8),
+ lockRow.leadingAnchor.constraint(equalTo: displayTitle.leadingAnchor),
+
+ levelRow.topAnchor.constraint(equalTo: lockRow.bottomAnchor, constant: 12),
+ levelRow.leadingAnchor.constraint(equalTo: displayTitle.leadingAnchor),
+
+ detachedScaleRow.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
+ detachedScaleRow.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -24),
+
+ hint.topAnchor.constraint(equalTo: detachedScaleRow.bottomAnchor, constant: 18),
+ hint.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
+ hint.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -24)
+ ])
+ // Skalierung unter der jeweils tieferen Spalte.
+ let scaleBelowList = detachedScaleRow.topAnchor.constraint(greaterThanOrEqualTo: metricGrid.bottomAnchor, constant: 16)
+ let scaleBelowRows = detachedScaleRow.topAnchor.constraint(equalTo: levelRow.bottomAnchor, constant: 12)
+ scaleBelowRows.priority = .defaultHigh
+ NSLayoutConstraint.activate([scaleBelowList, scaleBelowRows])
+
+ return container
+ }
+
+ private func dataSourcePane() -> NSView {
+ let container = NSView()
+ let title = sectionTitle(LocalizedText.text("Datenquelle", "Data Source"))
+ let hint = NSTextField(wrappingLabelWithString: "Die gewählte Datenquelle muss ein JSON-Objekt mit Feldern wie batteryPercent, solarWatts, homeWatts und updatedAt liefern. Mindestintervall: 60 Sekunden.")
+ hint.textColor = .secondaryLabelColor
+
+ let rows = NSStackView()
+ rows.orientation = .vertical
+ rows.spacing = 12
+
+ solixTitle.font = .boldSystemFont(ofSize: 13)
+ solixHint.textColor = .secondaryLabelColor
+
+ rows.addArrangedSubview(formRow(labelText: LocalizedText.text("Modus", "Mode"), control: modePopup))
+ rows.addArrangedSubview(solixTitle)
+ configure(row: solixEmailRow, labelText: LocalizedText.text("Mail", "Email"), control: solixEmailField)
+ configure(row: solixPasswordRow, labelText: LocalizedText.text("Passwort", "Password"), control: solixPasswordField)
+ configure(row: solixCountryRow, labelText: LocalizedText.text("Land", "Country"), control: solixCountryField)
+ configure(row: solixTodayBaseRow, labelText: LocalizedText.text("Ertrag heute", "Yield today"), control: solixTodayBaseField)
+ configure(row: solixTotalBaseRow, labelText: LocalizedText.text("Gesamtertrag", "Total yield"), control: solixTotalBaseField)
+ rows.addArrangedSubview(solixEmailRow)
+ rows.addArrangedSubview(solixPasswordRow)
+ rows.addArrangedSubview(solixCountryRow)
+ rows.addArrangedSubview(solixTodayBaseRow)
+ rows.addArrangedSubview(solixTotalBaseRow)
+ rows.addArrangedSubview(solixHint)
+ configure(row: commandRow, labelText: LocalizedText.text("Befehl", "Command"), control: commandField)
+ configure(row: urlRow, labelText: "URL", control: urlField)
+ rows.addArrangedSubview(commandRow)
+ rows.addArrangedSubview(urlRow)
+ rows.addArrangedSubview(formRow(labelText: LocalizedText.text("Intervall", "Interval"), control: intervalField))
+
+ for view in [title, rows, hint] {
+ view.translatesAutoresizingMaskIntoConstraints = false
+ container.addSubview(view)
+ }
+
+ NSLayoutConstraint.activate([
+ title.topAnchor.constraint(equalTo: container.topAnchor, constant: 22),
+ title.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
+
+ rows.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 16),
+ rows.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
+ rows.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -24),
+
+ hint.topAnchor.constraint(equalTo: rows.bottomAnchor, constant: 18),
+ hint.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
+ hint.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -24)
+ ])
+
+ return container
+ }
+
+ /// Darstellung, Sprache und Startverhalten in einem Tab — der frühere
+ /// eigene "Start"-Tab enthielt nur eine einzige Checkbox.
+ private func appPane() -> NSView {
+ let container = NSView()
+ let title = sectionTitle(LocalizedText.text("App-Darstellung", "App Appearance"))
+ let appearanceRow = formRow(labelText: LocalizedText.text("Design", "Theme"), control: appearancePopup)
+ let languageRow = formRow(labelText: LocalizedText.text("Sprache", "Language"), control: languagePopup)
+ let dashboardTitle = sectionTitle(LocalizedText.text("Dashboard", "Dashboard"))
+ let graphFitRow = settingRow(graphFitButton, help: graphFitButton.toolTip ?? "")
+ let graphSmoothRow = settingRow(graphSmoothButton, help: graphSmoothButton.toolTip ?? "")
+ let fillChecks = NSStackView(views: [fillBatteryButton, fillSolarButton, fillGridButton])
+ fillChecks.orientation = .horizontal
+ fillChecks.spacing = 10
+ let fillRow = NSStackView(views: [
+ label(LocalizedText.text("Flächen füllen", "Fill areas")),
+ fillChecks,
+ helpButton(fillSolarButton.toolTip ?? "")
+ ])
+ fillRow.orientation = .horizontal
+ fillRow.spacing = 12
+ fillRow.alignment = .centerY
+ customRangeField.alignment = .center
+ customRangeField.font = .monospacedDigitSystemFont(ofSize: 12, weight: .semibold)
+ customRangeField.target = self
+ customRangeField.action = #selector(applyPreview)
+ customRangeField.delegate = self
+ customRangeField.toolTip = LocalizedText.text(
+ "Dauer des eigenen Zeitraums (Chip \"Eig.\" im Verlauf).",
+ "Duration of the custom range (chip \"Eig.\" in the history)."
+ )
+ customRangeUnitPopup.target = self
+ customRangeUnitPopup.action = #selector(applyPreview)
+ customRangeUnitPopup.addItems(withTitles: [
+ LocalizedText.text("Stunden", "hours"),
+ LocalizedText.text("Tage", "days"),
+ LocalizedText.text("Wochen", "weeks"),
+ LocalizedText.text("Monate", "months")
+ ])
+ let customRangeStack = NSStackView(views: [customRangeField, customRangeUnitPopup])
+ customRangeStack.orientation = .horizontal
+ customRangeStack.spacing = 8
+ customRangeField.widthAnchor.constraint(equalToConstant: 64).isActive = true
+ let customRangeRow = NSStackView(views: [
+ label(LocalizedText.text("Eig. Zeitraum", "Custom range")),
+ customRangeStack,
+ helpButton(customRangeField.toolTip ?? "")
+ ])
+ customRangeRow.orientation = .horizontal
+ customRangeRow.spacing = 12
+ customRangeRow.alignment = .centerY
+ let dashboardLevelRow = NSStackView(views: [
+ label(LocalizedText.text("Fensterebene", "Window level")),
+ dashboardLevelPopup,
+ helpButton(dashboardLevelPopup.toolTip ?? "")
+ ])
+ dashboardLevelRow.orientation = .horizontal
+ dashboardLevelRow.spacing = 12
+ dashboardLevelRow.alignment = .centerY
+ let graphLevelRow = NSStackView(views: [
+ label(LocalizedText.text("Verlaufsfenster", "History window")),
+ graphLevelPopup,
+ helpButton(graphLevelPopup.toolTip ?? "")
+ ])
+ graphLevelRow.orientation = .horizontal
+ graphLevelRow.spacing = 12
+ graphLevelRow.alignment = .centerY
+ let perPVRow = NSStackView(views: [
+ label(LocalizedText.text("PV-Anzeige (Menü)", "PV display (menu)")),
+ dashboardPVPopup,
+ helpButton(dashboardPVPopup.toolTip ?? "")
+ ])
+ perPVRow.orientation = .horizontal
+ perPVRow.spacing = 12
+ perPVRow.alignment = .centerY
+ let detachedDashboardPVRow = NSStackView(views: [
+ label(LocalizedText.text("PV-Anzeige (Fenster)", "PV display (window)")),
+ detachedDashboardPVPopup,
+ helpButton(detachedDashboardPVPopup.toolTip ?? "")
+ ])
+ detachedDashboardPVRow.orientation = .horizontal
+ detachedDashboardPVRow.spacing = 12
+ detachedDashboardPVRow.alignment = .centerY
+ let startTitle = sectionTitle(LocalizedText.text("Startverhalten", "Startup"))
+ let autostartRow = settingRow(autostartButton, help: autostartButton.toolTip ?? "")
+ let updateCheckRow = settingRow(updateCheckButton, help: updateCheckButton.toolTip ?? "")
+ let hint = NSTextField(wrappingLabelWithString: LocalizedText.text(
+ "Änderungen wirken sofort als Vorschau. Erst Speichern macht sie dauerhaft.",
+ "Changes apply immediately as a preview. Press Save to keep them."
+ ))
+ hint.textColor = .secondaryLabelColor
+
+ for view in [title, appearanceRow, languageRow, dashboardTitle, graphFitRow, graphSmoothRow, fillRow, perPVRow, detachedDashboardPVRow, customRangeRow, dashboardLevelRow, graphLevelRow, startTitle, autostartRow, autostartStatus, updateCheckRow, hint] {
+ view.translatesAutoresizingMaskIntoConstraints = false
+ container.addSubview(view)
+ }
+
+ NSLayoutConstraint.activate([
+ title.topAnchor.constraint(equalTo: container.topAnchor, constant: 22),
+ title.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
+
+ appearanceRow.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 16),
+ appearanceRow.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
+
+ languageRow.topAnchor.constraint(equalTo: appearanceRow.bottomAnchor, constant: 12),
+ languageRow.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
+
+ dashboardTitle.topAnchor.constraint(equalTo: languageRow.bottomAnchor, constant: 24),
+ dashboardTitle.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
+
+ graphFitRow.topAnchor.constraint(equalTo: dashboardTitle.bottomAnchor, constant: 10),
+ graphFitRow.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
+
+ graphSmoothRow.topAnchor.constraint(equalTo: graphFitRow.bottomAnchor, constant: 10),
+ graphSmoothRow.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
+
+ fillRow.topAnchor.constraint(equalTo: graphSmoothRow.bottomAnchor, constant: 10),
+ fillRow.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
+
+ perPVRow.topAnchor.constraint(equalTo: fillRow.bottomAnchor, constant: 10),
+ perPVRow.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
+
+ detachedDashboardPVRow.topAnchor.constraint(equalTo: perPVRow.bottomAnchor, constant: 10),
+ detachedDashboardPVRow.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
+
+ customRangeRow.topAnchor.constraint(equalTo: detachedDashboardPVRow.bottomAnchor, constant: 10),
+ customRangeRow.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
+
+ dashboardLevelRow.topAnchor.constraint(equalTo: customRangeRow.bottomAnchor, constant: 10),
+ dashboardLevelRow.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
+
+ graphLevelRow.topAnchor.constraint(equalTo: dashboardLevelRow.bottomAnchor, constant: 10),
+ graphLevelRow.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
+
+ startTitle.topAnchor.constraint(equalTo: graphLevelRow.bottomAnchor, constant: 24),
+ startTitle.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
+
+ autostartRow.topAnchor.constraint(equalTo: startTitle.bottomAnchor, constant: 12),
+ autostartRow.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
+
+ autostartStatus.topAnchor.constraint(equalTo: autostartRow.bottomAnchor, constant: 8),
+ autostartStatus.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
+ autostartStatus.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -24),
+
+ updateCheckRow.topAnchor.constraint(equalTo: autostartStatus.bottomAnchor, constant: 12),
+ updateCheckRow.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
+
+ hint.topAnchor.constraint(equalTo: updateCheckRow.bottomAnchor, constant: 18),
+ hint.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
+ hint.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -24)
+ ])
+
+ return container
+ }
+
+ private func warningsPane() -> NSView {
+ let container = NSView()
+
+ let batteryTitle = sectionTitle(LocalizedText.text("Akku", "Battery"))
+ let batteryRow = settingRow(warnBatteryButton, help: warnBatteryButton.toolTip ?? "")
+ warnBatteryThresholdField.widthAnchor.constraint(equalToConstant: 64).isActive = true
+ let batteryThresholdStack = NSStackView(views: [warnBatteryThresholdField, NSTextField(labelWithString: "%")])
+ batteryThresholdStack.orientation = .horizontal
+ batteryThresholdStack.spacing = 6
+ let batteryThresholdRow = NSStackView(views: [
+ label(LocalizedText.text("Warnschwelle", "Threshold")),
+ batteryThresholdStack,
+ helpButton(warnBatteryThresholdField.toolTip ?? "")
+ ])
+
+ let pvTitle = sectionTitle("PV")
+ let pvStallRow = settingRow(warnPVStallButton, help: warnPVStallButton.toolTip ?? "")
+ warnPVMinutesField.widthAnchor.constraint(equalToConstant: 64).isActive = true
+ let pvMinutesStack = NSStackView(views: [warnPVMinutesField, NSTextField(labelWithString: LocalizedText.text("Minuten", "minutes"))])
+ pvMinutesStack.orientation = .horizontal
+ pvMinutesStack.spacing = 6
+ let pvMinutesRow = NSStackView(views: [
+ label(LocalizedText.text("Dauer", "Duration")),
+ pvMinutesStack,
+ helpButton(warnPVMinutesField.toolTip ?? "")
+ ])
+ warnPVWattsField.widthAnchor.constraint(equalToConstant: 64).isActive = true
+ let pvWattsStack = NSStackView(views: [warnPVWattsField, NSTextField(labelWithString: "W")])
+ pvWattsStack.orientation = .horizontal
+ pvWattsStack.spacing = 6
+ let pvWattsRow = NSStackView(views: [
+ label(LocalizedText.text("Erzeugung", "Production")),
+ pvWattsStack,
+ helpButton(warnPVWattsField.toolTip ?? "")
+ ])
+ let pvWindowRow = settingRow(warnPVWindowButton, help: warnPVWindowButton.toolTip ?? "")
+ warnPVWindowStartField.widthAnchor.constraint(equalToConstant: 48).isActive = true
+ warnPVWindowEndField.widthAnchor.constraint(equalToConstant: 48).isActive = true
+ let windowStack = NSStackView(views: [
+ warnPVWindowStartField,
+ NSTextField(labelWithString: LocalizedText.text("bis", "to")),
+ warnPVWindowEndField,
+ NSTextField(labelWithString: LocalizedText.text("Uhr", "o'clock"))
+ ])
+ windowStack.orientation = .horizontal
+ windowStack.spacing = 6
+ let windowFieldsRow = NSStackView(views: [
+ label(LocalizedText.text("Zeitfenster", "Window")),
+ windowStack,
+ helpButton(warnPVWindowStartField.toolTip ?? "")
+ ])
+
+ let channelsTitle = sectionTitle(LocalizedText.text("PV-Eingänge", "PV Inputs"))
+ let perPVWarnRow = settingRow(warnPerPVButton, help: warnPerPVButton.toolTip ?? "")
+ let perPVDipRow = settingRow(warnPerPVDipButton, help: warnPerPVDipButton.toolTip ?? "")
+
+ let hint = NSTextField(wrappingLabelWithString: LocalizedText.text(
+ "Warnungen erscheinen als macOS-Mitteilung (beim ersten Mal fragt das System um Erlaubnis) und zusätzlich oben im SolixBar-Menü, solange die Bedingung anhält.",
+ "Warnings appear as macOS notifications (the system asks for permission the first time) and additionally at the top of the SolixBar menu while the condition persists."
+ ))
+ hint.textColor = .secondaryLabelColor
+
+ for row in [batteryThresholdRow, pvMinutesRow, pvWattsRow, windowFieldsRow] {
+ row.orientation = .horizontal
+ row.spacing = 12
+ row.alignment = .centerY
+ }
+
+ let stack = NSStackView(views: [
+ batteryTitle, batteryRow, batteryThresholdRow,
+ pvTitle, pvStallRow, pvMinutesRow, pvWattsRow, pvWindowRow, windowFieldsRow,
+ channelsTitle, perPVWarnRow, perPVDipRow,
+ hint
+ ])
+ stack.orientation = .vertical
+ stack.alignment = .leading
+ stack.spacing = 10
+ stack.setCustomSpacing(20, after: batteryThresholdRow)
+ stack.setCustomSpacing(20, after: windowFieldsRow)
+ stack.setCustomSpacing(18, after: perPVDipRow)
+ stack.translatesAutoresizingMaskIntoConstraints = false
+ container.addSubview(stack)
+
+ NSLayoutConstraint.activate([
+ stack.topAnchor.constraint(equalTo: container.topAnchor, constant: 22),
+ stack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 24),
+ stack.trailingAnchor.constraint(lessThanOrEqualTo: container.trailingAnchor, constant: -24)
+ ])
+
+ return container
+ }
+
+ private func formRow(labelText: String, control: NSView) -> NSStackView {
+ let row = NSStackView()
+ configure(row: row, labelText: labelText, control: control)
+ return row
+ }
+
+ private func configure(row: NSStackView, labelText: String, control: NSView) {
+ row.orientation = .horizontal
+ row.spacing = 12
+ row.alignment = .centerY
+
+ let rowLabel = label(labelText)
+ rowLabel.alignment = .right
+ rowLabel.translatesAutoresizingMaskIntoConstraints = false
+ control.translatesAutoresizingMaskIntoConstraints = false
+ row.addArrangedSubview(rowLabel)
+ row.addArrangedSubview(control)
+ row.addArrangedSubview(helpButton(control.toolTip ?? labelTooltip(labelText)))
+
+ NSLayoutConstraint.activate([
+ rowLabel.widthAnchor.constraint(equalToConstant: 88),
+ control.widthAnchor.constraint(equalToConstant: 420)
+ ])
+ }
+
+
+ /// Werte-Auswahl mit Reihenfolge: Segment schaltet zwischen der
+ /// einzeiligen und der Kompakt-Liste um, Häkchen wählen aus, Ziehen
+ /// sortiert. Die Kompakt-Liste folgt der einzeiligen, bis sie über die
+ /// Checkbox entkoppelt wird.
+ private func buildMetricListSection(detached: Bool) -> NSView {
+ let segment = detached ? detachedMetricSegment : menuMetricSegment
+ let list = detached ? detachedMetricList : menuMetricList
+ let follow = detached ? detachedFollowButton : menuFollowButton
+
+ segment.segmentCount = 2
+ segment.setLabel(LocalizedText.text("Einzeilig", "Single line"), forSegment: 0)
+ segment.setLabel(LocalizedText.text("Kompakt", "Compact"), forSegment: 1)
+ segment.selectedSegment = 0
+ segment.target = self
+ segment.action = #selector(metricSegmentChanged(_:))
+ segment.toolTip = LocalizedText.text(
+ "Einzeilig und Kompakt (zweizeilig) haben je eine eigene Auswahl und Reihenfolge.",
+ "Single-line and compact (two-line) views each have their own selection and order."
+ )
+
+ follow.target = self
+ follow.action = #selector(metricFollowToggled(_:))
+ follow.isHidden = true
+
+ list.onChange = { [weak self] in
+ guard let self else { return }
+ self.storeActiveMetricList(detached: detached)
+ self.applyPreview()
+ }
+
+ let hint = NSTextField(wrappingLabelWithString: LocalizedText.text(
+ "Häkchen wählt die Werte aus, Ziehen ändert die Reihenfolge.",
+ "Checkboxes pick the values, dragging changes the order."
+ ))
+ hint.textColor = .secondaryLabelColor
+ hint.font = .systemFont(ofSize: 11)
+
+ let stack = NSStackView(views: [segment, list, follow, hint])
+ stack.orientation = .vertical
+ stack.alignment = .leading
+ stack.spacing = 8
+ return stack
+ }
+
+ private func activeMetricListKind(detached: Bool) -> MetricListKind {
+ let segment = detached ? detachedMetricSegment : menuMetricSegment
+ let stacked = segment.selectedSegment == 1
+ switch (detached, stacked) {
+ case (false, false): return .dockedNormal
+ case (false, true): return .dockedStacked
+ case (true, false): return .detachedNormal
+ case (true, true): return .detachedStacked
+ }
+ }
+
+ /// Übernimmt den sichtbaren Listen-Zustand in die Arbeitskopie — außer im
+ /// "Folgt"-Modus, wo die Liste nur die einzeilige Auswahl spiegelt.
+ private func storeActiveMetricList(detached: Bool) {
+ let kind = activeMetricListKind(detached: detached)
+ let follows = detached ? detachedStackedFollows : dockedStackedFollows
+ if (kind == .dockedStacked || kind == .detachedStacked) && follows { return }
+ let list = detached ? detachedMetricList : menuMetricList
+ metricListStates[kind] = MetricListState(order: list.orderedMetrics, selected: list.selected)
+ }
+
+ /// Zeigt die Arbeitskopie des aktiven Segments an; die Kompakt-Liste im
+ /// "Folgt"-Modus zeigt die einzeilige Liste ausgegraut.
+ private func displayActiveMetricList(detached: Bool) {
+ let kind = activeMetricListKind(detached: detached)
+ let list = detached ? detachedMetricList : menuMetricList
+ let follow = detached ? detachedFollowButton : menuFollowButton
+ let isStacked = kind == .dockedStacked || kind == .detachedStacked
+ let follows = detached ? detachedStackedFollows : dockedStackedFollows
+
+ follow.isHidden = !isStacked
+ follow.state = follows ? .on : .off
+
+ let sourceKind: MetricListKind = if isStacked && follows {
+ detached ? .detachedNormal : .dockedNormal
+ } else {
+ kind
+ }
+ let state = metricListStates[sourceKind]
+ ?? MetricListState(order: BarMetric.allCases, selected: Set(BarMetric.allCases))
+ list.load(order: state.order, selected: state.selected)
+ list.isListEnabled = !(isStacked && follows)
+ }
+
+ @objc private func metricSegmentChanged(_ sender: NSSegmentedControl) {
+ displayActiveMetricList(detached: sender == detachedMetricSegment)
+ }
+
+ @objc private func metricFollowToggled(_ sender: NSButton) {
+ let detached = sender == detachedFollowButton
+ let follows = sender.state == .on
+ if detached {
+ detachedStackedFollows = follows
+ } else {
+ dockedStackedFollows = follows
+ }
+ if !follows {
+ // Entkoppeln: aktuelle einzeilige Liste als Startzustand übernehmen.
+ let normal = metricListStates[detached ? .detachedNormal : .dockedNormal]
+ metricListStates[detached ? .detachedStacked : .dockedStacked] = normal
+ }
+ displayActiveMetricList(detached: detached)
+ applyPreview()
+ }
+
+ private func sectionTitle(_ text: String) -> NSTextField {
+ let label = NSTextField(labelWithString: text)
+ label.font = .boldSystemFont(ofSize: 13)
+ return label
+ }
+
+ private func label(_ text: String) -> NSTextField {
+ let label = NSTextField(labelWithString: text)
+ label.textColor = .secondaryLabelColor
+ label.toolTip = labelTooltip(text)
+ return label
+ }
+
+ private func settingRow(_ control: NSView, help: String) -> NSStackView {
+ let row = NSStackView(views: [control, helpButton(help)])
+ row.orientation = .horizontal
+ row.alignment = .centerY
+ row.spacing = 6
+ return row
+ }
+
+ /// Dezentes, klickbares Hilfesymbol: öffnet ein Popover mit dem Hilfetext.
+ /// Vorher: fettes "?"-Textlabel, das klickbar aussah, aber nur einen
+ /// Hover-Tooltip hatte.
+ private func helpButton(_ tooltip: String) -> NSButton {
+ let button = NSButton(title: "", target: self, action: #selector(showHelpPopover(_:)))
+ button.isBordered = false
+ button.image = Self.helpGlyph
+ button.contentTintColor = .tertiaryLabelColor
+ button.toolTip = tooltip
+ button.setButtonType(.momentaryChange)
+ button.translatesAutoresizingMaskIntoConstraints = false
+ button.widthAnchor.constraint(equalToConstant: 18).isActive = true
+ button.heightAnchor.constraint(equalToConstant: 18).isActive = true
+ return button
+ }
+
+ private static let helpGlyph: NSImage? = {
+ let image = NSImage(systemSymbolName: "questionmark.circle", accessibilityDescription: "Hilfe")
+ return image?.withSymbolConfiguration(.init(pointSize: 12, weight: .medium))
+ }()
+
+ @objc private func showHelpPopover(_ sender: NSButton) {
+ guard let shortText = sender.toolTip, !shortText.isEmpty else { return }
+ // Klick zeigt den ausführlichen Text aus dem Hilfe-Katalog; der
+ // Hover-Tooltip bleibt der kurze Hinweis.
+ let text = SettingsHelp.extended(for: shortText) ?? shortText
+ let (container, size) = Self.helpPopoverContent(for: text)
+ let controller = NSViewController()
+ controller.view = container
+ let popover = NSPopover()
+ popover.behavior = .transient
+ popover.contentViewController = controller
+ popover.contentSize = size
+ popover.show(relativeTo: sender.bounds, of: sender, preferredEdge: .maxY)
+ }
+
+ /// Baut den Popover-Inhalt mit expliziten Frames und misst die Höhe
+ /// selbst. Der frühere Autolayout-Weg lieferte über fittingSize eine
+ /// 0-Grösse (Container ohne translatesAutoresizingMask-Abschaltung) —
+ /// das Popover erschien als leere Sprechblase.
+ static func helpPopoverContent(for text: String) -> (view: NSView, size: NSSize) {
+ let font = NSFont.systemFont(ofSize: 12)
+ let textWidth: CGFloat = 300
+ let padding = NSSize(width: 14, height: 12)
+
+ let measured = NSAttributedString(string: text, attributes: [.font: font])
+ .boundingRect(
+ with: NSSize(width: textWidth, height: .greatestFiniteMagnitude),
+ options: [.usesLineFragmentOrigin, .usesFontLeading]
+ )
+ let textHeight = ceil(measured.height)
+ let size = NSSize(width: textWidth + padding.width * 2, height: textHeight + padding.height * 2)
+
+ let label = NSTextField(wrappingLabelWithString: text)
+ label.font = font
+ label.textColor = .labelColor
+ label.isSelectable = false
+ label.frame = NSRect(x: padding.width, y: padding.height, width: textWidth, height: textHeight)
+
+ let container = NSView(frame: NSRect(origin: .zero, size: size))
+ container.addSubview(label)
+ return (container, size)
+ }
+
+ private func labelTooltip(_ text: String) -> String {
+ switch text {
+ case "Skalierung", "Scale":
+ return "Passt die Größe der Menüleistenanzeige an."
+ case "Abgedockt", "Detached":
+ return "Passt nur die Größe der abgedockten Menüleistenleiste an."
+ case "Modus", "Mode":
+ return "Legt fest, woher SolixBar die Werte lädt."
+ case "Mail", "Email":
+ return "E-Mail-Adresse deines Anker/SOLIX-Kontos."
+ case "Passwort", "Password":
+ return "Passwort deines Anker/SOLIX-Kontos."
+ case "Land", "Country":
+ return "Land des Anker-Kontos, meistens DE."
+ case "Ertrag heute", "Yield today":
+ return "Korrigiert den heutigen Ertrag in kWh, wenn Anker für heute 0 kWh liefert."
+ case "Gesamtertrag", "Total yield":
+ return LocalizedText.text(
+ "Setzt optional den Gesamtertrag aus der Anker-App als Startwert. Ohne API-Gesamtwert zählt SolixBar alle fortlaufenden Messungen zusammen.",
+ "Optionally sets the Anker app total as a starting value. Without an API total, SolixBar adds up all continuous measurements."
+ )
+ case "Befehl", "Command":
+ return "Der lokale Befehl muss ein JSON-Objekt ausgeben."
+ case "URL":
+ return "Die Adresse muss ein JSON-Objekt liefern."
+ case "Intervall", "Interval":
+ return "Legt fest, wie oft neue Daten geholt werden."
+ case "Design", "Theme":
+ return "Wählt Hell, Dunkel oder Automatisch passend zum System."
+ case "Sprache", "Language":
+ return "Wählt Deutsch oder Englisch für sichtbare App-Texte."
+ default:
+ return text
+ }
+ }
+
+ private func loadSettings() {
+ isLoading = true
+ switch settings.dataSourceMode {
+ case .solix:
+ modePopup.selectItem(at: 0)
+ case .demo:
+ modePopup.selectItem(at: 1)
+ case .demoWarnings:
+ modePopup.selectItem(at: 2)
+ case .command:
+ modePopup.selectItem(at: 3)
+ case .url:
+ modePopup.selectItem(at: 4)
+ }
+ switch settings.appearanceMode {
+ case .system:
+ appearancePopup.selectItem(at: 0)
+ case .light:
+ appearancePopup.selectItem(at: 1)
+ case .dark:
+ appearancePopup.selectItem(at: 2)
+ }
+ languagePopup.selectItem(at: settings.appLanguage == .english ? 1 : 0)
+ commandField.stringValue = settings.command
+ urlField.stringValue = settings.urlString
+ intervalField.stringValue = String(Int(settings.refreshInterval))
+ loadSolixCredentials()
+ showIconButton.state = settings.showMenuBarIcon ? .on : .off
+ stackedButton.state = settings.menuBarStacked ? .on : .off
+ stackedDetachedButton.state = settings.detachedBarStacked ? .on : .off
+ graphFitButton.state = settings.graphFitsData ? .on : .off
+ graphSmoothButton.state = settings.graphSmoothing ? .on : .off
+ fillBatteryButton.state = settings.graphFilledMetrics.contains(.battery) ? .on : .off
+ fillSolarButton.state = settings.graphFilledMetrics.contains(.solar) ? .on : .off
+ fillGridButton.state = settings.graphFilledMetrics.contains(.grid) ? .on : .off
+ customRangeField.stringValue = String(
+ HistoryGraphMenuView.customValue(days: settings.customHistoryDays, unit: settings.customHistoryUnit)
+ )
+ let unitIndex = ["hours", "days", "weeks", "months"].firstIndex(of: settings.customHistoryUnit) ?? 1
+ customRangeUnitPopup.selectItem(at: unitIndex)
+ detachedLevelPopup.selectItem(at: WindowLevelMode.allCases.firstIndex(of: settings.detachedBarLevel) ?? 0)
+ dashboardLevelPopup.selectItem(at: WindowLevelMode.allCases.firstIndex(of: settings.dashboardWindowLevel) ?? 0)
+ graphLevelPopup.selectItem(at: WindowLevelMode.allCases.firstIndex(of: settings.graphWindowLevel) ?? 0)
+ detachedIconButton.state = settings.detachedShowIcon ? .on : .off
+ detachedLabelsButton.state = settings.detachedShowLabels ? .on : .off
+ detachedSymbolsButton.state = settings.detachedShowSymbols ? .on : .off
+ detachedArrowsButton.state = settings.detachedShowArrows ? .on : .off
+ showLabelsButton.state = settings.showMetricLabels ? .on : .off
+ showMetricSymbolsButton.state = settings.showMenuBarMetricSymbols ? .on : .off
+ showEnergyFlowArrowsButton.state = settings.showEnergyFlowArrows ? .on : .off
+ showFlowColorsButton.state = settings.showFlowColors ? .on : .off
+ detachedFlowColorsButton.state = settings.detachedShowFlowColors ? .on : .off
+ lockDetachedMenuBarButton.state = settings.lockDetachedMenuBar ? .on : .off
+ scaleSlider.doubleValue = settings.menuBarScale
+ scaleValue.stringValue = "\(Int(round(scaleSlider.doubleValue * 100))) %"
+ detachedScaleSlider.doubleValue = settings.detachedMenuBarScale
+ detachedScaleValue.stringValue = "\(Int(round(detachedScaleSlider.doubleValue * 100))) %"
+
+ metricListStates[.dockedNormal] = MetricListState(
+ order: settings.barMetrics, selected: Set(settings.barMetrics)
+ )
+ let dockedStacked = settings.stackedBarMetrics
+ dockedStackedFollows = dockedStacked.isEmpty
+ metricListStates[.dockedStacked] = MetricListState(
+ order: settings.effectiveStackedBarMetrics,
+ selected: Set(settings.effectiveStackedBarMetrics)
+ )
+ metricListStates[.detachedNormal] = MetricListState(
+ order: settings.detachedBarMetrics, selected: Set(settings.detachedBarMetrics)
+ )
+ let detachedStacked = settings.detachedStackedBarMetrics
+ detachedStackedFollows = detachedStacked.isEmpty
+ metricListStates[.detachedStacked] = MetricListState(
+ order: settings.effectiveDetachedStackedBarMetrics,
+ selected: Set(settings.effectiveDetachedStackedBarMetrics)
+ )
+ displayActiveMetricList(detached: false)
+ displayActiveMetricList(detached: true)
+ updateCheckButton.state = settings.updateCheckEnabled ? .on : .off
+ dashboardPVPopup.selectItem(at: PVDisplayMode.allCases.firstIndex(of: settings.dashboardPVDisplay) ?? 0)
+ detachedDashboardPVPopup.selectItem(at: PVDisplayMode.allCases.firstIndex(of: settings.detachedDashboardPVDisplay) ?? 0)
+ menuBarPVPopup.selectItem(at: PVDisplayMode.allCases.firstIndex(of: settings.menuBarPVDisplay) ?? 0)
+ detachedPVPopup.selectItem(at: PVDisplayMode.allCases.firstIndex(of: settings.detachedPVDisplay) ?? 0)
+ warnBatteryButton.state = settings.warnBatteryLowEnabled ? .on : .off
+ warnBatteryThresholdField.stringValue = String(settings.warnBatteryLowThreshold)
+ warnPVStallButton.state = settings.warnPVStallEnabled ? .on : .off
+ warnPVMinutesField.stringValue = String(settings.warnPVStallMinutes)
+ warnPVWattsField.stringValue = String(settings.warnPVStallMinRecentWatts)
+ warnPVWindowButton.state = settings.warnPVWindowEnabled ? .on : .off
+ warnPVWindowStartField.stringValue = String(settings.warnPVWindowStart)
+ warnPVWindowEndField.stringValue = String(settings.warnPVWindowEnd)
+ warnPerPVButton.state = settings.warnPerPVEnabled ? .on : .off
+ warnPerPVDipButton.state = settings.warnPerPVDipEnabled ? .on : .off
+ refreshAutostartState()
+ updateDataSourceFieldVisibility()
+ updateMenuBarPreview()
+ isLoading = false
+ }
+
+ private func refreshAutostartState(message: String? = nil) {
+ autostartButton.state = AutostartManager.isEnabled ? .on : .off
+ if let message {
+ autostartStatus.stringValue = message
+ } else {
+ autostartStatus.stringValue = AutostartManager.isEnabled
+ ? "Autostart ist aktiv."
+ : "Autostart ist deaktiviert."
+ }
+ }
+
+ private func applyControlsToSettings() {
+ switch modePopup.indexOfSelectedItem {
+ case 0:
+ settings.dataSourceMode = .solix
+ case 2:
+ settings.dataSourceMode = .demoWarnings
+ case 3:
+ settings.dataSourceMode = .command
+ case 4:
+ settings.dataSourceMode = .url
+ default:
+ settings.dataSourceMode = .demo
+ }
+ switch appearancePopup.indexOfSelectedItem {
+ case 1:
+ settings.appearanceMode = .light
+ case 2:
+ settings.appearanceMode = .dark
+ default:
+ settings.appearanceMode = .system
+ }
+ settings.appLanguage = languagePopup.indexOfSelectedItem == 1 ? .english : .german
+ settings.command = commandField.stringValue
+ settings.urlString = urlField.stringValue
+ settings.refreshInterval = TimeInterval(max(60, intervalField.integerValue))
+ storeActiveMetricList(detached: false)
+ storeActiveMetricList(detached: true)
+ func orderedResult(_ kind: MetricListKind) -> [BarMetric] {
+ guard let state = metricListStates[kind] else { return [] }
+ return state.order.filter(state.selected.contains)
+ }
+ settings.barMetrics = orderedResult(.dockedNormal)
+ settings.detachedBarMetrics = orderedResult(.detachedNormal)
+ settings.stackedBarMetrics = dockedStackedFollows ? [] : orderedResult(.dockedStacked)
+ settings.detachedStackedBarMetrics = detachedStackedFollows ? [] : orderedResult(.detachedStacked)
+ settings.showMenuBarIcon = showIconButton.state == .on
+ settings.menuBarStacked = stackedButton.state == .on
+ settings.detachedBarStacked = stackedDetachedButton.state == .on
+ settings.graphFitsData = graphFitButton.state == .on
+ settings.graphSmoothing = graphSmoothButton.state == .on
+ var filledMetrics: [GraphMetric] = []
+ if fillBatteryButton.state == .on { filledMetrics.append(.battery) }
+ if fillSolarButton.state == .on { filledMetrics.append(.solar) }
+ if fillGridButton.state == .on { filledMetrics.append(.grid) }
+ settings.graphFilledMetrics = filledMetrics
+ let unitKeys = ["hours", "days", "weeks", "months"]
+ let selectedUnit = unitKeys[max(0, min(unitKeys.count - 1, customRangeUnitPopup.indexOfSelectedItem))]
+ settings.customHistoryUnit = selectedUnit
+ let value = max(1, customRangeField.integerValue)
+ settings.customHistoryDays = min(365, HistoryGraphMenuView.days(fromValue: value, unit: selectedUnit))
+ let levelModes = WindowLevelMode.allCases
+ settings.detachedBarLevel = levelModes[max(0, min(levelModes.count - 1, detachedLevelPopup.indexOfSelectedItem))]
+ settings.dashboardWindowLevel = levelModes[max(0, min(levelModes.count - 1, dashboardLevelPopup.indexOfSelectedItem))]
+ settings.graphWindowLevel = levelModes[max(0, min(levelModes.count - 1, graphLevelPopup.indexOfSelectedItem))]
+ settings.updateCheckEnabled = updateCheckButton.state == .on
+ let pvModes = PVDisplayMode.allCases
+ settings.dashboardPVDisplay = pvModes[max(0, min(pvModes.count - 1, dashboardPVPopup.indexOfSelectedItem))]
+ settings.detachedDashboardPVDisplay = pvModes[max(0, min(pvModes.count - 1, detachedDashboardPVPopup.indexOfSelectedItem))]
+ settings.menuBarPVDisplay = pvModes[max(0, min(pvModes.count - 1, menuBarPVPopup.indexOfSelectedItem))]
+ settings.detachedPVDisplay = pvModes[max(0, min(pvModes.count - 1, detachedPVPopup.indexOfSelectedItem))]
+ settings.warnBatteryLowEnabled = warnBatteryButton.state == .on
+ if let threshold = Int(warnBatteryThresholdField.stringValue) {
+ settings.warnBatteryLowThreshold = threshold
+ }
+ settings.warnPVStallEnabled = warnPVStallButton.state == .on
+ if let minutes = Int(warnPVMinutesField.stringValue) {
+ settings.warnPVStallMinutes = minutes
+ }
+ if let watts = Int(warnPVWattsField.stringValue) {
+ settings.warnPVStallMinRecentWatts = watts
+ }
+ settings.warnPVWindowEnabled = warnPVWindowButton.state == .on
+ if let start = Int(warnPVWindowStartField.stringValue) {
+ settings.warnPVWindowStart = start
+ }
+ if let end = Int(warnPVWindowEndField.stringValue) {
+ settings.warnPVWindowEnd = end
+ }
+ settings.warnPerPVEnabled = warnPerPVButton.state == .on
+ settings.warnPerPVDipEnabled = warnPerPVDipButton.state == .on
+ settings.detachedShowIcon = detachedIconButton.state == .on
+ settings.detachedShowLabels = detachedLabelsButton.state == .on
+ settings.detachedShowSymbols = detachedSymbolsButton.state == .on
+ settings.detachedShowArrows = detachedArrowsButton.state == .on
+ settings.showMetricLabels = showLabelsButton.state == .on
+ settings.showMenuBarMetricSymbols = showMetricSymbolsButton.state == .on
+ settings.showEnergyFlowArrows = showEnergyFlowArrowsButton.state == .on
+ settings.showFlowColors = showFlowColorsButton.state == .on
+ settings.detachedShowFlowColors = detachedFlowColorsButton.state == .on
+ settings.lockDetachedMenuBar = lockDetachedMenuBarButton.state == .on
+ settings.menuBarScale = scaleSlider.doubleValue
+ settings.detachedMenuBarScale = detachedScaleSlider.doubleValue
+ updateDataSourceFieldVisibility()
+ }
+
+ private func updateDataSourceFieldVisibility() {
+ switch modePopup.indexOfSelectedItem {
+ case 0:
+ commandRow.isHidden = true
+ urlRow.isHidden = true
+ setSolixRowsHidden(false)
+ case 3:
+ commandRow.isHidden = false
+ urlRow.isHidden = true
+ setSolixRowsHidden(true)
+ case 4:
+ commandRow.isHidden = true
+ urlRow.isHidden = false
+ setSolixRowsHidden(true)
+ default:
+ commandRow.isHidden = true
+ urlRow.isHidden = true
+ setSolixRowsHidden(true)
+ }
+ }
+
+ private func setSolixRowsHidden(_ hidden: Bool) {
+ solixTitle.isHidden = hidden
+ solixHint.isHidden = hidden
+ solixEmailRow.isHidden = hidden
+ solixPasswordRow.isHidden = hidden
+ solixCountryRow.isHidden = hidden
+ solixTodayBaseRow.isHidden = hidden
+ solixTotalBaseRow.isHidden = hidden
+ }
+
+ private func restoreOriginalSettings() {
+ if let originalSettings {
+ settings.apply(originalSettings)
+ }
+ if AutostartManager.isEnabled != originalAutostart {
+ try? AutostartManager.setEnabled(originalAutostart)
+ }
+ refreshAutostartState()
+ onReset()
+ }
+
+ @objc private func toggleAutostart() {
+ do {
+ try AutostartManager.setEnabled(autostartButton.state == .on)
+ refreshAutostartState()
+ } catch {
+ refreshAutostartState(message: "Autostart konnte nicht geändert werden: \(error.localizedDescription)")
+ }
+ }
+
+ @objc private func applyPreview() {
+ guard !isLoading else { return }
+ scaleValue.stringValue = "\(Int(round(scaleSlider.doubleValue * 100))) %"
+ detachedScaleValue.stringValue = "\(Int(round(detachedScaleSlider.doubleValue * 100))) %"
+ applyControlsToSettings()
+ updateMenuBarPreview()
+ onPreview()
+ }
+
+ /// Live-Vorschau der Menüleisten-Anzeige (Demo-Werte) in heller und
+ /// dunkler Menüleiste — nutzt exakt dieselbe Formatter-Engine wie die App.
+ private func updateMenuBarPreview() {
+ let snapshot = SolixSnapshot.demo
+ let options = MenuBarDisplayOptions(
+ metrics: settings.menuBarStacked ? settings.effectiveStackedBarMetrics : settings.barMetrics,
+ showLabels: settings.showMetricLabels,
+ showSymbols: settings.showMenuBarMetricSymbols,
+ showArrows: settings.showEnergyFlowArrows,
+ showColors: settings.showFlowColors,
+ pvDisplay: settings.menuBarPVDisplay
+ )
+ let stripWidth: CGFloat = 560
+ let stripHeight: CGFloat = 28
+ let image = NSImage(size: NSSize(width: stripWidth, height: stripHeight * 2 + 6), flipped: false) { [self] _ in
+ let strips: [(NSAppearance.Name, NSColor, CGFloat)] = [
+ (.darkAqua, NSColor(calibratedWhite: 0.13, alpha: 1), 0),
+ (.aqua, NSColor(calibratedWhite: 0.93, alpha: 1), stripHeight + 6)
+ ]
+ for (appearanceName, background, y) in strips {
+ NSAppearance(named: appearanceName)?.performAsCurrentDrawingAppearance {
+ let strip = NSRect(x: 0, y: y, width: stripWidth, height: stripHeight)
+ background.setFill()
+ NSBezierPath(roundedRect: strip, xRadius: 6, yRadius: 6).fill()
+ if settings.menuBarStacked {
+ let entries = previewFormatter.stackedEntries(for: snapshot, options: options)
+ if entries.count >= 2,
+ let stacked = StackedMenuBarRenderer.image(
+ entries: entries,
+ scale: settings.menuBarScale,
+ showWarning: false
+ ) {
+ stacked.draw(
+ in: NSRect(x: 12, y: y + (stripHeight - 22) / 2, width: stacked.size.width, height: 22),
+ from: .zero,
+ operation: .sourceOver,
+ fraction: 1
+ )
+ return
+ }
+ }
+ let text = previewFormatter.attributedTitle(for: snapshot, scale: settings.menuBarScale, options: options)
+ let size = text.size()
+ text.draw(at: NSPoint(x: 12, y: y + (stripHeight - size.height) / 2))
+ }
+ }
+ return true
+ }
+ menuBarPreview.image = image
+ menuBarPreview.widthAnchor.constraint(equalToConstant: stripWidth).isActive = true
+ }
+
+ @objc private func cancelSettings() {
+ window?.close()
+ }
+
+ @objc private func saveSettings() {
+ isSaving = true
+ applyControlsToSettings()
+ if settings.dataSourceMode == .solix {
+ saveSolixCredentialsIfNeeded()
+ }
+ onSave()
+ window?.close()
+ }
+
+ /// Textänderungen entprellen: Vorher schrieb jeder einzelne Tastendruck
+ /// halbfertige URLs/Befehle live in die Settings (und störte laufende
+ /// Refreshes).
+ func controlTextDidChange(_ obj: Notification) {
+ previewDebounce?.invalidate()
+ previewDebounce = Timer.scheduledTimer(withTimeInterval: 0.4, repeats: false) { [weak self] _ in
+ Task { @MainActor in
+ self?.applyPreview()
+ }
+ }
+ }
+
+ func windowShouldClose(_ sender: NSWindow) -> Bool {
+ guard !isSaving, let originalSettings, settings.snapshot() != originalSettings else { return true }
+ let alert = NSAlert()
+ alert.messageText = LocalizedText.text("Änderungen speichern?", "Save changes?")
+ alert.informativeText = LocalizedText.text(
+ "Die Vorschau-Änderungen gehen sonst verloren.",
+ "Otherwise the previewed changes will be lost."
+ )
+ alert.addButton(withTitle: LocalizedText.text("Speichern", "Save"))
+ alert.addButton(withTitle: LocalizedText.text("Verwerfen", "Discard"))
+ alert.addButton(withTitle: LocalizedText.text("Abbrechen", "Cancel"))
+ switch alert.runModal() {
+ case .alertFirstButtonReturn:
+ saveSettings()
+ return false
+ case .alertSecondButtonReturn:
+ return true
+ default:
+ return false
+ }
+ }
+
+ func windowWillClose(_ notification: Notification) {
+ guard !isSaving else { return }
+ restoreOriginalSettings()
+ }
+
+ /// Nichts speichern, solange alle Felder leer sind — sonst würde ein
+ /// blosses Öffnen der Einstellungen gespeicherte Zugangsdaten leeren.
+ private var hasSolixCredentialInput: Bool {
+ !solixEmailField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ || !solixPasswordField.stringValue.isEmpty
+ || !solixCountryField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ }
+
+ private var solixHelperCommand: String {
+ SolixPaths.helperCommand() ?? ""
+ }
+
+ private func loadSolixCredentials() {
+ let values = SolixEnvFile.read(from: SolixPaths.envFileURL)
+ let email = values["ANKER_SOLIX_USER"] ?? ""
+ solixEmailField.stringValue = email
+ // Passwort bevorzugt aus dem Schlüsselbund; Env-Eintrag ist Altbestand.
+ if !email.isEmpty, let stored = KeychainStore.password(account: email) {
+ solixPasswordField.stringValue = stored
+ } else {
+ solixPasswordField.stringValue = values["ANKER_SOLIX_PASSWORD"] ?? ""
+ }
+ solixCountryField.stringValue = values["ANKER_SOLIX_COUNTRY"] ?? "DE"
+ solixTodayBaseField.stringValue = values["SOLIXBAR_TODAY_KWH_BASE"] ?? ""
+ solixTotalBaseField.stringValue = values["SOLIXBAR_TOTAL_KWH_BASE"] ?? ""
+ }
+
+ private func saveSolixCredentialsIfNeeded() {
+ guard hasSolixCredentialInput else { return }
+ let email = solixEmailField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
+ let password = solixPasswordField.stringValue
+ let country = solixCountryField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ ? "DE"
+ : solixCountryField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
+ let todayBase = solixTodayBaseField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
+ let totalBase = solixTotalBaseField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
+
+ // Passwort in den Schlüsselbund; die Env-Datei enthält keine Secrets.
+ var values: [(key: String, value: String)] = [
+ ("ANKER_SOLIX_USER", email),
+ ("ANKER_SOLIX_COUNTRY", country)
+ ]
+ if !todayBase.isEmpty {
+ values.append(("SOLIXBAR_TODAY_KWH_BASE", todayBase.replacingOccurrences(of: ",", with: ".")))
+ values.append(("SOLIXBAR_TODAY_KWH_DATE", Self.todayKey()))
+ }
+ if !totalBase.isEmpty {
+ values.append(("SOLIXBAR_TOTAL_KWH_BASE", totalBase.replacingOccurrences(of: ",", with: ".")))
+ }
+
+ do {
+ if !email.isEmpty, !password.isEmpty {
+ try KeychainStore.setPassword(password, account: email)
+ }
+ try SolixEnvFile.write(values, to: SolixPaths.envFileURL)
+ } catch {
+ NSAlert(error: error).runModal()
+ }
+ }
+
+ private static func todayKey() -> String {
+ let formatter = DateFormatter()
+ formatter.calendar = Calendar(identifier: .gregorian)
+ formatter.locale = Locale(identifier: "en_US_POSIX")
+ formatter.dateFormat = "yyyy-MM-dd"
+ return formatter.string(from: Date())
+ }
+}
diff --git a/Sources/SolixBarKit/SolixDataProvider.swift b/Sources/SolixBarKit/SolixDataProvider.swift
new file mode 100644
index 0000000..d1b3e5c
--- /dev/null
+++ b/Sources/SolixBarKit/SolixDataProvider.swift
@@ -0,0 +1,434 @@
+import Foundation
+
+protocol SolixDataProvider: Sendable {
+ func fetchSnapshot() async throws -> SolixSnapshot
+}
+
+enum SolixProviderError: LocalizedError, Sendable {
+ case missingCredentials
+ case missingBundledRuntime
+ case missingCommand
+ case missingURL
+ case commandFailed(String)
+ case commandTimedOut(TimeInterval)
+ case httpError(Int)
+
+ var errorDescription: String? {
+ switch self {
+ case .missingCredentials:
+ "SOLIX-Mail und Passwort fehlen."
+ case .missingBundledRuntime:
+ "Die mitgelieferte SOLIX-Laufzeit fehlt in diesem Build."
+ case .missingCommand:
+ "Kein JSON-Befehl konfiguriert."
+ case .missingURL:
+ "Keine JSON-URL konfiguriert."
+ case .commandFailed(let message):
+ message
+ case .commandTimedOut(let seconds):
+ "JSON-Befehl nach \(Int(seconds)) Sekunden abgebrochen."
+ case .httpError(let status):
+ "Server antwortete mit HTTP \(status)."
+ }
+ }
+}
+
+final class DemoSolixDataProvider: SolixDataProvider {
+ /// Akkustand als langsamer, stetiger Lade-/Entladezyklus (~3 h): der
+ /// frühere Minuten-Sägezahn (65 + minute % 25) sprang alle 25 Minuten
+ /// um 24 Punkte und machte die Akku-Kurve im Verlaufsgraphen zackig —
+ /// echte Akkus driften langsam.
+ static func demoBatteryPercent(at date: Date = Date()) -> Int {
+ let cycle: Double = 3 * 60 * 60
+ let phase = date.timeIntervalSince1970.truncatingRemainder(dividingBy: cycle) / cycle
+ return 62 + Int((20 * sin(phase * 2 * .pi)).rounded())
+ }
+
+ func fetchSnapshot() async throws -> SolixSnapshot {
+ var snapshot = SolixSnapshot.demo
+ let minute = Calendar.current.component(.minute, from: Date())
+ snapshot.solarWatts = 400 + ((minute * 37) % 420)
+ snapshot.homeWatts = 220 + ((minute * 19) % 260)
+ snapshot.batteryPercent = Self.demoBatteryPercent()
+ snapshot.batteryWatts = (snapshot.solarWatts ?? 0) - (snapshot.homeWatts ?? 0)
+ snapshot.updatedAt = Date()
+ // Zwei simulierte MPPT-Eingänge. Nur wenn die Pro-PV-Warnung aktiv
+ // ist, fällt Kanal 2 in den Minuten 40–45 auf 0 (Warnungs-Test ohne
+ // Hardware) — sonst wirkt das wie ein Anzeigefehler ("Summe und 0").
+ let solar = snapshot.solarWatts ?? 0
+ let simulateOutage = await MainActor.run { AppSettings.shared.warnPerPVEnabled }
+ if simulateOutage, (40...45).contains(minute) {
+ snapshot.pvWatts = [solar, 0]
+ } else {
+ let first = Int(Double(solar) * 0.62)
+ snapshot.pvWatts = [first, solar - first]
+ }
+ return snapshot
+ }
+}
+
+/// Warnungs-Test: spielt ab Aktivierung ein gerafftes Szenario ab (Zeit
+/// läuft 10-fach), damit aktivierte Warnungen innerhalb weniger Minuten
+/// wirklich feuern — mit den Standard-Einstellungen (Schwelle 20 %,
+/// Einbruch-Dauer 15 min):
+/// Demo-Minute 0–9: normale Erzeugung (Akku 42 %, PV 385+235 W)
+/// Demo-Minute 10–29: Akku fällt auf 16 % (→ Akku-Warnung),
+/// Eingang 2 fällt auf 0 W (→ Pro-PV ab Minute 25)
+/// ab Demo-Minute 30: kompletter PV-Einbruch (→ PV-Warnung ab Minute 45)
+/// Bei 30-Sekunden-Abruf entspricht das real ca. 1 / 2,5 / 4,5 Minuten.
+final class DemoWarningsSolixDataProvider: SolixDataProvider {
+ private let start: Date
+ /// Eine reale Sekunde = zehn Szenario-Sekunden.
+ private static let timeCompression: Double = 10
+
+ init(start: Date) {
+ self.start = start
+ }
+
+ func fetchSnapshot() async throws -> SolixSnapshot {
+ let demoMinute = Date().timeIntervalSince(start) * Self.timeCompression / 60
+ let updatedAt = start.addingTimeInterval(demoMinute * 60)
+
+ var snapshot = SolixSnapshot.demo
+ snapshot.siteName = "Anker SOLIX"
+ snapshot.status = await MainActor.run { LocalizedText.text("Warnungs-Test", "Warning test") }
+ snapshot.updatedAt = updatedAt
+ snapshot.homeWatts = 310
+ snapshot.gridWatts = 0
+ switch demoMinute {
+ case ..<10:
+ snapshot.batteryPercent = 42
+ snapshot.pvWatts = [385, 235]
+ case ..<30:
+ snapshot.batteryPercent = 16
+ snapshot.pvWatts = [385, 0]
+ default:
+ snapshot.batteryPercent = 16
+ snapshot.pvWatts = [0, 0]
+ }
+ snapshot.solarWatts = snapshot.pvWatts?.reduce(0, +)
+ snapshot.batteryWatts = (snapshot.solarWatts ?? 0) - (snapshot.homeWatts ?? 0)
+ return snapshot
+ }
+}
+
+/// Liest eine Pipe auf einem eigenen Thread leer, damit der Kindprozess bei
+/// grosser Ausgabe (>64 KB Pipe-Puffer) nicht blockiert.
+private final class PipeDrain: @unchecked Sendable {
+ private let semaphore = DispatchSemaphore(value: 0)
+ private var collected = Data()
+
+ init(_ handle: FileHandle) {
+ Thread.detachNewThread { [self] in
+ collected = (try? handle.readToEnd()) ?? Data()
+ semaphore.signal()
+ }
+ }
+
+ func data() -> Data {
+ semaphore.wait()
+ return collected
+ }
+}
+
+final class CommandSolixDataProvider: SolixDataProvider {
+ private let command: String
+ private let extraEnvironment: [String: String]
+ private let timeout: TimeInterval
+
+ init(command: String, extraEnvironment: [String: String] = [:], timeout: TimeInterval = 45) {
+ self.command = command
+ self.extraEnvironment = extraEnvironment
+ self.timeout = timeout
+ }
+
+ func fetchSnapshot() async throws -> SolixSnapshot {
+ guard !command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
+ throw SolixProviderError.missingCommand
+ }
+
+ let command = command
+ let extraEnvironment = extraEnvironment
+ let timeout = timeout
+ return try await Task.detached(priority: .utility) {
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: "/bin/zsh")
+ process.arguments = ["-lc", command]
+ if !extraEnvironment.isEmpty {
+ process.environment = ProcessInfo.processInfo.environment
+ .merging(extraEnvironment) { _, new in new }
+ }
+
+ let output = Pipe()
+ let error = Pipe()
+ process.standardOutput = output
+ process.standardError = error
+
+ try process.run()
+ let outputDrain = PipeDrain(output.fileHandleForReading)
+ let errorDrain = PipeDrain(error.fileHandleForReading)
+
+ if await !Self.waitForExit(process, timeout: timeout) {
+ process.terminate()
+ if await !Self.waitForExit(process, timeout: 2) {
+ kill(process.processIdentifier, SIGKILL)
+ _ = await Self.waitForExit(process, timeout: 2)
+ }
+ throw SolixProviderError.commandTimedOut(timeout)
+ }
+
+ let data = outputDrain.data()
+ let errorData = errorDrain.data()
+
+ guard process.terminationStatus == 0 else {
+ let message = String(data: errorData, encoding: .utf8)?
+ .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ throw SolixProviderError.commandFailed(
+ message.isEmpty ? "Befehl fehlgeschlagen (Exit-Code \(process.terminationStatus))." : message
+ )
+ }
+
+ return try SnapshotDecoder.decode(data)
+ }.value
+ }
+
+ private static func waitForExit(_ process: Process, timeout: TimeInterval) async -> Bool {
+ await waitForProcessExit(process, timeout: timeout)
+ }
+}
+
+private func waitForProcessExit(_ process: Process, timeout: TimeInterval) async -> Bool {
+ let deadline = Date().addingTimeInterval(timeout)
+ while process.isRunning && Date() < deadline {
+ try? await Task.sleep(nanoseconds: 100_000_000)
+ }
+ return !process.isRunning
+}
+
+/// Fundorte der SOLIX-Laufzeit: zuerst das App-Bundle (Release mit
+/// eingebetteter Python-Laufzeit), dann das Arbeitsverzeichnis für die
+/// Entwicklung (`swift run`) — portabel oder klassisches venv.
+struct SolixHelperRuntime: Sendable {
+ var python: URL
+ var script: URL
+ var sitePackages: URL?
+
+ static func locate(fileManager: FileManager = .default) -> SolixHelperRuntime? {
+ var candidates: [SolixHelperRuntime] = []
+ if let resources = Bundle.main.resourceURL {
+ candidates.append(
+ SolixHelperRuntime(
+ python: resources.appendingPathComponent("python/bin/python3.12"),
+ script: resources.appendingPathComponent("solix_snapshot.py"),
+ sitePackages: resources.appendingPathComponent("site-packages")
+ )
+ )
+ }
+ let root = URL(fileURLWithPath: fileManager.currentDirectoryPath, isDirectory: true)
+ candidates.append(
+ SolixHelperRuntime(
+ python: root.appendingPathComponent("work/python/bin/python3.12"),
+ script: root.appendingPathComponent("scripts/solix_snapshot.py"),
+ sitePackages: root.appendingPathComponent("work/solix-venv312/lib/python3.12/site-packages")
+ )
+ )
+ candidates.append(
+ SolixHelperRuntime(
+ python: root.appendingPathComponent("work/solix-venv312/bin/python"),
+ script: root.appendingPathComponent("scripts/solix_snapshot.py"),
+ sitePackages: nil
+ )
+ )
+ return candidates.first { $0.isComplete(fileManager) }
+ }
+
+ private func isComplete(_ fileManager: FileManager) -> Bool {
+ guard fileManager.isExecutableFile(atPath: python.path),
+ fileManager.fileExists(atPath: script.path) else { return false }
+ guard let sitePackages else { return true }
+ return fileManager.fileExists(atPath: sitePackages.path)
+ }
+}
+
+/// Direkter SOLIX-Abruf über die mitgelieferte Python-Laufzeit — ohne
+/// konfigurierten Befehl und ohne lokale Installation. Die Zugangsdaten
+/// gehen als eine JSON-Zeile über stdin an den Helper; Umgebungsvariablen
+/// wären für andere Prozesse desselben Nutzers via `ps -E` sichtbar.
+final class BundledSolixDataProvider: SolixDataProvider {
+ struct Credentials: Sendable {
+ var email: String
+ var password: String
+ var country: String
+ var todayBaseKWh: Double?
+ var totalBaseKWh: Double?
+ }
+
+ private struct HelperRequest: Encodable {
+ var email: String
+ var password: String
+ var country: String
+ var todayBaseKWh: Double?
+ var totalBaseKWh: Double?
+ }
+
+ private let credentials: Credentials
+ private let timeout: TimeInterval
+
+ init(credentials: Credentials, timeout: TimeInterval = 45) {
+ self.credentials = credentials
+ self.timeout = timeout
+ }
+
+ func fetchSnapshot() async throws -> SolixSnapshot {
+ guard !credentials.email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
+ !credentials.password.isEmpty else {
+ throw SolixProviderError.missingCredentials
+ }
+ guard let runtime = SolixHelperRuntime.locate() else {
+ throw SolixProviderError.missingBundledRuntime
+ }
+
+ let request = HelperRequest(
+ email: credentials.email,
+ password: credentials.password,
+ country: credentials.country,
+ todayBaseKWh: credentials.todayBaseKWh,
+ totalBaseKWh: credentials.totalBaseKWh
+ )
+ let inputData = try JSONEncoder().encode(request) + Data([0x0A])
+ let timeout = timeout
+ let supportDirectory = SolixPaths.appSupportDirectory
+ let statePath = SolixPaths.energyStateFileURL.path
+ let cachePath = SolixPaths.apiCacheFileURL.path
+
+ return try await Task.detached(priority: .utility) {
+ let process = Process()
+ process.executableURL = runtime.python
+ process.arguments = [runtime.script.path, "--stdin-config"]
+
+ var environment = ProcessInfo.processInfo.environment
+ if let sitePackages = runtime.sitePackages {
+ environment["PYTHONPATH"] = sitePackages.path
+ }
+ // Kein __pycache__ ins signierte Bundle schreiben.
+ environment["PYTHONDONTWRITEBYTECODE"] = "1"
+ environment["SOLIXBAR_STATE_PATH"] = statePath
+ environment["SOLIXBAR_CACHE_PATH"] = cachePath
+ process.environment = environment
+
+ let input = Pipe()
+ let output = Pipe()
+ let error = Pipe()
+ process.standardInput = input
+ process.standardOutput = output
+ process.standardError = error
+
+ try? FileManager.default.createDirectory(
+ at: supportDirectory,
+ withIntermediateDirectories: true
+ )
+ try process.run()
+ let outputDrain = PipeDrain(output.fileHandleForReading)
+ let errorDrain = PipeDrain(error.fileHandleForReading)
+ // Fehler beim Schreiben ignorieren (Helper könnte schon tot
+ // sein) — der Exit-Status liefert dann die eigentliche Meldung.
+ try? input.fileHandleForWriting.write(contentsOf: inputData)
+ try? input.fileHandleForWriting.close()
+
+ if await !waitForProcessExit(process, timeout: timeout) {
+ process.terminate()
+ if await !waitForProcessExit(process, timeout: 2) {
+ kill(process.processIdentifier, SIGKILL)
+ _ = await waitForProcessExit(process, timeout: 2)
+ }
+ throw SolixProviderError.commandTimedOut(timeout)
+ }
+
+ let data = outputDrain.data()
+ let errorData = errorDrain.data()
+
+ guard process.terminationStatus == 0 else {
+ let message = String(data: errorData, encoding: .utf8)?
+ .trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+ throw SolixProviderError.commandFailed(
+ message.isEmpty ? "SOLIX-Helper fehlgeschlagen (Exit-Code \(process.terminationStatus))." : message
+ )
+ }
+
+ return try SnapshotDecoder.decode(data)
+ }.value
+ }
+}
+
+final class URLSolixDataProvider: SolixDataProvider {
+ private let urlString: String
+ private let timeout: TimeInterval = 45
+
+ init(urlString: String) {
+ self.urlString = urlString
+ }
+
+ func fetchSnapshot() async throws -> SolixSnapshot {
+ guard let url = URL(string: urlString), !urlString.isEmpty else {
+ throw SolixProviderError.missingURL
+ }
+
+ var request = URLRequest(url: url)
+ request.timeoutInterval = timeout
+ let (data, response) = try await URLSession.shared.data(for: request)
+ if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
+ throw SolixProviderError.httpError(http.statusCode)
+ }
+ return try SnapshotDecoder.decode(data)
+ }
+}
+
+extension BundledSolixDataProvider.Credentials {
+ /// Liest Mail/Land/Basiswerte aus der Env-Datei und das Passwort aus dem
+ /// Schlüsselbund — derselbe Speicher, den die Einstellungen beschreiben.
+ @MainActor
+ static func stored() -> Self {
+ let values = SolixEnvFile.read(from: SolixPaths.envFileURL)
+ let email = values["ANKER_SOLIX_USER"] ?? ""
+ let password = email.isEmpty ? "" : (KeychainStore.password(account: email) ?? "")
+ return from(values: values, password: password, todayKey: currentDayKey())
+ }
+
+ /// Reine Abbildung Env-Werte → Zugangsdaten. Der Tages-Basiswert gilt
+ /// nur an dem Tag, an dem er eingetragen wurde (SOLIXBAR_TODAY_KWH_DATE).
+ static func from(values: [String: String], password: String, todayKey: String) -> Self {
+ func number(_ key: String) -> Double? {
+ guard let raw = values[key]?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
+ return nil
+ }
+ return Double(raw.replacingOccurrences(of: ",", with: "."))
+ }
+ let todayBase = values["SOLIXBAR_TODAY_KWH_DATE"] == todayKey
+ ? number("SOLIXBAR_TODAY_KWH_BASE")
+ : nil
+ let country = values["ANKER_SOLIX_COUNTRY"]?.trimmingCharacters(in: .whitespacesAndNewlines)
+ return Self(
+ email: values["ANKER_SOLIX_USER"] ?? "",
+ password: password,
+ country: (country?.isEmpty == false ? country! : "DE"),
+ todayBaseKWh: todayBase,
+ totalBaseKWh: number("SOLIXBAR_TOTAL_KWH_BASE")
+ )
+ }
+
+ static func currentDayKey(for date: Date = Date()) -> String {
+ let formatter = DateFormatter()
+ formatter.calendar = Calendar(identifier: .gregorian)
+ formatter.locale = Locale(identifier: "en_US_POSIX")
+ formatter.dateFormat = "yyyy-MM-dd"
+ return formatter.string(from: date)
+ }
+}
+
+enum SnapshotDecoder {
+ static func decode(_ data: Data) throws -> SolixSnapshot {
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+ return try decoder.decode(SolixSnapshot.self, from: data)
+ }
+}
diff --git a/Sources/SolixBarKit/SolixEnvFile.swift b/Sources/SolixBarKit/SolixEnvFile.swift
new file mode 100644
index 0000000..0acc529
--- /dev/null
+++ b/Sources/SolixBarKit/SolixEnvFile.swift
@@ -0,0 +1,93 @@
+import Foundation
+
+/// Pfade der App für lokale Daten und den SOLIX-Hilfsbefehl.
+/// Ersetzt die früher hartkodierten Pfade ins Home des Originalautors.
+enum SolixPaths {
+ static var appSupportDirectory: URL {
+ let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
+ ?? URL(fileURLWithPath: NSTemporaryDirectory())
+ return base.appendingPathComponent("SolixBar", isDirectory: true)
+ }
+
+ static var envFileURL: URL {
+ appSupportDirectory.appendingPathComponent("solixbar.env")
+ }
+
+ static var historyFileURL: URL {
+ appSupportDirectory.appendingPathComponent("history.json")
+ }
+
+ /// Ertragszustand des Python-Helpers (Tages-/Gesamt-Akkumulator).
+ static var energyStateFileURL: URL {
+ appSupportDirectory.appendingPathComponent("energy.json")
+ }
+
+ /// API-Antwort-Cache des Python-Helpers (Tages-/Gesamtstatistik).
+ static var apiCacheFileURL: URL {
+ appSupportDirectory.appendingPathComponent("api-cache.json")
+ }
+
+ /// Findet das SOLIX-Hilfsscript: bevorzugt im App-Bundle, sonst im
+ /// Arbeitsverzeichnis (Entwicklung mit `swift run`).
+ static func helperScriptURL() -> URL? {
+ if let bundled = Bundle.main.url(forResource: "run_solix_snapshot", withExtension: "sh") {
+ return bundled
+ }
+ let dev = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
+ .appendingPathComponent("scripts/run_solix_snapshot.sh")
+ return FileManager.default.fileExists(atPath: dev.path) ? dev : nil
+ }
+
+ /// Vollständiger Befehl für den Modus "Lokaler JSON-Befehl": Script mit
+ /// explizitem Verweis auf die Env-Datei in Application Support.
+ static func helperCommand() -> String? {
+ guard let script = helperScriptURL() else { return nil }
+ return "SOLIXBAR_ENV_FILE=\(SolixEnvFile.shellQuoted(envFileURL.path)) \(SolixEnvFile.shellQuoted(script.path))"
+ }
+}
+
+/// Lesen/Schreiben der lokalen Env-Datei (Key=Value, shell-quotiert).
+/// Wird mit 0600 geschrieben, da sie Zugangsdaten enthalten kann.
+enum SolixEnvFile {
+ static func read(from url: URL) -> [String: String] {
+ guard let text = try? String(contentsOf: url, encoding: .utf8) else { return [:] }
+ var values: [String: String] = [:]
+ for rawLine in text.components(separatedBy: .newlines) {
+ let line = rawLine.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !line.isEmpty, !line.hasPrefix("#"), let equals = line.firstIndex(of: "=") else { continue }
+ let key = String(line[.. String {
+ "'\(value.replacingOccurrences(of: "'", with: "'\\''"))'"
+ }
+
+ static func unquote(_ value: String) -> String {
+ var value = value.trimmingCharacters(in: .whitespacesAndNewlines)
+ if value.hasPrefix("'"), value.hasSuffix("'"), value.count >= 2 {
+ value.removeFirst()
+ value.removeLast()
+ return value.replacingOccurrences(of: "'\\''", with: "'")
+ }
+ if value.hasPrefix("\""), value.hasSuffix("\""), value.count >= 2 {
+ value.removeFirst()
+ value.removeLast()
+ }
+ return value
+ }
+}
diff --git a/Sources/SolixBarKit/SolixHistoryStore.swift b/Sources/SolixBarKit/SolixHistoryStore.swift
new file mode 100644
index 0000000..0593178
--- /dev/null
+++ b/Sources/SolixBarKit/SolixHistoryStore.swift
@@ -0,0 +1,173 @@
+import Foundation
+
+struct SolixHistorySample: Codable, Sendable {
+ var date: Date
+ var batteryPercent: Int?
+ var solarWatts: Int?
+ var gridWatts: Int?
+ // Seit 0.4.2 mitgeschrieben (für den Datenexport); ältere Dateien haben
+ // die Felder nicht — Optionals decodieren dann als nil.
+ var homeWatts: Int?
+ var batteryWatts: Int?
+}
+
+private struct SolixEnergyAccumulator: Codable {
+ var totalKWh: Double = 0
+ var lastDate: Date?
+ var lastSolarWatts: Int?
+}
+
+/// Verlaufsdatei: Samples getrennt pro Datenquelle, damit Demo-Daten den
+/// Live-Graphen nicht verfälschen. Persistenz als JSON-Datei in Application
+/// Support statt als UserDefaults-Blob.
+private struct HistoryFile: Codable {
+ var version = 1
+ var samples: [String: [SolixHistorySample]] = [:]
+}
+
+@MainActor
+final class SolixHistoryStore {
+ static let shared = SolixHistoryStore()
+
+ private let defaults: UserDefaults
+ private let fileURL: URL
+ private let legacyKey = "solixHistorySamples"
+ private let accumulatorKey = "solixEnergyAccumulators"
+ private let maxAge: TimeInterval = 366 * 24 * 60 * 60
+ private let fineWindow: TimeInterval = 31 * 24 * 60 * 60
+ private var cache: HistoryFile?
+
+ init(defaults: UserDefaults = .standard, fileURL: URL = SolixPaths.historyFileURL) {
+ self.defaults = defaults
+ self.fileURL = fileURL
+ }
+
+ func cumulativeSolarKWh(recording snapshot: SolixSnapshot, sourceKey: String) -> Double {
+ var accumulators = loadAccumulators()
+ var accumulator = accumulators[sourceKey] ?? SolixEnergyAccumulator()
+
+ if let lastDate = accumulator.lastDate,
+ let lastSolarWatts = accumulator.lastSolarWatts,
+ let solarWatts = snapshot.solarWatts {
+ let seconds = snapshot.updatedAt.timeIntervalSince(lastDate)
+ if seconds > 0, seconds <= 30 * 60 {
+ let measuredKWh = Double(lastSolarWatts + solarWatts) / 2 * seconds / 3_600_000
+ accumulator.totalKWh += max(0, measuredKWh)
+ }
+ }
+
+ if let providerTotal = snapshot.totalKWh, providerTotal > accumulator.totalKWh {
+ accumulator.totalKWh = providerTotal
+ }
+
+ accumulator.lastDate = snapshot.updatedAt
+ accumulator.lastSolarWatts = snapshot.solarWatts
+ accumulators[sourceKey] = accumulator
+ saveAccumulators(accumulators)
+ return accumulator.totalKWh
+ }
+
+ func record(_ snapshot: SolixSnapshot, sourceKey: String, refreshInterval: TimeInterval) {
+ var file = loadFile()
+ var samples = file.samples[sourceKey] ?? []
+ samples.append(
+ SolixHistorySample(
+ date: snapshot.updatedAt,
+ batteryPercent: snapshot.batteryPercent,
+ solarWatts: snapshot.solarWatts,
+ gridWatts: snapshot.gridWatts,
+ homeWatts: snapshot.homeWatts,
+ batteryWatts: snapshot.batteryWatts
+ )
+ )
+ file.samples[sourceKey] = pruned(samples, from: Date(), refreshInterval: refreshInterval)
+ saveFile(file)
+ }
+
+ func samples(duration: TimeInterval, sourceKey: String) -> [SolixHistorySample] {
+ let cutoff = Date().addingTimeInterval(-duration)
+ return (loadFile().samples[sourceKey] ?? [])
+ .filter { $0.date >= cutoff }
+ .sorted { $0.date < $1.date }
+ }
+
+ /// Cap für das Feinfenster (31 Tage volle Auflösung) so, dass es beim
+ /// konfigurierten Intervall vollständig gefüllt werden kann.
+ static func maxSamples(refreshInterval: TimeInterval) -> Int {
+ let interval = max(60, refreshInterval)
+ let thirtyDays = 30.0 * 24 * 60 * 60
+ return max(2000, Int(thirtyDays / interval) + 100)
+ }
+
+ /// Zweistufige Aufbewahrung: 31 Tage in voller Aufloesung, danach bis zu
+ /// 366 Tage auf Stundenaufloesung ausgeduennt — Jahresansichten ohne
+ /// Multi-Megabyte-Datei.
+ private func pruned(_ samples: [SolixHistorySample], from date: Date, refreshInterval: TimeInterval) -> [SolixHistorySample] {
+ let ageCutoff = date.addingTimeInterval(-maxAge)
+ let fineCutoff = date.addingTimeInterval(-fineWindow)
+ let kept = samples.filter { $0.date >= ageCutoff }
+
+ var hourly: [Int: SolixHistorySample] = [:]
+ var fine: [SolixHistorySample] = []
+ for sample in kept {
+ if sample.date >= fineCutoff {
+ fine.append(sample)
+ } else {
+ // letztes Sample je Stunde gewinnt
+ hourly[Int(sample.date.timeIntervalSince1970 / 3600)] = sample
+ }
+ }
+ let cap = Self.maxSamples(refreshInterval: refreshInterval)
+ if fine.count > cap {
+ fine = Array(fine.suffix(cap))
+ }
+ let coarse = hourly.values.sorted { $0.date < $1.date }
+ return coarse + fine
+ }
+
+ // MARK: Persistenz
+
+ private func loadFile() -> HistoryFile {
+ if let cache { return cache }
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .secondsSince1970
+ if let data = try? Data(contentsOf: fileURL),
+ let file = try? decoder.decode(HistoryFile.self, from: data) {
+ cache = file
+ return file
+ }
+ // Migration: alter UserDefaults-Blob (Quelle unbekannt, stammt praktisch
+ // immer aus dem Demo-Modus) wandert unter den Demo-Schlüssel.
+ var file = HistoryFile()
+ if let legacyData = defaults.data(forKey: legacyKey),
+ let legacy = try? JSONDecoder().decode([SolixHistorySample].self, from: legacyData) {
+ file.samples[DataSourceMode.demo.rawValue] = legacy
+ defaults.removeObject(forKey: legacyKey)
+ AppLogger.info("Migrated \(legacy.count) legacy history samples to per-source history file.")
+ }
+ cache = file
+ return file
+ }
+
+ private func saveFile(_ file: HistoryFile) {
+ cache = file
+ let encoder = JSONEncoder()
+ encoder.dateEncodingStrategy = .secondsSince1970
+ guard let data = try? encoder.encode(file) else { return }
+ try? FileManager.default.createDirectory(
+ at: fileURL.deletingLastPathComponent(),
+ withIntermediateDirectories: true
+ )
+ try? data.write(to: fileURL, options: .atomic)
+ }
+
+ private func loadAccumulators() -> [String: SolixEnergyAccumulator] {
+ guard let data = defaults.data(forKey: accumulatorKey) else { return [:] }
+ return (try? JSONDecoder().decode([String: SolixEnergyAccumulator].self, from: data)) ?? [:]
+ }
+
+ private func saveAccumulators(_ accumulators: [String: SolixEnergyAccumulator]) {
+ guard let data = try? JSONEncoder().encode(accumulators) else { return }
+ defaults.set(data, forKey: accumulatorKey)
+ }
+}
diff --git a/Sources/SolixBar/SolixMenuDashboardView.swift b/Sources/SolixBarKit/SolixMenuDashboardView.swift
similarity index 56%
rename from Sources/SolixBar/SolixMenuDashboardView.swift
rename to Sources/SolixBarKit/SolixMenuDashboardView.swift
index 6bd9ac5..2d1e2e6 100644
--- a/Sources/SolixBar/SolixMenuDashboardView.swift
+++ b/Sources/SolixBarKit/SolixMenuDashboardView.swift
@@ -2,30 +2,64 @@ import AppKit
@MainActor
final class SolixMenuDashboardView: NSView {
+ /// Wo das Panel lebt: im Dropdown-Menü (eigener Rahmen) oder als Inhalt
+ /// des abgedockten Fensters (Panel IST der Fensterrahmen; oben Platz für
+ /// die Ampel-Buttons).
+ enum Style {
+ case menu
+ case window
+ }
+
private let snapshot: SolixSnapshot
+ private let previous: SolixSnapshot?
+ private let style: Style
private let graphProvider: () -> [SolixHistorySample]
private let onRangeChange: () -> Void
private let onOpenLarge: () -> Void
private var updatedLabel: NSTextField?
private var updatedTimer: Timer?
+ private var isRebuildingForAppearance = false
+ private weak var detailsContainer: NSStackView?
init(
snapshot: SolixSnapshot,
+ previous: SolixSnapshot? = nil,
+ style: Style = .menu,
graphProvider: @escaping () -> [SolixHistorySample],
onRangeChange: @escaping () -> Void,
onOpenLarge: @escaping () -> Void
) {
self.snapshot = snapshot
+ self.previous = previous
+ self.style = style
self.graphProvider = graphProvider
self.onRangeChange = onRangeChange
self.onOpenLarge = onOpenLarge
- super.init(frame: NSRect(x: 0, y: 0, width: 430, height: 622))
+ super.init(frame: NSRect(x: 0, y: 0, width: 430, height: style == .window ? 672 : 646))
wantsLayer = true
layer?.backgroundColor = backgroundColor.cgColor
- layer?.cornerRadius = 16
- layer?.borderWidth = 1
- layer?.borderColor = NSColor.separatorColor.withAlphaComponent(0.65).cgColor
- buildView()
+ layer?.cornerRadius = Theme.radiusPanel
+ layer?.masksToBounds = true
+ // Im eigenen Fensterrahmen wirkt eine Konturlinie wie ein dunkler
+ // Rand — dort traegt der Fensterschatten die Abgrenzung.
+ if style == .menu {
+ layer?.borderWidth = 1
+ layer?.borderColor = NSColor.separatorColor.withAlphaComponent(0.65).cgColor
+ }
+ // cgColor-Auflösung dynamischer Farben folgt sonst der Prozess-
+ // Default-Appearance (hell), nicht der App/Fenster-Appearance —
+ // Ursache der weißen Streifen im Dark Mode.
+ effectiveAppearance.performAsCurrentDrawingAppearance {
+ buildView()
+ }
+ // Hoehe aus den Constraints ableiten: eine geratene Festhoehe brach
+ // das Layout im Menue-Kontext, sobald der Platzbedarf sich aenderte
+ // (das abgedockte Fenster hatte Luft, das Dropdown nicht).
+ layoutSubtreeIfNeeded()
+ let fitted = fittingSize.height
+ if fitted > 200 {
+ setFrameSize(NSSize(width: frame.width, height: fitted))
+ }
startUpdatedTimer()
}
@@ -50,33 +84,92 @@ final class SolixMenuDashboardView: NSView {
updated.toolTip = LocalizedText.text("Wann die Werte zuletzt aktualisiert wurden.", "When the values were last updated.")
updatedLabel = updated
- let status = badge(snapshot.status ?? "Online", color: statusColor)
+ // Demo-Modus deutlich kennzeichnen, damit niemand Beispieldaten für
+ // echte Anlagenwerte hält.
+ let isDemo = AppSettings.shared.dataSourceMode == .demo
+ || AppSettings.shared.dataSourceMode == .demoWarnings
+ let status = badge(
+ isDemo ? "Demo" : (snapshot.status ?? LocalizedText.text("Unbekannt", "Unknown")),
+ color: isDemo ? .systemBlue : statusColor
+ )
- let battery = primaryMetricPanel(LocalizedText.text("Akku", "Battery"), snapshot.batteryPercent.map { "\($0) %" }, "battery.100percent", batteryColor)
- let solar = primaryMetricPanel("Solar", snapshot.solarWatts.map { "\($0) W" }, "sun.max.fill", solarColor)
+ let batteryValue = withTrend(
+ snapshot.batteryPercent.map { "\($0) %" },
+ arrow: trendArrow(current: snapshot.batteryPercent, previous: previous?.batteryPercent, threshold: 1)
+ ) ?? "-"
+ let battery = primaryMetricPanel(
+ LocalizedText.text("Akku", "Battery"),
+ [batteryValue],
+ "battery.100percent",
+ batteryColor,
+ plateColor: Theme.vivid(Theme.battery(percent: snapshot.batteryPercent))
+ )
+ // PV-Kachel je nach Modus: Summe, Einzelwerte ("438 · 204 W") oder
+ // Summe in der Kachel plus Einzelwerte-Zeile in den Details.
+ // Angedocktes Menü-Dashboard und abgedocktes Fenster haben getrennte
+ // Einstellungen; der Trend-Pfeil bezieht sich immer auf die Summe.
+ let pvMode = style == .window
+ ? AppSettings.shared.detachedDashboardPVDisplay
+ : AppSettings.shared.dashboardPVDisplay
+ let pvChannels = (snapshot.pvWatts?.count ?? 0) > 1 ? snapshot.pvWatts : nil
+ let solarArrow = trendArrow(current: snapshot.solarWatts, previous: previous?.solarWatts, threshold: 5)
+ let solarLines = Self.solarValueLines(
+ pvMode: pvMode, channels: pvChannels, totalWatts: snapshot.solarWatts, arrow: solarArrow
+ )
+ let solar = primaryMetricPanel(
+ "Solar",
+ solarLines,
+ "sun.max.fill",
+ solarColor,
+ plateColor: Theme.vivid(.solar)
+ )
let primaryRow = NSStackView(views: [battery, solar])
primaryRow.orientation = .horizontal
primaryRow.spacing = 12
primaryRow.distribution = .fillEqually
- var detailRows = [
- compactMetricRow(LocalizedText.text("Hauslast", "Home Load"), snapshot.homeWatts.map { "\($0) W" }, "house.fill", .systemBlue),
- compactMetricRow(LocalizedText.text("Netzbezug", "Grid Import"), signedWatts(snapshot.gridWatts), "powerplug.fill", gridColor),
- compactMetricRow(LocalizedText.text("Akku-Fluss", "Battery Flow"), signedWatts(snapshot.batteryWatts), "bolt.fill", batteryFlowColor),
- compactMetricRow(LocalizedText.text("Heutiger Ertrag", "Today's Yield"), snapshot.todayKWh.map { String(format: "%.2f kWh", $0) }, "chart.bar.fill", .systemPurple)
+ var detailRows: [NSView] = []
+ // Modus "Gesamt + Einzelwerte": Summe bleibt in der Kachel, die
+ // Eingänge bekommen eine eigene Detail-Zeile.
+ if pvMode == .both, let pvChannels {
+ detailRows.append(compactMetricRow(
+ LocalizedText.text("PV-Eingänge", "PV Inputs"),
+ pvChannels.map { "\($0) W" }.joined(separator: " · "),
+ "sun.max",
+ Theme.vivid(.solar)
+ ))
+ }
+ detailRows += [
+ compactMetricRow(
+ LocalizedText.text("Hauslast", "Home Load"),
+ withTrend(snapshot.homeWatts.map { "\($0) W" }, arrow: trendArrow(current: snapshot.homeWatts, previous: previous?.homeWatts, threshold: 5)),
+ "house.fill",
+ Theme.vivid(.load)
+ ),
+ compactMetricRow(
+ LocalizedText.text("Netzbezug", "Grid Import"),
+ withTrend(signedWatts(snapshot.gridWatts), arrow: trendArrow(current: snapshot.gridWatts, previous: previous?.gridWatts, threshold: 5)),
+ "powerplug.fill",
+ gridColor
+ ),
+ compactMetricRow(
+ LocalizedText.text("Akku-Fluss", "Battery Flow"),
+ withTrend(signedWatts(snapshot.batteryWatts), arrow: trendArrow(current: snapshot.batteryWatts, previous: previous?.batteryWatts, threshold: 5)),
+ "bolt.fill",
+ batteryFlowColor
+ ),
+ compactMetricRow(LocalizedText.text("Heutiger Ertrag", "Today's Yield"), snapshot.todayKWh.map { String(format: "%.2f kWh", $0) }, "chart.bar.fill", Theme.vivid(.yieldToday))
]
if let totalKWh = snapshot.totalKWh {
- detailRows.append(compactMetricRow(LocalizedText.text("Gesamtertrag", "Total Yield"), String(format: "%.1f kWh", totalKWh), "sum", .systemIndigo))
+ detailRows.append(compactMetricRow(LocalizedText.text("Gesamtertrag", "Total Yield"), String(format: "%.1f kWh", totalKWh), "sum", Theme.vivid(.yieldTotal)))
}
let details = NSStackView(views: detailRows)
details.orientation = .vertical
details.spacing = 8
- details.wantsLayer = true
- details.layer?.cornerRadius = 13
- details.layer?.backgroundColor = panelColor.cgColor
- details.layer?.borderWidth = 1
- details.layer?.borderColor = NSColor.separatorColor.withAlphaComponent(0.45).cgColor
+ // Kein eigener Container-Hintergrund: die Lücken zwischen den Reihen
+ // zeigen den App-Hintergrund statt eines helleren Zwischentons.
+ detailsContainer = details
let graph = HistoryGraphMenuView(
graphProvider: graphProvider,
@@ -90,7 +183,7 @@ final class SolixMenuDashboardView: NSView {
}
NSLayoutConstraint.activate([
- title.topAnchor.constraint(equalTo: topAnchor, constant: 18),
+ title.topAnchor.constraint(equalTo: topAnchor, constant: style == .window ? 44 : 18),
title.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 18),
title.trailingAnchor.constraint(lessThanOrEqualTo: status.leadingAnchor, constant: -10),
@@ -118,6 +211,36 @@ final class SolixMenuDashboardView: NSView {
])
}
+ /// Kompletter Neuaufbau bei Appearance-Wechsel: einzelne Layer-Refreshes
+ /// haben immer wieder eingefrorene cgColor-Reste übersehen (weiße Streifen
+ /// zwischen den Reihen im Dark Mode) — neu bauen erschlägt die ganze
+ /// Fehlerklasse.
+ override func viewDidChangeEffectiveAppearance() {
+ super.viewDidChangeEffectiveAppearance()
+ guard !isRebuildingForAppearance else { return }
+ isRebuildingForAppearance = true
+ defer { isRebuildingForAppearance = false }
+ effectiveAppearance.performAsCurrentDrawingAppearance { [self] in
+ layer?.backgroundColor = backgroundColor.cgColor
+ if style == .menu {
+ layer?.borderColor = NSColor.separatorColor.withAlphaComponent(0.65).cgColor
+ }
+ subviews.forEach { $0.removeFromSuperview() }
+ buildView()
+ }
+ // Der Appearance-Neuaufbau passiert typischerweise beim ERSTEN
+ // Einblenden im Menüfenster. Ohne erzwungenen Layout-Pass und
+ // Höhen-Abgleich blieb der frisch gebaute Inhalt gelegentlich
+ // kollabiert (unterster Abschnitt — der Graph — unsichtbar), bis das
+ // Menü ein zweites Mal geöffnet wurde.
+ layoutSubtreeIfNeeded()
+ let fitted = fittingSize.height
+ if fitted > 200, abs(fitted - frame.height) > 1 {
+ setFrameSize(NSSize(width: frame.width, height: fitted))
+ }
+ needsDisplay = true
+ }
+
override func viewWillMove(toSuperview newSuperview: NSView?) {
super.viewWillMove(toSuperview: newSuperview)
if newSuperview == nil {
@@ -130,7 +253,13 @@ final class SolixMenuDashboardView: NSView {
updatedTimer?.invalidate()
let timer = Timer(timeInterval: 1, repeats: true) { [weak self] _ in
Task { @MainActor in
- self?.updatedLabel?.stringValue = self?.updatedText() ?? ""
+ guard let self else { return }
+ self.updatedLabel?.stringValue = self.updatedText()
+ // Überfällige Daten sichtbar machen: orange ab dem doppelten
+ // Aktualisierungsintervall.
+ let age = Date().timeIntervalSince(self.snapshot.updatedAt)
+ let staleAfter = max(120, AppSettings.shared.refreshInterval * 2)
+ self.updatedLabel?.textColor = age > staleAfter ? .systemOrange : .secondaryLabelColor
}
}
RunLoop.main.add(timer, forMode: .common)
@@ -158,42 +287,45 @@ final class SolixMenuDashboardView: NSView {
return LocalizedText.text("vor \(days) Tagen", "\(days) days ago")
}
- private func primaryMetricPanel(_ title: String, _ value: String?, _ symbol: String, _ color: NSColor) -> NSView {
+ private func primaryMetricPanel(_ title: String, _ valueLines: [String], _ symbol: String, _ color: NSColor, plateColor: NSColor) -> NSView {
let panel = AnimatedPanelView()
- panel.toolTip = tooltip(for: title, value: value)
+ panel.toolTip = tooltip(for: title, value: valueLines.joined(separator: " "))
panel.wantsLayer = true
- panel.layer?.cornerRadius = 14
- panel.baseColor = panelBackground(for: color, strength: 0.18)
- panel.highlightColor = panelBackground(for: color, strength: 0.28)
- panel.layer?.borderWidth = 1
- panel.layer?.borderColor = NSColor.separatorColor.withAlphaComponent(0.45).cgColor
+ panel.layer?.cornerRadius = Theme.radiusCard
+ panel.baseColor = panelBackground(for: color, strength: 0.22)
+ panel.highlightColor = panelBackground(for: color, strength: 0.30)
- let iconPlate = iconPlate(symbol: symbol, color: color, size: 36, pointSize: 21)
+ // Plate mit Leuchtfarbe (vivid): die Textfarbe allein ergab im Dark
+ // Mode einen kontrastarmen Kreis hinter dem Symbol.
+ let iconPlate = iconPlate(symbol: symbol, color: plateColor, size: 36, pointSize: 21)
let titleLabel = NSTextField(labelWithString: title)
titleLabel.font = .systemFont(ofSize: 15, weight: .bold)
titleLabel.textColor = .labelColor
- let valueLabel = NSTextField(labelWithString: value ?? "-")
- valueLabel.font = .monospacedDigitSystemFont(ofSize: 23, weight: .bold)
+ let valueLabel = FittingValueLabel()
valueLabel.textColor = color
- valueLabel.lineBreakMode = .byTruncatingTail
+ valueLabel.setLines(valueLines)
for view in [iconPlate, titleLabel, valueLabel] {
view.translatesAutoresizingMaskIntoConstraints = false
panel.addSubview(view)
}
+ // Titel oben, Wert direkt darunter (kompaktes Stat-Tile). Ein
+ // zweizeiliger PV-Wert wächst nach unten; ein einzeiliger Akku-Wert
+ // steht dann auf Höhe der ersten PV-Zeile — die Titel bleiben auf
+ // gleicher Höhe.
NSLayoutConstraint.activate([
- iconPlate.topAnchor.constraint(equalTo: panel.topAnchor, constant: 12),
- iconPlate.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 12),
+ iconPlate.centerYAnchor.constraint(equalTo: panel.centerYAnchor),
+ iconPlate.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 14),
iconPlate.widthAnchor.constraint(equalToConstant: 36),
iconPlate.heightAnchor.constraint(equalToConstant: 36),
- titleLabel.topAnchor.constraint(equalTo: panel.topAnchor, constant: 16),
- titleLabel.leadingAnchor.constraint(equalTo: iconPlate.trailingAnchor, constant: 10),
+ titleLabel.topAnchor.constraint(equalTo: panel.topAnchor, constant: 17),
+ titleLabel.leadingAnchor.constraint(equalTo: iconPlate.trailingAnchor, constant: 12),
titleLabel.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -12),
- valueLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8),
+ valueLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 4),
valueLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
valueLabel.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -12)
])
@@ -206,7 +338,7 @@ final class SolixMenuDashboardView: NSView {
row.toolTip = tooltip(for: title, value: value)
row.baseColor = panelBackground(for: color, strength: 0.14)
row.highlightColor = panelBackground(for: color, strength: 0.22)
- row.layer?.cornerRadius = 10
+ row.layer?.cornerRadius = Theme.radiusChip
let icon = iconPlate(symbol: symbol, color: color, size: 30, pointSize: 17)
let titleLabel = NSTextField(labelWithString: title)
titleLabel.font = .systemFont(ofSize: 14, weight: .semibold)
@@ -244,7 +376,7 @@ final class SolixMenuDashboardView: NSView {
let view = AnimatedPanelView()
view.toolTip = LocalizedText.text("Zeigt, ob die Datenquelle online ist.", "Shows whether the data source is online.")
view.wantsLayer = true
- view.layer?.cornerRadius = 12
+ view.layer?.cornerRadius = Theme.radiusChip
view.baseColor = color.withAlphaComponent(0.18)
view.highlightColor = color.withAlphaComponent(0.28)
view.layer?.borderWidth = 1
@@ -267,13 +399,7 @@ final class SolixMenuDashboardView: NSView {
private func coloredSymbol(_ symbol: String, color: NSColor) -> NSImage? {
guard let image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil) else { return nil }
let configured = image.withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 17, weight: .semibold)) ?? image
- let copy = configured.copy() as? NSImage ?? configured
- copy.isTemplate = false
- copy.lockFocus()
- color.set()
- NSRect(origin: .zero, size: copy.size).fill(using: .sourceAtop)
- copy.unlockFocus()
- return copy
+ return configured.tinted(color)
}
private func iconPlate(symbol: String, color: NSColor, size: CGFloat, pointSize: CGFloat) -> NSView {
@@ -300,13 +426,50 @@ final class SolixMenuDashboardView: NSView {
private func coloredSymbol(_ symbol: String, color: NSColor, pointSize: CGFloat) -> NSImage? {
guard let image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil) else { return nil }
let configured = image.withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: pointSize, weight: .bold)) ?? image
- let copy = configured.copy() as? NSImage ?? configured
- copy.isTemplate = false
- copy.lockFocus()
- color.set()
- NSRect(origin: .zero, size: copy.size).fill(using: .sourceAtop)
- copy.unlockFocus()
- return copy
+ return configured.tinted(color)
+ }
+
+ /// Trend gegenüber dem vorherigen Messwert: ▲/▼ ab kleiner Schwelle,
+ /// sonst nil (kein Rauschen anzeigen).
+ private func trendArrow(current: Int?, previous: Int?, threshold: Int) -> String? {
+ guard let current, let previous else { return nil }
+ if current - previous >= threshold { return "▲" }
+ if previous - current >= threshold { return "▼" }
+ return nil
+ }
+
+ private func withTrend(_ value: String?, arrow: String?) -> String? {
+ guard let value else { return nil }
+ guard let arrow else { return value }
+ return "\(value) \(arrow)"
+ }
+
+ /// Wertezeilen für die Solar-Kachel. Bei bis zu zwei PV-Eingängen (oder
+ /// Summenanzeige) eine Zeile; ab drei Eingängen ein 2×2-Raster auf zwei
+ /// Zeilen, damit die Werte nicht in einer überlangen Zeile abgeschnitten
+ /// werden. Der Trend-Pfeil (bezieht sich auf die Summe) sitzt am Ende der
+ /// letzten Zeile — konsistent mit den übrigen Feldern.
+ nonisolated static func solarValueLines(pvMode: PVDisplayMode, channels: [Int]?, totalWatts: Int?, arrow: String?) -> [String] {
+ func withArrow(_ text: String) -> String {
+ guard let arrow else { return text }
+ return "\(text) \(arrow)"
+ }
+
+ guard pvMode == .perInput, let channels, channels.count > 1 else {
+ guard let totalWatts else { return ["-"] }
+ return [withArrow("\(totalWatts) W")]
+ }
+
+ if channels.count <= 2 {
+ return [withArrow(channels.map(String.init).joined(separator: " · ") + " W")]
+ }
+
+ // Drei bis vier Eingänge: obere Hälfte, untere Hälfte (Einheit + Pfeil
+ // an die zweite Zeile).
+ let half = (channels.count + 1) / 2
+ let topLine = channels[.. String? {
@@ -381,39 +544,40 @@ final class SolixMenuDashboardView: NSView {
}
private var batteryColor: NSColor {
- guard let percent = snapshot.batteryPercent else { return .systemGray }
- if percent <= 20 {
- return isDarkMode
- ? NSColor(calibratedRed: 1.00, green: 0.42, blue: 0.46, alpha: 1)
- : NSColor(calibratedRed: 0.69, green: 0.00, blue: 0.13, alpha: 1)
- }
- if percent <= 60 {
- return isDarkMode
- ? NSColor(calibratedRed: 1.00, green: 0.85, blue: 0.30, alpha: 1)
- : NSColor(calibratedRed: 0.54, green: 0.35, blue: 0.00, alpha: 1)
- }
- return isDarkMode
- ? NSColor(calibratedRed: 0.42, green: 1.00, blue: 0.58, alpha: 1)
- : NSColor(calibratedRed: 0.00, green: 0.36, blue: 0.12, alpha: 1)
+ guard snapshot.batteryPercent != nil else { return .systemGray }
+ return Theme.color(Theme.battery(percent: snapshot.batteryPercent))
}
private var solarColor: NSColor {
- NSColor(calibratedRed: 0.93, green: 0.66, blue: 0.08, alpha: 1)
+ Theme.vivid(.solar)
}
private var gridColor: NSColor {
- guard let watts = snapshot.gridWatts else { return .systemGray }
- return watts > 0 ? .systemBlue : .systemTeal
+ guard snapshot.gridWatts != nil else { return .systemGray }
+ return Theme.vivid(Theme.grid(watts: snapshot.gridWatts))
}
private var batteryFlowColor: NSColor {
- guard let watts = snapshot.batteryWatts else { return .systemGray }
- return watts >= 0 ? .systemGreen : .systemOrange
+ guard snapshot.batteryWatts != nil else { return .systemGray }
+ return Theme.vivid(Theme.batteryFlow(watts: snapshot.batteryWatts))
}
+ /// Dynamische Panel-Farbe: löst sich pro Appearance auf, statt den
+ /// Build-Zeit-Zustand einzufrieren (sonst helle Pastellreihen im Dark Mode,
+ /// wenn die View vor dem Einhängen ins dunkle Fenster gebaut wird).
private func panelBackground(for color: NSColor, strength: CGFloat) -> NSColor {
- let adjustedStrength = isDarkMode ? strength * 0.8 : strength
- return color.withAlphaComponent(adjustedStrength)
- .blended(withFraction: isDarkMode ? 0.72 : 0.80, of: panelColor) ?? panelColor
+ NSColor(name: nil) { appearance in
+ let dark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
+ var resolvedTint = color
+ appearance.performAsCurrentDrawingAppearance {
+ resolvedTint = color.usingColorSpace(.deviceRGB) ?? color
+ }
+ let base = dark
+ ? NSColor(calibratedRed: 0.24, green: 0.25, blue: 0.26, alpha: 1)
+ : NSColor(calibratedRed: 0.995, green: 0.998, blue: 1, alpha: 1)
+ let adjusted = dark ? strength * 0.8 : strength
+ return resolvedTint.withAlphaComponent(adjusted)
+ .blended(withFraction: dark ? 0.72 : 0.80, of: base) ?? base
+ }
}
}
diff --git a/Sources/SolixBarKit/SolixSnapshot.swift b/Sources/SolixBarKit/SolixSnapshot.swift
new file mode 100644
index 0000000..360fd77
--- /dev/null
+++ b/Sources/SolixBarKit/SolixSnapshot.swift
@@ -0,0 +1,72 @@
+import Foundation
+
+struct SolixSnapshot: Codable, Sendable {
+ var siteName: String
+ var batteryPercent: Int?
+ var solarWatts: Int?
+ var homeWatts: Int?
+ var gridWatts: Int?
+ var batteryWatts: Int?
+ var todayKWh: Double?
+ var totalKWh: Double?
+ var status: String?
+ var updatedAt: Date
+ /// Leistung je PV-Eingang (MPPT-Kanal); nur bei Modellen mit
+ /// Kanal-Reporting vorhanden (Solarbank 2/3), sonst nil.
+ var pvWatts: [Int]?
+
+ init(
+ siteName: String,
+ batteryPercent: Int? = nil,
+ solarWatts: Int? = nil,
+ homeWatts: Int? = nil,
+ gridWatts: Int? = nil,
+ batteryWatts: Int? = nil,
+ todayKWh: Double? = nil,
+ totalKWh: Double? = nil,
+ status: String? = nil,
+ updatedAt: Date,
+ pvWatts: [Int]? = nil
+ ) {
+ self.siteName = siteName
+ self.batteryPercent = batteryPercent
+ self.solarWatts = solarWatts
+ self.homeWatts = homeWatts
+ self.gridWatts = gridWatts
+ self.batteryWatts = batteryWatts
+ self.todayKWh = todayKWh
+ self.totalKWh = totalKWh
+ self.status = status
+ self.updatedAt = updatedAt
+ self.pvWatts = pvWatts
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ siteName = try container.decodeIfPresent(String.self, forKey: .siteName) ?? "Anker SOLIX"
+ batteryPercent = try container.decodeIfPresent(Int.self, forKey: .batteryPercent)
+ solarWatts = try container.decodeIfPresent(Int.self, forKey: .solarWatts)
+ homeWatts = try container.decodeIfPresent(Int.self, forKey: .homeWatts)
+ gridWatts = try container.decodeIfPresent(Int.self, forKey: .gridWatts)
+ batteryWatts = try container.decodeIfPresent(Int.self, forKey: .batteryWatts)
+ todayKWh = try container.decodeIfPresent(Double.self, forKey: .todayKWh)
+ totalKWh = try container.decodeIfPresent(Double.self, forKey: .totalKWh)
+ status = try container.decodeIfPresent(String.self, forKey: .status)
+ updatedAt = try container.decodeIfPresent(Date.self, forKey: .updatedAt) ?? Date()
+ pvWatts = try container.decodeIfPresent([Int].self, forKey: .pvWatts)
+ }
+
+ static let demo = SolixSnapshot(
+ siteName: "Anker SOLIX",
+ batteryPercent: 82,
+ solarWatts: 642,
+ homeWatts: 318,
+ gridWatts: -86,
+ batteryWatts: 238,
+ todayKWh: 3.74,
+ totalKWh: 427.8,
+ status: "Online",
+ updatedAt: Date(),
+ pvWatts: [438, 204]
+ )
+}
diff --git a/Sources/SolixBarKit/StackedMenuBarRenderer.swift b/Sources/SolixBarKit/StackedMenuBarRenderer.swift
new file mode 100644
index 0000000..e0cc961
--- /dev/null
+++ b/Sources/SolixBarKit/StackedMenuBarRenderer.swift
@@ -0,0 +1,113 @@
+import AppKit
+
+/// Zweizeilige Kompaktanzeige für die Menüleiste: zwei Reihen à 9 pt
+/// Monospace-Ziffern mit kleinen Metrik-Glyphen — halbiert die Breite bei
+/// gleicher oder höherer Informationsdichte (bewährtes Muster, vgl. iStat
+/// Menus). Gezeichnet über einen NSImage-DrawingHandler, damit dynamische
+/// Theme-Farben zur Zeichenzeit gegen die tatsächliche Menüleisten-Appearance
+/// aufgelöst werden.
+@MainActor
+enum StackedMenuBarRenderer {
+ struct Entry {
+ let symbolName: String
+ let text: String
+ let role: ColorRole
+ }
+
+ /// - Parameters:
+ /// - brightPalette: feste helle Farben für dauerhaft dunkle Flächen
+ /// (abgedockte Leiste) statt der System-adaptiven Menüleistenfarben.
+ /// - height: Gesamthöhe; die Schriftgröße wird darauf gedeckelt, damit
+ /// zwei Zeilen nie überlappen (Menüleiste: 22 pt).
+ static func image(
+ entries: [Entry],
+ scale: Double,
+ showWarning: Bool,
+ brightPalette: Bool = false,
+ height: CGFloat = 22
+ ) -> NSImage? {
+ guard !entries.isEmpty else { return nil }
+ let half = (entries.count + 1) / 2
+ let rows = [Array(entries.prefix(half)), Array(entries.dropFirst(half))]
+ .filter { !$0.isEmpty }
+
+ let fontSize = min(round(9 * scale), floor(height / 2) - 2)
+ let font = NSFont.monospacedDigitSystemFont(ofSize: fontSize, weight: .semibold)
+ let glyphHeight = round(fontSize * 0.9)
+ let entryGap = round(7 * scale)
+ let glyphGap = round(2 * scale)
+
+ // Glyphenbreite folgt dem natürlichen Seitenverhältnis des Symbols —
+ // ein Batteriesymbol (~1,7:1) in ein Quadrat zu zeichnen staucht es.
+ func glyphSize(for symbolName: String) -> NSSize {
+ guard let glyph = NSImage(systemSymbolName: symbolName, accessibilityDescription: nil)?
+ .withSymbolConfiguration(.init(pointSize: glyphHeight, weight: .bold)),
+ glyph.size.height > 0 else {
+ return NSSize(width: glyphHeight, height: glyphHeight)
+ }
+ let aspect = glyph.size.width / glyph.size.height
+ return NSSize(width: round(glyphHeight * aspect), height: glyphHeight)
+ }
+
+ func rowWidth(_ row: [Entry]) -> CGFloat {
+ var width: CGFloat = 0
+ for (index, entry) in row.enumerated() {
+ if index > 0 { width += entryGap }
+ width += glyphSize(for: entry.symbolName).width + glyphGap
+ width += ceil((entry.text as NSString).size(withAttributes: [.font: font]).width)
+ }
+ return width
+ }
+
+ let warningWidth: CGFloat = showWarning ? round(11 * scale) : 0
+ let width = ceil((rows.map(rowWidth).max() ?? 0) + warningWidth) + 2
+
+ let size = NSSize(width: width, height: height)
+ return NSImage(size: size, flipped: false) { _ in
+ let rowHeight = height / CGFloat(rows.count)
+ for (rowIndex, row) in rows.enumerated() {
+ // Reihen von oben nach unten, vertikal in ihrer Hälfte zentriert.
+ let rowCenterY = height - rowHeight * (CGFloat(rowIndex) + 0.5)
+ var x: CGFloat = 1
+ for (index, entry) in row.enumerated() {
+ if index > 0 { x += entryGap }
+ let color = brightPalette ? Theme.hud(entry.role) : Theme.color(entry.role)
+ if let glyph = NSImage(systemSymbolName: entry.symbolName, accessibilityDescription: nil)?
+ .withSymbolConfiguration(.init(pointSize: glyphHeight, weight: .bold)) {
+ let tinted = glyph.tinted(color)
+ let drawSize = glyphSize(for: entry.symbolName)
+ let glyphRect = NSRect(
+ x: x,
+ y: rowCenterY - drawSize.height / 2,
+ width: drawSize.width,
+ height: drawSize.height
+ )
+ tinted.draw(in: glyphRect, from: .zero, operation: .sourceOver, fraction: 1)
+ x += drawSize.width + glyphGap
+ }
+ let attributes: [NSAttributedString.Key: Any] = [
+ .font: font,
+ .foregroundColor: color
+ ]
+ let textSize = (entry.text as NSString).size(withAttributes: attributes)
+ (entry.text as NSString).draw(
+ at: NSPoint(x: x, y: rowCenterY - textSize.height / 2),
+ withAttributes: attributes
+ )
+ x += ceil(textSize.width)
+ }
+ }
+ if showWarning {
+ let attributes: [NSAttributedString.Key: Any] = [
+ .font: NSFont.systemFont(ofSize: round(9 * scale), weight: .bold),
+ .foregroundColor: brightPalette ? Theme.hud(.batteryMedium) : Theme.color(.batteryMedium)
+ ]
+ ("⚠" as NSString).draw(
+ at: NSPoint(x: width - warningWidth, y: height / 2 - round(6 * scale)),
+ withAttributes: attributes
+ )
+ }
+ return true
+ }
+ }
+}
diff --git a/Sources/SolixBarKit/StatusController.swift b/Sources/SolixBarKit/StatusController.swift
new file mode 100644
index 0000000..fa31acd
--- /dev/null
+++ b/Sources/SolixBarKit/StatusController.swift
@@ -0,0 +1,1095 @@
+import AppKit
+import UniformTypeIdentifiers
+
+@MainActor
+final class StatusController: NSObject {
+ private let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
+ private let settings = AppSettings.shared
+ private let historyStore = SolixHistoryStore.shared
+ private var timer: Timer?
+ private var lastSnapshot: SolixSnapshot?
+ private var previousSnapshot: SolixSnapshot?
+ private var lastSnapshotMode: DataSourceMode?
+ private var lastError: String?
+ private var settingsWindow: SettingsWindowController?
+ private var largeGraphWindow: LargeGraphWindowController?
+ private var detachedDashboardWindow: DetachedDashboardWindowController?
+ private var detachedMenuBarWindow: DetachedMenuBarWindowController?
+ private var isMenuBarDetached = false
+ private var isTerminating = false
+ private var isRefreshing = false
+ private var consecutiveRefreshFailures = 0
+ private var displayLevel: MenuBarDisplayLevel = .full
+ private var lastDisplaySignature = ""
+ private let formatter = MenuBarFormatter()
+ private var refreshAnimationTimer: Timer?
+ private var refreshAnimationFrame = 0
+ private let refreshFrames = ["↻", "↺"]
+ private var updateCheckTimer: Timer?
+ private var availableUpdate: ReleaseInfo?
+ private var warningEngine = WarningEngine()
+ private var demoWarningsStart: Date?
+
+ func start() {
+ settings.migrateMenuBarGridMetricIfNeeded()
+ settings.migrateFlowMetricIfNeeded()
+ settings.migrateSolixCommandIfNeeded()
+ applyAppearance()
+ updateMenuBarIcon()
+ setStatusTitle("SOLIX")
+ rebuildMenu()
+ refresh()
+ logMenuBarDiagnostics()
+ DistributedNotificationCenter.default().addObserver(
+ self,
+ selector: #selector(systemAppearanceChanged),
+ name: NSNotification.Name("AppleInterfaceThemeChangedNotification"),
+ object: nil
+ )
+ if settings.isDetachedMenuBarActive {
+ AppLogger.info("Restoring detached slim bar from previous session.")
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
+ self?.openDetachedMenuBar()
+ }
+ }
+ if settings.isLargeGraphActive {
+ AppLogger.info("Restoring detached graph window from previous session.")
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
+ self?.openLargeGraph()
+ }
+ }
+ // Kein scheduleRefreshTimer() hier: refresh() plant den nächsten
+ // Abruf nach Abschluss selbst (Einmal-Timer mit Fehler-Backoff).
+ scheduleUpdateChecks()
+ }
+
+ /// Update-Check: einmal kurz nach dem Start, danach täglich. Fehler sind
+ /// still (offline/Rate-Limit) — es gibt schlicht keinen Hinweis.
+ private func scheduleUpdateChecks() {
+ updateCheckTimer?.invalidate()
+ updateCheckTimer = nil
+ guard settings.updateCheckEnabled else { return }
+ DispatchQueue.main.asyncAfter(deadline: .now() + 10) { [weak self] in
+ self?.checkForUpdates()
+ }
+ let timer = Timer(timeInterval: 24 * 60 * 60, repeats: true) { [weak self] _ in
+ Task { @MainActor in
+ self?.checkForUpdates()
+ }
+ }
+ RunLoop.main.add(timer, forMode: .common)
+ updateCheckTimer = timer
+ }
+
+ private func checkForUpdates() {
+ guard settings.updateCheckEnabled else { return }
+ Task { [weak self] in
+ guard let release = try? await UpdateChecker.fetchLatestRelease() else { return }
+ guard let self else { return }
+ guard UpdateChecker.isNewer(release.version, than: AppVersion.short) else { return }
+ AppLogger.info("Update available: \(release.version) (running \(AppVersion.short)).")
+ self.availableUpdate = release
+ self.rebuildMenu()
+ if self.settings.lastNotifiedUpdateVersion != release.version {
+ self.settings.lastNotifiedUpdateVersion = release.version
+ NotificationManager.shared.post(
+ id: "solixbar.update.\(release.version)",
+ title: LocalizedText.text("SolixBar-Update verfügbar", "SolixBar update available"),
+ body: LocalizedText.text(
+ "Version \(release.version) steht auf GitHub bereit.",
+ "Version \(release.version) is available on GitHub."
+ ),
+ url: release.url
+ )
+ }
+ }
+ }
+
+ @objc private func openUpdatePage() {
+ NSWorkspace.shared.open(availableUpdate?.url ?? UpdateChecker.releasesPageURL)
+ }
+
+ /// Exportiert die History der aktuellen Datenquelle als CSV oder JSON.
+ /// Formatwahl direkt im Speichern-Dialog (Popup unten im Panel).
+ @objc private func exportData() {
+ let panel = NSSavePanel()
+ panel.title = LocalizedText.text("Daten exportieren", "Export data")
+ panel.allowedContentTypes = [.commaSeparatedText]
+ panel.canCreateDirectories = true
+ panel.nameFieldStringValue = HistoryExporter.defaultFilename(ext: "csv")
+
+ let formatLabel = NSTextField(labelWithString: LocalizedText.text("Format:", "Format:"))
+ let formatPopup = NSPopUpButton()
+ formatPopup.addItems(withTitles: ["CSV", "JSON"])
+ let accessory = NSStackView(views: [formatLabel, formatPopup])
+ accessory.orientation = .horizontal
+ accessory.spacing = 8
+ accessory.edgeInsets = NSEdgeInsets(top: 8, left: 0, bottom: 8, right: 0)
+ accessory.translatesAutoresizingMaskIntoConstraints = false
+ let accessoryContainer = NSView(frame: NSRect(x: 0, y: 0, width: 220, height: 36))
+ accessoryContainer.addSubview(accessory)
+ NSLayoutConstraint.activate([
+ accessory.centerXAnchor.constraint(equalTo: accessoryContainer.centerXAnchor),
+ accessory.centerYAnchor.constraint(equalTo: accessoryContainer.centerYAnchor)
+ ])
+ panel.accessoryView = accessoryContainer
+
+ @MainActor
+ final class FormatSwitcher: NSObject {
+ let panel: NSSavePanel
+ init(panel: NSSavePanel) { self.panel = panel }
+ @objc func formatChanged(_ sender: NSPopUpButton) {
+ let isJSON = sender.indexOfSelectedItem == 1
+ panel.allowedContentTypes = [isJSON ? .json : .commaSeparatedText]
+ panel.nameFieldStringValue = HistoryExporter.defaultFilename(ext: isJSON ? "json" : "csv")
+ }
+ }
+ let switcher = FormatSwitcher(panel: panel)
+ formatPopup.target = switcher
+ formatPopup.action = #selector(FormatSwitcher.formatChanged(_:))
+
+ NSApp.activate(ignoringOtherApps: true)
+ let response = panel.runModal()
+ withExtendedLifetime(switcher) {}
+ guard response == .OK, let url = panel.url else { return }
+
+ let sourceKey = settings.dataSourceMode.rawValue
+ let samples = historyStore.samples(duration: 366 * 24 * 60 * 60, sourceKey: sourceKey)
+ let current = currentSnapshot()
+ do {
+ if formatPopup.indexOfSelectedItem == 1 {
+ let data = try HistoryExporter.json(samples: samples, current: current, sourceKey: sourceKey)
+ try data.write(to: url, options: .atomic)
+ } else {
+ let csv = HistoryExporter.csv(samples: samples, current: current)
+ try Data(csv.utf8).write(to: url, options: .atomic)
+ }
+ AppLogger.info("Exported \(samples.count) samples to \(url.lastPathComponent).")
+ } catch {
+ AppLogger.error("Export failed: \(error.localizedDescription)")
+ let alert = NSAlert()
+ alert.alertStyle = .warning
+ alert.messageText = LocalizedText.text("Export fehlgeschlagen", "Export failed")
+ alert.informativeText = error.localizedDescription
+ alert.runModal()
+ }
+ }
+
+ private func evaluateWarnings(for snapshot: SolixSnapshot) {
+ let events = warningEngine.evaluate(
+ snapshot: snapshot,
+ at: snapshot.updatedAt,
+ config: settings.warningConfig()
+ )
+ for event in events {
+ let (id, title, body) = warningText(for: event)
+ AppLogger.info("Warning fired: \(id)")
+ NotificationManager.shared.post(id: id, title: title, body: body)
+ }
+ // Aktive Warnungen erscheinen zusätzlich oben im Menü — der immer
+ // funktionierende Weg, falls Mitteilungen nicht erlaubt wurden.
+ if !events.isEmpty {
+ rebuildMenu()
+ }
+ }
+
+ private func warningText(for event: WarningEngine.Event) -> (id: String, title: String, body: String) {
+ switch event {
+ case .batteryLow(let percent):
+ return (
+ "solixbar.warning.battery-low",
+ LocalizedText.text("Akku niedrig", "Battery low"),
+ LocalizedText.text(
+ "Der Akku ist auf \(percent) % gefallen.",
+ "The battery has dropped to \(percent)%."
+ )
+ )
+ case .pvStalled:
+ return (
+ "solixbar.warning.pv-stalled",
+ LocalizedText.text("PV liefert nichts", "PV not generating"),
+ LocalizedText.text(
+ "Die Solarmodule liefern seit längerem keine Leistung.",
+ "The solar panels have not produced any power for a while."
+ )
+ )
+ case .pvChannelDead(let index):
+ return (
+ "solixbar.warning.pv-channel-\(index)",
+ LocalizedText.text("PV-Eingang \(index + 1) liefert nichts", "PV input \(index + 1) not generating"),
+ LocalizedText.text(
+ "Eingang \(index + 1) liefert 0 W, während die anderen Eingänge erzeugen.",
+ "Input \(index + 1) is at 0 W while the other inputs are producing."
+ )
+ )
+ }
+ }
+
+ func prepareForTermination() {
+ isTerminating = true
+ settings.isDetachedMenuBarActive = isMenuBarDetached
+ settings.isLargeGraphActive = largeGraphWindow != nil
+ AppLogger.info("Persisted detached slim bar state: \(isMenuBarDetached ? "active" : "inactive").")
+ }
+
+ private func provider() -> SolixDataProvider {
+ if settings.dataSourceMode != .demoWarnings {
+ demoWarningsStart = nil
+ }
+ switch settings.dataSourceMode {
+ case .solix:
+ return BundledSolixDataProvider(credentials: .stored())
+ case .demo:
+ return DemoSolixDataProvider()
+ case .demoWarnings:
+ // Szenario startet mit der Aktivierung des Modus; frische Engine,
+ // damit bereits gemeldete Warnungen erneut feuern können.
+ if demoWarningsStart == nil {
+ demoWarningsStart = Date()
+ warningEngine = WarningEngine()
+ AppLogger.info("Warning-test demo scenario started.")
+ }
+ return DemoWarningsSolixDataProvider(start: demoWarningsStart ?? Date())
+ case .command:
+ // Passwort kommt aus dem Schlüsselbund und wird nur als
+ // Umgebungsvariable an den Befehl übergeben, nie auf Platte.
+ var environment: [String: String] = [:]
+ let stored = SolixEnvFile.read(from: SolixPaths.envFileURL)
+ if let user = stored["ANKER_SOLIX_USER"],
+ let password = KeychainStore.password(account: user),
+ !password.isEmpty {
+ environment["ANKER_SOLIX_PASSWORD"] = password
+ }
+ return CommandSolixDataProvider(command: settings.command, extraEnvironment: environment)
+ case .url:
+ return URLSolixDataProvider(urlString: settings.urlString)
+ }
+ }
+
+ private func refresh() {
+ guard !isRefreshing else {
+ AppLogger.info("Refresh skipped because a previous refresh is still running.")
+ return
+ }
+ isRefreshing = true
+ startRefreshAnimation()
+ AppLogger.info("Refreshing data source: \(settings.dataSourceMode.rawValue).")
+ updateTitle()
+ Task {
+ defer {
+ isRefreshing = false
+ stopRefreshAnimation()
+ // Nächsten Abruf erst nach Abschluss planen — so verlängert
+ // das Backoff bei Fehlern automatisch den Abstand.
+ scheduleRefreshTimer()
+ }
+ do {
+ var snapshot = try await provider().fetchSnapshot()
+ snapshot.updatedAt = Date()
+ snapshot.totalKWh = historyStore.cumulativeSolarKWh(
+ recording: snapshot,
+ sourceKey: settings.dataSourceMode.rawValue
+ )
+ // Vorwert für Trend-Pfeile im Dashboard (nur bei gleicher Quelle).
+ previousSnapshot = (lastSnapshotMode == settings.dataSourceMode) ? lastSnapshot : nil
+ lastSnapshot = snapshot
+ lastSnapshotMode = settings.dataSourceMode
+ lastError = nil
+ consecutiveRefreshFailures = 0
+ historyStore.record(
+ snapshot,
+ sourceKey: settings.dataSourceMode.rawValue,
+ refreshInterval: settings.refreshInterval
+ )
+ let pvInfo = snapshot.pvWatts.map { ", pv=[\($0.map(String.init).joined(separator: ","))]W" } ?? ""
+ AppLogger.info("Refresh succeeded: battery=\(snapshot.batteryPercent.map(String.init) ?? "-")%, solar=\(snapshot.solarWatts.map(String.init) ?? "-")W, grid=\(snapshot.gridWatts.map(String.init) ?? "-")W\(pvInfo).")
+ evaluateWarnings(for: snapshot)
+ } catch {
+ // Letzten gültigen Snapshot behalten: ein transienter Fehler
+ // soll die Anzeige nicht leeren, nur als veraltet markieren.
+ lastError = error.localizedDescription
+ consecutiveRefreshFailures += 1
+ AppLogger.error("Refresh failed (keeping last snapshot): \(Self.describeError(error))")
+ }
+ updateTitle()
+ rebuildMenu()
+ // Unsichtbare Fenster nicht neu aufbauen — beim Öffnen wird
+ // ohnehin frisch gerendert (showBelowMenuBar/openLargeGraph).
+ if detachedDashboardWindow?.window?.isVisible == true {
+ detachedDashboardWindow?.rebuild()
+ }
+ detachedMenuBarWindow?.rebuild()
+ if largeGraphWindow?.window?.isVisible == true {
+ largeGraphWindow?.rebuild()
+ }
+ }
+ }
+
+ /// DecodingError & Co. mit vollem Kontext ins Log — die blosse
+ /// localizedDescription ("Die Daten konnten nicht gelesen werden") ist
+ /// für die Fehlersuche wertlos.
+ private static func describeError(_ error: Error) -> String {
+ if let decoding = error as? DecodingError {
+ return "DecodingError: \(decoding)"
+ }
+ if let urlError = error as? URLError {
+ return "URLError \(urlError.code.rawValue): \(urlError.localizedDescription)"
+ }
+ return error.localizedDescription
+ }
+
+ private func startRefreshAnimation() {
+ // Animation nur beim Erststart ohne Werte — danach bleibt die
+ // Anzeige während des Refreshs unangetastet.
+ guard currentSnapshot() == nil else { return }
+ refreshAnimationFrame = 0
+ refreshAnimationTimer?.invalidate()
+ let timer = Timer(timeInterval: 0.16, repeats: true) { [weak self] _ in
+ Task { @MainActor in
+ guard let self else { return }
+ self.refreshAnimationFrame = (self.refreshAnimationFrame + 1) % self.refreshFrames.count
+ self.updateTitle()
+ }
+ }
+ RunLoop.main.add(timer, forMode: .common)
+ refreshAnimationTimer = timer
+ }
+
+ private func stopRefreshAnimation() {
+ refreshAnimationTimer?.invalidate()
+ refreshAnimationTimer = nil
+ refreshAnimationFrame = 0
+ updateTitle()
+ }
+
+ private func scheduleRefreshTimer() {
+ timer?.invalidate()
+ // Warnungs-Test: fester 30-Sekunden-Takt, damit das geraffte
+ // Szenario zügig durchläuft.
+ let baseInterval = settings.dataSourceMode == .demoWarnings
+ ? 30
+ : max(60, settings.refreshInterval)
+ let interval = Self.backoffInterval(
+ base: baseInterval,
+ consecutiveFailures: consecutiveRefreshFailures
+ )
+ // Einmal-Timer: der nächste Abruf wird erst nach Abschluss des
+ // laufenden geplant, damit sich Fehler-Backoff und langsame Abrufe
+ // nicht mit einem festen Takt überlappen.
+ let newTimer = Timer(timeInterval: interval, repeats: false) { [weak self] _ in
+ Task { @MainActor in
+ self?.timer = nil
+ self?.refresh()
+ }
+ }
+ newTimer.tolerance = min(5, interval * 0.1)
+ RunLoop.main.add(newTimer, forMode: .common)
+ timer = newTimer
+ if consecutiveRefreshFailures > 0 {
+ AppLogger.info("Next refresh in \(Int(interval)) seconds (backoff after \(consecutiveRefreshFailures) failures).")
+ } else {
+ AppLogger.info("Next refresh scheduled in \(Int(interval)) seconds.")
+ }
+ }
+
+ /// Fehler-Backoff: Basisintervall × 2^Fehler, gedeckelt auf 30 Minuten.
+ /// So hämmert die App bei toter API nicht die ganze Nacht durch.
+ nonisolated static func backoffInterval(base: TimeInterval, consecutiveFailures: Int) -> TimeInterval {
+ let multiplier = pow(2.0, Double(min(4, max(0, consecutiveFailures))))
+ return min(30 * 60, base * multiplier)
+ }
+
+ private func updateTitle() {
+ if isRefreshing {
+ // Laufende Anzeige nicht verdrängen (kein Breiten-Zucken alle
+ // 5 Minuten); Lade-Feedback nur, solange noch keine Werte da sind.
+ if currentSnapshot() == nil {
+ setStatusAttributedTitle(refreshStatusAttributedTitle(scale: settings.menuBarScale))
+ }
+ return
+ }
+
+ guard let snapshot = currentSnapshot() else {
+ let title = lastError == nil ? "SOLIX" : "SOLIX !"
+ setStatusTitle(title)
+ return
+ }
+
+ applyTitle(for: snapshot, level: displayLevel)
+ enforceNotchFit(with: snapshot)
+ }
+
+ private var settingsDisplayOptions: MenuBarDisplayOptions {
+ MenuBarDisplayOptions(
+ metrics: settings.barMetrics,
+ showLabels: settings.showMetricLabels,
+ showSymbols: settings.showMenuBarMetricSymbols,
+ showArrows: settings.showEnergyFlowArrows,
+ showColors: settings.showFlowColors,
+ pvDisplay: settings.menuBarPVDisplay
+ )
+ }
+
+ private var detachedDisplayOptions: MenuBarDisplayOptions {
+ MenuBarDisplayOptions(
+ metrics: settings.detachedBarMetrics,
+ showLabels: settings.detachedShowLabels,
+ showSymbols: settings.detachedShowSymbols,
+ showArrows: settings.detachedShowArrows,
+ showColors: settings.detachedShowFlowColors,
+ pvDisplay: settings.detachedPVDisplay
+ )
+ }
+
+ /// Kompaktansichten haben eigene Werte-Listen (Auswahl + Reihenfolge);
+ /// nur die Metriken unterscheiden sich von den einzeiligen Optionen.
+ private var stackedSettingsDisplayOptions: MenuBarDisplayOptions {
+ var options = settingsDisplayOptions
+ options.metrics = settings.effectiveStackedBarMetrics
+ return options
+ }
+
+ private var stackedDetachedDisplayOptions: MenuBarDisplayOptions {
+ var options = detachedDisplayOptions
+ options.metrics = settings.effectiveDetachedStackedBarMetrics
+ return options
+ }
+
+ private func applyTitle(for snapshot: SolixSnapshot, level: MenuBarDisplayLevel) {
+ let options = settingsDisplayOptions.applying(level)
+
+ if settings.menuBarStacked {
+ let entries = formatter.stackedEntries(for: snapshot, options: stackedSettingsDisplayOptions.applying(level))
+ if entries.count >= 2,
+ let image = StackedMenuBarRenderer.image(
+ entries: entries,
+ scale: settings.menuBarScale,
+ showWarning: lastError != nil
+ ) {
+ item.button?.image = image
+ item.button?.imagePosition = .imageOnly
+ item.button?.attributedTitle = NSAttributedString()
+ return
+ }
+ }
+ // Normale einzeilige Darstellung: Icon-Zustand wiederherstellen,
+ // falls zuvor das gestapelte Bild aktiv war.
+ updateMenuBarIcon()
+ let warn = lastError == nil ? "" : " ⚠"
+
+ if options.metrics.isEmpty {
+ let battery = snapshot.batteryPercent.map { "\($0)%" } ?? "SOLIX"
+ setStatusTitle(battery + warn)
+ return
+ }
+
+ if options.showSymbols || options.showArrows || options.showColors {
+ setStatusAttributedTitle(formatter.attributedTitle(
+ for: snapshot,
+ scale: settings.menuBarScale,
+ options: options,
+ showWarning: lastError != nil
+ ))
+ } else {
+ let battery = snapshot.batteryPercent.map { "\($0)%" } ?? "--%"
+ let title = (formatter.plainTitle(for: snapshot, options: options) ?? battery) + warn
+ setStatusTitle(title)
+ }
+ }
+
+ /// Prüft nach dem Layout, ob das Item in die Notch-Zone ragt, und
+ /// verdichtet die Anzeige stufenweise, bis es passt. macOS würde ein
+ /// überlappendes Item sonst komplett ausblenden.
+ private func enforceNotchFit(with snapshot: SolixSnapshot, attempt: Int = 0) {
+ // Kurze Verzögerung: die Menüleiste positioniert das Item erst nach
+ // dem Setzen des Titels neu; ein sofortiger Check sähe veraltete Frames.
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.10) { [weak self] in
+ guard let self else { return }
+ let screen = self.item.button?.window?.screen ?? NSScreen.main
+ guard let notch = NotchGeometry.notchRect(on: screen) else { return }
+
+ let frame = self.statusButtonFrameOnScreen()
+ let isUsable = frame.map { $0.width > 1 && $0.minX > 1 } ?? false
+ guard let frame, isUsable else {
+ // Kein brauchbarer Frame: entweder ist das Layout noch nicht
+ // fertig (kurz erneut versuchen) oder macOS hat das Item
+ // bereits hinter der Notch versteckt — dann hilft nur
+ // Verdichten, sonst bleibt die Anzeige dauerhaft unsichtbar.
+ if attempt < 4 {
+ self.enforceNotchFit(with: snapshot, attempt: attempt + 1)
+ } else if let next = self.displayLevel.next {
+ AppLogger.info("Status item frame unusable (likely hidden behind the notch): degrading to level \(next.rawValue).")
+ self.displayLevel = next
+ self.applyTitle(for: snapshot, level: next)
+ self.enforceNotchFit(with: snapshot)
+ }
+ return
+ }
+
+ guard NotchGeometry.overlaps(
+ itemMinX: frame.minX,
+ itemMaxX: frame.maxX,
+ notchMinX: notch.minX,
+ notchMaxX: notch.maxX
+ ) else { return }
+ guard let next = self.displayLevel.next else { return }
+ AppLogger.info(
+ "Menu bar item overlaps notch (item \(Int(frame.minX))-\(Int(frame.maxX)), notch \(Int(notch.minX))-\(Int(notch.maxX))): degrading to level \(next.rawValue)."
+ )
+ self.displayLevel = next
+ self.applyTitle(for: snapshot, level: next)
+ self.enforceNotchFit(with: snapshot)
+ }
+ }
+
+ private func logMenuBarDiagnostics() {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in
+ guard let self, let frame = self.statusButtonFrameOnScreen() else { return }
+ if let notch = NotchGeometry.notchRect(on: self.item.button?.window?.screen) {
+ AppLogger.info(
+ "Menu bar item at x=\(Int(frame.minX))-\(Int(frame.maxX)) (width \(Int(frame.width))), notch zone \(Int(notch.minX))-\(Int(notch.maxX))."
+ )
+ } else {
+ AppLogger.info("Menu bar item at x=\(Int(frame.minX))-\(Int(frame.maxX)) (width \(Int(frame.width))), no notch.")
+ }
+ }
+ }
+
+ private func rebuildMenu() {
+ let menu = NSMenu()
+
+ if let snapshot = currentSnapshot() {
+ menu.addItem(dashboardItem(snapshot))
+ } else {
+ menu.addItem(header("Anker SOLIX"))
+ menu.addItem(value(LocalizedText.text("Status", "Status"), lastError ?? LocalizedText.text("Warte auf Daten ...", "Waiting for data ..."), symbol: "hourglass", color: .systemGray))
+ }
+
+ if let lastError {
+ menu.addItem(NSMenuItem.separator())
+ menu.addItem(value(LocalizedText.text("Fehler", "Error"), lastError, symbol: "exclamationmark.triangle.fill", color: .systemRed))
+ }
+
+ if !warningEngine.activeEvents.isEmpty {
+ menu.addItem(NSMenuItem.separator())
+ for event in warningEngine.activeEvents {
+ let text = warningText(for: event)
+ menu.addItem(value(
+ LocalizedText.text("Warnung", "Warning"),
+ text.title,
+ symbol: "exclamationmark.triangle.fill",
+ color: .systemOrange
+ ))
+ }
+ }
+
+ if let update = availableUpdate {
+ menu.addItem(NSMenuItem.separator())
+ menu.addItem(action(
+ LocalizedText.text("Update verfügbar (\(update.version))", "Update available (\(update.version))"),
+ #selector(openUpdatePage),
+ "arrow.down.circle.fill"
+ ))
+ }
+
+ menu.addItem(NSMenuItem.separator())
+ menu.addItem(action(LocalizedText.text("Aktualisieren", "Refresh"), #selector(refreshMenuAction), "arrow.clockwise"))
+ menu.addItem(action(
+ isMenuBarDetached
+ ? LocalizedText.text("Abgedockte Leiste ausblenden", "Hide detached bar")
+ : LocalizedText.text("Abgedockte Leiste anzeigen", "Show detached bar"),
+ #selector(toggleDetachedMenuBar),
+ "menubar.rectangle"
+ ))
+ menu.addItem(action(LocalizedText.text("Dashboard abdocken", "Detach dashboard"), #selector(openDetachedDashboard), "macwindow.on.rectangle"))
+ menu.addItem(action(
+ largeGraphWindow == nil
+ ? LocalizedText.text("Verlauf abdocken", "Detach history graph")
+ : LocalizedText.text("Verlaufsfenster schließen", "Close history window"),
+ #selector(toggleLargeGraph),
+ "chart.xyaxis.line"
+ ))
+ menu.addItem(action(LocalizedText.text("Daten exportieren ...", "Export data ..."), #selector(exportData), "square.and.arrow.up"))
+ menu.addItem(action(LocalizedText.text("Einstellungen ...", "Settings ..."), #selector(openSettings), "gearshape"))
+ menu.addItem(action(LocalizedText.text("Logdatei anzeigen", "Show log file"), #selector(showLogFile), "doc.text.magnifyingglass"))
+ menu.addItem(NSMenuItem.separator())
+ menu.addItem(versionItem())
+ menu.addItem(action(LocalizedText.text("Beenden", "Quit"), #selector(quit), "power"))
+ // NSMenus folgen NSApp.appearance nicht automatisch: Bei erzwungenem
+ // Hell/Dunkel muss das Menü die Appearance explizit erben, sonst
+ // bleibt das Dropdown im Systemlook.
+ menu.appearance = settings.appearanceMode == .system ? nil : NSApp.appearance
+ item.menu = menu
+ }
+
+ private func dashboardItem(_ snapshot: SolixSnapshot) -> NSMenuItem {
+ let item = NSMenuItem()
+ let view = SolixMenuDashboardView(
+ snapshot: snapshot,
+ previous: previousSnapshot,
+ graphProvider: { [weak self] in self?.graphSamples() ?? [] },
+ onRangeChange: { [weak self] in
+ self?.largeGraphWindow?.rebuild()
+ },
+ onOpenLarge: { [weak self] in
+ self?.openLargeGraph()
+ }
+ )
+ // Erzwungene Appearance schon beim Bau mitgeben: sonst baut sich die
+ // View beim ersten Einblenden im Menüfenster komplett neu auf
+ // (Appearance-Wechsel), was das Layout gelegentlich kollabieren ließ.
+ if settings.appearanceMode != .system {
+ view.appearance = NSApp.appearance
+ }
+ item.view = view
+ return item
+ }
+
+ private func historyGraphItem() -> NSMenuItem {
+ let item = NSMenuItem()
+ item.view = HistoryGraphMenuView(
+ graphProvider: { [weak self] in self?.graphSamples() ?? [] },
+ onRangeChange: { [weak self] in
+ self?.largeGraphWindow?.rebuild()
+ },
+ onOpenLarge: { [weak self] in
+ self?.openLargeGraph()
+ }
+ )
+ return item
+ }
+
+ private func graphSamples() -> [SolixHistorySample] {
+ // Demo-Modus: immer eine synthetische Kurve über die volle gewählte
+ // Spanne — der Verlauf enthält sonst nur die seit Erstnutzung
+ // aufgezeichneten Minuten und lange Zeiträume blieben leer.
+ if settings.dataSourceMode == .demo {
+ return demoGraphSamples(duration: settings.historyDuration)
+ }
+ return historyStore.samples(
+ duration: settings.historyDuration,
+ sourceKey: settings.dataSourceMode.rawValue
+ )
+ }
+
+ private func demoGraphSamples(duration: TimeInterval) -> [SolixHistorySample] {
+ let now = Date()
+ let days = max(1.0, duration / 86_400)
+ let count = min(240, max(48, Int(days * 24)))
+ return (0.. NSMenuItem {
+ let item = NSMenuItem(title: title, action: nil, keyEquivalent: "")
+ item.isEnabled = false
+ item.attributedTitle = NSAttributedString(
+ string: title,
+ attributes: [
+ .font: NSFont.boldSystemFont(ofSize: 13),
+ .foregroundColor: NSColor.labelColor
+ ]
+ )
+ return item
+ }
+
+ private func value(_ label: String, _ text: String?, symbol: String, color: NSColor) -> NSMenuItem {
+ let title = "\(label): \(text ?? "-")"
+ let item = NSMenuItem(title: title, action: nil, keyEquivalent: "")
+ item.isEnabled = false
+ item.attributedTitle = NSAttributedString(
+ string: title,
+ attributes: [
+ .font: NSFont.systemFont(ofSize: 13, weight: .medium),
+ .foregroundColor: NSColor.labelColor
+ ]
+ )
+ item.image = formatter.coloredSymbol(symbol, color: color, accessibilityDescription: label)
+ return item
+ }
+
+ private func metricValue(_ metric: BarMetric, _ text: String?, snapshot: SolixSnapshot, label: String? = nil) -> NSMenuItem {
+ value(label ?? metric.localizedTitle, text, symbol: formatter.symbol(for: metric, snapshot: snapshot), color: formatter.color(for: metric, snapshot: snapshot))
+ }
+
+ private func action(_ title: String, _ selector: Selector, _ symbol: String) -> NSMenuItem {
+ let item = NSMenuItem(title: title, action: selector, keyEquivalent: "")
+ item.target = self
+ item.image = NSImage(systemSymbolName: symbol, accessibilityDescription: title)
+ return item
+ }
+
+ private func versionItem() -> NSMenuItem {
+ let item = NSMenuItem(title: AppVersion.display, action: nil, keyEquivalent: "")
+ item.isEnabled = false
+ item.image = NSImage(systemSymbolName: "info.circle", accessibilityDescription: "Version")
+ item.attributedTitle = NSAttributedString(
+ string: AppVersion.display,
+ attributes: [
+ .font: NSFont.systemFont(ofSize: 12, weight: .medium),
+ .foregroundColor: NSColor.secondaryLabelColor
+ ]
+ )
+ return item
+ }
+
+ private func updateMenuBarIcon() {
+ guard shouldShowMenuBarIcon else {
+ item.button?.image = nil
+ item.button?.imagePosition = .noImage
+ return
+ }
+
+ item.button?.image = menuBarIcon()
+ item.button?.imagePosition = .imageLeading
+ }
+
+ private var shouldShowMenuBarIcon: Bool {
+ settings.showMenuBarIcon
+ }
+
+ /// Template-Glyph statt herunterskaliertem App-Icon: Das 1,5-MB-PNG war bei
+ /// 18 px nur noch ein Farbfleck, wurde bei jedem Menu-Rebuild neu von Platte
+ /// geladen und verstiess als Vollfarbbild an der HIG für Statusitems.
+ private func menuBarIcon() -> NSImage? {
+ guard let image = NSImage(systemSymbolName: "bolt.fill", accessibilityDescription: "SOLIX") else {
+ return nil
+ }
+ let configuration = NSImage.SymbolConfiguration(
+ pointSize: round(13 * settings.menuBarScale),
+ weight: .semibold
+ )
+ let configured = image.withSymbolConfiguration(configuration) ?? image
+ configured.isTemplate = true
+ return configured
+ }
+
+ private func setStatusTitle(_ title: String) {
+ let prefix = shouldShowMenuBarIcon ? " " : ""
+ let fontSize = round(13 * settings.menuBarScale)
+ item.button?.attributedTitle = NSAttributedString(
+ string: prefix + title,
+ attributes: [
+ .font: NSFont.monospacedDigitSystemFont(ofSize: fontSize, weight: .medium),
+ .foregroundColor: NSColor.labelColor
+ ]
+ )
+ }
+
+ private func setStatusAttributedTitle(_ value: NSAttributedString) {
+ let title = NSMutableAttributedString()
+ if shouldShowMenuBarIcon {
+ title.append(NSAttributedString(string: " "))
+ }
+ title.append(value)
+ item.button?.attributedTitle = title
+ }
+
+ private func refreshIndicator() -> String {
+ refreshFrames[refreshAnimationFrame % refreshFrames.count]
+ }
+
+ private func refreshIndicatorAttributedText(scale: Double) -> NSAttributedString {
+ NSAttributedString(
+ string: refreshIndicator(),
+ attributes: [
+ .font: NSFont.monospacedDigitSystemFont(ofSize: round(14 * scale), weight: .bold),
+ .foregroundColor: Theme.color(.refresh),
+ .shadow: formatter.textShadow
+ ]
+ )
+ }
+
+ private func refreshStatusAttributedTitle(scale: Double) -> NSAttributedString {
+ NSAttributedString(
+ string: "\(refreshIndicator()) \(LocalizedText.text("Aktualisiert ...", "Refreshing ..."))",
+ attributes: [
+ .font: NSFont.monospacedDigitSystemFont(ofSize: round(13 * scale), weight: .bold),
+ .foregroundColor: Theme.color(.refresh),
+ .shadow: formatter.textShadow
+ ]
+ )
+ }
+
+ @objc private func systemAppearanceChanged() {
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in
+ guard let self else { return }
+ AppLogger.info("System appearance changed: rebuilding views.")
+ self.updateTitle()
+ self.rebuildMenu()
+ self.detachedDashboardWindow?.rebuild()
+ self.detachedMenuBarWindow?.rebuild()
+ self.largeGraphWindow?.rebuild()
+ }
+ }
+
+ @objc private func refreshMenuAction() {
+ AppLogger.info("Manual refresh requested from menu.")
+ refresh()
+ }
+
+ @objc private func toggleDetachedMenuBar() {
+ AppLogger.info(isMenuBarDetached ? "Dock slim menu bar requested." : "Detach slim menu bar requested.")
+ if isMenuBarDetached {
+ dockDetachedMenuBar()
+ } else {
+ openDetachedMenuBar()
+ }
+ }
+
+ @objc private func openSettings() {
+ AppLogger.info("Settings window requested.")
+ if settingsWindow == nil {
+ settingsWindow = SettingsWindowController(
+ onPreview: { [weak self] in
+ AppLogger.info("Settings preview applied.")
+ self?.applyCurrentSettings(refreshNow: false)
+ },
+ onSave: { [weak self] in
+ AppLogger.info("Settings saved.")
+ self?.applyCurrentSettings(refreshNow: true)
+ },
+ onReset: { [weak self] in
+ AppLogger.info("Settings reset or cancelled.")
+ self?.applyCurrentSettings(refreshNow: true)
+ }
+ )
+ }
+ settingsWindow?.showWindow(nil)
+ NSApp.activate(ignoringOtherApps: true)
+ }
+
+ private func openLargeGraph() {
+ AppLogger.info("Large graph window requested.")
+ if largeGraphWindow == nil {
+ largeGraphWindow = LargeGraphWindowController(
+ graphProvider: { [weak self] in self?.graphSamples() ?? [] },
+ onClose: { [weak self] in
+ guard let self, !self.isTerminating, self.largeGraphWindow != nil else { return }
+ self.largeGraphWindow = nil
+ self.settings.isLargeGraphActive = false
+ self.rebuildMenu()
+ }
+ )
+ }
+ settings.isLargeGraphActive = true
+ largeGraphWindow?.rebuild()
+ largeGraphWindow?.showWindow(nil)
+ rebuildMenu()
+ NSApp.activate(ignoringOtherApps: true)
+ }
+
+ @objc private func toggleLargeGraph() {
+ if let open = largeGraphWindow {
+ open.close()
+ } else {
+ openLargeGraph()
+ }
+ }
+
+ @objc private func openDetachedDashboard() {
+ AppLogger.info("Detached dashboard requested.")
+ if detachedDashboardWindow == nil {
+ detachedDashboardWindow = DetachedDashboardWindowController(
+ snapshotProvider: { [weak self] in self?.currentSnapshot() },
+ previousProvider: { [weak self] in self?.previousSnapshot },
+ graphProvider: { [weak self] in self?.graphSamples() ?? [] },
+ onRangeChange: { [weak self] in
+ self?.detachedDashboardWindow?.rebuild()
+ self?.largeGraphWindow?.rebuild()
+ },
+ onOpenLarge: { [weak self] in
+ self?.openLargeGraph()
+ }
+ )
+ }
+ detachedDashboardWindow?.showBelowMenuBar(anchor: statusButtonFrameOnScreen())
+ }
+
+ @objc private func openDetachedMenuBar() {
+ if detachedMenuBarWindow == nil {
+ detachedMenuBarWindow = DetachedMenuBarWindowController(
+ attributedBarProvider: { [weak self] in
+ guard let self, let snapshot = self.currentSnapshot() else { return nil }
+ return self.formatter.attributedTitle(
+ for: snapshot,
+ scale: self.settings.detachedMenuBarScale,
+ options: self.detachedDisplayOptions
+ )
+ },
+ stackedImageProvider: { [weak self] in
+ guard let self, self.settings.detachedBarStacked,
+ let snapshot = self.currentSnapshot() else { return nil }
+ let entries = self.formatter.stackedEntries(for: snapshot, options: self.stackedDetachedDisplayOptions)
+ guard entries.count >= 2 else { return nil }
+ return StackedMenuBarRenderer.image(
+ entries: entries,
+ scale: self.settings.detachedMenuBarScale * 1.3,
+ showWarning: self.lastError != nil,
+ brightPalette: true,
+ height: round(30 * self.settings.detachedMenuBarScale)
+ )
+ },
+ onClose: { [weak self] in
+ self?.isMenuBarDetached = false
+ if self?.isTerminating != true {
+ self?.settings.isDetachedMenuBarActive = false
+ AppLogger.info("Detached slim bar closed by user.")
+ }
+ self?.detachedMenuBarWindow = nil
+ self?.updateMenuBarIcon()
+ self?.updateTitle()
+ self?.rebuildMenu()
+ }
+ )
+ }
+ isMenuBarDetached = true
+ settings.isDetachedMenuBarActive = true
+ AppLogger.info("Detached slim bar opened.")
+ updateMenuBarIcon()
+ updateTitle()
+ rebuildMenu()
+ detachedMenuBarWindow?.showBelowMenuBar(anchor: statusButtonFrameOnScreen())
+ }
+
+ private func dockDetachedMenuBar() {
+ if let detachedMenuBarWindow {
+ detachedMenuBarWindow.closeFromOwner()
+ return
+ }
+ isMenuBarDetached = false
+ settings.isDetachedMenuBarActive = false
+ updateMenuBarIcon()
+ updateTitle()
+ rebuildMenu()
+ }
+
+ private func statusButtonFrameOnScreen() -> NSRect? {
+ guard let button = item.button, let window = button.window else { return nil }
+ return window.convertToScreen(button.convert(button.bounds, to: nil))
+ }
+
+ private func applyCurrentSettings(refreshNow: Bool) {
+ // Verdichtungsstufe nur zurücksetzen, wenn sich anzeige-relevante
+ // Optionen geändert haben. Sonst blendet z. B. das Umschalten von
+ // "Leiste fixieren" das Item kurz in voller Breite ein, macOS
+ // versteckt es hinter der Notch, und die Anzeige verschwindet.
+ let signature = [
+ settings.barMetrics.map(\.rawValue).joined(separator: ","),
+ settings.effectiveStackedBarMetrics.map(\.rawValue).joined(separator: ","),
+ settings.menuBarPVDisplay.rawValue,
+ String(settings.showMetricLabels),
+ String(settings.showMenuBarMetricSymbols),
+ String(settings.showEnergyFlowArrows),
+ String(settings.showFlowColors),
+ String(settings.showMenuBarIcon),
+ String(settings.menuBarStacked),
+ String(settings.menuBarScale)
+ ].joined(separator: "|")
+ if signature != lastDisplaySignature {
+ lastDisplaySignature = signature
+ displayLevel = .full
+ }
+ scheduleUpdateChecks()
+ applyAppearance()
+ updateMenuBarIcon()
+ clearStaleSnapshotIfNeeded()
+ updateTitle()
+ rebuildMenu()
+ detachedMenuBarWindow?.rebuild()
+ detachedDashboardWindow?.rebuild()
+ largeGraphWindow?.applyWindowLevel()
+ largeGraphWindow?.rebuild()
+ if refreshNow {
+ // Quellenwechsel: alter Fehlerzähler soll die neue Quelle
+ // nicht ausbremsen; refresh() plant den Timer anschliessend.
+ consecutiveRefreshFailures = 0
+ refresh()
+ } else {
+ scheduleRefreshTimer()
+ }
+ }
+
+ private func applyAppearance() {
+ switch settings.appearanceMode {
+ case .system:
+ NSApp.appearance = nil
+ case .light:
+ NSApp.appearance = NSAppearance(named: .aqua)
+ case .dark:
+ NSApp.appearance = NSAppearance(named: .darkAqua)
+ }
+ }
+
+ private func currentSnapshot() -> SolixSnapshot? {
+ guard lastSnapshotMode == settings.dataSourceMode else { return nil }
+ return lastSnapshot
+ }
+
+ private func clearStaleSnapshotIfNeeded() {
+ guard lastSnapshotMode == settings.dataSourceMode else {
+ lastSnapshot = nil
+ lastSnapshotMode = nil
+ lastError = configurationMessage()
+ return
+ }
+
+ if !isCurrentDataSourceConfigured {
+ lastSnapshot = nil
+ lastSnapshotMode = nil
+ lastError = configurationMessage()
+ }
+ }
+
+ private var isCurrentDataSourceConfigured: Bool {
+ switch settings.dataSourceMode {
+ case .solix:
+ {
+ let credentials = BundledSolixDataProvider.Credentials.stored()
+ return !credentials.email.isEmpty && !credentials.password.isEmpty
+ }()
+ case .demo, .demoWarnings:
+ true
+ case .command:
+ !settings.command.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ case .url:
+ !settings.urlString.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
+ }
+ }
+
+ private func configurationMessage() -> String? {
+ switch settings.dataSourceMode {
+ case .solix:
+ isCurrentDataSourceConfigured
+ ? LocalizedText.text("Noch keine SOLIX-Daten geladen.", "No SOLIX data loaded yet.")
+ : LocalizedText.text("SOLIX-Mail und Passwort fehlen.", "SOLIX email and password are missing.")
+ case .demo, .demoWarnings:
+ nil
+ case .command:
+ isCurrentDataSourceConfigured ? "Noch keine Daten vom JSON-Befehl geladen." : "Kein JSON-Befehl konfiguriert."
+ case .url:
+ isCurrentDataSourceConfigured ? "Noch keine Daten von der JSON-URL geladen." : "Keine JSON-URL konfiguriert."
+ }
+ }
+
+ @objc private func quit() {
+ NSApp.terminate(nil)
+ }
+
+ @objc private func showLogFile() {
+ AppLogger.info("Log file requested from menu.")
+ NSWorkspace.shared.activateFileViewerSelecting([AppLogger.logURL])
+ }
+}
diff --git a/Sources/SolixBarKit/Theme.swift b/Sources/SolixBarKit/Theme.swift
new file mode 100644
index 0000000..43c8286
--- /dev/null
+++ b/Sources/SolixBarKit/Theme.swift
@@ -0,0 +1,203 @@
+import AppKit
+
+/// Semantische Farbrolle eines dargestellten Werts. Jede Stelle, die Energiewerte
+/// einfärbt, wählt eine Rolle statt einer konkreten Farbe.
+enum ColorRole: String {
+ case solar
+ case load
+ case gridImport
+ case gridExport
+ case batteryLow
+ case batteryMedium
+ case batteryHigh
+ case batteryCharging
+ case batteryDischarging
+ case yieldToday
+ case yieldTotal
+ case status
+ case refresh
+ case neutral
+}
+
+extension NSAttributedString.Key {
+ /// Markiert einen Textlauf mit seiner Farbrolle (rawValue von ColorRole),
+ /// damit abgeleitete Darstellungen (z. B. die abgedockte Leiste) die Farbe
+ /// semantisch neu auflösen können statt Text zu parsen.
+ static let solixRole = NSAttributedString.Key("solixColorRole")
+}
+
+/// Zentrale Design-Token: Farben, Radien, Abstände.
+/// Menüleiste/Dashboard nutzen `color(_:)` (hell/dunkel adaptiv),
+/// die dunkle abgedockte Leiste `bright(_:)`, Legenden/Akzente `accent(_:)`.
+@MainActor
+enum Theme {
+ // MARK: Radien
+ static let radiusPanel: CGFloat = 16
+ static let radiusCard: CGFloat = 12
+ static let radiusChip: CGFloat = 8
+
+ // MARK: Adaptive Wertfarben (hell, dunkel)
+ static func color(_ role: ColorRole) -> NSColor {
+ switch role {
+ case .solar:
+ adaptive(light: (0.48, 0.22, 0.00), dark: (1.00, 0.82, 0.30))
+ case .load:
+ adaptive(light: (0.10, 0.34, 0.78), dark: (0.40, 0.78, 1.00))
+ case .gridImport:
+ adaptive(light: (0.72, 0.10, 0.12), dark: (1.00, 0.48, 0.44))
+ case .gridExport:
+ adaptive(light: (0.34, 0.10, 0.58), dark: (0.82, 0.65, 1.00))
+ case .batteryLow:
+ adaptive(light: (0.69, 0.00, 0.13), dark: (1.00, 0.42, 0.46))
+ case .batteryMedium:
+ adaptive(light: (0.54, 0.35, 0.00), dark: (1.00, 0.85, 0.30))
+ case .batteryHigh:
+ adaptive(light: (0.00, 0.36, 0.12), dark: (0.42, 1.00, 0.58))
+ case .batteryCharging:
+ adaptive(light: (0.00, 0.36, 0.12), dark: (0.42, 1.00, 0.58))
+ case .batteryDischarging:
+ adaptive(light: (0.55, 0.12, 0.00), dark: (1.00, 0.55, 0.32))
+ case .yieldToday:
+ adaptive(light: (0.45, 0.18, 0.62), dark: (0.85, 0.62, 1.00))
+ case .yieldTotal:
+ adaptive(light: (0.26, 0.26, 0.66), dark: (0.72, 0.70, 1.00))
+ case .status:
+ adaptive(light: (0.00, 0.42, 0.18), dark: (0.42, 1.00, 0.58))
+ case .refresh:
+ adaptive(light: (0.10, 0.34, 0.78), dark: (0.40, 0.78, 1.00))
+ case .neutral:
+ .labelColor
+ }
+ }
+
+ // MARK: Helle Varianten für die dunkle abgedockte Leiste
+ static func bright(_ role: ColorRole) -> NSColor {
+ switch role {
+ case .solar:
+ rgb(1.00, 0.85, 0.30)
+ case .load:
+ rgb(0.46, 0.86, 1.00)
+ case .gridImport:
+ rgb(1.00, 0.52, 0.48)
+ case .gridExport:
+ rgb(0.84, 0.69, 1.00)
+ case .batteryLow:
+ rgb(1.00, 0.42, 0.46)
+ case .batteryMedium:
+ rgb(1.00, 0.85, 0.30)
+ case .batteryHigh, .batteryCharging, .status:
+ rgb(0.49, 1.00, 0.60)
+ case .batteryDischarging:
+ rgb(1.00, 0.58, 0.36)
+ case .yieldToday:
+ rgb(0.84, 0.69, 1.00)
+ case .yieldTotal:
+ rgb(0.76, 0.74, 1.00)
+ case .refresh:
+ rgb(0.46, 0.86, 1.00)
+ case .neutral:
+ .white
+ }
+ }
+
+ // MARK: Gesättigte Akzente für Legenden, Plates und Gradienten
+ static func accent(_ role: ColorRole) -> NSColor {
+ switch role {
+ case .solar:
+ rgb(1.00, 0.68, 0.03)
+ case .load:
+ rgb(0.16, 0.50, 0.96)
+ case .gridImport:
+ rgb(0.90, 0.20, 0.22)
+ case .gridExport:
+ rgb(0.48, 0.35, 0.95)
+ case .batteryLow:
+ rgb(0.90, 0.20, 0.22)
+ case .batteryMedium:
+ rgb(0.95, 0.72, 0.10)
+ case .batteryHigh, .batteryCharging, .status:
+ rgb(0.17, 0.78, 0.36)
+ case .batteryDischarging:
+ rgb(0.96, 0.52, 0.16)
+ case .yieldToday:
+ rgb(0.69, 0.32, 0.87)
+ case .yieldTotal:
+ rgb(0.35, 0.34, 0.84)
+ case .refresh:
+ rgb(0.16, 0.50, 0.96)
+ case .neutral:
+ .systemGray
+ }
+ }
+
+ /// Leuchtende Variante: hell = gesättigter Akzent, dunkel = Bright-Palette.
+ /// Für Icon-Plates und Werte, die im Dark Mode sonst absaufen.
+ static func vivid(_ role: ColorRole) -> NSColor {
+ NSColor(name: nil) { appearance in
+ usesDarkBackground(appearance) ? bright(role) : accent(role)
+ }
+ }
+
+ /// Farbe für die abgedockte Leiste: löst sich pro Appearance auf —
+ /// dunkles Glas -> Bright-Palette, helles Glas -> kräftige Lesefarben.
+ static func hud(_ role: ColorRole) -> NSColor {
+ NSColor(name: nil) { appearance in
+ // Exakt dieselbe Weiche wie der Leisten-Hintergrund (bestMatch),
+ // damit Text- und Hintergrundentscheidung nie auseinanderlaufen.
+ if appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua {
+ return bright(role)
+ }
+ var resolved = NSColor.labelColor
+ appearance.performAsCurrentDrawingAppearance {
+ resolved = color(role).usingColorSpace(.deviceRGB) ?? .labelColor
+ }
+ return resolved
+ }
+ }
+
+ static func battery(percent: Int?) -> ColorRole {
+ guard let percent else { return .neutral }
+ if percent <= 20 { return .batteryLow }
+ if percent <= 60 { return .batteryMedium }
+ return .batteryHigh
+ }
+
+ static func grid(watts: Int?) -> ColorRole {
+ guard let watts, watts != 0 else { return .neutral }
+ return watts > 0 ? .gridImport : .gridExport
+ }
+
+ static func batteryFlow(watts: Int?) -> ColorRole {
+ guard let watts, watts != 0 else { return .neutral }
+ return watts > 0 ? .batteryCharging : .batteryDischarging
+ }
+
+ // MARK: Hilfen
+ private static func adaptive(light: (CGFloat, CGFloat, CGFloat), dark: (CGFloat, CGFloat, CGFloat)) -> NSColor {
+ NSColor(name: nil) { appearance in
+ let c = usesDarkBackground(appearance) ? dark : light
+ return NSColor(calibratedRed: c.0, green: c.1, blue: c.2, alpha: 1)
+ }
+ }
+
+ /// Erkennt dunkle Hintergründe auch im Menüleisten-Kontext, wo die
+ /// Appearance nicht dem Systemthema folgt (dunkles Wallpaper -> helle Schrift).
+ static func usesDarkBackground(_ appearance: NSAppearance) -> Bool {
+ var label: NSColor?
+ appearance.performAsCurrentDrawingAppearance {
+ label = NSColor.labelColor.usingColorSpace(.deviceRGB)
+ }
+ if let label {
+ let luminance = 0.2126 * label.redComponent
+ + 0.7152 * label.greenComponent
+ + 0.0722 * label.blueComponent
+ return luminance > 0.55
+ }
+ let match = appearance.bestMatch(from: [.vibrantDark, .darkAqua, .vibrantLight, .aqua])
+ return match == .vibrantDark || match == .darkAqua
+ }
+
+ private static func rgb(_ r: CGFloat, _ g: CGFloat, _ b: CGFloat) -> NSColor {
+ NSColor(calibratedRed: r, green: g, blue: b, alpha: 1)
+ }
+}
diff --git a/Sources/SolixBarKit/UpdateChecker.swift b/Sources/SolixBarKit/UpdateChecker.swift
new file mode 100644
index 0000000..68125fc
--- /dev/null
+++ b/Sources/SolixBarKit/UpdateChecker.swift
@@ -0,0 +1,70 @@
+import Foundation
+
+struct ReleaseInfo: Equatable, Sendable {
+ let version: String
+ let url: URL
+}
+
+/// Fragt das neueste GitHub-Release ab und vergleicht Versionen.
+/// Netzwerkfehler sind hier nie fatal — der Aufrufer schluckt sie still,
+/// damit ein Offline-Rechner keine Fehlermeldungen produziert.
+enum UpdateChecker {
+ static let releasesPageURL = URL(string: "https://github.com/itsab1989/SolixBar/releases/latest")!
+ private static let apiURL = URL(string: "https://api.github.com/repos/itsab1989/SolixBar/releases/latest")!
+
+ private struct LatestRelease: Decodable {
+ let tagName: String
+ let htmlUrl: String
+
+ enum CodingKeys: String, CodingKey {
+ case tagName = "tag_name"
+ case htmlUrl = "html_url"
+ }
+ }
+
+ static func fetchLatestRelease() async throws -> ReleaseInfo {
+ var request = URLRequest(url: apiURL)
+ request.timeoutInterval = 30
+ request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
+ let (data, response) = try await URLSession.shared.data(for: request)
+ if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
+ throw SolixProviderError.httpError(http.statusCode)
+ }
+ let release = try JSONDecoder().decode(LatestRelease.self, from: data)
+ return ReleaseInfo(
+ version: release.tagName,
+ url: URL(string: release.htmlUrl) ?? releasesPageURL
+ )
+ }
+
+ /// Numerischer Segment-Vergleich ("v0.10.1" > "0.9"), tolerant gegenüber
+ /// v-Präfix und unterschiedlich vielen Segmenten. Unparsbares gilt nie
+ /// als neuer; ein Dev-Build (unbundled) ist nie veraltet.
+ static func isNewer(_ tag: String, than current: String) -> Bool {
+ guard current != "dev" else { return false }
+ guard let remote = versionComponents(tag), let local = versionComponents(current) else {
+ return false
+ }
+ let count = max(remote.count, local.count)
+ for index in 0.. l }
+ }
+ return false
+ }
+
+ private static func versionComponents(_ raw: String) -> [Int]? {
+ var text = raw.trimmingCharacters(in: .whitespaces)
+ if text.hasPrefix("v") || text.hasPrefix("V") {
+ text = String(text.dropFirst())
+ }
+ guard !text.isEmpty else { return nil }
+ var components: [Int] = []
+ for part in text.split(separator: ".", omittingEmptySubsequences: false) {
+ guard let value = Int(part), value >= 0 else { return nil }
+ components.append(value)
+ }
+ return components.isEmpty ? nil : components
+ }
+}
diff --git a/Sources/SolixBarKit/WarningEngine.swift b/Sources/SolixBarKit/WarningEngine.swift
new file mode 100644
index 0000000..f7b7878
--- /dev/null
+++ b/Sources/SolixBarKit/WarningEngine.swift
@@ -0,0 +1,181 @@
+import Foundation
+
+/// Bewertet Snapshots und meldet Warn-Ereignisse genau einmal pro Vorfall.
+///
+/// Reine Zustandsmaschine ohne AppKit/UserNotifications — vollständig per
+/// Unit-Test treibbar. Der Zustand lebt nur für die Laufzeit der App; nach
+/// einem Neustart beginnt die Bewertung frisch (bewusst: kein veralteter
+/// persistierter Zustand).
+struct WarningEngine {
+ struct Config: Equatable {
+ var batteryLowEnabled = false
+ /// Prozent-Schwelle; gefeuert wird beim Abwärts-Durchgang.
+ var batteryLowThreshold = 20
+ var pvStallEnabled = false
+ /// So lange muss die PV durchgehend (nahe) 0 liefern, bevor gewarnt wird.
+ var pvStallMinutes = 15
+ /// "Hat kürzlich erzeugt": Maximum der letzten Stunde muss darüber liegen.
+ var pvStallMinRecentWatts = 50
+ var pvWindowEnabled = false
+ /// Tagesfenster (lokale Stunden), in dem 0 W immer verdächtig ist.
+ var pvWindowStartHour = 9
+ var pvWindowEndHour = 17
+ /// Eingang tot, während die anderen Eingänge erzeugen.
+ var perPVEnabled = false
+ /// Einbruch je Eingang: hat selbst kürzlich erzeugt, liefert jetzt
+ /// nichts mehr — erkennt Defekte auch ohne Geschwister-Vergleich.
+ var perPVDipEnabled = false
+ }
+
+ enum Event: Equatable {
+ case batteryLow(percent: Int)
+ case pvStalled
+ case pvChannelDead(index: Int)
+ }
+
+ /// Unterhalb dieser Leistung gilt ein Eingang als "liefert nichts";
+ /// fängt Messrauschen um 0 W ab.
+ static let deadbandWatts = 5
+ /// Rückblick für "hat kürzlich erzeugt".
+ private static let lookback: TimeInterval = 60 * 60
+ /// Hysterese: Akku-Warnung erst wieder scharf ab Schwelle + 5.
+ private static let batteryRearmMargin = 5
+
+ private var solarHistory: [(date: Date, watts: Int)] = []
+ private var batteryArmed = true
+ private var pvStallArmed = true
+ private var pvStalledSince: Date?
+ private var channelDeadSince: [Int: Date] = [:]
+ private var channelArmed: [Int: Bool] = [:]
+ private var channelHistory: [Int: [(date: Date, watts: Int)]] = [:]
+
+ /// Meldet alle NEU eingetretenen Warn-Ereignisse für diesen Snapshot.
+ /// `calendar` ist nur fürs Zeitfenster relevant (Tests reichen UTC durch).
+ mutating func evaluate(
+ snapshot: SolixSnapshot,
+ at date: Date,
+ config: Config,
+ calendar: Calendar = .current
+ ) -> [Event] {
+ var events: [Event] = []
+ if let event = evaluateBattery(snapshot: snapshot, config: config) {
+ events.append(event)
+ }
+ if let event = evaluateSolar(snapshot: snapshot, at: date, config: config, calendar: calendar) {
+ events.append(event)
+ }
+ events.append(contentsOf: evaluateChannels(snapshot: snapshot, at: date, config: config))
+ return events
+ }
+
+ /// Aktuell aktive Warnbedingungen (für die Menü-Anzeige, solange die
+ /// Bedingung anhält) — unabhängig davon, ob das Ereignis schon gemeldet wurde.
+ private(set) var activeEvents: [Event] = []
+
+ private mutating func evaluateBattery(snapshot: SolixSnapshot, config: Config) -> Event? {
+ activeEvents.removeAll { if case .batteryLow = $0 { true } else { false } }
+ guard config.batteryLowEnabled, let percent = snapshot.batteryPercent else { return nil }
+ if percent > config.batteryLowThreshold + Self.batteryRearmMargin {
+ batteryArmed = true
+ return nil
+ }
+ guard percent <= config.batteryLowThreshold else { return nil }
+ activeEvents.append(.batteryLow(percent: percent))
+ guard batteryArmed else { return nil }
+ batteryArmed = false
+ return .batteryLow(percent: percent)
+ }
+
+ private mutating func evaluateSolar(
+ snapshot: SolixSnapshot,
+ at date: Date,
+ config: Config,
+ calendar: Calendar
+ ) -> Event? {
+ activeEvents.removeAll { $0 == .pvStalled }
+ guard let solar = snapshot.solarWatts else { return nil }
+ solarHistory.append((date, solar))
+ solarHistory.removeAll { $0.date < date.addingTimeInterval(-Self.lookback) }
+
+ guard config.pvStallEnabled || config.pvWindowEnabled else { return nil }
+
+ if solar >= Self.deadbandWatts {
+ pvStalledSince = nil
+ if solar >= config.pvStallMinRecentWatts {
+ pvStallArmed = true
+ }
+ return nil
+ }
+
+ if pvStalledSince == nil { pvStalledSince = date }
+ let stalledMinutes = date.timeIntervalSince(pvStalledSince ?? date) / 60
+ guard stalledMinutes >= Double(config.pvStallMinutes) else { return nil }
+
+ // Einbruch: vorher wurde nennenswert erzeugt (nachts also nie).
+ let recentMax = solarHistory.map(\.watts).max() ?? 0
+ let collapsed = config.pvStallEnabled && recentMax >= config.pvStallMinRecentWatts
+ // Zeitfenster: tagsüber ist 0 W auch ohne vorherige Erzeugung verdächtig.
+ let hour = calendar.component(.hour, from: date)
+ let inWindow = config.pvWindowEnabled
+ && hour >= config.pvWindowStartHour && hour < config.pvWindowEndHour
+ guard collapsed || inWindow else { return nil }
+
+ activeEvents.append(.pvStalled)
+ guard pvStallArmed else { return nil }
+ pvStallArmed = false
+ return .pvStalled
+ }
+
+ private mutating func evaluateChannels(
+ snapshot: SolixSnapshot,
+ at date: Date,
+ config: Config
+ ) -> [Event] {
+ activeEvents.removeAll { if case .pvChannelDead = $0 { true } else { false } }
+ guard config.perPVEnabled || config.perPVDipEnabled,
+ let channels = snapshot.pvWatts, channels.count > 1 else {
+ channelDeadSince.removeAll()
+ channelHistory.removeAll()
+ return []
+ }
+ var events: [Event] = []
+ for (index, watts) in channels.enumerated() {
+ var history = channelHistory[index] ?? []
+ history.append((date, watts))
+ history.removeAll { $0.date < date.addingTimeInterval(-Self.lookback) }
+ channelHistory[index] = history
+
+ guard watts < Self.deadbandWatts else {
+ channelDeadSince[index] = nil
+ channelArmed[index] = true
+ continue
+ }
+ if channelDeadSince[index] == nil { channelDeadSince[index] = date }
+ let deadMinutes = date.timeIntervalSince(channelDeadSince[index] ?? date) / 60
+ guard deadMinutes >= Double(config.pvStallMinutes) else { continue }
+
+ // Zwei Wege zum selben Ereignis (je einzeln zuschaltbar):
+ // Geschwister erzeugen = es ist hell, dieser Kanal sollte auch;
+ // oder der Kanal hat selbst kürzlich erzeugt und ist eingebrochen
+ // (Defekt-Erkennung ohne Geschwister-Vergleich, z. B. wenn alle
+ // Eingänge gleichzeitig betroffen sind).
+ let siblingsMax = channels.enumerated()
+ .filter { $0.offset != index }
+ .map(\.element)
+ .max() ?? 0
+ let deadWhileSiblingsProduce = config.perPVEnabled
+ && siblingsMax >= config.pvStallMinRecentWatts
+ let ownRecentMax = history.map(\.watts).max() ?? 0
+ let dippedAfterProduction = config.perPVDipEnabled
+ && ownRecentMax >= config.pvStallMinRecentWatts
+ guard deadWhileSiblingsProduce || dippedAfterProduction else { continue }
+
+ activeEvents.append(.pvChannelDead(index: index))
+ if channelArmed[index, default: true] {
+ channelArmed[index] = false
+ events.append(.pvChannelDead(index: index))
+ }
+ }
+ return events
+ }
+}
diff --git a/Tests/SolixBarTests/BundledProviderTests.swift b/Tests/SolixBarTests/BundledProviderTests.swift
new file mode 100644
index 0000000..1cbf856
--- /dev/null
+++ b/Tests/SolixBarTests/BundledProviderTests.swift
@@ -0,0 +1,72 @@
+import Foundation
+import Testing
+@testable import SolixBarKit
+
+@Suite("BundledSolixDataProvider")
+struct BundledProviderTests {
+ @Test("Ohne Zugangsdaten wirft der Provider missingCredentials")
+ func missingCredentials() async {
+ let provider = BundledSolixDataProvider(
+ credentials: .init(email: "", password: "", country: "DE", todayBaseKWh: nil, totalBaseKWh: nil)
+ )
+ await #expect(throws: SolixProviderError.self) {
+ _ = try await provider.fetchSnapshot()
+ }
+ }
+
+ @Test("Env-Werte werden vollständig übernommen")
+ func credentialMapping() {
+ let credentials = BundledSolixDataProvider.Credentials.from(
+ values: [
+ "ANKER_SOLIX_USER": "a@b.c",
+ "ANKER_SOLIX_COUNTRY": "AT",
+ "SOLIXBAR_TODAY_KWH_BASE": "1,5",
+ "SOLIXBAR_TODAY_KWH_DATE": "2026-07-12",
+ "SOLIXBAR_TOTAL_KWH_BASE": "427.8"
+ ],
+ password: "geheim",
+ todayKey: "2026-07-12"
+ )
+ #expect(credentials.email == "a@b.c")
+ #expect(credentials.password == "geheim")
+ #expect(credentials.country == "AT")
+ #expect(credentials.todayBaseKWh == 1.5)
+ #expect(credentials.totalBaseKWh == 427.8)
+ }
+
+ @Test("Tages-Basiswert gilt nur an seinem Eintragedatum")
+ func todayBaseExpires() {
+ let credentials = BundledSolixDataProvider.Credentials.from(
+ values: [
+ "ANKER_SOLIX_USER": "a@b.c",
+ "SOLIXBAR_TODAY_KWH_BASE": "7.2",
+ "SOLIXBAR_TODAY_KWH_DATE": "2026-07-11",
+ "SOLIXBAR_TOTAL_KWH_BASE": "427.8"
+ ],
+ password: "geheim",
+ todayKey: "2026-07-12"
+ )
+ #expect(credentials.todayBaseKWh == nil)
+ #expect(credentials.totalBaseKWh == 427.8)
+ }
+
+ @Test("Leeres Land fällt auf DE zurück")
+ func countryDefault() {
+ let credentials = BundledSolixDataProvider.Credentials.from(
+ values: ["ANKER_SOLIX_USER": "a@b.c", "ANKER_SOLIX_COUNTRY": " "],
+ password: "geheim",
+ todayKey: "2026-07-12"
+ )
+ #expect(credentials.country == "DE")
+ #expect(credentials.todayBaseKWh == nil)
+ #expect(credentials.totalBaseKWh == nil)
+ }
+
+ @Test("Tagesschlüssel ist ISO-Datum")
+ func dayKeyFormat() {
+ let date = Date(timeIntervalSince1970: 1_784_140_800) // 2026-07-15 UTC
+ let key = BundledSolixDataProvider.Credentials.currentDayKey(for: date)
+ #expect(key.count == 10)
+ #expect(key.hasPrefix("2026-07-1"))
+ }
+}
diff --git a/Tests/SolixBarTests/CommandProviderTests.swift b/Tests/SolixBarTests/CommandProviderTests.swift
new file mode 100644
index 0000000..004b449
--- /dev/null
+++ b/Tests/SolixBarTests/CommandProviderTests.swift
@@ -0,0 +1,64 @@
+import Foundation
+import Testing
+@testable import SolixBarKit
+
+@Suite("CommandSolixDataProvider")
+struct CommandProviderTests {
+ @Test("output larger than the 64KB pipe buffer does not deadlock")
+ func largeOutput() async throws {
+ // 200 KB Statusfeld: hätte den alten readDataToEndOfFile-nach-Exit-Code
+ // blockiert (Pipe-Puffer voll -> Prozess hängt -> falscher Timeout).
+ let provider = CommandSolixDataProvider(
+ command: #"printf '{"status":"%s","updatedAt":"2026-01-01T00:00:00Z"}' "$(head -c 200000 /dev/zero | tr '\0' 'x')""#,
+ timeout: 15
+ )
+ let snapshot = try await provider.fetchSnapshot()
+ #expect(snapshot.status?.count == 200_000)
+ }
+
+ @Test("a process ignoring SIGTERM is killed and reported as timeout")
+ func stubbornTimeout() async {
+ let provider = CommandSolixDataProvider(
+ command: "trap '' TERM; sleep 60",
+ timeout: 1
+ )
+ let start = Date()
+ await #expect(throws: SolixProviderError.self) {
+ _ = try await provider.fetchSnapshot()
+ }
+ // 1s Timeout + 2s SIGTERM-Frist + Puffer: deutlich unter den 60s des sleep
+ #expect(Date().timeIntervalSince(start) < 10)
+ }
+
+ @Test("stderr output becomes the error message")
+ func stderrMessage() async {
+ let provider = CommandSolixDataProvider(command: "echo kaputt >&2; exit 3", timeout: 10)
+ do {
+ _ = try await provider.fetchSnapshot()
+ Issue.record("expected commandFailed")
+ } catch let SolixProviderError.commandFailed(message) {
+ #expect(message.contains("kaputt"))
+ } catch {
+ Issue.record("unexpected error: \(error)")
+ }
+ }
+
+ @Test("extra environment variables reach the command")
+ func environmentInjection() async throws {
+ let provider = CommandSolixDataProvider(
+ command: #"printf '{"status":"%s","updatedAt":"2026-01-01T00:00:00Z"}' "$SOLIX_TEST_VALUE""#,
+ extraEnvironment: ["SOLIX_TEST_VALUE": "aus-dem-schlüsselbund"],
+ timeout: 10
+ )
+ let snapshot = try await provider.fetchSnapshot()
+ #expect(snapshot.status == "aus-dem-schlüsselbund")
+ }
+
+ @Test("empty command reports missing configuration")
+ func emptyCommand() async {
+ let provider = CommandSolixDataProvider(command: " ", timeout: 1)
+ await #expect(throws: SolixProviderError.self) {
+ _ = try await provider.fetchSnapshot()
+ }
+ }
+}
diff --git a/Tests/SolixBarTests/EnvFileAndDisplayTests.swift b/Tests/SolixBarTests/EnvFileAndDisplayTests.swift
new file mode 100644
index 0000000..fcd2408
--- /dev/null
+++ b/Tests/SolixBarTests/EnvFileAndDisplayTests.swift
@@ -0,0 +1,85 @@
+import AppKit
+import Testing
+@testable import SolixBarKit
+
+@Suite("SolixEnvFile")
+struct EnvFileTests {
+ private func tempURL() -> URL {
+ FileManager.default.temporaryDirectory
+ .appendingPathComponent("solixbar-env-\(UUID().uuidString)")
+ .appendingPathComponent("solixbar.env")
+ }
+
+ @Test("roundtrips values incl. quotes, spaces, and umlauts")
+ func roundtrip() throws {
+ let url = tempURL()
+ let values: [(key: String, value: String)] = [
+ ("ANKER_SOLIX_USER", "mail@example.com"),
+ ("ANKER_SOLIX_PASSWORD", "pa'ss wörd\"!"),
+ ("ANKER_SOLIX_COUNTRY", "DE")
+ ]
+ try SolixEnvFile.write(values, to: url)
+ let read = SolixEnvFile.read(from: url)
+ #expect(read["ANKER_SOLIX_USER"] == "mail@example.com")
+ #expect(read["ANKER_SOLIX_PASSWORD"] == "pa'ss wörd\"!")
+ #expect(read["ANKER_SOLIX_COUNTRY"] == "DE")
+ }
+
+ @Test("written file is only readable by the owner (0600)")
+ func permissions() throws {
+ let url = tempURL()
+ try SolixEnvFile.write([("A", "b")], to: url)
+ let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
+ let permissions = (attrs[.posixPermissions] as? NSNumber)?.uint16Value
+ #expect(permissions == 0o600)
+ }
+
+ @Test("helper command references app-support env file, never a foreign home")
+ @MainActor
+ func helperCommand() throws {
+ // Dev-Fallback sucht das Script relativ zum Arbeitsverzeichnis —
+ // fürs Testen deterministisch auf die Paketwurzel setzen.
+ let packageRoot = URL(fileURLWithPath: #filePath)
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+ .deletingLastPathComponent()
+ let previous = FileManager.default.currentDirectoryPath
+ FileManager.default.changeCurrentDirectoryPath(packageRoot.path)
+ defer { FileManager.default.changeCurrentDirectoryPath(previous) }
+ let command = try #require(SolixPaths.helperCommand())
+ #expect(command.contains("Application Support/SolixBar/solixbar.env"))
+ #expect(!command.contains("/Users/holger"))
+ #expect(command.hasSuffix("run_solix_snapshot.sh'"))
+ }
+}
+
+@Suite("MenuBarDisplay")
+struct MenuBarDisplayTests {
+ private let base = MenuBarDisplayOptions(
+ metrics: [.battery, .solar, .grid],
+ showLabels: true,
+ showSymbols: true,
+ showArrows: true
+ )
+
+ @Test("levels strip options progressively")
+ func degradation() {
+ #expect(base.applying(.full).showLabels)
+ #expect(!base.applying(.noLabels).showLabels)
+ #expect(base.applying(.noLabels).showSymbols)
+ let values = base.applying(.valuesOnly)
+ #expect(!values.showLabels && !values.showSymbols && !values.showArrows)
+ #expect(base.applying(.compact).metrics == [.battery, .solar])
+ #expect(base.applying(.minimal).metrics.isEmpty)
+ }
+
+ @Test("notch overlap predicate")
+ func overlap() {
+ // Item 739..1202, Notch 771..956 (der reale Fall auf dem 16" MBP)
+ #expect(NotchGeometry.overlaps(itemMinX: 739, itemMaxX: 1202, notchMinX: 771, notchMaxX: 956))
+ // Rechts der Notch: kein Overlap
+ #expect(!NotchGeometry.overlaps(itemMinX: 1037, itemMaxX: 1202, notchMinX: 771, notchMaxX: 956))
+ // Links der Notch endend: kein Overlap
+ #expect(!NotchGeometry.overlaps(itemMinX: 600, itemMaxX: 771, notchMinX: 771, notchMaxX: 956))
+ }
+}
diff --git a/Tests/SolixBarTests/GraphSmoothingTests.swift b/Tests/SolixBarTests/GraphSmoothingTests.swift
new file mode 100644
index 0000000..dd3a44d
--- /dev/null
+++ b/Tests/SolixBarTests/GraphSmoothingTests.swift
@@ -0,0 +1,101 @@
+import AppKit
+import Testing
+@testable import SolixBarKit
+
+@MainActor
+@Suite("Graph-Glättung und Demo-Akku")
+struct GraphSmoothingTests {
+ @Test("Geklemmte Glättung bleibt in der Plotfläche")
+ func smoothClampStaysInBounds() {
+ // Sägezahn mit harten Sprüngen — klassischer Überschwing-Kandidat.
+ let points = [
+ NSPoint(x: 0, y: 10), NSPoint(x: 10, y: 90), NSPoint(x: 20, y: 12),
+ NSPoint(x: 30, y: 88), NSPoint(x: 40, y: 15), NSPoint(x: 50, y: 15)
+ ]
+ // Auf den echten Wertebereich [10, 90] geklemmt (z. B. Nulllinie unten).
+ let path = HistoryGraphView.smoothPath(through: points, clampingY: 10...90)
+ let box = path.bounds
+ #expect(box.minY >= 10 - 0.001, "Kurve unterschreitet die Nulllinie: \(box.minY)")
+ #expect(box.maxY <= 90 + 0.001, "Kurve verlässt die Plotfläche oben: \(box.maxY)")
+ #expect(abs(box.minX - 0) < 0.001)
+ #expect(abs(box.maxX - 50) < 0.001)
+ }
+
+ @Test("Ohne Klemmung schwingt Catmull-Rom sichtbar über die Punkte")
+ func smoothOvershootsWithoutClamp() {
+ // Genau der Effekt, den die Klemmung bändigt: nach einem steilen
+ // Abfall auf 0 und anschliessendem Flachlauf zieht der Catmull-Rom-
+ // Bogen unter die Nulllinie (scheinbar negative Watt).
+ let points = [
+ NSPoint(x: 0, y: 50), NSPoint(x: 10, y: 50), NSPoint(x: 20, y: 0),
+ NSPoint(x: 30, y: 0), NSPoint(x: 40, y: 0)
+ ]
+ let unclamped = HistoryGraphView.smoothPath(through: points, clampingY: nil)
+ #expect(unclamped.bounds.minY < -0.5, "Erwartetes Überschwingen unter 0 blieb aus")
+ }
+
+ @Test("Kurve beginnt und endet exakt auf den Messpunkten")
+ func smoothEndpoints() {
+ let points = [NSPoint(x: 0, y: 5), NSPoint(x: 20, y: 40), NSPoint(x: 40, y: 22)]
+ let path = HistoryGraphView.smoothPath(through: points, clampingY: 0...100)
+ #expect(path.currentPoint == points.last)
+ }
+
+ @Test("Wenige Punkte (≤2) bleiben ein gerader Zug")
+ func smoothFewPoints() {
+ let two = [NSPoint(x: 0, y: 5), NSPoint(x: 10, y: 20)]
+ let path = HistoryGraphView.smoothPath(through: two, clampingY: 0...100)
+ #expect(path.currentPoint == two.last)
+ #expect(!path.isEmpty)
+ }
+
+ @Test("Gerader Pfad verbindet alle Punkte")
+ func straightPath() {
+ let points = [NSPoint(x: 0, y: 0), NSPoint(x: 5, y: 10), NSPoint(x: 10, y: 3)]
+ let path = HistoryGraphView.straightPath(through: points)
+ #expect(path.currentPoint == points.last)
+ #expect(abs(path.bounds.maxY - 10) < 0.001)
+ }
+
+ @Test("Demo-Akku driftet langsam statt zu springen")
+ func demoBatteryDriftsSlowly() {
+ let start = Date(timeIntervalSince1970: 1_800_000_000)
+ var maxStep = 0
+ var values: Set = []
+ // Ein kompletter 3-h-Zyklus in 5-Minuten-Schritten (Demo-Abruftakt).
+ for step in 0...36 {
+ let date = start.addingTimeInterval(Double(step) * 300)
+ let value = DemoSolixDataProvider.demoBatteryPercent(at: date)
+ #expect((40...84).contains(value), "Akkuwert ausserhalb des Demo-Bereichs: \(value)")
+ if step > 0 {
+ let previous = DemoSolixDataProvider.demoBatteryPercent(at: date.addingTimeInterval(-300))
+ maxStep = max(maxStep, abs(value - previous))
+ }
+ values.insert(value)
+ }
+ // Kein Sägezahn-Cliff mehr: früher sprang der Demo-Akku alle 25 min
+ // um 24 Punkte, jetzt driftet er (wenige Punkte je 5-min-Abruf).
+ #expect(maxStep <= 5, "Sprung von \(maxStep) Punkten zwischen zwei Demo-Abrufen")
+ // Und es bewegt sich wirklich etwas (kein konstanter Wert).
+ #expect(values.count > 10)
+ }
+
+ @Test("Glättung und Füllungen überleben den Snapshot/Apply-Rundlauf")
+ func settingsRoundTrip() {
+ let settings = AppSettings.shared
+ let original = settings.snapshot()
+ defer { settings.apply(original) }
+
+ settings.graphSmoothing = true
+ settings.graphFilledMetrics = [.battery, .grid]
+ let snapshot = settings.snapshot()
+ #expect(snapshot.graphSmoothing)
+ #expect(snapshot.graphFilledMetrics == [.battery, .grid])
+
+ settings.graphSmoothing = false
+ settings.graphFilledMetrics = []
+ settings.apply(snapshot)
+ #expect(settings.graphSmoothing)
+ #expect(settings.graphFilledMetrics == [.battery, .grid])
+ }
+}
diff --git a/Tests/SolixBarTests/HistoryExporterTests.swift b/Tests/SolixBarTests/HistoryExporterTests.swift
new file mode 100644
index 0000000..3560c89
--- /dev/null
+++ b/Tests/SolixBarTests/HistoryExporterTests.swift
@@ -0,0 +1,86 @@
+import Foundation
+import Testing
+@testable import SolixBarKit
+
+@Suite("HistoryExporter")
+struct HistoryExporterTests {
+ private let date = Date(timeIntervalSince1970: 1_752_000_000)
+
+ @Test("csv has the exact header and one row per sample")
+ func csvShape() {
+ let csv = HistoryExporter.csv(samples: [
+ SolixHistorySample(date: date, batteryPercent: 73, solarWatts: 410, gridWatts: -20, homeWatts: 210, batteryWatts: 180)
+ ])
+ let lines = csv.split(separator: "\n")
+ #expect(lines.count == 2)
+ #expect(lines[0] == "timestamp,batteryPercent,solarWatts,gridWatts,homeWatts,batteryWatts")
+ #expect(lines[1].hasSuffix(",73,410,-20,210,180"))
+ // ISO8601 mit Zeitzonen-Offset, maschinenlesbar
+ #expect(lines[1].contains("T"))
+ #expect(csv.hasSuffix("\n"))
+ }
+
+ @Test("missing values become empty cells")
+ func emptyCells() {
+ let csv = HistoryExporter.csv(samples: [
+ SolixHistorySample(date: date, batteryPercent: nil, solarWatts: 410, gridWatts: nil, homeWatts: nil, batteryWatts: nil)
+ ])
+ let row = csv.split(separator: "\n")[1]
+ #expect(row.hasSuffix(",,410,,,"))
+ }
+
+ @Test("a newer current snapshot is appended as the final row")
+ func currentSnapshotRow() {
+ let current = SolixSnapshot(
+ siteName: "Test",
+ batteryPercent: 80,
+ solarWatts: 500,
+ homeWatts: 200,
+ gridWatts: 0,
+ batteryWatts: 300,
+ updatedAt: date.addingTimeInterval(600)
+ )
+ let csv = HistoryExporter.csv(
+ samples: [SolixHistorySample(date: date, batteryPercent: 73, solarWatts: 410, gridWatts: -20)],
+ current: current
+ )
+ let lines = csv.split(separator: "\n")
+ #expect(lines.count == 3)
+ #expect(lines[2].hasSuffix(",80,500,0,200,300"))
+
+ // Älterer Snapshot wird nicht doppelt angehängt.
+ let stale = HistoryExporter.csv(
+ samples: [SolixHistorySample(date: date, batteryPercent: 73, solarWatts: 410, gridWatts: -20)],
+ current: SolixSnapshot(siteName: "Test", updatedAt: date.addingTimeInterval(-600))
+ )
+ #expect(stale.split(separator: "\n").count == 2)
+ }
+
+ @Test("json export round-trips its samples")
+ func jsonRoundTrip() throws {
+ let samples = [
+ SolixHistorySample(date: date, batteryPercent: 73, solarWatts: 410, gridWatts: -20, homeWatts: 210, batteryWatts: 180)
+ ]
+ let data = try HistoryExporter.json(samples: samples, sourceKey: "demo", exportedAt: date)
+ let object = try JSONSerialization.jsonObject(with: data) as? [String: Any]
+ #expect(object?["source"] as? String == "demo")
+
+ struct Shape: Decodable {
+ let samples: [SolixHistorySample]
+ }
+ let decoder = JSONDecoder()
+ decoder.dateDecodingStrategy = .iso8601
+ let decoded = try decoder.decode(Shape.self, from: data)
+ #expect(decoded.samples.count == 1)
+ #expect(decoded.samples.first?.homeWatts == 210)
+ }
+
+ @Test("default filename carries date and extension")
+ @MainActor
+ func filename() {
+ let name = HistoryExporter.defaultFilename(ext: "csv", date: date)
+ #expect(name.hasPrefix("SolixBar-"))
+ #expect(name.hasSuffix(".csv"))
+ #expect(name.contains("2026") || name.contains("2025"))
+ }
+}
diff --git a/Tests/SolixBarTests/HistoryStoreTests.swift b/Tests/SolixBarTests/HistoryStoreTests.swift
new file mode 100644
index 0000000..ccd9c18
--- /dev/null
+++ b/Tests/SolixBarTests/HistoryStoreTests.swift
@@ -0,0 +1,169 @@
+import Foundation
+import Testing
+@testable import SolixBarKit
+
+@MainActor
+@Suite("SolixHistoryStore")
+struct HistoryStoreTests {
+ private func makeStore() -> (SolixHistoryStore, UserDefaults) {
+ let suite = "solixbar-tests-\(UUID().uuidString)"
+ let defaults = UserDefaults(suiteName: suite)!
+ defaults.removePersistentDomain(forName: suite)
+ let fileURL = FileManager.default.temporaryDirectory
+ .appendingPathComponent("solixbar-history-\(UUID().uuidString).json")
+ return (SolixHistoryStore(defaults: defaults, fileURL: fileURL), defaults)
+ }
+
+ private func snapshot(solar: Int?, at date: Date, totalKWh: Double? = nil) -> SolixSnapshot {
+ SolixSnapshot(siteName: "Test", solarWatts: solar, totalKWh: totalKWh, updatedAt: date)
+ }
+
+ @Test("accumulates trapezoid energy between samples")
+ func accumulatesEnergy() {
+ let (store, _) = makeStore()
+ let start = Date()
+ _ = store.cumulativeSolarKWh(recording: snapshot(solar: 1000, at: start), sourceKey: "demo")
+ let total = store.cumulativeSolarKWh(
+ recording: snapshot(solar: 1000, at: start.addingTimeInterval(15 * 60)),
+ sourceKey: "demo"
+ )
+ // 1000 W constant over 15 minutes = 0.25 kWh
+ #expect(abs(total - 0.25) < 0.001)
+ }
+
+ @Test("ignores gaps longer than 30 minutes")
+ func ignoresLongGaps() {
+ let (store, _) = makeStore()
+ let start = Date()
+ _ = store.cumulativeSolarKWh(recording: snapshot(solar: 1000, at: start), sourceKey: "demo")
+ let total = store.cumulativeSolarKWh(
+ recording: snapshot(solar: 1000, at: start.addingTimeInterval(2 * 3600)),
+ sourceKey: "demo"
+ )
+ #expect(total == 0)
+ }
+
+ @Test("provider total overrides smaller accumulated value")
+ func providerTotalWins() {
+ let (store, _) = makeStore()
+ let total = store.cumulativeSolarKWh(
+ recording: snapshot(solar: 0, at: Date(), totalKWh: 427.8),
+ sourceKey: "url"
+ )
+ #expect(total == 427.8)
+ }
+
+ @Test("samples are separated per data source")
+ func samplesPerSource() {
+ let (store, _) = makeStore()
+ let now = Date()
+ store.record(snapshot(solar: 500, at: now), sourceKey: "demo", refreshInterval: 300)
+ store.record(snapshot(solar: 999, at: now), sourceKey: "url", refreshInterval: 300)
+ let demo = store.samples(duration: 3600, sourceKey: "demo")
+ let url = store.samples(duration: 3600, sourceKey: "url")
+ #expect(demo.count == 1 && demo.first?.solarWatts == 500)
+ #expect(url.count == 1 && url.first?.solarWatts == 999)
+ }
+
+ @Test("cap covers the 30 day view at the configured interval")
+ func capMath() {
+ // 300 s Intervall: 30 Tage = 8640 Samples -> Cap muss darüber liegen
+ #expect(SolixHistoryStore.maxSamples(refreshInterval: 300) >= 8640)
+ // 60 s Intervall: 43200 Samples
+ #expect(SolixHistoryStore.maxSamples(refreshInterval: 60) >= 43200)
+ // nie unter dem alten Limit
+ #expect(SolixHistoryStore.maxSamples(refreshInterval: 100_000) >= 2000)
+ }
+
+ @Test("migrates legacy UserDefaults blob to the demo source")
+ func legacyMigration() throws {
+ let suite = "solixbar-tests-\(UUID().uuidString)"
+ let defaults = UserDefaults(suiteName: suite)!
+ defaults.removePersistentDomain(forName: suite)
+ let legacy = [SolixHistorySample(date: Date(), batteryPercent: 50, solarWatts: 123, gridWatts: 0)]
+ defaults.set(try JSONEncoder().encode(legacy), forKey: "solixHistorySamples")
+ let fileURL = FileManager.default.temporaryDirectory
+ .appendingPathComponent("solixbar-history-\(UUID().uuidString).json")
+ let store = SolixHistoryStore(defaults: defaults, fileURL: fileURL)
+ let migrated = store.samples(duration: 3600, sourceKey: "demo")
+ #expect(migrated.count == 1)
+ #expect(migrated.first?.solarWatts == 123)
+ #expect(defaults.data(forKey: "solixHistorySamples") == nil)
+ }
+
+ @Test("keeps year-old data hourly-thinned, recent data at full resolution")
+ func twoTierRetention() {
+ let (store, _) = makeStore()
+ let now = Date()
+ // 40 Tage alt, auf eine volle Stunde gepinnt: 12 Samples in EINEM
+ // Stunden-Bucket -> genau eines (das letzte) bleibt.
+ let hourBase = Date(
+ timeIntervalSince1970: floor((now.timeIntervalSince1970 - 40 * 86_400) / 3600) * 3600
+ )
+ for minute in 0..<12 {
+ store.record(
+ snapshot(solar: 100 + minute, at: hourBase.addingTimeInterval(Double(minute) * 300)),
+ sourceKey: "url",
+ refreshInterval: 300
+ )
+ }
+ // 400 Tage alt: faellt ganz raus
+ store.record(snapshot(solar: 1, at: now.addingTimeInterval(-400 * 86_400)), sourceKey: "url", refreshInterval: 300)
+ // frisch: bleibt voll aufgeloest
+ store.record(snapshot(solar: 555, at: now.addingTimeInterval(-60)), sourceKey: "url", refreshInterval: 300)
+ store.record(snapshot(solar: 556, at: now.addingTimeInterval(-30)), sourceKey: "url", refreshInterval: 300)
+
+ let year = store.samples(duration: 366 * 86_400, sourceKey: "url")
+ let old = year.filter { $0.date < now.addingTimeInterval(-31 * 86_400) }
+ #expect(old.count == 1)
+ #expect(old.first?.solarWatts == 111)
+ let fresh = year.filter { $0.date >= now.addingTimeInterval(-3600) }
+ #expect(fresh.count == 2)
+ }
+
+ @Test("reads pre-0.4.2 history files without home/battery fields")
+ func decodesLegacySampleFormat() throws {
+ // Wörtliches Alt-Format (v1, nur battery/solar/grid) — darf nie brechen.
+ let legacyJSON = """
+ {"version":1,"samples":{"url":[{"date":1752000000,"batteryPercent":73,"solarWatts":410,"gridWatts":-20}]}}
+ """
+ let fileURL = FileManager.default.temporaryDirectory
+ .appendingPathComponent("solixbar-history-\(UUID().uuidString).json")
+ try legacyJSON.data(using: .utf8)!.write(to: fileURL)
+ let suite = "solixbar-tests-\(UUID().uuidString)"
+ let store = SolixHistoryStore(defaults: UserDefaults(suiteName: suite)!, fileURL: fileURL)
+ let loaded = store.samples(duration: 366 * 86_400 * 10, sourceKey: "url")
+ #expect(loaded.count == 1)
+ #expect(loaded.first?.solarWatts == 410)
+ #expect(loaded.first?.homeWatts == nil)
+ #expect(loaded.first?.batteryWatts == nil)
+ }
+
+ @Test("records and round-trips home and battery-flow watts")
+ func roundTripsNewFields() {
+ let (store, _) = makeStore()
+ let full = SolixSnapshot(
+ siteName: "Test",
+ solarWatts: 500,
+ homeWatts: 210,
+ batteryWatts: -120,
+ updatedAt: Date()
+ )
+ store.record(full, sourceKey: "url", refreshInterval: 300)
+ let sample = store.samples(duration: 3600, sourceKey: "url").first
+ #expect(sample?.homeWatts == 210)
+ #expect(sample?.batteryWatts == -120)
+ }
+
+ @Test("persists across store instances")
+ func persistence() {
+ let suite = "solixbar-tests-\(UUID().uuidString)"
+ let defaults = UserDefaults(suiteName: suite)!
+ let fileURL = FileManager.default.temporaryDirectory
+ .appendingPathComponent("solixbar-history-\(UUID().uuidString).json")
+ let store = SolixHistoryStore(defaults: defaults, fileURL: fileURL)
+ store.record(snapshot(solar: 321, at: Date()), sourceKey: "url", refreshInterval: 300)
+ let second = SolixHistoryStore(defaults: defaults, fileURL: fileURL)
+ #expect(second.samples(duration: 3600, sourceKey: "url").first?.solarWatts == 321)
+ }
+}
diff --git a/Tests/SolixBarTests/MetricOrderTests.swift b/Tests/SolixBarTests/MetricOrderTests.swift
new file mode 100644
index 0000000..e22fcc2
--- /dev/null
+++ b/Tests/SolixBarTests/MetricOrderTests.swift
@@ -0,0 +1,111 @@
+import Foundation
+import Testing
+@testable import SolixBarKit
+
+@MainActor
+@Suite("Metrik-Reihenfolge", .serialized)
+struct MetricOrderTests {
+ @Test("bar metric order survives storage and snapshot/apply")
+ func orderSurvives() {
+ let settings = AppSettings.shared
+ let original = settings.snapshot()
+ defer { settings.apply(original) }
+
+ let custom: [BarMetric] = [.status, .grid, .battery]
+ settings.barMetrics = custom
+ #expect(settings.barMetrics == custom)
+
+ var modified = settings.snapshot()
+ modified.barMetrics = [.total, .solar]
+ settings.apply(modified)
+ #expect(settings.barMetrics == [.total, .solar])
+ }
+
+ @Test("empty stacked lists follow their single-line list")
+ func stackedFollowChain() {
+ let settings = AppSettings.shared
+ let original = settings.snapshot()
+ defer { settings.apply(original) }
+
+ settings.barMetrics = [.solar, .battery]
+ settings.stackedBarMetrics = []
+ settings.detachedBarMetrics = [.grid, .home]
+ settings.detachedStackedBarMetrics = []
+
+ #expect(settings.effectiveStackedBarMetrics == [.solar, .battery])
+ #expect(settings.effectiveDetachedStackedBarMetrics == [.grid, .home])
+
+ settings.stackedBarMetrics = [.today, .total]
+ #expect(settings.effectiveStackedBarMetrics == [.today, .total])
+ // Einzeilige Liste bleibt unberührt.
+ #expect(settings.barMetrics == [.solar, .battery])
+ }
+
+ @Test("PV display modes: total, per input, and both")
+ func pvDisplayModes() {
+ let formatter = MenuBarFormatter()
+ var options = MenuBarDisplayOptions(
+ metrics: [.solar],
+ showLabels: false,
+ showSymbols: false,
+ showArrows: false,
+ showColors: false
+ )
+ var snapshot = SolixSnapshot.demo
+ snapshot.solarWatts = 642
+ snapshot.pvWatts = [438, 204]
+
+ options.pvDisplay = .perInput
+ #expect(formatter.plainTitle(for: snapshot, options: options) == "438·204W")
+ // Kompaktansicht nutzt dieselben Einzelwerte, mit Pfeil wenn aktiv.
+ options.showArrows = true
+ let stacked = formatter.stackedEntries(for: snapshot, options: options)
+ #expect(stacked.first?.text == "↓438·204W")
+ options.showArrows = false
+
+ options.pvDisplay = .both
+ #expect(formatter.plainTitle(for: snapshot, options: options) == "642W (438·204)")
+
+ // Ohne Kanalwerte (Solarbank 1) fällt jede Einstellung auf die Summe zurück.
+ snapshot.pvWatts = nil
+ #expect(formatter.plainTitle(for: snapshot, options: options) == "642W")
+
+ options.pvDisplay = .total
+ snapshot.pvWatts = [438, 204]
+ #expect(formatter.plainTitle(for: snapshot, options: options) == "642W")
+ }
+
+ @Test("PV display settings survive the snapshot/apply round-trip")
+ func pvDisplaySettingsRoundTrip() {
+ let settings = AppSettings.shared
+ let original = settings.snapshot()
+ defer { settings.apply(original) }
+
+ var modified = original
+ modified.dashboardPVDisplay = .both
+ modified.detachedDashboardPVDisplay = .perInput
+ modified.menuBarPVDisplay = .perInput
+ modified.detachedPVDisplay = .total
+ settings.apply(modified)
+ #expect(settings.snapshot() == modified)
+ }
+
+ @Test("stacked entries respect a custom order")
+ func stackedEntriesOrder() {
+ let formatter = MenuBarFormatter()
+ let options = MenuBarDisplayOptions(
+ metrics: [.grid, .battery, .solar],
+ showLabels: true,
+ showSymbols: false,
+ showArrows: false,
+ showColors: true
+ )
+ let entries = formatter.stackedEntries(for: .demo, options: options)
+ #expect(entries.count == 3)
+ // Reihenfolge der Einträge = Reihenfolge der Metrik-Liste
+ // (erkennbar an den Symbolen: Netz, Akku, Solar).
+ #expect(entries[0].symbolName == "powerplug.fill")
+ #expect(entries[1].symbolName.hasPrefix("battery"))
+ #expect(entries[2].symbolName == "sun.max.fill")
+ }
+}
diff --git a/Tests/SolixBarTests/RefreshBackoffTests.swift b/Tests/SolixBarTests/RefreshBackoffTests.swift
new file mode 100644
index 0000000..7668985
--- /dev/null
+++ b/Tests/SolixBarTests/RefreshBackoffTests.swift
@@ -0,0 +1,33 @@
+import Foundation
+import Testing
+@testable import SolixBarKit
+
+@Suite("Refresh-Backoff")
+struct RefreshBackoffTests {
+ @Test("Ohne Fehler bleibt das Basisintervall")
+ func noFailures() {
+ #expect(StatusController.backoffInterval(base: 60, consecutiveFailures: 0) == 60)
+ #expect(StatusController.backoffInterval(base: 300, consecutiveFailures: 0) == 300)
+ }
+
+ @Test("Jeder Fehler verdoppelt das Intervall")
+ func doubling() {
+ #expect(StatusController.backoffInterval(base: 60, consecutiveFailures: 1) == 120)
+ #expect(StatusController.backoffInterval(base: 60, consecutiveFailures: 2) == 240)
+ #expect(StatusController.backoffInterval(base: 60, consecutiveFailures: 3) == 480)
+ #expect(StatusController.backoffInterval(base: 60, consecutiveFailures: 4) == 960)
+ }
+
+ @Test("Deckel bei 30 Minuten, auch bei vielen Fehlern")
+ func cap() {
+ #expect(StatusController.backoffInterval(base: 60, consecutiveFailures: 5) == 960)
+ #expect(StatusController.backoffInterval(base: 60, consecutiveFailures: 100) == 960)
+ #expect(StatusController.backoffInterval(base: 300, consecutiveFailures: 3) == 30 * 60)
+ #expect(StatusController.backoffInterval(base: 30 * 60, consecutiveFailures: 4) == 30 * 60)
+ }
+
+ @Test("Negative Fehlerzahl wird wie 0 behandelt")
+ func negative() {
+ #expect(StatusController.backoffInterval(base: 60, consecutiveFailures: -3) == 60)
+ }
+}
diff --git a/Tests/SolixBarTests/RenderTests.swift b/Tests/SolixBarTests/RenderTests.swift
new file mode 100644
index 0000000..86b7274
--- /dev/null
+++ b/Tests/SolixBarTests/RenderTests.swift
@@ -0,0 +1,264 @@
+import AppKit
+import Testing
+@testable import SolixBarKit
+
+/// Rendert die zentralen Views offscreen als PNG — visuelle Verifikation ohne
+/// Screen-Recording-Berechtigung. Ausgabeort: $SOLIXBAR_RENDER_DIR oder .build/renders.
+@MainActor
+@Suite("Offscreen renders", .serialized)
+struct RenderTests {
+ static let outputDir: URL = {
+ let base = ProcessInfo.processInfo.environment["SOLIXBAR_RENDER_DIR"]
+ ?? FileManager.default.currentDirectoryPath + "/.build/renders"
+ let url = URL(fileURLWithPath: base)
+ try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
+ return url
+ }()
+
+ private func render(_ view: NSView, appearance: NSAppearance.Name, name: String, settle: TimeInterval = 1.2) throws {
+ let window = NSWindow(
+ contentRect: view.frame,
+ styleMask: [.borderless],
+ backing: .buffered,
+ defer: false
+ )
+ window.appearance = NSAppearance(named: appearance)
+ window.contentView = view
+ RunLoop.main.run(until: Date().addingTimeInterval(settle))
+ let rep = try #require(view.bitmapImageRepForCachingDisplay(in: view.bounds))
+ view.cacheDisplay(in: view.bounds, to: rep)
+ let png = try #require(rep.representation(using: .png, properties: [:]))
+ let url = Self.outputDir.appendingPathComponent(name + ".png")
+ try png.write(to: url)
+ #expect(rep.pixelsWide > 0)
+ }
+
+ private func demoSamples() -> [SolixHistorySample] {
+ let now = Date()
+ let duration: TimeInterval = 24 * 60 * 60
+ return (0..<48).map { index in
+ let progress = Double(index) / 47.0
+ let sunlight = max(0, sin(progress * .pi))
+ return SolixHistorySample(
+ date: now.addingTimeInterval(-duration * (1 - progress)),
+ batteryPercent: 55 + Int(progress * 30) + Int(sin(progress * .pi * 2.4) * 6),
+ solarWatts: Int(760 * sunlight),
+ gridWatts: Int(max(0, 240 - 760 * sunlight * 0.45))
+ )
+ }
+ }
+
+ /// Leicht abweichender Vorgänger-Snapshot, damit die Trend-Pfeile der
+ /// Dashboard-Karten im Render sichtbar sind (wie in der echten App ab
+ /// dem zweiten Refresh).
+ private func previousDemoSnapshot(of snapshot: SolixSnapshot) -> SolixSnapshot {
+ var previous = snapshot
+ previous.batteryPercent = snapshot.batteryPercent.map { $0 - 3 }
+ previous.solarWatts = snapshot.solarWatts.map { $0 - 60 }
+ previous.gridWatts = snapshot.gridWatts.map { $0 + 40 }
+ previous.batteryWatts = snapshot.batteryWatts.map { $0 + 25 }
+ return previous
+ }
+
+ @Test("dashboard renders in light and dark")
+ func dashboard() throws {
+ var snapshot = SolixSnapshot.demo
+ snapshot.updatedAt = Date().addingTimeInterval(-23)
+ let previous = previousDemoSnapshot(of: snapshot)
+ for (suffix, appearance) in [("light", NSAppearance.Name.aqua), ("dark", .darkAqua)] {
+ let view = SolixMenuDashboardView(
+ snapshot: snapshot,
+ previous: previous,
+ graphProvider: { self.demoSamples() },
+ onRangeChange: {},
+ onOpenLarge: {}
+ )
+ try render(view, appearance: appearance, name: "dashboard-\(suffix)")
+
+ let windowStyle = SolixMenuDashboardView(
+ snapshot: snapshot,
+ previous: previous,
+ style: .window,
+ graphProvider: { self.demoSamples() },
+ onRangeChange: {},
+ onOpenLarge: {}
+ )
+ try render(windowStyle, appearance: appearance, name: "dashboard-window-\(suffix)")
+ }
+ }
+
+ /// Rendert das echte Verlaufsfenster (inkl. Chip-Kopfzeile) statt nur
+ /// der nackten Graph-View — sonst zeigen die Bilder eine Kopfzeile,
+ /// die es im Fenster gar nicht gibt.
+ @Test("large graph window renders in light and dark")
+ func largeGraph() throws {
+ let savedAppearance = NSApp.appearance
+ defer { NSApp.appearance = savedAppearance }
+ for (suffix, name) in [("light", NSAppearance.Name.aqua), ("dark", .darkAqua)] {
+ let appearance = try #require(NSAppearance(named: name))
+ NSApp.appearance = appearance
+ let controller = LargeGraphWindowController(graphProvider: { self.demoSamples() })
+ let window = try #require(controller.window)
+ window.appearance = appearance
+ window.alphaValue = 0
+ controller.showWindow(nil)
+ let content = try #require(window.contentView)
+ content.wantsLayer = true
+ appearance.performAsCurrentDrawingAppearance {
+ content.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor
+ }
+ RunLoop.main.run(until: Date().addingTimeInterval(0.8))
+ let rep = try #require(content.bitmapImageRepForCachingDisplay(in: content.bounds))
+ content.cacheDisplay(in: content.bounds, to: rep)
+ let png = try #require(rep.representation(using: .png, properties: [:]))
+ try png.write(to: Self.outputDir.appendingPathComponent("graph-large-\(suffix).png"))
+ window.close()
+ }
+ }
+
+ /// Baut die Leiste pro Appearance neu auf (cgColor-Auflösung!) und legt
+ /// sie über einen Schreibtisch-artigen Verlauf: Offscreen rendert die
+ /// Vibrancy nichts — ohne Hintergrund sah "dunkel" hell aus.
+ @Test("detached slim bar renders")
+ func detachedBar() throws {
+ let formatter = MenuBarFormatter()
+ var snapshot = SolixSnapshot.demo
+ snapshot.updatedAt = Date()
+ let options = MenuBarDisplayOptions(
+ metrics: [.battery, .solar, .grid],
+ showLabels: true,
+ showSymbols: true,
+ showArrows: false
+ )
+ let savedAppearance = NSApp.appearance
+ defer { NSApp.appearance = savedAppearance }
+
+ for (suffix, name) in [("dark", NSAppearance.Name.darkAqua), ("light", .aqua)] {
+ let appearance = try #require(NSAppearance(named: name))
+ NSApp.appearance = appearance
+ let controller = DetachedMenuBarWindowController(
+ attributedBarProvider: { formatter.attributedTitle(for: snapshot, scale: 1.0, options: options) },
+ onClose: {}
+ )
+ let bar = try #require(controller.window?.contentView)
+ bar.autoresizingMask = []
+
+ let canvas = NSView(frame: NSRect(x: 0, y: 0, width: bar.frame.width + 48, height: bar.frame.height + 48))
+ canvas.wantsLayer = true
+ let gradient = CAGradientLayer()
+ gradient.frame = canvas.bounds
+ gradient.startPoint = CGPoint(x: 0, y: 0)
+ gradient.endPoint = CGPoint(x: 1, y: 1)
+ gradient.colors = suffix == "dark"
+ ? [NSColor(calibratedRed: 0.10, green: 0.12, blue: 0.18, alpha: 1).cgColor,
+ NSColor(calibratedRed: 0.17, green: 0.13, blue: 0.24, alpha: 1).cgColor]
+ : [NSColor(calibratedRed: 0.80, green: 0.86, blue: 0.93, alpha: 1).cgColor,
+ NSColor(calibratedRed: 0.94, green: 0.90, blue: 0.83, alpha: 1).cgColor]
+ canvas.layer?.addSublayer(gradient)
+ bar.setFrameOrigin(NSPoint(x: 24, y: 24))
+ canvas.addSubview(bar)
+
+ try render(canvas, appearance: name, name: "detached-bar-\(suffix)", settle: 0.6)
+ }
+ }
+
+ @Test("settings tabs render in light and dark")
+ func settingsTabs() throws {
+ let controller = SettingsWindowController(onPreview: {}, onSave: {}, onReset: {})
+ let window = try #require(controller.window)
+ let content = try #require(window.contentView)
+ // Nicht per alphaValue verstecken: dann lässt AppKit die Textzeichnung
+ // beim cacheDisplay weg (leere Panes) — stattdessen aus dem sichtbaren
+ // Bereich schieben.
+ window.setFrameOrigin(NSPoint(x: -4000, y: -4000))
+ controller.showWindow(nil)
+ window.setContentSize(NSSize(width: 720, height: 660))
+ RunLoop.main.run(until: Date().addingTimeInterval(0.5))
+ let tabs = try #require(findTabView(content))
+ let names = ["menubar", "detachedbar", "datasource", "warnings", "app"]
+ for (suffix, appearance) in [("light", NSAppearance.Name.aqua), ("dark", .darkAqua)] {
+ window.appearance = NSAppearance(named: appearance)
+ for (index, name) in names.enumerated() where index < tabs.numberOfTabViewItems {
+ tabs.selectTabViewItem(at: index)
+ RunLoop.main.run(until: Date().addingTimeInterval(0.3))
+ // cacheDisplay lässt bei Fenster-Inhalten Text/Hintergrund weg;
+ // der PDF-Pfad geht durch draw(_:) und erfasst alles.
+ let pdfData = content.dataWithPDF(inside: content.bounds)
+ let pdfImage = try #require(NSImage(data: pdfData))
+ let scale: CGFloat = 2
+ let size = content.bounds.size
+ let rep = try #require(NSBitmapImageRep(
+ bitmapDataPlanes: nil,
+ pixelsWide: Int(size.width * scale),
+ pixelsHigh: Int(size.height * scale),
+ bitsPerSample: 8,
+ samplesPerPixel: 4,
+ hasAlpha: true,
+ isPlanar: false,
+ colorSpaceName: .deviceRGB,
+ bytesPerRow: 0,
+ bitsPerPixel: 0
+ ))
+ rep.size = size
+ NSGraphicsContext.saveGraphicsState()
+ NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep)
+ (NSAppearance(named: appearance) ?? NSAppearance.currentDrawing()).performAsCurrentDrawingAppearance {
+ NSColor.windowBackgroundColor.setFill()
+ NSRect(origin: .zero, size: size).fill()
+ pdfImage.draw(in: NSRect(origin: .zero, size: size))
+ }
+ NSGraphicsContext.restoreGraphicsState()
+ let png = try #require(rep.representation(using: .png, properties: [:]))
+ try png.write(to: Self.outputDir.appendingPathComponent("settings-\(name)-\(suffix).png"))
+ }
+ }
+ window.close()
+ }
+
+ private func findTabView(_ view: NSView) -> NSTabView? {
+ if let tab = view as? NSTabView { return tab }
+ for sub in view.subviews {
+ if let found = findTabView(sub) { return found }
+ }
+ return nil
+ }
+}
+
+@MainActor
+@Suite("Stacked menu bar renders")
+struct StackedRenderTests {
+ @Test("two-line compact image renders in light and dark")
+ func stackedImage() throws {
+ let entries: [StackedMenuBarRenderer.Entry] = [
+ .init(symbolName: "battery.75percent", text: "82%", role: .batteryHigh),
+ .init(symbolName: "sun.max.fill", text: "642W", role: .solar),
+ .init(symbolName: "house.fill", text: "318W", role: .load),
+ .init(symbolName: "powerplug.fill", text: "-86W", role: .gridExport)
+ ]
+ let image = try #require(StackedMenuBarRenderer.image(entries: entries, scale: 1.0, showWarning: false))
+ #expect(image.size.width > 40 && image.size.width < 260)
+
+ for (suffix, name) in [("light", NSAppearance.Name.aqua), ("dark", .darkAqua)] {
+ let appearance = try #require(NSAppearance(named: name))
+ let canvas = NSImage(size: NSSize(width: image.size.width + 12, height: 28))
+ canvas.lockFocus()
+ appearance.performAsCurrentDrawingAppearance {
+ (suffix == "dark"
+ ? NSColor(calibratedWhite: 0.12, alpha: 1)
+ : NSColor(calibratedWhite: 0.94, alpha: 1)).setFill()
+ NSRect(origin: .zero, size: canvas.size).fill()
+ image.draw(
+ in: NSRect(x: 6, y: 3, width: image.size.width, height: 22),
+ from: .zero,
+ operation: .sourceOver,
+ fraction: 1
+ )
+ }
+ canvas.unlockFocus()
+ let tiff = try #require(canvas.tiffRepresentation)
+ let rep = try #require(NSBitmapImageRep(data: tiff))
+ let png = try #require(rep.representation(using: .png, properties: [:]))
+ try png.write(to: RenderTests.outputDir.appendingPathComponent("menubar-stacked-\(suffix).png"))
+ }
+ }
+}
diff --git a/Tests/SolixBarTests/SettingsHelpTests.swift b/Tests/SolixBarTests/SettingsHelpTests.swift
new file mode 100644
index 0000000..eafec54
--- /dev/null
+++ b/Tests/SolixBarTests/SettingsHelpTests.swift
@@ -0,0 +1,48 @@
+import AppKit
+import Testing
+@testable import SolixBarKit
+
+@MainActor
+@Suite("Einstellungs-Hilfe")
+struct SettingsHelpTests {
+ @Test("Bekannte Kurztexte liefern ausführliche Hilfe (DE und EN)")
+ func knownKeysHaveExtendedHelp() {
+ let shortTexts = [
+ "Legt fest, woher SolixBar die Werte lädt.",
+ "E-Mail-Adresse deines Anker/SOLIX-Kontos.",
+ "Passwort deines Anker/SOLIX-Kontos. Wird sicher im macOS-Schlüsselbund gespeichert und nie hochgeladen.",
+ "Land deines Anker-Kontos, normalerweise DE.",
+ "Zeit zwischen zwei Aktualisierungen in Sekunden.",
+ "Warnschwelle in Prozent (5–95).",
+ "Warning threshold in percent (5–95).",
+ "Startet SolixBar automatisch nach dem Anmelden.",
+ "Passt die Größe der Menüleistenanzeige an.",
+ "Window start (hour, 0–23)."
+ ]
+ for short in shortTexts {
+ let extended = SettingsHelp.extended(for: short)
+ #expect(extended != nil, "Kein ausführlicher Hilfetext für: \(short)")
+ // Ausführlich heisst: deutlich mehr Erklärung als der Kurztext.
+ #expect((extended ?? "").count > short.count, "Hilfetext nicht länger als Kurztext: \(short)")
+ }
+ }
+
+ @Test("Unbekannter Text fällt auf nil zurück")
+ func unknownKeyReturnsNil() {
+ #expect(SettingsHelp.extended(for: "gibt es nicht") == nil)
+ }
+
+ @Test("Popover-Inhalt hat echte Grösse und wächst mit dem Text")
+ func popoverContentHasRealSize() {
+ let short = SettingsWindowController.helpPopoverContent(for: "Kurzer Text.")
+ #expect(short.size.width > 100)
+ #expect(short.size.height > 20)
+ #expect(short.view.frame.size == short.size)
+ #expect(!short.view.subviews.isEmpty)
+
+ let long = SettingsWindowController.helpPopoverContent(
+ for: String(repeating: "Ein deutlich längerer Hilfetext über mehrere Zeilen. ", count: 10)
+ )
+ #expect(long.size.height > short.size.height * 3)
+ }
+}
diff --git a/Tests/SolixBarTests/SnapshotDecoderTests.swift b/Tests/SolixBarTests/SnapshotDecoderTests.swift
new file mode 100644
index 0000000..59fd53c
--- /dev/null
+++ b/Tests/SolixBarTests/SnapshotDecoderTests.swift
@@ -0,0 +1,49 @@
+import Foundation
+import Testing
+@testable import SolixBarKit
+
+@Suite("SnapshotDecoder")
+struct SnapshotDecoderTests {
+ @Test("decodes the full README example")
+ func decodesFullExample() throws {
+ let json = """
+ {
+ "siteName": "Anker SOLIX",
+ "batteryPercent": 82,
+ "solarWatts": 642,
+ "homeWatts": 318,
+ "gridWatts": -86,
+ "batteryWatts": 238,
+ "todayKWh": 3.74,
+ "totalKWh": 427.8,
+ "status": "Online",
+ "updatedAt": "2026-07-06T19:30:00Z"
+ }
+ """
+ let snapshot = try SnapshotDecoder.decode(Data(json.utf8))
+ #expect(snapshot.batteryPercent == 82)
+ #expect(snapshot.solarWatts == 642)
+ #expect(snapshot.gridWatts == -86)
+ #expect(snapshot.status == "Online")
+ }
+
+ @Test("decodes a minimal payload with only measurements")
+ func decodesMinimalPayload() throws {
+ let json = """
+ { "batteryPercent": 50, "updatedAt": "2026-07-06T19:30:00Z" }
+ """
+ let snapshot = try SnapshotDecoder.decode(Data(json.utf8))
+ #expect(snapshot.batteryPercent == 50)
+ #expect(snapshot.solarWatts == nil)
+ #expect(snapshot.pvWatts == nil)
+ }
+
+ @Test("decodes per-PV channel watts when present")
+ func decodesPVChannels() throws {
+ let json = """
+ { "solarWatts": 642, "pvWatts": [438, 204], "updatedAt": "2026-07-06T19:30:00Z" }
+ """
+ let snapshot = try SnapshotDecoder.decode(Data(json.utf8))
+ #expect(snapshot.pvWatts == [438, 204])
+ }
+}
diff --git a/Tests/SolixBarTests/SolarValueLinesTests.swift b/Tests/SolixBarTests/SolarValueLinesTests.swift
new file mode 100644
index 0000000..d36e6b8
--- /dev/null
+++ b/Tests/SolixBarTests/SolarValueLinesTests.swift
@@ -0,0 +1,49 @@
+import Testing
+@testable import SolixBarKit
+
+@Suite("Solar-Wertezeilen")
+struct SolarValueLinesTests {
+ typealias V = SolixMenuDashboardView
+
+ @Test("Summenmodus: eine Zeile mit Pfeil")
+ func totalMode() {
+ #expect(V.solarValueLines(pvMode: .total, channels: [438, 204], totalWatts: 659, arrow: "▲")
+ == ["659 W ▲"])
+ }
+
+ @Test("Ohne Pfeil kein angehängtes Zeichen")
+ func noArrow() {
+ #expect(V.solarValueLines(pvMode: .total, channels: nil, totalWatts: 659, arrow: nil)
+ == ["659 W"])
+ }
+
+ @Test("Fehlende Summe ergibt Platzhalter")
+ func missingTotal() {
+ #expect(V.solarValueLines(pvMode: .total, channels: nil, totalWatts: nil, arrow: nil)
+ == ["-"])
+ }
+
+ @Test("Zwei Eingänge bleiben einzeilig")
+ func twoChannels() {
+ #expect(V.solarValueLines(pvMode: .perInput, channels: [438, 204], totalWatts: 642, arrow: "▲")
+ == ["438 · 204 W ▲"])
+ }
+
+ @Test("Vier Eingänge: 2×2 auf zwei Zeilen, Pfeil unten")
+ func fourChannels() {
+ #expect(V.solarValueLines(pvMode: .perInput, channels: [438, 204, 210, 190], totalWatts: 1042, arrow: "▲")
+ == ["438 · 204", "210 · 190 W ▲"])
+ }
+
+ @Test("Drei Eingänge: zwei oben, einer unten")
+ func threeChannels() {
+ #expect(V.solarValueLines(pvMode: .perInput, channels: [438, 204, 210], totalWatts: 852, arrow: nil)
+ == ["438 · 204", "210 W"])
+ }
+
+ @Test("perInput ohne Kanäle fällt auf die Summe zurück")
+ func perInputWithoutChannels() {
+ #expect(V.solarValueLines(pvMode: .perInput, channels: nil, totalWatts: 659, arrow: "▼")
+ == ["659 W ▼"])
+ }
+}
diff --git a/Tests/SolixBarTests/URLProviderTests.swift b/Tests/SolixBarTests/URLProviderTests.swift
new file mode 100644
index 0000000..a176a90
--- /dev/null
+++ b/Tests/SolixBarTests/URLProviderTests.swift
@@ -0,0 +1,73 @@
+import Foundation
+import Testing
+@testable import SolixBarKit
+
+/// Testet den JSON-URL-Modus gegen einen lokalen HTTP-Server — deckt den
+/// kompletten App-seitigen Datenweg ab (Request, Statusprüfung, Decoding).
+@Suite("URLSolixDataProvider", .serialized)
+struct URLProviderTests {
+ private static func startServer(directory: URL, port: Int) throws -> Process {
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
+ process.arguments = ["python3", "-m", "http.server", String(port), "--directory", directory.path]
+ process.standardOutput = FileHandle.nullDevice
+ process.standardError = FileHandle.nullDevice
+ try process.run()
+ return process
+ }
+
+ private static func waitUntilReachable(port: Int) async throws {
+ let url = URL(string: "http://127.0.0.1:\(port)/")!
+ for _ in 0..<50 {
+ if (try? await URLSession.shared.data(from: url)) != nil { return }
+ try await Task.sleep(nanoseconds: 100_000_000)
+ }
+ Issue.record("local http server did not come up")
+ }
+
+ @Test("fetches and decodes a realistic payload over HTTP")
+ func fetchesPayload() async throws {
+ let dir = FileManager.default.temporaryDirectory
+ .appendingPathComponent("solixbar-url-\(UUID().uuidString)")
+ try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
+ let payload = """
+ {"siteName":"Balkonkraftwerk","batteryPercent":76,"solarWatts":512,"homeWatts":301,
+ "gridWatts":-45,"batteryWatts":180,"todayKWh":2.41,"totalKWh":390.2,
+ "status":"Online","updatedAt":"2026-07-11T10:00:00Z"}
+ """
+ try payload.write(to: dir.appendingPathComponent("solix.json"), atomically: true, encoding: .utf8)
+
+ let port = 18987
+ let server = try Self.startServer(directory: dir, port: port)
+ defer { server.terminate() }
+ try await Self.waitUntilReachable(port: port)
+
+ let provider = URLSolixDataProvider(urlString: "http://127.0.0.1:\(port)/solix.json")
+ let snapshot = try await provider.fetchSnapshot()
+ #expect(snapshot.siteName == "Balkonkraftwerk")
+ #expect(snapshot.batteryPercent == 76)
+ #expect(snapshot.gridWatts == -45)
+ }
+
+ @Test("non-2xx responses produce a clear HTTP error")
+ func httpErrorSurfaces() async throws {
+ let dir = FileManager.default.temporaryDirectory
+ .appendingPathComponent("solixbar-url-\(UUID().uuidString)")
+ try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
+
+ let port = 18988
+ let server = try Self.startServer(directory: dir, port: port)
+ defer { server.terminate() }
+ try await Self.waitUntilReachable(port: port)
+
+ let provider = URLSolixDataProvider(urlString: "http://127.0.0.1:\(port)/missing.json")
+ do {
+ _ = try await provider.fetchSnapshot()
+ Issue.record("expected httpError")
+ } catch let SolixProviderError.httpError(status) {
+ #expect(status == 404)
+ } catch {
+ Issue.record("unexpected error: \(error)")
+ }
+ }
+}
diff --git a/Tests/SolixBarTests/UpdateCheckerTests.swift b/Tests/SolixBarTests/UpdateCheckerTests.swift
new file mode 100644
index 0000000..561194c
--- /dev/null
+++ b/Tests/SolixBarTests/UpdateCheckerTests.swift
@@ -0,0 +1,36 @@
+import Foundation
+import Testing
+@testable import SolixBarKit
+
+@Suite("UpdateChecker")
+struct UpdateCheckerTests {
+ @Test("newer tags are recognized across formats")
+ func newerTags() {
+ #expect(UpdateChecker.isNewer("v0.4.3", than: "0.4.2"))
+ #expect(UpdateChecker.isNewer("0.4.3", than: "v0.4.2"))
+ #expect(UpdateChecker.isNewer("v0.5", than: "0.4.9"))
+ #expect(UpdateChecker.isNewer("v1.0.0", than: "0.4.2"))
+ #expect(UpdateChecker.isNewer("v0.4.2.1", than: "0.4.2"))
+ }
+
+ @Test("equal or older tags never count as updates")
+ func equalOrOlder() {
+ #expect(!UpdateChecker.isNewer("v0.4.2", than: "0.4.2"))
+ #expect(!UpdateChecker.isNewer("v0.4.1", than: "0.4.2"))
+ #expect(!UpdateChecker.isNewer("0.4", than: "0.4.0"))
+ #expect(!UpdateChecker.isNewer("v0.9.9", than: "1.0"))
+ }
+
+ @Test("dev builds are never outdated")
+ func devBuilds() {
+ #expect(!UpdateChecker.isNewer("v99.0", than: "dev"))
+ }
+
+ @Test("unparsable tags never count as updates")
+ func garbageTags() {
+ #expect(!UpdateChecker.isNewer("latest", than: "0.4.2"))
+ #expect(!UpdateChecker.isNewer("v0.4-beta", than: "0.4.2"))
+ #expect(!UpdateChecker.isNewer("", than: "0.4.2"))
+ #expect(!UpdateChecker.isNewer("v9.9", than: "unbekannt"))
+ }
+}
diff --git a/Tests/SolixBarTests/WarningEngineTests.swift b/Tests/SolixBarTests/WarningEngineTests.swift
new file mode 100644
index 0000000..a6b761d
--- /dev/null
+++ b/Tests/SolixBarTests/WarningEngineTests.swift
@@ -0,0 +1,252 @@
+import Foundation
+import Testing
+@testable import SolixBarKit
+
+@Suite("WarningEngine")
+struct WarningEngineTests {
+ private let base = Date(timeIntervalSince1970: 1_752_000_000)
+ private var utc: Calendar {
+ var calendar = Calendar(identifier: .gregorian)
+ calendar.timeZone = TimeZone(identifier: "UTC")!
+ return calendar
+ }
+
+ private func snapshot(
+ battery: Int? = nil,
+ solar: Int? = nil,
+ pv: [Int]? = nil,
+ at date: Date
+ ) -> SolixSnapshot {
+ SolixSnapshot(siteName: "Test", batteryPercent: battery, solarWatts: solar, updatedAt: date, pvWatts: pv)
+ }
+
+ /// Spielt eine Serie (Minuten-Offset, Snapshot-Werte) durch und sammelt
+ /// alle gefeuerten Ereignisse mit ihrem Offset ein.
+ private func run(
+ _ engine: inout WarningEngine,
+ config: WarningEngine.Config,
+ steps: [(minute: Int, battery: Int?, solar: Int?, pv: [Int]?)]
+ ) -> [(minute: Int, event: WarningEngine.Event)] {
+ var fired: [(Int, WarningEngine.Event)] = []
+ for step in steps {
+ let date = base.addingTimeInterval(Double(step.minute) * 60)
+ let events = engine.evaluate(
+ snapshot: snapshot(battery: step.battery, solar: step.solar, pv: step.pv, at: date),
+ at: date,
+ config: config,
+ calendar: utc
+ )
+ fired.append(contentsOf: events.map { (step.minute, $0) })
+ }
+ return fired
+ }
+
+ @Test("battery warning fires once and re-arms with hysteresis")
+ func batteryHysteresis() {
+ var engine = WarningEngine()
+ var config = WarningEngine.Config()
+ config.batteryLowEnabled = true
+ config.batteryLowThreshold = 20
+
+ let fired = run(&engine, config: config, steps: [
+ (0, 25, 300, nil),
+ (5, 19, 300, nil), // Durchgang nach unten -> feuert
+ (10, 18, 300, nil), // bleibt unten -> still
+ (15, 23, 300, nil), // über Schwelle, aber unter Schwelle+5 -> bleibt entschärft
+ (20, 19, 300, nil), // wieder unten -> still (nicht neu scharf)
+ (25, 26, 300, nil), // > Schwelle+5 -> neu scharf
+ (30, 20, 300, nil) // Durchgang -> feuert erneut
+ ])
+ #expect(fired.map(\.minute) == [5, 30])
+ #expect(fired.first?.event == .batteryLow(percent: 19))
+ }
+
+ @Test("a night of zeros never fires the stall warning")
+ func nightIsSilent() {
+ var engine = WarningEngine()
+ var config = WarningEngine.Config()
+ config.pvStallEnabled = true
+
+ let steps = (0..<48).map { (minute: $0 * 5, battery: Int?.none, solar: Int?(0), pv: [Int]?.none) }
+ let fired = run(&engine, config: config, steps: steps)
+ #expect(fired.isEmpty)
+ }
+
+ @Test("collapse after production fires once, recovery re-arms")
+ func collapseFiresOnce() {
+ var engine = WarningEngine()
+ var config = WarningEngine.Config()
+ config.pvStallEnabled = true
+ config.pvStallMinutes = 15
+
+ var steps: [(minute: Int, battery: Int?, solar: Int?, pv: [Int]?)] = []
+ // Eine Stunde solide Erzeugung, dann Einbruch auf 0.
+ for minute in stride(from: 0, through: 55, by: 5) { steps.append((minute, nil, 300, nil)) }
+ for minute in stride(from: 60, through: 90, by: 5) { steps.append((minute, nil, 0, nil)) }
+ // Erholung, dann zweiter Einbruch.
+ for minute in stride(from: 95, through: 150, by: 5) { steps.append((minute, nil, 250, nil)) }
+ for minute in stride(from: 155, through: 185, by: 5) { steps.append((minute, nil, 0, nil)) }
+
+ let fired = run(&engine, config: config, steps: steps)
+ #expect(fired.map(\.minute) == [75, 170])
+ #expect(fired.allSatisfy { $0.event == .pvStalled })
+ }
+
+ @Test("stall detection is refresh-interval independent")
+ func intervalIndependence() {
+ for interval in [1, 5] {
+ var engine = WarningEngine()
+ var config = WarningEngine.Config()
+ config.pvStallEnabled = true
+ config.pvStallMinutes = 15
+
+ var steps: [(minute: Int, battery: Int?, solar: Int?, pv: [Int]?)] = []
+ for minute in stride(from: 0, through: 55, by: interval) { steps.append((minute, nil, 300, nil)) }
+ for minute in stride(from: 60, through: 90, by: interval) { steps.append((minute, nil, 0, nil)) }
+ let fired = run(&engine, config: config, steps: steps)
+ #expect(fired.count == 1, "interval \(interval)")
+ #expect(fired.first?.minute == 75, "interval \(interval)")
+ }
+ }
+
+ @Test("daytime window fires at noon but not at night")
+ func daytimeWindow() {
+ var config = WarningEngine.Config()
+ config.pvWindowEnabled = true
+ config.pvWindowStartHour = 9
+ config.pvWindowEndHour = 17
+ config.pvStallMinutes = 15
+
+ // base auf 12:00 UTC schieben: 1_752_000_000 = 2025-07-08 18:40 UTC.
+ // Statt zu rechnen, bauen wir die Daten direkt über DateComponents.
+ func date(hour: Int, minute: Int) -> Date {
+ utc.date(from: DateComponents(year: 2026, month: 7, day: 11, hour: hour, minute: minute))!
+ }
+
+ var noonEngine = WarningEngine()
+ var fired: [WarningEngine.Event] = []
+ for offset in stride(from: 0, through: 30, by: 5) {
+ let at = date(hour: 12, minute: offset)
+ fired += noonEngine.evaluate(
+ snapshot: snapshot(solar: 0, at: at), at: at, config: config, calendar: utc
+ )
+ }
+ #expect(fired == [.pvStalled])
+
+ var nightEngine = WarningEngine()
+ var nightFired: [WarningEngine.Event] = []
+ for offset in stride(from: 0, through: 30, by: 5) {
+ let at = date(hour: 22, minute: offset)
+ nightFired += nightEngine.evaluate(
+ snapshot: snapshot(solar: 0, at: at), at: at, config: config, calendar: utc
+ )
+ }
+ #expect(nightFired.isEmpty)
+ }
+
+ @Test("dead channel fires while siblings produce, both dark stays silent")
+ func perChannel() {
+ var config = WarningEngine.Config()
+ config.perPVEnabled = true
+ config.pvStallMinutes = 15
+
+ var engine = WarningEngine()
+ var steps: [(minute: Int, battery: Int?, solar: Int?, pv: [Int]?)] = []
+ for minute in stride(from: 0, through: 30, by: 5) { steps.append((minute, nil, 250, [0, 250])) }
+ let fired = run(&engine, config: config, steps: steps)
+ #expect(fired.map(\.event) == [.pvChannelDead(index: 0)])
+ #expect(fired.first?.minute == 15)
+
+ var darkEngine = WarningEngine()
+ var darkSteps: [(minute: Int, battery: Int?, solar: Int?, pv: [Int]?)] = []
+ for minute in stride(from: 0, through: 30, by: 5) { darkSteps.append((minute, nil, 0, [0, 0])) }
+ #expect(run(&darkEngine, config: config, steps: darkSteps).isEmpty)
+ }
+
+ @Test("per-channel dip fires from own history even when siblings are dark too")
+ func perChannelDip() {
+ var config = WarningEngine.Config()
+ config.perPVDipEnabled = true
+ config.pvStallMinutes = 15
+
+ // Beide Eingänge erzeugen, dann brechen BEIDE ein (z. B. Kabeldefekt
+ // am gemeinsamen Strang) — der Geschwister-Vergleich griffe hier nicht.
+ var engine = WarningEngine()
+ var steps: [(minute: Int, battery: Int?, solar: Int?, pv: [Int]?)] = []
+ for minute in stride(from: 0, through: 30, by: 5) { steps.append((minute, nil, 500, [300, 200])) }
+ for minute in stride(from: 35, through: 60, by: 5) { steps.append((minute, nil, 0, [0, 0])) }
+ let fired = run(&engine, config: config, steps: steps)
+ #expect(fired.map(\.event).contains(.pvChannelDead(index: 0)))
+ #expect(fired.map(\.event).contains(.pvChannelDead(index: 1)))
+
+ // Ohne die Dip-Option bleibt derselbe Verlauf still (Geschwister dunkel).
+ var strictEngine = WarningEngine()
+ var strictConfig = WarningEngine.Config()
+ strictConfig.perPVEnabled = true
+ strictConfig.pvStallMinutes = 15
+ #expect(run(&strictEngine, config: strictConfig, steps: steps).isEmpty)
+
+ // Nachts (nie erzeugt) bleibt auch die Dip-Option still.
+ var nightEngine = WarningEngine()
+ var nightSteps: [(minute: Int, battery: Int?, solar: Int?, pv: [Int]?)] = []
+ for minute in stride(from: 0, through: 60, by: 5) { nightSteps.append((minute, nil, 0, [0, 0])) }
+ #expect(run(&nightEngine, config: config, steps: nightSteps).isEmpty)
+ }
+
+ @Test("warning-test demo scenario drives all three warnings")
+ func demoWarningsScenario() async throws {
+ // Szenario-Start 6 reale Minuten in der Vergangenheit = Demo-Minute 60:
+ // Endphase, kompletter Einbruch.
+ let provider = DemoWarningsSolixDataProvider(start: Date().addingTimeInterval(-6 * 60))
+ let late = try await provider.fetchSnapshot()
+ #expect(late.batteryPercent == 16)
+ #expect(late.pvWatts == [0, 0])
+ #expect(late.solarWatts == 0)
+
+ // Demo-Minute ~15: Akku niedrig, Eingang 2 tot, Eingang 1 erzeugt.
+ let mid = try await DemoWarningsSolixDataProvider(start: Date().addingTimeInterval(-90)).fetchSnapshot()
+ #expect(mid.batteryPercent == 16)
+ #expect(mid.pvWatts == [385, 0])
+
+ // Demo-Minute ~5: alles normal.
+ let early = try await DemoWarningsSolixDataProvider(start: Date().addingTimeInterval(-30)).fetchSnapshot()
+ #expect(early.batteryPercent == 42)
+ #expect(early.pvWatts == [385, 235])
+ }
+
+ @Test("disabled warnings never fire")
+ func disabledStaysSilent() {
+ var engine = WarningEngine()
+ let config = WarningEngine.Config() // alles aus
+ let fired = run(&engine, config: config, steps: [
+ (0, 25, 300, [300, 0]),
+ (5, 10, 0, [0, 0]),
+ (60, 10, 0, [0, 0])
+ ])
+ #expect(fired.isEmpty)
+ }
+
+ @Test("warning settings survive the snapshot/apply round-trip")
+ @MainActor
+ func settingsRoundTrip() {
+ let settings = AppSettings.shared
+ let original = settings.snapshot()
+ defer { settings.apply(original) }
+
+ var modified = original
+ modified.warnPerPVDipEnabled = true
+ modified.warnBatteryLowEnabled = true
+ modified.warnBatteryLowThreshold = 33
+ modified.warnPVStallEnabled = true
+ modified.warnPVStallMinutes = 42
+ modified.warnPVStallMinRecentWatts = 111
+ modified.warnPVWindowEnabled = true
+ modified.warnPVWindowStart = 8
+ modified.warnPVWindowEnd = 18
+ modified.warnPerPVEnabled = true
+ settings.apply(modified)
+ #expect(settings.snapshot() == modified)
+ #expect(settings.warningConfig().batteryLowThreshold == 33)
+ #expect(settings.warningConfig().pvWindowStartHour == 8)
+ }
+}
diff --git a/VERSION b/VERSION
index f9a4b5f..17b2ccd 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.3.20
+0.4.3
diff --git a/docs/review-assets/SolixBar-icon-preview.png b/docs/review-assets/SolixBar-icon-preview.png
new file mode 100644
index 0000000..1efe9eb
Binary files /dev/null and b/docs/review-assets/SolixBar-icon-preview.png differ
diff --git a/docs/review-assets/SolixBar-icon-proposal-1024.png b/docs/review-assets/SolixBar-icon-proposal-1024.png
new file mode 100644
index 0000000..872acaa
Binary files /dev/null and b/docs/review-assets/SolixBar-icon-proposal-1024.png differ
diff --git a/docs/review-assets/SolixBar-icon-proposal-b-1024.png b/docs/review-assets/SolixBar-icon-proposal-b-1024.png
new file mode 100644
index 0000000..58ed0ad
Binary files /dev/null and b/docs/review-assets/SolixBar-icon-proposal-b-1024.png differ
diff --git a/docs/review-assets/after-dashboard-dark.png b/docs/review-assets/after-dashboard-dark.png
new file mode 100644
index 0000000..129fbfa
Binary files /dev/null and b/docs/review-assets/after-dashboard-dark.png differ
diff --git a/docs/review-assets/after-dashboard-light.png b/docs/review-assets/after-dashboard-light.png
new file mode 100644
index 0000000..c0a6fd5
Binary files /dev/null and b/docs/review-assets/after-dashboard-light.png differ
diff --git a/docs/review-assets/after-detached-bar-dark.png b/docs/review-assets/after-detached-bar-dark.png
new file mode 100644
index 0000000..dc0f6fd
Binary files /dev/null and b/docs/review-assets/after-detached-bar-dark.png differ
diff --git a/docs/review-assets/after-detached-bar-light.png b/docs/review-assets/after-detached-bar-light.png
new file mode 100644
index 0000000..d50cca7
Binary files /dev/null and b/docs/review-assets/after-detached-bar-light.png differ
diff --git a/docs/review-assets/after-graph-large-dark.png b/docs/review-assets/after-graph-large-dark.png
new file mode 100644
index 0000000..4e115e3
Binary files /dev/null and b/docs/review-assets/after-graph-large-dark.png differ
diff --git a/docs/review-assets/before-dashboard-dark.png b/docs/review-assets/before-dashboard-dark.png
new file mode 100644
index 0000000..040c557
Binary files /dev/null and b/docs/review-assets/before-dashboard-dark.png differ
diff --git a/docs/review-assets/before-dashboard-light.png b/docs/review-assets/before-dashboard-light.png
new file mode 100644
index 0000000..5c6e3ac
Binary files /dev/null and b/docs/review-assets/before-dashboard-light.png differ
diff --git a/docs/review-assets/before-detached-bar.png b/docs/review-assets/before-detached-bar.png
new file mode 100644
index 0000000..a96fa78
Binary files /dev/null and b/docs/review-assets/before-detached-bar.png differ
diff --git a/docs/review-assets/before-graph-large-dark.png b/docs/review-assets/before-graph-large-dark.png
new file mode 100644
index 0000000..3136343
Binary files /dev/null and b/docs/review-assets/before-graph-large-dark.png differ
diff --git a/docs/review-assets/menubar-stacked-dark.png b/docs/review-assets/menubar-stacked-dark.png
new file mode 100644
index 0000000..e88ce96
Binary files /dev/null and b/docs/review-assets/menubar-stacked-dark.png differ
diff --git a/docs/review-assets/menubar-stacked-light.png b/docs/review-assets/menubar-stacked-light.png
new file mode 100644
index 0000000..41ac34c
Binary files /dev/null and b/docs/review-assets/menubar-stacked-light.png differ
diff --git a/docs/review-assets/settings-app-dark.png b/docs/review-assets/settings-app-dark.png
new file mode 100644
index 0000000..8111d0a
Binary files /dev/null and b/docs/review-assets/settings-app-dark.png differ
diff --git a/docs/review-assets/settings-menubar-dark.png b/docs/review-assets/settings-menubar-dark.png
new file mode 100644
index 0000000..65b3965
Binary files /dev/null and b/docs/review-assets/settings-menubar-dark.png differ
diff --git a/docs/review-assets/settings-warnings-dark.png b/docs/review-assets/settings-warnings-dark.png
new file mode 100644
index 0000000..f72dfe2
Binary files /dev/null and b/docs/review-assets/settings-warnings-dark.png differ
diff --git a/requirements-solix.txt b/requirements-solix.txt
new file mode 100644
index 0000000..b90f2d5
--- /dev/null
+++ b/requirements-solix.txt
@@ -0,0 +1,20 @@
+# Runtime dependencies used by the bundled SOLIX helper.
+# anker-solix-api is not on PyPI; it installs straight from the pinned
+# GitHub tag. Keep this list free of credentials, local paths, and
+# private package indexes.
+anker-solix-api @ https://github.com/thomluther/anker-solix-api/archive/refs/tags/v3.7.0.tar.gz
+aiofiles==25.1.0
+aiohappyeyeballs==2.7.1
+aiohttp==3.14.1
+aiosignal==1.4.0
+attrs==26.1.0
+cffi==2.1.0
+cryptography==49.0.0
+frozenlist==1.8.0
+idna==3.18
+multidict==6.7.1
+paho-mqtt==2.1.0
+propcache==0.5.2
+pycparser==3.0
+typing-extensions==4.16.0
+yarl==1.24.2
diff --git a/scripts/make_icon_proposal.swift b/scripts/make_icon_proposal.swift
new file mode 100644
index 0000000..7ed94ad
--- /dev/null
+++ b/scripts/make_icon_proposal.swift
@@ -0,0 +1,229 @@
+import AppKit
+
+// SolixBar App-Icon-Entwürfe: "Panel als Diagramm" (A) und ruhige Variante (B).
+// Ausgabe: icon-a-1024.png, icon-b-1024.png, Vorschau-Kontaktbogen.
+
+let out = URL(fileURLWithPath: CommandLine.arguments.count > 1
+ ? CommandLine.arguments[1]
+ : FileManager.default.currentDirectoryPath)
+
+func color(_ hex: UInt32, _ alpha: CGFloat = 1) -> NSColor {
+ NSColor(
+ calibratedRed: CGFloat((hex >> 16) & 0xFF) / 255,
+ green: CGFloat((hex >> 8) & 0xFF) / 255,
+ blue: CGFloat(hex & 0xFF) / 255,
+ alpha: alpha
+ )
+}
+
+func renderPNG(size: Int, _ draw: (CGFloat) -> Void) -> NSBitmapImageRep {
+ let rep = NSBitmapImageRep(
+ bitmapDataPlanes: nil, pixelsWide: size, pixelsHigh: size,
+ bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
+ colorSpaceName: .calibratedRGB, bytesPerRow: 0, bitsPerPixel: 0
+ )!
+ let ctx = NSGraphicsContext(bitmapImageRep: rep)!
+ NSGraphicsContext.saveGraphicsState()
+ NSGraphicsContext.current = ctx
+ ctx.imageInterpolation = .high
+ draw(CGFloat(size))
+ NSGraphicsContext.restoreGraphicsState()
+ return rep
+}
+
+func squirclePath(canvas: CGFloat) -> (NSBezierPath, NSRect) {
+ // Big-Sur-Raster: Kachel 824/1024 des Canvas, zentriert.
+ let side = canvas * 824 / 1024
+ let rect = NSRect(x: (canvas - side) / 2, y: (canvas - side) / 2, width: side, height: side)
+ let radius = side * 0.225
+ return (NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius), rect)
+}
+
+struct PanelSpec {
+ var skewDegrees: CGFloat // Neigung des Panels
+ var risingFill: Bool // true: Säulen füllen sich wie ein Diagramm
+}
+
+func drawIcon(canvas: CGFloat, spec: PanelSpec) {
+ let (squircle, tile) = squirclePath(canvas: canvas)
+ let s = tile.width / 824 // Skala relativ zum 824er-Raster
+
+ // Schatten der Kachel
+ NSGraphicsContext.current?.saveGraphicsState()
+ let shadow = NSShadow()
+ shadow.shadowColor = NSColor.black.withAlphaComponent(0.32)
+ shadow.shadowBlurRadius = 36 * s
+ shadow.shadowOffset = NSSize(width: 0, height: -10 * s)
+ shadow.set()
+ color(0x171A2B).setFill()
+ squircle.fill()
+ NSGraphicsContext.current?.restoreGraphicsState()
+
+ // Hintergrund: Abendhimmel-Verlauf
+ NSGraphicsContext.current?.saveGraphicsState()
+ squircle.addClip()
+ NSGradient(colors: [color(0x39406B), color(0x232746), color(0x14172A)], atLocations: [0, 0.55, 1], colorSpace: .deviceRGB)!
+ .draw(in: tile, angle: -90)
+
+ // Warmes Streulicht hinter der Sonne
+ let sunCenter = NSPoint(x: tile.midX + 272 * s, y: tile.midY + 262 * s)
+ let glow = NSGradient(colors: [color(0xFFB84D, 0.45), color(0xFFB84D, 0.0)])!
+ glow.draw(
+ fromCenter: sunCenter, radius: 0,
+ toCenter: sunCenter, radius: 430 * s,
+ options: []
+ )
+
+ // Sonne
+ let sunRadius = 132 * s
+ let sunRect = NSRect(x: sunCenter.x - sunRadius, y: sunCenter.y - sunRadius, width: sunRadius * 2, height: sunRadius * 2)
+ NSGradient(colors: [color(0xFFEBA1), color(0xFFC24E)])!
+ .draw(in: NSBezierPath(ovalIn: sunRect), angle: -90)
+
+ // Panel: leichte Scherung = Balkon-Neigung, ohne die Lesbarkeit zu brechen
+ let panelSide = 520 * s
+ let panelRect = NSRect(
+ x: tile.midX - panelSide / 2 - 30 * s,
+ y: tile.midY - panelSide / 2 - 28 * s,
+ width: panelSide, height: panelSide
+ )
+ let transform = NSAffineTransform()
+ transform.translateX(by: panelRect.midX, yBy: panelRect.midY)
+ if spec.skewDegrees != 0 {
+ let shear = tan(spec.skewDegrees * .pi / 180)
+ transform.transformStruct = NSAffineTransformStruct(
+ m11: 1, m12: 0, m21: shear, m22: 1,
+ tX: transform.transformStruct.tX, tY: transform.transformStruct.tY
+ )
+ }
+ transform.translateX(by: -panelRect.midX, yBy: -panelRect.midY)
+ NSGraphicsContext.current?.saveGraphicsState()
+ transform.concat()
+
+ // Rahmen
+ NSGraphicsContext.current?.saveGraphicsState()
+ let frameShadow = NSShadow()
+ frameShadow.shadowColor = NSColor.black.withAlphaComponent(0.45)
+ frameShadow.shadowBlurRadius = 30 * s
+ frameShadow.shadowOffset = NSSize(width: -6 * s, height: -10 * s)
+ frameShadow.set()
+ let frame = NSBezierPath(roundedRect: panelRect, xRadius: 44 * s, yRadius: 44 * s)
+ color(0x0D1020).setFill()
+ frame.fill()
+ NSGraphicsContext.current?.restoreGraphicsState()
+ frame.lineWidth = 3 * s
+ color(0x4A5478, 0.8).setStroke()
+ frame.stroke()
+
+ // Zellen: 4 Spalten x 5 Reihen
+ let pad = 40 * s
+ let gap = 16 * s
+ let cols = 4, rows = 5
+ let cellW = (panelRect.width - pad * 2 - gap * CGFloat(cols - 1)) / CGFloat(cols)
+ let cellH = (panelRect.height - pad * 2 - gap * CGFloat(rows - 1)) / CGFloat(rows)
+ let filledRows = spec.risingFill ? [2, 3, 4, 5] : [5, 5, 5, 5]
+
+ for col in 0.. NSBitmapImageRep {
+ let width = 1480, height = 1060
+ let rep = NSBitmapImageRep(
+ bitmapDataPlanes: nil, pixelsWide: width, pixelsHigh: height,
+ bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
+ colorSpaceName: .calibratedRGB, bytesPerRow: 0, bitsPerPixel: 0
+ )!
+ let ctx = NSGraphicsContext(bitmapImageRep: rep)!
+ NSGraphicsContext.saveGraphicsState()
+ NSGraphicsContext.current = ctx
+ ctx.imageInterpolation = .high
+
+ let half = CGFloat(height) / 2
+ color(0xE9EAF0).setFill()
+ NSRect(x: 0, y: half, width: CGFloat(width), height: half).fill()
+ color(0x131419).setFill()
+ NSRect(x: 0, y: 0, width: CGFloat(width), height: half).fill()
+
+ let sizes: [CGFloat] = [320, 128, 64, 32, 16]
+ let baseYs: [CGFloat] = [half + 60, 60]
+ for (index, entry) in icons.enumerated() {
+ let (title, rep0) = entry
+ let image = NSImage(size: NSSize(width: rep0.pixelsWide, height: rep0.pixelsHigh))
+ image.addRepresentation(rep0)
+ let originX: CGFloat = 70 + CGFloat(index) * 720
+ for (bgIndex, baseY) in baseYs.enumerated() {
+ var x = originX
+ for size in sizes {
+ let y = baseY + (320 - size) / 2
+ image.draw(
+ in: NSRect(x: x, y: y, width: size, height: size),
+ from: .zero, operation: .sourceOver, fraction: 1
+ )
+ x += size + 34
+ }
+ let labelColor = bgIndex == 0 ? color(0x30323C) : color(0xC7CAD6)
+ let label = NSAttributedString(string: title, attributes: [
+ .font: NSFont.systemFont(ofSize: 26, weight: .semibold),
+ .foregroundColor: labelColor
+ ])
+ label.draw(at: NSPoint(x: originX, y: baseY + 340))
+ }
+ }
+ NSGraphicsContext.restoreGraphicsState()
+ return rep
+}
+
+let sheet = contactSheet(icons: [("Variante A - Panel als Diagramm", iconA), ("Variante B - ruhig", iconB)])
+writePNG(sheet, "icon-preview.png")
diff --git a/scripts/package_app.sh b/scripts/package_app.sh
index db8f0f2..237c8bc 100644
--- a/scripts/package_app.sh
+++ b/scripts/package_app.sh
@@ -10,16 +10,73 @@ RESOURCES="$CONTENTS/Resources"
rm -rf "$APP"
mkdir -p "$MACOS"
mkdir -p "$RESOURCES"
+BIN="${SOLIXBAR_BIN:-$ROOT/.build/debug/SolixBar}"
cp "$ROOT/Bundle/Info.plist" "$CONTENTS/Info.plist"
-cp "$ROOT/.build/debug/SolixBar" "$MACOS/SolixBar"
+# Version aus der VERSION-Datei injizieren — einzige Quelle, damit die
+# Anzeige in App/Einstellungen bei jedem Release automatisch stimmt.
+if [ -f "$ROOT/VERSION" ]; then
+ VERSION="$(tr -d '[:space:]' < "$ROOT/VERSION")"
+ BUILD_NUMBER="$(git -C "$ROOT" rev-list --count HEAD 2>/dev/null || echo 1)"
+ /usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" "$CONTENTS/Info.plist"
+ /usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_NUMBER" "$CONTENTS/Info.plist"
+fi
+cp "$BIN" "$MACOS/SolixBar"
+# Debug-Map entfernen: die Symboltabelle enthält sonst absolute
+# Quellpfade des Build-Rechners (auch im Release-Build).
+strip -S "$MACOS/SolixBar" 2>/dev/null || true
if [ -f "$ROOT/Assets/SolixBar.icns" ]; then
cp "$ROOT/Assets/SolixBar.icns" "$RESOURCES/SolixBar.icns"
fi
if [ -f "$ROOT/Assets/SolixBar.png" ]; then
cp "$ROOT/Assets/SolixBar.png" "$RESOURCES/SolixBar.png"
fi
+cp "$ROOT/scripts/run_solix_snapshot.sh" "$RESOURCES/run_solix_snapshot.sh"
+cp "$ROOT/scripts/solix_snapshot.py" "$RESOURCES/solix_snapshot.py"
+chmod +x "$RESOURCES/run_solix_snapshot.sh"
chmod +x "$MACOS/SolixBar"
+
+# Portable SOLIX-Laufzeit einbetten (falls mit prepare_solix_runtime.sh
+# vorbereitet): der direkte SOLIX-Modus funktioniert dann ohne lokale
+# Python-Installation. Ohne Laufzeit entsteht ein Bundle ohne diesen Modus.
+PYTHON_ROOT="$ROOT/work/python"
+SITE_PACKAGES="$ROOT/work/solix-venv312/lib/python3.12/site-packages"
+if [ -x "$PYTHON_ROOT/bin/python3.12" ] && [ -d "$SITE_PACKAGES/anker_solix_api" ]; then
+ cp -R "$PYTHON_ROOT" "$RESOURCES/python"
+ cp -R "$SITE_PACKAGES" "$RESOURCES/site-packages"
+ # Nicht benötigte Teile entfernen (Header, Tk/Tcl, IDLE, Tests, pip):
+ # das spart rund die Hälfte der Laufzeitgrösse.
+ rm -rf "$RESOURCES/python/include" "$RESOURCES/python/share"
+ for extra in "$RESOURCES/python/lib"/tcl* "$RESOURCES/python/lib"/tk* \
+ "$RESOURCES/python/lib"/itcl* "$RESOURCES/python/lib"/thread* \
+ "$RESOURCES/python/lib/python3.12/tkinter" \
+ "$RESOURCES/python/lib/python3.12/idlelib" \
+ "$RESOURCES/python/lib/python3.12/turtledemo" \
+ "$RESOURCES/python/lib/python3.12/test" \
+ "$RESOURCES/python/lib/python3.12/lib-dynload/_tkinter"*.so; do
+ rm -rf "$extra"
+ done
+ find "$RESOURCES/python/bin" -mindepth 1 ! -name python3.12 -delete
+ rm -rf "$RESOURCES/site-packages/pip" "$RESOURCES/site-packages"/pip-*.dist-info
+ find "$RESOURCES/python" "$RESOURCES/site-packages" -type d -name __pycache__ -prune -exec rm -rf {} +
+ find "$RESOURCES/python" "$RESOURCES/site-packages" -type f -name '*.pyc' -delete
+ find "$RESOURCES" -type f -name .DS_Store -delete
+ # Import-Probe: schützt vor zu aggressivem Ausdünnen und kaputten Modulen.
+ PYTHONDONTWRITEBYTECODE=1 PYTHONPATH="$RESOURCES/site-packages" \
+ "$RESOURCES/python/bin/python3.12" -c 'import aiohttp, anker_solix_api' || {
+ echo "Eingebettete SOLIX-Module lassen sich nicht importieren." >&2
+ exit 1
+ }
+ find "$RESOURCES/python" "$RESOURCES/site-packages" -type d -name __pycache__ -prune -exec rm -rf {} +
+ echo "Portable SOLIX-Laufzeit eingebettet."
+else
+ echo "Hinweis: keine portable SOLIX-Laufzeit in work/ gefunden — Bundle ohne direkten SOLIX-Modus. Vorbereiten mit: sh scripts/prepare_solix_runtime.sh"
+fi
+
printf "APPL????" > "$CONTENTS/PkgInfo"
+# Ad-hoc-Signatur wie in der CI: ohne Signatur zeigt macOS keine
+# Benachrichtigungen (UNUserNotificationCenter) an.
+xattr -cr "$APP"
+codesign --force --deep -s - "$APP"
touch "$APP"
echo "$APP"
diff --git a/scripts/prepare_solix_runtime.sh b/scripts/prepare_solix_runtime.sh
new file mode 100644
index 0000000..b731e6f
--- /dev/null
+++ b/scripts/prepare_solix_runtime.sh
@@ -0,0 +1,45 @@
+#!/bin/sh
+set -eu
+
+# Lädt eine portable CPython-Laufzeit (python-build-standalone) nach
+# work/python und installiert die SOLIX-Module nach work/solix-venv312.
+# Beides bettet scripts/package_app.sh anschliessend ins App-Bundle ein,
+# damit der direkte SOLIX-Modus ohne lokale Installation funktioniert.
+#
+# Übersteuerbar:
+# SOLIXBAR_PBS_RELEASE python-build-standalone-Release-Tag
+# SOLIXBAR_PBS_ASSET Tarball-Name (Architektur/Version)
+
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+WORK="$ROOT/work"
+PYTHON_ROOT="$WORK/python"
+VENV="$WORK/solix-venv312"
+RELEASE="${SOLIXBAR_PBS_RELEASE:-20260623}"
+ASSET="${SOLIXBAR_PBS_ASSET:-cpython-3.12.13+${RELEASE}-aarch64-apple-darwin-install_only.tar.gz}"
+URL="https://github.com/astral-sh/python-build-standalone/releases/download/${RELEASE}/${ASSET}"
+
+mkdir -p "$WORK"
+
+if [ ! -x "$PYTHON_ROOT/bin/python3.12" ]; then
+ echo "Lade portable Python-Laufzeit: $ASSET"
+ TARBALL="$WORK/$ASSET"
+ curl -fsSL -o "$TARBALL" "$URL"
+ rm -rf "$PYTHON_ROOT"
+ # Das Archiv entpackt nach ./python
+ tar -xzf "$TARBALL" -C "$WORK"
+ rm -f "$TARBALL"
+fi
+"$PYTHON_ROOT/bin/python3.12" --version
+
+if [ ! -d "$VENV/lib/python3.12/site-packages/anker_solix_api" ]; then
+ echo "Installiere SOLIX-Module nach $VENV"
+ "$PYTHON_ROOT/bin/python3.12" -m venv "$VENV"
+ "$VENV/bin/pip" install --quiet --upgrade pip
+ "$VENV/bin/pip" install --quiet -r "$ROOT/requirements-solix.txt"
+fi
+
+PYTHONDONTWRITEBYTECODE=1 PYTHONPATH="$VENV/lib/python3.12/site-packages" \
+ "$PYTHON_ROOT/bin/python3.12" -c 'import aiohttp, anker_solix_api' \
+ || { echo "SOLIX-Module lassen sich nicht importieren." >&2; exit 1; }
+
+echo "SOLIX-Laufzeit bereit: $PYTHON_ROOT + $VENV"
diff --git a/scripts/run_solix_snapshot.sh b/scripts/run_solix_snapshot.sh
index 5b93f99..52cc186 100755
--- a/scripts/run_solix_snapshot.sh
+++ b/scripts/run_solix_snapshot.sh
@@ -1,10 +1,23 @@
#!/bin/zsh
set -euo pipefail
-ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
+# Laeuft sowohl aus dem Repo (scripts/) als auch aus dem App-Bundle
+# (Contents/Resources/). Pfade sind deshalb relativ zum Script bzw.
+# per Umgebungsvariable uebersteuerbar:
+# SOLIXBAR_ENV_FILE Pfad zur Env-Datei (Default: /work/solixbar.env)
+# SOLIXBAR_PYTHON Python-Interpreter (Default: Repo-venv, sonst python3)
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
ENV_FILE="${SOLIXBAR_ENV_FILE:-$ROOT_DIR/work/solixbar.env}"
-PYTHON="$ROOT_DIR/work/solix-venv312/bin/python"
-SNAPSHOT_SCRIPT="$ROOT_DIR/scripts/solix_snapshot.py"
+SNAPSHOT_SCRIPT="$SCRIPT_DIR/solix_snapshot.py"
+
+if [[ -n "${SOLIXBAR_PYTHON:-}" ]]; then
+ PYTHON="$SOLIXBAR_PYTHON"
+elif [[ -x "$ROOT_DIR/work/solix-venv312/bin/python" ]]; then
+ PYTHON="$ROOT_DIR/work/solix-venv312/bin/python"
+else
+ PYTHON="$(command -v python3)"
+fi
if [[ -f "$ENV_FILE" ]]; then
set -a
@@ -12,6 +25,11 @@ if [[ -f "$ENV_FILE" ]]; then
set +a
fi
+# Ertragszustand und API-Cache neben die Env-Datei legen — nie ins
+# (signierte) App-Bundle, aus dem das Script laufen kann.
+export SOLIXBAR_STATE_PATH="${SOLIXBAR_STATE_PATH:-$(dirname "$ENV_FILE")/energy.json}"
+export SOLIXBAR_CACHE_PATH="${SOLIXBAR_CACHE_PATH:-$(dirname "$ENV_FILE")/api-cache.json}"
+
: "${ANKER_SOLIX_USER:?ANKER_SOLIX_USER fehlt in $ENV_FILE}"
: "${ANKER_SOLIX_PASSWORD:?ANKER_SOLIX_PASSWORD fehlt in $ENV_FILE}"
: "${ANKER_SOLIX_COUNTRY:=DE}"
diff --git a/scripts/solix_snapshot.py b/scripts/solix_snapshot.py
index 8e5225a..edd3d86 100644
--- a/scripts/solix_snapshot.py
+++ b/scripts/solix_snapshot.py
@@ -7,9 +7,12 @@
- ANKER_SOLIX_USER, ANKER_SOLIX_PASSWORD and ANKER_SOLIX_COUNTRY env vars
"""
+import argparse
import asyncio
import json
import os
+import sys
+import time
from datetime import datetime, timezone
from pathlib import Path
@@ -86,6 +89,23 @@ def _first_dict(*values):
return {}
+def _pv_channel_watts(solarbank, first_solarbank):
+ """Leistung je MPPT-Eingang (solar_power_1..4), sofern das Modell sie meldet.
+
+ Solarbank 2 Pro / Solarbank 3 melden 4 Kanäle, Solarbank 2 Plus/AC 2,
+ die erste Solarbank-Generation keine. Ohne Kanal-Reporting: None.
+ (Feldnamen laut anker-solix-api; mangels Hardware unverifiziert.)
+ """
+ channels = []
+ for index in range(1, 5):
+ key = f"solar_power_{index}"
+ value = _as_int(solarbank.get(key), first_solarbank.get(key))
+ if value is None:
+ break
+ channels.append(value)
+ return channels or None
+
+
def _first_solarbank(devices):
return next(
(
@@ -106,9 +126,54 @@ def _energy_total(statistics, stat_type="1"):
def _state_path():
+ configured = os.environ.get("SOLIXBAR_STATE_PATH")
+ if configured:
+ return Path(configured)
return Path(__file__).resolve().parents[1] / "work" / "solixbar-energy.json"
+def _cache_path():
+ configured = os.environ.get("SOLIXBAR_CACHE_PATH")
+ if configured:
+ return Path(configured)
+ return Path(__file__).resolve().parents[1] / "work" / "solixbar-api-cache.json"
+
+
+def _write_private_json(path, value):
+ """Atomar schreiben mit 0600: Zustand/Cache liegen im Klartext auf Platte."""
+ path.parent.mkdir(parents=True, exist_ok=True)
+ temporary = path.with_name(f".{path.name}.tmp")
+ temporary.write_text(json.dumps(value, separators=(",", ":")), encoding="utf-8")
+ temporary.chmod(0o600)
+ temporary.replace(path)
+
+
+def _load_cache():
+ try:
+ return json.loads(_cache_path().read_text(encoding="utf-8"))
+ except Exception:
+ return {}
+
+
+def _save_cache(cache):
+ _write_private_json(_cache_path(), cache)
+
+
+def _fresh_cached_value(cache, key, max_age_seconds):
+ item = cache.get(key)
+ if not isinstance(item, dict):
+ return None
+ timestamp = _first_number(item.get("timestamp"))
+ if timestamp is None or time.time() - timestamp > max_age_seconds:
+ return None
+ return _first_number(item.get("value"))
+
+
+def _store_cached_value(cache, key, value):
+ if value is not None:
+ cache[key] = {"timestamp": time.time(), "value": value}
+
+
def _load_energy_state():
try:
return json.loads(_state_path().read_text(encoding="utf-8"))
@@ -117,9 +182,7 @@ def _load_energy_state():
def _save_energy_state(state):
- path = _state_path()
- path.parent.mkdir(parents=True, exist_ok=True)
- path.write_text(json.dumps(state, separators=(",", ":")), encoding="utf-8")
+ _write_private_json(_state_path(), state)
def _local_energy_totals(solar_watts, now):
@@ -174,7 +237,30 @@ def _local_energy_totals(solar_watts, now):
return current_today, current_total, has_manual_total
+def _load_stdin_configuration():
+ """Zugangsdaten per stdin-JSON statt Umgebungsvariablen.
+
+ Umgebungsvariablen sind für gleichnamige Nutzerprozesse via `ps e`
+ sichtbar; die App übergibt Mail/Passwort daher als eine JSON-Zeile.
+ """
+ parser = argparse.ArgumentParser(add_help=False)
+ parser.add_argument("--stdin-config", action="store_true")
+ args, _ = parser.parse_known_args()
+ if not args.stdin_config:
+ return
+ request = json.loads(sys.stdin.readline())
+ os.environ["ANKER_SOLIX_USER"] = str(request.get("email") or "")
+ os.environ["ANKER_SOLIX_PASSWORD"] = str(request.get("password") or "")
+ os.environ["ANKER_SOLIX_COUNTRY"] = str(request.get("country") or "DE")
+ if request.get("todayBaseKWh") is not None:
+ os.environ["SOLIXBAR_TODAY_KWH_BASE"] = str(request["todayBaseKWh"])
+ os.environ["SOLIXBAR_TODAY_KWH_DATE"] = datetime.now().astimezone().date().isoformat()
+ if request.get("totalBaseKWh") is not None:
+ os.environ["SOLIXBAR_TOTAL_KWH_BASE"] = str(request["totalBaseKWh"])
+
+
async def main():
+ _load_stdin_configuration()
user = os.environ["ANKER_SOLIX_USER"]
password = os.environ["ANKER_SOLIX_PASSWORD"]
country = os.environ.get("ANKER_SOLIX_COUNTRY", "DE")
@@ -184,9 +270,16 @@ async def main():
await client.update_sites()
await client.update_device_details()
- today_kwh = None
+ cache = _load_cache()
site = next(iter(client.sites.values()), {})
site_id = next(iter(client.sites.keys()), "")
+ cache_namespace = site_id or "default-site"
+ today_key = datetime.now().astimezone().date().isoformat()
+ today_cache_key = f"{cache_namespace}:todayKWh:{today_key}"
+ statistics_cache_key = f"{cache_namespace}:statisticsTotalKWh"
+ # Teure Statistik-Abfragen drosseln: Tages-kWh 10 min, Gesamt 15 min.
+ # Die Live-Leistungswerte kommen weiterhin bei jedem Abruf frisch.
+ today_kwh = _fresh_cached_value(cache, today_cache_key, 10 * 60)
devices = list(client.devices.values())
solarbank = _first_solarbank(devices)
device_sn = solarbank.get("device_sn") or ""
@@ -195,30 +288,35 @@ async def main():
solarbank_list = solarbank_info.get("solarbank_list") or []
first_solarbank = solarbank_list[0] if solarbank_list else {}
- try:
- energy = await client.energy_daily(
- siteId=site_id,
- deviceSn=device_sn,
- startDay=datetime.today(),
- numDays=1,
- dayTotals=True,
- devTypes={"solarbank"},
- )
- today = energy.get(datetime.today().strftime("%Y-%m-%d"), {})
- today_kwh = _first_number(today.get("solar_production"))
- except Exception:
- today_kwh = None
-
- api_statistics_total = None
- try:
- energy_stats = await client.energy_statistics(
- siteId=site_id,
- rangeType="year",
- sourceType="solar",
- )
- api_statistics_total = _energy_total(energy_stats.get("statistics"))
- except Exception:
- api_statistics_total = None
+ if today_kwh is None:
+ try:
+ energy = await client.energy_daily(
+ siteId=site_id,
+ deviceSn=device_sn,
+ startDay=datetime.today(),
+ numDays=1,
+ dayTotals=True,
+ devTypes={"solarbank"},
+ )
+ today = energy.get(datetime.today().strftime("%Y-%m-%d"), {})
+ today_kwh = _first_number(today.get("solar_production"))
+ _store_cached_value(cache, today_cache_key, today_kwh)
+ except Exception:
+ today_kwh = None
+
+ api_statistics_total = _fresh_cached_value(cache, statistics_cache_key, 15 * 60)
+ if api_statistics_total is None:
+ try:
+ energy_stats = await client.energy_statistics(
+ siteId=site_id,
+ rangeType="year",
+ sourceType="solar",
+ )
+ api_statistics_total = _energy_total(energy_stats.get("statistics"))
+ _store_cached_value(cache, statistics_cache_key, api_statistics_total)
+ except Exception:
+ api_statistics_total = None
+ _save_cache(cache)
battery_watts = _signed_battery_watts(solarbank_info, solarbank, first_solarbank)
now = datetime.now(timezone.utc)
@@ -273,6 +371,10 @@ async def main():
"updatedAt": now.isoformat(),
}
+ pv_watts = _pv_channel_watts(solarbank, first_solarbank)
+ if pv_watts is not None:
+ snapshot["pvWatts"] = pv_watts
+
print(json.dumps(snapshot, separators=(",", ":")))
diff --git a/scripts/verify_release.sh b/scripts/verify_release.sh
new file mode 100644
index 0000000..a2c5fa1
--- /dev/null
+++ b/scripts/verify_release.sh
@@ -0,0 +1,40 @@
+#!/bin/sh
+set -eu
+
+# Prüft das gepackte Bundle vor dem Release: Version, Signatur, keine
+# privaten Daten, importierbare SOLIX-Module (falls Laufzeit eingebettet).
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+APP="${1:-$ROOT/outputs/SolixBar.app}"
+PLIST="$APP/Contents/Info.plist"
+RESOURCES="$APP/Contents/Resources"
+
+test -d "$APP"
+VERSION="$(tr -d '[:space:]' < "$ROOT/VERSION")"
+PLIST_VERSION="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$PLIST")"
+if [ "$PLIST_VERSION" != "$VERSION" ]; then
+ echo "Version passt nicht: VERSION=$VERSION Info.plist=$PLIST_VERSION" >&2
+ exit 1
+fi
+
+codesign --verify --deep --strict "$APP"
+
+if find "$APP" -type f \( -name '*.env' -o -name 'energy.json' -o -name 'api-cache.json' \
+ -o -name 'solixbar-energy.json' -o -name 'solixbar-api-cache.json' \) | grep -q .; then
+ echo "Private Laufzeitdaten im App-Bundle gefunden." >&2
+ exit 1
+fi
+if grep -rl "/Users/Basti\|/Users/holger" "$APP" >/dev/null 2>&1; then
+ echo "Persönlicher Entwicklungspfad im App-Bundle gefunden." >&2
+ echo "(Debug-Binaries enthalten Quellpfade — Release-Binary paketieren: SOLIXBAR_BIN=.build/release/SolixBar)" >&2
+ exit 1
+fi
+
+if [ -x "$RESOURCES/python/bin/python3.12" ]; then
+ PYTHONDONTWRITEBYTECODE=1 PYTHONPATH="$RESOURCES/site-packages" \
+ "$RESOURCES/python/bin/python3.12" -c 'import aiohttp, anker_solix_api'
+ echo "SOLIX-Laufzeit im Bundle geprüft."
+else
+ echo "Hinweis: Bundle ohne eingebettete SOLIX-Laufzeit."
+fi
+
+echo "SolixBar $VERSION Bundle geprüft."