moved repo

This commit is contained in:
Tom Demeyer 2024-05-18 14:14:49 +02:00
commit d94c3e7fd2
16 changed files with 679 additions and 0 deletions

33
README.md Normal file
View file

@ -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.

8
licence.md Normal file
View file

@ -0,0 +1,8 @@
The MIT License (MIT)
Copyright © 2022 <Tom Demeyer>
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.

BIN
mqifproxy/.DS_Store vendored Normal file

Binary file not shown.

11
mqifproxy/go.mod Normal file
View file

@ -0,0 +1,11 @@
module github.com/tomdemeyer.nl/mqifproxy
go 1.18
require github.com/eclipse/paho.mqtt.golang v1.4.1
require (
github.com/gorilla/websocket v1.4.2 // indirect
golang.org/x/net v0.0.0-20200425230154-ff2c4b7c35a0 // indirect
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect
)

12
mqifproxy/go.sum Normal file
View file

@ -0,0 +1,12 @@
github.com/eclipse/paho.mqtt.golang v1.4.1 h1:tUSpviiL5G3P9SZZJPC4ZULZJsxQKXxfENpMvdbAXAI=
github.com/eclipse/paho.mqtt.golang v1.4.1/go.mod h1:JGt0RsEwEX+Xa/agj90YJ9d9DH2b7upDZMK9HRbFvCA=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/net v0.0.0-20200425230154-ff2c4b7c35a0 h1:Jcxah/M+oLZ/R4/z5RzfPzGbPXnVDPkEDtf2JnuxN+U=
golang.org/x/net v0.0.0-20200425230154-ff2c4b7c35a0/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=

124
mqifproxy/mqifproxy.go Normal file
View file

@ -0,0 +1,124 @@
package main
import (
"bytes"
"flag"
"fmt"
"log"
"math/rand"
"net/http"
_ "net/http/pprof"
"os"
"os/signal"
"strings"
"syscall"
"time"
mqtt "github.com/eclipse/paho.mqtt.golang"
)
// MQTT_SERVER: format: tcp://192.168.178.8:1883
// INFLUX_SERVER: http://192.168.178.8:8086
var haveIFdb bool = false
var broker = flag.String("mqtt", os.Getenv("MQTT_SERVER"), "the broker protocol://ip:port; protocol can be 'ws' or 'tcp'.")
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 ifch = make(chan string, 128)
func checkForInflux() {
respi, err := http.Get(*ifhost + "/ping")
if err != nil || respi.StatusCode != 204 {
fmt.Fprintf(os.Stderr, "InfluxDB error or not present.\n")
} else {
haveIFdb = true
go func() {
for {
updateInflux(<-ifch)
}
}()
}
}
func updateInflux(ifData string) {
if ifData != "" {
if *verbose {
fmt.Fprintf(os.Stderr, "ifdata: %s\n", ifData)
}
if haveIFdb {
respi, err := http.Post(*ifhost+"/write?db=pm", "text/plain", bytes.NewBufferString(ifData))
if err != nil {
fmt.Fprintf(os.Stderr, "Error post to influx: %v\n", err)
haveIFdb = false
} else {
respi.Body.Close()
}
} else {
if *verbose {
fmt.Fprintln(os.Stderr, "no post")
}
checkForInflux()
}
}
}
func connectSubscribe(client mqtt.Client) {
qos := 0
for {
if token := client.Connect(); token.Wait() && token.Error() == nil {
break
}
time.Sleep(5 * time.Second)
}
// ----------------------------------------
// subject smr5 will depend on smr2mqtt choice, of cource
var sub0 = "/smr5/#"
if token := client.Subscribe(sub0, byte(qos), func(client mqtt.Client, msg mqtt.Message) {
parts := strings.Split(msg.Topic(),"/")
ifch <-fmt.Sprintf("smr5 %s=%s",parts[3],string(msg.Payload()))
}); token.Wait() && token.Error() != nil {
fmt.Println(token.Error())
os.Exit(1)
}
if *verbose {
log.Println("subscribed " + sub0)
}
}
func main() {
var client mqtt.Client
flag.Parse()
rand.Seed(time.Now().UTC().UnixNano())
opts := mqtt.NewClientOptions()
opts.AddBroker(*broker)
rand.Seed(time.Now().UTC().UnixNano())
opts.SetClientID(fmt.Sprintf("%d",20000 + rand.Intn(10000)))
opts.SetCleanSession(true)
checkForInflux()
opts = opts.SetOnConnectHandler(func(client mqtt.Client) {
log.Printf("connected to: " + *broker + ".\n")
})
opts = opts.SetConnectionLostHandler(func(client mqtt.Client, err error) {
log.Printf("connection lost: %v.\n", err)
connectSubscribe(client)
})
// wait a while; seems mosquitto needs time to get ready
time.Sleep(3 * time.Second)
client = mqtt.NewClient(opts)
connectSubscribe(client)
sig := make(chan os.Signal,8)
signal.Notify(sig, os.Interrupt, syscall.SIGINT, syscall.SIGTERM, syscall.SIGKILL)
s := <-sig
fmt.Printf("\x1b[2KSignal: %v\n", s)
client.Disconnect(250)
}

View file

@ -0,0 +1,14 @@
[Unit]
Description=MQTT reflector
After=network.target
[Service]
ExecStart=/usr/local/bin/mqifproxy
WorkingDirectory=/tmp
StandardOutput=inherit
StandardError=inherit
Restart=always
User=root
[Install]
WantedBy=multi-user.target

15
smr2mqtt/go.mod Normal file
View file

@ -0,0 +1,15 @@
module github.com/tomdemeyer/smr2mqtt
go 1.18
require (
github.com/eclipse/paho.mqtt.golang v1.4.1
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07
)
require (
github.com/gorilla/websocket v1.4.2 // indirect
golang.org/x/net v0.0.0-20200425230154-ff2c4b7c35a0 // indirect
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd // indirect
)

15
smr2mqtt/go.sum Normal file
View file

@ -0,0 +1,15 @@
github.com/eclipse/paho.mqtt.golang v1.4.1 h1:tUSpviiL5G3P9SZZJPC4ZULZJsxQKXxfENpMvdbAXAI=
github.com/eclipse/paho.mqtt.golang v1.4.1/go.mod h1:JGt0RsEwEX+Xa/agj90YJ9d9DH2b7upDZMK9HRbFvCA=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07 h1:UyzmZLoiDWMRywV4DUYb9Fbt8uiOSooupjTq10vpvnU=
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/net v0.0.0-20200425230154-ff2c4b7c35a0 h1:Jcxah/M+oLZ/R4/z5RzfPzGbPXnVDPkEDtf2JnuxN+U=
golang.org/x/net v0.0.0-20200425230154-ff2c4b7c35a0/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd h1:xhmwyvizuTgC2qz7ZlMluP20uW+C3Rm0FD/WLDX8884=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=

14
smr2mqtt/p1.service Executable file
View file

@ -0,0 +1,14 @@
[Unit]
Description=SMR5 P1 Logger
After=network.target
[Service]
ExecStart=/usr/local/bin/smr2mqtt
WorkingDirectory=/tmp
StandardOutput=inherit
StandardError=inherit
Restart=always
User=root
[Install]
WantedBy=multi-user.target

237
smr2mqtt/smr2mqtt.go Normal file
View file

@ -0,0 +1,237 @@
package main
import (
"bufio"
_ "expvar"
"flag"
"fmt"
"log"
"math/rand"
"net/http"
"os"
"os/signal"
"regexp"
"syscall"
"time"
mqtt "github.com/eclipse/paho.mqtt.golang"
"github.com/tarm/serial"
)
const (
wait_TellerDemandDal uint32 = iota
wait_TellerDemandPiek
wait_TellerSupplyDal
wait_TellerSupplyPiek
wait_Tariff
wait_DemandWatt
wait_SupplyWatt
wait_Volt
)
// selection of data that we're interested in
var re_Teller1Demand = regexp.MustCompile(`^1-0:1\.8\.1\(([\d\.]+)\*kWh\)`)
var re_Teller2Demand = regexp.MustCompile(`^1-0:1\.8\.2\(([\d\.]+)\*kWh\)`)
var re_Teller1Supply = regexp.MustCompile(`^1-0:2\.8\.1\(([\d\.]+)\*kWh\)`)
var re_Teller2Supply = regexp.MustCompile(`^1-0:2\.8\.2\(([\d\.]+)\*kWh\)`)
var re_Tariff = regexp.MustCompile(`^0-0:96\.14\.0\(([\d]+)\)`)
var re_DemandWatt = regexp.MustCompile(`^1-0:1\.7\.0\(([\d\.]+)\*kW\)`)
var re_SupplyWatt = regexp.MustCompile(`^1-0:2\.7\.0\(([\d\.]+)\*kW\)`)
var re_Volt = regexp.MustCompile(`^1-0:32\.7\.0\(([\d\.]+)\*V\)`)
var state = wait_TellerDemandDal
var heartbeat uint64 = 1
var currentDemandWatt string = ""
var currentSupplyWatt string = ""
var currentVolt string = ""
var currentTeller1 string = ""
var currentTeller2 string = ""
var currentSupply1 string = ""
var currentSupply2 string = ""
var currentTariff string = ""
// MQTT_SERVER = "tcp://192.168.178.8:1883"
var broker = flag.String("mqtt", os.Getenv("MQTT_SERVER"), "the broker protocol://ip:port; protocol can be 'ws' or 'tcp'.")
var prefix = flag.String("pref", "/pm", "the 'home' prefix to the smr5 p1 message updates.")
var sendRaw = flag.Bool("raw", false, "send all P1 lines to /smr5/raw.")
var sendheartbeat = flag.Bool("hb", false, "send heartbeat.")
func main() {
flag.Parse()
rand.Seed(time.Now().UTC().UnixNano())
opts := mqtt.NewClientOptions()
opts.AddBroker(*broker)
opts.SetClientID(string(20000 + rand.Intn(10000)))
opts.SetCleanSession(true)
opts = opts.SetOnConnectHandler(func(client mqtt.Client) {
log.Printf("MQTT connected to: " + *broker + ".\n")
})
opts = opts.SetConnectionLostHandler(func(client mqtt.Client, err error) {
log.Printf("MQTT connection lost: %v.\n", err)
})
// "expvar" memory stats server at: http://<ip>: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

BIN
solaredge/.DS_Store vendored Normal file

Binary file not shown.

9
solaredge/go.mod Normal file
View file

@ -0,0 +1,9 @@
module github.com/tomdemeyer/solaredge
go 1.18
require (
github.com/Jeffail/gabs v1.4.0
github.com/davecgh/go-spew v1.1.1
github.com/robfig/cron/v3 v3.0.1
)

6
solaredge/go.sum Normal file
View file

@ -0,0 +1,6 @@
github.com/Jeffail/gabs v1.4.0 h1://5fYRRTq1edjfIrQGvdkcd22pkYUrHZ5YC/H2GJVAo=
github.com/Jeffail/gabs v1.4.0/go.mod h1:6xMvQMK4k33lb7GUUpaAPh6nKMmemQeg5d4gn7/bOXc=
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/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=

167
solaredge/solar.go Normal file
View file

@ -0,0 +1,167 @@
// The MIT License (MIT)
// Copyright © 2022 <Tom Demeyer>
// 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)
}
}
}

View file

@ -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