From 4202a53d27b8163b5fa29fc771b9684f9a8fbeb1 Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Fri, 31 Jul 2026 09:52:21 +0200 Subject: [PATCH 1/9] feat(ocpp): restore the OCPP 1.6J Central System package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings back go/internal/ocpp, retired as unused in #578, so EV chargers can connect to FTW directly instead of through a vendor cloud. The package is restored unchanged and still builds, vets and passes its own tests against the current tree. github.com/lorenzodonini/ocpp-go resolves to v0.19.0 at @latest — the same version that was removed, since upstream has not cut a release since August 2025. Nothing is wired into main.go yet, so this changes no runtime behaviour. Two known gaps are carried over from the original and must be closed before the server is enabled: - Config.Bind is advisory only. ocpp-go does not expose a bind address, so the listener takes 0.0.0.0 regardless, and Phase 1 has no TLS. - Handlers are read-only. Charge Amps needs SetChargingProfile-based control, because its RemoteStopTransaction is unreliable in the field. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com> --- go/go.mod | 8 +- go/go.sum | 36 ++++ go/internal/ocpp/config.go | 37 +++++ go/internal/ocpp/handlers.go | 286 ++++++++++++++++++++++++++++++++ go/internal/ocpp/server.go | 112 +++++++++++++ go/internal/ocpp/server_test.go | 188 +++++++++++++++++++++ 6 files changed, 666 insertions(+), 1 deletion(-) create mode 100644 go/internal/ocpp/config.go create mode 100644 go/internal/ocpp/handlers.go create mode 100644 go/internal/ocpp/server.go create mode 100644 go/internal/ocpp/server_test.go diff --git a/go/go.mod b/go/go.mod index 540e9c84..4d326575 100644 --- a/go/go.mod +++ b/go/go.mod @@ -9,6 +9,7 @@ require ( github.com/fsnotify/fsnotify v1.9.0 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 + github.com/lorenzodonini/ocpp-go v0.19.0 github.com/mochi-mqtt/server/v2 v2.7.9 github.com/parquet-go/parquet-go v0.29.0 github.com/shirou/gopsutil/v4 v4.26.3 @@ -24,8 +25,12 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/ebitengine/purego v0.10.0 // indirect github.com/go-ole/go-ole v1.2.6 // indirect + github.com/go-playground/locales v0.12.1 // indirect + github.com/go-playground/universal-translator v0.16.0 // indirect github.com/goburrow/serial v0.1.0 // indirect + github.com/gorilla/mux v1.8.1 // indirect github.com/klauspost/compress v1.17.11 // indirect + github.com/leodido/go-urn v1.1.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect @@ -33,6 +38,7 @@ require ( github.com/parquet-go/jsonlite v1.0.0 // indirect github.com/pierrec/lz4/v4 v4.1.21 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/relvacode/iso8601 v1.6.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rs/xid v1.4.0 // indirect github.com/teambition/rrule-go v1.8.2 // indirect @@ -43,7 +49,7 @@ require ( golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.45.0 // indirect google.golang.org/protobuf v1.34.2 // indirect - gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect + gopkg.in/go-playground/validator.v9 v9.30.0 // indirect modernc.org/libc v1.70.0 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go/go.sum b/go/go.sum index 48734711..d936371b 100644 --- a/go/go.sum +++ b/go/go.sum @@ -1,11 +1,16 @@ github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= +github.com/Shopify/toxiproxy v2.1.4+incompatible h1:TKdv8HiTLgE5wdJuEML90aBgNWsokNbMijUGhmcoBJc= +github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI= github.com/alecthomas/assert/v2 v2.10.0 h1:jjRCHsj6hBJhkmhznrCzoNpbA3zqy0fYiUcYZP/GkPY= github.com/alecthomas/assert/v2 v2.10.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= +github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA= +github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= @@ -24,6 +29,10 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-playground/locales v0.12.1 h1:2FITxuFt/xuCNP1Acdhv62OzaCiviiE4kotfhkmOqEc= +github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM= +github.com/go-playground/universal-translator v0.16.0 h1:X++omBR/4cE2MNg91AoC3rmGrCjJ8eAeUP/K/EKx4DM= +github.com/go-playground/universal-translator v0.16.0/go.mod h1:1AnU7NaIRDWWzGEKwgtJRd2xk99HeFyHw3yid4rvQIY= github.com/goburrow/serial v0.1.0 h1:v2T1SQa/dlUqQiYIT8+Cu7YolfqAi3K96UmhwYyuSrA= github.com/goburrow/serial v0.1.0/go.mod h1:sAiqG0nRVswsm1C97xsttiYCzSLBmUZ/VSlVLZJ8haA= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= @@ -33,6 +42,8 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= @@ -43,10 +54,18 @@ github.com/jinzhu/copier v0.3.5 h1:GlvfUwHk62RokgqVNvYsku0TATCF7bAHVwEXoBh3iJg= github.com/jinzhu/copier v0.3.5/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg= github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.1.0 h1:Sm1gr51B1kKyfD2BlRcLSiEkffoG96g6TPv6eRoEiB8= +github.com/leodido/go-urn v1.1.0/go.mod h1:+cyI34gQWZcE1eQU7NVgKkkzdXDQHr1dBMtdAPozLkw= +github.com/lorenzodonini/ocpp-go v0.19.0 h1:THNriVV3bUWkGzaIkDeytcgoeBRjoN9ezPHsddIDfgc= +github.com/lorenzodonini/ocpp-go v0.19.0/go.mod h1:2kcukDdhui4u730VfnYVWuwzDLgw+mBRGDir/QAyBhg= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -67,6 +86,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/relvacode/iso8601 v1.6.0 h1:eFXUhMJN3Gz8Rcq82f9DTMW0svjtAVuIEULglM7QHTU= +github.com/relvacode/iso8601 v1.6.0/go.mod h1:FlNp+jz+TXpyRqgmM7tnzHHzBnz776kmAH2h3sZCn0I= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= @@ -77,6 +98,14 @@ github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfx github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/simonvetter/modbus v1.6.4 h1:E03lBz/JftDza/+Ue+vxwkNZ/WW1xiqyFCUQ4NhqHn0= github.com/simonvetter/modbus v1.6.4/go.mod h1:hh90ZaTaPLcK2REj6/fpTbiV0J6S7GWmd8q+GVRObPw= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0 h1:M2gUjqZET1qApGOWNSnZ49BAIMX4F/1plDv3+l31EJ4= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8= @@ -99,8 +128,10 @@ golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220804214406-8e32c043e418/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= @@ -112,6 +143,11 @@ google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWn gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXadIrXTM= +gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= +gopkg.in/go-playground/validator.v9 v9.30.0 h1:Wk0Z37oBmKj9/n+tPyBHZmeL19LaCoK3Qq48VwYENss= +gopkg.in/go-playground/validator.v9 v9.30.0/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= diff --git a/go/internal/ocpp/config.go b/go/internal/ocpp/config.go new file mode 100644 index 00000000..39f560c6 --- /dev/null +++ b/go/internal/ocpp/config.go @@ -0,0 +1,37 @@ +package ocpp + +// Config controls the OCPP 1.6J Central System. +// +// Bind to LAN-only addresses by default — there is no TLS in Phase 1. +// Charge points connect via ws://:/; the chargerId +// segment becomes the driver name in telemetry.Store and shows up in +// /api/devices and /api/status.drivers. +// +// NOTE: Bind is advisory-only — the ocpp-go library does not expose a +// bind-address parameter, so the listener currently binds to 0.0.0.0 +// regardless of this field. See the TODO in server.go. +type Config struct { + Enabled bool `yaml:"enabled"` + Bind string `yaml:"bind"` + Port int `yaml:"port"` + Path string `yaml:"path"` + Username string `yaml:"username"` + Password string `yaml:"password"` + HeartbeatIntervalS int `yaml:"heartbeat_interval_s"` +} + +// Defaults fills in any unset fields with safe values. +func (c *Config) Defaults() { + if c.Bind == "" { + c.Bind = "0.0.0.0" + } + if c.Port == 0 { + c.Port = 8887 + } + if c.Path == "" { + c.Path = "/" + } + if c.HeartbeatIntervalS == 0 { + c.HeartbeatIntervalS = 60 + } +} diff --git a/go/internal/ocpp/handlers.go b/go/internal/ocpp/handlers.go new file mode 100644 index 00000000..866266a7 --- /dev/null +++ b/go/internal/ocpp/handlers.go @@ -0,0 +1,286 @@ +package ocpp + +import ( + "encoding/json" + "log/slog" + "strconv" + "sync" + "time" + + "github.com/lorenzodonini/ocpp-go/ocpp1.6/core" + "github.com/lorenzodonini/ocpp-go/ocpp1.6/types" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// Handler implements ocpp1.6/core.CentralSystemHandler. One Handler is shared +// across every connected charger; per-charger state lives in the chargers +// map. All access is mutex-guarded — handler callbacks fire from the OCPP +// library's goroutines. +type Handler struct { + tel *telemetry.Store + heartbeatIntervalS int + + mu sync.Mutex + chargers map[string]*chargerState + nextTxID int +} + +// chargerState is what we accumulate from successive OCPP messages for one +// charge point. Survives the OCPP library's stateless handler invocations. +type chargerState struct { + connected bool + charging bool + transactionID int + sessionStartMeterWh float64 + sessionMeterWh float64 + lastPowerW float64 +} + +// NewHandler returns a Handler ready to register with a CentralSystem. +// heartbeatIntervalS is what we tell each charger to use in the +// BootNotification confirmation. +func NewHandler(tel *telemetry.Store, heartbeatIntervalS int) *Handler { + return &Handler{ + tel: tel, + heartbeatIntervalS: heartbeatIntervalS, + chargers: map[string]*chargerState{}, + nextTxID: 1, + } +} + +// state returns the per-charger state, creating it lazily on first sight. +func (h *Handler) state(id string) *chargerState { + h.mu.Lock() + defer h.mu.Unlock() + s, ok := h.chargers[id] + if !ok { + s = &chargerState{transactionID: -1} + h.chargers[id] = s + } + return s +} + +// Snapshot returns a copy of all charger states for /api/status etc. +func (h *Handler) Snapshot() map[string]ChargerView { + h.mu.Lock() + defer h.mu.Unlock() + out := make(map[string]ChargerView, len(h.chargers)) + for id, s := range h.chargers { + out[id] = ChargerView{ + Connected: s.connected, + Charging: s.charging, + PowerW: s.lastPowerW, + SessionWh: s.sessionMeterWh, + TxID: s.transactionID, + } + } + return out +} + +// ChargerView is the public snapshot of a charger's state. +type ChargerView struct { + Connected bool `json:"connected"` + Charging bool `json:"charging"` + PowerW float64 `json:"power_w"` + SessionWh float64 `json:"session_wh"` + TxID int `json:"tx_id"` +} + +// OnConnect / OnDisconnect are wired by the Server to the OCPP library's +// connection callbacks, not part of CoreHandler. +func (h *Handler) OnConnect(id string) { + slog.Info("OCPP charger connected", "charger", id) + h.tel.RecordDriverSuccess(id) +} + +func (h *Handler) OnDisconnect(id string) { + slog.Info("OCPP charger disconnected", "charger", id) + s := h.state(id) + h.mu.Lock() + s.connected = false + s.charging = false + s.lastPowerW = 0 + h.mu.Unlock() + // Push a zero so the dispatch clamp releases — otherwise the last known + // non-zero w would survive until staleness kicks in. + h.pushReading(id, s) +} + +// ---- core.CentralSystemHandler ---- + +func (h *Handler) OnBootNotification(id string, req *core.BootNotificationRequest) (*core.BootNotificationConfirmation, error) { + slog.Info("OCPP boot", + "charger", id, + "vendor", req.ChargePointVendor, + "model", req.ChargePointModel, + "fw", req.FirmwareVersion) + h.tel.RecordDriverSuccess(id) + return core.NewBootNotificationConfirmation( + types.NewDateTime(time.Now()), + h.heartbeatIntervalS, + core.RegistrationStatusAccepted, + ), nil +} + +func (h *Handler) OnHeartbeat(id string, _ *core.HeartbeatRequest) (*core.HeartbeatConfirmation, error) { + h.tel.RecordDriverSuccess(id) + return core.NewHeartbeatConfirmation(types.NewDateTime(time.Now())), nil +} + +func (h *Handler) OnAuthorize(id string, req *core.AuthorizeRequest) (*core.AuthorizeConfirmation, error) { + // Phase 1: auto-authorize every tag. RFID gating is a Phase 2 feature + // alongside RemoteStartTransaction. + slog.Debug("OCPP authorize", "charger", id, "tag", req.IdTag) + return core.NewAuthorizationConfirmation( + types.NewIdTagInfo(types.AuthorizationStatusAccepted), + ), nil +} + +func (h *Handler) OnDataTransfer(id string, req *core.DataTransferRequest) (*core.DataTransferConfirmation, error) { + // Vendor-specific extensions — accept gracefully but do nothing. + slog.Debug("OCPP DataTransfer ignored", "charger", id, "vendor", req.VendorId) + return core.NewDataTransferConfirmation(core.DataTransferStatusUnknownVendorId), nil +} + +func (h *Handler) OnStatusNotification(id string, req *core.StatusNotificationRequest) (*core.StatusNotificationConfirmation, error) { + s := h.state(id) + h.mu.Lock() + switch req.Status { + case core.ChargePointStatusAvailable, core.ChargePointStatusUnavailable: + s.connected = false + s.charging = false + case core.ChargePointStatusPreparing, + core.ChargePointStatusFinishing, + core.ChargePointStatusSuspendedEV, + core.ChargePointStatusSuspendedEVSE, + core.ChargePointStatusReserved: + s.connected = true + s.charging = false + case core.ChargePointStatusCharging: + s.connected = true + s.charging = true + case core.ChargePointStatusFaulted: + s.connected = true + s.charging = false + } + h.mu.Unlock() + + if req.Status == core.ChargePointStatusFaulted { + h.tel.EmitMetric(id, "ev_fault", 1, "", "", "") + slog.Warn("OCPP charger faulted", "charger", id, "errorCode", req.ErrorCode, "info", req.Info) + } + slog.Info("OCPP status", + "charger", id, "connector", req.ConnectorId, "status", req.Status) + + h.pushReading(id, s) + h.tel.RecordDriverSuccess(id) + return core.NewStatusNotificationConfirmation(), nil +} + +func (h *Handler) OnMeterValues(id string, req *core.MeterValuesRequest) (*core.MeterValuesConfirmation, error) { + s := h.state(id) + h.mu.Lock() + for _, mv := range req.MeterValue { + for _, sv := range mv.SampledValue { + measurand := sv.Measurand + // OCPP 1.6 default measurand if unspecified. + if measurand == "" { + measurand = types.MeasurandEnergyActiveImportRegister + } + val, err := strconv.ParseFloat(sv.Value, 64) + if err != nil { + continue + } + switch measurand { + case types.MeasurandPowerActiveImport: + if sv.Unit == types.UnitOfMeasureKW { + val *= 1000 + } + s.lastPowerW = val + case types.MeasurandEnergyActiveImportRegister: + if sv.Unit == types.UnitOfMeasureKWh { + val *= 1000 + } + if s.transactionID >= 0 { + s.sessionMeterWh = val - s.sessionStartMeterWh + } + } + } + } + h.mu.Unlock() + + h.pushReading(id, s) + h.tel.RecordDriverSuccess(id) + return core.NewMeterValuesConfirmation(), nil +} + +func (h *Handler) OnStartTransaction(id string, req *core.StartTransactionRequest) (*core.StartTransactionConfirmation, error) { + h.mu.Lock() + txID := h.nextTxID + h.nextTxID++ + s := h.chargersLocked(id) + s.transactionID = txID + s.sessionStartMeterWh = float64(req.MeterStart) + s.sessionMeterWh = 0 + s.connected = true + s.charging = true + h.mu.Unlock() + + slog.Info("OCPP transaction started", + "charger", id, "txid", txID, "tag", req.IdTag, "meter_start_wh", req.MeterStart) + h.pushReading(id, s) + h.tel.RecordDriverSuccess(id) + return core.NewStartTransactionConfirmation( + types.NewIdTagInfo(types.AuthorizationStatusAccepted), + txID, + ), nil +} + +func (h *Handler) OnStopTransaction(id string, req *core.StopTransactionRequest) (*core.StopTransactionConfirmation, error) { + s := h.state(id) + h.mu.Lock() + sessionWh := float64(req.MeterStop) - s.sessionStartMeterWh + s.transactionID = -1 + s.charging = false + s.lastPowerW = 0 + s.sessionMeterWh = sessionWh + h.mu.Unlock() + + slog.Info("OCPP transaction stopped", + "charger", id, "txid", req.TransactionId, + "session_wh", sessionWh, "reason", req.Reason) + h.pushReading(id, s) + h.tel.EmitMetric(id, "ev_session_wh", sessionWh, "Wh", "", "") + h.tel.RecordDriverSuccess(id) + return core.NewStopTransactionConfirmation(), nil +} + +// chargersLocked is the same as state(id) but assumes h.mu is already held. +func (h *Handler) chargersLocked(id string) *chargerState { + s, ok := h.chargers[id] + if !ok { + s = &chargerState{transactionID: -1} + h.chargers[id] = s + } + return s +} + +// pushReading pushes the current state as a DerEV reading. The dispatch +// clamp (control/dispatch.go) sums all DerEV readings into state.EVChargingW +// every tick — so the charger's lastPowerW immediately suppresses home +// battery discharge. +func (h *Handler) pushReading(id string, s *chargerState) { + h.mu.Lock() + w := s.lastPowerW + data := map[string]any{ + "type": "ev", + "w": w, + "connected": s.connected, + "charging": s.charging, + "session_wh": s.sessionMeterWh, + } + h.mu.Unlock() + blob, _ := json.Marshal(data) + h.tel.Update(id, telemetry.DerEV, w, nil, blob) +} diff --git a/go/internal/ocpp/server.go b/go/internal/ocpp/server.go new file mode 100644 index 00000000..79ae077d --- /dev/null +++ b/go/internal/ocpp/server.go @@ -0,0 +1,112 @@ +// Package ocpp is the OCPP 1.6J Central System for FTW. +// +// EV chargers connect to us via WebSocket. We translate every BootNotification, +// MeterValues, and StatusNotification into a DerEV reading in telemetry.Store, +// keyed by the chargePointId from the URL path. The dispatch layer +// (control/dispatch.go:199-216) sums DerEV readings and prevents home batteries +// from discharging into an active EV charge. +// +// Phase 1 is read-only — handlers below ack everything but do not push remote +// commands. Phase 2 will add RemoteStartTransaction / SetChargingProfile. +// +// The library backing this is github.com/lorenzodonini/ocpp-go (MIT, also used +// by SteVe) — it owns the WebSocket + JSON layer; we own the message handlers +// and the telemetry mapping. +package ocpp + +import ( + "context" + "errors" + "fmt" + "log/slog" + "sync" + "time" + + ocpp16 "github.com/lorenzodonini/ocpp-go/ocpp1.6" + "github.com/lorenzodonini/ocpp-go/ws" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// Server is a running OCPP 1.6J Central System. +type Server struct { + cfg *Config + cs ocpp16.CentralSystem + handler *Handler + done chan struct{} + stopOnce sync.Once +} + +// Start brings up the OCPP CS on the configured bind:port. Returns +// immediately once the listener is up; the WebSocket loop runs in its own +// goroutine until ctx is cancelled or Stop() is called. +// +// The returned Server is the handle for shutdown — main.go is expected to +// call Stop() during graceful drain. +func Start(ctx context.Context, cfg *Config, tel *telemetry.Store) (*Server, error) { + if cfg == nil { + return nil, errors.New("ocpp: nil config") + } + if tel == nil { + return nil, errors.New("ocpp: nil telemetry store") + } + cfg.Defaults() + + wsServer := ws.NewServer() + if cfg.Username != "" || cfg.Password != "" { + u, p := cfg.Username, cfg.Password + wsServer.SetBasicAuthHandler(func(user, pass string) bool { + return user == u && pass == p + }) + } + + cs := ocpp16.NewCentralSystem(nil, wsServer) + h := NewHandler(tel, cfg.HeartbeatIntervalS) + cs.SetCoreHandler(h) + cs.SetNewChargePointHandler(func(cp ocpp16.ChargePointConnection) { + h.OnConnect(cp.ID()) + }) + cs.SetChargePointDisconnectedHandler(func(cp ocpp16.ChargePointConnection) { + h.OnDisconnect(cp.ID()) + }) + + s := &Server{cfg: cfg, cs: cs, handler: h, done: make(chan struct{})} + go func() { + defer close(s.done) + slog.Info("OCPP central system listening", + "bind", cfg.Bind, "port", cfg.Port, "path", cfg.Path, + "basic_auth", cfg.Username != "") + // TODO: cfg.Bind is not honored here. The ocpp-go library's + // CentralSystem.Start(port, path) and ws.Server.Start(port, path) + // only accept a port — there is no SetAddr or bind-address parameter. + // To support bind-address natively we would need to either: + // (a) upstream a PR to ocpp-go adding a SetListenAddr method, or + // (b) create our own net.Listener bound to cfg.Bind:cfg.Port and + // serve the ws.Server's http.Handler on it. + // For now cfg.Bind is advisory-only (documented in Config). + // cs.Start blocks until cs.Stop is called. + s.cs.Start(cfg.Port, fmt.Sprintf("%s{ws}", cfg.Path)) + }() + go func() { + <-ctx.Done() + s.Stop() + }() + return s, nil +} + +// Stop closes the WebSocket server and waits for the listener goroutine to exit. +// A 5-second timeout prevents deadlock if the listener goroutine is stuck. +func (s *Server) Stop() { + if s == nil || s.cs == nil { + return + } + s.stopOnce.Do(func() { s.cs.Stop() }) + select { + case <-s.done: + case <-time.After(5 * time.Second): + slog.Warn("ocpp: shutdown timeout — forcing close") + } +} + +// Handler exposes per-charger state for tests + introspection. +func (s *Server) Handler() *Handler { return s.handler } diff --git a/go/internal/ocpp/server_test.go b/go/internal/ocpp/server_test.go new file mode 100644 index 00000000..05eb88c7 --- /dev/null +++ b/go/internal/ocpp/server_test.go @@ -0,0 +1,188 @@ +package ocpp + +import ( + "context" + "fmt" + "net" + "sync" + "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/types" + "github.com/lorenzodonini/ocpp-go/ws" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// freePort returns a port the kernel just allocated and immediately gave back. +// Cheaper than racing a hardcoded port across parallel test runs. +func freePort(t *testing.T) int { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("free port: %v", err) + } + defer l.Close() + return l.Addr().(*net.TCPAddr).Port +} + +// startServer brings up an OCPP CS on a free port and returns the port + a +// matching ws://… URL plus the running server. Caller must defer Stop. +func startServer(t *testing.T, tel *telemetry.Store) (int, *Server) { + t.Helper() + port := freePort(t) + cfg := &Config{Enabled: true, Bind: "127.0.0.1", Port: port, HeartbeatIntervalS: 60} + srv, err := Start(context.Background(), cfg, tel) + if err != nil { + t.Fatalf("start: %v", err) + } + // Wait for the listener to be reachable. cs.Start launches goroutines — + // without a tiny pause the client will racily connect to nothing. + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + c, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 100*time.Millisecond) + if err == nil { + c.Close() + return port, srv + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("server did not bind on port %d within deadline", port) + return 0, nil +} + +func TestStopIsConcurrentAndIdempotent(t *testing.T) { + _, srv := startServer(t, telemetry.NewStore()) + + var wg sync.WaitGroup + for range 4 { + wg.Add(1) + go func() { + defer wg.Done() + srv.Stop() + }() + } + wg.Wait() + srv.Stop() +} + +func TestBootAndMeterValuesPushDerEV(t *testing.T) { + tel := telemetry.NewStore() + port, srv := startServer(t, tel) + defer srv.Stop() + + cp := ocpp16.NewChargePoint("EH123456", nil, nil) + url := fmt.Sprintf("ws://127.0.0.1:%d", port) + if err := cp.Start(url); err != nil { + t.Fatalf("client connect: %v", err) + } + defer cp.Stop() + + if _, err := cp.BootNotification("EaseeHome", "Easee"); err != nil { + t.Fatalf("boot: %v", err) + } + + // Charging status — handler should mark connected+charging. + if _, err := cp.StatusNotification(1, core.NoError, core.ChargePointStatusCharging); err != nil { + t.Fatalf("status: %v", err) + } + + // MeterValues with a 7200 W power sample. + mv := []types.MeterValue{{ + Timestamp: types.NewDateTime(time.Now()), + SampledValue: []types.SampledValue{ + { + Value: "7200", + Measurand: types.MeasurandPowerActiveImport, + Unit: types.UnitOfMeasureW, + }, + }, + }} + if _, err := cp.MeterValues(1, mv); err != nil { + t.Fatalf("meter values: %v", err) + } + + // Allow the handler goroutine to flush. + deadline := time.Now().Add(2 * time.Second) + var r *telemetry.DerReading + for time.Now().Before(deadline) { + r = tel.Get("EH123456", telemetry.DerEV) + if r != nil && r.RawW > 0 { + break + } + time.Sleep(50 * time.Millisecond) + } + if r == nil { + t.Fatal("expected DerEV reading for EH123456, got nil") + } + if r.RawW != 7200 { + t.Errorf("expected 7200 W, got %f", r.RawW) + } + + view := srv.Handler().Snapshot()["EH123456"] + if !view.Connected || !view.Charging { + t.Errorf("expected connected+charging, got %+v", view) + } +} + +func TestStartStopTransactionTracksSession(t *testing.T) { + tel := telemetry.NewStore() + port, srv := startServer(t, tel) + defer srv.Stop() + + cp := ocpp16.NewChargePoint("EH-SESSION", nil, nil) + if err := cp.Start(fmt.Sprintf("ws://127.0.0.1:%d", port)); err != nil { + t.Fatalf("connect: %v", err) + } + defer cp.Stop() + + if _, err := cp.BootNotification("Home", "Easee"); err != nil { + t.Fatalf("boot: %v", err) + } + startConf, err := cp.StartTransaction(1, "RFID-ABCD", 1000, types.NewDateTime(time.Now())) + if err != nil { + t.Fatalf("start tx: %v", err) + } + if startConf.IdTagInfo.Status != types.AuthorizationStatusAccepted { + t.Errorf("expected accepted, got %s", startConf.IdTagInfo.Status) + } + + if _, err := cp.StopTransaction(8500, types.NewDateTime(time.Now()), startConf.TransactionId); err != nil { + t.Fatalf("stop tx: %v", err) + } + + // Session energy = 8500 - 1000 = 7500 Wh + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + v := srv.Handler().Snapshot()["EH-SESSION"] + if v.SessionWh == 7500 { + return + } + time.Sleep(50 * time.Millisecond) + } + view := srv.Handler().Snapshot()["EH-SESSION"] + t.Errorf("expected session_wh=7500, got %+v", view) +} + +func TestBasicAuthRejectsWrongCredentials(t *testing.T) { + tel := telemetry.NewStore() + port := freePort(t) + cfg := &Config{Enabled: true, Bind: "127.0.0.1", Port: port, Username: "easee", Password: "secret"} + srv, err := Start(context.Background(), cfg, tel) + if err != nil { + t.Fatalf("start: %v", err) + } + defer srv.Stop() + time.Sleep(100 * time.Millisecond) + + wsClient := ws.NewClient() + wsClient.SetBasicAuth("easee", "wrong-password") + cp := ocpp16.NewChargePoint("EH-AUTH", nil, wsClient) + err = cp.Start(fmt.Sprintf("ws://127.0.0.1:%d", port)) + if err == nil { + cp.Stop() + t.Error("expected auth failure with wrong password, got nil") + } +} From 3a90d3003613ce1adb234a0049a043211bc66eb6 Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Fri, 31 Jul 2026 09:53:05 +0200 Subject: [PATCH 2/9] chore: add changeset for the OCPP central system restore Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com> --- .changeset/ocpp-central-system-restore.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .changeset/ocpp-central-system-restore.md diff --git a/.changeset/ocpp-central-system-restore.md b/.changeset/ocpp-central-system-restore.md new file mode 100644 index 00000000..f467d9a6 --- /dev/null +++ b/.changeset/ocpp-central-system-restore.md @@ -0,0 +1,15 @@ +--- +"ftw": minor +--- + +Restore the OCPP 1.6J Central System so EV chargers can connect to FTW directly +instead of through a vendor cloud. + +`go/internal/ocpp` was retired as unused in #578. Bringing it back is the only +integration path for several common chargers: Charge Amps has no FTW driver at +all and every current model speaks OCPP 1.6J, while Easee and Zaptec can both be +commissioned once through their vendor portal and then run against a local +central system with no cloud in the runtime path. + +The package is restored unchanged and is not yet wired into the process, so this +release carries no behaviour change on its own. From 80d27da32e21be6d636850b96c1f1c96a53a20d7 Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Fri, 31 Jul 2026 14:08:23 +0200 Subject: [PATCH 3/9] feat(ocpp): enable the central system and document driverless OCPP chargers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the restored OCPP 1.6J Central System into the process behind a new opt-in ocpp config section, and documents the part that is easy to miss: an OCPP charger needs no driver at all. OCPP is vendor-neutral, so one server in core covers every charger that speaks it. Chargers dial FTW rather than being polled, so there is nothing to add under drivers: — a charge point becomes a device on its first BootNotification, keyed by the last segment of the URL it connected to. Enabling the server requires a username and password, and config validation rejects an enabled section without them. This is deliberate. ocpp-go builds its listen address from the port alone, so the socket is reachable on every interface and cannot be pinned to one; basic auth is the only gate in front of it. That is a mitigation, not a fix, and the docs say so. Documented in docs/ocpp.md, with pointers from the README, config.example.yaml and writing-a-driver.md so nobody starts a Lua driver for a charger that does not need one. Tests cover the fail-closed credential rule and assert the shipped example config still parses, ships disabled and validates. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com> --- .changeset/ocpp-central-system-restore.md | 29 ++++-- README.md | 8 +- config.example.yaml | 17 ++++ docs/ocpp.md | 116 ++++++++++++++++++++++ docs/writing-a-driver.md | 5 + go/cmd/ftw/main.go | 29 ++++++ go/internal/config/config.go | 42 ++++++++ go/internal/config/example_config_test.go | 31 ++++++ go/internal/config/ocpp_test.go | 96 ++++++++++++++++++ go/internal/ocpp/server.go | 18 ++++ 10 files changed, 381 insertions(+), 10 deletions(-) create mode 100644 docs/ocpp.md create mode 100644 go/internal/config/example_config_test.go create mode 100644 go/internal/config/ocpp_test.go diff --git a/.changeset/ocpp-central-system-restore.md b/.changeset/ocpp-central-system-restore.md index f467d9a6..17a9f90c 100644 --- a/.changeset/ocpp-central-system-restore.md +++ b/.changeset/ocpp-central-system-restore.md @@ -2,14 +2,25 @@ "ftw": minor --- -Restore the OCPP 1.6J Central System so EV chargers can connect to FTW directly -instead of through a vendor cloud. +Add OCPP 1.6J support so EV chargers connect to FTW directly instead of through +a vendor cloud. An OCPP charger needs no driver: the protocol is vendor-neutral, +so one server in core handles every charger that speaks it. -`go/internal/ocpp` was retired as unused in #578. Bringing it back is the only -integration path for several common chargers: Charge Amps has no FTW driver at -all and every current model speaks OCPP 1.6J, while Easee and Zaptec can both be -commissioned once through their vendor portal and then run against a local -central system with no cloud in the runtime path. +Chargers dial FTW rather than the other way round, so there is nothing to add +under `drivers:`. A charge point becomes a device on its first BootNotification, +keyed by the last segment of the URL it connected to, and dispatch treats it +like any other EV reading. -The package is restored unchanged and is not yet wired into the process, so this -release carries no behaviour change on its own. +This reinstates `go/internal/ocpp`, retired as unused in #578, and wires it into +the process behind a new `ocpp` config section. It matters because Charge Amps +has no FTW driver at all and every current model speaks OCPP, while Easee and +Zaptec can be commissioned once through their vendor portal and then run with no +cloud in the runtime path. + +The server is off by default. Enabling it requires a username and password, and +FTW refuses to start without them: the OCPP library builds its listen address +from the port alone, so the socket is reachable on every interface and basic +auth is the only gate. Keep the port closed at your router. + +Phase 1 is read-only — chargers are metered and monitored, but FTW does not yet +start, stop or throttle a charge. diff --git a/README.md b/README.md index d0436a80..ca503e80 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,8 @@ rule. See [docs/architecture.md](docs/architecture.md). - local web UI, SQLite history and Parquet rolloff; - Home Assistant MQTT discovery; - CalDAV planning intents and published schedules; -- hot-reloadable, independently released Lua drivers. +- hot-reloadable, independently released Lua drivers; +- a built-in OCPP 1.6J server, so OCPP chargers connect with no driver at all. The current device catalog is generated from the `DRIVER` metadata in [`drivers/*.lua`](drivers/); that source is authoritative. @@ -113,6 +114,10 @@ Start with [docs/writing-a-driver.md](docs/writing-a-driver.md). Signed driver artifacts use the same `beta` → `stable` progression as core and can be installed or rolled back independently. +EV chargers that speak OCPP are the exception: they need no driver. FTW runs an +OCPP 1.6J Central System, so the charger connects to FTW and registers itself. +See [docs/ocpp.md](docs/ocpp.md). + ## Releases There are two channels: @@ -135,6 +140,7 @@ metadata are the detailed reference. - [Operations and recovery](docs/operations.md) - [Full backup and safe restore](docs/backup-and-restore.md) - [Writing a driver](docs/writing-a-driver.md) +- [OCPP chargers (no driver needed)](docs/ocpp.md) - [Self-update and release channels](docs/self-update.md) - [Home Assistant](docs/ha-integration.md) - [CalDAV](docs/caldav-integration.md) diff --git a/config.example.yaml b/config.example.yaml index 71ea3b53..bc96fe81 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -159,6 +159,23 @@ caldav: plan_path: /ftw/plan/ # plan_publish_interval_s: 900 # how often the plan calendar is reconciled +# Built-in OCPP 1.6J Central System. EV chargers that speak OCPP connect to FTW +# directly — there is no driver to write and nothing to add under `drivers:`. +# A charger appears as a device on its first BootNotification, keyed by the last +# segment of the URL it dialled: ws://:8887/garage-left +# +# Credentials are required when enabled, and FTW refuses to start without them: +# the OCPP library builds its listen address from the port alone, so the socket +# is reachable on every interface and basic auth is the only gate. Keep the port +# closed at your router. See docs/ocpp.md. +ocpp: + enabled: false + port: 8887 + path: / + username: ftw + password: "" # required when enabled; use a long random string + heartbeat_interval_s: 60 + # Persistent state (SQLite) state: path: state.db diff --git a/docs/ocpp.md b/docs/ocpp.md new file mode 100644 index 00000000..f266a0e2 --- /dev/null +++ b/docs/ocpp.md @@ -0,0 +1,116 @@ +# OCPP chargers + +FTW has a built-in OCPP 1.6J Central System. An EV charger that speaks OCPP +connects straight to FTW over your local network. + +**An OCPP charger does not need a driver.** There is no Lua file to write, no +entry in `drivers:`, and nothing to add to the device catalog. OCPP is a vendor- +neutral protocol, so one server in core handles every charger that speaks it — +Charge Amps, Easee, Zaptec, ABB, Alfen and the rest are all the same code path. + +This is the opposite of how the rest of FTW works. Every other device needs a +driver because every vendor invented its own protocol. OCPP is the standard that +makes drivers unnecessary, so the driver boundary does not apply. + +## How a charger becomes a device + +Drivers poll outward: FTW opens the connection and asks for data. OCPP runs the +other way — the charger dials FTW and pushes. + +So there is nothing to configure per charger. You point the charger at FTW, and +it appears as a device the moment it sends its first `BootNotification`. Its +identity is the last segment of the URL it connected to: + +``` +ws://:8887/garage-left + └── this becomes the device key +``` + +Give each charger a distinct identity segment. Reuse one and two chargers will +collapse into a single device. + +From there it behaves like any other EV reading: `MeterValues` and +`StatusNotification` become telemetry, and dispatch stops the home battery +discharging into an active EV charge. + +## Enabling the server + +OCPP is off by default. Add an `ocpp` section: + +```yaml +ocpp: + enabled: true + port: 8887 # default 8887 + path: / # default / + username: ftw + password: + heartbeat_interval_s: 60 +``` + +**Credentials are mandatory.** FTW refuses to start with `enabled: true` and an +empty username or password. That is deliberate, and the reason is below. + +## Security: the listener is on every interface + +The OCPP library builds its listen address from the port alone, so the socket +binds to every interface the host has. There is no bind-address setting, and +there is no TLS on this path yet. + +On a Raspberry Pi with one LAN connection that is usually fine. It is not fine +if the host also has a public interface or a permissive port forward. + +So: + +- Keep the port closed at your router. Never forward it from the internet. +- Treat the password as a real secret; it is the only gate in front of the + server. +- Basic auth over `ws://` sends the credential unencrypted. Anyone who can sniff + your LAN can read it. + +Credentials being required is a mitigation, not a fix. Binding to one interface +needs a change to the upstream library. + +## Pointing a charger at FTW + +The backend URL is always `ws://:`. What differs +is how you reach the charger to set it. + +| Charger | Where you set it | Cloud needed? | +|---|---|---| +| Charge Amps Halo, Aura | WiFi hotspot → `192.168.250.1` → Settings → OCPP | no | +| Charge Amps Dawn, Luna | Charge Amps Installer app over Bluetooth → CPMS settings | no | +| Easee | Easee's commissioning API, once | one-time | +| Zaptec | Zaptec Portal, needs the `Allow OCPP 1.6J` permission | one-time | + +Easee and Zaptec need a one-time commissioning step through the vendor portal. +After that the charger talks only to FTW and the cloud is out of the runtime +path. Charge Amps needs no cloud at all. + +Two traps worth knowing: + +- **Charge Amps Bluetooth is only discoverable for 10 minutes after power-up.** + If the unit has been on longer, cut the fuse and re-energise before searching. +- **Zaptec appends the charger serial to the URL itself.** Enter the URL without + it. + +For the full commissioning and factory-reset detail per model, see the bench +guide in the device-drivers repository. + +## Current limits + +- **Read-only.** The server accepts and records everything a charger reports, + but sends no commands, so FTW cannot yet start, stop or throttle an OCPP + charge. Control is the next step. +- **No TLS**, and the listener cannot be pinned to one interface. +- Chargers that also have a native protocol may work better through a driver. + Easee over Modbus and Zaptec over its cloud API already have drivers; OCPP is + the option when you want the cloud out of the loop. + +## When you still need a driver + +Only for chargers that do not speak OCPP, or where a vendor protocol exposes +something OCPP does not. The Ambibox V2G / InterControl ambiCHARGE is the +example on the bench: it is DC-coupled and bidirectional, runs over MQTT, and +uses the `ambibox_v2x` driver. + +See [writing-a-driver.md](writing-a-driver.md) for that path. diff --git a/docs/writing-a-driver.md b/docs/writing-a-driver.md index 140fee7d..91e4fe73 100644 --- a/docs/writing-a-driver.md +++ b/docs/writing-a-driver.md @@ -7,6 +7,11 @@ build is needed. Start from the closest existing file in `drivers/`, not from a generic template. +> **An EV charger that speaks OCPP does not need a driver.** FTW has a built-in +> OCPP 1.6J Central System, and one server in core handles every charger that +> speaks the protocol. Point the charger at FTW and it registers itself. See +> [ocpp.md](ocpp.md) before writing anything. + ## Metadata Every driver declares one authoritative catalog block: diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 0c127376..4a715d05 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -43,6 +43,7 @@ import ( mqttcli "github.com/srcfl/ftw/go/internal/mqtt" "github.com/srcfl/ftw/go/internal/notifications" "github.com/srcfl/ftw/go/internal/nova" + "github.com/srcfl/ftw/go/internal/ocpp" "github.com/srcfl/ftw/go/internal/priceforecast" "github.com/srcfl/ftw/go/internal/prices" "github.com/srcfl/ftw/go/internal/proxy" @@ -908,6 +909,34 @@ func main() { slog.Info("caldav started", "listen", cfg.CalDAV.ListenAddr(), "url", cfg.CalDAV.URL, "calendar", cfg.CalDAV.CalendarPath) } + // ---- Start OCPP 1.6J Central System (optional) ---- + // Chargers dial us, so there is nothing to add to cfg.Drivers and no Lua + // driver involved. A charge point becomes a device in tel the moment it + // sends its first BootNotification, keyed by the identity segment of the + // URL it connected to, and dispatch picks it up from there like any other + // EV reading. + if cfg.OCPP != nil && cfg.OCPP.Enabled { + ocppSrv, err := ocpp.Start(ctx, &ocpp.Config{ + Enabled: cfg.OCPP.Enabled, + Port: cfg.OCPP.Port, + Path: cfg.OCPP.Path, + Username: cfg.OCPP.Username, + Password: cfg.OCPP.Password, + HeartbeatIntervalS: cfg.OCPP.HeartbeatIntervalS, + }, tel) + if err != nil { + // A charger that cannot reach us is a missing device, not a + // broken site, so keep the rest of the process running. + slog.Error("ocpp: central system failed to start", "err", err) + } else { + defer ocppSrv.Stop() + slog.Info("ocpp: central system started", + "port", ocppSrv.Port(), + "path", ocppSrv.Path(), + "note", "listener is reachable on every interface; basic auth is the only gate") + } + } + // ---- Start MPC planner (optional) ---- mpcSvc = buildMPC(cfg, st, tel, capacities) if mpcSvc != nil { diff --git a/go/internal/config/config.go b/go/internal/config/config.go index 68a4c583..a94ff15b 100644 --- a/go/internal/config/config.go +++ b/go/internal/config/config.go @@ -37,6 +37,45 @@ type Config struct { Notifications *Notifications `yaml:"notifications,omitempty" json:"notifications,omitempty"` Nova *Nova `yaml:"nova,omitempty" json:"nova,omitempty"` DeviceRepository *DeviceRepository `yaml:"device_repository,omitempty" json:"device_repository,omitempty"` + OCPP *OCPP `yaml:"ocpp,omitempty" json:"ocpp,omitempty"` +} + +// OCPP configures the built-in OCPP 1.6J Central System. Chargers connect to +// us, so there is no driver and no per-charger config entry — a charge point +// appears as a device the moment it sends its first BootNotification, keyed by +// the identity segment of the URL it dialled. +// +// Disabled by default, and enabling it requires credentials. The listener +// cannot be restricted to one interface: the OCPP library builds its own +// listen address from the port alone, so the socket is reachable on every +// interface the host has. Basic auth is the only thing standing in front of +// it, which is why an empty Username or Password is rejected rather than +// silently accepted. +type OCPP struct { + Enabled bool `yaml:"enabled" json:"enabled"` + Port int `yaml:"port,omitempty" json:"port,omitempty"` + Path string `yaml:"path,omitempty" json:"path,omitempty"` + Username string `yaml:"username,omitempty" json:"username,omitempty"` + Password string `yaml:"password,omitempty" json:"password,omitempty"` + HeartbeatIntervalS int `yaml:"heartbeat_interval_s,omitempty" json:"heartbeat_interval_s,omitempty"` +} + +// Validate rejects an enabled server that would accept anonymous charge +// points. A nil or disabled section is fine — OCPP is opt-in. +func (o *OCPP) Validate() error { + if o == nil || !o.Enabled { + return nil + } + if o.Username == "" || o.Password == "" { + return errors.New("ocpp: username and password are required when enabled, because the listener cannot be bound to a single interface") + } + if o.Port < 0 || o.Port > 65535 { + return fmt.Errorf("ocpp.port must be between 0 and 65535, got %d", o.Port) + } + if o.HeartbeatIntervalS < 0 { + return fmt.Errorf("ocpp.heartbeat_interval_s must be >= 0, got %d", o.HeartbeatIntervalS) + } + return nil } // DeviceRepository configures independently distributed Lua drivers. Remote @@ -1411,6 +1450,9 @@ func (c *Config) Validate() error { if err := c.CalDAV.Validate(); err != nil { return err } + if err := c.OCPP.Validate(); err != nil { + return err + } // Empty drivers list is a valid shape — e.g. an EV-only site that // configured a cloud EV charger in the setup wizard and doesn't diff --git a/go/internal/config/example_config_test.go b/go/internal/config/example_config_test.go new file mode 100644 index 00000000..c7d9e042 --- /dev/null +++ b/go/internal/config/example_config_test.go @@ -0,0 +1,31 @@ +package config + +import ( + "os" + "testing" +) + +func TestExampleConfigParsesOCPP(t *testing.T) { + data, err := os.ReadFile("../../../config.example.yaml") + if err != nil { + t.Fatalf("read example: %v", err) + } + c, err := Parse(data, ".") + if err != nil { + t.Fatalf("parse example config: %v", err) + } + if c.OCPP == nil { + t.Fatal("example config has no ocpp section") + } + if c.OCPP.Enabled { + t.Error("example config must ship with ocpp disabled") + } + if c.OCPP.Port != 8887 { + t.Errorf("port: got %d want 8887", c.OCPP.Port) + } + if err := c.Validate(); err != nil { + t.Fatalf("example config must validate: %v", err) + } + t.Logf("ocpp parsed: enabled=%v port=%d path=%q heartbeat=%d", + c.OCPP.Enabled, c.OCPP.Port, c.OCPP.Path, c.OCPP.HeartbeatIntervalS) +} diff --git a/go/internal/config/ocpp_test.go b/go/internal/config/ocpp_test.go new file mode 100644 index 00000000..bd32ac8a --- /dev/null +++ b/go/internal/config/ocpp_test.go @@ -0,0 +1,96 @@ +package config + +import "testing" + +// The OCPP listener cannot be pinned to one interface — the library builds its +// listen address from the port alone. Basic auth is therefore the only thing +// between an enabled server and any host that can route to it, so an enabled +// section without credentials has to fail rather than start. +func TestOCPPValidate(t *testing.T) { + tests := []struct { + name string + ocpp *OCPP + wantErr bool + }{ + { + name: "absent section is fine", + ocpp: nil, + }, + { + name: "disabled without credentials is fine", + ocpp: &OCPP{Enabled: false}, + }, + { + name: "enabled without credentials is rejected", + ocpp: &OCPP{Enabled: true}, + wantErr: true, + }, + { + name: "enabled with only a username is rejected", + ocpp: &OCPP{Enabled: true, Username: "ftw"}, + wantErr: true, + }, + { + name: "enabled with only a password is rejected", + ocpp: &OCPP{Enabled: true, Password: "secret"}, + wantErr: true, + }, + { + name: "enabled with both is accepted", + ocpp: &OCPP{Enabled: true, Username: "ftw", Password: "secret"}, + }, + { + name: "port out of range is rejected", + ocpp: &OCPP{Enabled: true, Username: "ftw", Password: "secret", Port: 70000}, + wantErr: true, + }, + { + name: "negative heartbeat is rejected", + ocpp: &OCPP{Enabled: true, Username: "ftw", Password: "secret", HeartbeatIntervalS: -1}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.ocpp.Validate() + if tt.wantErr && err == nil { + t.Fatal("expected an error, got nil") + } + if !tt.wantErr && err != nil { + t.Fatalf("expected no error, got %v", err) + } + }) + } +} + +// A disabled or absent OCPP section must not stop the rest of the config from +// validating, and an enabled one without credentials must take the whole +// config down with it rather than being skipped. +func TestConfigValidateCoversOCPP(t *testing.T) { + // SmoothingAlpha and MaxAmps are normally filled in by defaults, which this + // test does not run; set them so the only thing under test is OCPP. + base := func() *Config { + return &Config{ + Site: Site{SmoothingAlpha: 0.3}, + Fuse: Fuse{MaxAmps: 20, Phases: 3, Voltage: 230}, + } + } + + c := base() + if err := c.Validate(); err != nil { + t.Fatalf("config without an ocpp section should validate, got %v", err) + } + + c = base() + c.OCPP = &OCPP{Enabled: true} + if err := c.Validate(); err == nil { + t.Fatal("config with an enabled, credential-less ocpp section should fail") + } + + c = base() + c.OCPP = &OCPP{Enabled: true, Username: "ftw", Password: "secret"} + if err := c.Validate(); err != nil { + t.Fatalf("config with a credentialed ocpp section should validate, got %v", err) + } +} diff --git a/go/internal/ocpp/server.go b/go/internal/ocpp/server.go index 79ae077d..75a4dc06 100644 --- a/go/internal/ocpp/server.go +++ b/go/internal/ocpp/server.go @@ -110,3 +110,21 @@ func (s *Server) Stop() { // Handler exposes per-charger state for tests + introspection. func (s *Server) Handler() *Handler { return s.handler } + +// Port is the port the listener actually took, after defaults were applied. +// Callers configuring an unset port need this to log or display the real value. +func (s *Server) Port() int { + if s == nil || s.cfg == nil { + return 0 + } + return s.cfg.Port +} + +// Path is the URL prefix charge points connect to, after defaults were applied. +// A charger dials , and that identity becomes its device key. +func (s *Server) Path() string { + if s == nil || s.cfg == nil { + return "" + } + return s.cfg.Path +} From 7952511be48c8cdf1adf3c595ce4603db8440126 Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Fri, 31 Jul 2026 14:27:32 +0200 Subject: [PATCH 4/9] feat(ocpp): control chargers with charging profiles instead of remote stop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FTW can now throttle, pause and resume an OCPP charger. Command has the same signature as drivers.Registry.Send and speaks the vocabulary every EV driver already implements, so main.go routes by name and loadpoints never learn that an OCPP charger is not a Lua driver. Every command is a current limit, never a remote start or stop. RemoteStopTransaction is unreliable on Charge Amps hardware — units acknowledge the stop and then resume charging on their own — while a 0 A charging profile is honoured consistently. It also leaves the transaction open, so the session meter keeps counting across a pause instead of splitting into two sessions. Two safety decisions worth calling out: - Below the IEC 61851 minimum of 6 A the charger is told 0 A rather than rounded up. When the allocator has less headroom than that to give, rounding up draws current the site fuse was never asked to carry, so refusing to charge is the safe direction of error. - A pause records no limit, so resuming returns to the last non-zero rate rather than the fallback ceiling. Caught by TestResumeRestoresLastLimit. Also splits charger state in two. connected already meant "a connector has a vehicle on it" and was never set by OnConnect, so it could not gate control: a default charging profile is exactly the thing you set on an idle charger. online now tracks the WebSocket session and is what control gates on. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com> --- .changeset/ocpp-central-system-restore.md | 11 +- docs/ocpp.md | 30 +- go/cmd/ftw/main.go | 19 +- go/internal/ocpp/control.go | 308 +++++++++++++++++ go/internal/ocpp/control_test.go | 403 ++++++++++++++++++++++ go/internal/ocpp/handlers.go | 13 + 6 files changed, 776 insertions(+), 8 deletions(-) create mode 100644 go/internal/ocpp/control.go create mode 100644 go/internal/ocpp/control_test.go diff --git a/.changeset/ocpp-central-system-restore.md b/.changeset/ocpp-central-system-restore.md index 17a9f90c..e1d37ca7 100644 --- a/.changeset/ocpp-central-system-restore.md +++ b/.changeset/ocpp-central-system-restore.md @@ -17,10 +17,15 @@ has no FTW driver at all and every current model speaks OCPP, while Easee and Zaptec can be commissioned once through their vendor portal and then run with no cloud in the runtime path. +FTW throttles, pauses and resumes an OCPP charger like any other EV charger. +Every command is a current limit rather than a remote start or stop, because +`RemoteStopTransaction` is unreliable on Charge Amps hardware — units +acknowledge the stop and resume charging on their own, while a 0 A charging +profile is honoured consistently and keeps the session meter intact across a +pause. Below the IEC 61851 minimum of 6 A the charger is told 0 A rather than +being rounded up to current the site fuse was not asked to carry. + The server is off by default. Enabling it requires a username and password, and FTW refuses to start without them: the OCPP library builds its listen address from the port alone, so the socket is reachable on every interface and basic auth is the only gate. Keep the port closed at your router. - -Phase 1 is read-only — chargers are metered and monitored, but FTW does not yet -start, stop or throttle a charge. diff --git a/docs/ocpp.md b/docs/ocpp.md index f266a0e2..d46330c6 100644 --- a/docs/ocpp.md +++ b/docs/ocpp.md @@ -96,11 +96,35 @@ Two traps worth knowing: For the full commissioning and factory-reset detail per model, see the bench guide in the device-drivers repository. +## Control + +FTW throttles, pauses and resumes an OCPP charger the same way it steers any +other EV charger. Loadpoints do not know the difference. + +Every command is expressed as a **current limit**, never as a remote start or +stop. Pausing means "you may draw zero amps"; resuming raises the limit again. + +That is not a stylistic choice. `RemoteStopTransaction` is unreliable on Charge +Amps hardware — units acknowledge the stop and then resume charging on their +own. A charging profile of 0 A is honoured consistently. It also leaves the +transaction open, so the session meter keeps counting across a pause instead of +being split into two sessions. + +Two limits worth knowing: + +- **Below 6 A, FTW sends 0 A.** IEC 61851 has no duty cycle under 6 A. When the + allocator has less headroom than that to give, rounding up would draw current + the site fuse was never asked to carry, so charging stops instead. +- **A pause remembers the previous rate.** Resuming returns to the last non-zero + limit, not to the maximum. + +If FTW loses contact, the charger holds its last granted limit. Dropping to zero +would strand a driver with an uncharged car because the EMS went down, and the +last limit was already judged safe for the site. This matches what every EV +driver in FTW already does. + ## Current limits -- **Read-only.** The server accepts and records everything a charger reports, - but sends no commands, so FTW cannot yet start, stop or throttle an OCPP - charge. Control is the next step. - **No TLS**, and the listener cannot be pinned to one interface. - Chargers that also have a native protocol may work better through a driver. Easee over Modbus and Zaptec over its cloud API already have drivers; OCPP is diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 4a715d05..ac4e1632 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -915,8 +915,9 @@ func main() { // sends its first BootNotification, keyed by the identity segment of the // URL it connected to, and dispatch picks it up from there like any other // EV reading. + var ocppSrv *ocpp.Server if cfg.OCPP != nil && cfg.OCPP.Enabled { - ocppSrv, err := ocpp.Start(ctx, &ocpp.Config{ + srv, err := ocpp.Start(ctx, &ocpp.Config{ Enabled: cfg.OCPP.Enabled, Port: cfg.OCPP.Port, Path: cfg.OCPP.Path, @@ -929,6 +930,7 @@ func main() { // broken site, so keep the rest of the process running. slog.Error("ocpp: central system failed to start", "err", err) } else { + ocppSrv = srv defer ocppSrv.Stop() slog.Info("ocpp: central system started", "port", ocppSrv.Port(), @@ -1321,7 +1323,20 @@ func main() { RequestActive: reqActive, }, true } - lpController = loadpoint.NewController(lpMgr, planAdapter, telAdapter, reg.Send) + // An OCPP charge point is not in the driver registry — it connected to + // us rather than being dialled — so route by name: if an online charger + // answers to it, command it over OCPP, otherwise fall through to the + // Lua driver registry. Loadpoints stay unaware of the difference. + send := reg.Send + if ocppSrv != nil { + send = func(ctx context.Context, name string, payload []byte) error { + if ocppSrv.Handler().IsOnline(name) { + return ocppSrv.Command(ctx, name, payload) + } + return reg.Send(ctx, name, payload) + } + } + lpController = loadpoint.NewController(lpMgr, planAdapter, telAdapter, send) // Wire the site fuse so the per-phase EV clamp and the // phase-split derivation can use the actual site voltage and // breaker rating instead of hard-coding 230 V × 16 A. diff --git a/go/internal/ocpp/control.go b/go/internal/ocpp/control.go new file mode 100644 index 00000000..a67497da --- /dev/null +++ b/go/internal/ocpp/control.go @@ -0,0 +1,308 @@ +package ocpp + +// Control for OCPP chargers. +// +// Command has the same shape as drivers.Registry.Send, so the loadpoint +// controller dispatches to an OCPP charger without knowing it is not a Lua +// driver. The command vocabulary is the one every EV driver already implements +// — ev_set_current, ev_pause, ev_start, ev_resume — so nothing upstream of here +// needs a special case. +// +// Everything is expressed as a current limit rather than a remote start/stop. +// That is deliberate: RemoteStopTransaction is unreliable in the field on +// Charge Amps hardware, where units acknowledge the stop and return to charging +// on their own. A charging profile of 0 A is honoured consistently, so pausing +// is "allow zero amps" rather than "end the transaction", and resuming raises +// the limit again. It also leaves the transaction open, so the session meter +// keeps accumulating across a pause instead of being split in two. + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "time" + + "github.com/lorenzodonini/ocpp-go/ocpp1.6/smartcharging" + "github.com/lorenzodonini/ocpp-go/ocpp1.6/types" +) + +const ( + // IEC 61851 does not allow a duty cycle below 6 A. Charging is either off + // or at least this much; anything between is refused by the vehicle or + // leaves it drawing an unpredictable current. + minChargeAmps = 6.0 + + // Fallback electrical assumptions, used only when the command omits them. + // A command from the loadpoint controller carries the real site values. + defaultVoltage = 230.0 + defaultPhases = 3 + + // Ceiling applied when a command asks to resume without saying how fast + // and we have never set a limit for this charger. + defaultMaxAmps = 16.0 + + // How long to wait for a charger to confirm a profile before giving up. + // Control runs on a tick; a command that has not landed by now is better + // reported as failed than left blocking the loop. + commandTimeout = 10 * time.Second + + // Charge-point-wide default profile. Connector 0 means "every connector", + // which avoids depending on per-connector ids — those are unreliable on + // dual-socket units such as the Charge Amps Aura. + allConnectors = 0 + + // A single, stable profile id and stack level means each new limit + // replaces the previous one instead of stacking on top of it. + ftwProfileID = 1 + ftwStackLevel = 0 + scheduleStartS = 0 +) + +// command is the JSON payload the loadpoint controller sends to EV drivers. +// Only the fields that affect a current limit are read here. +type command struct { + Action string `json:"action"` + PowerW float64 `json:"power_w"` + Voltage float64 `json:"voltage"` + SitePhases int `json:"site_phases"` + MaxAmpsPerPhase float64 `json:"max_amps_per_phase"` + PhaseMode string `json:"phase_mode"` +} + +// ErrNotConnected is returned when a command targets a charge point that is not +// currently connected. Callers use it to tell "wrong name" apart from "charger +// is offline right now". +var ErrNotConnected = errors.New("ocpp: charger not connected") + +// Command applies an EV control command to a connected charge point. The +// signature matches drivers.Registry.Send so it can back a loadpoint +// SenderFunc directly. +func (s *Server) Command(ctx context.Context, id string, payload []byte) error { + if s == nil || s.cs == nil { + return errors.New("ocpp: server not running") + } + if !s.handler.IsOnline(id) { + return fmt.Errorf("%w: %s", ErrNotConnected, id) + } + + var c command + if err := json.Unmarshal(payload, &c); err != nil { + return fmt.Errorf("ocpp: bad command payload for %s: %w", id, err) + } + + switch c.Action { + case "init", "deinit": + // Lifecycle hooks that only mean something to a Lua VM. + return nil + + case "ev_set_current": + return s.setLimit(ctx, id, c.amps(), c.numberPhases()) + + case "ev_pause": + // Phase count is meaningless at zero amps. + return s.setLimit(ctx, id, 0, nil) + + case "ev_start", "ev_resume": + // A resume without a rate means "as fast as previously allowed". + amps := c.amps() + if amps <= 0 { + amps = s.handler.LastAmps(id, c.ceiling()) + } + return s.setLimit(ctx, id, amps, c.numberPhases()) + + default: + return fmt.Errorf("ocpp: unknown action %q for %s", c.Action, id) + } +} + +// ceiling is the highest per-phase current this command permits. +func (c command) ceiling() float64 { + if c.MaxAmpsPerPhase > 0 { + return c.MaxAmpsPerPhase + } + return defaultMaxAmps +} + +// amps converts the requested site power into a per-phase current limit. +// +// Below the IEC minimum the result is zero rather than the minimum: when the +// allocator has less than 6 A of headroom to give, rounding up would draw +// current the site fuse was not asked to carry. Refusing to charge is the safe +// direction of error. +func (c command) amps() float64 { + if c.PowerW <= 0 { + return 0 + } + + voltage := c.Voltage + if voltage <= 0 { + voltage = defaultVoltage + } + phases := c.SitePhases + if phases <= 0 { + phases = defaultPhases + } + if c.PhaseMode == "1p" { + phases = 1 + } + + amps := c.PowerW / (voltage * float64(phases)) + if ceiling := c.ceiling(); amps > ceiling { + amps = ceiling + } + if amps < minChargeAmps { + return 0 + } + return amps +} + +// numberPhases is what to declare in the schedule period, or nil to let the +// charger decide. Only a pinned single-phase command is worth stating. +func (c command) numberPhases() *int { + if c.PhaseMode == "1p" { + n := 1 + return &n + } + return nil +} + +// setLimit installs a charge-point-wide default profile capping the per-phase +// current, and blocks until the charger confirms it, the context ends, or the +// command times out. +func (s *Server) setLimit(ctx context.Context, id string, amps float64, numberPhases *int) error { + if amps < 0 { + amps = 0 + } + + period := types.NewChargingSchedulePeriod(scheduleStartS, amps) + // Declared only when the loadpoint pinned single-phase charging. Left + // unset otherwise so a charger that can switch phases keeps deciding. + period.NumberPhases = numberPhases + schedule := types.NewChargingSchedule(types.ChargingRateUnitAmperes, period) + profile := types.NewChargingProfile( + ftwProfileID, + ftwStackLevel, + types.ChargingProfilePurposeTxDefaultProfile, + types.ChargingProfileKindAbsolute, + schedule, + ) + + type result struct { + conf *smartcharging.SetChargingProfileConfirmation + err error + } + // Buffered: the library's callback must never block if we have already + // stopped waiting. + done := make(chan result, 1) + + err := s.cs.SetChargingProfile(id, func(conf *smartcharging.SetChargingProfileConfirmation, err error) { + done <- result{conf: conf, err: err} + }, allConnectors, profile) + if err != nil { + return fmt.Errorf("ocpp: send charging profile to %s: %w", id, err) + } + + timeout := time.NewTimer(commandTimeout) + defer timeout.Stop() + + select { + case r := <-done: + if r.err != nil { + return fmt.Errorf("ocpp: %s rejected charging profile: %w", id, r.err) + } + if r.conf == nil { + return fmt.Errorf("ocpp: %s returned no charging profile confirmation", id) + } + if r.conf.Status != smartcharging.ChargingProfileStatusAccepted { + return fmt.Errorf("ocpp: %s answered %s to charging profile", id, r.conf.Status) + } + // Only a real charging rate is worth remembering. Recording the zero + // 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) + } + slog.Info("ocpp: charging limit applied", "charger", id, "amps", amps) + return nil + + case <-ctx.Done(): + return fmt.Errorf("ocpp: charging profile for %s cancelled: %w", id, ctx.Err()) + + case <-timeout.C: + return fmt.Errorf("ocpp: %s did not confirm charging profile within %s", id, commandTimeout) + } +} + +// DefaultMode is what a charger is left in when FTW stops steering it, and +// mirrors the stance every EV driver already takes: hold the last limit. +// +// An EV charger has no autonomous self-consumption mode to fall back to, and +// dropping to zero would strand a driver with an uncharged car because the EMS +// lost contact. Holding the last granted current keeps the site within the +// envelope that was already judged safe. +func (s *Server) DefaultMode(_ context.Context, id string) error { + slog.Info("ocpp: leaving charger at its last granted limit", "charger", id) + return nil +} + +// IsOnline reports whether a charge point currently holds a WebSocket session. +// +// This is deliberately not the same as having a vehicle plugged in. A default +// charging profile is exactly the thing you set on an idle charger, so control +// gates on the session being live, not on the connector being occupied. +func (h *Handler) IsOnline(id string) bool { + if h == nil { + return false + } + h.mu.Lock() + defer h.mu.Unlock() + s, ok := h.chargers[id] + return ok && s.online +} + +// LastAmps returns the last limit granted to a charger, or fallback if none +// has been set yet. +func (h *Handler) LastAmps(id string, fallback float64) float64 { + if h == nil { + return fallback + } + h.mu.Lock() + defer h.mu.Unlock() + s, ok := h.chargers[id] + if !ok || s.lastAmps <= 0 { + return fallback + } + return s.lastAmps +} + +// SetLastAmps records an accepted limit so a later resume can restore it. +func (h *Handler) SetLastAmps(id string, amps float64) { + if h == nil { + return + } + h.mu.Lock() + defer h.mu.Unlock() + s, ok := h.chargers[id] + if !ok { + return + } + s.lastAmps = amps +} + +// Names returns the charge points seen since start, connected or not. main.go +// uses it to decide whether a loadpoint driver name belongs to OCPP. +func (h *Handler) Names() []string { + if h == nil { + return nil + } + h.mu.Lock() + defer h.mu.Unlock() + out := make([]string, 0, len(h.chargers)) + for id := range h.chargers { + out = append(out, id) + } + return out +} diff --git a/go/internal/ocpp/control_test.go b/go/internal/ocpp/control_test.go new file mode 100644 index 00000000..6cc65834 --- /dev/null +++ b/go/internal/ocpp/control_test.go @@ -0,0 +1,403 @@ +package ocpp + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sync" + "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/smartcharging" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// fakeCharger is a charge point that records the charging profiles it is sent +// and answers with a configurable status. +type fakeCharger struct { + mu sync.Mutex + status smartcharging.ChargingProfileStatus + profiles []*smartcharging.SetChargingProfileRequest +} + +func newFakeCharger() *fakeCharger { + return &fakeCharger{status: smartcharging.ChargingProfileStatusAccepted} +} + +func (f *fakeCharger) OnSetChargingProfile(req *smartcharging.SetChargingProfileRequest) (*smartcharging.SetChargingProfileConfirmation, error) { + f.mu.Lock() + f.profiles = append(f.profiles, req) + status := f.status + f.mu.Unlock() + return smartcharging.NewSetChargingProfileConfirmation(status), nil +} + +func (f *fakeCharger) OnClearChargingProfile(*smartcharging.ClearChargingProfileRequest) (*smartcharging.ClearChargingProfileConfirmation, error) { + return nil, errors.New("not used") +} + +func (f *fakeCharger) OnGetCompositeSchedule(*smartcharging.GetCompositeScheduleRequest) (*smartcharging.GetCompositeScheduleConfirmation, error) { + return nil, errors.New("not used") +} + +func (f *fakeCharger) setStatus(s smartcharging.ChargingProfileStatus) { + f.mu.Lock() + defer f.mu.Unlock() + f.status = s +} + +// lastLimit returns the amp limit from the most recent profile received. +func (f *fakeCharger) lastLimit(t *testing.T) float64 { + t.Helper() + f.mu.Lock() + defer f.mu.Unlock() + if len(f.profiles) == 0 { + t.Fatal("charger received no charging profile") + } + p := f.profiles[len(f.profiles)-1] + if p.ChargingProfile == nil || p.ChargingProfile.ChargingSchedule == nil { + t.Fatal("charging profile had no schedule") + } + periods := p.ChargingProfile.ChargingSchedule.ChargingSchedulePeriod + if len(periods) == 0 { + t.Fatal("charging schedule had no periods") + } + return periods[0].Limit +} + +// lastNumberPhases returns the declared phase count from the most recent +// profile, or nil when the charger was left to decide. +func (f *fakeCharger) lastNumberPhases(t *testing.T) *int { + t.Helper() + f.mu.Lock() + defer f.mu.Unlock() + if len(f.profiles) == 0 { + t.Fatal("charger received no charging profile") + } + p := f.profiles[len(f.profiles)-1] + periods := p.ChargingProfile.ChargingSchedule.ChargingSchedulePeriod + if len(periods) == 0 { + t.Fatal("charging schedule had no periods") + } + return periods[0].NumberPhases +} + +func (f *fakeCharger) count() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.profiles) +} + +// connectCharger brings up a charge point against the server and waits until +// the server has registered the session. +// +// The returned stop is idempotent: ocpp-go panics on a second Stop, and the +// cleanup below would otherwise fire after a test that disconnects on purpose. +func connectCharger(t *testing.T, srv *Server, port int, id string) (ocpp16.ChargePoint, *fakeCharger, func()) { + t.Helper() + fake := newFakeCharger() + cp := ocpp16.NewChargePoint(id, nil, nil) + cp.SetSmartChargingHandler(fake) + + if err := cp.Start(fmt.Sprintf("ws://127.0.0.1:%d", port)); err != nil { + t.Fatalf("charge point connect: %v", err) + } + var once sync.Once + stop := func() { once.Do(cp.Stop) } + t.Cleanup(stop) + + if _, err := cp.BootNotification("Dawn", "Charge Amps"); err != nil { + t.Fatalf("boot: %v", err) + } + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if srv.Handler().IsOnline(id) { + return cp, fake, stop + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("server never registered charger %s as online", id) + return nil, nil, nil +} + +func mustPayload(t *testing.T, m map[string]any) []byte { + t.Helper() + b, err := json.Marshal(m) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + return b +} + +// The whole point of Phase 2: a pause has to arrive as a 0 A limit, not as a +// RemoteStopTransaction, because Charge Amps units resume on their own after a +// remote stop. +func TestPauseSendsZeroAmpLimit(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore()) + defer srv.Stop() + _, fake, _ := connectCharger(t, srv, port, "garage-left") + + err := srv.Command(context.Background(), "garage-left", mustPayload(t, map[string]any{ + "action": "ev_pause", + })) + if err != nil { + t.Fatalf("pause: %v", err) + } + if got := fake.lastLimit(t); got != 0 { + t.Errorf("pause limit: got %v A, want 0 A", got) + } +} + +func TestSetCurrentConvertsPowerToAmps(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore()) + defer srv.Stop() + _, fake, _ := connectCharger(t, srv, port, "garage-left") + + // 11040 W over 3 phases at 230 V = 16 A per phase. + err := srv.Command(context.Background(), "garage-left", mustPayload(t, map[string]any{ + "action": "ev_set_current", + "power_w": 11040.0, + "voltage": 230.0, + "site_phases": 3, + })) + if err != nil { + t.Fatalf("set current: %v", err) + } + if got := fake.lastLimit(t); got != 16 { + t.Errorf("limit: got %v A, want 16 A", got) + } +} + +// Below the IEC 61851 minimum the charger must be told zero, never a rounded-up +// 6 A the site fuse was not asked to carry. +func TestBelowMinimumBecomesZero(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore()) + defer srv.Stop() + _, fake, _ := connectCharger(t, srv, port, "garage-left") + + // 2000 W over 3 phases at 230 V ≈ 2.9 A per phase — under the minimum. + err := srv.Command(context.Background(), "garage-left", mustPayload(t, map[string]any{ + "action": "ev_set_current", + "power_w": 2000.0, + "voltage": 230.0, + "site_phases": 3, + })) + if err != nil { + t.Fatalf("set current: %v", err) + } + if got := fake.lastLimit(t); got != 0 { + t.Errorf("sub-minimum limit: got %v A, want 0 A", got) + } +} + +func TestMaxAmpsPerPhaseIsRespected(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore()) + defer srv.Stop() + _, fake, _ := connectCharger(t, srv, port, "garage-left") + + // 22 kW would be 32 A per phase, but the loadpoint allows only 16 A. + err := srv.Command(context.Background(), "garage-left", mustPayload(t, map[string]any{ + "action": "ev_set_current", + "power_w": 22080.0, + "voltage": 230.0, + "site_phases": 3, + "max_amps_per_phase": 16.0, + })) + if err != nil { + t.Fatalf("set current: %v", err) + } + if got := fake.lastLimit(t); got != 16 { + t.Errorf("clamped limit: got %v A, want 16 A", got) + } +} + +// A pause must not lose the previous rate: resuming without one restores it. +func TestResumeRestoresLastLimit(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore()) + defer srv.Stop() + _, fake, _ := connectCharger(t, srv, port, "garage-left") + + ctx := context.Background() + if err := srv.Command(ctx, "garage-left", mustPayload(t, map[string]any{ + "action": "ev_set_current", + "power_w": 6900.0, // 10 A over 3 phases at 230 V + "voltage": 230.0, + "site_phases": 3, + })); err != nil { + t.Fatalf("set current: %v", err) + } + if err := srv.Command(ctx, "garage-left", mustPayload(t, map[string]any{ + "action": "ev_pause", + })); err != nil { + t.Fatalf("pause: %v", err) + } + if got := fake.lastLimit(t); got != 0 { + t.Fatalf("pause limit: got %v A, want 0 A", got) + } + + if err := srv.Command(ctx, "garage-left", mustPayload(t, map[string]any{ + "action": "ev_resume", + })); err != nil { + t.Fatalf("resume: %v", err) + } + if got := fake.lastLimit(t); got != 10 { + t.Errorf("resumed limit: got %v A, want the previous 10 A", got) + } +} + +func TestRejectedProfileIsAnError(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore()) + defer srv.Stop() + _, fake, _ := connectCharger(t, srv, port, "garage-left") + fake.setStatus(smartcharging.ChargingProfileStatusRejected) + + err := srv.Command(context.Background(), "garage-left", mustPayload(t, map[string]any{ + "action": "ev_pause", + })) + if err == nil { + t.Fatal("expected an error when the charger rejects the profile") + } +} + +func TestUnknownChargerIsNotConnected(t *testing.T) { + _, srv := startServer(t, telemetry.NewStore()) + defer srv.Stop() + + err := srv.Command(context.Background(), "nobody-home", mustPayload(t, map[string]any{ + "action": "ev_pause", + })) + if !errors.Is(err, ErrNotConnected) { + t.Fatalf("expected ErrNotConnected, got %v", err) + } +} + +// A charger that drops off must not silently swallow commands — control needs +// the error so it can fall back. +func TestDisconnectedChargerRejectsCommands(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore()) + defer srv.Stop() + _, _, stop := connectCharger(t, srv, port, "garage-left") + + stop() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) && srv.Handler().IsOnline("garage-left") { + time.Sleep(20 * time.Millisecond) + } + + err := srv.Command(context.Background(), "garage-left", mustPayload(t, map[string]any{ + "action": "ev_pause", + })) + if !errors.Is(err, ErrNotConnected) { + t.Fatalf("expected ErrNotConnected after disconnect, got %v", err) + } +} + +// init/deinit are Lua lifecycle hooks. They must be accepted and do nothing +// rather than reaching the charger or erroring. +func TestLifecycleActionsAreNoOps(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore()) + defer srv.Stop() + _, fake, _ := connectCharger(t, srv, port, "garage-left") + + for _, action := range []string{"init", "deinit"} { + if err := srv.Command(context.Background(), "garage-left", mustPayload(t, map[string]any{ + "action": action, + })); err != nil { + t.Errorf("%s: unexpected error %v", action, err) + } + } + if n := fake.count(); n != 0 { + t.Errorf("lifecycle actions sent %d profiles, want 0", n) + } +} + +func TestUnknownActionIsAnError(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore()) + defer srv.Stop() + _, fake, _ := connectCharger(t, srv, port, "garage-left") + + err := srv.Command(context.Background(), "garage-left", mustPayload(t, map[string]any{ + "action": "self_destruct", + })) + if err == nil { + t.Fatal("expected an error for an unknown action") + } + if n := fake.count(); n != 0 { + t.Errorf("unknown action sent %d profiles, want 0", n) + } +} + +// Single-phase mode changes the conversion: the same watts land on one phase. +func TestSinglePhaseModeUsesOnePhase(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore()) + defer srv.Stop() + _, fake, _ := connectCharger(t, srv, port, "garage-left") + + // 3680 W on one phase at 230 V = 16 A. + err := srv.Command(context.Background(), "garage-left", mustPayload(t, map[string]any{ + "action": "ev_set_current", + "power_w": 3680.0, + "voltage": 230.0, + "site_phases": 3, + "phase_mode": "1p", + })) + if err != nil { + t.Fatalf("set current: %v", err) + } + if got := fake.lastLimit(t); got != 16 { + t.Errorf("1p limit: got %v A, want 16 A", got) + } + if got := fake.lastNumberPhases(t); got == nil || *got != 1 { + t.Errorf("1p numberPhases: got %v, want 1", got) + } +} + +// With no phase mode pinned, the phase count is left unset so a charger that +// can switch phases keeps deciding for itself. +func TestThreePhaseLeavesPhaseCountUnset(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore()) + defer srv.Stop() + _, fake, _ := connectCharger(t, srv, port, "garage-left") + + err := srv.Command(context.Background(), "garage-left", mustPayload(t, map[string]any{ + "action": "ev_set_current", + "power_w": 11040.0, + "voltage": 230.0, + "site_phases": 3, + })) + if err != nil { + t.Fatalf("set current: %v", err) + } + if got := fake.lastNumberPhases(t); got != nil { + t.Errorf("numberPhases: got %v, want unset", *got) + } +} + +// The status handler marks a charger connected; a suspended charger is still +// commandable, which is what lets a paused session be resumed. +func TestSuspendedChargerStillAcceptsCommands(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore()) + defer srv.Stop() + cp, fake, _ := connectCharger(t, srv, port, "garage-left") + + if _, err := cp.StatusNotification(1, core.NoError, core.ChargePointStatusSuspendedEV); err != nil { + t.Fatalf("status: %v", err) + } + + if err := srv.Command(context.Background(), "garage-left", mustPayload(t, map[string]any{ + "action": "ev_set_current", + "power_w": 6900.0, + "voltage": 230.0, + "site_phases": 3, + })); err != nil { + t.Fatalf("set current on suspended charger: %v", err) + } + if got := fake.lastLimit(t); got != 10 { + t.Errorf("limit: got %v A, want 10 A", got) + } +} diff --git a/go/internal/ocpp/handlers.go b/go/internal/ocpp/handlers.go index 866266a7..d46b233e 100644 --- a/go/internal/ocpp/handlers.go +++ b/go/internal/ocpp/handlers.go @@ -29,12 +29,20 @@ type Handler struct { // chargerState is what we accumulate from successive OCPP messages for one // charge point. Survives the OCPP library's stateless handler invocations. type chargerState struct { + // online is whether the charger currently holds a WebSocket session. + // Distinct from connected below, which tracks whether a connector has a + // vehicle on it. A charger can be online with nothing plugged in, and + // still needs to accept a default charging profile in that state. + online bool connected bool charging bool transactionID int sessionStartMeterWh float64 sessionMeterWh float64 lastPowerW float64 + // lastAmps is the most recent per-phase limit this charger accepted. + // A resume with no rate of its own restores it. + lastAmps float64 } // NewHandler returns a Handler ready to register with a CentralSystem. @@ -91,6 +99,10 @@ type ChargerView struct { // connection callbacks, not part of CoreHandler. func (h *Handler) OnConnect(id string) { slog.Info("OCPP charger connected", "charger", id) + s := h.state(id) + h.mu.Lock() + s.online = true + h.mu.Unlock() h.tel.RecordDriverSuccess(id) } @@ -98,6 +110,7 @@ func (h *Handler) OnDisconnect(id string) { slog.Info("OCPP charger disconnected", "charger", id) s := h.state(id) h.mu.Lock() + s.online = false s.connected = false s.charging = false s.lastPowerW = 0 From 219267b5be14bc215c58b67aaae3afedc456cb06 Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Fri, 31 Jul 2026 14:35:32 +0200 Subject: [PATCH 5/9] fix(ocpp): stop serving the OCPP password over /api/config MaskSecrets covered every other credential section but not the new ocpp one, so GET /api/config returned the password in plaintext to any UI client. That is the one credential that must not leak: the OCPP listener is reachable on every interface and basic auth is the only thing in front of it. Masking alone would have traded a leak for a wipe, because the settings tab posts the config back and the masked password returns empty. PreserveMaskedSecrets now keeps the stored value when the incoming one is blank, while a genuinely new password still wins. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com> --- go/internal/config/config.go | 13 +++++++++ go/internal/config/ocpp_test.go | 48 +++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/go/internal/config/config.go b/go/internal/config/config.go index a94ff15b..c5f8fb7f 100644 --- a/go/internal/config/config.go +++ b/go/internal/config/config.go @@ -999,6 +999,13 @@ func (c Config) MaskSecrets() Config { cp.Password = "" out.HomeAssistant = &cp } + // The OCPP password is the only thing standing in front of a listener that + // is reachable on every interface, so it must never leave over the API. + if out.OCPP != nil { + cp := *out.OCPP + cp.Password = "" + out.OCPP = &cp + } if out.Price != nil { cp := *out.Price cp.APIKey = "" @@ -1071,6 +1078,12 @@ func (incoming *Config) PreserveMaskedSecrets(existing *Config) { if incoming.CalDAV != nil && existing.CalDAV != nil && incoming.CalDAV.Password == "" { incoming.CalDAV.Password = existing.CalDAV.Password } + // Masked out on the way to the UI, so an unchanged password comes back + // empty. Without this a save from the settings tab would blank it, and an + // enabled server would then fail validation on the next reload. + if incoming.OCPP != nil && existing.OCPP != nil && incoming.OCPP.Password == "" { + incoming.OCPP.Password = existing.OCPP.Password + } if incoming.HomeAssistant != nil && existing.HomeAssistant != nil && incoming.HomeAssistant.Password == "" { incoming.HomeAssistant.Password = existing.HomeAssistant.Password } diff --git a/go/internal/config/ocpp_test.go b/go/internal/config/ocpp_test.go index bd32ac8a..abe403e3 100644 --- a/go/internal/config/ocpp_test.go +++ b/go/internal/config/ocpp_test.go @@ -64,6 +64,54 @@ func TestOCPPValidate(t *testing.T) { } } +// The OCPP password is the only gate in front of a listener reachable on every +// interface. It must never be served over /api/config, and a settings save that +// returns the masked (empty) value must not wipe it. +func TestOCPPPasswordIsMaskedAndPreserved(t *testing.T) { + stored := &Config{OCPP: &OCPP{ + Enabled: true, + Username: "ftw", + Password: "the-real-secret", + }} + + masked := stored.MaskSecrets() + if masked.OCPP == nil { + t.Fatal("masked config lost the ocpp section") + } + if masked.OCPP.Password != "" { + t.Errorf("password leaked through MaskSecrets: %q", masked.OCPP.Password) + } + if masked.OCPP.Username != "ftw" { + t.Errorf("username should survive masking, got %q", masked.OCPP.Username) + } + // Masking must not mutate the original. + if stored.OCPP.Password != "the-real-secret" { + t.Errorf("MaskSecrets mutated the source config: %q", stored.OCPP.Password) + } + + // The UI round-trips the masked config back on save. + incoming := &Config{OCPP: &OCPP{ + Enabled: true, + Username: "ftw", + Password: "", + }} + incoming.PreserveMaskedSecrets(stored) + if incoming.OCPP.Password != "the-real-secret" { + t.Errorf("password not preserved on save, got %q", incoming.OCPP.Password) + } + + // A genuinely new password must still win. + changed := &Config{OCPP: &OCPP{ + Enabled: true, + Username: "ftw", + Password: "a-new-secret", + }} + changed.PreserveMaskedSecrets(stored) + if changed.OCPP.Password != "a-new-secret" { + t.Errorf("new password was overwritten, got %q", changed.OCPP.Password) + } +} + // A disabled or absent OCPP section must not stop the rest of the config from // validating, and an enabled one without credentials must take the whole // config down with it rather than being skipped. From 855487f76d53e246dfe1319a074c5ab8842f4d87 Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Fri, 31 Jul 2026 14:47:18 +0200 Subject: [PATCH 6/9] feat(ocpp): serve OCPP 2.0.1 alongside 1.6J MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Newer chargers now connect without a driver too. Each version listens on its own port, because a charger picks its dialect during the WebSocket handshake before any message is sent, and ocpp-go's ws.Server keeps a single message handler per listener — one port cannot serve both. Set ocpp.port_v201 to enable 2.0.1; leaving it unset keeps 1.6J only. Only the encoding differs. Both dialects share one charger map, one telemetry path and one control path, so a 2.0.1 charger is metered, throttled and paused exactly like a 1.6 one and dispatch cannot tell them apart. Which listener a charger reached is recorded on connect, and control encodes the profile to match. 2.0.1 restructures more than the names suggest: Start/StopTransaction collapse into one TransactionEvent, transaction ids become strings, connector status loses its charging meaning, and meter samples arrive inside transaction events as well as alone. The new handler normalises all of it back to the same state. OCPP 2.1 is deliberately absent. No production-grade Go implementation exists — ocpp-go covers 1.6 and 2.0.1 only, and the Go projects claiming 2.1 are early-stage validators and emulators rather than servers. Adding it later is one handler and one listener; the version-neutral core is unaffected. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com> --- .changeset/ocpp-v201-support.md | 26 + README.md | 4 +- config.example.yaml | 481 +- docs/ocpp.md | 40 +- go/cmd/ftw/main.go | 6966 +++++++++++++------------ go/internal/config/config.go | 3684 ++++++------- go/internal/ocpp/config.go | 1 + go/internal/ocpp/control.go | 116 +- go/internal/ocpp/control_v201_test.go | 269 + go/internal/ocpp/handlers.go | 6 + go/internal/ocpp/handlers_v201.go | 253 + go/internal/ocpp/server.go | 322 +- go/internal/ocpp/version.go | 67 + 13 files changed, 6516 insertions(+), 5719 deletions(-) create mode 100644 .changeset/ocpp-v201-support.md create mode 100644 go/internal/ocpp/control_v201_test.go create mode 100644 go/internal/ocpp/handlers_v201.go create mode 100644 go/internal/ocpp/version.go diff --git a/.changeset/ocpp-v201-support.md b/.changeset/ocpp-v201-support.md new file mode 100644 index 00000000..59740198 --- /dev/null +++ b/.changeset/ocpp-v201-support.md @@ -0,0 +1,26 @@ +--- +"ftw": minor +--- + +Serve OCPP 2.0.1 alongside 1.6J, so newer chargers connect without a driver too. + +Each version listens on its own port. A charger picks its dialect during the +WebSocket handshake, before any message is sent, and the underlying library +keeps one message handler per listener — so a single port cannot serve both. +Set `ocpp.port_v201` to enable 2.0.1; leaving it unset keeps 1.6J only. + +Only the message encoding differs. Both dialects share one charger map, one +telemetry path and one control path, so a 2.0.1 charger is metered, throttled +and paused exactly like a 1.6 one, and dispatch cannot tell them apart. + +2.0.1 restructures the messages more than the names suggest: StartTransaction +and StopTransaction collapse into a single TransactionEvent, transaction ids +become strings, connector status loses its charging meaning, and meter samples +arrive inside transaction events as well as on their own. The new handler +normalises all of that back to the same charger state. + +OCPP 2.1 is not supported. No production-grade Go implementation of it exists: +the library FTW uses covers 1.6 and 2.0.1 and has no 2.1 support, and the Go +projects that do claim 2.1 are early-stage validators and emulators rather than +servers. Adding it later is one more handler and one more listener; the +version-neutral core does not change. diff --git a/README.md b/README.md index ca503e80..4683e72d 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ rule. See [docs/architecture.md](docs/architecture.md). - Home Assistant MQTT discovery; - CalDAV planning intents and published schedules; - hot-reloadable, independently released Lua drivers; -- a built-in OCPP 1.6J server, so OCPP chargers connect with no driver at all. +- a built-in OCPP 1.6J + 2.0.1 server, so OCPP chargers connect with no driver. The current device catalog is generated from the `DRIVER` metadata in [`drivers/*.lua`](drivers/); that source is authoritative. @@ -115,7 +115,7 @@ artifacts use the same `beta` → `stable` progression as core and can be installed or rolled back independently. EV chargers that speak OCPP are the exception: they need no driver. FTW runs an -OCPP 1.6J Central System, so the charger connects to FTW and registers itself. +OCPP Central System (1.6J and 2.0.1), so the charger connects and registers itself. See [docs/ocpp.md](docs/ocpp.md). ## Releases diff --git a/config.example.yaml b/config.example.yaml index bc96fe81..5f46ec19 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -1,238 +1,243 @@ -# FTW config example -# Copy to config.yaml and edit for your site. - -site: - name: "Home" - control_interval_s: 2 # 2 s matches Ferroamp ehub ~1 Hz; drop to 1 for snappier response - grid_target_w: 0 # 0 = self-consumption - grid_tolerance_w: 42 # deadband (The Answer) - watchdog_timeout_s: 60 - smoothing_alpha: 0.3 - slew_rate_w: 3000 # W per cycle. Both Ferroamp and Sungrow ramp internally — this is a soft ceiling, not the primary protection - # slew_enabled: false # disable the external slew limiter entirely and trust the inverter's internal ramp control (experimental) - min_dispatch_interval_s: 2 # debounce; keep == control_interval_s - -fuse: - max_amps: 16 - phases: 3 - voltage: 230 - -# ---------------------------------------------------------------------- -# Drivers — Lua scripts with per-driver capabilities. -# Capabilities are the ONLY way the sandbox talks to the outside world. -# A driver with only mqtt capability can't touch modbus, and vice versa. -# ---------------------------------------------------------------------- -drivers: - - name: ferroamp - lua: drivers/ferroamp.lua - is_site_meter: true # exactly one driver must have this - battery_capacity_wh: 15200 - capabilities: - mqtt: - host: 192.168.1.153 - port: 1883 - username: extapi - password: ferroampExtApi - - - name: sungrow - lua: drivers/sungrow.lua - battery_capacity_wh: 9600 - capabilities: - modbus: - host: 192.168.1.10 - port: 502 - unit_id: 1 - - # Pixii PowerShaper. When troubleshooting mode is enabled in Settings, - # Pixii also exposes calibration/control status and setpoint readback metrics. - # - name: pixii - # lua: drivers/pixii.lua - # is_site_meter: true - # battery_capacity_wh: 20000 - # capabilities: - # modbus: - # host: 192.168.1.50 - # port: 502 - # unit_id: 1 - - # V2X chargers are modeled separately from stationary batteries. - # They emit signed v2x_charger.w telemetry: - # +W = vehicle charging, -W = vehicle discharging into the site/grid. - # Manual test setpoints can be sent with POST /api/v2x/command. - # - # - name: ferroamp_dc2 - # lua: drivers/ferroamp_dc2_v2x.lua - # capabilities: - # mqtt: - # host: 192.168.1.70 - # port: 1883 - # username: dc2 - # password: dc2mqtt! - # - # - name: ambibox - # lua: drivers/ambibox_v2x.lua - # capabilities: - # mqtt: - # host: sid-os.local - # port: 1883 - - # Sourceful Zap — official local-API integration. Zap can be the site meter - # and can aggregate every PV, battery and V2X DER attached to the gateway. - # The driver is telemetry-only: battery_telemetry_only admits battery data - # without making Zap a dispatch target. Zap firmware has semantic local - # command routes, but its REST path currently lacks expiring leases and a - # hardware-execution acknowledgement. FTW control therefore still uses the - # native inverter/charger driver; see docs/sourceful-zap.md. - # - # - name: sourceful-zap - # lua: drivers/zap.lua - # is_site_meter: true - # battery_telemetry_only: true - # capabilities: - # http: - # allowed_hosts: ["zap.local"] # use the LAN IP if mDNS is unavailable - # config: - # host: zap.local - # # meter_serial: p1m-... # optional; P1 is auto-selected - # # disable_pv: true # if a native driver already emits PV - # # disable_battery: true # if a native driver emits the battery - # # disable_v2x: true # if a native V2X driver is configured - - # NIBE S-series heat pump — read-only telemetry over the on-prem Local - # REST API (HTTPS + Basic auth). The pump presents a SELF-SIGNED cert, so - # pin it: tls_pin_sha256 is the cert SHA-256 fingerprint (the - # "fingeravtryck" shown in the myUplink app, or from `openssl s_client - # -connect :8443 | openssl x509 -fingerprint -sha256`). The host then - # trusts exactly that one cert — never a blanket insecure-skip-verify. - # Leave the pump in read-only mode (aidMode off); this driver never writes. - # - name: nibe - # lua: drivers/nibe_local.lua - # config: - # host: 192.168.1.180 - # port: 8443 - # username: - # password: - # capabilities: - # http: - # allowed_hosts: ["192.168.1.180:8443"] - # tls_pin_sha256: "<64-hex-char certificate fingerprint>" - -api: - port: 8080 - -# Home Assistant MQTT bridge (optional) -homeassistant: - enabled: false - broker: 192.168.1.1 - port: 1883 - username: homeems - password: homeems - publish_interval_s: 5 - -# Calendar-based planner constraints (#498). FTW hosts its OWN in-process, -# pure-Go CalDAV server (emersion/go-webdav, MIT — no sidecar, works in a -# single container incl. a Home Assistant add-on; objects persist in state.db) -# and turns the events you add into planner intents: "Away"/"Vacation" → away -# load profile; "Charge car 80%" → EV target SoC by the event time. It also -# writes EVSE usage-history and forward-looking plan calendars you subscribe -# to. Recurring events are expanded server-side. Opt-in + fail-soft; the -# password is stored in state.db. See docs/caldav-integration.md. -caldav: - enabled: false - # listen: ":5232" # bind address for the in-process CalDAV server - url: http://localhost:5232 - username: ftw - # manage_credentials (default true): FTW generates the password and the - # in-process server authenticates against it, then shows it (with a QR) in - # Settings → Calendar. Set false to set `password` here by hand. - manage_credentials: true - password: "" - calendar_path: /ftw/energy/ - poll_interval_s: 300 - ev_default_target_soc_pct: 80 - # ev_loadpoint_id: "" # defaults to the first configured loadpoint - # away_keywords: [away, vacation, holiday] - # ev_keywords: [ev, car, charge] - evse_history: true # write a calendar event per EV charge session - history_path: /ftw/history/ - publish_plan: true # publish upcoming charge/discharge windows (read-only) - plan_path: /ftw/plan/ - # plan_publish_interval_s: 900 # how often the plan calendar is reconciled - -# Built-in OCPP 1.6J Central System. EV chargers that speak OCPP connect to FTW -# directly — there is no driver to write and nothing to add under `drivers:`. -# A charger appears as a device on its first BootNotification, keyed by the last -# segment of the URL it dialled: ws://:8887/garage-left -# -# Credentials are required when enabled, and FTW refuses to start without them: -# the OCPP library builds its listen address from the port alone, so the socket -# is reachable on every interface and basic auth is the only gate. Keep the port -# closed at your router. See docs/ocpp.md. -ocpp: - enabled: false - port: 8887 - path: / - username: ftw - password: "" # required when enabled; use a long random string - heartbeat_interval_s: 60 - -# Persistent state (SQLite) -state: - path: state.db - # backup_dir: backups # use an absolute mounted path for off-device backups - -# Independently distributed Lua drivers. The signed stable catalog is refreshed -# by default, but refresh never downloads, activates, or restarts a driver. -# Add `device_repository: { enabled: false }` to opt out. The expanded form is: -# device_repository: -# enabled: true -# refresh_interval_h: 24 -# repositories: -# - id: ftw-official -# name: FTW official drivers -# manifest_url: https://github.com/srcfl/ftw/releases/download/drivers-stable/manifest.json -# enabled: true -# trusted_keys: -# ftw-drivers-2026-01: MX+j27UBkyM099hTyJlmMLK9qlTTDUJsaK/vH12fFKc= - -# Mathematical MPC optimizer. Official Compose runs it as the independently -# updatable ftw-optimizer service; native installs can point optimizer_command -# at their venv's Python executable. -# planner: -# enabled: true -# engine: python -# mode: passive_arbitrage -# horizon_hours: 48 -# interval_min: 15 -# soc_min_pct: 10 -# soc_max_pct: 95 -# optimizer_solver: HIGHS -# optimizer_formulation: auto -# optimizer_transport: auto # auto | unix | process -# optimizer_socket: /run/ftw-optimizer/optimizer.sock -# optimizer_timeout_s: 30 -# optimizer_idle_timeout_s: 120 # release Python/CVXPY memory between planning bursts -# optimizer_mip_rel_gap: 0.005 -# optimizer_cvar_weight: 0.15 -# optimizer_cvar_alpha: 0.90 -# optimizer_recourse_shadow: false # diagnostic only; never controls dispatch -# optimizer_recourse_non_anticipative_slots: 1 -# optimizer_challenger_policy: multistage # recourse | multistage -# optimizer_multistage: -# scenario_limit: 12 -# branch_interval_slots: 4 -# branch_horizon_slots: 48 -# max_branching: 2 -# near_horizon_slots: 16 -# mid_horizon_slots: 96 -# mid_block_slots: 2 -# far_block_slots: 4 -# service_cvar_weight: 1.0 -# service_cvar_alpha: 0.95 -# economic_cvar_weight: 0 -# economic_cvar_alpha: 0.90 -# decomposition_threshold: 20 -# decomposition_method: auto # auto | extensive | progressive_hedging -# ph_max_iterations: 8 -# ph_rho: 50 -# ph_tolerance_w: 5 +# FTW config example +# Copy to config.yaml and edit for your site. + +site: + name: "Home" + control_interval_s: 2 # 2 s matches Ferroamp ehub ~1 Hz; drop to 1 for snappier response + grid_target_w: 0 # 0 = self-consumption + grid_tolerance_w: 42 # deadband (The Answer) + watchdog_timeout_s: 60 + smoothing_alpha: 0.3 + slew_rate_w: 3000 # W per cycle. Both Ferroamp and Sungrow ramp internally — this is a soft ceiling, not the primary protection + # slew_enabled: false # disable the external slew limiter entirely and trust the inverter's internal ramp control (experimental) + min_dispatch_interval_s: 2 # debounce; keep == control_interval_s + +fuse: + max_amps: 16 + phases: 3 + voltage: 230 + +# ---------------------------------------------------------------------- +# Drivers — Lua scripts with per-driver capabilities. +# Capabilities are the ONLY way the sandbox talks to the outside world. +# A driver with only mqtt capability can't touch modbus, and vice versa. +# ---------------------------------------------------------------------- +drivers: + - name: ferroamp + lua: drivers/ferroamp.lua + is_site_meter: true # exactly one driver must have this + battery_capacity_wh: 15200 + capabilities: + mqtt: + host: 192.168.1.153 + port: 1883 + username: extapi + password: ferroampExtApi + + - name: sungrow + lua: drivers/sungrow.lua + battery_capacity_wh: 9600 + capabilities: + modbus: + host: 192.168.1.10 + port: 502 + unit_id: 1 + + # Pixii PowerShaper. When troubleshooting mode is enabled in Settings, + # Pixii also exposes calibration/control status and setpoint readback metrics. + # - name: pixii + # lua: drivers/pixii.lua + # is_site_meter: true + # battery_capacity_wh: 20000 + # capabilities: + # modbus: + # host: 192.168.1.50 + # port: 502 + # unit_id: 1 + + # V2X chargers are modeled separately from stationary batteries. + # They emit signed v2x_charger.w telemetry: + # +W = vehicle charging, -W = vehicle discharging into the site/grid. + # Manual test setpoints can be sent with POST /api/v2x/command. + # + # - name: ferroamp_dc2 + # lua: drivers/ferroamp_dc2_v2x.lua + # capabilities: + # mqtt: + # host: 192.168.1.70 + # port: 1883 + # username: dc2 + # password: dc2mqtt! + # + # - name: ambibox + # lua: drivers/ambibox_v2x.lua + # capabilities: + # mqtt: + # host: sid-os.local + # port: 1883 + + # Sourceful Zap — official local-API integration. Zap can be the site meter + # and can aggregate every PV, battery and V2X DER attached to the gateway. + # The driver is telemetry-only: battery_telemetry_only admits battery data + # without making Zap a dispatch target. Zap firmware has semantic local + # command routes, but its REST path currently lacks expiring leases and a + # hardware-execution acknowledgement. FTW control therefore still uses the + # native inverter/charger driver; see docs/sourceful-zap.md. + # + # - name: sourceful-zap + # lua: drivers/zap.lua + # is_site_meter: true + # battery_telemetry_only: true + # capabilities: + # http: + # allowed_hosts: ["zap.local"] # use the LAN IP if mDNS is unavailable + # config: + # host: zap.local + # # meter_serial: p1m-... # optional; P1 is auto-selected + # # disable_pv: true # if a native driver already emits PV + # # disable_battery: true # if a native driver emits the battery + # # disable_v2x: true # if a native V2X driver is configured + + # NIBE S-series heat pump — read-only telemetry over the on-prem Local + # REST API (HTTPS + Basic auth). The pump presents a SELF-SIGNED cert, so + # pin it: tls_pin_sha256 is the cert SHA-256 fingerprint (the + # "fingeravtryck" shown in the myUplink app, or from `openssl s_client + # -connect :8443 | openssl x509 -fingerprint -sha256`). The host then + # trusts exactly that one cert — never a blanket insecure-skip-verify. + # Leave the pump in read-only mode (aidMode off); this driver never writes. + # - name: nibe + # lua: drivers/nibe_local.lua + # config: + # host: 192.168.1.180 + # port: 8443 + # username: + # password: + # capabilities: + # http: + # allowed_hosts: ["192.168.1.180:8443"] + # tls_pin_sha256: "<64-hex-char certificate fingerprint>" + +api: + port: 8080 + +# Home Assistant MQTT bridge (optional) +homeassistant: + enabled: false + broker: 192.168.1.1 + port: 1883 + username: homeems + password: homeems + publish_interval_s: 5 + +# Calendar-based planner constraints (#498). FTW hosts its OWN in-process, +# pure-Go CalDAV server (emersion/go-webdav, MIT — no sidecar, works in a +# single container incl. a Home Assistant add-on; objects persist in state.db) +# and turns the events you add into planner intents: "Away"/"Vacation" → away +# load profile; "Charge car 80%" → EV target SoC by the event time. It also +# writes EVSE usage-history and forward-looking plan calendars you subscribe +# to. Recurring events are expanded server-side. Opt-in + fail-soft; the +# password is stored in state.db. See docs/caldav-integration.md. +caldav: + enabled: false + # listen: ":5232" # bind address for the in-process CalDAV server + url: http://localhost:5232 + username: ftw + # manage_credentials (default true): FTW generates the password and the + # in-process server authenticates against it, then shows it (with a QR) in + # Settings → Calendar. Set false to set `password` here by hand. + manage_credentials: true + password: "" + calendar_path: /ftw/energy/ + poll_interval_s: 300 + ev_default_target_soc_pct: 80 + # ev_loadpoint_id: "" # defaults to the first configured loadpoint + # away_keywords: [away, vacation, holiday] + # ev_keywords: [ev, car, charge] + evse_history: true # write a calendar event per EV charge session + history_path: /ftw/history/ + publish_plan: true # publish upcoming charge/discharge windows (read-only) + plan_path: /ftw/plan/ + # plan_publish_interval_s: 900 # how often the plan calendar is reconciled + +# Built-in OCPP Central System, speaking 1.6J and 2.0.1. EV chargers that speak +# OCPP connect to FTW directly — there is no driver to write and nothing to add +# under `drivers:`. +# A charger appears as a device on its first BootNotification, keyed by the last +# segment of the URL it dialled: ws://:8887/garage-left +# +# Credentials are required when enabled, and FTW refuses to start without them: +# the OCPP library builds its listen address from the port alone, so the socket +# is reachable on every interface and basic auth is the only gate. Keep the port +# closed at your router. See docs/ocpp.md. +# Each version needs its own port: a charger picks its dialect in the WebSocket +# handshake, so one listener cannot serve both. 2.1 is not supported — no +# production-grade Go implementation of it exists yet. +ocpp: + enabled: false + port: 8887 # OCPP 1.6J + # port_v201: 8888 # OCPP 2.0.1; omit to disable + path: / + username: ftw + password: "" # required when enabled; use a long random string + heartbeat_interval_s: 60 + +# Persistent state (SQLite) +state: + path: state.db + # backup_dir: backups # use an absolute mounted path for off-device backups + +# Independently distributed Lua drivers. The signed stable catalog is refreshed +# by default, but refresh never downloads, activates, or restarts a driver. +# Add `device_repository: { enabled: false }` to opt out. The expanded form is: +# device_repository: +# enabled: true +# refresh_interval_h: 24 +# repositories: +# - id: ftw-official +# name: FTW official drivers +# manifest_url: https://github.com/srcfl/ftw/releases/download/drivers-stable/manifest.json +# enabled: true +# trusted_keys: +# ftw-drivers-2026-01: MX+j27UBkyM099hTyJlmMLK9qlTTDUJsaK/vH12fFKc= + +# Mathematical MPC optimizer. Official Compose runs it as the independently +# updatable ftw-optimizer service; native installs can point optimizer_command +# at their venv's Python executable. +# planner: +# enabled: true +# engine: python +# mode: passive_arbitrage +# horizon_hours: 48 +# interval_min: 15 +# soc_min_pct: 10 +# soc_max_pct: 95 +# optimizer_solver: HIGHS +# optimizer_formulation: auto +# optimizer_transport: auto # auto | unix | process +# optimizer_socket: /run/ftw-optimizer/optimizer.sock +# optimizer_timeout_s: 30 +# optimizer_idle_timeout_s: 120 # release Python/CVXPY memory between planning bursts +# optimizer_mip_rel_gap: 0.005 +# optimizer_cvar_weight: 0.15 +# optimizer_cvar_alpha: 0.90 +# optimizer_recourse_shadow: false # diagnostic only; never controls dispatch +# optimizer_recourse_non_anticipative_slots: 1 +# optimizer_challenger_policy: multistage # recourse | multistage +# optimizer_multistage: +# scenario_limit: 12 +# branch_interval_slots: 4 +# branch_horizon_slots: 48 +# max_branching: 2 +# near_horizon_slots: 16 +# mid_horizon_slots: 96 +# mid_block_slots: 2 +# far_block_slots: 4 +# service_cvar_weight: 1.0 +# service_cvar_alpha: 0.95 +# economic_cvar_weight: 0 +# economic_cvar_alpha: 0.90 +# decomposition_threshold: 20 +# decomposition_method: auto # auto | extensive | progressive_hedging +# ph_max_iterations: 8 +# ph_rho: 50 +# ph_tolerance_w: 5 diff --git a/docs/ocpp.md b/docs/ocpp.md index d46330c6..ed227c88 100644 --- a/docs/ocpp.md +++ b/docs/ocpp.md @@ -1,7 +1,7 @@ # OCPP chargers -FTW has a built-in OCPP 1.6J Central System. An EV charger that speaks OCPP -connects straight to FTW over your local network. +FTW has a built-in OCPP Central System speaking **1.6J and 2.0.1**. An EV +charger that speaks either connects straight to FTW over your local network. **An OCPP charger does not need a driver.** There is no Lua file to write, no entry in `drivers:`, and nothing to add to the device catalog. OCPP is a vendor- @@ -33,6 +33,39 @@ From there it behaves like any other EV reading: `MeterValues` and `StatusNotification` become telemetry, and dispatch stops the home battery discharging into an active EV charge. +## Protocol versions + +| Version | Status | Port | +|---|---|---| +| 1.6J | supported | `port`, default 8887 | +| 2.0.1 | supported | `port_v201`, off unless set | +| 2.1 | not yet — see below | — | + +**Each version needs its own port.** A charger picks its dialect in the +WebSocket handshake, before any message is sent, and the underlying library +keeps one message handler per listener — so one port cannot serve both. Point +each charger at the port matching what it speaks. + +Everything above the protocol is shared. Both dialects land in the same charger +map, produce the same telemetry, and take the same commands, so a 2.0.1 charger +is throttled and paused exactly like a 1.6 one and nothing downstream knows the +difference. + +### On OCPP 2.1 + +2.1 is not supported, because no production-grade Go implementation of it +exists. `lorenzodonini/ocpp-go`, the library FTW uses and the only mature option +at 367 stars, implements 1.6 and 2.0.1 and has no 2.1 support. The Go projects +that do claim 2.1 are all early — single-digit stars, and mostly message +validators or emulators rather than servers. + +This is not a blocker in practice. Every charger on the bench speaks 1.6J only, +and 2.0.1 is what current hardware is migrating to. + +Adding 2.1 later means writing one more handler file and one more listener. The +version-neutral core — charger state, telemetry mapping, control semantics — +does not change. + ## Enabling the server OCPP is off by default. Add an `ocpp` section: @@ -40,7 +73,8 @@ OCPP is off by default. Add an `ocpp` section: ```yaml ocpp: enabled: true - port: 8887 # default 8887 + port: 8887 # OCPP 1.6J, default 8887 + port_v201: 8888 # OCPP 2.0.1; omit or 0 to disable path: / # default / username: ftw password: diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index ac4e1632..ef03f4c6 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -1,3482 +1,3484 @@ -// ftw — Home Energy Management System. -// -// Don't Panic 🐬 -package main - -import ( - "context" - "encoding/json" - "errors" - "flag" - "fmt" - "log/slog" - "net/http" - "net/url" - "os" - "os/signal" - "path/filepath" - "strconv" - "strings" - "sync" - "syscall" - "time" - - "github.com/srcfl/ftw/go/internal/api" - "github.com/srcfl/ftw/go/internal/arp" - "github.com/srcfl/ftw/go/internal/battery" - "github.com/srcfl/ftw/go/internal/caldavserver" - "github.com/srcfl/ftw/go/internal/calendar" - "github.com/srcfl/ftw/go/internal/config" - "github.com/srcfl/ftw/go/internal/configreload" - "github.com/srcfl/ftw/go/internal/control" - "github.com/srcfl/ftw/go/internal/currency" - "github.com/srcfl/ftw/go/internal/devtools" - "github.com/srcfl/ftw/go/internal/driverrepo" - "github.com/srcfl/ftw/go/internal/drivers" - "github.com/srcfl/ftw/go/internal/events" - "github.com/srcfl/ftw/go/internal/forecast" - "github.com/srcfl/ftw/go/internal/ha" - "github.com/srcfl/ftw/go/internal/loadmodel" - "github.com/srcfl/ftw/go/internal/loadpoint" - modbuscli "github.com/srcfl/ftw/go/internal/modbus" - "github.com/srcfl/ftw/go/internal/mpc" - mqttcli "github.com/srcfl/ftw/go/internal/mqtt" - "github.com/srcfl/ftw/go/internal/notifications" - "github.com/srcfl/ftw/go/internal/nova" - "github.com/srcfl/ftw/go/internal/ocpp" - "github.com/srcfl/ftw/go/internal/priceforecast" - "github.com/srcfl/ftw/go/internal/prices" - "github.com/srcfl/ftw/go/internal/proxy" - "github.com/srcfl/ftw/go/internal/pvmodel" - "github.com/srcfl/ftw/go/internal/selftune" - "github.com/srcfl/ftw/go/internal/selfupdate" - "github.com/srcfl/ftw/go/internal/state" - "github.com/srcfl/ftw/go/internal/telemetry" -) - -// Version gets injected at build time via -ldflags. Defaults to "dev" for -// local runs. -var Version = "dev" - -func main() { - // Subcommand dispatch — a bare first non-flag argument selects one - // of the bootstrap CLIs, e.g. `ftw nova-claim --url=…`. - // Everything else is the long-running service. - if len(os.Args) > 1 && !strings.HasPrefix(os.Args[1], "-") { - switch os.Args[1] { - case "nova-claim": - // Shift os.Args so the subcommand's flag.FlagSet sees its own flags. - runNovaClaim(os.Args[2:]) - return - } - } - - configPath := flag.String("config", "config.yaml", "Path to config.yaml") - webDir := flag.String("web", "web", "Path to static web UI directory") - driverDirFlag := flag.String("drivers", "", "Path to drivers directory (default: /drivers)") - userDriversDirFlag := flag.String("user-drivers", "", "Path to PERSISTENT user-drivers directory (overlay on top of -drivers). Searched first; falls back to -drivers when a file isn't found here. Designed for docker deploys.") - // Developer utility — seeds state.db with N days of synthetic history - // so /api/energy/daily has something to render locally. Refuses to run - // if the target DB already holds non-synthetic rows (prod-safety gate); - // -backfill-force bypasses that check. Exits after seeding without - // starting the service. - backfillDays := flag.Int("backfill", 0, "DEV ONLY: seed N days of synthetic history into state.db then exit (0 disables)") - backfillStep := flag.Duration("backfill-step", 5*time.Second, "DEV ONLY: backfill sample interval") - backfillSeed := flag.Int64("backfill-seed", 0, "DEV ONLY: backfill rng seed (0 = random)") - backfillForce := flag.Bool("backfill-force", false, "DEV ONLY: bypass the non-synthetic-data safety gate") - flag.Parse() - - // Drivers default to a sibling of the config file (historical layout: - // config.yaml + drivers/ + seed/ + state.db all under one dir). Docker - // breaks that convention because /app/data is a host bind mount while - // drivers are baked into the image at /app/drivers — the flag lets the - // CMD point at the immutable image location. - resolveDriverDir := func() string { - if *driverDirFlag != "" { - return *driverDirFlag - } - return filepath.Join(filepath.Dir(*configPath), "drivers") - } - - // Wire a process-wide log ring so /api/drivers/{name}/logs and the - // support-bundle endpoint can return recent activity without going - // to disk. The ring captures every slog record AND forwards to the - // stdout text handler so journald/podman logs/docker logs are - // unchanged. Driver-scoped records (slog.With("driver", name)) are - // routed into a per-driver sub-ring by the wrapping handler. - logRing := telemetry.NewLogRing() - stdoutHandler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}) - logger := slog.New(telemetry.NewLogHandler(stdoutHandler, logRing)) - slog.SetDefault(logger) - slog.Info("FTW starting", "version", Version, "config", *configPath) - - // Route "drivers/.lua" path resolution through the drivers dir - // (from -drivers). Picked up by both the initial Load below and every - // subsequent reload via the file watcher. - config.DriversDirOverride = resolveDriverDir() - // UserDriversDirOverride is the persistent overlay — probed first. - // Empty when -user-drivers is not supplied (back-compat). - config.UserDriversDirOverride = *userDriversDirFlag - config.ManagedDriversDirOverride = filepath.Join(filepath.Dir(*configPath), "driver-repository", "active") - - // ---- Load config ---- - cfg, err := config.Load(*configPath) - if err != nil { - if isConfigMissing(err) { - runBootstrap(*configPath, *webDir, resolveDriverDir()) - return - } - slog.Error("load config", "err", err) - os.Exit(1) - } - slog.Info("config loaded", "site", cfg.Site.Name, "drivers", len(cfg.Drivers)) - - // ---- Open persistent state (SQLite) ---- - statePath := "state.db" - coldDir := "cold" - if cfg.State != nil { - if cfg.State.Path != "" { - statePath = cfg.State.Path - } - if cfg.State.ColdDir != "" { - coldDir = cfg.State.ColdDir - } - } - // Resolve to absolute so paths derived via filepath.Dir(statePath) - // (SnapshotDir, nova.key) don't end up cwd-relative on native installs - // where the working directory may differ from the data volume. - if abs, err := filepath.Abs(statePath); err == nil { - statePath = abs - } - dataDir := filepath.Dir(statePath) - backupDir := filepath.Join(dataDir, "backups") - if cfg.State != nil && cfg.State.BackupDir != "" { - backupDir = cfg.State.BackupDir - if !filepath.IsAbs(backupDir) { - backupDir = filepath.Join(dataDir, backupDir) - } - } - if abs, err := filepath.Abs(coldDir); err == nil { - coldDir = abs - } - dataMaintenanceMu := &sync.Mutex{} - - // Bind the API port BEFORE the potentially slow state open. A boot that - // runs a one-time VACUUM or a full integrity check on a multi-GB DB can - // take 25+ minutes, and an unbound port for that long makes the Docker - // healthcheck fail and the self-update sidecar judge the deploy failed — - // observed 2026-07-16 as an auto-rollback in the middle of a VACUUM. - // Until the real mux is wired, /api/health answers 200 "starting" and - // everything else 503. - apiHandler := newSwappableHandler(bootPhaseHandler()) - httpSrv := &http.Server{ - Addr: fmt.Sprintf(":%d", cfg.API.Port), - Handler: apiHandler, - ReadHeaderTimeout: 10 * time.Second, - } - go func() { - slog.Info("HTTP API listening (boot phase)", "addr", httpSrv.Addr) - if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - slog.Error("http server", "err", err) - } - }() - - st, err := state.Open(statePath) - if err != nil { - slog.Error("open state", "err", err) - os.Exit(1) - } - defer st.Close() - - // The repository is entirely local on startup: existing active symlinks are - // usable offline and remote refresh never blocks core boot. - driverRepository := driverrepo.New(cfg.DeviceRepository, filepath.Dir(statePath), st) - cfg.UnresolveDriverPaths(filepath.Dir(*configPath)) - config.ManagedDriversDirOverride = driverRepository.ActiveDir() - cfg.ResolveDriverPaths(filepath.Dir(*configPath)) - - // ---- Dev backfill (flag-gated, one-shot) ---- - // When -backfill N (N>0) is set, seed N days of synthetic history - // into state.db and exit WITHOUT starting the service. Refuses if - // the DB already holds non-synthetic rows unless -backfill-force. - if *backfillDays > 0 { - if err := devtools.Backfill(st, devtools.BackfillConfig{ - Days: *backfillDays, - Step: *backfillStep, - Seed: *backfillSeed, - Force: *backfillForce, - }, slog.Default()); err != nil { - slog.Error("backfill", "err", err) - os.Exit(1) - } - return - } - - if err := st.RecordEvent("startup"); err != nil { - slog.Warn("failed to persist startup event", "err", err) - } - - // ---- Restore EV charger password from state.db (not stored in YAML) ---- - if cfg.EVCharger != nil { - if pw, ok := st.LoadConfig("ev_charger_password"); ok { - cfg.EVCharger.Password = pw - } - } - - // ---- Restore CalDAV password from state.db (not stored in YAML) ---- - if cfg.CalDAV != nil { - if pw, ok := st.LoadConfig("caldav_password"); ok { - cfg.CalDAV.Password = pw - } - } - // Managed credential (#498): mint a random password on first enable so the - // operator never sets one by hand. Persisted to state.db; the in-process - // CalDAV server authenticates against it and the Settings tab shows it (with - // a QR) to paste into a calendar app. - if cfg.CalDAV.ManageCredentialsEnabled() && cfg.CalDAV.Password == "" { - if tok, err := calendar.GenerateToken(18); err != nil { - slog.Warn("caldav: failed to generate managed credential", "err", err) - } else if err := st.SaveConfig("caldav_password", tok); err != nil { - slog.Warn("caldav: failed to persist managed credential", "err", err) - } else { - cfg.CalDAV.Password = tok - slog.Info("caldav: generated managed credential") - } - } - - // ---- Telemetry store ---- - tel := telemetry.NewStore() - - // ---- Control state ---- - ctrl := newControlStateFromConfig(cfg) - // Restore persisted mode + target if present. The planner variants - // have to be listed too — without them the strategy the user picked in - // the UI (planner_self / planner_cheap / planner_arbitrage) is silently - // dropped on restart and the dashboard appears to forget the selection. - if v, ok := st.LoadConfig("mode"); ok { - if m := control.Mode(v); control.IsValidMode(m) { - ctrl.Mode = m - } - } - if v, ok := st.LoadConfig("grid_target_w"); ok { - if f, err := strconv.ParseFloat(v, 64); err == nil { - ctrl.SetGridTarget(f) - } - } - if v, ok := st.LoadConfig("battery_covers_ev"); ok { - ctrl.BatteryCoversEV = v == "true" - } - if v, ok := st.LoadConfig("peak_import_ceiling_w"); ok { - if f, err := strconv.ParseFloat(v, 64); err == nil && f >= 0 { - ctrl.PeakImportCeilingW = f - } - } - - // ---- Driver catalog (pure text scan, no Lua VM) ---- - // Loaded once here so the pre-flight capacity + warning logic can - // ask the catalog "is this driver an EV charger / vehicle source?" - // rather than sniffing filenames. The Lua DRIVER table's - // capabilities list is the driver's self-declaration. - driverCatalog, catErr := drivers.LoadCatalogMulti(*userDriversDirFlag, resolveDriverDir()) - if catErr != nil || len(driverCatalog) == 0 { - slog.Warn("driver catalog load failed; EV-driver classification will be conservative", - "err", catErr, "entries", len(driverCatalog)) - driverCatalog = nil - } - - // ---- Driver capacities (site, for control + fuse guard) ---- - // Loadpoint drivers are filtered out — their battery_capacity_wh - // is vehicle capacity, not site-battery capacity. - capacities := driverCapacitiesFrom(cfg.Drivers, cfg.Loadpoints, driverCatalog) - warnIfEVHasBatteryCapacity(cfg.Drivers, cfg.Loadpoints, driverCatalog) - - // ---- Battery models — restore from SQLite + ensure one per driver ---- - models := make(map[string]*battery.Model) - if stored, err := st.LoadAllBatteryModels(); err == nil { - for name, js := range stored { - m := &battery.Model{} - if err := json.Unmarshal([]byte(js), m); err == nil { - models[name] = m - slog.Info("restored battery model", - "name", name, "τ", m.TimeConstantS(float64(cfg.Site.ControlIntervalS)), - "gain", m.SteadyStateGain(), "samples", m.NSamples) - } - } - } - for name := range capacities { - if models[name] == nil { - models[name] = battery.New(name) - } - } - - // ---- Self-tune coordinator ---- - selfTune := selftune.NewCoordinator() - - // ---- Restart signal ---- - // Closing restartCh from /api/restart drops the main control loop out - // of its select, which returns from main() so every defer (HA Stop, - // state.Close, http.Shutdown, …) runs in normal LIFO order. The - // bottom-of-stack `os.Exit` defer below then translates exitCode 1 - // into a non-zero process exit so docker (`unless-stopped`) and - // systemd (`Restart=on-failure`) bring the binary back up. SIGTERM / - // SIGINT take the same return path with exitCode 0. - restartCh := make(chan struct{}) - var restartOnce sync.Once - exitCode := 0 - defer func() { - if exitCode != 0 { - os.Exit(exitCode) - } - }() - - // ---- Driver registry ---- - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - if cfg.DeviceRepository != nil && cfg.DeviceRepository.Enabled { - go driverRepositoryRefreshLoop(ctx, driverRepository, cfg.DeviceRepository.RefreshIntervalH) - } - reg := drivers.NewRegistry(tel) - reg.SetTroubleshootingMode(cfg.Site.TroubleshootingMode) - reg.MQTTFactory = func(name string, c *config.MQTTConfig) (drivers.MQTTCap, error) { - return mqttcli.Dial(c.Host, c.Port, c.Username, c.Password, "ftw-"+name) - } - reg.ModbusFactory = func(name string, c *config.ModbusConfig) (drivers.ModbusCap, error) { - return modbuscli.Dial(c.Host, c.Port, c.UnitID) - } - reg.ARPLookup = arp.Lookup - // Spawn initial drivers. config.Load has already joined relative Lua - // paths with the config directory — nothing to resolve here. - // WithBatterySoCBounds routes each battery's soc_min/soc_max into the - // matching driver's config (discharge_floor_soc/charge_ceil_soc) so the - // operator's SoC window reaches the driver, not just the planner. - for _, d := range config.WithBatterySoCBounds(cfg.Drivers, cfg.Batteries) { - if d.Disabled { - slog.Info("driver skipped (disabled)", "name", d.Name) - continue - } - if err := reg.Add(ctx, d); err != nil { - slog.Warn("failed to spawn driver", "name", d.Name, "err", err) - } - } - defer reg.ShutdownAll() - - // ---- Identity bootstrap ---- - // Drivers report make/serial inside driver_init via host.set_make / set_sn, - // and we resolved endpoint+MAC at registry-Add time. Now we wait briefly - // for those to populate, then register each device + run the one-shot - // migration that re-keys legacy battery_models from driver-name to - // device_id. Subsequent runs are no-ops. - go func() { - time.Sleep(3 * time.Second) // let driver_init finish + first SN be reported - registerAllDevices(st, reg) - if migrated, err := st.MigrateBatteryModelKeys(); err != nil { - slog.Warn("battery model key migration failed", "err", err) - } else if migrated > 0 { - slog.Info("battery model keys migrated to device_id", "count", migrated) - } - }() - - // ---- Shared mutexes for API/control/models ---- - ctrlMu := &sync.Mutex{} - capMu := &sync.RWMutex{} - cfgMu := &sync.RWMutex{} - modelsMu := &sync.Mutex{} - - // Durable secret write-back for drivers (rotated OAuth refresh tokens). - // Drivers call host.persist_secret(key, value); the registry routes it - // here with the driver's name. We write to the state KV store — NOT - // config.yaml — on purpose: config.yaml is watched by configreload, and - // rewriting it on every token rotation would restart the driver, which - // re-auths, rotates again, and loops. SecretOverride then layers these - // KV values back over config.yaml at driver_init, so the freshest token - // always reaches the driver while config.yaml keeps the bootstrap seed - // the UI renders as "saved". - driverSecretKey := func(driverName, key string) string { - return "driver_secret:" + driverName + ":" + key - } - reg.SecretPersister = func(driverName, key, value string) error { - return st.SaveConfig(driverSecretKey(driverName, key), value) - } - reg.SecretOverride = func(driverName, key string) (string, bool) { - return st.LoadConfig(driverSecretKey(driverName, key)) - } - - // Pre-declare services that the hot-reload Applier needs to touch. - // The Applier closure captures these by reference; they're assigned - // further down when their packages are wired, and the Applier only - // ever fires after `watcher.Start()` — by which point everything is - // in place. - var pvSvc *pvmodel.Service - var forecastSvc *forecast.Service - // Notifications: pre-declared so the hot-reload Applier can push - // fresh config into the provider + rule engine. Constructed - // unconditionally below so API handlers always have a live pointer. - var notifProvider notifications.Provider - var notifSvc *notifications.Service - // Event bus: decouples core loops (control tick, API) from - // cross-cutting subscribers (notifications today, audit/webhooks later). - bus := events.NewBus() - - // ---- EV loadpoints ---- - // Manager is created early so the config hot-reload closure can - // reference it; actual Load() runs against initial cfg below. - // The planner consumes loadpoint state so battery and EV can be - // co-optimized in one DP. - lpMgr := loadpoint.NewManager() - if len(cfg.Loadpoints) > 0 { - lpMgr.Load(buildLoadpointConfigs(cfg.Loadpoints)) - slog.Info("loadpoints configured", "count", len(cfg.Loadpoints)) - } - // Persist operator schedules in the existing state.config k/v. - // One row per LP keyed `loadpoint_schedule:`. Clearing a - // 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) { - 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 - } - 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) - } - }) - lpMgr.HydrateSchedules(func(id string) (loadpoint.Schedule, bool) { - v, ok := st.LoadConfig(lpSchedKeyPrefix + id) - if !ok || v == "" || v == "{}" { - return loadpoint.Schedule{}, false - } - var s loadpoint.Schedule - if err := json.Unmarshal([]byte(v), &s); err != nil { - slog.Warn("failed to parse persisted loadpoint schedule", - "lp", id, "err", err) - return loadpoint.Schedule{}, false - } - return s, !s.Empty() - }) - // Persist surplus_only toggles the same way schedules persist — - // state.config key `loadpoint_surplus_only:` stores "true" or - // "false". Operators toggle this from the dashboard EV modal, not - // 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) { - 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) - } - }) - hydrateLoadpointSurplusOnly := func() { - lpMgr.HydrateSurplusOnly(func(id string) (bool, bool) { - v, ok := st.LoadConfig(lpSurplusKeyPrefix + id) - if !ok || v == "" { - return false, false - } - return v == "true", true - }) - } - hydrateLoadpointSurplusOnly() - // Seed any recurring deadlines from boot — without this the first - // dispatch tick would race with an empty target_time and might - // miss the deadline penalty for ~5 s. - lpMgr.RollSchedules(time.Now().UTC()) - - // Forward-declared so the hot-reload closure below can push - // capacity changes into the running planner. Assigned at line - // ~450 after all its dependencies (pvSvc, loadSvc, priceFc) are - // wired up. nil until that point — the reload closure guards. - var mpcSvc *mpc.Service - - // Pre-declared for the same reason as mpcSvc — the hot-reload - // Applier needs to mutate loadSvc.SiteMeter when the operator - // moves the `is_site_meter: true` flag between drivers, and - // loadSvc itself is constructed further down (line ~619) after - // the telemetry store + state DB are wired. Closure capture - // works because Go closes over the variable, not its value. - var loadSvc *loadmodel.Service - - // Pre-declared so the hot-reload Applier can call (*ha.Bridge).Reload - // when broker / credentials / publish interval change. Constructed - // further down once the registry + control callbacks exist; the - // Applier nil-guards against the bridge being disabled. - var haBridge *ha.Bridge - - // deps is the API server's runtime dependency container. Forward- - // declared as a *Deps so the hot-reload Applier closure can capture - // it as a variable: the closure dereferences `deps` only after the - // HTTP server has been wired up (deps is populated below), so - // applier-time reads always observe the fully-constructed value. - // Updating deps.HA from the applier (e.g. when an operator toggles - // HA from disabled to enabled at runtime) keeps the API handler's - // pointer in sync without forcing a process restart. - var deps *api.Deps - - // Forward-declared before the reload watcher so the reload callback - // can keep the loadpoint controller's per-phase EV fuse clamp in sync - // with hot-reloaded fuse params. Assigned later (loadpoint.NewController). - var lpController *loadpoint.Controller - - // Forward-declared before the reload watcher so the callback can - // hot-reload the calendar client (#498). Assigned later (calendar.New). - var calSvc *calendar.Service - - // ---- Config hot-reload watcher ---- - watcher, err := configreload.New(*configPath, cfgMu, cfg, ctrlMu, ctrl, - func(newCfg, oldCfg *config.Config) { - // Restore EV charger password from state.db (not in YAML). - if newCfg.EVCharger != nil { - if pw, ok := st.LoadConfig("ev_charger_password"); ok { - newCfg.EVCharger.Password = pw - } - } - // Restore CalDAV password from state.db (not in YAML). Any CalDAV - // change is restart-gated because the native server and client must - // switch credentials, paths, and listeners atomically. - if newCfg.CalDAV != nil { - if pw, ok := st.LoadConfig("caldav_password"); ok { - newCfg.CalDAV.Password = pw - } - } - // Driver paths are already resolved by config.Load; no extra - // work needed here. Re-apply the battery SoC-window → driver - // config mapping so a hot-edited soc_max reaches the driver too. - reg.Reload(ctx, - config.WithBatterySoCBounds(newCfg.Drivers, newCfg.Batteries), - newCfg.Site.TroubleshootingMode) - // Refresh capacities — mutate the existing map in place so - // Deps.Capacities (a map header captured at init) sees the - // update. Rebinding the local variable would orphan the - // reference the api server still holds. - capMu.Lock() - for k := range capacities { - delete(capacities, k) - } - // Re-scan the catalog so a hot-edited Lua driver's - // capability change is picked up by the EV-classification - // filter on the very next reload tick. - reloadCatalog, err := drivers.LoadCatalogMulti(*userDriversDirFlag, resolveDriverDir()) - if err != nil || len(reloadCatalog) == 0 { - slog.Warn("driver catalog reload failed; retaining last known catalog", - "err", err, "entries", len(reloadCatalog)) - reloadCatalog = driverCatalog - } else { - driverCatalog = reloadCatalog - } - for k, v := range driverCapacitiesFrom(newCfg.Drivers, newCfg.Loadpoints, reloadCatalog) { - capacities[k] = v - } - capMu.Unlock() - warnIfEVHasBatteryCapacity(newCfg.Drivers, newCfg.Loadpoints, reloadCatalog) - - // Swap inverter-group tags (#143) and per-driver power - // limits (#145) together. Taken under ctrlMu because - // ComputeDispatch reads State.InverterGroups + .DriverLimits; - // a bare replace would race with the control loop's 5 s tick. - ctrlMu.Lock() - ctrl.InverterGroups = inverterGroupsFrom(newCfg.Drivers) - ctrl.SupportsPVCurtail = supportsPVCurtailFrom(newCfg.Drivers) - ctrl.DriverLimits = driverLimitsFrom(newCfg.Drivers, newCfg.Batteries) - // Fuse params + safety margin: previously startup-only. - // Hot-reload them so operators can tune the per-phase margin - // from the UI without restarting (e.g. raising it after the - // inverter's own protection trips, lowering it to recover - // last few hundred W of arbitrage headroom). - ctrl.SiteFuseAmps = newCfg.Fuse.MaxAmps - ctrl.SiteFuseVoltage = newCfg.Fuse.Voltage - ctrl.SiteFusePhases = newCfg.Fuse.Phases - // Mirror the startup-path default semantics — nil → 0.5, - // explicit 0 → disabled. See EffectiveSafetyMarginA. - ctrl.SiteFuseSafetyA = newCfg.Fuse.EffectiveSafetyMarginA() - ctrl.MaxExportW = newCfg.Site.MaxExportW - ctrlMu.Unlock() - - // Keep the loadpoint controller's per-phase EV fuse clamp in - // sync with hot-reloaded fuse params — previously startup-only, - // so an operator tuning max_amps / margin from the UI updated - // the control-package battery lever (above) but left the EV - // clamp on the stale startup value until restart. SetSiteFuse - // takes its own lock; call it outside ctrlMu. - if lpController != nil { - lpController.SetSiteFuse(loadpoint.SiteFuse{ - MaxAmps: newCfg.Fuse.MaxAmps, - Voltage: newCfg.Fuse.Voltage, - PhaseCnt: newCfg.Fuse.Phases, - }) - } - - // Site-meter swap propagation. The configreload watcher - // already updated ctrl.SiteMeterDriver under ctrlMu before - // this applier ran, so the dispatch loop reads from the - // right driver from the next tick. Two more sites cached - // the meter at construction and need the same hot-update - // treatment: - // - mpc.Service.SiteMeter — used by reactive replan to - // compute actual site load (grid − pv − bat). - // - loadmodel.Service.SiteMeter — drives twin learning; - // leaving it stale teaches the load model from a meter - // that may not even be emitting any more. - if newCfg.SiteMeterDriver() != oldCfg.SiteMeterDriver() { - if mpcSvc != nil { - mpcSvc.SetSiteMeter(newCfg.SiteMeterDriver()) - } - if loadSvc != nil { - loadSvc.SetSiteMeter(newCfg.SiteMeterDriver()) - } - slog.Info("site-meter hot-reloaded into mpc + loadmodel", - "driver", newCfg.SiteMeterDriver()) - } - - // Push the new pool totals into the planner so its next - // replan uses the right CapacityWh / MaxChargeW / - // MaxDischargeW. Without this the MPC keeps the snapshot - // it took at buildMPC time; SoC % and terminal credit go - // stale after an EV loadpoint is added/removed. Codex P1 - // on PR #121. - if mpcSvc != nil { - fleet := mpcBatteryFleetFromConfig(newCfg, capacities) - totalCap, maxChg, maxDis := aggregateBatteryFleetLimits(newCfg, fleet) - mpcSvc.UpdateBatteryFleet(fleet, totalCap, maxChg, maxDis) - slog.Info("mpc: capacity updated via hot-reload", - "capacity_wh", totalCap, "max_charge_w", maxChg, "max_discharge_w", maxDis) - } - - // Hot-reload EV loadpoints so operators can add / remove / - // retune them without restarting. Manager preserves - // observed state across reloads (plug status, session - // anchor, current SoC estimate) — see loadpoint.Manager.Load. - lpMgr.Load(buildLoadpointConfigs(newCfg.Loadpoints)) - hydrateLoadpointSurplusOnly() - - // Notifications: rebuild the provider from fresh config - // (handles the cold-start case where the initial config - // had no notifications: block and notifProvider was nil), - // wire it onto the service, then reset the rule-engine - // per-outage latch. All calls are nil-safe. - newProv := notifications.NewProvider(newCfg.Notifications) - notifProvider = newProv - var newPub notifications.Publisher - if newProv != nil { - newPub = newProv - } - notifSvc.SetPublisher(newPub) - notifSvc.Reload(newCfg.Notifications) - - // Home Assistant: hot-reload broker / credentials / publish - // interval / driver list. Bridge.Reload tears down the paho - // client and re-publishes discovery so an operator changing - // the broker IP from Settings sees HA reconnect within a - // second — no process restart required. - // - // Three transitions to handle: - // running → running: Bridge.Reload swaps connection. - // running → disabled: Stop the existing bridge. - // disabled → enabled: Start a fresh bridge (handles both - // the "previously toggled off" case and - // the "Start failed at boot, operator - // fixed the broker" recovery path). - haEnabled := newCfg.HomeAssistant != nil && newCfg.HomeAssistant.Enabled - switch { - case haBridge != nil && haEnabled: - if err := haBridge.Reload(newCfg.HomeAssistant, reg.Names()); err != nil { - slog.Warn("HA bridge reload failed", "err", err) - } else { - slog.Info("HA bridge reloaded", "broker", newCfg.HomeAssistant.Broker) - } - case haBridge != nil && !haEnabled: - haBridge.Stop() - haBridge = nil - deps.HA = nil - slog.Info("HA bridge stopped (disabled in config)") - case haBridge == nil && haEnabled: - if bridge, err := ha.Start(newCfg.HomeAssistant, tel, ctrl, ctrlMu, reg.Names(), haCallbacks(ctx, ctrl, ctrlMu, st, mpcSvc), mpcPlanSource(mpcSvc), haEnergySource(st)); err != nil { - slog.Warn("HA bridge start failed", "err", err) - } else { - haBridge = bridge - deps.HA = bridge - slog.Info("HA bridge started", "broker", newCfg.HomeAssistant.Broker) - } - } - - // Weather diff → push live into the PV twin + forecast - // fetcher without a process restart. Users adjust rated PV - // + lat/lon from Settings and expect the change to take - // effect right away. - if newCfg.Weather != nil { - oldLat, oldLon, oldRated := 0.0, 0.0, 0.0 - if oldCfg.Weather != nil { - oldLat = oldCfg.Weather.Latitude - oldLon = oldCfg.Weather.Longitude - oldRated = oldCfg.Weather.PVRatedW - } - newRated := newCfg.Weather.PVRatedW - if newRated > 0 && newRated != oldRated { - if pvSvc != nil { - pvSvc.SetRated(newRated) - } - if forecastSvc != nil { - forecastSvc.RatedPVW = newRated - } - } - newLat := newCfg.Weather.Latitude - newLon := newCfg.Weather.Longitude - if newLat != oldLat || newLon != oldLon { - if pvSvc != nil { - pvSvc.ClearSky = func(t time.Time) float64 { return forecast.ClearSkyW(newLat, newLon, t) } - } - if forecastSvc != nil { - forecastSvc.Lat = newLat - forecastSvc.Lon = newLon - } - slog.Info("weather location updated", "lat", newLat, "lon", newLon) - } - } - }) - if err != nil { - slog.Warn("could not start config watcher", "err", err) - } else { - watcher.Start() - defer watcher.Stop() - } - - // ---- Spot prices + weather forecast (optional, nil if not configured) ---- - // ---- FX rates (ECB, daily) — harmless to run even for SE-only users ---- - fxSvc := currency.New(st) - fxSvc.Start(ctx) - defer fxSvc.Stop() - - priceSvc := prices.FromConfig(cfg.Price, st, fxSvc) - - // ---- Price forecaster (fills in beyond day-ahead publication) ---- - zones := []string{"SE3"} - if cfg.Price != nil && cfg.Price.Zone != "" { - zones = []string{cfg.Price.Zone} - } - priceFc := priceforecast.NewService(st, zones) - // Optional: seed from bundled CSV on first boot. Idempotent so safe - // to call every boot — no-op once data is already in the store. - seedPath := filepath.Join(filepath.Dir(*configPath), "seed", "prices.csv") - if _, err := os.Stat(seedPath); err == nil { - n, err := priceFc.SeedFromCSV(seedPath) - if err != nil { - slog.Warn("priceforecast seed failed", "path", seedPath, "err", err) - } else if n > 0 { - slog.Info("priceforecast seeded", "rows", n, "path", seedPath) - } - } - priceFc.Start(ctx) - defer priceFc.Stop() - if priceSvc != nil { - priceSvc.Start(ctx) - defer priceSvc.Stop() - slog.Info("price service started", "zone", priceSvc.Zone, "provider", priceSvc.Provider.Name()) - } - - // Sum rated PV from all drivers for the forecast estimator - // Prefer explicit config; fall back to heuristic if unset. - ratedPVW := 0.0 - if cfg.Weather != nil && cfg.Weather.PVRatedW > 0 { - ratedPVW = cfg.Weather.PVRatedW - } else { - for _, d := range cfg.Drivers { - if d.BatteryCapacityWh > 0 { - ratedPVW += d.BatteryCapacityWh / 3 - } - } - if ratedPVW == 0 { - ratedPVW = 10000 - } - } - forecastSvc = forecast.FromConfig(cfg.Weather, ratedPVW, st, - "ftw/"+Version+" github.com/srcfl/ftw") - if forecastSvc != nil { - forecastSvc.Start(ctx) - defer forecastSvc.Stop() - slog.Info("forecast service started", "provider", forecastSvc.Provider.Name(), - "lat", forecastSvc.Lat, "lon", forecastSvc.Lon, "rated_pv_w", ratedPVW) - } - - // ---- Start PV digital twin (optional, requires weather config) ---- - // pvSvc is pre-declared above so the reload Applier can update it. - if cfg.Weather != nil && cfg.Weather.Provider != "" && cfg.Weather.Provider != "none" { - lat, lon := cfg.Weather.Latitude, cfg.Weather.Longitude - clearSkyFn := func(t time.Time) float64 { return forecast.ClearSkyW(lat, lon, t) } - cloudFn := func(t time.Time) (float64, bool) { - // Look up nearest forecast row covering `t`. - nowMs := t.UnixMilli() - rows, err := st.LoadForecasts(nowMs-2*3600*1000, nowMs+2*3600*1000) - if err != nil || len(rows) == 0 { - return 0, false - } - for _, r := range rows { - slotLen := r.SlotLenMin - if slotLen <= 0 { - slotLen = 60 - } - end := r.SlotTsMs + int64(slotLen)*60*1000 - if nowMs >= r.SlotTsMs && nowMs < end && r.CloudCoverPct != nil { - return *r.CloudCoverPct, true - } - } - return 0, false - } - pvSvc = pvmodel.NewService(st, tel, clearSkyFn, cloudFn, ratedPVW) - pvSvc.Start(ctx) - defer pvSvc.Stop() - slog.Info("pvmodel started", "rated_w", ratedPVW, "quality", pvSvc.Model().Quality()) - } - - // ---- Start load digital twin ---- - // Peak load proxy: use fuse power budget × 0.5 as a sane default - // until user configures an explicit value. Users can override by - // setting site.load_peak_w in config once we expose it. - loadPeakW := cfg.Fuse.MaxPowerW() * 0.5 - if loadPeakW <= 0 { - loadPeakW = 5000 - } - loadSvc = loadmodel.NewService(st, tel, cfg.SiteMeterDriver(), loadPeakW) - // SeedHeatingCoef — operator config is a cold-start prior. Once the - // load model has accumulated samples in production, its - // telemetry-fit HeatingW_per_degC survives restart and the config - // value is ignored. See loadmodel/service.go for the rationale. - if cfg.Weather != nil { - loadSvc.SeedHeatingCoef(cfg.Weather.HeatingWPerDegC) - } else { - loadSvc.SeedHeatingCoef(0) - } - // Temperature source for heating-gain fit: same forecast cache. - loadSvc.Temp = func(t time.Time) (float64, bool) { - nowMs := t.UnixMilli() - rows, err := st.LoadForecasts(nowMs-2*3600*1000, nowMs+2*3600*1000) - if err != nil || len(rows) == 0 { - return 0, false - } - for _, r := range rows { - slotLen := r.SlotLenMin - if slotLen <= 0 { - slotLen = 60 - } - end := r.SlotTsMs + int64(slotLen)*60*1000 - if nowMs >= r.SlotTsMs && nowMs < end && r.TempC != nil { - return *r.TempC, true - } - } - return 0, false - } - loadSvc.Start(ctx) - defer loadSvc.Stop() - slog.Info("loadmodel started", "peak_w", loadPeakW, "quality", loadSvc.Model().Quality()) - - // ---- Calendar (CalDAV) planner constraints (#498) ---- - // FTW hosts its own in-process, pure-Go CalDAV server (internal/caldavserver, - // emersion/go-webdav, MIT) and runs a CalDAV *client* against it: it maps - // "away" events onto the load model's away profile and EV - // "charged-by-departure" events onto loadpoint targets. Opt-in + fail-soft; - // enable/disable is restart-gated (config.RestartRequiredFor), so the runtime - // block only ever exists while enabled. Single-container friendly — runs in a - // Home Assistant add-on with no sidecar. - var caldavSrv *caldavserver.Server - if cfg.CalDAV != nil && cfg.CalDAV.Enabled { - // Host CalDAV in-process; the client below talks to it over localhost, so - // the inbound/outbound intent logic is the same regardless. Objects - // persist in state.db so they survive restarts. - principal, calPaths, feeds := nativeCalDAVLayout(cfg.CalDAV) - caldavSrv = caldavserver.New(cfg.CalDAV.ListenAddr(), caldavUsername(cfg.CalDAV), cfg.CalDAV.Password, principal, calPaths, st, caldavserver.WithFeeds(feeds)) - caldavSrv.Start() - defer caldavSrv.Stop() - - calSvc = calendar.New(*cfg.CalDAV, lpMgr, loadSvc, firstLoadpointID(cfg.Loadpoints)) - // Outbound EVSE history: feed live EV charge-point readings so the - // service can author a calendar event per completed session. - calSvc.SetEVSource(func() []calendar.EVSample { return evSamplesFromTelemetry(tel) }) - // Outbound plan publishing: feed the current MPC plan so the service - // can render forward-looking charge/discharge windows. mpcSvc is built - // just below; the closure reads it at call time (nil-safe until then). - calSvc.SetPlanSource(func() []calendar.PlanSlot { return planSlotsFromMPC(mpcSvc) }) - calSvc.Start(ctx) - defer calSvc.Stop() - slog.Info("caldav started", "listen", cfg.CalDAV.ListenAddr(), "url", cfg.CalDAV.URL, "calendar", cfg.CalDAV.CalendarPath) - } - - // ---- Start OCPP 1.6J Central System (optional) ---- - // Chargers dial us, so there is nothing to add to cfg.Drivers and no Lua - // driver involved. A charge point becomes a device in tel the moment it - // sends its first BootNotification, keyed by the identity segment of the - // URL it connected to, and dispatch picks it up from there like any other - // EV reading. - var ocppSrv *ocpp.Server - if cfg.OCPP != nil && cfg.OCPP.Enabled { - srv, err := ocpp.Start(ctx, &ocpp.Config{ - Enabled: cfg.OCPP.Enabled, - Port: cfg.OCPP.Port, - Path: cfg.OCPP.Path, - Username: cfg.OCPP.Username, - Password: cfg.OCPP.Password, - HeartbeatIntervalS: cfg.OCPP.HeartbeatIntervalS, - }, tel) - if err != nil { - // A charger that cannot reach us is a missing device, not a - // broken site, so keep the rest of the process running. - slog.Error("ocpp: central system failed to start", "err", err) - } else { - ocppSrv = srv - defer ocppSrv.Stop() - slog.Info("ocpp: central system started", - "port", ocppSrv.Port(), - "path", ocppSrv.Path(), - "note", "listener is reachable on every interface; basic auth is the only gate") - } - } - - // ---- Start MPC planner (optional) ---- - mpcSvc = buildMPC(cfg, st, tel, capacities) - if mpcSvc != nil { - // Plumb the site fuse so the DP joint-plans battery + EV under - // the fuse from the start (instead of producing plans that - // dispatch later has to scale via the joint allocator). - mpcSvc.FuseMaxW = cfg.Fuse.MaxPowerW() - // Cap planned export below the fuse when the operator set a site - // export ceiling, so the DP never schedules a discharge that would - // over-export and trip an inverter (the Ferroamp 0x8030 fault). - // Startup-only, matching FuseMaxW above. - mpcSvc.MaxExportW = cfg.Site.MaxExportW - if pvSvc != nil { - // Use the unanchored structural predictor here: the MPC also - // receives PVResidualCorrect, which captures the same - // structural-vs-live bias the now-anchor would apply, so - // wiring Predict (anchored) would double-correct and the - // planner would see ~0 W PV on a sunny day with a heavy - // downward residual. See pvmodel.Service.PredictStructural. - mpcSvc.PV = pvSvc.PredictStructural - mpcSvc.PVResidualCorrect = pvSvc.ResidualCorrect - mpcSvc.PVUncertaintyW = pvSvc.ResidualStdW - } - // Downside-PV safety planning (forecast − k·σ) — replaces the old SoC - // safety floor. Unset config → default 1.0; explicit 0 → raw forecast. - mpcSvc.PVForecastSafetyK = cfg.Planner.PVSafetyK() - if cfg.Planner != nil { - mpcSvc.MinArbitrageSpreadOreKwh = cfg.Planner.MinArbitrageSpreadOreKwh - } - // Away-aware load predictor (#498): for slots inside a calendar - // "away" interval, predict with the load model's away profile so the - // DP conserves battery over exactly those slots. Outside any away - // window (and whenever CalDAV is off), this is identical to - // loadSvc.Predict. - if calSvc != nil { - mpcSvc.Load = func(t time.Time) float64 { - if calSvc.IsAwayAt(t) { - return loadSvc.PredictWith(t, loadmodel.ProfileAway) - } - return loadSvc.PredictWith(t, loadmodel.ProfileHome) - } - } else { - mpcSvc.Load = loadSvc.Predict - } - mpcSvc.Price = priceFc.Predict - mpcSvc.SiteMeter = cfg.SiteMeterDriver() - // The mathematical planner co-optimizes every scheduled loadpoint. - // The service retains the first entry for its Go-DP emergency fallback. - mpcSvc.Loadpoints = func(slotLenMin int) []*mpc.LoadpointSpec { - specs := make([]*mpc.LoadpointSpec, 0) - for _, st := range lpMgr.States() { - if !st.PluggedIn { - continue - } - // Schedule gate: only extend the DP with an EV-SoC - // dimension when the operator has set BOTH a target SoC - // and a future deadline. Without a schedule the DP - // previously planned EV charging speculatively across - // the full 48 h horizon — drawing battery + grid budget - // against a target the operator never asked for. With - // no schedule, EV is left to the loadpoint controller's - // reactive surplus-only behaviour. - if st.TargetSoCPct <= 0 || st.TargetTime.IsZero() || - !st.TargetTime.After(time.Now()) { - continue - } - // Pull capacity off the configured loadpoint. - var capWh float64 = 60000 // 60 kWh fallback - for _, c := range cfg.Loadpoints { - if c.ID == st.ID && c.VehicleCapacityWh > 0 { - capWh = c.VehicleCapacityWh - break - } - } - // Prefer live DerVehicle SoC over the loadpoint manager's - // inferred plugin-anchor + delivered-Wh estimate. The - // inference is blind to BMS truth (Easee can't see the - // car); when a vehicle driver such as TeslaBLEProxy is - // online, its SoC reading is ground truth. - // - // Picker (rank + freshness + bounds + connection - // evidence when delivering power) lives in - // telemetry.PickBestVehicleForLoadpoint so api.go's - // loadpoint decoration agrees with us on which vehicle - // is "the one". Falls back to inferred SoC when nothing - // usable online matches. - initSoC := st.CurrentSoCPct - socSource := "inferred" - var vehicleChargeLimit float64 // 0 = unknown - delivering := st.CurrentPowerW > loadpoint.DeliveringW - if pick := telemetry.PickBestVehicleForLoadpoint(tel, delivering, time.Now()); pick.Driver != "" { - initSoC = pick.SoCPct - socSource = "vehicle:" + pick.Driver - vehicleChargeLimit = pick.ChargeLimitPct - } - // Map target time → slot index using the DP's - // actual slot length (hour-of-prices vs. 15-min - // quarters vary by market). Anything past horizon - // gets clamped by the DP itself; negative means - // "no deadline". - if slotLenMin <= 0 { - slotLenMin = 60 - } - targetSlot := -1 - if !st.TargetTime.IsZero() { - delta := time.Until(st.TargetTime) - if delta > 0 { - targetSlot = int(delta / (time.Duration(slotLenMin) * time.Minute)) - } - } - // Operational ceiling: the lower of the user's target - // and the vehicle-configured charge limit. The car - // won't accept current beyond charge_limit_pct anyway, - // so planning past it is wasted DP grid space. When - // the limit is unknown, fall back to the deadline - // target itself; never plan beyond what was requested. - maxPct := st.TargetSoCPct - if vehicleChargeLimit > 0 && vehicleChargeLimit < maxPct { - maxPct = vehicleChargeLimit - } - // Effective deadline target: when the operator asked - // for 100% but the vehicle (Tesla via TeslaBLEProxy - // etc.) is hard-capped at, say, 60%, the DP must plan - // against the cap — otherwise the deadline-shortfall - // penalty stays elevated forever (the SoC grid maxes - // at the cap, can never reach the operator target), - // and MPC keeps committing grid charging chasing an - // unreachable goal. Cap target_pct to whatever the - // car will physically accept. - targetPct := st.TargetSoCPct - if vehicleChargeLimit > 0 && vehicleChargeLimit < targetPct { - targetPct = vehicleChargeLimit - slog.Info("mpc: target capped to vehicle charge limit", - "lp", st.ID, "operator_target_pct", st.TargetSoCPct, - "vehicle_limit_pct", vehicleChargeLimit) - } - // Guard against degenerate grids: if current SoC > maxPct - // (already over target), grow the ceiling to current so - // the DP can at least represent it (no charging will be - // scheduled). The deadline penalty handles the rest. - if initSoC > maxPct { - maxPct = initSoC - } - // Defer grid-funded EV planning when the deadline lies - // past the last published price slot AND is more than ~3 h - // out. Without this, MPC commits today's afternoon grid - // (today's prices are confirmed) instead of waiting for - // tomorrow's pre-dawn slots (typically published ~13:00 UTC - // for next-day Nordpool). The user-facing intent is "burn - // surplus PV during day, only plan grid at night when the - // real cheap window is known". Setting LoadpointSpec.SurplusOnly - // makes MPC's DP refuse grid-import EV actions; the runtime - // loadpoint controller still grabs PV via the bat-SoC unlock - // path. Once tomorrow's prices land, MPC's next replan - // rebuilds the spec without this guard and proper grid - // planning kicks in. - deferGridPlan := false - if priceSvc != nil { - end := time.Now().Add(72 * time.Hour) - pts, _ := priceSvc.Load(time.Now().UnixMilli(), end.UnixMilli()) - var latestEnd time.Time - for _, p := range pts { - slotEnd := time.UnixMilli(p.SlotTsMs).Add(time.Duration(p.SlotLenMin) * time.Minute) - if slotEnd.After(latestEnd) { - latestEnd = slotEnd - } - } - if !latestEnd.IsZero() && - st.TargetTime.After(latestEnd) && - time.Until(st.TargetTime) > 3*time.Hour { - deferGridPlan = true - } - } - slog.Debug("mpc: loadpoint spec", - "id", st.ID, "soc_pct", initSoC, "soc_source", socSource, - "target_pct", st.TargetSoCPct, "target_slot", targetSlot, - "max_pct", maxPct, "vehicle_limit_pct", vehicleChargeLimit, - "defer_grid_plan", deferGridPlan) - if deferGridPlan { - slog.Info("mpc: LP grid-funded planning deferred — target past published prices", - "lp", st.ID, "target", st.TargetTime, "hours_to_target", time.Until(st.TargetTime).Hours()) - } - // Mirror the deferral into the runtime controller so live - // dispatch enforces "no grid import" too. Without this, - // MPC's plan would still record a small EV budget per slot - // (snapped to forecast surplus); when forecast PV - // undershoots reality, the runtime controller would - // happily import grid to fulfil the cached plan budget. - 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. - batSoCArmed := false - if lpController != nil { - batSoCArmed = lpController.IsBatSoCArmed(st.ID) - } - // 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. - ctrlMu.Lock() - noBatteryToEV := !ctrl.BatteryCoversEV - ctrlMu.Unlock() - specs = append(specs, &mpc.LoadpointSpec{ - ID: st.ID, - CapacityWh: capWh, - Levels: 11, - MinPct: 0, - MaxPct: maxPct, - InitialSoCPct: initSoC, - PluggedIn: true, - TargetSoCPct: targetPct, - TargetSlotIdx: targetSlot, - MaxChargeW: st.MaxChargeW, - AllowedStepsW: st.AllowedStepsW, - ChargeEfficiency: 0.9, - SurplusOnly: st.SurplusOnly || deferGridPlan || batSoCArmed, - NoBatteryToEV: noBatteryToEV, - }) - } - return specs - } - if cfg.Price != nil { - mpcSvc.ExportBonusOreKwh = cfg.Price.ExportBonusOreKwh - mpcSvc.ExportFeeOreKwh = cfg.Price.ExportFeeOreKwh - mpcSvc.ExportFloorOreKwh = cfg.Price.ExportFloorOreKwh - mpcSvc.GridTariffOreKwh = cfg.Price.GridTariffOreKwh - mpcSvc.VATPercent = cfg.Price.VATPercent - } - // Persist every replan's Diagnostic so operators can inspect - // past decisions in the planner_diagnostics table. - mpcSvc.SaveDiag = func(d *mpc.Diagnostic, reason string) error { - js, err := json.Marshal(d) - if err != nil { - return err - } - return st.SaveDiagnostic(d.ComputedAtMs, reason, d.Zone, - d.TotalCostOre, d.Horizon, string(js)) - } - mpcSvc.Start(ctx) - defer mpcSvc.Stop() - // Inject plan → control.State. Both callbacks are wired: - // PlanTarget — legacy grid-target path (grid_target_w, mode str) - // SlotDirective — new energy-allocation path (Wh per slot) - // State.UseEnergyDispatch picks which one is actually used when a - // planner mode is active. - ctrl.PlanTarget = mpcSvc.SlotAt - ctrl.SlotDirective = func(now time.Time) (control.SlotDirective, bool) { - d, ok := mpcSvc.SlotDirectiveAt(now) - if !ok { - return control.SlotDirective{}, false - } - return control.SlotDirective{ - SlotStart: d.SlotStart, - SlotEnd: d.SlotEnd, - BatteryEnergyWh: d.BatteryEnergyWh, - SoCTargetPct: d.SoCTargetPct, - Strategy: string(d.Strategy), - PVLimitW: d.PVLimitW, - PlannedGridW: d.GridW, - HasPlannedGridW: true, - LivePVSurplusSoCCapPct: d.LivePVSurplusSoCCapPct, - LoadpointEnergyWh: d.LoadpointEnergyWh, - }, true - } - // Default to the energy-allocation path. The plan is a - // scheduler (decides WHEN each strategy applies); the EMS is - // the regulator (decides HOW batteries react — from live - // telemetry, not plan forecasts). - // `planner.legacy_dispatch: true` opts back to the old - // PI-on-grid-target path for emergency rollback. - // - // Back-compat: honor the deprecated `use_energy_dispatch` - // key when explicitly set. An operator who had - // `use_energy_dispatch: false` in their config before v0.27.0 - // chose legacy on purpose — don't silently flip them. - ctrl.UseEnergyDispatch = cfg.Planner == nil || !cfg.Planner.LegacyDispatch - if cfg.Planner != nil && cfg.Planner.UseEnergyDispatch != nil { - v := *cfg.Planner.UseEnergyDispatch - slog.Warn("planner.use_energy_dispatch is deprecated — use planner.legacy_dispatch: "+ - "true to opt out of the energy path instead. Honored for this run.", - "value", v) - ctrl.UseEnergyDispatch = v - } - // If the restored control mode is a planner variant, push the - // corresponding mpc.Mode so the plan is built with the strategy - // the user actually picked — not whatever cfg.planner.mode says. - // control.PlannerMPCMode is the shared mapping (same one the API - // and HA setters use), so the three paths can't drift. - if mm, ok := control.PlannerMPCMode(ctrl.Mode); ok { - mpcSvc.SetMode(ctx, mm) - } - if mpcSvc.Latest() == nil { - restoreLatestMPCDiagnostic(st, mpcSvc, time.Now()) - } - slog.Info("mpc planner started", - "mode", mpcSvc.Defaults.Mode, - "capacity_wh", mpcSvc.Defaults.CapacityWh, - "horizon", mpcSvc.Horizon, - "interval", mpcSvc.Interval, - "pvtwin", pvSvc != nil) - // Startup replan: the scheduled tick is up to mpcSvc.Interval - // (15 min) away. Don't make the operator wait — fire one - // immediately so /api/mpc/plan is populated as soon as - // telemetry, prices, and forecasts have settled. Observe ctx - // during the warm-up sleep so SIGTERM during startup doesn't - // keep this goroutine alive past shutdown. - go func() { - select { - case <-ctx.Done(): - return - case <-time.After(2 * time.Second): // give drivers a moment to seed SoC - } - if ctx.Err() != nil { - return - } - _ = mpcSvc.Replan(ctx) - slog.Info("mpc: startup replan completed") - }() - } - - // ---- EV loadpoint controller ---- - // loadpoint.Controller owns per-tick EV dispatch, including the - // energy-allocation contract, snapping and phase transitions. - // - // Adapters here keep loadpoint independent of mpc/telemetry - // (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) { - d, ok := mpcSvc.SlotDirectiveAt(now) - if !ok { - return loadpoint.Directive{}, false - } - return loadpoint.Directive{ - SlotStart: d.SlotStart, - SlotEnd: d.SlotEnd, - LoadpointEnergyWh: d.LoadpointEnergyWh, - }, true - } - telAdapter := func(driver string) (loadpoint.EVSample, bool) { - r := tel.Get(driver, telemetry.DerEV) - if r == nil { - return loadpoint.EVSample{}, false - } - // 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 - } - return loadpoint.EVSample{ - PowerW: r.SmoothedW, - SessionWh: d.SessionWh, - Connected: d.Connected, - RequestActive: reqActive, - }, true - } - // An OCPP charge point is not in the driver registry — it connected to - // us rather than being dialled — so route by name: if an online charger - // answers to it, command it over OCPP, otherwise fall through to the - // Lua driver registry. Loadpoints stay unaware of the difference. - send := reg.Send - if ocppSrv != nil { - send = func(ctx context.Context, name string, payload []byte) error { - if ocppSrv.Handler().IsOnline(name) { - return ocppSrv.Command(ctx, name, payload) - } - return reg.Send(ctx, name, payload) - } - } - lpController = loadpoint.NewController(lpMgr, planAdapter, telAdapter, send) - // Wire the site fuse so the per-phase EV clamp and the - // phase-split derivation can use the actual site voltage and - // breaker rating instead of hard-coding 230 V × 16 A. - lpController.SetSiteFuse(loadpoint.SiteFuse{ - MaxAmps: cfg.Fuse.MaxAmps, - Voltage: cfg.Fuse.Voltage, - PhaseCnt: cfg.Fuse.Phases, - }) - // Wire the joint fuse-budget allocator: when battery + EV would - // together bust the fuse, dispatch publishes a cap on EV W; the - // loadpoint controller honours it so battery and EV cooperatively - // share the budget instead of oscillating against the fuse guard. - lpController.SetFuseEVMax(func() (float64, bool) { - ctrlMu.Lock() - defer ctrlMu.Unlock() - if !ctrl.FuseSaturated { - return 0, false - } - return ctrl.FuseEVMaxW, true - }) - // 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) - } - 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) - } - }) - // Wire the live per-phase site-meter current reader. The control - // package's fuse guard is site-TOTAL only (sum across phases); - // a single phase can still trip from house-load imbalance (e.g. - // a vacuum or oven on L1) stacked on top of the EV's per-phase - // draw. This reader feeds the loadpoint's reactive per-phase EV - // clamp (applyPerPhaseFuseClamp), which lowers max_amps_per_phase - // the instant the worst phase approaches the breaker. Reads the - // same meter_lN_a metrics the fuse_over_limit notifier uses. - lpController.SetPerPhaseMeterAmps(func() (float64, float64, float64, bool) { - cfgMu.RLock() - siteMeter := cfg.SiteMeterDriver() - cfgMu.RUnlock() - if siteMeter == "" { - return 0, 0, 0, false - } - l1, _, ok1 := tel.LatestMetric(siteMeter, "meter_l1_a") - l2, _, ok2 := tel.LatestMetric(siteMeter, "meter_l2_a") - l3, _, ok3 := tel.LatestMetric(siteMeter, "meter_l3_a") - if !ok1 && !ok2 && !ok3 { - return 0, 0, 0, false - } - return l1, l2, l3, true - }) - // Wire the matched-vehicle reader for auto-wake. When the - // loadpoint is commanding power but the matched Tesla - // reports `Stopped` / `Disconnected` / `Complete`, the - // controller fires a charge_start command at the vehicle - // driver — TeslaBLEProxy translates it to BLE and re-engages - // the session. Without this, a long pause from the surplus - // clamp (or arbitrage-mode planning) detaches Tesla and - // nothing software-side can wake it. - lpController.SetVehicleStatus(func(lpID string) (string, string, bool) { - st, ok := lpMgr.State(lpID) - if !ok || !st.PluggedIn { - return "", "", false - } - delivering := st.CurrentPowerW > loadpoint.DeliveringW - pick := telemetry.PickBestVehicleForLoadpoint(tel, delivering, time.Now()) - if pick.Driver == "" { - return "", "", false - } - return pick.Driver, pick.ChargingState, true - }) - - // Wire the EV-available surplus computation for the - // surplus_only clamp. We want the W of PV that exceeds house - // load, regardless of how the home battery is currently - // splitting it — otherwise on a sunny day with the home - // battery absorbing all surplus the EV controller would see - // gridW≈0 and conclude "no surplus", contradicting reality. - // - // Identity (using api.go's convention `loadW = gridW − batW - // − pvW − evW`): pvSurplus = −pvW − loadW = −gridW + batW - // + evW. We compute the right-hand form because the - // telemetry store already publishes those three signals - // directly. Returns (_, false) when the site meter is - // missing — without it we can't bound grid import. - // Wire the peak-remaining-surplus reader for the surplus_only - // 1Φ-fallback decision. Iterates the MPC plan's remaining - // daylight slots and returns max(−pvW − loadW). When that - // peak can't sustain a 3Φ minimum (4140 W on a 6 A 3Φ - // charger), surplus_only locks the loadpoint to 1Φ for the - // day rather than pausing it forever — a slow-charging EV - // is still better than no charging at all. - lpController.SetPeakRemainingSurplusW(func() (float64, bool) { - if mpcSvc == nil { - return 0, false - } - plan := mpcSvc.Latest() - if plan == nil || len(plan.Actions) == 0 { - return 0, false - } - now := time.Now() - endOfDay := time.Date(now.Year(), now.Month(), now.Day(), - 23, 59, 59, 0, now.Location()) - 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(endOfDay) { - break - } - surplus := -a.PVW - a.LoadW - if !any || surplus > peak { - peak = surplus - any = true - } - } - if !any { - return 0, false - } - return peak, true - }) - - // Near-term peak surplus: same scan but capped at now + window. - // pickSurplusSteps consults this to decide if a 3Φ window is - // imminent enough to be worth waiting for. When near-term < - // 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. - lpController.SetNearTermPeakSurplusW(func(window time.Duration) (float64, bool) { - if mpcSvc == nil { - return 0, false - } - plan := mpcSvc.Latest() - if plan == nil || len(plan.Actions) == 0 { - 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 - }) - - lpController.SetSiteSurplusForEV(func() (float64, bool) { - meterDriver := cfg.SiteMeterDriver() - if meterDriver == "" { - return 0, false - } - // Refuse to publish a surplus when the meter is stale — - // last-known SmoothedW lingers indefinitely after a - // driver crash, and trusting it would silently violate - // the surplus_only "never import" promise. The watchdog - // timeout matches what the control loop uses elsewhere - // for site-meter staleness. - watchdog := time.Duration(cfg.Site.WatchdogTimeoutS) * time.Second - if watchdog <= 0 { - watchdog = 60 * time.Second - } - if tel.IsStale(meterDriver, telemetry.DerMeter, watchdog) { - return 0, false - } - meter := tel.Get(meterDriver, telemetry.DerMeter) - if meter == nil { - return 0, false - } - gridW := meter.SmoothedW - // Sum battery only over drivers that are currently online. - // A crashed battery driver leaves SmoothedW at last-known - // (e.g. +5 kW from a sunny moment) which would inflate - // surplus indefinitely. - var batW float64 - for _, r := range tel.ReadingsByType(telemetry.DerBattery) { - if h := tel.DriverHealth(r.Driver); h == nil || h.Status == telemetry.StatusOffline { - continue - } - 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, even with the - // grid-charge ban now in place, may still legitimately - // charge the battery from PV surplus. 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. - 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 - }) - - // Bat-SoC surplus-unlock: feed the controller a live home-battery - // SoC reading so a loadpoint with a `surplus_unlock_bat_soc_pct` - // schedule can flip into surplus-snap mode when the battery is - // already comfortable. Sums online battery readings; (_, false) - // when no battery driver is online so the controller leaves the - // arm state untouched (hysteresis preserved across blips). - lpController.SetBatSoCProvider(func() (float64, bool) { - var total, count float64 - for _, r := range tel.ReadingsByType(telemetry.DerBattery) { - if h := tel.DriverHealth(r.Driver); h == nil || h.Status == telemetry.StatusOffline { - continue - } - if r.SoC == nil || *r.SoC <= 0 { - continue - } - total += *r.SoC - count++ - } - if count == 0 { - return 0, false - } - return total / count, true - }) - } - - // ---- Self-update checker ---- - // Probes the GitHub Releases API in the background; the UI reads the - // cached result via /api/version/check. Gated behind FTW_SELFUPDATE_ENABLED - // because the ftw-updater sidecar only exists in the docker-compose deploy. - // Native / OS-image builds will ship their own update mechanism and set - // their own gate (or leave this one off). Deps.SelfUpdate stays nil when - // disabled, which makes every /api/version/* handler return 503 and the - // UI hide the badge. - var selfUpdater *selfupdate.Checker - var optimizerUpdater *selfupdate.Checker - // Implicitly enable for dev binaries (Version=="dev") so `make dev` - // users can click the version label and exercise the probe + modal - // without setting FTW_SELFUPDATE_ENABLED=1. Production builds (real - // vX.Y.Z stamped via -ldflags) still require the explicit env var - // so the feature can't surprise an OS-image deploy. - if envBool("FTW_SELFUPDATE_ENABLED") || Version == "dev" { - // FTW_SELFUPDATE_CURRENT_VERSION overrides what the checker thinks - // it's running so dev / QA can force update_available=true without - // rebuilding with a fake -ldflags Version. Scoped to the checker - // only — /api/status, User-Agent, HA discovery keep reporting the - // real build version. Unset in production; logged loudly when set. - current := Version - if v, ok := os.LookupEnv("FTW_SELFUPDATE_CURRENT_VERSION"); ok && v != "" { - current = v - slog.Warn("selfupdate: CurrentVersion overridden for testing", - "real_version", Version, "reported_version", current, - "env", "FTW_SELFUPDATE_CURRENT_VERSION") - } - selfUpdater = selfupdate.New(selfupdate.Config{ - CurrentVersion: current, - SocketPath: envOr("FTW_UPDATER_SOCKET", "/run/ftw-update/sock"), - StatusPath: envOr("FTW_UPDATER_STATUS", "/run/ftw-update/state.json"), - // Publish events.UpdateAvailable when a new release lands so - // the notifications service (or any other subscriber) can act - // without polling the checker directly. - Bus: bus, - }, st) - selfUpdater.Start(ctx) - optimizerCurrent := "dev" - if mpcSvc != nil && mpcSvc.Optimizer != nil { - if health, ok := mpcSvc.Optimizer.(interface { - Health(context.Context) (mpc.OptimizerRuntimeInfo, error) - }); ok { - healthCtx, healthCancel := context.WithTimeout(ctx, 2*time.Second) - if runtime, err := health.Health(healthCtx); err == nil && runtime.Version != "" { - optimizerCurrent = runtime.Version - } - healthCancel() - } - } - optimizerUpdater = selfupdate.New(selfupdate.Config{ - Repo: "srcfl/ftw", Image: "srcfl/ftw-optimizer", - ReleaseTagPrefix: "optimizer-", StoragePrefix: "optimizer.", - CurrentVersion: optimizerCurrent, - SocketPath: envOr("FTW_UPDATER_SOCKET", "/run/ftw-update/sock"), - StatusPath: envOr("FTW_UPDATER_STATUS", "/run/ftw-update/state.json"), - }, st) - optimizerUpdater.Start(ctx) - slog.Info("selfupdate enabled", - "socket", envOr("FTW_UPDATER_SOCKET", "/run/ftw-update/sock"), - "channel", selfUpdater.Info().Channel) - } else { - slog.Info("selfupdate disabled — set FTW_SELFUPDATE_ENABLED=1 to turn on") - } - - // ---- Start HTTP API ---- - // haBridge is forward-declared at the top of the file so the config - // hot-reload closure can call Reload on it; the bridge instance gets - // wired further down (HA is optional + depends on reg.Names()). - // Self-sovereign site identity: always generated on first boot, Nova- - // format (P-256 PEM) so federation can reuse it, but never dependent on - // Nova being enabled. Canonical path is the same nova.key default so - // existing federated gateways keep their claimed key. - identityKeyPath := filepath.Join(filepath.Dir(statePath), "nova.key") - if cfg.Nova != nil && cfg.Nova.KeyPath != "" { - identityKeyPath = cfg.Nova.KeyPath - } - siteIdentity, err := nova.LoadOrCreateIdentity(identityKeyPath) - if err != nil { - slog.Warn("site identity: load/create failed", "err", err, "path", identityKeyPath) - } else { - slog.Info("site identity ready", "pubkey_prefix", siteIdentity.PublicKeyHex()[:16]) - } - - deps = &api.Deps{ - Tel: tel, LogRing: logRing, Ctrl: ctrl, CtrlMu: ctrlMu, - State: st, - CapMu: capMu, Capacities: capacities, - CfgMu: cfgMu, Cfg: cfg, ConfigPath: *configPath, - DriverDir: resolveDriverDir(), - UserDriverDir: *userDriversDirFlag, - DriverMQTTFactory: reg.MQTTFactory, - DriverModbusFactory: reg.ModbusFactory, - DriverARPLookup: reg.ARPLookup, - Models: models, ModelsMu: modelsMu, - SelfTune: selfTune, - DtS: float64(cfg.Site.ControlIntervalS), - SaveConfig: config.SaveAtomic, - WebDir: *webDir, - ColdDir: coldDir, - DataDir: dataDir, - StatePath: statePath, - BackupDir: backupDir, - DataMaintenanceMu: dataMaintenanceMu, - // Snapshots live next to the rest of the persistent data so - // docker-compose deploys only need one bind (./data). Derived - // from the state.db path rather than the config path because - // `state.db` is always in the main data volume; the config - // can legitimately live elsewhere (e.g. mounted RO from /etc). - SnapshotDir: filepath.Join(filepath.Dir(statePath), "snapshots"), - Prices: priceSvc, - Forecast: forecastSvc, - MPC: mpcSvc, - PVModel: pvSvc, - LoadModel: loadSvc, - Loadpoints: lpMgr, - LoadpointCtrl: lpController, - CalDAV: calSvc, - HA: haBridge, - Registry: reg, - DriverRepository: driverRepository, - Events: bus, - Notifications: notifSvc, - 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. - if selfUpdater != nil { - if err := selfUpdater.Trigger(reqCtx, "restart", ""); err == nil { - slog.Info("restart: dispatched via updater sidecar") - return nil - } else { - slog.Info("restart: sidecar unavailable, falling back to in-process exit", "err", err) - } - } - // Fallback: drop the main control loop out of its select so - // every defer (HA Stop, st.Close, http.Shutdown, …) runs - // cleanly. The os.Exit(1) at the bottom of the defer stack - // then makes docker (`unless-stopped`) and systemd - // (`Restart=on-failure`) bring the binary back up. - restartOnce.Do(func() { - exitCode = 1 - close(restartCh) - }) - return nil - }, - Version: Version, - } - srv := api.New(deps) - // Dev-mode proxy: when FTW_PROXY_UPSTREAM is set (e.g. - // http://192.168.1.139:8080), /api/* is forwarded to that instance so - // the local UI renders live data without owning the control loop. - // Unset / empty = proxy disabled, /api/* served locally as normal. - // Read-only by default — writes (POST/PUT/…) come back as 403 so a - // stray Save in the dev UI can't mutate the real instance. Set - // FTW_PROXY_READONLY=0 if you explicitly need to exercise write paths. - handler := srv.Handler() - if up := os.Getenv("FTW_PROXY_UPSTREAM"); up != "" { - u, err := url.Parse(up) - if err != nil || u.Scheme == "" || u.Host == "" { - slog.Error("FTW_PROXY_UPSTREAM invalid — must be like http://host:port", "value", up, "err", err) - return - } - readOnly := true - if v, ok := os.LookupEnv("FTW_PROXY_READONLY"); ok { - switch strings.ToLower(v) { - case "0", "false", "no", "off": - readOnly = false - } - } - handler = proxy.Wrap(handler, proxy.Config{Upstream: u, ReadOnly: readOnly}) - slog.Warn("proxy enabled — /api/* forwards upstream", - "upstream", u.String(), - "read_only", readOnly) - } - // Swap the boot-phase handler for the fully wired mux — the listener - // bound at startup stays; no port gap for healthcheck probes. - apiHandler.Swap(handler) - slog.Info("HTTP API ready", "addr", httpSrv.Addr) - - // Belt-and-suspenders integrity scan, off the startup hot path: a clean - // restart skips the blocking boot check (so a multi-GB DB starts in seconds), - // and this still catches at-rest corruption without making control wait. It - // self-arms a heal on the next boot if it finds rot. - st.VerifyInBackground() - defer func() { - shutdownCtx, c := context.WithTimeout(context.Background(), 5*time.Second) - defer c() - _ = httpSrv.Shutdown(shutdownCtx) - }() - - // ---- Notifications (always constructed so API + applier hold live refs) ---- - // Provider selection uses the strategy registry in internal/notifications; - // only "ntfy" is registered today, but adding a new one is drop-in. - notifProvider = notifications.NewProvider(cfg.Notifications) - var notifPub notifications.Publisher - if notifProvider != nil { - notifPub = notifProvider - } - notifSvc = notifications.New(cfg.Notifications, notifPub, func(name string) (string, string, string, bool) { - dev := st.LookupDeviceByDriverName(name) - if dev == nil { - return "", "", "", false - } - return dev.DeviceID, dev.Make, dev.Serial, true - }) - // FuseReader: on each HealthTick the fuse_over_limit rule reads - // the site meter's live per-phase currents from telemetry and - // compares against cfg.Fuse.MaxAmps. Closes over cfg + cfgMu so - // hot-reloaded fuse changes take effect without restart; closes - // over tel so new metric emits are picked up immediately. - notifSvc.SetFuseReader(func() (map[string]float64, float64, bool) { - cfgMu.RLock() - siteMeter := cfg.SiteMeterDriver() - limitA := cfg.Fuse.MaxAmps - cfgMu.RUnlock() - if siteMeter == "" || limitA <= 0 { - return nil, 0, false - } - amps := map[string]float64{} - for _, phase := range []string{"l1", "l2", "l3"} { - if v, _, ok := tel.LatestMetric(siteMeter, "meter_"+phase+"_a"); ok { - amps[strings.ToUpper(phase)] = v - } - } - if len(amps) == 0 { - return nil, limitA, false - } - return amps, limitA, true - }) - notifSvc.Subscribe(bus) - // Persist every dispatch to state.notification_log via a bus - // subscriber so the notifications package stays free of storage - // logic. The UI reads this table through /api/notifications/history. - bus.Subscribe(events.KindNotificationDispatched, func(e events.Event) { - ev, ok := e.(events.NotificationDispatched) - if !ok { - return - } - if err := st.RecordNotification(state.NotificationEntry{ - TsMs: ev.Time.UnixMilli(), - EventType: ev.EventType, - Driver: ev.Driver, - Title: ev.Title, - Body: ev.Body, - Priority: ev.Priority, - Status: ev.Status, - Error: ev.Error, - }); err != nil { - slog.Warn("notification_log: record failed", "err", err) - } - }) - // Late-bind onto the Deps literal that was built earlier with a nil - // notifSvc (the deps struct is assembled before this block runs). - // Same pattern haBridge uses a few lines below. - deps.Notifications = notifSvc - if cfg.Notifications != nil && cfg.Notifications.Enabled { - name := "ntfy" - if notifProvider != nil { - name = notifProvider.Name() - } - slog.Info("notifications enabled", "provider", name) - } - - // ---- HA MQTT bridge (optional) ---- - if cfg.HomeAssistant != nil && cfg.HomeAssistant.Enabled { - bridge, err := ha.Start(cfg.HomeAssistant, tel, ctrl, ctrlMu, reg.Names(), haCallbacks(ctx, ctrl, ctrlMu, st, mpcSvc), mpcPlanSource(mpcSvc), haEnergySource(st)) - if err != nil { - slog.Warn("HA MQTT bridge failed to start", "err", err) - } else { - haBridge = bridge - deps.HA = haBridge // late-binding for API - } - } - // Stop deferred for whichever bridge instance is current at exit - // time — Reload may have swapped haBridge mid-flight, so re-read here - // rather than capturing the boot-time pointer. - defer func() { - if haBridge != nil { - haBridge.Stop() - } - }() - - // ---- Nova Core federation (optional) ---- - // Publishes telemetry to Sourceful Nova Core's MQTT broker (NATS - // MQTT adapter). Requires a one-time `ftw nova-claim` - // bootstrap to register the gateway's ES256 key and provision - // device/DER records under an org. When disabled or unconfigured, - // this block is a no-op. - if cfg.Nova != nil && cfg.Nova.Enabled { - // Load the persistent gateway identity only for explicitly enabled - // federation. Keep the canonical nova.key path so already-claimed - // gateways retain their identity across upgrades. - identityKeyPath := filepath.Join(filepath.Dir(statePath), "nova.key") - if cfg.Nova.KeyPath != "" { - identityKeyPath = cfg.Nova.KeyPath - } - siteIdentity, err := nova.LoadOrCreateIdentity(identityKeyPath) - if err != nil { - slog.Warn("nova federation disabled — gateway identity unavailable", "err", err, "path", identityKeyPath) - } else if pub, err := nova.Start(cfg.Nova, siteIdentity, st, tel); err != nil { - slog.Warn("nova publisher failed to start", "err", err) - } else if pub != nil { - defer pub.Stop() - slog.Info("nova federation enabled", - "mqtt", fmt.Sprintf("%s:%d", cfg.Nova.MQTTHost, cfg.Nova.MQTTPort), - "gateway_serial", cfg.Nova.GatewaySerial, - "schema_mode", cfg.Nova.SchemaMode) - } - } - - // ---- Background: Parquet rolloff (>14d → cold dir) ---- - coldRetentionDays := 0 - if cfg.State != nil { - coldRetentionDays = cfg.State.ColdRetentionDays - } - go rolloffLoop(ctx, st, coldDir, coldRetentionDays, dataMaintenanceMu) - - // ---- Background: daily state.db recovery snapshot ---- - go snapshotLoop(ctx, st) - - // ---- Control loop ---- - controlInterval := time.Duration(cfg.Site.ControlIntervalS) * time.Second - // fuseMaxW is recomputed per tick from ctrl.SiteFuse* under ctrlMu — - // the configreload watcher updates those fields directly, so a - // startup snapshot here would go stale on the first hot-reload. - dtS := float64(cfg.Site.ControlIntervalS) - - // Graceful shutdown - sigc := make(chan os.Signal, 1) - signal.Notify(sigc, os.Interrupt, syscall.SIGTERM) - - ticker := time.NewTicker(controlInterval) - defer ticker.Stop() - var saveCount uint64 - // Track FuseSaturated edge so we replan once when the joint allocator - // kicks in — the plan was made without knowledge of the live overage, - // and a replan with current EV/PV/load state usually finds a feasible - // schedule that doesn't fight the fuse. - var prevFuseSaturated bool - var lastFuseReplan time.Time - const fuseReplanCooldown = 60 * time.Second - var lastMissingPlanReplan time.Time - const missingPlanReplanCooldown = 5 * time.Second - // One-shot replan when the FIRST DerVehicle reading arrives. The - // startup replan ran with whatever fallback SoC was available; once - // the Tesla / vehicle driver gets ground truth from the car, the - // plan should incorporate it (especially for EV target deadlines). - var vehicleReplanFired bool - // Per-loadpoint last observed draw for the EV-stop edge replan. - // A falling edge (was >100 W, now <50 W while still plugged in) - // signals the EV finished or paused itself — the slot's plan no - // longer reflects reality (battery may have been held at 0 to leave - // room for the EV; with EV gone the freed PV should be re-allocated). - // Cooldown shares the fuse-replan cooldown to keep per-second flap - // from spamming the optimizer. - evDrawPrev := map[string]float64{} - var lastEVStopReplan time.Time - const evStopReplanCooldown = 60 * time.Second - const evStopHigh = 100.0 // W — "was actually drawing" - const evStopLow = 50.0 // W — "now essentially zero" - for { - select { - case <-sigc: - slog.Info("shutting down") - if err := st.RecordEvent("shutdown"); err != nil { - slog.Warn("failed to persist shutdown event", "err", err) - } - return - case <-restartCh: - slog.Info("restart requested via API — exiting cleanly so the supervisor brings us back") - if err := st.RecordEvent("restart"); err != nil { - slog.Warn("failed to persist restart event", "err", err) - } - return - case <-ticker.C: - nowMs := time.Now().UnixMilli() - - // ---- Continuous learning: feed (last_command, actual) per battery ---- - // Skip while self-tune is active — the override would corrupt RLS. - if !selfTune.Status().Active { - modelsMu.Lock() - ctrlMu.Lock() - lastTargets := append([]control.DispatchTarget{}, ctrl.LastTargets...) - ctrlMu.Unlock() - for _, t := range lastTargets { - r := tel.Get(t.Driver, telemetry.DerBattery) - if r == nil { - continue - } - m, ok := models[t.Driver] - if !ok { - continue - } - soc := 0.5 - if r.SoC != nil { - soc = *r.SoC - } - m.Update(t.TargetW, r.SmoothedW, soc, dtS, nowMs) - } - modelsMu.Unlock() - } - - // ---- Self-tune tick ---- - if selfTune.Status().Active { - modelsMu.Lock() - selfTune.Tick(func(name string) (float64, float64, bool) { - r := tel.Get(name, telemetry.DerBattery) - if r == nil { - return 0, 0, false - } - soc := 0.5 - if r.SoC != nil { - soc = *r.SoC - } - return r.SmoothedW, soc, true - }, models, dtS, nowMs) - modelsMu.Unlock() - } - - // ---- Watchdog: mark stale drivers offline, revert them to autonomous ---- - watchdogTimeout := time.Duration(cfg.Site.WatchdogTimeoutS) * time.Second - if watchdogTimeout <= 0 { - watchdogTimeout = 60 * time.Second - } - cfgMu.RLock() - troubleshootingMode := cfg.Site.TroubleshootingMode - cfgMu.RUnlock() - for _, tr := range tel.WatchdogScan(watchdogTimeout) { - if !tr.Online { - slog.Warn("driver telemetry stale — marking offline + reverting to autonomous", - "name", tr.Name, "timeout", watchdogTimeout) - sendDriverDefault(ctx, reg, tr.Name, "watchdog") - bus.Publish(events.DriverLost{Driver: tr.Name, At: time.Now()}) - } else { - slog.Info("driver telemetry recovered — back online", "name", tr.Name) - bus.Publish(events.DriverRecovered{Driver: tr.Name, At: time.Now()}) - } - } - // Fire a HealthTick so subscribers that track user-level - // thresholds (e.g. notifications) can evaluate their own - // rules without the control loop knowing about them. - bus.Publish(events.HealthTick{Health: tel.AllHealth(), Now: time.Now()}) - - // ---- EV dispatch first — independent of the site meter ---- - // Loadpoint Observe() reads its own telemetry (the EV - // charger driver), so it MUST run before the site-meter - // staleness guard below — otherwise a missing/stale site - // meter silently freezes the LP manager's plugged_in - // state, MPC never extends the DP with the EV dimension, - // and the operator sees "schedule set but EV never - // charges". The surplus-only clamp inside the LP controller - // already returns 0 when site surplus is unknown, so this - // is safe: bad grid signal → LP paused, but at least the - // LP state machine is alive. - lpMgr.RollSchedules(time.Now().UTC()) - lpController.Tick(ctx, time.Now()) - - // Anchor each plugged-in loadpoint's inferred SoC to the live - // vehicle BMS reading when one is paired. Chargers like Easee - // can't read the car, so the manager otherwise drifts on a - // plug-in-anchor + delivered-Wh estimate; when a vehicle driver - // (TeslaBLEProxy etc.) is online and matched, its SoC is ground - // truth. Runs after Tick's Observe so the per-tick re-anchor - // wins over that tick's inference. Same picker the MPC spec and - // api.go's loadpoint decoration use, so all three agree on which - // vehicle is "the one"; we additionally require !Stale so a - // driver serving last-known cache (car asleep) can't pin the - // dashboard to a stale value — inference takes over until fresh - // BMS data returns. - for _, st := range lpMgr.States() { - if !st.PluggedIn { - continue - } - delivering := st.CurrentPowerW > loadpoint.DeliveringW - pick := telemetry.PickBestVehicleForLoadpoint(tel, delivering, time.Now()) - if pick.Driver == "" || pick.Stale { - continue - } - lpMgr.AnchorVehicleSoC(st.ID, pick.SoCPct) - } - - // ---- Safety: site meter stale → idle everything this cycle ---- - // Otherwise stale grid readings cause one battery to charge another. - // Only meaningful when a site meter is actually configured; - // without one, IsStale("", DerMeter) is permanently true and - // the SendDefault loop below would fire on every tick — and - // SendDefault is a blocking send into each driver's cmdCh, - // which deadlocks the dispatch loop the first time any - // driver's channel buffer fills. - ctrlMu.Lock() - siteMeterDriver := ctrl.SiteMeterDriver - ctrlMu.Unlock() - siteMeterStale := false - if siteMeterDriver != "" { - siteMeterStale = tel.IsStale(siteMeterDriver, telemetry.DerMeter, watchdogTimeout) - } - if siteMeterStale { - slog.Warn("site meter telemetry stale — idling batteries this cycle", - "driver", siteMeterDriver) - if troubleshootingMode { - slog.Info("troubleshooting: site meter stale, dispatch skipped", - "site_meter", siteMeterDriver, "timeout", watchdogTimeout) - } - for _, n := range driversToDefaultOnSiteMeterStale(reg.Names(), siteMeterDriver) { - sendDriverDefault(ctx, reg, n, "site_meter_stale") - } - continue - } - - // ---- Compute dispatch ---- - capMu.RLock() - capsSnap := make(map[string]float64, len(capacities)) - for k, v := range capacities { - capsSnap[k] = v - } - capMu.RUnlock() - - // Surplus-only EV reserve: aggregate PV headroom to leave - // for surplus_only loadpoints. Per LP, reserves - // min(MaxChargeW, CurrentPowerW + EVRampHeadroomW) so the - // figure tracks the EV's actual draw rather than its - // theoretical max — when an EV is physically holding at - // e.g. 2.5 kW (1Φ × 11 A under phase hysteresis), the prior - // "reserve = MaxChargeW = 11 kW" form ate ~8.5 kW of the - // available surplus and starved the battery on - // over-forecast PV slots even when the plan said charge. - // Result is injected into ctrl.EVSurplusOnlyReserveW and - // consumed by dispatch.go in both the energy and the - // legacy/reactive paths. Computed every tick so toggling - // surplus_only, plugging/unplugging, or an EV ramp picks - // up immediately. - // Build the wake-kick-active set so the reserve calc can - // hold the floor for an LP whose wallbox is actively - // offering current to a still-ramping EV. Without this, - // the home battery would snatch the freed surplus during - // the brief gap before the EV's contactor settles. - lpStatesSnapshot := lpMgr.States() - var wakeKickActiveIDs map[string]bool - if lpController != nil { - now := time.Now() - for i := range lpStatesSnapshot { - st := &lpStatesSnapshot[i] - // Populate ManualActive on the dispatch-path snapshot - // (lpMgr.States() doesn't set it — only the API does) so - // SurplusReserveW can drop the reserve for a force-charging - // LP. Without this the manual/schedule override in - // SurplusReserveW never sees a manual hold and the - // no-discharge floor flaps the battery support. - if _, ok := lpController.GetManualHold(st.ID, now); ok { - st.ManualActive = true - } - if !st.PluggedIn || !st.SurplusOnly { - continue - } - if lpController.IsWakeKickActive(st.ID, now) { - if wakeKickActiveIDs == nil { - wakeKickActiveIDs = map[string]bool{} - } - wakeKickActiveIDs[st.ID] = true - } - } - } - evReserveW := loadpoint.SurplusReserveW(lpStatesSnapshot, wakeKickActiveIDs) - // Parallel curtail-side reserve — more permissive than the - // dispatch reserve above; counts plugged-but-stopped EVs - // with SoC headroom so PV isn't cut when a vehicle could - // resume charging. - evCurtailHeadroomW := loadpoint.SurplusPotentialW(lpStatesSnapshot) - - ctrlMu.Lock() - ctrl.EVSurplusOnlyReserveW = evReserveW - ctrl.EVCurtailHeadroomW = evCurtailHeadroomW - fuseMaxW := ctrl.SiteFuseAmps * ctrl.SiteFuseVoltage * float64(ctrl.SiteFusePhases) - targets := control.ComputeDispatch(tel, ctrl, capsSnap, fuseMaxW) - planMissingNow := ctrl.Mode.IsPlannerMode() && ctrl.PlanStale - ctrlMu.Unlock() - - // ---- Self-tune override: force commanded battery, hold others at 0 ---- - finalTargets := targets - selfTuneName, selfTuneCmd, selfTuneActive := selfTune.CurrentCommand() - if selfTuneActive { - finalTargets = make([]control.DispatchTarget, 0, len(reg.Names())) - for _, n := range reg.Names() { - if n == selfTuneName { - finalTargets = append(finalTargets, control.DispatchTarget{Driver: n, TargetW: selfTuneCmd}) - } else { - finalTargets = append(finalTargets, control.DispatchTarget{Driver: n, TargetW: 0}) - } - } - } - if troubleshootingMode { - gridW, haveGrid := troubleshootingGridW(tel, siteMeterDriver) - pvW := troubleshootingSumOnlineW(tel, telemetry.DerPV) - batW := troubleshootingSumOnlineW(tel, telemetry.DerBattery) - evW := tel.SumOnlineEVW() - v2xW := tel.SumOnlineV2XW() - attrs := []any{ - "mode", ctrl.Mode, - "plan_stale", planMissingNow, - "site_meter", siteMeterDriver, - "grid_known", haveGrid, - "grid_w", gridW, - "pv_w", pvW, - "bat_w", batW, - "ev_w", evW, - "v2x_w", v2xW, - "self_tune_active", selfTuneActive, - "self_tune_driver", selfTuneName, - "self_tune_command_w", selfTuneCmd, - "targets", targets, - "final_targets", finalTargets, - } - if haveGrid { - attrs = append(attrs, "load_w", gridW-batW-pvW-evW-v2xW) - } - slog.Info("troubleshooting: dispatch decision", attrs...) - } - - // ---- Dispatch to drivers ---- - for _, t := range finalTargets { - payload, _ := json.Marshal(map[string]any{"action": "battery", "power_w": t.TargetW}) - if err := reg.Send(ctx, t.Driver, payload); err != nil { - slog.Warn("driver send", "name", t.Driver, "err", err) - } - } - - // ---- PV curtailment dispatch ---- - // MPC's annotateCurtailment sets pv_limit_w on slots where - // exporting more PV would lose money (negative spot, no - // positive feed-in tariff). ComputePVCurtail picks the - // drivers that opted in via supports_pv_curtail and emits - // either a `curtail` command (limit > 0) or a one-shot - // `curtail_disable` when a previously-curtailed driver - // drops out of the active set. - ctrlMu.Lock() - curtailTargets := control.ComputePVCurtail(ctrl, tel) - ctrlMu.Unlock() - for _, c := range curtailTargets { - var payload []byte - if c.LimitW > 0 { - payload, _ = json.Marshal(map[string]any{ - "action": "curtail", - "power_w": c.LimitW, - }) - } else { - payload, _ = json.Marshal(map[string]any{ - "action": "curtail_disable", - }) - } - if err := reg.Send(ctx, c.Driver, payload); err != nil { - slog.Warn("pv curtail send", "name", c.Driver, "err", err) - } - } - - // LP dispatch ran at the top of this tick — see the - // "EV dispatch first" block above. - - // ---- Trigger MPC replan on fuse-saturation rising edge ---- - // The joint allocator (control.dispatch) just throttled - // battery and EV to fit under the fuse. The plan was built - // without seeing this overage, so let MPC redraw with current - // state — usually it finds a slot allocation that doesn't - // require both battery charge and full-bore EV simultaneously. - ctrlMu.Lock() - fuseSatNow := ctrl.FuseSaturated - ctrlMu.Unlock() - if mpcSvc != nil && fuseSatNow && !prevFuseSaturated && time.Since(lastFuseReplan) > fuseReplanCooldown { - lastFuseReplan = time.Now() - go mpcSvc.Replan(ctx) - slog.Info("fuse-saturated → MPC replan triggered") - } - prevFuseSaturated = fuseSatNow - - // Missing-plan retry: plans are in-memory only, so after an - // update/restart the first dispatch cycles can see nil/stale - // planner state. That must not wait for the normal 15 min - // interval; rebuild immediately, retrying briefly if prices / - // forecasts / telemetry were not ready during startup. - if mpcSvc != nil && planMissingNow && time.Since(lastMissingPlanReplan) > missingPlanReplanCooldown { - lastMissingPlanReplan = time.Now() - go mpcSvc.ReplanWithReason(ctx, "missing_plan_retry") - slog.Info("missing MPC plan → replan triggered") - } - - // EV-stop edge replan trigger: when an LP's draw drops - // from a clearly-charging value to ~0 while still plugged, - // the current plan slot's allocation (e.g. "idle, the EV - // takes the surplus") is stale — fire a replan so the DP - // re-routes the freed PV. Unplug edges are NOT a trigger: - // the plug-out itself toggles plugged_in→false which the - // scheduled replan path picks up, and chaining a replan - // there would race with the LP manager. Cooldown bounded - // to one replan per evStopReplanCooldown so a hardware - // flap doesn't storm the optimizer. - if mpcSvc != nil { - fired := false - for _, lp := range lpMgr.States() { - prev := evDrawPrev[lp.ID] - curr := lp.CurrentPowerW - if lp.PluggedIn && prev >= evStopHigh && curr < evStopLow && - time.Since(lastEVStopReplan) > evStopReplanCooldown && !fired { - lastEVStopReplan = time.Now() - fired = true - go mpcSvc.ReplanWithReason(ctx, "loadpoint_ev_stopped") - slog.Info("loadpoint EV stopped → MPC replan triggered", - "lp", lp.ID, "prev_w", prev, "curr_w", curr) - } - evDrawPrev[lp.ID] = curr - } - } - - // First-vehicle-SoC replan trigger: as soon as any - // DerVehicle driver is online and reporting SoC, redo the - // plan once with measured-truth instead of the pluginSoC - // estimate the startup replan used. - if mpcSvc != nil && !vehicleReplanFired { - for _, vr := range tel.ReadingsByType(telemetry.DerVehicle) { - if vr.SoC == nil { - continue - } - if h := tel.DriverHealth(vr.Driver); h == nil || !h.IsOnline() { - continue - } - vehicleReplanFired = true - go mpcSvc.Replan(ctx) - slog.Info("first vehicle SoC seen → MPC replan triggered", - "driver", vr.Driver, "soc", *vr.SoC) - break - } - } - - // ---- Persist the tick: history snapshot + flushed metrics ---- - // One transaction for both — separate commits doubled the WAL - // commit rate for no isolation benefit (SD-card wear). - hp := buildHistoryPoint(tel, ctrl, nowMs) - samples := tel.FlushSamples() - stSamples := make([]state.Sample, len(samples)) - for i, sm := range samples { - stSamples[i] = state.Sample{Driver: sm.Driver, Metric: sm.Metric, TsMs: sm.TsMs, Value: sm.Value, Unit: sm.Unit} - } - if err := st.RecordTick(hp, stSamples); err != nil { - slog.Warn("tick persistence failed", "samples", len(samples), "err", err) - } - - // ---- Periodic battery-model persistence (every 12 cycles ≈ 60s) ---- - saveCount++ - if saveCount%12 == 0 { - modelsMu.Lock() - for name, m := range models { - if data, err := json.Marshal(m); err == nil { - if err := st.SaveBatteryModel(name, string(data)); err != nil { - slog.Warn("failed to persist battery model", "battery", name, "err", err) - } - } - } - modelsMu.Unlock() - } - } - } -} - -// snapshotLoop writes a recovery snapshot of state.db daily and once on -// shutdown. The snapshot is the restore source if state.db corrupts (see -// state.openChecked). cache.db needs none — it's re-fetchable. Daily (not -// hourly) keeps SD-card write-wear low while bounding worst-case loss of -// precious data to one day. -func snapshotLoop(ctx context.Context, st *state.Store) { - // Initial snapshot shortly after boot so a fresh install gets one fast. - first := time.NewTimer(2 * time.Minute) - defer first.Stop() - tick := time.NewTicker(24 * time.Hour) - defer tick.Stop() - doSnap := func() { - if err := st.SnapshotState(); err != nil { - slog.Warn("state snapshot failed", "err", err) - } else { - slog.Info("state snapshot written") - } - } - for { - select { - case <-ctx.Done(): - doSnap() // best-effort on graceful shutdown - return - case <-first.C: - doSnap() - case <-tick.C: - doSnap() - } - } -} - -// rolloffLoop runs the SQLite → Parquet roll-off once per hour. Cheap when -// nothing is due (a single SELECT returns 0 rows); only does real work once -// data crosses the 14-day boundary into cold storage. -func rolloffLoop(ctx context.Context, st *state.Store, coldDir string, coldRetentionDays int, dataMaintenanceMu *sync.Mutex) { - tick := time.NewTicker(1 * time.Hour) - defer tick.Stop() - var lastDiskWarn time.Time - run := func() { - if dataMaintenanceMu != nil { - dataMaintenanceMu.Lock() - defer dataMaintenanceMu.Unlock() - } - doRolloff(ctx, st, coldDir) - - // The bulk DELETEs above just generated a WAL burst; reclaim it now - // instead of letting the -wal file ratchet upward on the SD card. - st.CheckpointWAL() - - if removed, err := state.PruneColdParquet(coldDir, coldRetentionDays, time.Now()); err != nil { - slog.Warn("cold parquet retention prune failed", "err", err) - } else if len(removed) > 0 { - slog.Info("cold parquet retention", "removed_files", len(removed), "retention_days", coldRetentionDays) - } - - // Disk watch: an SD card that fills up takes SQLite down with it. - // Warn loudly (log + event feed) at most once per day. - if avail, err := state.DiskAvail(coldDir); err == nil { - const lowWater = 500 << 20 // 500 MB - if avail < lowWater && time.Since(lastDiskWarn) > 24*time.Hour { - lastDiskWarn = time.Now() - slog.Error("disk space low — history rolloff and SQLite writes are at risk", - "avail_mb", avail>>20) - if err := st.RecordEvent(fmt.Sprintf( - "disk space low: %d MB available — consider state.cold_retention_days", avail>>20)); err != nil { - slog.Warn("record disk-low event failed", "err", err) - } - } - } - } - // Run once at startup so a fresh boot catches any backlog. - run() - for { - select { - case <-ctx.Done(): - return - case <-tick.C: - run() - } - } -} - -func doRolloff(ctx context.Context, st *state.Store, coldDir string) { - // Age the fixed-column dashboard history on the same cadence as the - // long-format TS + diagnostics rolloff below. Prune is idempotent and pure - // SQL; without this call history_hot/history_warm grow forever even though - // ts_samples is correctly moved to Parquet. - if err := st.Prune(ctx); err != nil { - slog.Warn("history tier prune failed", "err", err) - } - - rows, files, err := st.RolloffToParquet(ctx, coldDir) - if err != nil { - slog.Warn("parquet rolloff failed", "err", err) - } else if rows > 0 { - slog.Info("parquet rolloff", "rows", rows, "files", len(files)) - } - // Planner diagnostics roll off on the same cadence but keep a - // longer hot tier (30 d vs. the 14 d of ts_samples) — they're - // sparse enough (~100/day) that the extra month in SQLite - // costs < 60 MB and makes the time-travel UI snappy for - // recent-incident debugging. - dRows, dFiles, err := st.RolloffDiagnosticsToParquet(ctx, coldDir) - if err != nil { - slog.Warn("diagnostics parquet rolloff failed", "err", err) - return - } - if dRows > 0 { - slog.Info("diagnostics parquet rolloff", - "rows", dRows, "files", len(dFiles)) - } -} - -// registerAllDevices snapshots the identity HostEnv has gathered for each -// running driver and upserts a row in the devices table. Idempotent. -// Called periodically because some drivers (notably MQTT) only learn their -// serial after the first message from the device. -func registerAllDevices(st *state.Store, reg *drivers.Registry) { - for _, name := range reg.Names() { - env := reg.Env(name) - if env == nil { - continue - } - make, sn, mac, ep := env.FullIdentity() - dev := state.Device{ - DriverName: name, - Make: make, - Serial: sn, - MAC: mac, - Endpoint: ep, - } - if id, err := st.RegisterDevice(dev); err == nil && id != "" { - slog.Debug("device registered", "name", name, "device_id", id, "make", make, "sn", sn, "mac", mac) - } - } -} - -const driverDefaultTimeout = 2 * time.Second - -func sendDriverDefault(ctx context.Context, reg *drivers.Registry, name, reason string) { - cmdCtx, cancel := context.WithTimeout(ctx, driverDefaultTimeout) - defer cancel() - if err := reg.SendDefault(cmdCtx, name); err != nil { - slog.Warn("driver default command failed", - "name", name, "reason", reason, "timeout", driverDefaultTimeout, "err", err) - } -} - -// driversToDefaultOnSiteMeterStale returns the driver names to revert to -// DefaultMode when the site meter goes stale: every driver EXCEPT the -// site-meter owner itself. Skipping the meter owner avoids a flap loop on -// combined meter+battery devices (Pixii / Ferroamp-class) whose stale meter -// reading is usually their own hung modbus poll — writing DefaultMode into -// that same session cuts the battery in/out every minute. The "don't act on -// a stale grid signal" protection only applies to the OTHER batteries. -func driversToDefaultOnSiteMeterStale(names []string, siteMeterDriver string) []string { - out := make([]string, 0, len(names)) - for _, n := range names { - if n == siteMeterDriver { - continue - } - out = append(out, n) - } - return out -} - -// driverCapacitiesFrom builds the driver-name → battery-capacity map -// the MPC sums into Params.CapacityWh and the control layer uses for -// fuse-guard / peak-shave sizing. -// -// Critically: drivers that are referenced by a loadpoint entry are -// EV chargers, not home batteries — their `battery_capacity_wh` -// represents VEHICLE capacity and must NOT land in the MPC battery -// pool (doing so inflates SoC %, terminal-value credit, and the -// discharge-headroom DP arithmetic). Found live on Fredrik's Pi: his -// Easee entry had battery_capacity_wh=75000, which combined with -// Ferroamp (15.2 kWh) + Sungrow (9.6 kWh) gave a fantasy 99.8 kWh -// battery pool. -// -// Filtering here rather than at config-parse time means the vehicle -// capacity is still available for EV-side logic (loadpoint manager) -// without a schema migration. -func driverCapacitiesFrom(drvList []config.Driver, loadpoints []config.Loadpoint, catalog []drivers.CatalogEntry) map[string]float64 { - evDrivers := make(map[string]struct{}, len(loadpoints)) - for _, lp := range loadpoints { - // Only treat a loadpoint row as authoritative when it's - // valid enough for loadpoint.Manager to actually load it. - // An entry with an empty id is rejected by the manager (see - // loadpoint.Manager.Load) — accepting it here would silently - // drop a real battery from the MPC pool on nothing but - // config noise. - if lp.ID == "" || lp.DriverName == "" { - continue - } - evDrivers[lp.DriverName] = struct{}{} - } - out := make(map[string]float64, len(drvList)) - for _, d := range drvList { - if d.BatteryCapacityWh <= 0 { - continue - } - if d.BatteryTelemetryOnly || drivers.IsReadOnlyDriver(catalog, d.Lua) { - // A telemetry gateway must never enter MPC/dispatch even if an old - // or hand-written config also carries a battery capacity. - continue - } - if _, isEV := evDrivers[d.Name]; isEV { - // Don't count EV vehicle capacity as battery capacity. - // (Value remains in cfg.Drivers for any driver-side use.) - continue - } - // Fallback detection for operators who haven't migrated to a - // `loadpoints:` config block: ask the catalog whether the - // driver self-declares an EV or vehicle capability. Source of - // truth is the Lua DRIVER table; Go matches on what the - // driver says it is, not what its filename happens to look - // like. - if drivers.IsEVOrVehicleDriver(catalog, d.Lua) { - continue - } - out[d.Name] = d.BatteryCapacityWh - } - return out -} - -// driverLimitsFrom builds the driver-name → per-battery PowerLimits map -// used by control.State for per-battery charge/discharge caps (#145). -// Reads the drivers section first, then falls back to the batteries -// section for the same key — operators commonly set per-battery limits -// only under `batteries:` (the MPC reads them from there), and without -// this fallback the dispatcher silently uses the 5 kW MaxCommandW -// default while the planner schedules against the configured 9 kW. -// Drivers without limits in either place are omitted from the map. -func driverLimitsFrom(drivers []config.Driver, batteries map[string]config.Battery) map[string]control.PowerLimits { - out := map[string]control.PowerLimits{} - for _, d := range drivers { - chg, dis := d.MaxChargeW, d.MaxDischargeW - if b, ok := batteries[d.Name]; ok { - if chg == 0 && b.MaxChargeW != nil && *b.MaxChargeW > 0 { - chg = *b.MaxChargeW - } - if dis == 0 && b.MaxDischargeW != nil && *b.MaxDischargeW > 0 { - dis = *b.MaxDischargeW - } - } - if chg == 0 && dis == 0 { - continue - } - out[d.Name] = control.PowerLimits{ - MaxChargeW: chg, - MaxDischargeW: dis, - } - } - return out -} - -// inverterGroupsFrom builds the driver-name → inverter-group map used by -// control.State for DC-local charge routing (see issue #143). Only -// drivers that set an explicit `inverter_group` make the map; untagged -// drivers inherit today's capacity-proportional behaviour. -// -// A PV-only driver and a battery driver on the same physical inverter -// should both set the same group (e.g. both `inverter_group: ferroamp`) -// so distributeProportional can link PV output to the co-located -// battery's charge target. Config-reload calls this again and swaps the -// map atomically in the control state. -func inverterGroupsFrom(drivers []config.Driver) map[string]string { - out := map[string]string{} - for _, d := range drivers { - if d.InverterGroup == "" { - continue - } - out[d.Name] = d.InverterGroup - } - return out -} - -// supportsPVCurtailFrom builds the per-driver opt-in map used by -// ComputePVCurtail. Operators set `supports_pv_curtail: true` on -// each driver whose lua handles the `curtail` / `curtail_disable` -// actions (sungrow, ferroamp, deye, huawei, solis ship with it). -// Drivers not in the map are silently skipped by the curtail -// dispatcher — no risk of an EV charger receiving a curtail payload. -func supportsPVCurtailFrom(drivers []config.Driver) map[string]bool { - out := map[string]bool{} - for _, d := range drivers { - if d.SupportsPVCurtail { - out[d.Name] = true - } - } - return out -} - -// warnIfEVHasBatteryCapacity surfaces operator mis-config where an EV -// driver's YAML entry still carries battery_capacity_wh. The value is -// now ignored for MPC battery-pool purposes, but we log at WARN so the -// operator moves it to the loadpoint's vehicle_capacity_wh (where it -// serves the DP's EV SoC inference) rather than leaving it as a -// silent no-op. -// -// catalog is the parsed driver catalog (Lua DRIVER tables); the -// detection consults it for self-declared "ev" / "vehicle" capability -// rather than sniffing filenames or vendor names. -func warnIfEVHasBatteryCapacity(drvList []config.Driver, loadpoints []config.Loadpoint, catalog []drivers.CatalogEntry) { - evDrivers := make(map[string]struct{}, len(loadpoints)) - for _, lp := range loadpoints { - if lp.ID == "" || lp.DriverName == "" { - continue - } - evDrivers[lp.DriverName] = struct{}{} - } - for _, d := range drvList { - if d.BatteryCapacityWh <= 0 { - continue - } - _, isEVByLoadpoint := evDrivers[d.Name] - isEVByCatalog := drivers.IsEVOrVehicleDriver(catalog, d.Lua) - if !isEVByLoadpoint && !isEVByCatalog { - continue - } - reason := "driver is referenced by a loadpoint" - if !isEVByLoadpoint && isEVByCatalog { - reason = "driver self-declares ev/vehicle capability in its Lua DRIVER table" - } - slog.Warn(reason+" — battery_capacity_wh is being ignored for MPC "+ - "battery-pool sizing. Move the value to "+ - "loadpoints[].vehicle_capacity_wh to keep EV SoC inference "+ - "working.", - "driver", d.Name, - "lua", d.Lua, - "battery_capacity_wh", d.BatteryCapacityWh) - } -} - -// firstLoadpointID returns the ID of the first configured loadpoint, or "". -// Used as the fallback target for a calendar EV event whose title names no -// specific loadpoint and when caldav.ev_loadpoint_id is unset. -func firstLoadpointID(src []config.Loadpoint) string { - if len(src) > 0 { - return src[0].ID - } - return "" -} - -// caldavUsername resolves the configured CalDAV username. The runtime fallback -// remains the former default so an existing config that omitted the field does -// not silently move its principal; fresh UI/example configs write `ftw`. -func caldavUsername(cv *config.CalDAV) string { - if cv != nil && strings.TrimSpace(cv.Username) != "" { - return strings.TrimSpace(cv.Username) - } - return config.DefaultCalDAVUsername -} - -// nativeCalDAVLayout derives the principal path + the collections the -// in-process CalDAV server (#498) should expose, from config (with defaults). -func nativeCalDAVLayout(cv *config.CalDAV) (principal string, calendarPaths []string, feeds map[string]string) { - principal = "/" + caldavUsername(cv) + "/" - calPath := config.DefaultCalDAVCalendarPath - histPath := config.DefaultCalDAVHistoryPath - planPath := config.DefaultCalDAVPlanPath - if cv != nil { - if strings.TrimSpace(cv.CalendarPath) != "" { - calPath = cv.CalendarPath - } - if strings.TrimSpace(cv.HistoryPath) != "" { - histPath = cv.HistoryPath - } - if strings.TrimSpace(cv.PlanPath) != "" { - planPath = cv.PlanPath - } - } - // Only the read-only collections get a one-tap webcal:// feed; the - // read-write "energy" collection is where the user *writes* intents, so a - // read-only subscription would be the wrong tool for it. - feeds = map[string]string{"plan": planPath, "history": histPath} - return principal, []string{calPath, histPath, planPath}, feeds -} - -// evSamplesFromTelemetry projects current DerEV readings into the shape the -// calendar service's history writer consumes (#498). One sample per EV -// charge-point driver; the writer turns charge→idle transitions into events. -func evSamplesFromTelemetry(tel *telemetry.Store) []calendar.EVSample { - readings := tel.ReadingsByType(telemetry.DerEV) - out := make([]calendar.EVSample, 0, len(readings)) - for _, r := range readings { - var d struct { - Connected *bool `json:"connected"` - Charging *bool `json:"charging"` - SessionWh *float64 `json:"session_wh"` - } - if len(r.Data) > 0 { - _ = json.Unmarshal(r.Data, &d) - } - var sessionWh float64 - if d.SessionWh != nil { - sessionWh = *d.SessionWh - } - out = append(out, calendar.EVSample{ - ID: r.Driver, - Connected: d.Connected != nil && *d.Connected, - Charging: d.Charging != nil && *d.Charging, - SessionWh: sessionWh, - PowerW: r.SmoothedW, - }) - } - return out -} - -// planSlotsFromMPC projects the latest MPC plan into the shape the calendar -// service's plan publisher consumes. Nil-safe: returns nil -// when the planner is disabled or has no plan yet. -func planSlotsFromMPC(mpcSvc *mpc.Service) []calendar.PlanSlot { - if mpcSvc == nil { - return nil - } - plan := mpcSvc.Latest() - if plan == nil { - return nil - } - out := make([]calendar.PlanSlot, 0, len(plan.Actions)) - for _, a := range plan.Actions { - start := time.UnixMilli(a.SlotStartMs) - ln := a.SlotLenMin - if ln <= 0 { - ln = 15 - } - out = append(out, calendar.PlanSlot{ - Start: start, - End: start.Add(time.Duration(ln) * time.Minute), - BatteryW: a.BatteryW, - GridW: a.GridW, - SoCPct: a.SoCPct, - Confidence: a.Confidence, - }) - } - return out -} - -// buildLoadpointConfigs adapts YAML-facing config.Loadpoint entries -// into the internal loadpoint.Config shape. Shared between initial -// boot and the hot-reload watcher so the two paths can't drift. -func buildLoadpointConfigs(src []config.Loadpoint) []loadpoint.Config { - out := make([]loadpoint.Config, 0, len(src)) - for _, lp := range src { - out = append(out, loadpoint.Config{ - ID: lp.ID, - DriverName: lp.DriverName, - MinChargeW: lp.MinChargeW, - MaxChargeW: lp.MaxChargeW, - AllowedStepsW: lp.AllowedStepsW, - VehicleCapacityWh: lp.VehicleCapacityWh, - PluginSoCPct: lp.PluginSoCPct, - PhaseMode: lp.PhaseMode, - PhaseSplitW: lp.PhaseSplitW, - MinPhaseHoldS: lp.MinPhaseHoldS, - SurplusOnly: lp.SurplusOnly, - }) - } - return out -} - -func mpcBatteryFleetFromConfig(cfg *config.Config, capacities map[string]float64) []mpc.BatteryFleetMember { - fleet := make([]mpc.BatteryFleetMember, 0, len(capacities)) - for _, d := range cfg.Drivers { - cap := capacities[d.Name] - if cap <= 0 { - continue - } - // Default max (de)charge = 0.5C unless overridden. Zero is a - // legitimate one-sided constraint — `max_charge_w: 0` means - // "forbid charging, allow discharge only" and mpc.Optimize's - // action grid (`-MaxDischargeW…+MaxChargeW`) supports it. - // Negative is always a config mistake. - // - // Only the *both-zero* case is treated as a config error (and - // almost certainly is — it kills the planner's entire action - // space while leaving the service running). We fall back to - // default in that case and log a warning. - defaultP := cap / 2 - chg := defaultP - dis := defaultP - if b, ok := cfg.Batteries[d.Name]; ok { - bothZero := b.MaxChargeW != nil && *b.MaxChargeW == 0 && - b.MaxDischargeW != nil && *b.MaxDischargeW == 0 - if bothZero { - slog.Warn("mpc: batteries.max_{charge,discharge}_w both 0 — treating as config error, using default 0.5C", - "driver", d.Name, "default_w", defaultP) - } else { - if b.MaxChargeW != nil && *b.MaxChargeW >= 0 { - chg = *b.MaxChargeW - } else if b.MaxChargeW != nil { - slog.Warn("mpc: ignoring negative batteries.max_charge_w; using default 0.5C", - "driver", d.Name, "value", *b.MaxChargeW, "default_w", defaultP) - } - if b.MaxDischargeW != nil && *b.MaxDischargeW >= 0 { - dis = *b.MaxDischargeW - } else if b.MaxDischargeW != nil { - slog.Warn("mpc: ignoring negative batteries.max_discharge_w; using default 0.5C", - "driver", d.Name, "value", *b.MaxDischargeW, "default_w", defaultP) - } - } - } - fleet = append(fleet, mpc.BatteryFleetMember{ - Driver: d.Name, - CapacityWh: cap, - MaxChargeW: chg, - MaxDischargeW: dis, - }) - } - return fleet -} - -// aggregateBatteryLimits sums capacity + max charge/discharge across -// battery drivers the MPC should plan for, applying fuse-capacity -// clamps. Returned values are what buildMPC used to compute inline at -// startup — hoisted into a helper so the config-reload path can call -// it and push the new totals into an already-running mpc.Service. -func aggregateBatteryLimits(cfg *config.Config, capacities map[string]float64) (totalCap, maxChg, maxDis float64) { - return aggregateBatteryFleetLimits(cfg, mpcBatteryFleetFromConfig(cfg, capacities)) -} - -func aggregateBatteryFleetLimits(cfg *config.Config, fleet []mpc.BatteryFleetMember) (totalCap, maxChg, maxDis float64) { - for _, b := range fleet { - totalCap += b.CapacityWh - maxChg += b.MaxChargeW - maxDis += b.MaxDischargeW - } - // Clamp aggregate charge/discharge to the grid fuse capacity. The - // control loop's fuse guard enforces this per-tick anyway, but a - // planner that schedules 45 kW of charge through a 16 A fuse (11 kW) - // produces SoC projections that can never be realised — the optimiser - // "charges" to 100% in the plan while the battery barely budges in - // reality, and every downstream decision (when to discharge, when to - // idle, what the total cost looks like) is based on that fantasy. - // Cheaper to keep the plan feasible up-front. - if fuseMaxW := cfg.Fuse.MaxPowerW(); fuseMaxW > 0 { - if maxChg > fuseMaxW { - slog.Info("mpc: clamping MaxChargeW to fuse capacity", - "requested_w", maxChg, "fuse_w", fuseMaxW) - maxChg = fuseMaxW - } - if maxDis > fuseMaxW { - slog.Info("mpc: clamping MaxDischargeW to fuse capacity", - "requested_w", maxDis, "fuse_w", fuseMaxW) - maxDis = fuseMaxW - } - } - return totalCap, maxChg, maxDis -} - -// buildMPC constructs a planner from config. Returns nil if disabled, -// if prices aren't configured, or if there are no batteries with capacity. -func buildMPC(cfg *config.Config, st *state.Store, tel *telemetry.Store, capacities map[string]float64) *mpc.Service { - if cfg.Planner == nil || !cfg.Planner.Enabled { - return nil - } - if cfg.Price == nil || cfg.Price.Provider == "" || cfg.Price.Provider == "none" { - slog.Warn("mpc requires price provider — skipping") - return nil - } - fleet := mpcBatteryFleetFromConfig(cfg, capacities) - totalCap, maxChg, maxDis := aggregateBatteryFleetLimits(cfg, fleet) - if totalCap <= 0 { - slog.Warn("mpc: no battery capacity — skipping") - return nil - } - pl := cfg.Planner - zone := "SE3" - if cfg.Price != nil && cfg.Price.Zone != "" { - zone = cfg.Price.Zone - } - mode := mpc.Mode(pl.Mode) - if mode == "" { - mode = mpc.ModeSelfConsumption - } - socMin := pl.SoCMinPct - if socMin <= 0 { - socMin = 10 - } - socMax := pl.SoCMaxPct - if socMax <= 0 || socMax > 100 { - socMax = 95 - } - if pl.SoCSafetyFloorPct != 0 || pl.SafetyFloorPenaltyOreKwhHour != 0 { - slog.Warn("config: soc_safety_floor_pct / safety_floor_penalty_ore_kwh_hour are deprecated and ignored — forecast-risk reserve is now handled by pv_forecast_safety_k (downside-PV planning)") - } - pvBonus := pl.PVChargeBonusOreKwh - if pvBonus < 0 { - pvBonus = 0 - } - chgEff := pl.ChargeEfficiency - if chgEff <= 0 { - chgEff = 0.95 - } - disEff := pl.DischargeEfficiency - if disEff <= 0 { - disEff = 0.95 - } - params := mpc.Params{ - Mode: mode, - SoCLevels: 41, - CapacityWh: totalCap, - SoCMinPct: socMin, - SoCMaxPct: socMax, - PVChargeBonusOreKwh: pvBonus, - InitialSoCPct: 50, - // ActionLevels = 81 → 225 W discretization step on a ±9 kW - // action range. Coarser values (21=900 W, 41=450 W) lose - // borderline-PV slots: on a 273 W net surplus the 450 W min - // charge action overshoots ModeSelfConsumption's no-battery- - // export rule (gridW ends up positive past tolerance) and the - // DP falls back to idle/export the surplus. 81 levels lets the - // DP land on +225 W and absorb the surplus into the battery. - // DP complexity is O(N×S×A×EL×EA) — at the production 192-slot - // × 41-SoC × 1-EV grid, 81 actions is ~636k evaluations, - // still ~5 ms per replan on the Pi. - ActionLevels: 81, - MaxChargeW: maxChg, - MaxDischargeW: maxDis, - ChargeEfficiency: chgEff, - DischargeEfficiency: disEff, - ExportOrePerKWh: pl.ExportOrePerKWh, - } - svc := mpc.New(st, tel, zone, params) - svc.UpdateBatteryFleet(fleet, totalCap, maxChg, maxDis) - engine := pl.Engine - if engine == "" { - engine = "python" - } - if engine == "python" { - transportMode := pl.OptimizerTransport - if fromEnv := os.Getenv("FTW_OPTIMIZER_TRANSPORT"); fromEnv != "" { - transportMode = fromEnv - } - if transportMode == "" { - transportMode = "process" - } - socketPath := pl.OptimizerSocket - if fromEnv := os.Getenv("FTW_OPTIMIZER_SOCKET"); fromEnv != "" { - socketPath = fromEnv - } - if socketPath == "" { - socketPath = "/run/ftw-optimizer/optimizer.sock" - } - python := pl.OptimizerCommand - if python == "" { - python = envOr("FTW_OPTIMIZER_PYTHON", "python3") - } - moduleDir := pl.OptimizerDir - if fromEnv := os.Getenv("FTW_OPTIMIZER_DIR"); fromEnv != "" { - moduleDir = fromEnv - } - if moduleDir == "" { - moduleDir = resolveOptimizerDir() - } - timeout := time.Duration(pl.OptimizerTimeoutS * float64(time.Second)) - if timeout <= 0 { - timeout = 30 * time.Second - } - idleTimeout := time.Duration(pl.OptimizerIdleTimeoutS * float64(time.Second)) - if idleTimeout <= 0 { - idleTimeout = 2 * time.Minute - } - cvarWeight := 0.15 - if pl.OptimizerCVaRWeight != nil { - cvarWeight = *pl.OptimizerCVaRWeight - } - var multistage mpc.MultistageOptimizerConfig - if ms := pl.OptimizerMultistage; ms != nil { - multistage = mpc.MultistageOptimizerConfig{ - ScenarioLimit: ms.ScenarioLimit, BranchIntervalSlots: ms.BranchIntervalSlots, - BranchHorizonSlots: ms.BranchHorizonSlots, MaxBranching: ms.MaxBranching, - NearHorizonSlots: ms.NearHorizonSlots, MidHorizonSlots: ms.MidHorizonSlots, - MidBlockSlots: ms.MidBlockSlots, FarBlockSlots: ms.FarBlockSlots, - ServiceCVaRWeight: ms.ServiceCVaRWeight, ServiceCVaRAlpha: ms.ServiceCVaRAlpha, - EconomicCVaRWeight: ms.EconomicCVaRWeight, EconomicCVaRAlpha: ms.EconomicCVaRAlpha, - DecompositionThreshold: ms.DecompositionThreshold, DecompositionMethod: ms.DecompositionMethod, - PHMaxIterations: ms.PHMaxIterations, PHRho: ms.PHRho, PHToleranceW: ms.PHToleranceW, - } - } - ext, err := mpc.NewExternalOptimizer(mpc.ExternalOptimizerConfig{ - Command: []string{python, "-m", "ftw_optimizer.worker"}, - ModuleDir: moduleDir, Timeout: timeout, - TransportMode: transportMode, SocketPath: socketPath, - Solver: pl.OptimizerSolver, Formulation: pl.OptimizerFormulation, - MIPRelGap: pl.OptimizerMIPRelGap, - CVaRWeight: cvarWeight, CVaRAlpha: pl.OptimizerCVaRAlpha, - IdleTimeout: idleTimeout, - Multistage: multistage, - }) - if err != nil { - slog.Error("mpc: configure primary optimizer failed; using Go DP", "err", err) - } else { - svc.Optimizer = ext - svc.EnableRecourseShadow = pl.OptimizerRecourseShadow - svc.RecourseNonAnticipativeSlots = pl.OptimizerRecourseNonAnticipativeSlots - svc.ChallengerPolicy = pl.OptimizerChallengerPolicy - if svc.ChallengerPolicy == "" { - svc.ChallengerPolicy = "recourse" - } - if svc.RecourseNonAnticipativeSlots <= 0 { - svc.RecourseNonAnticipativeSlots = 1 - } - slog.Info("mpc: Python optimizer configured", "python", python, - "module_dir", moduleDir, "transport", transportMode, "socket", socketPath, - "timeout", timeout, "idle_timeout", idleTimeout, - "recourse_shadow", svc.EnableRecourseShadow, - "challenger_policy", svc.ChallengerPolicy, - "recourse_non_anticipative_slots", svc.RecourseNonAnticipativeSlots) - } - } else { - slog.Warn("mpc: legacy Go DP selected explicitly", "engine", engine) - } - svc.BaseLoad = pl.BaseLoadW - if pl.HorizonHours > 0 { - svc.Horizon = time.Duration(pl.HorizonHours) * time.Hour - } - if pl.IntervalMin > 0 { - svc.Interval = time.Duration(pl.IntervalMin) * time.Minute - } - return svc -} - -func resolveOptimizerDir() string { - candidates := []string{"optimizer", "../optimizer", "/app/optimizer"} - if exe, err := os.Executable(); err == nil { - candidates = append([]string{filepath.Join(filepath.Dir(exe), "optimizer")}, candidates...) - } - for _, candidate := range candidates { - if st, err := os.Stat(filepath.Join(candidate, "ftw_optimizer")); err == nil && st.IsDir() { - return candidate - } - } - return "optimizer" -} - -func driverRepositoryRefreshLoop(ctx context.Context, repository *driverrepo.Manager, intervalHours int) { - if intervalHours <= 0 { - intervalHours = 24 - } - refresh := func() { - refreshCtx, cancel := context.WithTimeout(ctx, 30*time.Second) - defer cancel() - if err := repository.Refresh(refreshCtx, ""); err != nil { - slog.Warn("driver repository refresh failed; keeping last-good cache", "err", err) - } - } - refresh() - ticker := time.NewTicker(time.Duration(intervalHours) * time.Hour) - defer ticker.Stop() - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - refresh() - } - } -} - -// isConfigMissing checks whether the error from config.Load indicates the -// config file does not exist (as opposed to a parse or validation error). -// config.Load wraps the os error with fmt.Errorf, so we use errors.Is to -// unwrap through the chain. -func isConfigMissing(err error) bool { - if err == nil { - return false - } - if errors.Is(err, os.ErrNotExist) { - return true - } - return strings.Contains(err.Error(), "no such file") -} - -func buildHistoryPoint(tel *telemetry.Store, ctrl *control.State, nowMs int64) state.HistoryPoint { - gridW := 0.0 - if r := tel.Get(ctrl.SiteMeterDriver, telemetry.DerMeter); r != nil { - gridW = r.SmoothedW - } - var pvW, batW, sumSoC float64 - var socCount int - for _, r := range tel.ReadingsByType(telemetry.DerPV) { - pvW += r.SmoothedW - } - for _, r := range tel.ReadingsByType(telemetry.DerBattery) { - batW += r.SmoothedW - if r.SoC != nil { - sumSoC += *r.SoC - socCount++ - } - } - avgSoC := 0.0 - if socCount > 0 { - avgSoC = sumSoC / float64(socCount) - } - evW := tel.SumOnlineEVW() - v2xW := tel.SumOnlineV2XW() - loadW := gridW - batW - pvW - evW - v2xW - if loadW < 0 { - loadW = 0 - } - - // Per-driver detail packed into the JSON column. The schema is - // schema-less by design — UI code reads what it understands and - // ignores the rest, so drivers can add fields without a migration. - perDriver := make(map[string]map[string]float64) - for name, h := range tel.AllHealth() { - row := map[string]float64{} - if r := tel.Get(name, telemetry.DerBattery); r != nil { - row["bat_w"] = r.SmoothedW - if r.SoC != nil { - row["soc"] = *r.SoC - } - } - if r := tel.Get(name, telemetry.DerPV); r != nil { - row["pv_w"] = r.SmoothedW - } - if r := tel.Get(name, telemetry.DerMeter); r != nil { - row["meter_w"] = r.SmoothedW - } - // EV charge power: required for the live chart's EV series - // (web/app.js reads `d.ev_w` per driver from /api/history). - // Without it the chart's EV trace is always zero — the in-memory - // /api/status DOES carry ev_w, but history rows never did until - // this row was added. - if r := tel.Get(name, telemetry.DerEV); r != nil { - row["ev_w"] = r.SmoothedW - } - if r := tel.Get(name, telemetry.DerV2X); r != nil { - row["v2x_w"] = r.SmoothedW - if r.SoC != nil { - row["v2x_vehicle_soc"] = *r.SoC - } - } - _ = h - perDriver[name] = row - } - targets := make(map[string]float64) - for _, t := range ctrl.LastTargets { - targets[t.Driver] = t.TargetW - } - jsonBlob, _ := json.Marshal(map[string]any{ - "drivers": perDriver, - "targets": targets, - "ev_w": evW, - "v2x_w": v2xW, - "load_house_w": loadW, - }) - return state.HistoryPoint{ - TsMs: nowMs, GridW: gridW, PVW: pvW, BatW: batW, LoadW: loadW, BatSoC: avgSoC, - JSON: string(jsonBlob), - } -} - -func restoreLatestMPCDiagnostic(st *state.Store, svc *mpc.Service, now time.Time) { - if st == nil || svc == nil { - return - } - row, err := st.LoadDiagnosticAt(now.UnixMilli()) - if err != nil { - slog.Warn("mpc: load persisted diagnostic failed", "err", err) - return - } - if row == nil || row.JSON == "" { - return - } - var d mpc.Diagnostic - if err := json.Unmarshal([]byte(row.JSON), &d); err != nil { - slog.Warn("mpc: decode persisted diagnostic failed", "ts_ms", row.TsMs, "err", err) - return - } - if svc.RestoreDiagnostic(&d, now, "restored_diagnostic") { - slog.Info("mpc: restored active plan from persisted diagnostic", - "ts_ms", row.TsMs, - "mode", d.Params.Mode, - "horizon", d.Horizon, - "reason", row.Reason) - } -} - -// envOr returns the env var's value if it is set (even if empty, so an -// operator can explicitly blank a path to disable a feature — see -// docs/self-update.md on FTW_UPDATER_SOCKET=""). Returns def only when -// the variable is unset. -// haCallbacks builds the bridge's command-callback set. Extracted so -// the boot-time ha.Start path and the configreload "disabled → enabled" -// path can share the exact same wiring — drift between them would mean -// HA commands behave one way after boot and a different way after a -// hot-reload, which is the kind of silent skew that's hardest to debug. -func haCallbacks(ctx context.Context, ctrl *control.State, ctrlMu *sync.Mutex, st *state.Store, mpcSvc *mpc.Service) ha.CommandCallbacks { - return ha.CommandCallbacks{ - SetMode: func(m string) error { - mode := control.Mode(m) - // Accept exactly the set the HA discovery `select` advertises — - // control.AllModes via IsValidMode — so a planner_* option an - // operator picks in Home Assistant isn't silently rejected here. - // Mirror the full /api/mode side-effects (manual-hold + PI reset - // + MPC propagation); the two setters must behave identically or - // HA mode changes diverge from web-UI ones (#mode-drift). - if !control.IsValidMode(mode) { - return fmt.Errorf("unknown mode: %s", m) - } - ctrlMu.Lock() - ctrl.Mode = mode - ctrl.ClearBatteryManualHold() - if ctrl.PI != nil { - ctrl.PI.Reset() - } - ctrlMu.Unlock() - if err := st.SaveConfig("mode", m); err != nil { - return err - } - if mm, ok := control.PlannerMPCMode(mode); ok && mpcSvc != nil { - mpcSvc.SetMode(ctx, mm) - } - return nil - }, - SetGridTarget: func(w float64) error { - ctrlMu.Lock() - defer ctrlMu.Unlock() - ctrl.SetGridTarget(w) - return st.SaveConfig("grid_target_w", strconv.FormatFloat(w, 'f', 1, 64)) - }, - SetPeakLimit: func(w float64) error { - ctrlMu.Lock() - defer ctrlMu.Unlock() - ctrl.PeakLimitW = w - return nil - }, - SetEVCharging: func(w float64, active bool) error { - ctrlMu.Lock() - defer ctrlMu.Unlock() - ctrl.SetManualEVCharging(w, active) - return nil - }, - SetBatteryCoversEV: func(enabled bool) error { - ctrlMu.Lock() - ctrl.BatteryCoversEV = enabled - ctrlMu.Unlock() - val := "false" - if enabled { - val = "true" - } - return st.SaveConfig("battery_covers_ev", val) - }, - } -} - -// mpcPlanBridge adapts *mpc.Service to ha.PlanSource without creating an -// import cycle between ha and mpc. -type mpcPlanBridge struct{ svc *mpc.Service } - -func (b mpcPlanBridge) LatestActions() []ha.PlanAction { - if b.svc == nil { - return nil - } - plan := b.svc.Latest() - if plan == nil { - return nil - } - out := make([]ha.PlanAction, len(plan.Actions)) - for i, a := range plan.Actions { - out[i] = ha.PlanAction{ - SlotStartMs: a.SlotStartMs, - SlotLenMin: a.SlotLenMin, - BatteryW: a.BatteryW, - GridW: a.GridW, - SoCPct: a.SoCPct, - PriceOre: a.PriceOre, - SpotOre: a.SpotOre, - CostOre: a.CostOre, - Confidence: a.Confidence, - Reason: a.Reason, - EMSMode: a.EMSMode, - PVW: a.PVW, - LoadW: a.LoadW, - } - } - return out -} - -func mpcPlanSource(svc *mpc.Service) ha.PlanSource { - if svc == nil { - return nil - } - return mpcPlanBridge{svc: svc} -} - -// stateEnergyBridge adapts *state.Store to ha.EnergySource. -type stateEnergyBridge struct{ st *state.Store } - -func (b stateEnergyBridge) TodayEnergy() (ha.TodayEnergySnapshot, bool) { - now := time.Now() - midnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) - d, err := b.st.DailyEnergy(midnight.UnixMilli(), now.UnixMilli()) - if err != nil { - return ha.TodayEnergySnapshot{}, false - } - return ha.TodayEnergySnapshot{ - ImportWh: d.ImportWh, - ExportWh: d.ExportWh, - PVWh: d.PVWh, - BatChargedWh: d.BatChargedWh, - BatDischargedWh: d.BatDischargedWh, - LoadWh: d.LoadWh, - }, d.Intervals > 0 -} - -func haEnergySource(st *state.Store) ha.EnergySource { - if st == nil { - return nil - } - return stateEnergyBridge{st: st} -} - -func envOr(key, def string) string { - if v, ok := os.LookupEnv(key); ok { - return v - } - return def -} - -func troubleshootingGridW(tel *telemetry.Store, siteMeterDriver string) (float64, bool) { - if siteMeterDriver == "" { - return 0, false - } - r := tel.Get(siteMeterDriver, telemetry.DerMeter) - if r == nil { - return 0, false - } - return r.SmoothedW, true -} - -func troubleshootingSumOnlineW(tel *telemetry.Store, typ telemetry.DerType) float64 { - var sum float64 - for _, r := range tel.ReadingsByType(typ) { - h := tel.DriverHealth(r.Driver) - if h == nil || !h.IsOnline() { - continue - } - sum += r.SmoothedW - } - return sum -} - -// envBool returns true iff the env var is set to a positive value -// (1/true/yes/on, case-insensitive). Unset or any other value = false. -func envBool(key string) bool { - switch strings.ToLower(os.Getenv(key)) { - case "1", "true", "yes", "on": - return true - } - return false -} +// ftw — Home Energy Management System. +// +// Don't Panic 🐬 +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "log/slog" + "net/http" + "net/url" + "os" + "os/signal" + "path/filepath" + "strconv" + "strings" + "sync" + "syscall" + "time" + + "github.com/srcfl/ftw/go/internal/api" + "github.com/srcfl/ftw/go/internal/arp" + "github.com/srcfl/ftw/go/internal/battery" + "github.com/srcfl/ftw/go/internal/caldavserver" + "github.com/srcfl/ftw/go/internal/calendar" + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/configreload" + "github.com/srcfl/ftw/go/internal/control" + "github.com/srcfl/ftw/go/internal/currency" + "github.com/srcfl/ftw/go/internal/devtools" + "github.com/srcfl/ftw/go/internal/driverrepo" + "github.com/srcfl/ftw/go/internal/drivers" + "github.com/srcfl/ftw/go/internal/events" + "github.com/srcfl/ftw/go/internal/forecast" + "github.com/srcfl/ftw/go/internal/ha" + "github.com/srcfl/ftw/go/internal/loadmodel" + "github.com/srcfl/ftw/go/internal/loadpoint" + modbuscli "github.com/srcfl/ftw/go/internal/modbus" + "github.com/srcfl/ftw/go/internal/mpc" + mqttcli "github.com/srcfl/ftw/go/internal/mqtt" + "github.com/srcfl/ftw/go/internal/notifications" + "github.com/srcfl/ftw/go/internal/nova" + "github.com/srcfl/ftw/go/internal/ocpp" + "github.com/srcfl/ftw/go/internal/priceforecast" + "github.com/srcfl/ftw/go/internal/prices" + "github.com/srcfl/ftw/go/internal/proxy" + "github.com/srcfl/ftw/go/internal/pvmodel" + "github.com/srcfl/ftw/go/internal/selftune" + "github.com/srcfl/ftw/go/internal/selfupdate" + "github.com/srcfl/ftw/go/internal/state" + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// Version gets injected at build time via -ldflags. Defaults to "dev" for +// local runs. +var Version = "dev" + +func main() { + // Subcommand dispatch — a bare first non-flag argument selects one + // of the bootstrap CLIs, e.g. `ftw nova-claim --url=…`. + // Everything else is the long-running service. + if len(os.Args) > 1 && !strings.HasPrefix(os.Args[1], "-") { + switch os.Args[1] { + case "nova-claim": + // Shift os.Args so the subcommand's flag.FlagSet sees its own flags. + runNovaClaim(os.Args[2:]) + return + } + } + + configPath := flag.String("config", "config.yaml", "Path to config.yaml") + webDir := flag.String("web", "web", "Path to static web UI directory") + driverDirFlag := flag.String("drivers", "", "Path to drivers directory (default: /drivers)") + userDriversDirFlag := flag.String("user-drivers", "", "Path to PERSISTENT user-drivers directory (overlay on top of -drivers). Searched first; falls back to -drivers when a file isn't found here. Designed for docker deploys.") + // Developer utility — seeds state.db with N days of synthetic history + // so /api/energy/daily has something to render locally. Refuses to run + // if the target DB already holds non-synthetic rows (prod-safety gate); + // -backfill-force bypasses that check. Exits after seeding without + // starting the service. + backfillDays := flag.Int("backfill", 0, "DEV ONLY: seed N days of synthetic history into state.db then exit (0 disables)") + backfillStep := flag.Duration("backfill-step", 5*time.Second, "DEV ONLY: backfill sample interval") + backfillSeed := flag.Int64("backfill-seed", 0, "DEV ONLY: backfill rng seed (0 = random)") + backfillForce := flag.Bool("backfill-force", false, "DEV ONLY: bypass the non-synthetic-data safety gate") + flag.Parse() + + // Drivers default to a sibling of the config file (historical layout: + // config.yaml + drivers/ + seed/ + state.db all under one dir). Docker + // breaks that convention because /app/data is a host bind mount while + // drivers are baked into the image at /app/drivers — the flag lets the + // CMD point at the immutable image location. + resolveDriverDir := func() string { + if *driverDirFlag != "" { + return *driverDirFlag + } + return filepath.Join(filepath.Dir(*configPath), "drivers") + } + + // Wire a process-wide log ring so /api/drivers/{name}/logs and the + // support-bundle endpoint can return recent activity without going + // to disk. The ring captures every slog record AND forwards to the + // stdout text handler so journald/podman logs/docker logs are + // unchanged. Driver-scoped records (slog.With("driver", name)) are + // routed into a per-driver sub-ring by the wrapping handler. + logRing := telemetry.NewLogRing() + stdoutHandler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}) + logger := slog.New(telemetry.NewLogHandler(stdoutHandler, logRing)) + slog.SetDefault(logger) + slog.Info("FTW starting", "version", Version, "config", *configPath) + + // Route "drivers/.lua" path resolution through the drivers dir + // (from -drivers). Picked up by both the initial Load below and every + // subsequent reload via the file watcher. + config.DriversDirOverride = resolveDriverDir() + // UserDriversDirOverride is the persistent overlay — probed first. + // Empty when -user-drivers is not supplied (back-compat). + config.UserDriversDirOverride = *userDriversDirFlag + config.ManagedDriversDirOverride = filepath.Join(filepath.Dir(*configPath), "driver-repository", "active") + + // ---- Load config ---- + cfg, err := config.Load(*configPath) + if err != nil { + if isConfigMissing(err) { + runBootstrap(*configPath, *webDir, resolveDriverDir()) + return + } + slog.Error("load config", "err", err) + os.Exit(1) + } + slog.Info("config loaded", "site", cfg.Site.Name, "drivers", len(cfg.Drivers)) + + // ---- Open persistent state (SQLite) ---- + statePath := "state.db" + coldDir := "cold" + if cfg.State != nil { + if cfg.State.Path != "" { + statePath = cfg.State.Path + } + if cfg.State.ColdDir != "" { + coldDir = cfg.State.ColdDir + } + } + // Resolve to absolute so paths derived via filepath.Dir(statePath) + // (SnapshotDir, nova.key) don't end up cwd-relative on native installs + // where the working directory may differ from the data volume. + if abs, err := filepath.Abs(statePath); err == nil { + statePath = abs + } + dataDir := filepath.Dir(statePath) + backupDir := filepath.Join(dataDir, "backups") + if cfg.State != nil && cfg.State.BackupDir != "" { + backupDir = cfg.State.BackupDir + if !filepath.IsAbs(backupDir) { + backupDir = filepath.Join(dataDir, backupDir) + } + } + if abs, err := filepath.Abs(coldDir); err == nil { + coldDir = abs + } + dataMaintenanceMu := &sync.Mutex{} + + // Bind the API port BEFORE the potentially slow state open. A boot that + // runs a one-time VACUUM or a full integrity check on a multi-GB DB can + // take 25+ minutes, and an unbound port for that long makes the Docker + // healthcheck fail and the self-update sidecar judge the deploy failed — + // observed 2026-07-16 as an auto-rollback in the middle of a VACUUM. + // Until the real mux is wired, /api/health answers 200 "starting" and + // everything else 503. + apiHandler := newSwappableHandler(bootPhaseHandler()) + httpSrv := &http.Server{ + Addr: fmt.Sprintf(":%d", cfg.API.Port), + Handler: apiHandler, + ReadHeaderTimeout: 10 * time.Second, + } + go func() { + slog.Info("HTTP API listening (boot phase)", "addr", httpSrv.Addr) + if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + slog.Error("http server", "err", err) + } + }() + + st, err := state.Open(statePath) + if err != nil { + slog.Error("open state", "err", err) + os.Exit(1) + } + defer st.Close() + + // The repository is entirely local on startup: existing active symlinks are + // usable offline and remote refresh never blocks core boot. + driverRepository := driverrepo.New(cfg.DeviceRepository, filepath.Dir(statePath), st) + cfg.UnresolveDriverPaths(filepath.Dir(*configPath)) + config.ManagedDriversDirOverride = driverRepository.ActiveDir() + cfg.ResolveDriverPaths(filepath.Dir(*configPath)) + + // ---- Dev backfill (flag-gated, one-shot) ---- + // When -backfill N (N>0) is set, seed N days of synthetic history + // into state.db and exit WITHOUT starting the service. Refuses if + // the DB already holds non-synthetic rows unless -backfill-force. + if *backfillDays > 0 { + if err := devtools.Backfill(st, devtools.BackfillConfig{ + Days: *backfillDays, + Step: *backfillStep, + Seed: *backfillSeed, + Force: *backfillForce, + }, slog.Default()); err != nil { + slog.Error("backfill", "err", err) + os.Exit(1) + } + return + } + + if err := st.RecordEvent("startup"); err != nil { + slog.Warn("failed to persist startup event", "err", err) + } + + // ---- Restore EV charger password from state.db (not stored in YAML) ---- + if cfg.EVCharger != nil { + if pw, ok := st.LoadConfig("ev_charger_password"); ok { + cfg.EVCharger.Password = pw + } + } + + // ---- Restore CalDAV password from state.db (not stored in YAML) ---- + if cfg.CalDAV != nil { + if pw, ok := st.LoadConfig("caldav_password"); ok { + cfg.CalDAV.Password = pw + } + } + // Managed credential (#498): mint a random password on first enable so the + // operator never sets one by hand. Persisted to state.db; the in-process + // CalDAV server authenticates against it and the Settings tab shows it (with + // a QR) to paste into a calendar app. + if cfg.CalDAV.ManageCredentialsEnabled() && cfg.CalDAV.Password == "" { + if tok, err := calendar.GenerateToken(18); err != nil { + slog.Warn("caldav: failed to generate managed credential", "err", err) + } else if err := st.SaveConfig("caldav_password", tok); err != nil { + slog.Warn("caldav: failed to persist managed credential", "err", err) + } else { + cfg.CalDAV.Password = tok + slog.Info("caldav: generated managed credential") + } + } + + // ---- Telemetry store ---- + tel := telemetry.NewStore() + + // ---- Control state ---- + ctrl := newControlStateFromConfig(cfg) + // Restore persisted mode + target if present. The planner variants + // have to be listed too — without them the strategy the user picked in + // the UI (planner_self / planner_cheap / planner_arbitrage) is silently + // dropped on restart and the dashboard appears to forget the selection. + if v, ok := st.LoadConfig("mode"); ok { + if m := control.Mode(v); control.IsValidMode(m) { + ctrl.Mode = m + } + } + if v, ok := st.LoadConfig("grid_target_w"); ok { + if f, err := strconv.ParseFloat(v, 64); err == nil { + ctrl.SetGridTarget(f) + } + } + if v, ok := st.LoadConfig("battery_covers_ev"); ok { + ctrl.BatteryCoversEV = v == "true" + } + if v, ok := st.LoadConfig("peak_import_ceiling_w"); ok { + if f, err := strconv.ParseFloat(v, 64); err == nil && f >= 0 { + ctrl.PeakImportCeilingW = f + } + } + + // ---- Driver catalog (pure text scan, no Lua VM) ---- + // Loaded once here so the pre-flight capacity + warning logic can + // ask the catalog "is this driver an EV charger / vehicle source?" + // rather than sniffing filenames. The Lua DRIVER table's + // capabilities list is the driver's self-declaration. + driverCatalog, catErr := drivers.LoadCatalogMulti(*userDriversDirFlag, resolveDriverDir()) + if catErr != nil || len(driverCatalog) == 0 { + slog.Warn("driver catalog load failed; EV-driver classification will be conservative", + "err", catErr, "entries", len(driverCatalog)) + driverCatalog = nil + } + + // ---- Driver capacities (site, for control + fuse guard) ---- + // Loadpoint drivers are filtered out — their battery_capacity_wh + // is vehicle capacity, not site-battery capacity. + capacities := driverCapacitiesFrom(cfg.Drivers, cfg.Loadpoints, driverCatalog) + warnIfEVHasBatteryCapacity(cfg.Drivers, cfg.Loadpoints, driverCatalog) + + // ---- Battery models — restore from SQLite + ensure one per driver ---- + models := make(map[string]*battery.Model) + if stored, err := st.LoadAllBatteryModels(); err == nil { + for name, js := range stored { + m := &battery.Model{} + if err := json.Unmarshal([]byte(js), m); err == nil { + models[name] = m + slog.Info("restored battery model", + "name", name, "τ", m.TimeConstantS(float64(cfg.Site.ControlIntervalS)), + "gain", m.SteadyStateGain(), "samples", m.NSamples) + } + } + } + for name := range capacities { + if models[name] == nil { + models[name] = battery.New(name) + } + } + + // ---- Self-tune coordinator ---- + selfTune := selftune.NewCoordinator() + + // ---- Restart signal ---- + // Closing restartCh from /api/restart drops the main control loop out + // of its select, which returns from main() so every defer (HA Stop, + // state.Close, http.Shutdown, …) runs in normal LIFO order. The + // bottom-of-stack `os.Exit` defer below then translates exitCode 1 + // into a non-zero process exit so docker (`unless-stopped`) and + // systemd (`Restart=on-failure`) bring the binary back up. SIGTERM / + // SIGINT take the same return path with exitCode 0. + restartCh := make(chan struct{}) + var restartOnce sync.Once + exitCode := 0 + defer func() { + if exitCode != 0 { + os.Exit(exitCode) + } + }() + + // ---- Driver registry ---- + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + if cfg.DeviceRepository != nil && cfg.DeviceRepository.Enabled { + go driverRepositoryRefreshLoop(ctx, driverRepository, cfg.DeviceRepository.RefreshIntervalH) + } + reg := drivers.NewRegistry(tel) + reg.SetTroubleshootingMode(cfg.Site.TroubleshootingMode) + reg.MQTTFactory = func(name string, c *config.MQTTConfig) (drivers.MQTTCap, error) { + return mqttcli.Dial(c.Host, c.Port, c.Username, c.Password, "ftw-"+name) + } + reg.ModbusFactory = func(name string, c *config.ModbusConfig) (drivers.ModbusCap, error) { + return modbuscli.Dial(c.Host, c.Port, c.UnitID) + } + reg.ARPLookup = arp.Lookup + // Spawn initial drivers. config.Load has already joined relative Lua + // paths with the config directory — nothing to resolve here. + // WithBatterySoCBounds routes each battery's soc_min/soc_max into the + // matching driver's config (discharge_floor_soc/charge_ceil_soc) so the + // operator's SoC window reaches the driver, not just the planner. + for _, d := range config.WithBatterySoCBounds(cfg.Drivers, cfg.Batteries) { + if d.Disabled { + slog.Info("driver skipped (disabled)", "name", d.Name) + continue + } + if err := reg.Add(ctx, d); err != nil { + slog.Warn("failed to spawn driver", "name", d.Name, "err", err) + } + } + defer reg.ShutdownAll() + + // ---- Identity bootstrap ---- + // Drivers report make/serial inside driver_init via host.set_make / set_sn, + // and we resolved endpoint+MAC at registry-Add time. Now we wait briefly + // for those to populate, then register each device + run the one-shot + // migration that re-keys legacy battery_models from driver-name to + // device_id. Subsequent runs are no-ops. + go func() { + time.Sleep(3 * time.Second) // let driver_init finish + first SN be reported + registerAllDevices(st, reg) + if migrated, err := st.MigrateBatteryModelKeys(); err != nil { + slog.Warn("battery model key migration failed", "err", err) + } else if migrated > 0 { + slog.Info("battery model keys migrated to device_id", "count", migrated) + } + }() + + // ---- Shared mutexes for API/control/models ---- + ctrlMu := &sync.Mutex{} + capMu := &sync.RWMutex{} + cfgMu := &sync.RWMutex{} + modelsMu := &sync.Mutex{} + + // Durable secret write-back for drivers (rotated OAuth refresh tokens). + // Drivers call host.persist_secret(key, value); the registry routes it + // here with the driver's name. We write to the state KV store — NOT + // config.yaml — on purpose: config.yaml is watched by configreload, and + // rewriting it on every token rotation would restart the driver, which + // re-auths, rotates again, and loops. SecretOverride then layers these + // KV values back over config.yaml at driver_init, so the freshest token + // always reaches the driver while config.yaml keeps the bootstrap seed + // the UI renders as "saved". + driverSecretKey := func(driverName, key string) string { + return "driver_secret:" + driverName + ":" + key + } + reg.SecretPersister = func(driverName, key, value string) error { + return st.SaveConfig(driverSecretKey(driverName, key), value) + } + reg.SecretOverride = func(driverName, key string) (string, bool) { + return st.LoadConfig(driverSecretKey(driverName, key)) + } + + // Pre-declare services that the hot-reload Applier needs to touch. + // The Applier closure captures these by reference; they're assigned + // further down when their packages are wired, and the Applier only + // ever fires after `watcher.Start()` — by which point everything is + // in place. + var pvSvc *pvmodel.Service + var forecastSvc *forecast.Service + // Notifications: pre-declared so the hot-reload Applier can push + // fresh config into the provider + rule engine. Constructed + // unconditionally below so API handlers always have a live pointer. + var notifProvider notifications.Provider + var notifSvc *notifications.Service + // Event bus: decouples core loops (control tick, API) from + // cross-cutting subscribers (notifications today, audit/webhooks later). + bus := events.NewBus() + + // ---- EV loadpoints ---- + // Manager is created early so the config hot-reload closure can + // reference it; actual Load() runs against initial cfg below. + // The planner consumes loadpoint state so battery and EV can be + // co-optimized in one DP. + lpMgr := loadpoint.NewManager() + if len(cfg.Loadpoints) > 0 { + lpMgr.Load(buildLoadpointConfigs(cfg.Loadpoints)) + slog.Info("loadpoints configured", "count", len(cfg.Loadpoints)) + } + // Persist operator schedules in the existing state.config k/v. + // One row per LP keyed `loadpoint_schedule:`. Clearing a + // 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) { + 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 + } + 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) + } + }) + lpMgr.HydrateSchedules(func(id string) (loadpoint.Schedule, bool) { + v, ok := st.LoadConfig(lpSchedKeyPrefix + id) + if !ok || v == "" || v == "{}" { + return loadpoint.Schedule{}, false + } + var s loadpoint.Schedule + if err := json.Unmarshal([]byte(v), &s); err != nil { + slog.Warn("failed to parse persisted loadpoint schedule", + "lp", id, "err", err) + return loadpoint.Schedule{}, false + } + return s, !s.Empty() + }) + // Persist surplus_only toggles the same way schedules persist — + // state.config key `loadpoint_surplus_only:` stores "true" or + // "false". Operators toggle this from the dashboard EV modal, not + // 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) { + 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) + } + }) + hydrateLoadpointSurplusOnly := func() { + lpMgr.HydrateSurplusOnly(func(id string) (bool, bool) { + v, ok := st.LoadConfig(lpSurplusKeyPrefix + id) + if !ok || v == "" { + return false, false + } + return v == "true", true + }) + } + hydrateLoadpointSurplusOnly() + // Seed any recurring deadlines from boot — without this the first + // dispatch tick would race with an empty target_time and might + // miss the deadline penalty for ~5 s. + lpMgr.RollSchedules(time.Now().UTC()) + + // Forward-declared so the hot-reload closure below can push + // capacity changes into the running planner. Assigned at line + // ~450 after all its dependencies (pvSvc, loadSvc, priceFc) are + // wired up. nil until that point — the reload closure guards. + var mpcSvc *mpc.Service + + // Pre-declared for the same reason as mpcSvc — the hot-reload + // Applier needs to mutate loadSvc.SiteMeter when the operator + // moves the `is_site_meter: true` flag between drivers, and + // loadSvc itself is constructed further down (line ~619) after + // the telemetry store + state DB are wired. Closure capture + // works because Go closes over the variable, not its value. + var loadSvc *loadmodel.Service + + // Pre-declared so the hot-reload Applier can call (*ha.Bridge).Reload + // when broker / credentials / publish interval change. Constructed + // further down once the registry + control callbacks exist; the + // Applier nil-guards against the bridge being disabled. + var haBridge *ha.Bridge + + // deps is the API server's runtime dependency container. Forward- + // declared as a *Deps so the hot-reload Applier closure can capture + // it as a variable: the closure dereferences `deps` only after the + // HTTP server has been wired up (deps is populated below), so + // applier-time reads always observe the fully-constructed value. + // Updating deps.HA from the applier (e.g. when an operator toggles + // HA from disabled to enabled at runtime) keeps the API handler's + // pointer in sync without forcing a process restart. + var deps *api.Deps + + // Forward-declared before the reload watcher so the reload callback + // can keep the loadpoint controller's per-phase EV fuse clamp in sync + // with hot-reloaded fuse params. Assigned later (loadpoint.NewController). + var lpController *loadpoint.Controller + + // Forward-declared before the reload watcher so the callback can + // hot-reload the calendar client (#498). Assigned later (calendar.New). + var calSvc *calendar.Service + + // ---- Config hot-reload watcher ---- + watcher, err := configreload.New(*configPath, cfgMu, cfg, ctrlMu, ctrl, + func(newCfg, oldCfg *config.Config) { + // Restore EV charger password from state.db (not in YAML). + if newCfg.EVCharger != nil { + if pw, ok := st.LoadConfig("ev_charger_password"); ok { + newCfg.EVCharger.Password = pw + } + } + // Restore CalDAV password from state.db (not in YAML). Any CalDAV + // change is restart-gated because the native server and client must + // switch credentials, paths, and listeners atomically. + if newCfg.CalDAV != nil { + if pw, ok := st.LoadConfig("caldav_password"); ok { + newCfg.CalDAV.Password = pw + } + } + // Driver paths are already resolved by config.Load; no extra + // work needed here. Re-apply the battery SoC-window → driver + // config mapping so a hot-edited soc_max reaches the driver too. + reg.Reload(ctx, + config.WithBatterySoCBounds(newCfg.Drivers, newCfg.Batteries), + newCfg.Site.TroubleshootingMode) + // Refresh capacities — mutate the existing map in place so + // Deps.Capacities (a map header captured at init) sees the + // update. Rebinding the local variable would orphan the + // reference the api server still holds. + capMu.Lock() + for k := range capacities { + delete(capacities, k) + } + // Re-scan the catalog so a hot-edited Lua driver's + // capability change is picked up by the EV-classification + // filter on the very next reload tick. + reloadCatalog, err := drivers.LoadCatalogMulti(*userDriversDirFlag, resolveDriverDir()) + if err != nil || len(reloadCatalog) == 0 { + slog.Warn("driver catalog reload failed; retaining last known catalog", + "err", err, "entries", len(reloadCatalog)) + reloadCatalog = driverCatalog + } else { + driverCatalog = reloadCatalog + } + for k, v := range driverCapacitiesFrom(newCfg.Drivers, newCfg.Loadpoints, reloadCatalog) { + capacities[k] = v + } + capMu.Unlock() + warnIfEVHasBatteryCapacity(newCfg.Drivers, newCfg.Loadpoints, reloadCatalog) + + // Swap inverter-group tags (#143) and per-driver power + // limits (#145) together. Taken under ctrlMu because + // ComputeDispatch reads State.InverterGroups + .DriverLimits; + // a bare replace would race with the control loop's 5 s tick. + ctrlMu.Lock() + ctrl.InverterGroups = inverterGroupsFrom(newCfg.Drivers) + ctrl.SupportsPVCurtail = supportsPVCurtailFrom(newCfg.Drivers) + ctrl.DriverLimits = driverLimitsFrom(newCfg.Drivers, newCfg.Batteries) + // Fuse params + safety margin: previously startup-only. + // Hot-reload them so operators can tune the per-phase margin + // from the UI without restarting (e.g. raising it after the + // inverter's own protection trips, lowering it to recover + // last few hundred W of arbitrage headroom). + ctrl.SiteFuseAmps = newCfg.Fuse.MaxAmps + ctrl.SiteFuseVoltage = newCfg.Fuse.Voltage + ctrl.SiteFusePhases = newCfg.Fuse.Phases + // Mirror the startup-path default semantics — nil → 0.5, + // explicit 0 → disabled. See EffectiveSafetyMarginA. + ctrl.SiteFuseSafetyA = newCfg.Fuse.EffectiveSafetyMarginA() + ctrl.MaxExportW = newCfg.Site.MaxExportW + ctrlMu.Unlock() + + // Keep the loadpoint controller's per-phase EV fuse clamp in + // sync with hot-reloaded fuse params — previously startup-only, + // so an operator tuning max_amps / margin from the UI updated + // the control-package battery lever (above) but left the EV + // clamp on the stale startup value until restart. SetSiteFuse + // takes its own lock; call it outside ctrlMu. + if lpController != nil { + lpController.SetSiteFuse(loadpoint.SiteFuse{ + MaxAmps: newCfg.Fuse.MaxAmps, + Voltage: newCfg.Fuse.Voltage, + PhaseCnt: newCfg.Fuse.Phases, + }) + } + + // Site-meter swap propagation. The configreload watcher + // already updated ctrl.SiteMeterDriver under ctrlMu before + // this applier ran, so the dispatch loop reads from the + // right driver from the next tick. Two more sites cached + // the meter at construction and need the same hot-update + // treatment: + // - mpc.Service.SiteMeter — used by reactive replan to + // compute actual site load (grid − pv − bat). + // - loadmodel.Service.SiteMeter — drives twin learning; + // leaving it stale teaches the load model from a meter + // that may not even be emitting any more. + if newCfg.SiteMeterDriver() != oldCfg.SiteMeterDriver() { + if mpcSvc != nil { + mpcSvc.SetSiteMeter(newCfg.SiteMeterDriver()) + } + if loadSvc != nil { + loadSvc.SetSiteMeter(newCfg.SiteMeterDriver()) + } + slog.Info("site-meter hot-reloaded into mpc + loadmodel", + "driver", newCfg.SiteMeterDriver()) + } + + // Push the new pool totals into the planner so its next + // replan uses the right CapacityWh / MaxChargeW / + // MaxDischargeW. Without this the MPC keeps the snapshot + // it took at buildMPC time; SoC % and terminal credit go + // stale after an EV loadpoint is added/removed. Codex P1 + // on PR #121. + if mpcSvc != nil { + fleet := mpcBatteryFleetFromConfig(newCfg, capacities) + totalCap, maxChg, maxDis := aggregateBatteryFleetLimits(newCfg, fleet) + mpcSvc.UpdateBatteryFleet(fleet, totalCap, maxChg, maxDis) + slog.Info("mpc: capacity updated via hot-reload", + "capacity_wh", totalCap, "max_charge_w", maxChg, "max_discharge_w", maxDis) + } + + // Hot-reload EV loadpoints so operators can add / remove / + // retune them without restarting. Manager preserves + // observed state across reloads (plug status, session + // anchor, current SoC estimate) — see loadpoint.Manager.Load. + lpMgr.Load(buildLoadpointConfigs(newCfg.Loadpoints)) + hydrateLoadpointSurplusOnly() + + // Notifications: rebuild the provider from fresh config + // (handles the cold-start case where the initial config + // had no notifications: block and notifProvider was nil), + // wire it onto the service, then reset the rule-engine + // per-outage latch. All calls are nil-safe. + newProv := notifications.NewProvider(newCfg.Notifications) + notifProvider = newProv + var newPub notifications.Publisher + if newProv != nil { + newPub = newProv + } + notifSvc.SetPublisher(newPub) + notifSvc.Reload(newCfg.Notifications) + + // Home Assistant: hot-reload broker / credentials / publish + // interval / driver list. Bridge.Reload tears down the paho + // client and re-publishes discovery so an operator changing + // the broker IP from Settings sees HA reconnect within a + // second — no process restart required. + // + // Three transitions to handle: + // running → running: Bridge.Reload swaps connection. + // running → disabled: Stop the existing bridge. + // disabled → enabled: Start a fresh bridge (handles both + // the "previously toggled off" case and + // the "Start failed at boot, operator + // fixed the broker" recovery path). + haEnabled := newCfg.HomeAssistant != nil && newCfg.HomeAssistant.Enabled + switch { + case haBridge != nil && haEnabled: + if err := haBridge.Reload(newCfg.HomeAssistant, reg.Names()); err != nil { + slog.Warn("HA bridge reload failed", "err", err) + } else { + slog.Info("HA bridge reloaded", "broker", newCfg.HomeAssistant.Broker) + } + case haBridge != nil && !haEnabled: + haBridge.Stop() + haBridge = nil + deps.HA = nil + slog.Info("HA bridge stopped (disabled in config)") + case haBridge == nil && haEnabled: + if bridge, err := ha.Start(newCfg.HomeAssistant, tel, ctrl, ctrlMu, reg.Names(), haCallbacks(ctx, ctrl, ctrlMu, st, mpcSvc), mpcPlanSource(mpcSvc), haEnergySource(st)); err != nil { + slog.Warn("HA bridge start failed", "err", err) + } else { + haBridge = bridge + deps.HA = bridge + slog.Info("HA bridge started", "broker", newCfg.HomeAssistant.Broker) + } + } + + // Weather diff → push live into the PV twin + forecast + // fetcher without a process restart. Users adjust rated PV + // + lat/lon from Settings and expect the change to take + // effect right away. + if newCfg.Weather != nil { + oldLat, oldLon, oldRated := 0.0, 0.0, 0.0 + if oldCfg.Weather != nil { + oldLat = oldCfg.Weather.Latitude + oldLon = oldCfg.Weather.Longitude + oldRated = oldCfg.Weather.PVRatedW + } + newRated := newCfg.Weather.PVRatedW + if newRated > 0 && newRated != oldRated { + if pvSvc != nil { + pvSvc.SetRated(newRated) + } + if forecastSvc != nil { + forecastSvc.RatedPVW = newRated + } + } + newLat := newCfg.Weather.Latitude + newLon := newCfg.Weather.Longitude + if newLat != oldLat || newLon != oldLon { + if pvSvc != nil { + pvSvc.ClearSky = func(t time.Time) float64 { return forecast.ClearSkyW(newLat, newLon, t) } + } + if forecastSvc != nil { + forecastSvc.Lat = newLat + forecastSvc.Lon = newLon + } + slog.Info("weather location updated", "lat", newLat, "lon", newLon) + } + } + }) + if err != nil { + slog.Warn("could not start config watcher", "err", err) + } else { + watcher.Start() + defer watcher.Stop() + } + + // ---- Spot prices + weather forecast (optional, nil if not configured) ---- + // ---- FX rates (ECB, daily) — harmless to run even for SE-only users ---- + fxSvc := currency.New(st) + fxSvc.Start(ctx) + defer fxSvc.Stop() + + priceSvc := prices.FromConfig(cfg.Price, st, fxSvc) + + // ---- Price forecaster (fills in beyond day-ahead publication) ---- + zones := []string{"SE3"} + if cfg.Price != nil && cfg.Price.Zone != "" { + zones = []string{cfg.Price.Zone} + } + priceFc := priceforecast.NewService(st, zones) + // Optional: seed from bundled CSV on first boot. Idempotent so safe + // to call every boot — no-op once data is already in the store. + seedPath := filepath.Join(filepath.Dir(*configPath), "seed", "prices.csv") + if _, err := os.Stat(seedPath); err == nil { + n, err := priceFc.SeedFromCSV(seedPath) + if err != nil { + slog.Warn("priceforecast seed failed", "path", seedPath, "err", err) + } else if n > 0 { + slog.Info("priceforecast seeded", "rows", n, "path", seedPath) + } + } + priceFc.Start(ctx) + defer priceFc.Stop() + if priceSvc != nil { + priceSvc.Start(ctx) + defer priceSvc.Stop() + slog.Info("price service started", "zone", priceSvc.Zone, "provider", priceSvc.Provider.Name()) + } + + // Sum rated PV from all drivers for the forecast estimator + // Prefer explicit config; fall back to heuristic if unset. + ratedPVW := 0.0 + if cfg.Weather != nil && cfg.Weather.PVRatedW > 0 { + ratedPVW = cfg.Weather.PVRatedW + } else { + for _, d := range cfg.Drivers { + if d.BatteryCapacityWh > 0 { + ratedPVW += d.BatteryCapacityWh / 3 + } + } + if ratedPVW == 0 { + ratedPVW = 10000 + } + } + forecastSvc = forecast.FromConfig(cfg.Weather, ratedPVW, st, + "ftw/"+Version+" github.com/srcfl/ftw") + if forecastSvc != nil { + forecastSvc.Start(ctx) + defer forecastSvc.Stop() + slog.Info("forecast service started", "provider", forecastSvc.Provider.Name(), + "lat", forecastSvc.Lat, "lon", forecastSvc.Lon, "rated_pv_w", ratedPVW) + } + + // ---- Start PV digital twin (optional, requires weather config) ---- + // pvSvc is pre-declared above so the reload Applier can update it. + if cfg.Weather != nil && cfg.Weather.Provider != "" && cfg.Weather.Provider != "none" { + lat, lon := cfg.Weather.Latitude, cfg.Weather.Longitude + clearSkyFn := func(t time.Time) float64 { return forecast.ClearSkyW(lat, lon, t) } + cloudFn := func(t time.Time) (float64, bool) { + // Look up nearest forecast row covering `t`. + nowMs := t.UnixMilli() + rows, err := st.LoadForecasts(nowMs-2*3600*1000, nowMs+2*3600*1000) + if err != nil || len(rows) == 0 { + return 0, false + } + for _, r := range rows { + slotLen := r.SlotLenMin + if slotLen <= 0 { + slotLen = 60 + } + end := r.SlotTsMs + int64(slotLen)*60*1000 + if nowMs >= r.SlotTsMs && nowMs < end && r.CloudCoverPct != nil { + return *r.CloudCoverPct, true + } + } + return 0, false + } + pvSvc = pvmodel.NewService(st, tel, clearSkyFn, cloudFn, ratedPVW) + pvSvc.Start(ctx) + defer pvSvc.Stop() + slog.Info("pvmodel started", "rated_w", ratedPVW, "quality", pvSvc.Model().Quality()) + } + + // ---- Start load digital twin ---- + // Peak load proxy: use fuse power budget × 0.5 as a sane default + // until user configures an explicit value. Users can override by + // setting site.load_peak_w in config once we expose it. + loadPeakW := cfg.Fuse.MaxPowerW() * 0.5 + if loadPeakW <= 0 { + loadPeakW = 5000 + } + loadSvc = loadmodel.NewService(st, tel, cfg.SiteMeterDriver(), loadPeakW) + // SeedHeatingCoef — operator config is a cold-start prior. Once the + // load model has accumulated samples in production, its + // telemetry-fit HeatingW_per_degC survives restart and the config + // value is ignored. See loadmodel/service.go for the rationale. + if cfg.Weather != nil { + loadSvc.SeedHeatingCoef(cfg.Weather.HeatingWPerDegC) + } else { + loadSvc.SeedHeatingCoef(0) + } + // Temperature source for heating-gain fit: same forecast cache. + loadSvc.Temp = func(t time.Time) (float64, bool) { + nowMs := t.UnixMilli() + rows, err := st.LoadForecasts(nowMs-2*3600*1000, nowMs+2*3600*1000) + if err != nil || len(rows) == 0 { + return 0, false + } + for _, r := range rows { + slotLen := r.SlotLenMin + if slotLen <= 0 { + slotLen = 60 + } + end := r.SlotTsMs + int64(slotLen)*60*1000 + if nowMs >= r.SlotTsMs && nowMs < end && r.TempC != nil { + return *r.TempC, true + } + } + return 0, false + } + loadSvc.Start(ctx) + defer loadSvc.Stop() + slog.Info("loadmodel started", "peak_w", loadPeakW, "quality", loadSvc.Model().Quality()) + + // ---- Calendar (CalDAV) planner constraints (#498) ---- + // FTW hosts its own in-process, pure-Go CalDAV server (internal/caldavserver, + // emersion/go-webdav, MIT) and runs a CalDAV *client* against it: it maps + // "away" events onto the load model's away profile and EV + // "charged-by-departure" events onto loadpoint targets. Opt-in + fail-soft; + // enable/disable is restart-gated (config.RestartRequiredFor), so the runtime + // block only ever exists while enabled. Single-container friendly — runs in a + // Home Assistant add-on with no sidecar. + var caldavSrv *caldavserver.Server + if cfg.CalDAV != nil && cfg.CalDAV.Enabled { + // Host CalDAV in-process; the client below talks to it over localhost, so + // the inbound/outbound intent logic is the same regardless. Objects + // persist in state.db so they survive restarts. + principal, calPaths, feeds := nativeCalDAVLayout(cfg.CalDAV) + caldavSrv = caldavserver.New(cfg.CalDAV.ListenAddr(), caldavUsername(cfg.CalDAV), cfg.CalDAV.Password, principal, calPaths, st, caldavserver.WithFeeds(feeds)) + caldavSrv.Start() + defer caldavSrv.Stop() + + calSvc = calendar.New(*cfg.CalDAV, lpMgr, loadSvc, firstLoadpointID(cfg.Loadpoints)) + // Outbound EVSE history: feed live EV charge-point readings so the + // service can author a calendar event per completed session. + calSvc.SetEVSource(func() []calendar.EVSample { return evSamplesFromTelemetry(tel) }) + // Outbound plan publishing: feed the current MPC plan so the service + // can render forward-looking charge/discharge windows. mpcSvc is built + // just below; the closure reads it at call time (nil-safe until then). + calSvc.SetPlanSource(func() []calendar.PlanSlot { return planSlotsFromMPC(mpcSvc) }) + calSvc.Start(ctx) + defer calSvc.Stop() + slog.Info("caldav started", "listen", cfg.CalDAV.ListenAddr(), "url", cfg.CalDAV.URL, "calendar", cfg.CalDAV.CalendarPath) + } + + // ---- Start OCPP 1.6J Central System (optional) ---- + // Chargers dial us, so there is nothing to add to cfg.Drivers and no Lua + // driver involved. A charge point becomes a device in tel the moment it + // sends its first BootNotification, keyed by the identity segment of the + // URL it connected to, and dispatch picks it up from there like any other + // EV reading. + var ocppSrv *ocpp.Server + if cfg.OCPP != nil && cfg.OCPP.Enabled { + srv, err := ocpp.Start(ctx, &ocpp.Config{ + Enabled: cfg.OCPP.Enabled, + Port: cfg.OCPP.Port, + PortV201: cfg.OCPP.PortV201, + Path: cfg.OCPP.Path, + Username: cfg.OCPP.Username, + Password: cfg.OCPP.Password, + HeartbeatIntervalS: cfg.OCPP.HeartbeatIntervalS, + }, tel) + if err != nil { + // A charger that cannot reach us is a missing device, not a + // broken site, so keep the rest of the process running. + slog.Error("ocpp: central system failed to start", "err", err) + } else { + ocppSrv = srv + defer ocppSrv.Stop() + slog.Info("ocpp: central system started", + "port", ocppSrv.Port(), + "port_v201", cfg.OCPP.PortV201, + "path", ocppSrv.Path(), + "note", "listener is reachable on every interface; basic auth is the only gate") + } + } + + // ---- Start MPC planner (optional) ---- + mpcSvc = buildMPC(cfg, st, tel, capacities) + if mpcSvc != nil { + // Plumb the site fuse so the DP joint-plans battery + EV under + // the fuse from the start (instead of producing plans that + // dispatch later has to scale via the joint allocator). + mpcSvc.FuseMaxW = cfg.Fuse.MaxPowerW() + // Cap planned export below the fuse when the operator set a site + // export ceiling, so the DP never schedules a discharge that would + // over-export and trip an inverter (the Ferroamp 0x8030 fault). + // Startup-only, matching FuseMaxW above. + mpcSvc.MaxExportW = cfg.Site.MaxExportW + if pvSvc != nil { + // Use the unanchored structural predictor here: the MPC also + // receives PVResidualCorrect, which captures the same + // structural-vs-live bias the now-anchor would apply, so + // wiring Predict (anchored) would double-correct and the + // planner would see ~0 W PV on a sunny day with a heavy + // downward residual. See pvmodel.Service.PredictStructural. + mpcSvc.PV = pvSvc.PredictStructural + mpcSvc.PVResidualCorrect = pvSvc.ResidualCorrect + mpcSvc.PVUncertaintyW = pvSvc.ResidualStdW + } + // Downside-PV safety planning (forecast − k·σ) — replaces the old SoC + // safety floor. Unset config → default 1.0; explicit 0 → raw forecast. + mpcSvc.PVForecastSafetyK = cfg.Planner.PVSafetyK() + if cfg.Planner != nil { + mpcSvc.MinArbitrageSpreadOreKwh = cfg.Planner.MinArbitrageSpreadOreKwh + } + // Away-aware load predictor (#498): for slots inside a calendar + // "away" interval, predict with the load model's away profile so the + // DP conserves battery over exactly those slots. Outside any away + // window (and whenever CalDAV is off), this is identical to + // loadSvc.Predict. + if calSvc != nil { + mpcSvc.Load = func(t time.Time) float64 { + if calSvc.IsAwayAt(t) { + return loadSvc.PredictWith(t, loadmodel.ProfileAway) + } + return loadSvc.PredictWith(t, loadmodel.ProfileHome) + } + } else { + mpcSvc.Load = loadSvc.Predict + } + mpcSvc.Price = priceFc.Predict + mpcSvc.SiteMeter = cfg.SiteMeterDriver() + // The mathematical planner co-optimizes every scheduled loadpoint. + // The service retains the first entry for its Go-DP emergency fallback. + mpcSvc.Loadpoints = func(slotLenMin int) []*mpc.LoadpointSpec { + specs := make([]*mpc.LoadpointSpec, 0) + for _, st := range lpMgr.States() { + if !st.PluggedIn { + continue + } + // Schedule gate: only extend the DP with an EV-SoC + // dimension when the operator has set BOTH a target SoC + // and a future deadline. Without a schedule the DP + // previously planned EV charging speculatively across + // the full 48 h horizon — drawing battery + grid budget + // against a target the operator never asked for. With + // no schedule, EV is left to the loadpoint controller's + // reactive surplus-only behaviour. + if st.TargetSoCPct <= 0 || st.TargetTime.IsZero() || + !st.TargetTime.After(time.Now()) { + continue + } + // Pull capacity off the configured loadpoint. + var capWh float64 = 60000 // 60 kWh fallback + for _, c := range cfg.Loadpoints { + if c.ID == st.ID && c.VehicleCapacityWh > 0 { + capWh = c.VehicleCapacityWh + break + } + } + // Prefer live DerVehicle SoC over the loadpoint manager's + // inferred plugin-anchor + delivered-Wh estimate. The + // inference is blind to BMS truth (Easee can't see the + // car); when a vehicle driver such as TeslaBLEProxy is + // online, its SoC reading is ground truth. + // + // Picker (rank + freshness + bounds + connection + // evidence when delivering power) lives in + // telemetry.PickBestVehicleForLoadpoint so api.go's + // loadpoint decoration agrees with us on which vehicle + // is "the one". Falls back to inferred SoC when nothing + // usable online matches. + initSoC := st.CurrentSoCPct + socSource := "inferred" + var vehicleChargeLimit float64 // 0 = unknown + delivering := st.CurrentPowerW > loadpoint.DeliveringW + if pick := telemetry.PickBestVehicleForLoadpoint(tel, delivering, time.Now()); pick.Driver != "" { + initSoC = pick.SoCPct + socSource = "vehicle:" + pick.Driver + vehicleChargeLimit = pick.ChargeLimitPct + } + // Map target time → slot index using the DP's + // actual slot length (hour-of-prices vs. 15-min + // quarters vary by market). Anything past horizon + // gets clamped by the DP itself; negative means + // "no deadline". + if slotLenMin <= 0 { + slotLenMin = 60 + } + targetSlot := -1 + if !st.TargetTime.IsZero() { + delta := time.Until(st.TargetTime) + if delta > 0 { + targetSlot = int(delta / (time.Duration(slotLenMin) * time.Minute)) + } + } + // Operational ceiling: the lower of the user's target + // and the vehicle-configured charge limit. The car + // won't accept current beyond charge_limit_pct anyway, + // so planning past it is wasted DP grid space. When + // the limit is unknown, fall back to the deadline + // target itself; never plan beyond what was requested. + maxPct := st.TargetSoCPct + if vehicleChargeLimit > 0 && vehicleChargeLimit < maxPct { + maxPct = vehicleChargeLimit + } + // Effective deadline target: when the operator asked + // for 100% but the vehicle (Tesla via TeslaBLEProxy + // etc.) is hard-capped at, say, 60%, the DP must plan + // against the cap — otherwise the deadline-shortfall + // penalty stays elevated forever (the SoC grid maxes + // at the cap, can never reach the operator target), + // and MPC keeps committing grid charging chasing an + // unreachable goal. Cap target_pct to whatever the + // car will physically accept. + targetPct := st.TargetSoCPct + if vehicleChargeLimit > 0 && vehicleChargeLimit < targetPct { + targetPct = vehicleChargeLimit + slog.Info("mpc: target capped to vehicle charge limit", + "lp", st.ID, "operator_target_pct", st.TargetSoCPct, + "vehicle_limit_pct", vehicleChargeLimit) + } + // Guard against degenerate grids: if current SoC > maxPct + // (already over target), grow the ceiling to current so + // the DP can at least represent it (no charging will be + // scheduled). The deadline penalty handles the rest. + if initSoC > maxPct { + maxPct = initSoC + } + // Defer grid-funded EV planning when the deadline lies + // past the last published price slot AND is more than ~3 h + // out. Without this, MPC commits today's afternoon grid + // (today's prices are confirmed) instead of waiting for + // tomorrow's pre-dawn slots (typically published ~13:00 UTC + // for next-day Nordpool). The user-facing intent is "burn + // surplus PV during day, only plan grid at night when the + // real cheap window is known". Setting LoadpointSpec.SurplusOnly + // makes MPC's DP refuse grid-import EV actions; the runtime + // loadpoint controller still grabs PV via the bat-SoC unlock + // path. Once tomorrow's prices land, MPC's next replan + // rebuilds the spec without this guard and proper grid + // planning kicks in. + deferGridPlan := false + if priceSvc != nil { + end := time.Now().Add(72 * time.Hour) + pts, _ := priceSvc.Load(time.Now().UnixMilli(), end.UnixMilli()) + var latestEnd time.Time + for _, p := range pts { + slotEnd := time.UnixMilli(p.SlotTsMs).Add(time.Duration(p.SlotLenMin) * time.Minute) + if slotEnd.After(latestEnd) { + latestEnd = slotEnd + } + } + if !latestEnd.IsZero() && + st.TargetTime.After(latestEnd) && + time.Until(st.TargetTime) > 3*time.Hour { + deferGridPlan = true + } + } + slog.Debug("mpc: loadpoint spec", + "id", st.ID, "soc_pct", initSoC, "soc_source", socSource, + "target_pct", st.TargetSoCPct, "target_slot", targetSlot, + "max_pct", maxPct, "vehicle_limit_pct", vehicleChargeLimit, + "defer_grid_plan", deferGridPlan) + if deferGridPlan { + slog.Info("mpc: LP grid-funded planning deferred — target past published prices", + "lp", st.ID, "target", st.TargetTime, "hours_to_target", time.Until(st.TargetTime).Hours()) + } + // Mirror the deferral into the runtime controller so live + // dispatch enforces "no grid import" too. Without this, + // MPC's plan would still record a small EV budget per slot + // (snapped to forecast surplus); when forecast PV + // undershoots reality, the runtime controller would + // happily import grid to fulfil the cached plan budget. + 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. + batSoCArmed := false + if lpController != nil { + batSoCArmed = lpController.IsBatSoCArmed(st.ID) + } + // 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. + ctrlMu.Lock() + noBatteryToEV := !ctrl.BatteryCoversEV + ctrlMu.Unlock() + specs = append(specs, &mpc.LoadpointSpec{ + ID: st.ID, + CapacityWh: capWh, + Levels: 11, + MinPct: 0, + MaxPct: maxPct, + InitialSoCPct: initSoC, + PluggedIn: true, + TargetSoCPct: targetPct, + TargetSlotIdx: targetSlot, + MaxChargeW: st.MaxChargeW, + AllowedStepsW: st.AllowedStepsW, + ChargeEfficiency: 0.9, + SurplusOnly: st.SurplusOnly || deferGridPlan || batSoCArmed, + NoBatteryToEV: noBatteryToEV, + }) + } + return specs + } + if cfg.Price != nil { + mpcSvc.ExportBonusOreKwh = cfg.Price.ExportBonusOreKwh + mpcSvc.ExportFeeOreKwh = cfg.Price.ExportFeeOreKwh + mpcSvc.ExportFloorOreKwh = cfg.Price.ExportFloorOreKwh + mpcSvc.GridTariffOreKwh = cfg.Price.GridTariffOreKwh + mpcSvc.VATPercent = cfg.Price.VATPercent + } + // Persist every replan's Diagnostic so operators can inspect + // past decisions in the planner_diagnostics table. + mpcSvc.SaveDiag = func(d *mpc.Diagnostic, reason string) error { + js, err := json.Marshal(d) + if err != nil { + return err + } + return st.SaveDiagnostic(d.ComputedAtMs, reason, d.Zone, + d.TotalCostOre, d.Horizon, string(js)) + } + mpcSvc.Start(ctx) + defer mpcSvc.Stop() + // Inject plan → control.State. Both callbacks are wired: + // PlanTarget — legacy grid-target path (grid_target_w, mode str) + // SlotDirective — new energy-allocation path (Wh per slot) + // State.UseEnergyDispatch picks which one is actually used when a + // planner mode is active. + ctrl.PlanTarget = mpcSvc.SlotAt + ctrl.SlotDirective = func(now time.Time) (control.SlotDirective, bool) { + d, ok := mpcSvc.SlotDirectiveAt(now) + if !ok { + return control.SlotDirective{}, false + } + return control.SlotDirective{ + SlotStart: d.SlotStart, + SlotEnd: d.SlotEnd, + BatteryEnergyWh: d.BatteryEnergyWh, + SoCTargetPct: d.SoCTargetPct, + Strategy: string(d.Strategy), + PVLimitW: d.PVLimitW, + PlannedGridW: d.GridW, + HasPlannedGridW: true, + LivePVSurplusSoCCapPct: d.LivePVSurplusSoCCapPct, + LoadpointEnergyWh: d.LoadpointEnergyWh, + }, true + } + // Default to the energy-allocation path. The plan is a + // scheduler (decides WHEN each strategy applies); the EMS is + // the regulator (decides HOW batteries react — from live + // telemetry, not plan forecasts). + // `planner.legacy_dispatch: true` opts back to the old + // PI-on-grid-target path for emergency rollback. + // + // Back-compat: honor the deprecated `use_energy_dispatch` + // key when explicitly set. An operator who had + // `use_energy_dispatch: false` in their config before v0.27.0 + // chose legacy on purpose — don't silently flip them. + ctrl.UseEnergyDispatch = cfg.Planner == nil || !cfg.Planner.LegacyDispatch + if cfg.Planner != nil && cfg.Planner.UseEnergyDispatch != nil { + v := *cfg.Planner.UseEnergyDispatch + slog.Warn("planner.use_energy_dispatch is deprecated — use planner.legacy_dispatch: "+ + "true to opt out of the energy path instead. Honored for this run.", + "value", v) + ctrl.UseEnergyDispatch = v + } + // If the restored control mode is a planner variant, push the + // corresponding mpc.Mode so the plan is built with the strategy + // the user actually picked — not whatever cfg.planner.mode says. + // control.PlannerMPCMode is the shared mapping (same one the API + // and HA setters use), so the three paths can't drift. + if mm, ok := control.PlannerMPCMode(ctrl.Mode); ok { + mpcSvc.SetMode(ctx, mm) + } + if mpcSvc.Latest() == nil { + restoreLatestMPCDiagnostic(st, mpcSvc, time.Now()) + } + slog.Info("mpc planner started", + "mode", mpcSvc.Defaults.Mode, + "capacity_wh", mpcSvc.Defaults.CapacityWh, + "horizon", mpcSvc.Horizon, + "interval", mpcSvc.Interval, + "pvtwin", pvSvc != nil) + // Startup replan: the scheduled tick is up to mpcSvc.Interval + // (15 min) away. Don't make the operator wait — fire one + // immediately so /api/mpc/plan is populated as soon as + // telemetry, prices, and forecasts have settled. Observe ctx + // during the warm-up sleep so SIGTERM during startup doesn't + // keep this goroutine alive past shutdown. + go func() { + select { + case <-ctx.Done(): + return + case <-time.After(2 * time.Second): // give drivers a moment to seed SoC + } + if ctx.Err() != nil { + return + } + _ = mpcSvc.Replan(ctx) + slog.Info("mpc: startup replan completed") + }() + } + + // ---- EV loadpoint controller ---- + // loadpoint.Controller owns per-tick EV dispatch, including the + // energy-allocation contract, snapping and phase transitions. + // + // Adapters here keep loadpoint independent of mpc/telemetry + // (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) { + d, ok := mpcSvc.SlotDirectiveAt(now) + if !ok { + return loadpoint.Directive{}, false + } + return loadpoint.Directive{ + SlotStart: d.SlotStart, + SlotEnd: d.SlotEnd, + LoadpointEnergyWh: d.LoadpointEnergyWh, + }, true + } + telAdapter := func(driver string) (loadpoint.EVSample, bool) { + r := tel.Get(driver, telemetry.DerEV) + if r == nil { + return loadpoint.EVSample{}, false + } + // 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 + } + return loadpoint.EVSample{ + PowerW: r.SmoothedW, + SessionWh: d.SessionWh, + Connected: d.Connected, + RequestActive: reqActive, + }, true + } + // An OCPP charge point is not in the driver registry — it connected to + // us rather than being dialled — so route by name: if an online charger + // answers to it, command it over OCPP, otherwise fall through to the + // Lua driver registry. Loadpoints stay unaware of the difference. + send := reg.Send + if ocppSrv != nil { + send = func(ctx context.Context, name string, payload []byte) error { + if ocppSrv.Handler().IsOnline(name) { + return ocppSrv.Command(ctx, name, payload) + } + return reg.Send(ctx, name, payload) + } + } + lpController = loadpoint.NewController(lpMgr, planAdapter, telAdapter, send) + // Wire the site fuse so the per-phase EV clamp and the + // phase-split derivation can use the actual site voltage and + // breaker rating instead of hard-coding 230 V × 16 A. + lpController.SetSiteFuse(loadpoint.SiteFuse{ + MaxAmps: cfg.Fuse.MaxAmps, + Voltage: cfg.Fuse.Voltage, + PhaseCnt: cfg.Fuse.Phases, + }) + // Wire the joint fuse-budget allocator: when battery + EV would + // together bust the fuse, dispatch publishes a cap on EV W; the + // loadpoint controller honours it so battery and EV cooperatively + // share the budget instead of oscillating against the fuse guard. + lpController.SetFuseEVMax(func() (float64, bool) { + ctrlMu.Lock() + defer ctrlMu.Unlock() + if !ctrl.FuseSaturated { + return 0, false + } + return ctrl.FuseEVMaxW, true + }) + // 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) + } + 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) + } + }) + // Wire the live per-phase site-meter current reader. The control + // package's fuse guard is site-TOTAL only (sum across phases); + // a single phase can still trip from house-load imbalance (e.g. + // a vacuum or oven on L1) stacked on top of the EV's per-phase + // draw. This reader feeds the loadpoint's reactive per-phase EV + // clamp (applyPerPhaseFuseClamp), which lowers max_amps_per_phase + // the instant the worst phase approaches the breaker. Reads the + // same meter_lN_a metrics the fuse_over_limit notifier uses. + lpController.SetPerPhaseMeterAmps(func() (float64, float64, float64, bool) { + cfgMu.RLock() + siteMeter := cfg.SiteMeterDriver() + cfgMu.RUnlock() + if siteMeter == "" { + return 0, 0, 0, false + } + l1, _, ok1 := tel.LatestMetric(siteMeter, "meter_l1_a") + l2, _, ok2 := tel.LatestMetric(siteMeter, "meter_l2_a") + l3, _, ok3 := tel.LatestMetric(siteMeter, "meter_l3_a") + if !ok1 && !ok2 && !ok3 { + return 0, 0, 0, false + } + return l1, l2, l3, true + }) + // Wire the matched-vehicle reader for auto-wake. When the + // loadpoint is commanding power but the matched Tesla + // reports `Stopped` / `Disconnected` / `Complete`, the + // controller fires a charge_start command at the vehicle + // driver — TeslaBLEProxy translates it to BLE and re-engages + // the session. Without this, a long pause from the surplus + // clamp (or arbitrage-mode planning) detaches Tesla and + // nothing software-side can wake it. + lpController.SetVehicleStatus(func(lpID string) (string, string, bool) { + st, ok := lpMgr.State(lpID) + if !ok || !st.PluggedIn { + return "", "", false + } + delivering := st.CurrentPowerW > loadpoint.DeliveringW + pick := telemetry.PickBestVehicleForLoadpoint(tel, delivering, time.Now()) + if pick.Driver == "" { + return "", "", false + } + return pick.Driver, pick.ChargingState, true + }) + + // Wire the EV-available surplus computation for the + // surplus_only clamp. We want the W of PV that exceeds house + // load, regardless of how the home battery is currently + // splitting it — otherwise on a sunny day with the home + // battery absorbing all surplus the EV controller would see + // gridW≈0 and conclude "no surplus", contradicting reality. + // + // Identity (using api.go's convention `loadW = gridW − batW + // − pvW − evW`): pvSurplus = −pvW − loadW = −gridW + batW + // + evW. We compute the right-hand form because the + // telemetry store already publishes those three signals + // directly. Returns (_, false) when the site meter is + // missing — without it we can't bound grid import. + // Wire the peak-remaining-surplus reader for the surplus_only + // 1Φ-fallback decision. Iterates the MPC plan's remaining + // daylight slots and returns max(−pvW − loadW). When that + // peak can't sustain a 3Φ minimum (4140 W on a 6 A 3Φ + // charger), surplus_only locks the loadpoint to 1Φ for the + // day rather than pausing it forever — a slow-charging EV + // is still better than no charging at all. + lpController.SetPeakRemainingSurplusW(func() (float64, bool) { + if mpcSvc == nil { + return 0, false + } + plan := mpcSvc.Latest() + if plan == nil || len(plan.Actions) == 0 { + return 0, false + } + now := time.Now() + endOfDay := time.Date(now.Year(), now.Month(), now.Day(), + 23, 59, 59, 0, now.Location()) + 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(endOfDay) { + break + } + surplus := -a.PVW - a.LoadW + if !any || surplus > peak { + peak = surplus + any = true + } + } + if !any { + return 0, false + } + return peak, true + }) + + // Near-term peak surplus: same scan but capped at now + window. + // pickSurplusSteps consults this to decide if a 3Φ window is + // imminent enough to be worth waiting for. When near-term < + // 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. + lpController.SetNearTermPeakSurplusW(func(window time.Duration) (float64, bool) { + if mpcSvc == nil { + return 0, false + } + plan := mpcSvc.Latest() + if plan == nil || len(plan.Actions) == 0 { + 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 + }) + + lpController.SetSiteSurplusForEV(func() (float64, bool) { + meterDriver := cfg.SiteMeterDriver() + if meterDriver == "" { + return 0, false + } + // Refuse to publish a surplus when the meter is stale — + // last-known SmoothedW lingers indefinitely after a + // driver crash, and trusting it would silently violate + // the surplus_only "never import" promise. The watchdog + // timeout matches what the control loop uses elsewhere + // for site-meter staleness. + watchdog := time.Duration(cfg.Site.WatchdogTimeoutS) * time.Second + if watchdog <= 0 { + watchdog = 60 * time.Second + } + if tel.IsStale(meterDriver, telemetry.DerMeter, watchdog) { + return 0, false + } + meter := tel.Get(meterDriver, telemetry.DerMeter) + if meter == nil { + return 0, false + } + gridW := meter.SmoothedW + // Sum battery only over drivers that are currently online. + // A crashed battery driver leaves SmoothedW at last-known + // (e.g. +5 kW from a sunny moment) which would inflate + // surplus indefinitely. + var batW float64 + for _, r := range tel.ReadingsByType(telemetry.DerBattery) { + if h := tel.DriverHealth(r.Driver); h == nil || h.Status == telemetry.StatusOffline { + continue + } + 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, even with the + // grid-charge ban now in place, may still legitimately + // charge the battery from PV surplus. 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. + 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 + }) + + // Bat-SoC surplus-unlock: feed the controller a live home-battery + // SoC reading so a loadpoint with a `surplus_unlock_bat_soc_pct` + // schedule can flip into surplus-snap mode when the battery is + // already comfortable. Sums online battery readings; (_, false) + // when no battery driver is online so the controller leaves the + // arm state untouched (hysteresis preserved across blips). + lpController.SetBatSoCProvider(func() (float64, bool) { + var total, count float64 + for _, r := range tel.ReadingsByType(telemetry.DerBattery) { + if h := tel.DriverHealth(r.Driver); h == nil || h.Status == telemetry.StatusOffline { + continue + } + if r.SoC == nil || *r.SoC <= 0 { + continue + } + total += *r.SoC + count++ + } + if count == 0 { + return 0, false + } + return total / count, true + }) + } + + // ---- Self-update checker ---- + // Probes the GitHub Releases API in the background; the UI reads the + // cached result via /api/version/check. Gated behind FTW_SELFUPDATE_ENABLED + // because the ftw-updater sidecar only exists in the docker-compose deploy. + // Native / OS-image builds will ship their own update mechanism and set + // their own gate (or leave this one off). Deps.SelfUpdate stays nil when + // disabled, which makes every /api/version/* handler return 503 and the + // UI hide the badge. + var selfUpdater *selfupdate.Checker + var optimizerUpdater *selfupdate.Checker + // Implicitly enable for dev binaries (Version=="dev") so `make dev` + // users can click the version label and exercise the probe + modal + // without setting FTW_SELFUPDATE_ENABLED=1. Production builds (real + // vX.Y.Z stamped via -ldflags) still require the explicit env var + // so the feature can't surprise an OS-image deploy. + if envBool("FTW_SELFUPDATE_ENABLED") || Version == "dev" { + // FTW_SELFUPDATE_CURRENT_VERSION overrides what the checker thinks + // it's running so dev / QA can force update_available=true without + // rebuilding with a fake -ldflags Version. Scoped to the checker + // only — /api/status, User-Agent, HA discovery keep reporting the + // real build version. Unset in production; logged loudly when set. + current := Version + if v, ok := os.LookupEnv("FTW_SELFUPDATE_CURRENT_VERSION"); ok && v != "" { + current = v + slog.Warn("selfupdate: CurrentVersion overridden for testing", + "real_version", Version, "reported_version", current, + "env", "FTW_SELFUPDATE_CURRENT_VERSION") + } + selfUpdater = selfupdate.New(selfupdate.Config{ + CurrentVersion: current, + SocketPath: envOr("FTW_UPDATER_SOCKET", "/run/ftw-update/sock"), + StatusPath: envOr("FTW_UPDATER_STATUS", "/run/ftw-update/state.json"), + // Publish events.UpdateAvailable when a new release lands so + // the notifications service (or any other subscriber) can act + // without polling the checker directly. + Bus: bus, + }, st) + selfUpdater.Start(ctx) + optimizerCurrent := "dev" + if mpcSvc != nil && mpcSvc.Optimizer != nil { + if health, ok := mpcSvc.Optimizer.(interface { + Health(context.Context) (mpc.OptimizerRuntimeInfo, error) + }); ok { + healthCtx, healthCancel := context.WithTimeout(ctx, 2*time.Second) + if runtime, err := health.Health(healthCtx); err == nil && runtime.Version != "" { + optimizerCurrent = runtime.Version + } + healthCancel() + } + } + optimizerUpdater = selfupdate.New(selfupdate.Config{ + Repo: "srcfl/ftw", Image: "srcfl/ftw-optimizer", + ReleaseTagPrefix: "optimizer-", StoragePrefix: "optimizer.", + CurrentVersion: optimizerCurrent, + SocketPath: envOr("FTW_UPDATER_SOCKET", "/run/ftw-update/sock"), + StatusPath: envOr("FTW_UPDATER_STATUS", "/run/ftw-update/state.json"), + }, st) + optimizerUpdater.Start(ctx) + slog.Info("selfupdate enabled", + "socket", envOr("FTW_UPDATER_SOCKET", "/run/ftw-update/sock"), + "channel", selfUpdater.Info().Channel) + } else { + slog.Info("selfupdate disabled — set FTW_SELFUPDATE_ENABLED=1 to turn on") + } + + // ---- Start HTTP API ---- + // haBridge is forward-declared at the top of the file so the config + // hot-reload closure can call Reload on it; the bridge instance gets + // wired further down (HA is optional + depends on reg.Names()). + // Self-sovereign site identity: always generated on first boot, Nova- + // format (P-256 PEM) so federation can reuse it, but never dependent on + // Nova being enabled. Canonical path is the same nova.key default so + // existing federated gateways keep their claimed key. + identityKeyPath := filepath.Join(filepath.Dir(statePath), "nova.key") + if cfg.Nova != nil && cfg.Nova.KeyPath != "" { + identityKeyPath = cfg.Nova.KeyPath + } + siteIdentity, err := nova.LoadOrCreateIdentity(identityKeyPath) + if err != nil { + slog.Warn("site identity: load/create failed", "err", err, "path", identityKeyPath) + } else { + slog.Info("site identity ready", "pubkey_prefix", siteIdentity.PublicKeyHex()[:16]) + } + + deps = &api.Deps{ + Tel: tel, LogRing: logRing, Ctrl: ctrl, CtrlMu: ctrlMu, + State: st, + CapMu: capMu, Capacities: capacities, + CfgMu: cfgMu, Cfg: cfg, ConfigPath: *configPath, + DriverDir: resolveDriverDir(), + UserDriverDir: *userDriversDirFlag, + DriverMQTTFactory: reg.MQTTFactory, + DriverModbusFactory: reg.ModbusFactory, + DriverARPLookup: reg.ARPLookup, + Models: models, ModelsMu: modelsMu, + SelfTune: selfTune, + DtS: float64(cfg.Site.ControlIntervalS), + SaveConfig: config.SaveAtomic, + WebDir: *webDir, + ColdDir: coldDir, + DataDir: dataDir, + StatePath: statePath, + BackupDir: backupDir, + DataMaintenanceMu: dataMaintenanceMu, + // Snapshots live next to the rest of the persistent data so + // docker-compose deploys only need one bind (./data). Derived + // from the state.db path rather than the config path because + // `state.db` is always in the main data volume; the config + // can legitimately live elsewhere (e.g. mounted RO from /etc). + SnapshotDir: filepath.Join(filepath.Dir(statePath), "snapshots"), + Prices: priceSvc, + Forecast: forecastSvc, + MPC: mpcSvc, + PVModel: pvSvc, + LoadModel: loadSvc, + Loadpoints: lpMgr, + LoadpointCtrl: lpController, + CalDAV: calSvc, + HA: haBridge, + Registry: reg, + DriverRepository: driverRepository, + Events: bus, + Notifications: notifSvc, + 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. + if selfUpdater != nil { + if err := selfUpdater.Trigger(reqCtx, "restart", ""); err == nil { + slog.Info("restart: dispatched via updater sidecar") + return nil + } else { + slog.Info("restart: sidecar unavailable, falling back to in-process exit", "err", err) + } + } + // Fallback: drop the main control loop out of its select so + // every defer (HA Stop, st.Close, http.Shutdown, …) runs + // cleanly. The os.Exit(1) at the bottom of the defer stack + // then makes docker (`unless-stopped`) and systemd + // (`Restart=on-failure`) bring the binary back up. + restartOnce.Do(func() { + exitCode = 1 + close(restartCh) + }) + return nil + }, + Version: Version, + } + srv := api.New(deps) + // Dev-mode proxy: when FTW_PROXY_UPSTREAM is set (e.g. + // http://192.168.1.139:8080), /api/* is forwarded to that instance so + // the local UI renders live data without owning the control loop. + // Unset / empty = proxy disabled, /api/* served locally as normal. + // Read-only by default — writes (POST/PUT/…) come back as 403 so a + // stray Save in the dev UI can't mutate the real instance. Set + // FTW_PROXY_READONLY=0 if you explicitly need to exercise write paths. + handler := srv.Handler() + if up := os.Getenv("FTW_PROXY_UPSTREAM"); up != "" { + u, err := url.Parse(up) + if err != nil || u.Scheme == "" || u.Host == "" { + slog.Error("FTW_PROXY_UPSTREAM invalid — must be like http://host:port", "value", up, "err", err) + return + } + readOnly := true + if v, ok := os.LookupEnv("FTW_PROXY_READONLY"); ok { + switch strings.ToLower(v) { + case "0", "false", "no", "off": + readOnly = false + } + } + handler = proxy.Wrap(handler, proxy.Config{Upstream: u, ReadOnly: readOnly}) + slog.Warn("proxy enabled — /api/* forwards upstream", + "upstream", u.String(), + "read_only", readOnly) + } + // Swap the boot-phase handler for the fully wired mux — the listener + // bound at startup stays; no port gap for healthcheck probes. + apiHandler.Swap(handler) + slog.Info("HTTP API ready", "addr", httpSrv.Addr) + + // Belt-and-suspenders integrity scan, off the startup hot path: a clean + // restart skips the blocking boot check (so a multi-GB DB starts in seconds), + // and this still catches at-rest corruption without making control wait. It + // self-arms a heal on the next boot if it finds rot. + st.VerifyInBackground() + defer func() { + shutdownCtx, c := context.WithTimeout(context.Background(), 5*time.Second) + defer c() + _ = httpSrv.Shutdown(shutdownCtx) + }() + + // ---- Notifications (always constructed so API + applier hold live refs) ---- + // Provider selection uses the strategy registry in internal/notifications; + // only "ntfy" is registered today, but adding a new one is drop-in. + notifProvider = notifications.NewProvider(cfg.Notifications) + var notifPub notifications.Publisher + if notifProvider != nil { + notifPub = notifProvider + } + notifSvc = notifications.New(cfg.Notifications, notifPub, func(name string) (string, string, string, bool) { + dev := st.LookupDeviceByDriverName(name) + if dev == nil { + return "", "", "", false + } + return dev.DeviceID, dev.Make, dev.Serial, true + }) + // FuseReader: on each HealthTick the fuse_over_limit rule reads + // the site meter's live per-phase currents from telemetry and + // compares against cfg.Fuse.MaxAmps. Closes over cfg + cfgMu so + // hot-reloaded fuse changes take effect without restart; closes + // over tel so new metric emits are picked up immediately. + notifSvc.SetFuseReader(func() (map[string]float64, float64, bool) { + cfgMu.RLock() + siteMeter := cfg.SiteMeterDriver() + limitA := cfg.Fuse.MaxAmps + cfgMu.RUnlock() + if siteMeter == "" || limitA <= 0 { + return nil, 0, false + } + amps := map[string]float64{} + for _, phase := range []string{"l1", "l2", "l3"} { + if v, _, ok := tel.LatestMetric(siteMeter, "meter_"+phase+"_a"); ok { + amps[strings.ToUpper(phase)] = v + } + } + if len(amps) == 0 { + return nil, limitA, false + } + return amps, limitA, true + }) + notifSvc.Subscribe(bus) + // Persist every dispatch to state.notification_log via a bus + // subscriber so the notifications package stays free of storage + // logic. The UI reads this table through /api/notifications/history. + bus.Subscribe(events.KindNotificationDispatched, func(e events.Event) { + ev, ok := e.(events.NotificationDispatched) + if !ok { + return + } + if err := st.RecordNotification(state.NotificationEntry{ + TsMs: ev.Time.UnixMilli(), + EventType: ev.EventType, + Driver: ev.Driver, + Title: ev.Title, + Body: ev.Body, + Priority: ev.Priority, + Status: ev.Status, + Error: ev.Error, + }); err != nil { + slog.Warn("notification_log: record failed", "err", err) + } + }) + // Late-bind onto the Deps literal that was built earlier with a nil + // notifSvc (the deps struct is assembled before this block runs). + // Same pattern haBridge uses a few lines below. + deps.Notifications = notifSvc + if cfg.Notifications != nil && cfg.Notifications.Enabled { + name := "ntfy" + if notifProvider != nil { + name = notifProvider.Name() + } + slog.Info("notifications enabled", "provider", name) + } + + // ---- HA MQTT bridge (optional) ---- + if cfg.HomeAssistant != nil && cfg.HomeAssistant.Enabled { + bridge, err := ha.Start(cfg.HomeAssistant, tel, ctrl, ctrlMu, reg.Names(), haCallbacks(ctx, ctrl, ctrlMu, st, mpcSvc), mpcPlanSource(mpcSvc), haEnergySource(st)) + if err != nil { + slog.Warn("HA MQTT bridge failed to start", "err", err) + } else { + haBridge = bridge + deps.HA = haBridge // late-binding for API + } + } + // Stop deferred for whichever bridge instance is current at exit + // time — Reload may have swapped haBridge mid-flight, so re-read here + // rather than capturing the boot-time pointer. + defer func() { + if haBridge != nil { + haBridge.Stop() + } + }() + + // ---- Nova Core federation (optional) ---- + // Publishes telemetry to Sourceful Nova Core's MQTT broker (NATS + // MQTT adapter). Requires a one-time `ftw nova-claim` + // bootstrap to register the gateway's ES256 key and provision + // device/DER records under an org. When disabled or unconfigured, + // this block is a no-op. + if cfg.Nova != nil && cfg.Nova.Enabled { + // Load the persistent gateway identity only for explicitly enabled + // federation. Keep the canonical nova.key path so already-claimed + // gateways retain their identity across upgrades. + identityKeyPath := filepath.Join(filepath.Dir(statePath), "nova.key") + if cfg.Nova.KeyPath != "" { + identityKeyPath = cfg.Nova.KeyPath + } + siteIdentity, err := nova.LoadOrCreateIdentity(identityKeyPath) + if err != nil { + slog.Warn("nova federation disabled — gateway identity unavailable", "err", err, "path", identityKeyPath) + } else if pub, err := nova.Start(cfg.Nova, siteIdentity, st, tel); err != nil { + slog.Warn("nova publisher failed to start", "err", err) + } else if pub != nil { + defer pub.Stop() + slog.Info("nova federation enabled", + "mqtt", fmt.Sprintf("%s:%d", cfg.Nova.MQTTHost, cfg.Nova.MQTTPort), + "gateway_serial", cfg.Nova.GatewaySerial, + "schema_mode", cfg.Nova.SchemaMode) + } + } + + // ---- Background: Parquet rolloff (>14d → cold dir) ---- + coldRetentionDays := 0 + if cfg.State != nil { + coldRetentionDays = cfg.State.ColdRetentionDays + } + go rolloffLoop(ctx, st, coldDir, coldRetentionDays, dataMaintenanceMu) + + // ---- Background: daily state.db recovery snapshot ---- + go snapshotLoop(ctx, st) + + // ---- Control loop ---- + controlInterval := time.Duration(cfg.Site.ControlIntervalS) * time.Second + // fuseMaxW is recomputed per tick from ctrl.SiteFuse* under ctrlMu — + // the configreload watcher updates those fields directly, so a + // startup snapshot here would go stale on the first hot-reload. + dtS := float64(cfg.Site.ControlIntervalS) + + // Graceful shutdown + sigc := make(chan os.Signal, 1) + signal.Notify(sigc, os.Interrupt, syscall.SIGTERM) + + ticker := time.NewTicker(controlInterval) + defer ticker.Stop() + var saveCount uint64 + // Track FuseSaturated edge so we replan once when the joint allocator + // kicks in — the plan was made without knowledge of the live overage, + // and a replan with current EV/PV/load state usually finds a feasible + // schedule that doesn't fight the fuse. + var prevFuseSaturated bool + var lastFuseReplan time.Time + const fuseReplanCooldown = 60 * time.Second + var lastMissingPlanReplan time.Time + const missingPlanReplanCooldown = 5 * time.Second + // One-shot replan when the FIRST DerVehicle reading arrives. The + // startup replan ran with whatever fallback SoC was available; once + // the Tesla / vehicle driver gets ground truth from the car, the + // plan should incorporate it (especially for EV target deadlines). + var vehicleReplanFired bool + // Per-loadpoint last observed draw for the EV-stop edge replan. + // A falling edge (was >100 W, now <50 W while still plugged in) + // signals the EV finished or paused itself — the slot's plan no + // longer reflects reality (battery may have been held at 0 to leave + // room for the EV; with EV gone the freed PV should be re-allocated). + // Cooldown shares the fuse-replan cooldown to keep per-second flap + // from spamming the optimizer. + evDrawPrev := map[string]float64{} + var lastEVStopReplan time.Time + const evStopReplanCooldown = 60 * time.Second + const evStopHigh = 100.0 // W — "was actually drawing" + const evStopLow = 50.0 // W — "now essentially zero" + for { + select { + case <-sigc: + slog.Info("shutting down") + if err := st.RecordEvent("shutdown"); err != nil { + slog.Warn("failed to persist shutdown event", "err", err) + } + return + case <-restartCh: + slog.Info("restart requested via API — exiting cleanly so the supervisor brings us back") + if err := st.RecordEvent("restart"); err != nil { + slog.Warn("failed to persist restart event", "err", err) + } + return + case <-ticker.C: + nowMs := time.Now().UnixMilli() + + // ---- Continuous learning: feed (last_command, actual) per battery ---- + // Skip while self-tune is active — the override would corrupt RLS. + if !selfTune.Status().Active { + modelsMu.Lock() + ctrlMu.Lock() + lastTargets := append([]control.DispatchTarget{}, ctrl.LastTargets...) + ctrlMu.Unlock() + for _, t := range lastTargets { + r := tel.Get(t.Driver, telemetry.DerBattery) + if r == nil { + continue + } + m, ok := models[t.Driver] + if !ok { + continue + } + soc := 0.5 + if r.SoC != nil { + soc = *r.SoC + } + m.Update(t.TargetW, r.SmoothedW, soc, dtS, nowMs) + } + modelsMu.Unlock() + } + + // ---- Self-tune tick ---- + if selfTune.Status().Active { + modelsMu.Lock() + selfTune.Tick(func(name string) (float64, float64, bool) { + r := tel.Get(name, telemetry.DerBattery) + if r == nil { + return 0, 0, false + } + soc := 0.5 + if r.SoC != nil { + soc = *r.SoC + } + return r.SmoothedW, soc, true + }, models, dtS, nowMs) + modelsMu.Unlock() + } + + // ---- Watchdog: mark stale drivers offline, revert them to autonomous ---- + watchdogTimeout := time.Duration(cfg.Site.WatchdogTimeoutS) * time.Second + if watchdogTimeout <= 0 { + watchdogTimeout = 60 * time.Second + } + cfgMu.RLock() + troubleshootingMode := cfg.Site.TroubleshootingMode + cfgMu.RUnlock() + for _, tr := range tel.WatchdogScan(watchdogTimeout) { + if !tr.Online { + slog.Warn("driver telemetry stale — marking offline + reverting to autonomous", + "name", tr.Name, "timeout", watchdogTimeout) + sendDriverDefault(ctx, reg, tr.Name, "watchdog") + bus.Publish(events.DriverLost{Driver: tr.Name, At: time.Now()}) + } else { + slog.Info("driver telemetry recovered — back online", "name", tr.Name) + bus.Publish(events.DriverRecovered{Driver: tr.Name, At: time.Now()}) + } + } + // Fire a HealthTick so subscribers that track user-level + // thresholds (e.g. notifications) can evaluate their own + // rules without the control loop knowing about them. + bus.Publish(events.HealthTick{Health: tel.AllHealth(), Now: time.Now()}) + + // ---- EV dispatch first — independent of the site meter ---- + // Loadpoint Observe() reads its own telemetry (the EV + // charger driver), so it MUST run before the site-meter + // staleness guard below — otherwise a missing/stale site + // meter silently freezes the LP manager's plugged_in + // state, MPC never extends the DP with the EV dimension, + // and the operator sees "schedule set but EV never + // charges". The surplus-only clamp inside the LP controller + // already returns 0 when site surplus is unknown, so this + // is safe: bad grid signal → LP paused, but at least the + // LP state machine is alive. + lpMgr.RollSchedules(time.Now().UTC()) + lpController.Tick(ctx, time.Now()) + + // Anchor each plugged-in loadpoint's inferred SoC to the live + // vehicle BMS reading when one is paired. Chargers like Easee + // can't read the car, so the manager otherwise drifts on a + // plug-in-anchor + delivered-Wh estimate; when a vehicle driver + // (TeslaBLEProxy etc.) is online and matched, its SoC is ground + // truth. Runs after Tick's Observe so the per-tick re-anchor + // wins over that tick's inference. Same picker the MPC spec and + // api.go's loadpoint decoration use, so all three agree on which + // vehicle is "the one"; we additionally require !Stale so a + // driver serving last-known cache (car asleep) can't pin the + // dashboard to a stale value — inference takes over until fresh + // BMS data returns. + for _, st := range lpMgr.States() { + if !st.PluggedIn { + continue + } + delivering := st.CurrentPowerW > loadpoint.DeliveringW + pick := telemetry.PickBestVehicleForLoadpoint(tel, delivering, time.Now()) + if pick.Driver == "" || pick.Stale { + continue + } + lpMgr.AnchorVehicleSoC(st.ID, pick.SoCPct) + } + + // ---- Safety: site meter stale → idle everything this cycle ---- + // Otherwise stale grid readings cause one battery to charge another. + // Only meaningful when a site meter is actually configured; + // without one, IsStale("", DerMeter) is permanently true and + // the SendDefault loop below would fire on every tick — and + // SendDefault is a blocking send into each driver's cmdCh, + // which deadlocks the dispatch loop the first time any + // driver's channel buffer fills. + ctrlMu.Lock() + siteMeterDriver := ctrl.SiteMeterDriver + ctrlMu.Unlock() + siteMeterStale := false + if siteMeterDriver != "" { + siteMeterStale = tel.IsStale(siteMeterDriver, telemetry.DerMeter, watchdogTimeout) + } + if siteMeterStale { + slog.Warn("site meter telemetry stale — idling batteries this cycle", + "driver", siteMeterDriver) + if troubleshootingMode { + slog.Info("troubleshooting: site meter stale, dispatch skipped", + "site_meter", siteMeterDriver, "timeout", watchdogTimeout) + } + for _, n := range driversToDefaultOnSiteMeterStale(reg.Names(), siteMeterDriver) { + sendDriverDefault(ctx, reg, n, "site_meter_stale") + } + continue + } + + // ---- Compute dispatch ---- + capMu.RLock() + capsSnap := make(map[string]float64, len(capacities)) + for k, v := range capacities { + capsSnap[k] = v + } + capMu.RUnlock() + + // Surplus-only EV reserve: aggregate PV headroom to leave + // for surplus_only loadpoints. Per LP, reserves + // min(MaxChargeW, CurrentPowerW + EVRampHeadroomW) so the + // figure tracks the EV's actual draw rather than its + // theoretical max — when an EV is physically holding at + // e.g. 2.5 kW (1Φ × 11 A under phase hysteresis), the prior + // "reserve = MaxChargeW = 11 kW" form ate ~8.5 kW of the + // available surplus and starved the battery on + // over-forecast PV slots even when the plan said charge. + // Result is injected into ctrl.EVSurplusOnlyReserveW and + // consumed by dispatch.go in both the energy and the + // legacy/reactive paths. Computed every tick so toggling + // surplus_only, plugging/unplugging, or an EV ramp picks + // up immediately. + // Build the wake-kick-active set so the reserve calc can + // hold the floor for an LP whose wallbox is actively + // offering current to a still-ramping EV. Without this, + // the home battery would snatch the freed surplus during + // the brief gap before the EV's contactor settles. + lpStatesSnapshot := lpMgr.States() + var wakeKickActiveIDs map[string]bool + if lpController != nil { + now := time.Now() + for i := range lpStatesSnapshot { + st := &lpStatesSnapshot[i] + // Populate ManualActive on the dispatch-path snapshot + // (lpMgr.States() doesn't set it — only the API does) so + // SurplusReserveW can drop the reserve for a force-charging + // LP. Without this the manual/schedule override in + // SurplusReserveW never sees a manual hold and the + // no-discharge floor flaps the battery support. + if _, ok := lpController.GetManualHold(st.ID, now); ok { + st.ManualActive = true + } + if !st.PluggedIn || !st.SurplusOnly { + continue + } + if lpController.IsWakeKickActive(st.ID, now) { + if wakeKickActiveIDs == nil { + wakeKickActiveIDs = map[string]bool{} + } + wakeKickActiveIDs[st.ID] = true + } + } + } + evReserveW := loadpoint.SurplusReserveW(lpStatesSnapshot, wakeKickActiveIDs) + // Parallel curtail-side reserve — more permissive than the + // dispatch reserve above; counts plugged-but-stopped EVs + // with SoC headroom so PV isn't cut when a vehicle could + // resume charging. + evCurtailHeadroomW := loadpoint.SurplusPotentialW(lpStatesSnapshot) + + ctrlMu.Lock() + ctrl.EVSurplusOnlyReserveW = evReserveW + ctrl.EVCurtailHeadroomW = evCurtailHeadroomW + fuseMaxW := ctrl.SiteFuseAmps * ctrl.SiteFuseVoltage * float64(ctrl.SiteFusePhases) + targets := control.ComputeDispatch(tel, ctrl, capsSnap, fuseMaxW) + planMissingNow := ctrl.Mode.IsPlannerMode() && ctrl.PlanStale + ctrlMu.Unlock() + + // ---- Self-tune override: force commanded battery, hold others at 0 ---- + finalTargets := targets + selfTuneName, selfTuneCmd, selfTuneActive := selfTune.CurrentCommand() + if selfTuneActive { + finalTargets = make([]control.DispatchTarget, 0, len(reg.Names())) + for _, n := range reg.Names() { + if n == selfTuneName { + finalTargets = append(finalTargets, control.DispatchTarget{Driver: n, TargetW: selfTuneCmd}) + } else { + finalTargets = append(finalTargets, control.DispatchTarget{Driver: n, TargetW: 0}) + } + } + } + if troubleshootingMode { + gridW, haveGrid := troubleshootingGridW(tel, siteMeterDriver) + pvW := troubleshootingSumOnlineW(tel, telemetry.DerPV) + batW := troubleshootingSumOnlineW(tel, telemetry.DerBattery) + evW := tel.SumOnlineEVW() + v2xW := tel.SumOnlineV2XW() + attrs := []any{ + "mode", ctrl.Mode, + "plan_stale", planMissingNow, + "site_meter", siteMeterDriver, + "grid_known", haveGrid, + "grid_w", gridW, + "pv_w", pvW, + "bat_w", batW, + "ev_w", evW, + "v2x_w", v2xW, + "self_tune_active", selfTuneActive, + "self_tune_driver", selfTuneName, + "self_tune_command_w", selfTuneCmd, + "targets", targets, + "final_targets", finalTargets, + } + if haveGrid { + attrs = append(attrs, "load_w", gridW-batW-pvW-evW-v2xW) + } + slog.Info("troubleshooting: dispatch decision", attrs...) + } + + // ---- Dispatch to drivers ---- + for _, t := range finalTargets { + payload, _ := json.Marshal(map[string]any{"action": "battery", "power_w": t.TargetW}) + if err := reg.Send(ctx, t.Driver, payload); err != nil { + slog.Warn("driver send", "name", t.Driver, "err", err) + } + } + + // ---- PV curtailment dispatch ---- + // MPC's annotateCurtailment sets pv_limit_w on slots where + // exporting more PV would lose money (negative spot, no + // positive feed-in tariff). ComputePVCurtail picks the + // drivers that opted in via supports_pv_curtail and emits + // either a `curtail` command (limit > 0) or a one-shot + // `curtail_disable` when a previously-curtailed driver + // drops out of the active set. + ctrlMu.Lock() + curtailTargets := control.ComputePVCurtail(ctrl, tel) + ctrlMu.Unlock() + for _, c := range curtailTargets { + var payload []byte + if c.LimitW > 0 { + payload, _ = json.Marshal(map[string]any{ + "action": "curtail", + "power_w": c.LimitW, + }) + } else { + payload, _ = json.Marshal(map[string]any{ + "action": "curtail_disable", + }) + } + if err := reg.Send(ctx, c.Driver, payload); err != nil { + slog.Warn("pv curtail send", "name", c.Driver, "err", err) + } + } + + // LP dispatch ran at the top of this tick — see the + // "EV dispatch first" block above. + + // ---- Trigger MPC replan on fuse-saturation rising edge ---- + // The joint allocator (control.dispatch) just throttled + // battery and EV to fit under the fuse. The plan was built + // without seeing this overage, so let MPC redraw with current + // state — usually it finds a slot allocation that doesn't + // require both battery charge and full-bore EV simultaneously. + ctrlMu.Lock() + fuseSatNow := ctrl.FuseSaturated + ctrlMu.Unlock() + if mpcSvc != nil && fuseSatNow && !prevFuseSaturated && time.Since(lastFuseReplan) > fuseReplanCooldown { + lastFuseReplan = time.Now() + go mpcSvc.Replan(ctx) + slog.Info("fuse-saturated → MPC replan triggered") + } + prevFuseSaturated = fuseSatNow + + // Missing-plan retry: plans are in-memory only, so after an + // update/restart the first dispatch cycles can see nil/stale + // planner state. That must not wait for the normal 15 min + // interval; rebuild immediately, retrying briefly if prices / + // forecasts / telemetry were not ready during startup. + if mpcSvc != nil && planMissingNow && time.Since(lastMissingPlanReplan) > missingPlanReplanCooldown { + lastMissingPlanReplan = time.Now() + go mpcSvc.ReplanWithReason(ctx, "missing_plan_retry") + slog.Info("missing MPC plan → replan triggered") + } + + // EV-stop edge replan trigger: when an LP's draw drops + // from a clearly-charging value to ~0 while still plugged, + // the current plan slot's allocation (e.g. "idle, the EV + // takes the surplus") is stale — fire a replan so the DP + // re-routes the freed PV. Unplug edges are NOT a trigger: + // the plug-out itself toggles plugged_in→false which the + // scheduled replan path picks up, and chaining a replan + // there would race with the LP manager. Cooldown bounded + // to one replan per evStopReplanCooldown so a hardware + // flap doesn't storm the optimizer. + if mpcSvc != nil { + fired := false + for _, lp := range lpMgr.States() { + prev := evDrawPrev[lp.ID] + curr := lp.CurrentPowerW + if lp.PluggedIn && prev >= evStopHigh && curr < evStopLow && + time.Since(lastEVStopReplan) > evStopReplanCooldown && !fired { + lastEVStopReplan = time.Now() + fired = true + go mpcSvc.ReplanWithReason(ctx, "loadpoint_ev_stopped") + slog.Info("loadpoint EV stopped → MPC replan triggered", + "lp", lp.ID, "prev_w", prev, "curr_w", curr) + } + evDrawPrev[lp.ID] = curr + } + } + + // First-vehicle-SoC replan trigger: as soon as any + // DerVehicle driver is online and reporting SoC, redo the + // plan once with measured-truth instead of the pluginSoC + // estimate the startup replan used. + if mpcSvc != nil && !vehicleReplanFired { + for _, vr := range tel.ReadingsByType(telemetry.DerVehicle) { + if vr.SoC == nil { + continue + } + if h := tel.DriverHealth(vr.Driver); h == nil || !h.IsOnline() { + continue + } + vehicleReplanFired = true + go mpcSvc.Replan(ctx) + slog.Info("first vehicle SoC seen → MPC replan triggered", + "driver", vr.Driver, "soc", *vr.SoC) + break + } + } + + // ---- Persist the tick: history snapshot + flushed metrics ---- + // One transaction for both — separate commits doubled the WAL + // commit rate for no isolation benefit (SD-card wear). + hp := buildHistoryPoint(tel, ctrl, nowMs) + samples := tel.FlushSamples() + stSamples := make([]state.Sample, len(samples)) + for i, sm := range samples { + stSamples[i] = state.Sample{Driver: sm.Driver, Metric: sm.Metric, TsMs: sm.TsMs, Value: sm.Value, Unit: sm.Unit} + } + if err := st.RecordTick(hp, stSamples); err != nil { + slog.Warn("tick persistence failed", "samples", len(samples), "err", err) + } + + // ---- Periodic battery-model persistence (every 12 cycles ≈ 60s) ---- + saveCount++ + if saveCount%12 == 0 { + modelsMu.Lock() + for name, m := range models { + if data, err := json.Marshal(m); err == nil { + if err := st.SaveBatteryModel(name, string(data)); err != nil { + slog.Warn("failed to persist battery model", "battery", name, "err", err) + } + } + } + modelsMu.Unlock() + } + } + } +} + +// snapshotLoop writes a recovery snapshot of state.db daily and once on +// shutdown. The snapshot is the restore source if state.db corrupts (see +// state.openChecked). cache.db needs none — it's re-fetchable. Daily (not +// hourly) keeps SD-card write-wear low while bounding worst-case loss of +// precious data to one day. +func snapshotLoop(ctx context.Context, st *state.Store) { + // Initial snapshot shortly after boot so a fresh install gets one fast. + first := time.NewTimer(2 * time.Minute) + defer first.Stop() + tick := time.NewTicker(24 * time.Hour) + defer tick.Stop() + doSnap := func() { + if err := st.SnapshotState(); err != nil { + slog.Warn("state snapshot failed", "err", err) + } else { + slog.Info("state snapshot written") + } + } + for { + select { + case <-ctx.Done(): + doSnap() // best-effort on graceful shutdown + return + case <-first.C: + doSnap() + case <-tick.C: + doSnap() + } + } +} + +// rolloffLoop runs the SQLite → Parquet roll-off once per hour. Cheap when +// nothing is due (a single SELECT returns 0 rows); only does real work once +// data crosses the 14-day boundary into cold storage. +func rolloffLoop(ctx context.Context, st *state.Store, coldDir string, coldRetentionDays int, dataMaintenanceMu *sync.Mutex) { + tick := time.NewTicker(1 * time.Hour) + defer tick.Stop() + var lastDiskWarn time.Time + run := func() { + if dataMaintenanceMu != nil { + dataMaintenanceMu.Lock() + defer dataMaintenanceMu.Unlock() + } + doRolloff(ctx, st, coldDir) + + // The bulk DELETEs above just generated a WAL burst; reclaim it now + // instead of letting the -wal file ratchet upward on the SD card. + st.CheckpointWAL() + + if removed, err := state.PruneColdParquet(coldDir, coldRetentionDays, time.Now()); err != nil { + slog.Warn("cold parquet retention prune failed", "err", err) + } else if len(removed) > 0 { + slog.Info("cold parquet retention", "removed_files", len(removed), "retention_days", coldRetentionDays) + } + + // Disk watch: an SD card that fills up takes SQLite down with it. + // Warn loudly (log + event feed) at most once per day. + if avail, err := state.DiskAvail(coldDir); err == nil { + const lowWater = 500 << 20 // 500 MB + if avail < lowWater && time.Since(lastDiskWarn) > 24*time.Hour { + lastDiskWarn = time.Now() + slog.Error("disk space low — history rolloff and SQLite writes are at risk", + "avail_mb", avail>>20) + if err := st.RecordEvent(fmt.Sprintf( + "disk space low: %d MB available — consider state.cold_retention_days", avail>>20)); err != nil { + slog.Warn("record disk-low event failed", "err", err) + } + } + } + } + // Run once at startup so a fresh boot catches any backlog. + run() + for { + select { + case <-ctx.Done(): + return + case <-tick.C: + run() + } + } +} + +func doRolloff(ctx context.Context, st *state.Store, coldDir string) { + // Age the fixed-column dashboard history on the same cadence as the + // long-format TS + diagnostics rolloff below. Prune is idempotent and pure + // SQL; without this call history_hot/history_warm grow forever even though + // ts_samples is correctly moved to Parquet. + if err := st.Prune(ctx); err != nil { + slog.Warn("history tier prune failed", "err", err) + } + + rows, files, err := st.RolloffToParquet(ctx, coldDir) + if err != nil { + slog.Warn("parquet rolloff failed", "err", err) + } else if rows > 0 { + slog.Info("parquet rolloff", "rows", rows, "files", len(files)) + } + // Planner diagnostics roll off on the same cadence but keep a + // longer hot tier (30 d vs. the 14 d of ts_samples) — they're + // sparse enough (~100/day) that the extra month in SQLite + // costs < 60 MB and makes the time-travel UI snappy for + // recent-incident debugging. + dRows, dFiles, err := st.RolloffDiagnosticsToParquet(ctx, coldDir) + if err != nil { + slog.Warn("diagnostics parquet rolloff failed", "err", err) + return + } + if dRows > 0 { + slog.Info("diagnostics parquet rolloff", + "rows", dRows, "files", len(dFiles)) + } +} + +// registerAllDevices snapshots the identity HostEnv has gathered for each +// running driver and upserts a row in the devices table. Idempotent. +// Called periodically because some drivers (notably MQTT) only learn their +// serial after the first message from the device. +func registerAllDevices(st *state.Store, reg *drivers.Registry) { + for _, name := range reg.Names() { + env := reg.Env(name) + if env == nil { + continue + } + make, sn, mac, ep := env.FullIdentity() + dev := state.Device{ + DriverName: name, + Make: make, + Serial: sn, + MAC: mac, + Endpoint: ep, + } + if id, err := st.RegisterDevice(dev); err == nil && id != "" { + slog.Debug("device registered", "name", name, "device_id", id, "make", make, "sn", sn, "mac", mac) + } + } +} + +const driverDefaultTimeout = 2 * time.Second + +func sendDriverDefault(ctx context.Context, reg *drivers.Registry, name, reason string) { + cmdCtx, cancel := context.WithTimeout(ctx, driverDefaultTimeout) + defer cancel() + if err := reg.SendDefault(cmdCtx, name); err != nil { + slog.Warn("driver default command failed", + "name", name, "reason", reason, "timeout", driverDefaultTimeout, "err", err) + } +} + +// driversToDefaultOnSiteMeterStale returns the driver names to revert to +// DefaultMode when the site meter goes stale: every driver EXCEPT the +// site-meter owner itself. Skipping the meter owner avoids a flap loop on +// combined meter+battery devices (Pixii / Ferroamp-class) whose stale meter +// reading is usually their own hung modbus poll — writing DefaultMode into +// that same session cuts the battery in/out every minute. The "don't act on +// a stale grid signal" protection only applies to the OTHER batteries. +func driversToDefaultOnSiteMeterStale(names []string, siteMeterDriver string) []string { + out := make([]string, 0, len(names)) + for _, n := range names { + if n == siteMeterDriver { + continue + } + out = append(out, n) + } + return out +} + +// driverCapacitiesFrom builds the driver-name → battery-capacity map +// the MPC sums into Params.CapacityWh and the control layer uses for +// fuse-guard / peak-shave sizing. +// +// Critically: drivers that are referenced by a loadpoint entry are +// EV chargers, not home batteries — their `battery_capacity_wh` +// represents VEHICLE capacity and must NOT land in the MPC battery +// pool (doing so inflates SoC %, terminal-value credit, and the +// discharge-headroom DP arithmetic). Found live on Fredrik's Pi: his +// Easee entry had battery_capacity_wh=75000, which combined with +// Ferroamp (15.2 kWh) + Sungrow (9.6 kWh) gave a fantasy 99.8 kWh +// battery pool. +// +// Filtering here rather than at config-parse time means the vehicle +// capacity is still available for EV-side logic (loadpoint manager) +// without a schema migration. +func driverCapacitiesFrom(drvList []config.Driver, loadpoints []config.Loadpoint, catalog []drivers.CatalogEntry) map[string]float64 { + evDrivers := make(map[string]struct{}, len(loadpoints)) + for _, lp := range loadpoints { + // Only treat a loadpoint row as authoritative when it's + // valid enough for loadpoint.Manager to actually load it. + // An entry with an empty id is rejected by the manager (see + // loadpoint.Manager.Load) — accepting it here would silently + // drop a real battery from the MPC pool on nothing but + // config noise. + if lp.ID == "" || lp.DriverName == "" { + continue + } + evDrivers[lp.DriverName] = struct{}{} + } + out := make(map[string]float64, len(drvList)) + for _, d := range drvList { + if d.BatteryCapacityWh <= 0 { + continue + } + if d.BatteryTelemetryOnly || drivers.IsReadOnlyDriver(catalog, d.Lua) { + // A telemetry gateway must never enter MPC/dispatch even if an old + // or hand-written config also carries a battery capacity. + continue + } + if _, isEV := evDrivers[d.Name]; isEV { + // Don't count EV vehicle capacity as battery capacity. + // (Value remains in cfg.Drivers for any driver-side use.) + continue + } + // Fallback detection for operators who haven't migrated to a + // `loadpoints:` config block: ask the catalog whether the + // driver self-declares an EV or vehicle capability. Source of + // truth is the Lua DRIVER table; Go matches on what the + // driver says it is, not what its filename happens to look + // like. + if drivers.IsEVOrVehicleDriver(catalog, d.Lua) { + continue + } + out[d.Name] = d.BatteryCapacityWh + } + return out +} + +// driverLimitsFrom builds the driver-name → per-battery PowerLimits map +// used by control.State for per-battery charge/discharge caps (#145). +// Reads the drivers section first, then falls back to the batteries +// section for the same key — operators commonly set per-battery limits +// only under `batteries:` (the MPC reads them from there), and without +// this fallback the dispatcher silently uses the 5 kW MaxCommandW +// default while the planner schedules against the configured 9 kW. +// Drivers without limits in either place are omitted from the map. +func driverLimitsFrom(drivers []config.Driver, batteries map[string]config.Battery) map[string]control.PowerLimits { + out := map[string]control.PowerLimits{} + for _, d := range drivers { + chg, dis := d.MaxChargeW, d.MaxDischargeW + if b, ok := batteries[d.Name]; ok { + if chg == 0 && b.MaxChargeW != nil && *b.MaxChargeW > 0 { + chg = *b.MaxChargeW + } + if dis == 0 && b.MaxDischargeW != nil && *b.MaxDischargeW > 0 { + dis = *b.MaxDischargeW + } + } + if chg == 0 && dis == 0 { + continue + } + out[d.Name] = control.PowerLimits{ + MaxChargeW: chg, + MaxDischargeW: dis, + } + } + return out +} + +// inverterGroupsFrom builds the driver-name → inverter-group map used by +// control.State for DC-local charge routing (see issue #143). Only +// drivers that set an explicit `inverter_group` make the map; untagged +// drivers inherit today's capacity-proportional behaviour. +// +// A PV-only driver and a battery driver on the same physical inverter +// should both set the same group (e.g. both `inverter_group: ferroamp`) +// so distributeProportional can link PV output to the co-located +// battery's charge target. Config-reload calls this again and swaps the +// map atomically in the control state. +func inverterGroupsFrom(drivers []config.Driver) map[string]string { + out := map[string]string{} + for _, d := range drivers { + if d.InverterGroup == "" { + continue + } + out[d.Name] = d.InverterGroup + } + return out +} + +// supportsPVCurtailFrom builds the per-driver opt-in map used by +// ComputePVCurtail. Operators set `supports_pv_curtail: true` on +// each driver whose lua handles the `curtail` / `curtail_disable` +// actions (sungrow, ferroamp, deye, huawei, solis ship with it). +// Drivers not in the map are silently skipped by the curtail +// dispatcher — no risk of an EV charger receiving a curtail payload. +func supportsPVCurtailFrom(drivers []config.Driver) map[string]bool { + out := map[string]bool{} + for _, d := range drivers { + if d.SupportsPVCurtail { + out[d.Name] = true + } + } + return out +} + +// warnIfEVHasBatteryCapacity surfaces operator mis-config where an EV +// driver's YAML entry still carries battery_capacity_wh. The value is +// now ignored for MPC battery-pool purposes, but we log at WARN so the +// operator moves it to the loadpoint's vehicle_capacity_wh (where it +// serves the DP's EV SoC inference) rather than leaving it as a +// silent no-op. +// +// catalog is the parsed driver catalog (Lua DRIVER tables); the +// detection consults it for self-declared "ev" / "vehicle" capability +// rather than sniffing filenames or vendor names. +func warnIfEVHasBatteryCapacity(drvList []config.Driver, loadpoints []config.Loadpoint, catalog []drivers.CatalogEntry) { + evDrivers := make(map[string]struct{}, len(loadpoints)) + for _, lp := range loadpoints { + if lp.ID == "" || lp.DriverName == "" { + continue + } + evDrivers[lp.DriverName] = struct{}{} + } + for _, d := range drvList { + if d.BatteryCapacityWh <= 0 { + continue + } + _, isEVByLoadpoint := evDrivers[d.Name] + isEVByCatalog := drivers.IsEVOrVehicleDriver(catalog, d.Lua) + if !isEVByLoadpoint && !isEVByCatalog { + continue + } + reason := "driver is referenced by a loadpoint" + if !isEVByLoadpoint && isEVByCatalog { + reason = "driver self-declares ev/vehicle capability in its Lua DRIVER table" + } + slog.Warn(reason+" — battery_capacity_wh is being ignored for MPC "+ + "battery-pool sizing. Move the value to "+ + "loadpoints[].vehicle_capacity_wh to keep EV SoC inference "+ + "working.", + "driver", d.Name, + "lua", d.Lua, + "battery_capacity_wh", d.BatteryCapacityWh) + } +} + +// firstLoadpointID returns the ID of the first configured loadpoint, or "". +// Used as the fallback target for a calendar EV event whose title names no +// specific loadpoint and when caldav.ev_loadpoint_id is unset. +func firstLoadpointID(src []config.Loadpoint) string { + if len(src) > 0 { + return src[0].ID + } + return "" +} + +// caldavUsername resolves the configured CalDAV username. The runtime fallback +// remains the former default so an existing config that omitted the field does +// not silently move its principal; fresh UI/example configs write `ftw`. +func caldavUsername(cv *config.CalDAV) string { + if cv != nil && strings.TrimSpace(cv.Username) != "" { + return strings.TrimSpace(cv.Username) + } + return config.DefaultCalDAVUsername +} + +// nativeCalDAVLayout derives the principal path + the collections the +// in-process CalDAV server (#498) should expose, from config (with defaults). +func nativeCalDAVLayout(cv *config.CalDAV) (principal string, calendarPaths []string, feeds map[string]string) { + principal = "/" + caldavUsername(cv) + "/" + calPath := config.DefaultCalDAVCalendarPath + histPath := config.DefaultCalDAVHistoryPath + planPath := config.DefaultCalDAVPlanPath + if cv != nil { + if strings.TrimSpace(cv.CalendarPath) != "" { + calPath = cv.CalendarPath + } + if strings.TrimSpace(cv.HistoryPath) != "" { + histPath = cv.HistoryPath + } + if strings.TrimSpace(cv.PlanPath) != "" { + planPath = cv.PlanPath + } + } + // Only the read-only collections get a one-tap webcal:// feed; the + // read-write "energy" collection is where the user *writes* intents, so a + // read-only subscription would be the wrong tool for it. + feeds = map[string]string{"plan": planPath, "history": histPath} + return principal, []string{calPath, histPath, planPath}, feeds +} + +// evSamplesFromTelemetry projects current DerEV readings into the shape the +// calendar service's history writer consumes (#498). One sample per EV +// charge-point driver; the writer turns charge→idle transitions into events. +func evSamplesFromTelemetry(tel *telemetry.Store) []calendar.EVSample { + readings := tel.ReadingsByType(telemetry.DerEV) + out := make([]calendar.EVSample, 0, len(readings)) + for _, r := range readings { + var d struct { + Connected *bool `json:"connected"` + Charging *bool `json:"charging"` + SessionWh *float64 `json:"session_wh"` + } + if len(r.Data) > 0 { + _ = json.Unmarshal(r.Data, &d) + } + var sessionWh float64 + if d.SessionWh != nil { + sessionWh = *d.SessionWh + } + out = append(out, calendar.EVSample{ + ID: r.Driver, + Connected: d.Connected != nil && *d.Connected, + Charging: d.Charging != nil && *d.Charging, + SessionWh: sessionWh, + PowerW: r.SmoothedW, + }) + } + return out +} + +// planSlotsFromMPC projects the latest MPC plan into the shape the calendar +// service's plan publisher consumes. Nil-safe: returns nil +// when the planner is disabled or has no plan yet. +func planSlotsFromMPC(mpcSvc *mpc.Service) []calendar.PlanSlot { + if mpcSvc == nil { + return nil + } + plan := mpcSvc.Latest() + if plan == nil { + return nil + } + out := make([]calendar.PlanSlot, 0, len(plan.Actions)) + for _, a := range plan.Actions { + start := time.UnixMilli(a.SlotStartMs) + ln := a.SlotLenMin + if ln <= 0 { + ln = 15 + } + out = append(out, calendar.PlanSlot{ + Start: start, + End: start.Add(time.Duration(ln) * time.Minute), + BatteryW: a.BatteryW, + GridW: a.GridW, + SoCPct: a.SoCPct, + Confidence: a.Confidence, + }) + } + return out +} + +// buildLoadpointConfigs adapts YAML-facing config.Loadpoint entries +// into the internal loadpoint.Config shape. Shared between initial +// boot and the hot-reload watcher so the two paths can't drift. +func buildLoadpointConfigs(src []config.Loadpoint) []loadpoint.Config { + out := make([]loadpoint.Config, 0, len(src)) + for _, lp := range src { + out = append(out, loadpoint.Config{ + ID: lp.ID, + DriverName: lp.DriverName, + MinChargeW: lp.MinChargeW, + MaxChargeW: lp.MaxChargeW, + AllowedStepsW: lp.AllowedStepsW, + VehicleCapacityWh: lp.VehicleCapacityWh, + PluginSoCPct: lp.PluginSoCPct, + PhaseMode: lp.PhaseMode, + PhaseSplitW: lp.PhaseSplitW, + MinPhaseHoldS: lp.MinPhaseHoldS, + SurplusOnly: lp.SurplusOnly, + }) + } + return out +} + +func mpcBatteryFleetFromConfig(cfg *config.Config, capacities map[string]float64) []mpc.BatteryFleetMember { + fleet := make([]mpc.BatteryFleetMember, 0, len(capacities)) + for _, d := range cfg.Drivers { + cap := capacities[d.Name] + if cap <= 0 { + continue + } + // Default max (de)charge = 0.5C unless overridden. Zero is a + // legitimate one-sided constraint — `max_charge_w: 0` means + // "forbid charging, allow discharge only" and mpc.Optimize's + // action grid (`-MaxDischargeW…+MaxChargeW`) supports it. + // Negative is always a config mistake. + // + // Only the *both-zero* case is treated as a config error (and + // almost certainly is — it kills the planner's entire action + // space while leaving the service running). We fall back to + // default in that case and log a warning. + defaultP := cap / 2 + chg := defaultP + dis := defaultP + if b, ok := cfg.Batteries[d.Name]; ok { + bothZero := b.MaxChargeW != nil && *b.MaxChargeW == 0 && + b.MaxDischargeW != nil && *b.MaxDischargeW == 0 + if bothZero { + slog.Warn("mpc: batteries.max_{charge,discharge}_w both 0 — treating as config error, using default 0.5C", + "driver", d.Name, "default_w", defaultP) + } else { + if b.MaxChargeW != nil && *b.MaxChargeW >= 0 { + chg = *b.MaxChargeW + } else if b.MaxChargeW != nil { + slog.Warn("mpc: ignoring negative batteries.max_charge_w; using default 0.5C", + "driver", d.Name, "value", *b.MaxChargeW, "default_w", defaultP) + } + if b.MaxDischargeW != nil && *b.MaxDischargeW >= 0 { + dis = *b.MaxDischargeW + } else if b.MaxDischargeW != nil { + slog.Warn("mpc: ignoring negative batteries.max_discharge_w; using default 0.5C", + "driver", d.Name, "value", *b.MaxDischargeW, "default_w", defaultP) + } + } + } + fleet = append(fleet, mpc.BatteryFleetMember{ + Driver: d.Name, + CapacityWh: cap, + MaxChargeW: chg, + MaxDischargeW: dis, + }) + } + return fleet +} + +// aggregateBatteryLimits sums capacity + max charge/discharge across +// battery drivers the MPC should plan for, applying fuse-capacity +// clamps. Returned values are what buildMPC used to compute inline at +// startup — hoisted into a helper so the config-reload path can call +// it and push the new totals into an already-running mpc.Service. +func aggregateBatteryLimits(cfg *config.Config, capacities map[string]float64) (totalCap, maxChg, maxDis float64) { + return aggregateBatteryFleetLimits(cfg, mpcBatteryFleetFromConfig(cfg, capacities)) +} + +func aggregateBatteryFleetLimits(cfg *config.Config, fleet []mpc.BatteryFleetMember) (totalCap, maxChg, maxDis float64) { + for _, b := range fleet { + totalCap += b.CapacityWh + maxChg += b.MaxChargeW + maxDis += b.MaxDischargeW + } + // Clamp aggregate charge/discharge to the grid fuse capacity. The + // control loop's fuse guard enforces this per-tick anyway, but a + // planner that schedules 45 kW of charge through a 16 A fuse (11 kW) + // produces SoC projections that can never be realised — the optimiser + // "charges" to 100% in the plan while the battery barely budges in + // reality, and every downstream decision (when to discharge, when to + // idle, what the total cost looks like) is based on that fantasy. + // Cheaper to keep the plan feasible up-front. + if fuseMaxW := cfg.Fuse.MaxPowerW(); fuseMaxW > 0 { + if maxChg > fuseMaxW { + slog.Info("mpc: clamping MaxChargeW to fuse capacity", + "requested_w", maxChg, "fuse_w", fuseMaxW) + maxChg = fuseMaxW + } + if maxDis > fuseMaxW { + slog.Info("mpc: clamping MaxDischargeW to fuse capacity", + "requested_w", maxDis, "fuse_w", fuseMaxW) + maxDis = fuseMaxW + } + } + return totalCap, maxChg, maxDis +} + +// buildMPC constructs a planner from config. Returns nil if disabled, +// if prices aren't configured, or if there are no batteries with capacity. +func buildMPC(cfg *config.Config, st *state.Store, tel *telemetry.Store, capacities map[string]float64) *mpc.Service { + if cfg.Planner == nil || !cfg.Planner.Enabled { + return nil + } + if cfg.Price == nil || cfg.Price.Provider == "" || cfg.Price.Provider == "none" { + slog.Warn("mpc requires price provider — skipping") + return nil + } + fleet := mpcBatteryFleetFromConfig(cfg, capacities) + totalCap, maxChg, maxDis := aggregateBatteryFleetLimits(cfg, fleet) + if totalCap <= 0 { + slog.Warn("mpc: no battery capacity — skipping") + return nil + } + pl := cfg.Planner + zone := "SE3" + if cfg.Price != nil && cfg.Price.Zone != "" { + zone = cfg.Price.Zone + } + mode := mpc.Mode(pl.Mode) + if mode == "" { + mode = mpc.ModeSelfConsumption + } + socMin := pl.SoCMinPct + if socMin <= 0 { + socMin = 10 + } + socMax := pl.SoCMaxPct + if socMax <= 0 || socMax > 100 { + socMax = 95 + } + if pl.SoCSafetyFloorPct != 0 || pl.SafetyFloorPenaltyOreKwhHour != 0 { + slog.Warn("config: soc_safety_floor_pct / safety_floor_penalty_ore_kwh_hour are deprecated and ignored — forecast-risk reserve is now handled by pv_forecast_safety_k (downside-PV planning)") + } + pvBonus := pl.PVChargeBonusOreKwh + if pvBonus < 0 { + pvBonus = 0 + } + chgEff := pl.ChargeEfficiency + if chgEff <= 0 { + chgEff = 0.95 + } + disEff := pl.DischargeEfficiency + if disEff <= 0 { + disEff = 0.95 + } + params := mpc.Params{ + Mode: mode, + SoCLevels: 41, + CapacityWh: totalCap, + SoCMinPct: socMin, + SoCMaxPct: socMax, + PVChargeBonusOreKwh: pvBonus, + InitialSoCPct: 50, + // ActionLevels = 81 → 225 W discretization step on a ±9 kW + // action range. Coarser values (21=900 W, 41=450 W) lose + // borderline-PV slots: on a 273 W net surplus the 450 W min + // charge action overshoots ModeSelfConsumption's no-battery- + // export rule (gridW ends up positive past tolerance) and the + // DP falls back to idle/export the surplus. 81 levels lets the + // DP land on +225 W and absorb the surplus into the battery. + // DP complexity is O(N×S×A×EL×EA) — at the production 192-slot + // × 41-SoC × 1-EV grid, 81 actions is ~636k evaluations, + // still ~5 ms per replan on the Pi. + ActionLevels: 81, + MaxChargeW: maxChg, + MaxDischargeW: maxDis, + ChargeEfficiency: chgEff, + DischargeEfficiency: disEff, + ExportOrePerKWh: pl.ExportOrePerKWh, + } + svc := mpc.New(st, tel, zone, params) + svc.UpdateBatteryFleet(fleet, totalCap, maxChg, maxDis) + engine := pl.Engine + if engine == "" { + engine = "python" + } + if engine == "python" { + transportMode := pl.OptimizerTransport + if fromEnv := os.Getenv("FTW_OPTIMIZER_TRANSPORT"); fromEnv != "" { + transportMode = fromEnv + } + if transportMode == "" { + transportMode = "process" + } + socketPath := pl.OptimizerSocket + if fromEnv := os.Getenv("FTW_OPTIMIZER_SOCKET"); fromEnv != "" { + socketPath = fromEnv + } + if socketPath == "" { + socketPath = "/run/ftw-optimizer/optimizer.sock" + } + python := pl.OptimizerCommand + if python == "" { + python = envOr("FTW_OPTIMIZER_PYTHON", "python3") + } + moduleDir := pl.OptimizerDir + if fromEnv := os.Getenv("FTW_OPTIMIZER_DIR"); fromEnv != "" { + moduleDir = fromEnv + } + if moduleDir == "" { + moduleDir = resolveOptimizerDir() + } + timeout := time.Duration(pl.OptimizerTimeoutS * float64(time.Second)) + if timeout <= 0 { + timeout = 30 * time.Second + } + idleTimeout := time.Duration(pl.OptimizerIdleTimeoutS * float64(time.Second)) + if idleTimeout <= 0 { + idleTimeout = 2 * time.Minute + } + cvarWeight := 0.15 + if pl.OptimizerCVaRWeight != nil { + cvarWeight = *pl.OptimizerCVaRWeight + } + var multistage mpc.MultistageOptimizerConfig + if ms := pl.OptimizerMultistage; ms != nil { + multistage = mpc.MultistageOptimizerConfig{ + ScenarioLimit: ms.ScenarioLimit, BranchIntervalSlots: ms.BranchIntervalSlots, + BranchHorizonSlots: ms.BranchHorizonSlots, MaxBranching: ms.MaxBranching, + NearHorizonSlots: ms.NearHorizonSlots, MidHorizonSlots: ms.MidHorizonSlots, + MidBlockSlots: ms.MidBlockSlots, FarBlockSlots: ms.FarBlockSlots, + ServiceCVaRWeight: ms.ServiceCVaRWeight, ServiceCVaRAlpha: ms.ServiceCVaRAlpha, + EconomicCVaRWeight: ms.EconomicCVaRWeight, EconomicCVaRAlpha: ms.EconomicCVaRAlpha, + DecompositionThreshold: ms.DecompositionThreshold, DecompositionMethod: ms.DecompositionMethod, + PHMaxIterations: ms.PHMaxIterations, PHRho: ms.PHRho, PHToleranceW: ms.PHToleranceW, + } + } + ext, err := mpc.NewExternalOptimizer(mpc.ExternalOptimizerConfig{ + Command: []string{python, "-m", "ftw_optimizer.worker"}, + ModuleDir: moduleDir, Timeout: timeout, + TransportMode: transportMode, SocketPath: socketPath, + Solver: pl.OptimizerSolver, Formulation: pl.OptimizerFormulation, + MIPRelGap: pl.OptimizerMIPRelGap, + CVaRWeight: cvarWeight, CVaRAlpha: pl.OptimizerCVaRAlpha, + IdleTimeout: idleTimeout, + Multistage: multistage, + }) + if err != nil { + slog.Error("mpc: configure primary optimizer failed; using Go DP", "err", err) + } else { + svc.Optimizer = ext + svc.EnableRecourseShadow = pl.OptimizerRecourseShadow + svc.RecourseNonAnticipativeSlots = pl.OptimizerRecourseNonAnticipativeSlots + svc.ChallengerPolicy = pl.OptimizerChallengerPolicy + if svc.ChallengerPolicy == "" { + svc.ChallengerPolicy = "recourse" + } + if svc.RecourseNonAnticipativeSlots <= 0 { + svc.RecourseNonAnticipativeSlots = 1 + } + slog.Info("mpc: Python optimizer configured", "python", python, + "module_dir", moduleDir, "transport", transportMode, "socket", socketPath, + "timeout", timeout, "idle_timeout", idleTimeout, + "recourse_shadow", svc.EnableRecourseShadow, + "challenger_policy", svc.ChallengerPolicy, + "recourse_non_anticipative_slots", svc.RecourseNonAnticipativeSlots) + } + } else { + slog.Warn("mpc: legacy Go DP selected explicitly", "engine", engine) + } + svc.BaseLoad = pl.BaseLoadW + if pl.HorizonHours > 0 { + svc.Horizon = time.Duration(pl.HorizonHours) * time.Hour + } + if pl.IntervalMin > 0 { + svc.Interval = time.Duration(pl.IntervalMin) * time.Minute + } + return svc +} + +func resolveOptimizerDir() string { + candidates := []string{"optimizer", "../optimizer", "/app/optimizer"} + if exe, err := os.Executable(); err == nil { + candidates = append([]string{filepath.Join(filepath.Dir(exe), "optimizer")}, candidates...) + } + for _, candidate := range candidates { + if st, err := os.Stat(filepath.Join(candidate, "ftw_optimizer")); err == nil && st.IsDir() { + return candidate + } + } + return "optimizer" +} + +func driverRepositoryRefreshLoop(ctx context.Context, repository *driverrepo.Manager, intervalHours int) { + if intervalHours <= 0 { + intervalHours = 24 + } + refresh := func() { + refreshCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + if err := repository.Refresh(refreshCtx, ""); err != nil { + slog.Warn("driver repository refresh failed; keeping last-good cache", "err", err) + } + } + refresh() + ticker := time.NewTicker(time.Duration(intervalHours) * time.Hour) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + refresh() + } + } +} + +// isConfigMissing checks whether the error from config.Load indicates the +// config file does not exist (as opposed to a parse or validation error). +// config.Load wraps the os error with fmt.Errorf, so we use errors.Is to +// unwrap through the chain. +func isConfigMissing(err error) bool { + if err == nil { + return false + } + if errors.Is(err, os.ErrNotExist) { + return true + } + return strings.Contains(err.Error(), "no such file") +} + +func buildHistoryPoint(tel *telemetry.Store, ctrl *control.State, nowMs int64) state.HistoryPoint { + gridW := 0.0 + if r := tel.Get(ctrl.SiteMeterDriver, telemetry.DerMeter); r != nil { + gridW = r.SmoothedW + } + var pvW, batW, sumSoC float64 + var socCount int + for _, r := range tel.ReadingsByType(telemetry.DerPV) { + pvW += r.SmoothedW + } + for _, r := range tel.ReadingsByType(telemetry.DerBattery) { + batW += r.SmoothedW + if r.SoC != nil { + sumSoC += *r.SoC + socCount++ + } + } + avgSoC := 0.0 + if socCount > 0 { + avgSoC = sumSoC / float64(socCount) + } + evW := tel.SumOnlineEVW() + v2xW := tel.SumOnlineV2XW() + loadW := gridW - batW - pvW - evW - v2xW + if loadW < 0 { + loadW = 0 + } + + // Per-driver detail packed into the JSON column. The schema is + // schema-less by design — UI code reads what it understands and + // ignores the rest, so drivers can add fields without a migration. + perDriver := make(map[string]map[string]float64) + for name, h := range tel.AllHealth() { + row := map[string]float64{} + if r := tel.Get(name, telemetry.DerBattery); r != nil { + row["bat_w"] = r.SmoothedW + if r.SoC != nil { + row["soc"] = *r.SoC + } + } + if r := tel.Get(name, telemetry.DerPV); r != nil { + row["pv_w"] = r.SmoothedW + } + if r := tel.Get(name, telemetry.DerMeter); r != nil { + row["meter_w"] = r.SmoothedW + } + // EV charge power: required for the live chart's EV series + // (web/app.js reads `d.ev_w` per driver from /api/history). + // Without it the chart's EV trace is always zero — the in-memory + // /api/status DOES carry ev_w, but history rows never did until + // this row was added. + if r := tel.Get(name, telemetry.DerEV); r != nil { + row["ev_w"] = r.SmoothedW + } + if r := tel.Get(name, telemetry.DerV2X); r != nil { + row["v2x_w"] = r.SmoothedW + if r.SoC != nil { + row["v2x_vehicle_soc"] = *r.SoC + } + } + _ = h + perDriver[name] = row + } + targets := make(map[string]float64) + for _, t := range ctrl.LastTargets { + targets[t.Driver] = t.TargetW + } + jsonBlob, _ := json.Marshal(map[string]any{ + "drivers": perDriver, + "targets": targets, + "ev_w": evW, + "v2x_w": v2xW, + "load_house_w": loadW, + }) + return state.HistoryPoint{ + TsMs: nowMs, GridW: gridW, PVW: pvW, BatW: batW, LoadW: loadW, BatSoC: avgSoC, + JSON: string(jsonBlob), + } +} + +func restoreLatestMPCDiagnostic(st *state.Store, svc *mpc.Service, now time.Time) { + if st == nil || svc == nil { + return + } + row, err := st.LoadDiagnosticAt(now.UnixMilli()) + if err != nil { + slog.Warn("mpc: load persisted diagnostic failed", "err", err) + return + } + if row == nil || row.JSON == "" { + return + } + var d mpc.Diagnostic + if err := json.Unmarshal([]byte(row.JSON), &d); err != nil { + slog.Warn("mpc: decode persisted diagnostic failed", "ts_ms", row.TsMs, "err", err) + return + } + if svc.RestoreDiagnostic(&d, now, "restored_diagnostic") { + slog.Info("mpc: restored active plan from persisted diagnostic", + "ts_ms", row.TsMs, + "mode", d.Params.Mode, + "horizon", d.Horizon, + "reason", row.Reason) + } +} + +// envOr returns the env var's value if it is set (even if empty, so an +// operator can explicitly blank a path to disable a feature — see +// docs/self-update.md on FTW_UPDATER_SOCKET=""). Returns def only when +// the variable is unset. +// haCallbacks builds the bridge's command-callback set. Extracted so +// the boot-time ha.Start path and the configreload "disabled → enabled" +// path can share the exact same wiring — drift between them would mean +// HA commands behave one way after boot and a different way after a +// hot-reload, which is the kind of silent skew that's hardest to debug. +func haCallbacks(ctx context.Context, ctrl *control.State, ctrlMu *sync.Mutex, st *state.Store, mpcSvc *mpc.Service) ha.CommandCallbacks { + return ha.CommandCallbacks{ + SetMode: func(m string) error { + mode := control.Mode(m) + // Accept exactly the set the HA discovery `select` advertises — + // control.AllModes via IsValidMode — so a planner_* option an + // operator picks in Home Assistant isn't silently rejected here. + // Mirror the full /api/mode side-effects (manual-hold + PI reset + // + MPC propagation); the two setters must behave identically or + // HA mode changes diverge from web-UI ones (#mode-drift). + if !control.IsValidMode(mode) { + return fmt.Errorf("unknown mode: %s", m) + } + ctrlMu.Lock() + ctrl.Mode = mode + ctrl.ClearBatteryManualHold() + if ctrl.PI != nil { + ctrl.PI.Reset() + } + ctrlMu.Unlock() + if err := st.SaveConfig("mode", m); err != nil { + return err + } + if mm, ok := control.PlannerMPCMode(mode); ok && mpcSvc != nil { + mpcSvc.SetMode(ctx, mm) + } + return nil + }, + SetGridTarget: func(w float64) error { + ctrlMu.Lock() + defer ctrlMu.Unlock() + ctrl.SetGridTarget(w) + return st.SaveConfig("grid_target_w", strconv.FormatFloat(w, 'f', 1, 64)) + }, + SetPeakLimit: func(w float64) error { + ctrlMu.Lock() + defer ctrlMu.Unlock() + ctrl.PeakLimitW = w + return nil + }, + SetEVCharging: func(w float64, active bool) error { + ctrlMu.Lock() + defer ctrlMu.Unlock() + ctrl.SetManualEVCharging(w, active) + return nil + }, + SetBatteryCoversEV: func(enabled bool) error { + ctrlMu.Lock() + ctrl.BatteryCoversEV = enabled + ctrlMu.Unlock() + val := "false" + if enabled { + val = "true" + } + return st.SaveConfig("battery_covers_ev", val) + }, + } +} + +// mpcPlanBridge adapts *mpc.Service to ha.PlanSource without creating an +// import cycle between ha and mpc. +type mpcPlanBridge struct{ svc *mpc.Service } + +func (b mpcPlanBridge) LatestActions() []ha.PlanAction { + if b.svc == nil { + return nil + } + plan := b.svc.Latest() + if plan == nil { + return nil + } + out := make([]ha.PlanAction, len(plan.Actions)) + for i, a := range plan.Actions { + out[i] = ha.PlanAction{ + SlotStartMs: a.SlotStartMs, + SlotLenMin: a.SlotLenMin, + BatteryW: a.BatteryW, + GridW: a.GridW, + SoCPct: a.SoCPct, + PriceOre: a.PriceOre, + SpotOre: a.SpotOre, + CostOre: a.CostOre, + Confidence: a.Confidence, + Reason: a.Reason, + EMSMode: a.EMSMode, + PVW: a.PVW, + LoadW: a.LoadW, + } + } + return out +} + +func mpcPlanSource(svc *mpc.Service) ha.PlanSource { + if svc == nil { + return nil + } + return mpcPlanBridge{svc: svc} +} + +// stateEnergyBridge adapts *state.Store to ha.EnergySource. +type stateEnergyBridge struct{ st *state.Store } + +func (b stateEnergyBridge) TodayEnergy() (ha.TodayEnergySnapshot, bool) { + now := time.Now() + midnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + d, err := b.st.DailyEnergy(midnight.UnixMilli(), now.UnixMilli()) + if err != nil { + return ha.TodayEnergySnapshot{}, false + } + return ha.TodayEnergySnapshot{ + ImportWh: d.ImportWh, + ExportWh: d.ExportWh, + PVWh: d.PVWh, + BatChargedWh: d.BatChargedWh, + BatDischargedWh: d.BatDischargedWh, + LoadWh: d.LoadWh, + }, d.Intervals > 0 +} + +func haEnergySource(st *state.Store) ha.EnergySource { + if st == nil { + return nil + } + return stateEnergyBridge{st: st} +} + +func envOr(key, def string) string { + if v, ok := os.LookupEnv(key); ok { + return v + } + return def +} + +func troubleshootingGridW(tel *telemetry.Store, siteMeterDriver string) (float64, bool) { + if siteMeterDriver == "" { + return 0, false + } + r := tel.Get(siteMeterDriver, telemetry.DerMeter) + if r == nil { + return 0, false + } + return r.SmoothedW, true +} + +func troubleshootingSumOnlineW(tel *telemetry.Store, typ telemetry.DerType) float64 { + var sum float64 + for _, r := range tel.ReadingsByType(typ) { + h := tel.DriverHealth(r.Driver) + if h == nil || !h.IsOnline() { + continue + } + sum += r.SmoothedW + } + return sum +} + +// envBool returns true iff the env var is set to a positive value +// (1/true/yes/on, case-insensitive). Unset or any other value = false. +func envBool(key string) bool { + switch strings.ToLower(os.Getenv(key)) { + case "1", "true", "yes", "on": + return true + } + return false +} diff --git a/go/internal/config/config.go b/go/internal/config/config.go index c5f8fb7f..45909af3 100644 --- a/go/internal/config/config.go +++ b/go/internal/config/config.go @@ -1,1838 +1,1846 @@ -// Package config parses and validates the top-level YAML config. -// -// This is the single source of truth that the file-watcher re-parses on -// every change and that the settings UI writes back. All fields are -// hot-reloadable unless noted otherwise. -package config - -import ( - "errors" - "fmt" - "math" - "net/url" - "os" - "path/filepath" - "strings" - "time" - - "gopkg.in/yaml.v3" -) - -// Config is the full application config. -type Config struct { - Site Site `yaml:"site" json:"site"` - Fuse Fuse `yaml:"fuse" json:"fuse"` - Drivers []Driver `yaml:"drivers" json:"drivers"` - API API `yaml:"api" json:"api"` - HomeAssistant *HomeAssistant `yaml:"homeassistant,omitempty" json:"homeassistant,omitempty"` - State *StateConf `yaml:"state,omitempty" json:"state,omitempty"` - Price *Price `yaml:"price,omitempty" json:"price,omitempty"` - Weather *Weather `yaml:"weather,omitempty" json:"weather,omitempty"` - Planner *Planner `yaml:"planner,omitempty" json:"planner,omitempty"` - Batteries map[string]Battery `yaml:"batteries,omitempty" json:"batteries,omitempty"` - EVCharger *EVCharger `yaml:"ev_charger,omitempty" json:"ev_charger,omitempty"` - CalDAV *CalDAV `yaml:"caldav,omitempty" json:"caldav,omitempty"` - Loadpoints []Loadpoint `yaml:"loadpoints,omitempty" json:"loadpoints,omitempty"` - V2X *V2XPolicy `yaml:"v2x,omitempty" json:"v2x,omitempty"` - Notifications *Notifications `yaml:"notifications,omitempty" json:"notifications,omitempty"` - Nova *Nova `yaml:"nova,omitempty" json:"nova,omitempty"` - DeviceRepository *DeviceRepository `yaml:"device_repository,omitempty" json:"device_repository,omitempty"` - OCPP *OCPP `yaml:"ocpp,omitempty" json:"ocpp,omitempty"` -} - -// OCPP configures the built-in OCPP 1.6J Central System. Chargers connect to -// us, so there is no driver and no per-charger config entry — a charge point -// appears as a device the moment it sends its first BootNotification, keyed by -// the identity segment of the URL it dialled. -// -// Disabled by default, and enabling it requires credentials. The listener -// cannot be restricted to one interface: the OCPP library builds its own -// listen address from the port alone, so the socket is reachable on every -// interface the host has. Basic auth is the only thing standing in front of -// it, which is why an empty Username or Password is rejected rather than -// silently accepted. -type OCPP struct { - Enabled bool `yaml:"enabled" json:"enabled"` - Port int `yaml:"port,omitempty" json:"port,omitempty"` - Path string `yaml:"path,omitempty" json:"path,omitempty"` - Username string `yaml:"username,omitempty" json:"username,omitempty"` - Password string `yaml:"password,omitempty" json:"password,omitempty"` - HeartbeatIntervalS int `yaml:"heartbeat_interval_s,omitempty" json:"heartbeat_interval_s,omitempty"` -} - -// Validate rejects an enabled server that would accept anonymous charge -// points. A nil or disabled section is fine — OCPP is opt-in. -func (o *OCPP) Validate() error { - if o == nil || !o.Enabled { - return nil - } - if o.Username == "" || o.Password == "" { - return errors.New("ocpp: username and password are required when enabled, because the listener cannot be bound to a single interface") - } - if o.Port < 0 || o.Port > 65535 { - return fmt.Errorf("ocpp.port must be between 0 and 65535, got %d", o.Port) - } - if o.HeartbeatIntervalS < 0 { - return fmt.Errorf("ocpp.heartbeat_interval_s must be >= 0, got %d", o.HeartbeatIntervalS) - } - return nil -} - -// DeviceRepository configures independently distributed Lua drivers. Remote -// refresh never changes an active driver; activation is always an explicit API -// action. TrustedKeys maps key IDs to base64-encoded Ed25519 public keys. -type DeviceRepository struct { - Enabled bool `yaml:"enabled" json:"enabled"` - RefreshIntervalH int `yaml:"refresh_interval_h,omitempty" json:"refresh_interval_h,omitempty"` - RootDir string `yaml:"root_dir,omitempty" json:"root_dir,omitempty"` - Repositories []DriverRepositorySource `yaml:"repositories,omitempty" json:"repositories,omitempty"` -} - -type DriverRepositorySource struct { - ID string `yaml:"id" json:"id"` - Name string `yaml:"name,omitempty" json:"name,omitempty"` - ManifestURL string `yaml:"manifest_url" json:"manifest_url"` - Enabled bool `yaml:"enabled" json:"enabled"` - TrustedKeys map[string]string `yaml:"trusted_keys,omitempty" json:"trusted_keys,omitempty"` - AllowUnsigned bool `yaml:"allow_unsigned,omitempty" json:"allow_unsigned,omitempty"` - AllowInsecure bool `yaml:"allow_insecure,omitempty" json:"allow_insecure,omitempty"` -} - -const ( - DefaultDriverRepositoryID = "ftw-official" - DefaultDriverRepositoryName = "FTW official drivers" - DefaultDriverRepositoryManifestURL = "https://github.com/srcfl/ftw/releases/download/drivers-stable/manifest.json" - DefaultDriverRepositorySigningKeyID = "ftw-drivers-2026-01" - DefaultDriverRepositoryPublicKey = "MX+j27UBkyM099hTyJlmMLK9qlTTDUJsaK/vH12fFKc=" -) - -// Notifications configures outbound push notifications. Exactly one -// transport provider is active at a time, selected by Provider. Today -// the only implemented provider is "ntfy" (ntfy.sh or self-hosted); -// future providers add their own nested config block and register in -// go/internal/notifications. -type Notifications struct { - Enabled bool `yaml:"enabled" json:"enabled"` - Provider string `yaml:"provider,omitempty" json:"provider,omitempty"` - DefaultPriority int `yaml:"default_priority,omitempty" json:"default_priority,omitempty"` - Ntfy *NtfyConfig `yaml:"ntfy,omitempty" json:"ntfy,omitempty"` - Events []NotificationRule `yaml:"events,omitempty" json:"events,omitempty"` -} - -// NtfyConfig is the ntfy.sh transport settings. -type NtfyConfig struct { - Server string `yaml:"server,omitempty" json:"server,omitempty"` - Topic string `yaml:"topic,omitempty" json:"topic,omitempty"` - AccessToken string `yaml:"access_token,omitempty" json:"access_token,omitempty"` - Username string `yaml:"username,omitempty" json:"username,omitempty"` - Password string `yaml:"password,omitempty" json:"password,omitempty"` - - // HasAccessToken is a JSON-only signal for the UI: true means a - // token exists on disk. Set by MaskSecrets before AccessToken is - // blanked so the Settings form can render "configured — hidden" - // instead of an empty input. Never written to YAML. - HasAccessToken bool `yaml:"-" json:"has_access_token,omitempty"` -} - -// NotificationRule is one event type the operator can toggle. -type NotificationRule struct { - Type string `yaml:"type" json:"type"` - Enabled bool `yaml:"enabled" json:"enabled"` - ThresholdS int `yaml:"threshold_s,omitempty" json:"threshold_s,omitempty"` - // ThresholdN is a count-based threshold used by event types that - // aggregate across drivers (concurrent_drivers_offline). Ignored - // by per-driver events. Default behaviour per event documented - // alongside the const in notifications/service.go. - ThresholdN int `yaml:"threshold_n,omitempty" json:"threshold_n,omitempty"` - Priority int `yaml:"priority,omitempty" json:"priority,omitempty"` - Tags string `yaml:"tags,omitempty" json:"tags,omitempty"` - TitleTemplate string `yaml:"title_template,omitempty" json:"title_template,omitempty"` - BodyTemplate string `yaml:"body_template,omitempty" json:"body_template,omitempty"` - CooldownS int `yaml:"cooldown_s,omitempty" json:"cooldown_s,omitempty"` -} - -// Nova is the opt-in Sourceful Nova Core federation config. When enabled, -// FTW publishes telemetry to Nova's MQTT broker (NATS MQTT -// adapter) and reconciles device/DER registrations via Nova's core-api. -// -// Identity is an ES256 keypair generated on first run and stored at -// KeyPath (default /nova.key). The public key is -// registered in Nova via the claim flow; the private key signs a short- -// lived JWT used as the MQTT password. -// -// SchemaMode controls the wire format sent to Nova: -// - "legacy" (default): translate FTW's native clean payload -// to the current Nova wire shape (battery sign flip, -// PascalCase fields, pv→solar, ev→ev_port). The translation -// layer is in internal/nova and is designed to be deleted -// once Nova adopts the unified schema. -// - "unified": publish FTW's clean payload directly. Enable -// once the Nova schema-alignment PR lands. -type Nova struct { - Enabled bool `yaml:"enabled" json:"enabled"` - URL string `yaml:"url" json:"url"` - MQTTHost string `yaml:"mqtt_host" json:"mqtt_host"` - MQTTPort int `yaml:"mqtt_port,omitempty" json:"mqtt_port,omitempty"` - MQTTTLS bool `yaml:"mqtt_tls,omitempty" json:"mqtt_tls,omitempty"` - GatewaySerial string `yaml:"gateway_serial" json:"gateway_serial"` - OrgID string `yaml:"org_id" json:"org_id"` - SiteID string `yaml:"site_id" json:"site_id"` - KeyPath string `yaml:"key_path,omitempty" json:"key_path,omitempty"` - SchemaMode string `yaml:"schema_mode,omitempty" json:"schema_mode,omitempty"` - PublishIntervalS int `yaml:"publish_interval_s,omitempty" json:"publish_interval_s,omitempty"` - ReconcileIntervalH int `yaml:"reconcile_interval_h,omitempty" json:"reconcile_interval_h,omitempty"` -} - -// Loadpoint is one EV charge point the planner can reason about. -// The planner and go/internal/loadpoint optimize battery + EV jointly. -type Loadpoint struct { - ID string `yaml:"id" json:"id"` - DriverName string `yaml:"driver_name" json:"driver_name"` - MinChargeW float64 `yaml:"min_charge_w,omitempty" json:"min_charge_w,omitempty"` - MaxChargeW float64 `yaml:"max_charge_w,omitempty" json:"max_charge_w,omitempty"` - AllowedStepsW []float64 `yaml:"allowed_steps_w,omitempty" json:"allowed_steps_w,omitempty"` - VehicleCapacityWh float64 `yaml:"vehicle_capacity_wh,omitempty" json:"vehicle_capacity_wh,omitempty"` - PluginSoCPct float64 `yaml:"plugin_soc_pct,omitempty" json:"plugin_soc_pct,omitempty"` - - // PhaseMode selects how the controller picks between 1Φ and 3Φ - // delivery: "3p" (default) | "1p" | "auto". Empty == "3p" for - // backward compat with pre-switching configs. See loadpoint.Config. - PhaseMode string `yaml:"phase_mode,omitempty" json:"phase_mode,omitempty"` - PhaseSplitW float64 `yaml:"phase_split_w,omitempty" json:"phase_split_w,omitempty"` - MinPhaseHoldS int `yaml:"min_phase_hold_s,omitempty" json:"min_phase_hold_s,omitempty"` - SurplusOnly bool `yaml:"surplus_only,omitempty" json:"surplus_only,omitempty"` -} - -// V2XPolicy is the opt-in policy envelope for automatic V2X use. The -// current V2X pilot still dispatches only manual operator commands; this -// config lets the API expose "what would be safe right now?" before the -// planner is allowed to consume V2X as a dispatchable asset. -type V2XPolicy struct { - Enabled bool `yaml:"enabled" json:"enabled"` - - // DriverName, when set, scopes the policy to one configured V2X driver. - // Empty means the same policy applies to every V2X driver. - DriverName string `yaml:"driver_name,omitempty" json:"driver_name,omitempty"` - - // VehicleCapacityWh is optional if the charger reports capacity, but is - // required for reserve/departure energy math when the driver does not. - VehicleCapacityWh float64 `yaml:"vehicle_capacity_wh,omitempty" json:"vehicle_capacity_wh,omitempty"` - - // SoC percentages are YAML-facing 0..100 values. Telemetry stays 0..1. - MinReserveSoCPct float64 `yaml:"min_reserve_soc_pct,omitempty" json:"min_reserve_soc_pct,omitempty"` - DepartureTargetSoCPct float64 `yaml:"departure_target_soc_pct,omitempty" json:"departure_target_soc_pct,omitempty"` - - // DepartureTime is either "HH:MM" local time (next occurrence) or RFC3339. - DepartureTime string `yaml:"departure_time,omitempty" json:"departure_time,omitempty"` - - MaxChargeW float64 `yaml:"max_charge_w,omitempty" json:"max_charge_w,omitempty"` - MaxDischargeW float64 `yaml:"max_discharge_w,omitempty" json:"max_discharge_w,omitempty"` - - ExportAllowed bool `yaml:"export_allowed" json:"export_allowed"` - GridChargingAllowed bool `yaml:"grid_charging_allowed" json:"grid_charging_allowed"` - CycleCostOreKWh float64 `yaml:"cycle_cost_ore_kwh,omitempty" json:"cycle_cost_ore_kwh,omitempty"` -} - -// EVCharger is the high-level EV charger config written by the Settings UI. -// Exactly one transport block (HTTP or Modbus) is meaningful per provider — -// the runtime picks which to populate based on the provider's declared -// transport in evcloud.Provider. -// -// Password is stored in state.db (key "ev_charger_password"), NOT in config.yaml. -// It is populated at runtime by main.go after loading state and by the API -// handler on POST /api/config. Providers that don't need auth (e.g. local -// Modbus) leave Username + Password empty. -type EVCharger struct { - Provider string `yaml:"provider" json:"provider"` // "easee" | "ctek" - - // Connection — populate the block matching the provider's transport. - HTTP *EVChargerHTTP `yaml:"http,omitempty" json:"http,omitempty"` - Modbus *EVChargerModbus `yaml:"modbus,omitempty" json:"modbus,omitempty"` - - // Optional auth — required by cloud HTTP providers like Easee, - // unused by local Modbus providers like CTEK. - Username string `yaml:"username,omitempty" json:"username,omitempty"` - Password string `yaml:"-" json:"password,omitempty"` // persisted in state.db, not YAML - - Serial string `yaml:"serial,omitempty" json:"serial,omitempty"` - - // EmailLegacy preserves backward compatibility with the original - // `email:` field. Normalize() copies it into Username if Username - // is empty, so configs written before the generalization still load. - // New code should always read Username. - EmailLegacy string `yaml:"email,omitempty" json:"email,omitempty"` -} - -// EVChargerHTTP is the HTTP/cloud connection block. BaseURL is optional — -// when empty the provider uses its default (e.g. https://api.easee.com/api). -type EVChargerHTTP struct { - BaseURL string `yaml:"base_url,omitempty" json:"base_url,omitempty"` -} - -// EVChargerModbus is the Modbus/TCP connection block. Port defaults to 502 -// and UnitID defaults to 1 if zero — see provider-specific Validate. -type EVChargerModbus struct { - Host string `yaml:"host" json:"host"` - Port int `yaml:"port,omitempty" json:"port,omitempty"` - UnitID int `yaml:"unit_id,omitempty" json:"unit_id,omitempty"` -} - -// CalDAV configures the calendar-constraints feature (issue #498). FTW hosts -// its own in-process, pure-Go CalDAV server (emersion/go-webdav, MIT — see -// internal/caldavserver) and runs a CalDAV *client* against it that polls the -// calendar collection and maps events into planner intents: -// -// - an "away"/vacation event switches the load model to its away profile -// for the interval, so the planner conserves battery while the house is -// empty; -// - an EV "charged-by-departure" event sets the matching loadpoint's -// target SoC + deadline, which the MPC already honours. -// -// Events are classified by case-insensitive keyword match on the event -// title (SUMMARY). Keyword lists are configurable so non-English calendars -// work. The whole feature is opt-in (Enabled) and fail-soft: an unreachable -// server never blocks control. -// -// Password is stored in state.db (key "caldav_password"), NOT in config.yaml, -// mirroring EVCharger.Password. -type CalDAV struct { - Enabled bool `yaml:"enabled" json:"enabled"` - - // URL is the base URL of the CalDAV server. Defaults to the in-process - // native server at http://localhost:5232. - URL string `yaml:"url,omitempty" json:"url,omitempty"` - - Username string `yaml:"username,omitempty" json:"username,omitempty"` - Password string `yaml:"-" json:"password,omitempty"` // persisted in state.db, not YAML - - // CalendarPath is the collection path polled for events, relative to URL - // (e.g. "/ftw/energy/" for new configs). The runtime fallback below keeps - // the former path for configs that omitted this field before the rebrand. - CalendarPath string `yaml:"calendar_path,omitempty" json:"calendar_path,omitempty"` - - // PollIntervalS is how often the collection is re-fetched. Default 300s. - PollIntervalS int `yaml:"poll_interval_s,omitempty" json:"poll_interval_s,omitempty"` - - // HorizonDays bounds the calendar-query time range (recurrences are - // expanded server-side within it). Default 7. - HorizonDays int `yaml:"horizon_days,omitempty" json:"horizon_days,omitempty"` - - // EVLoadpointID is the loadpoint an EV event targets when the title - // names no specific one. Empty = the first/only configured loadpoint. - EVLoadpointID string `yaml:"ev_loadpoint_id,omitempty" json:"ev_loadpoint_id,omitempty"` - - // EVDefaultTargetSoCPct is used when an EV event's title carries no - // explicit percentage. Default 80. - EVDefaultTargetSoCPct float64 `yaml:"ev_default_target_soc_pct,omitempty" json:"ev_default_target_soc_pct,omitempty"` - - // AwayKeywords / EVKeywords classify an event by its title. Matching is - // case-insensitive substring. Empty lists fall back to the built-in - // defaults (see DefaultAwayKeywords / DefaultEVKeywords). - AwayKeywords []string `yaml:"away_keywords,omitempty" json:"away_keywords,omitempty"` - EVKeywords []string `yaml:"ev_keywords,omitempty" json:"ev_keywords,omitempty"` - - // EVSEHistory (default ON when enabled) makes FTW *write* a calendar - // event for each completed EV charging session into HistoryPath. This is - // an outbound capability — the user subscribes to HistoryPath to see when - // the charger was used. HistoryPath MUST differ from CalendarPath so FTW - // never re-reads its own history events as inbound intents. - EVSEHistory *bool `yaml:"evse_history,omitempty" json:"evse_history,omitempty"` - HistoryPath string `yaml:"history_path,omitempty" json:"history_path,omitempty"` - - // PublishPlan (default ON when enabled) makes FTW write its forward-looking - // plan — upcoming battery charge/discharge windows from the MPC — as - // read-only events into PlanPath (a SEPARATE collection), so you can see - // what FTW intends to do. Reconciled each publish so stale events are - // removed rather than piling up. - PublishPlan *bool `yaml:"publish_plan,omitempty" json:"publish_plan,omitempty"` - PlanPath string `yaml:"plan_path,omitempty" json:"plan_path,omitempty"` - PlanPublishIntervalS int `yaml:"plan_publish_interval_s,omitempty" json:"plan_publish_interval_s,omitempty"` - - // ManageCredentials (default ON when enabled) makes FTW generate a random - // password on first enable, which the in-process CalDAV server then - // authenticates against. The credential is shown in the Settings → Calendar - // tab (with a QR) to paste into a calendar app, so the operator never has to - // set one by hand. - ManageCredentials *bool `yaml:"manage_credentials,omitempty" json:"manage_credentials,omitempty"` - - // Listen is the bind address for the in-process CalDAV server. Default - // ":5232". FTW binds it on the LAN. - Listen string `yaml:"listen,omitempty" json:"listen,omitempty"` -} - -// ListenAddr returns the native CalDAV server bind address (default ":5232"). -func (cv *CalDAV) ListenAddr() string { - if cv != nil && strings.TrimSpace(cv.Listen) != "" { - return strings.TrimSpace(cv.Listen) - } - return ":5232" -} - -// ManageCredentialsEnabled reports whether FTW should auto-generate the managed -// CalDAV credential. Nil-safe; defaults ON when the feature is on. -func (cv *CalDAV) ManageCredentialsEnabled() bool { - return cv != nil && cv.Enabled && (cv.ManageCredentials == nil || *cv.ManageCredentials) -} - -// EVSEHistoryEnabled reports whether FTW should write EV-session history -// events. Nil-safe; defaults ON when the feature is enabled. -func (cv *CalDAV) EVSEHistoryEnabled() bool { - return cv != nil && cv.Enabled && (cv.EVSEHistory == nil || *cv.EVSEHistory) -} - -// PublishPlanEnabled reports whether FTW should publish its forward-looking -// plan calendar. Nil-safe; defaults ON when the feature is enabled. -func (cv *CalDAV) PublishPlanEnabled() bool { - return cv != nil && cv.Enabled && (cv.PublishPlan == nil || *cv.PublishPlan) -} - -// CalDAV defaults. Keyword identifiers are English; operators may override -// with localised terms via config (the values are user-facing). -var ( - DefaultCalDAVURL = "http://localhost:5232" - DefaultCalDAVCalendarPath = "/fortytwowatts/energy/" - DefaultCalDAVHistoryPath = "/fortytwowatts/history/" - DefaultCalDAVPlanPath = "/fortytwowatts/plan/" - DefaultCalDAVPlanPublishS = 900 - DefaultCalDAVUsername = "fortytwowatts" - DefaultCalDAVPollS = 300 - DefaultCalDAVHorizonDays = 7 - DefaultCalDAVEVTargetSoC = 80.0 - DefaultAwayKeywords = []string{"away", "vacation", "holiday"} - DefaultEVKeywords = []string{"ev", "car", "charge"} -) - -// Validate enforces range rules. Defaults are applied by the calendar -// service at construction time, so unset fields are legal here. -func (cv *CalDAV) Validate() error { - if cv == nil || !cv.Enabled { - return nil - } - if cv.PollIntervalS < 0 { - return errors.New("caldav.poll_interval_s must be >= 0") - } - if cv.HorizonDays < 0 { - return errors.New("caldav.horizon_days must be >= 0") - } - if cv.EVDefaultTargetSoCPct < 0 || cv.EVDefaultTargetSoCPct > 100 { - return errors.New("caldav.ev_default_target_soc_pct must be in [0, 100]") - } - return nil -} - -// Normalize folds the legacy `email:` YAML key into Username and clears -// it so subsequent writes use the canonical key. Idempotent. -func (e *EVCharger) Normalize() { - if e == nil { - return - } - if e.Username == "" && e.EmailLegacy != "" { - e.Username = e.EmailLegacy - } - e.EmailLegacy = "" -} - -// Validate enforces per-provider shape rules. Password is intentionally -// not required here — it's loaded from state.db after YAML parse (see -// main.go's ev_charger_password restore step), so at Validate() time -// the field may be legitimately empty. -func (e *EVCharger) Validate() error { - if e == nil { - return nil - } - switch e.Provider { - case "": - return errors.New("ev_charger.provider: required") - case "easee": - // Username/Password are NOT enforced here. The runtime easee - // driver logs + idles when creds are missing, and the API picker - // requires both before calling Easee Cloud. Letting a partial - // ev_charger block load is the original contract — the wizard - // writes provider intent first, then captures creds in a second - // API call. - if e.Modbus != nil { - return errors.New("ev_charger.modbus: not valid for provider easee (HTTP transport)") - } - case "ctek": - if e.Modbus == nil || e.Modbus.Host == "" { - return errors.New("ev_charger.modbus.host: required for provider ctek") - } - if e.Modbus.Port < 0 { - return errors.New("ev_charger.modbus.port: must be >= 0") - } - if e.Modbus.UnitID < 0 || e.Modbus.UnitID > 247 { - return errors.New("ev_charger.modbus.unit_id: must be in 0..247") - } - if e.HTTP != nil { - return errors.New("ev_charger.http: not valid for provider ctek (Modbus transport)") - } - if e.Username != "" || e.Password != "" { - return errors.New("ev_charger: username/password not valid for provider ctek") - } - default: - return fmt.Errorf("ev_charger.provider %q: not supported (valid: easee, ctek)", e.Provider) - } - return nil -} - -type OptimizerMultistage struct { - ScenarioLimit int `yaml:"scenario_limit,omitempty" json:"scenario_limit,omitempty"` - BranchIntervalSlots int `yaml:"branch_interval_slots,omitempty" json:"branch_interval_slots,omitempty"` - BranchHorizonSlots int `yaml:"branch_horizon_slots,omitempty" json:"branch_horizon_slots,omitempty"` - MaxBranching int `yaml:"max_branching,omitempty" json:"max_branching,omitempty"` - NearHorizonSlots int `yaml:"near_horizon_slots,omitempty" json:"near_horizon_slots,omitempty"` - MidHorizonSlots int `yaml:"mid_horizon_slots,omitempty" json:"mid_horizon_slots,omitempty"` - MidBlockSlots int `yaml:"mid_block_slots,omitempty" json:"mid_block_slots,omitempty"` - FarBlockSlots int `yaml:"far_block_slots,omitempty" json:"far_block_slots,omitempty"` - ServiceCVaRWeight *float64 `yaml:"service_cvar_weight,omitempty" json:"service_cvar_weight,omitempty"` - ServiceCVaRAlpha float64 `yaml:"service_cvar_alpha,omitempty" json:"service_cvar_alpha,omitempty"` - EconomicCVaRWeight float64 `yaml:"economic_cvar_weight,omitempty" json:"economic_cvar_weight,omitempty"` - EconomicCVaRAlpha float64 `yaml:"economic_cvar_alpha,omitempty" json:"economic_cvar_alpha,omitempty"` - DecompositionThreshold int `yaml:"decomposition_threshold,omitempty" json:"decomposition_threshold,omitempty"` - DecompositionMethod string `yaml:"decomposition_method,omitempty" json:"decomposition_method,omitempty"` - PHMaxIterations int `yaml:"ph_max_iterations,omitempty" json:"ph_max_iterations,omitempty"` - PHRho float64 `yaml:"ph_rho,omitempty" json:"ph_rho,omitempty"` - PHToleranceW float64 `yaml:"ph_tolerance_w,omitempty" json:"ph_tolerance_w,omitempty"` -} - -// Planner configures the MPC scheduler (optional — disabled if omitted). -// Mode: "self_consumption" (default) | "cheap_charge" | "arbitrage". -type Planner struct { - Enabled bool `yaml:"enabled" json:"enabled"` - Mode string `yaml:"mode,omitempty" json:"mode,omitempty"` - // Engine selects the primary optimizer: "python" (default) runs the - // CVXPY/HiGHS worker; "dp" is the legacy in-process rollback engine. - Engine string `yaml:"engine,omitempty" json:"engine,omitempty"` - // OptimizerCommand is the Python executable used for the local worker. - // It is an executable path, not a shell command. The module invocation is - // fixed by the host to avoid shell parsing and configuration injection. - OptimizerCommand string `yaml:"optimizer_command,omitempty" json:"optimizer_command,omitempty"` - OptimizerDir string `yaml:"optimizer_dir,omitempty" json:"optimizer_dir,omitempty"` - OptimizerTransport string `yaml:"optimizer_transport,omitempty" json:"optimizer_transport,omitempty"` - OptimizerSocket string `yaml:"optimizer_socket,omitempty" json:"optimizer_socket,omitempty"` - OptimizerSolver string `yaml:"optimizer_solver,omitempty" json:"optimizer_solver,omitempty"` - OptimizerFormulation string `yaml:"optimizer_formulation,omitempty" json:"optimizer_formulation,omitempty"` - OptimizerTimeoutS float64 `yaml:"optimizer_timeout_s,omitempty" json:"optimizer_timeout_s,omitempty"` - OptimizerIdleTimeoutS float64 `yaml:"optimizer_idle_timeout_s,omitempty" json:"optimizer_idle_timeout_s,omitempty"` - OptimizerMIPRelGap float64 `yaml:"optimizer_mip_rel_gap,omitempty" json:"optimizer_mip_rel_gap,omitempty"` - OptimizerCVaRWeight *float64 `yaml:"optimizer_cvar_weight,omitempty" json:"optimizer_cvar_weight,omitempty"` - OptimizerCVaRAlpha float64 `yaml:"optimizer_cvar_alpha,omitempty" json:"optimizer_cvar_alpha,omitempty"` - OptimizerRecourseShadow bool `yaml:"optimizer_recourse_shadow,omitempty" json:"optimizer_recourse_shadow,omitempty"` - OptimizerRecourseNonAnticipativeSlots int `yaml:"optimizer_recourse_non_anticipative_slots,omitempty" json:"optimizer_recourse_non_anticipative_slots,omitempty"` - OptimizerChallengerPolicy string `yaml:"optimizer_challenger_policy,omitempty" json:"optimizer_challenger_policy,omitempty"` - OptimizerMultistage *OptimizerMultistage `yaml:"optimizer_multistage,omitempty" json:"optimizer_multistage,omitempty"` - BaseLoadW float64 `yaml:"base_load_w,omitempty" json:"base_load_w,omitempty"` - HorizonHours int `yaml:"horizon_hours,omitempty" json:"horizon_hours,omitempty"` - IntervalMin int `yaml:"interval_min,omitempty" json:"interval_min,omitempty"` - SoCMinPct float64 `yaml:"soc_min_pct,omitempty" json:"soc_min_pct,omitempty"` - SoCMaxPct float64 `yaml:"soc_max_pct,omitempty" json:"soc_max_pct,omitempty"` - - // Deprecated: SoCSafetyFloorPct / SafetyFloorPenaltyOreKwhHour. The - // SoC-percentage safety floor was replaced by downside-PV planning - // (PVForecastSafetyK) — a percentage is the wrong unit (relative to - // battery size) for an absolute forecast risk. Still parsed so old - // config files load; ignored at runtime with a warning. Remove from - // your config and set pv_forecast_safety_k instead. - SoCSafetyFloorPct float64 `yaml:"soc_safety_floor_pct,omitempty" json:"soc_safety_floor_pct,omitempty"` - SafetyFloorPenaltyOreKwhHour float64 `yaml:"safety_floor_penalty_ore_kwh_hour,omitempty" json:"safety_floor_penalty_ore_kwh_hour,omitempty"` - - // PVForecastSafetyK scales the downside-PV haircut: the MPC plans - // against forecast PV minus k·σ, where σ is the recent PV forecast - // error std (pvmodel residual). The DP then won't run the battery - // down betting on PV that may not arrive — a reserve emerges from the - // live forecast uncertainty itself, sized to the real risk (large on - // variable cloudy days, ~zero on clear days or in winter), not a flat - // SoC %. Pointer so unset (→ default 1.0) is distinct from an explicit - // 0 (= raw forecast, no hedge: "use the battery you have"). - PVForecastSafetyK *float64 `yaml:"pv_forecast_safety_k,omitempty" json:"pv_forecast_safety_k,omitempty"` - - // PVChargeBonusOreKwh credits each kWh of battery charge fed from - // live PV surplus, in passive_arbitrage mode. Default 0 (disabled) - // — the import-tariff + VAT asymmetry already makes "store PV now" - // strictly preferred over "export PV now, reimport later" in the - // underlying DP economics, so the bonus is redundant under typical - // retail pricing. Setting it > 0 reinstates the bias and can pull - // battery charging forward; on days with future negative-price - // hours this leaves no headroom to absorb negative-priced PV and - // forces export at a loss. Use only if you have evidence that the - // DP is undervaluing storage in your specific configuration. - PVChargeBonusOreKwh float64 `yaml:"pv_charge_bonus_ore_kwh,omitempty" json:"pv_charge_bonus_ore_kwh,omitempty"` - - ChargeEfficiency float64 `yaml:"charge_efficiency,omitempty" json:"charge_efficiency,omitempty"` - DischargeEfficiency float64 `yaml:"discharge_efficiency,omitempty" json:"discharge_efficiency,omitempty"` - ExportOrePerKWh float64 `yaml:"export_ore_per_kwh,omitempty" json:"export_ore_per_kwh,omitempty"` // 0 = use mean spot - - // MinArbitrageSpreadOreKwh is the operator's "don't cycle the battery - // for marginal gains" knob, in öre per kWh. The planner won't cycle for - // grid arbitrage unless the price gain beats this many öre/kWh on top of - // round-trip losses. Applies only to the arbitrage modes - // (planner_arbitrage / planner_passive_arbitrage); self-consumption is - // never affected. It biases the planner's decision only — the savings - // statistics stay on real spot economics. 0 (default) = disabled. - MinArbitrageSpreadOreKwh float64 `yaml:"min_arbitrage_spread_ore_kwh,omitempty" json:"min_arbitrage_spread_ore_kwh,omitempty"` - - // LegacyDispatch reverts the control loop from the default - // energy-allocation path back to the legacy PI-on-grid-target - // path. Provided for emergency rollback only — the energy path - // respects the principle "plan allocates energy, EMS reacts to - // live data". - LegacyDispatch bool `yaml:"legacy_dispatch,omitempty" json:"legacy_dispatch,omitempty"` - - // UseEnergyDispatch is the deprecated inverse of LegacyDispatch. - // Pointer so we can distinguish "unset" (nil) from "explicitly - // false" (*false) — the latter matters because an operator who - // previously picked legacy dispatch must not be silently flipped - // to the energy path on upgrade. Honored with a startup WARN - // and will be removed after one release. - UseEnergyDispatch *bool `yaml:"use_energy_dispatch,omitempty" json:"use_energy_dispatch,omitempty"` -} - -// PVSafetyK resolves the downside-PV haircut scale (forecast − k·σ). Unset -// config (nil Planner or nil field) → default 1.0; an explicit value is -// honored verbatim, including 0 (no hedge — "use the battery you have"). -func (p *Planner) PVSafetyK() float64 { - if p == nil || p.PVForecastSafetyK == nil { - return 1.0 - } - return *p.PVForecastSafetyK -} - -// Site is the top-level control loop config. -type Site struct { - TroubleshootingMode bool `yaml:"troubleshooting_mode,omitempty" json:"troubleshooting_mode,omitempty"` - Name string `yaml:"name" json:"name"` - ControlIntervalS int `yaml:"control_interval_s" json:"control_interval_s"` - GridTargetW float64 `yaml:"grid_target_w" json:"grid_target_w"` - GridToleranceW float64 `yaml:"grid_tolerance_w" json:"grid_tolerance_w"` - WatchdogTimeoutS int `yaml:"watchdog_timeout_s" json:"watchdog_timeout_s"` - SmoothingAlpha float64 `yaml:"smoothing_alpha" json:"smoothing_alpha"` - Gain float64 `yaml:"gain" json:"gain"` - SlewRateW float64 `yaml:"slew_rate_w" json:"slew_rate_w"` - MinDispatchIntervalS int `yaml:"min_dispatch_interval_s" json:"min_dispatch_interval_s"` - - // SlewEnabled gates the external per-cycle ramp limiter. Both - // supported inverter families (Ferroamp, Sungrow) have their own - // internal power-ramp control loops; the external slew was - // originally added to dampen reactive-PI oscillation under noisy - // meter sampling, but it also slows legitimate step-response and - // can interact badly with PI integrator state (the 2026-05-25 - // recovery took ~3 min of slew-bounded ramping after the integral - // finally unwound). - // - // Pointer so we can distinguish "unset → default true" from - // "explicitly false". Defaults to enabled to preserve back-compat - // on existing installs. - SlewEnabled *bool `yaml:"slew_enabled,omitempty" json:"slew_enabled,omitempty"` - - // PVSurplusAbsorbSoCCapPct is the operator override for the PV-surplus - // absorber underlay in the energy-dispatch path (planner_cheap / - // planner_arbitrage). When the planner's slot allocation would still - // leave grid exporting beyond pv_surplus_absorb_threshold_w AND - // average SoC is below this cap, the dispatch redirects the leftover - // export into the battery instead of crossing the meter. Never - // reverses a discharge plan. 0 = no operator override; the planner can - // still enable a slot when capture displaces a more expensive future - // grid-funded charge. - // - // Suggested 88 — leaves 2 pp margin below the planner's typical - // soc_max_pct = 90 so the absorber doesn't slam into the wall. - PVSurplusAbsorbSoCCapPct float64 `yaml:"pv_surplus_absorb_soc_cap_pct,omitempty" json:"pv_surplus_absorb_soc_cap_pct,omitempty"` - - // PVSurplusAbsorbThresholdW is the trigger threshold for the - // absorber: only fires when projected grid export exceeds this many - // watts after the plan's target. Defaults to 100 W whenever the - // operator or planner enables absorption. - PVSurplusAbsorbThresholdW float64 `yaml:"pv_surplus_absorb_threshold_w,omitempty" json:"pv_surplus_absorb_threshold_w,omitempty"` - - // DCLinkProtectionEnabled opts into a live-state PV curtail that - // fires when SoC is near full AND PV significantly exceeds load - // — the configuration most exposed to a load-step-triggered - // inverter trip (real 2026-05-25 incident: Ferroamp EnergyHub - // fault from a 2.7 kW load step under 6 kW PV + 85 % SoC). - // Engaging pre-curtails PV to live load + margin so a sudden - // load step inside the margin lands without DC-link stress. - // Disabled by default — opt-in for sites that see repeated - // inverter trips. - DCLinkProtectionEnabled bool `yaml:"dc_link_protection_enabled,omitempty" json:"dc_link_protection_enabled,omitempty"` - - // DCLinkProtectionSoCThreshold (0-1) is the SoC fraction at or - // above which the protective curtail engages. Default 0.80. - DCLinkProtectionSoCThreshold float64 `yaml:"dc_link_protection_soc_threshold,omitempty" json:"dc_link_protection_soc_threshold,omitempty"` - - // DCLinkProtectionMarginW is the headroom (W) kept above live - // load when the protection fires. Larger margin = more PV - // allowed through, smaller load-step capacity before re-curtail. - // Default 1000. - DCLinkProtectionMarginW float64 `yaml:"dc_link_protection_margin_w,omitempty" json:"dc_link_protection_margin_w,omitempty"` - - // MaxExportW caps total site export (W, magnitude) below the physical - // fuse. 0 = disabled (export bounded only by the fuse). When > 0 it is - // enforced two ways: the dispatch fuse guard scales battery discharge - // back so predicted export stays under it, and the MPC caps each slot's - // export so the planner never schedules a discharge that would - // over-export. Protects inverters that trip on sustained export well - // below the breaker rating — the recurring Ferroamp EnergyHub fault - // state 0x8030 after ~8 kW sustained midday export, which only cleared - // as PV waned. Set it just under the observed trip point. - MaxExportW float64 `yaml:"max_export_w,omitempty" json:"max_export_w,omitempty"` -} - -// DefaultFuseSafetyMarginA is the fall-back per-phase amp headroom -// applied when fuse.safety_margin_a is unset (nil) in the YAML. -// Single source of truth — main.go routes through Fuse.Effective- -// SafetyMarginA() rather than re-declaring it. -const DefaultFuseSafetyMarginA = 0.5 - -// Fuse describes the shared breaker limit used by the fuse guard. -type Fuse struct { - MaxAmps float64 `yaml:"max_amps" json:"max_amps"` - Phases int `yaml:"phases" json:"phases"` - Voltage float64 `yaml:"voltage" json:"voltage"` - - // SafetyMarginA reserves headroom (per-phase amps) below MaxAmps - // inside the dispatch fuse guard. Pointer so we can distinguish - // "unset" (nil → DefaultFuseSafetyMarginA) from "explicitly - // disabled" (non-nil 0.0). Inverters often have their own per- - // phase current protection that trips before the breaker; without - // a margin the dispatch can ride right up to MaxAmps and the - // inverter cuts to 0 W in one tick, then dispatch ramps back up — - // visible as a flap. 0.5 A × 230 V × 3 phases ≈ 345 W of aggregate - // headroom. - SafetyMarginA *float64 `yaml:"safety_margin_a,omitempty" json:"safety_margin_a,omitempty"` -} - -// MaxPowerW returns the total power budget for the fuse guard. -func (f Fuse) MaxPowerW() float64 { - return f.MaxAmps * f.Voltage * float64(f.Phases) -} - -// EffectiveSafetyMarginA returns the per-phase amp headroom to apply, -// resolving nil ("unset → use default") vs an explicit value (including -// 0.0 to disable the margin entirely). Single read site so the default -// can never drift across consumers. -func (f Fuse) EffectiveSafetyMarginA() float64 { - if f.SafetyMarginA == nil { - return DefaultFuseSafetyMarginA - } - return *f.SafetyMarginA -} - -// Driver is one driver entry. Each driver is a Lua script loaded by -// the driver host at startup (or on hot-reload via the file watcher). -type Driver struct { - Name string `yaml:"name" json:"name"` - Lua string `yaml:"lua,omitempty" json:"lua,omitempty"` // path to .lua file - IsSiteMeter bool `yaml:"is_site_meter,omitempty" json:"is_site_meter,omitempty"` - BatteryCapacityWh float64 `yaml:"battery_capacity_wh,omitempty" json:"battery_capacity_wh,omitempty"` - // BatteryTelemetryOnly allows a read-only gateway driver to publish a - // physical battery's telemetry without making that driver eligible for - // battery dispatch. It is an explicit control-pool opt-out and wins even if - // a stale or hand-written config also contains BatteryCapacityWh. - // Sourceful Zap is the canonical user: its local API exposes battery data, - // but no stable semantic set-power endpoint. - BatteryTelemetryOnly bool `yaml:"battery_telemetry_only,omitempty" json:"battery_telemetry_only,omitempty"` - // MaxChargeW + MaxDischargeW set this driver's per-command power - // ceiling (site-signed +/-). Both optional; zero = fall through to - // the global MaxCommandW = 5 kW default the dispatcher has shipped - // with since v0.x. On a hybrid inverter that can actually deliver - // more (e.g. Ferroamp 10-15 kW, Sungrow 8-10 kW on 32 A), lifting - // the per-driver cap is the right move — site-wide fuse protection - // (applyFuseGuard) still enforces the grid-boundary budget above - // whatever per-battery cap you set. Issue #145. - MaxChargeW float64 `yaml:"max_charge_w,omitempty" json:"max_charge_w,omitempty"` - MaxDischargeW float64 `yaml:"max_discharge_w,omitempty" json:"max_discharge_w,omitempty"` - // InverterGroup tags this driver as belonging to a shared - // inverter+battery unit (e.g. set `inverter_group: ferroamp` on - // both the Ferroamp battery driver and anything publishing its PV - // telemetry). The dispatcher prefers routing charge to the battery - // whose group also has live PV output — staying DC-coupled on the - // same inverter avoids the DC→AC→AC→DC conversion overhead of - // cross-charging. Untagged drivers keep today's capacity-proportional - // behavior. See issue #143. - InverterGroup string `yaml:"inverter_group,omitempty" json:"inverter_group,omitempty"` - // SupportsPVCurtail flags this driver as one that handles the - // `curtail` / `curtail_disable` actions in its lua. Drivers with - // it set become eligible for ComputePVCurtail dispatch when the - // MPC's slot directive carries a PVLimitW > 0 (negative-export - // economic guard). Default false — operators must opt in per - // driver to avoid surprising older configs. The lua side has - // always been there for sungrow / ferroamp / deye / huawei / - // solis; this flag just turns on the Go-side dispatcher. - SupportsPVCurtail bool `yaml:"supports_pv_curtail,omitempty" json:"supports_pv_curtail,omitempty"` - // Disabled skips this driver at startup / reload. Set via the UI when - // you want to temporarily take a driver out without editing yaml. - Disabled bool `yaml:"disabled,omitempty" json:"disabled,omitempty"` - // HasPassword is a JSON-only signal to the UI that Config["password"] - // holds a non-empty value on disk. Populated by MaskSecrets after the - // real password is blanked out so the operator can still tell apart - // "never entered" from "saved but masked". Never written to yaml. - HasPassword bool `yaml:"-" json:"has_password,omitempty"` - - // Capabilities: the resources this driver is allowed to use. - // Unset capabilities are explicitly denied. - Capabilities Capabilities `yaml:"capabilities,omitempty" json:"capabilities,omitempty"` - - // Driver-specific config: arbitrary key/value map passed to - // driver_init(config) in Lua. Used for credentials, device addresses, - // thresholds, etc. that don't fit the generic capabilities model. - Config map[string]any `yaml:"config,omitempty" json:"config,omitempty"` - - // Legacy protocol fields (equivalent to capabilities, still accepted - // for backwards compatibility with master-branch configs). - MQTT *MQTTConfig `yaml:"mqtt,omitempty" json:"mqtt,omitempty"` - Modbus *ModbusConfig `yaml:"modbus,omitempty" json:"modbus,omitempty"` -} - -// Capabilities explicitly scope what host resources a driver can access. -type Capabilities struct { - MQTT *MQTTConfig `yaml:"mqtt,omitempty" json:"mqtt,omitempty"` - Modbus *ModbusConfig `yaml:"modbus,omitempty" json:"modbus,omitempty"` - HTTP *HTTPCapability `yaml:"http,omitempty" json:"http,omitempty"` - WebSocket *WSCapability `yaml:"websocket,omitempty" json:"websocket,omitempty"` - TCP *TCPCapability `yaml:"tcp,omitempty" json:"tcp,omitempty"` -} - -// MQTTConfig grants access to one MQTT broker. -type MQTTConfig struct { - Host string `yaml:"host" json:"host"` - Port int `yaml:"port,omitempty" json:"port,omitempty"` // default 1883 - Username string `yaml:"username,omitempty" json:"username,omitempty"` - Password string `yaml:"password,omitempty" json:"password,omitempty"` -} - -// ModbusConfig grants access to one Modbus TCP endpoint. -type ModbusConfig struct { - Host string `yaml:"host" json:"host"` - Port int `yaml:"port,omitempty" json:"port,omitempty"` // default 502 - UnitID int `yaml:"unit_id,omitempty" json:"unit_id,omitempty"` // default 1 -} - -// HTTPCapability grants HTTP access to specific hostnames (future). -type HTTPCapability struct { - AllowedHosts []string `yaml:"allowed_hosts" json:"allowed_hosts"` - // TLSPinSHA256, when set, pins the HTTPS server's leaf certificate to - // this SHA-256 fingerprint (hex; colons/whitespace ignored, case- - // insensitive). It is the SHA-256 over the DER certificate — identical - // to `openssl x509 -fingerprint -sha256`. Use it for HTTPS endpoints - // that present a self-signed certificate the system trust store cannot - // validate (e.g. a NIBE heat pump's local REST API). When set, normal - // chain/hostname verification is REPLACED by an exact fingerprint match - // for this driver only; when empty, standard verification against the - // system roots applies (unchanged for every existing HTTP driver). - TLSPinSHA256 string `yaml:"tls_pin_sha256,omitempty" json:"tls_pin_sha256,omitempty"` -} - -// WSCapability grants WebSocket (ws://, wss://) access. Same allowlist -// semantics as HTTPCapability — bare host = any port; "host:port" = exact. -type WSCapability struct { - AllowedHosts []string `yaml:"allowed_hosts" json:"allowed_hosts"` -} - -// TCPCapability grants raw TCP socket access (host.tcp_open). Same -// allowlist semantics as the HTTP/WS lists: bare host entry matches any -// port; "host:port" requires an exact match. Empty list = any host:port, -// which is fine for fully-trusted LAN deployments but loose enough to -// warrant an explicit list in shared installs. -type TCPCapability struct { - AllowedHosts []string `yaml:"allowed_hosts" json:"allowed_hosts"` -} - -// EffectiveMQTT returns the driver's MQTT config, preferring capabilities over legacy. -func (d Driver) EffectiveMQTT() *MQTTConfig { - if d.Capabilities.MQTT != nil { - return d.Capabilities.MQTT - } - return d.MQTT -} - -// EffectiveModbus returns the driver's Modbus config, preferring capabilities. -func (d Driver) EffectiveModbus() *ModbusConfig { - if d.Capabilities.Modbus != nil { - return d.Capabilities.Modbus - } - return d.Modbus -} - -// API is the HTTP server config. -type API struct { - Port int `yaml:"port" json:"port"` -} - -// HomeAssistant is the MQTT bridge config. -type HomeAssistant struct { - Enabled bool `yaml:"enabled" json:"enabled"` - Broker string `yaml:"broker" json:"broker"` - Port int `yaml:"port,omitempty" json:"port,omitempty"` - Username string `yaml:"username,omitempty" json:"username,omitempty"` - Password string `yaml:"password,omitempty" json:"password,omitempty"` - PublishIntervalS int `yaml:"publish_interval_s,omitempty" json:"publish_interval_s,omitempty"` -} - -// StateConf is the persistent state DB config. -// -// Path is the SQLite file (default "state.db"). ColdDir is the directory -// where >14d-old time-series data is rolled off as Parquet, partitioned -// YYYY/MM/DD.parquet (default "cold/" alongside Path). -// -// ColdRetentionDays bounds the cold Parquet tier: day files older than -// this are deleted by the hourly rolloff. 0 (default) keeps everything — -// a year of ~50 metrics is a few GB, so bounding is opt-in for small -// SD cards. -type StateConf struct { - Path string `yaml:"path" json:"path"` - ColdDir string `yaml:"cold_dir" json:"cold_dir"` - ColdRetentionDays int `yaml:"cold_retention_days,omitempty" json:"cold_retention_days,omitempty"` - // BackupDir stores verified full-backup archives. Relative paths resolve - // beside state.db; an absolute path can point at an externally mounted - // USB disk or network share. - BackupDir string `yaml:"backup_dir,omitempty" json:"backup_dir,omitempty"` -} - -// Price is the spot-price source config. -type Price struct { - Provider string `yaml:"provider" json:"provider"` // sourceful | elprisetjustnu | entsoe | none - Zone string `yaml:"zone,omitempty" json:"zone,omitempty"` - GridTariffOreKwh float64 `yaml:"grid_tariff_ore_kwh,omitempty" json:"grid_tariff_ore_kwh,omitempty"` - VATPercent float64 `yaml:"vat_percent,omitempty" json:"vat_percent,omitempty"` - APIKey string `yaml:"api_key,omitempty" json:"api_key,omitempty"` - - // Currency is the ISO code for pricing (default "SEK"). ENTSOE - // returns EUR/MWh; we convert using ECB daily FX rates. - Currency string `yaml:"currency,omitempty" json:"currency,omitempty"` - - // ExportBonusOreKwh is a per-kWh bonus on top of spot when exporting. - // Some retailers pay spot + fixed bonus (e.g. 60 öre in Sweden via - // "skattereduktion" + electricity-certificate value). Default 0. - ExportBonusOreKwh float64 `yaml:"export_bonus_ore_kwh,omitempty" json:"export_bonus_ore_kwh,omitempty"` - - // ExportFeeOreKwh is a per-kWh deduction on export (e.g. transmission - // fees some DSOs charge for feed-in). Reduces effective export price. - ExportFeeOreKwh float64 `yaml:"export_fee_ore_kwh,omitempty" json:"export_fee_ore_kwh,omitempty"` - - // ExportFloorOreKwh, if set, clamps per-slot export revenue at the - // given floor (öre/kWh). Use this only when your retailer caps - // negative-spot export at zero — i.e. they don't bill you when - // spot goes negative. Default (unset / nil) lets export revenue - // follow real spot, which can go negative; that's the physics - // most Swedish customer agreements pass through. Set to a pointer - // to 0.0 if you have a guaranteed-zero-floor agreement. - ExportFloorOreKwh *float64 `yaml:"export_floor_ore_kwh,omitempty" json:"export_floor_ore_kwh,omitempty"` -} - -// Weather is the weather-forecast source config. -type Weather struct { - Provider string `yaml:"provider" json:"provider"` // met_no | openweather | open_meteo | forecast_solar | none - Latitude float64 `yaml:"latitude" json:"latitude"` - Longitude float64 `yaml:"longitude" json:"longitude"` - APIKey string `yaml:"api_key,omitempty" json:"api_key,omitempty"` - - // PVRatedW is the system's nameplate PV output (W) — used as the - // initial twin prior AND the ceiling for naive PV estimates. If 0, - // we fall back to a heuristic (sum of battery_capacity_wh / 3), - // which is only roughly right for homes where PV and storage were - // sized together. Set explicitly for accurate day-1 forecasts. - PVRatedW float64 `yaml:"pv_rated_w,omitempty" json:"pv_rated_w,omitempty"` - - // PVTiltDeg / PVAzimuthDeg describe the physical orientation of a - // single panel group. Legacy single-array config — when PVArrays - // below is empty, the forecast_solar provider synthesizes one - // array from these + PVRatedW. Kept for backwards compatibility. - PVTiltDeg float64 `yaml:"pv_tilt_deg,omitempty" json:"pv_tilt_deg,omitempty"` - PVAzimuthDeg float64 `yaml:"pv_azimuth_deg,omitempty" json:"pv_azimuth_deg,omitempty"` - - // PVArrays is the list of physically-distinct panel groups at the - // site. Homes often have more than one roof plane (e.g. south and - // east), and the forecast_solar provider gives noticeably better - // predictions when each plane is described separately than when - // everything is averaged into a single tilt/azimuth. - // - // When set, PVArrays overrides the legacy single-array fields. - // Providers that can't use site geometry (met_no, open_meteo) - // ignore this entirely and just use PVRatedW. - PVArrays []PVArray `yaml:"pv_arrays,omitempty" json:"pv_arrays,omitempty"` - - // HeatingWPerDegC adds load proportional to max(18°C − outdoor_temp, 0). - // A rough-but-useful way to teach the planner that cold nights cost - // more than mild ones without running a full ML temperature fit. - // Typical Swedish single-family values: 200–500 W/°C. 0 disables. - HeatingWPerDegC float64 `yaml:"heating_w_per_degc,omitempty" json:"heating_w_per_degc,omitempty"` -} - -// PVArray is one physically-distinct panel group. Multi-plane -// residential installs typically have two or three (e.g. south roof -// + east roof + garage) with different tilt/azimuth. The sum of all -// KWp values should match the total PV nameplate at the site. -type PVArray struct { - Name string `yaml:"name,omitempty" json:"name,omitempty"` - KWp float64 `yaml:"kwp" json:"kwp"` - TiltDeg float64 `yaml:"tilt_deg" json:"tilt_deg"` - AzimuthDeg float64 `yaml:"azimuth_deg" json:"azimuth_deg"` -} - -// Battery is per-battery overrides (keyed by driver name in the top-level map). -type Battery struct { - SoCMin *float64 `yaml:"soc_min,omitempty" json:"soc_min,omitempty"` - SoCMax *float64 `yaml:"soc_max,omitempty" json:"soc_max,omitempty"` - MaxChargeW *float64 `yaml:"max_charge_w,omitempty" json:"max_charge_w,omitempty"` - MaxDischargeW *float64 `yaml:"max_discharge_w,omitempty" json:"max_discharge_w,omitempty"` - Weight *float64 `yaml:"weight,omitempty" json:"weight,omitempty"` -} - -// MaskSecrets returns a copy of the config with sensitive fields (passwords, -// API keys) replaced by empty strings so they are never exposed via the API. -// The original config is not modified. -func (c Config) MaskSecrets() Config { - out := c - - if out.EVCharger != nil { - cp := *out.EVCharger - cp.Password = "" - out.EVCharger = &cp - } - if out.CalDAV != nil { - cp := *out.CalDAV - cp.Password = "" - out.CalDAV = &cp - } - if out.HomeAssistant != nil { - cp := *out.HomeAssistant - cp.Password = "" - out.HomeAssistant = &cp - } - // The OCPP password is the only thing standing in front of a listener that - // is reachable on every interface, so it must never leave over the API. - if out.OCPP != nil { - cp := *out.OCPP - cp.Password = "" - out.OCPP = &cp - } - if out.Price != nil { - cp := *out.Price - cp.APIKey = "" - out.Price = &cp - } - if out.Weather != nil { - cp := *out.Weather - cp.APIKey = "" - out.Weather = &cp - } - if out.Notifications != nil { - cp := *out.Notifications - if cp.Ntfy != nil { - nc := *cp.Ntfy - nc.HasAccessToken = strings.TrimSpace(nc.AccessToken) != "" - nc.AccessToken = "" - nc.Password = "" - cp.Ntfy = &nc - } - if len(cp.Events) > 0 { - evs := make([]NotificationRule, len(cp.Events)) - copy(evs, cp.Events) - cp.Events = evs - } - out.Notifications = &cp - } - - if len(out.Drivers) > 0 { - drivers := make([]Driver, len(out.Drivers)) - copy(drivers, out.Drivers) - for i := range drivers { - if drivers[i].Config != nil { - cp := make(map[string]any, len(drivers[i].Config)) - for k, v := range drivers[i].Config { - cp[k] = v - } - if pw, has := cp["password"]; has { - // Signal "stored" to the UI before we blank it out. - if s, ok := pw.(string); ok && s != "" { - drivers[i].HasPassword = true - } - cp["password"] = "" - } - drivers[i].Config = cp - } - if drivers[i].Capabilities.MQTT != nil { - cp := *drivers[i].Capabilities.MQTT - cp.Password = "" - drivers[i].Capabilities.MQTT = &cp - } - if drivers[i].MQTT != nil { - cp := *drivers[i].MQTT - cp.Password = "" - drivers[i].MQTT = &cp - } - } - out.Drivers = drivers - } - - return out -} - -// PreserveMaskedSecrets copies real secrets from `existing` into `incoming` -// wherever the incoming value is empty (the UI sends "" for masked fields). -// Call this before saving a config received from the API. -func (incoming *Config) PreserveMaskedSecrets(existing *Config) { - if incoming.EVCharger != nil && existing.EVCharger != nil && incoming.EVCharger.Password == "" { - incoming.EVCharger.Password = existing.EVCharger.Password - } - if incoming.CalDAV != nil && existing.CalDAV != nil && incoming.CalDAV.Password == "" { - incoming.CalDAV.Password = existing.CalDAV.Password - } - // Masked out on the way to the UI, so an unchanged password comes back - // empty. Without this a save from the settings tab would blank it, and an - // enabled server would then fail validation on the next reload. - if incoming.OCPP != nil && existing.OCPP != nil && incoming.OCPP.Password == "" { - incoming.OCPP.Password = existing.OCPP.Password - } - if incoming.HomeAssistant != nil && existing.HomeAssistant != nil && incoming.HomeAssistant.Password == "" { - incoming.HomeAssistant.Password = existing.HomeAssistant.Password - } - if incoming.Price != nil && existing.Price != nil && incoming.Price.APIKey == "" { - incoming.Price.APIKey = existing.Price.APIKey - } - if incoming.Weather != nil && existing.Weather != nil && incoming.Weather.APIKey == "" { - incoming.Weather.APIKey = existing.Weather.APIKey - } - if incoming.Notifications != nil && existing.Notifications != nil && - incoming.Notifications.Ntfy != nil && existing.Notifications.Ntfy != nil { - if incoming.Notifications.Ntfy.AccessToken == "" { - incoming.Notifications.Ntfy.AccessToken = existing.Notifications.Ntfy.AccessToken - } - if incoming.Notifications.Ntfy.Password == "" { - incoming.Notifications.Ntfy.Password = existing.Notifications.Ntfy.Password - } - } - for i := range incoming.Drivers { - for _, ed := range existing.Drivers { - if incoming.Drivers[i].Name != ed.Name { - continue - } - if incoming.Drivers[i].Config != nil && ed.Config != nil { - if pw, ok := incoming.Drivers[i].Config["password"]; ok { - if pw == "" || pw == nil { - incoming.Drivers[i].Config["password"] = ed.Config["password"] - } - } - } - // Restore MQTT password in capabilities block. - if incoming.Drivers[i].Capabilities.MQTT != nil && ed.Capabilities.MQTT != nil && - incoming.Drivers[i].Capabilities.MQTT.Password == "" { - incoming.Drivers[i].Capabilities.MQTT.Password = ed.Capabilities.MQTT.Password - } - // Restore MQTT password in legacy block. - if incoming.Drivers[i].MQTT != nil && ed.MQTT != nil && - incoming.Drivers[i].MQTT.Password == "" { - incoming.Drivers[i].MQTT.Password = ed.MQTT.Password - } - break - } - } -} - -// Load parses a config file from disk. Returns a fully-validated Config. -func Load(path string) (*Config, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("read %s: %w", path, err) - } - return Parse(data, filepath.Dir(path)) -} - -// Parse parses config bytes and validates. baseDir resolves driver Lua paths. -func Parse(data []byte, baseDir string) (*Config, error) { - var c Config - if err := yaml.Unmarshal(data, &c); err != nil { - return nil, fmt.Errorf("yaml: %w", err) - } - applyDefaults(&c) - if err := c.Validate(); err != nil { - return nil, err - } - c.ResolveDriverPaths(baseDir) - return &c, nil -} - -// DriversDirOverride redirects resolution of relative "drivers/.lua" -// Lua paths to this directory instead of the config sibling. main.go sets -// it once at startup from the -drivers flag so Docker images — where -// drivers live in the immutable image layer (/app/drivers) rather than -// next to the user's config (/app/data) — can still load driver scripts. -// Empty string preserves the historical "sibling-of-config" behaviour. -var DriversDirOverride string - -// UserDriversDirOverride is the second lookup path tried before -// DriversDirOverride. Designed for persistent user-supplied drivers in -// the docker deploy where DriversDirOverride lives in the immutable -// image layer. When set, ResolveDriverPaths checks whether a file -// exists in this directory first and uses it when found; otherwise -// falls back to DriversDirOverride. Empty = single-dir behaviour -// (back-compat). -var UserDriversDirOverride string - -// ManagedDriversDirOverride contains stable active symlinks maintained by the -// signed driver repository. It is checked after the local user overlay and -// before the bundled recovery snapshot. -var ManagedDriversDirOverride string - -// ResolveDriverPaths joins relative Lua driver paths with baseDir, or -// with DriversDirOverride when the relative path starts with "drivers/". -// When UserDriversDirOverride is also set, paths starting with "drivers/" -// are first probed in UserDriversDirOverride; only if the file is absent -// there do they fall through to DriversDirOverride. -func (c *Config) ResolveDriverPaths(baseDir string) { - for i := range c.Drivers { - c.Drivers[i].Lua = stripLeadingDotDot(c.Drivers[i].Lua) - p := c.Drivers[i].Lua - if p == "" || filepath.IsAbs(p) { - continue - } - if strings.HasPrefix(p, "drivers/") { - rel := strings.TrimPrefix(p, "drivers/") - if UserDriversDirOverride != "" { - candidate := filepath.Join(UserDriversDirOverride, rel) - if _, err := os.Stat(candidate); err == nil { - c.Drivers[i].Lua = candidate - continue - } - } - if ManagedDriversDirOverride != "" { - candidate := filepath.Join(ManagedDriversDirOverride, rel) - if _, err := os.Stat(candidate); err == nil { - c.Drivers[i].Lua = candidate - continue - } - } - if DriversDirOverride != "" { - c.Drivers[i].Lua = filepath.Join(DriversDirOverride, rel) - continue - } - } - c.Drivers[i].Lua = filepath.Join(baseDir, p) - } -} - -func stripLeadingDotDot(p string) string { - for strings.HasPrefix(p, "../") { - p = p[3:] - } - return p -} - -// UnresolveDriverPaths converts resolved driver paths back to config-relative form. -// -// Paths that are outside baseDir (filepath.Rel would yield a ../-prefixed -// result) are left absolute — otherwise the next ResolveDriverPaths would -// strip the leading ../ via stripLeadingDotDot and silently re-anchor the -// driver under baseDir. When DriversDirOverride is set, paths resolved -// through it are rewritten back to "drivers/" so the YAML + UI -// round-trip stays portable (no /app/drivers/... baked into config.yaml). -func (c *Config) UnresolveDriverPaths(baseDir string) { - for i := range c.Drivers { - p := c.Drivers[i].Lua - if p != "" { - // Check UserDriversDirOverride first so that user-dir paths are - // re-serialised as portable "drivers/" just like bundled paths. - if UserDriversDirOverride != "" { - rel, err := filepath.Rel(UserDriversDirOverride, p) - if err == nil && !strings.HasPrefix(rel, "..") { - c.Drivers[i].Lua = filepath.ToSlash(filepath.Join("drivers", rel)) - continue - } - } - if ManagedDriversDirOverride != "" { - rel, err := filepath.Rel(ManagedDriversDirOverride, p) - if err == nil && !strings.HasPrefix(rel, "..") { - c.Drivers[i].Lua = filepath.ToSlash(filepath.Join("drivers", rel)) - continue - } - } - if DriversDirOverride != "" { - rel, err := filepath.Rel(DriversDirOverride, p) - if err == nil && !strings.HasPrefix(rel, "..") { - c.Drivers[i].Lua = filepath.ToSlash(filepath.Join("drivers", rel)) - continue - } - } - } - c.Drivers[i].Lua = relToBaseDir(baseDir, p) - } -} - -func relToBaseDir(baseDir, p string) string { - if p == "" { - return p - } - rel, err := filepath.Rel(baseDir, p) - if err != nil { - return p - } - if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { - return p - } - return rel -} - -// applyDefaults fills in sensible zero-value defaults. -func applyDefaults(c *Config) { - if c.DeviceRepository == nil { - // The official signed stable catalog is safe to discover by default: - // refresh is read-only and never activates or restarts a driver. An - // explicit enabled:false block remains the operator opt-out. - c.DeviceRepository = &DeviceRepository{Enabled: true} - } - if c.DeviceRepository != nil { - if c.DeviceRepository.RefreshIntervalH == 0 { - c.DeviceRepository.RefreshIntervalH = 24 - } - // The pinned official trust root is a secure default and needs no key - // copied into every site configuration. - if len(c.DeviceRepository.Repositories) == 0 { - c.DeviceRepository.Repositories = []DriverRepositorySource{{ - ID: DefaultDriverRepositoryID, - Name: DefaultDriverRepositoryName, - ManifestURL: DefaultDriverRepositoryManifestURL, - Enabled: true, - TrustedKeys: map[string]string{ - DefaultDriverRepositorySigningKeyID: DefaultDriverRepositoryPublicKey, - }, - }} - } - } - if c.Site.ControlIntervalS == 0 { - // 2 s matches Ferroamp's ehub MQTT cadence (~1 Hz) without - // dispatching twice on the same telemetry sample, and halves - // the perceived response lag operators saw at the original 5 s. - c.Site.ControlIntervalS = 2 - } - if c.Site.GridToleranceW == 0 { - c.Site.GridToleranceW = 42 // The Answer - } - if c.Site.WatchdogTimeoutS == 0 { - c.Site.WatchdogTimeoutS = 60 - } - if c.Site.SmoothingAlpha == 0 { - c.Site.SmoothingAlpha = 0.3 - } - if c.Site.Gain == 0 { - c.Site.Gain = 0.5 - } - if c.Site.SlewRateW == 0 { - // 3000 W/cycle at the 2 s default control interval = 1500 W/s - // ramp ceiling. Both Ferroamp and Sungrow internal EMS loops - // ramp slower than this naturally (Sungrow spec: ~1000 W/s), - // so the external slew rarely fires under normal conditions - // but still bounds the post-windup recovery from snapping to - // full output in a single cycle. - c.Site.SlewRateW = 3000 - } - if c.Site.SlewEnabled == nil { - t := true - c.Site.SlewEnabled = &t - } - if c.Site.MinDispatchIntervalS == 0 { - // Match control_interval_s. The holdoff exists to suppress - // command-spam when the tick is faster than the battery's - // response — at 2 s ticks the natural cadence is already the - // minimum, so the holdoff is a no-op debouncer in practice. - c.Site.MinDispatchIntervalS = 2 - } - if c.Fuse.Phases == 0 { - c.Fuse.Phases = 3 - } - if c.Fuse.Voltage == 0 { - c.Fuse.Voltage = 230 - } - if c.API.Port == 0 { - c.API.Port = 8080 - } - // Driver connection defaults - for i := range c.Drivers { - d := &c.Drivers[i] - if cap := d.Capabilities.MQTT; cap != nil && cap.Port == 0 { - cap.Port = 1883 - } - if cap := d.Capabilities.Modbus; cap != nil { - if cap.Port == 0 { - cap.Port = 502 - } - if cap.UnitID == 0 { - cap.UnitID = 1 - } - } - if cap := d.MQTT; cap != nil && cap.Port == 0 { - cap.Port = 1883 - } - if cap := d.Modbus; cap != nil { - if cap.Port == 0 { - cap.Port = 502 - } - if cap.UnitID == 0 { - cap.UnitID = 1 - } - } - } - if c.HomeAssistant != nil { - if c.HomeAssistant.Port == 0 { - c.HomeAssistant.Port = 1883 - } - if c.HomeAssistant.PublishIntervalS == 0 { - c.HomeAssistant.PublishIntervalS = 5 - } - } - // Backfill for configs that predate notifications: — lands a - // populated-but-disabled stub so upgrading an existing install - // lights up the Notifications tab with the defaults instead of an - // empty form. Nothing is written to disk until the operator Saves. - if c.Notifications == nil { - c.Notifications = &Notifications{ - Enabled: false, - Provider: "ntfy", - DefaultPriority: 3, - Ntfy: &NtfyConfig{Server: "https://ntfy.sh"}, - Events: []NotificationRule{ - {Type: "driver_offline", Enabled: false, ThresholdS: 600, Priority: 4, CooldownS: 3600}, - {Type: "driver_recovered", Enabled: false, Priority: 3}, - {Type: "update_available", Enabled: false, Priority: 3, CooldownS: 3600}, - {Type: "fuse_over_limit", Enabled: false, ThresholdS: 30, Priority: 5, CooldownS: 900}, - }, - } - } - // Rule-list migration: add new built-in event types to existing - // configs that predate them so upgrading lights up the toggle in - // Settings → Notifications instead of needing manual YAML edits. - if c.Notifications != nil { - builtins := []NotificationRule{ - {Type: "driver_offline", Enabled: false, ThresholdS: 600, Priority: 4, CooldownS: 3600}, - {Type: "driver_recovered", Enabled: false, Priority: 3}, - {Type: "update_available", Enabled: false, Priority: 3, CooldownS: 3600}, - {Type: "fuse_over_limit", Enabled: false, ThresholdS: 30, Priority: 5, CooldownS: 900}, - } - have := make(map[string]bool, len(c.Notifications.Events)) - for _, r := range c.Notifications.Events { - have[r.Type] = true - } - for _, b := range builtins { - if !have[b.Type] { - c.Notifications.Events = append(c.Notifications.Events, b) - } - } - } - if c.Notifications != nil { - if c.Notifications.Provider == "" { - c.Notifications.Provider = "ntfy" - } - if c.Notifications.DefaultPriority == 0 { - c.Notifications.DefaultPriority = 3 - } - if c.Notifications.Provider == "ntfy" { - if c.Notifications.Ntfy == nil { - c.Notifications.Ntfy = &NtfyConfig{} - } - if c.Notifications.Ntfy.Server == "" { - c.Notifications.Ntfy.Server = "https://ntfy.sh" - } - } - } - if c.Nova != nil { - if c.Nova.MQTTPort == 0 { - c.Nova.MQTTPort = 1883 - } - if c.Nova.SchemaMode == "" { - c.Nova.SchemaMode = "legacy" - } - if c.Nova.PublishIntervalS == 0 { - c.Nova.PublishIntervalS = 5 - } - if c.Nova.ReconcileIntervalH == 0 { - c.Nova.ReconcileIntervalH = 24 - } - } -} - -// Validate ensures the config is internally consistent and safe to run with. -func (c *Config) Validate() error { - if c.State != nil && c.State.ColdRetentionDays < 0 { - return fmt.Errorf("state.cold_retention_days must be >= 0, got %d", c.State.ColdRetentionDays) - } - if c.EVCharger != nil { - c.EVCharger.Normalize() - if err := c.EVCharger.Validate(); err != nil { - return err - } - } - if err := c.CalDAV.Validate(); err != nil { - return err - } - if err := c.OCPP.Validate(); err != nil { - return err - } - - // Empty drivers list is a valid shape — e.g. an EV-only site that - // configured a cloud EV charger in the setup wizard and doesn't - // own local inverter/meter hardware. Control loop becomes a no-op - // (SiteMeterDriver() returns "" and telemetry lookups just miss); - // the site meter check below only fires once at least one driver - // exists. - siteMeters := 0 - names := make(map[string]bool, len(c.Drivers)) - for _, d := range c.Drivers { - if d.Name == "" { - return errors.New("driver: name is required") - } - if names[d.Name] { - return fmt.Errorf("driver %q: duplicate name", d.Name) - } - names[d.Name] = true - - if d.IsSiteMeter { - siteMeters++ - } - if d.Lua == "" { - return fmt.Errorf("driver %q: must specify `lua`", d.Name) - } - if d.EffectiveMQTT() == nil && d.EffectiveModbus() == nil && - d.Capabilities.HTTP == nil && d.Capabilities.WebSocket == nil && - d.Capabilities.TCP == nil { - return fmt.Errorf("driver %q: must have mqtt, modbus, http, websocket, or tcp capability", d.Name) - } - } - if len(c.Drivers) > 0 && siteMeters == 0 { - return errors.New("at least one driver must be is_site_meter: true") - } - - if c.Site.ControlIntervalS < 0 { - return errors.New("site.control_interval_s must be >= 0") - } - if c.Site.GridToleranceW < 0 { - return errors.New("site.grid_tolerance_w must be >= 0") - } - if c.Site.WatchdogTimeoutS < 0 { - return errors.New("site.watchdog_timeout_s must be >= 0") - } - if c.Site.SmoothingAlpha <= 0 || c.Site.SmoothingAlpha > 1 { - return errors.New("site.smoothing_alpha must be in (0, 1]") - } - if c.Site.Gain < 0 { - return errors.New("site.gain must be >= 0") - } - if c.Site.SlewRateW < 0 { - return errors.New("site.slew_rate_w must be >= 0") - } - if c.Site.MinDispatchIntervalS < 0 { - return errors.New("site.min_dispatch_interval_s must be >= 0") - } - if c.Fuse.MaxAmps <= 0 { - return errors.New("fuse.max_amps must be > 0") - } - if c.Fuse.Phases <= 0 { - return errors.New("fuse.phases must be > 0") - } - if c.Fuse.Voltage <= 0 { - return errors.New("fuse.voltage must be > 0") - } - // safety_margin_a must be in [0, max_amps) when explicitly set. - // Negative would *raise* the per-phase threshold above the breaker - // rating (defeating the guard); >= max_amps zeroes the headroom - // and silently disables the per-phase clamp — both are real safety - // holes if reached through a typo'd config. nil (unset) is OK and - // resolves to DefaultFuseSafetyMarginA at the consumer. - if c.Fuse.SafetyMarginA != nil { - v := *c.Fuse.SafetyMarginA - if v < 0 { - return errors.New("fuse.safety_margin_a must be >= 0") - } - if v >= c.Fuse.MaxAmps { - return errors.New("fuse.safety_margin_a must be < fuse.max_amps") - } - } - if err := c.validateV2XPolicy(names); err != nil { - return err - } - if n := c.Notifications; n != nil { - if n.DefaultPriority < 0 || n.DefaultPriority > 5 { - return errors.New("notifications.default_priority must be in [0,5]") - } - if n.Enabled { - switch n.Provider { - case "", "ntfy": - if n.Ntfy == nil { - return errors.New("notifications.ntfy required when provider=ntfy and enabled") - } - if strings.TrimSpace(n.Ntfy.Server) == "" { - return errors.New("notifications.ntfy.server required when enabled") - } - if strings.TrimSpace(n.Ntfy.Topic) == "" { - return errors.New("notifications.ntfy.topic required when enabled") - } - default: - return fmt.Errorf("notifications.provider %q not supported", n.Provider) - } - } - for i, ev := range n.Events { - if strings.TrimSpace(ev.Type) == "" { - return fmt.Errorf("notifications.events[%d]: type required", i) - } - if ev.ThresholdS < 0 { - return fmt.Errorf("notifications.events[%d]: threshold_s must be >= 0", i) - } - if ev.Priority < 0 || ev.Priority > 5 { - return fmt.Errorf("notifications.events[%d]: priority must be in [0,5]", i) - } - if ev.CooldownS < 0 { - return fmt.Errorf("notifications.events[%d]: cooldown_s must be >= 0", i) - } - } - } - if c.Nova != nil && c.Nova.Enabled { - if c.Nova.URL == "" { - return errors.New("nova.url is required when nova.enabled") - } - if c.Nova.MQTTHost == "" { - return errors.New("nova.mqtt_host is required when nova.enabled") - } - if c.Nova.GatewaySerial == "" { - return errors.New("nova.gateway_serial is required when nova.enabled — run `ftw nova-claim`") - } - if c.Nova.OrgID == "" { - return errors.New("nova.org_id is required when nova.enabled") - } - if c.Nova.SiteID == "" { - return errors.New("nova.site_id is required when nova.enabled") - } - switch c.Nova.SchemaMode { - case "legacy", "unified": - default: - return fmt.Errorf("nova.schema_mode must be \"legacy\" or \"unified\", got %q", c.Nova.SchemaMode) - } - } - if c.Planner != nil && c.Planner.MinArbitrageSpreadOreKwh < 0 { - return fmt.Errorf("planner.min_arbitrage_spread_ore_kwh must be ≥ 0, got %g", c.Planner.MinArbitrageSpreadOreKwh) - } - if c.Planner != nil { - p := c.Planner - switch p.Engine { - case "", "python", "dp": - default: - return fmt.Errorf("planner.engine must be \"python\" or \"dp\", got %q", p.Engine) - } - switch strings.ToUpper(p.OptimizerSolver) { - case "", "HIGHS", "CLARABEL": - default: - return fmt.Errorf("planner.optimizer_solver must be \"HIGHS\" or \"CLARABEL\", got %q", p.OptimizerSolver) - } - switch p.OptimizerFormulation { - case "", "auto", "milp", "relaxed": - default: - return fmt.Errorf("planner.optimizer_formulation must be auto, milp, or relaxed, got %q", p.OptimizerFormulation) - } - switch p.OptimizerTransport { - case "", "auto", "unix", "process": - default: - return fmt.Errorf("planner.optimizer_transport must be auto, unix, or process, got %q", p.OptimizerTransport) - } - if p.OptimizerTimeoutS < 0 || p.OptimizerIdleTimeoutS < 0 || p.OptimizerMIPRelGap < 0 || (p.OptimizerCVaRWeight != nil && *p.OptimizerCVaRWeight < 0) { - return errors.New("planner optimizer timeout, idle timeout, MIP gap, and CVaR weight must be non-negative") - } - if p.OptimizerMIPRelGap > 1 { - return fmt.Errorf("planner.optimizer_mip_rel_gap must be <= 1, got %g", p.OptimizerMIPRelGap) - } - if p.OptimizerCVaRAlpha < 0 || p.OptimizerCVaRAlpha >= 1 { - return fmt.Errorf("planner.optimizer_cvar_alpha must be 0 (default) or in (0,1), got %g", p.OptimizerCVaRAlpha) - } - if p.OptimizerRecourseNonAnticipativeSlots < 0 { - return errors.New("planner.optimizer_recourse_non_anticipative_slots must be non-negative") - } - switch p.OptimizerChallengerPolicy { - case "", "recourse", "multistage": - default: - return fmt.Errorf("planner.optimizer_challenger_policy must be recourse or multistage, got %q", p.OptimizerChallengerPolicy) - } - if ms := p.OptimizerMultistage; ms != nil { - ints := []int{ms.ScenarioLimit, ms.BranchIntervalSlots, ms.BranchHorizonSlots, - ms.MaxBranching, ms.NearHorizonSlots, ms.MidHorizonSlots, ms.MidBlockSlots, - ms.FarBlockSlots, ms.DecompositionThreshold, ms.PHMaxIterations} - for _, value := range ints { - if value < 0 { - return errors.New("planner.optimizer_multistage integer settings must be non-negative") - } - } - if ms.MaxBranching == 1 { - return errors.New("planner.optimizer_multistage.max_branching must be 0 (default) or at least 2") - } - if (ms.ServiceCVaRWeight != nil && *ms.ServiceCVaRWeight < 0) || ms.EconomicCVaRWeight < 0 || ms.PHRho < 0 || ms.PHToleranceW < 0 { - return errors.New("planner.optimizer_multistage risk weights, PH rho, and PH tolerance must be non-negative") - } - if (ms.ServiceCVaRAlpha < 0 || ms.ServiceCVaRAlpha >= 1) || - (ms.EconomicCVaRAlpha < 0 || ms.EconomicCVaRAlpha >= 1) { - return errors.New("planner.optimizer_multistage CVaR alpha must be 0 (default) or in (0,1)") - } - switch ms.DecompositionMethod { - case "", "auto", "extensive", "progressive_hedging": - default: - return fmt.Errorf("planner.optimizer_multistage.decomposition_method is invalid: %q", ms.DecompositionMethod) - } - } - } - if repoCfg := c.DeviceRepository; repoCfg != nil { - if repoCfg.RefreshIntervalH < 0 { - return errors.New("device_repository.refresh_interval_h must be non-negative") - } - seen := make(map[string]bool, len(repoCfg.Repositories)) - for _, repo := range repoCfg.Repositories { - if repo.ID == "" || strings.ContainsAny(repo.ID, "/\\") { - return fmt.Errorf("device_repository repository has invalid id %q", repo.ID) - } - if seen[repo.ID] { - return fmt.Errorf("device_repository has duplicate repository id %q", repo.ID) - } - seen[repo.ID] = true - if !repo.Enabled { - continue - } - u, err := url.Parse(repo.ManifestURL) - if err != nil || u.Scheme == "" { - return fmt.Errorf("device_repository %s has invalid manifest_url", repo.ID) - } - if u.Scheme != "https" && !repo.AllowInsecure { - return fmt.Errorf("device_repository %s manifest_url must use https", repo.ID) - } - if repo.AllowUnsigned && u.Scheme != "file" { - return fmt.Errorf("device_repository %s allow_unsigned is restricted to local file manifests", repo.ID) - } - if !repo.AllowUnsigned && len(repo.TrustedKeys) == 0 { - return fmt.Errorf("device_repository %s requires at least one trusted Ed25519 key", repo.ID) - } - } - } - return nil -} - -func (c *Config) validateV2XPolicy(driverNames map[string]bool) error { - p := c.V2X - if p == nil { - return nil - } - if p.DriverName != "" && !driverNames[p.DriverName] { - return fmt.Errorf("v2x.driver_name %q: no such driver", p.DriverName) - } - for name, value := range map[string]float64{ - "v2x.vehicle_capacity_wh": p.VehicleCapacityWh, - "v2x.max_charge_w": p.MaxChargeW, - "v2x.max_discharge_w": p.MaxDischargeW, - "v2x.cycle_cost_ore_kwh": p.CycleCostOreKWh, - "v2x.min_reserve_soc_pct": p.MinReserveSoCPct, - "v2x.departure_target_soc_pct": p.DepartureTargetSoCPct, - } { - if math.IsNaN(value) || math.IsInf(value, 0) { - return fmt.Errorf("%s must be finite", name) - } - } - if p.VehicleCapacityWh < 0 { - return errors.New("v2x.vehicle_capacity_wh must be >= 0") - } - if p.MaxChargeW < 0 { - return errors.New("v2x.max_charge_w must be >= 0") - } - if p.MaxDischargeW < 0 { - return errors.New("v2x.max_discharge_w must be >= 0") - } - if p.CycleCostOreKWh < 0 { - return errors.New("v2x.cycle_cost_ore_kwh must be >= 0") - } - if p.MinReserveSoCPct < 0 || p.MinReserveSoCPct > 100 { - return errors.New("v2x.min_reserve_soc_pct must be in [0,100]") - } - if p.DepartureTargetSoCPct < 0 || p.DepartureTargetSoCPct > 100 { - return errors.New("v2x.departure_target_soc_pct must be in [0,100]") - } - if p.Enabled && p.MinReserveSoCPct <= 0 { - return errors.New("v2x.min_reserve_soc_pct must be > 0 when v2x.enabled") - } - if p.DepartureTime != "" { - if err := validateV2XDepartureTime(p.DepartureTime); err != nil { - return err - } - } - if (p.DepartureTargetSoCPct > 0) != (p.DepartureTime != "") { - return errors.New("v2x.departure_target_soc_pct and v2x.departure_time must be set together") - } - if p.DepartureTargetSoCPct > 0 && p.DepartureTargetSoCPct < p.MinReserveSoCPct { - return errors.New("v2x.departure_target_soc_pct must be >= v2x.min_reserve_soc_pct") - } - return nil -} - -func validateV2XDepartureTime(value string) error { - if _, err := time.Parse("15:04", value); err == nil { - return nil - } - if _, err := time.Parse(time.RFC3339, value); err == nil { - return nil - } - return fmt.Errorf("v2x.departure_time must be HH:MM or RFC3339, got %q", value) -} - -// SiteMeterDriver returns the name of the driver marked is_site_meter. -func (c *Config) SiteMeterDriver() string { - for _, d := range c.Drivers { - if d.IsSiteMeter { - return d.Name - } - } - return "" -} - -// SaveAtomic writes config to disk via tmp-file + rename. Safe from partial writes. -func SaveAtomic(path string, c *Config) error { - // Driver paths are resolved to absolute-ish paths at Load() time. - // Convert them back to config-relative before writing so that - // repeated save cycles don't accumulate extra "../" prefixes. - baseDir := filepath.Dir(path) - out := *c - if len(out.Drivers) > 0 { - drivers := make([]Driver, len(out.Drivers)) - copy(drivers, out.Drivers) - for i := range drivers { - drivers[i].Lua = relDriverPath(baseDir, drivers[i].Lua) - } - out.Drivers = drivers - } - data, err := yaml.Marshal(&out) - if err != nil { - return fmt.Errorf("yaml marshal: %w", err) - } - tmp := path + ".tmp" - if err := os.WriteFile(tmp, data, 0644); err != nil { - return fmt.Errorf("write tmp: %w", err) - } - return os.Rename(tmp, path) -} - -func relDriverPath(baseDir, p string) string { - if p == "" { - return "" - } - // Paths resolved through UserDriversDirOverride or DriversDirOverride - // land outside baseDir, so a straight Rel would emit "../drivers/.lua". - // Rewrite them as a clean "drivers/" to keep YAML portable between hosts. - if UserDriversDirOverride != "" { - rel, err := filepath.Rel(UserDriversDirOverride, p) - if err == nil && !strings.HasPrefix(rel, "..") { - return filepath.ToSlash(filepath.Join("drivers", rel)) - } - } - if DriversDirOverride != "" { - rel, err := filepath.Rel(DriversDirOverride, p) - if err == nil && !strings.HasPrefix(rel, "..") { - return filepath.ToSlash(filepath.Join("drivers", rel)) - } - } - rel, err := filepath.Rel(baseDir, p) - if err != nil { - return p - } - if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { - return p - } - return rel -} +// Package config parses and validates the top-level YAML config. +// +// This is the single source of truth that the file-watcher re-parses on +// every change and that the settings UI writes back. All fields are +// hot-reloadable unless noted otherwise. +package config + +import ( + "errors" + "fmt" + "math" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + "gopkg.in/yaml.v3" +) + +// Config is the full application config. +type Config struct { + Site Site `yaml:"site" json:"site"` + Fuse Fuse `yaml:"fuse" json:"fuse"` + Drivers []Driver `yaml:"drivers" json:"drivers"` + API API `yaml:"api" json:"api"` + HomeAssistant *HomeAssistant `yaml:"homeassistant,omitempty" json:"homeassistant,omitempty"` + State *StateConf `yaml:"state,omitempty" json:"state,omitempty"` + Price *Price `yaml:"price,omitempty" json:"price,omitempty"` + Weather *Weather `yaml:"weather,omitempty" json:"weather,omitempty"` + Planner *Planner `yaml:"planner,omitempty" json:"planner,omitempty"` + Batteries map[string]Battery `yaml:"batteries,omitempty" json:"batteries,omitempty"` + EVCharger *EVCharger `yaml:"ev_charger,omitempty" json:"ev_charger,omitempty"` + CalDAV *CalDAV `yaml:"caldav,omitempty" json:"caldav,omitempty"` + Loadpoints []Loadpoint `yaml:"loadpoints,omitempty" json:"loadpoints,omitempty"` + V2X *V2XPolicy `yaml:"v2x,omitempty" json:"v2x,omitempty"` + Notifications *Notifications `yaml:"notifications,omitempty" json:"notifications,omitempty"` + Nova *Nova `yaml:"nova,omitempty" json:"nova,omitempty"` + DeviceRepository *DeviceRepository `yaml:"device_repository,omitempty" json:"device_repository,omitempty"` + OCPP *OCPP `yaml:"ocpp,omitempty" json:"ocpp,omitempty"` +} + +// OCPP configures the built-in OCPP 1.6J Central System. Chargers connect to +// us, so there is no driver and no per-charger config entry — a charge point +// appears as a device the moment it sends its first BootNotification, keyed by +// the identity segment of the URL it dialled. +// +// Disabled by default, and enabling it requires credentials. The listener +// cannot be restricted to one interface: the OCPP library builds its own +// listen address from the port alone, so the socket is reachable on every +// interface the host has. Basic auth is the only thing standing in front of +// it, which is why an empty Username or Password is rejected rather than +// silently accepted. +type OCPP struct { + Enabled bool `yaml:"enabled" json:"enabled"` + Port int `yaml:"port,omitempty" json:"port,omitempty"` + PortV201 int `yaml:"port_v201,omitempty" json:"port_v201,omitempty"` + Path string `yaml:"path,omitempty" json:"path,omitempty"` + Username string `yaml:"username,omitempty" json:"username,omitempty"` + Password string `yaml:"password,omitempty" json:"password,omitempty"` + HeartbeatIntervalS int `yaml:"heartbeat_interval_s,omitempty" json:"heartbeat_interval_s,omitempty"` +} + +// Validate rejects an enabled server that would accept anonymous charge +// points. A nil or disabled section is fine — OCPP is opt-in. +func (o *OCPP) Validate() error { + if o == nil || !o.Enabled { + return nil + } + if o.Username == "" || o.Password == "" { + return errors.New("ocpp: username and password are required when enabled, because the listener cannot be bound to a single interface") + } + if o.Port < 0 || o.Port > 65535 { + return fmt.Errorf("ocpp.port must be between 0 and 65535, got %d", o.Port) + } + if o.PortV201 < 0 || o.PortV201 > 65535 { + return fmt.Errorf("ocpp.port_v201 must be between 0 and 65535, got %d", o.PortV201) + } + // Each version needs its own listener, so they cannot share a port. + if o.PortV201 > 0 && o.PortV201 == o.Port { + return fmt.Errorf("ocpp.port_v201 must differ from ocpp.port, both are %d", o.Port) + } + if o.HeartbeatIntervalS < 0 { + return fmt.Errorf("ocpp.heartbeat_interval_s must be >= 0, got %d", o.HeartbeatIntervalS) + } + return nil +} + +// DeviceRepository configures independently distributed Lua drivers. Remote +// refresh never changes an active driver; activation is always an explicit API +// action. TrustedKeys maps key IDs to base64-encoded Ed25519 public keys. +type DeviceRepository struct { + Enabled bool `yaml:"enabled" json:"enabled"` + RefreshIntervalH int `yaml:"refresh_interval_h,omitempty" json:"refresh_interval_h,omitempty"` + RootDir string `yaml:"root_dir,omitempty" json:"root_dir,omitempty"` + Repositories []DriverRepositorySource `yaml:"repositories,omitempty" json:"repositories,omitempty"` +} + +type DriverRepositorySource struct { + ID string `yaml:"id" json:"id"` + Name string `yaml:"name,omitempty" json:"name,omitempty"` + ManifestURL string `yaml:"manifest_url" json:"manifest_url"` + Enabled bool `yaml:"enabled" json:"enabled"` + TrustedKeys map[string]string `yaml:"trusted_keys,omitempty" json:"trusted_keys,omitempty"` + AllowUnsigned bool `yaml:"allow_unsigned,omitempty" json:"allow_unsigned,omitempty"` + AllowInsecure bool `yaml:"allow_insecure,omitempty" json:"allow_insecure,omitempty"` +} + +const ( + DefaultDriverRepositoryID = "ftw-official" + DefaultDriverRepositoryName = "FTW official drivers" + DefaultDriverRepositoryManifestURL = "https://github.com/srcfl/ftw/releases/download/drivers-stable/manifest.json" + DefaultDriverRepositorySigningKeyID = "ftw-drivers-2026-01" + DefaultDriverRepositoryPublicKey = "MX+j27UBkyM099hTyJlmMLK9qlTTDUJsaK/vH12fFKc=" +) + +// Notifications configures outbound push notifications. Exactly one +// transport provider is active at a time, selected by Provider. Today +// the only implemented provider is "ntfy" (ntfy.sh or self-hosted); +// future providers add their own nested config block and register in +// go/internal/notifications. +type Notifications struct { + Enabled bool `yaml:"enabled" json:"enabled"` + Provider string `yaml:"provider,omitempty" json:"provider,omitempty"` + DefaultPriority int `yaml:"default_priority,omitempty" json:"default_priority,omitempty"` + Ntfy *NtfyConfig `yaml:"ntfy,omitempty" json:"ntfy,omitempty"` + Events []NotificationRule `yaml:"events,omitempty" json:"events,omitempty"` +} + +// NtfyConfig is the ntfy.sh transport settings. +type NtfyConfig struct { + Server string `yaml:"server,omitempty" json:"server,omitempty"` + Topic string `yaml:"topic,omitempty" json:"topic,omitempty"` + AccessToken string `yaml:"access_token,omitempty" json:"access_token,omitempty"` + Username string `yaml:"username,omitempty" json:"username,omitempty"` + Password string `yaml:"password,omitempty" json:"password,omitempty"` + + // HasAccessToken is a JSON-only signal for the UI: true means a + // token exists on disk. Set by MaskSecrets before AccessToken is + // blanked so the Settings form can render "configured — hidden" + // instead of an empty input. Never written to YAML. + HasAccessToken bool `yaml:"-" json:"has_access_token,omitempty"` +} + +// NotificationRule is one event type the operator can toggle. +type NotificationRule struct { + Type string `yaml:"type" json:"type"` + Enabled bool `yaml:"enabled" json:"enabled"` + ThresholdS int `yaml:"threshold_s,omitempty" json:"threshold_s,omitempty"` + // ThresholdN is a count-based threshold used by event types that + // aggregate across drivers (concurrent_drivers_offline). Ignored + // by per-driver events. Default behaviour per event documented + // alongside the const in notifications/service.go. + ThresholdN int `yaml:"threshold_n,omitempty" json:"threshold_n,omitempty"` + Priority int `yaml:"priority,omitempty" json:"priority,omitempty"` + Tags string `yaml:"tags,omitempty" json:"tags,omitempty"` + TitleTemplate string `yaml:"title_template,omitempty" json:"title_template,omitempty"` + BodyTemplate string `yaml:"body_template,omitempty" json:"body_template,omitempty"` + CooldownS int `yaml:"cooldown_s,omitempty" json:"cooldown_s,omitempty"` +} + +// Nova is the opt-in Sourceful Nova Core federation config. When enabled, +// FTW publishes telemetry to Nova's MQTT broker (NATS MQTT +// adapter) and reconciles device/DER registrations via Nova's core-api. +// +// Identity is an ES256 keypair generated on first run and stored at +// KeyPath (default /nova.key). The public key is +// registered in Nova via the claim flow; the private key signs a short- +// lived JWT used as the MQTT password. +// +// SchemaMode controls the wire format sent to Nova: +// - "legacy" (default): translate FTW's native clean payload +// to the current Nova wire shape (battery sign flip, +// PascalCase fields, pv→solar, ev→ev_port). The translation +// layer is in internal/nova and is designed to be deleted +// once Nova adopts the unified schema. +// - "unified": publish FTW's clean payload directly. Enable +// once the Nova schema-alignment PR lands. +type Nova struct { + Enabled bool `yaml:"enabled" json:"enabled"` + URL string `yaml:"url" json:"url"` + MQTTHost string `yaml:"mqtt_host" json:"mqtt_host"` + MQTTPort int `yaml:"mqtt_port,omitempty" json:"mqtt_port,omitempty"` + MQTTTLS bool `yaml:"mqtt_tls,omitempty" json:"mqtt_tls,omitempty"` + GatewaySerial string `yaml:"gateway_serial" json:"gateway_serial"` + OrgID string `yaml:"org_id" json:"org_id"` + SiteID string `yaml:"site_id" json:"site_id"` + KeyPath string `yaml:"key_path,omitempty" json:"key_path,omitempty"` + SchemaMode string `yaml:"schema_mode,omitempty" json:"schema_mode,omitempty"` + PublishIntervalS int `yaml:"publish_interval_s,omitempty" json:"publish_interval_s,omitempty"` + ReconcileIntervalH int `yaml:"reconcile_interval_h,omitempty" json:"reconcile_interval_h,omitempty"` +} + +// Loadpoint is one EV charge point the planner can reason about. +// The planner and go/internal/loadpoint optimize battery + EV jointly. +type Loadpoint struct { + ID string `yaml:"id" json:"id"` + DriverName string `yaml:"driver_name" json:"driver_name"` + MinChargeW float64 `yaml:"min_charge_w,omitempty" json:"min_charge_w,omitempty"` + MaxChargeW float64 `yaml:"max_charge_w,omitempty" json:"max_charge_w,omitempty"` + AllowedStepsW []float64 `yaml:"allowed_steps_w,omitempty" json:"allowed_steps_w,omitempty"` + VehicleCapacityWh float64 `yaml:"vehicle_capacity_wh,omitempty" json:"vehicle_capacity_wh,omitempty"` + PluginSoCPct float64 `yaml:"plugin_soc_pct,omitempty" json:"plugin_soc_pct,omitempty"` + + // PhaseMode selects how the controller picks between 1Φ and 3Φ + // delivery: "3p" (default) | "1p" | "auto". Empty == "3p" for + // backward compat with pre-switching configs. See loadpoint.Config. + PhaseMode string `yaml:"phase_mode,omitempty" json:"phase_mode,omitempty"` + PhaseSplitW float64 `yaml:"phase_split_w,omitempty" json:"phase_split_w,omitempty"` + MinPhaseHoldS int `yaml:"min_phase_hold_s,omitempty" json:"min_phase_hold_s,omitempty"` + SurplusOnly bool `yaml:"surplus_only,omitempty" json:"surplus_only,omitempty"` +} + +// V2XPolicy is the opt-in policy envelope for automatic V2X use. The +// current V2X pilot still dispatches only manual operator commands; this +// config lets the API expose "what would be safe right now?" before the +// planner is allowed to consume V2X as a dispatchable asset. +type V2XPolicy struct { + Enabled bool `yaml:"enabled" json:"enabled"` + + // DriverName, when set, scopes the policy to one configured V2X driver. + // Empty means the same policy applies to every V2X driver. + DriverName string `yaml:"driver_name,omitempty" json:"driver_name,omitempty"` + + // VehicleCapacityWh is optional if the charger reports capacity, but is + // required for reserve/departure energy math when the driver does not. + VehicleCapacityWh float64 `yaml:"vehicle_capacity_wh,omitempty" json:"vehicle_capacity_wh,omitempty"` + + // SoC percentages are YAML-facing 0..100 values. Telemetry stays 0..1. + MinReserveSoCPct float64 `yaml:"min_reserve_soc_pct,omitempty" json:"min_reserve_soc_pct,omitempty"` + DepartureTargetSoCPct float64 `yaml:"departure_target_soc_pct,omitempty" json:"departure_target_soc_pct,omitempty"` + + // DepartureTime is either "HH:MM" local time (next occurrence) or RFC3339. + DepartureTime string `yaml:"departure_time,omitempty" json:"departure_time,omitempty"` + + MaxChargeW float64 `yaml:"max_charge_w,omitempty" json:"max_charge_w,omitempty"` + MaxDischargeW float64 `yaml:"max_discharge_w,omitempty" json:"max_discharge_w,omitempty"` + + ExportAllowed bool `yaml:"export_allowed" json:"export_allowed"` + GridChargingAllowed bool `yaml:"grid_charging_allowed" json:"grid_charging_allowed"` + CycleCostOreKWh float64 `yaml:"cycle_cost_ore_kwh,omitempty" json:"cycle_cost_ore_kwh,omitempty"` +} + +// EVCharger is the high-level EV charger config written by the Settings UI. +// Exactly one transport block (HTTP or Modbus) is meaningful per provider — +// the runtime picks which to populate based on the provider's declared +// transport in evcloud.Provider. +// +// Password is stored in state.db (key "ev_charger_password"), NOT in config.yaml. +// It is populated at runtime by main.go after loading state and by the API +// handler on POST /api/config. Providers that don't need auth (e.g. local +// Modbus) leave Username + Password empty. +type EVCharger struct { + Provider string `yaml:"provider" json:"provider"` // "easee" | "ctek" + + // Connection — populate the block matching the provider's transport. + HTTP *EVChargerHTTP `yaml:"http,omitempty" json:"http,omitempty"` + Modbus *EVChargerModbus `yaml:"modbus,omitempty" json:"modbus,omitempty"` + + // Optional auth — required by cloud HTTP providers like Easee, + // unused by local Modbus providers like CTEK. + Username string `yaml:"username,omitempty" json:"username,omitempty"` + Password string `yaml:"-" json:"password,omitempty"` // persisted in state.db, not YAML + + Serial string `yaml:"serial,omitempty" json:"serial,omitempty"` + + // EmailLegacy preserves backward compatibility with the original + // `email:` field. Normalize() copies it into Username if Username + // is empty, so configs written before the generalization still load. + // New code should always read Username. + EmailLegacy string `yaml:"email,omitempty" json:"email,omitempty"` +} + +// EVChargerHTTP is the HTTP/cloud connection block. BaseURL is optional — +// when empty the provider uses its default (e.g. https://api.easee.com/api). +type EVChargerHTTP struct { + BaseURL string `yaml:"base_url,omitempty" json:"base_url,omitempty"` +} + +// EVChargerModbus is the Modbus/TCP connection block. Port defaults to 502 +// and UnitID defaults to 1 if zero — see provider-specific Validate. +type EVChargerModbus struct { + Host string `yaml:"host" json:"host"` + Port int `yaml:"port,omitempty" json:"port,omitempty"` + UnitID int `yaml:"unit_id,omitempty" json:"unit_id,omitempty"` +} + +// CalDAV configures the calendar-constraints feature (issue #498). FTW hosts +// its own in-process, pure-Go CalDAV server (emersion/go-webdav, MIT — see +// internal/caldavserver) and runs a CalDAV *client* against it that polls the +// calendar collection and maps events into planner intents: +// +// - an "away"/vacation event switches the load model to its away profile +// for the interval, so the planner conserves battery while the house is +// empty; +// - an EV "charged-by-departure" event sets the matching loadpoint's +// target SoC + deadline, which the MPC already honours. +// +// Events are classified by case-insensitive keyword match on the event +// title (SUMMARY). Keyword lists are configurable so non-English calendars +// work. The whole feature is opt-in (Enabled) and fail-soft: an unreachable +// server never blocks control. +// +// Password is stored in state.db (key "caldav_password"), NOT in config.yaml, +// mirroring EVCharger.Password. +type CalDAV struct { + Enabled bool `yaml:"enabled" json:"enabled"` + + // URL is the base URL of the CalDAV server. Defaults to the in-process + // native server at http://localhost:5232. + URL string `yaml:"url,omitempty" json:"url,omitempty"` + + Username string `yaml:"username,omitempty" json:"username,omitempty"` + Password string `yaml:"-" json:"password,omitempty"` // persisted in state.db, not YAML + + // CalendarPath is the collection path polled for events, relative to URL + // (e.g. "/ftw/energy/" for new configs). The runtime fallback below keeps + // the former path for configs that omitted this field before the rebrand. + CalendarPath string `yaml:"calendar_path,omitempty" json:"calendar_path,omitempty"` + + // PollIntervalS is how often the collection is re-fetched. Default 300s. + PollIntervalS int `yaml:"poll_interval_s,omitempty" json:"poll_interval_s,omitempty"` + + // HorizonDays bounds the calendar-query time range (recurrences are + // expanded server-side within it). Default 7. + HorizonDays int `yaml:"horizon_days,omitempty" json:"horizon_days,omitempty"` + + // EVLoadpointID is the loadpoint an EV event targets when the title + // names no specific one. Empty = the first/only configured loadpoint. + EVLoadpointID string `yaml:"ev_loadpoint_id,omitempty" json:"ev_loadpoint_id,omitempty"` + + // EVDefaultTargetSoCPct is used when an EV event's title carries no + // explicit percentage. Default 80. + EVDefaultTargetSoCPct float64 `yaml:"ev_default_target_soc_pct,omitempty" json:"ev_default_target_soc_pct,omitempty"` + + // AwayKeywords / EVKeywords classify an event by its title. Matching is + // case-insensitive substring. Empty lists fall back to the built-in + // defaults (see DefaultAwayKeywords / DefaultEVKeywords). + AwayKeywords []string `yaml:"away_keywords,omitempty" json:"away_keywords,omitempty"` + EVKeywords []string `yaml:"ev_keywords,omitempty" json:"ev_keywords,omitempty"` + + // EVSEHistory (default ON when enabled) makes FTW *write* a calendar + // event for each completed EV charging session into HistoryPath. This is + // an outbound capability — the user subscribes to HistoryPath to see when + // the charger was used. HistoryPath MUST differ from CalendarPath so FTW + // never re-reads its own history events as inbound intents. + EVSEHistory *bool `yaml:"evse_history,omitempty" json:"evse_history,omitempty"` + HistoryPath string `yaml:"history_path,omitempty" json:"history_path,omitempty"` + + // PublishPlan (default ON when enabled) makes FTW write its forward-looking + // plan — upcoming battery charge/discharge windows from the MPC — as + // read-only events into PlanPath (a SEPARATE collection), so you can see + // what FTW intends to do. Reconciled each publish so stale events are + // removed rather than piling up. + PublishPlan *bool `yaml:"publish_plan,omitempty" json:"publish_plan,omitempty"` + PlanPath string `yaml:"plan_path,omitempty" json:"plan_path,omitempty"` + PlanPublishIntervalS int `yaml:"plan_publish_interval_s,omitempty" json:"plan_publish_interval_s,omitempty"` + + // ManageCredentials (default ON when enabled) makes FTW generate a random + // password on first enable, which the in-process CalDAV server then + // authenticates against. The credential is shown in the Settings → Calendar + // tab (with a QR) to paste into a calendar app, so the operator never has to + // set one by hand. + ManageCredentials *bool `yaml:"manage_credentials,omitempty" json:"manage_credentials,omitempty"` + + // Listen is the bind address for the in-process CalDAV server. Default + // ":5232". FTW binds it on the LAN. + Listen string `yaml:"listen,omitempty" json:"listen,omitempty"` +} + +// ListenAddr returns the native CalDAV server bind address (default ":5232"). +func (cv *CalDAV) ListenAddr() string { + if cv != nil && strings.TrimSpace(cv.Listen) != "" { + return strings.TrimSpace(cv.Listen) + } + return ":5232" +} + +// ManageCredentialsEnabled reports whether FTW should auto-generate the managed +// CalDAV credential. Nil-safe; defaults ON when the feature is on. +func (cv *CalDAV) ManageCredentialsEnabled() bool { + return cv != nil && cv.Enabled && (cv.ManageCredentials == nil || *cv.ManageCredentials) +} + +// EVSEHistoryEnabled reports whether FTW should write EV-session history +// events. Nil-safe; defaults ON when the feature is enabled. +func (cv *CalDAV) EVSEHistoryEnabled() bool { + return cv != nil && cv.Enabled && (cv.EVSEHistory == nil || *cv.EVSEHistory) +} + +// PublishPlanEnabled reports whether FTW should publish its forward-looking +// plan calendar. Nil-safe; defaults ON when the feature is enabled. +func (cv *CalDAV) PublishPlanEnabled() bool { + return cv != nil && cv.Enabled && (cv.PublishPlan == nil || *cv.PublishPlan) +} + +// CalDAV defaults. Keyword identifiers are English; operators may override +// with localised terms via config (the values are user-facing). +var ( + DefaultCalDAVURL = "http://localhost:5232" + DefaultCalDAVCalendarPath = "/fortytwowatts/energy/" + DefaultCalDAVHistoryPath = "/fortytwowatts/history/" + DefaultCalDAVPlanPath = "/fortytwowatts/plan/" + DefaultCalDAVPlanPublishS = 900 + DefaultCalDAVUsername = "fortytwowatts" + DefaultCalDAVPollS = 300 + DefaultCalDAVHorizonDays = 7 + DefaultCalDAVEVTargetSoC = 80.0 + DefaultAwayKeywords = []string{"away", "vacation", "holiday"} + DefaultEVKeywords = []string{"ev", "car", "charge"} +) + +// Validate enforces range rules. Defaults are applied by the calendar +// service at construction time, so unset fields are legal here. +func (cv *CalDAV) Validate() error { + if cv == nil || !cv.Enabled { + return nil + } + if cv.PollIntervalS < 0 { + return errors.New("caldav.poll_interval_s must be >= 0") + } + if cv.HorizonDays < 0 { + return errors.New("caldav.horizon_days must be >= 0") + } + if cv.EVDefaultTargetSoCPct < 0 || cv.EVDefaultTargetSoCPct > 100 { + return errors.New("caldav.ev_default_target_soc_pct must be in [0, 100]") + } + return nil +} + +// Normalize folds the legacy `email:` YAML key into Username and clears +// it so subsequent writes use the canonical key. Idempotent. +func (e *EVCharger) Normalize() { + if e == nil { + return + } + if e.Username == "" && e.EmailLegacy != "" { + e.Username = e.EmailLegacy + } + e.EmailLegacy = "" +} + +// Validate enforces per-provider shape rules. Password is intentionally +// not required here — it's loaded from state.db after YAML parse (see +// main.go's ev_charger_password restore step), so at Validate() time +// the field may be legitimately empty. +func (e *EVCharger) Validate() error { + if e == nil { + return nil + } + switch e.Provider { + case "": + return errors.New("ev_charger.provider: required") + case "easee": + // Username/Password are NOT enforced here. The runtime easee + // driver logs + idles when creds are missing, and the API picker + // requires both before calling Easee Cloud. Letting a partial + // ev_charger block load is the original contract — the wizard + // writes provider intent first, then captures creds in a second + // API call. + if e.Modbus != nil { + return errors.New("ev_charger.modbus: not valid for provider easee (HTTP transport)") + } + case "ctek": + if e.Modbus == nil || e.Modbus.Host == "" { + return errors.New("ev_charger.modbus.host: required for provider ctek") + } + if e.Modbus.Port < 0 { + return errors.New("ev_charger.modbus.port: must be >= 0") + } + if e.Modbus.UnitID < 0 || e.Modbus.UnitID > 247 { + return errors.New("ev_charger.modbus.unit_id: must be in 0..247") + } + if e.HTTP != nil { + return errors.New("ev_charger.http: not valid for provider ctek (Modbus transport)") + } + if e.Username != "" || e.Password != "" { + return errors.New("ev_charger: username/password not valid for provider ctek") + } + default: + return fmt.Errorf("ev_charger.provider %q: not supported (valid: easee, ctek)", e.Provider) + } + return nil +} + +type OptimizerMultistage struct { + ScenarioLimit int `yaml:"scenario_limit,omitempty" json:"scenario_limit,omitempty"` + BranchIntervalSlots int `yaml:"branch_interval_slots,omitempty" json:"branch_interval_slots,omitempty"` + BranchHorizonSlots int `yaml:"branch_horizon_slots,omitempty" json:"branch_horizon_slots,omitempty"` + MaxBranching int `yaml:"max_branching,omitempty" json:"max_branching,omitempty"` + NearHorizonSlots int `yaml:"near_horizon_slots,omitempty" json:"near_horizon_slots,omitempty"` + MidHorizonSlots int `yaml:"mid_horizon_slots,omitempty" json:"mid_horizon_slots,omitempty"` + MidBlockSlots int `yaml:"mid_block_slots,omitempty" json:"mid_block_slots,omitempty"` + FarBlockSlots int `yaml:"far_block_slots,omitempty" json:"far_block_slots,omitempty"` + ServiceCVaRWeight *float64 `yaml:"service_cvar_weight,omitempty" json:"service_cvar_weight,omitempty"` + ServiceCVaRAlpha float64 `yaml:"service_cvar_alpha,omitempty" json:"service_cvar_alpha,omitempty"` + EconomicCVaRWeight float64 `yaml:"economic_cvar_weight,omitempty" json:"economic_cvar_weight,omitempty"` + EconomicCVaRAlpha float64 `yaml:"economic_cvar_alpha,omitempty" json:"economic_cvar_alpha,omitempty"` + DecompositionThreshold int `yaml:"decomposition_threshold,omitempty" json:"decomposition_threshold,omitempty"` + DecompositionMethod string `yaml:"decomposition_method,omitempty" json:"decomposition_method,omitempty"` + PHMaxIterations int `yaml:"ph_max_iterations,omitempty" json:"ph_max_iterations,omitempty"` + PHRho float64 `yaml:"ph_rho,omitempty" json:"ph_rho,omitempty"` + PHToleranceW float64 `yaml:"ph_tolerance_w,omitempty" json:"ph_tolerance_w,omitempty"` +} + +// Planner configures the MPC scheduler (optional — disabled if omitted). +// Mode: "self_consumption" (default) | "cheap_charge" | "arbitrage". +type Planner struct { + Enabled bool `yaml:"enabled" json:"enabled"` + Mode string `yaml:"mode,omitempty" json:"mode,omitempty"` + // Engine selects the primary optimizer: "python" (default) runs the + // CVXPY/HiGHS worker; "dp" is the legacy in-process rollback engine. + Engine string `yaml:"engine,omitempty" json:"engine,omitempty"` + // OptimizerCommand is the Python executable used for the local worker. + // It is an executable path, not a shell command. The module invocation is + // fixed by the host to avoid shell parsing and configuration injection. + OptimizerCommand string `yaml:"optimizer_command,omitempty" json:"optimizer_command,omitempty"` + OptimizerDir string `yaml:"optimizer_dir,omitempty" json:"optimizer_dir,omitempty"` + OptimizerTransport string `yaml:"optimizer_transport,omitempty" json:"optimizer_transport,omitempty"` + OptimizerSocket string `yaml:"optimizer_socket,omitempty" json:"optimizer_socket,omitempty"` + OptimizerSolver string `yaml:"optimizer_solver,omitempty" json:"optimizer_solver,omitempty"` + OptimizerFormulation string `yaml:"optimizer_formulation,omitempty" json:"optimizer_formulation,omitempty"` + OptimizerTimeoutS float64 `yaml:"optimizer_timeout_s,omitempty" json:"optimizer_timeout_s,omitempty"` + OptimizerIdleTimeoutS float64 `yaml:"optimizer_idle_timeout_s,omitempty" json:"optimizer_idle_timeout_s,omitempty"` + OptimizerMIPRelGap float64 `yaml:"optimizer_mip_rel_gap,omitempty" json:"optimizer_mip_rel_gap,omitempty"` + OptimizerCVaRWeight *float64 `yaml:"optimizer_cvar_weight,omitempty" json:"optimizer_cvar_weight,omitempty"` + OptimizerCVaRAlpha float64 `yaml:"optimizer_cvar_alpha,omitempty" json:"optimizer_cvar_alpha,omitempty"` + OptimizerRecourseShadow bool `yaml:"optimizer_recourse_shadow,omitempty" json:"optimizer_recourse_shadow,omitempty"` + OptimizerRecourseNonAnticipativeSlots int `yaml:"optimizer_recourse_non_anticipative_slots,omitempty" json:"optimizer_recourse_non_anticipative_slots,omitempty"` + OptimizerChallengerPolicy string `yaml:"optimizer_challenger_policy,omitempty" json:"optimizer_challenger_policy,omitempty"` + OptimizerMultistage *OptimizerMultistage `yaml:"optimizer_multistage,omitempty" json:"optimizer_multistage,omitempty"` + BaseLoadW float64 `yaml:"base_load_w,omitempty" json:"base_load_w,omitempty"` + HorizonHours int `yaml:"horizon_hours,omitempty" json:"horizon_hours,omitempty"` + IntervalMin int `yaml:"interval_min,omitempty" json:"interval_min,omitempty"` + SoCMinPct float64 `yaml:"soc_min_pct,omitempty" json:"soc_min_pct,omitempty"` + SoCMaxPct float64 `yaml:"soc_max_pct,omitempty" json:"soc_max_pct,omitempty"` + + // Deprecated: SoCSafetyFloorPct / SafetyFloorPenaltyOreKwhHour. The + // SoC-percentage safety floor was replaced by downside-PV planning + // (PVForecastSafetyK) — a percentage is the wrong unit (relative to + // battery size) for an absolute forecast risk. Still parsed so old + // config files load; ignored at runtime with a warning. Remove from + // your config and set pv_forecast_safety_k instead. + SoCSafetyFloorPct float64 `yaml:"soc_safety_floor_pct,omitempty" json:"soc_safety_floor_pct,omitempty"` + SafetyFloorPenaltyOreKwhHour float64 `yaml:"safety_floor_penalty_ore_kwh_hour,omitempty" json:"safety_floor_penalty_ore_kwh_hour,omitempty"` + + // PVForecastSafetyK scales the downside-PV haircut: the MPC plans + // against forecast PV minus k·σ, where σ is the recent PV forecast + // error std (pvmodel residual). The DP then won't run the battery + // down betting on PV that may not arrive — a reserve emerges from the + // live forecast uncertainty itself, sized to the real risk (large on + // variable cloudy days, ~zero on clear days or in winter), not a flat + // SoC %. Pointer so unset (→ default 1.0) is distinct from an explicit + // 0 (= raw forecast, no hedge: "use the battery you have"). + PVForecastSafetyK *float64 `yaml:"pv_forecast_safety_k,omitempty" json:"pv_forecast_safety_k,omitempty"` + + // PVChargeBonusOreKwh credits each kWh of battery charge fed from + // live PV surplus, in passive_arbitrage mode. Default 0 (disabled) + // — the import-tariff + VAT asymmetry already makes "store PV now" + // strictly preferred over "export PV now, reimport later" in the + // underlying DP economics, so the bonus is redundant under typical + // retail pricing. Setting it > 0 reinstates the bias and can pull + // battery charging forward; on days with future negative-price + // hours this leaves no headroom to absorb negative-priced PV and + // forces export at a loss. Use only if you have evidence that the + // DP is undervaluing storage in your specific configuration. + PVChargeBonusOreKwh float64 `yaml:"pv_charge_bonus_ore_kwh,omitempty" json:"pv_charge_bonus_ore_kwh,omitempty"` + + ChargeEfficiency float64 `yaml:"charge_efficiency,omitempty" json:"charge_efficiency,omitempty"` + DischargeEfficiency float64 `yaml:"discharge_efficiency,omitempty" json:"discharge_efficiency,omitempty"` + ExportOrePerKWh float64 `yaml:"export_ore_per_kwh,omitempty" json:"export_ore_per_kwh,omitempty"` // 0 = use mean spot + + // MinArbitrageSpreadOreKwh is the operator's "don't cycle the battery + // for marginal gains" knob, in öre per kWh. The planner won't cycle for + // grid arbitrage unless the price gain beats this many öre/kWh on top of + // round-trip losses. Applies only to the arbitrage modes + // (planner_arbitrage / planner_passive_arbitrage); self-consumption is + // never affected. It biases the planner's decision only — the savings + // statistics stay on real spot economics. 0 (default) = disabled. + MinArbitrageSpreadOreKwh float64 `yaml:"min_arbitrage_spread_ore_kwh,omitempty" json:"min_arbitrage_spread_ore_kwh,omitempty"` + + // LegacyDispatch reverts the control loop from the default + // energy-allocation path back to the legacy PI-on-grid-target + // path. Provided for emergency rollback only — the energy path + // respects the principle "plan allocates energy, EMS reacts to + // live data". + LegacyDispatch bool `yaml:"legacy_dispatch,omitempty" json:"legacy_dispatch,omitempty"` + + // UseEnergyDispatch is the deprecated inverse of LegacyDispatch. + // Pointer so we can distinguish "unset" (nil) from "explicitly + // false" (*false) — the latter matters because an operator who + // previously picked legacy dispatch must not be silently flipped + // to the energy path on upgrade. Honored with a startup WARN + // and will be removed after one release. + UseEnergyDispatch *bool `yaml:"use_energy_dispatch,omitempty" json:"use_energy_dispatch,omitempty"` +} + +// PVSafetyK resolves the downside-PV haircut scale (forecast − k·σ). Unset +// config (nil Planner or nil field) → default 1.0; an explicit value is +// honored verbatim, including 0 (no hedge — "use the battery you have"). +func (p *Planner) PVSafetyK() float64 { + if p == nil || p.PVForecastSafetyK == nil { + return 1.0 + } + return *p.PVForecastSafetyK +} + +// Site is the top-level control loop config. +type Site struct { + TroubleshootingMode bool `yaml:"troubleshooting_mode,omitempty" json:"troubleshooting_mode,omitempty"` + Name string `yaml:"name" json:"name"` + ControlIntervalS int `yaml:"control_interval_s" json:"control_interval_s"` + GridTargetW float64 `yaml:"grid_target_w" json:"grid_target_w"` + GridToleranceW float64 `yaml:"grid_tolerance_w" json:"grid_tolerance_w"` + WatchdogTimeoutS int `yaml:"watchdog_timeout_s" json:"watchdog_timeout_s"` + SmoothingAlpha float64 `yaml:"smoothing_alpha" json:"smoothing_alpha"` + Gain float64 `yaml:"gain" json:"gain"` + SlewRateW float64 `yaml:"slew_rate_w" json:"slew_rate_w"` + MinDispatchIntervalS int `yaml:"min_dispatch_interval_s" json:"min_dispatch_interval_s"` + + // SlewEnabled gates the external per-cycle ramp limiter. Both + // supported inverter families (Ferroamp, Sungrow) have their own + // internal power-ramp control loops; the external slew was + // originally added to dampen reactive-PI oscillation under noisy + // meter sampling, but it also slows legitimate step-response and + // can interact badly with PI integrator state (the 2026-05-25 + // recovery took ~3 min of slew-bounded ramping after the integral + // finally unwound). + // + // Pointer so we can distinguish "unset → default true" from + // "explicitly false". Defaults to enabled to preserve back-compat + // on existing installs. + SlewEnabled *bool `yaml:"slew_enabled,omitempty" json:"slew_enabled,omitempty"` + + // PVSurplusAbsorbSoCCapPct is the operator override for the PV-surplus + // absorber underlay in the energy-dispatch path (planner_cheap / + // planner_arbitrage). When the planner's slot allocation would still + // leave grid exporting beyond pv_surplus_absorb_threshold_w AND + // average SoC is below this cap, the dispatch redirects the leftover + // export into the battery instead of crossing the meter. Never + // reverses a discharge plan. 0 = no operator override; the planner can + // still enable a slot when capture displaces a more expensive future + // grid-funded charge. + // + // Suggested 88 — leaves 2 pp margin below the planner's typical + // soc_max_pct = 90 so the absorber doesn't slam into the wall. + PVSurplusAbsorbSoCCapPct float64 `yaml:"pv_surplus_absorb_soc_cap_pct,omitempty" json:"pv_surplus_absorb_soc_cap_pct,omitempty"` + + // PVSurplusAbsorbThresholdW is the trigger threshold for the + // absorber: only fires when projected grid export exceeds this many + // watts after the plan's target. Defaults to 100 W whenever the + // operator or planner enables absorption. + PVSurplusAbsorbThresholdW float64 `yaml:"pv_surplus_absorb_threshold_w,omitempty" json:"pv_surplus_absorb_threshold_w,omitempty"` + + // DCLinkProtectionEnabled opts into a live-state PV curtail that + // fires when SoC is near full AND PV significantly exceeds load + // — the configuration most exposed to a load-step-triggered + // inverter trip (real 2026-05-25 incident: Ferroamp EnergyHub + // fault from a 2.7 kW load step under 6 kW PV + 85 % SoC). + // Engaging pre-curtails PV to live load + margin so a sudden + // load step inside the margin lands without DC-link stress. + // Disabled by default — opt-in for sites that see repeated + // inverter trips. + DCLinkProtectionEnabled bool `yaml:"dc_link_protection_enabled,omitempty" json:"dc_link_protection_enabled,omitempty"` + + // DCLinkProtectionSoCThreshold (0-1) is the SoC fraction at or + // above which the protective curtail engages. Default 0.80. + DCLinkProtectionSoCThreshold float64 `yaml:"dc_link_protection_soc_threshold,omitempty" json:"dc_link_protection_soc_threshold,omitempty"` + + // DCLinkProtectionMarginW is the headroom (W) kept above live + // load when the protection fires. Larger margin = more PV + // allowed through, smaller load-step capacity before re-curtail. + // Default 1000. + DCLinkProtectionMarginW float64 `yaml:"dc_link_protection_margin_w,omitempty" json:"dc_link_protection_margin_w,omitempty"` + + // MaxExportW caps total site export (W, magnitude) below the physical + // fuse. 0 = disabled (export bounded only by the fuse). When > 0 it is + // enforced two ways: the dispatch fuse guard scales battery discharge + // back so predicted export stays under it, and the MPC caps each slot's + // export so the planner never schedules a discharge that would + // over-export. Protects inverters that trip on sustained export well + // below the breaker rating — the recurring Ferroamp EnergyHub fault + // state 0x8030 after ~8 kW sustained midday export, which only cleared + // as PV waned. Set it just under the observed trip point. + MaxExportW float64 `yaml:"max_export_w,omitempty" json:"max_export_w,omitempty"` +} + +// DefaultFuseSafetyMarginA is the fall-back per-phase amp headroom +// applied when fuse.safety_margin_a is unset (nil) in the YAML. +// Single source of truth — main.go routes through Fuse.Effective- +// SafetyMarginA() rather than re-declaring it. +const DefaultFuseSafetyMarginA = 0.5 + +// Fuse describes the shared breaker limit used by the fuse guard. +type Fuse struct { + MaxAmps float64 `yaml:"max_amps" json:"max_amps"` + Phases int `yaml:"phases" json:"phases"` + Voltage float64 `yaml:"voltage" json:"voltage"` + + // SafetyMarginA reserves headroom (per-phase amps) below MaxAmps + // inside the dispatch fuse guard. Pointer so we can distinguish + // "unset" (nil → DefaultFuseSafetyMarginA) from "explicitly + // disabled" (non-nil 0.0). Inverters often have their own per- + // phase current protection that trips before the breaker; without + // a margin the dispatch can ride right up to MaxAmps and the + // inverter cuts to 0 W in one tick, then dispatch ramps back up — + // visible as a flap. 0.5 A × 230 V × 3 phases ≈ 345 W of aggregate + // headroom. + SafetyMarginA *float64 `yaml:"safety_margin_a,omitempty" json:"safety_margin_a,omitempty"` +} + +// MaxPowerW returns the total power budget for the fuse guard. +func (f Fuse) MaxPowerW() float64 { + return f.MaxAmps * f.Voltage * float64(f.Phases) +} + +// EffectiveSafetyMarginA returns the per-phase amp headroom to apply, +// resolving nil ("unset → use default") vs an explicit value (including +// 0.0 to disable the margin entirely). Single read site so the default +// can never drift across consumers. +func (f Fuse) EffectiveSafetyMarginA() float64 { + if f.SafetyMarginA == nil { + return DefaultFuseSafetyMarginA + } + return *f.SafetyMarginA +} + +// Driver is one driver entry. Each driver is a Lua script loaded by +// the driver host at startup (or on hot-reload via the file watcher). +type Driver struct { + Name string `yaml:"name" json:"name"` + Lua string `yaml:"lua,omitempty" json:"lua,omitempty"` // path to .lua file + IsSiteMeter bool `yaml:"is_site_meter,omitempty" json:"is_site_meter,omitempty"` + BatteryCapacityWh float64 `yaml:"battery_capacity_wh,omitempty" json:"battery_capacity_wh,omitempty"` + // BatteryTelemetryOnly allows a read-only gateway driver to publish a + // physical battery's telemetry without making that driver eligible for + // battery dispatch. It is an explicit control-pool opt-out and wins even if + // a stale or hand-written config also contains BatteryCapacityWh. + // Sourceful Zap is the canonical user: its local API exposes battery data, + // but no stable semantic set-power endpoint. + BatteryTelemetryOnly bool `yaml:"battery_telemetry_only,omitempty" json:"battery_telemetry_only,omitempty"` + // MaxChargeW + MaxDischargeW set this driver's per-command power + // ceiling (site-signed +/-). Both optional; zero = fall through to + // the global MaxCommandW = 5 kW default the dispatcher has shipped + // with since v0.x. On a hybrid inverter that can actually deliver + // more (e.g. Ferroamp 10-15 kW, Sungrow 8-10 kW on 32 A), lifting + // the per-driver cap is the right move — site-wide fuse protection + // (applyFuseGuard) still enforces the grid-boundary budget above + // whatever per-battery cap you set. Issue #145. + MaxChargeW float64 `yaml:"max_charge_w,omitempty" json:"max_charge_w,omitempty"` + MaxDischargeW float64 `yaml:"max_discharge_w,omitempty" json:"max_discharge_w,omitempty"` + // InverterGroup tags this driver as belonging to a shared + // inverter+battery unit (e.g. set `inverter_group: ferroamp` on + // both the Ferroamp battery driver and anything publishing its PV + // telemetry). The dispatcher prefers routing charge to the battery + // whose group also has live PV output — staying DC-coupled on the + // same inverter avoids the DC→AC→AC→DC conversion overhead of + // cross-charging. Untagged drivers keep today's capacity-proportional + // behavior. See issue #143. + InverterGroup string `yaml:"inverter_group,omitempty" json:"inverter_group,omitempty"` + // SupportsPVCurtail flags this driver as one that handles the + // `curtail` / `curtail_disable` actions in its lua. Drivers with + // it set become eligible for ComputePVCurtail dispatch when the + // MPC's slot directive carries a PVLimitW > 0 (negative-export + // economic guard). Default false — operators must opt in per + // driver to avoid surprising older configs. The lua side has + // always been there for sungrow / ferroamp / deye / huawei / + // solis; this flag just turns on the Go-side dispatcher. + SupportsPVCurtail bool `yaml:"supports_pv_curtail,omitempty" json:"supports_pv_curtail,omitempty"` + // Disabled skips this driver at startup / reload. Set via the UI when + // you want to temporarily take a driver out without editing yaml. + Disabled bool `yaml:"disabled,omitempty" json:"disabled,omitempty"` + // HasPassword is a JSON-only signal to the UI that Config["password"] + // holds a non-empty value on disk. Populated by MaskSecrets after the + // real password is blanked out so the operator can still tell apart + // "never entered" from "saved but masked". Never written to yaml. + HasPassword bool `yaml:"-" json:"has_password,omitempty"` + + // Capabilities: the resources this driver is allowed to use. + // Unset capabilities are explicitly denied. + Capabilities Capabilities `yaml:"capabilities,omitempty" json:"capabilities,omitempty"` + + // Driver-specific config: arbitrary key/value map passed to + // driver_init(config) in Lua. Used for credentials, device addresses, + // thresholds, etc. that don't fit the generic capabilities model. + Config map[string]any `yaml:"config,omitempty" json:"config,omitempty"` + + // Legacy protocol fields (equivalent to capabilities, still accepted + // for backwards compatibility with master-branch configs). + MQTT *MQTTConfig `yaml:"mqtt,omitempty" json:"mqtt,omitempty"` + Modbus *ModbusConfig `yaml:"modbus,omitempty" json:"modbus,omitempty"` +} + +// Capabilities explicitly scope what host resources a driver can access. +type Capabilities struct { + MQTT *MQTTConfig `yaml:"mqtt,omitempty" json:"mqtt,omitempty"` + Modbus *ModbusConfig `yaml:"modbus,omitempty" json:"modbus,omitempty"` + HTTP *HTTPCapability `yaml:"http,omitempty" json:"http,omitempty"` + WebSocket *WSCapability `yaml:"websocket,omitempty" json:"websocket,omitempty"` + TCP *TCPCapability `yaml:"tcp,omitempty" json:"tcp,omitempty"` +} + +// MQTTConfig grants access to one MQTT broker. +type MQTTConfig struct { + Host string `yaml:"host" json:"host"` + Port int `yaml:"port,omitempty" json:"port,omitempty"` // default 1883 + Username string `yaml:"username,omitempty" json:"username,omitempty"` + Password string `yaml:"password,omitempty" json:"password,omitempty"` +} + +// ModbusConfig grants access to one Modbus TCP endpoint. +type ModbusConfig struct { + Host string `yaml:"host" json:"host"` + Port int `yaml:"port,omitempty" json:"port,omitempty"` // default 502 + UnitID int `yaml:"unit_id,omitempty" json:"unit_id,omitempty"` // default 1 +} + +// HTTPCapability grants HTTP access to specific hostnames (future). +type HTTPCapability struct { + AllowedHosts []string `yaml:"allowed_hosts" json:"allowed_hosts"` + // TLSPinSHA256, when set, pins the HTTPS server's leaf certificate to + // this SHA-256 fingerprint (hex; colons/whitespace ignored, case- + // insensitive). It is the SHA-256 over the DER certificate — identical + // to `openssl x509 -fingerprint -sha256`. Use it for HTTPS endpoints + // that present a self-signed certificate the system trust store cannot + // validate (e.g. a NIBE heat pump's local REST API). When set, normal + // chain/hostname verification is REPLACED by an exact fingerprint match + // for this driver only; when empty, standard verification against the + // system roots applies (unchanged for every existing HTTP driver). + TLSPinSHA256 string `yaml:"tls_pin_sha256,omitempty" json:"tls_pin_sha256,omitempty"` +} + +// WSCapability grants WebSocket (ws://, wss://) access. Same allowlist +// semantics as HTTPCapability — bare host = any port; "host:port" = exact. +type WSCapability struct { + AllowedHosts []string `yaml:"allowed_hosts" json:"allowed_hosts"` +} + +// TCPCapability grants raw TCP socket access (host.tcp_open). Same +// allowlist semantics as the HTTP/WS lists: bare host entry matches any +// port; "host:port" requires an exact match. Empty list = any host:port, +// which is fine for fully-trusted LAN deployments but loose enough to +// warrant an explicit list in shared installs. +type TCPCapability struct { + AllowedHosts []string `yaml:"allowed_hosts" json:"allowed_hosts"` +} + +// EffectiveMQTT returns the driver's MQTT config, preferring capabilities over legacy. +func (d Driver) EffectiveMQTT() *MQTTConfig { + if d.Capabilities.MQTT != nil { + return d.Capabilities.MQTT + } + return d.MQTT +} + +// EffectiveModbus returns the driver's Modbus config, preferring capabilities. +func (d Driver) EffectiveModbus() *ModbusConfig { + if d.Capabilities.Modbus != nil { + return d.Capabilities.Modbus + } + return d.Modbus +} + +// API is the HTTP server config. +type API struct { + Port int `yaml:"port" json:"port"` +} + +// HomeAssistant is the MQTT bridge config. +type HomeAssistant struct { + Enabled bool `yaml:"enabled" json:"enabled"` + Broker string `yaml:"broker" json:"broker"` + Port int `yaml:"port,omitempty" json:"port,omitempty"` + Username string `yaml:"username,omitempty" json:"username,omitempty"` + Password string `yaml:"password,omitempty" json:"password,omitempty"` + PublishIntervalS int `yaml:"publish_interval_s,omitempty" json:"publish_interval_s,omitempty"` +} + +// StateConf is the persistent state DB config. +// +// Path is the SQLite file (default "state.db"). ColdDir is the directory +// where >14d-old time-series data is rolled off as Parquet, partitioned +// YYYY/MM/DD.parquet (default "cold/" alongside Path). +// +// ColdRetentionDays bounds the cold Parquet tier: day files older than +// this are deleted by the hourly rolloff. 0 (default) keeps everything — +// a year of ~50 metrics is a few GB, so bounding is opt-in for small +// SD cards. +type StateConf struct { + Path string `yaml:"path" json:"path"` + ColdDir string `yaml:"cold_dir" json:"cold_dir"` + ColdRetentionDays int `yaml:"cold_retention_days,omitempty" json:"cold_retention_days,omitempty"` + // BackupDir stores verified full-backup archives. Relative paths resolve + // beside state.db; an absolute path can point at an externally mounted + // USB disk or network share. + BackupDir string `yaml:"backup_dir,omitempty" json:"backup_dir,omitempty"` +} + +// Price is the spot-price source config. +type Price struct { + Provider string `yaml:"provider" json:"provider"` // sourceful | elprisetjustnu | entsoe | none + Zone string `yaml:"zone,omitempty" json:"zone,omitempty"` + GridTariffOreKwh float64 `yaml:"grid_tariff_ore_kwh,omitempty" json:"grid_tariff_ore_kwh,omitempty"` + VATPercent float64 `yaml:"vat_percent,omitempty" json:"vat_percent,omitempty"` + APIKey string `yaml:"api_key,omitempty" json:"api_key,omitempty"` + + // Currency is the ISO code for pricing (default "SEK"). ENTSOE + // returns EUR/MWh; we convert using ECB daily FX rates. + Currency string `yaml:"currency,omitempty" json:"currency,omitempty"` + + // ExportBonusOreKwh is a per-kWh bonus on top of spot when exporting. + // Some retailers pay spot + fixed bonus (e.g. 60 öre in Sweden via + // "skattereduktion" + electricity-certificate value). Default 0. + ExportBonusOreKwh float64 `yaml:"export_bonus_ore_kwh,omitempty" json:"export_bonus_ore_kwh,omitempty"` + + // ExportFeeOreKwh is a per-kWh deduction on export (e.g. transmission + // fees some DSOs charge for feed-in). Reduces effective export price. + ExportFeeOreKwh float64 `yaml:"export_fee_ore_kwh,omitempty" json:"export_fee_ore_kwh,omitempty"` + + // ExportFloorOreKwh, if set, clamps per-slot export revenue at the + // given floor (öre/kWh). Use this only when your retailer caps + // negative-spot export at zero — i.e. they don't bill you when + // spot goes negative. Default (unset / nil) lets export revenue + // follow real spot, which can go negative; that's the physics + // most Swedish customer agreements pass through. Set to a pointer + // to 0.0 if you have a guaranteed-zero-floor agreement. + ExportFloorOreKwh *float64 `yaml:"export_floor_ore_kwh,omitempty" json:"export_floor_ore_kwh,omitempty"` +} + +// Weather is the weather-forecast source config. +type Weather struct { + Provider string `yaml:"provider" json:"provider"` // met_no | openweather | open_meteo | forecast_solar | none + Latitude float64 `yaml:"latitude" json:"latitude"` + Longitude float64 `yaml:"longitude" json:"longitude"` + APIKey string `yaml:"api_key,omitempty" json:"api_key,omitempty"` + + // PVRatedW is the system's nameplate PV output (W) — used as the + // initial twin prior AND the ceiling for naive PV estimates. If 0, + // we fall back to a heuristic (sum of battery_capacity_wh / 3), + // which is only roughly right for homes where PV and storage were + // sized together. Set explicitly for accurate day-1 forecasts. + PVRatedW float64 `yaml:"pv_rated_w,omitempty" json:"pv_rated_w,omitempty"` + + // PVTiltDeg / PVAzimuthDeg describe the physical orientation of a + // single panel group. Legacy single-array config — when PVArrays + // below is empty, the forecast_solar provider synthesizes one + // array from these + PVRatedW. Kept for backwards compatibility. + PVTiltDeg float64 `yaml:"pv_tilt_deg,omitempty" json:"pv_tilt_deg,omitempty"` + PVAzimuthDeg float64 `yaml:"pv_azimuth_deg,omitempty" json:"pv_azimuth_deg,omitempty"` + + // PVArrays is the list of physically-distinct panel groups at the + // site. Homes often have more than one roof plane (e.g. south and + // east), and the forecast_solar provider gives noticeably better + // predictions when each plane is described separately than when + // everything is averaged into a single tilt/azimuth. + // + // When set, PVArrays overrides the legacy single-array fields. + // Providers that can't use site geometry (met_no, open_meteo) + // ignore this entirely and just use PVRatedW. + PVArrays []PVArray `yaml:"pv_arrays,omitempty" json:"pv_arrays,omitempty"` + + // HeatingWPerDegC adds load proportional to max(18°C − outdoor_temp, 0). + // A rough-but-useful way to teach the planner that cold nights cost + // more than mild ones without running a full ML temperature fit. + // Typical Swedish single-family values: 200–500 W/°C. 0 disables. + HeatingWPerDegC float64 `yaml:"heating_w_per_degc,omitempty" json:"heating_w_per_degc,omitempty"` +} + +// PVArray is one physically-distinct panel group. Multi-plane +// residential installs typically have two or three (e.g. south roof +// + east roof + garage) with different tilt/azimuth. The sum of all +// KWp values should match the total PV nameplate at the site. +type PVArray struct { + Name string `yaml:"name,omitempty" json:"name,omitempty"` + KWp float64 `yaml:"kwp" json:"kwp"` + TiltDeg float64 `yaml:"tilt_deg" json:"tilt_deg"` + AzimuthDeg float64 `yaml:"azimuth_deg" json:"azimuth_deg"` +} + +// Battery is per-battery overrides (keyed by driver name in the top-level map). +type Battery struct { + SoCMin *float64 `yaml:"soc_min,omitempty" json:"soc_min,omitempty"` + SoCMax *float64 `yaml:"soc_max,omitempty" json:"soc_max,omitempty"` + MaxChargeW *float64 `yaml:"max_charge_w,omitempty" json:"max_charge_w,omitempty"` + MaxDischargeW *float64 `yaml:"max_discharge_w,omitempty" json:"max_discharge_w,omitempty"` + Weight *float64 `yaml:"weight,omitempty" json:"weight,omitempty"` +} + +// MaskSecrets returns a copy of the config with sensitive fields (passwords, +// API keys) replaced by empty strings so they are never exposed via the API. +// The original config is not modified. +func (c Config) MaskSecrets() Config { + out := c + + if out.EVCharger != nil { + cp := *out.EVCharger + cp.Password = "" + out.EVCharger = &cp + } + if out.CalDAV != nil { + cp := *out.CalDAV + cp.Password = "" + out.CalDAV = &cp + } + if out.HomeAssistant != nil { + cp := *out.HomeAssistant + cp.Password = "" + out.HomeAssistant = &cp + } + // The OCPP password is the only thing standing in front of a listener that + // is reachable on every interface, so it must never leave over the API. + if out.OCPP != nil { + cp := *out.OCPP + cp.Password = "" + out.OCPP = &cp + } + if out.Price != nil { + cp := *out.Price + cp.APIKey = "" + out.Price = &cp + } + if out.Weather != nil { + cp := *out.Weather + cp.APIKey = "" + out.Weather = &cp + } + if out.Notifications != nil { + cp := *out.Notifications + if cp.Ntfy != nil { + nc := *cp.Ntfy + nc.HasAccessToken = strings.TrimSpace(nc.AccessToken) != "" + nc.AccessToken = "" + nc.Password = "" + cp.Ntfy = &nc + } + if len(cp.Events) > 0 { + evs := make([]NotificationRule, len(cp.Events)) + copy(evs, cp.Events) + cp.Events = evs + } + out.Notifications = &cp + } + + if len(out.Drivers) > 0 { + drivers := make([]Driver, len(out.Drivers)) + copy(drivers, out.Drivers) + for i := range drivers { + if drivers[i].Config != nil { + cp := make(map[string]any, len(drivers[i].Config)) + for k, v := range drivers[i].Config { + cp[k] = v + } + if pw, has := cp["password"]; has { + // Signal "stored" to the UI before we blank it out. + if s, ok := pw.(string); ok && s != "" { + drivers[i].HasPassword = true + } + cp["password"] = "" + } + drivers[i].Config = cp + } + if drivers[i].Capabilities.MQTT != nil { + cp := *drivers[i].Capabilities.MQTT + cp.Password = "" + drivers[i].Capabilities.MQTT = &cp + } + if drivers[i].MQTT != nil { + cp := *drivers[i].MQTT + cp.Password = "" + drivers[i].MQTT = &cp + } + } + out.Drivers = drivers + } + + return out +} + +// PreserveMaskedSecrets copies real secrets from `existing` into `incoming` +// wherever the incoming value is empty (the UI sends "" for masked fields). +// Call this before saving a config received from the API. +func (incoming *Config) PreserveMaskedSecrets(existing *Config) { + if incoming.EVCharger != nil && existing.EVCharger != nil && incoming.EVCharger.Password == "" { + incoming.EVCharger.Password = existing.EVCharger.Password + } + if incoming.CalDAV != nil && existing.CalDAV != nil && incoming.CalDAV.Password == "" { + incoming.CalDAV.Password = existing.CalDAV.Password + } + // Masked out on the way to the UI, so an unchanged password comes back + // empty. Without this a save from the settings tab would blank it, and an + // enabled server would then fail validation on the next reload. + if incoming.OCPP != nil && existing.OCPP != nil && incoming.OCPP.Password == "" { + incoming.OCPP.Password = existing.OCPP.Password + } + if incoming.HomeAssistant != nil && existing.HomeAssistant != nil && incoming.HomeAssistant.Password == "" { + incoming.HomeAssistant.Password = existing.HomeAssistant.Password + } + if incoming.Price != nil && existing.Price != nil && incoming.Price.APIKey == "" { + incoming.Price.APIKey = existing.Price.APIKey + } + if incoming.Weather != nil && existing.Weather != nil && incoming.Weather.APIKey == "" { + incoming.Weather.APIKey = existing.Weather.APIKey + } + if incoming.Notifications != nil && existing.Notifications != nil && + incoming.Notifications.Ntfy != nil && existing.Notifications.Ntfy != nil { + if incoming.Notifications.Ntfy.AccessToken == "" { + incoming.Notifications.Ntfy.AccessToken = existing.Notifications.Ntfy.AccessToken + } + if incoming.Notifications.Ntfy.Password == "" { + incoming.Notifications.Ntfy.Password = existing.Notifications.Ntfy.Password + } + } + for i := range incoming.Drivers { + for _, ed := range existing.Drivers { + if incoming.Drivers[i].Name != ed.Name { + continue + } + if incoming.Drivers[i].Config != nil && ed.Config != nil { + if pw, ok := incoming.Drivers[i].Config["password"]; ok { + if pw == "" || pw == nil { + incoming.Drivers[i].Config["password"] = ed.Config["password"] + } + } + } + // Restore MQTT password in capabilities block. + if incoming.Drivers[i].Capabilities.MQTT != nil && ed.Capabilities.MQTT != nil && + incoming.Drivers[i].Capabilities.MQTT.Password == "" { + incoming.Drivers[i].Capabilities.MQTT.Password = ed.Capabilities.MQTT.Password + } + // Restore MQTT password in legacy block. + if incoming.Drivers[i].MQTT != nil && ed.MQTT != nil && + incoming.Drivers[i].MQTT.Password == "" { + incoming.Drivers[i].MQTT.Password = ed.MQTT.Password + } + break + } + } +} + +// Load parses a config file from disk. Returns a fully-validated Config. +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + return Parse(data, filepath.Dir(path)) +} + +// Parse parses config bytes and validates. baseDir resolves driver Lua paths. +func Parse(data []byte, baseDir string) (*Config, error) { + var c Config + if err := yaml.Unmarshal(data, &c); err != nil { + return nil, fmt.Errorf("yaml: %w", err) + } + applyDefaults(&c) + if err := c.Validate(); err != nil { + return nil, err + } + c.ResolveDriverPaths(baseDir) + return &c, nil +} + +// DriversDirOverride redirects resolution of relative "drivers/.lua" +// Lua paths to this directory instead of the config sibling. main.go sets +// it once at startup from the -drivers flag so Docker images — where +// drivers live in the immutable image layer (/app/drivers) rather than +// next to the user's config (/app/data) — can still load driver scripts. +// Empty string preserves the historical "sibling-of-config" behaviour. +var DriversDirOverride string + +// UserDriversDirOverride is the second lookup path tried before +// DriversDirOverride. Designed for persistent user-supplied drivers in +// the docker deploy where DriversDirOverride lives in the immutable +// image layer. When set, ResolveDriverPaths checks whether a file +// exists in this directory first and uses it when found; otherwise +// falls back to DriversDirOverride. Empty = single-dir behaviour +// (back-compat). +var UserDriversDirOverride string + +// ManagedDriversDirOverride contains stable active symlinks maintained by the +// signed driver repository. It is checked after the local user overlay and +// before the bundled recovery snapshot. +var ManagedDriversDirOverride string + +// ResolveDriverPaths joins relative Lua driver paths with baseDir, or +// with DriversDirOverride when the relative path starts with "drivers/". +// When UserDriversDirOverride is also set, paths starting with "drivers/" +// are first probed in UserDriversDirOverride; only if the file is absent +// there do they fall through to DriversDirOverride. +func (c *Config) ResolveDriverPaths(baseDir string) { + for i := range c.Drivers { + c.Drivers[i].Lua = stripLeadingDotDot(c.Drivers[i].Lua) + p := c.Drivers[i].Lua + if p == "" || filepath.IsAbs(p) { + continue + } + if strings.HasPrefix(p, "drivers/") { + rel := strings.TrimPrefix(p, "drivers/") + if UserDriversDirOverride != "" { + candidate := filepath.Join(UserDriversDirOverride, rel) + if _, err := os.Stat(candidate); err == nil { + c.Drivers[i].Lua = candidate + continue + } + } + if ManagedDriversDirOverride != "" { + candidate := filepath.Join(ManagedDriversDirOverride, rel) + if _, err := os.Stat(candidate); err == nil { + c.Drivers[i].Lua = candidate + continue + } + } + if DriversDirOverride != "" { + c.Drivers[i].Lua = filepath.Join(DriversDirOverride, rel) + continue + } + } + c.Drivers[i].Lua = filepath.Join(baseDir, p) + } +} + +func stripLeadingDotDot(p string) string { + for strings.HasPrefix(p, "../") { + p = p[3:] + } + return p +} + +// UnresolveDriverPaths converts resolved driver paths back to config-relative form. +// +// Paths that are outside baseDir (filepath.Rel would yield a ../-prefixed +// result) are left absolute — otherwise the next ResolveDriverPaths would +// strip the leading ../ via stripLeadingDotDot and silently re-anchor the +// driver under baseDir. When DriversDirOverride is set, paths resolved +// through it are rewritten back to "drivers/" so the YAML + UI +// round-trip stays portable (no /app/drivers/... baked into config.yaml). +func (c *Config) UnresolveDriverPaths(baseDir string) { + for i := range c.Drivers { + p := c.Drivers[i].Lua + if p != "" { + // Check UserDriversDirOverride first so that user-dir paths are + // re-serialised as portable "drivers/" just like bundled paths. + if UserDriversDirOverride != "" { + rel, err := filepath.Rel(UserDriversDirOverride, p) + if err == nil && !strings.HasPrefix(rel, "..") { + c.Drivers[i].Lua = filepath.ToSlash(filepath.Join("drivers", rel)) + continue + } + } + if ManagedDriversDirOverride != "" { + rel, err := filepath.Rel(ManagedDriversDirOverride, p) + if err == nil && !strings.HasPrefix(rel, "..") { + c.Drivers[i].Lua = filepath.ToSlash(filepath.Join("drivers", rel)) + continue + } + } + if DriversDirOverride != "" { + rel, err := filepath.Rel(DriversDirOverride, p) + if err == nil && !strings.HasPrefix(rel, "..") { + c.Drivers[i].Lua = filepath.ToSlash(filepath.Join("drivers", rel)) + continue + } + } + } + c.Drivers[i].Lua = relToBaseDir(baseDir, p) + } +} + +func relToBaseDir(baseDir, p string) string { + if p == "" { + return p + } + rel, err := filepath.Rel(baseDir, p) + if err != nil { + return p + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return p + } + return rel +} + +// applyDefaults fills in sensible zero-value defaults. +func applyDefaults(c *Config) { + if c.DeviceRepository == nil { + // The official signed stable catalog is safe to discover by default: + // refresh is read-only and never activates or restarts a driver. An + // explicit enabled:false block remains the operator opt-out. + c.DeviceRepository = &DeviceRepository{Enabled: true} + } + if c.DeviceRepository != nil { + if c.DeviceRepository.RefreshIntervalH == 0 { + c.DeviceRepository.RefreshIntervalH = 24 + } + // The pinned official trust root is a secure default and needs no key + // copied into every site configuration. + if len(c.DeviceRepository.Repositories) == 0 { + c.DeviceRepository.Repositories = []DriverRepositorySource{{ + ID: DefaultDriverRepositoryID, + Name: DefaultDriverRepositoryName, + ManifestURL: DefaultDriverRepositoryManifestURL, + Enabled: true, + TrustedKeys: map[string]string{ + DefaultDriverRepositorySigningKeyID: DefaultDriverRepositoryPublicKey, + }, + }} + } + } + if c.Site.ControlIntervalS == 0 { + // 2 s matches Ferroamp's ehub MQTT cadence (~1 Hz) without + // dispatching twice on the same telemetry sample, and halves + // the perceived response lag operators saw at the original 5 s. + c.Site.ControlIntervalS = 2 + } + if c.Site.GridToleranceW == 0 { + c.Site.GridToleranceW = 42 // The Answer + } + if c.Site.WatchdogTimeoutS == 0 { + c.Site.WatchdogTimeoutS = 60 + } + if c.Site.SmoothingAlpha == 0 { + c.Site.SmoothingAlpha = 0.3 + } + if c.Site.Gain == 0 { + c.Site.Gain = 0.5 + } + if c.Site.SlewRateW == 0 { + // 3000 W/cycle at the 2 s default control interval = 1500 W/s + // ramp ceiling. Both Ferroamp and Sungrow internal EMS loops + // ramp slower than this naturally (Sungrow spec: ~1000 W/s), + // so the external slew rarely fires under normal conditions + // but still bounds the post-windup recovery from snapping to + // full output in a single cycle. + c.Site.SlewRateW = 3000 + } + if c.Site.SlewEnabled == nil { + t := true + c.Site.SlewEnabled = &t + } + if c.Site.MinDispatchIntervalS == 0 { + // Match control_interval_s. The holdoff exists to suppress + // command-spam when the tick is faster than the battery's + // response — at 2 s ticks the natural cadence is already the + // minimum, so the holdoff is a no-op debouncer in practice. + c.Site.MinDispatchIntervalS = 2 + } + if c.Fuse.Phases == 0 { + c.Fuse.Phases = 3 + } + if c.Fuse.Voltage == 0 { + c.Fuse.Voltage = 230 + } + if c.API.Port == 0 { + c.API.Port = 8080 + } + // Driver connection defaults + for i := range c.Drivers { + d := &c.Drivers[i] + if cap := d.Capabilities.MQTT; cap != nil && cap.Port == 0 { + cap.Port = 1883 + } + if cap := d.Capabilities.Modbus; cap != nil { + if cap.Port == 0 { + cap.Port = 502 + } + if cap.UnitID == 0 { + cap.UnitID = 1 + } + } + if cap := d.MQTT; cap != nil && cap.Port == 0 { + cap.Port = 1883 + } + if cap := d.Modbus; cap != nil { + if cap.Port == 0 { + cap.Port = 502 + } + if cap.UnitID == 0 { + cap.UnitID = 1 + } + } + } + if c.HomeAssistant != nil { + if c.HomeAssistant.Port == 0 { + c.HomeAssistant.Port = 1883 + } + if c.HomeAssistant.PublishIntervalS == 0 { + c.HomeAssistant.PublishIntervalS = 5 + } + } + // Backfill for configs that predate notifications: — lands a + // populated-but-disabled stub so upgrading an existing install + // lights up the Notifications tab with the defaults instead of an + // empty form. Nothing is written to disk until the operator Saves. + if c.Notifications == nil { + c.Notifications = &Notifications{ + Enabled: false, + Provider: "ntfy", + DefaultPriority: 3, + Ntfy: &NtfyConfig{Server: "https://ntfy.sh"}, + Events: []NotificationRule{ + {Type: "driver_offline", Enabled: false, ThresholdS: 600, Priority: 4, CooldownS: 3600}, + {Type: "driver_recovered", Enabled: false, Priority: 3}, + {Type: "update_available", Enabled: false, Priority: 3, CooldownS: 3600}, + {Type: "fuse_over_limit", Enabled: false, ThresholdS: 30, Priority: 5, CooldownS: 900}, + }, + } + } + // Rule-list migration: add new built-in event types to existing + // configs that predate them so upgrading lights up the toggle in + // Settings → Notifications instead of needing manual YAML edits. + if c.Notifications != nil { + builtins := []NotificationRule{ + {Type: "driver_offline", Enabled: false, ThresholdS: 600, Priority: 4, CooldownS: 3600}, + {Type: "driver_recovered", Enabled: false, Priority: 3}, + {Type: "update_available", Enabled: false, Priority: 3, CooldownS: 3600}, + {Type: "fuse_over_limit", Enabled: false, ThresholdS: 30, Priority: 5, CooldownS: 900}, + } + have := make(map[string]bool, len(c.Notifications.Events)) + for _, r := range c.Notifications.Events { + have[r.Type] = true + } + for _, b := range builtins { + if !have[b.Type] { + c.Notifications.Events = append(c.Notifications.Events, b) + } + } + } + if c.Notifications != nil { + if c.Notifications.Provider == "" { + c.Notifications.Provider = "ntfy" + } + if c.Notifications.DefaultPriority == 0 { + c.Notifications.DefaultPriority = 3 + } + if c.Notifications.Provider == "ntfy" { + if c.Notifications.Ntfy == nil { + c.Notifications.Ntfy = &NtfyConfig{} + } + if c.Notifications.Ntfy.Server == "" { + c.Notifications.Ntfy.Server = "https://ntfy.sh" + } + } + } + if c.Nova != nil { + if c.Nova.MQTTPort == 0 { + c.Nova.MQTTPort = 1883 + } + if c.Nova.SchemaMode == "" { + c.Nova.SchemaMode = "legacy" + } + if c.Nova.PublishIntervalS == 0 { + c.Nova.PublishIntervalS = 5 + } + if c.Nova.ReconcileIntervalH == 0 { + c.Nova.ReconcileIntervalH = 24 + } + } +} + +// Validate ensures the config is internally consistent and safe to run with. +func (c *Config) Validate() error { + if c.State != nil && c.State.ColdRetentionDays < 0 { + return fmt.Errorf("state.cold_retention_days must be >= 0, got %d", c.State.ColdRetentionDays) + } + if c.EVCharger != nil { + c.EVCharger.Normalize() + if err := c.EVCharger.Validate(); err != nil { + return err + } + } + if err := c.CalDAV.Validate(); err != nil { + return err + } + if err := c.OCPP.Validate(); err != nil { + return err + } + + // Empty drivers list is a valid shape — e.g. an EV-only site that + // configured a cloud EV charger in the setup wizard and doesn't + // own local inverter/meter hardware. Control loop becomes a no-op + // (SiteMeterDriver() returns "" and telemetry lookups just miss); + // the site meter check below only fires once at least one driver + // exists. + siteMeters := 0 + names := make(map[string]bool, len(c.Drivers)) + for _, d := range c.Drivers { + if d.Name == "" { + return errors.New("driver: name is required") + } + if names[d.Name] { + return fmt.Errorf("driver %q: duplicate name", d.Name) + } + names[d.Name] = true + + if d.IsSiteMeter { + siteMeters++ + } + if d.Lua == "" { + return fmt.Errorf("driver %q: must specify `lua`", d.Name) + } + if d.EffectiveMQTT() == nil && d.EffectiveModbus() == nil && + d.Capabilities.HTTP == nil && d.Capabilities.WebSocket == nil && + d.Capabilities.TCP == nil { + return fmt.Errorf("driver %q: must have mqtt, modbus, http, websocket, or tcp capability", d.Name) + } + } + if len(c.Drivers) > 0 && siteMeters == 0 { + return errors.New("at least one driver must be is_site_meter: true") + } + + if c.Site.ControlIntervalS < 0 { + return errors.New("site.control_interval_s must be >= 0") + } + if c.Site.GridToleranceW < 0 { + return errors.New("site.grid_tolerance_w must be >= 0") + } + if c.Site.WatchdogTimeoutS < 0 { + return errors.New("site.watchdog_timeout_s must be >= 0") + } + if c.Site.SmoothingAlpha <= 0 || c.Site.SmoothingAlpha > 1 { + return errors.New("site.smoothing_alpha must be in (0, 1]") + } + if c.Site.Gain < 0 { + return errors.New("site.gain must be >= 0") + } + if c.Site.SlewRateW < 0 { + return errors.New("site.slew_rate_w must be >= 0") + } + if c.Site.MinDispatchIntervalS < 0 { + return errors.New("site.min_dispatch_interval_s must be >= 0") + } + if c.Fuse.MaxAmps <= 0 { + return errors.New("fuse.max_amps must be > 0") + } + if c.Fuse.Phases <= 0 { + return errors.New("fuse.phases must be > 0") + } + if c.Fuse.Voltage <= 0 { + return errors.New("fuse.voltage must be > 0") + } + // safety_margin_a must be in [0, max_amps) when explicitly set. + // Negative would *raise* the per-phase threshold above the breaker + // rating (defeating the guard); >= max_amps zeroes the headroom + // and silently disables the per-phase clamp — both are real safety + // holes if reached through a typo'd config. nil (unset) is OK and + // resolves to DefaultFuseSafetyMarginA at the consumer. + if c.Fuse.SafetyMarginA != nil { + v := *c.Fuse.SafetyMarginA + if v < 0 { + return errors.New("fuse.safety_margin_a must be >= 0") + } + if v >= c.Fuse.MaxAmps { + return errors.New("fuse.safety_margin_a must be < fuse.max_amps") + } + } + if err := c.validateV2XPolicy(names); err != nil { + return err + } + if n := c.Notifications; n != nil { + if n.DefaultPriority < 0 || n.DefaultPriority > 5 { + return errors.New("notifications.default_priority must be in [0,5]") + } + if n.Enabled { + switch n.Provider { + case "", "ntfy": + if n.Ntfy == nil { + return errors.New("notifications.ntfy required when provider=ntfy and enabled") + } + if strings.TrimSpace(n.Ntfy.Server) == "" { + return errors.New("notifications.ntfy.server required when enabled") + } + if strings.TrimSpace(n.Ntfy.Topic) == "" { + return errors.New("notifications.ntfy.topic required when enabled") + } + default: + return fmt.Errorf("notifications.provider %q not supported", n.Provider) + } + } + for i, ev := range n.Events { + if strings.TrimSpace(ev.Type) == "" { + return fmt.Errorf("notifications.events[%d]: type required", i) + } + if ev.ThresholdS < 0 { + return fmt.Errorf("notifications.events[%d]: threshold_s must be >= 0", i) + } + if ev.Priority < 0 || ev.Priority > 5 { + return fmt.Errorf("notifications.events[%d]: priority must be in [0,5]", i) + } + if ev.CooldownS < 0 { + return fmt.Errorf("notifications.events[%d]: cooldown_s must be >= 0", i) + } + } + } + if c.Nova != nil && c.Nova.Enabled { + if c.Nova.URL == "" { + return errors.New("nova.url is required when nova.enabled") + } + if c.Nova.MQTTHost == "" { + return errors.New("nova.mqtt_host is required when nova.enabled") + } + if c.Nova.GatewaySerial == "" { + return errors.New("nova.gateway_serial is required when nova.enabled — run `ftw nova-claim`") + } + if c.Nova.OrgID == "" { + return errors.New("nova.org_id is required when nova.enabled") + } + if c.Nova.SiteID == "" { + return errors.New("nova.site_id is required when nova.enabled") + } + switch c.Nova.SchemaMode { + case "legacy", "unified": + default: + return fmt.Errorf("nova.schema_mode must be \"legacy\" or \"unified\", got %q", c.Nova.SchemaMode) + } + } + if c.Planner != nil && c.Planner.MinArbitrageSpreadOreKwh < 0 { + return fmt.Errorf("planner.min_arbitrage_spread_ore_kwh must be ≥ 0, got %g", c.Planner.MinArbitrageSpreadOreKwh) + } + if c.Planner != nil { + p := c.Planner + switch p.Engine { + case "", "python", "dp": + default: + return fmt.Errorf("planner.engine must be \"python\" or \"dp\", got %q", p.Engine) + } + switch strings.ToUpper(p.OptimizerSolver) { + case "", "HIGHS", "CLARABEL": + default: + return fmt.Errorf("planner.optimizer_solver must be \"HIGHS\" or \"CLARABEL\", got %q", p.OptimizerSolver) + } + switch p.OptimizerFormulation { + case "", "auto", "milp", "relaxed": + default: + return fmt.Errorf("planner.optimizer_formulation must be auto, milp, or relaxed, got %q", p.OptimizerFormulation) + } + switch p.OptimizerTransport { + case "", "auto", "unix", "process": + default: + return fmt.Errorf("planner.optimizer_transport must be auto, unix, or process, got %q", p.OptimizerTransport) + } + if p.OptimizerTimeoutS < 0 || p.OptimizerIdleTimeoutS < 0 || p.OptimizerMIPRelGap < 0 || (p.OptimizerCVaRWeight != nil && *p.OptimizerCVaRWeight < 0) { + return errors.New("planner optimizer timeout, idle timeout, MIP gap, and CVaR weight must be non-negative") + } + if p.OptimizerMIPRelGap > 1 { + return fmt.Errorf("planner.optimizer_mip_rel_gap must be <= 1, got %g", p.OptimizerMIPRelGap) + } + if p.OptimizerCVaRAlpha < 0 || p.OptimizerCVaRAlpha >= 1 { + return fmt.Errorf("planner.optimizer_cvar_alpha must be 0 (default) or in (0,1), got %g", p.OptimizerCVaRAlpha) + } + if p.OptimizerRecourseNonAnticipativeSlots < 0 { + return errors.New("planner.optimizer_recourse_non_anticipative_slots must be non-negative") + } + switch p.OptimizerChallengerPolicy { + case "", "recourse", "multistage": + default: + return fmt.Errorf("planner.optimizer_challenger_policy must be recourse or multistage, got %q", p.OptimizerChallengerPolicy) + } + if ms := p.OptimizerMultistage; ms != nil { + ints := []int{ms.ScenarioLimit, ms.BranchIntervalSlots, ms.BranchHorizonSlots, + ms.MaxBranching, ms.NearHorizonSlots, ms.MidHorizonSlots, ms.MidBlockSlots, + ms.FarBlockSlots, ms.DecompositionThreshold, ms.PHMaxIterations} + for _, value := range ints { + if value < 0 { + return errors.New("planner.optimizer_multistage integer settings must be non-negative") + } + } + if ms.MaxBranching == 1 { + return errors.New("planner.optimizer_multistage.max_branching must be 0 (default) or at least 2") + } + if (ms.ServiceCVaRWeight != nil && *ms.ServiceCVaRWeight < 0) || ms.EconomicCVaRWeight < 0 || ms.PHRho < 0 || ms.PHToleranceW < 0 { + return errors.New("planner.optimizer_multistage risk weights, PH rho, and PH tolerance must be non-negative") + } + if (ms.ServiceCVaRAlpha < 0 || ms.ServiceCVaRAlpha >= 1) || + (ms.EconomicCVaRAlpha < 0 || ms.EconomicCVaRAlpha >= 1) { + return errors.New("planner.optimizer_multistage CVaR alpha must be 0 (default) or in (0,1)") + } + switch ms.DecompositionMethod { + case "", "auto", "extensive", "progressive_hedging": + default: + return fmt.Errorf("planner.optimizer_multistage.decomposition_method is invalid: %q", ms.DecompositionMethod) + } + } + } + if repoCfg := c.DeviceRepository; repoCfg != nil { + if repoCfg.RefreshIntervalH < 0 { + return errors.New("device_repository.refresh_interval_h must be non-negative") + } + seen := make(map[string]bool, len(repoCfg.Repositories)) + for _, repo := range repoCfg.Repositories { + if repo.ID == "" || strings.ContainsAny(repo.ID, "/\\") { + return fmt.Errorf("device_repository repository has invalid id %q", repo.ID) + } + if seen[repo.ID] { + return fmt.Errorf("device_repository has duplicate repository id %q", repo.ID) + } + seen[repo.ID] = true + if !repo.Enabled { + continue + } + u, err := url.Parse(repo.ManifestURL) + if err != nil || u.Scheme == "" { + return fmt.Errorf("device_repository %s has invalid manifest_url", repo.ID) + } + if u.Scheme != "https" && !repo.AllowInsecure { + return fmt.Errorf("device_repository %s manifest_url must use https", repo.ID) + } + if repo.AllowUnsigned && u.Scheme != "file" { + return fmt.Errorf("device_repository %s allow_unsigned is restricted to local file manifests", repo.ID) + } + if !repo.AllowUnsigned && len(repo.TrustedKeys) == 0 { + return fmt.Errorf("device_repository %s requires at least one trusted Ed25519 key", repo.ID) + } + } + } + return nil +} + +func (c *Config) validateV2XPolicy(driverNames map[string]bool) error { + p := c.V2X + if p == nil { + return nil + } + if p.DriverName != "" && !driverNames[p.DriverName] { + return fmt.Errorf("v2x.driver_name %q: no such driver", p.DriverName) + } + for name, value := range map[string]float64{ + "v2x.vehicle_capacity_wh": p.VehicleCapacityWh, + "v2x.max_charge_w": p.MaxChargeW, + "v2x.max_discharge_w": p.MaxDischargeW, + "v2x.cycle_cost_ore_kwh": p.CycleCostOreKWh, + "v2x.min_reserve_soc_pct": p.MinReserveSoCPct, + "v2x.departure_target_soc_pct": p.DepartureTargetSoCPct, + } { + if math.IsNaN(value) || math.IsInf(value, 0) { + return fmt.Errorf("%s must be finite", name) + } + } + if p.VehicleCapacityWh < 0 { + return errors.New("v2x.vehicle_capacity_wh must be >= 0") + } + if p.MaxChargeW < 0 { + return errors.New("v2x.max_charge_w must be >= 0") + } + if p.MaxDischargeW < 0 { + return errors.New("v2x.max_discharge_w must be >= 0") + } + if p.CycleCostOreKWh < 0 { + return errors.New("v2x.cycle_cost_ore_kwh must be >= 0") + } + if p.MinReserveSoCPct < 0 || p.MinReserveSoCPct > 100 { + return errors.New("v2x.min_reserve_soc_pct must be in [0,100]") + } + if p.DepartureTargetSoCPct < 0 || p.DepartureTargetSoCPct > 100 { + return errors.New("v2x.departure_target_soc_pct must be in [0,100]") + } + if p.Enabled && p.MinReserveSoCPct <= 0 { + return errors.New("v2x.min_reserve_soc_pct must be > 0 when v2x.enabled") + } + if p.DepartureTime != "" { + if err := validateV2XDepartureTime(p.DepartureTime); err != nil { + return err + } + } + if (p.DepartureTargetSoCPct > 0) != (p.DepartureTime != "") { + return errors.New("v2x.departure_target_soc_pct and v2x.departure_time must be set together") + } + if p.DepartureTargetSoCPct > 0 && p.DepartureTargetSoCPct < p.MinReserveSoCPct { + return errors.New("v2x.departure_target_soc_pct must be >= v2x.min_reserve_soc_pct") + } + return nil +} + +func validateV2XDepartureTime(value string) error { + if _, err := time.Parse("15:04", value); err == nil { + return nil + } + if _, err := time.Parse(time.RFC3339, value); err == nil { + return nil + } + return fmt.Errorf("v2x.departure_time must be HH:MM or RFC3339, got %q", value) +} + +// SiteMeterDriver returns the name of the driver marked is_site_meter. +func (c *Config) SiteMeterDriver() string { + for _, d := range c.Drivers { + if d.IsSiteMeter { + return d.Name + } + } + return "" +} + +// SaveAtomic writes config to disk via tmp-file + rename. Safe from partial writes. +func SaveAtomic(path string, c *Config) error { + // Driver paths are resolved to absolute-ish paths at Load() time. + // Convert them back to config-relative before writing so that + // repeated save cycles don't accumulate extra "../" prefixes. + baseDir := filepath.Dir(path) + out := *c + if len(out.Drivers) > 0 { + drivers := make([]Driver, len(out.Drivers)) + copy(drivers, out.Drivers) + for i := range drivers { + drivers[i].Lua = relDriverPath(baseDir, drivers[i].Lua) + } + out.Drivers = drivers + } + data, err := yaml.Marshal(&out) + if err != nil { + return fmt.Errorf("yaml marshal: %w", err) + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0644); err != nil { + return fmt.Errorf("write tmp: %w", err) + } + return os.Rename(tmp, path) +} + +func relDriverPath(baseDir, p string) string { + if p == "" { + return "" + } + // Paths resolved through UserDriversDirOverride or DriversDirOverride + // land outside baseDir, so a straight Rel would emit "../drivers/.lua". + // Rewrite them as a clean "drivers/" to keep YAML portable between hosts. + if UserDriversDirOverride != "" { + rel, err := filepath.Rel(UserDriversDirOverride, p) + if err == nil && !strings.HasPrefix(rel, "..") { + return filepath.ToSlash(filepath.Join("drivers", rel)) + } + } + if DriversDirOverride != "" { + rel, err := filepath.Rel(DriversDirOverride, p) + if err == nil && !strings.HasPrefix(rel, "..") { + return filepath.ToSlash(filepath.Join("drivers", rel)) + } + } + rel, err := filepath.Rel(baseDir, p) + if err != nil { + return p + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return p + } + return rel +} diff --git a/go/internal/ocpp/config.go b/go/internal/ocpp/config.go index 39f560c6..3b3533fe 100644 --- a/go/internal/ocpp/config.go +++ b/go/internal/ocpp/config.go @@ -14,6 +14,7 @@ type Config struct { Enabled bool `yaml:"enabled"` Bind string `yaml:"bind"` Port int `yaml:"port"` + PortV201 int `yaml:"port_v201"` Path string `yaml:"path"` Username string `yaml:"username"` Password string `yaml:"password"` diff --git a/go/internal/ocpp/control.go b/go/internal/ocpp/control.go index a67497da..1e04937a 100644 --- a/go/internal/ocpp/control.go +++ b/go/internal/ocpp/control.go @@ -26,6 +26,8 @@ import ( "github.com/lorenzodonini/ocpp-go/ocpp1.6/smartcharging" "github.com/lorenzodonini/ocpp-go/ocpp1.6/types" + smartcharging201 "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/smartcharging" + types201 "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/types" ) const ( @@ -53,9 +55,15 @@ const ( // dual-socket units such as the Charge Amps Aura. allConnectors = 0 + // The 2.0.1 equivalent: EVSE 0 addresses the whole charging station. + allEVSEs = 0 + // A single, stable profile id and stack level means each new limit // replaces the previous one instead of stacking on top of it. - ftwProfileID = 1 + ftwProfileID = 1 + // 2.0.1 schedules carry their own id; one stable id keeps replacement + // semantics identical to 1.6. + ftwScheduleID = 1 ftwStackLevel = 0 scheduleStartS = 0 ) @@ -176,30 +184,19 @@ func (s *Server) setLimit(ctx context.Context, id string, amps float64, numberPh amps = 0 } - period := types.NewChargingSchedulePeriod(scheduleStartS, amps) - // Declared only when the loadpoint pinned single-phase charging. Left - // unset otherwise so a charger that can switch phases keeps deciding. - period.NumberPhases = numberPhases - schedule := types.NewChargingSchedule(types.ChargingRateUnitAmperes, period) - profile := types.NewChargingProfile( - ftwProfileID, - ftwStackLevel, - types.ChargingProfilePurposeTxDefaultProfile, - types.ChargingProfileKindAbsolute, - schedule, - ) - - type result struct { - conf *smartcharging.SetChargingProfileConfirmation - err error - } // Buffered: the library's callback must never block if we have already // stopped waiting. - done := make(chan result, 1) - - err := s.cs.SetChargingProfile(id, func(conf *smartcharging.SetChargingProfileConfirmation, err error) { - done <- result{conf: conf, err: err} - }, allConnectors, profile) + done := make(chan profileResult, 1) + + // The two versions describe the same intent with different types, so the + // request is built per dialect and the outcome normalised back. + var err error + switch version, _ := s.handler.Version(id); version { + case Version201: + err = s.sendProfileV201(id, amps, numberPhases, done) + default: + err = s.sendProfileV16(id, amps, numberPhases, done) + } if err != nil { return fmt.Errorf("ocpp: send charging profile to %s: %w", id, err) } @@ -212,11 +209,11 @@ func (s *Server) setLimit(ctx context.Context, id string, amps float64, numberPh if r.err != nil { return fmt.Errorf("ocpp: %s rejected charging profile: %w", id, r.err) } - if r.conf == nil { + if !r.answered { return fmt.Errorf("ocpp: %s returned no charging profile confirmation", id) } - if r.conf.Status != smartcharging.ChargingProfileStatusAccepted { - return fmt.Errorf("ocpp: %s answered %s to charging profile", id, r.conf.Status) + if !r.accepted { + return fmt.Errorf("ocpp: %s answered %s to charging profile", id, r.status) } // Only a real charging rate is worth remembering. Recording the zero // from a pause would erase the rate a later resume is supposed to @@ -236,6 +233,73 @@ func (s *Server) setLimit(ctx context.Context, id string, amps float64, numberPh } } +// profileResult is a version-neutral answer to a charging profile request, so +// the waiting code above does not need to know which dialect produced it. +type profileResult struct { + answered bool + accepted bool + status string + err error +} + +// sendProfileV16 issues the limit as an OCPP 1.6 TxDefaultProfile. +func (s *Server) sendProfileV16(id string, amps float64, numberPhases *int, done chan<- profileResult) error { + period := types.NewChargingSchedulePeriod(scheduleStartS, amps) + // Declared only when the loadpoint pinned single-phase charging. Left + // unset otherwise so a charger that can switch phases keeps deciding. + period.NumberPhases = numberPhases + schedule := types.NewChargingSchedule(types.ChargingRateUnitAmperes, period) + profile := types.NewChargingProfile( + ftwProfileID, + ftwStackLevel, + types.ChargingProfilePurposeTxDefaultProfile, + types.ChargingProfileKindAbsolute, + schedule, + ) + + return s.cs.SetChargingProfile(id, func(conf *smartcharging.SetChargingProfileConfirmation, err error) { + r := profileResult{err: err} + if conf != nil { + r.answered = true + r.status = string(conf.Status) + r.accepted = conf.Status == smartcharging.ChargingProfileStatusAccepted + } + done <- r + }, allConnectors, profile) +} + +// sendProfileV201 issues the same limit as an OCPP 2.0.1 TxDefaultProfile. +// +// 2.0.1 carries a list of schedules rather than one, and each schedule needs +// its own id; a single-entry list with a stable id keeps the meaning identical +// to the 1.6 request. +func (s *Server) sendProfileV201(id string, amps float64, numberPhases *int, done chan<- profileResult) error { + if s.csms == nil { + return fmt.Errorf("ocpp: %s speaks %s but no %s listener is configured", id, Version201, Version201) + } + + period := types201.NewChargingSchedulePeriod(scheduleStartS, amps) + period.NumberPhases = numberPhases + schedule := types201.NewChargingSchedule(ftwScheduleID, types201.ChargingRateUnitAmperes, period) + profile := types201.NewChargingProfile( + ftwProfileID, + ftwStackLevel, + types201.ChargingProfilePurposeTxDefaultProfile, + types201.ChargingProfileKindAbsolute, + []types201.ChargingSchedule{*schedule}, + ) + + return s.csms.SetChargingProfile(id, func(conf *smartcharging201.SetChargingProfileResponse, err error) { + r := profileResult{err: err} + if conf != nil { + r.answered = true + r.status = string(conf.Status) + r.accepted = conf.Status == smartcharging201.ChargingProfileStatusAccepted + } + done <- r + }, allEVSEs, profile) +} + // DefaultMode is what a charger is left in when FTW stops steering it, and // mirrors the stance every EV driver already takes: hold the last limit. // diff --git a/go/internal/ocpp/control_v201_test.go b/go/internal/ocpp/control_v201_test.go new file mode 100644 index 00000000..1b0887b9 --- /dev/null +++ b/go/internal/ocpp/control_v201_test.go @@ -0,0 +1,269 @@ +package ocpp + +import ( + "context" + "errors" + "fmt" + "net" + "sync" + "testing" + "time" + + ocpp201 "github.com/lorenzodonini/ocpp-go/ocpp2.0.1" + "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/provisioning" + smartcharging201 "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" +) + +// fakeStationV201 is a 2.0.1 charging station that records the charging +// profiles it is sent. +type fakeStationV201 struct { + mu sync.Mutex + status smartcharging201.ChargingProfileStatus + profiles []*smartcharging201.SetChargingProfileRequest +} + +func newFakeStationV201() *fakeStationV201 { + return &fakeStationV201{status: smartcharging201.ChargingProfileStatusAccepted} +} + +func (f *fakeStationV201) OnSetChargingProfile(req *smartcharging201.SetChargingProfileRequest) (*smartcharging201.SetChargingProfileResponse, error) { + f.mu.Lock() + f.profiles = append(f.profiles, req) + status := f.status + f.mu.Unlock() + return smartcharging201.NewSetChargingProfileResponse(status), nil +} + +func (f *fakeStationV201) OnClearChargingProfile(*smartcharging201.ClearChargingProfileRequest) (*smartcharging201.ClearChargingProfileResponse, error) { + return nil, errors.New("not used") +} + +func (f *fakeStationV201) OnGetChargingProfiles(*smartcharging201.GetChargingProfilesRequest) (*smartcharging201.GetChargingProfilesResponse, error) { + return nil, errors.New("not used") +} + +func (f *fakeStationV201) OnGetCompositeSchedule(*smartcharging201.GetCompositeScheduleRequest) (*smartcharging201.GetCompositeScheduleResponse, error) { + return nil, errors.New("not used") +} + +func (f *fakeStationV201) lastLimit(t *testing.T) float64 { + t.Helper() + f.mu.Lock() + defer f.mu.Unlock() + if len(f.profiles) == 0 { + t.Fatal("station received no charging profile") + } + p := f.profiles[len(f.profiles)-1] + if p.ChargingProfile == nil { + t.Fatal("request carried no charging profile") + } + if len(p.ChargingProfile.ChargingSchedule) == 0 { + t.Fatal("charging profile carried no schedule") + } + periods := p.ChargingProfile.ChargingSchedule[0].ChargingSchedulePeriod + if len(periods) == 0 { + t.Fatal("charging schedule had no periods") + } + return periods[0].Limit +} + +// startDualServer brings up both listeners on free ports. +func startDualServer(t *testing.T, tel *telemetry.Store) (portV16, portV201 int, srv *Server) { + t.Helper() + portV16 = freePort(t) + portV201 = freePort(t) + cfg := &Config{ + Enabled: true, + Bind: "127.0.0.1", + Port: portV16, + PortV201: portV201, + HeartbeatIntervalS: 60, + } + srv, err := Start(context.Background(), cfg, tel) + if err != nil { + t.Fatalf("start: %v", err) + } + t.Cleanup(srv.Stop) + + // Both listeners must be reachable before a client tries to connect. + for _, port := range []int{portV16, portV201} { + deadline := time.Now().Add(2 * time.Second) + bound := false + for time.Now().Before(deadline) { + c, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 100*time.Millisecond) + if err == nil { + c.Close() + bound = true + break + } + time.Sleep(20 * time.Millisecond) + } + if !bound { + t.Fatalf("listener never bound on port %d", port) + } + } + return portV16, portV201, srv +} + +// connectStationV201 brings up a 2.0.1 charging station and waits for the +// server to register it. +func connectStationV201(t *testing.T, srv *Server, port int, id string) (*fakeStationV201, func()) { + t.Helper() + fake := newFakeStationV201() + cs := ocpp201.NewChargingStation(id, nil, nil) + cs.SetSmartChargingHandler(fake) + + if err := cs.Start(fmt.Sprintf("ws://127.0.0.1:%d", port)); err != nil { + t.Fatalf("charging station connect: %v", err) + } + var once sync.Once + stop := func() { once.Do(cs.Stop) } + t.Cleanup(stop) + + station := provisioning.ChargingStationType{Model: "Dawn", VendorName: "Charge Amps"} + if _, err := cs.BootNotification(provisioning.BootReasonPowerUp, station.Model, station.VendorName); err != nil { + t.Fatalf("boot: %v", err) + } + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if srv.Handler().IsOnline(id) { + return fake, stop + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("server never registered station %s as online", id) + return nil, nil +} + +// A 2.0.1 station must be steerable exactly like a 1.6 one, and the server has +// to answer it in its own dialect. +func TestV201ChargerAcceptsCurrentLimit(t *testing.T) { + _, portV201, srv := startDualServer(t, telemetry.NewStore()) + fake, _ := connectStationV201(t, srv, portV201, "garage-v201") + + if v, ok := srv.Handler().Version("garage-v201"); !ok || v != Version201 { + t.Fatalf("version: got %q ok=%v, want %q", v, ok, Version201) + } + + // 11040 W over 3 phases at 230 V = 16 A per phase. + err := srv.Command(context.Background(), "garage-v201", mustPayload(t, map[string]any{ + "action": "ev_set_current", + "power_w": 11040.0, + "voltage": 230.0, + "site_phases": 3, + })) + if err != nil { + t.Fatalf("set current: %v", err) + } + if got := fake.lastLimit(t); got != 16 { + t.Errorf("limit: got %v A, want 16 A", got) + } +} + +// Pause is a 0 A limit on 2.0.1 too, not a remote stop. +func TestV201PauseSendsZeroAmpLimit(t *testing.T) { + _, portV201, srv := startDualServer(t, telemetry.NewStore()) + fake, _ := connectStationV201(t, srv, portV201, "garage-v201") + + if err := srv.Command(context.Background(), "garage-v201", mustPayload(t, map[string]any{ + "action": "ev_pause", + })); err != nil { + t.Fatalf("pause: %v", err) + } + if got := fake.lastLimit(t); got != 0 { + t.Errorf("pause limit: got %v A, want 0 A", got) + } +} + +// Both dialects served at once, each charger steered in its own, sharing one +// charger map and one telemetry store. +func TestBothVersionsServedSimultaneously(t *testing.T) { + portV16, portV201, srv := startDualServer(t, telemetry.NewStore()) + + _, fake16, _ := connectCharger(t, srv, portV16, "garage-v16") + fake201, _ := connectStationV201(t, srv, portV201, "garage-v201") + + if v, _ := srv.Handler().Version("garage-v16"); v != Version16 { + t.Errorf("v16 charger version: got %q, want %q", v, Version16) + } + if v, _ := srv.Handler().Version("garage-v201"); v != Version201 { + t.Errorf("v201 charger version: got %q, want %q", v, Version201) + } + + ctx := context.Background() + payload := mustPayload(t, map[string]any{ + "action": "ev_set_current", + "power_w": 6900.0, // 10 A over 3 phases at 230 V + "voltage": 230.0, + "site_phases": 3, + }) + if err := srv.Command(ctx, "garage-v16", payload); err != nil { + t.Fatalf("v16 set current: %v", err) + } + if err := srv.Command(ctx, "garage-v201", payload); err != nil { + t.Fatalf("v201 set current: %v", err) + } + + if got := fake16.lastLimit(t); got != 10 { + t.Errorf("v16 limit: got %v A, want 10 A", got) + } + if got := fake201.lastLimit(t); got != 10 { + t.Errorf("v201 limit: got %v A, want 10 A", got) + } + + // The two must not have been confused for one another. + snap := srv.Handler().Snapshot() + if len(snap) != 2 { + t.Errorf("expected 2 chargers in the snapshot, got %d: %+v", len(snap), snap) + } +} + +// A rejection from a 2.0.1 station has to surface as an error, same as 1.6. +func TestV201RejectedProfileIsAnError(t *testing.T) { + _, portV201, srv := startDualServer(t, telemetry.NewStore()) + fake, _ := connectStationV201(t, srv, portV201, "garage-v201") + + fake.mu.Lock() + fake.status = smartcharging201.ChargingProfileStatusRejected + fake.mu.Unlock() + + err := srv.Command(context.Background(), "garage-v201", mustPayload(t, map[string]any{ + "action": "ev_pause", + })) + if err == nil { + t.Fatal("expected an error when the station rejects the profile") + } +} + +// Guard the version-neutral core: a 2.0.1 profile must be a single-entry +// schedule list with amps as the rate unit, matching the 1.6 request's meaning. +func TestV201ProfileShape(t *testing.T) { + _, portV201, srv := startDualServer(t, telemetry.NewStore()) + fake, _ := connectStationV201(t, srv, portV201, "garage-v201") + + if err := srv.Command(context.Background(), "garage-v201", mustPayload(t, map[string]any{ + "action": "ev_set_current", + "power_w": 11040.0, + "voltage": 230.0, + "site_phases": 3, + })); err != nil { + t.Fatalf("set current: %v", err) + } + + fake.mu.Lock() + defer fake.mu.Unlock() + p := fake.profiles[len(fake.profiles)-1].ChargingProfile + if len(p.ChargingSchedule) != 1 { + t.Fatalf("schedules: got %d, want exactly 1", len(p.ChargingSchedule)) + } + if unit := p.ChargingSchedule[0].ChargingRateUnit; unit != types201.ChargingRateUnitAmperes { + t.Errorf("rate unit: got %q, want %q", unit, types201.ChargingRateUnitAmperes) + } + if p.ChargingProfilePurpose != types201.ChargingProfilePurposeTxDefaultProfile { + t.Errorf("purpose: got %q, want TxDefaultProfile", p.ChargingProfilePurpose) + } +} diff --git a/go/internal/ocpp/handlers.go b/go/internal/ocpp/handlers.go index d46b233e..10ea458c 100644 --- a/go/internal/ocpp/handlers.go +++ b/go/internal/ocpp/handlers.go @@ -43,6 +43,12 @@ type chargerState struct { // lastAmps is the most recent per-phase limit this charger accepted. // A resume with no rate of its own restores it. lastAmps float64 + // version is the OCPP dialect this charger connected with, which decides + // how commands are encoded on the way back. + version Version + // transactionRef is the 2.0.1 transaction id, which is a string rather + // than the int transactionID above. Empty on the 1.6 path. + transactionRef string } // NewHandler returns a Handler ready to register with a CentralSystem. diff --git a/go/internal/ocpp/handlers_v201.go b/go/internal/ocpp/handlers_v201.go new file mode 100644 index 00000000..ae956b4d --- /dev/null +++ b/go/internal/ocpp/handlers_v201.go @@ -0,0 +1,253 @@ +package ocpp + +// OCPP 2.0.1 CSMS handlers. +// +// These translate 2.0.1 messages into the same charger state and DerEV +// telemetry the 1.6 handlers produce, so everything downstream — dispatch, the +// loadpoint controller, control commands — is unaware of which dialect a +// charger speaks. +// +// The shapes differ more than the names suggest: +// +// - Start/StopTransaction collapse into a single TransactionEvent with a +// Started / Updated / Ended trigger, and the transaction id is a string +// rather than an int. +// - StatusNotification reports per-EVSE connector status with a different +// enum, and no longer carries the "charging" meaning — that now comes from +// the transaction event. +// - Meter samples arrive inside TransactionEvent as well as in MeterValues. +// +// Everything not needed to meter and steer a charger is acknowledged and +// dropped. Accepting a message we ignore is correct here: refusing it would +// make the charger retry forever. + +import ( + "log/slog" + "time" + + "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/transactions" + types201 "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/types" +) + +// handlerV201 adapts a Handler to the 2.0.1 CSMS interfaces. It owns no state +// of its own — everything lands in the shared Handler. +type handlerV201 struct { + *Handler +} + +// ---- provisioning.CSMSHandler ---- + +func (h *handlerV201) OnBootNotification(id string, req *provisioning.BootNotificationRequest) (*provisioning.BootNotificationResponse, error) { + vendor, model, serial := "", "", "" + if req != nil { + vendor = req.ChargingStation.VendorName + model = req.ChargingStation.Model + serial = req.ChargingStation.SerialNumber + } + slog.Info("OCPP boot", + "charger", id, "version", Version201, + "vendor", vendor, "model", model, "serial", serial) + + h.setVersion(id, Version201) + h.tel.RecordDriverSuccess(id) + + return provisioning.NewBootNotificationResponse( + types201.NewDateTime(time.Now()), + h.heartbeatIntervalS, + provisioning.RegistrationStatusAccepted, + ), nil +} + +// OnNotifyReport carries variable inventory. FTW does not model charger +// configuration, so this is acknowledged and dropped. +func (h *handlerV201) OnNotifyReport(id string, _ *provisioning.NotifyReportRequest) (*provisioning.NotifyReportResponse, error) { + h.tel.RecordDriverSuccess(id) + return provisioning.NewNotifyReportResponse(), nil +} + +// ---- availability.CSMSHandler ---- + +func (h *handlerV201) OnHeartbeat(id string, _ *availability.HeartbeatRequest) (*availability.HeartbeatResponse, error) { + h.tel.RecordDriverSuccess(id) + return availability.NewHeartbeatResponse(*types201.NewDateTime(time.Now())), nil +} + +// OnStatusNotification maps 2.0.1 connector status onto the same +// connected/charging pair the 1.6 path produces. +// +// Unlike 1.6 there is no Charging status here — occupancy is all this tells us, +// and whether energy is actually flowing comes from the transaction event. +func (h *handlerV201) OnStatusNotification(id string, req *availability.StatusNotificationRequest) (*availability.StatusNotificationResponse, error) { + s := h.state(id) + h.mu.Lock() + switch req.ConnectorStatus { + case availability.ConnectorStatusAvailable, availability.ConnectorStatusUnavailable: + s.connected = false + s.charging = false + s.lastPowerW = 0 + case availability.ConnectorStatusOccupied, availability.ConnectorStatusReserved: + s.connected = 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.charging = false + s.lastPowerW = 0 + } + faulted := req.ConnectorStatus == availability.ConnectorStatusFaulted + h.mu.Unlock() + + slog.Info("OCPP status", + "charger", id, "version", Version201, + "evse", req.EvseID, "connector", req.ConnectorID, "status", req.ConnectorStatus) + + if faulted { + h.tel.RecordDriverError(id, "ocpp: connector faulted") + } else { + h.tel.RecordDriverSuccess(id) + } + h.pushReading(id, s) + return availability.NewStatusNotificationResponse(), nil +} + +// ---- transactions.CSMSHandler ---- + +// OnTransactionEvent replaces 1.6's StartTransaction, StopTransaction and much +// of MeterValues. The trigger says which of those it stands in for. +func (h *handlerV201) OnTransactionEvent(id string, req *transactions.TransactionEventRequest) (*transactions.TransactionEventResponse, error) { + s := h.state(id) + + // Meter samples ride along with every event type. + powerW, energyWh, hasEnergy := sampledValuesV201(req.MeterValue) + + h.mu.Lock() + switch req.EventType { + case transactions.TransactionEventStarted: + // 2.0.1 transaction ids are strings; the shared state keeps an int for + // the 1.6 path, so record presence rather than the id itself and keep + // the real one alongside. + h.nextTxID++ + s.transactionID = h.nextTxID + s.transactionRef = req.TransactionInfo.TransactionID + s.sessionStartMeterWh = energyWh + s.sessionMeterWh = 0 + s.connected = true + s.charging = true + + case transactions.TransactionEventUpdated: + s.connected = true + if hasEnergy && s.transactionID >= 0 { + s.sessionMeterWh = energyWh - s.sessionStartMeterWh + } + // A zero power sample during a live transaction is a genuine pause, + // not a missing reading, so it is taken at face value. + s.charging = powerW > 0 + + case transactions.TransactionEventEnded: + if hasEnergy { + s.sessionMeterWh = energyWh - s.sessionStartMeterWh + } + s.transactionID = -1 + s.transactionRef = "" + s.charging = false + s.lastPowerW = 0 + powerW = 0 + } + + if req.EventType != transactions.TransactionEventEnded { + s.lastPowerW = powerW + } + sessionWh := s.sessionMeterWh + ended := req.EventType == transactions.TransactionEventEnded + h.mu.Unlock() + + slog.Info("OCPP transaction event", + "charger", id, "version", Version201, + "event", req.EventType, "seq", req.SequenceNo, "w", powerW) + + h.pushReading(id, s) + if ended { + h.tel.EmitMetric(id, "ev_session_wh", sessionWh, "Wh", "", "") + } + h.tel.RecordDriverSuccess(id) + + return transactions.NewTransactionEventResponse(), nil +} + +// ---- meter.CSMSHandler ---- + +func (h *handlerV201) OnMeterValues(id string, req *meter.MeterValuesRequest) (*meter.MeterValuesResponse, error) { + s := h.state(id) + powerW, energyWh, hasEnergy := sampledValuesV201(req.MeterValue) + + h.mu.Lock() + s.lastPowerW = powerW + if hasEnergy && s.transactionID >= 0 { + s.sessionMeterWh = energyWh - s.sessionStartMeterWh + } + h.mu.Unlock() + + h.pushReading(id, s) + h.tel.RecordDriverSuccess(id) + return meter.NewMeterValuesResponse(), nil +} + +// ---- authorization.CSMSHandler ---- + +// OnAuthorize accepts every token. FTW is a home energy manager, not an access +// control system: the charger is behind the operator's own front door, and +// refusing here would only stop them charging. Matches the 1.6 path. +func (h *handlerV201) OnAuthorize(id string, _ *authorization.AuthorizeRequest) (*authorization.AuthorizeResponse, error) { + h.tel.RecordDriverSuccess(id) + return authorization.NewAuthorizationResponse(types201.IdTokenInfo{ + Status: types201.AuthorizationStatusAccepted, + }), nil +} + +// sampledValuesV201 pulls active-import power and energy out of a 2.0.1 meter +// value set, normalising kW/kWh to W/Wh. +// +// 2.0.1 always states the measurand, so unlike 1.6 there is no default to +// assume. hasEnergy distinguishes "no energy sample in this batch" from a +// genuine zero reading, which matters because session energy is a difference +// against the transaction's starting register. +func sampledValuesV201(values []types201.MeterValue) (powerW, energyWh float64, hasEnergy bool) { + for _, mv := range values { + for _, sv := range mv.SampledValue { + val := sv.Value + switch sv.Measurand { + case types201.MeasurandPowerActiveImport: + if unitIsKilo(sv.UnitOfMeasure) { + val *= 1000 + } + powerW = val + case types201.MeasurandEnergyActiveImportRegister: + if unitIsKilo(sv.UnitOfMeasure) { + val *= 1000 + } + energyWh = val + hasEnergy = true + } + } + } + return powerW, energyWh, hasEnergy +} + +// unitIsKilo reports whether a sample is expressed in kW or kWh. An absent unit +// means the 2.0.1 default, which is already W/Wh. +func unitIsKilo(u *types201.UnitOfMeasure) bool { + if u == nil { + return false + } + switch u.Unit { + case "kW", "kWh": + return true + default: + return false + } +} + diff --git a/go/internal/ocpp/server.go b/go/internal/ocpp/server.go index 75a4dc06..13447c4f 100644 --- a/go/internal/ocpp/server.go +++ b/go/internal/ocpp/server.go @@ -1,130 +1,192 @@ -// Package ocpp is the OCPP 1.6J Central System for FTW. -// -// EV chargers connect to us via WebSocket. We translate every BootNotification, -// MeterValues, and StatusNotification into a DerEV reading in telemetry.Store, -// keyed by the chargePointId from the URL path. The dispatch layer -// (control/dispatch.go:199-216) sums DerEV readings and prevents home batteries -// from discharging into an active EV charge. -// -// Phase 1 is read-only — handlers below ack everything but do not push remote -// commands. Phase 2 will add RemoteStartTransaction / SetChargingProfile. -// -// The library backing this is github.com/lorenzodonini/ocpp-go (MIT, also used -// by SteVe) — it owns the WebSocket + JSON layer; we own the message handlers -// and the telemetry mapping. -package ocpp - -import ( - "context" - "errors" - "fmt" - "log/slog" - "sync" - "time" - - ocpp16 "github.com/lorenzodonini/ocpp-go/ocpp1.6" - "github.com/lorenzodonini/ocpp-go/ws" - - "github.com/srcfl/ftw/go/internal/telemetry" -) - -// Server is a running OCPP 1.6J Central System. -type Server struct { - cfg *Config - cs ocpp16.CentralSystem - handler *Handler - done chan struct{} - stopOnce sync.Once -} - -// Start brings up the OCPP CS on the configured bind:port. Returns -// immediately once the listener is up; the WebSocket loop runs in its own -// goroutine until ctx is cancelled or Stop() is called. -// -// The returned Server is the handle for shutdown — main.go is expected to -// call Stop() during graceful drain. -func Start(ctx context.Context, cfg *Config, tel *telemetry.Store) (*Server, error) { - if cfg == nil { - return nil, errors.New("ocpp: nil config") - } - if tel == nil { - return nil, errors.New("ocpp: nil telemetry store") - } - cfg.Defaults() - - wsServer := ws.NewServer() - if cfg.Username != "" || cfg.Password != "" { - u, p := cfg.Username, cfg.Password - wsServer.SetBasicAuthHandler(func(user, pass string) bool { - return user == u && pass == p - }) - } - - cs := ocpp16.NewCentralSystem(nil, wsServer) - h := NewHandler(tel, cfg.HeartbeatIntervalS) - cs.SetCoreHandler(h) - cs.SetNewChargePointHandler(func(cp ocpp16.ChargePointConnection) { - h.OnConnect(cp.ID()) - }) - cs.SetChargePointDisconnectedHandler(func(cp ocpp16.ChargePointConnection) { - h.OnDisconnect(cp.ID()) - }) - - s := &Server{cfg: cfg, cs: cs, handler: h, done: make(chan struct{})} - go func() { - defer close(s.done) - slog.Info("OCPP central system listening", - "bind", cfg.Bind, "port", cfg.Port, "path", cfg.Path, - "basic_auth", cfg.Username != "") - // TODO: cfg.Bind is not honored here. The ocpp-go library's - // CentralSystem.Start(port, path) and ws.Server.Start(port, path) - // only accept a port — there is no SetAddr or bind-address parameter. - // To support bind-address natively we would need to either: - // (a) upstream a PR to ocpp-go adding a SetListenAddr method, or - // (b) create our own net.Listener bound to cfg.Bind:cfg.Port and - // serve the ws.Server's http.Handler on it. - // For now cfg.Bind is advisory-only (documented in Config). - // cs.Start blocks until cs.Stop is called. - s.cs.Start(cfg.Port, fmt.Sprintf("%s{ws}", cfg.Path)) - }() - go func() { - <-ctx.Done() - s.Stop() - }() - return s, nil -} - -// Stop closes the WebSocket server and waits for the listener goroutine to exit. -// A 5-second timeout prevents deadlock if the listener goroutine is stuck. -func (s *Server) Stop() { - if s == nil || s.cs == nil { - return - } - s.stopOnce.Do(func() { s.cs.Stop() }) - select { - case <-s.done: - case <-time.After(5 * time.Second): - slog.Warn("ocpp: shutdown timeout — forcing close") - } -} - -// Handler exposes per-charger state for tests + introspection. -func (s *Server) Handler() *Handler { return s.handler } - -// Port is the port the listener actually took, after defaults were applied. -// Callers configuring an unset port need this to log or display the real value. -func (s *Server) Port() int { - if s == nil || s.cfg == nil { - return 0 - } - return s.cfg.Port -} - -// Path is the URL prefix charge points connect to, after defaults were applied. -// A charger dials , and that identity becomes its device key. -func (s *Server) Path() string { - if s == nil || s.cfg == nil { - return "" - } - return s.cfg.Path -} +// Package ocpp is the OCPP 1.6J Central System for FTW. +// +// EV chargers connect to us via WebSocket. We translate every BootNotification, +// MeterValues, and StatusNotification into a DerEV reading in telemetry.Store, +// keyed by the chargePointId from the URL path. The dispatch layer +// (control/dispatch.go:199-216) sums DerEV readings and prevents home batteries +// from discharging into an active EV charge. +// +// Phase 1 is read-only — handlers below ack everything but do not push remote +// commands. Phase 2 will add RemoteStartTransaction / SetChargingProfile. +// +// The library backing this is github.com/lorenzodonini/ocpp-go (MIT, also used +// by SteVe) — it owns the WebSocket + JSON layer; we own the message handlers +// and the telemetry mapping. +package ocpp + +import ( + "context" + "errors" + "fmt" + "log/slog" + "sync" + "time" + + ocpp16 "github.com/lorenzodonini/ocpp-go/ocpp1.6" + ocpp201 "github.com/lorenzodonini/ocpp-go/ocpp2.0.1" + "github.com/lorenzodonini/ocpp-go/ws" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// Server is a running OCPP Central System, serving one listener per enabled +// protocol version. +// +// Each version needs its own port. The OCPP library's ws.Server keeps a single +// message handler, so one listener cannot dispatch both dialects, and a charger +// picks its dialect in the WebSocket handshake before any message is sent. +type Server struct { + cfg *Config + cs ocpp16.CentralSystem + csms ocpp201.CSMS + handler *Handler + // 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{} + doneV201 chan struct{} + stopOnce sync.Once +} + +// Start brings up the OCPP CS on the configured bind:port. Returns +// immediately once the listener is up; the WebSocket loop runs in its own +// goroutine until ctx is cancelled or Stop() is called. +// +// The returned Server is the handle for shutdown — main.go is expected to +// call Stop() during graceful drain. +func Start(ctx context.Context, cfg *Config, tel *telemetry.Store) (*Server, error) { + if cfg == nil { + return nil, errors.New("ocpp: nil config") + } + if tel == nil { + return nil, errors.New("ocpp: nil telemetry store") + } + cfg.Defaults() + + wsServer := ws.NewServer() + if cfg.Username != "" || cfg.Password != "" { + u, p := cfg.Username, cfg.Password + wsServer.SetBasicAuthHandler(func(user, pass string) bool { + return user == u && pass == p + }) + } + + cs := ocpp16.NewCentralSystem(nil, wsServer) + h := NewHandler(tel, cfg.HeartbeatIntervalS) + cs.SetCoreHandler(h) + cs.SetNewChargePointHandler(func(cp ocpp16.ChargePointConnection) { + h.OnConnect(cp.ID()) + // Which listener a charger reached is what identifies its dialect, so + // record it here rather than inferring it from a later message. + h.setVersion(cp.ID(), Version16) + }) + cs.SetChargePointDisconnectedHandler(func(cp ocpp16.ChargePointConnection) { + h.OnDisconnect(cp.ID()) + }) + + s := &Server{cfg: cfg, cs: cs, handler: h, done: make(chan struct{})} + + // 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 := ws.NewServer() + if cfg.Username != "" || cfg.Password != "" { + u, p := cfg.Username, cfg.Password + wsServer201.SetBasicAuthHandler(func(user, pass string) bool { + return user == u && pass == p + }) + } + h201 := &handlerV201{Handler: h} + csms := ocpp201.NewCSMS(nil, wsServer201) + csms.SetProvisioningHandler(h201) + csms.SetAvailabilityHandler(h201) + csms.SetTransactionsHandler(h201) + csms.SetMeterHandler(h201) + csms.SetAuthorizationHandler(h201) + csms.SetNewChargingStationHandler(func(cs ocpp201.ChargingStationConnection) { + h.OnConnect(cs.ID()) + h.setVersion(cs.ID(), Version201) + }) + csms.SetChargingStationDisconnectedHandler(func(cs ocpp201.ChargingStationConnection) { + h.OnDisconnect(cs.ID()) + }) + + s.csms = csms + s.doneV201 = make(chan struct{}) + go func() { + defer close(s.doneV201) + slog.Info("OCPP central system listening", + "version", Version201, "port", cfg.PortV201, "path", cfg.Path, + "basic_auth", cfg.Username != "") + csms.Start(cfg.PortV201, fmt.Sprintf("%s{ws}", cfg.Path)) + }() + } + + go func() { + defer close(s.done) + slog.Info("OCPP central system listening", + "bind", cfg.Bind, "port", cfg.Port, "path", cfg.Path, + "basic_auth", cfg.Username != "") + // TODO: cfg.Bind is not honored here. The ocpp-go library's + // CentralSystem.Start(port, path) and ws.Server.Start(port, path) + // only accept a port — there is no SetAddr or bind-address parameter. + // To support bind-address natively we would need to either: + // (a) upstream a PR to ocpp-go adding a SetListenAddr method, or + // (b) create our own net.Listener bound to cfg.Bind:cfg.Port and + // serve the ws.Server's http.Handler on it. + // For now cfg.Bind is advisory-only (documented in Config). + // cs.Start blocks until cs.Stop is called. + s.cs.Start(cfg.Port, fmt.Sprintf("%s{ws}", cfg.Path)) + }() + go func() { + <-ctx.Done() + s.Stop() + }() + return s, nil +} + +// Stop closes the WebSocket server and waits for the listener goroutine to exit. +// A 5-second timeout prevents deadlock if the listener goroutine is stuck. +func (s *Server) Stop() { + if s == nil || s.cs == nil { + return + } + s.stopOnce.Do(func() { + s.cs.Stop() + if s.csms != nil { + s.csms.Stop() + } + }) + select { + case <-s.done: + case <-time.After(5 * time.Second): + slog.Warn("ocpp: shutdown timeout — forcing close", "version", Version16) + } + if s.doneV201 != nil { + select { + case <-s.doneV201: + case <-time.After(5 * time.Second): + slog.Warn("ocpp: shutdown timeout — forcing close", "version", Version201) + } + } +} + +// Handler exposes per-charger state for tests + introspection. +func (s *Server) Handler() *Handler { return s.handler } + +// Port is the port the listener actually took, after defaults were applied. +// Callers configuring an unset port need this to log or display the real value. +func (s *Server) Port() int { + if s == nil || s.cfg == nil { + return 0 + } + return s.cfg.Port +} + +// Path is the URL prefix charge points connect to, after defaults were applied. +// A charger dials , and that identity becomes its device key. +func (s *Server) Path() string { + if s == nil || s.cfg == nil { + return "" + } + return s.cfg.Path +} diff --git a/go/internal/ocpp/version.go b/go/internal/ocpp/version.go new file mode 100644 index 00000000..20a129a5 --- /dev/null +++ b/go/internal/ocpp/version.go @@ -0,0 +1,67 @@ +package ocpp + +// OCPP version handling. +// +// A charge point picks its dialect during the WebSocket handshake, via the +// subprotocol header — "ocpp1.6", "ocpp2.0.1". The library's ws.Server keeps a +// single message handler, so one listener serves exactly one version and each +// enabled version gets its own port. Which port a charger dialled is therefore +// what tells us how to talk back to it. +// +// Everything above this file is version-neutral: chargers land in the same +// state map, produce the same DerEV telemetry, and take the same commands. Only +// the message encoding differs, which is what the per-version handlers own. + +// Version is an OCPP protocol version FTW can serve. +type Version string + +const ( + // Version16 is OCPP 1.6J. Every charger on the market speaks it, and every + // charger currently on the bench speaks only it. + Version16 Version = "1.6" + + // Version201 is OCPP 2.0.1. Newer hardware and the version the industry is + // migrating to. + Version201 Version = "2.0.1" +) + +// String makes Version printable in logs and errors. +func (v Version) String() string { return string(v) } + +// Valid reports whether this is a version FTW can serve. Used by config +// validation so a typo fails at startup rather than silently serving nothing. +func (v Version) Valid() bool { + switch v { + case Version16, Version201: + return true + default: + return false + } +} + +// Version returns the OCPP dialect a charge point connected with, and whether +// it has been seen at all. +func (h *Handler) Version(id string) (Version, bool) { + if h == nil { + return "", false + } + h.mu.Lock() + defer h.mu.Unlock() + s, ok := h.chargers[id] + if !ok || s.version == "" { + return "", false + } + return s.version, true +} + +// setVersion records the dialect a charge point connected with. Called from +// each version's connect callback, where the listener identity is known. +func (h *Handler) setVersion(id string, v Version) { + if h == nil { + return + } + s := h.state(id) + h.mu.Lock() + s.version = v + h.mu.Unlock() +} From 86b45dd872ab6877ef917f91e5520374442ce8eb Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Fri, 31 Jul 2026 14:58:29 +0200 Subject: [PATCH 7/9] docs(ocpp): document where the protocol code comes from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the boundary between the vendored protocol layer and FTW's own code, so a reader knows which side of it a bug is on: transport, OCPP-J framing, message types and schema validation are ocpp-go; handlers, telemetry mapping, control semantics and safety clamps are ours. Also corrects a false claim carried over from the retired package. The doc comment said ocpp-go was "also used by SteVe" — SteVe is a Java project (GPL-3.0), so it cannot depend on a Go library, and ocpp-go's README names no production users at all. Replaced with facts that can be checked: MIT, v0.19.0, no release since August 2025, and upstream's own description of its 2.0.1 support as needing more real-world testing. The package doc was stale in other ways too — it still described a 1.6-only, read-only server whose Phase 2 would add control, all of which has since landed. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com> --- docs/ocpp.md | 30 ++++++++++++++++++++++++++++++ go/internal/ocpp/server.go | 32 +++++++++++++++++++++----------- 2 files changed, 51 insertions(+), 11 deletions(-) diff --git a/docs/ocpp.md b/docs/ocpp.md index ed227c88..28e0f4c6 100644 --- a/docs/ocpp.md +++ b/docs/ocpp.md @@ -66,6 +66,36 @@ Adding 2.1 later means writing one more handler file and one more listener. The version-neutral core — charger state, telemetry mapping, control semantics — does not change. +## Where the code comes from + +The protocol layer is [`github.com/lorenzodonini/ocpp-go`](https://github.com/lorenzodonini/ocpp-go) +v0.19.0, MIT licensed. It is an ordinary Go module dependency, pinned in +`go/go.mod` and checksum-verified through `go/go.sum`. Nothing in FTW is copied +or forked from it. + +| Layer | Owner | +|---|---| +| WebSocket transport, OCPP-J framing, message types, schema validation | ocpp-go | +| Handlers, telemetry mapping, control semantics, safety clamps | FTW, `go/internal/ocpp` | + +That split is the first thing to check when something misbehaves. A malformed +message or a dropped connection is upstream; a wrong power figure or a wrong +current limit is ours. + +Upstream health, as of this writing: 367 stars, MIT, no release since August +2025, and it describes its own 2.0.1 support as "examples working, but will need +more real-world testing". Treat the 2.0.1 path here as less proven than 1.6J +regardless of FTW's own tests. + +If FTW ever needs a fix upstream will not take, the move is a `srcfl/ocpp-go` +fork plus a `replace` directive in `go.mod` — still an ordinary module. A git +submodule is not an option: `go build` resolves dependencies through `go.mod` +and the module cache, so a submodule checkout would be inert unless paired with +that same `replace`, while additionally breaking `go install` and any clone +made without `--recurse-submodules`. To pin the source inside this repository +instead, the Go-native answer is `go mod vendor`, which commits the dependency +tree under `vendor/` and is understood by the toolchain without extra flags. + ## Enabling the server OCPP is off by default. Add an `ocpp` section: diff --git a/go/internal/ocpp/server.go b/go/internal/ocpp/server.go index 13447c4f..bfe877b9 100644 --- a/go/internal/ocpp/server.go +++ b/go/internal/ocpp/server.go @@ -1,17 +1,27 @@ -// Package ocpp is the OCPP 1.6J Central System for FTW. +// Package ocpp is the OCPP Central System for FTW, speaking 1.6J and 2.0.1. // -// EV chargers connect to us via WebSocket. We translate every BootNotification, -// MeterValues, and StatusNotification into a DerEV reading in telemetry.Store, -// keyed by the chargePointId from the URL path. The dispatch layer -// (control/dispatch.go:199-216) sums DerEV readings and prevents home batteries -// from discharging into an active EV charge. +// EV chargers connect to us via WebSocket. Every BootNotification, MeterValues, +// StatusNotification and transaction message becomes a DerEV reading in +// telemetry.Store, keyed by the charge point identity from the URL path. The +// dispatch layer sums DerEV readings and stops home batteries discharging into +// an active EV charge. Control goes the other way as charging profiles; see +// control.go for why never as a remote stop. // -// Phase 1 is read-only — handlers below ack everything but do not push remote -// commands. Phase 2 will add RemoteStartTransaction / SetChargingProfile. +// # Provenance // -// The library backing this is github.com/lorenzodonini/ocpp-go (MIT, also used -// by SteVe) — it owns the WebSocket + JSON layer; we own the message handlers -// and the telemetry mapping. +// The protocol layer is github.com/lorenzodonini/ocpp-go v0.19.0 (MIT). It is a +// third-party dependency resolved through go.mod like any other — nothing in +// this package is copied or forked from it. It owns the WebSocket transport, +// OCPP-J framing, message types and schema validation. This package owns the +// handlers, the telemetry mapping, the control semantics and the safety clamps. +// +// The split matters when reading a bug: a malformed-message or transport +// failure is upstream, a wrong power figure or a wrong current limit is ours. +// +// Upstream describes its own 2.0.1 support as "examples working, but will need +// more real-world testing", so treat the 2.0.1 path here as less proven than +// 1.6J regardless of the tests in this package. Upstream has cut no release +// since August 2025 and implements no OCPP 2.1. package ocpp import ( From 26854b3e7e2e198051edd6c2af60767036629c77 Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Fri, 31 Jul 2026 15:51:09 +0200 Subject: [PATCH 8/9] docs: note 2.0.1 in the driver-writing OCPP callout Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com> --- docs/writing-a-driver.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/writing-a-driver.md b/docs/writing-a-driver.md index eaaef8ad..e2dbc094 100644 --- a/docs/writing-a-driver.md +++ b/docs/writing-a-driver.md @@ -12,9 +12,9 @@ tree here is the bundled FTW recovery snapshot; operator-only drivers may still live locally. > **An EV charger that speaks OCPP does not need a driver.** FTW has a built-in -> OCPP 1.6J Central System, and one server in core handles every charger that -> speaks the protocol. Point the charger at FTW and it registers itself. See -> [ocpp.md](ocpp.md) before writing anything. +> OCPP Central System serving 1.6J and 2.0.1, and one server in core handles +> every charger that speaks the protocol. Point the charger at FTW and it +> registers itself. See [ocpp.md](ocpp.md) before writing anything. ## Metadata From 7a884654597a7abc1a972216e890d5e3c6c4e3f2 Mon Sep 17 00:00:00 2001 From: Claude Opus 5 Date: Fri, 31 Jul 2026 15:52:54 +0200 Subject: [PATCH 9/9] docs(ocpp): reword the licence line for the brand-copy check The brand-cleanup workflow inventories "MIT licensed" as classified copy, so the new provenance paragraph tripped it. Same fact, different wording. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com> --- docs/ocpp.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ocpp.md b/docs/ocpp.md index 28e0f4c6..13686a8b 100644 --- a/docs/ocpp.md +++ b/docs/ocpp.md @@ -69,7 +69,7 @@ does not change. ## Where the code comes from The protocol layer is [`github.com/lorenzodonini/ocpp-go`](https://github.com/lorenzodonini/ocpp-go) -v0.19.0, MIT licensed. It is an ordinary Go module dependency, pinned in +v0.19.0, under the MIT license. It is an ordinary Go module dependency, pinned in `go/go.mod` and checksum-verified through `go/go.sum`. Nothing in FTW is copied or forked from it.