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
#/tvdrops 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.
- How it works
- Data sources and how each authenticates
- Quick start (β10 minutes)
- Getting credentials β SolisCloud Β· SolarMan Β· SolarMan fallback
- SolisCloud relay agent β one command on Windows Β· more than one machine Β· replacing a token
- Notifications on your phone
- Configuration reference
- Local development
- Testing β what runs on every pull request Β· where the reports are
- Data model
- HTTP API
- Project layout
- Troubleshooting
- Security and privacy
- Changelog Β· Handover
- Roadmap Β· Contributing Β· License
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
A single Cloudflare Worker does three jobs:
-
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. -
API β a few JSON endpoints over D1: latest reading per inverter, a time series for charts, poll health, and push endpoints for local agents.
-
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.mdgap 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
#/flowbookmark lands there. - Overview (
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.
| 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.
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 install1. Log in to Cloudflare and create the database
npx wrangler login
npx wrangler d1 create solar-lensThat 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.varsand 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 hatch2. 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 agentsGenerate 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_TOKENIf 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 deployThis 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/pollWindows PowerShell:
&&is not a statement separator there β run chained commands on separate lines.
- 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
KeyIdandKeySecret. - 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.
- 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.
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'- Log in at
https://home.solarmanpv.comin Chrome. - Press F12 β Application β Cookies β
https://home.solarmanpv.com. - Two cookies hold JWTs (
eyJβ¦). The persistent one (expiry months away, ~940 bytes) is the refresh token β copy its value intoSOLARMAN_WEB_REFRESH_TOKEN. The Session one (~880 bytes) is the access token; optionally copy it intoSOLARMAN_WEB_ACCESS_TOKENso the very first poll needs no refresh. - 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.
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.
Double-click setup-relay.cmd, or run it from a terminal:
setup-relay.cmdIt 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, notnode.exedirectly. 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 fromCOMPUTERNAMEandUSERNAME. On a domain or Entra-joined machine the user resolves asDOMAIN\nameorAzureAD\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) |
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:solisThe 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.
- Every 5 minutes (
RELAY_INTERVAL_MIN) it opens each plant page, waits for the portal's owndetailMixresponse, 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_IDSto 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.varsitself, 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_NAMEif 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.cmdin 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-runningsetup-relay.cmddoes 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.vbssetsRELAY_HEADLESS=1for the process it starts, which beats anything in.dev.vars, so a0left there by a login done by hand can never put a Chrome window on screen at every logon. RELAY_ONCE=1runs a single cycle and exits:0a reading went through,3SolisCloud wants a login,1anything 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.cmdand strands the installer half-finished.RELAY_SKIP_EXTRAS=1leaves 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.
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.ps1It 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:
- A checkout already on that machine β used as it stands, updated with
git pullwhen that works. git clonefromgithub.comβ the host that stayed up whileraw.githubusercontent.comwas answering 503.- 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.
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.
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.
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:
-
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_KEYstraight away; the private half is never printed or saved to a file. Run it again and it changes nothing;--replacemakes a new key, after which every device has to turn notifications on again. -
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.
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. |
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.
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:8787npm 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.sqlfile and use--file, or callnpx wrangler β¦ --config .wrangler.local.jsoncdirectly 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.
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 failure401 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.
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:uiA 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.
| 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. |
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 aundefinedreaches 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/MWhscaling, 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 andv3/detailregister categories. This is where the conventions are pinned down βgrid_power_wpositive on import,battery_power_wpositive 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 stubbedfetch: 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:capacitymerely contains the letters ofcity. -
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 inpackage.json, and the changelog has a section for it. -
service-worker.test.tsβpublic/sw.jsrun 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 noAPI_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 asunknownrather 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, anAuthorizationheader that is not a bearer token, a curve nested insidedata, 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 nofromopens 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.
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 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.
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.
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.
.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 optionalPRIVACY_VALUESsecret, 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.txtwith 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, insrc/index.tsandpublic/_headers. They exist twice because Cloudflare servespublic/without running the Worker.check-cmd-shape.mjsβ refuses a.cmdthat 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.
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.
.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.
.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.
- Remember the version serving right now, which is what a rollback needs.
- 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. - Deploy the Worker.
- Ask the live site whether it works -
scripts/ci/smoke.mjs: the page loads and still carries its security policy,/api/healthand/api/latestanswer, a vendor feed is current, the public responses carry no identifiers, and/api/polland/api/ingest/relaystill refuse a caller with no token. - If any of that fails, the previous version is put back automatically and the run is marked failed, with the reason in its log.
- 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.
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 treeEleven 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 asEurope/London, when the vendor states one) andtz_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), andmetricsβ 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 β andbattery, 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 ownvendor_level,advice,begin_ts,end_ts(null while active, or where the vendor never says), andstate(active/recovered/unknown). Itsidcontains the vendor's plant id and is never served.vendor_periodsβ each vendor's own totals perperiod(day/month/year) andkey(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 randomid(never a computer name, never served), an optionalname,state(ok/login-expired/error),login_expires_atfrom 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 pushendpoint(never served), the dashboardoriginit subscribed from, a shortaudiencehash, 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.
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 itA 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.
| 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.
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
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 insrc/db.ts(most of the SQL) orsrc/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.
-
What the Worker is doing:
npm run cf -- tailstreams 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/healthanswers 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 cfgoes through a shell, which splits a statement at its spaces). Anynpm run cfcommand 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.
| 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. |
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.
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.tsstrips 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/pollmakes live vendor calls, so it keeps theAPI_TOKENgate;/api/ingest/*keeps its ownINGEST_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.devURL serves readings to anyone who opens it, with vendor identifiers stripped.INGEST_TOKENandAPI_TOKENgate 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.
- 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.mdgap 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
Issues and pull requests are welcome. Please:
- keep vendor field names and sign conventions documented in
docs/api-notes.mdwhen 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
.gitignoreis set up for this, keep it that way; - run
npm run typecheck && npm testbefore 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.
MIT.