Skip to content
muhammadmobiPublic

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

Β 

History

139 Commits

Folders and files

Repository files navigation

SolarLens

One screen for all your inverters. SolarLens polls SolisCloud and SolarMan, stores every reading in your own database, and shows both systems side by side β€” live power, today / month / year / lifetime energy, grid import & export, battery state β€” on a page you can open from any device. It runs entirely on Cloudflare's free tier (Workers + D1) or locally.

  • Two vendors, one model. SolisCloud and SolarMan are normalised into the same reading shape, so the UI never cares where a number came from.
  • Your data, kept. Every sample is stored (with the untouched vendor payload), so you get history the vendor apps don't let you keep or export.
  • Works with whatever access you have. Official API keys are best; a browser-session fallback for SolarMan and a local relay agent for SolisCloud cover you while keys are pending.
  • Honest about freshness. Every panel shows when it was last updated. A system reads offline the moment either the vendor says so or nothing has arrived for 25 minutes β€” and an offline system's live figures are zero, not whatever it managed just before it dropped.
  • Reads well in either theme. A three-state toggle in the top-right corner follows your system, or forces light or dark; the choice is remembered and applied before first paint.
  • Labelled, not cryptic. Every headline figure says what it is and what it covers β€” "Producing now", "Produced today", "Consumed today" β€” and each system's live output is set against its rated size.
  • Open it and it is there. No login, no token to copy onto each device β€” the readings are public by design, with vendor identifiers stripped from every response before it leaves the Worker.
  • Each system's day ends where its own sun sets. Every plant's timezone is stored, and every figure, curve and daily row is cut at that plant's midnight rather than at the reader's β€” so two systems in different countries are each shown their own day, on the same screen.
  • Fault history, with what the vendor advises. Every alarm each vendor has on record, back to installation: when, how severe, the fault code, how long it lasted, and the vendor's own advice where it gives any. SolarMan's alert list says only when a fault was raised; its timeline, read for the newest alerts each hour, supplies when each one cleared, and the occurrences the list folds into one entry per day.
  • Tells your phone, even with the dashboard closed. Turn it on once per device on the Alerts tab, and that device is notified when a system stops reporting while it was producing, when a fault is recorded, when a SolisCloud login is about to run out, or when a vendor stops answering. A system going quiet at dusk is not news, so it is not announced. While a tab is open, a second switch announces every change the Alerts tab shows.
  • Never stuck on an old version. A tab left open across a release offers a Reload when a new version is out; a TV display reloads itself.
  • A guide built in. The Guide button - an open book - at the top of every page opens a guide to the app: what SolarLens is, what each tab holds, a "where do I find…" table, what is new in this release, and the few things worth knowing - such as an on-grid system reading offline every night. It opens even before the data has loaded.
  • Made for a wall, too. TV mode at #/tv drops the header and tabs, sizes everything to the screen, keeps the display awake, and shows a clock so a frozen page is obvious from across a room.
  • Each system on its own, or all together. Historical Data shows every system one after the other, or - with the switch at the top - just one, with its chart, table and CSV.
  • History that goes back to the start. Days, months or years. Months and years use each vendor's own totals, which reach back to the day the plant was installed, and every row says whether its figure is the vendor's or SolarLens's own and how many of its days SolarLens saw.
  • Installable. Add it to a phone's home screen and it opens in its own window. The worker behind that goes to the network first and falls back to a cache only when there is none, so an installed copy can never show a stale reading as a live one.
  • Tested. Unit tests for every normaliser and unit conversion; Playwright end-to-end tests for the dashboard on desktop and mobile.

Not affiliated with Ginlong/Solis or IGEN Tech/SolarMan.


Contents

  1. How it works
  2. Data sources and how each authenticates
  3. Quick start (β‰ˆ10 minutes)
  4. Getting credentials β€” SolisCloud Β· SolarMan Β· SolarMan fallback
  5. SolisCloud relay agent β€” one command on Windows Β· more than one machine Β· replacing a token
  6. Notifications on your phone
  7. Configuration reference
  8. Local development
  9. Testing β€” what runs on every pull request Β· where the reports are
  10. Data model
  11. HTTP API
  12. Project layout
  13. Troubleshooting
  14. Security and privacy
  15. Changelog Β· Handover
  16. Roadmap Β· Contributing Β· License

How it works

