From d94c3e7fd2add48bd60bdb106a8457b5c50aa62f Mon Sep 17 00:00:00 2001 From: Tom Demeyer Date: Sat, 18 May 2024 14:14:49 +0200 Subject: [PATCH] moved repo --- README.md | 33 +++++ licence.md | 8 ++ mqifproxy/.DS_Store | Bin 0 -> 6148 bytes mqifproxy/go.mod | 11 ++ mqifproxy/go.sum | 12 ++ mqifproxy/mqifproxy.go | 124 +++++++++++++++++++ mqifproxy/mqpifroxy.service | 14 +++ smr2mqtt/go.mod | 15 +++ smr2mqtt/go.sum | 15 +++ smr2mqtt/p1.service | 14 +++ smr2mqtt/smr2mqtt.go | 237 ++++++++++++++++++++++++++++++++++++ solaredge/.DS_Store | Bin 0 -> 6148 bytes solaredge/go.mod | 9 ++ solaredge/go.sum | 6 + solaredge/solar.go | 167 +++++++++++++++++++++++++ solaredge/solaredge.service | 14 +++ 16 files changed, 679 insertions(+) create mode 100644 README.md create mode 100644 licence.md create mode 100644 mqifproxy/.DS_Store create mode 100644 mqifproxy/go.mod create mode 100644 mqifproxy/go.sum create mode 100644 mqifproxy/mqifproxy.go create mode 100644 mqifproxy/mqpifroxy.service create mode 100644 smr2mqtt/go.mod create mode 100644 smr2mqtt/go.sum create mode 100755 smr2mqtt/p1.service create mode 100644 smr2mqtt/smr2mqtt.go create mode 100644 solaredge/.DS_Store create mode 100644 solaredge/go.mod create mode 100644 solaredge/go.sum create mode 100644 solaredge/solar.go create mode 100644 solaredge/solaredge.service diff --git a/README.md b/README.md new file mode 100644 index 0000000..6c15045 --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# HEMS +## Home Energy Monitoring Solution :) + + +Met de SMR5 'smart' meter in NL is het mogelijk zelf via de **P1** poort met een seriele verbinding de meter uit te lezen. +Dit opent de weg voor een aantal leveranciers 'gadgets' verkopen, meestal gekoppeld aan een app op de telefoon; mogelijk via de 'cloud' van die leverancier. + +Er is natuurlijk ook zelf te knutselen, of met open source technologie, en zonder cloud-dwang inzicht te krijgen in je verbruik en eventueel in de opbrengst van de zonnenpanelen (PV systeem). +Dit is een min of meer zelfgeknuselde oplossing, gebouwd op een aantal belangrijke open-source pakketten. + +### Overzicht: + +Drie services verzamelen informatie en zet deze via MQTT in een Influx database. +Een Grafana server gebruikt informatie uit die database en presenteerd deze in een aantal mooie grafieken. + +Het is, helaas, **niet** geheel open source. De SolarEdge inverter is een 'black box' waar ook de eigenaar niet in kan kijken, behalve via de SolarEdge cloud. +Dit is natuurlijk niet acceptabel; de volgende stap is dan ook de communicatie tussen de SolarEdge en de inverter te hacken. Zo ver zijn we hier nog niet. + +Het geheel loopt simpel en effectief op een RaspBerry-PI(4), bijvoorbeeld in de meterkast, eventueel samen met een home-automation platform als home-assistant. Een voorbeeld docker-compose file is bijgevoegd. + +Er zijn drie linux services: + +1. **smr2mqtt** + Leest informatie uit de SMR5, en stuurt deze gaat naar een locale (in-house) MQTT broker. +2. **solaredge** + Deze serivce haalt via de SolarEdge API informatie op over de locale PV productie, en zet deze vervolgens ook op de MQTT bus. +3. **mqifproxy** + Dit is een 'proxy' welke informatie van de MQTT bus in de influx database zet. + +Wat er niet mogelijk in deze opzet is het meten van verbruik **wanneer de PV installatie produceert**. Dankzij SolarEdge hebben we geen inzicht in de real-time performance van de installatie. Op de API zijn er maar een beperkt aantal calls mogelijk, anders wordt het IP asdres geblocked. Op de SMR5 meter zie je alleen de netto consumptie / productie. SMR5 geeft geen aparte informatie over de feitelijke getallen, dat is technisch niet mogelijk. In de huidige opzet loopt de PV informatie tot een uur achter. + + + diff --git a/licence.md b/licence.md new file mode 100644 index 0000000..72bec50 --- /dev/null +++ b/licence.md @@ -0,0 +1,8 @@ +The MIT License (MIT) +Copyright © 2022 + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/mqifproxy/.DS_Store b/mqifproxy/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..5008ddfcf53c02e82d7eee2e57c38e5672ef89f6 GIT binary patch literal 6148 zcmeH~Jr2S!425mzP>H1@V-^m;4Wg<&0T*E43hX&L&p$$qDprKhvt+--jT7}7np#A3 zem<@ulZcFPQ@L2!n>{z**++&mCkOWA81W14cNZlEfg7;MkzE(HCqgga^y>{tEnwC%0;vJ&^%eQ zLs35+`xjp>T0:5160/debug/vars + go func() { + http.ListenAndServe(":5160", nil) + }() + + // wait a while; seems mosquitto needs time to get ready after startup + time.Sleep(time.Second) + + client := mqtt.NewClient(opts) + if token := client.Connect(); token.Wait() && token.Error() != nil { + panic(token.Error()) + } + + go readSerial(client) + go tellerUpdate(client) + + sig := make(chan os.Signal) + signal.Notify(sig, os.Interrupt, syscall.SIGINT, syscall.SIGTERM, syscall.SIGKILL) + s := <-sig + fmt.Printf("\x1b[2KSignal: %v\n", s) + client.Disconnect(250) +} + +// SMR5_SERIAL = /dev/ttyUSB0 +func readSerial(client mqtt.Client) { + sportstring := os.Getenv("SMR5_SERIAL") + log.Printf("Serial connection to: %s\n",sportstring) + c := &serial.Config{Name: sportstring, Baud: 115200, Size: 8, Parity: serial.ParityNone, StopBits: serial.Stop1} + sport, err := serial.OpenPort(c) + if err != nil { + log.Fatal(err) + } + defer sport.Close() + reader := bufio.NewReader(sport) + for { + s, _ := reader.ReadString('\n') + handleStatement(client, s) + } +} + +func tellerUpdate(client mqtt.Client) { + for { + time.Sleep(60 * time.Second) + client.Publish(*prefix+"/smr5/tariff", 0, false, currentTariff) + client.Publish(*prefix+"/smr5/kwh_demand_dal", 0, false, currentTeller1) + client.Publish(*prefix+"/smr5/kwh_demand_piek", 0, false, currentTeller2) + client.Publish(*prefix+"/smr5/kwh_supply_dal", 0, false, currentSupply1) + client.Publish(*prefix+"/smr5/kwh_supply_piek", 0, false, currentSupply2) + } +} + +func handleStatement(client mqtt.Client, message string) { + if *sendRaw { + client.Publish(*prefix+"/smr5/raw", 0, false, message) + } + + switch state { + case wait_TellerDemandDal: + found := re_Teller1Demand.FindStringSubmatch(message) + if found != nil { + currentTeller1 = found[1] + state = wait_TellerDemandPiek + } + case wait_TellerDemandPiek: + found := re_Teller2Demand.FindStringSubmatch(message) + if found != nil { + currentTeller2 = found[1] + state = wait_TellerSupplyDal + } + case wait_TellerSupplyDal: + found := re_Teller1Supply.FindStringSubmatch(message) + if found != nil { + currentSupply1 = found[1] + state = wait_TellerSupplyPiek + } + case wait_TellerSupplyPiek: + found := re_Teller2Supply.FindStringSubmatch(message) + if found != nil { + currentSupply2 = found[1] + state = wait_Tariff + } + case wait_Tariff: + found := re_Tariff.FindStringSubmatch(message) + if found != nil { + currentTariff = found[1] + state = wait_DemandWatt + } + case wait_DemandWatt: + found := re_DemandWatt.FindStringSubmatch(message) + if found != nil { + if currentDemandWatt != found[1] { + client.Publish(*prefix+"/smr5/watt_demand", 0, false, found[1]) + client.Publish(*prefix+"/smr5/watt_supply", 0, false, "00.000") + currentDemandWatt = found[1] + } + state = wait_SupplyWatt + } + case wait_SupplyWatt: + found := re_SupplyWatt.FindStringSubmatch(message) + if found != nil { + if currentSupplyWatt != found[1] { + client.Publish(*prefix+"/smr5/watt_demand", 0, false, "00.000") + client.Publish(*prefix+"/smr5/watt_supply", 0, false, found[1]) + currentSupplyWatt = found[1] + } + state = wait_Volt + } + case wait_Volt: + found := re_Volt.FindStringSubmatch(message) + if found != nil { + if *sendheartbeat { + heartbeat = heartbeat + 1 + client.Publish(*prefix+"/smr5/heartbeat", 0, false, fmt.Sprintf("%d", heartbeat)) + } + if currentVolt != found[1] { + client.Publish(*prefix+"/smr5/volt", 0, false, found[1]) + currentVolt = found[1] + } + state = wait_TellerDemandDal + } + } +} + + +// SMR5 'telegram': + +// # /XMX5LGBBLB2410018242 + +// # 1-3:0.2.8(50) +// # 0-0:1.0.0(171229103442W) +// # 0-0:96.1.1(4530303335303033373439313036343136) +// # 1-0:1.8.1(001268.417*kWh) # afgenomen stroom daltarief +// # 1-0:1.8.2(001258.762*kWh) # afgenomen stroom piektarief +// # 1-0:2.8.1(000000.000*kWh) # gesaldeerde stroom daltarief +// # 1-0:2.8.2(000000.000*kWh) # gesaldeerde stroom piektarief +// # 0-0:96.14.0(0002) # actueel tarief: piek = 2, dal = 1 +// # 1-0:1.7.0(00.268*kW) # actueel verbruik +// # 1-0:2.7.0(00.000*kW) # actuele saldering +// # 0-0:96.7.21(00003) +// # 0-0:96.7.9(00000) +// # 1-0:99.97.0(0)(0-0:96.7.19) +// # 1-0:32.32.0(00001) +// # 1-0:32.36.0(00000) +// # 0-0:96.13.0() +// # 1-0:32.7.0(231.0*V) # actueel voltage +// # 1-0:31.7.0(001*A) +// # 1-0:21.7.0(00.268*kW) +// # 1-0:22.7.0(00.000*kW) +// # !8A43 + + + diff --git a/solaredge/.DS_Store b/solaredge/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..5008ddfcf53c02e82d7eee2e57c38e5672ef89f6 GIT binary patch literal 6148 zcmeH~Jr2S!425mzP>H1@V-^m;4Wg<&0T*E43hX&L&p$$qDprKhvt+--jT7}7np#A3 zem<@ulZcFPQ@L2!n>{z**++&mCkOWA81W14cNZlEfg7;MkzE(HCqgga^y>{tEnwC%0;vJ&^%eQ zLs35+`xjp>T0 +// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +// associated documentation files (the “Software”), to deal in the Software without restriction, +// including without limitation the rights to use, copy, modify, merge, publish, distribute, +// sublicense, and/or sell copies of the Software, and to permit persons to whom the Software +// is furnished to do so, subject to the following conditions: +// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +// THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING +// BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +// IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +// This is a libux service that periodically queries the Solaredge API server and dumps the data into +// an influx database (that needs to be reachable and available without authentication). +// Typical setup is a NAS or Raspberry Pi that runs both this service, the influx database and grafana + +package main + +import ( + "bytes" + "flag" + "fmt" + "io" + "log" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/Jeffail/gabs" + "github.com/davecgh/go-spew/spew" + "github.com/robfig/cron/v3" +) + +var cetLoc *time.Location +var jobs *cron.Cron + +const dtFormat string = "2006-01-02 15:04:05" + +func update(retry uint8, ue string) { + now := time.Now() + t_e := time.Date(now.Year(), now.Month(), now.Day(), now.Hour(), 0, 0, 0, now.Location()) + t_s := time.Date(now.Year(), now.Month(), now.Day(), now.Hour()-1, 0, 0, 0, now.Location()) + eurl := strings.Replace(fmt.Sprintf(ue, t_s.Format(dtFormat), t_e.Format(dtFormat)), " ", "%20", -1) + + if *xverbose { + log.Printf("GET %s\n", eurl) + } + + eresp, err := http.Get(eurl) + if err != nil { + log.Println(err) + return + } + defer eresp.Body.Close() + + // api calls need to be paced consierably to prevent being locked out. + if eresp.StatusCode != 200 && retry < 5 { + log.Printf("status = %s, retrying...\n", eresp.Status) + time.Sleep(5 * time.Minute) + update(retry+1, ue) + return + } + + if eresp.StatusCode == 200 { + if *verbose { + log.Printf("got data from %v to %v\n", t_s, t_e) + } + body, err := io.ReadAll(eresp.Body) + if err != nil { + log.Println(err) + return + } + update_equipment(body) + } else { + log.Println(eresp.Status) + } +} + +func update_equipment(body []byte) { + jsonParsed, err := gabs.ParseJSON(body) + if err != nil { + log.Println(err) + log.Printf("error: %v\nbody:\n%s\n", err, string(body)) + return + } + children, err := jsonParsed.S("data", "telemetries").Children() + if err != nil { + log.Println(err) + return + } + + for _, child := range children { + var dcVoltage float64 + timeT, _ := time.ParseInLocation(dtFormat, child.S("date").Data().(string), cetLoc) + if v, ok := child.S("dcVoltage").Data().(float64); ok { + dcVoltage = float64(v) + } else { + dcVoltage = 0.0 + } + acCurrent := child.S("L1Data", "acCurrent").Data().(float64) + acVoltage := child.S("L1Data", "acVoltage").Data().(float64) + acFrequency := child.S("L1Data", "acFrequency").Data().(float64) + activePower := child.S("L1Data", "activePower").Data().(float64) + totalEnergy := child.S("totalEnergy").Data().(float64) + temperature := child.S("temperature").Data().(float64) + s := fmt.Sprintf("solar dcVoltage=%0.2f,acCurrent=%0.2f,acVoltage=%0.2f,acFrequency=%0.2f,activePower=%0.2f,totalEnergy=%0.2f,temperature=%0.2f %d", + dcVoltage, acCurrent, acVoltage, acFrequency, activePower, totalEnergy, temperature, timeT.UnixNano()) + if *xverbose { + log.Printf("update_en: %s\n", s) + } + updateInflux(s) + } +} + +// INFLUX_SERVER format: http://192.168.178.8:8086 +var ifhost = flag.String("influx", os.Getenv("INFLUX_SERVER"), "the http write url of the influx db; includes port if not 80.") +var verbose = flag.Bool("v", false, "log events.") +var xverbose = flag.Bool("vv", false, "log more events.") + +func updateInflux(ifData string) { + if ifData != "" { + respi, err := http.Post(*ifhost+"/write?db=solaredge", "text/plain", bytes.NewBufferString(ifData)) + if err != nil { + fmt.Fprintf(os.Stderr, "Error post to influx: %v\n", err) + } else { + if *xverbose { + fmt.Fprintf(os.Stderr, "influx write: %s\n", ifData) + } + respi.Body.Close() + } + } +} + +func dumpStatus() { + for j := range jobs.Entries() { + spew.Dump(j) + } +} + +func main() { + flag.Parse() + api_url := "https://monitoringapi.solaredge.com/equipment/2353133/74037025-0C/data?api_key=" + os.Getenv("SE_API_KEY") + "&startTime=%s&endTime=%s" + cetLoc, _ = time.LoadLocation("Europe/Amsterdam") + jobs = cron.New() + // every hour on the 25th minute + // api calls need to be paced consierably to prevent being locked out. + jobs.AddFunc("25 * * * *", func() { update(0, api_url) }) + jobs.Start() + fmt.Printf("solaredge API query service started; q every hour at minute 25.\n") + sig := make(chan os.Signal, 4) + signal.Notify(sig, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM) + for { + s := <-sig + switch s { + case syscall.SIGHUP: + dumpStatus() + default: + log.Printf("\x1b[2KSignal: %v\n", s) + os.Exit(0) + } + } +} diff --git a/solaredge/solaredge.service b/solaredge/solaredge.service new file mode 100644 index 0000000..4d5e7e3 --- /dev/null +++ b/solaredge/solaredge.service @@ -0,0 +1,14 @@ +[Unit] +Description=SolarEdge API Query +After=network.target + +[Service] +ExecStart=/usr/local/bin/solaredge -v +WorkingDirectory=/tmp +StandardOutput=inherit +StandardError=inherit +Restart=always +User=root + +[Install] +WantedBy=multi-user.target