diff --git a/.changeset/driver-config-numbers.md b/.changeset/driver-config-numbers.md new file mode 100644 index 00000000..f6493be9 --- /dev/null +++ b/.changeset/driver-config-numbers.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Keep unchanged drivers running when settings pass through the web API. Compare numeric values from JSON and YAML equally, including nested driver settings. Bound charger settings requests so a missing reply leads to a visible retry state. diff --git a/.changeset/easee-session-driver.md b/.changeset/easee-session-driver.md new file mode 100644 index 00000000..3c557a9b --- /dev/null +++ b/.changeset/easee-session-driver.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Use Easee session evidence to retain a confirmed battery level across a restart only when the same active session is verified. diff --git a/.changeset/ev-battery-session-truth.md b/.changeset/ev-battery-session-truth.md new file mode 100644 index 00000000..df51e5b5 --- /dev/null +++ b/.changeset/ev-battery-session-truth.md @@ -0,0 +1,9 @@ +--- +"ftw": patch +--- + +Keep a confirmed EV battery level across restart only when fresh charger telemetry identifies the same hardware and charging session. Show when the level cannot be retained or the disk write failed. Changing battery capacity preserves the current level and its confidence. + +Treat a car declining current as a separate charging status. It no longer changes the estimated battery level to the target or sends a completed notification. Completion needs a fresh matched vehicle battery reading. + +A higher goal, an explicit retry or measured charging lets the planner resume after a prior refusal. diff --git a/.changeset/ev-battery-size-api.md b/.changeset/ev-battery-size-api.md new file mode 100644 index 00000000..d6c51d98 --- /dev/null +++ b/.changeset/ev-battery-size-api.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Let the charging view save the usual car’s usable battery size without replacing other settings. Apply a saved size through the shared config path and keep the current charge level steady. A failed save leaves the previous size in use. diff --git a/.changeset/ev-charger-limit-words.md b/.changeset/ev-charger-limit-words.md new file mode 100644 index 00000000..f1510d7d --- /dev/null +++ b/.changeset/ev-charger-limit-words.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Explain when the charger’s own current limit reduces a request, without blaming the main fuse. diff --git a/.changeset/ev-client-readiness.md b/.changeset/ev-client-readiness.md new file mode 100644 index 00000000..cbae8cc8 --- /dev/null +++ b/.changeset/ev-client-readiness.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Add Pause charging and Resume plan beside Charge now, with status that waits for the charger to stop. Let users change battery size beside the current level. Explain whether that level survives a restart, and never call a car that declined charge full. Keep the request deadline active until the response body arrives. diff --git a/.changeset/ev-command-acknowledgement.md b/.changeset/ev-command-acknowledgement.md new file mode 100644 index 00000000..9395a754 --- /dev/null +++ b/.changeset/ev-command-acknowledgement.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Keep a changed charging request pending until the controller processes that choice and receives a fresh charger reading. An earlier command cannot confirm a new current or pause. diff --git a/.changeset/ev-connected-status.md b/.changeset/ev-connected-status.md new file mode 100644 index 00000000..7d006778 --- /dev/null +++ b/.changeset/ev-connected-status.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Notify subscribed phones when a car is plugged in. Ignore first readings and recovery after an outage. Charging notifications carry the charger identity so the app can open its status and controls. diff --git a/.changeset/ev-connection-confirmation-copy.md b/.changeset/ev-connection-confirmation-copy.md new file mode 100644 index 00000000..3e6f2d8d --- /dev/null +++ b/.changeset/ev-connection-confirmation-copy.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Explain when charging needs a new choice because the charger or connection could not be confirmed, including after reconnecting. Keep the message separate from confirmation that current has stopped. diff --git a/.changeset/ev-effective-capacity.md b/.changeset/ev-effective-capacity.md new file mode 100644 index 00000000..4a397e6c --- /dev/null +++ b/.changeset/ev-effective-capacity.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Show when the current car uses a different battery size from the usual size just saved. diff --git a/.changeset/ev-failed-plan-dispatch.md b/.changeset/ev-failed-plan-dispatch.md new file mode 100644 index 00000000..c9470cda --- /dev/null +++ b/.changeset/ev-failed-plan-dispatch.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Stop dispatching an old plan when its replacement fails, so a removed charging goal cannot keep charging the car. Keep the current plan during normal recalculation, require a successful new plan after failure, and preserve manual Start and Pause with the usual safety limits. diff --git a/.changeset/ev-feedback-and-direct-controls.md b/.changeset/ev-feedback-and-direct-controls.md new file mode 100644 index 00000000..a7a0305b --- /dev/null +++ b/.changeset/ev-feedback-and-direct-controls.md @@ -0,0 +1,9 @@ +--- +"ftw": patch +--- + +Add a charger without inventing an ID or pressing Save again. Charger settings apply on change, with errors and a retry beside the form. OCPP setup stays separate from cloud chargers. + +Charging feedback separates the FTW request, the charger's reported limit and measured power. Old charger readings cannot claim current charging. Manual current changes apply on release; Return to plan names the action that ends a manual hold. Charge level and schedule writes run in order, and failed requests stay visible. + +Keep the charging goal and solar rule together, with no mode tabs. Show when Charge now overrides them. Show the current slider only while manual charging is active. Opening goal settings does not send a command. diff --git a/.changeset/ev-first-connection-path.md b/.changeset/ev-first-connection-path.md new file mode 100644 index 00000000..9b86f82a --- /dev/null +++ b/.changeset/ev-first-connection-path.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Guide the first charger connection from Chargers into the charger catalog and back after saving the connection. Hide the unrelated global Save button for charger autosave; keep explicit saves beside OCPP and shared-car settings. diff --git a/.changeset/ev-first-session-level.md b/.changeset/ev-first-session-level.md new file mode 100644 index 00000000..7de59c03 --- /dev/null +++ b/.changeset/ev-first-session-level.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Keep the battery level entered while waiting when the same charger first verifies its session at charging start. Count newly delivered energy from that level and save it once the session is verified. diff --git a/.changeset/ev-fuse-ceiling.md b/.changeset/ev-fuse-ceiling.md new file mode 100644 index 00000000..a3bcc854 --- /dev/null +++ b/.changeset/ev-fuse-ceiling.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Keep EV power below the fuse budget when the budget falls between charging steps or below the minimum. diff --git a/.changeset/ev-goal-save-errors.md b/.changeset/ev-goal-save-errors.md new file mode 100644 index 00000000..b3c076e2 --- /dev/null +++ b/.changeset/ev-goal-save-errors.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Save charging goals before applying them. If storage fails, keep the previous goal and return a clear error for both goal edits and removals. A successful retry applies and saves the new goal together. diff --git a/.changeset/ev-manual-hold-session-restore.md b/.changeset/ev-manual-hold-session-restore.md new file mode 100644 index 00000000..dc78adbc --- /dev/null +++ b/.changeset/ev-manual-hold-session-restore.md @@ -0,0 +1,7 @@ +--- +"ftw": patch +--- + +Bind a saved manual charging request to its charger hardware and verified charging session. Keep a saved pause on the same charger. When a prior positive request cannot be verified, pause and ask the owner to confirm instead of resuming automatic charging. Preserve explicit Start or Clear actions that arrive before the first charger reading. + +A running request also stops if the charger, session or loadpoint binding changes. A clear issued before telemetry survives another immediate restart, and concurrent Set/Clear writes preserve the order shown by the controller. diff --git a/.changeset/ev-manual-save-feedback.md b/.changeset/ev-manual-save-feedback.md new file mode 100644 index 00000000..03e15e10 --- /dev/null +++ b/.changeset/ev-manual-save-feedback.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Show when a charging choice applies now but could not be saved for restart. Retry its save when fresh charger data arrives and clear the message only after storage confirms it. diff --git a/.changeset/ev-missing-reading.md b/.changeset/ev-missing-reading.md new file mode 100644 index 00000000..b125959a --- /dev/null +++ b/.changeset/ev-missing-reading.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Preserve manual charging while the charger driver starts or has no reading. An absent reading no longer counts as an unplug. A confirmed unplug still ends the manual session. diff --git a/.changeset/ev-ocpp-reconnect-proof.md b/.changeset/ev-ocpp-reconnect-proof.md new file mode 100644 index 00000000..527d6d7d --- /dev/null +++ b/.changeset/ev-ocpp-reconnect-proof.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Ask OCPP chargers for fresh hardware identity after reconnecting, with bounded retries for missing replies and a safe fallback when the request is unsupported. Pause an older manual Start when hardware identity is lost, keep an explicit Pause, and allow a new Start to bind to the next verified identity. Keep cable status unknown during a network interruption, and preserve the reconnect boundary even when it falls between control ticks. diff --git a/.changeset/ev-ocpp-socket-boundary.md b/.changeset/ev-ocpp-socket-boundary.md new file mode 100644 index 00000000..80e71f3b --- /dev/null +++ b/.changeset/ev-ocpp-socket-boundary.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Keep OCPP messages and command replies bound to the connection they came from. A delayed status or BootNotification from an older connection can no longer clear Pause or replace the current charger's identity. Check capabilities again after reconnecting. diff --git a/.changeset/ev-owner-flow.md b/.changeset/ev-owner-flow.md new file mode 100644 index 00000000..2c949209 --- /dev/null +++ b/.changeset/ev-owner-flow.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Show the connected car and its next action on the home screen, with a direct route to charging controls. Keep stale status visible until a fresh reading confirms unplugging. Let the user choose the displayed first goal without changing its time or battery target. diff --git a/.changeset/ev-pause-and-limits.md b/.changeset/ev-pause-and-limits.md new file mode 100644 index 00000000..50da7d13 --- /dev/null +++ b/.changeset/ev-pause-and-limits.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Keep a manual pause until the user resumes or unplugs. Show when a pause is waiting for the charger and when it is confirmed. Keep manual charging available without a planner and enforce charger and installation limits on every manual request. diff --git a/.changeset/ev-release-current-request.md b/.changeset/ev-release-current-request.md new file mode 100644 index 00000000..5e679644 --- /dev/null +++ b/.changeset/ev-release-current-request.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Apply an automatic charging stop only if the manual request has not changed since the controller checked it. Keep a newer Pause, Start or slider change, and give each explicit retry a fresh wait for the car to draw current. diff --git a/.changeset/ev-remove-goal.md b/.changeset/ev-remove-goal.md new file mode 100644 index 00000000..28cd5992 --- /dev/null +++ b/.changeset/ev-remove-goal.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Remove the active derived target when a charging goal is removed. The old deadline no longer drives the planner after the UI says the goal is gone. A separate Charge now request continues unchanged. diff --git a/.changeset/ev-restart-choice.md b/.changeset/ev-restart-choice.md new file mode 100644 index 00000000..ca819546 --- /dev/null +++ b/.changeset/ev-restart-choice.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Ask how to continue when an earlier charge request cannot be matched after restart. Offer Charge now, Resume plan and Pause charging without calling it a user pause. Keep actual power visible until a changed current limit reaches the charger. diff --git a/.changeset/ev-save-before-planning.md b/.changeset/ev-save-before-planning.md new file mode 100644 index 00000000..d8018d12 --- /dev/null +++ b/.changeset/ev-save-before-planning.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Confirm saved charging settings before calculating the plan. Show that planning is in progress and keep old charging windows out of that state. diff --git a/.changeset/ev-soc-confirmation.md b/.changeset/ev-soc-confirmation.md new file mode 100644 index 00000000..24ea182f --- /dev/null +++ b/.changeset/ev-soc-confirmation.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Mark the default EV battery level as unconfirmed. Ask for the car's level after a box restart instead of presenting a calculation from the default as a confirmed estimate. An entered level still applies on slider release and survives a settings reload. diff --git a/.changeset/ev-solar-save-errors.md b/.changeset/ev-solar-save-errors.md new file mode 100644 index 00000000..5f7849b1 --- /dev/null +++ b/.changeset/ev-solar-save-errors.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Save the solar charging choice before applying it. If storage fails, keep the previous choice and reject the change in both the box UI and Webapp. A retry can save and apply the choice once storage recovers. diff --git a/.changeset/ev-unbound-manual-pause.md b/.changeset/ev-unbound-manual-pause.md new file mode 100644 index 00000000..f8728198 --- /dev/null +++ b/.changeset/ev-unbound-manual-pause.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Keep an explicit charging pause across restart even before the charger reports hardware identity. A prior Start without matching session proof restores only a pause that needs confirmation. Use the current OCPP connection's boot identity, and retry failed saves when charger data returns. diff --git a/.changeset/ev-unverified-session-reset.md b/.changeset/ev-unverified-session-reset.md new file mode 100644 index 00000000..637ff4b8 --- /dev/null +++ b/.changeset/ev-unverified-session-reset.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Pause a prior manual request when a session counter resets, including when the previous session had no verified ID. Keep an explicit Start when the same uninterrupted session gains its first verified ID. diff --git a/.changeset/manual-charge-feedback.md b/.changeset/manual-charge-feedback.md new file mode 100644 index 00000000..50b7d44c --- /dev/null +++ b/.changeset/manual-charge-feedback.md @@ -0,0 +1,7 @@ +--- +"ftw": patch +--- + +EV modal, Manual tab: after Charge now the line under the button follows the charger instead of repeating the request. It says that the amps were sent and the box is waiting for the charger to confirm, that the charger has taken the limit and the car has not started drawing, that the car is charging, that the charger offers the current but the car is not drawing it (with the charger's own reason, such as "EV not accepting current"), that the command stalled, or that the main fuse limits the charge right now — each with the time elapsed. The plan strip above the tabs says the same while a manual charge runs, so the charger's reason is no longer hidden behind the manual sentence. A refused Start (403, 404, 409) now reads as a failure with the server's reason instead of "Charging at 16 A". + +`GET /api/loadpoints` carries this as `manual` per loadpoint: `state` (`sent`, `accepted`, `charging`, `not_drawing`, `stalled`, `limited`), `started_at_ms`, `since_ms`, requested and commanded watts and amps, the charger's reported limit and reason. `POST …/manual_hold` answers with `started_at_ms`, and an Update of the amps keeps the first press as the start. `commanded_since_ms` says when the box's current order was first given. diff --git a/.changeset/portable-managed-driver-backups.md b/.changeset/portable-managed-driver-backups.md new file mode 100644 index 00000000..f6e9eb8d --- /dev/null +++ b/.changeset/portable-managed-driver-backups.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Full backups now include managed drivers whose active links use absolute paths inside the data directory. The archive stores relative links so restore works at a new path. Links that escape the data directory or form cycles remain blocked. diff --git a/.changeset/quiet-optimizer-pins.md b/.changeset/quiet-optimizer-pins.md new file mode 100644 index 00000000..6a6ad4a6 --- /dev/null +++ b/.changeset/quiet-optimizer-pins.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Keep the selected optimizer image after updates and rollbacks by saving and checking its Compose pin. Report a failed pin write instead of a successful update, preserve other host settings and file permissions, and keep shell payloads containing credentials out of updater logs. diff --git a/.changeset/restart-keeps-running-release.md b/.changeset/restart-keeps-running-release.md new file mode 100644 index 00000000..58e34fed --- /dev/null +++ b/.changeset/restart-keeps-running-release.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Restart restarts the existing container and keeps its exact image, including local test builds. It never pulls or recreates from a stale Compose tag. Core refuses the unsafe restart path on older updaters and explains how to update the updater. diff --git a/.changeset/savings-request-cancellation.md b/.changeset/savings-request-cancellation.md new file mode 100644 index 00000000..4b39e231 --- /dev/null +++ b/.changeset/savings-request-cancellation.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Stop savings database reads when an app request times out or is canceled, so the request releases its place for later app reads. Keep canceled calculations out of the daily savings cache. diff --git a/.changeset/settings-driver-paths.md b/.changeset/settings-driver-paths.md new file mode 100644 index 00000000..c3f7b3f2 --- /dev/null +++ b/.changeset/settings-driver-paths.md @@ -0,0 +1,5 @@ +--- +"ftw": patch +--- + +Keep driver paths consistent when settings are saved. Adding a charger no longer restarts other drivers with paths that fail to load. diff --git a/.changeset/surplus-only-ev-pv-beside-battery-grid.md b/.changeset/surplus-only-ev-pv-beside-battery-grid.md new file mode 100644 index 00000000..ab709578 --- /dev/null +++ b/.changeset/surplus-only-ev-pv-beside-battery-grid.md @@ -0,0 +1,7 @@ +--- +"ftw": patch +--- + +A surplus-only EV can take leftover PV while the home battery buys from the grid. Surplus-only is an EV policy, not a site-wide import ban: the car still cannot import, and the home battery still cannot feed the car. + +Ship a new optimizer image with this core. HiGHS still plans under the leftover constraint; an old optimizer image will keep idling the car on cheap sun and never produce the combo ValidatePlan now accepts. diff --git a/.github/workflows/optimizer-release.yml b/.github/workflows/optimizer-release.yml index a4a33f77..ceeaaf68 100644 --- a/.github/workflows/optimizer-release.yml +++ b/.github/workflows/optimizer-release.yml @@ -13,18 +13,42 @@ on: description: Optimizer SemVer without prefix (for example 1.3.2-beta.1 or 1.3.2) required: true type: string + dry_run: + description: Check package access and run tests without building images, tagging or publishing + required: true + type: boolean + default: true permissions: - contents: write - packages: write + contents: read concurrency: group: optimizer-${{ inputs.channel }}-${{ inputs.version }} cancel-in-progress: false jobs: + registry: + name: verify optimizer package write access + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - name: Verify canonical optimizer package writes + env: + GHCR_USERNAME: ${{ github.actor }} + GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + bash scripts/check-ghcr-write-access.sh srcfl/ftw-optimizer + echo 'Optimizer package write preflight passed; no image or tag was published.' + validate: name: validate independent optimizer version + needs: registry runs-on: ubuntu-latest outputs: release_tag: ${{ steps.version.outputs.release_tag }} @@ -167,8 +191,11 @@ jobs: publish: name: build optimizer image needs: [validate, test] - if: needs.validate.outputs.image_exists != 'true' + if: ${{ !inputs.dry_run && needs.validate.outputs.image_exists != 'true' }} runs-on: ubuntu-latest + permissions: + contents: read + packages: write steps: - uses: actions/checkout@v7 with: @@ -206,11 +233,14 @@ jobs: if: >- ${{ always() && + !inputs.dry_run && needs.validate.result == 'success' && needs.test.result == 'success' && (needs.publish.result == 'success' || needs.publish.result == 'skipped') }} runs-on: ubuntu-latest + permissions: + contents: write steps: - uses: actions/checkout@v7 with: @@ -239,3 +269,16 @@ jobs: if [ "${CHANNEL}" = beta ]; then prerelease=(--prerelease); fi gh release create "${TAG}" --repo "${GITHUB_REPOSITORY}" \ --title "FTW optimizer ${TAG#optimizer-}" --generate-notes --latest=false "${prerelease[@]}" + + dry-run: + name: confirm optimizer dry run + needs: [registry, validate, test] + if: ${{ inputs.dry_run }} + runs-on: ubuntu-latest + steps: + - name: Record validation result + env: + RELEASE_TAG: ${{ needs.validate.outputs.release_tag }} + run: | + printf 'Optimizer dry run passed for %s at %s. Package write access and tests passed. No image was built or published, and no git tag or release was created.\n' \ + "${RELEASE_TAG}" "${GITHUB_SHA}" | tee -a "${GITHUB_STEP_SUMMARY}" diff --git a/contract/push-catalogue.yaml b/contract/push-catalogue.yaml index 5f4a2e12..f34ab4c1 100644 --- a/contract/push-catalogue.yaml +++ b/contract/push-catalogue.yaml @@ -13,6 +13,9 @@ # lost every notification that mattered. version: 1 events: + - kind: charging.connected + title: Car plugged in + body: "Open to see charging status, check the battery level and set a ready time." - kind: charging.session_complete title: Car charged body: "{kwh} kWh delivered — ready to go." diff --git a/docs/backup-and-restore.md b/docs/backup-and-restore.md index 4a235714..b41aad35 100644 --- a/docs/backup-and-restore.md +++ b/docs/backup-and-restore.md @@ -24,6 +24,11 @@ FTW: 4. hashes every file, verifies the finished archive and runs SQLite `quick_check` before publishing it. +Managed-driver links that point inside the persistent directory become relative +links in the archive. Restore can therefore move the data to another directory +or machine. Backup never follows these links to copy a host file; verification +rejects link chains that escape the data directory or form a cycle. + Choose **Download**, save the `.ftwbak` file on another computer or USB disk, and keep at least one older known-good copy. **Verify** rechecks the server copy; it does not prove that a download exists elsewhere. diff --git a/docs/self-update.md b/docs/self-update.md index 5ef9e094..b9d42e9c 100644 --- a/docs/self-update.md +++ b/docs/self-update.md @@ -90,7 +90,13 @@ restored Core fails health. See [backup-and-restore.md](backup-and-restore.md). Optimizer-only updates use `optimizer-vX.Y.Z[-beta.N]`, recreate and health-check only `ftw-optimizer`, and never replace Core. Failure restores the -previous Optimizer image while Core continues on its Go fallback. +previous Optimizer image while Core continues on its Go fallback. After health +succeeds, both update and rollback save `FTW_OPTIMIZER_IMAGE_TAG` in the host +project's `.env` and check it before reporting success. Other settings, file +owner and mode stay intact. A pin write failure is reported as a failed +operation even if the optimizer is healthy; repair the host project and retry. +The host Compose image must use `${FTW_OPTIMIZER_IMAGE_TAG}` (an optional default +is allowed), or the operation stops before replacing the optimizer. A Driver update downloads one signed artifact, verifies hash, metadata and host API compatibility, then atomically activates exactly that version. Core puts @@ -120,7 +126,18 @@ The version badge selects `stable` or `beta`, checks availability and starts an update. Changing channel does not deploy anything. A skipped version remains hidden only until a newer version appears. -For manual Core + updater operation: +**Restart** stops and starts the existing Core container, then checks its health. +It keeps that container's image and environment, even if `.env` or Compose now +names a different release. It does not apply changes to Compose; use the update +flow for a new image. Core sends `restart_existing` so an older updater refuses +before it can pull or replace anything. If FTW reports that safe restart needs a +newer updater, update Core and updater together using the paired commands below. +A normal Core update also asks the updater to replace itself with the same tag +after Core passes its health check. + +For manual Core + updater operation, first set `FTW_IMAGE_TAG` and +`FTW_UPDATER_IMAGE_TAG` in the project's `.env` to the same published immutable +tag. Run these commands with the Core service name from your Compose file: ```bash cd ~/ftw diff --git a/drivers/BUNDLED_SOURCE.json b/drivers/BUNDLED_SOURCE.json index 57ab29e7..c3566b62 100644 --- a/drivers/BUNDLED_SOURCE.json +++ b/drivers/BUNDLED_SOURCE.json @@ -17,7 +17,7 @@ "for coverage. Run scripts/sync-bundled-drivers.sh to update." ], "repository": "srcfl/device-drivers", - "commit": "f98d5c5f3519fa90f010a69573aa8fcf8595c8b0", + "commit": "3890ca922a627fe0eee5df825a4fefe95cefd1b9", "source_dir": "drivers/lua", "drivers": [ "ambibox_v2x", "ctek", "ctek_hybrid", "ctek_v2", "deye", "easee_cloud", diff --git a/go/cmd/ftw-updater/env_pin.go b/go/cmd/ftw-updater/env_pin.go index 455b898c..46ce1f87 100644 --- a/go/cmd/ftw-updater/env_pin.go +++ b/go/cmd/ftw-updater/env_pin.go @@ -88,7 +88,7 @@ func mergeEnvFile(existing string, tags map[string]string) string { // Appending in a fixed order keeps the file stable across runs; map order // would otherwise reshuffle it and make every update look like a change. - for _, key := range []string{mainTagEnv, updaterTagEnv} { + for _, key := range []string{mainTagEnv, updaterTagEnv, optimizerTagEnv} { if value, ok := remaining[key]; ok { out = append(out, key+"="+value) } diff --git a/go/cmd/ftw-updater/main.go b/go/cmd/ftw-updater/main.go index b33b1d26..f988f984 100644 --- a/go/cmd/ftw-updater/main.go +++ b/go/cmd/ftw-updater/main.go @@ -126,6 +126,7 @@ type server struct { // Injectable so the ordering — only after a verified Core update, never able // to fail one — is testable without Docker. See self_replace.go. selfReplace func(target string) error + optimizerPin func(target string) error chownFile func(string, int, int) error checkSnapshotFile func(context.Context, string, string, string) error stageSnapshotFile func(context.Context, string, string, string, string) error @@ -286,6 +287,7 @@ func main() { defer cancel() return srv.replaceUpdater(ctx, target) } + srv.optimizerPin = srv.persistOptimizerPin srv.chownFile = os.Chown srv.checkSnapshotFile = func(ctx context.Context, containerID, snapshotID, file string) error { return srv.runner(ctx, nil, "exec", containerID, "test", "-f", "/app/data/snapshots/"+snapshotID+"/"+file) @@ -346,6 +348,11 @@ func (s *server) handleUpdate(w http.ResponseWriter, r *http.Request) { http.Error(w, "bad json: "+err.Error(), 400) return } + // A distinct action lets new Core fail closed on an old updater: old + // sidecars reject it before running Docker instead of pulling :latest. + if body.Action == "restart_existing" { + body.Action = "restart" + } if body.Component == "" { body.Component = "core" } @@ -372,9 +379,13 @@ func (s *server) handleUpdate(w http.ResponseWriter, r *http.Request) { return } case "restart": - // target optional — when empty, compose's `${FTW_IMAGE_TAG:-latest}` - // substitution falls through to :latest. That's the dev path for - // exercising the flow without a real release. + // Older Core may send a release target. Validate the old wire shape, + // but never use that hint to select an image during a restart. + if body.Target != "" && !isImmutableImageTag(body.Target) { + http.Error(w, "target must be stable vX.Y.Z or beta vX.Y.Z-beta.N", 400) + return + } + body.Target = "" case "rollback": if body.Snapshot == "" { http.Error(w, "rollback requires snapshot id", 400) @@ -451,19 +462,40 @@ func (s *server) handleStatus(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(st) } -// runJob executes a pull+up (or pull+up --force-recreate) sequence, -// emitting state transitions between steps. Runs inside a goroutine so -// the HTTP handler that kicked it off has already responded. -// -// When target is non-empty (always the case for action=update), it's -// passed as FTW_IMAGE_TAG= so docker-compose.yml's image tag -// substitution pulls the specific version. action=restart with empty -// target falls through to compose's default (`:latest`) — that's the -// dev path for exercising the flow without a real release. +// runJob dispatches a component update or a restart of its existing container. func (s *server) runJob(action, target string) { s.runComponentJob(action, target, "core", time.Time{}) } +// restartExisting never pulls or recreates a container. Its image ID, mounts +// and environment survive even when Compose or .env now names another build. +func (s *server) restartExisting(spec componentSpec, startedAt time.Time) { + st := State{State: "restarting", Action: "restart", Component: spec.name, + StartedAt: startedAt, PhaseStartedAt: time.Now(), UpdatedAt: time.Now(), + Message: "Restarting the existing container", Step: 1, TotalSteps: 3} + s.writeState(st) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + err := s.runWithStateHeartbeat(st, func() error { + return s.runner(ctx, nil, s.composeArgs("restart", "--no-deps", spec.service)...) + }) + cancel() + if err == nil && s.healthCheck != nil { + st.State, st.Message, st.Step = "checking", "Waiting for the service to become ready", 2 + st.PhaseStartedAt, st.UpdatedAt = time.Now(), time.Now() + s.writeState(st) + ctx, cancel = context.WithTimeout(context.Background(), componentHealthTimeout(spec.name)) + err = s.runWithStateHeartbeat(st, func() error { return s.healthCheck(ctx, spec.service) }) + cancel() + } + st.UpdatedAt = time.Now() + if err != nil { + st.State, st.Message = "failed", "restart failed: "+err.Error() + } else { + st.State, st.Message, st.Step = "done", "Service restarted and ready", 3 + } + s.writeState(st) +} + func (s *server) runComponentJob(action, target, component string, startedAt time.Time) { now := startedAt if now.IsZero() { @@ -474,6 +506,16 @@ func (s *server) runComponentJob(action, target, component string, startedAt tim s.writeState(State{State: "failed", Action: action, Component: component, Target: target, StartedAt: now, UpdatedAt: now, Message: err.Error()}) return } + if action == "restart" { + s.restartExisting(spec, now) + return + } + if action == "update" && spec.name == "optimizer" { + if err := s.validateOptimizerPinLayout(); err != nil { + s.writeState(State{State: "failed", Action: action, Component: component, Target: target, StartedAt: now, UpdatedAt: time.Now(), Message: "optimizer update blocked: " + err.Error()}) + return + } + } if action == "update" && spec.name == "core" { if err := s.requireHealthyOptimizer(); err != nil { msg := "core update blocked: " + err.Error() @@ -503,7 +545,7 @@ func (s *server) runComponentJob(action, target, component string, startedAt tim if target != "" { env = []string{spec.tagEnv + "=" + target} } - if action == "update" || action == "restart" { + if action == "update" { cleanup, err := s.prepareComponentImagePin(spec) if err != nil { msg := "compose preflight failed: " + err.Error() @@ -590,12 +632,6 @@ func (s *server) runComponentJob(action, target, component string, startedAt tim defer upCancel() upArgs := s.composeArgs("up", "-d", spec.service) - if action == "restart" { - // --force-recreate is what makes restart actually restart when the - // image digest didn't change — exactly the dev/test path the main - // UI exposes as the "Restart" button. - upArgs = s.composeArgs("up", "-d", "--force-recreate", spec.service) - } if err := s.runWithStateHeartbeat(restartState, func() error { return s.runner(upCtx, env, upArgs...) }); err != nil { @@ -626,6 +662,13 @@ func (s *server) runComponentJob(action, target, component string, startedAt tim } } + if spec.name == "optimizer" { + if err := s.saveOptimizerPin(target); err != nil { + s.writeState(State{State: "failed", Action: action, Component: component, Target: target, StartedAt: now, UpdatedAt: time.Now(), Message: "optimizer is ready, but its image pin was not saved: " + err.Error(), PreviousImageID: previousImageID}) + return + } + } + // The main container is now being recreated. The brand-new replica // will read this "done" state on startup and serve it to the UI that's // still polling in the browser. @@ -734,6 +777,11 @@ func (s *server) restorePreviousComponentImage(imageID string, spec componentSpe } func (s *server) restorePreviousComponentImageWithTag(imageID, previousTag string, spec componentSpec) error { + if spec.name == "optimizer" { + if err := s.validateOptimizerPinLayout(); err != nil { + return err + } + } image, ok, err := serviceImageFromComposeFiles(s.composeFiles(), spec.service) if err != nil { return err @@ -768,6 +816,11 @@ func (s *server) restorePreviousComponentImageWithTag(imageID, previousTag strin return fmt.Errorf("previous image health check: %w", err) } } + if spec.name == "optimizer" { + if err := s.saveOptimizerPin(rollbackTag); err != nil { + return fmt.Errorf("previous optimizer is ready, but its image pin was not saved: %w", err) + } + } return nil } @@ -1583,10 +1636,22 @@ func dockerCompose(ctx context.Context, extraEnv []string, args ...string) error if err != nil { return fmt.Errorf("%w: %s", err, truncate(string(out), 400)) } - slog.Info("docker compose ok", "args", args, "env", extraEnv, "out", truncate(string(out), 200)) + slog.Info("docker compose ok", "args", loggedDockerArgs(args), "env", extraEnv, "out", truncate(string(out), 200)) return nil } +// Shell payloads can contain the base64-encoded .env. Keep command shape in +// logs without publishing credentials in support bundles. +func loggedDockerArgs(args []string) []string { + redacted := append([]string(nil), args...) + for i, arg := range redacted { + if arg == "-c" && i+1 < len(redacted) { + redacted[i+1] = "[shell payload hidden]" + } + } + return redacted +} + func dockerOutput(ctx context.Context, args ...string) (string, error) { cmd := exec.CommandContext(ctx, "docker", args...) var stdout, stderr bytes.Buffer diff --git a/go/cmd/ftw-updater/main_test.go b/go/cmd/ftw-updater/main_test.go index fb24b25f..5e68bb7b 100644 --- a/go/cmd/ftw-updater/main_test.go +++ b/go/cmd/ftw-updater/main_test.go @@ -78,6 +78,7 @@ func newTestServer(t *testing.T) (*server, *fakeRunner) { imageID: func(context.Context, string) (string, error) { return "sha256:current", nil }, containerID: func(context.Context, string) (string, error) { return "ftw-container", nil }, chownFile: func(string, int, int) error { return nil }, + optimizerPin: func(string) error { return nil }, } s.checkSnapshotFile = func(_ context.Context, _ string, snapshotID, file string) error { _, err := os.Stat(filepath.Join(dir, "data", "snapshots", snapshotID, file)) @@ -319,7 +320,7 @@ func TestHandleUpdate_MissingOptimizerLeavesUserOverrideUntouched(t *testing.T) } } -func TestHandleUpdate_RestartForceRecreates(t *testing.T) { +func TestHandleUpdate_RestartDoesNotRecreate(t *testing.T) { s, runner := newTestServer(t) req := httptest.NewRequest(http.MethodPost, "/update", strings.NewReader(`{"action":"restart"}`)) rr := httptest.NewRecorder() @@ -328,9 +329,9 @@ func TestHandleUpdate_RestartForceRecreates(t *testing.T) { t.Fatalf("status = %d", rr.Code) } waitForState(t, s, "done") - up := strings.Join(runner.snapshot()[1], " ") - if !strings.Contains(up, "--force-recreate") { - t.Errorf("restart path must force-recreate: %v", up) + calls := runner.snapshot() + if len(calls) != 1 || strings.Join(calls[0], " ") != strings.Join(s.composeArgs("restart", "--no-deps", s.mainServiceName), " ") { + t.Fatalf("restart must only restart the existing container: %v", calls) } } @@ -473,7 +474,7 @@ func TestHandleUpdate_MigratesHardcodedImageWithTransientOverride(t *testing.T) } } -func TestHandleUpdate_RestartMigratesHardcodedImageWithTransientOverride(t *testing.T) { +func TestHandleUpdate_RestartPreservesHardcodedImage(t *testing.T) { s, runner := newTestServer(t) writeCompose(t, s.composeFile, `services: forty-two-watts: @@ -494,8 +495,8 @@ func TestHandleUpdate_RestartMigratesHardcodedImageWithTransientOverride(t *test waitForState(t, s, "done") for _, call := range runner.snapshot() { joined := strings.Join(call, " ") - if !strings.Contains(joined, "ftw-compose-update-") { - t.Fatalf("legacy restart must use compatibility override: %v", call) + if strings.Contains(joined, "ftw-compose-update-") || !strings.Contains(joined, "restart --no-deps") { + t.Fatalf("legacy restart must keep its container without a migration override: %v", call) } if call[len(call)-1] != legacyMainServiceName { t.Fatalf("legacy service identity must be preserved: %v", call) @@ -696,7 +697,7 @@ func TestValidateComponentImagePinRequiresExactVariable(t *testing.T) { } } -func TestComponentRollbackPinsUnsupportedOptimizerImages(t *testing.T) { +func TestComponentRollbackRejectsNonPersistentOptimizerImages(t *testing.T) { for _, image := range []string{ "ghcr.io/srcfl/ftw-optimizer:latest", "ghcr.io/srcfl/ftw-optimizer:${MY_TAG:-latest}", @@ -708,16 +709,13 @@ func TestComponentRollbackPinsUnsupportedOptimizerImages(t *testing.T) { s.runComponentRollback("optimizer", time.Now()) state := s.readState() - if state.State != "done" || state.Action != "component_rollback" { - t.Fatalf("rollback state = %+v", state) - } - calls, envs := runner.snapshot(), runner.envSnapshot() - if len(calls) != 2 || !strings.Contains(strings.Join(calls[0], " "), "image tag sha256:optimizer-old "+canonicalOptimizerImage+":ftw-rollback-") { - t.Fatalf("rollback calls = %v", calls) + if state.State != "failed" || !strings.Contains(state.Message, "must use ${FTW_OPTIMIZER_IMAGE_TAG}") { + t.Fatalf("rollback must reject a pin Compose cannot use: %+v", state) } - if len(envs) != 2 || len(envs[1]) != 1 || !strings.HasPrefix(envs[1][0], "FTW_OPTIMIZER_IMAGE_TAG=ftw-rollback-") { - t.Fatalf("rollback env = %v", envs) + if len(runner.snapshot()) != 0 { + t.Fatalf("unsupported rollback changed an image: %v", runner.snapshot()) } + }) } } @@ -744,21 +742,88 @@ func TestPrepareUpdateImagePin_WinsOverHardcodedUserOverride(t *testing.T) { } } -// `restart` is the dev path — no target needed, no env override, falls -// through to compose's :latest default. -func TestHandleUpdate_RestartLeavesEnvUnset(t *testing.T) { +func TestHandleUpdate_RestartKeepsEveryRunningImage(t *testing.T) { + for _, tc := range []struct{ name, image, target, action string }{ + {"beta with stale env", "ghcr.io/srcfl/ftw:v2.14.0-beta.1", "", "restart"}, + {"wrong caller version", "ghcr.io/srcfl/ftw:v2.14.0-beta.1", "v2.0.0", "restart"}, + {"moving tag", "ghcr.io/srcfl/ftw:latest", "", "restart"}, + {"local review", "ftw-ev-review:46d04a7a", "", "restart_existing"}, + {"digest", "ghcr.io/srcfl/ftw@sha256:abc123", "", "restart_existing"}, + } { + t.Run(tc.name, func(t *testing.T) { + s, runner := newTestServer(t) + writeCompose(t, s.composeFile, "services:\n ftw:\n image: "+tc.image+"\n") + writeCompose(t, filepath.Join(filepath.Dir(s.composeFile), ".env"), "FTW_IMAGE_TAG=v2.0.0-beta.2\n") + s.imageRef = func(context.Context, string) (string, error) { + t.Error("restart must not resolve an image") + return "", errors.New("inspect unavailable") + } + healthChecks := 0 + s.healthCheck = func(_ context.Context, service string) error { + healthChecks++ + if service != canonicalMainServiceName { + t.Errorf("health checked %s", service) + } + return nil + } + req := httptest.NewRequest(http.MethodPost, "/update", strings.NewReader(`{"action":"`+tc.action+`","target":"`+tc.target+`"}`)) + rr := httptest.NewRecorder() + s.handleUpdate(rr, req) + if rr.Code != http.StatusAccepted { + t.Fatalf("status = %d: %s", rr.Code, rr.Body.String()) + } + st := waitForState(t, s, "done") + if st.Action != "restart" || st.Target != "" || st.Step != st.TotalSteps { + t.Fatalf("restart state = %+v", st) + } + calls := runner.snapshot() + if len(calls) != 1 || strings.Join(calls[0], " ") != strings.Join(s.composeArgs("restart", "--no-deps", canonicalMainServiceName), " ") { + t.Fatalf("restart selected or pulled a replacement image: %v", calls) + } + for _, env := range runner.envSnapshot() { + if len(env) != 0 { + t.Fatalf("restart image env = %v", env) + } + } + if healthChecks != 1 { + t.Fatalf("health checks = %d", healthChecks) + } + pin, _ := os.ReadFile(filepath.Join(filepath.Dir(s.composeFile), ".env")) + if string(pin) != "FTW_IMAGE_TAG=v2.0.0-beta.2\n" { + t.Fatalf("restart rewrote .env: %s", pin) + } + }) + } +} + +func TestHandleUpdate_RestartRejectsMovingTarget(t *testing.T) { s, runner := newTestServer(t) - req := httptest.NewRequest(http.MethodPost, "/update", strings.NewReader(`{"action":"restart"}`)) + req := httptest.NewRequest(http.MethodPost, "/update", strings.NewReader(`{"action":"restart","target":"latest"}`)) rr := httptest.NewRecorder() s.handleUpdate(rr, req) - if rr.Code != 202 { - t.Fatalf("status = %d", rr.Code) + if rr.Code != 400 { + t.Fatalf("status = %d, want 400", rr.Code) } - waitForState(t, s, "done") - for i, env := range runner.envSnapshot() { - if len(env) != 0 { - t.Errorf("restart call %d should have no extra env, got %v", i, env) - } + if len(runner.snapshot()) != 0 { + t.Fatal("rejected restart called Docker") + } +} + +func TestRestartFailureDoesNotFallBackToRecreate(t *testing.T) { + for _, failed := range []string{"restart", "health"} { + t.Run(failed, func(t *testing.T) { + s, runner := newTestServer(t) + runner.fail = failed == "restart" + s.healthCheck = func(context.Context, string) error { return errors.New("health did not recover") } + s.runJob("restart", "v2.0.0") + st := s.readState() + if st.State != "failed" || !strings.Contains(st.Message, "restart failed") { + t.Fatalf("state = %+v", st) + } + if len(runner.snapshot()) != 1 { + t.Fatalf("failure must not pull/recreate: %v", runner.snapshot()) + } + }) } } diff --git a/go/cmd/ftw-updater/optimizer_pin.go b/go/cmd/ftw-updater/optimizer_pin.go new file mode 100644 index 00000000..ebeec456 --- /dev/null +++ b/go/cmd/ftw-updater/optimizer_pin.go @@ -0,0 +1,87 @@ +package main + +import ( + "context" + "crypto/sha256" + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +const optimizerTagEnv = "FTW_OPTIMIZER_IMAGE_TAG" + +func (s *server) validateOptimizerPinLayout() error { + image, ok, err := serviceImageFromComposeFiles(s.hostComposeFiles(), optimizerServiceName) + if err != nil { + return err + } + if !ok { + return fmt.Errorf("service %s has no host Compose image", optimizerServiceName) + } + if _, ok := composeImageRepositoryForTag(image, optimizerTagEnv); !ok { + return fmt.Errorf("service %s must use ${%s} in its image to preserve an update; change the host Compose image before updating", optimizerServiceName, optimizerTagEnv) + } + _, err = readEnvFile(filepath.Dir(s.composeFile)) + return err +} + +// persistOptimizerPin runs only after optimizer health succeeds. The updater's +// project mount is read-only, so a short helper uses the current updater image +// to write the pin, preserving the operator's other keys, owner and mode. +// Unlike best-effort updater replacement, this runs synchronously and verifies +// readback before the component operation may report done. +func (s *server) persistOptimizerPin(target string) error { + if err := s.validateOptimizerPinLayout(); err != nil { + return err + } + projectDir := filepath.Dir(s.composeFile) + existing, err := readEnvFile(projectDir) + if err != nil { + return err + } + content := mergeEnvFile(existing, map[string]string{optimizerTagEnv: target}) + service, err := s.updaterServiceName() + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if s.imageID == nil { + return fmt.Errorf("cannot identify updater image for persisting optimizer pin") + } + helperImage, err := s.imageID(ctx, service) + if err != nil { + return err + } + envPath := filepath.Join(projectDir, ".env") + tmpPath := envPath + ".ftw-optimizer-pin-tmp" + sum := fmt.Sprintf("%x", sha256.Sum256([]byte(existing))) + // Reject a file changed since read/merge; never overwrite a new operator + // setting with an old copy. Payload and paths remain shell-quoted. + check := fmt.Sprintf("if [ -e %s ]; then test \"$(sha256sum %s | cut -d ' ' -f 1)\" = %s; else test %s = %s; fi", shellQuote(envPath), shellQuote(envPath), shellQuote(sum), shellQuote(sum), shellQuote(fmt.Sprintf("%x", sha256.Sum256(nil)))) + script := "set -eu; " + check + "; test ! -L " + shellQuote(tmpPath) + "; " + envTempWriteScript(envPath, tmpPath, content) + " && { " + check + "; } && mv " + shellQuote(tmpPath) + " " + shellQuote(envPath) + args := []string{"run", "--rm", "--network", "none", "--user", "0:0", "-v", projectDir + ":" + projectDir, "--entrypoint", "sh", helperImage, "-c", script} + if err := s.runner(ctx, nil, args...); err != nil { + return fmt.Errorf("write optimizer image pin: %w", err) + } + got, err := os.ReadFile(envPath) + if err != nil { + return fmt.Errorf("read optimizer image pin: %w", err) + } + if string(got) != content { + return fmt.Errorf("optimizer image pin did not persist; retry after repairing %s", envPath) + } + return nil +} + +func (s *server) saveOptimizerPin(target string) error { + if s.optimizerPin == nil { + return fmt.Errorf("optimizer image pin persistence is unavailable") + } + if target == "" || strings.ContainsAny(target, "\r\n") { + return fmt.Errorf("invalid optimizer image pin") + } + return s.optimizerPin(target) +} diff --git a/go/cmd/ftw-updater/optimizer_pin_test.go b/go/cmd/ftw-updater/optimizer_pin_test.go new file mode 100644 index 00000000..a57000cf --- /dev/null +++ b/go/cmd/ftw-updater/optimizer_pin_test.go @@ -0,0 +1,228 @@ +package main + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "testing" + "time" +) + +func TestOptimizerPinPreservesEnvMetadataAndOtherTags(t *testing.T) { + s, _ := newTestServer(t) + writeCompose(t, s.composeFile, composeWithUpdater) + file := filepath.Join(filepath.Dir(s.composeFile), ".env") + original := "# keep this\nFTW_IMAGE_TAG=v2.14.0-beta.1\nFTW_UPDATER_IMAGE_TAG=v2.14.0-beta.1\nFTW_OPTIMIZER_IMAGE_TAG=old\nSECRET=a=b=c\nexport FTW_OPTIMIZER_IMAGE_TAG=v1.4.0-beta.3\n" + if err := os.WriteFile(file, []byte(original), 0o640); err != nil { + t.Fatal(err) + } + before, _ := os.Stat(file) + calls := 0 + s.runner = func(ctx context.Context, _ []string, args ...string) error { + calls++ + joined := strings.Join(args, " ") + if !strings.Contains(joined, "run --rm --network none --user 0:0") || strings.Contains(joined, "docker.sock") { + t.Fatalf("pin helper has unnecessary privileges: %v", args) + } + if args[len(args)-2] != "-c" { + t.Fatal("missing shell script") + } + out, err := exec.CommandContext(ctx, "sh", "-c", args[len(args)-1]).CombinedOutput() + if err != nil { + t.Fatalf("pin script: %v %s", err, out) + } + return nil + } + if err := s.persistOptimizerPin("v1.4.0-beta.4"); err != nil { + t.Fatal(err) + } + got, _ := os.ReadFile(file) + want := mergeEnvFile(original, map[string]string{optimizerTagEnv: "v1.4.0-beta.4"}) + if string(got) != want || strings.Count(string(got), optimizerTagEnv+"=") != 1 { + t.Fatalf("merged pin = %q", got) + } + after, _ := os.Stat(file) + b, a := before.Sys().(*syscall.Stat_t), after.Sys().(*syscall.Stat_t) + if before.Mode() != after.Mode() || b.Uid != a.Uid || b.Gid != a.Gid { + t.Fatal("pin changed owner or mode") + } + if calls != 1 { + t.Fatalf("helper calls=%d", calls) + } +} + +func TestOptimizerPinCreatesNewPrivateEnvAndChecksReadback(t *testing.T) { + for _, write := range []bool{true, false} { + t.Run(map[bool]string{true: "new file", false: "helper did not write"}[write], func(t *testing.T) { + s, _ := newTestServer(t) + writeCompose(t, s.composeFile, composeWithUpdater) + s.runner = func(ctx context.Context, _ []string, args ...string) error { + if !write { + return nil + } + return exec.CommandContext(ctx, "sh", "-c", args[len(args)-1]).Run() + } + err := s.persistOptimizerPin("v1.4.0-beta.4") + if !write { + if err == nil { + t.Fatal("missing readback accepted") + } + return + } + if err != nil { + t.Fatal(err) + } + st, err := os.Stat(filepath.Join(filepath.Dir(s.composeFile), ".env")) + if err != nil { + t.Fatal(err) + } + if st.Mode().Perm() != 0o600 { + t.Fatalf("new env mode=%o", st.Mode().Perm()) + } + }) + } +} + +func TestOptimizerPinDoesNotOverwriteConcurrentOperatorEdit(t *testing.T) { + s, _ := newTestServer(t) + writeCompose(t, s.composeFile, composeWithUpdater) + file := filepath.Join(filepath.Dir(s.composeFile), ".env") + if err := os.WriteFile(file, []byte("SITE=before\n"), 0o600); err != nil { + t.Fatal(err) + } + s.runner = func(ctx context.Context, _ []string, args ...string) error { + if err := os.WriteFile(file, []byte("SITE=operator-edit\n"), 0o600); err != nil { + return err + } + return exec.CommandContext(ctx, "sh", "-c", args[len(args)-1]).Run() + } + if err := s.persistOptimizerPin("v1.4.0-beta.4"); err == nil { + t.Fatal("concurrent edit overwritten") + } + got, _ := os.ReadFile(file) + if string(got) != "SITE=operator-edit\n" { + t.Fatalf("operator edit lost: %q", got) + } +} + +func TestOptimizerPinDoesNotReplaceEnvWhenMetadataCopyFails(t *testing.T) { + s, _ := newTestServer(t) + writeCompose(t, s.composeFile, composeWithUpdater) + file := filepath.Join(filepath.Dir(s.composeFile), ".env") + if err := os.WriteFile(file, []byte("SITE=current\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(file+".ftw-optimizer-pin-tmp", []byte("SITE=stale\n"), 0o600); err != nil { + t.Fatal(err) + } + s.runner = func(ctx context.Context, _ []string, args ...string) error { + // A failed metadata copy must not rename a leftover temporary file. + return exec.CommandContext(ctx, "sh", "-c", "cp() { return 1; }; "+args[len(args)-1]).Run() + } + if err := s.persistOptimizerPin("v1.4.0-beta.4"); err == nil { + t.Fatal("failed copy accepted") + } + got, _ := os.ReadFile(file) + if string(got) != "SITE=current\n" { + t.Fatalf("original env replaced after failed copy: %q", got) + } +} + +func TestOptimizerUpdateAndRollbackPersistBeforeDone(t *testing.T) { + for _, action := range []string{"update", "rollback"} { + for _, fail := range []bool{false, true} { + t.Run(action+map[bool]string{false: " success", true: " pin failure"}[fail], func(t *testing.T) { + s, _ := newTestServer(t) + healthy := false + s.healthCheck = func(context.Context, string) error { healthy = true; return nil } + var saved string + s.optimizerPin = func(target string) error { + if !healthy || s.readState().State == "done" { + t.Fatal("pin must follow health and precede done") + } + saved = target + if fail { + return errors.New("read-only project") + } + return nil + } + if action == "update" { + s.runComponentJob("update", "v1.4.0-beta.4", "optimizer", time.Now()) + } else { + s.writeState(State{State: "done", Component: "optimizer", PreviousImageID: "sha256:previous"}) + s.runComponentRollback("optimizer", time.Now()) + } + st := s.readState() + if fail { + if st.State != "failed" || !strings.Contains(st.Message, "image pin was not saved") { + t.Fatalf("pin failure hidden: %+v", st) + } + } else if st.State != "done" { + t.Fatalf("state=%+v", st) + } + if action == "update" && saved != "v1.4.0-beta.4" { + t.Fatalf("saved=%q", saved) + } + if action == "rollback" && !strings.HasPrefix(saved, "ftw-rollback-") { + t.Fatalf("rollback pin=%q", saved) + } + }) + } + } +} + +func TestDockerLogHidesEnvShellPayloadWithoutChangingCommand(t *testing.T) { + args := []string{"run", "--entrypoint", "sh", "image", "-c", "echo SECRET=base64-payload"} + logged := loggedDockerArgs(args) + if strings.Contains(strings.Join(logged, " "), "SECRET") { + t.Fatal("shell secret logged") + } + if args[5] != "echo SECRET=base64-payload" { + t.Fatal("log redaction changed executed arguments") + } +} + +func TestOptimizerUpdateRejectsNonPersistentLayoutBeforeDocker(t *testing.T) { + s, runner := newTestServer(t) + writeCompose(t, s.composeFile, "services:\n ftw-optimizer:\n image: ghcr.io/srcfl/ftw-optimizer:latest\n") + s.runComponentJob("update", "v1.4.0-beta.4", "optimizer", time.Now()) + st := s.readState() + if st.State != "failed" || !strings.Contains(st.Message, "must use ${FTW_OPTIMIZER_IMAGE_TAG}") { + t.Fatalf("unusable pin accepted: %+v", st) + } + if len(runner.snapshot()) != 0 { + t.Fatal("blocked update called Docker") + } +} + +func TestOptimizerFailedHealthRestoresPreviousPersistentTag(t *testing.T) { + s, _ := newTestServer(t) + s.imageRef = func(context.Context, string) (string, error) { + return canonicalOptimizerImage + ":v1.4.0-beta.3", nil + } + checks := 0 + s.healthCheck = func(context.Context, string) error { + checks++ + if checks == 1 { + return errors.New("new optimizer unhealthy") + } + return nil + } + var saved string + s.optimizerPin = func(target string) error { + if checks != 2 { + t.Fatal("previous tag saved before recovery health") + } + saved = target + return nil + } + s.runComponentJob("update", "v1.4.0-beta.4", "optimizer", time.Now()) + st := s.readState() + if st.State != "failed" || !strings.Contains(st.Message, "previous image restored") || saved != "v1.4.0-beta.3" { + t.Fatalf("recovery did not persist previous image: %+v saved=%q", st, saved) + } +} diff --git a/go/cmd/ftw-updater/self_replace_test.go b/go/cmd/ftw-updater/self_replace_test.go index 0b86f1bc..2221fa30 100644 --- a/go/cmd/ftw-updater/self_replace_test.go +++ b/go/cmd/ftw-updater/self_replace_test.go @@ -268,3 +268,26 @@ func TestIsUpdaterImage(t *testing.T) { } } } + +func TestBetaUpdateReplacesUpdaterWithTheSameCandidate(t *testing.T) { + s, _ := newTestServer(t) + healthy := false + s.healthCheck = func(_ context.Context, service string) error { + if service == canonicalMainServiceName { + healthy = true + } + return nil + } + var replacement string + s.selfReplace = func(target string) error { + if !healthy || s.readState().State != "done" { + t.Error("updater replacement preceded healthy Core") + } + replacement = target + return nil + } + s.runJob("update", "v2.15.0-beta.1") + if replacement != "v2.15.0-beta.1" { + t.Fatalf("updater replacement = %q", replacement) + } +} diff --git a/go/cmd/ftw/app_link.go b/go/cmd/ftw/app_link.go index edc2bb5f..66a893d6 100644 --- a/go/cmd/ftw/app_link.go +++ b/go/cmd/ftw/app_link.go @@ -392,7 +392,7 @@ func (a *appLoadpoints) Boost(id string, lease loadpoint.BatteryBoostLease, now // its next scheduled run. Off this goroutine and unattached to the // session — a phone that drops its socket right after tapping must // not abort the planner mid-run. - go a.mpc.ReplanWithReason(context.Background(), "loadpoint_battery_boost_enabled") + a.mpc.RequestReplan("loadpoint_battery_boost_enabled") } return nil } @@ -411,10 +411,7 @@ func (a *appLoadpoints) SetSoC(id string, soc float64) bool { return false } if a.mpc != nil { - // Before returning, on a fresh context, for the reason the HTTP - // route gives: the plan pushed after the result must be drawn from - // the corrected level, not from the estimate it replaced. - a.mpc.ReplanWithReason(context.Background(), "loadpoint_soc_corrected") + a.mpc.RequestReplan("loadpoint_soc_corrected") } return true } @@ -431,15 +428,10 @@ func (a *appLoadpoints) SetSurplusOnly(id string, v bool) (bool, bool) { } if a.mpc != nil { if prev && !v { - // Turning PV-only off is a regime change: the car may now - // import from the grid. The same synchronous, tagged replan the - // HTTP target route forces, so the plan pushed after the result - // already says so. - slog.Info("loadpoint surplus_only disabled — forcing replan", "lp", id) - a.mpc.ReplanWithReason(context.Background(), "surplus_only_disabled") + slog.Info("loadpoint surplus_only disabled — requesting replan", "lp", id) + a.mpc.RequestReplan("surplus_only_disabled") } else { - // Any other edit gets the HTTP route's background nudge. - go a.mpc.ReplanWithReason(context.Background(), "loadpoint_target_changed") + a.mpc.RequestReplan("loadpoint_target_changed") } } return prev, true diff --git a/go/cmd/ftw/app_link_replan_ack_test.go b/go/cmd/ftw/app_link_replan_ack_test.go new file mode 100644 index 00000000..07bd0c9b --- /dev/null +++ b/go/cmd/ftw/app_link_replan_ack_test.go @@ -0,0 +1,83 @@ +package main + +import ( + "path/filepath" + "sync" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/mpc" + "github.com/srcfl/ftw/go/internal/state" +) + +func TestAppEVEditsConfirmWhilePlanIsBlocked(t *testing.T) { + for _, which := range []string{"soc", "solar"} { + t.Run(which, func(t *testing.T) { + st, err := state.Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + now := time.Now().UTC().Truncate(15 * time.Minute) + if err := st.SavePrices([]state.PricePoint{{Zone: "SE4", SlotTsMs: now.UnixMilli(), SlotLenMin: 15, SpotOreKwh: 50, TotalOreKwh: 100}}); err != nil { + t.Fatal(err) + } + svc := mpc.New(st, nil, "SE4", mpc.Params{Mode: mpc.ModeSelfConsumption, SoCLevels: 11, ActionLevels: 5, CapacityWh: 10000, InitialSoC: .5, SoCMin: .1, SoCMax: .95, MaxChargeW: 3000, MaxDischargeW: 3000, ChargeEfficiency: .95, DischargeEfficiency: .95}) + svc.Horizon = time.Hour + svc.BaseLoad = 500 + entered, release := make(chan struct{}), make(chan struct{}) + var enterOnce, releaseOnce sync.Once + unblock := func() { releaseOnce.Do(func() { close(release) }) } + t.Cleanup(func() { + unblock() + until := time.Now().Add(5 * time.Second) + for svc.IsReplanning() { + if time.Now().After(until) { + t.Fatal("planner did not finish") + } + time.Sleep(time.Millisecond) + } + }) + svc.PV = func(time.Time, float64) float64 { enterOnce.Do(func() { close(entered) }); <-release; return 0 } + mgr := loadpoint.NewManager() + mgr.Load([]loadpoint.Config{{ID: "garage", DriverName: "easee", VehicleCapacityWh: 60000, SurplusOnly: true}}) + mgr.Observe("garage", true, 0, 0, true) + port := &appLoadpoints{mgr: mgr, mpc: svc} + done := make(chan bool, 1) + go func() { + if which == "soc" { + done <- port.SetSoC("garage", .62) + } else { + _, ok := port.SetSurplusOnly("garage", false) + done <- ok + } + }() + select { + case <-entered: + case <-time.After(time.Second): + t.Fatal("planner did not start") + } + select { + case ok := <-done: + if !ok { + t.Fatal("edit refused") + } + case <-time.After(time.Second): + unblock() + <-done + t.Fatal("saved edit waited for planner") + } + got, _ := mgr.State("garage") + if which == "soc" && got.CurrentSoC != .62 { + t.Fatalf("soc=%v", got.CurrentSoC) + } + if which == "solar" && got.SurplusOnly { + t.Fatal("solar choice not applied") + } + if !svc.PlanSnapshot().Pending { + t.Fatal("pending plan hidden") + } + }) + } +} diff --git a/go/cmd/ftw/app_link_solar_save_test.go b/go/cmd/ftw/app_link_solar_save_test.go new file mode 100644 index 00000000..5667bcb5 --- /dev/null +++ b/go/cmd/ftw/app_link_solar_save_test.go @@ -0,0 +1,42 @@ +package main + +import ( + "errors" + "testing" + + "github.com/srcfl/ftw/go/internal/loadpoint" +) + +func TestAppSolarPreferenceRejectsFailedSaveAndAcceptsRetry(t *testing.T) { + m := loadpoint.NewManager() + m.Load([]loadpoint.Config{{ID: "garage", DriverName: "easee", SurplusOnly: true}}) + failure := errors.New("disk full") + saved := true + m.SetSurplusOnlySaver(func(_ string, v bool) error { + if failure != nil { + return failure + } + saved = v + return nil + }) + if previous, found, err := m.SetSurplusOnlyChecked("garage", false); !previous || !found || !errors.Is(err, failure) { + t.Fatalf("checked setter = %v, %v, %v", previous, found, err) + } + if previous, ok := m.SetSurplusOnly("garage", false); !previous || ok { + t.Fatalf("compatibility wrapper = %v, %v", previous, ok) + } + port := &appLoadpoints{mgr: m} + if _, ok := port.SetSurplusOnly("garage", false); ok { + t.Fatal("app port accepted a failed save") + } + if actual, ok := port.ObservedSurplusOnly("garage"); !ok || !actual || !saved { + t.Fatalf("failed save changed the active choice: actual=%v known=%v saved=%v", actual, ok, saved) + } + failure = nil + if previous, ok := port.SetSurplusOnly("garage", false); !ok || !previous { + t.Fatalf("retry = %v, %v", previous, ok) + } + if actual, ok := port.ObservedSurplusOnly("garage"); !ok || actual || saved { + t.Fatalf("retry did not save the choice: actual=%v known=%v saved=%v", actual, ok, saved) + } +} diff --git a/go/cmd/ftw/control_state_test.go b/go/cmd/ftw/control_state_test.go index 07f975ae..6c6aa6c9 100644 --- a/go/cmd/ftw/control_state_test.go +++ b/go/cmd/ftw/control_state_test.go @@ -37,7 +37,7 @@ func TestControlSlotDirectiveFromMPCPreservesDecisionIdentity(t *testing.T) { LoadpointEnergyWh: loadpoints, } - got := controlSlotDirectiveFromMPC(in) + got := control.SlotDirectiveFromMPC(in) if got.DecisionID != in.DecisionID || !got.SlotStart.Equal(in.SlotStart) || !got.SlotEnd.Equal(in.SlotEnd) { t.Fatalf("identity or timing changed across adapter: %+v", got) } diff --git a/go/cmd/ftw/ev_observation.go b/go/cmd/ftw/ev_observation.go new file mode 100644 index 00000000..7199f9cd --- /dev/null +++ b/go/cmd/ftw/ev_observation.go @@ -0,0 +1,55 @@ +package main + +import ( + "encoding/json" + "time" + + "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/ocpp" + "github.com/srcfl/ftw/go/internal/state" + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// A missing or stale cloud response is not an unplug. Keep the last session +// until a fresh, valid reading reports the connection has ended. +func currentEVSample(r *telemetry.DerReading, health *telemetry.DriverHealth, watchdog time.Duration, now time.Time, ocppOnline bool, deviceID string) (loadpoint.EVSample, bool) { + if watchdog <= 0 { + watchdog = time.Minute + } + if r == nil || r.UpdatedAt.IsZero() || now.Sub(r.UpdatedAt) > watchdog { + return loadpoint.EVSample{}, false + } + var d struct { + ConnectionGeneration uint64 `json:"connection_generation"` + ConnectionUnknown bool `json:"connection_unknown"` + Connected *bool `json:"connected"` + SessionWh float64 `json:"session_wh"` + RequestActive *bool `json:"request_active"` + SessionID string `json:"session_id"` + } + if json.Unmarshal(r.Data, &d) != nil || d.SessionWh < 0 { + return loadpoint.EVSample{}, false + } + if d.ConnectionUnknown { + return loadpoint.EVSample{ConnectionUnknown: true, ConnectionGeneration: d.ConnectionGeneration}, true + } + if d.Connected == nil || (!ocppOnline && (health == nil || !health.TelemetryLive())) { + return loadpoint.EVSample{}, false + } + active := true + if d.RequestActive != nil { + active = *d.RequestActive + } + return loadpoint.EVSample{ConnectionGeneration: d.ConnectionGeneration, PowerW: r.SmoothedW, SessionWh: d.SessionWh, + Connected: *d.Connected, RequestActive: active, DeviceID: deviceID, SessionID: d.SessionID}, true +} + +// OCPP has no driver registry entry. Only the current adopted charger's +// BootNotification supplies hardware identity; its dialled name is an address. +func currentOCPPDeviceID(h *ocpp.Handler, name string) string { + ident, ok := h.CurrentIdentity(name) + if !ok || ident.Serial == "" { + return "" + } + return state.ResolveDeviceID(ident.Vendor, ident.Serial, "", "") +} diff --git a/go/cmd/ftw/ev_observation_test.go b/go/cmd/ftw/ev_observation_test.go new file mode 100644 index 00000000..9f630d1d --- /dev/null +++ b/go/cmd/ftw/ev_observation_test.go @@ -0,0 +1,34 @@ +package main + +import ( + "encoding/json" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +func TestEVObservationPreservesSessionWhenCloudIsStale(t *testing.T) { + now := time.Now() + health := &telemetry.DriverHealth{Status: telemetry.StatusOk} + r := &telemetry.DerReading{UpdatedAt: now, SmoothedW: 4140, Data: json.RawMessage(`{"connected":true,"session_wh":600,"session_id":"728"}`)} + s, ok := currentEVSample(r, health, time.Minute, now, false, "sn:charger") + if !ok || !s.Connected || !s.RequestActive || s.SessionID != "728" || s.DeviceID != "sn:charger" { + t.Fatalf("valid session lost: %+v %v", s, ok) + } + r.UpdatedAt = now.Add(-2 * time.Minute) + if _, ok := currentEVSample(r, health, time.Minute, now, false, "sn:charger"); ok { + t.Fatal("stale reading accepted") + } + r.UpdatedAt = now + for _, body := range []string{`{`, `{}`, `{"connected":true,"session_wh":-1}`} { + r.Data = json.RawMessage(body) + if _, ok := currentEVSample(r, health, time.Minute, now, false, "sn:charger"); ok { + t.Fatalf("bad response became a session reading: %s", body) + } + } + r.Data = json.RawMessage(`{"connected":false}`) + if s, ok := currentEVSample(r, nil, time.Minute, now, true, ""); !ok || s.Connected { + t.Fatalf("fresh OCPP unplug lost: %+v %v", s, ok) + } +} diff --git a/go/cmd/ftw/ev_ocpp_identity_test.go b/go/cmd/ftw/ev_ocpp_identity_test.go new file mode 100644 index 00000000..da7c23d8 --- /dev/null +++ b/go/cmd/ftw/ev_ocpp_identity_test.go @@ -0,0 +1,49 @@ +package main + +import ( + "testing" + + "github.com/lorenzodonini/ocpp-go/ocpp1.6/core" + "github.com/srcfl/ftw/go/internal/ocpp" + "github.com/srcfl/ftw/go/internal/telemetry" +) + +func TestEVSampleUsesCurrentOCPPBootHardware(t *testing.T) { + h := ocpp.NewHandler(telemetry.NewStore(), 60) + h.SetApprovedIDs([]string{"garage"}) + h.OnConnect("garage") + if got := currentOCPPDeviceID(h, "garage"); got != "" { + t.Fatalf("pre-boot name became identity: %q", got) + } + boot := &core.BootNotificationRequest{ChargePointVendor: "Easee", ChargePointModel: "Home", ChargePointSerialNumber: "A"} + if _, err := h.OnBootNotification("garage", boot); err != nil { + t.Fatal(err) + } + if got := currentOCPPDeviceID(h, "garage"); got != "easee:A" { + t.Fatalf("hardware not bound: %q", got) + } + h.OnDisconnect("garage") + if got := currentOCPPDeviceID(h, "garage"); got != "" { + t.Fatalf("offline identity trusted: %q", got) + } + h.OnConnect("garage") + if got := currentOCPPDeviceID(h, "garage"); got != "" { + t.Fatalf("reconnect reused previous boot identity: %q", got) + } + boot.ChargePointSerialNumber = "B" + h.OnBootNotification("garage", boot) + if got := currentOCPPDeviceID(h, "garage"); got != "easee:B" { + t.Fatalf("replacement inherited prior identity: %q", got) + } + boot.ChargePointSerialNumber = "" + h.OnBootNotification("garage", boot) + if got := currentOCPPDeviceID(h, "garage"); got != "" { + t.Fatalf("missing serial reused prior identity: %q", got) + } + h.OnConnect("pending") + boot.ChargePointSerialNumber = "C" + h.OnBootNotification("pending", boot) + if got := currentOCPPDeviceID(h, "pending"); got != "" { + t.Fatalf("unadopted hardware trusted: %q", got) + } +} diff --git a/go/cmd/ftw/ev_ocpp_reconnect_test.go b/go/cmd/ftw/ev_ocpp_reconnect_test.go new file mode 100644 index 00000000..b9e6e0c4 --- /dev/null +++ b/go/cmd/ftw/ev_ocpp_reconnect_test.go @@ -0,0 +1,199 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "net/http/httptest" + "testing" + "time" + + ocpp16 "github.com/lorenzodonini/ocpp-go/ocpp1.6" + "github.com/lorenzodonini/ocpp-go/ocpp1.6/core" + "github.com/srcfl/ftw/go/internal/api" + "github.com/srcfl/ftw/go/internal/events" + "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/ocpp" + "github.com/srcfl/ftw/go/internal/telemetry" +) + +func TestOCPPReconnectDoesNotUnplugOrResumeOldStart(t *testing.T) { + for _, power := range []float64{0, 4140} { + for _, skipUnknown := range []bool{false, true} { + t.Run(fmt.Sprintf("power=%v/fast=%v", power, skipUnknown), func(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + port := listener.Addr().(*net.TCPAddr).Port + listener.Close() + tel := telemetry.NewStore() + srv, err := ocpp.Start(context.Background(), &ocpp.Config{Enabled: true, Bind: "127.0.0.1", Port: port, ApprovedIDs: []string{"garage"}}, tel) + if err != nil { + t.Fatal(err) + } + defer srv.Stop() + await := func(fn func() bool) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for !fn() { + if time.Now().After(deadline) { + t.Fatal("OCPP state did not settle") + } + time.Sleep(time.Millisecond) + } + } + addr := fmt.Sprintf("127.0.0.1:%d", port) + await(func() bool { + c, e := net.DialTimeout("tcp", addr, 10*time.Millisecond) + if e != nil { + return false + } + c.Close() + return true + }) + connect := func() ocpp16.ChargePoint { + t.Helper() + cp := ocpp16.NewChargePoint("garage", nil, nil) + if e := cp.Start("ws://" + addr); e != nil { + t.Fatal(e) + } + return cp + } + boot := func(cp ocpp16.ChargePoint, serial string) { + t.Helper() + _, e := cp.BootNotification("Home", "Easee", func(r *core.BootNotificationRequest) { r.ChargePointSerialNumber = serial }) + if e != nil { + t.Fatal(e) + } + } + status := func(cp ocpp16.ChargePoint, connected bool) { + t.Helper() + s := core.ChargePointStatusAvailable + if connected { + s = core.ChargePointStatusSuspendedEVSE + } + if _, e := cp.StatusNotification(1, core.NoError, s); e != nil { + t.Fatal(e) + } + } + cp := connect() + boot(cp, "A") + status(cp, true) + manager := loadpoint.NewManager() + manager.Load([]loadpoint.Config{{ID: "garage", DriverName: "garage", MinChargeW: 1380, MaxChargeW: 11040, VehicleCapacityWh: 60000}}) + now := time.Now() + manager.SetNowFn(func() time.Time { return now }) + bus := events.NewBus() + manager.SetBus(bus) + connectedEvents := 0 + bus.Subscribe(events.KindChargingConnected, func(events.Event) { connectedEvents++ }) + bus.Publish(events.HealthTick{Health: map[string]telemetry.DriverHealth{"garage": {Status: telemetry.StatusOk, LastSuccess: &now}}, Now: now}) + commands := []float64{} + ctrl := loadpoint.NewController(manager, func(time.Time) (loadpoint.Directive, bool) { return loadpoint.Directive{}, false }, func(name string) (loadpoint.EVSample, bool) { + return currentEVSample(tel.Get(name, telemetry.DerEV), tel.DriverHealth(name), time.Minute, time.Now(), srv.Handler().IsOnline(name), currentOCPPDeviceID(srv.Handler(), name)) + }, func(_ context.Context, _ string, p []byte) error { + var d struct { + Power float64 `json:"power_w"` + } + if e := json.Unmarshal(p, &d); e != nil { + return e + } + commands = append(commands, d.Power) + return nil + }) + ctrl.SetSiteFuse(loadpoint.SiteFuse{MaxAmps: 16, Voltage: 230, PhaseCnt: 3}) + tick := func() { ctrl.Tick(context.Background(), now); now = now.Add(3 * time.Second) } + tick() + manager.SetCurrentSoC("garage", .12) + ctrl.SetManualHold("garage", loadpoint.ManualHold{Persistent: true, PowerW: power}) + tick() + before, _ := manager.State("garage") + commandCount := len(commands) + cp.Stop() + await(func() bool { + r := tel.Get("garage", telemetry.DerEV) + var d struct { + Unknown bool `json:"connection_unknown"` + } + return r != nil && json.Unmarshal(r.Data, &d) == nil && d.Unknown && !srv.Handler().IsOnline("garage") + }) + checkUnknown := func() { + t.Helper() + tick() + st, _ := manager.State("garage") + hold, ok := ctrl.GetManualHold("garage", now) + if !ok || hold.PowerW != 0 || !st.PluggedIn || st.ManualRestoreUnconfirmed != (power > 0) { + t.Fatalf("unknown socket lost safety: hold=%+v state=%+v", hold, st) + } + if len(commands) != commandCount { + t.Fatal("unknown cable dispatched a command") + } + if st.UpdatedAtMs != before.UpdatedAtMs { + t.Fatal("socket event refreshed physical observation") + } + if st.SoCSource != "assumed" { + t.Fatalf("kept confirmed SoC after identity loss: %s", st.SoCSource) + } + endpoint := api.New(&api.Deps{Loadpoints: manager, LoadpointCtrl: ctrl, Tel: tel}) + rr := httptest.NewRecorder() + endpoint.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/loadpoints", nil)) + var body struct { + Loadpoints []loadpoint.State `json:"loadpoints"` + } + if e := json.Unmarshal(rr.Body.Bytes(), &body); e != nil { + t.Fatal(e) + } + if len(body.Loadpoints) != 1 || body.Loadpoints[0].Charger.Available || body.Loadpoints[0].Manual.State != loadpoint.ManualUnavailable { + t.Fatalf("unknown socket became pause acknowledgement: %s", rr.Body.String()) + } + } + if !skipUnknown { + checkUnknown() + } + cp = connect() + defer cp.Stop() + serial := "B" + if skipUnknown { + serial = "A" + } + boot(cp, serial) + if !skipUnknown { + checkUnknown() + } + status(cp, true) + tick() + hold, ok := ctrl.GetManualHold("garage", now) + if !ok || hold.PowerW != 0 || commands[len(commands)-1] != 0 { + t.Fatalf("status revived an old Start: %+v %v", hold, commands) + } + if st, _ := manager.State("garage"); st.ManualRestoreUnconfirmed != (power > 0) { + t.Fatal("status changed required confirmation") + } + if connectedEvents != 0 { + t.Fatalf("socket reconnect invented %d plug notifications", connectedEvents) + } + ctrl.SetManualHold("garage", loadpoint.ManualHold{Persistent: true, PowerW: 4140}) + tick() + if commands[len(commands)-1] != 4140 { + t.Fatal("fresh explicit Start did not work") + } + status(cp, false) + tick() + if _, ok := ctrl.GetManualHold("garage", now); ok { + t.Fatal("physical unplug did not clear hold") + } + if st, _ := manager.State("garage"); st.PluggedIn { + t.Fatal("physical unplug not recorded") + } + status(cp, true) + tick() + if connectedEvents != 1 { + t.Fatalf("real plug edge notifications=%d", connectedEvents) + } + }) + } + } +} diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index c577c7e3..24da4f23 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -136,24 +136,6 @@ func decimalDigits(s string) bool { return s != "" } -// controlSlotDirectiveFromMPC keeps the import-cycle bridge explicit. The -// decision ID is report metadata; control does not use it for dispatch math. -func controlSlotDirectiveFromMPC(d mpc.SlotDirective) control.SlotDirective { - return control.SlotDirective{ - DecisionID: d.DecisionID, - SlotStart: d.SlotStart, - SlotEnd: d.SlotEnd, - BatteryEnergyWh: d.BatteryEnergyWh, - SoCTarget: d.SoCTarget, - Strategy: string(d.Strategy), - PVLimitW: d.PVLimitW, - PlannedGridW: d.GridW, - HasPlannedGridW: true, - LivePVSurplusSoCCap: d.LivePVSurplusSoCCap, - LoadpointEnergyWh: d.LoadpointEnergyWh, - } -} - // siteIdentityLoad is the machine's own identity, not a user's. // // Bound is set when nova.key has been adopted into a hardware-protected @@ -766,6 +748,7 @@ func main() { // The planner consumes loadpoint state so battery and EV can be // co-optimized in one DP. lpMgr := loadpoint.NewManager() + lpMgr.SetSessionStore(st) if len(cfg.Loadpoints) > 0 { lpMgr.Load(buildLoadpointConfigs(cfg.Loadpoints)) slog.Info("loadpoints configured", "count", len(cfg.Loadpoints)) @@ -775,22 +758,16 @@ func main() { // schedule writes the empty JSON ("{}"), which HydrateSchedules // treats as no-config so a future reload doesn't resurrect it. const lpSchedKeyPrefix = "loadpoint_schedule:" - lpMgr.SetScheduleSaver(func(id string, s loadpoint.Schedule) { + lpMgr.SetScheduleSaver(func(id string, s loadpoint.Schedule) error { key := lpSchedKeyPrefix + id if s.Empty() { - if err := st.SaveConfig(key, "{}"); err != nil { - slog.Warn("failed to clear loadpoint schedule", "lp", id, "err", err) - } - return + return st.SaveConfig(key, "{}") } b, err := json.Marshal(s) if err != nil { - slog.Warn("failed to marshal loadpoint schedule", "lp", id, "err", err) - return - } - if err := st.SaveConfig(key, string(b)); err != nil { - slog.Warn("failed to persist loadpoint schedule", "lp", id, "err", err) + return err } + return st.SaveConfig(key, string(b)) }) lpMgr.HydrateSchedules(func(id string) (loadpoint.Schedule, bool) { v, ok := st.LoadConfig(lpSchedKeyPrefix + id) @@ -811,15 +788,13 @@ func main() { // YAML, so the previous in-memory-only behaviour reverted the // flag on every restart. const lpSurplusKeyPrefix = "loadpoint_surplus_only:" - lpMgr.SetSurplusOnlySaver(func(id string, v bool) { + lpMgr.SetSurplusOnlySaver(func(id string, v bool) error { key := lpSurplusKeyPrefix + id val := "false" if v { val = "true" } - if err := st.SaveConfig(key, val); err != nil { - slog.Warn("failed to persist loadpoint surplus_only", "lp", id, "err", err) - } + return st.SaveConfig(key, val) }) hydrateLoadpointSurplusOnly := func() { lpMgr.HydrateSurplusOnly(func(id string) (bool, bool) { @@ -1536,7 +1511,10 @@ func main() { mpcSvc.Loadpoints = func(slotLenMin int) []*mpc.LoadpointSpec { specs := make([]*mpc.LoadpointSpec, 0) for _, st := range lpMgr.States() { - if !st.PluggedIn { + if lpController != nil { + lpController.SetGridDeferred(st.ID, false) + } + if !st.PluggedIn || st.ChargingDeclined { continue } // An active boost lease may carry a session-local EV target and @@ -1694,37 +1672,12 @@ func main() { if lpController != nil { lpController.SetGridDeferred(st.ID, deferGridPlan) } - // Surplus-only sources, in order of precedence: - // 1. Operator's explicit surplus_only flag on the LP - // 2. MPC grid-funded planning deferral (target past - // published prices) - // 3. Runtime bat-SoC unlock arming — when the home - // battery is at/above the schedule's threshold AND - // live PV surplus is available, the dispatch layer - // already treats the LP as surplus-only. Without - // threading it into the MPC spec here, the plan - // would prescribe battery→EV transfers that - // dispatch then has to censor — producing - // misleading slot entries the operator sees in - // /api/mpc/plan that never actually execute. - // The arm only replaces the plan while no schedule - // target is set. Under a target it adds spare PV on top - // of the plan (loadpoint.Controller.surplusAddsToPlan, - // #1060), so the planner must keep planning the grid - // charge the deadline needs and is not told surplus-only. - batSoCArmed := false - if lpController != nil { - sched, _ := lpMgr.GetSchedule(st.ID) - batSoCArmed = lpController.IsBatSoCArmed(st.ID) && !sched.HasTarget() - } - // NoBatteryToEV mirrors the site-wide ctrl.BatteryCoversEV - // flag (inverted). Plumbing the constraint into the DP - // here means the planner stops scheduling battery→EV - // transfers that dispatch's safety net would just clamp - // at runtime; this closes the plan↔reality divergence - // where operators saw "plan: 7 kW discharge + 11 kW EV" - // while live execution held the battery at house-only - // levels. Take ctrlMu for the bool read. + // Surplus-only on the 48 h spec is the operator flag or + // the "deadline is past published prices" deferral. + // The bat-SoC unlock is a this-tick opportunistic clamp + // and must not poison night-time grid EV in a plan + // computed while the sun is still up. Battery→EV is + // already blocked by NoBatteryToEV below. ctrlMu.Lock() noBatteryToEV := !(ctrl.BatteryCoversEV || boostActive) ctrlMu.Unlock() @@ -1741,7 +1694,7 @@ func main() { MaxChargeW: st.MaxChargeW, AllowedStepsW: st.AllowedStepsW, ChargeEfficiency: 0.9, - SurplusOnly: st.SurplusOnly || deferGridPlan || batSoCArmed, + SurplusOnly: loadpoint.PlannerTreatsLoadpointAsSurplusOnly(st.SurplusOnly, deferGridPlan), NoBatteryToEV: noBatteryToEV, }) } @@ -1777,7 +1730,9 @@ func main() { if !ok { return control.SlotDirective{}, false } - return controlSlotDirectiveFromMPC(d), true + // SlotDirectiveFromMPC lives in package control so tests + // and main share the plan→EMS field map. + return control.SlotDirectiveFromMPC(d), true } // Default to the energy-allocation path. The plan is a // scheduler (decides WHEN each strategy applies); the EMS is @@ -1869,44 +1824,31 @@ func main() { // (mpc already imports loadpoint — the cycle must go this way). // lpController is forward-declared earlier so the MPC spec builder // closure can push grid-deferred state into it. - if mpcSvc != nil { + { planAdapter := func(now time.Time) (loadpoint.Directive, bool) { + if mpcSvc == nil { + return loadpoint.Directive{}, false + } d, ok := mpcSvc.SlotDirectiveAt(now) if !ok { return loadpoint.Directive{}, false } - return loadpoint.Directive{ - SlotStart: d.SlotStart, - SlotEnd: d.SlotEnd, - LoadpointEnergyWh: d.LoadpointEnergyWh, - }, true + return d.LoadpointDirective(), true } telAdapter := func(driver string) (loadpoint.EVSample, bool) { - r := tel.Get(driver, telemetry.DerEV) - if r == nil { - return loadpoint.EVSample{}, false + cfgMu.RLock() + watchdog := time.Duration(cfg.Site.WatchdogTimeoutS) * time.Second + cfgMu.RUnlock() + health := tel.DriverHealth(driver) + if health != nil && health.WatchdogTimeoutOverride > 0 { + watchdog = health.WatchdogTimeoutOverride } - // RequestActive defaults to true so drivers that - // don't emit the field keep their pre-existing - // behaviour — only drivers that explicitly emit - // request_active=false will trip the - // session-completion detector. - d := struct { - Connected bool `json:"connected"` - SessionWh float64 `json:"session_wh"` - RequestActive *bool `json:"request_active"` - }{} - _ = json.Unmarshal(r.Data, &d) - reqActive := true - if d.RequestActive != nil { - reqActive = *d.RequestActive + deviceID, _ := runningDeviceID(reg, driver) + ocppOnline := ocppSrv != nil && ocppSrv.Handler().IsOnline(driver) + if ocppOnline { + deviceID = currentOCPPDeviceID(ocppSrv.Handler(), driver) } - return loadpoint.EVSample{ - PowerW: r.SmoothedW, - SessionWh: d.SessionWh, - Connected: d.Connected, - RequestActive: reqActive, - }, true + return currentEVSample(tel.Get(driver, telemetry.DerEV), health, watchdog, time.Now(), ocppOnline, deviceID) } // evSend routes OCPP chargers past the driver registry; loadpoints // stay unaware of the difference. @@ -2025,49 +1967,11 @@ func main() { } return "" }) - // Persist operator manual holds (the amp-slider "Start") so they - // survive reboot / firmware update and the EV keeps charging across - // the restart — the in-memory hold would otherwise be lost (Stefan - // 2026-06-11: a binary deploy dropped the live manual charge). Mirrors - // the loadpoint_schedule k/v pattern: one row per LP keyed - // `loadpoint_manual_hold:`, "{}" = cleared. - const lpManualHoldKeyPrefix = "loadpoint_manual_hold:" - // Restore FIRST (before wiring the saver) so re-applying a persisted - // hold doesn't immediately re-write what we just read. A stale hold - // for a car unplugged during downtime self-clears on the first tick - // (tickOne unplug → ClearManualHold). - for _, lpState := range lpMgr.States() { - v, ok := st.LoadConfig(lpManualHoldKeyPrefix + lpState.ID) - if !ok || v == "" || v == "{}" { - continue - } - var h loadpoint.ManualHold - if err := json.Unmarshal([]byte(v), &h); err != nil { - slog.Warn("failed to parse persisted manual hold", "lp", lpState.ID, "err", err) - continue - } - if !h.Persistent { - continue // only operator (never-expiring) holds persist - } - lpController.SetManualHold(lpState.ID, h) - slog.Info("restored persistent manual hold across restart", - "lp", lpState.ID, "power_w", h.PowerW, "phase_mode", h.PhaseMode) - } + // The manager binds saved holds to charger hardware and session. The + // controller restores only after fresh telemetry supplies those IDs. lpController.SetManualHoldSaver(func(id string, h loadpoint.ManualHold, cleared bool) { - key := lpManualHoldKeyPrefix + id - if cleared { - if err := st.SaveConfig(key, "{}"); err != nil { - slog.Warn("failed to clear persisted manual hold", "lp", id, "err", err) - } - return - } - b, err := json.Marshal(h) - if err != nil { - slog.Warn("failed to marshal manual hold", "lp", id, "err", err) - return - } - if err := st.SaveConfig(key, string(b)); err != nil { - slog.Warn("failed to persist manual hold", "lp", id, "err", err) + if err := lpMgr.PersistManualHold(id, h, cleared); err != nil { + slog.Warn("failed to persist manual charging choice", "lp", id, "err", err) } }) const lpBatteryBoostKeyPrefix = "loadpoint_battery_boost:" @@ -2219,60 +2123,21 @@ func main() { // 3Φ minimum but day-peak is, we'd rather charge 1Φ now and // switch to 3Φ later than sit idle waiting. // - // "Surplus" here is what the EV can claim, not the raw PV - // excess. The MPC has already allocated battery_w out of PV; - // the EV gets only what's left after PV - Load - Battery. A - // borderline-PV day where MPC reserves 4.5 kW for battery - // charging while raw -PV - Load = 5 kW would otherwise pin - // the gate to 3Φ-only based on a peak the battery is going - // to consume — leaving the EV stuck at 0 W in 3Φ-only step - // land because real-time room is below 4140 W. + // "Surplus" here is leftover PV after house load, minus + // planned PV-soak battery charge. Grid-funded battery + // charge does not consume leftover the car can take. A + // borderline-PV day where the battery soaks 4.5 kW of a + // 5 kW leftover would otherwise pin the gate to 3Φ based + // on a peak the battery is about to eat. lpController.SetNearTermPeakSurplusW(func(window time.Duration) (float64, bool) { if mpcSvc == nil { return 0, false } plan := mpcSvc.Latest() - if plan == nil || len(plan.Actions) == 0 { + if plan == nil { return 0, false } - now := time.Now() - horizon := now.Add(window) - var peak float64 - any := false - for _, a := range plan.Actions { - slotEnd := time.UnixMilli(a.SlotStartMs).Add( - time.Duration(a.SlotLenMin) * time.Minute) - if slotEnd.Before(now) { - continue - } - if time.UnixMilli(a.SlotStartMs).After(horizon) { - break - } - // Net PV headroom for non-battery loads: positive when - // PV export exceeds load + planned battery charge. - // BatteryW is site-signed: positive = charge (import), - // negative = discharge (export). Only subtract planned - // CHARGE — planned discharge is already earmarked to - // cover house load (or grid export in arbitrage), not - // available room for the EV to claim. Counting it would - // route plan-discharge → EV → re-charge cycles: the EV - // takes power the plan reserved for load coverage, then - // the dispatch has to re-import or further discharge to - // keep the original balance. - plannedChargeW := a.BatteryW - if plannedChargeW < 0 { - plannedChargeW = 0 - } - surplus := -a.PVW - a.LoadW - plannedChargeW - if !any || surplus > peak { - peak = surplus - any = true - } - } - if !any { - return 0, false - } - return peak, true + return mpc.PeakPlannedSurplusForEV(plan.Actions, time.Now(), window) }) lpController.SetSiteSurplusForEV(func() (float64, bool) { @@ -2310,42 +2175,17 @@ func main() { batW += r.SmoothedW } evW := tel.SumOnlineEVW() - // Surplus-only EV priority: when any loadpoint is in - // surplus-only mode, battery charging power is NOT - // available for the EV. The original formula assumed - // "if I told the battery to stop, that surplus would - // free up for the EV" — but the MPC may still - // legitimately charge the battery from PV surplus - // (and, in active arbitrage, from the grid). If we - // hand that power back to the EV, the controller - // commands the EV on, the battery loses its share, - // the planner re-budgets the EV down → flap. The - // truthful surplus for an EV under surplus-only is - // what's left AFTER the battery has taken its share: - // -gridW + max(0, -batW) (battery counts only if - // it's discharging, contributing to site supply). - // A bat-SoC-armed loadpoint is just as much a "PV-priority" - // claimant as a configured surplus_only LP — both want PV - // routed to the EV ahead of the home battery. Counting - // either via the controller's combined view (configured OR - // armed) keeps the flap-avoidance protection symmetric and - // closes the loophole where an armed LP would inflate the - // apparent surplus by the battery's PV-charge rate. + // Surplus-only EV may take leftover PV after house load. + // When the battery is soaking PV, that charge is not offered + // this tick (EV dispatch runs first). When the battery is + // already importing, leftover PV is the car's — surplus-only + // is an EV policy, not a site import ban. See + // loadpoint.SurplusAvailableForEVW. surplusOnlyActive := false if lpController != nil && lpController.AnyLoadpointSurplusActive() { surplusOnlyActive = true } - if surplusOnlyActive && batW > 0 { - batW = 0 - } - // Open follow-up: in self-consumption / planner_self mode, - // the dispatch PI absorbs PV into the battery before the - // EV controller sees it, defeating surplus-only priority. - // The MPC arbitrage path is covered by the new mpc.go - // feasibility constraint; the self-consumption fallback - // needs a battery-charge cap in control/dispatch.go to - // match. Tracked separately to keep this change focused. - return -gridW + batW + evW, true + return loadpoint.SurplusAvailableForEVW(gridW, batW, evW, surplusOnlyActive), true }) // Bat-SoC surplus-unlock: feed the controller a live home-battery @@ -2591,12 +2431,10 @@ func main() { SelfUpdate: selfUpdater, OptimizerUpdate: optimizerUpdater, Restart: func(reqCtx context.Context) error { - // Prefer the docker-compose sidecar path when wired up: the - // updater container does docker compose up -d --force-recreate, - // which is the same code path post-update restarts use, so - // there's only one battle-tested escape hatch in production. + // Restart the existing container through the updater. + // An old updater refuses this action before touching Docker. if selfUpdater != nil { - if err := selfUpdater.Trigger(reqCtx, "restart", ""); err == nil { + if err := selfUpdater.TriggerRestart(reqCtx); err == nil { slog.Info("restart: dispatched via updater sidecar") return nil } else { diff --git a/go/internal/api/api.go b/go/internal/api/api.go index 38bd42dc..91185cf8 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -525,6 +525,7 @@ func (s *Server) routes() { // actuation: a schedule saved late is the same instruction, only // later, while target/soc/force_start move energy now. The split is // what lets a phone save one through the passthrough. + s.handle("POST /api/loadpoints/{id}/vehicle", Configure, s.handleLoadpointVehicle) s.handle("PUT /api/loadpoints/{id}/schedule", Configure, s.handleLoadpointSchedulePut) s.handle("DELETE /api/loadpoints/{id}/schedule", Configure, s.handleLoadpointScheduleClear) s.handle("POST /api/loadpoints/{id}/soc", Actuate, s.handleLoadpointSoC, Via(appproto.OpLoadpointSoCSet)) @@ -1653,6 +1654,10 @@ func (s *Server) handlePostConfig(w http.ResponseWriter, r *http.Request) { writeJSON(w, 400, map[string]string{"error": "validation: " + err.Error()}) return } + // API input carries portable paths, while the registry and file watcher + // use resolved paths. Resolve before comparing or applying so an unrelated + // settings edit cannot restart every driver with a missing relative file. + newCfg.ResolveDriverPaths(filepath.Dir(s.deps.ConfigPath)) // Diff against the live config BEFORE we mutate the shared pointer — // otherwise the comparison would always come back empty. s.deps.CfgMu.RLock() @@ -2496,11 +2501,16 @@ func (s *Server) handleMPCPlan(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, s.mpcDisabledPayload()) return } - plan := s.deps.MPC.Latest() - at, reason := s.deps.MPC.LastReplanInfo() + snapshot := s.deps.MPC.PlanSnapshot() + plan := snapshot.Plan + if snapshot.Outdated { + plan = nil + } meta := map[string]any{ - "last_replan_ms": at.UnixMilli(), - "last_replan_reason": reason, + "last_replan_ms": snapshot.ReplanAt.UnixMilli(), + "last_replan_reason": snapshot.Reason, + "replanning": snapshot.Pending, + "outdated": snapshot.Outdated, } if plan == nil { writeJSON(w, 200, map[string]any{"enabled": true, "plan": nil, "meta": meta}) @@ -3452,19 +3462,17 @@ func applyManualEVHold(deps *Deps, driverName string, action string) { return } if action == "ev_pause" { - deps.LoadpointCtrl.ClearManualHold(lpID) - slog.Info("ev manual pause — cleared manual hold, reverting to plan", "lp", lpID) + deps.LoadpointCtrl.SetManualHold(lpID, loadpoint.ManualHold{PowerW: 0, Persistent: true}) + slog.Info("ev manual pause — held at zero power", "lp", lpID) return } if maxW <= 0 { maxW = 11000 // 16 A × 3φ × 230 V fallback when the LP config didn't set it } - // 100-year expiry serves as "sticky until the operator cancels". - // Using time.Now() + a long delta rather than time.Time{} because - // SetManualHold treats zero ExpiresAt as "delete" (controller.go:653). + // Keep an explicit start active until the operator changes it or unplugs. deps.LoadpointCtrl.SetManualHold(lpID, loadpoint.ManualHold{ - PowerW: maxW, - ExpiresAt: time.Now().Add(100 * 365 * 24 * time.Hour), + PowerW: maxW, + Persistent: true, }) slog.Info("ev manual start/resume — installed sticky hold", "lp", lpID, "action", action, "hold_w", maxW) @@ -3700,18 +3708,18 @@ func (s *Server) handleLoadpointTarget(w http.ResponseWriter, r *http.Request) { } surplusDisabled := false if req.SurplusOnly != nil { - prev, ok := s.deps.Loadpoints.SetSurplusOnly(id, *req.SurplusOnly) + prev, ok, err := s.deps.Loadpoints.SetSurplusOnlyChecked(id, *req.SurplusOnly) + if err != nil { + slog.Warn("failed to save loadpoint solar preference", "lp", id, "err", err) + writeJSON(w, 500, map[string]string{"error": "Could not save solar charging preference. Your previous choice is unchanged. Try again."}) + return + } if !ok { writeJSON(w, 404, map[string]string{"error": "loadpoint not found"}) return } - // Disabling surplus_only is a planner regime change: the - // loadpoint may now import from the grid (and the home - // battery may feed it if BatteryCoversEV is on). Force a - // synchronous replan with a tagged reason so the new - // schedule is in place by the time this HTTP response - // returns and the diagnose snapshot records "why" the - // plan changed at this timestamp. + // The saved rule takes effect in dispatch immediately. Rebuild the + // plan separately so a slow solve cannot hide the storage acknowledgement. if prev && !*req.SurplusOnly { surplusDisabled = true } @@ -3723,20 +3731,11 @@ func (s *Server) handleLoadpointTarget(w http.ResponseWriter, r *http.Request) { if surplusDisabled { slog.Info("loadpoint surplus_only disabled — forcing replan", "lp", id) - // Synchronous + fresh context (the request context dies the - // moment we writeJSON). Replan typically completes in - // <100ms for current grid sizes; the API caller blocks - // briefly and returns to a UI that can immediately fetch - // /api/mpc/plan and see the new schedule. - s.deps.MPC.ReplanWithReason(context.Background(), "surplus_only_disabled") + s.deps.MPC.RequestReplan("surplus_only_disabled") } else if scheduleChanged { s.replanForScheduleChange(id) } else { - // Other field changes: replan is helpful but not load- - // bearing — kick it off in the background so the API stays - // snappy. The goroutine uses a fresh context for the same - // reason as above (request ctx cancellation). - go s.deps.MPC.ReplanWithReason(context.Background(), "loadpoint_target_changed") + s.deps.MPC.RequestReplan("loadpoint_target_changed") } } writeJSON(w, 200, map[string]any{"ok": true}) @@ -3751,7 +3750,12 @@ func (s *Server) handleLoadpointTarget(w http.ResponseWriter, r *http.Request) { // schedule-only route. func (s *Server) applyLoadpointSchedule(id string, raw json.RawMessage) (int, string) { if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { - if !s.deps.Loadpoints.ClearSchedule(id) { + ok, err := s.deps.Loadpoints.ClearScheduleChecked(id) + if err != nil { + slog.Warn("failed to clear loadpoint schedule", "lp", id, "err", err) + return 500, "Could not remove charging goal. Your previous goal is unchanged. Try again." + } + if !ok { return 404, "loadpoint not found" } return 0, "" @@ -3766,7 +3770,12 @@ func (s *Server) applyLoadpointSchedule(id string, raw json.RawMessage) (int, st if sched.Days > 0x7F { return 400, "days must be a 7-bit weekday mask (0..127, bit 0 = Monday)" } - if !s.deps.Loadpoints.SetSchedule(id, sched) { + ok, err := s.deps.Loadpoints.SetScheduleChecked(id, sched) + if err != nil { + slog.Warn("failed to save loadpoint schedule", "lp", id, "err", err) + return 500, "Could not save charging goal. Your previous goal is unchanged. Try again." + } + if !ok { return 404, "loadpoint not found" } // Roll immediately so a read-modify-write on the heels of this set @@ -3796,17 +3805,15 @@ func (s *Server) refreshVehicleForSchedule(id string) { }(id) } -// replanForScheduleChange forces a synchronous MPC replan tagged with -// the schedule-change reason. Synchronous + fresh context (the request -// context dies the moment the handler answers) so the caller returns -// to a UI that can immediately fetch /api/mpc/plan and see the new -// schedule. +// replanForScheduleChange requests a new plan after the goal has been stored. +// Saving and calculating are separate outcomes; GET /api/loadpoints reports +// plan_pending until the new plan is ready. func (s *Server) replanForScheduleChange(id string) { if s.deps.MPC == nil { return } slog.Info("loadpoint schedule changed — forcing replan", "lp", id) - s.deps.MPC.ReplanWithReason(context.Background(), "loadpoint_schedule_changed") + s.deps.MPC.RequestReplan("loadpoint_schedule_changed") } // PUT /api/loadpoints/{id}/schedule replaces the loadpoint's schedule. @@ -3845,10 +3852,8 @@ func (s *Server) handleLoadpointSchedulePut(w http.ResponseWriter, r *http.Reque } // DELETE /api/loadpoints/{id}/schedule clears the schedule. Same price -// as PUT: removing the standing instruction is configuration too. The -// one-shot target a previous roll derived stays until it expires — -// clearing the schedule is not a stop button, and stopping a charge in -// progress remains an actuation. +// as PUT: removing the standing instruction is configuration too. Its derived +// target clears after storage succeeds. Manual charging remains active. func (s *Server) handleLoadpointScheduleClear(w http.ResponseWriter, r *http.Request) { if s.deps.Loadpoints == nil { writeJSON(w, 404, map[string]string{"error": "loadpoints not configured"}) @@ -3859,8 +3864,8 @@ func (s *Server) handleLoadpointScheduleClear(w http.ResponseWriter, r *http.Req writeJSON(w, 400, map[string]string{"error": "id required"}) return } - if !s.deps.Loadpoints.ClearSchedule(id) { - writeJSON(w, 404, map[string]string{"error": "loadpoint not found"}) + if status, msg := s.applyLoadpointSchedule(id, json.RawMessage("null")); status != 0 { + writeJSON(w, status, map[string]string{"error": msg}) return } s.refreshVehicleForSchedule(id) @@ -3911,12 +3916,8 @@ func (s *Server) handleLoadpointSoC(w http.ResponseWriter, r *http.Request) { }) return } - // Replan before answering, on a fresh context: the request context - // dies with the response, and the client reads /api/loadpoints the - // moment this returns to redraw the plan from the corrected SoC. - // A replan is well under a second on current grids. if s.deps.MPC != nil { - s.deps.MPC.ReplanWithReason(context.Background(), "loadpoint_soc_corrected") + s.deps.MPC.RequestReplan("loadpoint_soc_corrected") } writeJSON(w, 200, map[string]any{"ok": true}) } diff --git a/go/internal/api/api_config_apply_test.go b/go/internal/api/api_config_apply_test.go index e17a822a..34b4d2ef 100644 --- a/go/internal/api/api_config_apply_test.go +++ b/go/internal/api/api_config_apply_test.go @@ -3,6 +3,7 @@ package api import ( "net/http" "net/http/httptest" + "path/filepath" "strings" "sync" "testing" @@ -124,3 +125,30 @@ func TestPostConfigRunsTheSharedApplierWithOldSnapshot(t *testing.T) { t.Fatalf("applier oldCfg.SiteMeterDriver() = %q, want the pre-POST snapshot %q", got, "") } } + +func TestPostConfigResolvesDriverPathsBeforeApply(t *testing.T) { + for _, bundled := range []bool{false, true} { + t.Run(map[bool]string{false: "relative-to-config", true: "container-driver-directory"}[bundled], func(t *testing.T) { + oldOverride := config.DriversDirOverride + if bundled { + config.DriversDirOverride = t.TempDir() + } else { + config.DriversDirOverride = "" + } + t.Cleanup(func() { config.DriversDirOverride = oldOverride }) + var appliedPath string + srv, _, cfg := postConfigServer(t, func(next, old *config.Config) { appliedPath = next.Drivers[0].Lua }) + srv.deps.SaveConfig = config.SaveAtomic + if code := postConfig(t, srv, firstSiteMeterConfig); code != 200 { + t.Fatalf("status %d", code) + } + loaded, err := config.Load(srv.deps.ConfigPath) + if err != nil { + t.Fatal(err) + } + if !filepath.IsAbs(appliedPath) || appliedPath != loaded.Drivers[0].Lua || cfg.Drivers[0].Lua != appliedPath { + t.Fatalf("API apply %q, file watcher %q, live %q must agree", appliedPath, loaded.Drivers[0].Lua, cfg.Drivers[0].Lua) + } + }) + } +} diff --git a/go/internal/api/api_ev_replan_ack_test.go b/go/internal/api/api_ev_replan_ack_test.go new file mode 100644 index 00000000..3b868ae0 --- /dev/null +++ b/go/internal/api/api_ev_replan_ack_test.go @@ -0,0 +1,103 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/loadpoint" +) + +func TestEVSettingsAcknowledgeWhilePlanIsBlocked(t *testing.T) { + for _, tc := range []struct{ name, method, path, body string }{ + {"goal", "PUT", "/schedule", `{"soc":0.8,"time_of_day_min_utc":420}`}, + {"remove", "DELETE", "/schedule", ""}, + {"target_goal", "POST", "/target", `{"schedule":{"soc":0.9,"time_of_day_min_utc":480}}`}, + {"solar", "POST", "/target", `{"surplus_only":false}`}, + } { + t.Run(tc.name, func(t *testing.T) { + srv, mgr, svc := newScheduleServer(t) + mgr.SetSchedule("garage", loadpoint.Schedule{SoC: .7, TimeOfDayMinUTC: 360}) + mgr.SetSurplusOnly("garage", true) + entered, release := make(chan struct{}), make(chan struct{}) + var enterOnce, releaseOnce sync.Once + unblock := func() { releaseOnce.Do(func() { close(release) }) } + t.Cleanup(unblock) + svc.PV = func(time.Time, float64) float64 { enterOnce.Do(func() { close(entered) }); <-release; return 0 } + req := httptest.NewRequest(tc.method, "/api/loadpoints/garage"+tc.path, strings.NewReader(tc.body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + done := make(chan struct{}) + go func() { srv.Handler().ServeHTTP(rr, req); close(done) }() + select { + case <-entered: + case <-time.After(time.Second): + t.Fatal("planner did not enter blocked predictor") + } + select { + case <-done: + case <-time.After(time.Second): + unblock() + <-done + t.Fatal("saved settings waited for planner before acknowledgement") + } + if rr.Code != http.StatusOK { + t.Fatalf("write=%d %s", rr.Code, rr.Body.String()) + } + if !svc.IsReplanning() { + t.Fatal("pending plan hidden") + } + read := func(path string) map[string]json.RawMessage { + t.Helper() + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, httptest.NewRequest("GET", path, nil)) + if rr.Code != 200 { + t.Fatalf("read %s=%d", path, rr.Code) + } + var body map[string]json.RawMessage + if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + return body + } + body := read("/api/loadpoints") + var points []loadpoint.State + if err := json.Unmarshal(body["loadpoints"], &points); err != nil { + t.Fatal(err) + } + if len(points) != 1 || !points[0].PlanPending || len(points[0].PlanWindows) != 0 { + t.Fatalf("pending point=%s", body["loadpoints"]) + } + if tc.name == "goal" && points[0].Schedule.SoC != .8 { + t.Fatalf("ack without saved goal: %+v", points[0].Schedule) + } + if tc.name == "remove" && !points[0].Schedule.Empty() { + t.Fatal("ack without clearing goal") + } + if tc.name == "solar" && points[0].SurplusOnly { + t.Fatal("ack without changed solar rule") + } + var meta struct { + Replanning bool `json:"replanning"` + } + if err := json.Unmarshal(read("/api/mpc/plan")["meta"], &meta); err != nil { + t.Fatal(err) + } + if !meta.Replanning { + t.Fatal("plan API hides pending work") + } + unblock() + waitForSchedulePlan(t, svc) + if svc.IsReplanning() { + t.Fatal("pending did not clear") + } + if plan := svc.Latest(); plan == nil { + t.Fatal("acknowledged change never produced a plan") + } + }) + } +} diff --git a/go/internal/api/api_loadpoint_manual.go b/go/internal/api/api_loadpoint_manual.go index a063300f..fa490155 100644 --- a/go/internal/api/api_loadpoint_manual.go +++ b/go/internal/api/api_loadpoint_manual.go @@ -1,12 +1,14 @@ package api import ( + "encoding/json" "fmt" "math" "net/http" "time" "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/telemetry" ) // Manual-hold diagnostics endpoint. Lets an operator pin a loadpoint @@ -48,7 +50,7 @@ type manualHoldRequest struct { // confirm what's installed. Returned by POST and GET. type manualHoldResponse struct { Active bool `json:"active"` - PowerW float64 `json:"power_w,omitempty"` + PowerW float64 `json:"power_w"` PhaseMode string `json:"phase_mode,omitempty"` PhaseSplitW float64 `json:"phase_split_w,omitempty"` MinPhaseHoldS int `json:"min_phase_hold_s,omitempty"` @@ -57,6 +59,10 @@ type manualHoldResponse struct { SitePhases int `json:"site_phases,omitempty"` ExpiresAtMs int64 `json:"expires_at_ms,omitempty"` ReleaseAtSoCPct float64 `json:"release_at_soc_pct,omitempty"` + // StartedAtMs is when the operator installed the hold; an Update of + // the amps keeps it. The live account of what the charger did with the + // hold is `manual` on GET /api/loadpoints. + StartedAtMs int64 `json:"started_at_ms,omitempty"` } // maxManualHoldS bounds the hold duration so a forgotten hold can't @@ -104,8 +110,12 @@ func (s *Server) handleLoadpointManualHold(w http.ResponseWriter, r *http.Reques }) return } - if req.PowerW < 0 { - writeJSON(w, 400, map[string]string{"error": "power_w must be >= 0"}) + if req.PowerW < 0 || req.PhaseSplitW < 0 || req.Voltage < 0 || req.MaxAmpsPerPhase < 0 || req.MinPhaseHoldS < 0 { + writeJSON(w, 400, map[string]string{"error": "power and optional phase limits must be >= 0"}) + return + } + if req.SitePhases != 0 && req.SitePhases != 1 && req.SitePhases != 3 { + writeJSON(w, 400, map[string]string{"error": "site_phases must be 1 or 3 when set"}) return } switch req.PhaseMode { @@ -157,6 +167,13 @@ func (s *Server) handleLoadpointManualHold(w http.ResponseWriter, r *http.Reques Persistent: persistent, ReleaseAtSoC: req.ReleaseAtSoCPct / 100, } + // An Update of the amps keeps the hold's start, so the manual tab keeps + // counting from the first press; a fresh hold starts now. + now := time.Now() + hold.StartedAt = now + if prev, ok := s.deps.LoadpointCtrl.GetManualHold(id, now); ok && !prev.StartedAt.IsZero() { + hold.StartedAt = prev.StartedAt + } s.deps.LoadpointCtrl.SetManualHold(id, hold) writeJSON(w, 200, manualHoldResponseFrom(hold, true)) } @@ -240,6 +257,7 @@ func (s *Server) decorateLoadpointsWithManual(states []loadpoint.State) { } now := time.Now() + chargers := s.chargerReadings() for i := range states { phases := fusePhases switch phaseModeByID[states[i].ID] { @@ -250,14 +268,61 @@ func (s *Server) decorateLoadpointsWithManual(states []loadpoint.State) { } states[i].Phases = phases states[i].VoltageV = voltage + reading := chargers[states[i].DriverName] + status := &loadpoint.ChargerStatus{Known: reading.Known, Available: reading.Known && !reading.Unavailable, Reason: reading.Reason} + if !reading.UpdatedAt.IsZero() { + status.UpdatedAtMs = reading.UpdatedAt.UnixMilli() + } + if reading.LimitKnown { + limit := reading.LimitA + status.LimitA = &limit + } + states[i].Charger = status if s.deps.LoadpointCtrl != nil { - if h, ok := s.deps.LoadpointCtrl.GetManualHold(states[i].ID, now); ok { + h, ok := s.deps.LoadpointCtrl.GetManualHold(states[i].ID, now) + if ok { states[i].ManualActive = true states[i].ManualChargeW = h.PowerW states[i].ManualReleaseSoC = h.ReleaseAtSoC } + states[i].Manual = loadpoint.ManualStatusFrom(h, ok, states[i], chargers[states[i].DriverName], now) + } + } +} + +// chargerReadings collects, per EV driver, what the charger last reported +// about the current it allows and why it delivers none. The field names +// follow the EV driver contract (easee_cloud.lua and its siblings): max_a, +// charging, reason_no_current_label, command_stalled. A driver that emits +// none of them still yields a reading, so the manual status knows the +// charger is there but cannot confirm a limit. +func (s *Server) chargerReadings() map[string]loadpoint.ChargerReading { + out := map[string]loadpoint.ChargerReading{} + if s.deps.Tel == nil { + return out + } + for _, rd := range s.deps.Tel.ReadingsByType(telemetry.DerEV) { + var d struct { + MaxA *float64 `json:"max_a"` + Charging bool `json:"charging"` + Reason string `json:"reason_no_current_label"` + CommandStalled bool `json:"command_stalled"` + Online *bool `json:"is_online"` } + if len(rd.Data) > 0 { + _ = json.Unmarshal(rd.Data, &d) + } + r := loadpoint.ChargerReading{Known: true, UpdatedAt: rd.UpdatedAt, Unavailable: d.Online != nil && !*d.Online, Charging: d.Charging, Reason: d.Reason, Stalled: d.CommandStalled} + if health := s.deps.Tel.DriverHealth(rd.Driver); health != nil && !health.TelemetryLive() { + r.Unavailable = true + } + if d.MaxA != nil { + r.LimitA = *d.MaxA + r.LimitKnown = true + } + out[rd.Driver] = r } + return out } func manualHoldResponseFrom(h loadpoint.ManualHold, active bool) manualHoldResponse { @@ -276,5 +341,8 @@ func manualHoldResponseFrom(h loadpoint.ManualHold, active bool) manualHoldRespo resp.ExpiresAtMs = h.ExpiresAt.UnixMilli() } resp.ReleaseAtSoCPct = h.ReleaseAtSoC * 100 + if !h.StartedAt.IsZero() { + resp.StartedAtMs = h.StartedAt.UnixMilli() + } return resp } diff --git a/go/internal/api/api_loadpoint_manual_test.go b/go/internal/api/api_loadpoint_manual_test.go index 409fefc1..10f18259 100644 --- a/go/internal/api/api_loadpoint_manual_test.go +++ b/go/internal/api/api_loadpoint_manual_test.go @@ -1,6 +1,7 @@ package api import ( + "context" "encoding/json" "net/http" "net/http/httptest" @@ -9,6 +10,7 @@ import ( "time" "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/telemetry" ) // Manual-hold endpoint tests. Validation, route wiring, and the full @@ -230,3 +232,142 @@ func TestManualHoldRefusesReleaseTargetAlreadyMet(t *testing.T) { t.Errorf("no-target hold should be installed without a SoC release, got active=%v %+v", active, h) } } + +// After Charge now the loadpoint carries a live account of the hold: what +// was ordered, since when, and what the charger did with it (#1002). The +// manual tab renders this instead of a sentence written at click time. +func TestLoadpointsCarryManualStatus(t *testing.T) { + mgr := loadpoint.NewManager() + mgr.Load([]loadpoint.Config{{ID: "garage", DriverName: "easee", MinChargeW: 1380, MaxChargeW: 11000}}) + ctrl := loadpoint.NewController(mgr, func(time.Time) (loadpoint.Directive, bool) { return loadpoint.Directive{}, false }, func(string) (loadpoint.EVSample, bool) { + return loadpoint.EVSample{Connected: true, RequestActive: true}, true + }, nil) + tel := telemetry.NewStore() + srv := New(&Deps{Loadpoints: mgr, LoadpointCtrl: ctrl, Tel: tel}) + + post := func(body string) { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/api/loadpoints/garage/manual_hold", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("POST status = %d: %s", rr.Code, rr.Body.String()) + } + var resp manualHoldResponse + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatal(err) + } + if resp.StartedAtMs == 0 { + t.Error("POST response must carry started_at_ms") + } + } + manual := func() loadpoint.ManualStatus { + t.Helper() + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/loadpoints", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("GET /api/loadpoints = %d: %s", rr.Code, rr.Body.String()) + } + var got struct { + Loadpoints []loadpoint.State `json:"loadpoints"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if len(got.Loadpoints) != 1 { + t.Fatalf("loadpoints = %d, want 1", len(got.Loadpoints)) + } + return got.Loadpoints[0].Manual + } + + if m := manual(); m.Active { + t.Fatalf("no hold yet, got %+v", m) + } + + // 6 A on three phases at 230 V; the charger has not answered yet. + post(`{"power_w":4140,"hold_s":0}`) + m := manual() + if !m.Active || m.State != loadpoint.ManualSent || m.RequestedA != 6 || m.StartedAtMs == 0 { + t.Fatalf("after Charge now: %+v", m) + } + first := m.StartedAtMs + + // The Easee echoes the limit: accepted, waiting for the car. + tel.Update("easee", telemetry.DerEV, 0, nil, json.RawMessage(`{"max_a":6,"charging":false,"reason_no_current_label":"car not drawing current"}`)) + if m = manual(); m.State != loadpoint.ManualSent { + t.Fatalf("charger echo cannot confirm an unprocessed request: %+v", m) + } + ctrl.Tick(context.Background(), time.Now()) + // Confirmation must arrive after dispatch; the pre-tick echo above is + // deliberately too early, even when both fall in the same millisecond. + tel.Update("easee", telemetry.DerEV, 0, nil, json.RawMessage(`{"max_a":6,"charging":false,"reason_no_current_label":"car not drawing current"}`)) + m = manual() + if m.State != loadpoint.ManualAccepted || !m.ChargerLimitKnown || m.ChargerLimitA != 6 || m.ChargerReason != "car not drawing current" { + t.Fatalf("after the charger took the limit: %+v", m) + } + + // An Update of the amps keeps the first press as the start. + post(`{"power_w":11040,"hold_s":0}`) + if m = manual(); m.StartedAtMs != first || m.RequestedA != 16 { + t.Fatalf("after Update: %+v (first start %d)", m, first) + } + + // The controller processes the new choice before its charger response. + ctrl.Tick(context.Background(), time.Now()) + + // The charger says the command stalled. + tel.Update("easee", telemetry.DerEV, 0, nil, json.RawMessage(`{"max_a":16,"charging":false,"reason_no_current_label":"EV not accepting current","command_stalled":true}`)) + if m = manual(); m.State != loadpoint.ManualStalled || m.ChargerReason != "EV not accepting current" { + t.Fatalf("after a stall: %+v", m) + } + + // Power flows. + tel.Update("easee", telemetry.DerEV, 10800, nil, json.RawMessage(`{"max_a":16,"charging":true}`)) + if m = manual(); m.State != loadpoint.ManualCharging { + t.Fatalf("while charging: %+v", m) + } + + // Stop clears the account. + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodDelete, "/api/loadpoints/garage/manual_hold", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("DELETE = %d", rr.Code) + } + if m = manual(); m.Active || m.State != "" { + t.Fatalf("after Stop: %+v", m) + } +} + +func TestLoadpointsReportChargerFreshnessWithoutManualHold(t *testing.T) { + mgr := loadpoint.NewManager() + mgr.Load([]loadpoint.Config{{ID: "garage", DriverName: "easee"}}) + tel := telemetry.NewStore() + srv := New(&Deps{Loadpoints: mgr, Tel: tel}) + read := func() *loadpoint.ChargerStatus { + t.Helper() + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/loadpoints", nil)) + var body struct { + Loadpoints []loadpoint.State `json:"loadpoints"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if len(body.Loadpoints) != 1 || body.Loadpoints[0].Charger == nil { + t.Fatalf("missing charger status: %s", rr.Body.String()) + } + return body.Loadpoints[0].Charger + } + if got := read(); got.Known || got.Available { + t.Fatalf("no reading must stay unknown: %+v", got) + } + tel.Update("easee", telemetry.DerEV, 10800, nil, json.RawMessage(`{"is_online":false,"charging":true,"max_a":16,"reason_no_current_label":"offline"}`)) + if got := read(); !got.Known || got.Available || got.UpdatedAtMs == 0 || got.Reason != "offline" { + t.Fatalf("offline cached power became current: %+v", got) + } + tel.Update("easee", telemetry.DerEV, 10800, nil, json.RawMessage(`{"is_online":true,"charging":true,"max_a":16}`)) + if got := read(); !got.Available || got.LimitA == nil || *got.LimitA != 16 { + t.Fatalf("fresh report did not recover: %+v", got) + } +} diff --git a/go/internal/api/api_loadpoint_plan.go b/go/internal/api/api_loadpoint_plan.go index c343f2f1..6c8db228 100644 --- a/go/internal/api/api_loadpoint_plan.go +++ b/go/internal/api/api_loadpoint_plan.go @@ -4,6 +4,7 @@ import ( "time" "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/mpc" ) // Planner-visibility decoration for GET /api/loadpoints. Fills the @@ -25,14 +26,23 @@ import ( // decorateLoadpointsWithPlan mutates states in place. func (s *Server) decorateLoadpointsWithPlan(states []loadpoint.State) { now := time.Now() + var snapshot mpc.PlanSnapshot + if s.deps.MPC != nil { + snapshot = s.deps.MPC.PlanSnapshot() + } for i := range states { + states[i].PlanPending = snapshot.Pending + states[i].PlanOutdated = snapshot.Outdated + if snapshot.Outdated { + continue + } if s.deps.LoadpointCtrl != nil { states[i].GridDeferred = s.deps.LoadpointCtrl.GridDeferred(states[i].ID) } if s.deps.MPC == nil { continue } - windows, totalWh := s.deps.MPC.LoadpointPlanWindows(states[i].ID, now, maxPlanWindows) + windows, totalWh := snapshot.LoadpointPlanWindows(states[i].ID, now, maxPlanWindows) if len(windows) == 0 { continue } diff --git a/go/internal/api/api_loadpoint_schedule_test.go b/go/internal/api/api_loadpoint_schedule_test.go index 9ccfd136..68cb3c66 100644 --- a/go/internal/api/api_loadpoint_schedule_test.go +++ b/go/internal/api/api_loadpoint_schedule_test.go @@ -1,6 +1,7 @@ package api import ( + "encoding/json" "net/http" "net/http/httptest" "path/filepath" @@ -13,6 +14,83 @@ import ( "github.com/srcfl/ftw/go/internal/state" ) +func TestScheduleStorageFailureKeepsGoalAndRetrySaves(t *testing.T) { + for _, tc := range []struct { + name, method, path, body string + clear bool + }{ + {"put", http.MethodPut, "/schedule", `{"soc_pct":90,"time_of_day_min_utc":420}`, false}, + {"delete", http.MethodDelete, "/schedule", "", true}, + {"put_null", http.MethodPut, "/schedule", "null", true}, + {"target_set", http.MethodPost, "/target", `{"schedule":{"soc_pct":90,"time_of_day_min_utc":420}}`, false}, + {"target_clear", http.MethodPost, "/target", `{"schedule":null}`, true}, + } { + t.Run(tc.name, func(t *testing.T) { + srv, mgr, svc := newScheduleServer(t) + path := filepath.Join(t.TempDir(), "goals.db") + disk, err := state.Open(path) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { disk.Close() }) + mgr.SetScheduleSaver(func(_ string, s loadpoint.Schedule) error { + b, err := json.Marshal(s) + if err != nil { + return err + } + return disk.SaveConfig("goal", string(b)) + }) + old := loadpoint.Schedule{SoC: .8, TimeOfDayMinUTC: 360, Recurring: true} + if !mgr.SetSchedule("garage", old) { + t.Fatal("initial save failed") + } + mgr.RollSchedules(time.Now()) + before, _ := mgr.State("garage") + if err := disk.Close(); err != nil { + t.Fatal(err) + } + request := func() *httptest.ResponseRecorder { + r := httptest.NewRequest(tc.method, "/api/loadpoints/garage"+tc.path, strings.NewReader(tc.body)) + r.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, r) + return rr + } + rr := request() + if rr.Code != http.StatusInternalServerError || !strings.Contains(rr.Body.String(), "previous goal is unchanged") { + t.Fatalf("failed storage returned %d: %s", rr.Code, rr.Body.String()) + } + after, _ := mgr.State("garage") + if after.Schedule != old || after.TargetSoC != before.TargetSoC || after.TargetTime != before.TargetTime { + t.Fatalf("failed request changed the running goal: %+v", after) + } + if _, reason := svc.LastReplanInfo(); reason != "" { + t.Fatalf("failed save triggered replan: %s", reason) + } + disk, err = state.Open(path) + if err != nil { + t.Fatal(err) + } + if rr = request(); rr.Code != http.StatusOK { + t.Fatalf("retry returned %d: %s", rr.Code, rr.Body.String()) + } + raw, found := disk.LoadConfig("goal") + var saved loadpoint.Schedule + if !found || json.Unmarshal([]byte(raw), &saved) != nil { + t.Fatalf("retry did not persist a goal: %q", raw) + } + after, _ = mgr.State("garage") + if tc.clear { + if !saved.Empty() || !after.Schedule.Empty() || after.TargetSoC != 0 || !after.TargetTime.IsZero() { + t.Fatalf("retry failed to remove goal: saved=%+v state=%+v", saved, after) + } + } else if saved.SoC != .9 || after.Schedule != saved || after.TargetSoC != .9 || after.TargetTime.IsZero() { + t.Fatalf("retry did not apply the saved goal: saved=%+v state=%+v", saved, after) + } + }) + } +} + // The schedule-only route. Its tier is pinned alongside the other // verb-blind cases in TestRouteTierIgnoresTheMethod; these tests cover // what the handlers do: PUT stores and rolls, DELETE clears, and both @@ -49,6 +127,7 @@ func newScheduleServer(t *testing.T) (*Server, *loadpoint.Manager, *mpc.Service) }) svc.Horizon = time.Hour svc.BaseLoad = 500 + t.Cleanup(func() { waitForSchedulePlan(t, svc) }) return New(&Deps{Loadpoints: mgr, MPC: svc}), mgr, svc } @@ -89,6 +168,7 @@ func TestSchedulePutStoresRollsAndReplans(t *testing.T) { t.Fatalf("PUT did not roll: target_soc_pct = %v, want 80", lpState.TargetSoC) } + waitForSchedulePlan(t, svc) if _, reason := svc.LastReplanInfo(); reason != "loadpoint_schedule_changed" { t.Fatalf("replan reason = %q, want loadpoint_schedule_changed", reason) } @@ -99,6 +179,7 @@ func TestScheduleDeleteClearsAndReplans(t *testing.T) { // Seeded on the manager directly, so the replan reason below can // only have come from the DELETE. mgr.SetSchedule("garage", loadpoint.Schedule{SoC: 0.8, TimeOfDayMinUTC: 360, Recurring: true}) + mgr.RollSchedules(time.Now()) req := httptest.NewRequest(http.MethodDelete, "/api/loadpoints/garage/schedule", nil) rr := httptest.NewRecorder() @@ -110,6 +191,11 @@ func TestScheduleDeleteClearsAndReplans(t *testing.T) { if _, ok := mgr.GetSchedule("garage"); ok { t.Fatal("DELETE did not clear the schedule") } + state, _ := mgr.State("garage") + if state.TargetSoC != 0 || !state.TargetTime.IsZero() { + t.Fatalf("DELETE left an active target after removing the goal: %+v", state) + } + waitForSchedulePlan(t, svc) if _, reason := svc.LastReplanInfo(); reason != "loadpoint_schedule_changed" { t.Fatalf("replan reason = %q, want loadpoint_schedule_changed", reason) } @@ -186,6 +272,7 @@ func TestTargetRouteStillCarriesSchedule(t *testing.T) { if !ok || got.SoC != 0.7 || got.TimeOfDayMinUTC != 420 { t.Fatalf("target route stopped storing schedules: got=%+v ok=%v", got, ok) } + waitForSchedulePlan(t, svc) if _, reason := svc.LastReplanInfo(); reason != "loadpoint_schedule_changed" { t.Fatalf("replan reason = %q, want loadpoint_schedule_changed", reason) } @@ -203,3 +290,14 @@ func TestTargetRouteStillCarriesSchedule(t *testing.T) { t.Fatal("target route stopped clearing schedules via null") } } + +func waitForSchedulePlan(t *testing.T, svc *mpc.Service) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for svc.IsReplanning() { + if time.Now().After(deadline) { + t.Fatal("planner did not finish") + } + time.Sleep(time.Millisecond) + } +} diff --git a/go/internal/api/api_loadpoint_solar_save_test.go b/go/internal/api/api_loadpoint_solar_save_test.go new file mode 100644 index 00000000..90f66c88 --- /dev/null +++ b/go/internal/api/api_loadpoint_solar_save_test.go @@ -0,0 +1,52 @@ +package api + +import ( + "errors" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" + + "github.com/srcfl/ftw/go/internal/loadpoint" +) + +func TestSolarPreferenceStorageFailureKeepsChoiceAndRetrySaves(t *testing.T) { + for _, previous := range []bool{false, true} { + t.Run(strconv.FormatBool(previous), func(t *testing.T) { + m := loadpoint.NewManager() + m.Load([]loadpoint.Config{{ID: "garage", DriverName: "easee", SurplusOnly: previous}}) + saved, fail := previous, true + m.SetSurplusOnlySaver(func(_ string, v bool) error { + if fail { + return errors.New("disk full") + } + saved = v + return nil + }) + srv := New(&Deps{Loadpoints: m}) + request := func() *httptest.ResponseRecorder { + r := httptest.NewRequest(http.MethodPost, "/api/loadpoints/garage/target", + strings.NewReader(`{"surplus_only":`+strconv.FormatBool(!previous)+`}`)) + r.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, r) + return rr + } + rr := request() + if rr.Code != http.StatusInternalServerError || !strings.Contains(rr.Body.String(), "previous choice is unchanged") { + t.Fatalf("failed save returned %d: %s", rr.Code, rr.Body.String()) + } + if s, _ := m.State("garage"); s.SurplusOnly != previous || saved != previous { + t.Fatalf("failure changed solar preference: state=%v saved=%v", s.SurplusOnly, saved) + } + fail = false + if rr = request(); rr.Code != http.StatusOK { + t.Fatalf("retry returned %d: %s", rr.Code, rr.Body.String()) + } + if s, _ := m.State("garage"); s.SurplusOnly == previous || saved != s.SurplusOnly { + t.Fatalf("retry did not save the active preference: state=%v saved=%v", s.SurplusOnly, saved) + } + }) + } +} diff --git a/go/internal/api/api_savings.go b/go/internal/api/api_savings.go index 8c08381d..62884e00 100644 --- a/go/internal/api/api_savings.go +++ b/go/internal/api/api_savings.go @@ -176,7 +176,7 @@ func (s *Server) handleSavingsDaily(w http.ResponseWriter, r *http.Request) { var ds daySavings if isToday { - b, err := s.deps.State.DailyCostBreakdown(dayStart.UnixMilli(), now.UnixMilli(), zone, ep) + b, err := s.deps.State.DailyCostBreakdownContext(r.Context(), dayStart.UnixMilli(), now.UnixMilli(), zone, ep) if err != nil { slog.Error("handleSavingsDaily: DailyCostBreakdown failed", "err", err, "day", dayKey) http.Error(w, "savings load failed", http.StatusInternalServerError) @@ -191,7 +191,7 @@ func (s *Server) handleSavingsDaily(w http.ResponseWriter, r *http.Request) { ds = cached } else { dayEnd := dayStart.AddDate(0, 0, 1) - b, err := s.deps.State.DailyCostBreakdown(dayStart.UnixMilli(), dayEnd.UnixMilli(), zone, ep) + b, err := s.deps.State.DailyCostBreakdownContext(r.Context(), dayStart.UnixMilli(), dayEnd.UnixMilli(), zone, ep) if err != nil { slog.Error("handleSavingsDaily: DailyCostBreakdown failed", "err", err, "day", dayKey) http.Error(w, "savings load failed", http.StatusInternalServerError) diff --git a/go/internal/api/api_savings_test.go b/go/internal/api/api_savings_test.go index 83b70474..760b3f65 100644 --- a/go/internal/api/api_savings_test.go +++ b/go/internal/api/api_savings_test.go @@ -1,10 +1,12 @@ package api import ( + "context" "encoding/json" "net/http" "net/http/httptest" "path/filepath" + "strconv" "sync" "testing" "time" @@ -13,6 +15,55 @@ import ( "github.com/srcfl/ftw/go/internal/state" ) +func TestHandleSavingsDailyCanceledRequestDoesNotCacheSuccess(t *testing.T) { + for _, days := range []int{1, 2} { + t.Run(strconv.Itoa(days), func(t *testing.T) { + testSavingsDailyCanceledRequest(t, days) + }) + } +} + +func testSavingsDailyCanceledRequest(t *testing.T, days int) { + t.Helper() + st, err := state.Open(filepath.Join(t.TempDir(), "t.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) + srv := New(&Deps{ + State: st, + Cfg: &config.Config{Price: &config.Price{Zone: "SE3"}}, + CfgMu: &sync.RWMutex{}, + }) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + path := "/api/savings/daily?days=" + strconv.Itoa(days) + req := httptest.NewRequest(http.MethodGet, path, nil).WithContext(ctx) + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, req) + if rr.Code != http.StatusInternalServerError { + t.Fatalf("canceled savings status = %d, want 500", rr.Code) + } + if len(srv.savingsCache) != 0 { + t.Fatal("canceled request cached an incomplete day") + } + + rr = httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, path, nil)) + if rr.Code != http.StatusOK { + t.Fatalf("next savings status = %d, want 200: %s", rr.Code, rr.Body.String()) + } + var body struct { + Days []any `json:"days"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if len(body.Days) != days || len(srv.savingsCache) != days-1 { + t.Fatalf("retry returned %d days and cached %d, want %d and %d", len(body.Days), len(srv.savingsCache), days, days-1) + } +} + // No state and no config → empty days, 200. Matches /api/energy/daily's // "history is optional" contract so dev / test harnesses without a DB // don't 500. diff --git a/go/internal/api/api_selfupdate.go b/go/internal/api/api_selfupdate.go index 7ec268fc..55704841 100644 --- a/go/internal/api/api_selfupdate.go +++ b/go/internal/api/api_selfupdate.go @@ -425,16 +425,14 @@ func containsTraversal(id string) bool { return id == "." || id == ".." } -// handleVersionRestart signals the sidecar to pull + force-recreate the -// main service regardless of whether a newer image exists. Exists so the -// full update flow can be exercised end-to-end in dev / CI before cutting -// a real release. +// handleVersionRestart restarts the existing Core container. No image is +// selected or downloaded, even when Compose now names a different release. func (s *Server) handleVersionRestart(w http.ResponseWriter, r *http.Request) { if s.deps.SelfUpdate == nil { writeJSON(w, 503, map[string]string{"error": "self-update disabled"}) return } - if err := s.deps.SelfUpdate.Trigger(r.Context(), "restart", ""); err != nil { + if err := s.deps.SelfUpdate.TriggerRestart(r.Context()); err != nil { writeJSON(w, 502, map[string]string{"error": err.Error()}) return } diff --git a/go/internal/api/api_selfupdate_test.go b/go/internal/api/api_selfupdate_test.go index d348b8ea..62d3b0c6 100644 --- a/go/internal/api/api_selfupdate_test.go +++ b/go/internal/api/api_selfupdate_test.go @@ -883,3 +883,23 @@ func TestVersionUpdateStatus_Idle(t *testing.T) { t.Errorf("state = %q, want idle (no StatusPath configured)", out.State) } } + +func TestVersionRestartSurfacesOldUpdaterRefusal(t *testing.T) { + checker := selfupdate.New(selfupdate.Config{ + CurrentVersion: "v2.14.0-beta.1", + SocketPath: startFakeSidecar(t, http.StatusBadRequest), + }, newMemStore()) + srv := New(&Deps{SelfUpdate: checker}) + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodPost, "/api/version/restart", nil)) + if rr.Code != http.StatusBadGateway { + t.Fatalf("status = %d: %s", rr.Code, rr.Body.String()) + } + var body map[string]string + if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil { + t.Fatal(err) + } + if !strings.Contains(body["error"], "safe restart requires a newer updater") { + t.Fatalf("missing user recovery instruction: %v", body) + } +} diff --git a/go/internal/api/loadpoint_vehicle.go b/go/internal/api/loadpoint_vehicle.go new file mode 100644 index 00000000..721c94b4 --- /dev/null +++ b/go/internal/api/loadpoint_vehicle.go @@ -0,0 +1,72 @@ +package api + +import ( + "math" + "net/http" + "sync" + + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/configreload" +) + +// Keep two capacity edits from saving different copies of the same config. +// FTW runs one API server; the mutex spans the save and shared apply callback. +var loadpointVehicleWrites sync.Mutex + +// handleLoadpointVehicle changes only the usual car's battery capacity. The +// runtime may still prefer a capacity reported by the car for this session. +func (s *Server) handleLoadpointVehicle(w http.ResponseWriter, r *http.Request) { + if s.deps.Loadpoints == nil || s.deps.Cfg == nil || s.deps.CfgMu == nil || s.deps.SaveConfig == nil || s.deps.Ctrl == nil || s.deps.CtrlMu == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "Charging settings are not available yet."}) + return + } + var body struct { + CapacityWh float64 `json:"capacity_wh"` + } + if err := readJSON(r, &body); err != nil || math.IsNaN(body.CapacityWh) || math.IsInf(body.CapacityWh, 0) || body.CapacityWh < 1000 || body.CapacityWh > 300000 { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Enter a usable battery size from 1 to 300 kWh."}) + return + } + id := r.PathValue("id") + loadpointVehicleWrites.Lock() + defer loadpointVehicleWrites.Unlock() + + // Copy only the slice we edit. Hold the read lock through serialization so + // another config writer cannot change referenced fields while they save. + s.deps.CfgMu.RLock() + next := *s.deps.Cfg + next.Loadpoints = append([]config.Loadpoint(nil), s.deps.Cfg.Loadpoints...) + index := -1 + for i := range next.Loadpoints { + if next.Loadpoints[i].ID == id { + index = i + break + } + } + if index < 0 { + s.deps.CfgMu.RUnlock() + writeJSON(w, http.StatusNotFound, map[string]string{"error": "Charger not found in settings."}) + return + } + next.Loadpoints[index].VehicleCapacityWh = body.CapacityWh + err := s.deps.SaveConfig(s.deps.ConfigPath, &next) + s.deps.CfgMu.RUnlock() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "Battery size could not be saved. The previous size is still in use."}) + return + } + configreload.Apply(s.deps.CfgMu, s.deps.Cfg, s.deps.CtrlMu, s.deps.Ctrl, &next, s.deps.ConfigApplier) + if s.deps.ConfigApplier == nil { + // Minimal embeddings do not wire main's shared callback. Change just + // this capacity in the manager's existing configuration. + points := s.deps.Loadpoints.Configs() + for i := range points { + if points[i].ID == id { + points[i].VehicleCapacityWh = body.CapacityWh + } + } + s.deps.Loadpoints.Load(points) + } + s.replanForScheduleChange(id) + writeJSON(w, http.StatusOK, map[string]any{"ok": true, "vehicle_capacity_wh": body.CapacityWh, "capacity_source": "configured"}) +} diff --git a/go/internal/api/loadpoint_vehicle_test.go b/go/internal/api/loadpoint_vehicle_test.go new file mode 100644 index 00000000..2ac4422a --- /dev/null +++ b/go/internal/api/loadpoint_vehicle_test.go @@ -0,0 +1,122 @@ +package api + +import ( + "encoding/json" + "errors" + "math" + "net/http" + "net/http/httptest" + "os" + "reflect" + "strings" + "testing" + + "github.com/srcfl/ftw/go/internal/apiauth" + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/loadpoint" +) + +func vehicleServer(t *testing.T) (*Server, *loadpoint.Manager) { + t.Helper() + srv, _, cfg := postConfigServer(t, nil) + cfg.Site.Name = "Home" + cfg.Drivers = []config.Driver{{Name: "easee", Lua: "/app/drivers/easee.lua", Config: map[string]any{"token": "preserve-me"}}} + cfg.Loadpoints = []config.Loadpoint{{ID: "garage", DriverName: "easee", VehicleCapacityWh: 60000, MaxChargeW: 11000}, {ID: "guest", DriverName: "other", VehicleCapacityWh: 45000}} + mgr := loadpoint.NewManager() + mgr.Load([]loadpoint.Config{{ID: "garage", DriverName: "easee", VehicleCapacityWh: 60000, MaxChargeW: 11000}, {ID: "guest", DriverName: "other", VehicleCapacityWh: 45000}}) + mgr.Observe("garage", true, 7000, 0, true) + mgr.Observe("garage", true, 7000, 6000, true) + mgr.SetCurrentSoC("garage", 0.42) + srv.deps.Loadpoints = mgr + srv.deps.SaveConfig = config.SaveAtomic + return srv, mgr +} + +func postVehicle(srv *Server, id, body string) *httptest.ResponseRecorder { + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/loadpoints/"+id+"/vehicle", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + srv.Handler().ServeHTTP(rr, req) + return rr +} + +func TestVehicleCapacitySaveReadbackAndSession(t *testing.T) { + srv, mgr := vehicleServer(t) + beforeDrivers := srv.deps.Cfg.Drivers + beforeGuest := srv.deps.Cfg.Loadpoints[1] + rr := postVehicle(srv, "garage", `{"capacity_wh":77400}`) + if rr.Code != http.StatusOK { + t.Fatalf("status %d: %s", rr.Code, rr.Body.String()) + } + var result struct { + OK bool `json:"ok"` + Capacity float64 `json:"vehicle_capacity_wh"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &result); err != nil || !result.OK || result.Capacity != 77400 { + t.Fatalf("response %s: %v", rr.Body.String(), err) + } + if !reflect.DeepEqual(beforeDrivers, srv.deps.Cfg.Drivers) || beforeGuest.VehicleCapacityWh != srv.deps.Cfg.Loadpoints[1].VehicleCapacityWh || srv.deps.Cfg.Site.Name != "Home" { + t.Fatal("unrelated settings changed") + } + raw, err := os.ReadFile(srv.deps.ConfigPath) + if err != nil || !strings.Contains(string(raw), "vehicle_capacity_wh: 77400") || !strings.Contains(string(raw), "preserve-me") { + t.Fatalf("saved config lost data: %v", err) + } + state, _ := mgr.State("garage") + if state.VehicleCapacityWh != 77400 || math.Abs(state.CurrentSoC-0.42) > 0.000001 || state.DeliveredWhSession != 6000 { + t.Fatalf("capacity edit changed current session: %+v", state) + } + read := httptest.NewRecorder() + srv.Handler().ServeHTTP(read, httptest.NewRequest(http.MethodGet, "/api/loadpoints", nil)) + if read.Code != 200 || !strings.Contains(read.Body.String(), `"vehicle_capacity_wh":77400`) { + t.Fatalf("readback: %s", read.Body.String()) + } +} + +func TestVehicleCapacityFailedSaveDoesNotApply(t *testing.T) { + srv, mgr := vehicleServer(t) + called := false + srv.deps.ConfigApplier = func(_, _ *config.Config) { called = true } + srv.deps.SaveConfig = func(string, *config.Config) error { return errors.New("disk full") } + rr := postVehicle(srv, "garage", `{"capacity_wh":80000}`) + state, _ := mgr.State("garage") + if rr.Code != 500 || called || state.VehicleCapacityWh != 60000 || srv.deps.Cfg.Loadpoints[0].VehicleCapacityWh != 60000 { + t.Fatalf("failed save reached runtime: status %d, callback %v, capacity %v", rr.Code, called, state.VehicleCapacityWh) + } +} + +func TestVehicleCapacityUsesSharedApplier(t *testing.T) { + srv, _ := vehicleServer(t) + var oldCapacity, nextCapacity float64 + srv.deps.ConfigApplier = func(next, old *config.Config) { + oldCapacity = old.Loadpoints[0].VehicleCapacityWh + nextCapacity = next.Loadpoints[0].VehicleCapacityWh + } + if rr := postVehicle(srv, "garage", `{"capacity_wh":80000}`); rr.Code != 200 { + t.Fatal(rr.Body.String()) + } + if oldCapacity != 60000 || nextCapacity != 80000 { + t.Fatalf("wrong apply snapshots: %v -> %v", oldCapacity, nextCapacity) + } +} + +func TestVehicleCapacityValidatesAndIsConfigure(t *testing.T) { + srv, _ := vehicleServer(t) + for _, body := range []string{``, `{`, `{}`, `{"capacity_wh":0}`, `{"capacity_wh":999}`, `{"capacity_wh":300001}`, `{"capacity_wh":1e1000}`, `{"capacity_wh":"80000"}`} { + if rr := postVehicle(srv, "garage", body); rr.Code != 400 { + t.Fatalf("body %q gave %d", body, rr.Code) + } + } + if rr := postVehicle(srv, "unknown", `{"capacity_wh":80000}`); rr.Code != 404 { + t.Fatalf("unknown id gave %d", rr.Code) + } + for _, body := range []string{`{"capacity_wh":1000}`, `{"capacity_wh":300000}`} { + if rr := postVehicle(srv, "garage", body); rr.Code != 200 { + t.Fatalf("boundary %q gave %d", body, rr.Code) + } + } + facts := srv.Route(httptest.NewRequest(http.MethodPost, "/api/loadpoints/garage/vehicle", nil)) + if facts.Tier != apiauth.TierConfigure || facts.ReplacesAll { + t.Fatalf("route price: %+v", facts) + } +} diff --git a/go/internal/appproto/ev.go b/go/internal/appproto/ev.go index ac1f6e87..e23cfa52 100644 --- a/go/internal/appproto/ev.go +++ b/go/internal/appproto/ev.go @@ -27,7 +27,7 @@ func argNum(args map[string]any, key string) (float64, bool) { case uint64: return float64(v), true case float64: - return v, true + return v, !math.IsNaN(v) && !math.IsInf(v, 0) } return 0, false } @@ -36,7 +36,7 @@ func argNum(args map[string]any, key string) (float64, bool) { // millisecond is a client bug worth refusing rather than rounding. func argInt(args map[string]any, key string) (int64, bool) { f, ok := argNum(args, key) - if !ok || f != math.Trunc(f) { + if !ok || f != math.Trunc(f) || f < math.MinInt64 || f >= math.MaxInt64 { return 0, false } return int64(f), true @@ -98,9 +98,25 @@ func (h *Handler) loadpointHold(cmd Cmd, uptimeMs int64) error { // absent means 0, a valid "pause" hold, exactly as an omitted JSON field // would; hold_s of 0 or absent is the persistent operator hold that only // clear or an unplug releases. - powerW, _ := argNum(cmd.Args, "power_w") - if powerW < 0 { - return h.rejectArg(cmd, "power_w", powerW) + powerW, powerOK := argNum(cmd.Args, "power_w") + if _, present := cmd.Args["power_w"]; (present && !powerOK) || powerW < 0 { + return h.rejectArg(cmd, "power_w", cmd.Args["power_w"]) + } + for _, key := range []string{"phase_split_w", "voltage", "max_amps_per_phase"} { + if value, present := cmd.Args[key]; present { + n, ok := argNum(cmd.Args, key) + if !ok || n < 0 { + return h.rejectArg(cmd, key, value) + } + } + } + for _, key := range []string{"min_phase_hold_s", "site_phases"} { + if value, present := cmd.Args[key]; present { + n, ok := argInt(cmd.Args, key) + if !ok || n < 0 || (key == "site_phases" && n != 0 && n != 1 && n != 3) { + return h.rejectArg(cmd, key, value) + } + } } var holdS int64 if _, present := cmd.Args["hold_s"]; present { @@ -113,7 +129,10 @@ func (h *Handler) loadpointHold(cmd Cmd, uptimeMs int64) error { if holdS > int64(loadpoint.MaxManualHold/time.Second) { return h.rejectArg(cmd, "hold_s", holdS) } - phaseMode, _ := cmd.Args["phase_mode"].(string) + phaseMode, phaseOK := cmd.Args["phase_mode"].(string) + if _, present := cmd.Args["phase_mode"]; present && !phaseOK { + return h.rejectArg(cmd, "phase_mode", cmd.Args["phase_mode"]) + } switch phaseMode { case "", "auto", "1p", "3p": default: @@ -401,8 +420,8 @@ func (h *Handler) loadpointSurplusOnlySet(cmd Cmd, uptimeMs int64) error { } if _, ok := lp.SetSurplusOnly(id, want); !ok { - // The loadpoint went away between the existence check and the - // write — a configuration reload mid-command. + // The loadpoint disappeared or storage rejected the change. Keep + // the previous choice and let the app offer a retry. return h.settleAndReport(cmd.CmdID, CmdResult{ CmdID: cmd.CmdID, State: CmdRejected, diff --git a/go/internal/appproto/ev_test.go b/go/internal/appproto/ev_test.go index 818c4fa0..4ec0aaf8 100644 --- a/go/internal/appproto/ev_test.go +++ b/go/internal/appproto/ev_test.go @@ -552,6 +552,11 @@ func TestAMalformedLoadpointCommandIsRefused(t *testing.T) { args map[string]any arg string }{ + {"text power", OpLoadpointHold, map[string]any{"id": "lp1", "power_w": "6000"}, "power_w"}, + {"text limit", OpLoadpointHold, map[string]any{"id": "lp1", "power_w": 4140, "max_amps_per_phase": "32"}, "max_amps_per_phase"}, + {"negative voltage", OpLoadpointHold, map[string]any{"id": "lp1", "power_w": 4140, "voltage": -230}, "voltage"}, + {"fractional phase hold", OpLoadpointHold, map[string]any{"id": "lp1", "power_w": 4140, "min_phase_hold_s": 1.5}, "min_phase_hold_s"}, + {"invalid phases", OpLoadpointHold, map[string]any{"id": "lp1", "power_w": 4140, "site_phases": 2}, "site_phases"}, {"negative power", OpLoadpointHold, map[string]any{"id": "lp1", "power_w": -100, "hold_s": 0}, "power_w"}, {"hold beyond the cap", OpLoadpointHold, diff --git a/go/internal/appproto/ports.go b/go/internal/appproto/ports.go index 9a0d8404..b59fa6f4 100644 --- a/go/internal/appproto/ports.go +++ b/go/internal/appproto/ports.go @@ -171,7 +171,8 @@ type Loadpoints interface { // ObservedSoC is the state of charge the box holds for the car now. ObservedSoC(id string) (soc float64, ok bool) // SetSurplusOnly turns PV-only charging on or off and reports the value - // it replaced. The implementation carries the replan the HTTP target + // it replaced, or ok=false if the loadpoint is missing or the save fails. + // The implementation carries the replan the HTTP target // route does — synchronous when the flag turns off, because the car may // now draw from the grid and the plan must say so before the app reads // it back. diff --git a/go/internal/backup/archive.go b/go/internal/backup/archive.go index 014064b7..85d429ed 100644 --- a/go/internal/backup/archive.go +++ b/go/internal/backup/archive.go @@ -299,6 +299,23 @@ func describeSource(ctx context.Context, dataDir string, source sourceEntry) (Fi if err != nil || !pathInside(dataDir, resolved) { return FileEntry{}, fmt.Errorf("backup: symlink escapes data dir: %s -> %s", source.sourcePath, target) } + // Managed driver activations use absolute paths in the running + // container. Store an internal relative link so restore can relocate + // data without referring back to /app/data or the original machine. + // Do not follow the link; validate the complete archive graph below. + if filepath.IsAbs(target) { + rootPrefix := dataDir + string(filepath.Separator) + if !strings.HasPrefix(target, rootPrefix) { + return FileEntry{}, fmt.Errorf("backup: absolute symlink is outside data dir: %s", source.sourcePath) + } + back, relErr := filepath.Rel(filepath.Dir(source.sourcePath), dataDir) + if relErr != nil { + return FileEntry{}, relErr + } + // Preserve .. until archive graph validation has expanded links. + target = back + string(filepath.Separator) + strings.TrimPrefix(target, rootPrefix) + } + target = filepath.ToSlash(target) entry.Type, entry.LinkTarget = "symlink", target h := sha256.Sum256([]byte(target)) entry.SHA256 = hex.EncodeToString(h[:]) @@ -495,7 +512,7 @@ func validateManifest(manifest Manifest) error { return errors.New("backup: invalid manifest identity or database path") } seen := make(map[string]bool, len(manifest.Files)) - symlinks := make(map[string]bool) + symlinks := make(map[string]string) databaseFound := false for _, entry := range manifest.Files { if !safeDataPath(entry.Path) || entry.Path == manifestPath || seen[entry.Path] { @@ -528,16 +545,24 @@ func validateManifest(manifest Manifest) error { if !safeDataPath(resolved) { return fmt.Errorf("backup: symlink target escapes data root: %s -> %s", entry.Path, entry.LinkTarget) } - symlinks[entry.Path] = true + symlinks[entry.Path] = entry.LinkTarget } } if !databaseFound { return errors.New("backup: compressed database missing from manifest") } + // A lexical path check alone misses `alias/../outside`: .. applies + // after alias is followed. Resolve each link against archive entries, + // never the host filesystem, and reject cycles or escape chains. + for name, target := range symlinks { + if err := validateArchiveLink(name, target, symlinks); err != nil { + return err + } + } for _, entry := range manifest.Files { parent := path.Dir(entry.Path) for parent != "." && parent != "/" { - if symlinks[parent] { + if _, ok := symlinks[parent]; ok { return fmt.Errorf("backup: entry %s is nested below symlink %s", entry.Path, parent) } parent = path.Dir(parent) @@ -546,6 +571,42 @@ func validateManifest(manifest Manifest) error { return nil } +// validateArchiveLink models relative symlink traversal without cleaning away +// .. before symlink expansion. Archive paths start at the data root. +func validateArchiveLink(name, target string, links map[string]string) error { + parts := strings.Split(strings.TrimPrefix(path.Dir(name), "data/"), "/") + if path.Dir(name) == "data" { + parts = nil + } + pending := strings.Split(target, "/") + expansions := 0 + for len(pending) > 0 { + part := pending[0] + pending = pending[1:] + switch part { + case "", ".": + continue + case "..": + if len(parts) == 0 { + return fmt.Errorf("backup: symlink chain escapes data root: %s", name) + } + parts = parts[:len(parts)-1] + default: + candidate := "data/" + strings.Join(append(append([]string{}, parts...), part), "/") + if next, ok := links[candidate]; ok { + expansions++ + if expansions > 40 { + return fmt.Errorf("backup: cyclic or excessive symlink chain: %s", name) + } + pending = append(strings.Split(next, "/"), pending...) + } else { + parts = append(parts, part) + } + } + } + return nil +} + // Restore verifies and extracts the archive into a new directory, then swaps // it into place while retaining the previous data directory beside it. // Callers must stop FTW before invoking this function. diff --git a/go/internal/backup/archive_test.go b/go/internal/backup/archive_test.go index 9561c715..3b0c3f4a 100644 --- a/go/internal/backup/archive_test.go +++ b/go/internal/backup/archive_test.go @@ -233,3 +233,116 @@ func writeTestFile(t *testing.T, filename, body string) { t.Fatal(err) } } + +func TestAbsoluteManagedDriverBackupRestoresAtAnotherPath(t *testing.T) { + root := t.TempDir() + dataDir := filepath.Join(root, "original-data") + installed := filepath.Join(dataDir, "driver-repository", "installed", "ftw-official", "goodwe", "1.0.1", strings.Repeat("a", 64), "goodwe.lua") + active := filepath.Join(dataDir, "driver-repository", "active", "goodwe.lua") + if err := os.MkdirAll(filepath.Dir(installed), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(active), 0o755); err != nil { + t.Fatal(err) + } + writeTestFile(t, installed, "DRIVER = { id = 'goodwe', version = '1.0.1' }") + if err := os.Symlink(installed, active); err != nil { + t.Fatal(err) + } + statePath := filepath.Join(dataDir, "state.db") + st, err := state.Open(statePath) + if err != nil { + t.Fatal(err) + } + defer st.Close() + archive, err := Create(context.Background(), CreateOptions{State: st, StatePath: statePath, DataDir: dataDir, OutputDir: filepath.Join(root, "backups")}) + if err != nil { + t.Fatal(err) + } + manifest, err := Verify(archive.Path) + if err != nil { + t.Fatal(err) + } + for _, entry := range manifest.Files { + if entry.Type == "symlink" && filepath.IsAbs(entry.LinkTarget) { + t.Fatalf("archive kept host link: %+v", entry) + } + } + // The archive may not silently alter the active installation. + if target, _ := os.Readlink(active); target != installed { + t.Fatalf("source link changed: %q", target) + } + if err := st.Close(); err != nil { + t.Fatal(err) + } + // Remove the source completely before reading the restored link. Otherwise + // a link back to the old host path could make this test pass by accident. + if err := os.RemoveAll(dataDir); err != nil { + t.Fatal(err) + } + restoredDir := filepath.Join(root, "new-installation", "data") + if err := os.MkdirAll(filepath.Dir(restoredDir), 0o755); err != nil { + t.Fatal(err) + } + if _, err := Restore(archive.Path, restoredDir, time.Now()); err != nil { + t.Fatal(err) + } + restoredLink := filepath.Join(restoredDir, "driver-repository", "active", "goodwe.lua") + body, err := os.ReadFile(restoredLink) + if err != nil || !strings.Contains(string(body), "goodwe") { + t.Fatalf("restored driver = %q, %v", body, err) + } + restoredRoot, err := filepath.EvalSymlinks(restoredDir) + if err != nil { + t.Fatal(err) + } + target, err := filepath.EvalSymlinks(restoredLink) + if err != nil || !pathInside(restoredRoot, target) { + t.Fatalf("restored link escapes: %s, %v", target, err) + } +} + +func TestValidateManifestChecksSymlinkChains(t *testing.T) { + for _, tc := range []struct { + name string + links map[string]string + valid bool + }{ + {"internal driver chain", map[string]string{"data/active.lua": "installed.lua", "data/installed.lua": "version/driver.lua"}, true}, + {"cycle", map[string]string{"data/a": "b", "data/b": "a"}, false}, + // Lexically each target stays inside data/. Resolving alias first + // changes the depth, so ../.. then escapes the archive root. + {"dotdot after directory link", map[string]string{"data/dir/alias": "../target", "data/escape": "dir/alias/../../outside"}, false}, + {"absolute host path", map[string]string{"data/active.lua": "/app/data/driver.lua"}, false}, + {"direct escape", map[string]string{"data/active.lua": "../outside"}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + m := Manifest{Format: Format, SchemaVersion: SchemaVersion, CreatedAt: time.Now(), DatabaseFile: "state.db", DatabaseEntry: "data/state.db.gz", Files: []FileEntry{{Path: "data/state.db.gz", Type: "file", SHA256: strings.Repeat("a", 64)}}} + for name, target := range tc.links { + m.Files = append(m.Files, FileEntry{Path: name, Type: "symlink", LinkTarget: target, SHA256: strings.Repeat("b", 64)}) + } + err := validateManifest(m) + if (err == nil) != tc.valid { + t.Fatalf("valid=%v, err=%v", tc.valid, err) + } + }) + } +} + +func TestDescribeSourceRejectsExternalAbsoluteLinkWithoutReadingIt(t *testing.T) { + root := t.TempDir() + dataDir := filepath.Join(root, "data") + if err := os.Mkdir(dataDir, 0o700); err != nil { + t.Fatal(err) + } + outside := filepath.Join(root, "secret") + writeTestFile(t, outside, "must not enter the backup") + link := filepath.Join(dataDir, "driver.lua") + if err := os.Symlink(outside, link); err != nil { + t.Fatal(err) + } + _, err := describeSource(context.Background(), dataDir, sourceEntry{archivePath: "data/driver.lua", sourcePath: link}) + if err == nil || !strings.Contains(err.Error(), "escapes data dir") { + t.Fatalf("external link error = %v", err) + } +} diff --git a/go/internal/control/ev_site_harness_test.go b/go/internal/control/ev_site_harness_test.go new file mode 100644 index 00000000..67b23e4d --- /dev/null +++ b/go/internal/control/ev_site_harness_test.go @@ -0,0 +1,481 @@ +package control + +// Joined EV + home-battery + planner clock. +// +// Isolated suites did not catch "EV charging is broken beside the battery": +// +// - mpc tests call Optimize / ValidatePlan and never Tick the charger +// - loadpoint tests inject a Directive and never run ComputeDispatch +// - control golden / forecast_scenarios call ComputeDispatch with a +// SlotDirective and a pre-baked EVChargingW — no loadpoint controller +// - go/test/e2e has Ferroamp / Sungrow batteries and no EV charger +// +// This is not a parallel mapper. The site clock holds an mpc.Service, +// publishes the plan with InstallPlan, and reads it the same way +// go/cmd/ftw/main.go does: +// +// SlotDirectiveAt → control.SlotDirectiveFromMPC → ComputeDispatch +// SlotDirectiveAt → LoadpointDirective → loadpoint.Controller +// Latest + PeakPlannedSurplusForEV → 3Φ gate +// SurplusAvailableForEVW → surplus clamp +// +// Tick order matches main.go: charger first, then battery dispatch, +// then the next meter sample sees both commands. +// +// SlotDirectiveAt ages GeneratedAtMs on the wall clock (MaxPlanAge), +// not the pinned site clock. Injected noon slots still stamp +// GeneratedAtMs with time.Now(). The 3Φ gate is the one legitimate +// test difference: it scans with the pinned clock so a 12:00 slot is +// "now", matching what main.go does with time.Now() on a live site. +// +// Run: go test -run 'TestEVSite' ./go/internal/control + +import ( + "context" + "encoding/json" + "fmt" + "math" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/mpc" + "github.com/srcfl/ftw/go/internal/telemetry" +) + +const ( + evSiteMeter = "meter" + evSiteBattery = "pixii" + evSitePV = "pv" + evSiteCharger = "easee" + evSiteLP = "garage" + + evSiteTickS = 5 +) + +// evSiteConfig is one joined-site scenario. Live PV/load stay at the +// values given here for the whole run (happy-path: live matches the +// slot the plan was built from). Plan is either injected or produced +// by mpc.Optimize when OptimizeSlots/OptimizeParams are set. +type evSiteConfig struct { + Start time.Time + Plan mpc.Plan + + OptimizeSlots []mpc.Slot + OptimizeParams mpc.Params + + LP loadpoint.Config + + LoadW float64 + PVW float64 // site-signed (generation is negative) + + BatCapWh float64 + BatEnergyWh float64 + BatMaxCharge float64 + + EVCapWh float64 + EVEnergyWh float64 + + FuseMaxW float64 +} + +type evSiteTick struct { + N int + At time.Time + LoadW, PVW float64 + BatW, EVW, GridW float64 + BatCmdW, EVCmdW float64 + SurplusW float64 + PlanBatW, PlanEVW float64 + PlanGridW float64 +} + +type evCmdSender struct { + lastW float64 + lastSet bool +} + +func (s *evCmdSender) Send(_ context.Context, _ string, payload []byte) error { + var d struct { + PowerW float64 `json:"power_w"` + } + if err := json.Unmarshal(payload, &d); err != nil { + return err + } + s.lastW = d.PowerW + s.lastSet = true + return nil +} + +// siteClock is the joined plant: pinned time, live flows, and the same +// mpc.Service cache main.go reads. +type siteClock struct { + t *testing.T + cfg evSiteConfig + now time.Time + dt time.Duration + + loadW, pvW float64 + batW, evW, gridW float64 + batEnergyWh float64 + evEnergyWh float64 + sessionWh float64 + surplusOnly bool + + planner *mpc.Service + store *telemetry.Store + st *State + mgr *loadpoint.Manager + lp *loadpoint.Controller + sender *evCmdSender + caps map[string]float64 + fuseW float64 + + ticks []evSiteTick +} + +func newSiteClock(t *testing.T, cfg evSiteConfig) *siteClock { + t.Helper() + if cfg.Start.IsZero() { + cfg.Start = time.Date(2026, 8, 18, 12, 0, 1, 0, time.UTC) + } + if cfg.BatCapWh <= 0 { + cfg.BatCapWh = 20000 + } + if cfg.BatEnergyWh <= 0 { + cfg.BatEnergyWh = 4000 + } + if cfg.BatMaxCharge <= 0 { + cfg.BatMaxCharge = 10000 + } + if cfg.EVCapWh <= 0 { + cfg.EVCapWh = 60000 + } + if cfg.FuseMaxW <= 0 { + cfg.FuseMaxW = 25 * 230 * 3 // 17.25 kW — combo charge must fit + } + if cfg.LP.ID == "" { + cfg.LP.ID = evSiteLP + } + if cfg.LP.DriverName == "" { + cfg.LP.DriverName = evSiteCharger + } + if cfg.LP.MinChargeW <= 0 { + cfg.LP.MinChargeW = 1380 + } + if cfg.LP.MaxChargeW <= 0 { + cfg.LP.MaxChargeW = 11040 + } + if len(cfg.LP.AllowedStepsW) == 0 { + cfg.LP.AllowedStepsW = []float64{0, 1380, 4140, 6900, 11040} + } + if cfg.LP.PhaseSplitW <= 0 { + cfg.LP.PhaseSplitW = 3680 + } + + plan := cfg.Plan + params := mpc.Params{Mode: mpc.ModeArbitrage} + if len(cfg.OptimizeSlots) > 0 { + params = cfg.OptimizeParams + plan = mpc.Optimize(cfg.OptimizeSlots, params) + if len(plan.Actions) == 0 { + t.Fatalf("Optimize returned no actions") + } + } + if len(plan.Actions) == 0 { + t.Fatal("ev site needs an injected plan or OptimizeSlots") + } + // SlotDirectiveAt ages GeneratedAtMs on the wall clock. + plan.GeneratedAtMs = time.Now().UnixMilli() + + planner := &mpc.Service{Defaults: mpc.Params{Mode: params.Mode}} + planner.InstallPlan(plan, params, cfg.LP.ID) + + s := &siteClock{ + t: t, + cfg: cfg, + now: cfg.Start, + dt: evSiteTickS * time.Second, + loadW: cfg.LoadW, + pvW: cfg.PVW, + batEnergyWh: cfg.BatEnergyWh, + evEnergyWh: cfg.EVEnergyWh, + surplusOnly: cfg.LP.SurplusOnly, + planner: planner, + store: telemetry.NewStore(), + sender: &evCmdSender{}, + caps: map[string]float64{evSiteBattery: cfg.BatCapWh}, + fuseW: cfg.FuseMaxW, + } + s.gridW = loadpoint.GridW(s.loadW, s.pvW, s.batW, s.evW) + + st := NewState(0, 0, evSiteMeter) + st.Mode = ModePlannerArbitrage + st.UseEnergyDispatch = true + st.SlewEnabled = false + st.SlewRateW = 100_000 + st.MinDispatchIntervalS = 0 + st.BatteryCoversEV = false + st.DriverLimits = map[string]PowerLimits{ + evSiteBattery: {MaxChargeW: cfg.BatMaxCharge, MaxDischargeW: cfg.BatMaxCharge}, + } + st.clock = func() time.Time { return s.now } + st.SlotDirective = func(now time.Time) (SlotDirective, bool) { + d, ok := s.planner.SlotDirectiveAt(now) + if !ok { + return SlotDirective{}, false + } + return SlotDirectiveFromMPC(d), true + } + s.st = st + + mgr := loadpoint.NewManager() + mgr.Load([]loadpoint.Config{cfg.LP}) + s.mgr = mgr + + lp := loadpoint.NewController(mgr, + func(now time.Time) (loadpoint.Directive, bool) { + d, ok := s.planner.SlotDirectiveAt(now) + if !ok { + return loadpoint.Directive{}, false + } + return d.LoadpointDirective(), true + }, + func(driver string) (loadpoint.EVSample, bool) { + if driver != cfg.LP.DriverName { + return loadpoint.EVSample{}, false + } + return loadpoint.EVSample{ + PowerW: s.evW, + SessionWh: s.sessionWh, + Connected: true, + RequestActive: true, + }, true + }, + s.sender.Send, + ) + lp.SetSiteSurplusForEV(func() (float64, bool) { + return loadpoint.SurplusAvailableForEVW(s.gridW, s.batW, s.evW, lp.AnyLoadpointSurplusActive()), true + }) + lp.SetNearTermPeakSurplusW(func(window time.Duration) (float64, bool) { + plan := s.planner.Latest() + if plan == nil { + return 0, false + } + return mpc.PeakPlannedSurplusForEV(plan.Actions, s.now, window) + }) + s.lp = lp + s.publish() + return s +} + +func (s *siteClock) plan() mpc.Plan { + s.t.Helper() + p := s.planner.Latest() + if p == nil { + s.t.Fatal("planner has no plan") + } + return *p +} + +func (s *siteClock) slotDirective() (mpc.SlotDirective, bool) { + return s.planner.SlotDirectiveAt(s.now) +} + +func (s *siteClock) publish() { + s.t.Helper() + soc := s.batEnergyWh / s.cfg.BatCapWh + if soc < 0 { + soc = 0 + } + if soc > 1 { + soc = 1 + } + s.store.Update(evSiteMeter, telemetry.DerMeter, s.gridW, nil, nil) + s.store.Update(evSiteBattery, telemetry.DerBattery, s.batW, &soc, nil) + s.store.Update(evSitePV, telemetry.DerPV, s.pvW, nil, nil) + s.store.Update(evSiteCharger, telemetry.DerEV, s.evW, nil, nil) + s.store.DriverHealthMut(evSiteMeter).RecordSuccess() + s.store.DriverHealthMut(evSiteBattery).RecordSuccess() + s.store.DriverHealthMut(evSitePV).RecordSuccess() + s.store.DriverHealthMut(evSiteCharger).RecordSuccess() +} + +func (s *siteClock) tick() evSiteTick { + s.t.Helper() + s.publish() + + d, ok := s.slotDirective() + if !ok { + s.t.Fatalf("tick %d: SlotDirectiveAt(%s) empty — GeneratedAtMs ages on the wall clock (MaxPlanAge), not the site clock", + len(s.ticks), s.now.Format(time.RFC3339)) + } + hours := d.SlotEnd.Sub(d.SlotStart).Hours() + planBatW, planEVW := 0.0, 0.0 + if hours > 0 { + planBatW = d.BatteryEnergyWh / hours + planEVW = d.LoadpointEnergyWh[s.cfg.LP.ID] / hours + } + + surplus := loadpoint.SurplusAvailableForEVW(s.gridW, s.batW, s.evW, s.lp.AnyLoadpointSurplusActive()) + + s.sender.lastSet = false + s.lp.TickWithDispatch(context.Background(), s.now, true) + evCmd := 0.0 + if s.sender.lastSet { + evCmd = s.sender.lastW + } + + lpStates := s.mgr.States() + s.st.EVSurplusOnlyReserveW = loadpoint.SurplusReserveW(lpStates, nil) + s.st.EVSurplusOnlyChargingW = loadpoint.SurplusChargingW(lpStates) + s.st.EVCurtailHeadroomW = loadpoint.SurplusPotentialW(lpStates) + + targets := ComputeDispatch(s.store, s.st, s.caps, s.fuseW) + var batCmd float64 + for _, tg := range targets { + if tg.Driver == evSiteBattery { + batCmd = tg.TargetW + } + } + + dtH := s.dt.Hours() + s.evW = evCmd + if s.evW < 0 { + s.evW = 0 + } + s.batW = batCmd + s.gridW = loadpoint.GridW(s.loadW, s.pvW, s.batW, s.evW) + if s.batW >= 0 { + s.batEnergyWh += s.batW * dtH * 0.95 + } else { + s.batEnergyWh += s.batW * dtH / 0.95 + } + if s.batEnergyWh < 0 { + s.batEnergyWh = 0 + } + if s.batEnergyWh > s.cfg.BatCapWh { + s.batEnergyWh = s.cfg.BatCapWh + } + s.evEnergyWh += s.evW * dtH * 0.90 + s.sessionWh += s.evW * dtH + + rec := evSiteTick{ + N: len(s.ticks), + At: s.now, + LoadW: s.loadW, + PVW: s.pvW, + BatW: s.batW, + EVW: s.evW, + GridW: s.gridW, + BatCmdW: batCmd, + EVCmdW: evCmd, + SurplusW: surplus, + PlanBatW: planBatW, + PlanEVW: planEVW, + PlanGridW: d.GridW, + } + s.checkInvariants(rec) + s.ticks = append(s.ticks, rec) + s.now = s.now.Add(s.dt) + return rec +} + +func (s *siteClock) run(n int) []evSiteTick { + s.t.Helper() + out := make([]evSiteTick, 0, n) + for i := 0; i < n; i++ { + out = append(out, s.tick()) + } + return out +} + +func (s *siteClock) leftoverW() float64 { + return loadpoint.PVLeftoverAfterHouseW(s.loadW, s.pvW) +} + +func (s *siteClock) checkInvariants(rec evSiteTick) { + s.t.Helper() + ident := loadpoint.GridW(rec.LoadW, rec.PVW, rec.BatW, rec.EVW) + if math.Abs(rec.GridW-ident) > 1 { + s.t.Fatalf("tick %d: grid identity %.1f != load+pv+bat+ev %.1f", rec.N, rec.GridW, ident) + } + if rec.GridW > s.fuseW+loadpoint.SitePowerEpsW { + s.t.Fatalf("tick %d: grid %.0f W over fuse %.0f W", rec.N, rec.GridW, s.fuseW) + } + if s.surplusOnly && rec.EVW > loadpoint.SitePowerEpsW { + if loadpoint.SurplusOnlyExceedsHousePV(rec.EVW, rec.LoadW, rec.PVW) { + s.t.Fatalf("tick %d: surplus-only EV %.0f W exceeds leftover PV after house %.0f W (grid=%.0f bat=%.0f)", + rec.N, rec.EVW, s.leftoverW(), rec.GridW, rec.BatW) + } + if loadpoint.BatteryDischargeFeedsEV(rec.BatW, rec.EVW, rec.LoadW, rec.PVW) { + s.t.Fatalf("tick %d: battery discharge %.0f W feeds surplus-only EV %.0f W (house residual %.0f W)", + rec.N, rec.BatW, rec.EVW, loadpoint.HouseResidualW(rec.LoadW, rec.PVW)) + } + } +} + +func (s *siteClock) requireCombo(afterTicks int) evSiteTick { + s.t.Helper() + for _, rec := range s.ticks { + if rec.N < afterTicks { + continue + } + if rec.EVW > 1000 && rec.BatW > 500 && rec.GridW > 100 { + return rec + } + } + s.t.Fatalf("no tick after %d had EV charging from leftover PV while the home battery grid-charged; ticks=%s", + afterTicks, s.dumpTicks()) + return evSiteTick{} +} + +func (s *siteClock) requireIdleEV(afterTicks int) { + s.t.Helper() + for _, rec := range s.ticks { + if rec.N < afterTicks { + continue + } + if rec.EVW > loadpoint.SitePowerEpsW { + s.t.Fatalf("tick %d: surplus-only EV imported without leftover PV: ev=%.0f grid=%.0f bat=%.0f pv=%.0f; ticks=%s", + rec.N, rec.EVW, rec.GridW, rec.BatW, rec.PVW, s.dumpTicks()) + } + } +} + +func (s *siteClock) dumpTicks() string { + b := make([]byte, 0, 256) + for _, rec := range s.ticks { + b = append(b, []byte(fmt.Sprintf("%s ev=%.0f bat=%.0f grid=%.0f surplus=%.0f\n", + rec.At.Format("15:04:05"), rec.EVW, rec.BatW, rec.GridW, rec.SurplusW))...) + } + return string(b) +} + +func injectedChargePlan(start time.Time, slotMin int, batW, evW, loadW, pvW float64) mpc.Plan { + return mpc.Plan{ + Mode: mpc.ModeArbitrage, + HorizonSlots: 1, + Actions: []mpc.Action{{ + SlotStartMs: start.UnixMilli(), + SlotLenMin: slotMin, + BatteryW: batW, + LoadpointW: evW, + GridW: loadpoint.GridW(loadW, pvW, batW, evW), + LoadW: loadW, + PVW: pvW, + }}, + } +} + +func surplusOnlyGarage() loadpoint.Config { + return loadpoint.Config{ + ID: evSiteLP, + DriverName: evSiteCharger, + MinChargeW: 1380, + MaxChargeW: 11040, + AllowedStepsW: []float64{0, 1380, 4140, 6900, 11040}, + PhaseSplitW: 3680, + SurplusOnly: true, + } +} diff --git a/go/internal/control/ev_site_test.go b/go/internal/control/ev_site_test.go new file mode 100644 index 00000000..353a0cb6 --- /dev/null +++ b/go/internal/control/ev_site_test.go @@ -0,0 +1,228 @@ +package control + +import ( + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/mpc" +) + +// Cheap-hour leftover PV while the home battery is buying from the grid. +// 6 kW leftover is above the 3Φ minimum so the surplus clamp can start. +const ( + evComboLoadW = 500 + evComboPVW = -8000 // leftover after house = 7500 W (holds 3Φ × 10 A) + evComboBatW = 10000 +) + +func evComboSlotStart() time.Time { + return time.Date(2026, 8, 18, 12, 0, 0, 0, time.UTC) +} + +func evComboSiteStart() time.Time { + return evComboSlotStart().Add(time.Second) +} + +func TestEVSiteSurplusOnlyTakesLeftoverPVWhileBatteryGridCharges(t *testing.T) { + // Injected plan: battery buys 10 kW. EV budget is 0 — the charger + // must start from the opportunistic surplus clamp, the path Easee + // sites without a vehicle SoC actually use. The old surplus reader + // treated battery charge as already-claimed PV and offered the car + // −grid+ev < 0 while Pixii imported, so the EV never moved. + start := evComboSiteStart() + site := newSiteClock(t, evSiteConfig{ + Start: start, + Plan: injectedChargePlan(evComboSlotStart(), 15, evComboBatW, 0, evComboLoadW, evComboPVW), + LP: surplusOnlyGarage(), + LoadW: evComboLoadW, + PVW: evComboPVW, + }) + site.run(12) + got := site.requireCombo(4) + if got.EVW > site.leftoverW()+loadpoint.SitePowerEpsW { + t.Errorf("EV %.0f W exceeded leftover %.0f W", got.EVW, site.leftoverW()) + } +} + +func TestEVSitePlannedSurplusEVChargesBesideBatteryGridCharge(t *testing.T) { + start := evComboSiteStart() + site := newSiteClock(t, evSiteConfig{ + Start: start, + Plan: injectedChargePlan(evComboSlotStart(), 15, evComboBatW, 4140, evComboLoadW, evComboPVW), + LP: surplusOnlyGarage(), + LoadW: evComboLoadW, + PVW: evComboPVW, + }) + site.run(12) + site.requireCombo(4) +} + +func TestEVSiteOptimizeThenDispatchChargesEVFromPVBesideBatteryImport(t *testing.T) { + slot := evComboSlotStart() + slots := []mpc.Slot{ + { + StartMs: slot.UnixMilli(), LenMin: 60, + PriceOre: 20, SpotOre: 10, LoadW: evComboLoadW, PVW: evComboPVW, Confidence: 1, + }, + { + StartMs: slot.Add(time.Hour).UnixMilli(), LenMin: 60, + PriceOre: 300, SpotOre: 240, LoadW: 2500, PVW: 0, Confidence: 1, + }, + } + params := mpc.Params{ + Mode: mpc.ModeArbitrage, + SoCLevels: 11, + CapacityWh: 20000, + SoCMin: 0.10, + SoCMax: 0.95, + InitialSoC: 0.20, + ActionLevels: 11, + MaxChargeW: 10000, + MaxDischargeW: 10000, + ChargeEfficiency: 0.95, + DischargeEfficiency: 0.95, + TerminalSoCPrice: 150, + Loadpoint: &mpc.LoadpointSpec{ + ID: evSiteLP, + CapacityWh: 40000, + Levels: 11, + InitialSoC: 0.20, + PluggedIn: true, + TargetSoC: 0.40, + TargetSlotIdx: 1, + MaxChargeW: 4140, + AllowedStepsW: []float64{0, 4140}, + ChargeEfficiency: 0.9, + SurplusOnly: true, + NoBatteryToEV: true, + }, + } + site := newSiteClock(t, evSiteConfig{ + Start: evComboSiteStart(), + OptimizeSlots: slots, + OptimizeParams: params, + LP: surplusOnlyGarage(), + LoadW: evComboLoadW, + PVW: evComboPVW, + BatMaxCharge: 10000, + }) + if site.plan().Actions[0].BatteryW < 500 { + t.Fatalf("cheap slot should charge the home battery, got %+v", site.plan().Actions[0]) + } + site.run(12) + site.requireCombo(4) +} + +func TestEVSiteIdleSurplusOnlyEVDoesNotBlockNightGridCharge(t *testing.T) { + // #953: plugged idle surplus-only car, no PV, cheap night. + start := time.Date(2026, 8, 18, 2, 0, 1, 0, time.UTC) + slot := time.Date(2026, 8, 18, 2, 0, 0, 0, time.UTC) + site := newSiteClock(t, evSiteConfig{ + Start: start, + Plan: injectedChargePlan(slot, 15, 5000, 0, 500, 0), + LP: surplusOnlyGarage(), + LoadW: 500, + PVW: 0, + }) + site.run(8) + site.requireIdleEV(2) + var charged bool + for _, rec := range site.ticks { + if rec.N >= 2 && rec.BatW > 500 && rec.GridW > 100 { + charged = true + break + } + } + if !charged { + t.Fatalf("home battery should grid-charge at night beside an idle surplus-only EV; ticks=%s", site.dumpTicks()) + } +} + +func TestEVSiteSurplusOnlyPausesWhenLeftoverCannotHold3Phase(t *testing.T) { + // 1.3 kW leftover is below the 3Φ minimum. The clamp must pause + // rather than import the gap. Battery may still buy from the grid. + start := evComboSiteStart() + const loadW, pvW = 500.0, -1800.0 // leftover 1300 W + site := newSiteClock(t, evSiteConfig{ + Start: start, + Plan: injectedChargePlan(evComboSlotStart(), 15, 5000, 11000, loadW, pvW), + LP: surplusOnlyGarage(), + LoadW: loadW, + PVW: pvW, + }) + site.run(8) + site.requireIdleEV(4) +} + +func TestEVSiteScheduledEVMayImportOnCheapNight(t *testing.T) { + start := time.Date(2026, 8, 18, 2, 0, 1, 0, time.UTC) + slot := time.Date(2026, 8, 18, 2, 0, 0, 0, time.UTC) + lp := surplusOnlyGarage() + lp.SurplusOnly = false + site := newSiteClock(t, evSiteConfig{ + Start: start, + Plan: injectedChargePlan(slot, 15, 4000, 4140, 500, 0), + LP: lp, + LoadW: 500, + PVW: 0, + }) + site.run(8) + var imported bool + for _, rec := range site.ticks { + if rec.N >= 2 && rec.EVW > 1000 && rec.GridW > 100 { + imported = true + break + } + } + if !imported { + t.Fatalf("scheduled (not surplus-only) EV should import on a cheap night; ticks=%s", site.dumpTicks()) + } +} + +func TestEVSiteSurplusOnlyDoesNotClaimPVSoakWhenTogetherTheyWouldImport(t *testing.T) { + // Battery plan 4 kW of 7.5 kW leftover — soak, not Pixii buying. + // The old live reader treated any meter import as grid-charge and + // offered the full leftover, so a 3Φ snap held while soak+EV imported. + start := evComboSiteStart() + site := newSiteClock(t, evSiteConfig{ + Start: start, + Plan: injectedChargePlan(evComboSlotStart(), 15, 4000, 0, evComboLoadW, evComboPVW), + LP: surplusOnlyGarage(), + LoadW: evComboLoadW, + PVW: evComboPVW, + }) + site.run(12) + const soakHeadroom = 3500.0 // leftover 7500 − soak 4000 + for _, rec := range site.ticks { + if rec.N < 4 { + continue + } + if rec.EVW > soakHeadroom+loadpoint.SitePowerEpsW { + t.Fatalf("tick %d: surplus-only EV %.0f W claimed PV-soak (headroom %.0f); ticks=%s", + rec.N, rec.EVW, soakHeadroom, site.dumpTicks()) + } + } +} + +func TestEVSiteBatteryDoesNotDischargeIntoSurplusOnlyEV(t *testing.T) { + start := evComboSiteStart() + site := newSiteClock(t, evSiteConfig{ + Start: start, + Plan: injectedChargePlan(evComboSlotStart(), 15, -4000, 4000, 500, 0), + LP: surplusOnlyGarage(), + LoadW: 500, + PVW: 0, + BatEnergyWh: 16000, + BatMaxCharge: 10000, + }) + site.run(8) + for _, rec := range site.ticks { + if rec.EVW > loadpoint.SitePowerEpsW && rec.BatW < -loadpoint.SitePowerEpsW { + t.Fatalf("tick %d: surplus-only EV %.0f W with battery discharge %.0f W", rec.N, rec.EVW, rec.BatW) + } + if rec.EVW > loadpoint.SitePowerEpsW { + t.Fatalf("tick %d: surplus-only EV charged without PV: %.0f W", rec.N, rec.EVW) + } + } +} diff --git a/go/internal/control/slot_directive.go b/go/internal/control/slot_directive.go new file mode 100644 index 00000000..17a80c21 --- /dev/null +++ b/go/internal/control/slot_directive.go @@ -0,0 +1,24 @@ +package control + +import ( + "github.com/srcfl/ftw/go/internal/mpc" +) + +// SlotDirectiveFromMPC is the plan→EMS bridge. main.go and the site +// clock must share it so EV energy budgets cannot drift from the +// battery slot the dispatcher executes. +func SlotDirectiveFromMPC(d mpc.SlotDirective) SlotDirective { + return SlotDirective{ + DecisionID: d.DecisionID, + SlotStart: d.SlotStart, + SlotEnd: d.SlotEnd, + BatteryEnergyWh: d.BatteryEnergyWh, + SoCTarget: d.SoCTarget, + Strategy: string(d.Strategy), + PVLimitW: d.PVLimitW, + PlannedGridW: d.GridW, + HasPlannedGridW: true, + LivePVSurplusSoCCap: d.LivePVSurplusSoCCap, + LoadpointEnergyWh: d.LoadpointEnergyWh, + } +} diff --git a/go/internal/drivers/registry.go b/go/internal/drivers/registry.go index 5e71c49c..7f0810c1 100644 --- a/go/internal/drivers/registry.go +++ b/go/internal/drivers/registry.go @@ -1,6 +1,7 @@ package drivers import ( + "bytes" "context" "crypto/rand" "encoding/hex" @@ -1686,9 +1687,18 @@ func sameDriverConfig(a, b config.Driver) bool { // Compare the free-form Config map. Previously omitted, so a changed // cloud-driver password in drivers[i].config.password was silently // ignored by the hot-reload diff — the driver kept running with the - // stale credentials. DeepEqual also treats nil and empty maps as equal. + // stale credentials. The empty-map case below handles nil versus empty. if len(a.Config) == 0 && len(b.Config) == 0 { return true } - return reflect.DeepEqual(a.Config, b.Config) + if reflect.DeepEqual(a.Config, b.Config) { + return true + } + // YAML decodes whole numbers as int; JSON decodes them as float64. + // Compare their wire values so a settings save and its file-watcher + // reload do not restart an unchanged driver twice. JSON keeps strings, + // booleans and numbers distinct, including inside nested settings. + aj, aerr := json.Marshal(a.Config) + bj, berr := json.Marshal(b.Config) + return aerr == nil && berr == nil && bytes.Equal(aj, bj) } diff --git a/go/internal/drivers/registry_config_values_test.go b/go/internal/drivers/registry_config_values_test.go new file mode 100644 index 00000000..3b9e92ec --- /dev/null +++ b/go/internal/drivers/registry_config_values_test.go @@ -0,0 +1,47 @@ +package drivers + +import ( + "encoding/json" + "testing" + + "github.com/srcfl/ftw/go/internal/config" +) + +func TestDriverConfigJSONRoundTripDoesNotRestart(t *testing.T) { + fromYAML := config.Driver{Lua: "/app/drivers/sungrow.lua", Config: map[string]any{ + "unit_id": 1, "port": 502, "timeout": 2.5, + "nested": map[string]any{"registers": []any{1, 2, 3}, "enabled": true}, + "password": "test-value", + }} + data, err := json.Marshal(fromYAML) + if err != nil { + t.Fatal(err) + } + var fromJSON config.Driver + if err := json.Unmarshal(data, &fromJSON); err != nil { + t.Fatal(err) + } + if !sameDriverConfig(fromYAML, fromJSON) || !sameDriverConfig(fromJSON, fromYAML) { + t.Fatal("unchanged values restart on API save or file reload") + } + for _, tc := range []struct { + name, key string + value any + }{ + {"changed number", "unit_id", float64(2)}, + {"number became string", "unit_id", "1"}, + {"changed secret", "password", "new-value"}, + {"nested change", "nested", map[string]any{"registers": []any{1, 2, 4}, "enabled": true}}, + } { + t.Run(tc.name, func(t *testing.T) { + var changed config.Driver + if err := json.Unmarshal(data, &changed); err != nil { + t.Fatal(err) + } + changed.Config[tc.key] = tc.value + if sameDriverConfig(fromYAML, changed) { + t.Fatal("real setting change ignored") + } + }) + } +} diff --git a/go/internal/events/bus.go b/go/internal/events/bus.go index ab845385..27072c48 100644 --- a/go/internal/events/bus.go +++ b/go/internal/events/bus.go @@ -68,6 +68,7 @@ const ( KindNotificationDispatched = "notifications.dispatched" KindUpdateAvailable = "update.available" KindUpdateInstalled = "update.installed" + KindChargingConnected = "charging.connected" KindChargingSessionComplete = "charging.session_complete" KindChargingInterrupted = "charging.interrupted" ) @@ -155,9 +156,9 @@ type UpdateInstalled struct { func (UpdateInstalled) Kind() string { return KindUpdateInstalled } -// ChargingSessionComplete is emitted by the loadpoint manager at its -// session-completion latch — the vehicle held "not requesting" past -// SessionCompletionTimeout — which already fires exactly once per plug-in. +// ChargingSessionComplete is emitted once per session when a fresh, +// matched vehicle BMS reading confirms that the active target was reached. +// A charger refusing current does not establish battery state. type ChargingSessionComplete struct { LoadpointID string KWh float64 // what the session meter delivered @@ -176,3 +177,11 @@ type ChargingInterrupted struct { } func (ChargingInterrupted) Kind() string { return KindChargingInterrupted } + +// ChargingConnected names a confirmed cable connection, not a charge start. +type ChargingConnected struct { + LoadpointID string + At time.Time +} + +func (ChargingConnected) Kind() string { return KindChargingConnected } diff --git a/go/internal/loadpoint/connection_events.go b/go/internal/loadpoint/connection_events.go new file mode 100644 index 00000000..ab519d70 --- /dev/null +++ b/go/internal/loadpoint/connection_events.go @@ -0,0 +1,29 @@ +package loadpoint + +import "time" + +type connectionEdge struct { + known bool + plugged bool + unpluggedAt time.Time +} + +// Called under the manager lock. Unknown readings never count as unplugging. +// A first reading, including after an outage or restart, only sets a baseline. +func (m *Manager) observeConnectionLocked(id, driver string, plugged bool, now time.Time) bool { + if m.connectionEdges == nil { + m.connectionEdges = make(map[string]connectionEdge) + } + edge := m.connectionEdges[id] + if !m.connectionHealth[driver] { + delete(m.connectionEdges, id) + return false + } + fire := edge.known && !edge.plugged && plugged && now.Sub(edge.unpluggedAt) >= 2*time.Second + if !plugged && (!edge.known || edge.plugged) { + edge.unpluggedAt = now + } + edge.known, edge.plugged = true, plugged + m.connectionEdges[id] = edge + return fire +} diff --git a/go/internal/loadpoint/connection_events_test.go b/go/internal/loadpoint/connection_events_test.go new file mode 100644 index 00000000..1205e4de --- /dev/null +++ b/go/internal/loadpoint/connection_events_test.go @@ -0,0 +1,101 @@ +package loadpoint + +import ( + "github.com/srcfl/ftw/go/internal/events" + "github.com/srcfl/ftw/go/internal/telemetry" + "testing" + "time" +) + +func TestConnectionEventNeedsFreshEdgeAndSurvivesReload(t *testing.T) { + m := NewManager() + cfg := []Config{{ID: "garage", DriverName: "easee"}} + m.Load(cfg) + now := time.Unix(1700000000, 0) + m.SetNowFn(func() time.Time { return now }) + b := events.NewBus() + m.SetBus(b) + count := 0 + b.Subscribe(events.KindChargingConnected, func(e events.Event) { + count++ + if e.(events.ChargingConnected).LoadpointID != "garage" { + t.Fatal(e) + } + m.State("garage") + }) + health := func(status telemetry.DriverStatus) { + b.Publish(events.HealthTick{Health: map[string]telemetry.DriverHealth{"easee": {Status: status, LastSuccess: &now}}, Now: now}) + } + tick := func(connected bool) { m.Observe("garage", connected, 0, 0, true); now = now.Add(3 * time.Second) } + // Registered does not mean a charger has supplied its first reading. + b.Publish(events.HealthTick{Health: map[string]telemetry.DriverHealth{"easee": {Status: telemetry.StatusOk}}, Now: now}) + tick(false) + health(telemetry.StatusOk) + tick(true) + tick(true) + if count != 0 { + t.Fatal("startup is not a new plug-in") + } + tick(false) + tick(true) + tick(true) + if count != 1 { + t.Fatalf("got %d, want one plug event", count) + } + m.Load(cfg) + tick(true) + if count != 1 { + t.Fatal("reload repeated the plug event") + } + health(telemetry.StatusOffline) + tick(false) + health(telemetry.StatusOk) + tick(true) + if count != 1 { + t.Fatal("recovery claimed a new plug-in") + } + tick(false) + tick(true) + if count != 2 { + t.Fatal("next real plug-in was lost") + } +} + +func TestLostSessionProofRevokesLevelAndStartWithoutAPlugEvent(t *testing.T) { + store := &sessionMemory{data: map[string]string{}} + m := sessionManager(store, "garage", "charger") + now := time.Unix(1700000000, 0) + m.SetNowFn(func() time.Time { return now }) + b := events.NewBus() + m.SetBus(b) + count := 0 + b.Subscribe(events.KindChargingConnected, func(events.Event) { count++ }) + b.Publish(events.HealthTick{Health: map[string]telemetry.DriverHealth{ + "charger": {Status: telemetry.StatusOk, LastSuccess: &now}, + }, Now: now}) + m.ObserveSession("garage", false, 0, 0, false, "easee:A", "") + now = now.Add(3 * time.Second) + m.ObserveSession("garage", true, 4300, 6000, true, "easee:A", "session-1") + m.SetCurrentSoC("garage", .84) + c := NewController(m, nil, nil, nil) + c.SetManualHold("garage", ManualHold{PowerW: 4140, Persistent: true}) + if count != 1 { + t.Fatalf("real plug-in events=%d, want 1", count) + } + // A terminal charger state withdraws its proof. The cable remains in, + // and later waiting/paused samples cannot revive the previous Start. + for i := 0; i < 3; i++ { + now = now.Add(5 * time.Second) + m.ObserveSession("garage", true, 0, 6000, false, "easee:A", "") + c.restoreManualHoldForSession("garage") + if s, _ := m.State("garage"); !s.PluggedIn || s.SoCSource != "assumed" || s.SoCRetention != "unavailable" || !s.ManualRestoreUnconfirmed { + t.Fatalf("lost proof state=%+v", s) + } + if hold, ok := c.GetManualHold("garage", now); !ok || hold.PowerW != 0 { + t.Fatalf("lost proof retained old Start: %+v %v", hold, ok) + } + if count != 1 { + t.Fatalf("lost proof invented a plug-in: events=%d", count) + } + } +} diff --git a/go/internal/loadpoint/controller.go b/go/internal/loadpoint/controller.go index f123912c..5d28fecd 100644 --- a/go/internal/loadpoint/controller.go +++ b/go/internal/loadpoint/controller.go @@ -76,13 +76,11 @@ type Controller struct { fusePhaseCapA map[string]float64 // siteSurplusForEVW returns the live PV surplus that this loadpoint - // could legally claim under surplus_only — i.e. *what's left of PV - // after house load*, regardless of what the home battery is - // currently absorbing. The arithmetic lives in main.go because - // it depends on per-site telemetry layout (pv driver, load driver, - // battery drivers, site-meter driver). Returns (_, false) when any - // of the inputs are stale; the controller then pauses rather than - // guess, which is the conservative default for "never import". + // could legally claim under surplus_only: leftover PV after house + // load, minus home-battery PV-soak. Grid-funded battery charge is + // not soak. Wired from main.go via SurplusAvailableForEVW. + // Returns (_, false) when any of the inputs are stale; the + // controller then pauses rather than guess. siteSurplusForEVW func() (float64, bool) // site is the grid-boundary fuse. Its values are passed through @@ -104,8 +102,11 @@ type Controller struct { // 5-second control loop. Missing entries (or expired holds, which // `GetManualHold` lazily evicts) fall through to the normal // compute-from-plan path. - holdMu sync.Mutex - holds map[string]ManualHold + holdMu sync.Mutex + holds map[string]ManualHold + manualRestored map[string]bool + manualBindings map[string]manualSessionBinding + manualPersistMu sync.Mutex // manualIdleSince[id] is when a loadpoint with an active manual hold // first observed the vehicle "not requesting current" this idle spell. // Once it has stayed not-requesting for SessionCompletionTimeout the @@ -408,6 +409,11 @@ type ManualHold struct { // Persisted with the hold, so a restart mid-boost keeps the // release target. ReleaseAtSoC float64 + + // StartedAt remains the first request time when the current changes. + StartedAt time.Time + // UpdatedAt identifies the latest choice, including a current change. + UpdatedAt time.Time `json:"updated_at,omitempty"` } // Directive is the loadpoint-relevant slice of mpc.SlotDirective. @@ -432,10 +438,16 @@ type Directive struct { // true. The loadpoint manager uses it to detect vehicle-side // completion via the SessionCompletionTimeout timer. type EVSample struct { - PowerW float64 - SessionWh float64 - Connected bool - RequestActive bool + // ConnectionUnknown is a socket transition without fresh physical status. + // It revokes hardware proof but must not imply a physical unplug. + ConnectionUnknown bool + ConnectionGeneration uint64 // process-local transport epoch, not durable session proof + PowerW float64 + SessionWh float64 + Connected bool + RequestActive bool + DeviceID string + SessionID string } // PlanFunc returns the current-slot directive for now, or (_, false) @@ -658,11 +670,10 @@ func (c *Controller) SetPerPhaseMeterAmps(f func() (l1, l2, l3 float64, ok bool) } // SetSiteSurplusForEV wires a per-tick "PV surplus available to the -// EV" reader for the surplus_only clamp. The function returns total -// W the EV could safely claim without forcing site import — typically -// `(-pvW - houseLoadW)` since that's PV-minus-load regardless of how -// the home battery is currently splitting it. Called once at startup -// from main.go. Pass nil to disable, in which case surplus_only is +// EV" reader for the surplus_only clamp. The function returns watts +// the EV may claim this tick: leftover after house load, minus +// PV-soak (SurplusAvailableForEVW). Called once at startup from +// main.go. Pass nil to disable, in which case surplus_only is // enforced only by the MPC plan (no live clamp). func (c *Controller) SetSiteSurplusForEV(f func() (float64, bool)) { if c == nil { @@ -825,7 +836,7 @@ func (c *Controller) applyFuseClampAndCooldown(now time.Time, lpCfg Config, want return wantW, "" } // Need to ramp down. Snap to the largest allowed step ≤ cap. - snapped := SnapChargeW(cap, lpCfg.MinChargeW, lpCfg.MaxChargeW, lpCfg.AllowedStepsW) + snapped := floorChargeW(cap, lpCfg.MinChargeW, lpCfg.MaxChargeW, lpCfg.AllowedStepsW) if snapped > 0 && snapped >= lpCfg.MinChargeW { slog.Info("loadpoint fuse-clamp: ramped down", "lp", lpCfg.ID, "want_w", wantW, "fuse_cap_w", cap, "snapped_w", snapped) @@ -1305,6 +1316,12 @@ func (c *Controller) SetManualHold(id string, h ManualHold) { if c == nil { return } + c.manualPersistMu.Lock() + defer c.manualPersistMu.Unlock() + c.markManualExplicit(id) + if h.PowerW > 0 && c.manager != nil { + c.manager.RetryCharging(id) + } c.holdMu.Lock() if c.holds == nil { c.holds = map[string]ManualHold{} @@ -1314,10 +1331,15 @@ func (c *Controller) SetManualHold(id string, h ManualHold) { delete(c.holds, id) cleared = true } else { + if h.StartedAt.IsZero() { + h.StartedAt = time.Now() + } + h.UpdatedAt = time.Now() c.holds[id] = h } saver := c.manualHoldSaver c.holdMu.Unlock() + c.resetManualIdle(id) // Persist outside the lock (saver may do disk I/O). Only persistent // operator holds survive a restart; clearing or a timed hold writes the // "cleared" sentinel so a stale persistent hold isn't resurrected. @@ -1336,6 +1358,33 @@ func (c *Controller) ClearManualHold(id string) { if c == nil { return } + c.manualPersistMu.Lock() + defer c.manualPersistMu.Unlock() + c.clearManualHoldLocked(id) +} + +// releaseManualHoldIfCurrent applies a tick's decision only to the request +// it read. A newer Pause, Start or slider change keeps its own command. +func (c *Controller) releaseManualHoldIfCurrent(id string, expected ManualHold) bool { + if c == nil { + return false + } + c.manualPersistMu.Lock() + defer c.manualPersistMu.Unlock() + c.holdMu.Lock() + current, found := c.holds[id] + c.holdMu.Unlock() + if !found || current != expected { + return false + } + c.clearManualHoldLocked(id) + return true +} + +// clearManualHoldLocked requires manualPersistMu. Keep removal and its save +// ordered with explicit commands and session restoration. +func (c *Controller) clearManualHoldLocked(id string) { + first := c.markManualExplicit(id) c.holdMu.Lock() _, existed := c.holds[id] delete(c.holds, id) @@ -1343,7 +1392,7 @@ func (c *Controller) ClearManualHold(id string) { c.holdMu.Unlock() // Only persist the clear if a hold actually existed — ClearManualHold is // called on every unplugged tick, and we must not hammer the store. - if saver != nil && existed { + if saver != nil && (existed || first) { saver(id, ManualHold{}, true) } // The auto-release idle timer is meaningless without a hold. @@ -1450,9 +1499,21 @@ func (c *Controller) TickWithDispatch(ctx context.Context, now time.Time, dispat } func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, dispatchAllowed bool) { - var sample EVSample - if c.tel != nil { - sample, _ = c.tel(lpCfg.DriverName) + if c.tel == nil { + return + } + sample, observed := c.tel(lpCfg.DriverName) + if !observed { + // No reading is not an unplug. In particular, startup must not + // clear a restored manual hold while the driver is still logging in. + // Core's driver-health owner handles autonomous recovery; without + // an EV sample this loop cannot confirm a session or send a setpoint. + return + } + c.manager.observeConnectionProof(lpCfg.ID, sample.ConnectionGeneration, sample.ConnectionUnknown) + if sample.ConnectionUnknown { + c.restoreManualHoldForSession(lpCfg.ID) + return } // Resolve the schedule once per tick — used for the bat-SoC unlock // (surplusActive / surplusAddsToPlan) and the phase decision below. @@ -1494,7 +1555,8 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, d enteringSurplusPaused, _ := c.getSurplusPause(lpCfg.ID) selfWithheld := surplusOn && enteringSurplusPaused c.manager.SetSurplusWithheld(lpCfg.ID, selfWithheld) - c.manager.Observe(lpCfg.ID, sample.Connected, sample.PowerW, sample.SessionWh, sample.RequestActive) + c.manager.ObserveSession(lpCfg.ID, sample.Connected, sample.PowerW, sample.SessionWh, sample.RequestActive, sample.DeviceID, sample.SessionID) + c.restoreManualHoldForSession(lpCfg.ID) c.evaluateBatteryBoost(lpCfg.ID, now, sample.Connected, dispatchAllowed) if !sample.Connected { c.resetSurplusSession(lpCfg.ID) @@ -1528,7 +1590,11 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, d // being handled is the meter's. // The standdown is still the box ordering zero; record it so the // interruption latch knows this stop is ours. - c.manager.SetCommanded(lpCfg.ID, 0, "site_meter_stale") + var manualUpdatedAt time.Time + if hold, held := c.GetManualHold(lpCfg.ID, now); held { + manualUpdatedAt = hold.UpdatedAt + } + c.manager.setCommandedForManual(lpCfg.ID, 0, "site_meter_stale", manualUpdatedAt) payload, err := json.Marshal(map[string]any{ "action": "ev_set_current", "power_w": 0, @@ -1562,12 +1628,11 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, d // "throttled to 0" (RequestActive); others leave it true and never // auto-release. Done before the dispatch branch below so the freed // tick falls straight through to automatic (surplus/plan) dispatch. - if _, held := c.GetManualHold(lpCfg.ID, now); held { + if hold, held := c.GetManualHold(lpCfg.ID, now); held && hold.PowerW > 0 { if !sample.RequestActive { - if c.manualHoldIdleFor(lpCfg.ID, now) >= SessionCompletionTimeout { + if c.manualHoldIdleFor(lpCfg.ID, now) >= SessionCompletionTimeout && c.releaseManualHoldIfCurrent(lpCfg.ID, hold) { slog.Info("loadpoint manual hold auto-released — vehicle stopped requesting current (full/declined)", "lp", lpCfg.ID, "idle", SessionCompletionTimeout) - c.ClearManualHold(lpCfg.ID) } } else { c.resetManualIdle(lpCfg.ID) @@ -1580,10 +1645,9 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, d // surplus/plan dispatch instead of holding the wallbox at a fixed // amperage the rest of the session. if hold, held := c.GetManualHold(lpCfg.ID, now); held && hold.ReleaseAtSoC > 0 { - if st, ok := c.manager.State(lpCfg.ID); ok && st.CurrentSoC >= hold.ReleaseAtSoC { + if st, ok := c.manager.State(lpCfg.ID); ok && st.CurrentSoC >= hold.ReleaseAtSoC && c.releaseManualHoldIfCurrent(lpCfg.ID, hold) { slog.Info("loadpoint manual hold released — charge-now target reached", "lp", lpCfg.ID, "soc", st.CurrentSoC, "release_at_soc", hold.ReleaseAtSoC) - c.ClearManualHold(lpCfg.ID) } } @@ -1592,14 +1656,19 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, d // clamp that overrides the value overrides the reason with it. Fed // to Manager.SetCommanded after the last clamp has spoken. cmdReason := "" + var manualCommandUpdatedAt time.Time if hold, ok := c.GetManualHold(lpCfg.ID, now); ok { cmdReason = "manual_hold" + manualCommandUpdatedAt = hold.UpdatedAt // Manual override active — skip MPC translation. The hold's // non-zero fields override the loadpoint config + site fuse; // zero/empty fields fall through to the normal defaults so a // minimal hold (just `power_w`) still carries the per-phase // fuse clamp inputs the driver needs to stay safe. - holdW := hold.PowerW + holdW := clampManualPower(lpCfg, hold, c.siteFuse()) + if holdW != hold.PowerW { + cmdReason = "charger_limit" + } // An explicit manual hold ("Start" / amp slider) takes priority // over surplus_only: when the operator deliberately pins a charge // rate we honour it even if that means importing from the grid. @@ -1821,13 +1890,19 @@ func (c *Controller) tickOne(ctx context.Context, now time.Time, lpCfg Config, d } } + // A manual diagnostic may lower a site limit, but never replace the + // installation's voltage, phase count or fuse with a larger offer. + c.applyInstallationLimits(cmd) c.applyPerPhaseFuseClamp(lpCfg, cmd) + if applyCurrentCeiling(cmd) { + cmdReason = "fuse_limit" + } // Tell the manager what was ordered, after every clamp has spoken. // The interruption latch reads this to keep a pause the box chose — // plan slot, Stop hold, surplus clamp — from ever reading as a // charge that failed. if w, ok := cmd["power_w"].(float64); ok { - c.manager.SetCommanded(lpCfg.ID, w, cmdReason) + c.manager.setCommandedForManual(lpCfg.ID, w, cmdReason, manualCommandUpdatedAt) } payload, err := json.Marshal(cmd) if err != nil { diff --git a/go/internal/loadpoint/controller_charge_now_release_test.go b/go/internal/loadpoint/controller_charge_now_release_test.go index 4bb36fbd..d997f94e 100644 --- a/go/internal/loadpoint/controller_charge_now_release_test.go +++ b/go/internal/loadpoint/controller_charge_now_release_test.go @@ -49,8 +49,8 @@ func TestChargeNowHoldReleasesAtTargetSoC(t *testing.T) { if _, active := c.GetManualHold(cfg.ID, base); !active { t.Fatalf("hold released below its target SoC") } - if n := len(sender.calls); n == 0 || sender.calls[n-1].power != 11040 { - t.Fatalf("below target: want the 11040 W hold dispatched, got %+v", sender.calls) + if n := len(sender.calls); n == 0 || sender.calls[n-1].power != 11000 { + t.Fatalf("below target: want the 11000 W charger limit dispatched, got %+v", sender.calls) } // Session energy grows past the target: SoC = 0.5 + 18300/60000 = diff --git a/go/internal/loadpoint/controller_commanded_reason_test.go b/go/internal/loadpoint/controller_commanded_reason_test.go index 68c13432..5306f0ea 100644 --- a/go/internal/loadpoint/controller_commanded_reason_test.go +++ b/go/internal/loadpoint/controller_commanded_reason_test.go @@ -127,7 +127,14 @@ func TestCommandedReasonSiteMeterStale(t *testing.T) { samples := map[string]EVSample{cfg.DriverName: {Connected: true, PowerW: 0, RequestActive: true}} c := newTestController(t, []Config{cfg}, nil, samples, sender) + c.SetManualHold(cfg.ID, ManualHold{PowerW: 4140, Persistent: true}) c.TickWithDispatch(context.Background(), base, false) + h, _ := c.GetManualHold(cfg.ID, base) + st, _ := c.manager.State(cfg.ID) + got := ManualStatusFrom(h, true, st, ChargerReading{Known: true, UpdatedAt: time.Now()}, time.Now()) + if got.State != ManualLimited || got.LimitReason != "site_meter_stale" { + t.Fatalf("manual standdown lost its known reason: %+v", got) + } if w, r := commandedReason(t, c, cfg.ID); w != 0 || r != "site_meter_stale" { t.Errorf("standdown: want (0, site_meter_stale), got (%.0f, %q)", w, r) @@ -144,7 +151,7 @@ func TestCommandedReasonManualHold(t *testing.T) { c.Tick(context.Background(), base) - if w, r := commandedReason(t, c, cfg.ID); w != 11040 || r != "manual_hold" { - t.Errorf("hold: want (11040, manual_hold), got (%.0f, %q)", w, r) + if w, r := commandedReason(t, c, cfg.ID); w != 11000 || r != "charger_limit" { + t.Errorf("hold: want (11000, charger_limit), got (%.0f, %q)", w, r) } } diff --git a/go/internal/loadpoint/controller_hold_restore.go b/go/internal/loadpoint/controller_hold_restore.go new file mode 100644 index 00000000..04ac994c --- /dev/null +++ b/go/internal/loadpoint/controller_hold_restore.go @@ -0,0 +1,109 @@ +package loadpoint + +type manualSessionBinding struct { + deviceID, sessionID string + generation uint64 + configuration uint64 + connectionGeneration uint64 +} + +func (m *Manager) manualSessionBinding(id string) manualSessionBinding { + m.mu.RLock() + defer m.mu.RUnlock() + if lp := m.byID[id]; lp != nil { + return manualSessionBinding{lp.sessionDeviceID, lp.sessionID, lp.sessionGeneration, lp.configGeneration, lp.connectionGeneration} + } + return manualSessionBinding{} +} + +// markManualExplicit prevents a delayed first telemetry sample from replacing +// an action the user already took. Call outside holdMu in Set/ClearManualHold. +func (c *Controller) markManualExplicit(id string) bool { + c.holdMu.Lock() + defer c.holdMu.Unlock() + if c.manualRestored == nil { + c.manualRestored = map[string]bool{} + } + first := !c.manualRestored[id] + c.manualRestored[id] = true + if c.manager != nil { + if c.manualBindings == nil { + c.manualBindings = map[string]manualSessionBinding{} + } + c.manualBindings[id] = c.manager.manualSessionBinding(id) + c.manager.SetManualRestoreUnconfirmed(id, false) + } + return first +} + +// restoreManualHoldForSession runs after ObserveSession, before dispatch. The +// disk read stays outside holdMu; recheck explicit actions before installing. +func (c *Controller) restoreManualHoldForSession(id string) { + if c.manager == nil { + return + } + c.manualPersistMu.Lock() + defer c.manualPersistMu.Unlock() + current := c.manager.manualSessionBinding(id) + c.holdMu.Lock() + if c.manualBindings == nil { + c.manualBindings = map[string]manualSessionBinding{} + } + previous, bound := c.manualBindings[id] + changed := bound && (previous.configuration != current.configuration || + (previous.connectionGeneration != 0 && current.connectionGeneration != 0 && previous.connectionGeneration != current.connectionGeneration)) + if bound && previous.deviceID != "" { + changed = changed || previous.deviceID != current.deviceID + // ObserveSession preserves the generation for a valid first proof. + // A changed generation therefore means a new connection or a counter + // reset, even when the earlier session ID was still unknown. + changed = changed || previous.generation != current.generation + } + // A Start before the first hardware reading binds here. Once bound, a + // different hardware/session/config generation must not inherit its power. + c.manualBindings[id] = current + if changed { + if hold, held := c.holds[id]; held { + if hold.PowerW > 0 { + c.holds[id] = ManualHold{Persistent: true} + c.manager.SetManualRestoreUnconfirmed(id, true) + } + c.manualRestored[id] = true + c.holdMu.Unlock() + // Keep the safe pause across another restart. The next explicit + // Start or Return to plan decides how this new session proceeds. + _ = c.manager.PersistManualHold(id, ManualHold{Persistent: true}, false) + c.resetManualIdle(id) + return + } + } + done := c.manualRestored[id] + c.holdMu.Unlock() + if done { + return + } + h, status := c.manager.RestoreManualHold(id) + if status == "pending" && !h.Persistent { + return + } + c.holdMu.Lock() + defer c.holdMu.Unlock() + if c.manualRestored[id] { + return + } + if c.manualRestored == nil { + c.manualRestored = map[string]bool{} + } + _, hadPending := c.manualRestored[id] + c.manualRestored[id] = status != "pending" + if status == "pending" || status == "restored" || status == "unconfirmed" { + if c.holds == nil { + c.holds = map[string]ManualHold{} + } + c.holds[id] = h + c.manager.SetManualRestoreUnconfirmed(id, status == "unconfirmed" || status == "pending") + } else if hadPending { + delete(c.holds, id) + c.manager.SetManualRestoreUnconfirmed(id, false) + } +} diff --git a/go/internal/loadpoint/controller_manual_hold_test.go b/go/internal/loadpoint/controller_manual_hold_test.go index 5a660fe4..c706e04f 100644 --- a/go/internal/loadpoint/controller_manual_hold_test.go +++ b/go/internal/loadpoint/controller_manual_hold_test.go @@ -63,7 +63,7 @@ func TestManualHoldOverridesPlannerBudget(t *testing.T) { } } -func TestManualHoldPropagatesSiteFuseFields(t *testing.T) { +func TestManualHoldCannotReplaceInstallationLimits(t *testing.T) { now := time.Date(2026, 4, 26, 18, 0, 0, 0, time.UTC) cfg := holdLoadpoint() cmd := runHoldTick(t, cfg, ManualHold{ @@ -74,11 +74,11 @@ func TestManualHoldPropagatesSiteFuseFields(t *testing.T) { SitePhases: 3, ExpiresAt: now.Add(60 * time.Second), }, now) - if cmd.voltage != 240 { - t.Errorf("voltage = %.0f, want 240", cmd.voltage) + if cmd.voltage != 230 { + t.Errorf("voltage = %.0f, want configured 230", cmd.voltage) } - if cmd.maxAmpsPerPhase != 20 { - t.Errorf("max_amps_per_phase = %.0f, want 20", cmd.maxAmpsPerPhase) + if cmd.maxAmpsPerPhase != 16 { + t.Errorf("max_amps_per_phase = %.0f, want configured 16", cmd.maxAmpsPerPhase) } if cmd.sitePhases != 3 { t.Errorf("site_phases = %d, want 3", cmd.sitePhases) @@ -255,7 +255,7 @@ func TestManualHoldExplicitFieldsOverrideDefaults(t *testing.T) { if cmd.phaseMode != "1p" { t.Errorf("phase_mode = %q, want \"1p\" (operator override)", cmd.phaseMode) } - if cmd.voltage != 220 { - t.Errorf("voltage = %.0f, want 220 (operator override)", cmd.voltage) + if cmd.voltage != 230 { + t.Errorf("voltage = %.0f, want configured 230", cmd.voltage) } } diff --git a/go/internal/loadpoint/controller_manual_release_race_test.go b/go/internal/loadpoint/controller_manual_release_race_test.go new file mode 100644 index 00000000..76cf41e6 --- /dev/null +++ b/go/internal/loadpoint/controller_manual_release_race_test.go @@ -0,0 +1,83 @@ +package loadpoint + +import ( + "context" + "testing" + "time" +) + +func TestStaleAutoReleaseCannotDeleteNewPauseStartOrRetry(t *testing.T) { + for _, action := range []string{"pause", "start", "same_request_retry"} { + t.Run(action, func(t *testing.T) { + m := NewManager() + m.Load([]Config{{ID: "garage", DriverName: "charger"}}) + c := NewController(m, nil, nil, nil) + c.SetManualHold("garage", ManualHold{PowerW: 4140, Persistent: true}) + old, _ := c.GetManualHold("garage", time.Now()) + next := old // The UI preserves StartedAt when editing the request. + switch action { + case "pause": + next.PowerW = 0 + case "start": + next.PowerW = 5520 + } + entered, finishSave := make(chan struct{}), make(chan struct{}) + setDone, releaseDone := make(chan struct{}), make(chan bool, 1) + clears := 0 + c.SetManualHoldSaver(func(_ string, _ ManualHold, cleared bool) { + if cleared { + clears++ + return + } + close(entered) + <-finishSave + }) + go func() { c.SetManualHold("garage", next); close(setDone) }() + <-entered + // A tick decided to release the old request just before this + // operator change. It waits behind the new request's disk write. + go func() { releaseDone <- c.releaseManualHoldIfCurrent("garage", old) }() + select { + case <-releaseDone: + t.Fatal("auto-release overtook the explicit save") + case <-time.After(20 * time.Millisecond): + } + close(finishSave) + <-setDone + if released := <-releaseDone; released { + t.Fatal("stale tick removed the newer request") + } + current, ok := c.GetManualHold("garage", time.Now()) + if !ok || current.PowerW != next.PowerW || current.UpdatedAt == old.UpdatedAt || clears != 0 { + t.Fatalf("new choice lost: current=%+v active=%v clears=%d", current, ok, clears) + } + if !c.releaseManualHoldIfCurrent("garage", current) || clears != 1 { + t.Fatal("a current release decision must still clear and save") + } + }) + } +} + +func TestExplicitRetryGetsAFullNewIdleTimeout(t *testing.T) { + now := time.Now() + cfg := chargeNowLoadpoint() + sender := &fakeSender{} + samples := map[string]EVSample{cfg.DriverName: {Connected: true, RequestActive: false}} + c := newTestController(t, []Config{cfg}, nil, samples, sender) + c.SetManualHold(cfg.ID, ManualHold{PowerW: 4140, Persistent: true}) + c.Tick(context.Background(), now) + c.Tick(context.Background(), now.Add(SessionCompletionTimeout-time.Second)) + old, ok := c.GetManualHold(cfg.ID, now) + if !ok { + t.Fatal("old request released before its timeout") + } + c.SetManualHold(cfg.ID, old) + c.Tick(context.Background(), now.Add(SessionCompletionTimeout+time.Second)) + if _, ok := c.GetManualHold(cfg.ID, now); !ok { + t.Fatal("retry inherited the old request's nearly expired idle timer") + } + c.Tick(context.Background(), now.Add(2*SessionCompletionTimeout+2*time.Second)) + if _, ok := c.GetManualHold(cfg.ID, now); ok { + t.Fatal("retry never released after its own full idle timeout") + } +} diff --git a/go/internal/loadpoint/controller_missing_reading_test.go b/go/internal/loadpoint/controller_missing_reading_test.go new file mode 100644 index 00000000..f65aa3ac --- /dev/null +++ b/go/internal/loadpoint/controller_missing_reading_test.go @@ -0,0 +1,43 @@ +package loadpoint + +import ( + "context" + "testing" + "time" +) + +func TestMissingReadingDoesNotEndManualSession(t *testing.T) { + now := time.Now() + cfg := holdLoadpoint() + samples := map[string]EVSample{} + sender := &fakeSender{} + c := newTestController(t, []Config{cfg}, &Directive{SlotStart: now, SlotEnd: now.Add(time.Hour)}, samples, sender) + c.SetManualHold(cfg.ID, ManualHold{PowerW: 4140, PhaseMode: "3p", Persistent: true}) + c.Tick(context.Background(), now) + if _, ok := c.GetManualHold(cfg.ID, now); !ok { + t.Fatal("startup without a reading cleared the hold") + } + if len(sender.calls) != 0 { + t.Fatal("sent a command without charger data") + } + samples[cfg.DriverName] = EVSample{Connected: true, RequestActive: true, PowerW: 4140, SessionWh: 6000} + c.Tick(context.Background(), now.Add(time.Second)) + if len(sender.calls) != 1 || sender.calls[0].power != 4140 { + t.Fatalf("confirmed connection did not restore manual charging: %+v", sender.calls) + } + c.manager.SetCurrentSoC(cfg.ID, .84) + delete(samples, cfg.DriverName) + c.TickWithDispatch(context.Background(), now.Add(2*time.Second), false) + if _, ok := c.GetManualHold(cfg.ID, now); !ok { + t.Fatal("missing reading during outage cleared the hold") + } + state, _ := c.manager.State(cfg.ID) + if !state.PluggedIn || state.CurrentSoC < .83 { + t.Fatalf("missing reading replaced the confirmed session: %+v", state) + } + samples[cfg.DriverName] = EVSample{Connected: false} + c.Tick(context.Background(), now.Add(3*time.Second)) + if _, ok := c.GetManualHold(cfg.ID, now); ok { + t.Fatal("a confirmed unplug must clear the hold") + } +} diff --git a/go/internal/loadpoint/loadpoint.go b/go/internal/loadpoint/loadpoint.go index c89212df..cebb867c 100644 --- a/go/internal/loadpoint/loadpoint.go +++ b/go/internal/loadpoint/loadpoint.go @@ -121,6 +121,14 @@ func (f SiteFuse) Phases() int { // Read-only for consumers — only the Manager or dispatch paths mutate // it under lock. type State struct { + ManualRestoreUnconfirmed bool `json:"manual_restore_unconfirmed"` + ManualSaveError bool `json:"manual_save_error"` + VehicleCapacityWh float64 `json:"vehicle_capacity_wh"` + CapacitySource string `json:"capacity_source"` + // ChargingDeclined is a sustained vehicle-side refusal, not a battery level. + ChargingDeclined bool `json:"charging_declined"` + // SoCRetention reports whether the confirmed estimate can survive restart. + SoCRetention string `json:"soc_retention,omitempty"` ID string `json:"id"` DriverName string `json:"driver_name"` PluggedIn bool `json:"plugged_in"` @@ -136,7 +144,8 @@ type State struct { // physical connection. Zero values when no online vehicle driver is // reporting. SoCSource is "vehicle" when CurrentSoC was overridden // from the car's BMS, "inferred" when it's the loadpoint manager's - // pluginSoC + deliveredWh estimate, "" when not plugged in. + // confirmed anchor + deliveredWh estimate, "assumed" before the user + // or a matched vehicle confirms a level, "" when not plugged in. VehicleSoC float64 `json:"vehicle_soc,omitempty"` VehicleChargeLimit float64 `json:"vehicle_charge_limit,omitempty"` VehicleChargingState string `json:"vehicle_charging_state,omitempty"` @@ -168,6 +177,12 @@ type State struct { ManualActive bool `json:"manual_active"` ManualChargeW float64 `json:"manual_charge_w,omitempty"` ManualReleaseSoC float64 `json:"manual_release_soc,omitempty"` + // Manual is the live account of the hold: what was ordered, since when, + // and what the charger did with it. Populated by the API layer from the + // controller and the charger's reading; see ManualStatusFrom. + Manual ManualStatus `json:"manual"` + // Charger is the driver reading used for feedback in every charging mode. + Charger *ChargerStatus `json:"charger,omitempty"` // BatteryBoost is the explicit, bounded home-battery-to-EV permission // for this loadpoint. Populated by the API layer from Controller state. @@ -192,6 +207,11 @@ type State struct { // "the box is pausing on purpose". CommandedW float64 `json:"commanded_w"` CommandedKnown bool `json:"commanded_known"` + // CommandedSinceMs is when the current order was first given; it moves + // when CommandedW or CommandedReason changes. Zero until the first tick. + CommandedSinceMs int64 `json:"commanded_since_ms,omitempty"` + // Internal identity of the manual choice used to compute the order. + ManualCommandUpdatedAt time.Time `json:"-"` // CommandedReason names the dispatch branch that decided CommandedW: // "plan", "no_plan_budget", "pv_surplus", "pv_surplus_pause", @@ -213,6 +233,8 @@ type State struct { // loadpoint; PlanTotalWh is everything the plan still intends to // deliver over the horizon. All zero when the planner has no // allocation. Populated by the API layer from the MPC plan. + PlanPending bool `json:"plan_pending"` + PlanOutdated bool `json:"plan_outdated"` PlanNextStartMs int64 `json:"plan_next_start_ms,omitempty"` PlanNextEndMs int64 `json:"plan_next_end_ms,omitempty"` PlanNextWh float64 `json:"plan_next_wh,omitempty"` @@ -235,14 +257,22 @@ type PlanWindow struct { // Manager holds the running set of loadpoints. Thread-safe. type Manager struct { - mu sync.RWMutex - byID map[string]*loadpointRuntime - order []string // insertion-preserving id list for deterministic listing - + nextSessionGeneration uint64 + sessionMu sync.Mutex + sessionStore SessionStore + pendingManual map[string]pendingManualHold + connectionHealth map[string]bool + connectionEdges map[string]connectionEdge + mu sync.RWMutex + byID map[string]*loadpointRuntime + order []string // insertion-preserving id list for deterministic listing + + // intentMu serializes durable goal and solar edits with config reloads. + intentMu sync.Mutex // scheduleSaver, if non-nil, is invoked synchronously whenever a // schedule is set or cleared. Wired by main.go to persist via // state.SaveConfig. Left nil in tests / sites without storage. - scheduleSaver func(id string, s Schedule) + scheduleSaver func(id string, s Schedule) error // surplusOnlySaver, if non-nil, persists the runtime surplus_only // flag whenever an operator toggles it. Without this the flag @@ -250,7 +280,7 @@ type Manager struct { // finding that frustrating since the toggle lives in the dashboard // EV modal, not the YAML they'd think to edit. Same pattern as // scheduleSaver. - surplusOnlySaver func(id string, v bool) + surplusOnlySaver func(id string, v bool) error // nowFn is the clock the manager uses for time-sensitive logic // (session-completion timer in particular). Defaults to time.Now; @@ -271,13 +301,8 @@ type Manager struct { bus *events.Bus } -// SessionCompletionTimeout is how long a vehicle must stay connected -// but explicitly not requesting current before the loadpoint treats -// the session as vehicle-side-complete. Tuned to swallow short bursts -// of retry-flap that some EVSEs emit while the vehicle holds steady -// at refusing (observed cycles in the ~10 s–90 s range) without -// snapping on a transient hiccup. Once tripped, the snap persists -// until the cable is unplugged. +// SessionCompletionTimeout debounces a sustained vehicle-side refusal. +// A refusal is not evidence that the battery reached its target. const SessionCompletionTimeout = 90 * time.Second // The interruption hysteresis. A charge that had run steadily for at least @@ -305,6 +330,15 @@ const ( // union of configured parameters and observed state. Lives behind // Manager so consumers access it via the public State snapshot. type loadpointRuntime struct { + configGeneration uint64 + sessionGeneration uint64 + connectionGeneration uint64 + manualRestoreUnconfirmed bool + manualSaveError bool + sessionDeviceID string + sessionID string + socRetention string + completionNotified bool Config pluggedIn bool @@ -356,18 +390,15 @@ type loadpointRuntime struct { // Drives session-completion (see Observe). notRequestingSince time.Time - // sessionComplete latches once the vehicle has held "not + // chargingDeclined latches once the vehicle has held "not // requesting" past SessionCompletionTimeout for this session. - // While set, the inferred SoC is pinned to targetSoC so the - // MPC stops allocating PV surplus to a sink the vehicle has - // already declined. Cleared on plug-out. - sessionComplete bool + // It suspends planning while the vehicle refuses energy, without + // changing the estimated battery level. Cleared on plug-out. + chargingDeclined bool - // socSource, when non-empty, overrides the API layer's - // vehicle-driver attribution in the State snapshot. Set to - // "completed" when sessionComplete is latched so operators can - // see why the inferred SoC pinned at target. - socSource string + // socConfirmed is true only after a level from the user or a matched car. + // A configured/default plug-in level remains a planning assumption. + socConfirmed bool // surplusWithheld is set by the controller each tick: true when WE // are intentionally withholding power from this loadpoint (a @@ -389,6 +420,10 @@ type loadpointRuntime struct { commandedW float64 commandedKnown bool commandedReason string + // commandedSince is when the current (commandedW, commandedReason) pair + // was first ordered. The manual status counts elapsed time from it. + commandedSince time.Time + manualCommandUpdatedAt time.Time // The interruption hysteresis state. chargingSteadySince anchors the // current continuous above-floor run; steadyRunArmed latches once that @@ -405,13 +440,26 @@ func NewManager() *Manager { return &Manager{byID: map[string]*loadpointRuntime{}} } -// SetBus wires the shared event bus. The manager publishes exactly two -// things on it: the session-completion latch tripping, and the interruption -// hysteresis firing. Nil stays a no-op. +// SetBus wires charging events and the freshness used to qualify cable edges. func (m *Manager) SetBus(bus *events.Bus) { m.mu.Lock() defer m.mu.Unlock() m.bus = bus + if bus == nil { + return + } + bus.Subscribe(events.KindHealthTick, func(e events.Event) { + tick, ok := e.(events.HealthTick) + if !ok { + return + } + m.mu.Lock() + defer m.mu.Unlock() + m.connectionHealth = make(map[string]bool, len(tick.Health)) + for name, h := range tick.Health { + m.connectionHealth[name] = h.TelemetryLive() && h.LastSuccess != nil && !h.LastSuccess.IsZero() + } + }) } // SetCommandedW records what the controller last ordered this loadpoint to @@ -430,22 +478,42 @@ func (m *Manager) SetCommandedW(id string, w float64) { // "wake_kick". Empty keeps whatever was recorded before (used by the // legacy SetCommandedW wrapper). No-op for an unknown id. func (m *Manager) SetCommanded(id string, w float64, reason string) { + m.setCommandedForManual(id, w, reason, time.Time{}) +} + +func (m *Manager) setCommandedForManual(id string, w float64, reason string, updatedAt time.Time) { m.mu.Lock() defer m.mu.Unlock() if lp, ok := m.byID[id]; ok { + changed := !lp.commandedKnown || lp.commandedW != w || + (reason != "" && reason != lp.commandedReason) lp.commandedW = w + lp.manualCommandUpdatedAt = updatedAt lp.commandedKnown = true if reason != "" { lp.commandedReason = reason } + if changed { + lp.commandedSince = time.Now() + } } } // Load replaces the configured set. Idempotent: existing state is // carried across when the ID is kept; removed IDs are dropped. func (m *Manager) Load(cfgs []Config) { + m.intentMu.Lock() + defer m.intentMu.Unlock() + m.sessionMu.Lock() + defer m.sessionMu.Unlock() + var changedCapacity []string m.mu.Lock() - defer m.mu.Unlock() + defer func() { + m.mu.Unlock() + for _, id := range changedCapacity { + m.persistSession(id) + } + }() newByID := make(map[string]*loadpointRuntime, len(cfgs)) newOrder := make([]string, 0, len(cfgs)) @@ -454,6 +522,15 @@ func (m *Manager) Load(cfgs []Config) { continue } lp := &loadpointRuntime{Config: c} + if existing := m.byID[c.ID]; existing != nil && existing.DriverName == c.DriverName { + lp.sessionGeneration = existing.sessionGeneration + lp.connectionGeneration = existing.connectionGeneration + lp.configGeneration = existing.configGeneration + } else { + m.nextSessionGeneration++ + lp.sessionGeneration = m.nextSessionGeneration + lp.configGeneration = m.nextSessionGeneration + } if existing, ok := m.byID[c.ID]; ok { // Preserve observed state across reload. The session // plug-in anchor is carried too — otherwise a config @@ -471,11 +548,25 @@ func (m *Manager) Load(cfgs []Config) { lp.schedule = existing.schedule lp.lastRolledFor = existing.lastRolledFor lp.notRequestingSince = existing.notRequestingSince - lp.sessionComplete = existing.sessionComplete - lp.socSource = existing.socSource + lp.chargingDeclined = existing.chargingDeclined + lp.socConfirmed = existing.socConfirmed && existing.DriverName == c.DriverName + if existing.DriverName == c.DriverName { + lp.sessionDeviceID = existing.sessionDeviceID + lp.sessionID = existing.sessionID + lp.socRetention = existing.socRetention + lp.completionNotified = existing.completionNotified + lp.manualRestoreUnconfirmed = existing.manualRestoreUnconfirmed + lp.manualSaveError = existing.manualSaveError + } else { + lp.pluggedIn = false + lp.currentSoC = 0 + lp.chargingDeclined = false + } lp.commandedW = existing.commandedW lp.commandedReason = existing.commandedReason lp.commandedKnown = existing.commandedKnown + lp.commandedSince = existing.commandedSince + lp.manualCommandUpdatedAt = existing.manualCommandUpdatedAt lp.chargingSteadySince = existing.chargingSteadySince lp.stoppedSince = existing.stoppedSince lp.steadyRunArmed = existing.steadyRunArmed @@ -490,10 +581,27 @@ func (m *Manager) Load(cfgs []Config) { lp.Config.VehicleCapacityWh = existing.VehicleCapacityWh } } + if existing.DriverName == c.DriverName && lp.pluggedIn && existing.VehicleCapacityWh != lp.VehicleCapacityWh { + // A capacity correction changes future Wh-to-SoC conversion, + // not the battery level the user just saw or its confidence. + delivered := 0.0 + if lp.VehicleCapacityWh > 0 { + delivered = lp.deliveredWhSession / lp.VehicleCapacityWh + } + lp.sessionPluginSoC = existing.currentSoC - delivered + changedCapacity = append(changedCapacity, c.ID) + } } newByID[c.ID] = lp newOrder = append(newOrder, c.ID) } + for id := range m.connectionEdges { + next, present := newByID[id] + previous := m.byID[id] + if !present || previous == nil || previous.DriverName != next.DriverName { + delete(m.connectionEdges, id) + } + } m.byID = newByID m.order = newOrder } @@ -565,8 +673,8 @@ func (m *Manager) Configs() []Config { // false on the latter; drivers without that distinction always pass // true and pre-existing behaviour is preserved. After // SessionCompletionTimeout of sustained !requestActive on a connected -// session, the inferred SoC is pinned to targetSoC so the MPC stops -// allocating PV surplus to a sink the vehicle has already declined. +// session, charging_declined tells the planner to stop allocating energy. +// This never changes the battery level or claims that its target was reached. // // No-op for unknown IDs — a misconfigured driver shouldn't crash the // manager. @@ -584,11 +692,15 @@ func (m *Manager) SetSurplusWithheld(id string, withheld bool) { } func (m *Manager) Observe(id string, pluggedIn bool, powerW, deliveredWh float64, requestActive bool) { + m.ObserveSession(id, pluggedIn, powerW, deliveredWh, requestActive, "", "") +} + +func (m *Manager) observe(id string, pluggedIn bool, powerW, deliveredWh float64, requestActive bool) ([]events.Event, *events.Bus) { m.mu.Lock() lp, ok := m.byID[id] if !ok { m.mu.Unlock() - return + return nil, nil } // Events decided under the lock, published after it: the bus runs // handlers inline on the publisher, and a handler that looked back at @@ -596,6 +708,9 @@ func (m *Manager) Observe(id string, pluggedIn bool, powerW, deliveredWh float64 var fired []events.Event bus := m.bus now := m.now() + if m.observeConnectionLocked(id, lp.DriverName, pluggedIn, now) { + fired = append(fired, events.ChargingConnected{LoadpointID: id, At: now}) + } if pluggedIn && !lp.pluggedIn { // Plug-in transition: seed the session anchor and clear any // session-completion latched from a prior session. @@ -604,15 +719,15 @@ func (m *Manager) Observe(id string, pluggedIn bool, powerW, deliveredWh float64 anchor = units.DefaultPluginSoC } lp.sessionPluginSoC = anchor + lp.socConfirmed = false + lp.completionNotified = false lp.notRequestingSince = time.Time{} - lp.sessionComplete = false - lp.socSource = "" + lp.chargingDeclined = false } if !pluggedIn { // Plug-out: drop any pending completion timer / latch. lp.notRequestingSince = time.Time{} - lp.sessionComplete = false - lp.socSource = "" + lp.chargingDeclined = false if lp.vehicleName != "" || lp.capacityFromCar { // The identified car left with its session — the next one may // be different, so restore the loadpoint's own capacity. @@ -626,7 +741,12 @@ func (m *Manager) Observe(id string, pluggedIn bool, powerW, deliveredWh float64 lp.currentPowerW = powerW lp.deliveredWhSession = deliveredWh - if pluggedIn && !requestActive && lp.surplusWithheld { + if pluggedIn && powerW >= DeliveringW { + // Measured energy delivery is stronger evidence than a delayed + // request_active flag. Let planning follow the car immediately. + lp.chargingDeclined = false + lp.notRequestingSince = time.Time{} + } else if pluggedIn && !requestActive && (lp.surplusWithheld || (lp.commandedKnown && lp.commandedW < DeliveringW)) { // Self-induced "not requesting": we paused this surplus_only // loadpoint below its floor, so the vehicle dropping current is // our doing, not a vehicle-side decline. Do not start/advance the @@ -635,29 +755,21 @@ func (m *Manager) Observe(id string, pluggedIn bool, powerW, deliveredWh float64 // day. Reset the clock so a genuine refusal (once we resume // offering power) is timed from a clean start. lp.notRequestingSince = time.Time{} - } else if pluggedIn && !requestActive { + } else if pluggedIn && !requestActive && (!lp.commandedKnown || lp.commandedW >= DeliveringW) { // Vehicle has explicitly stopped requesting current while we ARE // offering power. Start (or continue) the completion timer; latch // once it elapses. if lp.notRequestingSince.IsZero() { lp.notRequestingSince = now } - if !lp.sessionComplete && lp.targetSoC > 0 && + if !lp.chargingDeclined && lp.targetSoC > 0 && !lp.notRequestingSince.IsZero() && now.Sub(lp.notRequestingSince) >= SessionCompletionTimeout { - lp.sessionComplete = true - lp.socSource = "completed" - // The latch is the once-per-session moment, so it is the - // publish point: nothing downstream needs its own dedupe. - fired = append(fired, events.ChargingSessionComplete{ - LoadpointID: id, - KWh: deliveredWh / 1000, - At: now, - }) + lp.chargingDeclined = true } } else if pluggedIn && requestActive { // Vehicle is back to requesting. Reset the timer, but keep - // sessionComplete latched — once a vehicle has declined this + // chargingDeclined latched — once a vehicle has declined this // session, treating it as "still hungry" the moment an EVSE // retry briefly succeeds would reopen the export hole the // completion latch exists to close. Plug-cycle to reset. @@ -692,7 +804,7 @@ func (m *Manager) Observe(id string, pluggedIn bool, powerW, deliveredWh float64 // that stopped requesting chose to stop. Neither is a failure. selfInflicted := lp.surplusWithheld || (lp.commandedKnown && lp.commandedW < steadyChargeFloorW) - if lp.steadyRunArmed && !lp.sessionComplete && requestActive && + if lp.steadyRunArmed && !lp.chargingDeclined && requestActive && !selfInflicted && !lp.stoppedSince.IsZero() && now.Sub(lp.stoppedSince) >= interruptConfirm { lp.steadyRunArmed = false @@ -707,37 +819,16 @@ func (m *Manager) Observe(id string, pluggedIn bool, powerW, deliveredWh float64 lp.steadyRunArmed = false } - // InterruptSteadyRun of real current after the completion latch is - // not an EVSE retry blip: the car is taking energy again (operator - // Start, a raised charge limit in the car). Release the latch so the - // estimate follows delivered Wh instead of sitting at targetSoC while - // kilowatt-hours go in; if the car declines once more, the timer - // re-arms it. A 900 W renegotiation burst stays below the steady - // floor and never gets here. - if pluggedIn && lp.sessionComplete && lp.steadyRunArmed && requestActive && powerW >= steadyChargeFloorW { - lp.sessionComplete = false - lp.socSource = "" - } - if pluggedIn { - if lp.sessionComplete && lp.targetSoC > 0 { - // Snap the inferred SoC to target; the planner reads - // currentSoC as the MPC LoadpointSpec.InitialSoC, - // so InitialSoC == TargetSoC → DP allocates 0 W. - lp.currentSoC = lp.targetSoC - } else { - lp.currentSoC = estimateSoC(lp.sessionPluginSoC, - deliveredWh, lp.VehicleCapacityWh) - } + lp.currentSoC = estimateSoC(lp.sessionPluginSoC, + deliveredWh, lp.VehicleCapacityWh) } else { lp.currentSoC = 0 } lp.updatedAtMs = now.UnixMilli() m.mu.Unlock() - for _, e := range fired { - bus.Publish(e) - } + return fired, bus } // now returns the manager's clock, defaulting to time.Now when nowFn @@ -789,40 +880,53 @@ func (m *Manager) SetTarget(id string, soc float64, targetTime time.Time) bool { if !ok { return false } + if units.ClampFraction(soc) > lp.targetSoC { + lp.chargingDeclined = false + lp.notRequestingSince = time.Time{} + } lp.targetSoC = units.ClampFraction(soc) lp.targetTime = targetTime return true } -// SetSurplusOnly toggles the runtime surplus_only flag for a loadpoint. -// Mutates Config.SurplusOnly so subsequent Configs() calls reflect the -// new value (both the MPC LoadpointSpec builder in main.go and the -// dispatch controller read from there). Returns (previous, ok) so a -// caller can detect the transition direction — disabling surplus_only -// is a regime change for the planner (the EV may now import from the -// grid) and the API handler forces a tagged replan in that case. +// SetSurplusOnly changes solar-only charging and returns the previous choice. +// A missing loadpoint or failed save returns ok=false. Consumers use the +// transition to replan before charging can draw from the grid. func (m *Manager) SetSurplusOnly(id string, v bool) (prev bool, ok bool) { - m.mu.Lock() + prev, ok, err := m.SetSurplusOnlyChecked(id, v) + return prev, ok && err == nil +} + +// SetSurplusOnlyChecked keeps the previous solar preference until storage +// accepts the change. Readers and charging continue with the current choice. +func (m *Manager) SetSurplusOnlyChecked(id string, v bool) (prev bool, ok bool, err error) { + m.intentMu.Lock() + defer m.intentMu.Unlock() + m.mu.RLock() lp, ok := m.byID[id] if !ok { - m.mu.Unlock() - return false, false + m.mu.RUnlock() + return false, false, nil } prev = lp.Config.SurplusOnly - lp.Config.SurplusOnly = v saver := m.surplusOnlySaver - m.mu.Unlock() + m.mu.RUnlock() if saver != nil && prev != v { - saver(id, v) + if err := saver(id, v); err != nil { + return prev, true, err + } } - return prev, true + m.mu.Lock() + lp.Config.SurplusOnly = v + m.mu.Unlock() + return prev, true, nil } // SetSurplusOnlySaver wires the persistence callback. Pass nil to -// disable. Mirrors SetScheduleSaver — the saver runs on every change -// (after the mutex is released, so the storage I/O isn't on the hot -// path). -func (m *Manager) SetSurplusOnlySaver(saver func(id string, v bool)) { +// disable. The saver runs before each change without blocking state reads. +func (m *Manager) SetSurplusOnlySaver(saver func(id string, v bool) error) { + m.intentMu.Lock() + defer m.intentMu.Unlock() m.mu.Lock() defer m.mu.Unlock() m.surplusOnlySaver = saver @@ -834,6 +938,8 @@ func (m *Manager) SetSurplusOnlySaver(saver func(id string, v bool)) { // the YAML default, (zero, false) otherwise. Matches the pattern used // by HydrateSchedules. func (m *Manager) HydrateSurplusOnly(load func(id string) (bool, bool)) { + m.intentMu.Lock() + defer m.intentMu.Unlock() m.mu.Lock() defer m.mu.Unlock() for id, lp := range m.byID { @@ -909,22 +1015,20 @@ func (m *Manager) SetSessionCapacityWh(id string, capacityWh float64) bool { // // Returns false for unknown IDs or when the loadpoint is unplugged. func (m *Manager) SetCurrentSoC(id string, socPct float64) bool { + m.sessionMu.Lock() + defer m.sessionMu.Unlock() m.mu.Lock() - defer m.mu.Unlock() + defer func() { m.mu.Unlock(); m.persistSession(id) }() lp, ok := m.byID[id] if !ok { return false } - if !lp.pluggedIn { + if !lp.pluggedIn || !finite(socPct) { return false } - // An operator who sets the level is telling us the latch's guess - // ("declined, so it must be at target") was wrong. Drop it, or the - // next Observe pins the estimate straight back to targetSoC and the - // slider looks broken. If the car really does decline, the latch - // re-arms after SessionCompletionTimeout as usual. - lp.sessionComplete = false - lp.socSource = "" + // A correction gives the planner another chance to offer energy. + // Sustained refusal can re-arm after SessionCompletionTimeout. + lp.chargingDeclined = false lp.notRequestingSince = time.Time{} reanchorSoCLocked(lp, socPct) return true @@ -950,15 +1054,29 @@ func (m *Manager) SetCurrentSoC(id string, socPct float64) bool { // // Returns false for unknown IDs or when the loadpoint is unplugged. func (m *Manager) AnchorVehicleSoC(id string, socPct float64) bool { + m.sessionMu.Lock() m.mu.Lock() - defer m.mu.Unlock() + var completion *events.ChargingSessionComplete + bus := m.bus + defer func() { + m.mu.Unlock() + m.persistSession(id) + m.sessionMu.Unlock() + if completion != nil { + bus.Publish(*completion) + } + }() lp, ok := m.byID[id] if !ok { return false } - if !lp.pluggedIn { + if !lp.pluggedIn || !finite(socPct) || socPct < 0 || socPct > 1 { return false } + if lp.targetSoC > 0 && socPct >= lp.targetSoC && !lp.completionNotified { + lp.completionNotified = true + completion = &events.ChargingSessionComplete{LoadpointID: id, KWh: lp.deliveredWhSession / 1000, At: m.now()} + } reanchorSoCLocked(lp, socPct) return true } @@ -970,12 +1088,15 @@ func (m *Manager) AnchorVehicleSoC(id string, socPct float64) bool { // paths so they stay arithmetically identical. func reanchorSoCLocked(lp *loadpointRuntime, soc float64) { soc = units.ClampFraction(soc) + lp.socConfirmed = true // Re-anchor: new_anchor + delivered/capacity == soc. delivered := 0.0 if lp.VehicleCapacityWh > 0 { delivered = lp.deliveredWhSession / lp.VehicleCapacityWh } - lp.sessionPluginSoC = units.ClampFraction(soc - delivered) + // The offset may be negative when the corrected level is below the + // energy already delivered. Clamp the resulting level, not the offset. + lp.sessionPluginSoC = soc - delivered lp.currentSoC = estimateSoC(lp.sessionPluginSoC, lp.deliveredWhSession, lp.VehicleCapacityWh) lp.updatedAtMs = time.Now().UnixMilli() } @@ -984,52 +1105,93 @@ func (lp *loadpointRuntime) snapshot() State { steps := make([]float64, len(lp.AllowedStepsW)) copy(steps, lp.AllowedStepsW) sort.Float64s(steps) - return State{ - ID: lp.ID, - DriverName: lp.DriverName, - PluggedIn: lp.pluggedIn, - CurrentSoC: lp.currentSoC, - CurrentPowerW: lp.currentPowerW, - DeliveredWhSession: lp.deliveredWhSession, - TargetSoC: lp.targetSoC, - TargetTime: lp.targetTime, - UpdatedAtMs: lp.updatedAtMs, - MinChargeW: lp.MinChargeW, - MaxChargeW: lp.MaxChargeW, - AllowedStepsW: steps, - SurplusOnly: lp.Config.SurplusOnly, - Schedule: lp.schedule, - SoCSource: lp.socSource, - VehicleName: lp.vehicleName, - CommandedW: lp.commandedW, - CommandedReason: lp.commandedReason, - CommandedKnown: lp.commandedKnown, + st := State{ + ManualRestoreUnconfirmed: lp.manualRestoreUnconfirmed, + ManualSaveError: lp.manualSaveError, + VehicleCapacityWh: lp.VehicleCapacityWh, + CapacitySource: "configured", + ID: lp.ID, + DriverName: lp.DriverName, + PluggedIn: lp.pluggedIn, + CurrentSoC: lp.currentSoC, + CurrentPowerW: lp.currentPowerW, + DeliveredWhSession: lp.deliveredWhSession, + TargetSoC: lp.targetSoC, + TargetTime: lp.targetTime, + UpdatedAtMs: lp.updatedAtMs, + MinChargeW: lp.MinChargeW, + MaxChargeW: lp.MaxChargeW, + AllowedStepsW: steps, + SurplusOnly: lp.Config.SurplusOnly, + Schedule: lp.schedule, + ChargingDeclined: lp.chargingDeclined, + SoCRetention: lp.socRetention, + VehicleName: lp.vehicleName, + CommandedW: lp.commandedW, + CommandedReason: lp.commandedReason, + CommandedKnown: lp.commandedKnown, + } + if lp.VehicleCapacityWh <= 0 { + st.VehicleCapacityWh = 60000 + st.CapacitySource = "default" + } + if st.PluggedIn && st.SoCSource == "" && !lp.socConfirmed { + st.SoCSource = "assumed" } + st.ManualCommandUpdatedAt = lp.manualCommandUpdatedAt + if !lp.commandedSince.IsZero() { + st.CommandedSinceMs = lp.commandedSince.UnixMilli() + } + return st } // SetScheduleSaver wires the persistence callback. Pass nil to disable. // Safe to call before or after Load(). -func (m *Manager) SetScheduleSaver(saver func(id string, s Schedule)) { +func (m *Manager) SetScheduleSaver(saver func(id string, s Schedule) error) { + m.intentMu.Lock() + defer m.intentMu.Unlock() m.mu.Lock() defer m.mu.Unlock() m.scheduleSaver = saver } -// SetSchedule stores the operator's intent for a loadpoint. Empty -// schedules clear (equivalent to ClearSchedule). Returns false for -// unknown IDs. The persistence callback (if wired) is invoked outside -// the lock so a slow disk doesn't block other readers. +// SetSchedule stores the operator's intent. It returns false for an unknown +// loadpoint or a failed save. Use SetScheduleChecked to distinguish them. func (m *Manager) SetSchedule(id string, s Schedule) bool { - m.mu.Lock() + ok, err := m.SetScheduleChecked(id, s) + return ok && err == nil +} + +// SetScheduleChecked saves before changing the active goal. On storage +// failure the previous schedule and derived target remain in effect. +// The callback runs without m.mu so readers can keep seeing the current goal. +func (m *Manager) SetScheduleChecked(id string, s Schedule) (bool, error) { + m.intentMu.Lock() + defer m.intentMu.Unlock() + m.mu.RLock() lp, ok := m.byID[id] + saver := m.scheduleSaver + m.mu.RUnlock() if !ok { - m.mu.Unlock() - return false + return false, nil } s.Normalize() // The weekday mask is 7 bits; a stray high bit from a future // client is dropped rather than left to confuse the roll. s.Days &= 0x7F + if saver != nil { + if err := saver(id, s); err != nil { + return true, err + } + } + m.mu.Lock() + defer m.mu.Unlock() + // Load shares intentMu, so the configured loadpoint cannot change + // between the save and the in-memory update. + if s.SoC > lp.schedule.SoC { + lp.chargingDeclined = false + lp.notRequestingSince = time.Time{} + } lp.schedule = s // Force RollSchedules to re-evaluate on next call — operator just // changed the contract so any previous idempotence cache is stale. @@ -1045,12 +1207,7 @@ func (m *Manager) SetSchedule(id string, s Schedule) bool { // non-recurring saves. lp.targetTime = time.Time{} lp.targetSoC = 0 - saver := m.scheduleSaver - m.mu.Unlock() - if saver != nil { - saver(id, s) - } - return true + return true, nil } // GetSchedule returns the current schedule and a found flag. The flag @@ -1071,22 +1228,18 @@ func (m *Manager) GetSchedule(id string) (Schedule, bool) { // ClearSchedule removes the operator's intent. Persists Empty so a // reload doesn't resurrect the old schedule from disk. Returns false -// for unknown IDs. +// for unknown IDs or a failed save. func (m *Manager) ClearSchedule(id string) bool { - m.mu.Lock() - lp, ok := m.byID[id] - if !ok { - m.mu.Unlock() - return false - } - lp.schedule = Schedule{} - lp.lastRolledFor = time.Time{} - saver := m.scheduleSaver - m.mu.Unlock() - if saver != nil { - saver(id, Schedule{}) - } - return true + ok, err := m.ClearScheduleChecked(id) + return ok && err == nil +} + +// ClearScheduleChecked keeps the previous goal when its removal cannot save. +func (m *Manager) ClearScheduleChecked(id string) (bool, error) { + // Removing the goal also removes its active derived deadline. Leaving + // that target behind would keep planning a charge the UI says was removed. + // Manual holds belong to the controller and are unaffected. + return m.SetScheduleChecked(id, Schedule{}) } // HydrateSchedules loads persisted schedules at boot. `loader(id)` @@ -1098,6 +1251,8 @@ func (m *Manager) ClearSchedule(id string) bool { // Does NOT invoke the saver — this is a load path. Does NOT call // RollSchedules either; the controller's first tick will handle that. func (m *Manager) HydrateSchedules(loader func(id string) (Schedule, bool)) { + m.intentMu.Lock() + defer m.intentMu.Unlock() m.mu.Lock() defer m.mu.Unlock() for _, id := range m.order { @@ -1155,3 +1310,14 @@ func (m *Manager) RollSchedules(now time.Time) { } } } + +// RetryCharging lets an explicit Start action retry a vehicle that previously +// declined current. It changes no battery level or stored user intent. +func (m *Manager) RetryCharging(id string) { + m.mu.Lock() + defer m.mu.Unlock() + if lp := m.byID[id]; lp != nil { + lp.chargingDeclined = false + lp.notRequestingSince = time.Time{} + } +} diff --git a/go/internal/loadpoint/loadpoint_surplus_withheld_test.go b/go/internal/loadpoint/loadpoint_surplus_withheld_test.go index e5bfbafe..dc4be0ba 100644 --- a/go/internal/loadpoint/loadpoint_surplus_withheld_test.go +++ b/go/internal/loadpoint/loadpoint_surplus_withheld_test.go @@ -28,7 +28,7 @@ func TestSelfWithheldNCRQDoesNotComplete(t *testing.T) { clock = clock.Add(5 * time.Minute) // well past the 90s completion timeout m.Observe("garage", true, 0, 0, false) - if st, _ := m.State("garage"); st.SoCSource == "completed" { + if st, _ := m.State("garage"); st.ChargingDeclined { t.Errorf("self-withheld NCRQ must not latch session complete: %+v", st) } } @@ -60,7 +60,7 @@ func TestGenuineNCRQStillCompletesAfterWithheldClears(t *testing.T) { clock = clock.Add(2 * time.Minute) // past 90s of genuine refusal m.Observe("garage", true, 0, 0, false) - if st, _ := m.State("garage"); st.SoCSource != "completed" { + if st, _ := m.State("garage"); !st.ChargingDeclined { t.Errorf("genuine NCRQ after withheld clears should complete: %+v", st) } } diff --git a/go/internal/loadpoint/loadpoint_test.go b/go/internal/loadpoint/loadpoint_test.go index 22f0a026..cfbad190 100644 --- a/go/internal/loadpoint/loadpoint_test.go +++ b/go/internal/loadpoint/loadpoint_test.go @@ -1,6 +1,7 @@ package loadpoint import ( + "math" "testing" "time" ) @@ -209,7 +210,7 @@ func TestStatesReturnsAllInOrder(t *testing.T) { } } -// TestSessionCompletionSnapsToTarget walks the scenario where the +// TestVehicleDeclineDoesNotInventTargetSoC walks the scenario where the // vehicle charges normally, then explicitly stops requesting current // for a sustained window (typically because it hit its own onboard // SoC target or its onboard schedule ended). Without the completion @@ -217,7 +218,7 @@ func TestStatesReturnsAllInOrder(t *testing.T) { // the MPC would keep allocating PV surplus to a phantom sink, spilling // it to the grid. With the latch the inferred SoC snaps to the target // and the planner sees the EV as done. -func TestSessionCompletionSnapsToTarget(t *testing.T) { +func TestVehicleDeclineDoesNotInventTargetSoC(t *testing.T) { m := NewManager() m.Load([]Config{{ ID: "garage", DriverName: "evse-test", @@ -230,8 +231,8 @@ func TestSessionCompletionSnapsToTarget(t *testing.T) { // Tick 1: connected and charging — request_active = true. m.Observe("garage", true, 7400, 0, true) - if st, _ := m.State("garage"); st.SoCSource != "" { - t.Errorf("session start should not be marked completed: %+v", st) + if st, _ := m.State("garage"); st.SoCSource != "assumed" { + t.Errorf("session start should expose its unconfirmed level: %+v", st) } // Tick 2 (T+1m of charging): some energy delivered, inferred SoC rises. @@ -245,14 +246,14 @@ func TestSessionCompletionSnapsToTarget(t *testing.T) { // delivered_wh frozen, power drops to 0. clock = clock.Add(6 * time.Second) m.Observe("garage", true, 0, 1000, false) - if st, _ := m.State("garage"); st.SoCSource == "completed" { + if st, _ := m.State("garage"); st.ChargingDeclined { t.Errorf("first not-requesting tick should NOT yet complete (under threshold): %+v", st) } // Tick 4 (T+30s of not-requesting): below threshold — still not completed. clock = clock.Add(30 * time.Second) m.Observe("garage", true, 0, 1000, false) - if st, _ := m.State("garage"); st.SoCSource == "completed" { + if st, _ := m.State("garage"); st.ChargingDeclined { t.Errorf("30s not-requesting should NOT yet complete (under 90s threshold): %+v", st) } @@ -260,10 +261,10 @@ func TestSessionCompletionSnapsToTarget(t *testing.T) { clock = clock.Add(60 * time.Second) m.Observe("garage", true, 0, 1000, false) st, _ := m.State("garage") - if st.SoCSource != "completed" { + if !st.ChargingDeclined { t.Errorf("expected SoCSource='completed' after threshold, got %q (state=%+v)", st.SoCSource, st) } - if st.CurrentSoC != 0.6 { + if math.Abs(st.CurrentSoC-(0.2+1000.0/60000)) > 1e-9 { t.Errorf("expected inferred SoC pinned to target 60, got %.2f", st.CurrentSoC) } @@ -274,7 +275,7 @@ func TestSessionCompletionSnapsToTarget(t *testing.T) { // clears it. clock = clock.Add(15 * time.Second) m.Observe("garage", true, 0, 1000, true) - if st, _ := m.State("garage"); st.SoCSource != "completed" || st.CurrentSoC != 0.6 { + if st, _ := m.State("garage"); !st.ChargingDeclined || math.Abs(st.CurrentSoC-(0.2+1000.0/60000)) > 1e-9 { t.Errorf("brief request_active flicker should not clear latch: %+v", st) } @@ -306,7 +307,7 @@ func TestSessionCompletionRequiresTarget(t *testing.T) { m.Observe("garage", true, 0, 0, false) st, _ := m.State("garage") - if st.SoCSource == "completed" { + if st.ChargingDeclined { t.Errorf("completion should not trigger without a target: %+v", st) } } @@ -332,7 +333,7 @@ func TestRequestActiveDefaultPreservesInference(t *testing.T) { clock = clock.Add(5 * time.Minute) m.Observe("garage", true, 7400, 600, true) // 600 Wh in → SoC = 30 + 1 st, _ := m.State("garage") - if st.SoCSource == "completed" { + if st.ChargingDeclined { t.Errorf("request_active=true must not trigger completion: %+v", st) } if st.CurrentSoC < 0.305 || st.CurrentSoC > 0.315 { @@ -359,7 +360,7 @@ func latchedManager(t *testing.T) (*Manager, *time.Time) { clock = clock.Add(SessionCompletionTimeout) m.Observe("garage", true, 0, 1000, false) st, _ := m.State("garage") - if st.SoCSource != "completed" || st.CurrentSoC != 0.6 { + if !st.ChargingDeclined || math.Abs(st.CurrentSoC-(0.2+1000.0/60000)) > 1e-9 { t.Fatalf("precondition: latch should have fired, got %+v", st) } return m, &clock @@ -377,7 +378,7 @@ func TestSetCurrentSoCClearsCompletionLatch(t *testing.T) { *clock = clock.Add(3 * time.Second) m.Observe("garage", true, 0, 1000, false) // still not requesting, timer restarts st, _ := m.State("garage") - if st.SoCSource == "completed" { + if st.ChargingDeclined { t.Errorf("latch should be cleared by the operator's correction: %+v", st) } if st.CurrentSoC < 0.49 || st.CurrentSoC > 0.51 { @@ -386,43 +387,43 @@ func TestSetCurrentSoCClearsCompletionLatch(t *testing.T) { // A car that keeps declining re-arms the latch after the timeout. *clock = clock.Add(SessionCompletionTimeout) m.Observe("garage", true, 0, 1000, false) - if st, _ := m.State("garage"); st.SoCSource != "completed" || st.CurrentSoC != 0.6 { + if st, _ := m.State("garage"); !st.ChargingDeclined || math.Abs(st.CurrentSoC-0.5) > 1e-9 { t.Errorf("latch should re-arm after sustained not-requesting: %+v", st) } } -// Same morning, later: a manual charge pushed 11 kW into the car for -// forty minutes and the estimate still read 80 %, because the latch -// only ever released on plug-out. Ten minutes of steady current is the -// car charging, not an EVSE retry blip; the estimate must follow the -// delivered energy again. -func TestSustainedChargingClearsCompletionLatch(t *testing.T) { +// A car that actually resumes charging is eligible for planning immediately. +func TestMeasuredChargingClearsVehicleDecline(t *testing.T) { m, clock := latchedManager(t) - // Charging resumes at full power; a brief run must not release. *clock = clock.Add(5 * time.Second) m.Observe("garage", true, 11000, 1500, true) - if st, _ := m.State("garage"); st.SoCSource != "completed" || st.CurrentSoC != 0.6 { - t.Fatalf("a fresh run must not release the latch yet: %+v", st) + st, _ := m.State("garage") + if st.ChargingDeclined || math.Abs(st.CurrentSoC-(0.2+1500.0/60000)) > 1e-9 { + t.Fatalf("resumed delivery did not clear refusal: %+v", st) } - *clock = clock.Add(InterruptSteadyRun / 2) - m.Observe("garage", true, 11000, 3000, true) - if st, _ := m.State("garage"); st.SoCSource != "completed" { - t.Fatalf("half a steady run must not release the latch: %+v", st) +} + +func TestHigherTargetRetriesVehicleWithoutInventingBatteryLevel(t *testing.T) { + m, _ := latchedManager(t) + before, _ := m.State("garage") + m.SetTarget("garage", .9, time.Now().Add(time.Hour)) + after, _ := m.State("garage") + if after.ChargingDeclined || after.CurrentSoC != before.CurrentSoC { + t.Fatalf("higher target did not retry honestly: %+v", after) } - *clock = clock.Add(InterruptSteadyRun/2 + time.Second) - m.Observe("garage", true, 11000, 4000, true) - st, _ := m.State("garage") - if st.SoCSource == "completed" { - t.Errorf("a full steady run should release the latch: %+v", st) - } - // anchor 0.2 + 4000/60000 ≈ 0.267 — the estimate moved off the pin. - if st.CurrentSoC < 0.26 || st.CurrentSoC > 0.27 { - t.Errorf("estimate should follow delivered Wh after release, got %.3f", st.CurrentSoC) - } - // And keeps following as more energy goes in. - *clock = clock.Add(time.Minute) - m.Observe("garage", true, 11000, 6000, true) - if st, _ := m.State("garage"); st.CurrentSoC < 0.29 || st.CurrentSoC > 0.31 { - t.Errorf("estimate should keep rising, got %.3f", st.CurrentSoC) +} + +func TestExplicitRetryAndHigherScheduleClearVehicleDecline(t *testing.T) { + m, _ := latchedManager(t) + before, _ := m.State("garage") + m.RetryCharging("garage") + after, _ := m.State("garage") + if after.ChargingDeclined || after.CurrentSoC != before.CurrentSoC { + t.Fatalf("retry changed level: %+v", after) + } + m, _ = latchedManager(t) + m.SetSchedule("garage", Schedule{SoC: .9, TimeOfDayMinUTC: 7 * 60}) + if after, _ := m.State("garage"); after.ChargingDeclined { + t.Fatalf("new goal retained refusal: %+v", after) } } diff --git a/go/internal/loadpoint/manual_hold_state.go b/go/internal/loadpoint/manual_hold_state.go new file mode 100644 index 00000000..6df7afbc --- /dev/null +++ b/go/internal/loadpoint/manual_hold_state.go @@ -0,0 +1,208 @@ +package loadpoint + +import ( + "encoding/json" + "fmt" + "strings" +) + +type storedManualHold struct { + Version int `json:"version"` + DeviceID string `json:"device_id"` + SessionID string `json:"session_id,omitempty"` + Hold ManualHold `json:"hold"` +} + +type pendingManualHold struct { + hold ManualHold + cleared bool +} + +func manualHoldKey(deviceID string) string { + return "ev_manual_hold:" + strings.TrimPrefix(sessionKey(deviceID), "ev_session:") +} + +// PersistManualHold records an explicit operator action. Before first hardware +// telemetry, persist only a zero-power restriction and bind the actual action +// to the first fresh device reading. A Start still works immediately, but +// cannot grant positive power after restart without matching session proof. +func (m *Manager) PersistManualHold(id string, h ManualHold, cleared bool) (err error) { + m.sessionMu.Lock() + defer m.sessionMu.Unlock() + defer func() { + if err != nil { + m.setManualSaveError(id, true) + } + }() + if m.pendingManual == nil { + m.pendingManual = map[string]pendingManualHold{} + } + m.pendingManual[id] = pendingManualHold{h, cleared} + return m.flushManualHold(id) +} + +func (m *Manager) flushManualHold(id string) (err error) { + defer func() { + if err != nil { + m.setManualSaveError(id, true) + } + }() + pending, ok := m.pendingManual[id] + if !ok || m.sessionStore == nil { + return nil + } + // One atomic, name-keyed marker is authoritative until the hardware-bound + // write finishes. It grants no positive manual power, including for OCPP + // chargers that have not booted or never report a serial. No watts go here. + restriction := "clear" + if !pending.cleared && pending.hold.Persistent { + restriction = "unconfirmed" + if pending.hold.PowerW == 0 { + restriction = "pause" + } + } + if err := m.sessionStore.SaveConfig("ev_manual_unbound:"+id, restriction); err != nil { + return err + } + m.mu.RLock() + lp := m.byID[id] + var deviceID, sessionID string + if lp != nil { + deviceID, sessionID = lp.sessionDeviceID, lp.sessionID + } + m.mu.RUnlock() + if deviceID == "" { + m.setManualSaveError(id, false) + return nil + } + body := "{}" + if !pending.cleared && pending.hold.Persistent { + h := pending.hold + if !finite(h.PowerW) || h.PowerW < 0 { + return fmt.Errorf("invalid stored manual power") + } + b, err := json.Marshal(storedManualHold{Version: 1, DeviceID: deviceID, SessionID: sessionID, Hold: h}) + if err != nil { + return err + } + body = string(b) + } + if err := m.sessionStore.SaveConfig(manualHoldKey(deviceID), body); err != nil { + return err + } + // This name-keyed index is only a hint to pause while identity is unknown; + // it never grants positive power or bypasses the hardware/session match. + if err := m.sessionStore.SaveConfig("ev_manual_binding:"+id, deviceID); err != nil { + return err + } + // Retire the legacy name key too: a cleared new record must not fall back + // to an earlier unbound hold on the next boot. + if err := m.sessionStore.SaveConfig("loadpoint_manual_hold:"+id, "{}"); err != nil { + return err + } + if err := m.sessionStore.SaveConfig("ev_manual_clear:"+id, ""); err != nil { + return err + } + if err := m.sessionStore.SaveConfig("ev_manual_unbound:"+id, ""); err != nil { + return err + } + delete(m.pendingManual, id) + m.setManualSaveError(id, false) + return nil +} + +func (m *Manager) setManualSaveError(id string, value bool) { + m.mu.Lock() + defer m.mu.Unlock() + if lp := m.byID[id]; lp != nil { + lp.manualSaveError = value + } +} + +// RestoreManualHold returns pending until hardware identity is known. A +// verified same-session positive hold or a hardware-bound pause is restored. +// Unknown/changed sessions and legacy records return a persistent zero-W hold +// plus unconfirmed: failed restoration must not start automatic charging. +func (m *Manager) RestoreManualHold(id string) (ManualHold, string) { + m.sessionMu.Lock() + defer m.sessionMu.Unlock() + m.mu.RLock() + lp := m.byID[id] + var deviceID, sessionID string + if lp != nil { + deviceID, sessionID = lp.sessionDeviceID, lp.sessionID + } + m.mu.RUnlock() + if m.sessionStore == nil { + return ManualHold{}, "none" + } + if restriction, _ := m.sessionStore.LoadConfig("ev_manual_unbound:" + id); restriction != "" { + if restriction == "clear" { + if m.pendingManual == nil { + m.pendingManual = map[string]pendingManualHold{} + } + m.pendingManual[id] = pendingManualHold{cleared: true} + _ = m.flushManualHold(id) + return ManualHold{}, "none" + } + if restriction == "pause" { + return ManualHold{Persistent: true}, "restored" + } + return ManualHold{Persistent: true}, "unconfirmed" + } + if clear, _ := m.sessionStore.LoadConfig("ev_manual_clear:" + id); clear == "pending" { + if m.pendingManual == nil { + m.pendingManual = map[string]pendingManualHold{} + } + m.pendingManual[id] = pendingManualHold{cleared: true} + _ = m.flushManualHold(id) + return ManualHold{}, "none" + } + if deviceID == "" { + binding, bound := m.sessionStore.LoadConfig("ev_manual_binding:" + id) + if bound && binding != "" { + if raw, found := m.sessionStore.LoadConfig(manualHoldKey(binding)); !found || (raw != "" && raw != "{}") { + return ManualHold{Persistent: true}, "pending" + } + } + legacy, found := m.sessionStore.LoadConfig("loadpoint_manual_hold:" + id) + if found && legacy != "" && legacy != "{}" { + return ManualHold{Persistent: true}, "pending" + } + return ManualHold{}, "pending" + } + raw, found := m.sessionStore.LoadConfig(manualHoldKey(deviceID)) + if !found { + if binding, ok := m.sessionStore.LoadConfig("ev_manual_binding:" + id); ok && binding != "" { + if old, present := m.sessionStore.LoadConfig(manualHoldKey(binding)); !present || (old != "" && old != "{}") { + return ManualHold{Persistent: true}, "unconfirmed" + } + } + legacy, ok := m.sessionStore.LoadConfig("loadpoint_manual_hold:" + id) + if ok && legacy != "" && legacy != "{}" { + return ManualHold{Persistent: true}, "unconfirmed" + } + return ManualHold{}, "none" + } + if raw == "" || raw == "{}" { + return ManualHold{}, "none" + } + var record storedManualHold + if json.Unmarshal([]byte(raw), &record) != nil || record.Version != 1 || + record.DeviceID != deviceID || !record.Hold.Persistent || + !record.Hold.ExpiresAt.IsZero() || !finite(record.Hold.PowerW) || record.Hold.PowerW < 0 { + return ManualHold{Persistent: true}, "unconfirmed" + } + if record.Hold.PowerW == 0 || (sessionID != "" && sessionID == record.SessionID) { + return record.Hold, "restored" + } + return ManualHold{Persistent: true}, "unconfirmed" +} + +func (m *Manager) SetManualRestoreUnconfirmed(id string, value bool) { + m.mu.Lock() + defer m.mu.Unlock() + if lp := m.byID[id]; lp != nil { + lp.manualRestoreUnconfirmed = value + } +} diff --git a/go/internal/loadpoint/manual_hold_state_test.go b/go/internal/loadpoint/manual_hold_state_test.go new file mode 100644 index 00000000..e6de0d33 --- /dev/null +++ b/go/internal/loadpoint/manual_hold_state_test.go @@ -0,0 +1,386 @@ +package loadpoint + +import ( + "context" + "testing" + "time" +) + +func TestLostHardwareProofPausesOldStartButAllowsANewExplicitStart(t *testing.T) { + cfg := holdLoadpoint() + sender := &fakeSender{} + sample := EVSample{Connected: true, RequestActive: true, DeviceID: "easee:A", SessionID: "session-1"} + samples := map[string]EVSample{cfg.DriverName: sample} + c := newTestController(t, []Config{cfg}, nil, samples, sender) + c.SetSiteFuse(SiteFuse{MaxAmps: 16, Voltage: 230, PhaseCnt: 3}) + now := time.Now() + tick := func(want float64) { + t.Helper() + c.Tick(context.Background(), now) + now = now.Add(time.Second) + if n := len(sender.calls); n == 0 || sender.calls[n-1].power != want { + t.Fatalf("commands=%+v, want latest power %v", sender.calls, want) + } + } + tick(0) + c.SetManualHold(cfg.ID, ManualHold{PowerW: 4140, Persistent: true}) + tick(4140) + sample.DeviceID, sample.SessionID = "", "" + samples[cfg.DriverName] = sample + tick(0) + if state, _ := c.manager.State(cfg.ID); !state.ManualRestoreUnconfirmed { + t.Fatal("lost proof did not explain the pause") + } + tick(0) + if state, _ := c.manager.State(cfg.ID); !state.ManualRestoreUnconfirmed { + t.Fatal("a later unknown sample cleared the confirmation requirement") + } + // This request belongs to the currently unknown socket, not the older + // device. It works now and may acquire its first fresh hardware identity. + c.SetManualHold(cfg.ID, ManualHold{PowerW: 4140, Persistent: true}) + tick(4140) + sample.DeviceID, sample.SessionID = "easee:B", "session-2" + samples[cfg.DriverName] = sample + tick(4140) + if state, _ := c.manager.State(cfg.ID); state.ManualRestoreUnconfirmed { + t.Fatal("new explicit Start still asks for confirmation") + } +} + +func TestLostHardwareProofKeepsExplicitPauseConfirmed(t *testing.T) { + m := sessionManager(nil, "garage", "charger") + m.ObserveSession("garage", true, 0, 1000, false, "easee:A", "session-1") + c := NewController(m, nil, nil, nil) + c.SetManualHold("garage", ManualHold{Persistent: true}) + for _, device := range []string{"", "", "easee:B"} { + m.ObserveSession("garage", true, 0, 1000, false, device, "") + c.restoreManualHoldForSession("garage") + if hold, ok := c.GetManualHold("garage", time.Now()); !ok || hold.PowerW != 0 { + t.Fatalf("explicit pause lost: %+v %v", hold, ok) + } + if state, _ := m.State("garage"); state.ManualRestoreUnconfirmed { + t.Fatal("explicit Pause unnecessarily asks for confirmation") + } + } +} + +func TestManualSaveErrorKeepsCommandAndRetriesOnFreshReading(t *testing.T) { + for _, action := range []string{"start", "clear", "before_identity"} { + t.Run(action, func(t *testing.T) { + store := &sessionMemory{data: map[string]string{}} + m := sessionManager(store, "garage", "charger") + if action != "before_identity" { + m.ObserveSession("garage", true, 4300, 1000, true, "easee:A", "session-1") + } + c := NewController(m, nil, nil, nil) + var saveErr error + c.SetManualHoldSaver(func(id string, h ManualHold, cleared bool) { + saveErr = m.PersistManualHold(id, h, cleared) + }) + if action == "clear" { + c.SetManualHold("garage", ManualHold{PowerW: 4140, Persistent: true}) + if saveErr != nil { + t.Fatal(saveErr) + } + } + store.fail = true + if action == "clear" { + c.ClearManualHold("garage") + } else { + c.SetManualHold("garage", ManualHold{PowerW: 5520, Persistent: true}) + } + attempted := true + if (saveErr != nil) != attempted { + t.Fatalf("save error = %v, attempted = %v", saveErr, attempted) + } + if s, _ := m.State("garage"); s.ManualSaveError != attempted { + t.Fatalf("save failure not reported: %+v", s) + } + // A later telemetry flush can fail too, including the first write + // of an explicit request received before hardware was known. + m.ObserveSession("garage", true, 4300, 1100, true, "easee:A", "session-1") + if s, _ := m.State("garage"); !s.ManualSaveError { + t.Fatal("retry failure not reported") + } + if h, ok := c.GetManualHold("garage", time.Now()); (action == "clear" && ok) || + (action != "clear" && (!ok || h.PowerW != 5520)) { + t.Fatalf("disk failure changed the current command: %+v %v", h, ok) + } + // Reloading settings cannot hide an outstanding failed save. + m.Load([]Config{{ID: "garage", DriverName: "charger", VehicleCapacityWh: 60000}}) + if s, _ := m.State("garage"); !s.ManualSaveError { + t.Fatal("config reload hid the failed save") + } + store.fail = false + m.ObserveSession("garage", true, 4300, 1200, true, "easee:A", "session-1") + if s, _ := m.State("garage"); s.ManualSaveError { + t.Fatal("successful retry did not clear the warning") + } + // Prove the retry reached storage rather than only clearing a flag. + restarted := sessionManager(store, "garage", "charger") + restarted.ObserveSession("garage", true, 4300, 1200, true, "easee:A", "session-1") + h, status := restarted.RestoreManualHold("garage") + if (action == "clear" && status != "none") || + (action != "clear" && (status != "restored" || h.PowerW != 5520)) { + t.Fatalf("retried command did not survive restart: %+v %s", h, status) + } + }) + } +} + +func TestManualHoldRestoreRequiresHardwareAndActiveSession(t *testing.T) { + store := &sessionMemory{data: map[string]string{}} + m := sessionManager(store, "garage", "charger") + m.ObserveSession("garage", true, 4300, 1000, true, "easee:ABC", "session-1") + if err := m.PersistManualHold("garage", ManualHold{PowerW: 4140, Persistent: true}, false); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + device, session, status string + power float64 + }{ + {"easee:ABC", "session-1", "restored", 4140}, + {"easee:ABC", "session-2", "unconfirmed", 0}, + {"easee:ABC", "", "unconfirmed", 0}, + {"easee:OTHER", "session-1", "unconfirmed", 0}, + } { + m := sessionManager(store, "garage", "charger") + m.ObserveSession("garage", true, 0, 1000, true, tc.device, tc.session) + h, status := m.RestoreManualHold("garage") + if status != tc.status || h.PowerW != tc.power || !h.Persistent { + t.Fatalf("%+v: got %+v %s", tc, h, status) + } + } + if err := m.PersistManualHold("garage", ManualHold{PowerW: 0, Persistent: true}, false); err != nil { + t.Fatal(err) + } + m = sessionManager(store, "garage", "charger") + m.ObserveSession("garage", true, 0, 1000, true, "easee:ABC", "") + if h, status := m.RestoreManualHold("garage"); status != "restored" || h.PowerW != 0 { + t.Fatalf("pause failed: %+v %s", h, status) + } +} + +func TestManualHoldBeforeIdentityBindsOnlyAfterFreshReading(t *testing.T) { + store := &sessionMemory{data: map[string]string{}} + m := sessionManager(store, "garage", "charger") + m.PersistManualHold("garage", ManualHold{PowerW: 4140, Persistent: true}, false) + if h, status := sessionManager(store, "garage", "charger").RestoreManualHold("garage"); status != "unconfirmed" || !h.Persistent || h.PowerW != 0 { + t.Fatalf("unidentified Start gained power after restart: %+v %s", h, status) + } + m.ObserveSession("garage", true, 4300, 1000, true, "easee:ABC", "session-1") + m = sessionManager(store, "garage", "charger") + m.ObserveSession("garage", true, 4300, 1100, true, "easee:ABC", "session-1") + if h, status := m.RestoreManualHold("garage"); status != "restored" || h.PowerW != 4140 { + t.Fatalf("explicit start was lost: %+v %s", h, status) + } +} + +func TestControllerPausesPendingRestoreAndHonoursExplicitOverride(t *testing.T) { + store := &sessionMemory{data: map[string]string{}} + m := sessionManager(store, "garage", "charger") + m.ObserveSession("garage", true, 4300, 1000, true, "easee:ABC", "session-1") + m.PersistManualHold("garage", ManualHold{PowerW: 4140, Persistent: true}, false) + m = sessionManager(store, "garage", "charger") + c := NewController(m, nil, nil, nil) + c.restoreManualHoldForSession("garage") + if h, ok := c.GetManualHold("garage", time.Now()); !ok || h.PowerW != 0 { + t.Fatalf("unknown session could use automatic charging: %+v %v", h, ok) + } + if s, _ := m.State("garage"); !s.ManualRestoreUnconfirmed { + t.Fatal("pending restore not visible") + } + c.markManualExplicit("garage") + c.SetManualHold("garage", ManualHold{PowerW: 5520, Persistent: true}) + m.ObserveSession("garage", true, 4300, 1100, true, "easee:ABC", "session-1") + c.restoreManualHoldForSession("garage") + if h, ok := c.GetManualHold("garage", time.Now()); !ok || h.PowerW != 5520 { + t.Fatalf("recovery overwrote explicit choice: %+v %v", h, ok) + } + if s, _ := m.State("garage"); s.ManualRestoreUnconfirmed { + t.Fatal("explicit action did not confirm restore") + } +} + +func TestLegacyManualHoldNeedsConfirmationAndClearCannotResurrectIt(t *testing.T) { + store := &sessionMemory{data: map[string]string{"loadpoint_manual_hold:garage": `{"PowerW":11000,"Persistent":true}`}} + m := sessionManager(store, "garage", "charger") + m.ObserveSession("garage", true, 4300, 1000, true, "easee:ABC", "session-1") + if h, status := m.RestoreManualHold("garage"); status != "unconfirmed" || h.PowerW != 0 { + t.Fatalf("legacy hold resumed: %+v %s", h, status) + } + m.PersistManualHold("garage", ManualHold{}, true) + m = sessionManager(store, "garage", "charger") + m.ObserveSession("garage", true, 4300, 1100, true, "easee:ABC", "session-1") + if _, status := m.RestoreManualHold("garage"); status != "none" { + t.Fatalf("cleared hold resurrected: %s", status) + } +} + +func TestRuntimeManualHoldCannotFollowChangedDeviceOrSession(t *testing.T) { + for _, change := range []string{"hardware", "session", "remove_readd"} { + t.Run(change, func(t *testing.T) { + store := &sessionMemory{data: map[string]string{}} + m := sessionManager(store, "garage", "charger") + m.ObserveSession("garage", true, 4300, 1000, true, "easee:A", "session-1") + c := NewController(m, nil, nil, nil) + c.SetManualHold("garage", ManualHold{PowerW: 4140, Persistent: true}) + device, session := "easee:A", "session-1" + switch change { + case "hardware": + device = "easee:B" + case "session": + session = "session-2" + case "remove_readd": + m.Load(nil) + m.Load([]Config{{ID: "garage", DriverName: "charger", VehicleCapacityWh: 60000}}) + } + m.ObserveSession("garage", true, 4300, 1100, true, device, session) + c.restoreManualHoldForSession("garage") + if h, ok := c.GetManualHold("garage", time.Now()); !ok || h.PowerW != 0 { + t.Fatalf("old power followed %s: %+v %v", change, h, ok) + } + if s, _ := m.State("garage"); !s.ManualRestoreUnconfirmed { + t.Fatal("changed session was not shown") + } + // The safe pause also survives another restart on that hardware. + m = sessionManager(store, "garage", "charger") + m.ObserveSession("garage", true, 0, 1100, true, device, session) + if h, status := m.RestoreManualHold("garage"); status != "restored" || h.PowerW != 0 { + t.Fatalf("pause was not durable: %+v %s", h, status) + } + }) + } +} + +func TestExplicitStartCanAcquireFirstSessionProof(t *testing.T) { + for _, initialDevice := range []string{"", "easee:A"} { + m := sessionManager(nil, "garage", "charger") + if initialDevice != "" { + m.ObserveSession("garage", true, 0, 0, true, initialDevice, "") + } + c := NewController(m, nil, nil, nil) + c.SetManualHold("garage", ManualHold{PowerW: 4140, Persistent: true}) + m.ObserveSession("garage", true, 4300, 100, true, "easee:A", "session-1") + c.restoreManualHoldForSession("garage") + if h, ok := c.GetManualHold("garage", time.Now()); !ok || h.PowerW != 4140 { + t.Fatalf("explicit start before proof was lost: %+v %v", h, ok) + } + } +} + +func TestFirstSessionIDAfterCounterResetCannotInheritManualStart(t *testing.T) { + store := &sessionMemory{data: map[string]string{}} + m := sessionManager(store, "garage", "charger") + m.ObserveSession("garage", true, 0, 11000, true, "easee:A", "") + c := NewController(m, nil, nil, nil) + c.SetManualHold("garage", ManualHold{PowerW: 4140, Persistent: true}) + // The old session was paused and unverified. A fresh charging session + // appears with less energy, after an unplug the box did not observe. + m.ObserveSession("garage", true, 4300, 500, true, "easee:A", "session-2") + c.restoreManualHoldForSession("garage") + if h, ok := c.GetManualHold("garage", time.Now()); !ok || h.PowerW != 0 { + t.Fatalf("new car inherited the previous Start: %+v %v", h, ok) + } + if s, _ := m.State("garage"); !s.ManualRestoreUnconfirmed { + t.Fatal("changed session did not ask for confirmation") + } +} + +func TestClearBeforeTelemetrySurvivesAnotherImmediateRestart(t *testing.T) { + store := &sessionMemory{data: map[string]string{}} + m := sessionManager(store, "garage", "charger") + m.ObserveSession("garage", true, 4300, 1000, true, "easee:A", "session-1") + m.PersistManualHold("garage", ManualHold{PowerW: 4140, Persistent: true}, false) + store.data["loadpoint_manual_hold:garage"] = `{"PowerW":11000,"Persistent":true}` + m = sessionManager(store, "garage", "charger") + c := NewController(m, nil, nil, nil) + c.SetManualHoldSaver(func(id string, h ManualHold, cleared bool) { + if err := m.PersistManualHold(id, h, cleared); err != nil { + t.Error(err) + } + }) + c.ClearManualHold("garage") + m = sessionManager(store, "garage", "charger") + c = NewController(m, nil, nil, nil) + c.restoreManualHoldForSession("garage") + m.ObserveSession("garage", true, 4300, 1000, true, "easee:A", "session-1") + c.restoreManualHoldForSession("garage") + if _, ok := c.GetManualHold("garage", time.Now()); ok { + t.Fatal("cleared hold returned after reboot before telemetry") + } + if _, status := m.RestoreManualHold("garage"); status != "none" { + t.Fatalf("record survived explicit clear: %s", status) + } +} + +func TestConcurrentSetAndClearPersistInControllerOrder(t *testing.T) { + m := sessionManager(nil, "garage", "charger") + c := NewController(m, nil, nil, nil) + entered, release, setDone, clearDone := make(chan struct{}), make(chan struct{}), make(chan struct{}), make(chan struct{}) + var order []float64 + c.SetManualHoldSaver(func(_ string, h ManualHold, cleared bool) { + if !cleared { + close(entered) + <-release + order = append(order, h.PowerW) + } else { + order = append(order, 0) + } + }) + go func() { c.SetManualHold("garage", ManualHold{PowerW: 4140, Persistent: true}); close(setDone) }() + <-entered + go func() { c.ClearManualHold("garage"); close(clearDone) }() + select { + case <-clearDone: + t.Fatal("Clear overtook an earlier pending disk write") + case <-time.After(20 * time.Millisecond): + } + if h, ok := c.GetManualHold("garage", time.Now()); !ok || h.PowerW != 4140 { + t.Fatalf("read was blocked or changed before ordered clear: %+v %v", h, ok) + } + close(release) + <-setDone + <-clearDone + if len(order) != 2 || order[0] != 4140 || order[1] != 0 { + t.Fatalf("persist order: %v", order) + } + if _, ok := c.GetManualHold("garage", time.Now()); ok { + t.Fatal("clear lost to earlier save") + } +} + +func TestManualStartWithoutHardwareCannotFollowAnotherSocket(t *testing.T) { + cfg := holdLoadpoint() + sender := &fakeSender{} + sample := EVSample{Connected: true, RequestActive: true, ConnectionGeneration: 1} + samples := map[string]EVSample{cfg.DriverName: sample} + c := newTestController(t, []Config{cfg}, nil, samples, sender) + c.SetSiteFuse(SiteFuse{MaxAmps: 16, Voltage: 230, PhaseCnt: 3}) + now := time.Now() + tick := func(want float64) { + t.Helper() + c.Tick(context.Background(), now) + now = now.Add(time.Second) + if sender.calls[len(sender.calls)-1].power != want { + t.Fatalf("commands=%+v, want %v", sender.calls, want) + } + } + tick(0) + c.SetManualHold(cfg.ID, ManualHold{PowerW: 4140, Persistent: true}) + tick(4140) + sample.ConnectionGeneration = 2 + samples[cfg.DriverName] = sample + tick(0) + if st, _ := c.manager.State(cfg.ID); !st.ManualRestoreUnconfirmed { + t.Fatal("new socket inherited unknown hardware Start") + } + c.SetManualHold(cfg.ID, ManualHold{PowerW: 4140, Persistent: true}) + tick(4140) + sample.DeviceID = "easee:A" + samples[cfg.DriverName] = sample + tick(4140) + if st, _ := c.manager.State(cfg.ID); st.ManualRestoreUnconfirmed { + t.Fatal("first proof on same socket invalidated explicit Start") + } +} diff --git a/go/internal/loadpoint/manual_limits.go b/go/internal/loadpoint/manual_limits.go new file mode 100644 index 00000000..7d497af1 --- /dev/null +++ b/go/internal/loadpoint/manual_limits.go @@ -0,0 +1,71 @@ +package loadpoint + +import "math" + +// A manual selection is a ceiling. Never round it up to a larger step or +// let it bypass the configured charger's rating. +func clampManualPower(cfg Config, hold ManualHold, site SiteFuse) float64 { + floor := cfg.MinChargeW + mode := hold.PhaseMode + if mode == "" { + mode = cfg.PhaseMode + } + if (mode == "1p" || mode == "auto") && site.Phases() == 3 { + floor /= 3 + } + return floorChargeW(hold.PowerW, floor, cfg.MaxChargeW, cfg.AllowedStepsW) +} + +func (c *Controller) applyInstallationLimits(cmd map[string]any) { + site := c.siteFuse() + if site.Voltage > 0 { + cmd["voltage"] = site.Voltage + } + if site.PhaseCnt > 0 { + cmd["site_phases"] = site.Phases() + } + if site.Phases() == 1 { + cmd["phase_mode"] = "1p" + } + if site.MaxAmps > 0 { + limit, _ := cmd["max_amps_per_phase"].(float64) + if limit <= 0 || math.IsNaN(limit) || math.IsInf(limit, 0) || limit > site.MaxAmps { + cmd["max_amps_per_phase"] = site.MaxAmps + } + } +} + +// The watt order must reflect the final current ceiling too. In particular, +// drivers often interpret max_amps_per_phase=0 as an absent override; send an +// explicit zero-power order when the fuse leaves less than the 6 A minimum. +func applyCurrentCeiling(cmd map[string]any) bool { + w, _ := cmd["power_w"].(float64) + a, known := cmd["max_amps_per_phase"].(float64) + if w <= 0 || !known { + return false + } + if a < 6 { + cmd["power_w"] = float64(0) + return true + } + v, _ := cmd["voltage"].(float64) + if v <= 0 { + return false + } + mode, _ := cmd["phase_mode"].(string) + split, _ := cmd["phase_split_w"].(float64) + if split <= 0 { + split = v * a + } + // A three-phase switch cannot work below 6 A on every phase. + if split < 18*v { + split = 18 * v + } + phases := PhaseFor(mode, w, split) + ceiling := v * float64(phases) * math.Floor(a) + if w > ceiling { + cmd["power_w"] = ceiling + return true + } + return false +} diff --git a/go/internal/loadpoint/manual_limits_test.go b/go/internal/loadpoint/manual_limits_test.go new file mode 100644 index 00000000..cbe4f1ca --- /dev/null +++ b/go/internal/loadpoint/manual_limits_test.go @@ -0,0 +1,95 @@ +package loadpoint + +import ( + "context" + "math" + "testing" + "time" +) + +func TestManualSelectionIsAPowerCeiling(t *testing.T) { + cfg := holdLoadpoint() + for _, tc := range []struct{ request, max, want float64 }{ + {12000, 11000, 11000}, {5500, 11000, 4830}, {11000, 7000, 6900}, + {1000, 11000, 0}, {0, 11000, 0}, {math.NaN(), 11000, 0}, + } { + cfg.MaxChargeW = tc.max + got := clampManualPower(cfg, ManualHold{PowerW: tc.request, PhaseMode: "3p"}, SiteFuse{MaxAmps: 16, Voltage: 230, PhaseCnt: 3}) + if got != tc.want { + t.Errorf("request %v max %v: got %v want %v", tc.request, tc.max, got, tc.want) + } + } +} + +func TestPauseHoldsThroughCarRefusalAndResumesOnlyOnRequest(t *testing.T) { + now := time.Now() + cfg := overrideLoadpoint() + sender := &fakeSender{} + samples := map[string]EVSample{cfg.DriverName: {Connected: true, RequestActive: false}} + c := newTestController(t, []Config{cfg}, nil, samples, sender) + c.SetSiteFuse(SiteFuse{MaxAmps: 16, Voltage: 230, PhaseCnt: 3}) + c.SetManualHold(cfg.ID, ManualHold{PowerW: 0, Persistent: true}) + for _, dt := range []time.Duration{0, time.Minute, 3 * time.Minute, time.Hour} { + c.Tick(context.Background(), now.Add(dt)) + if h, ok := c.GetManualHold(cfg.ID, now.Add(dt)); !ok || h.PowerW != 0 { + t.Fatalf("pause was released at %v: %+v %v", dt, h, ok) + } + if len(sender.calls) == 0 || sender.calls[len(sender.calls)-1].power != 0 { + t.Fatal("pause ordered nonzero power") + } + } + c.SetManualHold(cfg.ID, ManualHold{PowerW: 4140, PhaseMode: "3p", Persistent: true}) + c.Tick(context.Background(), now.Add(time.Hour+time.Second)) + if got := sender.calls[len(sender.calls)-1].power; got != 4140 { + t.Fatalf("explicit start failed without planner: %v", got) + } +} + +func TestSnapStepsCannotExceedChargerRating(t *testing.T) { + if got := SnapChargeW(5000, 1380, 5000, []float64{0, 4140, 5520}); got != 4140 { + t.Fatalf("unsafe step above maximum: %v", got) + } + if got := SnapChargeW(2000, 1380, 2000, []float64{0, 4140}); got != 0 { + t.Fatalf("no valid step must stop: %v", got) + } +} + +func TestCurrentCeilingCannotTurnZeroFuseBudgetIntoDefaultAmps(t *testing.T) { + for _, tc := range []struct{ amps, want float64 }{{0, 0}, {5.5, 0}, {8.9, 5520}, {16, 11000}} { + cmd := map[string]any{"power_w": float64(11000), "voltage": float64(230), "phase_mode": "3p", "max_amps_per_phase": tc.amps} + applyCurrentCeiling(cmd) + if got := cmd["power_w"].(float64); got != tc.want { + t.Fatalf("ceiling %vA sent %vW, want %vW", tc.amps, got, tc.want) + } + } +} + +// A fuse budget is a ceiling, including between steps and below the minimum. +// The dispatch may pause; it must never increase an already reduced request. +func TestFusePowerBudgetNeverRoundsUp(t *testing.T) { + for _, tc := range []struct { + name string + want, cap, max, expected float64 + steps []float64 + }{ + {"below minimum", 11000, 3000, 11000, 0, []float64{0, 4140, 6900, 11000}}, + {"between steps", 11000, 6000, 11000, 4140, []float64{0, 4140, 6900, 11000}}, + {"no steps below minimum", 11000, 3000, 11000, 0, nil}, + {"continuous budget", 11000, 5000, 11000, 5000, nil}, + {"invalid step above rating", 11000, 8000, 7000, 6900, []float64{0, 4140, 6900, 8000}}, + {"reduced one phase request", 2000, 1500, 11000, 0, []float64{0, 4140, 6900, 11000}}, + } { + t.Run(tc.name, func(t *testing.T) { + cfg := holdLoadpoint() + cfg.MinChargeW = 4140 + cfg.MaxChargeW = tc.max + cfg.AllowedStepsW = tc.steps + c := newTestController(t, []Config{cfg}, nil, map[string]EVSample{}, &fakeSender{}) + c.SetFuseEVMax(func() (float64, bool) { return tc.cap, true }) + got, _ := c.applyFuseClampAndCooldown(time.Now(), cfg, tc.want) + if got != tc.expected || got > tc.cap || got > tc.want { + t.Fatalf("want %v cap %v: sent %v, expected %v", tc.want, tc.cap, got, tc.expected) + } + }) + } +} diff --git a/go/internal/loadpoint/manual_pause_status_test.go b/go/internal/loadpoint/manual_pause_status_test.go new file mode 100644 index 00000000..c6c0f6f5 --- /dev/null +++ b/go/internal/loadpoint/manual_pause_status_test.go @@ -0,0 +1,87 @@ +package loadpoint + +import ( + "encoding/json" + "strings" + "testing" + "time" +) + +func TestPauseNeedsFreshStoppedCharger(t *testing.T) { + now := time.Now() + h := ManualHold{PowerW: 0, Persistent: true, StartedAt: now} + st := State{Phases: 3, VoltageV: 230, CommandedKnown: true, CommandedW: 0, CommandedReason: "manual_hold", CommandedSinceMs: now.UnixMilli(), ManualCommandUpdatedAt: now} + for _, tc := range []struct { + name string + reading ChargerReading + power float64 + want string + }{ + {"still drawing", ChargerReading{Known: true, UpdatedAt: now, Charging: true, LimitKnown: true, LimitA: 16}, 11000, ManualPausing}, + {"old zero", ChargerReading{Known: true, UpdatedAt: now.Add(-time.Minute), LimitKnown: true, LimitA: 0}, 0, ManualPausing}, + {"car stopped but charger still offers", ChargerReading{Known: true, UpdatedAt: now, LimitKnown: true, LimitA: 16}, 0, ManualPausing}, + {"fresh pause", ChargerReading{Known: true, UpdatedAt: now, LimitKnown: true, LimitA: 0}, 0, ManualPaused}, + {"cloud lost", ChargerReading{Known: true, Unavailable: true, UpdatedAt: now, LimitKnown: true, LimitA: 0}, 0, ManualUnavailable}, + } { + t.Run(tc.name, func(t *testing.T) { + st.CurrentPowerW = tc.power + got := ManualStatusFrom(h, true, st, tc.reading, now.Add(time.Second)) + if got.State != tc.want { + t.Fatalf("got %+v want %s", got, tc.want) + } + b, _ := json.Marshal(got) + if !strings.Contains(string(b), `"requested_w":0`) { + t.Fatalf("pause omitted zero: %s", b) + } + }) + } +} + +func TestLowerCurrentWaitsForChargerEvenWhilePowerFlows(t *testing.T) { + now := time.Now() + h := ManualHold{PowerW: 4140, Persistent: true, StartedAt: now.Add(-time.Hour), UpdatedAt: now} + st := State{Phases: 3, VoltageV: 230, CurrentPowerW: 11000, CommandedKnown: true, CommandedW: 4140, CommandedReason: "manual_hold", CommandedSinceMs: now.UnixMilli(), ManualCommandUpdatedAt: now} + ch := ChargerReading{Known: true, Charging: true, LimitKnown: true, LimitA: 16, UpdatedAt: now} + if got := ManualStatusFrom(h, true, st, ch, now.Add(5*time.Second)); got.State != ManualSent { + t.Fatalf("old11kW falsely confirmed6A: %+v", got) + } + if got := ManualStatusFrom(h, true, st, ch, now.Add(4*time.Minute)); got.State != ManualStalled { + t.Fatalf("unconfirmed reduction did not time out: %+v", got) + } + ch.LimitA = 6 + ch.UpdatedAt = now.Add(10 * time.Second) + if got := ManualStatusFrom(h, true, st, ch, now.Add(12*time.Second)); got.State != ManualCharging { + t.Fatalf("confirmed reduction not shown: %+v", got) + } +} + +func TestFreshOldCommandCannotConfirmANewManualChoice(t *testing.T) { + now := time.Now() + h := ManualHold{PowerW: 4140, Persistent: true, StartedAt: now.Add(-time.Hour), UpdatedAt: now} + st := State{Phases: 3, VoltageV: 230, CurrentPowerW: 11000, CommandedKnown: true, CommandedW: 11000, CommandedReason: "manual_hold", ManualCommandUpdatedAt: now.Add(-time.Minute)} + ch := ChargerReading{Known: true, Charging: true, LimitKnown: true, LimitA: 16, UpdatedAt: now.Add(time.Second)} + if got := ManualStatusFrom(h, true, st, ch, now.Add(2*time.Second)); got.State != ManualSent { + t.Fatalf("old command acknowledged new choice: %+v", got) + } + // Even an unchanged clamped order must be computed for the new choice. + st.CommandedReason = "fuse_limit" + st.CommandedW = 4140 + ch.LimitA = 6 + if got := ManualStatusFrom(h, true, st, ch, now.Add(2*time.Second)); got.State != ManualSent { + t.Fatalf("old clamp acknowledged new choice: %+v", got) + } + st.ManualCommandUpdatedAt = h.UpdatedAt + if got := ManualStatusFrom(h, true, st, ch, now.Add(2*time.Second)); got.State != ManualCharging || got.LimitReason != "fuse_limit" { + t.Fatalf("new command not acknowledged: %+v", got) + } + h.PowerW = 0 + st.CommandedW = 0 + st.CurrentPowerW = 0 + st.CommandedReason = "manual_hold" + st.ManualCommandUpdatedAt = now.Add(-time.Minute) + ch.Charging = false + ch.LimitA = 0 + if got := ManualStatusFrom(h, true, st, ch, now.Add(2*time.Second)); got.State != ManualPausing { + t.Fatalf("old pause acknowledged new choice: %+v", got) + } +} diff --git a/go/internal/loadpoint/manual_status.go b/go/internal/loadpoint/manual_status.go new file mode 100644 index 00000000..19fe5553 --- /dev/null +++ b/go/internal/loadpoint/manual_status.go @@ -0,0 +1,221 @@ +package loadpoint + +import ( + "math" + "time" +) + +// ManualStatus is the live account of an operator hold ("Charge now"): +// what was asked, what the box ordered after its clamps, since when, and +// what the charger did with it. The manual tab reads it every poll, so the +// operator never has to guess from a 0 W readout whether the button worked. +// Field report 2026-09-05 (#1002): Charge now at 22:00, the tab said +// "Charging at 16 A" within a tenth of a second, the Easee cloud takes +// 5–15 s to act, nothing on screen moved, and the operator removed the +// charger to charge by hand. +type ManualStatus struct { + Active bool `json:"active"` + ChargerUpdatedAtMs int64 `json:"charger_updated_at_ms,omitempty"` + // State is one of ManualSent, ManualAccepted, ManualCharging, + // ManualNotDrawing, ManualStalled or ManualLimited. Empty when inactive. + State string `json:"state,omitempty"` + // StartedAtMs is when the operator installed the hold. + StartedAtMs int64 `json:"started_at_ms,omitempty"` + // SinceMs is when the current order took effect: the hold's start, or + // the last change of the ordered watts (an Update on the amp slider, a + // fuse clamp coming or going). Elapsed time in the UI counts from here. + SinceMs int64 `json:"since_ms,omitempty"` + // RequestedW is what the operator asked for; CommandedW is what the box + // ordered after every clamp. They differ while the main fuse limits. + RequestedW float64 `json:"requested_w"` + CommandedW float64 `json:"commanded_w,omitempty"` + RequestedA float64 `json:"requested_a"` + CommandedA float64 `json:"commanded_a,omitempty"` + // ChargerLimitA is the current limit the charger itself reports, when + // its driver exposes one (Easee: max_a). ChargerLimitKnown separates a + // reading of zero from no reading at all. + ChargerLimitA float64 `json:"charger_limit_a,omitempty"` + ChargerLimitKnown bool `json:"charger_limit_known,omitempty"` + // ChargerReason is the charger's own explanation for delivering no + // current, in its words (reason_no_current_label). Empty when it has none. + ChargerReason string `json:"charger_reason,omitempty"` + // LimitReason names the clamp behind ManualLimited: "fuse_limit", + // "fuse_cooldown" or "site_meter_stale". + LimitReason string `json:"limit_reason,omitempty"` +} + +const ( + // ManualSent: the hold is installed; the charger has not yet reflected + // the ordered limit. + ManualSent = "sent" + // ManualAccepted: the charger reports the ordered limit; the car has not + // started drawing yet. + ManualAccepted = "accepted" + // ManualCharging: power is flowing. + ManualCharging = "charging" + // ManualNotDrawing: the charger reports the ordered limit, and the car + // still draws nothing after the grace period. + ManualNotDrawing = "not_drawing" + // ManualStalled: the charger says the command stalled, or never + // confirmed it within the timeout. + ManualStalled = "stalled" + // ManualLimited: a clamp the hold cannot override (main fuse, stale site + // meter) holds the order below what was asked. + ManualLimited = "limited" + ManualUnavailable = "unavailable" + ManualPausing = "pausing" + ManualPaused = "paused" +) + +// ChargerStatus separates a current report from a cached reading. +// Power and connection state cannot be treated as current when Available is false. +type ChargerStatus struct { + Known bool `json:"known"` + Available bool `json:"available"` + UpdatedAtMs int64 `json:"updated_at_ms,omitempty"` + Reason string `json:"reason,omitempty"` + LimitA *float64 `json:"limit_a,omitempty"` +} + +// ChargerReading is what the charger's driver last reported, as far as the +// manual status needs it. Known is false when there is no reading. +type ChargerReading struct { + Known bool + UpdatedAt time.Time + Unavailable bool + LimitA float64 + LimitKnown bool + Charging bool + Reason string + Stalled bool +} + +const ( + // manualAcceptGrace is how long a charger that has taken the limit may + // sit at 0 W before the box calls it "not drawing". An Easee contactor + // plus the car's ramp takes 5–15 s; a phase flip up to 90 s. + manualAcceptGrace = 2 * time.Minute + // manualConfirmTimeout is how long the box waits for the charger to + // reflect the ordered limit at all before calling the command stalled. + manualConfirmTimeout = 3 * time.Minute + // manualChargingFloorW is the draw above which the hold counts as + // charging. Same floor the plan strip uses. + manualChargingFloorW = 100.0 +) + +// holdClampReason reports whether a commanded reason is one of the clamps +// that override an operator hold. Any other reason belongs to a tick from +// before the hold was installed and says nothing about it. +func holdClampReason(reason string) bool { + switch reason { + case "fuse_limit", "fuse_cooldown", "site_meter_stale", "charger_limit": + return true + } + return false +} + +// ManualStatusFrom derives the operator-facing status of a hold from the +// controller's hold, the loadpoint snapshot (with Phases and VoltageV set) +// and the charger's reading. Pure, so a tick-by-tick test can drive it; the +// API layer calls it on every poll. +func ManualStatusFrom(h ManualHold, held bool, st State, ch ChargerReading, now time.Time) ManualStatus { + if !held { + return ManualStatus{} + } + perA := float64(st.Phases) * st.VoltageV + toA := func(w float64) float64 { + if perA <= 0 || w <= 0 { + return 0 + } + return math.Round(w/perA*10) / 10 + } + m := ManualStatus{ + Active: true, + RequestedW: h.PowerW, + RequestedA: toA(h.PowerW), + ChargerLimitA: ch.LimitA, + ChargerLimitKnown: ch.Known && ch.LimitKnown, + ChargerReason: ch.Reason, + } + if !ch.UpdatedAt.IsZero() { + m.ChargerUpdatedAtMs = ch.UpdatedAt.UnixMilli() + } + // The ordered value is the box's last command only once a tick has run + // the hold branch; before that the snapshot still carries the previous + // automatic order, which says nothing about this hold. + clamp := st.CommandedKnown && holdClampReason(st.CommandedReason) + ordered := h.PowerW + if st.CommandedKnown && (st.CommandedReason == "manual_hold" || clamp) { + ordered = st.CommandedW + } + m.CommandedW = ordered + m.CommandedA = toA(ordered) + + since := h.StartedAt + if h.UpdatedAt.After(since) { + since = h.UpdatedAt + } + if !h.StartedAt.IsZero() { + m.StartedAtMs = h.StartedAt.UnixMilli() + } + if st.CommandedSinceMs > 0 && (st.CommandedReason == "manual_hold" || clamp) { + if t := time.UnixMilli(st.CommandedSinceMs); t.After(since) { + since = t + } + } + var elapsed time.Duration + if !since.IsZero() { + m.SinceMs = since.UnixMilli() + elapsed = now.Sub(since) + } + + commandMatches := h.UpdatedAt.IsZero() || st.ManualCommandUpdatedAt.Equal(h.UpdatedAt) + if h.PowerW == 0 { + m.State = ManualPausing + switch { + case ch.Unavailable: + m.State = ManualUnavailable + case commandMatches && st.CommandedKnown && st.CommandedReason == "manual_hold" && st.CommandedW == 0 && + !ch.UpdatedAt.IsZero() && !ch.UpdatedAt.Before(since) && + ch.Known && !ch.Charging && st.CurrentPowerW < manualChargingFloorW && + (!ch.LimitKnown || ch.LimitA < 0.1): + m.State = ManualPaused + case elapsed >= manualConfirmTimeout: + m.State = ManualStalled + } + return m + } + + limitMatches := m.ChargerLimitKnown && m.CommandedA >= 0 && math.Abs(ch.LimitA-m.CommandedA) < 1 + switch { + case ch.Unavailable: + m.State = ManualUnavailable + case !commandMatches && elapsed >= manualConfirmTimeout: + m.State = ManualStalled + case !commandMatches: + m.State = ManualSent + case ch.Known && ch.Stalled: + m.State = ManualStalled + case m.ChargerLimitKnown && !limitMatches && elapsed >= manualConfirmTimeout: + m.State = ManualStalled + case (m.ChargerLimitKnown && !limitMatches) || (!ch.UpdatedAt.IsZero() && ch.UpdatedAt.Before(since)): + m.State = ManualSent + case st.CurrentPowerW >= manualChargingFloorW || (ch.Known && ch.Charging): + m.State = ManualCharging + if clamp { + m.LimitReason = st.CommandedReason + } + case clamp: + m.State = ManualLimited + m.LimitReason = st.CommandedReason + case limitMatches && elapsed >= manualAcceptGrace: + m.State = ManualNotDrawing + case limitMatches: + m.State = ManualAccepted + case elapsed >= manualConfirmTimeout: + m.State = ManualStalled + default: + m.State = ManualSent + } + return m +} diff --git a/go/internal/loadpoint/manual_status_test.go b/go/internal/loadpoint/manual_status_test.go new file mode 100644 index 00000000..af33f6fa --- /dev/null +++ b/go/internal/loadpoint/manual_status_test.go @@ -0,0 +1,160 @@ +package loadpoint + +import ( + "testing" + "time" +) + +// The manual status follows one Charge now press on an Easee tick by tick: +// sent, taken by the charger, charging — or not drawing, stalled, limited. +// Every branch the manual tab renders is pinned here. +func TestManualStatusFrom(t *testing.T) { + t0 := time.Date(2026, 9, 5, 22, 0, 0, 0, time.UTC) + hold := ManualHold{PowerW: 11040, Persistent: true, StartedAt: t0} // 16 A × 3 × 230 V + base := State{Phases: 3, VoltageV: 230} + ordered := func(w float64, reason string, at time.Time) State { + st := base + st.CommandedW = w + st.CommandedKnown = true + st.CommandedReason = reason + st.CommandedSinceMs = at.UnixMilli() + return st + } + easee := func(limitA float64, charging bool, reason string, stalled bool) ChargerReading { + return ChargerReading{Known: true, LimitA: limitA, LimitKnown: true, Charging: charging, Reason: reason, Stalled: stalled} + } + + for _, tc := range []struct { + name string + st State + ch ChargerReading + now time.Time + wantState string + wantCmdA float64 + wantLimit string + }{ + { + name: "no reading yet, previous automatic order still in the snapshot", + st: ordered(0, "no_plan_budget", t0.Add(-time.Hour)), ch: ChargerReading{}, + now: t0.Add(2 * time.Second), wantState: ManualSent, wantCmdA: 16, + }, + { + name: "charger still shows the old limit", + st: ordered(11040, "manual_hold", t0.Add(2*time.Second)), ch: easee(6, false, "car not drawing current", false), + now: t0.Add(10 * time.Second), wantState: ManualSent, wantCmdA: 16, + }, + { + name: "charger took the limit, car has not started", + st: ordered(11040, "manual_hold", t0.Add(2*time.Second)), ch: easee(16, false, "car not drawing current", false), + now: t0.Add(20 * time.Second), wantState: ManualAccepted, wantCmdA: 16, + }, + { + name: "power flows", + st: func() State { + st := ordered(11040, "manual_hold", t0.Add(2*time.Second)) + st.CurrentPowerW = 10800 + return st + }(), ch: easee(16, true, "", false), + now: t0.Add(30 * time.Second), wantState: ManualCharging, wantCmdA: 16, + }, + { + name: "charger took the limit, car still not drawing after the grace period", + st: ordered(11040, "manual_hold", t0.Add(2*time.Second)), ch: easee(16, false, "EV not accepting current", false), + now: t0.Add(3 * time.Minute), wantState: ManualNotDrawing, wantCmdA: 16, + }, + { + name: "driver reports the command stalled", + st: ordered(11040, "manual_hold", t0.Add(2*time.Second)), ch: easee(16, false, "EV not accepting current", true), + now: t0.Add(45 * time.Second), wantState: ManualStalled, wantCmdA: 16, + }, + { + name: "charger never reflected the limit", + st: ordered(11040, "manual_hold", t0.Add(2*time.Second)), ch: easee(6, false, "", false), + now: t0.Add(4 * time.Minute), wantState: ManualStalled, wantCmdA: 16, + }, + { + name: "main fuse clamps the hold", + st: ordered(6900, "fuse_limit", t0.Add(40*time.Second)), ch: easee(10, false, "", false), + now: t0.Add(50 * time.Second), wantState: ManualLimited, wantCmdA: 10, wantLimit: "fuse_limit", + }, + { + name: "fuse cooldown pauses the hold", + st: ordered(0, "fuse_cooldown", t0.Add(40*time.Second)), ch: easee(0, false, "", false), + now: t0.Add(50 * time.Second), wantState: ManualLimited, wantCmdA: 0, wantLimit: "fuse_cooldown", + }, + { + name: "a charger without a limit reading is only ever sent or charging", + st: ordered(11040, "manual_hold", t0.Add(2*time.Second)), ch: ChargerReading{Known: true}, + now: t0.Add(time.Minute), wantState: ManualSent, wantCmdA: 16, + }, + } { + t.Run(tc.name, func(t *testing.T) { + got := ManualStatusFrom(hold, true, tc.st, tc.ch, tc.now) + if !got.Active { + t.Fatal("status must be active while a hold is held") + } + if got.State != tc.wantState { + t.Errorf("state = %q, want %q (%+v)", got.State, tc.wantState, got) + } + if got.CommandedA != tc.wantCmdA { + t.Errorf("commanded_a = %v, want %v", got.CommandedA, tc.wantCmdA) + } + if got.LimitReason != tc.wantLimit { + t.Errorf("limit_reason = %q, want %q", got.LimitReason, tc.wantLimit) + } + if got.RequestedA != 16 || got.RequestedW != 11040 { + t.Errorf("requested = %v A / %v W, want 16 A / 11040 W", got.RequestedA, got.RequestedW) + } + if got.StartedAtMs != t0.UnixMilli() { + t.Errorf("started_at_ms = %d, want %d", got.StartedAtMs, t0.UnixMilli()) + } + if tc.ch.Reason != "" && got.ChargerReason != tc.ch.Reason { + t.Errorf("charger_reason = %q, want %q", got.ChargerReason, tc.ch.Reason) + } + }) + } +} + +func TestManualStatusFrom_SinceFollowsTheLatestOrder(t *testing.T) { + t0 := time.Date(2026, 9, 5, 22, 0, 0, 0, time.UTC) + hold := ManualHold{PowerW: 11040, Persistent: true, StartedAt: t0} + st := State{Phases: 3, VoltageV: 230, CommandedW: 11040, CommandedKnown: true, CommandedReason: "manual_hold", + CommandedSinceMs: t0.Add(30 * time.Second).UnixMilli()} + got := ManualStatusFrom(hold, true, st, ChargerReading{}, t0.Add(time.Minute)) + if got.SinceMs != t0.Add(30*time.Second).UnixMilli() { + t.Errorf("since_ms = %d, want the order's time", got.SinceMs) + } + // An automatic order from before the hold does not move "since". + st.CommandedReason = "no_plan_budget" + st.CommandedSinceMs = t0.Add(-time.Hour).UnixMilli() + got = ManualStatusFrom(hold, true, st, ChargerReading{}, t0.Add(time.Minute)) + if got.SinceMs != t0.UnixMilli() { + t.Errorf("since_ms = %d, want the hold's start", got.SinceMs) + } +} + +func TestManualStatusFrom_InactiveIsEmpty(t *testing.T) { + got := ManualStatusFrom(ManualHold{}, false, State{}, ChargerReading{}, time.Now()) + if got != (ManualStatus{}) { + t.Errorf("inactive status must be the zero value, got %+v", got) + } +} + +func TestManualStatusUnavailableDoesNotReuseChargingPower(t *testing.T) { + now := time.Now() + hold := ManualHold{PowerW: 11040, Persistent: true, StartedAt: now.Add(-time.Minute)} + st := State{Phases: 3, VoltageV: 230, CurrentPowerW: 10800} + reading := ChargerReading{Known: true, Unavailable: true, Charging: true, UpdatedAt: now.Add(-5 * time.Minute)} + got := ManualStatusFrom(hold, true, st, reading, now) + if got.State != ManualUnavailable { + t.Fatalf("old power became current charging: %+v", got) + } + if got.ChargerUpdatedAtMs != reading.UpdatedAt.UnixMilli() { + t.Fatalf("missing age: %+v", got) + } + reading.Unavailable = false + reading.UpdatedAt = now + if got := ManualStatusFrom(hold, true, st, reading, now); got.State != ManualCharging { + t.Fatalf("did not recover: %+v", got) + } +} diff --git a/go/internal/loadpoint/manual_unbound_failures_test.go b/go/internal/loadpoint/manual_unbound_failures_test.go new file mode 100644 index 00000000..1bd3465f --- /dev/null +++ b/go/internal/loadpoint/manual_unbound_failures_test.go @@ -0,0 +1,166 @@ +package loadpoint + +import ( + "errors" + "fmt" + "testing" + "time" +) + +type manualIntentCrash struct{} + +// Each SaveConfig is one durable write. A crash happens after that write, +// before its caller can write the next key or acknowledge the action. +type manualIntentCrashStore struct { + data map[string]string + fail bool + writes int + crashAfter int +} + +func (s *manualIntentCrashStore) LoadConfig(key string) (string, bool) { + v, ok := s.data[key] + return v, ok +} + +func (s *manualIntentCrashStore) SaveConfig(key, value string) error { + if s.fail { + return errors.New("disk unavailable") + } + s.data[key] = value + s.writes++ + if s.crashAfter > 0 && s.writes == s.crashAfter { + panic(manualIntentCrash{}) + } + return nil +} + +func runManualIntentUntilCrash(t *testing.T, action func() error) { + t.Helper() + defer func() { + if _, ok := recover().(manualIntentCrash); !ok { + t.Fatal("action did not stop at the selected durable write") + } + }() + if err := action(); err != nil { + t.Fatalf("unexpected storage error: %v", err) + } +} + +func TestManualIntentSurvivesEveryWriteBoundary(t *testing.T) { + for _, device := range []string{"", "easee:A"} { + for _, clear := range []bool{false, true} { + name := "clear_to_pause" + if clear { + name = "pause_to_clear" + } + t.Run(fmt.Sprintf("%s/device=%s", name, device), func(t *testing.T) { + prepare := func() (*Manager, *manualIntentCrashStore) { + store := &manualIntentCrashStore{data: map[string]string{ + "loadpoint_manual_hold:garage": `{"PowerW":11000,"Persistent":true}`, + "ev_manual_clear:garage": "pending", + }} + m := sessionManager(store, "garage", "charger") + m.ObserveSession("garage", true, 0, 1000, true, device, "") + if err := m.PersistManualHold("garage", ManualHold{Persistent: true}, !clear); err != nil { + t.Fatal(err) + } + store.writes = 0 + return m, store + } + m, store := prepare() + if err := m.PersistManualHold("garage", ManualHold{Persistent: true}, clear); err != nil { + t.Fatal(err) + } + boundaries := store.writes + if boundaries == 0 { + t.Fatal("explicit action made no durable write") + } + for cut := 1; cut <= boundaries; cut++ { + t.Run(fmt.Sprintf("after_write_%d", cut), func(t *testing.T) { + m, store := prepare() + store.crashAfter = cut + runManualIntentUntilCrash(t, func() error { + return m.PersistManualHold("garage", ManualHold{Persistent: true}, clear) + }) + store.crashAfter = 0 + // Reboot twice: restore may finish cleanup, but neither the + // old clear nor the legacy positive hold may return later. + for boot := 1; boot <= 2; boot++ { + restarted := sessionManager(store, "garage", "charger") + restarted.ObserveSession("garage", true, 0, 1000, true, device, "") + h, status := restarted.RestoreManualHold("garage") + if clear { + if status != "none" || h.Persistent { + t.Fatalf("boot %d: old hold returned after clear: %+v %s", boot, h, status) + } + } else if status != "restored" || !h.Persistent || h.PowerW != 0 { + t.Fatalf("boot %d: latest pause lost: %+v %s", boot, h, status) + } + } + }) + } + }) + } + } +} + +func TestManualIntentRetriesWithoutHardwareIdentity(t *testing.T) { + for _, action := range []string{"pause", "start", "clear"} { + t.Run(action, func(t *testing.T) { + store := &manualIntentCrashStore{data: map[string]string{}} + m := sessionManager(store, "garage", "charger") + m.ObserveSession("garage", true, 0, 1000, true, "", "") + c := NewController(m, nil, nil, nil) + var saveErr error + c.SetManualHoldSaver(func(id string, h ManualHold, cleared bool) { + saveErr = m.PersistManualHold(id, h, cleared) + }) + if action == "clear" { + c.SetManualHold("garage", ManualHold{Persistent: true}) + if saveErr != nil { + t.Fatal(saveErr) + } + } + store.fail = true + if action == "clear" { + c.ClearManualHold("garage") + } else { + watts := 0.0 + if action == "start" { + watts = 4140 + } + c.SetManualHold("garage", ManualHold{Persistent: true, PowerW: watts}) + } + if saveErr == nil { + t.Fatal("failed intent write was not reported") + } + if st, _ := m.State("garage"); !st.ManualSaveError { + t.Fatal("failed intent write was not visible") + } + // Serial-less OCPP samples must be enough to retry the restriction. + // The operator does not need to repeat the action after disk recovery. + store.fail = false + m.ObserveSession("garage", true, 0, 1000, true, "", "") + if st, _ := m.State("garage"); st.ManualSaveError { + t.Fatal("successful retry left the save warning set") + } + restarted := sessionManager(store, "garage", "charger") + restarted.ObserveSession("garage", true, 0, 1000, true, "", "") + restored := NewController(restarted, nil, nil, nil) + restored.restoreManualHoldForSession("garage") + h, held := restored.GetManualHold("garage", time.Now()) + if action == "clear" { + if held { + t.Fatalf("cleared hold returned after retry and reboot: %+v", h) + } + } else if !held || !h.Persistent || h.PowerW != 0 { + t.Fatalf("retry did not preserve the zero-power restriction: %+v %v", h, held) + } + st, _ := restarted.State("garage") + if st.ManualRestoreUnconfirmed != (action == "start") || st.ManualSaveError { + t.Fatalf("wrong restore feedback after retry: %+v", st) + } + }) + } +} diff --git a/go/internal/loadpoint/manual_unbound_state_test.go b/go/internal/loadpoint/manual_unbound_state_test.go new file mode 100644 index 00000000..da51da1f --- /dev/null +++ b/go/internal/loadpoint/manual_unbound_state_test.go @@ -0,0 +1,53 @@ +package loadpoint + +import ( + "testing" + "time" +) + +func TestManualRestrictionSurvivesRestartWithoutHardwareIdentity(t *testing.T) { + for _, device := range []string{"", "ep:ocpp://garage", "easee:ABC"} { + for _, watts := range []float64{0, 4140} { + store := &sessionMemory{data: map[string]string{}} + m := sessionManager(store, "garage", "charger") + m.ObserveSession("garage", true, 0, 1000, true, device, "") + if err := m.PersistManualHold("garage", ManualHold{Persistent: true, PowerW: watts}, false); err != nil { + t.Fatal(err) + } + restarted := sessionManager(store, "garage", "charger") + restarted.ObserveSession("garage", true, 0, 1000, true, device, "") + c := NewController(restarted, nil, nil, nil) + c.restoreManualHoldForSession("garage") + if h, ok := c.GetManualHold("garage", time.Now()); !ok || !h.Persistent || h.PowerW != 0 { + t.Fatalf("%q/%v: restart lost restriction: %+v %v", device, watts, h, ok) + } + st, _ := restarted.State("garage") + if st.ManualRestoreUnconfirmed != (watts > 0) { + t.Fatalf("%q/%v: confirmation=%v", device, watts, st.ManualRestoreUnconfirmed) + } + if err := restarted.PersistManualHold("garage", ManualHold{}, true); err != nil { + t.Fatal(err) + } + afterClear := sessionManager(store, "garage", "charger") + afterClear.ObserveSession("garage", true, 0, 1000, true, device, "") + if h, status := afterClear.RestoreManualHold("garage"); status != "none" || h.Persistent { + t.Fatalf("clear did not survive restart: %+v %s", h, status) + } + } + } +} + +func TestNewPauseSupersedesClearBeforeHardwareArrives(t *testing.T) { + store := &sessionMemory{data: map[string]string{}} + m := sessionManager(store, "garage", "charger") + if err := m.PersistManualHold("garage", ManualHold{}, true); err != nil { + t.Fatal(err) + } + if err := m.PersistManualHold("garage", ManualHold{Persistent: true}, false); err != nil { + t.Fatal(err) + } + restarted := sessionManager(store, "garage", "charger") + if h, status := restarted.RestoreManualHold("garage"); status != "restored" || !h.Persistent || h.PowerW != 0 { + t.Fatalf("old clear overwrote new pause: %+v %s", h, status) + } +} diff --git a/go/internal/loadpoint/schedule_remove_goal_test.go b/go/internal/loadpoint/schedule_remove_goal_test.go new file mode 100644 index 00000000..1865a6ac --- /dev/null +++ b/go/internal/loadpoint/schedule_remove_goal_test.go @@ -0,0 +1,40 @@ +package loadpoint + +import ( + "context" + "testing" + "time" +) + +func TestRemoveScheduleClearsDerivedGoalButKeepsManualCharge(t *testing.T) { + now := time.Date(2026, 9, 6, 20, 0, 0, 0, time.UTC) + cfg := holdLoadpoint() + m := NewManager() + m.Load([]Config{cfg}) + m.SetSchedule(cfg.ID, Schedule{SoC: .8, TimeOfDayMinUTC: 420}) + m.RollSchedules(now) + before, _ := m.State(cfg.ID) + if before.TargetSoC != .8 || before.TargetTime.IsZero() { + t.Fatal("test needs a rolled goal") + } + var saved Schedule + m.SetScheduleSaver(func(_ string, s Schedule) error { saved = s; return nil }) + sender := &fakeSender{} + c := NewController(m, func(time.Time) (Directive, bool) { return Directive{}, false }, func(string) (EVSample, bool) { return EVSample{Connected: true, RequestActive: true}, true }, sender.Send) + c.SetManualHold(cfg.ID, ManualHold{PowerW: 4140, PhaseMode: "3p", Persistent: true}) + if !m.ClearSchedule(cfg.ID) { + t.Fatal("goal removal failed") + } + m.RollSchedules(now.Add(time.Second)) + after, _ := m.State(cfg.ID) + if !after.Schedule.Empty() || after.TargetSoC != 0 || !after.TargetTime.IsZero() || !saved.Empty() { + t.Fatalf("removed goal still affects planning: %+v", after) + } + c.Tick(context.Background(), now.Add(time.Second)) + if _, ok := c.GetManualHold(cfg.ID, now); !ok { + t.Fatal("removing a goal ended manual charging") + } + if len(sender.calls) != 1 || sender.calls[0].power != 4140 { + t.Fatalf("manual charge changed: %+v", sender.calls) + } +} diff --git a/go/internal/loadpoint/schedule_save_test.go b/go/internal/loadpoint/schedule_save_test.go new file mode 100644 index 00000000..b3dac180 --- /dev/null +++ b/go/internal/loadpoint/schedule_save_test.go @@ -0,0 +1,85 @@ +package loadpoint + +import ( + "errors" + "testing" + "time" +) + +func TestScheduleCheckedReportsStorageFailureWithoutChangingGoal(t *testing.T) { + m := NewManager() + m.Load([]Config{{ID: "garage", DriverName: "easee"}}) + old := Schedule{SoC: .8, TimeOfDayMinUTC: 420, Recurring: true} + m.SetSchedule("garage", old) + m.RollSchedules(time.Now()) + before, _ := m.State("garage") + failure := errors.New("disk full") + m.SetScheduleSaver(func(string, Schedule) error { return failure }) + if ok, err := m.SetScheduleChecked("garage", Schedule{SoC: .9}); !ok || !errors.Is(err, failure) { + t.Fatalf("set = %v, %v", ok, err) + } + if ok, err := m.ClearScheduleChecked("garage"); !ok || !errors.Is(err, failure) { + t.Fatalf("clear = %v, %v", ok, err) + } + if m.SetSchedule("garage", Schedule{SoC: .9}) || m.ClearSchedule("garage") { + t.Fatal("compatibility wrapper reported a failed save as successful") + } + if ok, err := m.SetScheduleChecked("missing", old); ok || err != nil { + t.Fatalf("unknown ID = %v, %v", ok, err) + } + m.RollSchedules(time.Now()) + after, _ := m.State("garage") + if after.Schedule != old || after.TargetSoC != before.TargetSoC || after.TargetTime != before.TargetTime { + t.Fatalf("failed save changed the running goal: %+v", after) + } +} + +func TestScheduleWritesKeepOrderAndReadersSeeOldGoalDuringSave(t *testing.T) { + m := NewManager() + m.Load([]Config{{ID: "garage", DriverName: "easee"}}) + m.SetSchedule("garage", Schedule{SoC: .8, TimeOfDayMinUTC: 420, Recurring: true}) + m.RollSchedules(time.Now()) + before, _ := m.State("garage") + entered, release := make(chan struct{}), make(chan struct{}) + setDone, clearDone := make(chan error, 1), make(chan error, 1) + var writes []Schedule + m.SetScheduleSaver(func(_ string, s Schedule) error { + if !s.Empty() { + close(entered) + <-release + } + writes = append(writes, s) + return nil + }) + go func() { _, err := m.SetScheduleChecked("garage", Schedule{SoC: .9}); setDone <- err }() + <-entered + go func() { _, err := m.ClearScheduleChecked("garage"); clearDone <- err }() + select { + case err := <-clearDone: + t.Fatalf("Clear overtook an earlier pending save: %v", err) + case <-time.After(20 * time.Millisecond): + } + readDone := make(chan State, 1) + go func() { s, _ := m.State("garage"); readDone <- s }() + select { + case s := <-readDone: + if s.Schedule != before.Schedule || s.TargetSoC != before.TargetSoC || s.TargetTime != before.TargetTime { + t.Fatalf("new goal appeared before storage completed: %+v", s) + } + case <-time.After(time.Second): + t.Fatal("disk write blocked state reads") + } + close(release) + if err := <-setDone; err != nil { + t.Fatal(err) + } + if err := <-clearDone; err != nil { + t.Fatal(err) + } + if len(writes) != 2 || writes[0].SoC != .9 || !writes[1].Empty() { + t.Fatalf("save order = %+v", writes) + } + if s, _ := m.State("garage"); !s.Schedule.Empty() || s.TargetSoC != 0 || !s.TargetTime.IsZero() { + t.Fatalf("Clear lost to an older save: %+v", s) + } +} diff --git a/go/internal/loadpoint/session_events_test.go b/go/internal/loadpoint/session_events_test.go index 52e4cab0..232492d3 100644 --- a/go/internal/loadpoint/session_events_test.go +++ b/go/internal/loadpoint/session_events_test.go @@ -81,6 +81,10 @@ func TestSessionCompletePublishesOnceWithSessionKWh(t *testing.T) { t.Fatal("latched before the timeout") } r.tick(SessionCompletionTimeout, true, 0, 7_420, false) + if c, _ := r.log.counts(); c != 0 { + t.Fatal("refusal invented a completed goal") + } + r.mgr.AnchorVehicleSoC("garage", .8) if c, _ := r.log.counts(); c != 1 { t.Fatalf("complete events = %d, want 1", c) } @@ -102,6 +106,7 @@ func TestSessionCompletePublishesOnceWithSessionKWh(t *testing.T) { r.tick(time.Minute, true, 11_000, 0, true) r.tick(time.Minute, true, 0, 500, false) r.tick(SessionCompletionTimeout, true, 0, 500, false) + r.mgr.AnchorVehicleSoC("garage", .8) if c, _ := r.log.counts(); c != 2 { t.Fatalf("complete events = %d after replug, want 2", c) } @@ -189,8 +194,8 @@ func TestVehicleDeclineIsNotAnInterruption(t *testing.T) { r.tick(time.Second, true, 0, 8_000, false) r.tick(interruptConfirm+SessionCompletionTimeout, true, 0, 8_000, false) c, i := r.log.counts() - if c != 1 { - t.Fatalf("complete events = %d, want 1", c) + if c != 0 { + t.Fatalf("complete events = %d, want 0", c) } if i != 0 { t.Fatalf("interrupted events = %d for a finished car, want 0", i) diff --git a/go/internal/loadpoint/session_sqlite_test.go b/go/internal/loadpoint/session_sqlite_test.go new file mode 100644 index 00000000..3320bfe5 --- /dev/null +++ b/go/internal/loadpoint/session_sqlite_test.go @@ -0,0 +1,51 @@ +package loadpoint_test + +import ( + "fmt" + "math" + "path/filepath" + "testing" + + "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/state" +) + +func TestConfirmedBatteryLevelSurvivesDatabaseCloseAndReopen(t *testing.T) { + for _, capacityWh := range []float64{60000, 100000} { + t.Run(fmt.Sprintf("capacity_%.0f", capacityWh), func(t *testing.T) { + path := filepath.Join(t.TempDir(), "state.db") + store, err := state.Open(path) + if err != nil { + t.Fatal(err) + } + cfg := []loadpoint.Config{{ID: "garage", DriverName: "charger", VehicleCapacityWh: 60000}} + m := loadpoint.NewManager() + m.Load(cfg) + m.SetSessionStore(store) + m.ObserveSession("garage", true, 4300, 9000, true, "easee:TEST", "728:2026-01-01T08:00:00Z") + if !m.SetCurrentSoC("garage", .84) { + t.Fatal("level refused") + } + // The capacity endpoint applies its saved config through Manager.Load. + // A correction must rewrite the durable anchor before the next restart. + cfg[0].VehicleCapacityWh = capacityWh + m.Load(cfg) + if err := store.Close(); err != nil { + t.Fatal(err) + } + store, err = state.Open(path) + if err != nil { + t.Fatal(err) + } + defer store.Close() + m = loadpoint.NewManager() + m.Load(cfg) + m.SetSessionStore(store) + m.ObserveSession("garage", true, 4300, 9600, true, "easee:TEST", "728:2026-01-01T08:00:00Z") + s, _ := m.State("garage") + if math.Abs(s.CurrentSoC-(.84+600/capacityWh)) > 1e-9 || s.SoCSource == "assumed" || s.SoCRetention != "session" { + t.Fatalf("restart did not retain level: %+v", s) + } + }) + } +} diff --git a/go/internal/loadpoint/session_state.go b/go/internal/loadpoint/session_state.go new file mode 100644 index 00000000..c4817c21 --- /dev/null +++ b/go/internal/loadpoint/session_state.go @@ -0,0 +1,233 @@ +package loadpoint + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "math" + "strings" + "time" + + "github.com/srcfl/ftw/go/internal/events" + "github.com/srcfl/ftw/go/internal/units" +) + +// SessionStore is implemented by state.Store. The charger hardware identity, +// never a loadpoint or driver name, keys the saved record. +type SessionStore interface { + LoadConfig(key string) (string, bool) + SaveConfig(key, value string) error +} + +type savedSession struct { + Version int `json:"version"` + DeviceID string `json:"device_id"` + SessionID string `json:"session_id"` + AnchorSoC float64 `json:"anchor_soc"` + ConfirmedAtWh float64 `json:"confirmed_at_wh"` + CapacityWh float64 `json:"capacity_wh"` + CompletionNotified bool `json:"completion_notified,omitempty"` +} + +func sessionKey(deviceID string) string { + h := sha256.Sum256([]byte(deviceID)) + return "ev_session:" + hex.EncodeToString(h[:]) +} + +func finite(v float64) bool { return !math.IsNaN(v) && !math.IsInf(v, 0) } + +// SetSessionStore wires durable storage before the controller starts. A +// missing store or hardware session identity leaves SoC usable in memory and +// reports soc_retention=unavailable; it never guesses a prior car's level. +func (m *Manager) SetSessionStore(store SessionStore) { + m.sessionMu.Lock() + defer m.sessionMu.Unlock() + m.sessionStore = store +} + +// ObserveSession accepts the same reading as Observe plus hardware-issued +// identity. The caller must use fresh telemetry from the currently running +// device. SessionID must identify one physical connection across process +// restart and change after disconnect; missing or ambiguous IDs are empty. +// Endpoint addresses, YAML names and timestamps invented by core are not IDs. +func (m *Manager) ObserveSession(id string, pluggedIn bool, powerW, deliveredWh float64, requestActive bool, deviceID, sessionID string) { + m.sessionMu.Lock() + var fired []events.Event + var bus *events.Bus + defer func() { + m.sessionMu.Unlock() + for _, event := range fired { + bus.Publish(event) + } + }() + deviceID, sessionID = strings.TrimSpace(deviceID), strings.TrimSpace(sessionID) + // Endpoint identity can move to another charger without its name changing. + if strings.HasPrefix(deviceID, "ep:") { + deviceID = "" + } + if !finite(deliveredWh) || deliveredWh < 0 { + return + } + + m.mu.Lock() + lp := m.byID[id] + if lp == nil { + m.mu.Unlock() + return + } + previousDevice, previousSession := lp.sessionDeviceID, lp.sessionID + regressed := pluggedIn && lp.pluggedIn && deliveredWh < lp.deliveredWhSession + firstSessionProof := deviceID != "" && previousDevice == deviceID && previousSession == "" && sessionID != "" && + lp.pluggedIn && pluggedIn && !regressed + changed := previousDevice != deviceID || (previousSession != sessionID && !firstSessionProof) + // A changed session can arrive after an unseen unplug while core was + // offline. Run the ordinary plug-in reset even if connected stayed true. + if changed || regressed { + m.nextSessionGeneration++ + lp.sessionGeneration = m.nextSessionGeneration + lp.pluggedIn = false + lp.chargingSteadySince = time.Time{} + lp.stoppedSince = time.Time{} + lp.steadyRunArmed = false + if lp.vehicleName != "" || lp.capacityFromCar { + lp.VehicleCapacityWh = lp.baseCapacityWh + lp.vehicleName = "" + lp.capacityFromCar = false + lp.baseCapacityWh = 0 + } + } + lp.sessionDeviceID, lp.sessionID = deviceID, sessionID + confirmed := lp.socConfirmed && lp.pluggedIn + m.mu.Unlock() + + if !pluggedIn || regressed { + // Tombstone the hardware record. A later reconnect cannot resurrect a + // level from before an observed unplug or a session-counter reset. + if m.sessionStore != nil { + for _, knownDevice := range []string{previousDevice, deviceID} { + if knownDevice != "" { + _ = m.sessionStore.SaveConfig(sessionKey(knownDevice), "{}") + } + } + } + } + var restore *savedSession + if pluggedIn && !confirmed && !regressed && deviceID != "" && sessionID != "" && m.sessionStore != nil { + if raw, ok := m.sessionStore.LoadConfig(sessionKey(deviceID)); ok { + var saved savedSession + if json.Unmarshal([]byte(raw), &saved) == nil && saved.Version == 1 && + saved.DeviceID == deviceID && saved.SessionID == sessionID && + finite(saved.AnchorSoC) && finite(saved.ConfirmedAtWh) && saved.ConfirmedAtWh >= 0 && + deliveredWh >= saved.ConfirmedAtWh && finite(saved.CapacityWh) && saved.CapacityWh > 0 { + atConfirmation := saved.AnchorSoC + saved.ConfirmedAtWh/saved.CapacityWh + if atConfirmation >= 0 && atConfirmation <= 1 { + restore = &saved + } + } + } + } + fired, bus = m.observe(id, pluggedIn, powerW, deliveredWh, requestActive) + m.mu.Lock() + lp = m.byID[id] + if restore != nil && lp.pluggedIn && lp.VehicleCapacityWh == restore.CapacityWh { + lp.sessionPluginSoC = restore.AnchorSoC + lp.currentSoC = estimateSoC(restore.AnchorSoC, deliveredWh, restore.CapacityWh) + lp.socConfirmed = true + lp.completionNotified = restore.CompletionNotified + lp.socRetention = "session" + } else if !lp.socConfirmed || deviceID == "" || sessionID == "" || m.sessionStore == nil { + lp.socRetention = "unavailable" + } + m.mu.Unlock() + if firstSessionProof && confirmed { + // The driver can first verify a session when charging starts. Preserve + // the level the owner entered while waiting and now make it durable. + m.persistSession(id) + } + _ = m.flushManualHold(id) +} + +// persistSession runs outside Manager.mu, but sessionMu serializes it with +// observations, unplug and user edits. A slow write cannot block API reads or +// allow an older edit to overwrite a newer one. +func (m *Manager) persistSession(id string) { + m.mu.RLock() + lp := m.byID[id] + if lp == nil { + m.mu.RUnlock() + return + } + record := savedSession{Version: 1, DeviceID: lp.sessionDeviceID, SessionID: lp.sessionID, + AnchorSoC: lp.sessionPluginSoC, ConfirmedAtWh: lp.deliveredWhSession, + CapacityWh: lp.VehicleCapacityWh, CompletionNotified: lp.completionNotified} + eligible := lp.pluggedIn && lp.socConfirmed && record.DeviceID != "" && record.SessionID != "" && + finite(record.AnchorSoC) && finite(record.ConfirmedAtWh) && record.ConfirmedAtWh >= 0 && + finite(record.CapacityWh) && record.CapacityWh > 0 + m.mu.RUnlock() + retention := "unavailable" + if eligible && m.sessionStore != nil { + b, err := json.Marshal(record) + if err == nil { + err = m.sessionStore.SaveConfig(sessionKey(record.DeviceID), string(b)) + } + retention = "session" + if err != nil { + retention = "error" + } + } + m.mu.Lock() + if lp := m.byID[id]; lp != nil { + lp.socRetention = retention + } + m.mu.Unlock() +} + +// observeConnectionProof handles a lost socket without inventing a cable +// edge or refreshing the last physical observation. A fresh Boot alone must +// not let an older manual Start follow an unidentified connection. +func (m *Manager) observeConnectionProof(id string, generation uint64, unknown bool) { + m.sessionMu.Lock() + defer m.sessionMu.Unlock() + m.mu.Lock() + lp := m.byID[id] + if lp == nil { + m.mu.Unlock() + return + } + changed := generation != 0 && lp.connectionGeneration != 0 && generation != lp.connectionGeneration + if generation != 0 { + lp.connectionGeneration = generation + } + if (!unknown && !changed) || (!changed && lp.sessionDeviceID == "" && lp.sessionID == "") { + m.mu.Unlock() + return + } + previousDevice := lp.sessionDeviceID + lp.sessionDeviceID, lp.sessionID = "", "" + m.nextSessionGeneration++ + lp.sessionGeneration = m.nextSessionGeneration + lp.socConfirmed = false + lp.socRetention = "unavailable" + if lp.vehicleName != "" || lp.capacityFromCar { + lp.VehicleCapacityWh = lp.baseCapacityWh + lp.vehicleName = "" + lp.capacityFromCar = false + lp.baseCapacityWh = 0 + } + anchor := lp.PluginSoC + if anchor <= 0 { + anchor = units.DefaultPluginSoC + } + lp.sessionPluginSoC = anchor + lp.currentSoC = estimateSoC(anchor, lp.deliveredWhSession, lp.VehicleCapacityWh) + lp.chargingSteadySince = time.Time{} + lp.stoppedSince = time.Time{} + lp.steadyRunArmed = false + lp.notRequestingSince = time.Time{} + lp.chargingDeclined = false + lp.completionNotified = false + m.mu.Unlock() + if m.sessionStore != nil && previousDevice != "" { + _ = m.sessionStore.SaveConfig(sessionKey(previousDevice), "{}") + } +} diff --git a/go/internal/loadpoint/session_state_test.go b/go/internal/loadpoint/session_state_test.go new file mode 100644 index 00000000..3af052e4 --- /dev/null +++ b/go/internal/loadpoint/session_state_test.go @@ -0,0 +1,195 @@ +package loadpoint + +import ( + "errors" + "math" + "testing" +) + +type sessionMemory struct { + data map[string]string + fail bool +} + +func (s *sessionMemory) LoadConfig(k string) (string, bool) { v, ok := s.data[k]; return v, ok } +func (s *sessionMemory) SaveConfig(k, v string) error { + if s.fail { + return errors.New("disk full") + } + s.data[k] = v + return nil +} +func sessionManager(store SessionStore, id, driver string) *Manager { + m := NewManager() + m.Load([]Config{{ID: id, DriverName: driver, VehicleCapacityWh: 60000}}) + m.SetSessionStore(store) + return m +} +func TestConfirmedSoCSurvivesRestartOnlyForSameHardwareSession(t *testing.T) { + store := &sessionMemory{data: map[string]string{}} + m := sessionManager(store, "garage", "charger") + m.ObserveSession("garage", true, 4300, 9000, true, "easee:ABC", "connection-1") + m.SetCurrentSoC("garage", .84) + if s, _ := m.State("garage"); s.SoCRetention != "session" { + t.Fatalf("not saved: %+v", s) + } + // Renaming the configured driver and loadpoint does not change hardware. + m = sessionManager(store, "renamed", "renamed-driver") + m.ObserveSession("renamed", true, 4300, 9600, true, "easee:ABC", "connection-1") + if s, _ := m.State("renamed"); math.Abs(s.CurrentSoC-.85) > 1e-9 || s.SoCSource == "assumed" { + t.Fatalf("same session failed to restore: %+v", s) + } + for _, tc := range []struct { + name, device, session string + wh float64 + }{ + {"new session", "easee:ABC", "connection-2", 9600}, + {"new charger", "easee:OTHER", "connection-1", 9600}, + {"no session", "easee:ABC", "", 9600}, + {"endpoint identity", "ep:192.168.1.40", "connection-1", 9600}, + {"counter reset", "easee:ABC", "connection-1", 500}, + } { + t.Run(tc.name, func(t *testing.T) { + m := sessionManager(store, "garage", "charger") + m.ObserveSession("garage", true, 0, tc.wh, true, tc.device, tc.session) + if s, _ := m.State("garage"); s.SoCSource != "assumed" { + t.Fatalf("restored wrong car: %+v", s) + } + }) + } +} + +func TestConfirmedSoCSurvivesFirstSessionProofWhenChargingStarts(t *testing.T) { + store := &sessionMemory{data: map[string]string{}} + m := sessionManager(store, "garage", "charger") + m.ObserveSession("garage", true, 0, 0, true, "easee:A", "") + m.SetCurrentSoC("garage", .12) + if s, _ := m.State("garage"); s.SoCRetention != "unavailable" { + t.Fatalf("unverified session was saved: %+v", s) + } + m.ObserveSession("garage", true, 4300, 600, true, "easee:A", "session-1") + if s, _ := m.State("garage"); math.Abs(s.CurrentSoC-.13) > 1e-9 || s.SoCSource == "assumed" || s.SoCRetention != "session" { + t.Fatalf("charging lost the level entered while waiting: %+v", s) + } + m = sessionManager(store, "garage", "charger") + m.ObserveSession("garage", true, 4300, 600, true, "easee:A", "session-1") + if s, _ := m.State("garage"); math.Abs(s.CurrentSoC-.13) > 1e-9 || s.SoCSource == "assumed" { + t.Fatalf("verified level did not survive restart: %+v", s) + } +} + +func TestFirstSessionProofDoesNotCrossConnectionChanges(t *testing.T) { + for _, change := range []string{"unplug", "hardware", "counter_regression"} { + t.Run(change, func(t *testing.T) { + m := sessionManager(&sessionMemory{data: map[string]string{}}, "garage", "charger") + m.ObserveSession("garage", true, 0, 1000, true, "easee:A", "") + m.SetCurrentSoC("garage", .12) + device, wh := "easee:A", 1600.0 + switch change { + case "unplug": + m.ObserveSession("garage", false, 0, 1000, false, "easee:A", "") + case "hardware": + device = "easee:B" + case "counter_regression": + wh = 500 + } + m.ObserveSession("garage", true, 4300, wh, true, device, "session-1") + if s, _ := m.State("garage"); s.SoCSource != "assumed" { + t.Fatalf("level followed %s: %+v", change, s) + } + }) + } +} + +func TestUnseenReconnectResetsConfirmedSoC(t *testing.T) { + store := &sessionMemory{data: map[string]string{}} + m := sessionManager(store, "garage", "charger") + m.ObserveSession("garage", true, 4300, 9000, true, "easee:ABC", "session-1") + m.SetCurrentSoC("garage", .84) + // A charger returns after an outage. No unplug sample reached core. + m.ObserveSession("garage", true, 4300, 12000, true, "easee:ABC", "session-2") + if s, _ := m.State("garage"); s.SoCSource != "assumed" { + t.Fatalf("previous car level leaked: %+v", s) + } +} +func TestObservedUnplugErasesConfirmedSoC(t *testing.T) { + store := &sessionMemory{data: map[string]string{}} + m := sessionManager(store, "garage", "charger") + m.ObserveSession("garage", true, 4300, 9000, true, "easee:ABC", "session-1") + m.SetCurrentSoC("garage", .84) + m.ObserveSession("garage", false, 0, 0, false, "easee:ABC", "") + m = sessionManager(store, "garage", "charger") + m.ObserveSession("garage", true, 0, 9000, true, "easee:ABC", "session-1") + if s, _ := m.State("garage"); s.SoCSource != "assumed" { + t.Fatalf("unplug resurrected old level: %+v", s) + } +} +func TestSessionSaveFailureIsVisibleAndRetryable(t *testing.T) { + store := &sessionMemory{data: map[string]string{}, fail: true} + m := sessionManager(store, "garage", "charger") + m.ObserveSession("garage", true, 4300, 9000, true, "easee:ABC", "session-1") + m.SetCurrentSoC("garage", .84) + if s, _ := m.State("garage"); s.SoCRetention != "error" || math.Abs(s.CurrentSoC-.84) > 1e-9 { + t.Fatalf("failed write hidden: %+v", s) + } + store.fail = false + m.SetCurrentSoC("garage", .84) + if s, _ := m.State("garage"); s.SoCRetention != "session" { + t.Fatalf("retry failed: %+v", s) + } +} +func TestUnknownSessionKeepsManualLevelOnlyInMemory(t *testing.T) { + m := sessionManager(&sessionMemory{data: map[string]string{}}, "garage", "charger") + m.Observe("garage", true, 4300, 9000, true) + m.SetCurrentSoC("garage", .84) + m.Observe("garage", true, 4300, 9600, true) + if s, _ := m.State("garage"); s.SoCRetention != "unavailable" || math.Abs(s.CurrentSoC-.85) > 1e-9 { + t.Fatalf("unsupported charger lost input: %+v", s) + } +} + +func TestCapacityChangeKeepsCurrentLevelAndConfidence(t *testing.T) { + for _, confirmed := range []bool{false, true} { + m := sessionManager(&sessionMemory{data: map[string]string{}}, "garage", "charger") + m.ObserveSession("garage", true, 4300, 9000, true, "easee:ABC", "session-1") + if confirmed { + m.SetCurrentSoC("garage", .84) + } + before, _ := m.State("garage") + m.Load([]Config{{ID: "garage", DriverName: "charger", VehicleCapacityWh: 100000}}) + m.ObserveSession("garage", true, 4300, 9000, true, "easee:ABC", "session-1") + after, _ := m.State("garage") + if math.Abs(after.CurrentSoC-before.CurrentSoC) > 1e-9 || after.SoCSource != before.SoCSource { + t.Fatalf("capacity changed level or confidence: before=%+v after=%+v", before, after) + } + m.ObserveSession("garage", true, 4300, 10000, true, "easee:ABC", "session-1") + after, _ = m.State("garage") + if math.Abs(after.CurrentSoC-before.CurrentSoC-.01) > 1e-9 { + t.Fatalf("new capacity not used: %+v", after) + } + } +} + +func TestNewSessionAfterOutageDropsPriorCarCapacity(t *testing.T) { + m := sessionManager(nil, "garage", "charger") + m.ObserveSession("garage", true, 4300, 1000, true, "easee:ABC", "session-1") + m.ApplyVehicleProfile("garage", "Old car", 100000) + m.ObserveSession("garage", true, 4300, 2000, true, "easee:ABC", "session-2") + if s, _ := m.State("garage"); s.VehicleName != "" || s.VehicleCapacityWh != 60000 { + t.Fatalf("prior car leaked after an unseen reconnect: %+v", s) + } +} + +func TestFirstReadingUnpluggedTombstonesStoredSession(t *testing.T) { + store := &sessionMemory{data: map[string]string{}} + m := sessionManager(store, "garage", "charger") + m.ObserveSession("garage", true, 4300, 1000, true, "easee:ABC", "session-1") + m.SetCurrentSoC("garage", .84) + m = sessionManager(store, "garage", "charger") + m.ObserveSession("garage", false, 0, 0, false, "easee:ABC", "") + m = sessionManager(store, "garage", "charger") + m.ObserveSession("garage", true, 0, 1000, true, "easee:ABC", "session-1") + if s, _ := m.State("garage"); s.SoCSource != "assumed" { + t.Fatalf("cold unplug failed to clear stored session: %+v", s) + } +} diff --git a/go/internal/loadpoint/site_power.go b/go/internal/loadpoint/site_power.go new file mode 100644 index 00000000..cb4f0091 --- /dev/null +++ b/go/internal/loadpoint/site_power.go @@ -0,0 +1,63 @@ +package loadpoint + +// Site power identity (site-signed: PV generation is negative, battery +// charge and EV charge are positive, grid import is positive): +// +// gridW = loadW + pvW + batteryW + evW +// +// Leftover PV after the house and house residual after PV are the two +// sides of the same number. Surplus-only EV and "battery may not feed +// EV" are policies on this identity, not extra meters. +const SitePowerEpsW = 50 + +// GridW is the meter flow implied by house, PV, battery and EV. +func GridW(loadW, pvW, batteryW, evW float64) float64 { + return loadW + pvW + batteryW + evW +} + +// PVLeftoverAfterHouseW is PV remaining after house load. Zero when the +// house consumes the whole array (or more). +func PVLeftoverAfterHouseW(loadW, pvW float64) float64 { + leftover := -(loadW + pvW) + if leftover < 0 { + return 0 + } + return leftover +} + +// HouseResidualW is house demand still to be covered after PV. Zero when +// PV covers the house (or more). Battery discharge up to this amount can +// still be claimed as "house only"; anything beyond it feeds the EV or +// the grid. +func HouseResidualW(loadW, pvW float64) float64 { + residual := loadW + pvW + if residual < 0 { + return 0 + } + return residual +} + +// SurplusOnlyExceedsHousePV reports whether evW would have to come from +// the grid or the home battery rather than leftover PV. Site import from +// a simultaneous home-battery grid-charge is not the car importing. +func SurplusOnlyExceedsHousePV(evW, loadW, pvW float64) bool { + return evW > PVLeftoverAfterHouseW(loadW, pvW)+SitePowerEpsW +} + +// BatteryDischargeFeedsEV reports whether a simultaneous battery +// discharge and EV charge would, by conservation, put battery energy +// into the car. +func BatteryDischargeFeedsEV(batteryW, evW, loadW, pvW float64) bool { + if evW <= 0 || batteryW >= 0 { + return false + } + return -batteryW > HouseResidualW(loadW, pvW)+SitePowerEpsW +} + +// PlannedSurplusForEVW is the near-term 3Φ-gate quantity: leftover PV +// after house load, minus planned PV-soak battery charge. gridW is the +// grid flow attributed to house+battery (planned GridW minus EV). +// Grid-funded battery charge does not consume leftover the car can take. +func PlannedSurplusForEVW(loadW, pvW, batteryW, gridW float64) float64 { + return -pvW - loadW - PlannedPVSoakW(batteryW, gridW) +} diff --git a/go/internal/loadpoint/site_power_test.go b/go/internal/loadpoint/site_power_test.go new file mode 100644 index 00000000..c98495dd --- /dev/null +++ b/go/internal/loadpoint/site_power_test.go @@ -0,0 +1,67 @@ +package loadpoint + +import "testing" + +func TestGridWIncludesEV(t *testing.T) { + // 500 house, 8 kW PV, 10 kW battery charge, 4.14 kW EV → import. + if got := GridW(500, -8000, 10000, 4140); got != 6640 { + t.Errorf("GridW = %.0f, want 6640", got) + } +} + +func TestPVLeftoverAndHouseResidualAreComplements(t *testing.T) { + if got := PVLeftoverAfterHouseW(500, -8000); got != 7500 { + t.Errorf("leftover = %.0f, want 7500", got) + } + if got := HouseResidualW(500, -8000); got != 0 { + t.Errorf("residual with surplus = %.0f, want 0", got) + } + if got := PVLeftoverAfterHouseW(2000, -500); got != 0 { + t.Errorf("leftover when house wins = %.0f, want 0", got) + } + if got := HouseResidualW(2000, -500); got != 1500 { + t.Errorf("residual = %.0f, want 1500", got) + } +} + +func TestSurplusOnlyExceedsHousePV(t *testing.T) { + if SurplusOnlyExceedsHousePV(4140, 500, -8000) { + t.Fatal("4140 W fits in 7500 W leftover") + } + if !SurplusOnlyExceedsHousePV(4140, 500, 0) { + t.Fatal("4140 W with no PV must exceed leftover") + } + if SurplusOnlyExceedsHousePV(SitePowerEpsW, 500, 0) { + t.Fatal("idle/noise EV must not trip leftover") + } +} + +func TestBatteryDischargeFeedsEV(t *testing.T) { + if !BatteryDischargeFeedsEV(-4000, 4000, 500, 0) { + t.Fatal("4000 W discharge with 500 W house residual must count as feeding EV") + } + if BatteryDischargeFeedsEV(-400, 4000, 500, 0) { + t.Fatal("discharge within house residual is house cover, not EV feed") + } + if BatteryDischargeFeedsEV(4000, 4140, 500, -8000) { + t.Fatal("battery charge cannot feed the EV") + } + if BatteryDischargeFeedsEV(-4000, 0, 500, 0) { + t.Fatal("idle EV cannot be fed") + } +} + +func TestPlannedSurplusForEVWSkipsGridFundedCharge(t *testing.T) { + // leftover 7500, battery soaking 2000 of it. + if got := PlannedSurplusForEVW(500, -8000, 2000, 0); got != 5500 { + t.Errorf("PV-soak: got %.0f, want 5500", got) + } + if got := PlannedSurplusForEVW(500, -8000, 10000, 2500); got != 7500 { + t.Errorf("grid-funded: got %.0f, want 7500 (soak does not apply)", got) + } + // Soak + EV that together import: pass grid minus EV so soak is + // still detected (meter import 640 is the leak, not battery buying). + if got := PlannedSurplusForEVW(500, -8000, 4000, 640-6900); got != 3500 { + t.Errorf("soak+EV: got %.0f, want 3500", got) + } +} diff --git a/go/internal/loadpoint/snap.go b/go/internal/loadpoint/snap.go index 0c27546b..a9c606f1 100644 --- a/go/internal/loadpoint/snap.go +++ b/go/internal/loadpoint/snap.go @@ -21,7 +21,7 @@ import "math" // on a {0, 4.1, 7.4, 11} step set should hit 4.1 exactly even when // floating-point math puts it at 4099 W. func SnapChargeW(want, min, max float64, steps []float64) float64 { - if want <= 0 { + if math.IsNaN(want) || math.IsInf(want, 0) || want <= 0 { return 0 } if want < min { @@ -33,17 +33,43 @@ func SnapChargeW(want, min, max float64, steps []float64) float64 { if len(steps) == 0 { return want } - best := steps[0] - bestDiff := math.Abs(want - best) - for _, s := range steps[1:] { - if d := math.Abs(want - s); d < bestDiff { - best = s + best := 0.0 + bestDiff := math.Inf(1) + for _, step := range steps { + if math.IsNaN(step) || math.IsInf(step, 0) || step < min || (max > 0 && step > max) { + continue + } + if d := math.Abs(want - step); d < bestDiff { + best = step bestDiff = d } } return best } +// floorChargeW treats want as a hard ceiling. No feasible step means pause. +func floorChargeW(want, min, max float64, steps []float64) float64 { + if math.IsNaN(want) || math.IsInf(want, 0) || want <= 0 { + return 0 + } + if max > 0 && want > max { + want = max + } + if want < min { + return 0 + } + if len(steps) == 0 { + return want + } + best := 0.0 + for _, step := range steps { + if step >= min && step <= want && step > best { + best = step + } + } + return best +} + // PhaseFor returns the phase count chosen for wantW given the mode // and split threshold (W). "auto" below split → 1Φ, above → 3Φ. // Unknown modes fall back to 3Φ for safety (the pre-switching diff --git a/go/internal/loadpoint/soc_confirmation_test.go b/go/internal/loadpoint/soc_confirmation_test.go new file mode 100644 index 00000000..78e8639f --- /dev/null +++ b/go/internal/loadpoint/soc_confirmation_test.go @@ -0,0 +1,58 @@ +package loadpoint + +import "testing" + +func TestSoCConfirmationDoesNotSurviveUnknownSession(t *testing.T) { + cfg := []Config{{ID: "garage", DriverName: "evse", VehicleCapacityWh: 60000}} + m := NewManager() + m.Load(cfg) + m.Observe("garage", true, 4300, 6000, true) + assertSource := func(want string) { + t.Helper() + s, _ := m.State("garage") + if s.SoCSource != want { + t.Fatalf("source = %q, want %q", s.SoCSource, want) + } + } + assertSource("assumed") + if !m.SetCurrentSoC("garage", .84) { + t.Fatal("input rejected") + } + assertSource("") // API supplies "inferred" for a confirmed anchor. + m.Load(cfg) + m.Observe("garage", true, 4300, 6600, true) + assertSource("") + s, _ := m.State("garage") + if s.CurrentSoC < .849 || s.CurrentSoC > .851 { + t.Fatalf("confirmed estimate lost on config reload: %v", s.CurrentSoC) + } + // Until the charger reports a stable session identity, a process restart + // cannot prove the same car stayed connected. Never present the default as + // a level inferred from the user's last input. + m = NewManager() + m.Load(cfg) + m.Observe("garage", true, 4300, 6600, true) + assertSource("assumed") + m.AnchorVehicleSoC("garage", .85) + assertSource("") + m.Observe("garage", false, 0, 0, true) + assertSource("") + m.Observe("garage", true, 0, 0, true) + assertSource("assumed") +} + +func TestLowSoCCorrectionAfterEnergyWasDelivered(t *testing.T) { + m := NewManager() + m.Load([]Config{{ID: "garage", VehicleCapacityWh: 60000}}) + m.Observe("garage", true, 4300, 9000, true) + m.SetCurrentSoC("garage", .05) + s, _ := m.State("garage") + if s.CurrentSoC < .049 || s.CurrentSoC > .051 { + t.Fatalf("slider correction was ignored: %v", s.CurrentSoC) + } + m.Observe("garage", true, 4300, 9600, true) + s, _ = m.State("garage") + if s.CurrentSoC < .059 || s.CurrentSoC > .061 { + t.Fatalf("energy must accrue from the corrected level: %v", s.CurrentSoC) + } +} diff --git a/go/internal/loadpoint/surplus_reserve.go b/go/internal/loadpoint/surplus_reserve.go index 9005dd72..20da3d9e 100644 --- a/go/internal/loadpoint/surplus_reserve.go +++ b/go/internal/loadpoint/surplus_reserve.go @@ -26,6 +26,11 @@ package loadpoint // real site. const EVRampHeadroomW = 2000 +// GridChargeImportW is the live/plan grid band that means the site is +// deliberately importing, not soaking PV. Matches control's +// coverLoadChargeSlot / energy-path grid-charge skip. +const GridChargeImportW = 100.0 + // SurplusReserveW returns the aggregate PV headroom that must be // preserved for surplus_only loadpoints. For each surplus_only + // plugged_in LP it reserves min(MaxChargeW, CurrentPowerW + @@ -215,3 +220,47 @@ func SurplusPotentialW(states []State) float64 { } return sum } + +// PlannerTreatsLoadpointAsSurplusOnly is the SurplusOnly flag the MPC spec +// should carry. The bat-SoC unlock is a this-tick opportunistic clamp; +// putting it on the 48 h spec forbids night-time grid EV in a plan that +// was computed while the sun was still up. Battery→EV is already blocked +// by NoBatteryToEV. +func PlannerTreatsLoadpointAsSurplusOnly(operatorSurplusOnly, deferGridPlan bool) bool { + return operatorSurplusOnly || deferGridPlan +} + +// SurplusAvailableForEVW is the live PV leftover the surplus-only clamp +// may offer the charger this tick, in watts. +// +// Site identity: -gridW + batW + evW = -pvW - loadW (house leftover). +// +// The EV controller runs before battery dispatch on the same tick. If the +// home battery is soaking PV, counting that charge as EV-available would +// command the charger on before the battery has yielded and leak into +// import. Meter import is not enough to call the charge grid-funded: +// soak plus EV can import together while the battery is still taking +// leftover PV. Import beyond the car (gridW − evW) is the battery +// buying; that leftover is the car's without waiting for a yield. +func SurplusAvailableForEVW(gridW, batW, evW float64, surplusOnlyActive bool) float64 { + leftover := -gridW + batW + evW + if surplusOnlyActive { + leftover -= PlannedPVSoakW(batW, gridW-evW) + } + if leftover < 0 { + return 0 + } + return leftover +} + +// PlannedPVSoakW is the portion of a battery charge that is soaking +// leftover PV rather than buying from the grid. gridW is the grid +// flow attributed to house+battery (live meter minus EV, or planned +// GridW minus LoadpointW). A reading above the import band means the +// battery is buying, so soak is zero and leftover PV stays with the car. +func PlannedPVSoakW(batteryW, gridW float64) float64 { + if batteryW <= 0 || gridW > GridChargeImportW { + return 0 + } + return batteryW +} diff --git a/go/internal/loadpoint/surplus_reserve_test.go b/go/internal/loadpoint/surplus_reserve_test.go index d32a4f34..dd4cf0b9 100644 --- a/go/internal/loadpoint/surplus_reserve_test.go +++ b/go/internal/loadpoint/surplus_reserve_test.go @@ -225,6 +225,57 @@ func TestSurplusReserveWPluggedStoppedSoCUnknownBootstraps(t *testing.T) { } } +func TestSurplusAvailableForEVWHidesPVSoakButNotGridCharge(t *testing.T) { + // Identity leftover after house: -grid + bat + ev. + // PV-soak (battery charging, site not importing): hide the battery + // so the charger cannot claim watts the battery has not yielded yet. + if got := SurplusAvailableForEVW(0, 4000, 0, true); got != 0 { + t.Errorf("PV-soak: got %.0f, want 0 (battery charge is not yet EV-available)", got) + } + // Soak + EV that together import: leftover 7640, battery 5000 < leftover + // so this is still soak. Meter import is the leak, not Pixii buying. + // Offering 7640 would keep the 4140 W setpoint that caused the import. + if got := SurplusAvailableForEVW(1500, 5000, 4140, true); got != 2640 { + t.Errorf("soak+EV import: got %.0f, want 2640 (leftover minus soak)", got) + } + // Grid-funded battery charge: leftover 7500, battery 10 kW, EV 4140, + // grid 6640. Import beyond the car is the battery buying. + if got := SurplusAvailableForEVW(6640, 10000, 4140, true); got != 7500 { + t.Errorf("grid-charge combo: got %.0f, want 7500", got) + } + if got := SurplusAvailableForEVW(-6500, 0, 0, true); got != 6500 { + t.Errorf("exporting idle: got %.0f, want 6500", got) + } + if got := SurplusAvailableForEVW(0, 4000, 0, false); got != 4000 { + t.Errorf("not surplus-only: got %.0f, want identity 4000", got) + } +} + +func TestPlannedPVSoakWIgnoresGridFundedCharge(t *testing.T) { + if got := PlannedPVSoakW(5000, 0); got != 5000 { + t.Errorf("PV-soak: got %.0f, want 5000", got) + } + if got := PlannedPVSoakW(5000, 1500); got != 0 { + t.Errorf("grid-funded: got %.0f, want 0", got) + } + if got := PlannedPVSoakW(-2000, 0); got != 0 { + t.Errorf("discharge: got %.0f, want 0", got) + } +} + +func TestPlannerTreatsLoadpointAsSurplusOnly(t *testing.T) { + if !PlannerTreatsLoadpointAsSurplusOnly(true, false) { + t.Fatal("operator surplus_only") + } + if !PlannerTreatsLoadpointAsSurplusOnly(false, true) { + t.Fatal("deadline past published prices") + } + if PlannerTreatsLoadpointAsSurplusOnly(false, false) { + t.Fatal("plain loadpoint") + } + // Bat-SoC unlock is this-tick only and must not appear here. +} + // Manual/schedule override: a force-charging (manual hold) surplus_only EV // must contribute NO reserve — the battery is meant to cover it, and a // non-zero reserve arms the dispatch no-discharge floor which flaps the diff --git a/go/internal/mpc/external_optimizer.go b/go/internal/mpc/external_optimizer.go index 189416b2..d506abf7 100644 --- a/go/internal/mpc/external_optimizer.go +++ b/go/internal/mpc/external_optimizer.go @@ -11,6 +11,7 @@ import ( "time" "github.com/google/uuid" + "github.com/srcfl/ftw/go/internal/loadpoint" "github.com/srcfl/ftw/go/internal/optimizercontract" ) @@ -700,20 +701,17 @@ func ValidatePlan(slots []Slot, p Params, plan *Plan) error { if len(a.LoadpointPowerW) == 0 && lpIdx == 0 { powerW = a.LoadpointW } - if lp.SurplusOnly && powerW > 0 && a.GridW > 50 { - return fmt.Errorf("slot %d surplus-only loadpoint %s imports from grid", i, lp.ID) + if lp.SurplusOnly && surplusOnlyExceedsHousePV(powerW, slot.LoadW, effectivePVW) { + return fmt.Errorf("slot %d surplus-only loadpoint %s exceeds PV leftover after house load", i, lp.ID) } if powerW > 0 && a.BatteryW < 0 && a.GridW < -50 { return fmt.Errorf("slot %d loadpoint %s charges during battery-driven export", i, lp.ID) } - if lp.blocksBatteryToEV() && powerW > 0 && a.BatteryW < 0 { - houseResidualW := math.Max(0, slot.LoadW+effectivePVW) - if -a.BatteryW > houseResidualW+50 { - return fmt.Errorf("slot %d battery discharge feeds loadpoint %s", i, lp.ID) - } + if lp.blocksBatteryToEV() && loadpoint.BatteryDischargeFeedsEV(a.BatteryW, powerW, slot.LoadW, effectivePVW) { + return fmt.Errorf("slot %d battery discharge feeds loadpoint %s", i, lp.ID) } } - wantGridW := slot.LoadW + effectivePVW + a.BatteryW + totalLoadpointW + wantGridW := loadpoint.GridW(slot.LoadW, effectivePVW, a.BatteryW, totalLoadpointW) if math.Abs(a.GridW-wantGridW) > 2 { return fmt.Errorf("slot %d grid balance %.3f, want %.3f", i, a.GridW, wantGridW) } diff --git a/go/internal/mpc/external_optimizer_test.go b/go/internal/mpc/external_optimizer_test.go index 42649645..f57aacfb 100644 --- a/go/internal/mpc/external_optimizer_test.go +++ b/go/internal/mpc/external_optimizer_test.go @@ -351,6 +351,61 @@ func TestValidatePlanAllowsGridChargeWithIdleSurplusOnlyEV(t *testing.T) { } } +func TestValidatePlanAllowsEVPVWithBatteryGridCharge(t *testing.T) { + slots := []Slot{{StartMs: 1, LenMin: 60, PriceOre: 20, SpotOre: 10, Confidence: 1, LoadW: 500, PVW: -6500}} + p := Params{ + Mode: ModeArbitrage, CapacityWh: 10000, + SoCMin: 0.10, SoCMax: 0.95, InitialSoC: 0.20, + MaxChargeW: 5000, MaxDischargeW: 5000, + ChargeEfficiency: 0.95, DischargeEfficiency: 0.95, + Loadpoint: &LoadpointSpec{ + ID: "car", CapacityWh: 40000, Levels: 11, SoCMin: 0, SoCMax: 1, + InitialSoC: 0.25, PluggedIn: true, MaxChargeW: 4140, + AllowedStepsW: []float64{0, 4140}, ChargeEfficiency: 1, + SurplusOnly: true, NoBatteryToEV: true, + }, + } + // leftover PV after house = 6000 W. EV 4140 + battery 5000 → + // grid = 500-6500+5000+4140 = 3140 import. Battery SoC: 0.20 + 0.475 = 0.675. + // EV SoC: 0.25 + 4140/40000 = 0.3535. + plan := Plan{Mode: p.Mode, HorizonSlots: 1, CapacityWh: p.CapacityWh, InitialSoC: 0.20, + TotalCostOre: 62.8, Actions: []Action{{ + SlotStartMs: 1, SlotLenMin: 60, + BatteryW: 5000, GridW: 3140, SoC: 0.675, + LoadpointW: 4140, LoadpointSoC: 0.3535, CostOre: 62.8, + }}} + if err := ValidatePlan(slots, p, &plan); err != nil { + t.Fatalf("ValidatePlan rejected leftover-PV EV beside battery grid-charge: %v", err) + } +} + +func TestValidatePlanRejectsSurplusOnlyEVAboveLeftoverPV(t *testing.T) { + slots := []Slot{{StartMs: 1, LenMin: 60, PriceOre: 20, SpotOre: 10, Confidence: 1, LoadW: 500, PVW: -6500}} + p := Params{ + Mode: ModeArbitrage, CapacityWh: 10000, + SoCMin: 0.10, SoCMax: 0.95, InitialSoC: 0.20, + MaxChargeW: 5000, MaxDischargeW: 5000, + ChargeEfficiency: 0.95, DischargeEfficiency: 0.95, + Loadpoint: &LoadpointSpec{ + ID: "car", CapacityWh: 40000, Levels: 11, SoCMin: 0, SoCMax: 1, + InitialSoC: 0.25, PluggedIn: true, MaxChargeW: 11000, + AllowedStepsW: []float64{0, 7000}, ChargeEfficiency: 1, + SurplusOnly: true, NoBatteryToEV: true, + }, + } + // leftover after house = 6000 W. EV 7000 exceeds it even though + // the home battery is the one importing. + plan := Plan{Mode: p.Mode, HorizonSlots: 1, CapacityWh: p.CapacityWh, InitialSoC: 0.20, + TotalCostOre: 120, Actions: []Action{{ + SlotStartMs: 1, SlotLenMin: 60, + BatteryW: 5000, GridW: 6000, SoC: 0.675, + LoadpointW: 7000, LoadpointSoC: 0.425, CostOre: 120, + }}} + if err := ValidatePlan(slots, p, &plan); err == nil { + t.Fatal("ValidatePlan accepted surplus-only EV above leftover PV") + } +} + func TestExternalOptimizerEndToEnd(t *testing.T) { python := os.Getenv("FTW_TEST_OPTIMIZER_PYTHON") if python == "" { diff --git a/go/internal/mpc/loadpoint_directive.go b/go/internal/mpc/loadpoint_directive.go new file mode 100644 index 00000000..a55d048f --- /dev/null +++ b/go/internal/mpc/loadpoint_directive.go @@ -0,0 +1,46 @@ +package mpc + +import ( + "time" + + "github.com/srcfl/ftw/go/internal/loadpoint" +) + +// LoadpointDirective is the charger slice of this slot. main.go and the +// site-clock tests both send this to loadpoint.Controller so EV energy +// budgets cannot drift from the plan the battery dispatch sees. +func (d SlotDirective) LoadpointDirective() loadpoint.Directive { + return loadpoint.Directive{ + SlotStart: d.SlotStart, + SlotEnd: d.SlotEnd, + LoadpointEnergyWh: d.LoadpointEnergyWh, + } +} + +// PeakPlannedSurplusForEV is the near-term 3Φ-gate scan: peak leftover +// PV after house load, minus planned PV-soak battery charge, over +// slots that overlap [now, now+window]. Soak uses GridW minus EV so +// soak+EV import is not treated as the battery buying. +func PeakPlannedSurplusForEV(actions []Action, now time.Time, window time.Duration) (float64, bool) { + if len(actions) == 0 { + return 0, false + } + horizon := now.Add(window) + var peak float64 + any := false + for _, a := range actions { + slotEnd := time.UnixMilli(a.SlotStartMs).Add(time.Duration(a.SlotLenMin) * time.Minute) + if slotEnd.Before(now) { + continue + } + if time.UnixMilli(a.SlotStartMs).After(horizon) { + break + } + surplus := loadpoint.PlannedSurplusForEVW(a.LoadW, a.PVW, a.BatteryW, a.GridW-a.LoadpointW) + if !any || surplus > peak { + peak = surplus + any = true + } + } + return peak, any +} diff --git a/go/internal/mpc/loadpoint_directive_test.go b/go/internal/mpc/loadpoint_directive_test.go new file mode 100644 index 00000000..aa1677c5 --- /dev/null +++ b/go/internal/mpc/loadpoint_directive_test.go @@ -0,0 +1,66 @@ +package mpc + +import ( + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/loadpoint" +) + +func TestSlotDirectiveLoadpointDirectiveCarriesEVBudget(t *testing.T) { + start := time.Date(2026, 8, 18, 12, 0, 0, 0, time.UTC) + d := SlotDirective{ + SlotStart: start, + SlotEnd: start.Add(15 * time.Minute), + BatteryEnergyWh: 2500, + LoadpointEnergyWh: map[string]float64{"garage": 1035}, + } + got := d.LoadpointDirective() + if !got.SlotStart.Equal(d.SlotStart) || !got.SlotEnd.Equal(d.SlotEnd) { + t.Fatalf("slot window changed: %+v", got) + } + if got.LoadpointEnergyWh["garage"] != 1035 { + t.Fatalf("EV budget dropped: %+v", got.LoadpointEnergyWh) + } +} + +func TestPeakPlannedSurplusForEVSkipsGridFundedCharge(t *testing.T) { + start := time.Date(2026, 8, 18, 12, 0, 0, 0, time.UTC) + actions := []Action{ + { + SlotStartMs: start.UnixMilli(), SlotLenMin: 15, + LoadW: 500, PVW: -8000, BatteryW: 10000, GridW: 2500, + }, + { + SlotStartMs: start.Add(15 * time.Minute).UnixMilli(), SlotLenMin: 15, + LoadW: 500, PVW: 0, BatteryW: 0, GridW: 500, + }, + } + peak, ok := PeakPlannedSurplusForEV(actions, start.Add(time.Second), 30*time.Minute) + if !ok { + t.Fatal("expected a peak") + } + want := loadpoint.PlannedSurplusForEVW(500, -8000, 10000, 2500) + if peak != want { + t.Errorf("peak = %.0f, want %.0f", peak, want) + } + if peak != 7500 { + t.Errorf("grid-funded slot must still offer leftover PV %.0f, got %.0f", 7500.0, peak) + } +} + +func TestPeakPlannedSurplusForEVHidesSoakWhenEVMakesMeterImport(t *testing.T) { + start := time.Date(2026, 8, 18, 12, 0, 0, 0, time.UTC) + actions := []Action{{ + SlotStartMs: start.UnixMilli(), SlotLenMin: 15, + LoadW: 500, PVW: -8000, BatteryW: 4000, LoadpointW: 6900, + GridW: loadpoint.GridW(500, -8000, 4000, 6900), + }} + peak, ok := PeakPlannedSurplusForEV(actions, start.Add(time.Second), 30*time.Minute) + if !ok { + t.Fatal("expected a peak") + } + if peak != 3500 { + t.Errorf("soak+EV peak = %.0f, want 3500 (leftover minus soak, not full leftover)", peak) + } +} diff --git a/go/internal/mpc/loadpoint_service_test.go b/go/internal/mpc/loadpoint_service_test.go index c4586d64..1ca419f0 100644 --- a/go/internal/mpc/loadpoint_service_test.go +++ b/go/internal/mpc/loadpoint_service_test.go @@ -3,6 +3,8 @@ package mpc import ( "testing" "time" + + "github.com/srcfl/ftw/go/internal/loadpoint" ) // TestSlotDirectiveCarriesLoadpointEnergyWh asserts that when the DP @@ -68,12 +70,8 @@ func TestSlotDirectiveCarriesLoadpointEnergyWh(t *testing.T) { t.Fatalf("DP never scheduled EV charging; actions: %+v", plan.Actions) } - svc := &Service{ - Zone: "SE3", - Defaults: Params{Mode: ModeCheapCharge}, - last: &plan, - lastLoadpointID: "garage", - } + svc := &Service{Zone: "SE3", Defaults: Params{Mode: ModeCheapCharge}} + svc.InstallPlan(plan, p, "garage") // Query inside the charged slot. queryAt := time.UnixMilli(plan.Actions[chargedSlotIdx].SlotStartMs).Add(1 * time.Minute) d, ok := svc.SlotDirectiveAt(queryAt) @@ -285,11 +283,7 @@ func TestSurplusOnlyForbidsBatteryFeedingEVEvenWhenCoverEVEnabled(t *testing.T) plan := Optimize(slots, mkParams(true)) for i, a := range plan.Actions { - houseResidualW := slots[i].LoadW + slots[i].PVW - if houseResidualW < 0 { - houseResidualW = 0 - } - if a.LoadpointW > 100 && a.BatteryW < -(houseResidualW+50) { + if loadpoint.BatteryDischargeFeedsEV(a.BatteryW, a.LoadpointW, slots[i].LoadW, slots[i].PVW) { t.Errorf("slot %d: surplus_only used battery as EV surplus — battW=%.0f loadpointW=%.0f gridW=%.0f", i, a.BatteryW, a.LoadpointW, a.GridW) } @@ -430,8 +424,98 @@ func TestSurplusOnlyEVCannotImportEvenWithDeadline(t *testing.T) { if len(plan.Actions) != 1 { t.Fatalf("got %d actions, want 1", len(plan.Actions)) } - if plan.Actions[0].LoadpointW > 50 && plan.Actions[0].GridW > 50 { - t.Errorf("surplus-only EV imported from grid: evW=%.0f gridW=%.0f", - plan.Actions[0].LoadpointW, plan.Actions[0].GridW) + a := plan.Actions[0] + if surplusOnlyExceedsHousePV(a.LoadpointW, slots[0].LoadW, slots[0].PVW) { + t.Errorf("surplus-only EV exceeded leftover PV: evW=%.0f leftover=%.0f gridW=%.0f", + a.LoadpointW, loadpoint.PVLeftoverAfterHouseW(slots[0].LoadW, slots[0].PVW), a.GridW) + } +} + +func TestArbitrageChargesSurplusOnlyEVFromPVWhileBatteryGridCharges(t *testing.T) { + // Cheap sun + empty battery + expensive evening. Surplus-only may + // take leftover PV after the house; the battery may still buy from + // the grid in the same slot. The old feasibility rule rejected any + // (evW>0 AND gridW>50) pair and forced "car sits / Pixii never buys". + slots := []Slot{ + {StartMs: 0, LenMin: 60, PriceOre: 20, SpotOre: 10, LoadW: 500, PVW: -6500, Confidence: 1}, + {StartMs: 3600_000, LenMin: 60, PriceOre: 300, SpotOre: 240, LoadW: 2500, PVW: 0, Confidence: 1}, + } + plan := Optimize(slots, Params{ + Mode: ModeArbitrage, + SoCLevels: 11, + CapacityWh: 20000, + SoCMin: 0.10, + SoCMax: 0.95, + InitialSoC: 0.20, + ActionLevels: 11, + MaxChargeW: 10000, + MaxDischargeW: 10000, + ChargeEfficiency: 0.95, + DischargeEfficiency: 0.95, + TerminalSoCPrice: 150, + Loadpoint: &LoadpointSpec{ + ID: "garage", + CapacityWh: 40000, + Levels: 11, + InitialSoC: 0.20, + PluggedIn: true, + TargetSoC: 0.40, + TargetSlotIdx: 1, + MaxChargeW: 4140, + AllowedStepsW: []float64{0, 4140}, + ChargeEfficiency: 1.0, + SurplusOnly: true, + NoBatteryToEV: true, + }, + }) + if len(plan.Actions) != 2 { + t.Fatalf("got %d actions, want 2", len(plan.Actions)) + } + a := plan.Actions[0] + if a.LoadpointW < 1000 { + t.Errorf("cheap PV slot should charge the surplus-only EV from leftover PV, got %+v", a) + } + if a.BatteryW < 500 { + t.Errorf("cheap slot should still grid-charge the home battery, got %+v", a) + } + if a.GridW < 100 { + t.Errorf("battery charge past leftover PV must import: %+v", a) + } + if surplusOnlyExceedsHousePV(a.LoadpointW, slots[0].LoadW, slots[0].PVW) { + t.Errorf("EV %.0f W exceeded leftover %.0f W", a.LoadpointW, loadpoint.PVLeftoverAfterHouseW(slots[0].LoadW, slots[0].PVW)) + } +} + +func TestInstallPlanPublishesLoadpointEnergyToSlotDirectiveAt(t *testing.T) { + now := time.Now().UTC().Truncate(time.Second) + plan := Plan{ + GeneratedAtMs: now.UnixMilli(), + Mode: ModeArbitrage, + Actions: []Action{{ + SlotStartMs: now.UnixMilli(), + SlotLenMin: 15, + BatteryW: 10000, + LoadpointW: 4140, + LoadW: 500, + PVW: -8000, + GridW: loadpoint.GridW(500, -8000, 10000, 4140), + }}, + } + svc := &Service{} + svc.InstallPlan(plan, Params{Mode: ModeArbitrage}, "garage") + d, ok := svc.SlotDirectiveAt(now.Add(time.Second)) + if !ok { + t.Fatal("fresh InstallPlan must be visible to SlotDirectiveAt") + } + wantEV := 4140.0 * 15 / 60 + if d.LoadpointEnergyWh["garage"] != wantEV { + t.Fatalf("EV budget = %+v, want garage=%.0f Wh", d.LoadpointEnergyWh, wantEV) + } + if d.Strategy != ModeArbitrage { + t.Fatalf("Strategy = %q, want %q", d.Strategy, ModeArbitrage) + } + lp := d.LoadpointDirective() + if lp.LoadpointEnergyWh["garage"] != wantEV { + t.Fatalf("LoadpointDirective dropped EV budget: %+v", lp.LoadpointEnergyWh) } } diff --git a/go/internal/mpc/loadpoint_spec.go b/go/internal/mpc/loadpoint_spec.go index cd0e08fa..0526fd2a 100644 --- a/go/internal/mpc/loadpoint_spec.go +++ b/go/internal/mpc/loadpoint_spec.go @@ -1,5 +1,7 @@ package mpc +import "github.com/srcfl/ftw/go/internal/loadpoint" + // LoadpointSpec tells the DP how to extend its state space with an EV // loadpoint. Set `Params.Loadpoint` to a non-nil spec to have the // optimizer treat charging the EV as a decision variable alongside @@ -37,11 +39,11 @@ type LoadpointSpec struct { // Plan-start conditions. InitialSoC float64 // EV SoC at the first slot - PluggedIn bool // when false, Optimize treats the loadpoint as absent + PluggedIn bool // when false, Optimize treats the loadpoint as absent // User intent. A zero target means no deadline — charge // opportunistically based on price/PV surplus only. - TargetSoC float64 + TargetSoC float64 TargetSlotIdx int // zero-based slot by whose end the target must be met; ignored when target is zero // Electrical constraints. AllowedStepsW MUST include 0 (off) and @@ -55,30 +57,20 @@ type LoadpointSpec struct { ChargeEfficiency float64 // SurplusOnly forbids EV actions that need grid import or home-battery - // discharge into the car. The loadpoint may use real site surplus only: - // PV already covering house load, or PV left after the battery's own - // planned charge. It must not treat battery discharge as synthetic PV - // surplus, even when the global BatteryCoversEV opt-in is enabled. - // The home battery itself may still grid-charge for house load or - // arbitrage — surplus-only is an EV policy, not a site import ban. + // discharge into the car. The loadpoint may take at most the PV leftover + // after house load. Site import caused by a simultaneous home-battery + // grid-charge does not count as the car importing — surplus-only is an + // EV policy, not a site import ban. Battery discharge still cannot be + // treated as synthetic PV surplus, even when BatteryCoversEV is on. SurplusOnly bool // NoBatteryToEV mirrors ctrl.State.BatteryCoversEV inverted: when // true (operator's default), the home battery's discharge MUST NOT - // end up at the EV. The DP feasibility check enforces this by - // rejecting any (battW, evW) combination where battery discharge - // exceeds the PV-residual house demand — i.e. where some of the - // battery's energy must, by conservation, have flowed into the EV - // or out to grid (and the existing battery-export-vs-EV rule - // already covers the export case). The runtime dispatch in - // control/dispatch.go has the canonical clamp using identical - // accounting (search "CANONICAL \"battery may not feed EV\""); the - // DP rule here stops the planner from emitting infeasible - // allocations that dispatch then has to censor, removing the - // plan↔reality divergence operators were seeing on - // planner_arbitrage slots. A future refactor should extract the - // shared houseResidualW + feasibility predicate into a helper so - // the two sites can't drift. + // end up at the EV. Enforced by loadpoint.BatteryDischargeFeedsEV + // in the DP and in ValidatePlan. The runtime clamp in + // control/dispatch.go (search CANONICAL "battery may not feed EV") + // is the same conservation rule, not this helper — dispatch.go is + // owned by a separate PV-export PR. NoBatteryToEV bool } @@ -86,6 +78,13 @@ func (l *LoadpointSpec) blocksBatteryToEV() bool { return l != nil && (l.NoBatteryToEV || l.SurplusOnly) } +// surplusOnlyExceedsHousePV is the planner name for the site-power +// leftover check. Surplus-only is an EV policy: leftover PV after the +// house, not a ban on site import while the car is charging. +func surplusOnlyExceedsHousePV(evW, loadW, pvW float64) bool { + return loadpoint.SurplusOnlyExceedsHousePV(evW, loadW, pvW) +} + // normalizedSteps returns a non-nil, 0-included, dedup'd + sorted // action set. Used internally by the DP. func (l *LoadpointSpec) normalizedSteps() []float64 { diff --git a/go/internal/mpc/loadpoint_spec_test.go b/go/internal/mpc/loadpoint_spec_test.go index 502570cd..742026e4 100644 --- a/go/internal/mpc/loadpoint_spec_test.go +++ b/go/internal/mpc/loadpoint_spec_test.go @@ -98,10 +98,10 @@ func TestOptimizePrefersCheapSlotsForEV(t *testing.T) { ID: "garage", CapacityWh: 60000, // 60 kWh Levels: 11, - InitialSoC: 0.2, + InitialSoC: 0.2, PluggedIn: true, - TargetSoC: 0.3, // need 10 % → 6 kWh - TargetSlotIdx: 3, // deadline at end of horizon + TargetSoC: 0.3, // need 10 % → 6 kWh + TargetSlotIdx: 3, // deadline at end of horizon MaxChargeW: 11000, AllowedStepsW: []float64{0, 11000}, ChargeEfficiency: 0.9, diff --git a/go/internal/mpc/mpc.go b/go/internal/mpc/mpc.go index 9f1ae7c1..790aa258 100644 --- a/go/internal/mpc/mpc.go +++ b/go/internal/mpc/mpc.go @@ -17,9 +17,10 @@ // battery > 0 → charging (load on site) // battery < 0 → discharging (source on site) // -// Power balance per slot (from the grid meter's point of view): +// Power balance per slot (from the grid meter's point of view). EV +// charge is a site load; the identity lives in loadpoint.GridW: // -// grid_w = load_w + pv_w + battery_w +// grid_w = load_w + pv_w + battery_w + ev_w // // Battery efficiency: the `battery_w` we command is measured at the AC // terminals (site-facing). Due to conversion losses, only a fraction @@ -41,6 +42,7 @@ import ( "time" "github.com/srcfl/ftw/go/internal/gridcost" + "github.com/srcfl/ftw/go/internal/loadpoint" ) // Mode selects how aggressively the planner uses the battery. @@ -742,32 +744,33 @@ func Optimize(slots []Slot, p Params) Plan { } } // EV appears as a site load (+ site-signed). - // GridW = load + PV + battery + EV. - gridW := slot.LoadW + slot.PVW + battW + evW - - // Surplus-only EV: forbid any non-zero EV - // action that turns the site into a net - // importer. evW = 0 is always feasible (the - // constraint short-circuits), so the DP - // degrades gracefully on low-PV days — the - // deadline shortfall penalty then makes the - // "miss target" outcome expensive but legal. - // 50 W epsilon absorbs floating-point dither - // from the discretized PV/load grid so the - // constraint isn't artificially tight against - // an action that's effectively zero net. - if evActive && lp.SurplusOnly && evW > 0 && gridW > 50 { + gridW := loadpoint.GridW(slot.LoadW, slot.PVW, battW, evW) + + // Surplus-only EV: take at most leftover PV + // after house load. Site import caused by a + // simultaneous home-battery grid-charge is not + // the car importing — forbidding gridW > 0 + // whenever evW > 0 forced the DP to idle the + // car on every cheap slot the battery wanted + // to buy, which is how "EV takes the PV, Pixii + // never grid-charges" and the reverse + // "battery buys, car sits in the sun" both + // appear on the same site. evW = 0 is always + // feasible, so a no-PV day degrades to "miss + // the deadline" rather than an infeasible + // plan. Epsilon is loadpoint.SitePowerEpsW. + if evActive && lp.SurplusOnly && surplusOnlyExceedsHousePV(evW, slot.LoadW, slot.PVW) { continue } // Surplus-only must not also ban home-battery - // grid charge. The EV-import rule above keeps - // the car off grid, and blocksBatteryToEV() + // grid charge. The leftover-PV rule above + // keeps the car off grid, and blocksBatteryToEV() // below already rejects battery→EV, so the // "launder cheap grid through the battery into // the car" path is closed without forbidding // Pixii/house-battery arbitrage while the car - // is plugged in. Active arbitrage with a + // is taking real PV. Active arbitrage with a // surplus-only EV is a real operator setup. // Don't simultaneously discharge the home battery @@ -789,41 +792,19 @@ func Optimize(slots []Slot, p Params) Plan { } // Battery-to-EV block: operator has BatteryCoversEV=false - // (the default), or this loadpoint is surplus-only. In - // both cases, the home battery's energy may cover house - // load but must not become synthetic EV surplus. - // Reject any allocation where the battery's - // discharge exceeds the PV-residual house demand - // — i.e. where, by conservation, some of the - // battery's energy must have flowed into the EV. - // houseResidualW = max(0, load - pv_gen) is how - // much house demand is left after PV has covered - // what it can; the battery can supply up to that - // much and still be claimed as "house only". - // Anything beyond it must go to EV (illegal here) - // or grid (covered by the rule above). - // Matches the canonical runtime safety clamp in - // control/dispatch.go (search "CANONICAL - // \"battery may not feed EV\"") — keep them - // aligned. 50 W epsilon mirrors the surrounding - // constraints. TODO(refactor): the - // houseResidualW math + feasibility predicate - // is duplicated; extract a shared helper. - if evActive && lp.blocksBatteryToEV() && evW > 0 && battW < 0 { - houseResidualW := slot.LoadW + slot.PVW // PVW is negative - if houseResidualW < 0 { - houseResidualW = 0 - } - if (-battW) > houseResidualW+50 { - continue - } + // (the default), or this loadpoint is surplus-only. Same + // conservation check as ValidatePlan and the runtime + // clamp in control/dispatch.go (search CANONICAL + // "battery may not feed EV"): loadpoint.BatteryDischargeFeedsEV. + if evActive && lp.blocksBatteryToEV() && loadpoint.BatteryDischargeFeedsEV(battW, evW, slot.LoadW, slot.PVW) { + continue } // Mode-based feasibility. Baseline includes // EV so the mode check asks "is the extra // battery action pulling the grid further // into import/export than baseline?". - baseGridW := slot.LoadW + slot.PVW + evW + baseGridW := loadpoint.GridW(slot.LoadW, slot.PVW, 0, evW) if !modeAllows(p.Mode, baseGridW, gridW, battW) { continue } @@ -1100,7 +1081,7 @@ func Optimize(slots []Slot, p Params) Plan { evSoc2 = lp.SoCMax } } - gridW := slot.LoadW + slot.PVW + actW + evW + gridW := loadpoint.GridW(slot.LoadW, slot.PVW, actW, evW) gridKWh := gridW * dtH / 1000.0 // Report the ACTUAL expected cost using the raw (un-blended) // prices so the UI summary reflects "what we'd actually pay diff --git a/go/internal/mpc/service.go b/go/internal/mpc/service.go index 440acbbc..d1b72fb6 100644 --- a/go/internal/mpc/service.go +++ b/go/internal/mpc/service.go @@ -207,10 +207,14 @@ type Service struct { // latestReplanGeneration identifies the newest requested solve. Starting a // newer generation cancels the older request; the generation check remains // the final guard for work that does not stop promptly on context cancel. - latestReplanGeneration uint64 // guarded by mu - activeReplanCancel context.CancelFunc - stopping bool - replanWG sync.WaitGroup + latestReplanGeneration uint64 // guarded by mu + publishedReplanGeneration uint64 + failedReplanGeneration uint64 + activeReplanCancel context.CancelFunc + queuedReplan *replanRequest + requestedReplanRunning bool + stopping bool + replanWG sync.WaitGroup // decisionIDFactory is a test seam. Production uses a random UUID for every // accepted plan. It is read only while mu is held at the publish gate. decisionIDFactory func() string @@ -395,6 +399,54 @@ func (s *Service) Latest() *Plan { return s.last } +// PlanSnapshot keeps the visible plan and its freshness in one read. Outdated +// remains true if the latest request fails; Pending only describes active work. +type PlanSnapshot struct { + Plan *Plan + ReplanAt time.Time + Reason string + Pending bool + Outdated bool + loadpointID string +} + +func (s *Service) PlanSnapshot() PlanSnapshot { + if s == nil { + return PlanSnapshot{} + } + s.mu.RLock() + defer s.mu.RUnlock() + outdated := s.publishedReplanGeneration != s.latestReplanGeneration + return PlanSnapshot{ + Plan: s.last, ReplanAt: s.lastReplanAt, Reason: s.lastReason, + Pending: outdated && s.activeReplanCancel != nil, Outdated: outdated, + loadpointID: s.lastLoadpointID, + } +} + +// InstallPlan puts a plan in the cache SlotDirectiveAt and Latest read. +// Optimize and RestoreDiagnostic already write that cache after a +// successful solve. Tests that inject a known Action use the same seam +// so the charger and battery cannot be given two different mappings of +// one slot. +// +// GeneratedAtMs is aged against the wall clock (MaxPlanAge), not the +// slot clock passed to SlotDirectiveAt. A simulated site clock must +// still stamp GeneratedAtMs with time.Now(). +func (s *Service) InstallPlan(plan Plan, params Params, loadpointID string) { + if s == nil { + return + } + s.mu.Lock() + defer s.mu.Unlock() + copied := plan + s.last = &copied + s.lastParams = params + s.lastLoadpointID = loadpointID + s.lastReplanAt = time.Now() + s.publishedReplanGeneration = s.latestReplanGeneration +} + // MaxPlanAge is the staleness cutoff. Once a plan's `generated_at_ms` // is older than this, we consider it stale and the control loop falls // back to self_consumption. Picked to be ~2× the replan interval so a @@ -473,6 +525,7 @@ func (s *Service) SlotDirectiveAt(now time.Time) (SlotDirective, bool) { // plan under one lock so a concurrent replan cannot mix generations. s.mu.RLock() p := s.last + failedReplacement := s.failedReplanGeneration > s.publishedReplanGeneration lpID := s.lastLoadpointID params := s.lastParams if params.Mode == "" { @@ -481,7 +534,7 @@ func (s *Service) SlotDirectiveAt(now time.Time) (SlotDirective, bool) { params = s.Defaults } s.mu.RUnlock() - if p == nil { + if p == nil || failedReplacement { return SlotDirective{}, false } if time.Since(time.UnixMilli(p.GeneratedAtMs)) > MaxPlanAge { @@ -547,13 +600,16 @@ type PlanWindow struct { // there is no fresh plan — same MaxPlanAge cutoff as SlotDirectiveAt, // because a stale plan must not promise start times. func (s *Service) LoadpointPlanWindows(id string, now time.Time, max int) ([]PlanWindow, float64) { - if s == nil || id == "" { + return s.PlanSnapshot().LoadpointPlanWindows(id, now, max) +} + +// LoadpointPlanWindows uses the plan captured with this snapshot's status. +func (snapshot PlanSnapshot) LoadpointPlanWindows(id string, now time.Time, max int) ([]PlanWindow, float64) { + if snapshot.Outdated || id == "" { return nil, 0 } - s.mu.RLock() - p := s.last - legacyID := s.lastLoadpointID - s.mu.RUnlock() + p := snapshot.Plan + legacyID := snapshot.loadpointID if p == nil || time.Since(time.UnixMilli(p.GeneratedAtMs)) > MaxPlanAge { return nil, 0 } @@ -669,12 +725,13 @@ func (s *Service) SlotAt(now time.Time) (string, float64, string, bool) { } s.mu.RLock() p := s.last + failedReplacement := s.failedReplanGeneration > s.publishedReplanGeneration params := s.lastParams if params.Mode == "" { params = s.Defaults } s.mu.RUnlock() - if p == nil { + if p == nil || failedReplacement { return "", 0, "", false } if time.Since(time.UnixMilli(p.GeneratedAtMs)) > MaxPlanAge { @@ -1117,6 +1174,61 @@ func (s *Service) ReplanWithReason(ctx context.Context, reason string) *Plan { return s.replan(ctx, reason) } +// RequestReplan registers the new generation before returning and computes it +// in the background. A saved setting must not wait for the planner to finish. +// One worker keeps only the latest waiting request. Cancellation may not stop +// a Go solve already in progress, so starting a worker per edit would pile up +// obsolete solves. Stop waits for accepted work, including the queued request. +func (s *Service) RequestReplan(reason string) { + if s == nil { + return + } + s.mu.Lock() + request := s.beginReplanLocked(context.Background(), reason) + if !request.accepted { + s.mu.Unlock() + request.cancel() + return + } + previous := s.queuedReplan + s.queuedReplan = &request + startWorker := !s.requestedReplanRunning + s.requestedReplanRunning = true + s.mu.Unlock() + if previous != nil { + s.finishReplan(*previous) + } + if startWorker { + go s.runRequestedReplans() + } +} + +func (s *Service) runRequestedReplans() { + for { + s.mu.Lock() + request := s.queuedReplan + s.queuedReplan = nil + if request == nil { + s.requestedReplanRunning = false + s.mu.Unlock() + return + } + s.mu.Unlock() + s.runReplan(*request) + } +} + +// IsReplanning reports whether the newest request is queued or running, +// including diagnostic writes after its plan has been published. +func (s *Service) IsReplanning() bool { + if s == nil { + return false + } + s.mu.RLock() + defer s.mu.RUnlock() + return s.activeReplanCancel != nil +} + func (s *Service) replan(ctx context.Context, reason string) *Plan { return s.runReplan(s.beginReplan(ctx, reason)) } @@ -1181,6 +1293,12 @@ func (s *Service) finishReplan(request replanRequest) { s.mu.Lock() if request.generation == s.latestReplanGeneration { s.activeReplanCancel = nil + if s.publishedReplanGeneration != request.generation { + // Keep the last plan during a normal recalculation, but stop using + // it when the replacement fails. A later retry must publish a new + // plan before dispatch resumes; queuing work alone is not enough. + s.failedReplanGeneration = request.generation + } } s.mu.Unlock() } @@ -1694,6 +1812,7 @@ func (s *Service) runReplan(request replanRequest) *Plan { s.lastReplanAt = time.Now() s.plannedPredictions = pp s.lastReason = request.reason + s.publishedReplanGeneration = request.generation reason := request.reason replanAtMs := s.lastReplanAt.UnixMilli() saveDiag := s.SaveDiag diff --git a/go/internal/mpc/service_requested_replan_test.go b/go/internal/mpc/service_requested_replan_test.go new file mode 100644 index 00000000..b1c41b11 --- /dev/null +++ b/go/internal/mpc/service_requested_replan_test.go @@ -0,0 +1,273 @@ +package mpc + +import ( + "context" + "encoding/json" + "fmt" + "sync" + "testing" + "time" + + "github.com/srcfl/ftw/go/internal/loadpoint" +) + +func TestPlanSnapshotFailedRequestDoesNotMakeOldPlanCurrent(t *testing.T) { + svc := newCancellationTestService(t, nil) + now := time.Now() + svc.InstallPlan(Plan{GeneratedAtMs: now.UnixMilli(), Actions: []Action{{ + SlotStartMs: now.UnixMilli(), SlotLenMin: 60, LoadpointW: 6000, + }}}, svc.Defaults, "garage") + before := svc.PlanSnapshot() + if windows, wh := before.LoadpointPlanWindows("garage", now, 0); len(windows) != 1 || wh != 6000 { + t.Fatalf("initial windows=%v wh=%v", windows, wh) + } + // Missing prices keeps the last result for diagnostics. It must neither + // display nor dispatch that result as the newly saved setting. + svc.Zone = "no-prices" + svc.RequestReplan("saved-goal") + waitForRequestedReplans(t, svc) + failed := svc.PlanSnapshot() + if failed.Pending || !failed.Outdated || failed.Plan != before.Plan || !failed.ReplanAt.Equal(before.ReplanAt) || failed.Reason != before.Reason { + t.Fatalf("failed request freshness=%+v", failed) + } + if windows, wh := failed.LoadpointPlanWindows("garage", now, 0); len(windows) != 0 || wh != 0 { + t.Fatalf("failed new request exposes old windows=%v wh=%v", windows, wh) + } + assertPlanDispatchAvailable(t, svc, now, false) + + svc.Zone = "SE3" + svc.RequestReplan("retry-goal") + waitForRequestedReplans(t, svc) + after := svc.PlanSnapshot() + if after.Pending || after.Outdated || after.Plan == nil || after.Plan == before.Plan || after.Reason != "retry-goal" { + t.Fatalf("successful retry freshness=%+v", after) + } + assertPlanDispatchAvailable(t, svc, time.UnixMilli(after.Plan.Actions[0].SlotStartMs), true) + // A caller that already captured the old snapshot keeps its own matching + // plan and legacy loadpoint ID even after the service publishes another. + if windows, wh := before.LoadpointPlanWindows("garage", now, 0); len(windows) != 1 || wh != 6000 { + t.Fatalf("captured snapshot changed with the service: windows=%v wh=%v", windows, wh) + } +} + +func assertPlanDispatchAvailable(t *testing.T, svc *Service, now time.Time, want bool) { + t.Helper() + if _, ok := svc.SlotDirectiveAt(now); ok != want { + t.Fatalf("energy dispatch available=%v, want %v", ok, want) + } + if _, _, _, ok := svc.SlotAt(now); ok != want { + t.Fatalf("legacy dispatch available=%v, want %v", ok, want) + } +} + +func TestPendingReplanKeepsPlanUntilFailureButRetryCannotReviveIt(t *testing.T) { + svc := newCancellationTestService(t, nil) + now := time.Now() + svc.InstallPlan(Plan{GeneratedAtMs: now.UnixMilli(), Actions: []Action{{ + SlotStartMs: now.UnixMilli(), SlotLenMin: 60, BatteryW: 2000, LoadpointW: 6000, + }}}, svc.Defaults, "garage") + request := svc.beginReplan(context.Background(), "saved-goal") + assertPlanDispatchAvailable(t, svc, now, true) + // Finishing without publishing is the same path as missing prices or a + // rejected solve. A retry alone is not a fresh, validated plan. + svc.finishReplan(request) + assertPlanDispatchAvailable(t, svc, now, false) + retry := svc.beginReplan(context.Background(), "retry-goal") + assertPlanDispatchAvailable(t, svc, now, false) + svc.finishReplan(retry) +} + +func TestFailedGoalReplanStopsOldEVAllocationButKeepsManualControl(t *testing.T) { + svc := newCancellationTestService(t, nil) + now := time.Now() + svc.InstallPlan(Plan{GeneratedAtMs: now.UnixMilli(), Actions: []Action{{ + SlotStartMs: now.UnixMilli(), SlotLenMin: 60, LoadpointW: 6000, + }}}, svc.Defaults, "garage") + mgr := loadpoint.NewManager() + mgr.Load([]loadpoint.Config{{ID: "garage", DriverName: "charger", VehicleCapacityWh: 60000, + MinChargeW: 4140, MaxChargeW: 11000, PhaseMode: "3p"}}) + mgr.SetSchedule("garage", loadpoint.Schedule{SoC: .9, TimeOfDayMinUTC: 7 * 60, Recurring: true}) + var sent []float64 + ctrl := loadpoint.NewController(mgr, func(at time.Time) (loadpoint.Directive, bool) { + d, ok := svc.SlotDirectiveAt(at) + return d.LoadpointDirective(), ok + }, func(string) (loadpoint.EVSample, bool) { + return loadpoint.EVSample{Connected: true, RequestActive: true, DeviceID: "easee:A", SessionID: "session-1"}, true + }, func(_ context.Context, _ string, payload []byte) error { + var command struct { + PowerW float64 `json:"power_w"` + } + if err := json.Unmarshal(payload, &command); err != nil { + return err + } + sent = append(sent, command.PowerW) + return nil + }) + ctrl.SetSiteFuse(loadpoint.SiteFuse{MaxAmps: 16, Voltage: 230, PhaseCnt: 3}) + tickPower := func() float64 { + t.Helper() + before := len(sent) + ctrl.Tick(context.Background(), now) + now = now.Add(time.Second) + if len(sent) != before+1 { + t.Fatalf("tick sent %d commands", len(sent)-before) + } + return sent[len(sent)-1] + } + if got := tickPower(); got <= 0 { + t.Fatalf("initial plan did not charge: %v", got) + } + mgr.ClearSchedule("garage") + svc.Zone = "no-prices" + svc.RequestReplan("schedule_cleared") + waitForRequestedReplans(t, svc) + if got := tickPower(); got != 0 { + t.Fatalf("removed goal still dispatched %v W", got) + } + ctrl.SetManualHold("garage", loadpoint.ManualHold{PowerW: 4140, Persistent: true}) + if got := tickPower(); got != 4140 { + t.Fatalf("manual Start lost during failed plan: %v W", got) + } + ctrl.SetManualHold("garage", loadpoint.ManualHold{Persistent: true}) + if got := tickPower(); got != 0 { + t.Fatalf("manual Pause lost during failed plan: %v W", got) + } +} + +func TestPlanSnapshotIsCurrentBeforeDiagnosticWriteFinishes(t *testing.T) { + svc := newCancellationTestService(t, nil) + entered, release := make(chan struct{}), make(chan struct{}) + var once sync.Once + unblock := func() { once.Do(func() { close(release) }) } + t.Cleanup(func() { unblock(); waitForRequestedReplans(t, svc) }) + svc.SaveDiag = func(*Diagnostic, string) error { + close(entered) + <-release + return nil + } + svc.RequestReplan("saved-soc") + select { + case <-entered: + case <-time.After(time.Second): + t.Fatal("request did not publish its plan") + } + snapshot := svc.PlanSnapshot() + if snapshot.Plan == nil || snapshot.ReplanAt.IsZero() || snapshot.Reason != "saved-soc" || snapshot.Pending || snapshot.Outdated { + t.Fatalf("published snapshot=%+v", snapshot) + } + if !svc.IsReplanning() { + t.Fatal("work waiter lost the outstanding diagnostic write") + } +} + +func TestRequestReplanCoalescesEditsWhileOldSolveIsBlocked(t *testing.T) { + optimizer := &blockingFirstOptimizer{ + firstMode: make(chan Mode, 1), secondMode: make(chan Mode, 1), releaseFirst: make(chan struct{}), + } + svc := newCancellationTestService(t, optimizer) + var once sync.Once + unblock := func() { once.Do(func() { close(optimizer.releaseFirst) }) } + t.Cleanup(func() { unblock(); waitForRequestedReplans(t, svc) }) + svc.RequestReplan("first") + select { + case <-optimizer.firstMode: + case <-time.After(time.Second): + t.Fatal("first request did not reach optimizer") + } + for i := 0; i < 100; i++ { + svc.RequestReplan(fmt.Sprintf("edit-%d", i)) + } + if calls := optimizer.calls.Load(); calls != 1 { + t.Fatalf("edits started %d parallel solves while the old one was blocked", calls) + } + if snapshot := svc.PlanSnapshot(); !snapshot.Pending || !snapshot.Outdated || snapshot.Plan != nil { + t.Fatalf("pending edits freshness=%+v", snapshot) + } + unblock() + waitForRequestedReplans(t, svc) + if calls := optimizer.calls.Load(); calls != 2 { + t.Fatalf("optimizer calls=%d, want old request and latest edit only", calls) + } + snapshot := svc.PlanSnapshot() + if snapshot.Plan == nil || snapshot.Pending || snapshot.Outdated || snapshot.Reason != "edit-99" { + t.Fatalf("latest edit not published: %+v", snapshot) + } +} + +func TestStopWaitsForRequestedReplanAndDiscardsQueuedEdits(t *testing.T) { + optimizer := &stopWaitOptimizer{ + secondStarted: make(chan struct{}), secondCanceled: make(chan struct{}), + releaseSecond: make(chan struct{}), closed: make(chan struct{}), + } + svc := newCancellationTestService(t, optimizer) + var once sync.Once + unblock := func() { once.Do(func() { close(optimizer.releaseSecond) }) } + svc.Start(context.Background()) + t.Cleanup(func() { unblock(); svc.Stop() }) + deadline := time.Now().Add(time.Second) + for svc.Latest() == nil || svc.IsReplanning() { + if time.Now().After(deadline) { + t.Fatal("initial scheduled plan did not finish") + } + time.Sleep(time.Millisecond) + } + svc.RequestReplan("blocked-edit") + select { + case <-optimizer.secondStarted: + case <-time.After(time.Second): + t.Fatal("requested plan did not reach optimizer") + } + for i := 0; i < 10; i++ { + svc.RequestReplan("queued-edit") + } + stopped := make(chan struct{}) + go func() { svc.Stop(); close(stopped) }() + deadline = time.Now().Add(time.Second) + for { + svc.mu.RLock() + stopping := svc.stopping + svc.mu.RUnlock() + if stopping { + break + } + if time.Now().After(deadline) { + t.Fatal("Stop did not begin") + } + time.Sleep(time.Millisecond) + } + select { + case <-stopped: + t.Fatal("Stop returned while the optimizer still ran") + default: + } + unblock() + select { + case <-stopped: + case <-time.After(time.Second): + t.Fatal("Stop did not drain the requested replan queue") + } + svc.RequestReplan("after-stop") + if optimizer.closedWhileActive.Load() || optimizer.calls.Load() != 2 { + t.Fatalf("unsafe stop: closed while active=%v, calls=%d", optimizer.closedWhileActive.Load(), optimizer.calls.Load()) + } + if snapshot := svc.PlanSnapshot(); snapshot.Pending || !snapshot.Outdated { + t.Fatalf("stopped request freshness=%+v", snapshot) + } +} + +func waitForRequestedReplans(t *testing.T, svc *Service) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for { + svc.mu.RLock() + idle := !svc.requestedReplanRunning && svc.queuedReplan == nil + svc.mu.RUnlock() + if idle { + return + } + if time.Now().After(deadline) { + t.Fatal("requested replans did not finish") + } + time.Sleep(time.Millisecond) + } +} diff --git a/go/internal/notifications/catalogue_gen.go b/go/internal/notifications/catalogue_gen.go index c9c1e5ae..96a3415a 100644 --- a/go/internal/notifications/catalogue_gen.go +++ b/go/internal/notifications/catalogue_gen.go @@ -8,6 +8,7 @@ const PushCatalogueVersion = 1 // Push event kinds. Hand-writing one of these strings is what the // catalogue exists to stop. const ( + PushChargingConnected = "charging.connected" PushChargingSessionComplete = "charging.session_complete" PushChargingInterrupted = "charging.interrupted" PushUpdateInstalled = "update.installed" @@ -25,6 +26,7 @@ type PushSentence struct { // PushSentences is every sentence a push may carry, keyed by kind. var PushSentences = map[string]PushSentence{ + PushChargingConnected: {Title: "Car plugged in", Body: "Open to see charging status, check the battery level and set a ready time."}, PushChargingSessionComplete: {Title: "Car charged", Body: "{kwh} kWh delivered — ready to go."}, PushChargingInterrupted: {Title: "Charging stopped early", Body: "The car stopped charging before it was done."}, PushUpdateInstalled: {Title: "Your box updated itself", Body: "Now running {version}. Everything came back on its own."}, diff --git a/go/internal/notifications/service.go b/go/internal/notifications/service.go index 0cfd2575..2e6ae2ed 100644 --- a/go/internal/notifications/service.go +++ b/go/internal/notifications/service.go @@ -94,6 +94,7 @@ func DefaultRules() []config.NotificationRule { // fires once per plug-in. interrupted keeps an hour on top of the // emitter's hysteresis, because a charger failing repeatedly is // one fact, not a feed. + {Type: PushChargingConnected, Enabled: false, Priority: 3, CooldownS: 60}, {Type: PushChargingSessionComplete, Enabled: false, Priority: 3}, {Type: PushChargingInterrupted, Enabled: false, Priority: 4, CooldownS: 3600}, {Type: PushUpdateInstalled, Enabled: false, Priority: 2}, @@ -114,7 +115,7 @@ func KnownRuleTypes() []string { return []string{ EventDriverOffline, EventDriverRecovered, EventUpdateAvailable, EventFuseOverLimit, EventConcurrentDriversOffline, - PushChargingSessionComplete, PushChargingInterrupted, + PushChargingConnected, PushChargingSessionComplete, PushChargingInterrupted, PushUpdateInstalled, PushDriverOffline, PushFuseOverLimit, } } @@ -131,10 +132,12 @@ type FuseReader = func() (amps map[string]float64, limitA float64, ok bool) // Message is a rendered notification payload. type Message struct { - Title string - Body string - Priority int - Tags []string + Kind string + LoadpointID string + Title string + Body string + Priority int + Tags []string } // Publisher dispatches a rendered Message to its transport. @@ -386,6 +389,11 @@ func (s *Service) Subscribe(bus *events.Bus) { // real-world moment — the loadpoint latches, the boot version check — // so the rule adds only the operator's gate: enabled, priority, // cooldown. Words come from the catalogue and nowhere else. + bus.Subscribe(events.KindChargingConnected, func(e events.Event) { + if ev, ok := e.(events.ChargingConnected); ok { + go s.handleCatalogued(PushChargingConnected, ev.LoadpointID, nil) + } + }) bus.Subscribe(events.KindChargingSessionComplete, func(e events.Event) { ev, ok := e.(events.ChargingSessionComplete) if !ok { @@ -452,7 +460,12 @@ func (s *Service) handleCatalogued(kind, device string, args map[string]string) s.emitDispatched(kind, device, Message{Priority: prio}, "failed", err.Error()) return } + loadpointID := "" + if strings.HasPrefix(kind, "charging.") { + loadpointID = device + } s.deliver(kind, device, Message{ + Kind: kind, LoadpointID: loadpointID, Title: title, Body: body, Priority: prio, diff --git a/go/internal/notifications/service_push_test.go b/go/internal/notifications/service_push_test.go index 5fbf063a..6d960e66 100644 --- a/go/internal/notifications/service_push_test.go +++ b/go/internal/notifications/service_push_test.go @@ -212,3 +212,28 @@ func TestEngineOwnedPublisherSurvivesReload(t *testing.T) { bus.Publish(events.ChargingSessionComplete{LoadpointID: "garage", KWh: 1.0, At: time.Now()}) waitForMsgs(t, push, 1) } + +func TestConnectedPushNeedsOptInAndKeepsChargerDestination(t *testing.T) { + pub := &fakePub{published: make(chan struct{}, 4)} + svc, clk := newSvc(pushCfg(config.NotificationRule{Type: PushChargingConnected, Enabled: false, CooldownS: 60}), pub) + bus := events.NewBus() + svc.Subscribe(bus) + bus.Publish(events.ChargingConnected{LoadpointID: "garage", At: clk.now()}) + settle(svc) + if len(pub.Messages()) != 0 { + t.Fatal("sent without opt-in") + } + svc.Reload(pushCfg(config.NotificationRule{Type: PushChargingConnected, Enabled: true, CooldownS: 60})) + bus.Publish(events.ChargingConnected{LoadpointID: "garage", At: clk.now()}) + msgs := waitForMsgs(t, pub, 1) + if msgs[0].Kind != PushChargingConnected || msgs[0].LoadpointID != "garage" || msgs[0].Title != "Car plugged in" { + t.Fatalf("wrong message: %+v", msgs[0]) + } + bus.Publish(events.ChargingConnected{LoadpointID: "garage", At: clk.now()}) + settle(svc) + if len(pub.Messages()) != 1 { + t.Fatal("repeated inside cooldown") + } + bus.Publish(events.ChargingConnected{LoadpointID: "street", At: clk.now()}) + waitForMsgs(t, pub, 2) +} diff --git a/go/internal/notifications/webpush.go b/go/internal/notifications/webpush.go index e228e5d8..8d905d7a 100644 --- a/go/internal/notifications/webpush.go +++ b/go/internal/notifications/webpush.go @@ -151,7 +151,7 @@ func (w *WebPush) Publish(ctx context.Context, m Message) error { return ErrNothingToSend } - payload, err := json.Marshal(map[string]string{"title": m.Title, "body": m.Body}) + payload, err := json.Marshal(map[string]string{"title": m.Title, "body": m.Body, "kind": m.Kind, "loadpoint_id": m.LoadpointID}) if err != nil { return fmt.Errorf("webpush: encode payload: %w", err) } diff --git a/go/internal/notifications/webpush_test.go b/go/internal/notifications/webpush_test.go index cfa60a08..7ad43b6a 100644 --- a/go/internal/notifications/webpush_test.go +++ b/go/internal/notifications/webpush_test.go @@ -255,7 +255,7 @@ func TestPublishCarriesVAPIDAndDecryptablePayload(t *testing.T) { if err != nil { t.Fatal(err) } - if err := wp.Publish(context.Background(), Message{Title: "Car charged", Body: "7.4 kWh delivered — ready to go.", Priority: 3}); err != nil { + if err := wp.Publish(context.Background(), Message{Title: "Car charged", Body: "7.4 kWh delivered — ready to go.", Priority: 3, Kind: PushChargingSessionComplete, LoadpointID: "garage"}); err != nil { t.Fatalf("publish: %v", err) } @@ -279,6 +279,10 @@ func TestPublishCarriesVAPIDAndDecryptablePayload(t *testing.T) { t.Fatalf("payload = %v", payload) } + if payload["kind"] != PushChargingSessionComplete || payload["loadpoint_id"] != "garage" { + t.Fatalf("charger destination lost in encryption: %v", payload) + } + // The Authorization header verifies against the published key. auth := r.headers.Get("Authorization") if !strings.HasPrefix(auth, "vapid t=") { diff --git a/go/internal/ocpp/capabilities.go b/go/internal/ocpp/capabilities.go index 2a3d641b..091edc15 100644 --- a/go/internal/ocpp/capabilities.go +++ b/go/internal/ocpp/capabilities.go @@ -40,22 +40,31 @@ func profilesIncludeSmartCharging(csv string) bool { // probeFeatureProfiles16 asks a 1.6 charge point for its feature profiles. // Async: the library delivers the confirmation on its own goroutine. -func probeFeatureProfiles16(cs ocpp16.CentralSystem, h *Handler, id string) { - err := cs.GetConfiguration(id, func(conf *core.GetConfigurationConfirmation, err error) { - defer h.probeFinished(id) - if err != nil || conf == nil { - slog.Info("ocpp: charger did not answer GetConfiguration — control capability stays unknown", - "charger", id, "err", err) - return - } - for _, kv := range conf.ConfigurationKey { - if kv.Key == "SupportedFeatureProfiles" && kv.Value != nil { - h.setControlCapability(id, *kv.Value, profilesIncludeSmartCharging(*kv.Value)) - return +func probeFeatureProfiles16(cs ocpp16.CentralSystem, h *Handler, id string, sockets *socketSessions) { + alias, err := sockets.currentID(id) + if err != nil { + h.probeFinished(id) + return + } + err = cs.GetConfiguration(alias, func(conf *core.GetConfigurationConfirmation, err error) { + _, _ = boundCall(sockets, alias, func(id string) (bool, error) { + defer h.probeFinished(id) + if err != nil || conf == nil { + slog.Info("ocpp: charger did not answer GetConfiguration — control capability stays unknown", + "charger", id, "err", err) + return false, nil } - } - slog.Info("ocpp: charger answered GetConfiguration without SupportedFeatureProfiles — control capability stays unknown", - "charger", id) + for _, kv := range conf.ConfigurationKey { + if kv.Key == "SupportedFeatureProfiles" && kv.Value != nil { + h.setControlCapability(id, *kv.Value, profilesIncludeSmartCharging(*kv.Value)) + return true, nil + } + } + slog.Info("ocpp: charger answered GetConfiguration without SupportedFeatureProfiles — control capability stays unknown", + "charger", id) + + return true, nil + }) }, []string{"SupportedFeatureProfiles"}) if err != nil { // The request never left, so no callback will clear the marker. @@ -66,22 +75,31 @@ func probeFeatureProfiles16(cs ocpp16.CentralSystem, h *Handler, id string) { // probeSmartChargingV201 asks a 2.0.1 station whether SmartChargingCtrlr is // available — the 2.0.1 shape of the SmartCharging feature profile. -func probeSmartChargingV201(csms ocpp201.CSMS, h *Handler, id string) { - err := csms.GetVariables(id, func(resp *provisioning.GetVariablesResponse, err error) { - defer h.probeFinished(id) - if err != nil || resp == nil || len(resp.GetVariableResult) == 0 { - slog.Info("ocpp: station did not answer GetVariables — control capability stays unknown", - "charger", id, "err", err) - return - } - r := resp.GetVariableResult[0] - if r.AttributeStatus != provisioning.GetVariableStatusAccepted { - slog.Info("ocpp: SmartChargingCtrlr not reported — control capability stays unknown", - "charger", id, "status", r.AttributeStatus) - return - } - available := strings.EqualFold(strings.TrimSpace(r.AttributeValue), "true") - h.setControlCapability(id, "SmartChargingCtrlr.Available="+strings.TrimSpace(r.AttributeValue), available) +func probeSmartChargingV201(csms ocpp201.CSMS, h *Handler, id string, sockets *socketSessions) { + alias, err := sockets.currentID(id) + if err != nil { + h.probeFinished(id) + return + } + err = csms.GetVariables(alias, func(resp *provisioning.GetVariablesResponse, err error) { + _, _ = boundCall(sockets, alias, func(id string) (bool, error) { + defer h.probeFinished(id) + if err != nil || resp == nil || len(resp.GetVariableResult) == 0 { + slog.Info("ocpp: station did not answer GetVariables — control capability stays unknown", + "charger", id, "err", err) + return false, nil + } + r := resp.GetVariableResult[0] + if r.AttributeStatus != provisioning.GetVariableStatusAccepted { + slog.Info("ocpp: SmartChargingCtrlr not reported — control capability stays unknown", + "charger", id, "status", r.AttributeStatus) + return false, nil + } + available := strings.EqualFold(strings.TrimSpace(r.AttributeValue), "true") + h.setControlCapability(id, "SmartChargingCtrlr.Available="+strings.TrimSpace(r.AttributeValue), available) + + return true, nil + }) }, []provisioning.GetVariableData{{ Component: types201.Component{Name: "SmartChargingCtrlr"}, Variable: types201.Variable{Name: "Available"}, diff --git a/go/internal/ocpp/control.go b/go/internal/ocpp/control.go index 2e39ae3c..a39fd015 100644 --- a/go/internal/ocpp/control.go +++ b/go/internal/ocpp/control.go @@ -211,7 +211,11 @@ func (s *Server) setLimit(ctx context.Context, id string, amps float64, numberPh amps = 0 } - r, err := s.attemptLimit(ctx, id, amps, numberPhases, allConnectors) + alias, err := s.sockets.currentID(id) + if err != nil { + return err + } + r, err := s.attemptLimit(ctx, id, alias, amps, numberPhases, allConnectors) if err != nil { return err } @@ -225,7 +229,7 @@ func (s *Server) setLimit(ctx context.Context, id string, amps float64, numberPh if r.answered && !r.accepted { slog.Info("ocpp: charger refused a charge-point-wide profile, retrying on connector 1", "charger", id, "status", r.status) - retry, retryErr := s.attemptLimit(ctx, id, amps, numberPhases, firstConnector) + retry, retryErr := s.attemptLimit(ctx, id, alias, amps, numberPhases, firstConnector) if retryErr != nil { return retryErr } @@ -245,8 +249,14 @@ func (s *Server) setLimit(ctx context.Context, id string, amps float64, numberPh // from a pause would erase the rate a later resume is supposed to // restore, and the charger would come back at the fallback ceiling // instead of where it left off. - if amps > 0 { - s.handler.SetLastAmps(id, amps) + _, err = boundCall(s.sockets, alias, func(id string) (bool, error) { + if amps > 0 { + s.handler.SetLastAmps(id, amps) + } + return true, nil + }) + if err != nil { + return err } slog.Info("ocpp: charging limit applied", "charger", id, "amps", amps) return nil @@ -257,7 +267,7 @@ func (s *Server) setLimit(ctx context.Context, id string, amps float64, numberPh // The returned profileResult carries the charger's verdict, including a // refusal; the error is reserved for the cases where no verdict exists — // transport failure, cancellation, silence. -func (s *Server) attemptLimit(ctx context.Context, id string, amps float64, numberPhases *int, connectorID int) (profileResult, error) { +func (s *Server) attemptLimit(ctx context.Context, id, alias string, amps float64, numberPhases *int, connectorID int) (profileResult, error) { // Buffered: the library's callback must never block if we have already // stopped waiting. done := make(chan profileResult, 1) @@ -267,9 +277,9 @@ func (s *Server) attemptLimit(ctx context.Context, id string, amps float64, numb var err error switch version, _ := s.handler.Version(id); version { case Version201: - err = s.sendProfileV201(id, amps, numberPhases, connectorID, done) + err = s.sendProfileV201(alias, amps, numberPhases, connectorID, done) default: - err = s.sendProfileV16(id, amps, numberPhases, connectorID, done) + err = s.sendProfileV16(alias, amps, numberPhases, connectorID, done) } if err != nil { return profileResult{}, fmt.Errorf("ocpp: send charging profile to %s: %w", id, err) diff --git a/go/internal/ocpp/handlers.go b/go/internal/ocpp/handlers.go index 2a920518..4b523953 100644 --- a/go/internal/ocpp/handlers.go +++ b/go/internal/ocpp/handlers.go @@ -56,6 +56,11 @@ type Handler struct { // the connect and boot triggers — which arrive milliseconds apart, long // before any answer — ask once between them rather than twice. probing map[string]bool + // identityProbe asks for a fresh BootNotification; only that message can + // establish hardware identity on a new socket. See identity_probe.go. + identityProbe func(string, Version, func(error)) error + identityProbeTiming identityProbeTiming + identityProbeStopped bool } // chargerState is what we accumulate from successive OCPP messages for one @@ -90,6 +95,14 @@ type chargerState struct { model string serial string firmware string + // identityCurrent is set only by BootNotification on this connection. + connectionGeneration uint64 // process-local socket epoch; never a physical session ID + connectedKnown bool // physical status received on the current socket + identityCurrent bool + identityProbeTimer *time.Timer + identityProbeEpoch uint64 + identityProbeAttempts int + identityProbeAfter time.Time // vehicleID is the identity presented when the current/last transaction // started: the RFID idTag on 1.6, or a 2.0.1 idToken — where the token // type MacAddress (autocharge) or eMAID (ISO 15118) names the actual @@ -115,12 +128,13 @@ type chargerState struct { // BootNotification confirmation. func NewHandler(tel *telemetry.Store, heartbeatIntervalS int) *Handler { return &Handler{ - tel: tel, - heartbeatIntervalS: heartbeatIntervalS, - chargers: map[string]*chargerState{}, - approved: map[string]bool{}, - probing: map[string]bool{}, - nextTxID: 1, + tel: tel, + heartbeatIntervalS: heartbeatIntervalS, + chargers: map[string]*chargerState{}, + approved: map[string]bool{}, + probing: map[string]bool{}, + identityProbeTiming: defaultIdentityProbeTiming, + nextTxID: 1, } } @@ -145,12 +159,16 @@ func (h *Handler) SetApprovedIDs(ids []string) { } h.mu.Lock() var revoked []string - for id := range h.chargers { + for id, charger := range h.chargers { if h.approved[id] && !m[id] { revoked = append(revoked, id) + h.cancelIdentityProbeLocked(charger) } } h.approved = m + for id, charger := range h.chargers { + h.scheduleIdentityProbeLocked(id, charger) + } h.mu.Unlock() for _, id := range revoked { blob, _ := json.Marshal(map[string]any{"type": "ev", "w": 0.0}) @@ -364,7 +382,18 @@ func (h *Handler) OnConnect(id string) { s := h.state(id) h.mu.Lock() s.online = true + s.connectionGeneration++ + s.connectedKnown = false + s.charging = false + s.lastPowerW = 0 + s.identityCurrent = false + s.featureProfiles = "" + s.steerable = nil + h.cancelIdentityProbeLocked(s) + s.identityProbeAttempts = 0 + h.scheduleIdentityProbeLocked(id, s) h.mu.Unlock() + h.pushReading(id, s) h.telSuccess(id) h.maybeProbeCapability(id) } @@ -378,7 +407,9 @@ func (h *Handler) OnDisconnect(id string) { // reconnect is exactly when we want to ask again, so clear it here. delete(h.probing, id) s.online = false - s.connected = false + s.identityCurrent = false + h.cancelIdentityProbeLocked(s) + s.connectedKnown = false s.charging = false s.lastPowerW = 0 h.mu.Unlock() @@ -409,6 +440,8 @@ func (h *Handler) OnBootNotification(id string, req *core.BootNotificationReques s.vendor = req.ChargePointVendor s.model = req.ChargePointModel s.serial = serial + s.identityCurrent = true + h.cancelIdentityProbeLocked(s) s.firmware = req.FirmwareVersion h.mu.Unlock() h.noteIdentity(id) @@ -444,6 +477,7 @@ func (h *Handler) OnDataTransfer(id string, req *core.DataTransferRequest) (*cor func (h *Handler) OnStatusNotification(id string, req *core.StatusNotificationRequest) (*core.StatusNotificationConfirmation, error) { s := h.state(id) h.mu.Lock() + s.connectedKnown = true switch req.Status { case core.ChargePointStatusAvailable, core.ChargePointStatusUnavailable: s.connected = false @@ -522,6 +556,7 @@ func (h *Handler) OnStartTransaction(id string, req *core.StartTransactionReques s.sessionStartMeterWh = float64(req.MeterStart) s.sessionMeterWh = 0 s.connected = true + s.connectedKnown = true s.charging = true h.mu.Unlock() @@ -578,10 +613,19 @@ func (h *Handler) pushReading(id string, s *chargerState) { data := map[string]any{ "type": "ev", "w": w, - "connected": s.connected, "charging": s.charging, "session_wh": s.sessionMeterWh, } + data["connection_generation"] = s.connectionGeneration + if s.online && s.connectedKnown { + data["connected"] = s.connected + } else { + // A socket transition says nothing about the physical cable. Keep + // the zero out of manual-command acknowledgements in both clients. + data["connection_unknown"] = true + data["is_online"] = false + data["reason_no_current_label"] = "Waiting for charger connection status" + } h.mu.Unlock() if !approved { // Pending charger: visible in Snapshot, absent from the site model. diff --git a/go/internal/ocpp/handlers_v201.go b/go/internal/ocpp/handlers_v201.go index 5b9e0520..26054bc3 100644 --- a/go/internal/ocpp/handlers_v201.go +++ b/go/internal/ocpp/handlers_v201.go @@ -57,6 +57,8 @@ func (h *handlerV201) OnBootNotification(id string, req *provisioning.BootNotifi s.vendor = vendor s.model = model s.serial = serial + s.identityCurrent = true + h.cancelIdentityProbeLocked(s) if req != nil && req.ChargingStation.FirmwareVersion != "" { s.firmware = req.ChargingStation.FirmwareVersion } @@ -94,6 +96,7 @@ func (h *handlerV201) OnHeartbeat(id string, _ *availability.HeartbeatRequest) ( func (h *handlerV201) OnStatusNotification(id string, req *availability.StatusNotificationRequest) (*availability.StatusNotificationResponse, error) { s := h.state(id) h.mu.Lock() + s.connectedKnown = true switch req.ConnectorStatus { case availability.ConnectorStatusAvailable, availability.ConnectorStatusUnavailable: s.connected = false @@ -101,10 +104,12 @@ func (h *handlerV201) OnStatusNotification(id string, req *availability.StatusNo s.lastPowerW = 0 case availability.ConnectorStatusOccupied, availability.ConnectorStatusReserved: s.connected = true + s.connectedKnown = true case availability.ConnectorStatusFaulted: // Matches the 1.6 path: a faulted connector still has a cable in it, // so it stays connected while charging stops. s.connected = true + s.connectedKnown = true s.charging = false s.lastPowerW = 0 } @@ -146,10 +151,12 @@ func (h *handlerV201) OnTransactionEvent(id string, req *transactions.Transactio s.sessionStartMeterWh = energyWh s.sessionMeterWh = 0 s.connected = true + s.connectedKnown = true s.charging = true case transactions.TransactionEventUpdated: s.connected = true + s.connectedKnown = true if hasEnergy && s.transactionID >= 0 { s.sessionMeterWh = energyWh - s.sessionStartMeterWh } diff --git a/go/internal/ocpp/identity.go b/go/internal/ocpp/identity.go index 593ff969..5497ca3d 100644 --- a/go/internal/ocpp/identity.go +++ b/go/internal/ocpp/identity.go @@ -101,3 +101,18 @@ func (h *Handler) noteIdentity(id string) { } fn(ident) } + +// CurrentIdentity returns only an adopted charger's identity reported on its +// current connection. Historical UI labels cannot bind a new control request. +func (h *Handler) CurrentIdentity(id string) (ChargerIdentity, bool) { + if h == nil { + return ChargerIdentity{}, false + } + h.mu.Lock() + defer h.mu.Unlock() + s := h.chargers[id] + if s == nil || !h.approved[id] || !s.online || !s.identityCurrent { + return ChargerIdentity{}, false + } + return ChargerIdentity{ID: id, Vendor: s.vendor, Model: s.model, Serial: s.serial, Firmware: s.firmware}, true +} diff --git a/go/internal/ocpp/identity_current_test.go b/go/internal/ocpp/identity_current_test.go new file mode 100644 index 00000000..513d311f --- /dev/null +++ b/go/internal/ocpp/identity_current_test.go @@ -0,0 +1,54 @@ +package ocpp + +import ( + "testing" + + "github.com/lorenzodonini/ocpp-go/ocpp1.6/core" + "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/provisioning" + "github.com/srcfl/ftw/go/internal/telemetry" +) + +func TestCurrentIdentityRequiresBootOnThisConnection(t *testing.T) { + for _, dialect := range []string{"1.6", "2.0.1"} { + t.Run(dialect, func(t *testing.T) { + h := NewHandler(telemetry.NewStore(), 60) + h.SetApprovedIDs([]string{"garage"}) + boot := func(serial string) { + t.Helper() + if dialect == "1.6" { + _, err := h.OnBootNotification("garage", &core.BootNotificationRequest{ChargePointVendor: "Easee", ChargePointModel: "Home", ChargePointSerialNumber: serial}) + if err != nil { + t.Fatal(err) + } + } else { + req := provisioning.NewBootNotificationRequest(provisioning.BootReasonPowerUp, "Home", "Easee") + req.ChargingStation.SerialNumber = serial + _, err := (&handlerV201{h}).OnBootNotification("garage", req) + if err != nil { + t.Fatal(err) + } + } + } + h.OnConnect("garage") + boot("A") + if got, ok := h.CurrentIdentity("garage"); !ok || got.Serial != "A" { + t.Fatalf("boot not current: %+v %v", got, ok) + } + h.OnDisconnect("garage") + if _, ok := h.CurrentIdentity("garage"); ok { + t.Fatal("offline identity current") + } + h.OnConnect("garage") + if _, ok := h.CurrentIdentity("garage"); ok { + t.Fatal("previous socket's boot trusted on reconnect") + } + if got := h.Identities(); len(got) != 1 || got[0].Serial != "A" { + t.Fatal("historical UI identity lost") + } + boot("B") + if got, ok := h.CurrentIdentity("garage"); !ok || got.Serial != "B" { + t.Fatalf("new hardware not current: %+v %v", got, ok) + } + }) + } +} diff --git a/go/internal/ocpp/identity_probe.go b/go/internal/ocpp/identity_probe.go new file mode 100644 index 00000000..ca8e369a --- /dev/null +++ b/go/internal/ocpp/identity_probe.go @@ -0,0 +1,135 @@ +package ocpp + +import ( + "errors" + "fmt" + "log/slog" + "time" + + "github.com/lorenzodonini/ocpp-go/ocpp1.6/core" + "github.com/lorenzodonini/ocpp-go/ocpp1.6/remotetrigger" + "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/remotecontrol" +) + +type identityProbeTiming struct{ grace, timeout, interval time.Duration } + +var defaultIdentityProbeTiming = identityProbeTiming{time.Second, 10 * time.Second, time.Minute} + +const maxIdentityProbeAttempts = 3 + +var errIdentityProbeUnsupported = errors.New("BootNotification trigger is not supported") + +// One timer per charger covers the initial grace, response deadline and retry +// delay. The rate limit survives reconnects. Neither Accepted nor a historical +// serial establishes identity; only a fresh BootNotification does that. +func (h *Handler) scheduleIdentityProbeLocked(id string, s *chargerState) { + if h.identityProbeStopped || h.identityProbe == nil || !h.approved[id] || !s.online || + s.identityCurrent || s.identityProbeTimer != nil || s.identityProbeAttempts >= maxIdentityProbeAttempts { + return + } + delay := h.identityProbeTiming.grace + if remaining := time.Until(s.identityProbeAfter); remaining > delay { + delay = remaining + } + s.identityProbeEpoch++ + epoch := s.identityProbeEpoch + s.identityProbeTimer = time.AfterFunc(delay, func() { h.requestIdentity(id, epoch) }) +} + +func (h *Handler) cancelIdentityProbeLocked(s *chargerState) { + s.identityProbeEpoch++ + if s.identityProbeTimer != nil { + s.identityProbeTimer.Stop() + s.identityProbeTimer = nil + } +} + +func (h *Handler) requestIdentity(id string, epoch uint64) { + h.mu.Lock() + s := h.chargers[id] + if s == nil || s.identityProbeEpoch != epoch { + h.mu.Unlock() + return + } + s.identityProbeTimer = nil + if h.identityProbeStopped || !h.approved[id] || !s.online || s.identityCurrent { + h.mu.Unlock() + return + } + s.identityProbeAttempts++ + s.identityProbeAfter = time.Now().Add(h.identityProbeTiming.interval) + s.identityProbeTimer = time.AfterFunc(h.identityProbeTiming.timeout, func() { + h.identityProbeFailed(id, epoch, errors.New("fresh BootNotification did not arrive before the deadline")) + }) + probe, version := h.identityProbe, s.version + h.mu.Unlock() + if err := probe(id, version, func(err error) { + if err != nil { + h.identityProbeFailed(id, epoch, err) + } + }); err != nil { + h.identityProbeFailed(id, epoch, err) + } +} + +func (h *Handler) identityProbeFailed(id string, epoch uint64, err error) { + h.mu.Lock() + s := h.chargers[id] + if s == nil || s.identityProbeEpoch != epoch { + h.mu.Unlock() + return + } + h.cancelIdentityProbeLocked(s) + if errors.Is(err, errIdentityProbeUnsupported) { + s.identityProbeAttempts = maxIdentityProbeAttempts + } + h.scheduleIdentityProbeLocked(id, s) + h.mu.Unlock() + slog.Info("ocpp: charger identity remains unconfirmed", "charger", id, "err", err) +} + +func (h *Handler) stopIdentityProbes() { + h.mu.Lock() + defer h.mu.Unlock() + h.identityProbeStopped = true + for _, s := range h.chargers { + h.cancelIdentityProbeLocked(s) + } +} + +func (s *Server) requestBootNotification(id string, version Version, done func(error)) error { + alias, err := s.sockets.currentID(id) + if err != nil { + return err + } + if version == Version201 && s.csms != nil { + return s.csms.TriggerMessage(alias, func(reply *remotecontrol.TriggerMessageResponse, err error) { + if err != nil { + done(err) + return + } + if reply != nil && reply.Status == remotecontrol.TriggerMessageStatusAccepted { + return + } + if reply != nil && reply.Status == remotecontrol.TriggerMessageStatusNotImplemented { + done(errIdentityProbeUnsupported) + return + } + done(fmt.Errorf("BootNotification trigger rejected: %v", reply)) + }, remotecontrol.MessageTriggerBootNotification) + } + return s.cs.TriggerMessage(alias, func(reply *remotetrigger.TriggerMessageConfirmation, err error) { + if err != nil { + done(err) + return + } + if reply != nil && reply.Status == remotetrigger.TriggerMessageStatusAccepted { + return + } + if reply != nil && reply.Status == remotetrigger.TriggerMessageStatusNotImplemented { + done(errIdentityProbeUnsupported) + return + } + done(fmt.Errorf("BootNotification trigger rejected: %v", reply)) + }, remotetrigger.MessageTrigger(core.BootNotificationFeatureName)) +} diff --git a/go/internal/ocpp/identity_probe_test.go b/go/internal/ocpp/identity_probe_test.go new file mode 100644 index 00000000..07cdf30b --- /dev/null +++ b/go/internal/ocpp/identity_probe_test.go @@ -0,0 +1,283 @@ +package ocpp + +import ( + "encoding/json" + "fmt" + "testing" + "time" + + ocpp16 "github.com/lorenzodonini/ocpp-go/ocpp1.6" + "github.com/lorenzodonini/ocpp-go/ocpp1.6/core" + "github.com/lorenzodonini/ocpp-go/ocpp1.6/remotetrigger" + ocpp201 "github.com/lorenzodonini/ocpp-go/ocpp2.0.1" + "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/provisioning" + "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/remotecontrol" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +type bootTrigger16 struct { + requested chan string + unsupported bool +} + +func (h bootTrigger16) OnTriggerMessage(req *remotetrigger.TriggerMessageRequest) (*remotetrigger.TriggerMessageConfirmation, error) { + h.requested <- string(req.RequestedMessage) + status := remotetrigger.TriggerMessageStatusAccepted + if h.unsupported { + status = remotetrigger.TriggerMessageStatusNotImplemented + } + return remotetrigger.NewTriggerMessageConfirmation(status), nil +} + +type bootTrigger201 struct { + // Other remote-control requests are not used by this charger fixture. + remotecontrol.ChargingStationHandler + requested chan string + unsupported bool +} + +func (h bootTrigger201) OnTriggerMessage(req *remotecontrol.TriggerMessageRequest) (*remotecontrol.TriggerMessageResponse, error) { + h.requested <- string(req.RequestedMessage) + status := remotecontrol.TriggerMessageStatusAccepted + if h.unsupported { + status = remotecontrol.TriggerMessageStatusNotImplemented + } + return remotecontrol.NewTriggerMessageResponse(status), nil +} + +func connectIdentityCharger(t *testing.T, version Version, port int, unsupported bool) (stop func(), boot func(string) error, requested <-chan string) { + t.Helper() + requests := make(chan string, 10) + url := fmt.Sprintf("ws://127.0.0.1:%d", port) + if version == Version201 { + cp := ocpp201.NewChargingStation("garage", nil, nil) + cp.SetRemoteControlHandler(bootTrigger201{requested: requests, unsupported: unsupported}) + if err := cp.Start(url); err != nil { + t.Fatal(err) + } + return cp.Stop, func(serial string) error { + _, err := cp.BootNotification(provisioning.BootReasonTriggered, "Home", "Easee", func(req *provisioning.BootNotificationRequest) { req.ChargingStation.SerialNumber = serial }) + return err + }, requests + } + cp := ocpp16.NewChargePoint("garage", nil, nil) + cp.SetRemoteTriggerHandler(bootTrigger16{requests, unsupported}) + if err := cp.Start(url); err != nil { + t.Fatal(err) + } + return cp.Stop, func(serial string) error { + _, err := cp.BootNotification("Home", "Easee", func(req *core.BootNotificationRequest) { req.ChargePointSerialNumber = serial }) + return err + }, requests +} + +func awaitIdentityCondition(t *testing.T, condition func() bool) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for !condition() { + if time.Now().After(deadline) { + t.Fatal("identity condition did not settle") + } + time.Sleep(time.Millisecond) + } +} + +func TestReconnectRequestsFreshBootOverBothOCPPProtocols(t *testing.T) { + for _, version := range []Version{Version16, Version201} { + t.Run(string(version), func(t *testing.T) { + p16, p201, srv := startDualServer(t, telemetry.NewStore()) + h := srv.Handler() + h.SetApprovedIDs([]string{"garage"}) + h.mu.Lock() + h.identityProbeTiming = identityProbeTiming{20 * time.Millisecond, time.Second, 100 * time.Millisecond} + h.mu.Unlock() + port := p16 + if version == Version201 { + port = p201 + } + stop, boot, _ := connectIdentityCharger(t, version, port, false) + if err := boot("A"); err != nil { + stop() + t.Fatal(err) + } + stop() + awaitIdentityCondition(t, func() bool { return !h.IsOnline("garage") }) + stop, boot, requested := connectIdentityCharger(t, version, port, false) + defer stop() + select { + case got := <-requested: + if got != "BootNotification" { + t.Fatalf("requested %s", got) + } + case <-time.After(2 * time.Second): + t.Fatal("reconnected charger received no BootNotification trigger") + } + if _, ok := h.CurrentIdentity("garage"); ok { + t.Fatal("Accepted trigger trusted historical hardware") + } + if err := boot("B"); err != nil { + t.Fatal(err) + } + if got, ok := h.CurrentIdentity("garage"); !ok || got.Serial != "B" { + t.Fatalf("fresh Boot identity=%+v %v", got, ok) + } + }) + } +} + +func TestUnsupportedBootTriggerLeavesIdentityUnknown(t *testing.T) { + for _, version := range []Version{Version16, Version201} { + t.Run(string(version), func(t *testing.T) { + p16, p201, srv := startDualServer(t, telemetry.NewStore()) + h := srv.Handler() + h.SetApprovedIDs([]string{"garage"}) + h.mu.Lock() + h.identityProbeTiming = identityProbeTiming{time.Millisecond, time.Second, 5 * time.Millisecond} + h.mu.Unlock() + port := p16 + if version == Version201 { + port = p201 + } + stop, _, requested := connectIdentityCharger(t, version, port, true) + defer stop() + select { + case <-requested: + case <-time.After(2 * time.Second): + t.Fatal("no Boot trigger") + } + awaitIdentityCondition(t, func() bool { h.mu.Lock(); defer h.mu.Unlock(); return h.chargers["garage"].identityProbeTimer == nil }) + if _, ok := h.CurrentIdentity("garage"); ok { + t.Fatal("unsupported trigger invented identity") + } + select { + case <-requested: + t.Fatal("unsupported trigger retried") + case <-time.After(25 * time.Millisecond): + } + }) + } +} + +func TestIdentityProbeTimeoutAndReconnectAreBounded(t *testing.T) { + h := NewHandler(telemetry.NewStore(), 60) + t.Cleanup(h.stopIdentityProbes) + h.SetApprovedIDs([]string{"garage"}) + h.identityProbeTiming = identityProbeTiming{time.Millisecond, 5 * time.Millisecond, 30 * time.Millisecond} + requests := make(chan time.Time, 10) + h.identityProbe = func(_ string, _ Version, done func(error)) error { requests <- time.Now(); done(nil); return nil } + h.OnConnect("garage") + var first time.Time + select { + case first = <-requests: + case <-time.After(time.Second): + t.Fatal("initial identity request missing") + } + h.OnDisconnect("garage") + h.OnConnect("garage") + select { + case <-requests: + t.Fatal("reconnect bypassed rate limit") + case <-time.After(10 * time.Millisecond): + } + for i := 0; i < maxIdentityProbeAttempts; i++ { + select { + case next := <-requests: + if next.Sub(first) < 25*time.Millisecond { + t.Fatal("retry bypassed interval") + } + first = next + case <-time.After(time.Second): + t.Fatal("bounded retry missing") + } + } + awaitIdentityCondition(t, func() bool { h.mu.Lock(); defer h.mu.Unlock(); return h.chargers["garage"].identityProbeTimer == nil }) + if _, ok := h.CurrentIdentity("garage"); ok { + t.Fatal("timeout invented identity") + } + select { + case <-requests: + t.Fatal("retried beyond limit") + case <-time.After(45 * time.Millisecond): + } + h.OnDisconnect("garage") + h.OnConnect("garage") + h.stopIdentityProbes() + select { + case <-requests: + t.Fatal("probe outlived server stop") + case <-time.After(45 * time.Millisecond): + } +} + +func TestAdoptionStartsIdentityProbeWithoutReconnect(t *testing.T) { + for _, version := range []Version{Version16, Version201} { + t.Run(string(version), func(t *testing.T) { + p16, p201, srv := startDualServer(t, telemetry.NewStore()) + h := srv.Handler() + h.mu.Lock() + h.identityProbeTiming = identityProbeTiming{time.Millisecond, time.Second, 100 * time.Millisecond} + h.mu.Unlock() + port := p16 + if version == Version201 { + port = p201 + } + stop, boot, requested := connectIdentityCharger(t, version, port, false) + defer stop() + awaitIdentityCondition(t, func() bool { return h.IsOnline("garage") }) + select { + case <-requested: + t.Fatal("quarantined charger received identity probe") + case <-time.After(10 * time.Millisecond): + } + h.SetApprovedIDs([]string{"garage"}) + select { + case <-requested: + case <-time.After(time.Second): + t.Fatal("adoption did not request fresh identity") + } + if err := boot("A"); err != nil { + t.Fatal(err) + } + if got, ok := h.CurrentIdentity("garage"); !ok || got.Serial != "A" { + t.Fatalf("adopted identity=%+v %v", got, ok) + } + }) + } +} + +func TestIdentityProbeRevocationCancelsPendingRequest(t *testing.T) { + h := NewHandler(telemetry.NewStore(), 60) + t.Cleanup(h.stopIdentityProbes) + h.identityProbeTiming = identityProbeTiming{20 * time.Millisecond, time.Second, time.Minute} + requested := make(chan struct{}, 1) + h.identityProbe = func(string, Version, func(error)) error { requested <- struct{}{}; return nil } + h.SetApprovedIDs([]string{"garage"}) + h.OnConnect("garage") + h.SetApprovedIDs(nil) + select { + case <-requested: + t.Fatal("revoked charger received probe") + case <-time.After(40 * time.Millisecond): + } +} + +func TestDelayedStatusAfterDisconnectStaysUnknown(t *testing.T) { + tel := telemetry.NewStore() + h := NewHandler(tel, 60) + h.SetApprovedIDs([]string{"garage"}) + h.OnConnect("garage") + h.OnDisconnect("garage") + h.OnStatusNotification("garage", &core.StatusNotificationRequest{ConnectorId: 1, Status: core.ChargePointStatusAvailable, ErrorCode: core.NoError}) + var data struct { + Connected *bool `json:"connected"` + Unknown bool `json:"connection_unknown"` + Online *bool `json:"is_online"` + } + if err := json.Unmarshal(tel.Get("garage", telemetry.DerEV).Data, &data); err != nil { + t.Fatal(err) + } + if data.Connected != nil || !data.Unknown || data.Online == nil || *data.Online { + t.Fatalf("late status looked physically unplugged: %+v", data) + } +} diff --git a/go/internal/ocpp/inbound_socket_test.go b/go/internal/ocpp/inbound_socket_test.go new file mode 100644 index 00000000..7c04bb21 --- /dev/null +++ b/go/internal/ocpp/inbound_socket_test.go @@ -0,0 +1,305 @@ +package ocpp + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "testing" + "time" + + ocpp16 "github.com/lorenzodonini/ocpp-go/ocpp1.6" + "github.com/lorenzodonini/ocpp-go/ocpp1.6/core" + ocpp201 "github.com/lorenzodonini/ocpp-go/ocpp2.0.1" + "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/availability" + "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/provisioning" + types201 "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/types" + "github.com/srcfl/ftw/go/internal/loadpoint" + "github.com/srcfl/ftw/go/internal/telemetry" +) + +type delayedStatus16 struct { + core.CentralSystemHandler + entered, release, finished chan struct{} +} + +func (h delayedStatus16) OnStatusNotification(id string, r *core.StatusNotificationRequest) (*core.StatusNotificationConfirmation, error) { + if r.Status == core.ChargePointStatusAvailable && r.Info == "delay-old-socket" { + select { + case <-h.entered: + default: + close(h.entered) + <-h.release + defer close(h.finished) + } + } + return h.CentralSystemHandler.OnStatusNotification(id, r) +} + +type delayedStatus201 struct { + availability.CSMSHandler + entered, release, finished chan struct{} +} + +func (h delayedStatus201) OnStatusNotification(id string, r *availability.StatusNotificationRequest) (*availability.StatusNotificationResponse, error) { + if r.ConnectorStatus == availability.ConnectorStatusAvailable && r.Timestamp.Time.Year() == 2020 { + select { + case <-h.entered: + default: + close(h.entered) + <-h.release + defer close(h.finished) + } + } + return h.CSMSHandler.OnStatusNotification(id, r) +} + +func TestOldSocketStatusCannotClearPauseAfterReconnect(t *testing.T) { + for _, version := range []Version{Version16, Version201} { + t.Run(string(version), func(t *testing.T) { + tel := telemetry.NewStore() + p16, p201, srv := startDualServer(t, tel) + h := srv.Handler() + h.SetApprovedIDs([]string{"garage"}) + entered, release, finished := make(chan struct{}), make(chan struct{}), make(chan struct{}) + // The real SDK starts a goroutine for each CALL. Delay its invocation of + // the application handler, as a scheduler pause or a blocked callback can. + srv.cs.SetCoreHandler(delayedStatus16{&boundHandler16{inner: h, sessions: srv.sockets}, entered, release, finished}) + srv.csms.SetAvailabilityHandler(delayedStatus201{&boundHandler201{inner: &handlerV201{h}, sessions: srv.sockets}, entered, release, finished}) + connect := func() (func(), func(bool) error) { + t.Helper() + if version == Version201 { + cp := ocpp201.NewChargingStation("garage", nil, nil) + if e := cp.Start(fmt.Sprintf("ws://127.0.0.1:%d", p201)); e != nil { + t.Fatal(e) + } + if _, e := cp.BootNotification(provisioning.BootReasonPowerUp, "Home", "Easee"); e != nil { + t.Fatal(e) + } + firstAvailable := true + return cp.Stop, func(connected bool) error { + s := availability.ConnectorStatusAvailable + if connected { + s = availability.ConnectorStatusOccupied + } + stamp := time.Now() + if !connected && firstAvailable { + stamp = time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC) + firstAvailable = false + } + _, e := cp.StatusNotification(types201.NewDateTime(stamp), s, 1, 1) + return e + } + } + cp := ocpp16.NewChargePoint("garage", nil, nil) + if e := cp.Start(fmt.Sprintf("ws://127.0.0.1:%d", p16)); e != nil { + t.Fatal(e) + } + if _, e := cp.BootNotification("Home", "Easee"); e != nil { + t.Fatal(e) + } + return cp.Stop, func(connected bool) error { + s := core.ChargePointStatusAvailable + if connected { + s = core.ChargePointStatusSuspendedEVSE + } + _, e := cp.StatusNotification(1, core.NoError, s, func(r *core.StatusNotificationRequest) { + if !connected { + r.Info = "delay-old-socket" + } + }) + return e + } + } + stop, status := connect() + if e := status(true); e != nil { + t.Fatal(e) + } + manager := loadpoint.NewManager() + manager.Load([]loadpoint.Config{{ID: "garage", DriverName: "garage", MinChargeW: 1380, MaxChargeW: 11040}}) + ctrl := loadpoint.NewController(manager, func(time.Time) (loadpoint.Directive, bool) { return loadpoint.Directive{}, false }, func(string) (loadpoint.EVSample, bool) { + var r struct { + Connected bool `json:"connected"` + } + json.Unmarshal(tel.Get("garage", telemetry.DerEV).Data, &r) + return loadpoint.EVSample{Connected: r.Connected, RequestActive: true}, true + }, nil) + ctrl.Tick(context.Background(), time.Now()) + ctrl.SetManualHold("garage", loadpoint.ManualHold{Persistent: true}) + go status(false) + select { + case <-entered: + case <-time.After(time.Second): + t.Fatal("SDK did not dispatch old Available") + } + stop() + awaitIdentityCondition(t, func() bool { return !h.IsOnline("garage") }) + stop, status = connect() + defer stop() + if e := status(true); e != nil { + t.Fatal(e) + } + close(release) + select { + case <-finished: + case <-time.After(time.Second): + t.Fatal("old SDK handler did not finish") + } + ctrl.Tick(context.Background(), time.Now()) + var raw map[string]any + json.Unmarshal(tel.Get("garage", telemetry.DerEV).Data, &raw) + if hold, ok := ctrl.GetManualHold("garage", time.Now()); !ok || hold.PowerW != 0 { + t.Fatalf("old socket cleared explicit Pause: reading=%v", raw) + } + if raw["connected"] != true { + t.Fatalf("old socket replaced fresh connection: %v", raw) + } + if err := status(false); err != nil { + t.Fatal(err) + } + ctrl.Tick(context.Background(), time.Now()) + if _, ok := ctrl.GetManualHold("garage", time.Now()); ok { + t.Fatal("fresh physical unplug did not clear Pause") + } + }) + } +} + +type delayedBoot16 struct { + core.CentralSystemHandler + entered, release, finished chan struct{} +} + +func (h delayedBoot16) OnBootNotification(id string, r *core.BootNotificationRequest) (*core.BootNotificationConfirmation, error) { + if r.ChargePointSerialNumber == "stale" { + close(h.entered) + <-h.release + defer close(h.finished) + } + return h.CentralSystemHandler.OnBootNotification(id, r) +} + +type delayedBoot201 struct { + provisioning.CSMSHandler + entered, release, finished chan struct{} +} + +func (h delayedBoot201) OnBootNotification(id string, r *provisioning.BootNotificationRequest) (*provisioning.BootNotificationResponse, error) { + if r.ChargingStation.SerialNumber == "stale" { + close(h.entered) + <-h.release + defer close(h.finished) + } + return h.CSMSHandler.OnBootNotification(id, r) +} + +func TestOldSocketBootCannotReplaceCurrentHardware(t *testing.T) { + for _, first := range []Version{Version16, Version201} { + for _, second := range []Version{Version16, Version201} { + t.Run(string(first)+"->"+string(second), func(t *testing.T) { + p16, p201, srv := startDualServer(t, telemetry.NewStore()) + h := srv.Handler() + h.SetApprovedIDs([]string{"garage"}) + entered, release, finished := make(chan struct{}), make(chan struct{}), make(chan struct{}) + srv.cs.SetCoreHandler(delayedBoot16{&boundHandler16{h, srv.sockets}, entered, release, finished}) + srv.csms.SetProvisioningHandler(delayedBoot201{&boundHandler201{&handlerV201{h}, srv.sockets}, entered, release, finished}) + port := func(v Version) int { + if v == Version201 { + return p201 + } + return p16 + } + stop, boot, _ := connectIdentityCharger(t, first, port(first), false) + if err := boot("A"); err != nil { + t.Fatal(err) + } + oldID, err := srv.sockets.currentID("garage") + if err != nil { + t.Fatal(err) + } + go boot("stale") + select { + case <-entered: + case <-time.After(time.Second): + t.Fatal("old Boot did not enter SDK goroutine") + } + stop() + awaitIdentityCondition(t, func() bool { return !h.IsOnline("garage") }) + stop, boot, _ = connectIdentityCharger(t, second, port(second), false) + defer stop() + if err := boot("B"); err != nil { + t.Fatal(err) + } + newID, err := srv.sockets.currentID("garage") + if err != nil || newID == oldID { + t.Fatal("socket reused SDK identity") + } + close(release) + select { + case <-finished: + case <-time.After(time.Second): + t.Fatal("old Boot did not complete") + } + if identity, ok := h.CurrentIdentity("garage"); !ok || identity.Serial != "B" { + t.Fatalf("old Boot changed hardware: %+v %v", identity, ok) + } + // A late callback and response cannot resolve an old alias to its replacement. + called := false + _, err = boundCall(srv.sockets, oldID, func(string) (bool, error) { called = true; return true, nil }) + if err == nil || called { + t.Fatal("old callback acquired replacement") + } + }) + } + } +} + +func TestFailedAndDuplicateHandshakesPreserveCurrentSocket(t *testing.T) { + p16, p201, srv := startDualServer(t, telemetry.NewStore()) + h := srv.Handler() + h.SetApprovedIDs([]string{"garage"}) + // This reaches the connection check but fails WebSocket Upgrade. Request + // completion must release its reservation so a real charger can connect. + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("http://127.0.0.1:%d/garage", p16), nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Sec-WebSocket-Protocol", "ocpp1.6") + response, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + response.Body.Close() + if response.StatusCode == http.StatusSwitchingProtocols { + t.Fatal("malformed upgrade unexpectedly succeeded") + } + awaitIdentityCondition(t, func() bool { + slot := srv.sockets.slot("garage") + slot.mu.Lock() + defer slot.mu.Unlock() + return slot.pending == nil + }) + stop, boot, _ := connectIdentityCharger(t, Version16, p16, false) + defer stop() + if err := boot("A"); err != nil { + t.Fatal(err) + } + oldID, err := srv.sockets.currentID("garage") + if err != nil { + t.Fatal(err) + } + other := ocpp201.NewChargingStation("garage", nil, nil) + if err := other.Start(fmt.Sprintf("ws://127.0.0.1:%d", p201)); err == nil { + other.Stop() + t.Fatal("other protocol took an active public charger ID") + } + if current, err := srv.sockets.currentID("garage"); err != nil || current != oldID { + t.Fatal("duplicate handshake removed active owner") + } + if err := boot("A"); err != nil { + t.Fatal("duplicate handshake stopped active connection:", err) + } + if identity, ok := h.CurrentIdentity("garage"); !ok || identity.Serial != "A" { + t.Fatal("duplicate changed hardware") + } +} diff --git a/go/internal/ocpp/server.go b/go/internal/ocpp/server.go index 863e102d..202ba3fe 100644 --- a/go/internal/ocpp/server.go +++ b/go/internal/ocpp/server.go @@ -58,6 +58,7 @@ type Server struct { cs ocpp16.CentralSystem csms ocpp201.CSMS handler *Handler + sockets *socketSessions // done closes when the 1.6 listener goroutine exits; doneV201 likewise for // 2.0.1. A nil channel means that version was not enabled. done chan struct{} @@ -81,7 +82,8 @@ func Start(ctx context.Context, cfg *Config, tel *telemetry.Store) (*Server, err cfg.Defaults() auth := newAuthorizer(cfg) - wsServer, err := newListener(cfg, auth) + sockets := newSocketSessions() + wsServer, err := newListener(cfg, auth, sockets) if err != nil { return nil, err } @@ -89,28 +91,28 @@ func Start(ctx context.Context, cfg *Config, tel *telemetry.Store) (*Server, err cs := ocpp16.NewCentralSystem(nil, wsServer) h := NewHandler(tel, cfg.HeartbeatIntervalS) h.SetApprovedIDs(cfg.ApprovedIDs) - cs.SetCoreHandler(h) + cs.SetCoreHandler(&boundHandler16{inner: h, sessions: sockets}) cs.SetNewChargePointHandler(func(cp ocpp16.ChargePointConnection) { // Which listener a charger reached is what identifies its dialect, so // record it here rather than inferring it from a later message — and // before OnConnect, whose capability probe dispatches on it. - h.setVersion(cp.ID(), Version16) - h.OnConnect(cp.ID()) + _, _ = boundCall(sockets, cp.ID(), func(id string) (bool, error) { h.setVersion(id, Version16); h.OnConnect(id); return true, nil }) }) cs.SetChargePointDisconnectedHandler(func(cp ocpp16.ChargePointConnection) { - h.OnDisconnect(cp.ID()) + sockets.disconnected(cp.ID(), h.OnDisconnect) }) - s := &Server{cfg: cfg, cs: cs, handler: h, done: make(chan struct{})} + s := &Server{cfg: cfg, cs: cs, handler: h, sockets: sockets, done: make(chan struct{})} + h.identityProbe = s.requestBootNotification // OCPP 2.0.1 on its own port, when configured. Same handler and therefore // the same charger state and telemetry — only the message encoding differs. if cfg.PortV201 > 0 { - wsServer201, err := newListener(cfg, auth) + wsServer201, err := newListener(cfg, auth, sockets) if err != nil { return nil, err } - h201 := &handlerV201{Handler: h} + h201 := &boundHandler201{inner: &handlerV201{Handler: h}, sessions: sockets} csms := ocpp201.NewCSMS(nil, wsServer201) csms.SetProvisioningHandler(h201) csms.SetAvailabilityHandler(h201) @@ -124,11 +126,10 @@ func Start(ctx context.Context, cfg *Config, tel *telemetry.Store) (*Server, err // and dropped. See charging_needs.go. csms.SetSmartChargingHandler(h201) csms.SetNewChargingStationHandler(func(cs ocpp201.ChargingStationConnection) { - h.setVersion(cs.ID(), Version201) - h.OnConnect(cs.ID()) + _, _ = boundCall(sockets, cs.ID(), func(id string) (bool, error) { h.setVersion(id, Version201); h.OnConnect(id); return true, nil }) }) csms.SetChargingStationDisconnectedHandler(func(cs ocpp201.ChargingStationConnection) { - h.OnDisconnect(cs.ID()) + sockets.disconnected(cs.ID(), h.OnDisconnect) }) s.csms = csms @@ -155,10 +156,10 @@ func Start(ctx context.Context, cfg *Config, tel *telemetry.Store) (*Server, err ver := h.chargersLocked(id).version h.mu.Unlock() if ver == Version201 && s.csms != nil { - probeSmartChargingV201(s.csms, h, id) + probeSmartChargingV201(s.csms, h, id, sockets) return } - probeFeatureProfiles16(cs, h, id) + probeFeatureProfiles16(cs, h, id, sockets) } go func() { @@ -192,7 +193,7 @@ func Start(ctx context.Context, cfg *Config, tel *telemetry.Store) (*Server, err // would lock out every charger on a server with no username instead of // admitting them all. checkClient is always safe to register; it authorizes // everything when nothing is configured. -func newListener(cfg *Config, auth *authorizer) (ws.WsServer, error) { +func newListener(cfg *Config, auth *authorizer, sockets *socketSessions) (ws.WsServer, error) { var srv *ws.Server if cfg.TLS.configured() { // Half a TLS section is an error, not a reason to serve plaintext: @@ -210,7 +211,7 @@ func newListener(cfg *Config, auth *authorizer) (ws.WsServer, error) { srv.SetBasicAuthHandler(auth.basicAuth) } srv.SetCheckClientHandler(auth.checkClient) - return &guardedServer{Server: srv, check: auth.checkClient}, nil + return &guardedServer{Server: srv, check: auth.checkClient, sessions: sockets}, nil } // guardedServer keeps our connection check installed. @@ -224,15 +225,16 @@ func newListener(cfg *Config, auth *authorizer) (ws.WsServer, error) { // the library's own check still runs after. type guardedServer struct { *ws.Server - check func(id string, r *http.Request) bool + check func(id string, r *http.Request) bool + sessions *socketSessions } func (g *guardedServer) SetCheckClientHandler(handler func(id string, r *http.Request) bool) { g.Server.SetCheckClientHandler(func(id string, r *http.Request) bool { - if !g.check(id, r) { + if !g.check(id, r) || (handler != nil && !handler(id, r)) { return false } - return handler == nil || handler(id, r) + return g.sessions.reserveConnection(id, r) }) } @@ -243,6 +245,7 @@ func (s *Server) Stop() { return } s.stopOnce.Do(func() { + s.handler.stopIdentityProbes() s.cs.Stop() if s.csms != nil { s.csms.Stop() diff --git a/go/internal/ocpp/socket_command_test.go b/go/internal/ocpp/socket_command_test.go new file mode 100644 index 00000000..ae55650c --- /dev/null +++ b/go/internal/ocpp/socket_command_test.go @@ -0,0 +1,127 @@ +package ocpp + +import ( + "context" + "fmt" + "sync" + "testing" + "time" + + ocpp16 "github.com/lorenzodonini/ocpp-go/ocpp1.6" + "github.com/lorenzodonini/ocpp-go/ocpp1.6/smartcharging" + "github.com/lorenzodonini/ocpp-go/ocpp1.6/types" + ocpp201 "github.com/lorenzodonini/ocpp-go/ocpp2.0.1" + smart201 "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/smartcharging" + types201 "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/types" + "github.com/srcfl/ftw/go/internal/telemetry" +) + +type callbackGate struct { + once sync.Once + entered, release chan struct{} +} + +func (g *callbackGate) wait() { + first := false + g.once.Do(func() { first = true }) + if first { + close(g.entered) + <-g.release + } +} + +type gatedProfile16 struct { + ocpp16.CentralSystem + gate *callbackGate +} + +func (g *gatedProfile16) SetChargingProfile(id string, cb func(*smartcharging.SetChargingProfileConfirmation, error), connector int, p *types.ChargingProfile, opts ...func(*smartcharging.SetChargingProfileRequest)) error { + return g.CentralSystem.SetChargingProfile(id, func(r *smartcharging.SetChargingProfileConfirmation, e error) { g.gate.wait(); cb(r, e) }, connector, p, opts...) +} + +type gatedProfile201 struct { + ocpp201.CSMS + gate *callbackGate +} + +func (g *gatedProfile201) SetChargingProfile(id string, cb func(*smart201.SetChargingProfileResponse, error), evse int, p *types201.ChargingProfile, opts ...func(*smart201.SetChargingProfileRequest)) error { + return g.CSMS.SetChargingProfile(id, func(r *smart201.SetChargingProfileResponse, e error) { g.gate.wait(); cb(r, e) }, evse, p, opts...) +} + +func TestOldSocketAcceptedCommandCannotAcknowledgeReplacement(t *testing.T) { + for _, version := range []Version{Version16, Version201} { + t.Run(string(version), func(t *testing.T) { + p16, p201, srv := startDualServer(t, telemetry.NewStore()) + srv.Handler().SetApprovedIDs([]string{"garage"}) + gate := &callbackGate{entered: make(chan struct{}), release: make(chan struct{})} + // The charger answers on the real wire. Delay only the SDK's asynchronous + // callback, then reconnect before it reaches the caller waiting for ACK. + srv.cs = &gatedProfile16{srv.cs, gate} + srv.csms = &gatedProfile201{srv.csms, gate} + connect := func() (func(), func() int) { + if version == Version201 { + fake, stop := connectStationV201(t, srv, p201, "garage") + return stop, func() int { fake.mu.Lock(); defer fake.mu.Unlock(); return len(fake.profiles) } + } + _, fake, stop := connectCharger(t, srv, p16, "garage") + return stop, fake.count + } + stop, _ := connect() + result := make(chan error, 1) + go func() { + result <- srv.Command(context.Background(), "garage", []byte(`{"action":"ev_set_current","power_w":4140}`)) + }() + select { + case <-gate.entered: + case <-time.After(time.Second): + t.Fatal("wire ACK did not reach SDK callback") + } + stop() + awaitIdentityCondition(t, func() bool { return !srv.Handler().IsOnline("garage") }) + _, count := connect() + close(gate.release) + select { + case err := <-result: + if err == nil { + t.Fatal("old wire ACK acknowledged replacement") + } + case <-time.After(time.Second): + t.Fatal("old callback did not settle") + } + if count() != 0 { + t.Fatal("old command or retry reached replacement") + } + if got := srv.Handler().LastAmps("garage", 7); got != 7 { + t.Fatalf("old ACK stored %v A on replacement", got) + } + if err := srv.Command(context.Background(), "garage", []byte(`{"action":"ev_set_current","power_w":4140}`)); err != nil { + t.Fatal(err) + } + if count() != 1 { + t.Fatal("new command did not reach replacement exactly once") + } + }) + } +} + +func TestReconnectProbesCapabilitiesAgain(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore(), "garage") + first := ocpp16.NewChargePoint("garage", nil, nil) + first.SetCoreHandler(&capabilityCP{profiles: "Core,SmartCharging"}) + if err := first.Start(fmt.Sprintf("ws://127.0.0.1:%d", port)); err != nil { + t.Fatal(err) + } + if _, err := first.BootNotification("Home", "Easee"); err != nil { + t.Fatal(err) + } + if view := waitSteerable(t, srv, "garage"); view.Steerable == nil || !*view.Steerable { + t.Fatal("first capabilities missing") + } + first.Stop() + awaitIdentityCondition(t, func() bool { return !srv.Handler().IsOnline("garage") }) + next := &capabilityCP{profiles: "Core"} + connectCapabilityCP(t, port, "garage", next) + if view := waitSteerable(t, srv, "garage"); view.Steerable == nil || *view.Steerable || view.FeatureProfiles != "Core" { + t.Fatalf("replacement retained old capabilities: %+v", view) + } +} diff --git a/go/internal/ocpp/socket_handlers.go b/go/internal/ocpp/socket_handlers.go new file mode 100644 index 00000000..a2a99e5f --- /dev/null +++ b/go/internal/ocpp/socket_handlers.go @@ -0,0 +1,105 @@ +package ocpp + +import ( + "github.com/lorenzodonini/ocpp-go/ocpp1.6/core" + "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/authorization" + "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/availability" + "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/meter" + "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/provisioning" + "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/smartcharging" + "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/transactions" +) + +type boundHandler16 struct { + inner core.CentralSystemHandler + sessions *socketSessions +} + +func (h *boundHandler16) OnBootNotification(alias string, req *core.BootNotificationRequest) (*core.BootNotificationConfirmation, error) { + return boundCall(h.sessions, alias, func(id string) (*core.BootNotificationConfirmation, error) { + return h.inner.OnBootNotification(id, req) + }) +} +func (h *boundHandler16) OnHeartbeat(alias string, req *core.HeartbeatRequest) (*core.HeartbeatConfirmation, error) { + return boundCall(h.sessions, alias, func(id string) (*core.HeartbeatConfirmation, error) { return h.inner.OnHeartbeat(id, req) }) +} +func (h *boundHandler16) OnAuthorize(alias string, req *core.AuthorizeRequest) (*core.AuthorizeConfirmation, error) { + return boundCall(h.sessions, alias, func(id string) (*core.AuthorizeConfirmation, error) { return h.inner.OnAuthorize(id, req) }) +} +func (h *boundHandler16) OnDataTransfer(alias string, req *core.DataTransferRequest) (*core.DataTransferConfirmation, error) { + return boundCall(h.sessions, alias, func(id string) (*core.DataTransferConfirmation, error) { return h.inner.OnDataTransfer(id, req) }) +} +func (h *boundHandler16) OnStatusNotification(alias string, req *core.StatusNotificationRequest) (*core.StatusNotificationConfirmation, error) { + return boundCall(h.sessions, alias, func(id string) (*core.StatusNotificationConfirmation, error) { + return h.inner.OnStatusNotification(id, req) + }) +} +func (h *boundHandler16) OnMeterValues(alias string, req *core.MeterValuesRequest) (*core.MeterValuesConfirmation, error) { + return boundCall(h.sessions, alias, func(id string) (*core.MeterValuesConfirmation, error) { return h.inner.OnMeterValues(id, req) }) +} +func (h *boundHandler16) OnStartTransaction(alias string, req *core.StartTransactionRequest) (*core.StartTransactionConfirmation, error) { + return boundCall(h.sessions, alias, func(id string) (*core.StartTransactionConfirmation, error) { + return h.inner.OnStartTransaction(id, req) + }) +} +func (h *boundHandler16) OnStopTransaction(alias string, req *core.StopTransactionRequest) (*core.StopTransactionConfirmation, error) { + return boundCall(h.sessions, alias, func(id string) (*core.StopTransactionConfirmation, error) { return h.inner.OnStopTransaction(id, req) }) +} + +type boundHandler201 struct { + inner *handlerV201 + sessions *socketSessions +} + +func (h *boundHandler201) OnBootNotification(alias string, req *provisioning.BootNotificationRequest) (*provisioning.BootNotificationResponse, error) { + return boundCall(h.sessions, alias, func(id string) (*provisioning.BootNotificationResponse, error) { + return h.inner.OnBootNotification(id, req) + }) +} +func (h *boundHandler201) OnNotifyReport(alias string, req *provisioning.NotifyReportRequest) (*provisioning.NotifyReportResponse, error) { + return boundCall(h.sessions, alias, func(id string) (*provisioning.NotifyReportResponse, error) { return h.inner.OnNotifyReport(id, req) }) +} +func (h *boundHandler201) OnHeartbeat(alias string, req *availability.HeartbeatRequest) (*availability.HeartbeatResponse, error) { + return boundCall(h.sessions, alias, func(id string) (*availability.HeartbeatResponse, error) { return h.inner.OnHeartbeat(id, req) }) +} +func (h *boundHandler201) OnStatusNotification(alias string, req *availability.StatusNotificationRequest) (*availability.StatusNotificationResponse, error) { + return boundCall(h.sessions, alias, func(id string) (*availability.StatusNotificationResponse, error) { + return h.inner.OnStatusNotification(id, req) + }) +} +func (h *boundHandler201) OnTransactionEvent(alias string, req *transactions.TransactionEventRequest) (*transactions.TransactionEventResponse, error) { + return boundCall(h.sessions, alias, func(id string) (*transactions.TransactionEventResponse, error) { + return h.inner.OnTransactionEvent(id, req) + }) +} +func (h *boundHandler201) OnMeterValues(alias string, req *meter.MeterValuesRequest) (*meter.MeterValuesResponse, error) { + return boundCall(h.sessions, alias, func(id string) (*meter.MeterValuesResponse, error) { return h.inner.OnMeterValues(id, req) }) +} +func (h *boundHandler201) OnAuthorize(alias string, req *authorization.AuthorizeRequest) (*authorization.AuthorizeResponse, error) { + return boundCall(h.sessions, alias, func(id string) (*authorization.AuthorizeResponse, error) { return h.inner.OnAuthorize(id, req) }) +} +func (h *boundHandler201) OnNotifyEVChargingNeeds(alias string, req *smartcharging.NotifyEVChargingNeedsRequest) (*smartcharging.NotifyEVChargingNeedsResponse, error) { + return boundCall(h.sessions, alias, func(id string) (*smartcharging.NotifyEVChargingNeedsResponse, error) { + return h.inner.OnNotifyEVChargingNeeds(id, req) + }) +} +func (h *boundHandler201) OnNotifyEVChargingSchedule(alias string, req *smartcharging.NotifyEVChargingScheduleRequest) (*smartcharging.NotifyEVChargingScheduleResponse, error) { + return boundCall(h.sessions, alias, func(id string) (*smartcharging.NotifyEVChargingScheduleResponse, error) { + return h.inner.OnNotifyEVChargingSchedule(id, req) + }) +} +func (h *boundHandler201) OnNotifyChargingLimit(alias string, req *smartcharging.NotifyChargingLimitRequest) (*smartcharging.NotifyChargingLimitResponse, error) { + return boundCall(h.sessions, alias, func(id string) (*smartcharging.NotifyChargingLimitResponse, error) { + return h.inner.OnNotifyChargingLimit(id, req) + }) +} +func (h *boundHandler201) OnClearedChargingLimit(alias string, req *smartcharging.ClearedChargingLimitRequest) (*smartcharging.ClearedChargingLimitResponse, error) { + return boundCall(h.sessions, alias, func(id string) (*smartcharging.ClearedChargingLimitResponse, error) { + return h.inner.OnClearedChargingLimit(id, req) + }) +} +func (h *boundHandler201) OnReportChargingProfiles(alias string, req *smartcharging.ReportChargingProfilesRequest) (*smartcharging.ReportChargingProfilesResponse, error) { + return boundCall(h.sessions, alias, func(id string) (*smartcharging.ReportChargingProfilesResponse, error) { + return h.inner.OnReportChargingProfiles(id, req) + }) +} diff --git a/go/internal/ocpp/socket_sessions.go b/go/internal/ocpp/socket_sessions.go new file mode 100644 index 00000000..ead90bd9 --- /dev/null +++ b/go/internal/ocpp/socket_sessions.go @@ -0,0 +1,233 @@ +package ocpp + +import ( + "context" + "errors" + "fmt" + "net/http" + "sync" + + "github.com/gorilla/websocket" + "github.com/lorenzodonini/ocpp-go/ws" +) + +var errOldSocket = errors.New("OCPP connection is no longer current") + +// The SDK dispatches requests and replies on goroutines and retains only +// Channel.ID. Give each socket an immutable internal ID; public charger names +// remain the authentication, configuration and hardware-state keys. +// Both protocol listeners share this owner table. +type socketSessions struct { + mu sync.Mutex + next uint64 + slots map[string]*socketSlot + aliases map[string]*socketBinding + raw map[ws.Channel]*socketBinding +} +type socketSlot struct { + mu sync.Mutex + current *socketBinding + pending *socketHandshake +} +type socketBinding struct { + ws.Channel + alias string + slot *socketSlot + ready chan struct{} + active bool // slot.mu + initialized bool // slot.mu +} + +func (b *socketBinding) ID() string { return b.alias } +func newSocketSessions() *socketSessions { + return &socketSessions{slots: map[string]*socketSlot{}, aliases: map[string]*socketBinding{}, raw: map[ws.Channel]*socketBinding{}} +} +func (s *socketSessions) slot(id string) *socketSlot { + s.mu.Lock() + defer s.mu.Unlock() + slot := s.slots[id] + if slot == nil { + slot = &socketSlot{} + s.slots[id] = slot + } + return slot +} +func (s *socketSessions) lookup(alias string) *socketBinding { + s.mu.Lock() + defer s.mu.Unlock() + return s.aliases[alias] +} +func (s *socketSessions) currentID(id string) (string, error) { + // Lookup must not acquire slot.mu: a Boot handler can request an async + // capability probe while holding that slot's mutation guard. + s.mu.Lock() + defer s.mu.Unlock() + for alias, b := range s.aliases { + if b.Channel.ID() == id && b.active { + return alias, nil + } + } + return "", errOldSocket +} + +type socketHandshake struct { + remote string + stop func() bool +} + +func (s *socketSessions) reserveConnection(id string, r *http.Request) bool { + slot := s.slot(id) + slot.mu.Lock() + defer slot.mu.Unlock() + if slot.current != nil || slot.pending != nil || r.Context().Err() != nil { + return false + } + pending := &socketHandshake{remote: r.RemoteAddr} + slot.pending = pending + pending.stop = context.AfterFunc(r.Context(), func() { + slot.mu.Lock() + defer slot.mu.Unlock() + if slot.pending == pending { + slot.pending = nil + } + }) + return true +} +func (g *guardedServer) binding(ch ws.Channel) *socketBinding { + s := g.sessions + slot := s.slot(ch.ID()) + s.mu.Lock() + defer s.mu.Unlock() + if b := s.raw[ch]; b != nil { + return b + } + if g.Server.Connections(ch.ID()) != ch { + return nil + } + s.next++ + b := &socketBinding{Channel: ch, alias: fmt.Sprintf("socket-%d", s.next), slot: slot, ready: make(chan struct{})} + s.raw[ch], s.aliases[b.alias] = b, b + return b +} +func (g *guardedServer) SetNewClientHandler(fn func(ws.Channel)) { + g.Server.SetNewClientHandler(func(ch ws.Channel) { + b := g.binding(ch) + if b == nil { + return + } + b.slot.mu.Lock() + if b.slot.current != nil || b.slot.pending == nil || b.slot.pending.remote != ch.RemoteAddr().String() { + close(b.ready) + if g.Server.Connections(ch.ID()) == ch { + _ = g.Server.StopConnection(ch.ID(), websocket.CloseError{Code: websocket.ClosePolicyViolation, Text: "charger already connected"}) + } + b.slot.mu.Unlock() + return + } + b.slot.pending.stop() + b.slot.pending = nil + b.slot.current = b + g.sessions.mu.Lock() + b.active = true + g.sessions.mu.Unlock() + b.initialized = true + b.slot.mu.Unlock() + // SDK request queues and Core OnConnect must exist before the read pump + // delivers its first message (the SDK starts that pump before this hook). + fn(b) + close(b.ready) + }) +} +func (g *guardedServer) SetDisconnectedClientHandler(fn func(ws.Channel)) { + g.Server.SetDisconnectedClientHandler(func(ch ws.Channel) { + g.sessions.mu.Lock() + b := g.sessions.raw[ch] + g.sessions.mu.Unlock() + if b == nil { + return + } + <-b.ready + b.slot.mu.Lock() + g.sessions.mu.Lock() + b.active = false + g.sessions.mu.Unlock() + initialized := b.initialized + b.slot.mu.Unlock() + if initialized { + fn(b) + } + b.slot.mu.Lock() + if b.slot.current == b { + b.slot.current = nil + } + g.sessions.mu.Lock() + delete(g.sessions.aliases, b.alias) + delete(g.sessions.raw, ch) + g.sessions.mu.Unlock() + b.slot.mu.Unlock() + }) +} +func (g *guardedServer) SetMessageHandler(fn func(ws.Channel, []byte) error) { + g.Server.SetMessageHandler(func(ch ws.Channel, data []byte) error { + b := g.binding(ch) + if b == nil { + return nil + } + <-b.ready + return fn(b, data) + }) +} +func (g *guardedServer) Write(alias string, data []byte) error { + b := g.sessions.lookup(alias) + if b == nil { + return errOldSocket + } + b.slot.mu.Lock() + defer b.slot.mu.Unlock() + if b.slot.current != b || !b.active || g.Server.Connections(b.Channel.ID()) != b.Channel { + return errOldSocket + } + // Connection validation takes this same slot lock and refuses an occupied + // slot. A replacement cannot appear between this check and raw Write. + return g.Server.Write(b.Channel.ID(), data) +} + +// Match and mutate under one per-charger lock. SDK goroutines from a closed +// socket cannot alter its replacement, including via a late BootNotification. +func boundCall[T any](s *socketSessions, alias string, fn func(string) (T, error)) (zero T, err error) { + b := s.lookup(alias) + if b == nil { + return zero, errOldSocket + } + b.slot.mu.Lock() + defer b.slot.mu.Unlock() + if b.slot.current != b || !b.active { + return zero, errOldSocket + } + return fn(b.Channel.ID()) +} +func (s *socketSessions) disconnected(alias string, fn func(string)) { + b := s.lookup(alias) + if b == nil { + return + } + b.slot.mu.Lock() + defer b.slot.mu.Unlock() + if b.slot.current == b { + fn(b.Channel.ID()) + b.slot.current = nil + } +} + +func (g *guardedServer) StopConnection(alias string, reason websocket.CloseError) error { + b := g.sessions.lookup(alias) + if b == nil { + return errOldSocket + } + b.slot.mu.Lock() + defer b.slot.mu.Unlock() + if b.slot.current != b || !b.active || g.Server.Connections(b.Channel.ID()) != b.Channel { + return errOldSocket + } + return g.Server.StopConnection(b.Channel.ID(), reason) +} diff --git a/go/internal/selfupdate/selfupdate.go b/go/internal/selfupdate/selfupdate.go index bc9d2243..1ac5feb9 100644 --- a/go/internal/selfupdate/selfupdate.go +++ b/go/internal/selfupdate/selfupdate.go @@ -723,6 +723,13 @@ func (c *Checker) Trigger(ctx context.Context, action, target string) error { return c.TriggerComponentAt(ctx, action, target, "core", time.Time{}) } +// TriggerRestart requests a restart of the existing container. The separate +// wire action fails closed on older sidecars whose restart pulls/recreates an +// image; checker versions (including QA overrides) never select a restart image. +func (c *Checker) TriggerRestart(ctx context.Context) error { + return c.Trigger(ctx, "restart", "") +} + // TriggerComponent requests a selective core or optimizer compose update. func (c *Checker) TriggerComponent(ctx context.Context, action, target, component string) error { return c.TriggerComponentAt(ctx, action, target, component, time.Time{}) @@ -743,10 +750,18 @@ func (c *Checker) TriggerComponentAt(ctx context.Context, action, target, compon if action == "component_rollback" && component != "optimizer" { return errors.New("selfupdate: component rollback is only available for optimizer") } + if action == "restart" { + action, target = "restart_existing", "" + } body, _ := json.Marshal(map[string]any{ "action": action, "target": target, "component": component, "started_at": startedAt, }) - return c.postSidecar(ctx, body) + err := c.postSidecar(ctx, body) + var rejection *sidecarHTTPError + if action == "restart_existing" && errors.As(err, &rejection) && rejection.status == http.StatusBadRequest { + return fmt.Errorf("safe restart requires a newer updater; update Core and updater together: %w", err) + } + return err } // TriggerRollback asks the sidecar to restore a snapshot over the main @@ -775,6 +790,15 @@ func (c *Checker) TriggerRollback(ctx context.Context, snapshotID string, files return c.postSidecar(ctx, body) } +type sidecarHTTPError struct { + status int + message string +} + +func (e *sidecarHTTPError) Error() string { + return fmt.Sprintf("sidecar %d: %s", e.status, e.message) +} + // postSidecar wraps the Unix-socket POST to the sidecar's /update // endpoint. Shared by Trigger and TriggerRollback so the HTTP client // config (socket dialer + timeout) only lives in one place. @@ -797,7 +821,7 @@ func (c *Checker) postSidecar(ctx context.Context, body []byte) error { defer resp.Body.Close() if resp.StatusCode >= 400 { b, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10)) - return fmt.Errorf("sidecar %d: %s", resp.StatusCode, strings.TrimSpace(string(b))) + return &sidecarHTTPError{status: resp.StatusCode, message: strings.TrimSpace(string(b))} } return nil } diff --git a/go/internal/selfupdate/selfupdate_test.go b/go/internal/selfupdate/selfupdate_test.go index eca97b0e..1d4ea843 100644 --- a/go/internal/selfupdate/selfupdate_test.go +++ b/go/internal/selfupdate/selfupdate_test.go @@ -3,12 +3,14 @@ package selfupdate import ( "context" "encoding/json" + "net" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync" + "sync/atomic" "testing" "time" ) @@ -854,6 +856,76 @@ func TestTrigger_NoSocket(t *testing.T) { } } +// Reported versions, including QA overrides and baked stable identities, +// cannot select another image when a user presses Restart. +func TestTriggerRestartPreservesContainerRegardlessOfReportedVersion(t *testing.T) { + for _, current := range []string{"v2.14.0-beta.1", "v2.14.0", "v2.0.0", "dev", "edge-20260101"} { + t.Run(current, func(t *testing.T) { + dir, err := os.MkdirTemp("", "ftw-su-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(dir) }) + sock := filepath.Join(dir, "sock") + ln, err := net.Listen("unix", sock) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { ln.Close() }) + got := make(chan map[string]any, 1) + srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + got <- body + w.WriteHeader(202) + })} + go srv.Serve(ln) + t.Cleanup(func() { srv.Close() }) + c := New(Config{SocketPath: sock, CurrentVersion: current}, newMemStore()) + if err := c.TriggerRestart(context.Background()); err != nil { + t.Fatal(err) + } + body := <-got + if body["action"] != "restart_existing" || body["target"] != "" { + t.Fatalf("unsafe restart request: %v", body) + } + }) + } +} + +func TestTriggerRestartFailsClosedOnOldUpdater(t *testing.T) { + dir, err := os.MkdirTemp("", "ftw-su-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.RemoveAll(dir) }) + sock := filepath.Join(dir, "sock") + ln, err := net.Listen("unix", sock) + if err != nil { + t.Fatal(err) + } + var calls atomic.Int32 + srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + var body map[string]any + _ = json.NewDecoder(r.Body).Decode(&body) + if body["action"] != "restart_existing" { + t.Errorf("unsafe fallback: %v", body) + } + http.Error(w, "action must be update, restart, rollback, or component_rollback", http.StatusBadRequest) + })} + go srv.Serve(ln) + t.Cleanup(func() { srv.Close() }) + c := New(Config{SocketPath: sock, CurrentVersion: "v2.14.0-beta.1"}, newMemStore()) + err = c.TriggerRestart(context.Background()) + if err == nil || !strings.Contains(err.Error(), "safe restart requires a newer updater") { + t.Fatalf("restart error = %v", err) + } + if calls.Load() != 1 { + t.Fatalf("restart requests = %d", calls.Load()) + } +} + // Reported from the field: an amber update badge on a site already running the // newest stable. The badge counts Core + optimizer + drivers, and the optimizer // was the one claiming an update — its current version was never learned, so diff --git a/go/internal/state/cost.go b/go/internal/state/cost.go index 25099fda..e4f582d9 100644 --- a/go/internal/state/cost.go +++ b/go/internal/state/cost.go @@ -1,6 +1,7 @@ package state import ( + "context" "fmt" "time" @@ -128,20 +129,25 @@ const maxCostIntegrationGap = 20 * time.Minute // Returns zeroes (not an error) when the history is empty over the range — // callers can render that as "no data" without special-casing nil. func (s *Store) DailyCostBreakdown(sinceMs, untilMs int64, zone string, ep ExportPricing) (DayCostBreakdown, error) { + return s.DailyCostBreakdownContext(context.Background(), sinceMs, untilMs, zone, ep) +} + +// DailyCostBreakdownContext stops database work when the caller leaves or times out. +func (s *Store) DailyCostBreakdownContext(ctx context.Context, sinceMs, untilMs int64, zone string, ep ExportPricing) (DayCostBreakdown, error) { if untilMs <= sinceMs { return DayCostBreakdown{}, nil } - slots, err := s.loadPriceSlotsForRange(zone, sinceMs, untilMs) + slots, err := s.loadPriceSlotsForRange(ctx, zone, sinceMs, untilMs) if err != nil { return DayCostBreakdown{}, fmt.Errorf("DailyCostBreakdown: load slots: %w", err) } - out, err := s.integrateHistoryRange(sinceMs, untilMs, slots, ep) + out, err := s.integrateHistoryRange(ctx, sinceMs, untilMs, slots, ep) if err != nil { return DayCostBreakdown{}, fmt.Errorf("DailyCostBreakdown: integrate: %w", err) } - avgImp, avgExp, priceSlots, err := s.avgSlotPricesForRange(zone, sinceMs, untilMs, ep) + avgImp, avgExp, priceSlots, err := s.avgSlotPricesForRange(ctx, zone, sinceMs, untilMs, ep) if err != nil { return DayCostBreakdown{}, fmt.Errorf("DailyCostBreakdown: avg slots: %w", err) } @@ -160,8 +166,8 @@ func (s *Store) DailyCostBreakdown(sinceMs, untilMs int64, zone string, ep Expor // [sinceMs, untilMs], sorted ascending by StartMs. The pre-range pad is // maxSlotPadMs (1 day) — generous against any real provider slot length so // a slot that started just before sinceMs and extends into it is included. -func (s *Store) loadPriceSlotsForRange(zone string, sinceMs, untilMs int64) ([]priceSlot, error) { - rows, err := s.cache.Query(` +func (s *Store) loadPriceSlotsForRange(ctx context.Context, zone string, sinceMs, untilMs int64) ([]priceSlot, error) { + rows, err := s.cache.QueryContext(ctx, ` SELECT slot_ts_ms, slot_len_min, spot_ore_kwh, total_ore_kwh FROM prices WHERE zone = ? @@ -214,9 +220,9 @@ func (s *Store) loadPriceSlotsForRange(zone string, sinceMs, untilMs int64) ([]p // non-negative — the same identity main.go uses in reverse to compute // `load_w` for the history rows). Pricing of EVWh is deferred to the // caller (DailyCostBreakdown applies the day's avg import). -func (s *Store) integrateHistoryRange(sinceMs, untilMs int64, slots []priceSlot, ep ExportPricing) (DayCostBreakdown, error) { +func (s *Store) integrateHistoryRange(ctx context.Context, sinceMs, untilMs int64, slots []priceSlot, ep ExportPricing) (DayCostBreakdown, error) { historyStartMs := sinceMs - maxCostIntegrationGap.Milliseconds() - rows, err := s.db.Query(` + rows, err := s.db.QueryContext(ctx, ` WITH all_rows AS ( SELECT ts_ms, COALESCE(grid_w, 0) AS grid_w, @@ -340,8 +346,8 @@ func (s *Store) integrateHistoryRange(sinceMs, untilMs int64, slots []priceSlot, // avgSlotPricesForRange computes time-weighted import / export price metadata // over price slots overlapping [sinceMs, untilMs), including variable slot // lengths and partial edge slots. -func (s *Store) avgSlotPricesForRange(zone string, sinceMs, untilMs int64, ep ExportPricing) (avgImport, avgExport float64, count int, err error) { - rows, err := s.cache.Query(` +func (s *Store) avgSlotPricesForRange(ctx context.Context, zone string, sinceMs, untilMs int64, ep ExportPricing) (avgImport, avgExport float64, count int, err error) { + rows, err := s.cache.QueryContext(ctx, ` SELECT slot_ts_ms, slot_len_min, spot_ore_kwh, total_ore_kwh FROM prices WHERE zone = ? diff --git a/go/internal/state/cost_context_test.go b/go/internal/state/cost_context_test.go new file mode 100644 index 00000000..89afe28f --- /dev/null +++ b/go/internal/state/cost_context_test.go @@ -0,0 +1,68 @@ +package state + +import ( + "context" + "database/sql" + "errors" + "testing" + "time" +) + +func TestDailyCostBreakdownContextCancelsWaitingRead(t *testing.T) { + for _, database := range []string{"prices", "history"} { + t.Run(database, func(t *testing.T) { + s := freshStore(t) + db := s.cache + if database == "history" { + db = s.db + } + // Hold the whole pool, so the request must wait for an actual + // database connection rather than a timing-dependent SQLite lock. + db.SetMaxOpenConns(1) + conn, err := db.Conn(context.Background()) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + waits := db.Stats().WaitCount + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { + _, err := s.DailyCostBreakdownContext(ctx, 0, 3_600_000, "SE3", ExportPricing{}) + done <- err + }() + waitForCostDBWait(t, db, waits) + cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("canceled read = %v, want context.Canceled", err) + } + case <-time.After(time.Second): + // Release the blocked worker before failing, so the store can + // close even when this test runs against the old implementation. + _ = conn.Close() + <-done + t.Fatal("canceled cost read still waits for a database connection") + } + if err := conn.Close(); err != nil { + t.Fatal(err) + } + if _, err := s.DailyCostBreakdownContext(context.Background(), 0, 3_600_000, "SE3", ExportPricing{}); err != nil { + t.Fatalf("next read after cancellation: %v", err) + } + }) + } +} + +func waitForCostDBWait(t *testing.T, db *sql.DB, before int64) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for db.Stats().WaitCount == before { + if time.Now().After(deadline) { + t.Fatal("cost query did not wait for the held database connection") + } + time.Sleep(time.Millisecond) + } +} diff --git a/optimizer/ftw_optimizer/model.py b/optimizer/ftw_optimizer/model.py index b1e0aaeb..f566ce8b 100644 --- a/optimizer/ftw_optimizer/model.py +++ b/optimizer/ftw_optimizer/model.py @@ -917,8 +917,15 @@ def solve( zero_idx = steps.index(0.0) active = 1 - flex.selection[zero_idx, :] if bool(flex.spec.get("surplus_only", False)): - for sv in scenario_vars: - constraints.append(sv["import"] <= max_site_power * (1 - active)) + # Leftover PV after house load. Site import from a simultaneous + # home-battery grid-charge is not the car importing; forbidding + # import whenever the EV is active forced the solver to idle the + # car on every cheap slot the battery wanted to buy. + house_surplus = np.maximum(0.0, -base_pv - base_load) + # Base forecast leftover. Robust low-PV scenarios are not a + # tighter leftover here; Core ValidatePlan rejects a plan + # that exceeds the slot's actual leftover. + constraints.append(flex.power <= house_surplus + 50.0) if bool(flex.spec.get("no_storage_to_load", False)) and storages: house_residual = np.maximum(0.0, base_load + base_pv) constraints.append(total_discharge <= house_residual + max_site_power * (1 - active)) diff --git a/optimizer/tests/test_model.py b/optimizer/tests/test_model.py index 03382b1d..de00889d 100644 --- a/optimizer/tests/test_model.py +++ b/optimizer/tests/test_model.py @@ -2087,9 +2087,45 @@ def test_surplus_only_ev_does_not_block_home_battery_grid_charge() -> None: assert actions[0]["grid_w"] > 100 +def test_surplus_only_ev_takes_pv_while_battery_grid_charges() -> None: + """Leftover PV may go to the car while the home battery buys from the grid. + + Forbidding site import whenever the EV was active idled the car on + every cheap slot the battery wanted to charge. + """ + + request = base_request() + request["slots"][0]["pv_w"] = -6500 + request["slots"][0]["max_import_w"] = 16000 + request["storages"][0]["max_charge_w"] = 10000 + request["flex_loads"] = [ + { + "id": "surplus-car", + "capacity_wh": 40000, + "initial_energy_wh": 8000, + "max_energy_wh": 40000, + "target_energy_wh": 16000, + "target_slot": 1, + "charge_efficiency": 1, + "allowed_steps_w": [0, 3000], + "surplus_only": True, + "no_storage_to_load": True, + } + ] + response = handle(request) + assert response["ok"], response + action = response["plan"]["actions"][0] + assert action["flex_power_w"]["surplus-car"] > 100 + assert action["battery_w"] > 100 + assert action["grid_w"] > 100 + leftover = max(0.0, 6500 - 500) + assert action["flex_power_w"]["surplus-car"] <= leftover + 50 + 1e-5 + + def test_surplus_only_ev_still_cannot_import() -> None: request = base_request() request["slots"] = [request["slots"][1]] # expensive slot only + request["slots"][0]["pv_w"] = -3000 # leftover 500 W, below the 2 kW step request["storages"][0]["initial_energy_wh"] = 2000 request["flex_loads"] = [ { @@ -2107,8 +2143,9 @@ def test_surplus_only_ev_still_cannot_import() -> None: response = handle(request) assert response["ok"], response action = response["plan"]["actions"][0] - if action["flex_power_w"]["surplus-car"] > 1e-5: - assert action["grid_w"] <= 50 + 1e-5 + leftover = max(0.0, 3000 - 2500) + assert action["flex_power_w"]["surplus-car"] <= leftover + 50 + 1e-5 + assert action["flex_power_w"]["surplus-car"] <= 1e-5 def test_ev_charge_never_coincides_with_battery_export() -> None: diff --git a/scripts/test-exact-image-promotion.sh b/scripts/test-exact-image-promotion.sh index 87031fb7..570228de 100755 --- a/scripts/test-exact-image-promotion.sh +++ b/scripts/test-exact-image-promotion.sh @@ -23,6 +23,20 @@ grep -Fq 'CANONICAL_GHCR_USER: ${{ github.actor }}' "${assets}" grep -Fq 'CANONICAL_GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}' "${assets}" grep -Fq 'username: ${{ github.actor }}' "${optimizer_release}" grep -Fq 'password: ${{ secrets.GITHUB_TOKEN }}' "${optimizer_release}" +if grep -Fq 'LEGACY_GHCR_TOKEN' "${optimizer_release}"; then + echo "canonical optimizer writes must not use the personal namespace credential" >&2 + exit 1 +fi +grep -Fq 'bash scripts/check-ghcr-write-access.sh srcfl/ftw-optimizer' "${optimizer_release}" +grep -Fq 'GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }}' "${optimizer_release}" +grep -A3 '^ validate:$' "${optimizer_release}" | grep -Fq 'needs: registry' +grep -A5 '^ dry_run:$' "${optimizer_release}" | grep -Fq 'default: true' +grep -A5 '^ publish:$' "${optimizer_release}" | \ + grep -Fq 'if: ${{ !inputs.dry_run && needs.validate.outputs.image_exists != '\''true'\'' }}' +grep -A9 '^ release:$' "${optimizer_release}" | grep -Fq '!inputs.dry_run &&' +grep -A1 '^permissions:$' "${optimizer_release}" | grep -Fq 'contents: read' +grep -A7 '^ registry:$' "${optimizer_release}" | grep -Fq 'packages: write' +grep -A5 '^ dry-run:$' "${optimizer_release}" | grep -Fq 'needs: [registry, validate, test]' grep -Fq 'LEGACY_GHCR_TOKEN' "${beta}" grep -Fq 'LEGACY_GHCR_TOKEN' "${release}" grep -Fq 'LEGACY_GHCR_TOKEN' "${assets}" diff --git a/web/app.js b/web/app.js index f02f9153..2a3ed360 100644 --- a/web/app.js +++ b/web/app.js @@ -65,6 +65,22 @@ return request.then(decode).finally(function () { clearTimeout(timer); }); } + function evWrite(path, options) { + var controller = new AbortController(); + var timer = setTimeout(function () { controller.abort(); }, 30000); + return apiFetch(path, Object.assign({}, options, { signal: controller.signal })) + .then(function (response) { + // Keep the deadline until the body arrives, including an error body. + return response.arrayBuffer().then(function (body) { + return new Response(response.status === 204 ? null : body, { status: response.status, statusText: response.statusText, headers: response.headers }); + }); + }) + .catch(function (e) { + if (e && e.name === "AbortError") throw new Error("FTW has not confirmed the request. Check its current state before trying again"); + throw e; + }).finally(function () { clearTimeout(timer); }); + } + // ---- Chart data ---- var chartHistory = { grid: [], @@ -546,7 +562,7 @@ if (lpEv.vehicle_soc > 0) { p.soc = lpEv.vehicle_soc * 100; p.socSource = "vehicle"; - } else if (lpEv.current_soc > 0) { + } else if (lpEv.current_soc > 0 && lpEv.soc_source !== "assumed") { p.soc = lpEv.current_soc * 100; p.socSource = lpEv.soc_source || "inferred"; } @@ -2111,6 +2127,68 @@ // entries mean "no loadpoint for this driver" and the planet // falls back to legacy kW-only rendering. var loadpointsByDriver = null; + var chargingNoticePoints = []; + var chargingNoticeRows = new Map(); + var chargingNoticeTimer = null; + function updateChargingNotice(payload) { + var fresh = !!(payload && Array.isArray(payload.loadpoints)); + if (fresh) { + chargingNoticePoints = payload.loadpoints.map(function (lp) { + var previous = chargingNoticePoints.find(function (old) { return old.id === lp.id; }); + // Offline telemetry cannot establish that the cable was removed. + return previous && previous.plugged_in && lp.charger && !lp.charger.available + ? Object.assign({}, lp, { plugged_in: true }) : lp; + }); + if (chargingNoticeTimer) clearTimeout(chargingNoticeTimer); + chargingNoticeTimer = setTimeout(function () { updateChargingNotice(null); }, 15000); + } + fresh = fresh && !document.hidden; + var host = document.getElementById("charging-notices"); + var anchor = document.getElementById("power-now"); + if (!anchor) return; + if (!host) { + host = document.createElement("div"); + host.id = "charging-notices"; + anchor.parentNode.insertBefore(host, anchor); + } + var keep = new Set(); + chargingNoticePoints.filter(function (lp) { return lp.plugged_in; }).forEach(function (lp) { + keep.add(lp.id); + var row = chargingNoticeRows.get(lp.id); + if (!row) { + var section = document.createElement("section"); + section.className = "overview-card"; + section.setAttribute("aria-label", "Car connection"); + section.style.cssText = "padding:0.9rem;margin-bottom:1rem"; + var title = document.createElement("strong"); + var status = document.createElement("p"); + status.setAttribute("role", "status"); + status.style.cssText = "margin:0.45rem 0;font-size:0.9rem"; + var button = document.createElement("button"); + button.type = "button"; + button.style.cssText = "background:none;border:0;color:var(--accent-e);padding:0.3rem 0;text-align:left;text-decoration:underline;font:inherit;cursor:pointer"; + section.append(title, status, button); + host.appendChild(section); + row = { section: section, title: title, status: status, button: button }; + chargingNoticeRows.set(lp.id, row); + } + var available = fresh && (!lp.charger || lp.charger.available); + row.title.textContent = available ? "Car connected" : "Car status is out of date"; + var message = available ? renderEvPlanStatus(lp, null) : null; + row.status.textContent = message ? message.textContent : "Waiting for current charger status. The last reading cannot confirm charging."; + row.button.textContent = "Check charging" + (lp.soc_source !== "vehicle" ? " and battery level" : ""); + row.button.onclick = function () { + if (energyFlowEl) energyFlowEl.dispatchEvent(new CustomEvent("ftw-planet-click", { detail: { role: "ev", name: lp.driver_name } })); + }; + }); + chargingNoticeRows.forEach(function (row, id) { + if (!keep.has(id)) { row.section.remove(); chargingNoticeRows.delete(id); } + }); + host.hidden = keep.size === 0; + } + document.addEventListener("visibilitychange", function () { + if (document.hidden) updateChargingNotice(null); + }); // Last successful /api/status payload — surfaced so secondary // consumers (e.g. the EV modal's 5 s refresh) can read derived // facts like siteHasPV() without re-fetching. `null` until the @@ -2146,6 +2224,7 @@ }); loadpointsByDriver = idx; } + updateChargingNotice(lp); setConnected(true); if (firstLoad) { firstLoad = false; } if (setupBannerShown) { hideSetupBanner(); } @@ -2162,6 +2241,7 @@ }) .catch(function (e) { console.warn("status fetch failed:", e); + updateChargingNotice(null); setConnected(false); if (firstLoad) { showSetupBanner(); } }); @@ -2647,6 +2727,68 @@ return String(d.getHours()).padStart(2, "0") + ":" + String(d.getMinutes()).padStart(2, "0"); } + function evFmtElapsed(ms) { + var s = Math.max(0, Math.round(ms / 1000)); + if (s < 90) return s + " s"; + var m = Math.round(s / 60); + if (m < 90) return m + " min"; + return Math.round(m / 60) + " h"; + } + + // manualStatusText is the one sentence the Manual tab and the plan strip + // show while an operator hold runs. It follows the charger, not the + // request: sent, taken by the charger, charging, not drawing, stalled or + // limited by the fuse, each with the time elapsed. The box derives the + // state (loadpoint.manual); this only puts words on it. Returns null + // when no hold is active. + function manualStatusText(lp, d) { + if (lp && lp.manual_restore_unconfirmed) return "Confirm how to continue charging. FTW could not confirm the charger or connection."; + var m = lp && lp.manual; + if (!lp || !lp.manual_active || !m || !m.active) return null; + var reqA = m.requested_a > 0 ? Math.round(m.requested_a) + " A" : formatW(m.requested_w || lp.manual_charge_w || 0); + var cmdA = m.commanded_a > 0 ? Math.round(m.commanded_a) + " A" : formatW(m.commanded_w || 0); + var since = m.since_ms > 0 ? evFmtElapsed(Date.now() - m.since_ms) : ""; + var sinceP = since ? " (" + since + ")" : ""; + var reason = m.charger_reason ? " Charger reports: " + m.charger_reason + "." : ""; + var end = lp.manual_release_soc > 0 + ? " Returns to the plan at the estimated " + Math.round(lp.manual_release_soc * 100) + " % target." + : " Continues until the car stops drawing, you return to the plan, or unplug."; + switch (m.state) { + case "pausing": + return "Pause requested. " + ((lp.current_power_w || 0) >= 100 ? formatW(lp.current_power_w) + " is still flowing. " : "") + "Waiting for the charger to stop."; + case "paused": + return "Paused by you. Charging stays off until you resume the plan, choose Charge now, or unplug."; + case "unavailable": + return "Charger status is out of date. FTW cannot confirm whether the car is charging."; + case "charging": + return ((lp.current_power_w || 0) >= 100 + ? "Charging at " + formatW(lp.current_power_w) + " — " + reqA + " requested." + : "The charger reports charging. Waiting for a power reading.") + end; + case "sent": + return "FTW received " + reqA + ". Waiting for the charger to confirm the new limit." + sinceP + ((lp.current_power_w || 0) >= 100 ? " Still charging at " + formatW(lp.current_power_w) + "." : "") + end; + case "accepted": + return "Charger reports a " + cmdA + " limit. Waiting for the car to start drawing…" + sinceP + reason + end; + case "not_drawing": + return "Charger offers " + cmdA + " but the car is not drawing" + sinceP + "." + + (reason || " It may be full, or held by its own charge limit or schedule."); + case "stalled": + if (evIsPaused(lp)) return "The charger has not stopped after your pause request. Check the charger’s app."; + return "The charger has not acted on " + reqA + (since ? " after " + since : "") + "." + + ((lp.current_power_w || 0) >= 100 ? " Still charging at " + formatW(lp.current_power_w) + "." : "") + + (reason || " Check the car's own charge limit or schedule, then the charger's app."); + case "limited": + if (m.limit_reason === "charger_limit") return "The charger limits this request to " + cmdA + " (" + reqA + " requested)."; + if (m.limit_reason === "fuse_cooldown") { + return "Paused: main-fuse protection — " + reqA + " requested; charging resumes on its own." + sinceP; + } + if (m.limit_reason === "site_meter_stale") { + return "Paused for safety: site-meter data is stale — " + reqA + " requested; charging resumes when telemetry recovers."; + } + return "Main fuse limits this charge to " + cmdA + " right now (" + reqA + " requested)." + sinceP; + } + return "FTW received " + reqA + ". Waiting for charger status." + end; + } + // renderEvPlanStatus answers the question the status table can't: // "why isn't it charging right now, and when will it?" Field // experience: a car plugged in against a schedule sits at 0 W until @@ -2656,19 +2798,27 @@ // there is nothing worth saying (no loadpoint, or unplugged — the // schedule note covers that case). function renderEvPlanStatus(lp, d) { - if (!lp || !lp.plugged_in) return null; + if (!lp || (!lp.plugged_in && !lp.manual_restore_unconfirmed)) return null; var text = null; var tone = "var(--text-dim)"; var kwPlanned = lp.plan_total_wh > 0 ? " ~" + (lp.plan_total_wh / 1000).toFixed(1) + " kWh planned." : ""; var winActive = lp.plan_next_start_ms > 0 && lp.plan_next_start_ms <= Date.now() && Date.now() < lp.plan_next_end_ms; var charging = (lp.current_power_w || 0) >= 100; var hasSchedule = lp.schedule && lp.schedule.soc > 0; - if (lp.manual_active) { - text = lp.manual_release_soc > 0 - ? "Charging now at " + formatW(lp.manual_charge_w || 0) + " → returns to plan at " + - Math.round(lp.manual_release_soc * 100) + " %." - : "Manual charge at " + formatW(lp.manual_charge_w || 0) + - " — plan and PV logic are off until the car is full, Stop or unplug."; + if (lp.manual_restore_unconfirmed) { + text = manualStatusText(lp, d); + } else if (lp.charger && !lp.charger.available) { + text = lp.charger.known + ? "Charger status is out of date. FTW cannot confirm whether the car is charging." + : "Waiting for the charger's first status report."; + tone = "var(--text)"; + } else if (lp.manual_active) { + // The same sentence as the charge controls, so the charger's own reason is + // never hidden behind "manual charge is running". + text = manualStatusText(lp, d) || "Manual charge requested. Waiting for charger status."; + if (lp.manual && (lp.manual.state === "not_drawing" || lp.manual.state === "stalled")) { + tone = "var(--text)"; + } } else if (charging) { text = winActive ? "Charging on plan until " + evFmtClock(lp.plan_next_end_ms) + "." + kwPlanned @@ -2676,12 +2826,12 @@ if (lp.commanded_reason === "fuse_limit") { text += " Rate is limited by the main fuse right now."; } + } else if (lp.charging_declined) { + text = "The car stopped asking for charge. Check its charge limit or schedule. This does not confirm the battery is full."; } else if (lp.commanded_known && lp.commanded_w > 0) { - text = "Charger offers " + formatW(lp.commanded_w) + - " but the car isn't drawing — it may be full or at its own charge limit."; - if (d && d.reason_no_current_label) { - text += " Charger reports: " + d.reason_no_current_label + "."; - } + text = "FTW requests " + formatW(lp.commanded_w) + ". Waiting for the car to draw power."; + var chargerReason = lp.charger && lp.charger.reason || d && d.reason_no_current_label; + if (chargerReason) text += " Charger reports: " + chargerReason + "."; tone = "var(--text)"; } else if (lp.commanded_known && !lp.commanded_w && (lp.commanded_reason === "fuse_cooldown" || lp.commanded_reason === "fuse_limit")) { @@ -2693,7 +2843,11 @@ } else if (lp.commanded_known && !lp.commanded_w && lp.commanded_reason === "site_meter_stale") { text = "Paused for safety: site-meter data is stale — charging resumes when telemetry recovers."; tone = "var(--text)"; - } else if (lp.grid_deferred) { + } else if (lp.plan_pending) { + text = "Updating the charging plan…"; + } else if (lp.plan_outdated) { + text = "Charging times are unavailable. Your settings are saved."; + } else if (lp.grid_deferred && hasSchedule) { // Richer than the pv_surplus_pause reason it usually co-occurs // with: it also says when normal planning resumes. text = "Waiting for tomorrow's electricity prices — until they arrive (~13:00) the car charges from PV surplus only."; @@ -2708,11 +2862,13 @@ } else if (lp.surplus_only) { text = "PV surplus only — charges when solar exceeds house load."; } else if (!hasSchedule) { - text = "Nothing will start charging: set a schedule, turn on PV only, or press Start."; + text = "No charging plan yet. Set a ready time, or choose Charge now."; tone = "var(--text)"; } else { - text = "No charge window in the current plan — the target may already be reached."; + text = "No charge window yet for this goal. Choose Charge now if you need to charge immediately."; } + if (lp.plan_pending && !lp.manual_active && text && text.indexOf("Updating the charging plan") < 0) text += " Updating the charging plan…"; + if (lp.manual_save_error) text = (text ? text + " " : "") + "This choice is active now, but could not be saved for restart. FTW is retrying."; if (!text) return null; var p = document.createElement("p"); p.style.color = tone; @@ -2724,24 +2880,17 @@ return p; } - // EV modal sub-elements held across refreshes. The status table is - // updated in place on every poll. The tabbed control (PV charging / - // Manual / Scheduled) is mounted exactly once per (modal-open × LP) - // and is NEVER detached on a poll — detaching+reattaching a focused - // blurs it mid-keystroke and resets caret position. After a - // Save/Clear/Start/Stop we set the matching *NeedsRebuild flag so the - // next poll rebuilds from the new authoritative server state. The - // active tab persists across rebuilds via evActiveTab. + // Keep controls mounted while polling so editing never loses focus. var statusTableEl = null; var evPlanEl = null; // { el, update } from buildEvPlanView var evPlanLpId = null; - var evTabsEl = null; - var evTabsLpId = null; + var evControlsEl = null; + var evControlsLpId = null; var evBoostEl = null; // { el, update } from buildEvBoostView var evBoostLpId = null; + var evBoostDetails = null; var schedNeedsRebuild = false; var manualNeedsRebuild = false; - var evActiveTab = "pv"; // "pv" | "manual" | "scheduled" // Detect whether the site has any PV driver configured. Used to hide // the "surplus charge from PV" option on PV-less sites where the @@ -2757,7 +2906,13 @@ return false; } + var evReading = false; + var evReadPromise = null; + var evLastLp = null; function refreshEvModal() { + if (evReading) return evReadPromise; + evReading = true; + var requestedDriver = evModalDriver; // Pass driver query if known so the backend can scope the response // to the clicked planet (multi-EV setups). Falls back to whatever // the backend returns when no driver filter is honored. @@ -2766,11 +2921,15 @@ // recent payload cached by fetchStatus() instead of issuing a // duplicate /api/status fetch on every 5 s modal tick. Falls back // to "no PV" until the dashboard's own fetchStatus lands once. - Promise.all([ + evReadPromise = Promise.all([ // Local API reads. - apiFetch(url).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; }), - apiFetch("/api/loadpoints").then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; }), + boundedApiRead(url, function (r) { if (!r.ok) throw new Error("Charger status unavailable"); return r.json(); }), + boundedApiRead("/api/loadpoints", function (r) { if (!r.ok) throw new Error("Charging settings unavailable"); return r.json(); }), ]).then(function (results) { + if (requestedDriver !== evModalDriver || evModal.classList.contains("hidden")) return; + var problem = evModalBody.querySelector('.ev-read-problem'); + if (problem) problem.remove(); + if (statusTableEl) statusTableEl.hidden = false; var d = results[0]; var lps = results[1]; var status = lastStatusPayload; @@ -2789,8 +2948,8 @@ evPlanLpId = null; evBoostEl = null; evBoostLpId = null; - evTabsEl = null; - evTabsLpId = null; + evControlsEl = null; + evControlsLpId = null; return; } // Status table: replace in place so the rest of the modal body @@ -2802,13 +2961,18 @@ // the modal still has a header before the schedule editor. var freshStatus; if (carConnected) { - freshStatus = renderEvStatusTable(d); + freshStatus = document.createElement("details"); + freshStatus.open = !!(statusTableEl && statusTableEl.open); + var detailsLabel = document.createElement("summary"); + detailsLabel.textContent = "Charger details"; + freshStatus.appendChild(detailsLabel); + freshStatus.appendChild(renderEvStatusTable(d)); } else { freshStatus = document.createElement("p"); freshStatus.style.color = "var(--text-dim)"; freshStatus.style.fontStyle = "italic"; freshStatus.style.margin = "0 0 0.6rem 0"; - freshStatus.textContent = "No car connected — schedule below is saved to the loadpoint and applies on next plug-in."; + freshStatus.textContent = "No car connected. Set a ready time, or plug in and choose Charge now."; } if (statusTableEl && statusTableEl.parentNode === evModalBody) { evModalBody.replaceChild(freshStatus, statusTableEl); @@ -2839,15 +3003,39 @@ // schedule is persistent loadpoint state, not driver state, and // operators routinely want to set tomorrow morning's target // before plugging in tonight). - if (!matched) { + if (!matched && !evModalDriver) { for (var j = 0; j < lps.loadpoints.length; j++) { if (lps.loadpoints[j].plugged_in) { matched = lps.loadpoints[j]; break; } } } - if (!matched) { + if (!matched && !evModalDriver) { matched = lps.loadpoints[0]; } } + if (!carConnected && matched && matched.schedule && matched.schedule.soc > 0) { + freshStatus.textContent = "No car connected. This goal is saved and applies when you plug in." + (matched.plan_pending ? " Updating the plan…" : matched.plan_outdated ? " Charging times are unavailable." : ""); + } + evLastLp = matched; + if (matched && matched.charger && !matched.charger.available) { + var oldReport = document.createElement("p"); + oldReport.textContent = matched.charger.updated_at_ms + ? "Last charger report: " + evFmtClock(matched.charger.updated_at_ms) + " (out of date)." + : "Waiting for the charger's first status report."; + evModalBody.replaceChild(oldReport, statusTableEl); + statusTableEl = oldReport; + } + var setupNote = evModalBody.querySelector(".ev-setup-note"); + if (!matched && lps) { + if (!setupNote) { + setupNote = document.createElement("p"); + setupNote.className = "ev-setup-note"; + setupNote.setAttribute("role", "status"); + evModalBody.insertBefore(setupNote, statusTableEl.nextSibling); + } + setupNote.textContent = "FTW can read this charger, but charging control is not set up. Open Settings → Chargers and add the charger."; + } else if (setupNote) { + setupNote.remove(); + } // Plan view: what the box will do with this car (one sentence + // the planned windows on a 24 h track) and the car's charge level, // which the plan is built from. Mounted once per loadpoint so the @@ -2859,11 +3047,11 @@ } evPlanEl = buildEvPlanView(matched, d); evPlanLpId = matched.id; - evModalBody.insertBefore(evPlanEl.el, statusTableEl.nextSibling); + evModalBody.insertBefore(evPlanEl.el, statusTableEl); } else { evPlanEl.update(matched, d); if (evPlanEl.el.parentNode !== evModalBody) { - evModalBody.insertBefore(evPlanEl.el, statusTableEl.nextSibling); + evModalBody.insertBefore(evPlanEl.el, statusTableEl); } } } else if (evPlanEl) { @@ -2874,61 +3062,88 @@ evPlanLpId = null; } if (matched) { - // Build the tabbed control (PV charging / Manual / Scheduled) - // exactly once per LP. Polling never rebuilds it — inputs keep - // focus/value/caret and the active tab persists. Only a - // Save/Clear (schedNeedsRebuild) or Start/Stop/Set-SoC - // (manualNeedsRebuild), or switching LP (planet), forces a build. - var lpChanged = evTabsEl == null || evTabsLpId !== matched.id; + // Rebuild only for a changed charger or a removed goal. + var lpChanged = evControlsEl == null || evControlsLpId !== matched.id; if (lpChanged || schedNeedsRebuild || manualNeedsRebuild) { - if (evTabsEl && evTabsEl.parentNode === evModalBody) { - evModalBody.removeChild(evTabsEl); + if (evControlsEl && evControlsEl.parentNode === evModalBody) { + evModalBody.removeChild(evControlsEl); } - evTabsEl = buildEvTabbedControl(matched, siteHasPV(status)); - evTabsLpId = matched.id; + evControlsEl = buildEvControls(matched, siteHasPV(status)); + evControlsLpId = matched.id; schedNeedsRebuild = false; manualNeedsRebuild = false; - evModalBody.appendChild(evTabsEl); - } else if (evTabsEl.parentNode !== evModalBody) { + evModalBody.appendChild(evControlsEl); + } else if (evControlsEl.parentNode !== evModalBody) { // Modal was previously closed: body got wiped but our cached // section is still valid — re-attach. - evModalBody.appendChild(evTabsEl); + evModalBody.appendChild(evControlsEl); } } else { - if (evTabsEl && evTabsEl.parentNode === evModalBody) { - evModalBody.removeChild(evTabsEl); + if (evControlsEl && evControlsEl.parentNode === evModalBody) { + evModalBody.removeChild(evControlsEl); } - evTabsEl = null; - evTabsLpId = null; + evControlsEl = null; + evControlsLpId = null; + } + if (matched && evControlsEl && statusTableEl) evModalBody.appendChild(statusTableEl); + if (matched && evControlsEl && typeof evControlsEl.update === "function") { + evControlsEl.update(matched, d); } - // Boost from the home battery: one control, below the tabs, for + // Boost from the home battery: one control, below the goal, for // whichever mode the loadpoint is in. Mounted once per loadpoint // (the reserve slider keeps its value); update() redraws state - // every poll. Always kept last so a tabs rebuild cannot land + // every poll. Always kept last so a controls rebuild cannot land // underneath it. if (matched) { if (evBoostEl == null || evBoostLpId !== matched.id) { if (evBoostEl && evBoostEl.el.parentNode === evModalBody) { evModalBody.removeChild(evBoostEl.el); } + if (evBoostDetails && evBoostDetails.parentNode === evModalBody) evBoostDetails.remove(); evBoostEl = buildEvBoostView(matched, status); evBoostLpId = matched.id; + evBoostDetails = document.createElement("details"); + var boostSummary = document.createElement("summary"); + boostSummary.textContent = "Use the home battery"; + evBoostDetails.appendChild(boostSummary); + evBoostDetails.appendChild(evBoostEl.el); } else { evBoostEl.update(matched, status); } - if (evModalBody.lastChild !== evBoostEl.el) { - evModalBody.appendChild(evBoostEl.el); + if (evModalBody.lastChild !== evBoostDetails) { + evModalBody.appendChild(evBoostDetails); } } else if (evBoostEl) { - if (evBoostEl.el.parentNode === evModalBody) { - evModalBody.removeChild(evBoostEl.el); - } + if (evBoostDetails && evBoostDetails.parentNode === evModalBody) evBoostDetails.remove(); evBoostEl = null; evBoostLpId = null; } }).catch(function () { - setEvModalMessage("Failed to load EV status"); - }); + if (requestedDriver !== evModalDriver || evModal.classList.contains("hidden")) return; + var problem = evModalBody.querySelector('.ev-read-problem'); + if (!problem) { + problem = document.createElement("p"); + problem.className = "ev-read-problem"; + problem.setAttribute("role", "alert"); + evModalBody.prepend(problem); + } + problem.textContent = "Waiting for current charger status. FTW cannot confirm whether the car is charging. Trying again…"; + if (statusTableEl) statusTableEl.hidden = true; + if (evLastLp) { + var stale = Object.assign({}, evLastLp, { + charger: { known: true, available: false }, + manual: Object.assign({}, evLastLp.manual, { state: "unavailable" }), + }); + if (evControlsEl && evControlsEl.update) evControlsEl.update(stale, null); + if (evPlanEl) evPlanEl.update(stale, null); + } + }).finally(function () { evReading = false; }); + return evReadPromise; + } + + function refreshEvModalAfterWrite() { + if (evReading) return evReadPromise.then(function () { return refreshEvModal(); }); + return refreshEvModal(); } // Stop reasons the controller reports for a battery boost lease, in @@ -3099,7 +3314,7 @@ startBtn.disabled = true; msg.textContent = "Starting boost…"; // CONTROL write — strict (FIX-B): time-boxed battery boost lease. - apiFetch("/api/loadpoints/" + encodeURIComponent(lp.id) + "/battery_boost", { + evWrite("/api/loadpoints/" + encodeURIComponent(lp.id) + "/battery_boost", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -3109,7 +3324,7 @@ }).then(function (res) { return res.json().then(function (j) { return { ok: res.ok, body: j }; }); }) .then(function (res) { startBtn.disabled = false; - if (res.ok) { msg.textContent = ""; refreshEvModal(); } + if (res.ok) { msg.textContent = ""; refreshEvModalAfterWrite(); } else { msg.textContent = (res.body && res.body.error) || "Boost could not start."; } }).catch(function (e) { startBtn.disabled = false; msg.textContent = "Boost could not start: " + e.message; }); }); @@ -3117,10 +3332,10 @@ stopBtn.addEventListener("click", function () { stopBtn.disabled = true; msg.textContent = "Stopping boost…"; - apiFetch("/api/loadpoints/" + encodeURIComponent(lp.id) + "/battery_boost", { method: "DELETE" }) + evWrite("/api/loadpoints/" + encodeURIComponent(lp.id) + "/battery_boost", { method: "DELETE" }) .then(function (res) { stopBtn.disabled = false; - if (res.ok) { msg.textContent = ""; refreshEvModal(); } + if (res.ok) { msg.textContent = ""; refreshEvModalAfterWrite(); } else { msg.textContent = "Boost could not be stopped."; } }).catch(function (e) { stopBtn.disabled = false; msg.textContent = "Boost could not be stopped: " + e.message; }); }); @@ -3168,6 +3383,11 @@ // loadpoint is in (PV-surplus-only if that toggle is on). The amperage // is sent as watts (power_w = A × phases × voltage); the driver // converts back to amps given the wallbox it's talking to. + function evIsPaused(lp) { + return !!(lp && !lp.manual_restore_unconfirmed && lp.manual_active && (lp.manual_charge_w === 0 || + lp.manual && (lp.manual.requested_w === 0 || lp.manual.state === "paused" || lp.manual.state === "pausing"))); + } + function buildManualChargeSection(lp) { var phases = (lp && lp.phases) || 3; var voltage = (lp && lp.voltage_v) || 230; @@ -3197,7 +3417,8 @@ box.style.borderTop = "1px solid var(--line)"; var eyebrow = document.createElement("div"); - eyebrow.textContent = "Manual Charge"; + eyebrow.textContent = "Charge now is active"; + eyebrow.hidden = !active; eyebrow.style.fontFamily = "var(--mono)"; eyebrow.style.fontSize = "0.7rem"; eyebrow.style.letterSpacing = "0.18em"; @@ -3218,6 +3439,7 @@ slider.max = String(maxA); slider.step = "1"; slider.value = String(curA); + slider.setAttribute("aria-label", "Charging current"); slider.style.flex = "1"; slider.style.accentColor = "var(--accent-e)"; @@ -3232,10 +3454,13 @@ readout.textContent = a + " A · " + (aToW(a) / 1000).toFixed(1) + " kW"; } renderReadout(); - slider.addEventListener("input", renderReadout); + var currentEditingUntil = 0; + slider.addEventListener("input", function () { currentEditingUntil = Date.now() + 1500; renderReadout(); }); row.appendChild(slider); row.appendChild(readout); + row.hidden = !active; + row.style.display = active ? "flex" : "none"; box.appendChild(row); // Status line. @@ -3244,11 +3469,10 @@ status.style.color = "var(--text-dim)"; status.style.marginTop = "0.35rem"; status.style.minHeight = "1em"; - status.textContent = active - ? (lp && lp.manual_release_soc > 0 - ? "Charging now → stops at " + Math.round(lp.manual_release_soc * 100) + " %, then back to the plan (fuse still limits)." - : "Charging at the slider's amps until the car is full, Stop or unplug (fuse still limits).") - : "Charges at the slider's amps until the car is full, Stop or unplug. The plan takes over again after that."; + status.setAttribute("role", "status"); + status.setAttribute("aria-live", "polite"); + var idleText = "Starts at up to " + maxA + " A · " + (aToW(maxA) / 1000).toFixed(1) + " kW. Ignores the goal and solar rule until you return to the plan or unplug."; + status.textContent = active ? "Changes apply when you release the slider." : idleText; box.appendChild(status); // Start / Stop buttons. @@ -3259,7 +3483,8 @@ var startBtn = document.createElement("button"); startBtn.type = "button"; - startBtn.textContent = active ? "Update" : "Charge now"; + startBtn.textContent = "Charge now"; + startBtn.hidden = active; startBtn.style.flex = "1"; startBtn.style.padding = "0.4rem 0.6rem"; startBtn.style.border = "none"; @@ -3271,7 +3496,7 @@ var stopBtn = document.createElement("button"); stopBtn.type = "button"; - stopBtn.textContent = "Stop"; + stopBtn.textContent = "Return to plan"; stopBtn.style.flex = "1"; stopBtn.style.padding = "0.4rem 0.6rem"; stopBtn.style.border = "1px solid var(--line)"; @@ -3279,51 +3504,140 @@ stopBtn.style.cursor = "pointer"; stopBtn.style.background = "transparent"; stopBtn.style.color = "var(--fg)"; + stopBtn.hidden = !active; stopBtn.disabled = !active; stopBtn.style.opacity = active ? "1" : "0.5"; + var pauseBtn = stopBtn.cloneNode(false); + pauseBtn.textContent = "Pause charging"; + pauseBtn.hidden = false; + pauseBtn.disabled = false; + pauseBtn.style.opacity = "1"; btnRow.appendChild(startBtn); + btnRow.appendChild(pauseBtn); btnRow.appendChild(stopBtn); box.appendChild(btnRow); - startBtn.addEventListener("click", function () { + // Live state. The section is mounted once per loadpoint; update() runs + // on every poll and rewrites the status line from lp.manual, so what the + // operator reads follows the charger instead of the click. A message + // written by a click holds the line until the box shows the new state. + var lastLp = lp; + var lastD = null; + var busy = false; + var holdLineUntil = 0; + function renderStatus() { + var on = !!(lastLp && lastLp.manual_active); + var paused = evIsPaused(lastLp); + var restore = !!lastLp.manual_restore_unconfirmed; + if (!busy && paused && lastLp.manual && lastLp.manual.state === "paused") holdLineUntil = 0; + if (!busy) { + stopBtn.hidden = !on && !restore; + stopBtn.disabled = !on && !restore; + eyebrow.hidden = !on || paused || restore; + row.hidden = !on || paused || restore; + row.style.display = on && !paused && !restore ? "flex" : "none"; + pauseBtn.hidden = paused; + pauseBtn.disabled = false; + stopBtn.textContent = paused || restore ? "Resume plan" : "Return to plan"; + stopBtn.style.opacity = on || restore ? "1" : "0.5"; + startBtn.hidden = on && !paused && !restore; + if (!on || paused || restore) { slider.value = String(maxA); renderReadout(); } + startBtn.disabled = false; + } + if (busy || Date.now() < holdLineUntil) return; + status.textContent = restore ? "Choose Charge now to request charging, Resume plan to use your goal, or Pause charging to request a stop." : paused ? "The goal and solar rule wait until you resume the plan. Charge now starts immediately." : on ? "Changes apply when you release the slider." : idleText; + } + function update(nextLp, d) { + if (nextLp) lastLp = nextLp; + if (d) lastD = d; + if (!busy && Date.now() >= currentEditingUntil && lastLp.manual_active) { + slider.value = String(Math.min(maxA, Math.max(minA, Math.round(wToA(lastLp.manual_charge_w || 0))))); + renderReadout(); + } + renderStatus(); + } + + // failOn turns an HTTP error into a rejection carrying the server's + // reason, so a refused Start (403, 404, 409) never reads as success. + function failOn(r) { + if (r.ok) return r; + return r.json().catch(function () { return {}; }).then(function (j) { + throw new Error((j && j.error) || ("HTTP " + r.status)); + }); + } + + function requestCharge(pause) { + pause = pause === true; + if (busy) return; + busy = true; + slider.disabled = true; + stopBtn.disabled = true; startBtn.disabled = true; - status.textContent = "Starting…"; var a = parseInt(slider.value, 10) || minA; + pauseBtn.disabled = true; + status.textContent = pause ? "Sending pause request…" : "Asking FTW for " + a + " A…"; // CONTROL write — strict (FIX-B): persistent manual hold (hold_s:0) // with no SoC release — see the note above the slider. - apiFetch("/api/loadpoints/" + encodeURIComponent(lp.id) + "/manual_hold", { + evWrite("/api/loadpoints/" + encodeURIComponent(lp.id) + "/manual_hold", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - power_w: aToW(a), + power_w: pause ? 0 : aToW(a), hold_s: 0, phase_mode: phases === 1 ? "1p" : "3p", }), - }).then(function () { - status.textContent = "Charging at " + a + " A until the car is full, Stop or unplug."; - manualNeedsRebuild = true; // reflect active state on next poll - }).catch(function () { + }).then(failOn).then(function () { + busy = false; + slider.disabled = false; + status.textContent = pause ? "Pause requested. Waiting for the charger to stop…" : "FTW received " + a + " A. Waiting for the charger…"; + holdLineUntil = Date.now() + 6000; + refreshEvModalAfterWrite(); // read the hold and charger response without rebuilding focused inputs + }).catch(function (e) { + busy = false; + slider.disabled = false; startBtn.disabled = false; - status.textContent = "Start failed — try again."; + stopBtn.disabled = !lastLp.manual_active; + pauseBtn.disabled = false; + status.textContent = "Request not confirmed: " + ((e && e.message) || "try again") + "."; + holdLineUntil = Infinity; }); + } + startBtn.addEventListener("click", function () { requestCharge(false); }); + pauseBtn.addEventListener("click", function () { requestCharge(true); }); + slider.addEventListener("change", function () { + if (lastLp.manual_active) requestCharge(); }); stopBtn.addEventListener("click", function () { + busy = true; stopBtn.disabled = true; - status.textContent = "Stopping…"; - apiFetch("/api/loadpoints/" + encodeURIComponent(lp.id) + "/manual_hold", { + slider.disabled = true; + startBtn.disabled = true; + pauseBtn.disabled = true; + status.textContent = "Returning to the plan…"; + evWrite("/api/loadpoints/" + encodeURIComponent(lp.id) + "/manual_hold", { method: "DELETE", - }).then(function () { - status.textContent = "Released — back to automatic charging."; - manualNeedsRebuild = true; - }).catch(function () { + }).then(failOn).then(function () { + busy = false; + slider.disabled = false; + startBtn.disabled = false; + pauseBtn.disabled = false; + status.textContent = "The plan decides when to charge."; + holdLineUntil = Date.now() + 6000; + refreshEvModalAfterWrite(); + }).catch(function (e) { + busy = false; + slider.disabled = false; + startBtn.disabled = false; stopBtn.disabled = false; - status.textContent = "Stop failed — try again."; + pauseBtn.disabled = false; + status.textContent = "Return to plan not confirmed: " + ((e && e.message) || "try again") + "."; + holdLineUntil = Infinity; }); }); - return box; + return { el: box, update: update }; } // sliderHeader builds a "LABEL ............ value" row (mono uppercase @@ -3369,7 +3683,7 @@ return s; } - // buildEvPlanView — the plug-in moment. Above the tabs, the operator + // buildEvPlanView — the plug-in moment. Above the controls, the operator // sees what the box will do with this car: the plan-status sentence, // the planned charge windows drawn on a 24 h track, and right under // it the car's CURRENT charge, which is the input the plan is built @@ -3418,7 +3732,7 @@ // Car's current charge. var socWrap = document.createElement("div"); socWrap.style.marginTop = "0.75rem"; - var hdr = sliderHeader("Car is at", "—"); + var hdr = sliderHeader("Battery now", "—"); socWrap.appendChild(hdr.row); var slider = fullWidthSlider(50, hdr.value); slider.setAttribute("aria-label", "Car's current charge, percent"); @@ -3432,6 +3746,8 @@ box.appendChild(planWrap); box.appendChild(socWrap); + var capacityView = buildEvCapacityView(lp); + box.appendChild(capacityView.el); // While the operator holds the slider (pointer down, or keyboard // input in the last moment), polls must not snap it back to the @@ -3440,42 +3756,71 @@ var dragging = false; var lastInputAt = 0; var noteTimer = null; + var socFailed = false; slider.addEventListener("pointerdown", function () { dragging = true; }); ["pointerup", "pointercancel"].forEach(function (ev) { slider.addEventListener(ev, function () { dragging = false; }); }); slider.addEventListener("input", function () { lastInputAt = Date.now(); }); - function operatorHolds() { return dragging || (Date.now() - lastInputAt) < 1500; } + function operatorHolds() { return dragging || socSaving || socPending !== null || (Date.now() - lastInputAt) < 1500; } function sourceNote(lpNow) { var src = (lpNow && lpNow.soc_source) || ""; - if (src === "vehicle") return "Live from the car. Drag only to correct drift."; - if (src === "completed") return "The car stopped asking for current, so the box assumes the target was reached. Drag to correct."; - return "Estimated from energy delivered. Drag to the real value and the plan follows."; + var retention = lpNow.soc_retention === "session" + ? " FTW keeps this level for the same charging session, including after a box restart." + : lpNow.soc_retention === "error" + ? " This level could not be saved for a box restart. Enter it again before relying on the plan after restarting." + : " This level must be entered again after a box restart."; + if (src === "assumed") return "Battery level needs confirmation. The plan currently assumes " + Math.round(lpNow.current_soc * 100) + " %. Drag to match the car." + retention; + if (src === "vehicle") return "Reported by the car. Drag only to correct drift."; + if (src === "completed") return "The car stopped asking for charge. Its actual battery level is not confirmed. Drag to match the car."; + return "Estimated from energy delivered. Drag to the real value and the plan follows." + retention; } - // Write on release. The handler replans before answering, so the - // refetch right after shows the plan built from the new value. - slider.addEventListener("change", function () { - var v = parseInt(slider.value, 10); - if (!isFinite(v) || v < 0 || v > 100) return; - if (noteTimer) { clearTimeout(noteTimer); noteTimer = null; } - note.textContent = "Replanning from " + v + " %…"; - // CONTROL write — strict (FIX-B): corrects the SoC estimate and replans. - apiFetch("/api/loadpoints/" + encodeURIComponent(lp.id) + "/soc", { + var socPending = null; + var socSaving = false; + var socRevision = 0; + function sendSoc() { + if (socSaving || socPending === null) return; + var v = socPending; + var revision = socRevision; + socPending = null; + socSaving = true; + evWrite("/api/loadpoints/" + encodeURIComponent(lp.id) + "/soc", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ soc: v / 100 }), }).then(function (r) { return r.json().then(function (j) { return { ok: r.ok, body: j }; }); }) .then(function (res) { - if (res.ok && res.body && res.body.ok) { - note.textContent = "Plan updated from " + v + " %."; - noteTimer = setTimeout(function () { note.textContent = sourceNote(lastLp); noteTimer = null; }, 4000); - refreshEvModal(); - } else { - note.textContent = (res.body && res.body.error) || "Could not set the charge level."; - } - }).catch(function (e) { note.textContent = "Could not set the charge level: " + e.message; }); + if (!(res.ok && res.body && res.body.ok)) throw new Error((res.body && res.body.error) || "FTW refused the change."); + if (revision !== socRevision) return; + note.textContent = "Charge level saved: " + v + " %." + + (!(lastLp.schedule && lastLp.schedule.soc > 0) && !lastLp.manual_active && !lastLp.surplus_only + ? " Set a ready time, or choose Charge now." : " Reading the updated plan…"); + noteTimer = setTimeout(function () { noteTimer = null; if (!socFailed) note.textContent = sourceNote(lastLp); }, 6000); + refreshEvModalAfterWrite().then(function () { + if (revision === socRevision && lastLp.soc_retention === "error") { + if (noteTimer) { clearTimeout(noteTimer); noteTimer = null; } + socFailed = true; + note.textContent = "Charge level updated: " + v + " %. It could not be saved for a box restart."; + } + }); + }).catch(function (e) { + if (revision === socRevision) { socFailed = true; note.textContent = "Charge level not confirmed: " + e.message; } + }).finally(function () { + socSaving = false; + if (socPending !== null) sendSoc(); + }); + } + slider.addEventListener("change", function () { + var v = parseInt(slider.value, 10); + if (!isFinite(v) || v < 0 || v > 100) return; + if (noteTimer) { clearTimeout(noteTimer); noteTimer = null; } + socRevision++; + socFailed = false; + socPending = v; + note.textContent = "Sending charge level: " + v + " %…"; + sendSoc(); }); var lastLp = lp; @@ -3485,6 +3830,7 @@ ticks.textContent = ""; var now = Date.now(); var windows = (lpNow && Array.isArray(lpNow.plan_windows)) ? lpNow.plan_windows : []; + planWrap.hidden = windows.length === 0 || !!lpNow.manual_active; var shown = 0; var shownWh = 0; windows.forEach(function (w) { @@ -3512,8 +3858,8 @@ } if (lpNow && lpNow.manual_active) { caption.textContent = shown > 0 - ? "Manual charge is running. The plan below resumes after it." - : "Manual charge is running. Nothing else is planned in the next 24 h."; + ? "Manual charge is selected. The plan below resumes when you return to it." + : "Manual charge is selected. Nothing else is planned in the next 24 h."; } else if (shown > 0) { var first = windows[0]; caption.textContent = "Charges " + evFmtClock(first.start_ms) + "–" + evFmtClock(first.end_ms) + @@ -3528,6 +3874,7 @@ function update(lpNow, dNow) { lastLp = lpNow; + capacityView.update(lpNow); var fresh = renderEvPlanStatus(lpNow, dNow); if (headline && headline.parentNode === box) { if (fresh) { box.replaceChild(fresh, headline); } else { box.removeChild(headline); } @@ -3542,91 +3889,140 @@ var cur = (lpNow.current_soc != null) ? Math.max(0, Math.min(100, Math.round(lpNow.current_soc * 100))) : null; if (!operatorHolds() && cur != null) { slider.value = String(cur); - hdr.value.textContent = cur + "%"; + hdr.value.textContent = lpNow.soc_source === "assumed" ? "Not confirmed" : cur + "%"; } - if (!noteTimer && !operatorHolds()) note.textContent = sourceNote(lpNow); + if (!noteTimer && !socFailed && !operatorHolds()) note.textContent = sourceNote(lpNow); } update(lp, d); return { el: box, update: update, slider: slider }; } - // buildPVModeSection — the per-loadpoint surplus-only toggle (PV - // charging tab). A hard flag, *independent* of any schedule: when on, - // dispatch refuses to import grid for this loadpoint regardless of what - // the MPC plans. Operators can run with this alone ("harvest PV when - // there's enough") or layer a schedule on top. Saves on click. + function buildEvCapacityView(lp) { + var wrap = document.createElement("details"); + wrap.style.cssText = "margin-top:0.6rem;font-size:0.85rem"; + var summary = document.createElement("summary"); + summary.style.cssText = "cursor:pointer;color:var(--text-dim)"; + wrap.appendChild(summary); + var hint = document.createElement("p"); + hint.style.cssText = "color:var(--text-dim);margin:0.4rem 0"; + wrap.appendChild(hint); + var label = document.createElement("label"); + label.textContent = "Usable battery size (kWh) "; + var input = document.createElement("input"); + input.type = "number"; input.min = "1"; input.max = "300"; input.step = "0.1"; + input.setAttribute("inputmode", "decimal"); + input.setAttribute("aria-label", "Usable battery size, kWh"); + input.style.cssText = "width:7em;padding:0.3rem;border:1px solid var(--line);border-radius:4px;background:var(--bg);color:var(--fg)"; + label.appendChild(input); wrap.appendChild(label); + var help = document.createElement("p"); + help.style.cssText = "color:var(--text-dim);margin:0.4rem 0"; + help.textContent = "Applies when you leave the field. Find the usable size in your car’s specifications."; + wrap.appendChild(help); + var note = document.createElement("p"); + note.setAttribute("role", "status"); + note.style.cssText = "color:var(--text-dim);margin:0.4rem 0"; + wrap.appendChild(note); + var retry = document.createElement("button"); + retry.type = "button"; retry.textContent = "Try battery size again"; retry.hidden = true; + wrap.appendChild(retry); + var busy = false, dirty = false, currentCapacity = null, available = true; + input.addEventListener("input", function () { dirty = true; }); + function save() { + if (busy) return; + var value = Number(input.value); + if (!isFinite(value) || value < 1 || value > 300) { note.textContent = "Enter the usable battery size from 1 to 300 kWh."; note.setAttribute("role", "alert"); return; } + busy = true; input.disabled = true; retry.hidden = true; + note.setAttribute("role", "status"); note.textContent = "Sending battery size…"; + evWrite("/api/loadpoints/" + encodeURIComponent(lp.id) + "/vehicle", { + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ capacity_wh: Math.round(value * 1000) }), + }).then(function (r) { return r.json().then(function (j) { if (!r.ok || !j.ok) throw new Error(j.error || "FTW refused the change."); return j; }); }) + .then(function () { + dirty = false; + note.textContent = "Battery size saved. Reading charging status…"; + return refreshEvModalAfterWrite().then(function () { + note.textContent = !available ? "Battery size saved. Current charging status is unavailable." + : currentCapacity !== null && currentCapacity !== Math.round(value * 1000) + ? "Saved as the usual battery size. This session uses " + (currentCapacity / 1000) + " kWh." + : "Battery size saved. The plan uses this size for its estimates."; + }); + }) + .catch(function (err) { note.textContent = "Battery size not confirmed: " + err.message; note.setAttribute("role", "alert"); retry.hidden = false; }) + .finally(function () { busy = false; input.disabled = false; }); + } + input.addEventListener("change", save); retry.addEventListener("click", save); + function update(next) { + var capacity = Number(next.vehicle_capacity_wh); + currentCapacity = isFinite(capacity) && capacity > 0 ? capacity : null; + available = !next.charger || next.charger.available !== false; + wrap.hidden = !next.plugged_in || !(capacity > 0); + summary.textContent = "Car battery · " + (capacity / 1000) + " kWh"; + hint.textContent = next.capacity_source === "default" ? "FTW is using a default size. Check it against your car." : "Used for estimates. Check this size if you use another car."; + if (!busy && !dirty && document.activeElement !== input) input.value = String(capacity / 1000); + } + update(lp); + return { el: wrap, update: update }; + } + + // Solar is a rule of the plan. A manual hold overrides it. function buildPVModeSection(lp) { var soBox = document.createElement("div"); - soBox.style.marginTop = "0.25rem"; - - var soEyebrow = document.createElement("div"); - soEyebrow.textContent = "PV Mode"; - soEyebrow.style.fontFamily = "var(--mono)"; - soEyebrow.style.fontSize = "0.7rem"; - soEyebrow.style.letterSpacing = "0.18em"; - soEyebrow.style.textTransform = "uppercase"; - soEyebrow.style.color = "var(--text-dim)"; - soEyebrow.style.marginBottom = "0.45rem"; - soBox.appendChild(soEyebrow); - + soBox.style.marginTop = "0.7rem"; var soWrap = document.createElement("label"); - soWrap.style.display = "flex"; - soWrap.style.alignItems = "center"; - soWrap.style.gap = "0.4rem"; - soWrap.style.fontSize = "0.85rem"; - soWrap.style.cursor = "pointer"; + soWrap.style.cssText = "display:flex;align-items:center;gap:0.5rem;font-size:0.85rem;cursor:pointer"; var soCb = document.createElement("input"); soCb.type = "checkbox"; - soCb.checked = !!(lp && lp.surplus_only); + soCb.setAttribute("role", "switch"); soCb.style.accentColor = "var(--accent-e)"; var soText = document.createElement("span"); - soText.textContent = "Surplus only (PV only — no grid or battery)"; + soText.textContent = "Only spare solar"; soWrap.appendChild(soCb); soWrap.appendChild(soText); - var soStatus = document.createElement("small"); - soStatus.style.display = "block"; - soStatus.style.color = "var(--text-dim)"; - soStatus.style.marginTop = "0.25rem"; - soStatus.style.marginLeft = "1.4rem"; - soStatus.style.minHeight = "1em"; - soStatus.textContent = "Saves automatically on click. Independent of the schedule below."; - - soBox.appendChild(soWrap); - soBox.appendChild(soStatus); - + soStatus.style.cssText = "display:block;color:var(--text-dim);margin:0.25rem 0 0 1.5rem"; + soStatus.setAttribute("role", "status"); + var busy = false, failure = "", latest = lp; + function update(next) { + latest = next; + if (busy) return; + soCb.checked = !!latest.surplus_only; + soCb.disabled = !!latest.manual_active || !!latest.manual_restore_unconfirmed; + soStatus.textContent = failure || (evIsPaused(latest) || latest.manual_restore_unconfirmed + ? "This rule resumes with the plan." + : latest.manual_active + ? "Charge now overrides this rule. It resumes when you return to the plan." + : latest.surplus_only + ? "No grid or home battery. Your target may not be reached in time." + : "The plan may use grid power to reach your target."); + } soCb.addEventListener("change", function () { - // Surface the surplus-only ↔ schedule interaction immediately on - // toggle, before the network save returns — operators get instant - // feedback that flipping surplus on turns the deadline soft. - if (typeof surplusBestEffortHint !== "undefined" && surplusBestEffortHint) { - surplusBestEffortHint.style.display = soCb.checked ? "" : "none"; - } + var on = soCb.checked; + busy = true; + failure = ""; soCb.disabled = true; - soStatus.textContent = "Saving…"; - // CONTROL write — strict (FIX-B): loadpoint surplus-only toggle. - apiFetch("/api/loadpoints/" + encodeURIComponent(lp.id) + "/target", { + soStatus.textContent = "Applying solar rule…"; + evWrite("/api/loadpoints/" + encodeURIComponent(lp.id) + "/target", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ surplus_only: soCb.checked }), - }).then(function () { - soCb.disabled = false; - soStatus.textContent = "Saved. Independent of the schedule below."; - // Rebuild on next poll so the schedule section reflects any - // server-side side effects (e.g. soc_source recompute). - schedNeedsRebuild = true; - }).catch(function () { - soCb.disabled = false; - soStatus.textContent = "Save failed — try again."; - }); + body: JSON.stringify({ surplus_only: on }), + }).then(function (r) { + if (!r.ok) return r.json().then(function (j) { throw new Error(j.error || ("HTTP " + r.status)); }); + soStatus.textContent = "Solar rule saved. Reading the plan…"; + // Keep it pending until the read finishes; a poll cannot undo the choice. + return refreshEvModalAfterWrite(); + }).catch(function (e) { + failure = "Solar rule not confirmed: " + e.message; + }).finally(function () { busy = false; update(latest); }); }); - + soBox.appendChild(soWrap); + soBox.appendChild(soStatus); + soBox.update = update; + update(lp); return soBox; } // buildScheduleSection — target SoC by a deadline + recurring + the - // bat-SoC surplus-unlock threshold (Scheduled tab). Persisted across + // home battery threshold for spare solar. Persisted across // restarts; the backend rolls the deadline forward daily when Recurring // is set and arms the surplus-grab when the home battery is at/above the // threshold (5 pp release hysteresis). @@ -3636,8 +4032,8 @@ // local zone. The UI shows local time everywhere; we marshal back to // UTC minutes on save. var hasSched = !!(sched.soc || sched.recurring || sched.surplus_unlock_bat_soc); - var initLocalHHMM = utcMinsToLocalHHMM(typeof sched.time_of_day_min_utc === "number" ? sched.time_of_day_min_utc : 360); - var initSoC = typeof sched.soc === "number" && sched.soc > 0 ? sched.soc * 100 : 50; + var initLocalHHMM = hasSched ? utcMinsToLocalHHMM(typeof sched.time_of_day_min_utc === "number" ? sched.time_of_day_min_utc : 360) : "07:00"; + var initSoC = typeof sched.soc === "number" && sched.soc > 0 ? sched.soc * 100 : 80; var initRec = !!sched.recurring; var savedUnlock = typeof sched.surplus_unlock_bat_soc === "number" ? sched.surplus_unlock_bat_soc * 100 : 0; // Surplus on/off is derived from the saved threshold: > 0 ⇒ enabled. @@ -3646,10 +4042,7 @@ var initSurplus = savedUnlock > 0; var initUnlock = savedUnlock > 0 ? savedUnlock : 50; - // Hairline divider separating the current-charge slider above from the - // target + deadline controls below. The tab is already named - // "Scheduled", so no section eyebrow/explainer here — the field labels - // carry the meaning. + // The goal editor opens below the saved goal summary. var box = document.createElement("div"); box.style.marginTop = "0.9rem"; box.style.paddingTop = "0.9rem"; @@ -3684,7 +4077,7 @@ // above, so toggling it gives instant feedback without waiting on // the network save round-trip. var surplusBestEffortHint = document.createElement("div"); - surplusBestEffortHint.textContent = "Surplus only is on — the deadline becomes best-effort from real PV surplus only. Turn it off to let the planner grid-charge if PV can't cover."; + surplusBestEffortHint.textContent = "Only spare solar is on. Your target may not be reached in time."; surplusBestEffortHint.style.fontSize = "0.72rem"; surplusBestEffortHint.style.color = "var(--fg)"; surplusBestEffortHint.style.fontStyle = "italic"; @@ -3775,8 +4168,8 @@ return wrap; } - var recWrap = checkbox(initRec, "Repeat daily"); - var surWrap = checkbox(initSurplus && !!hasPV, "Also charge from PV surplus"); + var recWrap = checkbox(initRec, "Repeat on chosen days"); + var surWrap = checkbox(initSurplus && !!hasPV, "Also use spare solar before the planned hours"); var recCb = recWrap.input; var surCb = surWrap.input; @@ -3824,13 +4217,15 @@ paintChips(); // Target: same header + full-width slider treatment as the car's - // current charge above the tabs. - var targetHdr = sliderHeader("Target", Math.max(0, Math.min(100, Math.round(initSoC))) + "%"); + // current charge above the goal. + var targetHdr = sliderHeader("Charge to", Math.max(0, Math.min(100, Math.round(initSoC))) + "%"); box.appendChild(targetHdr.row); var targetSlider = fullWidthSlider(Math.max(0, Math.min(100, Math.round(initSoC))), targetHdr.value); + targetSlider.min = "10"; + targetSlider.step = "5"; targetSlider.setAttribute("aria-label", "Target charge, percent"); box.appendChild(targetSlider); - box.appendChild(row("Charge by", timeInp)); + box.appendChild(row("Ready by", timeInp)); var checkRow = document.createElement("div"); checkRow.style.display = "flex"; @@ -3840,11 +4235,17 @@ checkRow.appendChild(recWrap); box.appendChild(checkRow); box.appendChild(daysRow); + var solarDetails = document.createElement("details"); + var solarSummary = document.createElement("summary"); + solarSummary.textContent = "Solar timing"; + solarSummary.style.cssText = "cursor:pointer;font-size:0.8rem;color:var(--text-dim)"; + solarDetails.appendChild(solarSummary); + if (hasPV) box.appendChild(solarDetails); // Surplus-from-PV only makes sense on sites with a PV driver — the // bat-SoC unlock would have no surplus to grab otherwise. Omit the // checkbox + threshold entirely on PV-less sites. if (hasPV) { - checkRow.appendChild(surWrap); + solarDetails.appendChild(surWrap); } var unlockHint = document.createElement("small"); @@ -3857,8 +4258,8 @@ var thresholdRow = row("Home battery ≥", unlockWrap); if (hasPV) { - box.appendChild(unlockHint); - box.appendChild(thresholdRow); + solarDetails.appendChild(unlockHint); + solarDetails.appendChild(thresholdRow); } function applySurplusGate() { @@ -3901,8 +4302,15 @@ b.style.color = "var(--fg)"; return b; } + var chooseBtn = mkBtn("Use " + Math.round(initSoC) + " % by " + initLocalHHMM); + chooseBtn.style.textTransform = "none"; + chooseBtn.style.letterSpacing = "normal"; + chooseBtn.addEventListener("click", scheduleSave); + btnRow.appendChild(chooseBtn); var clearBtn = mkBtn("Remove schedule"); function paintClear() { + chooseBtn.hidden = hasSched; + clearBtn.hidden = !hasSched; clearBtn.disabled = !hasSched; clearBtn.style.opacity = hasSched ? "1" : "0.4"; } @@ -3917,7 +4325,7 @@ status.style.minHeight = "1em"; status.textContent = hasSched ? "Changes save as you make them; the plan above follows." - : "Move the target or pick a time to set a schedule; the plan above follows."; + : "No goal set yet. Choose this goal, or change the level or time."; box.appendChild(status); // Every control writes when it changes: the slider on release, the @@ -3928,15 +4336,18 @@ // from the new schedule. No Save button. var saveTimer = null; var saveSeq = 0; + var writeQueue = Promise.resolve(); var statusTimer = null; function scheduleSave() { if (saveTimer) clearTimeout(saveTimer); if (statusTimer) { clearTimeout(statusTimer); statusTimer = null; } - status.textContent = "Saving…"; + saveSeq++; + chooseBtn.disabled = true; + status.textContent = "Applying schedule…"; saveTimer = setTimeout(function () { saveTimer = null; doSave(); }, 400); } function doSave() { - var seq = ++saveSeq; + var seq = saveSeq; var localHHMM = timeInp.value || initLocalHHMM; var minUTC = localHHMMToUtcMins(localHHMM); // Surplus checkbox gates the threshold: when off (or hidden on @@ -3958,24 +4369,27 @@ surplus_unlock_bat_soc: unlockVal > 0 ? unlockVal / 100 : 0, }, }; - // CONTROL write — strict (FIX-B): loadpoint schedule save. - apiFetch("/api/loadpoints/" + encodeURIComponent(lp.id) + "/target", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }).then(function (r) { - if (!r.ok) throw new Error("HTTP " + r.status); - if (seq !== saveSeq) return; - hasSched = true; - paintClear(); - status.textContent = "Saved · replanning…"; - refreshEvModal(); - statusTimer = setTimeout(function () { - if (seq === saveSeq) status.textContent = "Plan updated."; - }, 1500); - }).catch(function (e) { + // Complete writes in order, even when the charger or planner is slow. + writeQueue = writeQueue.catch(function () {}).then(function () { if (seq !== saveSeq) return; - status.textContent = "Save failed: " + e.message; + return evWrite("/api/loadpoints/" + encodeURIComponent(lp.id) + "/target", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }).then(function (r) { + if (!r.ok) return r.json().then(function (j) { throw new Error(j.error || ("HTTP " + r.status)); }); + if (seq !== saveSeq) return; + hasSched = true; + paintClear(); + status.textContent = "Schedule saved."; + refreshEvModalAfterWrite(); + }).catch(function (e) { + if (seq === saveSeq) { + chooseBtn.disabled = false; + chooseBtn.textContent = "Use " + targetSlider.value + " % by " + timeInp.value; + status.textContent = "Schedule not confirmed: " + e.message; + } + }); }); } @@ -3995,99 +4409,80 @@ if (saveTimer) { clearTimeout(saveTimer); saveTimer = null; } saveSeq++; status.textContent = "Removing…"; - // CONTROL write — strict (FIX-B): loadpoint schedule clear. - apiFetch("/api/loadpoints/" + encodeURIComponent(lp.id) + "/target", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ schedule: null }), - }).then(function (r) { - if (!r.ok) throw new Error("HTTP " + r.status); - status.textContent = "Schedule removed."; - schedNeedsRebuild = true; - refreshEvModal(); - }).catch(function (e) { - status.textContent = "Remove failed: " + e.message; - clearBtn.disabled = false; + writeQueue = writeQueue.catch(function () {}).then(function () { + return evWrite("/api/loadpoints/" + encodeURIComponent(lp.id) + "/target", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ schedule: null }), + }).then(function (r) { + if (!r.ok) return r.json().then(function (j) { throw new Error(j.error || ("HTTP " + r.status)); }); + status.textContent = "Schedule removed."; + schedNeedsRebuild = true; + refreshEvModalAfterWrite(); + }).catch(function (e) { + status.textContent = "Removal not confirmed: " + e.message; + clearBtn.disabled = false; + }); }); }); + box.update = function (nextLp) { + surplusBestEffortHint.style.display = nextLp.surplus_only && !nextLp.manual_active ? "" : "none"; + }; return box; } - // buildEvTabbedControl assembles the modal's three tabs and routes each - // section into the right one: - // PV charging → surplus-only toggle - // Manual → amp slider + Start/Stop - // Scheduled → the target-SoC-by-deadline schedule (the car's - // current charge lives above the tabs, with the plan) - // The active tab persists across rebuilds via evActiveTab. - function buildEvTabbedControl(lp, hasPV) { + // One session view. Opening settings never changes the charging mode. + function buildEvControls(lp, hasPV) { var container = document.createElement("div"); - container.style.marginTop = "0.75rem"; - container.style.paddingTop = "0.6rem"; - container.style.borderTop = "1px solid var(--line)"; - - var tabBar = document.createElement("div"); - tabBar.style.display = "flex"; - tabBar.style.gap = "0.15rem"; - tabBar.style.borderBottom = "1px solid var(--line)"; - tabBar.style.marginBottom = "0.7rem"; - - var pvPanel = document.createElement("div"); - pvPanel.appendChild(buildPVModeSection(lp)); - - var manualPanel = document.createElement("div"); - manualPanel.appendChild(buildManualChargeSection(lp)); - - var schedPanel = document.createElement("div"); - schedPanel.appendChild(buildScheduleSection(lp, hasPV)); - - var panels = { pv: pvPanel, manual: manualPanel, scheduled: schedPanel }; - var tabs = [ - { id: "pv", label: "PV charging" }, - { id: "manual", label: "Manual" }, - { id: "scheduled", label: "Scheduled" }, - ]; - var tabBtns = {}; - - function selectTab(id) { - if (!panels[id]) { id = "pv"; } - evActiveTab = id; - for (var k in panels) { panels[k].style.display = (k === id) ? "" : "none"; } - tabs.forEach(function (t) { - var on = t.id === id; - var b = tabBtns[t.id]; - b.style.color = on ? "var(--fg)" : "var(--text-dim)"; - b.style.borderBottom = on ? "2px solid var(--accent-e)" : "2px solid transparent"; - b.style.fontWeight = on ? "600" : "400"; - }); - } - - tabs.forEach(function (t) { - var b = document.createElement("button"); - b.type = "button"; - b.textContent = t.label; - b.style.background = "transparent"; - b.style.border = "none"; - b.style.borderBottom = "2px solid transparent"; - b.style.padding = "0.4rem 0.7rem"; - b.style.marginBottom = "-1px"; - b.style.cursor = "pointer"; - b.style.fontFamily = "var(--mono)"; - b.style.fontSize = "0.72rem"; - b.style.letterSpacing = "0.08em"; - b.style.textTransform = "uppercase"; - b.addEventListener("click", function () { selectTab(t.id); }); - tabBtns[t.id] = b; - tabBar.appendChild(b); - }); - - container.appendChild(tabBar); - container.appendChild(pvPanel); - container.appendChild(manualPanel); - container.appendChild(schedPanel); - - selectTab(evActiveTab); + container.className = "ev-controls"; + var manual = buildManualChargeSection(lp); + container.appendChild(manual.el); + + var goal = document.createElement("section"); + goal.className = "ev-goal"; + goal.style.cssText = "margin-top:1rem;padding:0.8rem;border:1px solid var(--line);border-radius:8px"; + var heading = document.createElement("div"); + heading.textContent = "Your goal"; + heading.style.cssText = "font-weight:600;font-size:0.95rem;margin-bottom:0.3rem"; + goal.appendChild(heading); + var summary = document.createElement("p"); + summary.style.cssText = "margin:0;font-size:0.9rem"; + goal.appendChild(summary); + var suspended = document.createElement("small"); + suspended.style.cssText = "display:block;color:var(--text-dim);margin-top:0.3rem"; + goal.appendChild(suspended); + + var editor = document.createElement("details"); + var editLabel = document.createElement("summary"); + editLabel.style.cssText = "cursor:pointer;color:var(--accent-e);font-size:0.85rem;margin-top:0.65rem"; + editor.appendChild(editLabel); + var schedule = buildScheduleSection(lp, hasPV); + editor.appendChild(schedule); + goal.appendChild(editor); + var solar = hasPV || lp.surplus_only ? buildPVModeSection(lp) : null; + if (solar) goal.appendChild(solar); + container.appendChild(goal); + + container.update = function (nextLp, d) { + manual.el.hidden = !nextLp.plugged_in; + manual.update(nextLp, d); + var s = nextLp.schedule; + var hasGoal = !!(s && s.soc > 0); + summary.textContent = hasGoal + ? Math.round(s.soc * 100) + " % by " + utcMinsToLocalHHMM(s.time_of_day_min_utc) + + (s.recurring ? " · repeats" : " · once") + : "No ready time set."; + editLabel.textContent = hasGoal ? "Change goal" : "Set a ready time"; + suspended.textContent = evIsPaused(nextLp) || nextLp.manual_restore_unconfirmed + ? "Resume the plan to use this goal. Edits apply then." + : nextLp.manual_active + ? "Charge now overrides this goal. Edits apply when you return to the plan." + : "Changes apply as you make them."; + schedule.update(nextLp); + if (solar) solar.update(nextLp); + }; + container.update(lp, null); return container; } diff --git a/web/ev-charge-now.test.mjs b/web/ev-charge-now.test.mjs index 89755105..db4d2b5c 100644 --- a/web/ev-charge-now.test.mjs +++ b/web/ev-charge-now.test.mjs @@ -19,13 +19,14 @@ test('Start installs a hold without a SoC release', () => { assert.doesNotMatch(manual, /release_at_soc_pct/); assert.doesNotMatch(manual, /lp\.schedule/); // The button names its contract without a percentage. - assert.match(manual, /startBtn\.textContent = active \? "Update" : "Charge now";/); - assert.match(manual, /until the car is full, Stop or unplug/); + assert.match(manual, /startBtn\.textContent = "Charge now";/); + assert.match(manual, /slider\.addEventListener\("change"/); + assert.match(manual, /if \(lastLp\.manual_active\) requestCharge\(\)/); }); test('an API-installed release target is still explained when active', () => { // A hold with release_at_soc_pct can still arrive through the API; // the manual tab and the plan strip keep saying where it stops. - assert.match(source, /stops at " \+ Math\.round\(lp\.manual_release_soc \* 100\)/); - assert.match(source, /returns to plan at/); + assert.match(source, /Math\.round\(lp\.manual_release_soc \* 100\)/); + assert.match(source, /Returns to the plan at the estimated/); }); diff --git a/web/ev-manual-feedback.test.mjs b/web/ev-manual-feedback.test.mjs new file mode 100644 index 00000000..685dc5d5 --- /dev/null +++ b/web/ev-manual-feedback.test.mjs @@ -0,0 +1,127 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +const source = readFileSync(new URL('./app.js', import.meta.url), 'utf8'); +const manual = source.slice( + source.indexOf('function buildManualChargeSection'), + source.indexOf('function sliderHeader'), +); +const statusText = source.slice( + source.indexOf('function manualStatusText'), + source.indexOf('function renderEvPlanStatus'), +); + +// After Charge now the operator must always know what is happening (#1002). +// Field report 2026-09-05: the tab said "Charging at 16 A" a tenth of a +// second after the click, the Easee cloud takes 5–15 s to act, nothing on +// screen moved, and the operator removed the charger to charge by hand. + +test('the status line follows the charger through every state', () => { + for (const state of ['sent', 'accepted', 'charging', 'not_drawing', 'stalled', 'limited', 'unavailable', 'pausing', 'paused']) { + assert.match(statusText, new RegExp(`case "${state}":`)); + } + assert.match(statusText, /Waiting for the charger/); + assert.match(statusText, /Waiting for the car to start drawing/); + assert.match(statusText, /but the car is not drawing/); + assert.match(statusText, /The charger has not acted on/); + assert.match(statusText, /Main fuse limits this charge/); + // The charger's own words are part of the sentence. + assert.match(statusText, /Charger reports: " \+ m\.charger_reason/); + // Elapsed time comes from the box's since_ms, not from click time. + assert.match(statusText, /m\.since_ms/); +}); + +test('the manual controls follow every poll', () => { + assert.match(manual, /return \{ el: box, update: update \};/); + assert.match(source, /manual\.update\(nextLp, d\);/); + assert.match(source, /evControlsEl\.update\(matched, d\);/); +}); + +test("a refused Start reads as a failure with the server's reason", () => { + assert.match(manual, /if \(r\.ok\) return r;/); + assert.match(manual, /\.then\(failOn\)/); + assert.match(manual, /"Request not confirmed: " \+ \(\(e && e\.message\) \|\| "try again"\)/); + assert.doesNotMatch(manual, /"Charging at " \+ a \+ " A until the car is full/); +}); + +test('the plan strip uses the same sentence while a manual charge runs', () => { + const strip = source.slice( + source.indexOf('function renderEvPlanStatus'), + source.indexOf('// EV modal sub-elements held across refreshes'), + ); + assert.match(strip, /manualStatusText\(lp, d\)/); +}); + +const describeManual = new Function('formatW', 'evFmtElapsed', statusText + '; return manualStatusText;')( + w => `${w} W`, ms => `${Math.floor(ms / 1000)} s`, +); +const lp = { manual_active: true, current_power_w: 0, manual: { active: true, requested_a: 16, commanded_a: 16 } }; +test('an accepted limit at zero watts never claims that the car is charging', () => { + const words = describeManual({ ...lp, manual: { ...lp.manual, state: 'accepted' } }); + assert.match(words, /Charger reports a 16 A limit/); + assert.match(words, /Waiting for the car/); + assert.doesNotMatch(words, /Charging at/); +}); +test('a stale report overrides previously positive charging power', () => { + const words = describeManual({ ...lp, current_power_w: 11000, manual: { ...lp.manual, state: 'unavailable' } }); + assert.match(words, /out of date/); + assert.doesNotMatch(words, /Charging at/); +}); +test('charging reported without a power reading stays explicit', () => { + const words = describeManual({ ...lp, manual: { ...lp.manual, state: 'charging' } }); + assert.match(words, /Waiting for a power reading/); + assert.doesNotMatch(words, /Charging at 0/); +}); +test('an estimated release target remains visible while waiting', () => { + const words = describeManual({ ...lp, manual_release_soc: 0.8, manual: { ...lp.manual, state: 'sent' } }); + assert.match(words, /estimated 80 % target/); + assert.doesNotMatch(words, /Charging at/); +}); + + +test('pause status waits for the charger before claiming it stopped', () => { + const waiting = describeManual({ ...lp, manual: { ...lp.manual, state: 'pausing', requested_a: 0, requested_w: 0 }, current_power_w: 11000 }); + assert.match(waiting, /Waiting for the charger to stop/); + assert.doesNotMatch(waiting, /Paused by you/); + const paused = describeManual({ ...lp, manual: { ...lp.manual, state: 'paused', requested_a: 0, requested_w: 0 } }); + assert.match(paused, /Paused by you/); + assert.match(paused, /until you resume the plan/); +}); + + +test('a charger limit does not blame the main fuse', () => { + const words = describeManual({ ...lp, manual: { ...lp.manual, state: 'limited', limit_reason: 'charger_limit', commanded_a: 10 } }); + assert.match(words, /The charger limits this request to 10 A/); + assert.doesNotMatch(words, /Main fuse/); +}); + + +test('an unconfirmed charger or connection asks for a choice without claiming a stop', () => { + const words = describeManual({ manual_restore_unconfirmed: true, manual_active: false }); + assert.match(words, /Confirm how to continue charging/); + assert.match(words, /could not confirm the charger or connection/); + assert.doesNotMatch(words, /after restart|[Pp]aused|stopped/); + assert.match(manual, /Pause charging to request a stop/); + assert.doesNotMatch(words, /Paused by you/); +}); +test('a lower requested current keeps actual old power visible until it arrives', () => { + const words = describeManual({ ...lp, current_power_w: 11000, manual: { ...lp.manual, state: 'sent', requested_a: 6 } }); + assert.match(words, /confirm the new limit/); + assert.match(words, /Still charging at 11000 W/); +}); + + +test('failed hold persistence stays visible until the box reports recovery', () => { + const strip = source.slice(source.indexOf('function renderEvPlanStatus'), source.indexOf('// Keep controls mounted while polling')); + const render = new Function('document', 'manualStatusText', strip + '; return renderEvPlanStatus;')( + { createElement: () => ({ style: {} }) }, describeManual, + ); + const paused = { ...lp, plugged_in: true, manual_save_error: true, manual: { ...lp.manual, state: 'paused', requested_a: 0, requested_w: 0 } }; + assert.match(render(paused).textContent, /Paused by you/); + assert.match(render(paused).textContent, /This choice is active now, but could not be saved for restart. FTW is retrying/); + assert.doesNotMatch(render({ ...paused, manual_save_error: false }).textContent, /could not be saved/); + // Returning to the plan also changes the persisted choice: a failed clear + // must remain visible even though there is no active hold anymore. + assert.match(render({ ...paused, manual_active: false }).textContent, /could not be saved for restart/); +}); diff --git a/web/ev-plan-status.test.mjs b/web/ev-plan-status.test.mjs index f10a9200..8d7855ed 100644 --- a/web/ev-plan-status.test.mjs +++ b/web/ev-plan-status.test.mjs @@ -14,7 +14,7 @@ test('plan-status strip renders every visibility state', () => { assert.match(source, /plan_total_wh \/ 1000/); // Charger offering power the car does not take, with the charger's // own reason when the driver reports one. - assert.match(source, /but the car isn't drawing/); + assert.match(source, /Waiting for the car to draw power/); assert.match(source, /reason_no_current_label/); // The silent grid-plan deferral is named instead of looking like a // PV-only mode nobody chose. @@ -22,9 +22,9 @@ test('plan-status strip renders every visibility state', () => { assert.match(source, /grid_deferred/); // Manual hold names its cost: the plan is off until the car is full, // Stop or unplug. - assert.match(source, /plan and PV logic are off until the car is full, Stop or unplug/); + assert.match(source, /Continues until the car stops drawing/); // The do-nothing default is called out with the three ways out. - assert.match(source, /set a schedule, turn on PV only, or press Start/); + assert.match(source, /No charging plan yet/); }); test('plan-status strip is the headline of the plan view', () => { @@ -35,3 +35,19 @@ test('plan-status strip is the headline of the plan view', () => { // And reset when the modal short-circuits to "no charger". assert.match(source, /statusTableEl = null;\s*\n\s*evPlanEl = null;/); }); + +const planStatus = new Function('document', 'manualStatusText', 'formatW', 'evFmtClock', + source.slice(source.indexOf('function renderEvPlanStatus'), source.indexOf('// Keep controls mounted while polling')) + ';return renderEvPlanStatus;' +)( + { createElement: () => ({ style: {}, textContent: '' }) }, + () => 'Paused by you.', w => `${w} W`, () => '07:00', +); + +test('pending and failed plans do not pretend that charging windows are ready', () => { + const lp = { plugged_in: true, schedule: { soc: .8 }, current_power_w: 0 }; + assert.match(planStatus({ ...lp, plan_pending: true, plan_outdated: true }, {}).textContent, /Updating the charging plan/); + const failed = planStatus({ ...lp, plan_pending: false, plan_outdated: true }, {}).textContent; + assert.match(failed, /Charging times are unavailable.*settings are saved/); + assert.doesNotMatch(failed, /Updating|Charging planned/); + assert.equal(planStatus({ ...lp, manual_active: true, plan_pending: true }, {}).textContent, 'Paused by you.'); +}); diff --git a/web/ev-plug-in-view.test.mjs b/web/ev-plug-in-view.test.mjs index 9f20a362..6a6ee5dc 100644 --- a/web/ev-plug-in-view.test.mjs +++ b/web/ev-plug-in-view.test.mjs @@ -5,7 +5,7 @@ import test from 'node:test'; const source = readFileSync(new URL('./app.js', import.meta.url), 'utf8'); const view = source.slice( source.indexOf('function buildEvPlanView'), - source.indexOf('function buildPVModeSection'), + source.indexOf('function buildEvCapacityView'), ); // The plug-in moment (#1059): the modal shows what the box will do and @@ -18,7 +18,7 @@ test('the plan view draws the planned windows on a 24 h track', () => { assert.match(view, /w\.start_ms/); assert.match(view, /w\.wh \/ 1000/); // A manual hold is explained instead of drawn as a plan. - assert.match(view, /Manual charge is running/); + assert.match(view, /Manual charge is selected/); }); test('the charge-level slider writes on release, with no button', () => { @@ -27,23 +27,23 @@ test('the charge-level slider writes on release, with no button', () => { assert.doesNotMatch(view, /Set current charge/); assert.doesNotMatch(view, /createElement\("button"\)/); // The refetch right after the write is what moves the plan on screen. - assert.match(view, /Plan updated from/); - assert.match(view, /refreshEvModal\(\)/); + assert.match(view, /Charge level saved:/); + assert.match(view, /refreshEvModalAfterWrite\(\)/); // Polls do not snap the slider while the operator holds it. assert.match(view, /operatorHolds\(\)/); }); -test('the SoC editor left the Scheduled tab', () => { +test('the battery estimate stays separate from goal controls', () => { assert.doesNotMatch(source, /buildSoCSection/); const tabs = source.slice( - source.indexOf('function buildEvTabbedControl'), + source.indexOf('function buildEvControls'), source.indexOf('function utcMinsToLocalHHMM'), ); - assert.doesNotMatch(tabs, /soc/i); + assert.doesNotMatch(tabs, /Car is at|Car\'s current charge/); }); test('the plan view is mounted once per loadpoint and updated on polls', () => { assert.match(source, /evPlanEl = buildEvPlanView\(matched, d\)/); assert.match(source, /evPlanLpId !== matched\.id/); - assert.match(source, /evModalBody\.insertBefore\(evPlanEl\.el, statusTableEl\.nextSibling\)/); + assert.match(source, /evModalBody\.insertBefore\(evPlanEl\.el, statusTableEl\)/); }); diff --git a/web/ev-schedule-autosave.test.mjs b/web/ev-schedule-autosave.test.mjs index 9dcab9fa..b10b7c8b 100644 --- a/web/ev-schedule-autosave.test.mjs +++ b/web/ev-schedule-autosave.test.mjs @@ -5,10 +5,10 @@ import test from 'node:test'; const source = readFileSync(new URL('./app.js', import.meta.url), 'utf8'); const sched = source.slice( source.indexOf('function buildScheduleSection'), - source.indexOf('function buildEvTabbedControl'), + source.indexOf('function buildEvControls'), ); -// The Scheduled tab is direct manipulation (#1065): every control writes +// The goal editor is direct manipulation (#1065): every control writes // when it changes and the plan view above redraws. No Save button. test('every schedule control saves on change', () => { @@ -22,8 +22,8 @@ test('every schedule control saves on change', () => { // view above moves with the schedule. assert.match(sched, /setTimeout\(function \(\) \{ saveTimer = null; doSave\(\); \}, 400\)/); assert.match(sched, /if \(seq !== saveSeq\) return;/); - assert.match(sched, /Saved · replanning…/); - assert.match(sched, /refreshEvModal\(\)/); + assert.match(sched, /Schedule saved\./); + assert.match(sched, /refreshEvModalAfterWrite\(\)/); }); test('weekday chips speak the wire: bit 0 = Monday, all seven = zero', () => { diff --git a/web/ev-write-timeout.test.mjs b/web/ev-write-timeout.test.mjs new file mode 100644 index 00000000..bbb1bd68 --- /dev/null +++ b/web/ev-write-timeout.test.mjs @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +const source = readFileSync(new URL('./app.js', import.meta.url), 'utf8'); +const implementation = source.slice(source.indexOf(' function evWrite('), source.indexOf(' // ---- Chart data ----')); + +test('a charger write that sends headers but stalls its body settles with an unconfirmed result', async () => { + let expire; + const write = new Function('apiFetch', 'setTimeout', 'clearTimeout', implementation + '; return evWrite;')( + (_path, options) => Promise.resolve({ + arrayBuffer: () => new Promise((_resolve, reject) => { + options.signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError'))); + }), + }), + callback => { expire = callback; return 1; }, + () => {}, + ); + const request = write('/api/loadpoints/car/manual_hold', { method: 'POST' }); + await Promise.resolve(); + expire(); + await assert.rejects(request, /has not confirmed the request/); +}); + +test('a complete refusal keeps the server response for the control that sent it', async () => { + const write = new Function('apiFetch', implementation + '; return evWrite;')( + () => Promise.resolve(new Response(JSON.stringify({ error: 'Charger unavailable' }), { status: 409 })), + ); + const response = await write('/api/loadpoints/car/manual_hold', { method: 'POST' }); + assert.equal(response.status, 409); + assert.deepEqual(await response.json(), { error: 'Charger unavailable' }); +}); diff --git a/web/loadpoint-battery-boost.test.mjs b/web/loadpoint-battery-boost.test.mjs index 23718013..3890cdfc 100644 --- a/web/loadpoint-battery-boost.test.mjs +++ b/web/loadpoint-battery-boost.test.mjs @@ -44,7 +44,7 @@ test('every controller stop reason has words', () => { test('the boost view is mounted last in the modal and updated on polls', () => { assert.match(app, /evBoostEl = buildEvBoostView\(matched, status\)/); assert.match(app, /evBoostEl\.update\(matched, status\)/); - assert.match(app, /evModalBody\.lastChild !== evBoostEl\.el/); + assert.match(app, /evModalBody\.lastChild !== evBoostDetails/); }); test('the legacy site-wide cover left the modal but is never hidden while on', () => { diff --git a/web/settings-shell.test.mjs b/web/settings-shell.test.mjs index 4b41ccca..1a4b8c6e 100644 --- a/web/settings-shell.test.mjs +++ b/web/settings-shell.test.mjs @@ -25,6 +25,7 @@ function stubElement() { className: "", innerHTML: "", dataset: {}, + style: {}, handlers: {}, classList: { add() {}, remove() {}, toggle() {} }, addEventListener(type, fn) { this.handlers[type] = fn; }, @@ -99,3 +100,22 @@ describe("the settings shell after a save", () => { assert.equal(called, 0, "a rejected save told the tab it landed"); }); }); + + +describe("charger setup navigation and saves", () => { + it("keeps the selected tab, hides global Save only there and still exposes explicit saves", async () => { + const { elements, requests, tabs } = loadShell({ drivers: [], loadpoints: [] }); + let context; + tabs.control = { render: ctx => { context = ctx; return ''; } }; + tabs.loadpoints = { render: ctx => { context = ctx; return ''; } }; + elements['settings-btn'].handlers.click(); + await settled(); + context.navigateTab('loadpoints'); + assert.equal(elements['settings-save'].hidden, true); + assert.equal(elements['settings-save'].style.display, 'none'); + await context.saveConfig(); + assert.equal(requests.filter(r => r.opts?.method === 'POST').length, 1); + context.navigateTab('control'); + assert.equal(elements['settings-save'].hidden, false); + }); +}); diff --git a/web/settings.js b/web/settings.js index cce54823..3b79ed7b 100644 --- a/web/settings.js +++ b/web/settings.js @@ -67,19 +67,25 @@ tabsEl.addEventListener("click", function (e) { if (e.target.tagName === "BUTTON" && e.target.dataset.tab) { - tabsEl.querySelectorAll("button").forEach(function (b) { - b.classList.toggle("active", b === e.target); - }); - captureCurrentTab(); - currentTab = e.target.dataset.tab; - renderTab(currentTab); + navigateTab(e.target.dataset.tab); } }); - saveBtn.addEventListener("click", function () { + function navigateTab(tab) { + captureCurrentTab(); + currentTab = tab; + tabsEl.querySelectorAll("button").forEach(function (button) { + button.classList.toggle("active", button.dataset.tab === tab); + }); + renderTab(tab); + } + + saveBtn.addEventListener("click", function () { saveSettings().catch(function () {}); }); + + function saveSettings() { captureCurrentTab(); setStatus("Saving..."); - apiFetch("/api/config", { + return apiFetch("/api/config", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(currentConfig), @@ -106,11 +112,13 @@ if (res && res.restart_required) { showRestartModal(res.restart_reasons || []); } + return res; }) .catch(function (e) { setStatus("Save failed: " + e.message, "error"); + throw e; }); - }); + } // ---- Restart-required modal ---- @@ -269,6 +277,8 @@ } function renderTab(tab) { + saveBtn.hidden = tab === "loadpoints" || (tab === "devices" && !!S.chargerSetup); + saveBtn.style.display = saveBtn.hidden ? "none" : ""; var def = S.tabs[tab]; if (!def) { bodyEl.innerHTML = '

Unknown tab: ' + escHtml(tab) + '

'; @@ -285,6 +295,8 @@ setByPath: setByPath, captureCurrentTab: captureCurrentTab, renderTab: renderTab, + navigateTab: navigateTab, + saveConfig: saveSettings, apiFetch: apiFetch, }; var html = ""; diff --git a/web/settings/tabs/devices.js b/web/settings/tabs/devices.js index 7f3cc8a7..7d96a051 100644 --- a/web/settings/tabs/devices.js +++ b/web/settings/tabs/devices.js @@ -919,7 +919,11 @@ render: function (ctx) { var help = ctx.help, escHtml = ctx.escHtml, config = ctx.config; if (!config.drivers) config.drivers = []; - var html = '
Add from catalog' + + var html = S.chargerSetup ? '

Connect your charger

' + + '

Choose your charger below and enter its connection details. Continue to charging when the connection is ready.

' + + '' + + '

' : ''; + html += '
' + (S.chargerSetup ? 'Choose your charger' : 'Add from catalog') + '' + '
' + '' + '' + @@ -1325,7 +1329,10 @@ if (!host || !picker) return; var query = (search && search.value || "").trim(); - var matches = searchCatalog(entries, query); + var choices = S.chargerSetup ? entries.filter(function (entry) { + return (entry.capabilities || []).indexOf('ev') >= 0; + }) : entries; + var matches = searchCatalog(choices, query); host.textContent = ""; if (matches.length === 0) { @@ -1355,7 +1362,7 @@ var tags = document.createElement("div"); tags.className = "drv-catalog-tags"; - (e.capabilities || []).forEach(function (cap) { + (S.chargerSetup ? [] : (e.capabilities || [])).forEach(function (cap) { var tag = document.createElement("span"); tag.className = "drv-catalog-tag"; tag.textContent = cap; @@ -1781,7 +1788,13 @@ } var finishAdd = function () { config.drivers.push(driver); + if (S.chargerSetup) S.chargerSetupPending = driver.name; ctx.renderTab("devices"); + if (S.chargerSetup) { + var connection = bodyEl.querySelector('[data-path="drivers.' + (config.drivers.length - 1) + '.config.email"]') || + bodyEl.querySelector('[data-path="drivers.' + (config.drivers.length - 1) + '.config.host"]'); + if (connection) { connection.scrollIntoView({ block: 'center' }); connection.focus(); } + } }; if (chosen.dataset.channel !== "beta") { finishAdd(); @@ -1801,6 +1814,49 @@ }); }); + var continueCharging = document.getElementById('charger-setup-continue'); + if (continueCharging) { + bodyEl.querySelectorAll('.device-meta,.driver-module-status,.device-core-row').forEach(function (element) { element.hidden = true; element.style.display = 'none'; }); + var channel = document.getElementById('driver-catalog-channel'); + if (channel) channel.parentElement.hidden = true; + var name = document.getElementById('driver-catalog-name'); + if (name) name.placeholder = 'e.g. garage'; + var picker = document.getElementById('driver-catalog-picker'); + if (picker) picker.closest('fieldset').querySelectorAll(':scope > p').forEach(function (paragraph) { paragraph.hidden = true; }); + var pendingIndex = (config.drivers || []).findIndex(function (device) { return device.name === S.chargerSetupPending; }); + var connectionInput = bodyEl.querySelector('[data-path="drivers.' + pendingIndex + '.config.email"]') || bodyEl.querySelector('[data-path="drivers.' + pendingIndex + '.config.host"]'); + var connectionBox = connectionInput && connectionInput.closest('fieldset'); + if (connectionBox) { + connectionBox.appendChild(continueCharging); + connectionBox.appendChild(document.getElementById('charger-setup-status')); + } + } + if (continueCharging) continueCharging.addEventListener('click', function () { + ctx.captureCurrentTab(); + var charger = (config.drivers || []).find(function (device) { return device.name === S.chargerSetupPending; }); + var status = document.getElementById('charger-setup-status'); + if (!charger) { status.textContent = 'Choose your charger below first.'; return; } + var connection = charger.config || {}; + if (Object.prototype.hasOwnProperty.call(connection, 'email') && (!connection.email || !connection.serial)) { + status.textContent = 'Connect your account and choose the charger first.'; + return; + } + if (Object.prototype.hasOwnProperty.call(connection, 'host') && !connection.host) { + status.textContent = 'Enter your charger’s address first.'; + return; + } + continueCharging.disabled = true; + status.textContent = 'Saving the charger connection…'; + ctx.saveConfig().then(function () { + S.chargerSetup = false; + S.chargerSetupPending = null; + ctx.navigateTab('loadpoints'); + }).catch(function (error) { + status.textContent = 'Connection not saved: ' + error.message + '. Try again.'; + continueCharging.disabled = false; + }); + }); + // Cloud-driver Connect buttons. bodyEl.querySelectorAll(".ev-connect-btn").forEach(function (connectBtn) { connectBtn.addEventListener("click", function () { @@ -1852,7 +1908,7 @@ if (d && d.config) d.config.serial = sel.value; if (config.ev_charger) config.ev_charger.serial = sel.value; }; - if (statusEl) statusEl.textContent = chargers.length + " charger(s) found"; + if (statusEl) statusEl.textContent = chargers.length + (S.chargerSetup ? " charger(s) found. Choose Continue to charging to save the connection." : " charger(s) found"); }).catch(function (e) { if (statusEl) statusEl.textContent = "Error: " + e.message; }).finally(function () { diff --git a/web/settings/tabs/loadpoints.js b/web/settings/tabs/loadpoints.js index 01bb1bfc..ecc7af31 100644 --- a/web/settings/tabs/loadpoints.js +++ b/web/settings/tabs/loadpoints.js @@ -164,6 +164,11 @@ return html; } + if (!status.enabled && config && !config.ocpp) { + return html + '

For chargers that connect directly to FTW using OCPP.

' + + '
'; + } + if (!status.enabled) { html += '

' + @@ -262,8 +267,7 @@ '

' + 'Pending chargers are connected but not part of the site: FTW ignores their ' + 'telemetry and never commands them, so an unknown device cannot influence dispatch. ' + - 'To adopt one, add a charger entry below with its id as the charger driver and save ' + - '— it joins the site on that save.' + + 'To use one, choose it under Add charger. FTW saves and adds it to the site.' + '

'; } } @@ -366,28 +370,95 @@ }); } + var chargerSaveQueue = Promise.resolve(); + var chargerSaveText = ''; + var chargerSaveFailed = false; + function persistChargers(ctx) { + var snapshot = JSON.parse(JSON.stringify(ctx.config.loadpoints || [])); + var button = document.getElementById('settings-save'); + var status = document.getElementById('settings-status'); + function feedback(text, kind) { + chargerSaveText = text; + chargerSaveFailed = kind === 'error'; + var inline = document.getElementById('charger-save-status'); + if (inline) inline.textContent = text; + var retry = document.getElementById('charger-save-retry'); + if (retry) retry.hidden = !chargerSaveFailed; + var added = document.getElementById('new-lp-status'); + if (added && /^Adding /.test(added.textContent) && kind) added.textContent = text; + if (status) { + status.textContent = text; + status.className = 'settings-status' + (kind ? ' ' + kind : ''); + status.setAttribute('role', kind === 'error' ? 'alert' : 'status'); + } + } + if (button) button.disabled = true; + feedback('Applying charger settings…'); + var operation = chargerSaveQueue.catch(function () {}).then(function () { + // Read the current config so this save never commits another tab's draft. + function request(opts) { + var abort = new AbortController(); + var timeout = setTimeout(function () { abort.abort(); }, 30000); + return ctx.apiFetch('/api/config', Object.assign({}, opts, { signal: abort.signal })) + .then(function (r) { return r.json().then(function (body) { + if (!r.ok) throw new Error(body.error || ('HTTP ' + r.status)); + return body; + }); }) + .catch(function (e) { + if (abort.signal.aborted) throw new Error('FTW did not answer within 30 seconds'); + throw e; + }) + .finally(function () { clearTimeout(timeout); }); + } + return request().then(function (latest) { + latest.loadpoints = snapshot; + return request({ + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(latest), + }); + }); + }); + chargerSaveQueue = operation; + return operation.then(function (result) { + if (chargerSaveQueue !== operation) return; + if (button) button.disabled = false; + feedback(result && result.restart_required + ? 'Charger settings saved. FTW requires a restart to apply them.' + : 'Charger settings saved. No Save button needed.', 'success'); + }).catch(function (e) { + if (chargerSaveQueue !== operation) return; + if (button) button.disabled = false; + feedback('Charger settings not confirmed: ' + e.message + '. Try again when ready.', 'error'); + }); + } + S.tabs.loadpoints = { render: function (ctx) { var help = ctx.help, escHtml = ctx.escHtml, config = ctx.config; if (!config.loadpoints) config.loadpoints = []; var ocppIds = ocppChargerIds(S.ocppStatus); - var drivers = evDriverNames(config, S.ocppStatus); + var drivers = evDriverNames(config, S.ocppStatus).filter(function (name) { + return name !== S.chargerSetupPending; + }); var html = '

' + - 'A charger entry binds an EV charger to the planner so it can schedule charging against your tariff + PV forecast. ' + - 'The power source is either a driver added under Devices, or an OCPP charge point from the list below — ' + - 'pick it here and set the electrical envelope. (Config files call this binding a loadpoint.)' + + 'Choose the charger FTW will control, then check its power limit and your car’s battery size. ' + + 'Charger settings apply as you change them. Set when to charge from the car on Overview.' + '

'; - html += ocppSection(S.ocppStatus, (window.location && window.location.hostname) || "", escHtml, config.vehicles, config, help); + html += '

' + escHtml(chargerSaveText) + '

' + + ''; + + var ocppHtml = ocppSection(S.ocppStatus, (window.location && window.location.hostname) || "", escHtml, config.vehicles, config, help); if (!drivers.length) { html += - '
' + - '⚠ No EV-capable driver configured and no OCPP charger connected. Add a driver under Devices ' + - '(e.g. drivers/ctek_hybrid.lua), or point an OCPP charger at the URL above.' + - '
'; + '
' + + '

Connect a charger

' + + '

Choose your charger and enter its connection details. Then check its power limit and your car’s battery size here.

' + + '' + + '

If your charger connects directly using OCPP, open OCPP connection settings below.

' + + '
'; } html += '
'; @@ -405,7 +476,7 @@ // that is not connected right now). Operator can re-pick from // the list to fix it. driverOpts = '' + driverOpts; + escHtml(lp.driver_name) + ' (saved connection)' + driverOpts; } html += @@ -414,13 +485,13 @@ '
' + '
' + - '' + + '' + '' + '
' + '
' + - '' + + '' + '' + '
' + @@ -462,24 +533,28 @@ }); html += '
'; - html += + if (drivers.length) html += '
Add charger' + '
' + - '' + + '' + '
' + - '' + + '' + '' + '
' + - '' + + '' + + '

' + '
'; - html += vehiclesSection(config, escHtml, help); + html += '
OCPP connection settings' + ocppHtml + + '

'; + html += '
Cars that share a charger' + vehiclesSection(config, escHtml, help) + + '

'; return html; }, @@ -487,6 +562,47 @@ after: function (ctx) { var bodyEl = ctx.bodyEl, config = ctx.config; + var connectCharger = document.getElementById('connect-charger'); + if (connectCharger) connectCharger.addEventListener('click', function () { + S.chargerSetup = true; + ctx.navigateTab('devices'); + }); + bodyEl.querySelectorAll('[data-save-charger-extra]').forEach(function (button) { + button.addEventListener('click', function () { + var status = button.parentElement.querySelector('[data-extra-save-status]'); + button.disabled = true; + status.textContent = 'Saving ' + button.dataset.saveChargerExtra.toLowerCase() + '…'; + chargerSaveQueue.catch(function () {}).then(function () { return ctx.saveConfig(); }).then(function (result) { + status.textContent = button.dataset.saveChargerExtra + (result && result.restart_required ? ' saved. Restart FTW to apply them.' : ' saved.'); + }).catch(function (error) { + status.textContent = 'Not saved: ' + error.message + '. Try again.'; + }).finally(function () { button.disabled = false; }); + }); + }); + + var configureOcpp = document.getElementById('configure-ocpp'); + if (configureOcpp) configureOcpp.addEventListener('click', function () { + ctx.captureCurrentTab(); + config.ocpp = { enabled: true, port: 8887, username: 'ftw', path: '/' }; + ctx.renderTab('loadpoints'); + var button = bodyEl.querySelector('[data-checkbox-path="ocpp.enabled"]'); + if (button) button.closest('details').open = true; + }); + + function refreshTab() { + var idInput = document.getElementById('new-lp-id'); + var driverInput = document.getElementById('new-lp-driver'); + if (!driverInput) return; + var name = idInput ? idInput.value : ''; + var driver = driverInput ? driverInput.value : ''; + ctx.captureCurrentTab(); + ctx.renderTab('loadpoints'); + var nextName = document.getElementById('new-lp-id'); + var nextDriver = document.getElementById('new-lp-driver'); + if (nextName) nextName.value = name; + if (nextDriver && driver) nextDriver.value = driver; + } + // Live OCPP view. Re-render only when the answer actually changed, // so the refetch on every tab open cannot loop. apiFetch('/api/ocpp/chargers') @@ -497,8 +613,7 @@ if (raw === S._ocppStatusRaw) return; S._ocppStatusRaw = raw; S.ocppStatus = data; - ctx.captureCurrentTab(); - ctx.renderTab('loadpoints'); + refreshTab(); }) .catch(function () { /* section keeps its last known state */ }); @@ -515,12 +630,20 @@ }); S.catalogByLua = byLua; // Re-render so driver dropdowns populate. - ctx.captureCurrentTab(); - ctx.renderTab('loadpoints'); + refreshTab(); }) .catch(function () { /* leave dropdowns empty; user can still type */ }); } + function applyChargers(skipCapture) { + if (skipCapture !== true) ctx.captureCurrentTab(); + (config.loadpoints || []).forEach(function (lp) { delete lp.allowed_steps_w__str; }); + persistChargers(ctx); + } + + var retry = document.getElementById('charger-save-retry'); + if (retry) retry.addEventListener('click', applyChargers); + // Remove handlers. bodyEl.querySelectorAll('[data-action="remove-lp"]').forEach(function (btn) { btn.addEventListener('click', function () { @@ -528,6 +651,7 @@ if (!isFinite(idx)) return; ctx.captureCurrentTab(); config.loadpoints.splice(idx, 1); + applyChargers(true); ctx.renderTab('loadpoints'); }); }); @@ -540,11 +664,23 @@ var drvEl = document.getElementById('new-lp-driver'); var id = (idEl && idEl.value || '').trim(); var drv = (drvEl && drvEl.value || '').trim(); - if (!id) { idEl && idEl.focus(); return; } + var message = document.getElementById('new-lp-status'); + if (!drv) { + if (message) message.textContent = 'Choose a charger first. If it is missing, add it under Devices.'; + if (drvEl) drvEl.focus(); + return; + } + if (!id) id = drv; + var bound = (config.loadpoints || []).some(function (lp) { return lp.driver_name === drv; }); + if (bound) { + if (message) message.textContent = 'This charger is already in the list above.'; + return; + } // Reject duplicates — the controller treats id as the join key. var exists = (config.loadpoints || []).some(function (lp) { return lp.id === id; }); if (exists) { - alert('A charger with id "' + id + '" already exists.'); + if (message) message.textContent = 'That name is already in use. Choose another name.'; + if (idEl) idEl.focus(); return; } ctx.captureCurrentTab(); @@ -558,7 +694,12 @@ phase_mode: '3p', allowed_steps_w: [], }); + applyChargers(true); ctx.renderTab('loadpoints'); + var added = document.getElementById('new-lp-status'); + if (added) added.textContent = 'Adding ' + id + ' to FTW… Check its power limit and battery size above.'; + var card = bodyEl.querySelector('[data-action="remove-lp"][data-idx="' + (config.loadpoints.length - 1) + '"]'); + if (card) card.closest('fieldset').scrollIntoView({ block: 'nearest' }); }); } @@ -577,7 +718,7 @@ addVehicleBtn.addEventListener('click', function () { var idEl = document.getElementById('new-vehicle-id'); var id = (idEl && idEl.value || '').trim(); - if (!id) { idEl && idEl.focus(); return; } + if (!id) { if (idEl) idEl.focus(); return; } var exists = (config.vehicles || []).some(function (v) { return v.id === id; }); if (exists) { alert('A vehicle with id "' + id + '" already exists.'); @@ -627,6 +768,9 @@ inp.dispatchEvent(new Event('change')); }); }); + bodyEl.querySelectorAll('[data-path^="loadpoints."]').forEach(function (input) { + input.addEventListener('change', applyChargers); + }); }, // Pure helpers exposed for node tests. diff --git a/web/settings/tabs/loadpoints.test.mjs b/web/settings/tabs/loadpoints.test.mjs index ab2ecf95..20aaacb6 100644 --- a/web/settings/tabs/loadpoints.test.mjs +++ b/web/settings/tabs/loadpoints.test.mjs @@ -128,9 +128,9 @@ describe("charger state labels", () => { describe("OCPP section", () => { it("lets the operator turn a disabled server on, rather than sending them to config.yaml", () => { const html = ocppSection({ enabled: false, chargers: [] }, "192.168.1.209", escHtml, [], {}); - assert.match(html, /data-checkbox-path="ocpp\.enabled"/); - assert.match(html, /data-path="ocpp\.password"/); - assert.match(html, /ws:\/\/192\.168\.1\.209:8887/); + assert.match(html, /Set up OCPP/); + assert.doesNotMatch(html, /data-path="ocpp\.password"/); + assert.doesNotMatch(html, /data-checkbox-path="ocpp\.enabled"/); }); it("offers the server settings when it is already on", () => { @@ -159,7 +159,7 @@ describe("OCPP section", () => { assert.match(html, /intruder/); assert.match(html, /· pending/); assert.match(html, /ignores their telemetry/); - assert.match(html, /joins the site on that save/); + assert.match(html, /FTW saves and adds it to the site/); }); it("shows no quarantine note when every charger is adopted", () => { @@ -221,3 +221,27 @@ describe("OCPP server form", () => { assert.doesNotMatch(html, /