flowchart LR
  subgraph vendors [Vendor clouds]
    SC[SolisCloud API]
    SM[SolarMan API / portal]
  end
  subgraph cf [Cloudflare free tier]
    CRON[Cron trigger every 5 min] --> POLL[Worker: poll]
    POLL -->|normalise| DB[(D1 SQLite)]
    API[Worker: /api/*] --> DB
    UI[Static dashboard] --> API
  end
  subgraph home [Your machine, optional]
    RELAY[Solis relay agent<br/>Chrome + your login] -->|/api/ingest/station| API
    MODBUS[Local Modbus agent<br/>roadmap] -.->|/api/ingest| API
  end
  SC --> POLL
  SM --> POLL
  Browser((You)) --> UI
Loading

A single Cloudflare Worker does three jobs:

  1. Poller β€” on a cron tick it asks each configured provider for its plants, then for each plant's live snapshot, normalises the vendor payload into one Reading, and inserts it (idempotently) into D1.

  2. API β€” a few JSON endpoints over D1: latest reading per inverter, a time series for charts, poll health, and push endpoints for local agents.

  3. Static UI β€” a dependency-free, hash-routed HTML page served from the same Worker. Five tabs, plus a per-system page they all link into:

    • Overview (#/) β€” one column per system, sized to fit a laptop screen without scrolling: the energy-flow diagram, then that system's figures as tiles, then its day curve. Producing now, house load, grid direction and battery charge live in the diagram and are not repeated as figures. Model and datalogger signal are not here at all β€” they never change, so they sit on Devices. Anything above the curve opens that system's detail page; the curve opens Power. Below 1080px wide the columns stack, and below 660px tall the page scrolls, because two systems will not fit on a phone and a very short window cannot hold a diagram, twelve figures and a readable curve at once.
    • Power (#/power) β€” the combined day curve (click a name in the legend to show or hide that line), then each system in a collapsible section carrying its full detail set: identity, datalogger, live power, counters, PV strings, per-phase AC, battery, diagnostics and raw telemetry (built, but not shown on the live site yet: the payload it reads is withheld from public answers - docs/feature-gaps.md gap 5).
    • Historical Data (#/history) β€” day by day per system: produced, consumed, imported, exported, battery in and out, peak and sample count, with a bar per day. Columns appear only where that system measures the quantity, and the page says plainly that the record begins when SolarLens started collecting rather than when the array was installed.
    • Alerts (#/alerts) β€” everything either cloud says is wrong, one collapsible section per system. Nothing is invented: each row names the field it came from, so an empty section reads as "both vendors report normal" rather than "nobody looked". The tab carries a count badge.
    • Devices (#/devices) β€” hardware inventory: inverters and dataloggers with serial, model, firmware, rated power, signal strength and last contact.
    • System detail (#/system/<id>) β€” identity and hardware, datalogger and link, live power, energy counters, per-MPPT-string PV power, battery (hybrid only), diagnostics, and a searchable raw-telemetry table (not shown on the live site yet - gap 5).

    There is no Energy flow tab: the diagrams lead the overview instead, and an old #/flow bookmark lands there.

Every provider is an adapter behind one interface (listPlants β†’ listInverters β†’ getReading). A provider is active purely when its secrets are present, so the same deploy works with one vendor today and both tomorrow.

Data sources and how each authenticates

Route Vendor How it authenticates Stability When to use
Official API SolisCloud HMAC-SHA1-signed requests with KeyId/KeySecret Documented, stable Always, once Solis enables API access on your account
Official Business API SolarMan appId/appSecret + email + sha256(password) β†’ bearer token (~2 months, auto-renewed) Documented, stable Always, once SolarMan issues your keys
Web-session fallback SolarMan Refresh token copied once from your browser; the Worker renews the 24 h access token itself Unofficial; works until you log out or the portal changes While waiting for keys
Relay agent SolisCloud Your logged-in Chrome session on your machine; the real portal makes the calls, the agent relays the responses Unofficial; robust to portal releases, needs your PC on While waiting for API access
Local Modbus either Direct LAN read of the datalogger Planned Second-by-second data, cloud-independent

Why the difference between the two fallbacks: SolarMan's portal uses a plain bearer token that can be replayed from anywhere. SolisCloud's portal signs every call with a secret hidden in its JavaScript, so a copied token cannot be reused β€” the relay agent lets the portal itself do the signing instead. Details in docs/api-notes.md.

Quick start (β‰ˆ10 minutes)

Prerequisites: Node.js 22 or newer, a free Cloudflare account, and Git. .nvmrc pins 22, the version the automated checks use; newer versions are fine to develop on.

git clone https://github.com/<you>/SolarLens.git
cd SolarLens
npm install

1. Log in to Cloudflare and create the database

npx wrangler login
npx wrangler d1 create solar-lens

That prints a database_id. It is not a credential β€” nobody can touch the database without your Cloudflare login β€” but it identifies your account, so this repository keeps it out of version control. Put it in .dev.vars instead, which is gitignored:

cp .dev.vars.example .dev.vars

and set the line:

CF_D1_DATABASE_ID=<the id wrangler just printed>

wrangler.jsonc carries the placeholder ${CF_D1_DATABASE_ID}, and scripts/wrangler.mjs substitutes your real id into a temporary, gitignored copy of the config each time you run a command. Because of that, use the npm scripts rather than npx wrangler directly β€” npm run cf -- <anything> passes any wrangler command through the wrapper:

npm run db:remote                 # apply the schema to the deployed database
npm run cf -- d1 info solar-lens  # the general escape hatch

2. Set your secrets β€” each command prompts for the value; nothing is stored in the repo.

npm run cf -- secret put API_TOKEN       # gates the routes that write or spend quota
npm run cf -- secret put INGEST_TOKEN    # gates the push endpoints used by local agents

Generate strong tokens with:

node -e "console.log(require('crypto').randomBytes(24).toString('base64url'))"

Keep both somewhere you can find them again β€” a password manager, not a file on the machine. Cloudflare will never show a secret back to you, so a lost token can only be replaced, not recovered (see Replacing a token). Put the same values in .dev.vars as well: the relay agent and npm run dev read them from there.

Then add whichever provider credentials you have (see Getting credentials):

npm run cf -- secret put SOLIS_KEY_ID
npm run cf -- secret put SOLIS_KEY_SECRET
# and/or
npm run cf -- secret put SOLARMAN_APP_ID
npm run cf -- secret put SOLARMAN_APP_SECRET
npm run cf -- secret put SOLARMAN_EMAIL
npm run cf -- secret put SOLARMAN_PASSWORD_SHA256
# or the SolarMan browser-session fallback
npm run cf -- secret put SOLARMAN_WEB_REFRESH_TOKEN

If you have neither vendor's API keys yet, that is the normal starting point β€” both are approvals you have to request. Skip to Getting credentials for how to ask, and use the SolarMan browser-session fallback and the SolisCloud relay agent to have real data on screen the same day.

3. Deploy

npm run deploy

This is the first deploy, from your own machine. Afterwards deploys happen through the pipeline: merging to main runs the checks, waits for a person to approve on GitHub, applies migrations, deploys and checks the live site - see what happens after a merge.

The first deploy asks you to register a workers.dev subdomain (a one-time name for your account); pick one and run the deploy again. It prints your URL, e.g. https://solar-lens.<your-subdomain>.workers.dev.

4. Open the dashboard

Open https://solar-lens.<your-subdomain>.workers.dev. That is all β€” on any phone, tablet or laptop, with nothing to copy first. Readings are public by design, with vendor identifiers stripped from every response; see Reads are public; writes are not for exactly what that publishes and how to put a login in front of it if your site needs one.

The first data arrives on the next 5-minute cron tick, or immediately with:

curl -X POST -H "Authorization: Bearer <API_TOKEN>" https://solar-lens.<your-subdomain>.workers.dev/api/poll

Windows PowerShell: && is not a statement separator there β€” run chained commands on separate lines.

Getting credentials

SolisCloud (official API key)

  1. In the SolisCloud web portal, check avatar β†’ Basic Settings for an API Management section. If it is there: Activate Now β†’ solve the puzzle β†’ enter the emailed code β†’ copy KeyId and KeySecret.
  2. If it is not there, API access is not yet enabled on your account. Open the Solis Service Center β†’ Submit a ticket, choose the Service Support Ticket form and fill it with:
    • Product Type Monitoring Platform, Product Name Solis Cloud Web
    • Tickets Type API Request - System Owner (this is the "API Access Request" the guide refers to)
    • your plant ID, country, and your SolisCloud login email as the API Account Email Address
    • a short description: read-only monitoring for a personal dashboard, system owner, no remote control needed.
  3. Once approved, API Management appears under Basic Settings. Only end users (not installers) are eligible.

Response times vary widely β€” use the relay agent in the meantime.

SolarMan (official Business API)

Email service@solarmanpv.com asking for Business API appId/appSecret. Include your SolarMan login email, your station ID, that you are the system owner (not an installer/distributor), that you need read-only monitoring for a personal dashboard, and expected usage (one request every ~5 minutes). Replies usually take a day or two.

SOLARMAN_PASSWORD_SHA256 is the SHA-256 hex of your SolarMan password:

node -e "console.log(require('crypto').createHash('sha256').update(process.argv[1]).digest('hex'))" 'your-password'

SolarMan (browser-session fallback)

  1. Log in at https://home.solarmanpv.com in Chrome.
  2. Press F12 β†’ Application β†’ Cookies β†’ https://home.solarmanpv.com.
  3. Two cookies hold JWTs (eyJ…). The persistent one (expiry months away, ~940 bytes) is the refresh token β€” copy its value into SOLARMAN_WEB_REFRESH_TOKEN. The Session one (~880 bytes) is the access token; optionally copy it into SOLARMAN_WEB_ACCESS_TOKEN so the very first poll needs no refresh.
  4. Do not log out of SolarMan in that browser β€” logging out revokes the tokens.

Password login is deliberately not automated: the portal requires a Cloudflare Turnstile token with every password grant, and that is a human step.

SolisCloud relay agent

SolarMan reaches the Worker on its own: its portal hands out an ordinary bearer token that can be replayed from anywhere, so Cloudflare talks to SolarMan directly and nothing of yours has to be running. SolisCloud cannot work that way. Its portal signs every request with a secret buried in its own JavaScript, so a copied token is worthless off the page that made it. Until Solis approves an API key for your account, the way to get Solis data is to let the real portal make the calls in a real browser and forward what comes back.

That is the relay agent: a small script that drives a logged-in Chrome on a machine of yours and POSTs each response to /api/ingest/station. It only produces Solis data while that machine is awake. SolarMan keeps updating regardless.

One command on Windows

Double-click setup-relay.cmd, or run it from a terminal:

setup-relay.cmd

It installs anything missing (Node, Git, Chrome, via winget), clones the repository if it is not already there, asks for your Worker URL and INGEST_TOKEN, walks you through one SolisCloud login, and registers a scheduled task so the relay starts itself at every logon and keeps running after you close the terminal.

It is safe to run twice, and a second run is how you update a machine: it pulls, checks the saved login, and re-registers the task. Anything already in place is skipped - Node, Git and Chrome that are installed, dependencies that have not changed - and the login is checked in a hidden browser, so a machine that needs nothing shows no window at all. A Chrome window opens only when SolisCloud wants someone to log in. The terminal closes by itself when setup worked and stays open when it did not, so the reason can be read. A relay task someone disabled is left disabled. Two details it gets right that are easy to get wrong by hand:

  • No console window. The task starts scripts\relay-hidden.vbs, not node.exe directly. node is a console application, so running it from a task puts a black terminal on screen at every logon and leaves it there. Task Scheduler's Hidden setting does not help β€” that hides the task from the Task Scheduler list β€” and the S4U principal that would needs an elevated prompt this installer deliberately does not ask for.
  • The account name comes from Windows, via WindowsIdentity::GetCurrent().Name, rather than being assembled from COMPUTERNAME and USERNAME. On a domain or Entra-joined machine the user resolves as DOMAIN\name or AzureAD\name, and the composed version maps to no SID at all β€” registration fails with No mapping between account names and security IDs was done.

Useful switches:

Switch Effect
-InstallDir <path> Where to put the checkout (default %USERPROFILE%\SolarLens)
-WorkerUrl, -IngestToken, -PlantIds Answer the prompts up front
-NoTask Set everything up but do not register the scheduled task
-UseMyChrome Attach to the Chrome you already have open instead of running a second one β€” see below
-DebugPort <n> Which port -UseMyChrome connects on (default 9222)

On macOS or Linux, or by hand

git clone https://github.com/<you>/SolarLens.git && cd SolarLens && npm install
cp .dev.vars.example .dev.vars      # set SOLARLENS_URL and INGEST_TOKEN
npm run relay:solis

The first run opens a Chrome window on the SolisCloud login page. Sign in once; the session is kept in ./.relay-profile (gitignored), so later runs need no interaction. Then set RELAY_HEADLESS=1 in .dev.vars and it runs invisibly. To keep it alive: pm2 start agent/solis-relay.mjs --name solis-relay, or a systemd --user unit, or Windows Task Scheduler if you skipped the installer.

How it behaves

  • Every 5 minutes (RELAY_INTERVAL_MIN) it opens each plant page, waits for the portal's own detailMix response, and POSTs it. The Worker normalises it with the same code path as the cloud poller, so field mapping and sign conventions can never drift between the two routes.
  • Set SOLIS_PLANT_IDS to limit it to specific plants; otherwise it relays every plant the account can see, including plants shared into it by someone else.
  • Readings arrive tagged source: soliscloud-relay; the dashboard shows "via soliscloud-relay" under the panel.
  • It reads .dev.vars itself, and clears its own stale Chrome profile lock if a previous run was killed.
  • It retries a failed browser launch twice with a short backoff, and clears the Singleton* files Chrome leaves when a machine is shut down under it β€” but only when nothing holds the profile, because a live Chrome owns those files. Without this, the first cycle after a restart fails and Solis loses a whole interval: Chrome starts, exits before Playwright can speak to it, and the error is not the one a message-matching retry would recognise.
  • Hourly it also reads the plant's alarm history, and daily the vendor's own period totals, by setting the alarm page's Status filter to Recovered and pressing Month, Lifetime and Year on the plant's chart - again waiting for the portal's own signed responses. The first cycle after the relay starts reads every page of alarms and steps back through every year of totals, which adds about a minute to that one cycle; later cycles read only the newest page and the current year. Neither step can cost the live reading: both run last and a failure is logged and skipped.
  • After every cycle it reports whether its SolisCloud login works, and when that login runs out. A SolisCloud web login lasts exactly seven days and using it does not extend it, and the login page carries hCaptcha, so a relay cannot renew its own login: once a week, someone logs in again. The relay reads the expiry from the portal's own login cookie, so the date is exact, and the dashboard warns two days ahead on the Alerts tab and lists every relay's expiry on the Devices tab. Each relay is named by RELAY_NAME if you set one, such as Office laptop, or else Relay 1, Relay 2. It identifies itself to the Worker with a random id it keeps beside its browser profile, never the computer's name, and that id never appears on the dashboard.
  • To renew a login, double-click renew-solis-login.cmd in the SolarLens folder on that computer. It stops the hidden relay, updates the code, checks the saved login in a hidden browser, and starts the hidden relay again. If the login still works, a reading goes through and nothing appears on screen. Only when SolisCloud wants a login does a Chrome window open, and it closes itself once a reading has been sent. Re-running setup-relay.cmd does the same check.
  • A cycle that delivers no reading counts as failed. The portal decides whether a login is needed only after its own scripts run: with no login, the plant page stays put for about three seconds, then moves to the login page. The relay now watches until the plant list loads or the login page appears, rather than looking once at three seconds, which could call a missing login fine and report success with nothing sent.
  • The background relay is always hidden. scripts\relay-hidden.vbs sets RELAY_HEADLESS=1 for the process it starts, which beats anything in .dev.vars, so a 0 left there by a login done by hand can never put a Chrome window on screen at every logon.
  • RELAY_ONCE=1 runs a single cycle and exits: 0 a reading went through, 3 SolisCloud wants a login, 1 anything else. The installer and the renewal script use it, so each step ends by itself instead of asking anyone to press Ctrl+C β€” which on Windows raises Terminate batch job (Y/N)? inside a .cmd and strands the installer half-finished. RELAY_SKIP_EXTRAS=1 leaves out the alarm history and period totals, which turns that check from about a minute into about twenty seconds; the background relay started straight afterwards reads them anyway.

Running it on more than one machine

Encouraged, and the reason the ingest endpoint is idempotent. Run the same setup on a second computer β€” a work laptop, a desktop that is on at different hours β€” with the same Worker URL and INGEST_TOKEN. A reading that arrives twice is stored once, and whichever machine is awake backfills the part of the day the others missed. Two machines with complementary schedules cover far more of the day than either alone.

setup-relay.cmd still stops three times to ask for the Worker URL, the ingest token and the plant ids, which means carrying those values across and typing a 43-character token correctly. To skip that, generate a personalised installer on the machine that already works:

powershell -NoProfile -ExecutionPolicy Bypass -File scripts\make-laptop-installer.ps1

It reads those three values out of your .dev.vars and writes setup-solarlens-relay.cmd to your Desktop (or wherever -OutFile says) with them already filled in. Copy it to the other machine and double-click it: it installs what is missing, fetches the code, configures itself, and registers the scheduled task without asking you anything.

The same file also updates a machine later. Double-clicked again, it stops the hidden relay, pulls the new code, installs any new dependencies, checks the SolisCloud login in a hidden browser - opening a window only if a login is needed - and starts the relay again. If you deleted the file, as its warning suggests, renew-solis-login.cmd in the SolarLens folder updates the code, checks the login and restarts the relay without needing the token, though it does not install new dependencies.

The one step that stays manual is the SolisCloud login, in the browser window it opens when one is needed. That is not an omission β€” the relay works by driving a logged-in browser session, and no script can type your password into a login form for you. Everything after it is automatic, and the window closes itself.

It reaches the code three ways, in order of how reliable they proved to be:

  1. A checkout already on that machine β€” used as it stands, updated with git pull when that works.
  2. git clone from github.com β€” the host that stayed up while raw.githubusercontent.com was answering 503.
  3. A single file over HTTPS from raw.githubusercontent.com β€” last resort, for a machine with no git at all. When that is the situation the message says so and names the fix, instead of blaming a busy server.

If step 1 finds a checkout that can no longer fast-forward, it is fetched and reset to origin/main. That case is not exotic: a checkout left on a branch that was later squash-merged, or holding any commit that never reached main in that form, has diverged from the published branch and can never fast-forward to it. A checkout in that state would otherwise sit on stale code forever β€” including a stale copy of the installer, which is how one machine ended up unable to deliver its own fix. The reset happens only when git status reports nothing to lose; a working tree with local modifications is left alone with a warning.

The generated file contains your ingest token in plain text. That is why it is written outside the repository. The token only permits pushing readings β€” it cannot read your dashboard and cannot reach your Cloudflare account β€” but carry the file on a USB stick rather than emailing it, and delete it from both machines once the setup is done.

-UseMyChrome, and why it is not the default

By default the relay runs a Chrome of its own with its own profile. That is not an oversight: Chrome refuses to let two programs share one profile directory, so the agent genuinely cannot borrow the browser you are using.

-UseMyChrome takes the other route β€” it attaches over the DevTools protocol to a Chrome you started yourself, and uses the SolisCloud login already in it. The cost is real and worth understanding before choosing it:

  • Chrome only accepts that connection if it was started with --remote-debugging-port=9222. The flag cannot be switched on afterwards, so you must close every Chrome window and relaunch it that way.
  • While that port is open, any program on the machine can drive your browser and everything it is signed in to.
  • The relay stops whenever you close Chrome.

The separate hidden browser has none of those drawbacks, which is why it is the default. -UseMyChrome exists for people who would rather not have a second browser profile at all.

Replacing a token

Cloudflare never shows a secret back, so a token you have lost β€” or one that has leaked β€” can only be replaced. On Windows:

powershell -NoProfile -ExecutionPolicy Bypass -File scripts\rotate-tokens.ps1 -Ingest

-Ingest sets a new INGEST_TOKEN in Cloudflare, writes it to .dev.vars and restarts the relay. -Api does the same for API_TOKEN and prints the fresh /auth?t=… link β€” note that it signs out every device, since the cookie is the token. Cloudflare is updated first, so a failure leaves the old token working everywhere rather than half-changed.

Elsewhere, do the same three steps by hand: npm run cf -- secret put <NAME>, update .dev.vars, restart the relay.

Notifications on your phone

The Alerts tab has two switches. Tell me when something changes works while the dashboard is open in that browser. Also when this browser is closed reaches a phone in a pocket, through the browser's own push service, and needs setting up once:

  1. Give the Worker its signing key β€” once, from the project folder on a computer with a signed-in wrangler:

    node scripts/make-vapid-key.mjs

    It makes the key and stores it as the Worker secret VAPID_KEY straight away; the private half is never printed or saved to a file. Run it again and it changes nothing; --replace makes a new key, after which every device has to turn notifications on again.

  2. On each phone or computer, open the dashboard, go to the Alerts tab and press Turn on for this device. Signing a device up is a write, so it needs the key: a device you have opened the /auth?t=<API_TOKEN> link on is already signed in, and any other asks for it once and does not keep it. On an iPhone or iPad, add the dashboard to the home screen first and turn this on from there; Safari allows web notifications only there.

A device that turns this on gets one message at once, so you can see the whole path work, and a Send a test button afterwards. Turning it off needs no key.

What is announced. Each rule is written for a phone, not a screen:

Announced When Why it is shaped so
A system stopped reporting nothing for 30 minutes after a reading that showed it producing the on-grid system goes quiet every night because its inverter sleeps; silence only matters mid-generation
A fault a vendor alarm first seen within six hours of when it began says whether it has already cleared, and the vendor's advice unless the advice is to do nothing
A SolisCloud login two days before it runs out, and when it has names the relay as the Devices tab names it, never by its id
A vendor not answering its last three polls failed, across at least fifteen minutes one failure is weather; three in a row is usually a token

Each is told once - however long it lasts - and again only if it clears and returns. The push carries no text: it only wakes the device, which then asks /api/push/recent what to show, so what a notification says never passes through Google, Apple, Mozilla or Microsoft.

Configuration reference

Secrets go in with npm run cf -- secret put NAME (production) or in .dev.vars (local, gitignored β€” copy from .dev.vars.example).

Read by the Worker, in Cloudflare:

Name Required Purpose
API_TOKEN for POST /api/poll Gates the routes that write or spend vendor quota. Reads are public by design.
INGEST_TOKEN for agents Gates /api/ingest, /api/ingest/station and /api/ingest/history.
VAPID_KEY for notifications to a closed browser The Web Push signing key, a private P-256 JWK. Made and stored by node scripts/make-vapid-key.mjs; never typed by hand. Unset, the Alerts tab says notifications to a closed browser are not set up.
SOLIS_KEY_ID, SOLIS_KEY_SECRET Solis official From SolisCloud API Management. Present = the Worker polls Solis directly and the relay becomes optional.
SOLARMAN_APP_ID, SOLARMAN_APP_SECRET, SOLARMAN_EMAIL, SOLARMAN_PASSWORD_SHA256 SolarMan official From SolarMan support + your login.
SOLARMAN_WEB_REFRESH_TOKEN, SOLARMAN_WEB_ACCESS_TOKEN SolarMan fallback Used only when the official keys are absent.
INCLUDE_PLANTS optional Comma-separated vendor plant/station ids to poll. Unset = every plant visible to the accounts, including plants shared into them.

Read locally only, from .dev.vars β€” never sent to Cloudflare:

Name Used by Purpose
CF_D1_DATABASE_ID scripts/wrangler.mjs Your D1 id, substituted into a temporary config so the real one stays out of git. Every npm run wrangler script needs it.
SOLARLENS_URL relay agent Where to POST readings, e.g. https://solar-lens.<your-subdomain>.workers.dev.
SOLIS_PLANT_IDS relay agent Which Solis plants to relay. Unset = all of them.
RELAY_HEADLESS relay agent 1 runs the relay browser invisibly. The background task always runs hidden whatever this says; the installer and renew-solis-login.cmd set it for their own checks. Only matters when running the relay by hand.
RELAY_NAME relay agent A nickname the dashboard uses for this relay, such as Office laptop. Shown publicly, so keep it vague. Unset = Relay 1, Relay 2.
RELAY_CDP relay agent Attach to an already-running Chrome, e.g. http://127.0.0.1:9222, instead of starting one. Set by setup-relay.cmd -UseMyChrome.
CAPTURE_MINUTES scripts/capture-portals.mjs How long a capture session runs (default 9); 25 gives time to log in first.
CAPTURE_CDP_PORT scripts/capture-portals.mjs Opens the capture window's debugging port, so a second script can drive the same window.
RELAY_INTERVAL_MIN relay agent Minutes between pushes (default 5).
RELAY_ONCE relay agent 1 runs one cycle and exits: 0 sent, 3 needs a login, 1 other failure. Used by the installer and renew-solis-login.cmd; not set in normal running.
RELAY_SKIP_EXTRAS relay agent 1 skips alarm history and period totals, for a quick login check. Not set in normal running.
RELAY_PROFILE relay agent Where the relay's own Chrome profile lives (default ./.relay-profile).
CHROME_PATH relay agent Explicit Chrome binary, if it is not in a standard location.

How often anything actually happens

Five intervals, easily confused:

What How often Set where
Worker polls the vendor clouds 5 min triggers.crons in wrangler.jsonc
Relay agent pushes Solis readings 5 min RELAY_INTERVAL_MIN
Alarm history is re-read (both vendors) 1 hour EXTRAS in agent/solis-relay.mjs; HOUR in src/poll.ts
Vendor period totals are re-read (both vendors) 1 day EXTRAS in agent/solis-relay.mjs; DAY in src/poll.ts
Open dashboard re-fetches 10 min, and never in a hidden tab REFRESH_MS in public/index.html

A system is drawn as offline once its newest sample is older than STALE_AFTER_S (25 min). That figure has to stay comfortably above the poll interval, or a perfectly healthy inverter reads as offline in the minutes before the next poll.

Local development

cp .dev.vars.example .dev.vars   # fill in what you have
npm run db:local                 # schema into the local D1
npm run dev                      # http://localhost:8787

npm run seed:local inserts a day of synthetic readings so the UI has something to draw without any credentials. With npm run dev running, trigger the cron handler by hand at http://localhost:8787/__scheduled, then inspect rows with:

npm run cf -- d1 execute solar-lens --local --command "SELECT * FROM readings ORDER BY ts DESC LIMIT 5"

On Windows, quoting a SQL string through the npm script can lose the quotes and turn > into a redirect. If a query behaves strangely there, put it in a .sql file and use --file, or call npx wrangler … --config .wrangler.local.jsonc directly once the wrapper has generated that file.

npm run probe:solis signs and sends a single userStationList request with the keys in .dev.vars and prints the raw response β€” the fastest way to confirm your Solis key works before it goes near the cron.

Testing

npm test                    # typecheck + unit + e2e β€” the whole gate
npm run typecheck           # tsc over src/ and tests/
npm run test:unit           # vitest
npm run test:unit:watch     # vitest, re-running as you edit
npm run test:unit:coverage  # vitest + v8 coverage, enforces thresholds
npm run test:e2e            # playwright
npm run test:e2e:ui         # playwright's inspector, for stepping through a failure

401 unit tests and 364 end-to-end tests (182 specs across a desktop and a mobile project), all runnable on a laptop with no Cloudflare account, no database and no vendor credentials.

Debugging a failing test

Each test's name is a sentence saying what it holds the code to, so a failure reads as the promise that broke. To look closer:

# One unit test file, or one test in it by part of its name
npx vitest run tests/unit/push.test.ts
npx vitest run tests/unit/push.test.ts -t "dusk"

# One end-to-end spec, in one browser project, with the browser visible
npx playwright test tests/e2e/guide.spec.ts --project=chrome --headed

# Step through it line by line, or pick tests from a window
npx playwright test tests/e2e/guide.spec.ts --project=chrome --debug
npm run test:e2e:ui

A failed end-to-end test leaves a trace in test-results/. Open it with npx playwright show-trace test-results/<test>/trace.zip: it replays the test step by step, with the page, the network and the console at every step. On CI the same traces are in the run's playwright-report artifact. For coverage, npm run test:unit:coverage writes coverage/index.html, which shows the exact lines no test reaches.

The frameworks, and why each

Layer Tool Runs against Why not the other one
Types tsc, two projects src/ and tests/ Catches shape drift before a test can even start. The specs import the Worker's own Reading and Metrics, so changing a field breaks compilation rather than one assertion in the browser.
Unit Vitest Pure modules and the vendor clients: unit scaling, both normalisers, the call queue, day-curve backfill, PII stripping, log redaction, the public-view redactor, and request signing, token refresh and error handling against a stubbed fetch Fast, no browser. These are the parts where a wrong answer is silent β€” a sign convention or a kW/W slip looks perfectly plausible on screen.
End-to-end Playwright The real public/index.html, served statically, with /api/* stubbed The UI is a single dependency-free file with no components to unit-test. What matters is what a person sees, so that is what is asserted.

Unit tests β€” tests/unit/

Twenty-five files, one concern each. Most are pure-function tests against fixtures shaped like real vendor payloads; eight drive the Worker or its database layer against a real database, through the two helpers in tests/helpers/:

  • helpers/d1.ts β€” SQLite behind the D1 interface, with the project's own migrations applied. D1 is SQLite and Node ships one, so the SQL a test exercises is the SQL that runs in production. It also refuses a bound value D1 would refuse, which is how a undefined reaches a test rather than a deploy.

  • helpers/worker.ts β€” the Worker's exported fetch and cron handlers, called with that database, a stub for the static-assets binding and whichever tokens the case is about.

  • units.test.ts β€” the paired value/unit fields the vendors use (power + powerStr), kWp/MWh scaling, numeric strings, and epoch milliseconds vs seconds. A missing unit means watts rather than an invented factor.

  • normalize.test.ts β€” both vendor normalisers end to end: SolisCloud's signed-API and relay payloads, SolarMan's station snapshot and v3/detail register categories. This is where the conventions are pinned down β€” grid_power_w positive on import, battery_power_w positive on charge, under 50 W of battery drift reading as idle, an on-grid plant getting no battery at all, and the state/status mappings for both clouds.

  • queue.test.ts β€” the rate limiter that stands between a cron run and a SolisCloud ban: calls stay in order, the minimum gap is a floor, and one failed call does not strand the ones behind it.

  • history.test.ts β€” the day-curve backfill: the shapes the chart payload has been seen in, epoch-ms/epoch-s/datetime timestamps, trailing zero padding trimmed but an interior zero kept, and rows missing either half skipped rather than guessed at.

  • logging.test.ts β€” what is cut out of a vendor error before it is persisted, and just as importantly that an ordinary log line passes through untouched.

  • clients.test.ts β€” all three vendor HTTP clients against a stubbed fetch: SolisCloud request signing, SolarMan token acquisition and refresh-and-retry, the browser-session refresh flow, the alert and period reads, error envelopes and HTTP failures.

  • public-view.test.ts β€” what a public response may carry: systems named by alias, serial numbers masked, and no vendor plant id anywhere, including inside an alarm's internal id.

  • pii.test.ts β€” what gets stripped from a stored payload and, just as important, what does not: capacity merely contains the letters of city.

  • events.test.ts β€” alarms and period totals from both vendors: severity mapping, a SolisCloud alarm record's owner fields proven dropped, SolarMan's missing end time kept missing, fault names made readable, and an unmetered plant's copied load figures refused.

  • solarman-alarm-detail.test.ts β€” SolarMan's advice and timeline read the way its own detail panel reads them: each run of five-minute samples one occurrence with an end, a recent run left active, a run into midnight left unknown, the plant's own day asked for, and only the newest few alerts detailed each hour.

  • push.test.ts β€” a real ES256 signature verified with the public key the browser is given; what is worth waking a phone for, including that a system going quiet at dusk is not; each event told once; a device the browser dropped forgotten; and the routes keeping sign-up behind the key while anyone can turn their own device off.

  • release-version.test.ts β€” the release the guide names is the one in package.json, and the changelog has a section for it.

  • service-worker.test.ts β€” public/sw.js run in a stand-in worker scope: a push shows what is new and never brings back a notification already shown, and a tap opens the Alerts tab.

  • extras.test.ts β€” the hourly and daily schedule for those reads: what the first run walks back through, what later runs skip, and that an empty current year in January does not stop the walk.

  • relays.test.ts β€” a relay's report on itself: what the Worker refuses, including a computer name offered as an id; the login expiry read from the portal's cookie; the random id a relay keeps; relays named by nickname or order, never by id; and the exit code that tells the renewal script to open a window for a login.

  • worker-routes.test.ts β€” the Worker itself, against a real database: the security headers on both an API response and the page; reads open and writes refused; /auth's cookie; every ingest route's contract, including what each refuses; identifiers proved absent from what is served; and the cron entry point.

  • worker-edges.test.ts β€” what only happens when something is unusual: the generic ingest route, a Worker deployed with no API_TOKEN, the poll log's trimming and per-provider newest line, the token store, and the hardware fan-out that fetches each inverter's own page.

  • poll.test.ts β€” the cron fan-out: which providers get built from which secrets, INCLUDE_PLANTS, a provider that throws not costing the other one, a hardware list failing without costing the reading, and the extras' hourly and daily schedules including the first run's walk back through the years.

  • readings.test.ts β€” turning a vendor reply into a reading: SolisCloud's inverter page with the grid sign flipped to the project's convention, and SolarMan's per-device registers layered over its station snapshot, including a register that is present but empty.

  • sparse.test.ts β€” what happens when a vendor sends almost nothing: nulls rather than zeros, an empty database answering every read, a device merged rather than overwritten by a thinner second view, and an id with no alias answered as unknown rather than echoed.

  • device-shapes.test.ts β€” the shapes a device record arrives in and the fallback each takes: serial, then device id, then nothing; the plant from the record when the caller did not say; every status code the portals use; per-phase AC when only the voltage or only the current is there; import and export preferred over a net wire figure; and metering decided on lifetime totals rather than on a number being present.

  • fallbacks.test.ts β€” the arms that only run when something is missing: a Worker deployed with no ingest token refusing every write with 503, an Authorization header that is not a bearer token, a curve nested inside data, a provider with no hardware list, an inverter that already knows its own name, and the three ways a vendor states a timezone.

  • series-rules.test.ts β€” the two rules the chart depends on: a live sample beats a backfilled one for the same instant, whichever arrived first, and a window with no from opens at the earliest of the plants' own midnights, falling back to the reader's offset only for a plant the vendor never placed.

  • daylight-saving.test.ts β€” a plant in a zone that switches: the zone name kept rather than only its offset, a different offset either side of a real switch, each reading stamped with the offset in force when it was taken, and a summer evening still landing in its own day when the history is read back in winter.

  • timezone.test.ts β€” the three shapes a vendor states a timezone in, and where a plant's day begins once one is known: east and west of Greenwich, on it, and on the half hour.

End-to-end tests β€” tests/e2e/

Ten spec files, 182 tests, each run twice: chrome (Desktop Chrome) and mobile (Pixel 7). scripts/serve-static.mjs serves public/, and every /api/* route is answered from fixtures in the spec, so the suite needs no Worker, database or vendor. They assert what a person sees and what the page sends:

Spec What it holds the dashboard to
dashboard.spec.ts The bulk, 133 tests: the overview and its layout at both widths, the theme, the header figures, each system's page, charts, alerts, history, devices, relays, TV mode, freshness and the auth gate
accessibility.spec.ts axe-core's WCAG 2 A and AA rules on every view, a keyboard walk-through, and a full keyboard lap of each view showing focus
guide.spec.ts The guide: one tap from anywhere, opens with no data or a refused request, names the systems, links only to real pages
history-systems.spec.ts Historical Data's system switch: narrows charts, tables, count and CSV; remembered; falls back when a system is gone
new-version.spec.ts The reload notice: silent when nothing changed, offered after a release, "Later" respected, TV reloads itself, a release caught even in the moment after load
push.spec.ts Notifications to a closed browser: on, off, the key asked for and never stored, every refusal explained
export-and-notify.spec.ts Download CSV, and the while-open notification switch
daylight-saving.spec.ts The morning the clocks go back: the day's first hour kept, and times shown on the clock they were read under
phone-width.spec.ts No view wider than a phone, and the last tab reachable
page-coverage.spec.ts Walks the dashboard and measures how much of its script ran (below)

One retry is allowed locally (two on CI): the suite drives two real Chrome projects in parallel and a page load occasionally overruns the timeout on a loaded laptop. A genuine break still fails twice.

The dashboard's own script

The end-to-end suite drives public/index.html, which carries the whole dashboard in one inline script. tests/e2e/page-coverage.spec.ts records what that walk-through actually executes, using V8's own coverage, and writes the figure to coverage/page-coverage.json β€” uploaded on every run. Measured on the desktop walk-through only: both projects are Chromium and both would write the same file, so the figure kept would otherwise be whichever finished last.

70.7% of the dashboard script, against a floor of 65% that fails the run if it drops. The figure moves as the page grows: it was 69.6% before this release added the CSV writer and the notification switch, which the walk-through only partly reaches. The floor is set below the reading, not flush against it, so an honest change does not fail on arithmetic. What is not covered is the dashboard answering situations the fixture does not create: a vendor error, TV mode's rotation, and the branches behind figures neither system reports.

Accessibility

tests/e2e/accessibility.spec.ts runs axe-core's WCAG 2 A and AA rules over every view, on desktop and mobile, with real data on screen, and fails the run on anything rated serious or critical. It also checks that the page can be worked through with a keyboard alone, and that every control shows when it has focus - tabbed to, one full lap of each view, because :focus-visible is what draws the ring and it deliberately ignores a focus set by script.

It found real faults on its first run, all since fixed: muted text at 2.93:1 where 4.5 is the bar, the brand orange used for 16px type at 3.42:1, status pills a shade under, and tables that scroll sideways with no way to reach them from a keyboard. The brand colours now have darker ink versions for type, while charts keep the brighter ones.

Coverage

npm run test:unit:coverage writes a terminal summary plus coverage/index.html (and lcov.info for CI tooling), and fails the run if it drops below the thresholds in vitest.config.ts.

Scope Statements Branches Functions Lines
All of src/ β€” everything the Worker ships 98.2% 91.6% 97.6% 99.5%
Β Β index.ts β€” routes, auth, headers, cron 97% 91% 91% 100%
Β Β db.ts β€” every line of SQL 99% 91% 100% 100%
Β Β poll.ts β€” the cron fan-out 99% 90% 92% 100%
Β Β push.ts β€” phone notifications 100% 99% 100% 100%
Β Β public-view.ts β€” what may leave the Worker 100% 92% 100% 100%
Β Β relays.ts β€” a relay's report, validated 100% 100% 100% 100%
Β Β events.ts β€” alarms and period totals 100% 91% 100% 100%
Β Β units.ts β€” W / kWh / timestamp / timezone scaling 94% 93% 100% 100%
Β Β solarman.ts 99% 91% 100% 99%
Β Β soliscloud.ts 98% 91% 100% 99%
Β Β solarman-web.ts β€” unofficial fallback 95% 84% 93% 97%
Thresholds enforced in CI 97% 90% 97% 99%

There is one figure now, and it covers the whole Worker. Until 2.7 there were two: an enforced scope of pure functions at 85.9%, and a whole-src/ row at 57.4% that existed as a warning, because index.ts had no automated test of any kind and neither did most of db.ts or poll.ts. The end-to-end suite does not reach them β€” it serves public/ from a static server and stubs every /api/* route β€” so routing, the auth middleware, the SQL and the cron fan-out were covered by deploying them and watching.

They are covered now, without Cloudflare's runtime: tests/helpers/d1.ts puts SQLite behind the D1 interface with the real migrations applied, and tests/helpers/worker.ts calls the exported handlers with it. What still cannot be reached from a unit test is workerd itself β€” crypto.subtle's MD5, the asset binding, real network β€” which npm run probe:solis, the end-to-end suite and the deploy's smoke test cover instead.

Every measure is at or above 90%, branches included. They got there by walking the arms rather than by lowering the bar: each fallback in the vendor normalisers β€” pick(r, 'stationName', 'name') ?? r.id β€” was given a payload that takes it. That exercise found a real one: a device record with no station id was stored with the plant id "null", the four-character string, because pick answers null for a missing key and the code tested it against undefined. Nothing had ever taken that arm, because every caller passes the plant in.

Raise the thresholds when you add tests; never lower them to turn a red build green.

public-view.ts sat outside the measured scope until 2026-09-11 β€” the one file deciding which vendor station ids, plant ids and serial numbers leave the Worker was the one file with no coverage number, despite carrying thirteen tests. It measures 100% of statements; the single uncovered branch is the fallback for an id the alias map has never seen.

The vendor HTTP clients were the gap until 2026-09-12 and are now the bulk of what is tested: tests/unit/clients.test.ts drives all three against a stubbed fetch β€” request signing, token acquisition, refresh-and-retry on both a bare 401 and SolarMan's own 2101 code, the error envelopes, and the HTTP failure paths. That file took the providers from 6–65% to 67–85%.

Two things it deliberately does not do. It does not check that the SolisCloud signature is one SolisCloud would accept β€” only that it is assembled from the documented parts; npm run probe:solis verifies the rest against the live endpoint. And it does not reach the deep paging and device-detail fan-out, or the parts of the browser-session fallback that only run without official keys.

Two details in that file worth knowing before you edit it. SolisCloud signs with MD5, which WebCrypto does not define and Cloudflare Workers adds β€” so the tests delegate that one algorithm to node:crypto. And each provider's CallQueue holds 1.5–2s between calls, so the tests set queue.minGapMs = 0; faking timers instead strands a pending timer in a module-level singleton and every later test in the file hangs.

Request signing itself (crypto.subtle MD5 + HMAC) only runs in the Workers runtime, so it is verified against the live API by npm run probe:solis.

What runs on every pull request

.github/workflows/checks.yml runs six jobs in parallel on every pull request and on every push to main. None of them needs a secret, a Cloudflare account or a SolisCloud login, so a fork gets the same checks.

Job What it catches
Type check Shape errors in the Worker and in the tests.
Unit tests Failures, and coverage falling below the thresholds in vitest.config.ts. The report is attached to the run.
End-to-end tests Dashboard regressions on desktop and mobile Chrome. On failure the screenshots and traces are attached.
Build check A Worker that would not bundle, found by wrangler deploy --dry-run, which uploads nothing.
Privacy, attribution and headers The four guards below.
Relay scripts (Windows) PowerShell that will not parse, analyser errors, and a relay that hangs instead of refusing when its settings are missing.

The guards are small Node scripts in scripts/ci/, each runnable by hand:

  • check-privacy.mjs β€” refuses an identifier: the deployment address, an email that is not a vendor's or plainly fake, a database id, a coordinate, or a long number that could be a plant or station id. It reads three places: every file, every commit of the pull request (patch and message, because a value added in one commit and removed in the next still lives in the pull request's ref for good), and the pull request's own description. The real values come from the optional PRIVACY_VALUES secret, which GitHub masks; a failure names the file and line and the rule, never the text it matched. Numbers that are genuinely invented β€” the ids in the fixtures β€” are listed in .github/privacy-allow.txt with a note saying so.
  • check-attribution.mjs β€” refuses a commit authored by anyone but this repository's account, or a message that hands authorship to something else: a co-author trailer, a "generated with" line, or a sentence crediting an assistant for the work. Naming a tool is ordinary prose and passes - the rule is about credit, not vocabulary, which the commit configuring an automated reviewer proved by failing the blunter version of it. Dependabot's own commits pass.
  • check-headers.mjs β€” refuses drift between the two copies of the security headers, in src/index.ts and public/_headers. They exist twice because Cloudflare serves public/ without running the Worker.
  • check-cmd-shape.mjs β€” refuses a .cmd that is not one parenthesised block with Windows line endings. Both run a script that updates the code underneath them, and cmd.exe reads a batch file a line at a time.

Third-party actions are pinned to a commit rather than a tag, because a tag can be moved after it has been reviewed.

Who reviews a pull request

Two reviewers are asked for on every pull request that is ready for one, so nobody has to remember:

  • Copilot, which reviews the diff again after every push. Its suggestions are not approvals, but each one opens a conversation, and the ruleset refuses a merge while any conversation is unresolved. So each suggestion is checked, fixed with a test or answered, replied to, and resolved - then the push that fixes it is reviewed again, until nothing is open. On the free plan its reviews have a monthly allowance, so it will sometimes not answer - the run's summary says when it could not be asked. Note that GitHub does not wait for the review to arrive: merging in the minutes before Copilot has posted is not blocked, which is why waiting for it is part of the routine rather than a rule.
  • The repository's owner, when the pull request is somebody else's: a contributor's, or one of Dependabot's. GitHub never asks anyone to review their own, so the owner's own pull requests get Copilot alone.

.github/workflows/reviewers.yml asks Copilot, which CODEOWNERS cannot name; .github/CODEOWNERS covers the human half and would also drive GitHub's own "review required" rule if that is ever switched on. A pull request left as a draft is not bothered until it is marked ready.

What scans for vulnerabilities

.github/workflows/security.yml answers a different question - not "does this change work" but "does this, or the code around it, put anything at risk". It runs on every pull request, on every push to main, and again every Monday, because an advisory can be published against code nobody has touched.

Job What it does
CodeQL GitHub's analysis of the TypeScript and JavaScript, with the security-extended rules. Findings appear in the repository's Security tab.
Dependency review Refuses a pull request that adds a package with a known high or critical advisory.
npm audit What the Worker ships with must be clean, and that fails the job. Advisories in Wrangler, Vitest or Playwright are printed in the run's summary instead, because they are not served to anyone.
Secret scan Gitleaks over the whole history, not only the new files: a token removed in a later commit was still published. GitHub's own secret scanning is on as well; this is a second set of rules, and it runs before the merge.
Workflow lint actionlint reads the workflow files the way GitHub will, so a bad expression is found now rather than on the day it should have run.
Actions are pinned scripts/ci/check-pinned-actions.mjs refuses uses: someone/action@v4, because a tag can be moved to point at code written after anyone here read it.

The two downloaded tools are pinned to a version and a SHA-256, checked before they run.

Dependencies are updated by hand, on purpose. There is no dependabot.yml: automatic update pull requests were tried and turned off, because a queue of them is work rather than safety on a project this size. What replaces them is deliberate: npm outdated says what has moved, one pull request takes the lot, and the checks above decide whether it may land - the same bar as any other change.

Safety is not left to that, though. The npm audit job fails the build on any advisory in a package the Worker ships with, whether or not anyone went looking, and dependency review refuses a pull request that introduces one. Turning Dependabot alerts on in the repository's settings adds a warning when an advisory is published against a package already here; it only notifies, and opens nothing.

What happens after a merge

.github/workflows/deploy.yml runs only when Checks has passed on main, so a merge that combines two changes which each passed alone cannot reach the live dashboard. Then it stops: the production environment carries a required reviewer, and nothing deploys until someone approves the run on GitHub.

  1. Remember the version serving right now, which is what a rollback needs.
  2. Apply database migrations (npm run db:remote). Migrations stay additive by rule, so the version still serving keeps working against the new schema - which is what makes rolling back the Worker alone a safe answer.
  3. Deploy the Worker.
  4. Ask the live site whether it works - scripts/ci/smoke.mjs: the page loads and still carries its security policy, /api/health and /api/latest answer, a vendor feed is current, the public responses carry no identifiers, and /api/poll and /api/ingest/relay still refuse a caller with no token.
  5. If any of that fails, the previous version is put back automatically and the run is marked failed, with the reason in its log.
  6. The run's summary says whether the relay computers need updating, when the merge touched agent/, the relay scripts or a .cmd.

.github/workflows/release.yml is the release button: run it from the Actions tab with a version, and it refuses unless package.json carries that version, the changelog has a section and a link reference for it, and no such tag exists. Then it scans the notes for identifiers, tags the commit, and publishes the release. It pushes the tag by its full ref name, because a branch sharing a tag's name makes a plain git push origin <name> ambiguous.

What the deploy needs, and where it lives. Four values, as secrets on the production environment - never in the repository:

Secret What it is
CLOUDFLARE_API_TOKEN A token limited to editing Workers and D1 on one account
CLOUDFLARE_ACCOUNT_ID The account the Worker belongs to
CF_D1_DATABASE_ID The database wrangler.jsonc refers to by placeholder
SOLARLENS_URL The deployment's address, which the smoke test asks

PRIVACY_VALUES is a repository secret rather than an environment one, because the privacy guard runs on every pull request.

Where the reports are

Nothing here is emailed and nothing is buried: every report is one click from the repository.

Report Where
Coverage, per file The Unit tests job of any run β†’ Artifacts β†’ coverage (HTML and lcov, kept 14 days)
End-to-end failures: screenshots, video-free traces, the exact step The End-to-end tests job of a failed run β†’ Artifacts β†’ playwright-report. Open a trace with npx playwright show-trace <file>
How much of the dashboard script the walk-through ran The End-to-end tests job of any run β†’ Artifacts β†’ page-coverage (kept 14 days)
Code analysis findings (CodeQL) Repository β†’ Security β†’ Code scanning
Vulnerable dependencies Repository β†’ Security β†’ Dependabot (alerts are on; they warn and open nothing)
Leaked secrets Repository β†’ Security β†’ Secret scanning, plus the Secret scan job, which reads the whole history
npm audit, development dependencies included The npm audit job's summary, printed on every run
What a deploy did, and whether the relay laptops need updating The Deploy run's summary
Which checks a pull request passed The pull request's own Checks tab

The same things locally, without GitHub:

npm run test:unit:coverage   # writes coverage/ - open coverage/index.html
npm run test:e2e             # writes playwright-report/ on failure
npm audit                    # dependencies, development included
npm audit --omit=dev         # only what the Worker ships with
node scripts/ci/check-privacy.mjs     # the guard, over the working tree

Data model

Eleven tables in D1, made by the files in migrations/, applied in order:

  • inverters β€” one row per monitored unit: id ({provider}:{vendor_id} or {provider}:station:{plant_id} when the plant is the unit), provider, serial, name, plant_id, plant_name, capacity_w, display_order, enabled, first_seen, last_seen, and where the plant stands: tz_name (the zone's own name, such as Europe/London, when the vendor states one) and tz_offset_sec (the offset in force now).
  • readings β€” one row per sample, keyed on (inverter_id, ts, source): tz_offset_sec (the offset in force when this was read, so a day keeps the boundary it was recorded under after the clocks change), ac_power_w, dc_power_w, today_kwh, total_kwh, battery_soc, battery_power_w, grid_power_w, load_power_w, temp_c, status, raw (untouched vendor JSON), and metrics β€” a JSON object with the extended figures the vendor apps show: generation by month/year/lifetime, consumption, self-consumption, grid import/export today and lifetime, battery charge/discharge today and lifetime, full-load hours, today's weather, and grid/battery status strings. Re-polling a vendor that has not produced a new sample stores no new row β€” but it does refresh that row's derived columns, so an improvement to a normaliser reaches the newest sample instead of waiting for the vendor to produce a fresh timestamp.
  • devices β€” hardware behind the readings: kind (inverter / datalogger / battery / meter), sn, model, firmware, rated_power_w, status, signal_dbm (datalogger RSSI), upload_cycle_s, commissioned_at, warranty_until, last_seen, strings β€” a JSON array of per-MPPT-string DC power β€” and battery, a JSON record of the pack: temperature, voltage, current, BMS figures and limits, nameplate capacity, nominal voltage and chemistry. Filled by the relay agent; the vendor payload is stripped of address, coordinates and account identifiers before storage.
  • alarms β€” each vendor fault: code, message, severity (info / warning / fault), the vendor's own vendor_level, advice, begin_ts, end_ts (null while active, or where the vendor never says), and state (active / recovered / unknown). Its id contains the vendor's plant id and is never served.
  • vendor_periods β€” each vendor's own totals per period (day / month / year) and key (2026-09, 2026): generation, load, grid both ways, battery both ways and full-load hours. These reach back to installation, which SolarLens's own readings cannot.
  • relays β€” each SolisCloud relay's report on itself: a random id (never a computer name, never served), an optional name, state (ok / login-expired / error), login_expires_at from the portal's own login cookie, and when it was first and last heard from.
  • kv β€” a small expiring key/value shelf (k, v, expires_at), used by the weather cache, the hourly and daily schedules, and to remember which notification events have already been told.
  • push_subscriptions β€” each device signed up for notifications: its push endpoint (never served), the dashboard origin it subscribed from, a short audience hash, and its delivery record. At most ten. push_messages β€” what a woken device shows (title, body), for every device or for one; kept a week.
  • tokens β€” cached bearer/refresh tokens per provider. poll_log β€” one line per poll with success and detail, surfaced in the dashboard footer.

Upgrading from before 2.9: stamping the readings you already have

Migration 0012 adds readings.tz_offset_sec and every reading from 2.9 on carries it, but rows stored earlier come back null and fall back to the plant's current offset. scripts/backfill-reading-offsets.mjs gives them the offset they were taken under. It reads your signed-in wrangler session and needs CF_D1_DATABASE_ID like every other wrangler script:

node scripts/backfill-reading-offsets.mjs                        # what it would do; writes nothing
node scripts/backfill-reading-offsets.mjs --apply                # do it

A plant whose zone name is not known is left alone rather than stamped with today's number, which would look like a repair and could never be revisited. If a plant's payload only ever carries a number - the SolisCloud plant this was first run against did, although SolisCloud can send a zone name - and the plant's zone does not observe daylight saving, that number is right for every past reading: add --use-current-offset. It costs one write per row repaired, against D1's 100,000 a day.

Neither cloud reports a battery cycle counter, so the detail view derives one β€” lifetime charge energy over the pack's usable capacity β€” and labels it derived rather than presenting it as a vendor figure.

Conventions: power in W, energy in kWh, timestamps in epoch seconds; grid_power_w is + import / βˆ’ export; battery_power_w is + charging / βˆ’ discharging (|x| < 50 W is shown as idle). Free-tier headroom is comfortable: two inverters every 5 minutes is β‰ˆ 600 writes/day against D1's 100 000.

HTTP API

Route Auth Purpose
GET /api/latest open newest reading per inverter, with metrics
GET /api/series?from=&to=&tz= open readings in a range (≀ 31 days). Omit from and the window opens at the earliest plant's own midnight; tz is the fallback for a plant whose vendor reports no timezone
GET /api/health open recent poll log, the newest line per feed, and each SolisCloud relay heard from in the last 14 days with its login's expiry. Relay ids are never returned
GET /api/history?days=&tz= open one row per inverter per day, each cut at that plant's own midnight (tz is the fallback, the caller's UTC offset in minutes)
GET /api/devices open hardware inventory
GET /api/alarms?days= open fault history, newest first (default 730 days). An alarm's internal id is never returned, since it contains the vendor's plant id
GET /api/periods open each vendor's own month and year totals, back to installation
POST /api/poll API_TOKEN poll all providers now β€” makes live vendor calls, so it spends quota
POST /api/ingest INGEST_TOKEN push an already-normalised reading ({inverter, reading})
POST /api/ingest/station INGEST_TOKEN push a raw vendor station payload ({provider, plantId, name?, capacityW?, raw}); normalised server-side
POST /api/ingest/devices INGEST_TOKEN push raw vendor device records ({provider, plantId, inverters[], collectors[]}); normalised server-side
POST /api/ingest/history INGEST_TOKEN backfill a day curve; rejects a peak above 5Γ— nameplate
POST /api/ingest/relay INGEST_TOKEN a relay's report on itself ({provider, id, name?, state: ok|login-expired|error, loginExpiresAt?}), validated to that narrow shape
POST /api/ingest/alarms INGEST_TOKEN raw SolisCloud alarm records ({provider, plantId, records[]}), normalised and stripped of owner fields in the Worker
POST /api/ingest/periods INGEST_TOKEN raw SolisCloud chart totals ({provider, plantId, which: month|year|all, points[]}); rejects a total the nameplate could not produce
GET /api/push/key open the public half of the Web Push signing key; 404 when none is set
POST /api/push/subscribe API_TOKEN or the /auth cookie sign this device up ({endpoint}); records what is already wrong without announcing it, and sends the device a first message
POST /api/push/test API_TOKEN or the /auth cookie send one test notification to one signed-up device ({endpoint})
POST /api/push/unsubscribe open turn a device off ({endpoint}). Needs no key: only that device knows its endpoint
GET /api/push/recent?for= a signed-up device what a woken device shows: the last day's messages - as long as a push service holds a wake-up - for every device and for the one whose hash is for, newest first, at most 20, with more when there were others. 404 for any hash that is not a signed-up device. Never cached
GET /auth?t= β€” set the cookie the write routes accept

Every GET answers anyone, with vendor identifiers stripped β€” see Reads are public; writes are not. Writes take a bearer header (Authorization: Bearer …) or the cookie set by /auth.

Project layout

solar-lens/
β”œβ”€β”€ wrangler.jsonc            Worker, D1 binding, cron, static assets
β”œβ”€β”€ .gitattributes            Windows line endings for the relay's .cmd, .ps1 and .vbs
β”œβ”€β”€ .nvmrc                    the Node version the automated checks use
β”œβ”€β”€ .github/
β”‚   β”œβ”€β”€ workflows/checks.yml  the checks every pull request must pass
β”‚   β”œβ”€β”€ workflows/security.yml  CodeQL, dependency review, audit, secrets, lint
β”‚   β”œβ”€β”€ workflows/deploy.yml  migrations, deploy, smoke test, rollback
β”‚   β”œβ”€β”€ workflows/release.yml  the release button: tag and publish the notes
β”‚   β”œβ”€β”€ privacy-allow.txt     long numbers the privacy guard may let through
β”‚   β”œβ”€β”€ CODEOWNERS            asks the owner to review anyone elses pull request
β”‚   └── workflows/reviewers.yml  asks Copilot, and the owner, for a review
β”œβ”€β”€ tsconfig.json             typecheck for src/
β”œβ”€β”€ tsconfig.tests.json       typecheck for tests/ (browser + Worker types)
β”œβ”€β”€ vitest.config.ts          unit test runner, coverage provider and thresholds
β”œβ”€β”€ playwright.config.ts      two browser projects, static server, retries
β”œβ”€β”€ migrations/               D1 schema, applied with `wrangler d1 migrations apply`
β”‚                             (0012 zone names, and the offset each reading was taken under
β”‚                              0013 push subscriptions and the messages they are woken for)
β”‚                             (0001 base Β· 0002 metrics Β· 0003 devices Β· 0004 signal
β”‚                              0005 electrical Β· 0006 battery Β· 0007 kv cache
β”‚                              0008 read indexes on readings.ts and poll_log
β”‚                              0009 the plant's own UTC offset on inverters
β”‚                              0010 alarms and vendor period totals
β”‚                              0011 SolisCloud relays and their login expiry)
β”œβ”€β”€ src/
β”‚   β”œβ”€β”€ index.ts              Hono app: API routes, ingest, static UI, scheduled()
β”‚   β”œβ”€β”€ poll.ts               builds providers from present secrets; polls; plant filter;
β”‚   β”‚                         hourly alarms and daily period totals
β”‚   β”œβ”€β”€ db.ts                 D1 queries and the Env type
β”‚   β”œβ”€β”€ public-view.ts        strips vendor identifiers from public responses
β”‚   β”œβ”€β”€ push.ts               Web Push: signing, who is signed up, what is worth announcing
β”‚   β”œβ”€β”€ relays.ts             validates a relay's report on itself
β”‚   └── providers/
β”‚       β”œβ”€β”€ types.ts          Provider / Inverter / Reading / Metrics
β”‚       β”œβ”€β”€ units.ts          W / kWh / timestamp normalisation
β”‚       β”œβ”€β”€ events.ts         alarms and vendor period totals, owner fields dropped
β”‚       β”œβ”€β”€ queue.ts          serialised call queue (vendor rate limits)
β”‚       β”œβ”€β”€ soliscloud.ts     official API adapter + station normaliser
β”‚       β”œβ”€β”€ solarman.ts       official API adapter + station normaliser
β”‚       └── solarman-web.ts   browser-session fallback (refresh token)
β”œβ”€β”€ public/
β”‚   β”œβ”€β”€ index.html            the dashboard (no build step)
β”‚   β”œβ”€β”€ _headers              the same CSP as src/index.ts, for edge-served requests
β”‚   β”œβ”€β”€ manifest.webmanifest  makes it installable: home screen, own window
β”‚   β”œβ”€β”€ sw.js                 network-first worker; a cache is the offline fallback
β”‚   β”œβ”€β”€ icon.svg              app icon and favicon
β”‚   └── icon-192.png          rendered from icon.svg for installs and iOS
β”‚       icon-512.png
β”œβ”€β”€ agent/
β”‚   β”œβ”€β”€ solis-relay.mjs       local Chrome relay for SolisCloud
β”‚   β”œβ”€β”€ solis-extras.mjs      its slower reads: alarm history and period totals
β”‚   β”œβ”€β”€ relay-status.mjs      login expiry, and the relay's random id and nickname
β”‚   └── relay-status.d.mts    its types, for the tests that import it
β”œβ”€β”€ setup-relay.cmd           double-click entry point for the relay installer
β”œβ”€β”€ renew-solis-login.cmd     double-click when a SolisCloud login needs renewing
β”œβ”€β”€ scripts/
β”‚   β”œβ”€β”€ wrangler.mjs             fills CF_D1_DATABASE_ID into a temp config
β”‚   β”œβ”€β”€ wrangler-config.mjs      that config step, shared with the scripts below
β”‚   β”œβ”€β”€ backfill-reading-offsets.mjs  stamps pre-2.9 readings with their offset
β”‚   β”œβ”€β”€ make-vapid-key.mjs       makes the Web Push key and stores it as a Worker secret
β”‚   β”œβ”€β”€ ci/check-privacy.mjs     refuses an identifier in a file, commit or description
β”‚   β”œβ”€β”€ ci/check-attribution.mjs refuses a commit credited to anyone else
β”‚   β”œβ”€β”€ ci/check-headers.mjs     refuses drift between the two copies of the headers
β”‚   β”œβ”€β”€ ci/check-cmd-shape.mjs   refuses a .cmd that its own update could break
β”‚   β”œβ”€β”€ ci/check-pinned-actions.mjs  refuses an action pinned to a movable tag
β”‚   β”œβ”€β”€ ci/smoke.mjs             asks the live site whether the deploy worked
β”‚   β”œβ”€β”€ ci/worker-version.mjs    the version now serving, for a rollback
β”‚   β”œβ”€β”€ ci/release-notes.mjs     the changelog section, if the release is ready
β”‚   β”œβ”€β”€ setup-relay.ps1          installs the relay on a machine, start to finish
β”‚   β”œβ”€β”€ renew-solis-login.ps1    renews the login and restarts the hidden relay
β”‚   β”œβ”€β”€ relay-hidden.vbs         starts the relay with no console window
β”‚   β”œβ”€β”€ make-laptop-installer.ps1  writes a pre-filled installer for a 2nd machine
β”‚   β”œβ”€β”€ rotate-tokens.ps1        replaces API_TOKEN / INGEST_TOKEN in both places
β”‚   β”œβ”€β”€ probe-solis.mjs          one signed Solis request, raw response printed
β”‚   β”œβ”€β”€ seed-local.mjs           a day of synthetic readings for the local DB
β”‚   β”œβ”€β”€ capture-portals.mjs      saves portal responses as test fixtures
β”‚   β”œβ”€β”€ serve-static.mjs         serves public/ for the e2e run
β”‚   └── make-icons.mjs           renders icon.svg to the manifest's PNG sizes
β”œβ”€β”€ tests/
β”‚   β”œβ”€β”€ unit/units.test.ts       W / kWh / timestamp scaling
β”‚   β”œβ”€β”€ unit/normalize.test.ts   both vendor normalisers, signs and statuses
β”‚   β”œβ”€β”€ unit/queue.test.ts       vendor rate-limit queue
β”‚   β”œβ”€β”€ unit/history.test.ts     day-curve backfill normalisation
β”‚   β”œβ”€β”€ unit/pii.test.ts         what is stripped from a stored payload
β”‚   β”œβ”€β”€ unit/logging.test.ts     query strings redacted before they reach the log
β”‚   β”œβ”€β”€ unit/worker-routes.test.ts  every route, against a real database
β”‚   β”œβ”€β”€ unit/worker-edges.test.ts   the unusual paths: odd ingest bodies, failures
β”‚   β”œβ”€β”€ unit/poll.test.ts        the cron fan-out: which providers and plants
β”‚   β”œβ”€β”€ unit/readings.test.ts    the two paths from a vendor reply to a reading
β”‚   β”œβ”€β”€ unit/sparse.test.ts      a vendor that sends almost nothing
β”‚   β”œβ”€β”€ unit/device-shapes.test.ts  each shape a device record arrives in
β”‚   β”œβ”€β”€ unit/fallbacks.test.ts   the fallback branches nothing else reached
β”‚   β”œβ”€β”€ unit/series-rules.test.ts   live beats backfilled; where "today" opens
β”‚   β”œβ”€β”€ unit/daylight-saving.test.ts  history that keeps its day across a clock change
β”‚   β”œβ”€β”€ unit/public-view.test.ts what a public response may and may not carry
β”‚   β”œβ”€β”€ unit/clients.test.ts     the vendor HTTP clients against a stubbed fetch
β”‚   β”œβ”€β”€ unit/events.test.ts      alarms and period totals from both vendors
β”‚   β”œβ”€β”€ unit/solarman-alarm-detail.test.ts  SolarMan's advice, and when each fault cleared
β”‚   β”œβ”€β”€ unit/push.test.ts        the push signature, what is worth announcing, the routes
β”‚   β”œβ”€β”€ unit/service-worker.test.ts  the service worker's push and tap handling
β”‚   β”œβ”€β”€ unit/extras.test.ts      the hourly and daily schedule for them
β”‚   β”œβ”€β”€ unit/timezone.test.ts    plant timezones and where a plant's day begins
β”‚   β”œβ”€β”€ unit/relays.test.ts      relay reports, login expiry, and relay naming
β”‚   β”œβ”€β”€ fixtures/               captured vendor payloads, scrubbed of identifiers
β”‚   β”œβ”€β”€ unit/release-version.test.ts  the guide's release is the package's
β”‚   β”œβ”€β”€ e2e/push.spec.ts         turning notifications to a closed browser on and off
β”‚   β”œβ”€β”€ e2e/guide.spec.ts        the guide page and its button
β”‚   β”œβ”€β”€ e2e/history-systems.spec.ts  Historical Data, one system or all
β”‚   β”œβ”€β”€ e2e/new-version.spec.ts  the reload notice after a release
β”‚   β”œβ”€β”€ e2e/phone-width.spec.ts  no view wider than a phone
β”‚   β”œβ”€β”€ e2e/export-and-notify.spec.ts  Download CSV, and while-open notifications
β”‚   β”œβ”€β”€ e2e/daylight-saving.spec.ts  the morning the clocks go back
β”‚   β”œβ”€β”€ e2e/accessibility.spec.ts  axe WCAG A/AA and a keyboard lap on every view
β”‚   β”œβ”€β”€ e2e/page-coverage.spec.ts  how much of the dashboard script the walk-through runs
β”‚   └── e2e/dashboard.spec.ts    the dashboard, desktop and mobile
β”œβ”€β”€ CHANGELOG.md              release history, newest first
β”œβ”€β”€ docs/handoff.md           running, repairing and handing over the system
β”œβ”€β”€ docs/api-notes.md         observed vendor field names and conventions
└── docs/feature-gaps.md      SolisCloud vs SolarMan vs SolarLens, feature by feature

Finding your way around the code

Every source file starts with a comment saying what it is for and how it fits with the others - except the dashboard, public/index.html, which is HTML first: its explanation is the map at the start of its script. Opening a file is the quickest way to learn it. Three places to start:

  • src/index.ts - every route and the cron. Follow a route to the function it calls in src/db.ts (most of the SQL) or src/push.ts (notifications, with its own tables).
  • public/index.html - the whole dashboard. Its script opens with a map of its sections in order; search for ---------- <name> to jump to one.
  • docs/handoff.md - the system as a whole: how data flows, the file-by-file tour, the data model, and what has gone wrong before and why.

Debugging the live system

  • What the Worker is doing: npm run cf -- tail streams its log live - every poll failure, and every failed phone notification as a line like {"push":"failed","host":"fcm.googleapis.com","status":503}.

  • What it last saw: /api/health answers with the recent poll log, the newest line per vendor feed, and every relay's state and login expiry. The dashboard's footer and Devices tab show the same.

  • What is in the database: run a query against production with wrangler's own entry point, so the statement reaches it in one piece (npm run cf goes through a shell, which splits a statement at its spaces). Any npm run cf command first writes the filled-in config it needs:

    node node_modules/wrangler/bin/wrangler.js d1 execute solar-lens --remote --json --config .wrangler.local.jsonc --command "SELECT provider, ok, detail FROM poll_log ORDER BY ts DESC LIMIT 10"

    Use --command, not --file, for a query: against the remote database a file is run as an import, and answers with statistics instead of rows.

  • What a relay laptop is doing: the hidden relay writes no log - its output goes nowhere, by design, so nothing appears on screen. Its state and last report are on the Devices tab. To watch it work, stop the task and run one cycle in a visible window, in PowerShell from the SolarLens folder:

    Stop-ScheduledTask -TaskName 'SolarLens relay'
    $env:RELAY_HEADLESS = '0'; $env:RELAY_ONCE = '1'; node agent\solis-relay.mjs
    Start-ScheduledTask -TaskName 'SolarLens relay'

    It logs each step with the time, and exits 0 when a reading went through, 3 when SolisCloud wants a login, and 1 on anything else.

Troubleshooting

Symptom Cause / fix
Dashboard says unauthorized Reads are public, so this means the deployment is older than that change. Run npm run deploy.
Footer: no provider credentials configured No provider secrets present. Set at least one route's secrets and redeploy or POST /api/poll.
soliscloud: HTTP 408 Your clock is > 15 min off SolisCloud's. Fix the system clock (Workers are fine; this affects local probes/agents).
soliscloud: HTTP 403/401 on official API Key not activated, or API access not enabled on the account. Check Basic Settings β†’ API Management.
solarman: token refused Wrong appId/appSecret, or the password hash is not lowercase sha256 hex.
SolarMan panel goes stale after ~24 h The refresh grant failed; re-copy the refresh token (you may have logged out of SolarMan). Check GET /api/health.
Alerts: SolisCloud login on … expires in … or has expired A SolisCloud login lasts seven days and cannot renew itself. On the computer named in the alert, double-click renew-solis-login.cmd and log in. The alert clears on that relay's next report.
Solis reads offline although the plant is producing, and the footer's SolisCloud feed is hours old Either every relay's login has expired, which the Devices tab shows, or the computers running relays are asleep or off. A relay only works while its computer is awake: set sleep to Never when plugged in. Renew a login with renew-solis-login.cmd.
Relay console: session expired The login has expired; renew it with renew-solis-login.cmd. By hand: stop the hidden relay first, run RELAY_HEADLESS=0 RELAY_ONCE=1 node agent/solis-relay.mjs, log in, then start the hidden relay again.
A .cmd window stays open with Something did not work or Setup did not finish That run failed, and the window stays so the reason can be read. The lines above it say what; the usual one is a SolisCloud login that was not completed. Double-click it again.
Deploy: register a workers.dev subdomain One-time account step; follow the printed link or pick a name in the dashboard, then deploy again.
PowerShell: The token '&&' is not valid Run the two commands on separate lines.
A shared plant you don't own shows up Set INCLUDE_PLANTS to the ids you want.
Installer: 503 Backend.max_conn reached raw.githubusercontent.com is having a bad day β€” nothing to do with your network. The installer only falls back to that host when the machine has no git; install Git and it uses github.com instead, which stays up when the CDN does not.
git pull: Not possible to fast-forward That checkout has diverged from main - typically it holds commits that were later squash-merged - so it can never fast-forward. Re-run the installer, which resets it to origin/main, or do it by hand: git fetch origin && git reset --hard origin/main.
No mapping between account names and security IDs was done An older installer composed the task's account as COMPUTERNAME\USERNAME, which is wrong on a domain or Entra-joined machine. git pull and run the installer again.
Task exists but State: Ready, no relay Start it: Start-ScheduledTask -TaskName 'SolarLens relay'. If it drops straight back to Ready, Get-ScheduledTaskInfo -TaskName 'SolarLens relay' gives the result code the action returned.
A console window appears at every logon The task is registered to run node.exe directly. Re-run the installer; it registers scripts\relay-hidden.vbs instead.
Relay pushed nothing for one interval after a restart Older agents gave up for a whole cycle when Chrome lost a launch race at boot. Current ones retry twice and clear stale profile locks β€” git pull on that machine.
Installer: does not contain a method named 'Fill' You are on Windows PowerShell 5.1 and the script is older than this fix. git pull and re-run.
Relay stopped after a token change INGEST_TOKEN must match in Cloudflare and in .dev.vars on every relay machine. Use scripts\rotate-tokens.ps1 so both move together, then restart the relay.
You have lost API_TOKEN or INGEST_TOKEN Cloudflare never reads a secret back. Replace it β€” see Replacing a token.

Security and privacy

Headers on every response: a Content-Security-Policy that is strict about where anything may be sent as well as where it may come from (connect-src 'self', so injected script could not exfiltrate a reading), frame-ancestors 'none', X-Content-Type-Options: nosniff and Referrer-Policy: no-referrer β€” the last of which also stops the one-time /auth?t=<token> link putting the token in a Referer. The policy is written twice, in src/index.ts and public/_headers, because Cloudflare serves the static page from the edge without invoking the Worker; change both or neither.

Vendor errors are stripped of query strings before they reach poll_log β€” SolarMan's token endpoint takes the account's appId in the URL, and that log is kept for a week, served by /api/health and printed in the footer. No credential should travel that far because a DNS lookup failed.

Reads are public; writes are not

GET /api/* answers anybody. That is a deliberate choice, not an oversight: the dashboard is meant to be opened on a phone, a work laptop or a relative's tablet without first copying a token onto each one, and a per-device unlock step is a tax that gets paid every time and forgotten exactly when it matters.

What that choice costs is bounded rather than accepted:

  • Vendor identifiers never leave the Worker. src/public-view.ts strips them from every response. Station and plant ids become positional aliases (s1, s2), serial numbers are masked to their last four characters, and the stored raw vendor payload is not served at all. A station id, a plant id and a serial are account-level handles β€” what a vendor's support desk asks for, what a warranty is keyed on β€” and none of them is needed to draw a chart.
  • Aliases are positional rather than hashed on purpose. A SolarMan station id is eight digits, so a hash of one can be reversed by trying all hundred million of them.
  • Nothing readable can spend money or change data. POST /api/poll makes live vendor calls, so it keeps the API_TOKEN gate; /api/ingest/* keeps its own INGEST_TOKEN. An agent key still cannot read, and a reader still cannot write.
  • Signing a phone up for notifications is a write, so it needs the key too, and a stranger who can read the dashboard cannot have their own phone told about your systems. Turning a device off is the one write that needs no key: it takes that device's own push endpoint, which only it knows, and a gate there would only keep unwanted notifications coming. Endpoints are held to the push services browsers use and never served; at most ten devices.
  • Read endpoints send Cache-Control: public, max-age=60, so a burst of requests is answered at the edge instead of against D1. A public URL can be requested by anything at any rate, and this project has exhausted the free tier's row budget twice already.

What it does not protect is the measurements themselves. Anyone with the URL can see your generation and consumption, and a consumption curve says when a building is occupied. If that matters for your site, put Cloudflare Access in front of the Worker β€” it covers the workers.dev URL, needs no code change here, and gives a normal sign-in page instead of a token to copy.

API_TOKEN still exists for the write routes, and /auth?t=<token> still sets the HttpOnly, Secure, SameSite=Lax cookie those routes accept. Vendor payloads are stripped of the account holder's name, email and the site's coordinates before storage, and every vendor-controlled string is escaped before it reaches the page.

Plant names are still published. They are what the dashboard labels each system with, so they are the one identifying string deliberately left in. If yours name a person or a business, rename the plant in the vendor portal.

One third-party request remains: the page loads its web font from Google, which sees the viewer's IP. Self-hosting it (Manrope is OFL-licensed) or dropping to the system font stack removes that.

  • Nothing identifying belongs in the repo: credentials, tokens and plant ids live only in wrangler secret, the Cloudflare dashboard, or the gitignored .dev.vars. captures/, .capture-profile/ and .relay-profile/ (browser sessions) are gitignored too.
  • The workers.dev URL serves readings to anyone who opens it, with vendor identifiers stripped. INGEST_TOKEN and API_TOKEN gate the routes that write or spend quota.
  • The SolarMan portal login sends your password in clear text in the form body. The capture helper redacts it, but never paste DevTools request bodies anywhere.
  • Unofficial routes reuse your browser session against your data only. Vendor terms may restrict automation; the official APIs are the durable path and everything here prefers them when their secrets are present.

Roadmap

  • Worker, D1, cron, two-panel dashboard with divider
  • SolisCloud and SolarMan official adapters fitted to real payloads
  • SolarMan browser-session fallback (refresh-token based)
  • SolisCloud local relay agent
  • Extended metrics (month/year/lifetime, grid, battery, self-consumption)
  • Unit tests (Vitest) and e2e tests (Playwright, desktop + mobile)
  • Hardware inventory: Devices view, datalogger status and RSSI, per-MPPT-string PV power
  • Per-system detail view
  • Searchable raw telemetry, and the alerts built from vendor flags, on the live site - they read a payload the server withholds (docs/feature-gaps.md gap 5)
  • Energy-flow diagram (PV / grid / battery / load), battery arm omitted for on-grid
  • Checks on every pull request, required before merging: types, unit, end-to-end, build, privacy, attribution, headers, relay scripts
  • Vulnerability, secret and code scanning, weekly as well as per pull request
  • Deployment behind an approval, with a live smoke test and automatic rollback
  • An automated test for the Worker's own routes against a real D1 in CI β€” SQLite behind the D1 interface, since 2.7
  • SolarMan alarm detail: when each fault cleared, its advice, and the occurrences the alert list folds away
  • Notifications that reach a closed browser (Web Push)
  • Local Modbus agent for LSW-3/LSE-3 loggers β†’ /api/ingest
  • SolarMan device endpoints β€” inverter/collector list, datalogger signal and firmware
  • Per-string voltage & current, per-phase AC, heatsink temperature β€” both vendors, no API key needed

Contributing

Issues and pull requests are welcome. Please:

  • keep vendor field names and sign conventions documented in docs/api-notes.md when you add or change a mapping;
  • add or update a fixture and a unit test for any normaliser change, and an e2e assertion for anything a person can see;
  • never commit credentials, tokens, plant ids or portal captures β€” the .gitignore is set up for this, keep it that way;
  • run npm run typecheck && npm test before opening a PR.

The same suites run on the pull request itself, along with the privacy, attribution, header and batch-file guards, vulnerability scanning and a Windows job for the relay scripts. All twelve must pass before a pull request can merge; what runs on every pull request says what each one refuses. Two are worth knowing before you write the commit: an identifier in any commit of the branch fails the privacy guard even if a later commit removes it, and every commit must be authored by the repository's own account with no co-author trailer.

If you have a different inverter brand on the same SolarMan/Solis platform family (Deye, Sofar, …), a new provider is one file implementing Provider in src/providers/ plus a fixture β€” contributions there are especially welcome.

License

MIT.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages