├── .github └── workflows │ └── go.yml ├── .gitignore ├── LICENSE ├── README.md ├── build_and_copy.sh ├── go.mod ├── go.sum ├── grafana └── dashboard.json ├── main.go ├── measurement.go ├── media ├── dashboard.png ├── reader.jpg ├── schaltkasten.jpg └── smartmeter.jpg ├── obis_parser.go ├── obis_parser_test.go ├── persist.go ├── persist_test.go ├── prom.go └── smartmeter.service /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | on: [push] 3 | jobs: 4 | 5 | build: 6 | name: Build 7 | runs-on: ubuntu-latest 8 | steps: 9 | 10 | - name: Set up Go 1.12 11 | uses: actions/setup-go@v1 12 | with: 13 | go-version: 1.12 14 | id: go 15 | 16 | - name: Check out code into the Go module directory 17 | uses: actions/checkout@v1 18 | 19 | - name: Get dependencies 20 | run: | 21 | go get -v -t -d ./... 22 | if [ -f Gopkg.toml ]; then 23 | curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh 24 | dep ensure 25 | fi 26 | 27 | - name: Build 28 | run: go build -v . 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Go template 3 | # Binaries for programs and plugins 4 | *.exe 5 | *.exe~ 6 | *.dll 7 | *.so 8 | *.dylib 9 | 10 | # Test binary, build with `go test -c` 11 | *.test 12 | 13 | # Output of the go coverage tool, specifically when used with LiteIDE 14 | *.out 15 | smartmeter 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This is free and unencumbered software released into the public domain. 2 | 3 | Anyone is free to copy, modify, publish, use, compile, sell, or 4 | distribute this software, either in source code form or as a compiled 5 | binary, for any purpose, commercial or non-commercial, and by any 6 | means. 7 | 8 | In jurisdictions that recognize copyright laws, the author or authors 9 | of this software dedicate any and all copyright interest in the 10 | software to the public domain. We make this dedication for the benefit 11 | of the public at large and to the detriment of our heirs and 12 | successors. We intend this dedication to be an overt act of 13 | relinquishment in perpetuity of all present and future rights to this 14 | software under copyright law. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR 20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | 24 | For more information, please refer to 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Smartmeter 2 | Monitor an electricity smart meter (OBIS) with prometheus and persist to a databse 3 | 4 | ![Image of Grafana Dashboard](media/dashboard.png) 5 | 6 | ## Setup 7 | ### What you need 8 | - A digital electricity meter with data interface 9 | ![](media/smartmeter.jpg) 10 | - A IR reader with USB ([ebay example device](https://www.ebay.de/itm/USB-IR-Infrarot-Lese-Schreibkopf-fuer-Stromzaehler-Smart-Meter-/273204540009)) 11 | ![](media/reader.jpg) 12 | - A Raspberry Pi (I use a Zero W in my setup) 13 | - Another Raspberry pi or other server able to run Grafana and Prometheus 14 | 15 | ### Final setup 16 | ![](media/schaltkasten.jpg) 17 | 18 | ## Run app 19 | 20 | #### Compile 21 | 22 | ```bash 23 | # for your current machine 24 | $ go build -o smartmeter 25 | 26 | # for Raspberry Pi zero w 27 | $ env GOOS=linux GOARCH=arm GOARM=6 go build -o smartmeter 28 | ``` 29 | 30 | #### Run 31 | 32 | ```bash 33 | Usage of ./smartmeter: 34 | --db-flush duration Flush after duration (default 1m0s) 35 | --db-host string Db host (default "localhost") 36 | --db-name string Db name (default "root") 37 | --db-password string Db password (default "root") 38 | --db-port string Db port (default "5432") 39 | --db-user string Db user (default "postgres") 40 | --http-port string Port for http server serving prometheus endpoint (default "8080") 41 | --persist string type of persistence: [none, postgres] (default "none") 42 | --reader-port string Device name of reader (default "/dev/ttyUSB0") 43 | ``` -------------------------------------------------------------------------------- /build_and_copy.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -ex 3 | 4 | TARGET=192.168.2.38 5 | FILE=smartmeter 6 | 7 | env GOOS=linux GOARCH=arm GOARM=6 go build -ldflags "-X main.gitSha=$(git rev-parse --short HEAD)" -o $FILE 8 | 9 | ssh root@$TARGET service $FILE stop 10 | scp $FILE root@$TARGET:/usr/bin/ 11 | ssh root@$TARGET service $FILE start 12 | 13 | rm $FILE 14 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module smartmeter 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/creack/goselect v0.1.1 // indirect 7 | github.com/jmoiron/sqlx v1.2.0 8 | github.com/lib/pq v1.3.0 9 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 10 | github.com/modern-go/reflect2 v1.0.1 // indirect 11 | github.com/pkg/errors v0.8.1 12 | github.com/prometheus/client_golang v1.3.0 13 | github.com/spf13/pflag v1.0.5 14 | github.com/stretchr/testify v1.3.0 15 | go.bug.st/serial.v1 v0.0.0-20191202182710-24a6610f0541 16 | golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8 // indirect 17 | google.golang.org/appengine v1.4.0 // indirect 18 | ) 19 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= 2 | github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= 3 | github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= 4 | github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= 5 | github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973 h1:xJ4a3vCFaGF/jqvzLMYoU8P317H5OQ+Via4RmuPwCS0= 6 | github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= 7 | github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= 8 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 9 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 10 | github.com/cespare/xxhash/v2 v2.1.1 h1:6MnRN8NT7+YBpUIWxHtefFZOKTAPgGjpQSxqLNn0+qY= 11 | github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 12 | github.com/creack/goselect v0.1.1 h1:tiSSgKE1eJtxs1h/VgGQWuXUP0YS4CDIFMp6vaI1ls0= 13 | github.com/creack/goselect v0.1.1/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY= 14 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 15 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 16 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 17 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 18 | github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= 19 | github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= 20 | github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= 21 | github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= 22 | github.com/go-sql-driver/mysql v1.4.0 h1:7LxgVwFb2hIQtMm87NdgAVfXjnt4OePseqT1tKx+opk= 23 | github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= 24 | github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= 25 | github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= 26 | github.com/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= 27 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 28 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 29 | github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs= 30 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 31 | github.com/google/go-cmp v0.3.1 h1:Xye71clBPdm5HgqGwUkwhbynsUJZhDbS20FvLhQ2izg= 32 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 33 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 34 | github.com/jmoiron/sqlx v1.2.0 h1:41Ip0zITnmWNR/vHV+S4m+VoUivnWY5E4OJfLZjCJMA= 35 | github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks= 36 | github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= 37 | github.com/json-iterator/go v1.1.8/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= 38 | github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= 39 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 40 | github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= 41 | github.com/lib/pq v1.0.0 h1:X5PMW56eZitiTeO7tKzZxFCSpbFZJtkMMooicw2us9A= 42 | github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 43 | github.com/lib/pq v1.3.0 h1:/qkRGz8zljWiDcFvgpwUpwIAPu3r07TDvs3Rws+o/pU= 44 | github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 45 | github.com/mattn/go-sqlite3 v1.9.0 h1:pDRiWfl+++eC2FEFRy6jXmQlvp4Yh3z1MJKg4UeYM/4= 46 | github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= 47 | github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= 48 | github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= 49 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 50 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 51 | github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 52 | github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 53 | github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= 54 | github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 55 | github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= 56 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 57 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 58 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 59 | github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= 60 | github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= 61 | github.com/prometheus/client_golang v1.3.0 h1:miYCvYqFXtl/J9FIy8eNpBfYthAEFg+Ys0XyUVEcDsc= 62 | github.com/prometheus/client_golang v1.3.0/go.mod h1:hJaj2vgQTGQmVCsAACORcieXFeDPbaTKGT+JTgUa3og= 63 | github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910 h1:idejC8f05m9MGOsuEi1ATq9shN03HrxNkD/luQvxCv8= 64 | github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= 65 | github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 66 | github.com/prometheus/client_model v0.1.0 h1:ElTg5tNp4DqfV7UQjDqv2+RJlNzsDtvNAWccbItceIE= 67 | github.com/prometheus/client_model v0.1.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 68 | github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= 69 | github.com/prometheus/common v0.7.0 h1:L+1lyG48J1zAQXA3RBX/nG/B3gjlHq0zTt2tlbJLyCY= 70 | github.com/prometheus/common v0.7.0/go.mod h1:DjGbpBbp5NYNiECxcL/VnbXCCaQpKd3tt26CguLLsqA= 71 | github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= 72 | github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= 73 | github.com/prometheus/procfs v0.0.8 h1:+fpWZdT24pJBiqJdAwYBjPSk+5YmQzYNPYzQsdzLkt8= 74 | github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= 75 | github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= 76 | github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= 77 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 78 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 79 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 80 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 81 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 82 | github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= 83 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 84 | go.bug.st/serial.v1 v0.0.0-20191202182710-24a6610f0541 h1:eQfoPfT+gNSh63t/oKanQlZyKgblRa/LMZRPIT+MHzA= 85 | go.bug.st/serial.v1 v0.0.0-20191202182710-24a6610f0541/go.mod h1:dRSl/CVCTf56CkXgJMDOdSwNfo2g1orOGE/gBGdvjZw= 86 | golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 87 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 88 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 89 | golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 90 | golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 91 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f h1:Bl/8QSvNqXvPGPGXa2z5xUTmV7VDcZyvRZ+QQXkXTZQ= 92 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 93 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 94 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 95 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 96 | golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 97 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 98 | golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 99 | golang.org/x/sys v0.0.0-20191220142924-d4481acd189f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 100 | golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8 h1:JA8d3MPx/IToSyXZG/RhwYEtfrKO1Fxrqe8KrkiLXKM= 101 | golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 102 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 103 | google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508= 104 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 105 | gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= 106 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 107 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 108 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 109 | -------------------------------------------------------------------------------- /grafana/dashboard.json: -------------------------------------------------------------------------------- 1 | { 2 | "annotations": { 3 | "list": [ 4 | { 5 | "builtIn": 1, 6 | "datasource": "-- Grafana --", 7 | "enable": true, 8 | "hide": true, 9 | "iconColor": "rgba(0, 211, 255, 1)", 10 | "name": "Annotations & Alerts", 11 | "type": "dashboard" 12 | } 13 | ] 14 | }, 15 | "editable": true, 16 | "gnetId": null, 17 | "graphTooltip": 1, 18 | "id": 16, 19 | "iteration": 1551543662001, 20 | "links": [], 21 | "panels": [ 22 | { 23 | "collapsed": false, 24 | "gridPos": { 25 | "h": 1, 26 | "w": 24, 27 | "x": 0, 28 | "y": 0 29 | }, 30 | "id": 26, 31 | "panels": [], 32 | "title": "Summary", 33 | "type": "row" 34 | }, 35 | { 36 | "cacheTimeout": null, 37 | "colorBackground": false, 38 | "colorValue": false, 39 | "colors": [ 40 | "#299c46", 41 | "rgba(237, 129, 40, 0.89)", 42 | "#d44a3a" 43 | ], 44 | "decimals": 3, 45 | "format": "none", 46 | "gauge": { 47 | "maxValue": 100, 48 | "minValue": 0, 49 | "show": false, 50 | "thresholdLabels": false, 51 | "thresholdMarkers": true 52 | }, 53 | "gridPos": { 54 | "h": 3, 55 | "w": 3, 56 | "x": 0, 57 | "y": 1 58 | }, 59 | "id": 31, 60 | "interval": null, 61 | "links": [], 62 | "mappingType": 1, 63 | "mappingTypes": [ 64 | { 65 | "name": "value to text", 66 | "value": 1 67 | }, 68 | { 69 | "name": "range to text", 70 | "value": 2 71 | } 72 | ], 73 | "maxDataPoints": 100, 74 | "nullPointMode": "connected", 75 | "nullText": null, 76 | "postfix": " kWh", 77 | "postfixFontSize": "30%", 78 | "prefix": "", 79 | "prefixFontSize": "50%", 80 | "rangeMaps": [ 81 | { 82 | "from": "null", 83 | "text": "N/A", 84 | "to": "null" 85 | } 86 | ], 87 | "sparkline": { 88 | "fillColor": "rgba(31, 118, 189, 0.18)", 89 | "full": false, 90 | "lineColor": "rgb(31, 120, 193)", 91 | "show": false 92 | }, 93 | "tableColumn": "", 94 | "targets": [ 95 | { 96 | "expr": "smart_meter_current_total_reading", 97 | "format": "time_series", 98 | "instant": true, 99 | "intervalFactor": 1, 100 | "legendFormat": "", 101 | "refId": "A" 102 | } 103 | ], 104 | "thresholds": "", 105 | "timeFrom": null, 106 | "timeShift": null, 107 | "title": "Total Consumed", 108 | "type": "singlestat", 109 | "valueFontSize": "80%", 110 | "valueMaps": [ 111 | { 112 | "op": "=", 113 | "text": "N/A", 114 | "value": "null" 115 | } 116 | ], 117 | "valueName": "current" 118 | }, 119 | { 120 | "cacheTimeout": null, 121 | "colorBackground": false, 122 | "colorValue": false, 123 | "colors": [ 124 | "#299c46", 125 | "rgba(237, 129, 40, 0.89)", 126 | "#d44a3a" 127 | ], 128 | "decimals": 2, 129 | "format": "currencyEUR", 130 | "gauge": { 131 | "maxValue": 100, 132 | "minValue": 0, 133 | "show": false, 134 | "thresholdLabels": false, 135 | "thresholdMarkers": true 136 | }, 137 | "gridPos": { 138 | "h": 3, 139 | "w": 3, 140 | "x": 3, 141 | "y": 1 142 | }, 143 | "id": 44, 144 | "interval": null, 145 | "links": [], 146 | "mappingType": 1, 147 | "mappingTypes": [ 148 | { 149 | "name": "value to text", 150 | "value": 1 151 | }, 152 | { 153 | "name": "range to text", 154 | "value": 2 155 | } 156 | ], 157 | "maxDataPoints": 100, 158 | "nullPointMode": "connected", 159 | "nullText": null, 160 | "postfix": " ", 161 | "postfixFontSize": "30%", 162 | "prefix": "", 163 | "prefixFontSize": "50%", 164 | "rangeMaps": [ 165 | { 166 | "from": "null", 167 | "text": "N/A", 168 | "to": "null" 169 | } 170 | ], 171 | "sparkline": { 172 | "fillColor": "rgba(31, 118, 189, 0.18)", 173 | "full": false, 174 | "lineColor": "rgb(31, 120, 193)", 175 | "show": false 176 | }, 177 | "tableColumn": "", 178 | "targets": [ 179 | { 180 | "expr": "delta(smart_meter_current_total_reading[3w]) * 0.2738", 181 | "format": "time_series", 182 | "instant": true, 183 | "intervalFactor": 1, 184 | "legendFormat": "", 185 | "refId": "A" 186 | } 187 | ], 188 | "thresholds": "", 189 | "timeFrom": null, 190 | "timeShift": null, 191 | "title": "Cost 3 weeks", 192 | "type": "singlestat", 193 | "valueFontSize": "80%", 194 | "valueMaps": [ 195 | { 196 | "op": "=", 197 | "text": "N/A", 198 | "value": "null" 199 | } 200 | ], 201 | "valueName": "current" 202 | }, 203 | { 204 | "cacheTimeout": null, 205 | "colorBackground": false, 206 | "colorValue": false, 207 | "colors": [ 208 | "#299c46", 209 | "rgba(237, 129, 40, 0.89)", 210 | "#d44a3a" 211 | ], 212 | "format": "watt", 213 | "gauge": { 214 | "maxValue": 5000, 215 | "minValue": 0, 216 | "show": true, 217 | "thresholdLabels": false, 218 | "thresholdMarkers": true 219 | }, 220 | "gridPos": { 221 | "h": 9, 222 | "w": 5, 223 | "x": 6, 224 | "y": 1 225 | }, 226 | "id": 4, 227 | "interval": null, 228 | "links": [], 229 | "mappingType": 1, 230 | "mappingTypes": [ 231 | { 232 | "name": "value to text", 233 | "value": 1 234 | }, 235 | { 236 | "name": "range to text", 237 | "value": 2 238 | } 239 | ], 240 | "maxDataPoints": 100, 241 | "nullPointMode": "connected", 242 | "nullText": null, 243 | "postfix": "", 244 | "postfixFontSize": "50%", 245 | "prefix": "", 246 | "prefixFontSize": "50%", 247 | "rangeMaps": [ 248 | { 249 | "from": "null", 250 | "text": "N/A", 251 | "to": "null" 252 | } 253 | ], 254 | "sparkline": { 255 | "fillColor": "rgba(31, 118, 189, 0.18)", 256 | "full": false, 257 | "lineColor": "rgb(31, 120, 193)", 258 | "show": false 259 | }, 260 | "tableColumn": "", 261 | "targets": [ 262 | { 263 | "expr": "smart_meter_current_total_power", 264 | "format": "time_series", 265 | "instant": true, 266 | "intervalFactor": 1, 267 | "legendFormat": "", 268 | "refId": "A" 269 | } 270 | ], 271 | "thresholds": "500,3000", 272 | "title": "Current Power", 273 | "type": "singlestat", 274 | "valueFontSize": "80%", 275 | "valueMaps": [ 276 | { 277 | "op": "=", 278 | "text": "N/A", 279 | "value": "null" 280 | } 281 | ], 282 | "valueName": "avg" 283 | }, 284 | { 285 | "aliasColors": {}, 286 | "bars": false, 287 | "dashLength": 10, 288 | "dashes": false, 289 | "fill": 1, 290 | "gridPos": { 291 | "h": 9, 292 | "w": 13, 293 | "x": 11, 294 | "y": 1 295 | }, 296 | "id": 20, 297 | "legend": { 298 | "avg": false, 299 | "current": false, 300 | "max": true, 301 | "min": false, 302 | "show": true, 303 | "total": false, 304 | "values": true 305 | }, 306 | "lines": true, 307 | "linewidth": 1, 308 | "links": [], 309 | "nullPointMode": "null", 310 | "paceLength": 10, 311 | "percentage": false, 312 | "pointradius": 5, 313 | "points": false, 314 | "renderer": "flot", 315 | "seriesOverrides": [], 316 | "spaceLength": 10, 317 | "stack": true, 318 | "steppedLine": false, 319 | "targets": [ 320 | { 321 | "expr": "smart_meter_power{phase!=\"all\"}", 322 | "format": "time_series", 323 | "intervalFactor": 1, 324 | "legendFormat": "{{phase}}", 325 | "refId": "A" 326 | } 327 | ], 328 | "thresholds": [], 329 | "timeFrom": null, 330 | "timeRegions": [], 331 | "timeShift": null, 332 | "title": "Power per Phase", 333 | "tooltip": { 334 | "shared": true, 335 | "sort": 0, 336 | "value_type": "individual" 337 | }, 338 | "type": "graph", 339 | "xaxis": { 340 | "buckets": null, 341 | "mode": "time", 342 | "name": null, 343 | "show": true, 344 | "values": [] 345 | }, 346 | "yaxes": [ 347 | { 348 | "decimals": null, 349 | "format": "watt", 350 | "label": null, 351 | "logBase": 1, 352 | "max": null, 353 | "min": null, 354 | "show": true 355 | }, 356 | { 357 | "format": "short", 358 | "label": null, 359 | "logBase": 1, 360 | "max": null, 361 | "min": null, 362 | "show": false 363 | } 364 | ], 365 | "yaxis": { 366 | "align": false, 367 | "alignLevel": null 368 | } 369 | }, 370 | { 371 | "cacheTimeout": null, 372 | "colorBackground": false, 373 | "colorValue": false, 374 | "colors": [ 375 | "#299c46", 376 | "rgba(237, 129, 40, 0.89)", 377 | "#d44a3a" 378 | ], 379 | "decimals": 3, 380 | "format": "none", 381 | "gauge": { 382 | "maxValue": 100, 383 | "minValue": 0, 384 | "show": false, 385 | "thresholdLabels": false, 386 | "thresholdMarkers": true 387 | }, 388 | "gridPos": { 389 | "h": 3, 390 | "w": 3, 391 | "x": 0, 392 | "y": 4 393 | }, 394 | "id": 32, 395 | "interval": null, 396 | "links": [], 397 | "mappingType": 1, 398 | "mappingTypes": [ 399 | { 400 | "name": "value to text", 401 | "value": 1 402 | }, 403 | { 404 | "name": "range to text", 405 | "value": 2 406 | } 407 | ], 408 | "maxDataPoints": 100, 409 | "nullPointMode": "connected", 410 | "nullText": null, 411 | "postfix": " kWh", 412 | "postfixFontSize": "30%", 413 | "prefix": "", 414 | "prefixFontSize": "50%", 415 | "rangeMaps": [ 416 | { 417 | "from": "null", 418 | "text": "N/A", 419 | "to": "null" 420 | } 421 | ], 422 | "sparkline": { 423 | "fillColor": "rgba(31, 118, 189, 0.18)", 424 | "full": false, 425 | "lineColor": "rgb(31, 120, 193)", 426 | "show": false 427 | }, 428 | "tableColumn": "", 429 | "targets": [ 430 | { 431 | "expr": "delta(smart_meter_current_total_reading[1w])", 432 | "format": "time_series", 433 | "instant": true, 434 | "intervalFactor": 1, 435 | "legendFormat": "", 436 | "refId": "A" 437 | } 438 | ], 439 | "thresholds": "", 440 | "timeFrom": null, 441 | "timeShift": null, 442 | "title": "Consumed last week", 443 | "type": "singlestat", 444 | "valueFontSize": "80%", 445 | "valueMaps": [ 446 | { 447 | "op": "=", 448 | "text": "N/A", 449 | "value": "null" 450 | } 451 | ], 452 | "valueName": "current" 453 | }, 454 | { 455 | "cacheTimeout": null, 456 | "colorBackground": false, 457 | "colorValue": false, 458 | "colors": [ 459 | "#299c46", 460 | "rgba(237, 129, 40, 0.89)", 461 | "#d44a3a" 462 | ], 463 | "decimals": 2, 464 | "format": "currencyEUR", 465 | "gauge": { 466 | "maxValue": 100, 467 | "minValue": 0, 468 | "show": false, 469 | "thresholdLabels": false, 470 | "thresholdMarkers": true 471 | }, 472 | "gridPos": { 473 | "h": 3, 474 | "w": 3, 475 | "x": 3, 476 | "y": 4 477 | }, 478 | "id": 42, 479 | "interval": null, 480 | "links": [], 481 | "mappingType": 1, 482 | "mappingTypes": [ 483 | { 484 | "name": "value to text", 485 | "value": 1 486 | }, 487 | { 488 | "name": "range to text", 489 | "value": 2 490 | } 491 | ], 492 | "maxDataPoints": 100, 493 | "nullPointMode": "connected", 494 | "nullText": null, 495 | "postfix": " ", 496 | "postfixFontSize": "30%", 497 | "prefix": "", 498 | "prefixFontSize": "50%", 499 | "rangeMaps": [ 500 | { 501 | "from": "null", 502 | "text": "N/A", 503 | "to": "null" 504 | } 505 | ], 506 | "sparkline": { 507 | "fillColor": "rgba(31, 118, 189, 0.18)", 508 | "full": false, 509 | "lineColor": "rgb(31, 120, 193)", 510 | "show": false 511 | }, 512 | "tableColumn": "", 513 | "targets": [ 514 | { 515 | "expr": "delta(smart_meter_current_total_reading[1w]) * 0.2738", 516 | "format": "time_series", 517 | "instant": true, 518 | "intervalFactor": 1, 519 | "legendFormat": "", 520 | "refId": "A" 521 | } 522 | ], 523 | "thresholds": "", 524 | "timeFrom": null, 525 | "timeShift": null, 526 | "title": "Cost last week", 527 | "type": "singlestat", 528 | "valueFontSize": "80%", 529 | "valueMaps": [ 530 | { 531 | "op": "=", 532 | "text": "N/A", 533 | "value": "null" 534 | } 535 | ], 536 | "valueName": "current" 537 | }, 538 | { 539 | "cacheTimeout": null, 540 | "colorBackground": false, 541 | "colorValue": false, 542 | "colors": [ 543 | "#299c46", 544 | "rgba(237, 129, 40, 0.89)", 545 | "#d44a3a" 546 | ], 547 | "decimals": 3, 548 | "format": "none", 549 | "gauge": { 550 | "maxValue": 100, 551 | "minValue": 0, 552 | "show": false, 553 | "thresholdLabels": false, 554 | "thresholdMarkers": true 555 | }, 556 | "gridPos": { 557 | "h": 3, 558 | "w": 3, 559 | "x": 0, 560 | "y": 7 561 | }, 562 | "id": 2, 563 | "interval": null, 564 | "links": [], 565 | "mappingType": 1, 566 | "mappingTypes": [ 567 | { 568 | "name": "value to text", 569 | "value": 1 570 | }, 571 | { 572 | "name": "range to text", 573 | "value": 2 574 | } 575 | ], 576 | "maxDataPoints": 100, 577 | "nullPointMode": "connected", 578 | "nullText": null, 579 | "postfix": " kWh", 580 | "postfixFontSize": "30%", 581 | "prefix": "", 582 | "prefixFontSize": "50%", 583 | "rangeMaps": [ 584 | { 585 | "from": "null", 586 | "text": "N/A", 587 | "to": "null" 588 | } 589 | ], 590 | "sparkline": { 591 | "fillColor": "rgba(31, 118, 189, 0.18)", 592 | "full": false, 593 | "lineColor": "rgb(31, 120, 193)", 594 | "show": false 595 | }, 596 | "tableColumn": "", 597 | "targets": [ 598 | { 599 | "expr": "delta(smart_meter_current_total_reading[24h])", 600 | "format": "time_series", 601 | "instant": true, 602 | "intervalFactor": 1, 603 | "legendFormat": "", 604 | "refId": "A" 605 | } 606 | ], 607 | "thresholds": "", 608 | "timeFrom": null, 609 | "timeShift": null, 610 | "title": "Consumed last 24h", 611 | "type": "singlestat", 612 | "valueFontSize": "80%", 613 | "valueMaps": [ 614 | { 615 | "op": "=", 616 | "text": "N/A", 617 | "value": "null" 618 | } 619 | ], 620 | "valueName": "current" 621 | }, 622 | { 623 | "cacheTimeout": null, 624 | "colorBackground": false, 625 | "colorValue": false, 626 | "colors": [ 627 | "#299c46", 628 | "rgba(237, 129, 40, 0.89)", 629 | "#d44a3a" 630 | ], 631 | "decimals": 2, 632 | "format": "currencyEUR", 633 | "gauge": { 634 | "maxValue": 100, 635 | "minValue": 0, 636 | "show": false, 637 | "thresholdLabels": false, 638 | "thresholdMarkers": true 639 | }, 640 | "gridPos": { 641 | "h": 3, 642 | "w": 3, 643 | "x": 3, 644 | "y": 7 645 | }, 646 | "id": 43, 647 | "interval": null, 648 | "links": [], 649 | "mappingType": 1, 650 | "mappingTypes": [ 651 | { 652 | "name": "value to text", 653 | "value": 1 654 | }, 655 | { 656 | "name": "range to text", 657 | "value": 2 658 | } 659 | ], 660 | "maxDataPoints": 100, 661 | "nullPointMode": "connected", 662 | "nullText": null, 663 | "postfix": " ", 664 | "postfixFontSize": "30%", 665 | "prefix": "", 666 | "prefixFontSize": "50%", 667 | "rangeMaps": [ 668 | { 669 | "from": "null", 670 | "text": "N/A", 671 | "to": "null" 672 | } 673 | ], 674 | "sparkline": { 675 | "fillColor": "rgba(31, 118, 189, 0.18)", 676 | "full": false, 677 | "lineColor": "rgb(31, 120, 193)", 678 | "show": false 679 | }, 680 | "tableColumn": "", 681 | "targets": [ 682 | { 683 | "expr": "delta(smart_meter_current_total_reading[1d]) * 0.2738", 684 | "format": "time_series", 685 | "instant": true, 686 | "intervalFactor": 1, 687 | "legendFormat": "", 688 | "refId": "A" 689 | } 690 | ], 691 | "thresholds": "", 692 | "timeFrom": null, 693 | "timeShift": null, 694 | "title": "Cost last 24h", 695 | "type": "singlestat", 696 | "valueFontSize": "80%", 697 | "valueMaps": [ 698 | { 699 | "op": "=", 700 | "text": "N/A", 701 | "value": "null" 702 | } 703 | ], 704 | "valueName": "current" 705 | }, 706 | { 707 | "collapsed": false, 708 | "gridPos": { 709 | "h": 1, 710 | "w": 24, 711 | "x": 0, 712 | "y": 10 713 | }, 714 | "id": 12, 715 | "panels": [], 716 | "title": "Power", 717 | "type": "row" 718 | }, 719 | { 720 | "cacheTimeout": null, 721 | "colorBackground": false, 722 | "colorValue": false, 723 | "colors": [ 724 | "#299c46", 725 | "rgba(237, 129, 40, 0.89)", 726 | "#d44a3a" 727 | ], 728 | "decimals": 2, 729 | "format": "watt", 730 | "gauge": { 731 | "maxValue": 3000, 732 | "minValue": 0, 733 | "show": true, 734 | "thresholdLabels": false, 735 | "thresholdMarkers": true 736 | }, 737 | "gridPos": { 738 | "h": 6, 739 | "w": 8, 740 | "x": 0, 741 | "y": 11 742 | }, 743 | "id": 10, 744 | "interval": null, 745 | "links": [], 746 | "mappingType": 1, 747 | "mappingTypes": [ 748 | { 749 | "name": "value to text", 750 | "value": 1 751 | }, 752 | { 753 | "name": "range to text", 754 | "value": 2 755 | } 756 | ], 757 | "maxDataPoints": 100, 758 | "nullPointMode": "connected", 759 | "nullText": null, 760 | "postfix": "", 761 | "postfixFontSize": "50%", 762 | "prefix": "", 763 | "prefixFontSize": "50%", 764 | "rangeMaps": [ 765 | { 766 | "from": "null", 767 | "text": "N/A", 768 | "to": "null" 769 | } 770 | ], 771 | "repeat": "Phase", 772 | "repeatDirection": "h", 773 | "scopedVars": { 774 | "Phase": { 775 | "selected": false, 776 | "text": "1", 777 | "value": "1" 778 | } 779 | }, 780 | "sparkline": { 781 | "fillColor": "rgba(31, 118, 189, 0.18)", 782 | "full": false, 783 | "lineColor": "rgb(31, 120, 193)", 784 | "show": false 785 | }, 786 | "tableColumn": "", 787 | "targets": [ 788 | { 789 | "expr": "smart_meter_power{phase=\"$Phase\"}", 790 | "format": "time_series", 791 | "instant": true, 792 | "intervalFactor": 1, 793 | "refId": "A" 794 | } 795 | ], 796 | "thresholds": "200,1500,", 797 | "title": "Power Phase $Phase", 798 | "type": "singlestat", 799 | "valueFontSize": "80%", 800 | "valueMaps": [ 801 | { 802 | "op": "=", 803 | "text": "N/A", 804 | "value": "null" 805 | } 806 | ], 807 | "valueName": "avg" 808 | }, 809 | { 810 | "cacheTimeout": null, 811 | "colorBackground": false, 812 | "colorValue": false, 813 | "colors": [ 814 | "#299c46", 815 | "rgba(237, 129, 40, 0.89)", 816 | "#d44a3a" 817 | ], 818 | "decimals": 2, 819 | "format": "watt", 820 | "gauge": { 821 | "maxValue": 3000, 822 | "minValue": 0, 823 | "show": true, 824 | "thresholdLabels": false, 825 | "thresholdMarkers": true 826 | }, 827 | "gridPos": { 828 | "h": 6, 829 | "w": 8, 830 | "x": 8, 831 | "y": 11 832 | }, 833 | "id": 45, 834 | "interval": null, 835 | "links": [], 836 | "mappingType": 1, 837 | "mappingTypes": [ 838 | { 839 | "name": "value to text", 840 | "value": 1 841 | }, 842 | { 843 | "name": "range to text", 844 | "value": 2 845 | } 846 | ], 847 | "maxDataPoints": 100, 848 | "nullPointMode": "connected", 849 | "nullText": null, 850 | "postfix": "", 851 | "postfixFontSize": "50%", 852 | "prefix": "", 853 | "prefixFontSize": "50%", 854 | "rangeMaps": [ 855 | { 856 | "from": "null", 857 | "text": "N/A", 858 | "to": "null" 859 | } 860 | ], 861 | "repeat": null, 862 | "repeatDirection": "h", 863 | "repeatIteration": 1551543662001, 864 | "repeatPanelId": 10, 865 | "scopedVars": { 866 | "Phase": { 867 | "selected": false, 868 | "text": "2", 869 | "value": "2" 870 | } 871 | }, 872 | "sparkline": { 873 | "fillColor": "rgba(31, 118, 189, 0.18)", 874 | "full": false, 875 | "lineColor": "rgb(31, 120, 193)", 876 | "show": false 877 | }, 878 | "tableColumn": "", 879 | "targets": [ 880 | { 881 | "expr": "smart_meter_power{phase=\"$Phase\"}", 882 | "format": "time_series", 883 | "instant": true, 884 | "intervalFactor": 1, 885 | "refId": "A" 886 | } 887 | ], 888 | "thresholds": "200,1500,", 889 | "title": "Power Phase $Phase", 890 | "type": "singlestat", 891 | "valueFontSize": "80%", 892 | "valueMaps": [ 893 | { 894 | "op": "=", 895 | "text": "N/A", 896 | "value": "null" 897 | } 898 | ], 899 | "valueName": "avg" 900 | }, 901 | { 902 | "cacheTimeout": null, 903 | "colorBackground": false, 904 | "colorValue": false, 905 | "colors": [ 906 | "#299c46", 907 | "rgba(237, 129, 40, 0.89)", 908 | "#d44a3a" 909 | ], 910 | "decimals": 2, 911 | "format": "watt", 912 | "gauge": { 913 | "maxValue": 3000, 914 | "minValue": 0, 915 | "show": true, 916 | "thresholdLabels": false, 917 | "thresholdMarkers": true 918 | }, 919 | "gridPos": { 920 | "h": 6, 921 | "w": 8, 922 | "x": 16, 923 | "y": 11 924 | }, 925 | "id": 46, 926 | "interval": null, 927 | "links": [], 928 | "mappingType": 1, 929 | "mappingTypes": [ 930 | { 931 | "name": "value to text", 932 | "value": 1 933 | }, 934 | { 935 | "name": "range to text", 936 | "value": 2 937 | } 938 | ], 939 | "maxDataPoints": 100, 940 | "nullPointMode": "connected", 941 | "nullText": null, 942 | "postfix": "", 943 | "postfixFontSize": "50%", 944 | "prefix": "", 945 | "prefixFontSize": "50%", 946 | "rangeMaps": [ 947 | { 948 | "from": "null", 949 | "text": "N/A", 950 | "to": "null" 951 | } 952 | ], 953 | "repeat": null, 954 | "repeatDirection": "h", 955 | "repeatIteration": 1551543662001, 956 | "repeatPanelId": 10, 957 | "scopedVars": { 958 | "Phase": { 959 | "selected": false, 960 | "text": "3", 961 | "value": "3" 962 | } 963 | }, 964 | "sparkline": { 965 | "fillColor": "rgba(31, 118, 189, 0.18)", 966 | "full": false, 967 | "lineColor": "rgb(31, 120, 193)", 968 | "show": false 969 | }, 970 | "tableColumn": "", 971 | "targets": [ 972 | { 973 | "expr": "smart_meter_power{phase=\"$Phase\"}", 974 | "format": "time_series", 975 | "instant": true, 976 | "intervalFactor": 1, 977 | "refId": "A" 978 | } 979 | ], 980 | "thresholds": "200,1500,", 981 | "title": "Power Phase $Phase", 982 | "type": "singlestat", 983 | "valueFontSize": "80%", 984 | "valueMaps": [ 985 | { 986 | "op": "=", 987 | "text": "N/A", 988 | "value": "null" 989 | } 990 | ], 991 | "valueName": "avg" 992 | }, 993 | { 994 | "collapsed": false, 995 | "gridPos": { 996 | "h": 1, 997 | "w": 24, 998 | "x": 0, 999 | "y": 17 1000 | }, 1001 | "id": 14, 1002 | "panels": [], 1003 | "title": "Voltage", 1004 | "type": "row" 1005 | }, 1006 | { 1007 | "cacheTimeout": null, 1008 | "colorBackground": false, 1009 | "colorValue": false, 1010 | "colors": [ 1011 | "#e24d42", 1012 | "#629e51", 1013 | "#d44a3a" 1014 | ], 1015 | "decimals": 1, 1016 | "format": "volt", 1017 | "gauge": { 1018 | "maxValue": 235, 1019 | "minValue": 225, 1020 | "show": true, 1021 | "thresholdLabels": false, 1022 | "thresholdMarkers": true 1023 | }, 1024 | "gridPos": { 1025 | "h": 6, 1026 | "w": 8, 1027 | "x": 0, 1028 | "y": 18 1029 | }, 1030 | "id": 6, 1031 | "interval": null, 1032 | "links": [], 1033 | "mappingType": 1, 1034 | "mappingTypes": [ 1035 | { 1036 | "name": "value to text", 1037 | "value": 1 1038 | }, 1039 | { 1040 | "name": "range to text", 1041 | "value": 2 1042 | } 1043 | ], 1044 | "maxDataPoints": 100, 1045 | "maxPerRow": 12, 1046 | "nullPointMode": "connected", 1047 | "nullText": null, 1048 | "postfix": "", 1049 | "postfixFontSize": "50%", 1050 | "prefix": "", 1051 | "prefixFontSize": "50%", 1052 | "rangeMaps": [ 1053 | { 1054 | "from": "null", 1055 | "text": "N/A", 1056 | "to": "null" 1057 | } 1058 | ], 1059 | "repeat": "Phase", 1060 | "repeatDirection": "h", 1061 | "scopedVars": { 1062 | "Phase": { 1063 | "selected": false, 1064 | "text": "1", 1065 | "value": "1" 1066 | } 1067 | }, 1068 | "sparkline": { 1069 | "fillColor": "rgba(31, 118, 189, 0.18)", 1070 | "full": false, 1071 | "lineColor": "rgb(31, 120, 193)", 1072 | "show": false 1073 | }, 1074 | "tableColumn": "", 1075 | "targets": [ 1076 | { 1077 | "expr": "smart_meter_voltage{phase=\"$Phase\"}", 1078 | "format": "time_series", 1079 | "instant": true, 1080 | "intervalFactor": 1, 1081 | "refId": "A" 1082 | } 1083 | ], 1084 | "thresholds": "228,232", 1085 | "title": "Voltage Phase $Phase", 1086 | "type": "singlestat", 1087 | "valueFontSize": "80%", 1088 | "valueMaps": [ 1089 | { 1090 | "op": "=", 1091 | "text": "N/A", 1092 | "value": "null" 1093 | } 1094 | ], 1095 | "valueName": "current" 1096 | }, 1097 | { 1098 | "cacheTimeout": null, 1099 | "colorBackground": false, 1100 | "colorValue": false, 1101 | "colors": [ 1102 | "#e24d42", 1103 | "#629e51", 1104 | "#d44a3a" 1105 | ], 1106 | "decimals": 1, 1107 | "format": "volt", 1108 | "gauge": { 1109 | "maxValue": 235, 1110 | "minValue": 225, 1111 | "show": true, 1112 | "thresholdLabels": false, 1113 | "thresholdMarkers": true 1114 | }, 1115 | "gridPos": { 1116 | "h": 6, 1117 | "w": 8, 1118 | "x": 8, 1119 | "y": 18 1120 | }, 1121 | "id": 47, 1122 | "interval": null, 1123 | "links": [], 1124 | "mappingType": 1, 1125 | "mappingTypes": [ 1126 | { 1127 | "name": "value to text", 1128 | "value": 1 1129 | }, 1130 | { 1131 | "name": "range to text", 1132 | "value": 2 1133 | } 1134 | ], 1135 | "maxDataPoints": 100, 1136 | "maxPerRow": 12, 1137 | "nullPointMode": "connected", 1138 | "nullText": null, 1139 | "postfix": "", 1140 | "postfixFontSize": "50%", 1141 | "prefix": "", 1142 | "prefixFontSize": "50%", 1143 | "rangeMaps": [ 1144 | { 1145 | "from": "null", 1146 | "text": "N/A", 1147 | "to": "null" 1148 | } 1149 | ], 1150 | "repeat": null, 1151 | "repeatDirection": "h", 1152 | "repeatIteration": 1551543662001, 1153 | "repeatPanelId": 6, 1154 | "scopedVars": { 1155 | "Phase": { 1156 | "selected": false, 1157 | "text": "2", 1158 | "value": "2" 1159 | } 1160 | }, 1161 | "sparkline": { 1162 | "fillColor": "rgba(31, 118, 189, 0.18)", 1163 | "full": false, 1164 | "lineColor": "rgb(31, 120, 193)", 1165 | "show": false 1166 | }, 1167 | "tableColumn": "", 1168 | "targets": [ 1169 | { 1170 | "expr": "smart_meter_voltage{phase=\"$Phase\"}", 1171 | "format": "time_series", 1172 | "instant": true, 1173 | "intervalFactor": 1, 1174 | "refId": "A" 1175 | } 1176 | ], 1177 | "thresholds": "228,232", 1178 | "title": "Voltage Phase $Phase", 1179 | "type": "singlestat", 1180 | "valueFontSize": "80%", 1181 | "valueMaps": [ 1182 | { 1183 | "op": "=", 1184 | "text": "N/A", 1185 | "value": "null" 1186 | } 1187 | ], 1188 | "valueName": "current" 1189 | }, 1190 | { 1191 | "cacheTimeout": null, 1192 | "colorBackground": false, 1193 | "colorValue": false, 1194 | "colors": [ 1195 | "#e24d42", 1196 | "#629e51", 1197 | "#d44a3a" 1198 | ], 1199 | "decimals": 1, 1200 | "format": "volt", 1201 | "gauge": { 1202 | "maxValue": 235, 1203 | "minValue": 225, 1204 | "show": true, 1205 | "thresholdLabels": false, 1206 | "thresholdMarkers": true 1207 | }, 1208 | "gridPos": { 1209 | "h": 6, 1210 | "w": 8, 1211 | "x": 16, 1212 | "y": 18 1213 | }, 1214 | "id": 48, 1215 | "interval": null, 1216 | "links": [], 1217 | "mappingType": 1, 1218 | "mappingTypes": [ 1219 | { 1220 | "name": "value to text", 1221 | "value": 1 1222 | }, 1223 | { 1224 | "name": "range to text", 1225 | "value": 2 1226 | } 1227 | ], 1228 | "maxDataPoints": 100, 1229 | "maxPerRow": 12, 1230 | "nullPointMode": "connected", 1231 | "nullText": null, 1232 | "postfix": "", 1233 | "postfixFontSize": "50%", 1234 | "prefix": "", 1235 | "prefixFontSize": "50%", 1236 | "rangeMaps": [ 1237 | { 1238 | "from": "null", 1239 | "text": "N/A", 1240 | "to": "null" 1241 | } 1242 | ], 1243 | "repeat": null, 1244 | "repeatDirection": "h", 1245 | "repeatIteration": 1551543662001, 1246 | "repeatPanelId": 6, 1247 | "scopedVars": { 1248 | "Phase": { 1249 | "selected": false, 1250 | "text": "3", 1251 | "value": "3" 1252 | } 1253 | }, 1254 | "sparkline": { 1255 | "fillColor": "rgba(31, 118, 189, 0.18)", 1256 | "full": false, 1257 | "lineColor": "rgb(31, 120, 193)", 1258 | "show": false 1259 | }, 1260 | "tableColumn": "", 1261 | "targets": [ 1262 | { 1263 | "expr": "smart_meter_voltage{phase=\"$Phase\"}", 1264 | "format": "time_series", 1265 | "instant": true, 1266 | "intervalFactor": 1, 1267 | "refId": "A" 1268 | } 1269 | ], 1270 | "thresholds": "228,232", 1271 | "title": "Voltage Phase $Phase", 1272 | "type": "singlestat", 1273 | "valueFontSize": "80%", 1274 | "valueMaps": [ 1275 | { 1276 | "op": "=", 1277 | "text": "N/A", 1278 | "value": "null" 1279 | } 1280 | ], 1281 | "valueName": "current" 1282 | }, 1283 | { 1284 | "collapsed": false, 1285 | "gridPos": { 1286 | "h": 1, 1287 | "w": 24, 1288 | "x": 0, 1289 | "y": 24 1290 | }, 1291 | "id": 28, 1292 | "panels": [], 1293 | "title": "Consumption over time", 1294 | "type": "row" 1295 | }, 1296 | { 1297 | "aliasColors": {}, 1298 | "bars": false, 1299 | "dashLength": 10, 1300 | "dashes": false, 1301 | "fill": 1, 1302 | "gridPos": { 1303 | "h": 9, 1304 | "w": 12, 1305 | "x": 0, 1306 | "y": 25 1307 | }, 1308 | "id": 30, 1309 | "legend": { 1310 | "avg": false, 1311 | "current": false, 1312 | "max": false, 1313 | "min": false, 1314 | "show": true, 1315 | "total": true, 1316 | "values": true 1317 | }, 1318 | "lines": true, 1319 | "linewidth": 1, 1320 | "links": [], 1321 | "nullPointMode": "null", 1322 | "paceLength": 10, 1323 | "percentage": false, 1324 | "pointradius": 5, 1325 | "points": false, 1326 | "renderer": "flot", 1327 | "seriesOverrides": [ 1328 | { 1329 | "alias": "Power", 1330 | "yaxis": 2 1331 | } 1332 | ], 1333 | "spaceLength": 10, 1334 | "stack": false, 1335 | "steppedLine": false, 1336 | "targets": [ 1337 | { 1338 | "expr": "rate(smart_meter_current_total_reading{}[5m]) * 60", 1339 | "format": "time_series", 1340 | "instant": false, 1341 | "interval": "1m", 1342 | "intervalFactor": 1, 1343 | "legendFormat": "Consumption", 1344 | "refId": "A" 1345 | }, 1346 | { 1347 | "expr": "smart_meter_current_total_power{}", 1348 | "format": "time_series", 1349 | "hide": true, 1350 | "instant": false, 1351 | "interval": "", 1352 | "intervalFactor": 1, 1353 | "legendFormat": "Power", 1354 | "refId": "B" 1355 | } 1356 | ], 1357 | "thresholds": [], 1358 | "timeFrom": null, 1359 | "timeRegions": [], 1360 | "timeShift": null, 1361 | "title": "Energy consumption", 1362 | "tooltip": { 1363 | "shared": true, 1364 | "sort": 0, 1365 | "value_type": "individual" 1366 | }, 1367 | "type": "graph", 1368 | "xaxis": { 1369 | "buckets": null, 1370 | "mode": "time", 1371 | "name": null, 1372 | "show": true, 1373 | "values": [] 1374 | }, 1375 | "yaxes": [ 1376 | { 1377 | "format": "kwatth", 1378 | "label": null, 1379 | "logBase": 1, 1380 | "max": null, 1381 | "min": null, 1382 | "show": true 1383 | }, 1384 | { 1385 | "format": "watt", 1386 | "label": null, 1387 | "logBase": 1, 1388 | "max": null, 1389 | "min": null, 1390 | "show": false 1391 | } 1392 | ], 1393 | "yaxis": { 1394 | "align": false, 1395 | "alignLevel": null 1396 | } 1397 | }, 1398 | { 1399 | "aliasColors": {}, 1400 | "bars": false, 1401 | "dashLength": 10, 1402 | "dashes": false, 1403 | "decimals": 2, 1404 | "fill": 0, 1405 | "gridPos": { 1406 | "h": 9, 1407 | "w": 12, 1408 | "x": 12, 1409 | "y": 25 1410 | }, 1411 | "id": 37, 1412 | "legend": { 1413 | "avg": true, 1414 | "current": false, 1415 | "max": false, 1416 | "min": false, 1417 | "show": true, 1418 | "total": false, 1419 | "values": true 1420 | }, 1421 | "lines": true, 1422 | "linewidth": 1, 1423 | "links": [], 1424 | "nullPointMode": "connected", 1425 | "paceLength": 10, 1426 | "percentage": false, 1427 | "pointradius": 5, 1428 | "points": false, 1429 | "renderer": "flot", 1430 | "seriesOverrides": [ 1431 | { 1432 | "alias": "Power", 1433 | "yaxis": 1 1434 | } 1435 | ], 1436 | "spaceLength": 10, 1437 | "stack": false, 1438 | "steppedLine": true, 1439 | "targets": [ 1440 | { 1441 | "expr": "smart_meter_voltage", 1442 | "format": "time_series", 1443 | "hide": false, 1444 | "instant": false, 1445 | "interval": "", 1446 | "intervalFactor": 1, 1447 | "legendFormat": "Phase: {{phase}}", 1448 | "refId": "B" 1449 | } 1450 | ], 1451 | "thresholds": [ 1452 | { 1453 | "colorMode": "custom", 1454 | "fill": false, 1455 | "fillColor": "rgba(255, 255, 255, 1)", 1456 | "line": true, 1457 | "lineColor": "#73BF69", 1458 | "op": "gt", 1459 | "value": 230, 1460 | "yaxis": "left" 1461 | } 1462 | ], 1463 | "timeFrom": null, 1464 | "timeRegions": [], 1465 | "timeShift": null, 1466 | "title": "Voltage", 1467 | "tooltip": { 1468 | "shared": true, 1469 | "sort": 0, 1470 | "value_type": "individual" 1471 | }, 1472 | "type": "graph", 1473 | "xaxis": { 1474 | "buckets": null, 1475 | "mode": "time", 1476 | "name": null, 1477 | "show": true, 1478 | "values": [] 1479 | }, 1480 | "yaxes": [ 1481 | { 1482 | "format": "volt", 1483 | "label": null, 1484 | "logBase": 1, 1485 | "max": "240", 1486 | "min": "222", 1487 | "show": true 1488 | }, 1489 | { 1490 | "format": "watt", 1491 | "label": null, 1492 | "logBase": 1, 1493 | "max": null, 1494 | "min": null, 1495 | "show": false 1496 | } 1497 | ], 1498 | "yaxis": { 1499 | "align": false, 1500 | "alignLevel": null 1501 | } 1502 | } 1503 | ], 1504 | "refresh": "5s", 1505 | "schemaVersion": 18, 1506 | "style": "dark", 1507 | "tags": [], 1508 | "templating": { 1509 | "list": [ 1510 | { 1511 | "allValue": null, 1512 | "current": { 1513 | "tags": [], 1514 | "text": "All", 1515 | "value": [ 1516 | "$__all" 1517 | ] 1518 | }, 1519 | "hide": 0, 1520 | "includeAll": true, 1521 | "label": "phase", 1522 | "multi": true, 1523 | "name": "Phase", 1524 | "options": [ 1525 | { 1526 | "selected": true, 1527 | "text": "All", 1528 | "value": "$__all" 1529 | }, 1530 | { 1531 | "selected": false, 1532 | "text": "1", 1533 | "value": "1" 1534 | }, 1535 | { 1536 | "selected": false, 1537 | "text": "2", 1538 | "value": "2" 1539 | }, 1540 | { 1541 | "selected": false, 1542 | "text": "3", 1543 | "value": "3" 1544 | } 1545 | ], 1546 | "query": "1,2,3", 1547 | "skipUrlSync": false, 1548 | "type": "custom" 1549 | } 1550 | ] 1551 | }, 1552 | "time": { 1553 | "from": "now-24h", 1554 | "to": "now" 1555 | }, 1556 | "timepicker": { 1557 | "refresh_intervals": [ 1558 | "5s", 1559 | "10s", 1560 | "30s", 1561 | "1m", 1562 | "5m", 1563 | "15m", 1564 | "30m", 1565 | "1h", 1566 | "2h", 1567 | "1d" 1568 | ], 1569 | "time_options": [ 1570 | "5m", 1571 | "15m", 1572 | "1h", 1573 | "6h", 1574 | "12h", 1575 | "24h", 1576 | "2d", 1577 | "7d", 1578 | "30d" 1579 | ] 1580 | }, 1581 | "timezone": "", 1582 | "title": "Smartmeter", 1583 | "uid": "m-EWEjRgk", 1584 | "version": 13 1585 | } -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "net" 7 | "net/http" 8 | "os" 9 | "os/signal" 10 | "sync" 11 | "syscall" 12 | "time" 13 | 14 | flag "github.com/spf13/pflag" 15 | "go.bug.st/serial.v1" 16 | ) 17 | 18 | var gitSha = "" 19 | 20 | var ( 21 | httpPort = flag.String("http-port", "8080", "Port for http server serving prometheus endpoint") 22 | 23 | flagPersistence = flag.String("persist", "none", "type of persistence: [none, postgres]") 24 | 25 | dbHost = flag.String("db-host", "localhost", "Db host") 26 | dbPort = flag.String("db-port", "5432", "Db port") 27 | dbUser = flag.String("db-user", "postgres", "Db user") 28 | dbPassword = flag.String("db-password", "root", "Db password") 29 | dbName = flag.String("db-name", "root", "Db name") 30 | flushInterval = flag.Duration("db-flush", time.Minute, "Flush after duration") 31 | 32 | usbDevName = flag.String("reader-port", "/dev/ttyUSB0", "Device name of reader") 33 | ) 34 | 35 | var ( 36 | collector = make(chan Measurement) 37 | retry = make(chan []Measurement) 38 | ) 39 | 40 | func main() { 41 | log.Println("Starting Version:", gitSha) 42 | flag.Parse() 43 | flag.VisitAll(func(f *flag.Flag) { 44 | log.Println("Flag:", f.Name, f.DefValue, f.Value, ":::", f.Usage) 45 | }) 46 | 47 | initProm() 48 | 49 | ctx, c := context.WithCancel(context.Background()) 50 | defer c() 51 | 52 | // TODO make this configurable 53 | mode := &serial.Mode{ 54 | BaudRate: 9600, 55 | DataBits: 7, 56 | Parity: serial.EvenParity, 57 | StopBits: serial.OneStopBit, 58 | } 59 | file, err := serial.Open(*usbDevName, mode) 60 | if err != nil { 61 | log.Fatal("Error open", err) 62 | time.Sleep(time.Second * 5) 63 | } 64 | defer file.Close() 65 | 66 | parser := NewObisParser(ctx) 67 | go parser.Parse(file, handleMeasurement) 68 | 69 | go func() { 70 | // Start server for serving prometheus 71 | log.Fatalln(http.ListenAndServe(net.JoinHostPort("", *httpPort), nil)) 72 | }() 73 | 74 | go func() { 75 | signalC := make(chan os.Signal, 1) 76 | signal.Notify(signalC, syscall.SIGTERM) 77 | <-signalC 78 | log.Println("Shutdown received") 79 | c() 80 | }() 81 | 82 | startCollector(ctx, getWriter(*flagPersistence)) 83 | } 84 | 85 | func getWriter(option string) MeasurementPersister { 86 | switch option { 87 | case "none": 88 | return NoOpWriter(true) 89 | case "postgres": 90 | return NewPostgresWriter(PostgresConfig{ 91 | User: *dbUser, 92 | Password: *dbPassword, 93 | Host: *dbHost, 94 | Port: *dbPort, 95 | DbName: *dbName, 96 | }) 97 | default: 98 | log.Fatalf("Unkown persist flag: %v of [none, postgres]\n", option) 99 | } 100 | return nil 101 | } 102 | 103 | func startCollector(ctx context.Context, persister MeasurementPersister) { 104 | var measurements []Measurement 105 | var wg sync.WaitGroup 106 | 107 | ticker := time.NewTicker(*flushInterval) 108 | defer ticker.Stop() 109 | 110 | for { 111 | flushBuffer.Set(float64(len(measurements))) 112 | 113 | select { 114 | case m := <-collector: 115 | measurements = append(measurements, m) 116 | case m := <-retry: 117 | measurements = append(measurements, m...) 118 | case <-ticker.C: 119 | wg.Add(1) 120 | go func(m []Measurement) { 121 | defer wg.Done() 122 | err := persister.Flush(m) 123 | if err != nil { 124 | // TODO add len of slice to error output 125 | log.Println(err) 126 | return 127 | } 128 | }(measurements) 129 | 130 | measurements = []Measurement{} 131 | case <-ctx.Done(): 132 | err := persister.Flush(measurements) 133 | if err != nil { 134 | log.Println(err) 135 | } 136 | 137 | wg.Wait() 138 | log.Println("Done with all things.. bye bye") 139 | return 140 | } 141 | } 142 | } 143 | 144 | func handleMeasurement(m Measurement, err error) { 145 | if err != nil { 146 | log.Println(err) 147 | return 148 | } 149 | 150 | meter(m) 151 | collector <- m 152 | } 153 | -------------------------------------------------------------------------------- /measurement.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "time" 4 | 5 | type MeasurementProcessor func(m Measurement, err error) 6 | 7 | type Measurement struct { 8 | Created time.Time `db:"created_at"` 9 | MeterID int `db:"meter_id"` 10 | TotalKwhNeg float64 `db:"total_kwh_neg"` 11 | TotalKwhPos float64 `db:"total_kwh_pos"` 12 | TotalT1KwhPos float64 `db:"total_kwh_t1_pos"` 13 | TotalT2KwhPos float64 `db:"total_kwh_t2_pos"` 14 | PTotal float64 `db:"total_p"` 15 | P1 float64 `db:"p1"` 16 | P2 float64 `db:"p2"` 17 | P3 float64 `db:"p3"` 18 | V1 float64 `db:"v1"` 19 | V2 float64 `db:"v2"` 20 | V3 float64 `db:"v3"` 21 | } 22 | -------------------------------------------------------------------------------- /media/dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/panzerdev/smartmeter/f72f5661051b3bfab94224bf9e0d877516bd1d16/media/dashboard.png -------------------------------------------------------------------------------- /media/reader.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/panzerdev/smartmeter/f72f5661051b3bfab94224bf9e0d877516bd1d16/media/reader.jpg -------------------------------------------------------------------------------- /media/schaltkasten.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/panzerdev/smartmeter/f72f5661051b3bfab94224bf9e0d877516bd1d16/media/schaltkasten.jpg -------------------------------------------------------------------------------- /media/smartmeter.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/panzerdev/smartmeter/f72f5661051b3bfab94224bf9e0d877516bd1d16/media/smartmeter.jpg -------------------------------------------------------------------------------- /obis_parser.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "io" 7 | "log" 8 | "regexp" 9 | "strconv" 10 | "strings" 11 | "time" 12 | 13 | "github.com/pkg/errors" 14 | ) 15 | 16 | const ( 17 | OBISRegex = `(?P\d-\d:\d{1,2}\.\d{1,2}\.\d\*\d{3})(?P\(\S*\))` 18 | ValueRegex = `(\d*\.\d*)\*(\S[^\)]*)` 19 | ) 20 | 21 | const ( 22 | OBISMsgSeparator = "!" 23 | OBIScodeTotalConsumptionPositive = "1-0:1.8.0*255" // 2248.01818051 kWh 24 | OBIScodeT1ConsumptionPositive = "1-0:1.8.1*255" // 2248.01818051 kWh 25 | OBIScodeT2ConsumptionPositive = "1-0:1.8.2*255" // 2248.01818051 kWh 26 | OBIScodeTotalConsumptionNegative = "1-0:2.8.0*255" // 2248.01818051 kWh 27 | OBIScodeT1ConsumptionNegative = "1-0:2.8.1*255" // 2248.01818051 kWh not used yet 28 | OBIScodeT2ConsumptionNegative = "1-0:2.8.2*255" // 2248.01818051 kWh not used yet 29 | OBIScodePt = "1-0:16.7.0*255" // 282.03 W 30 | OBIScodeP1 = "1-0:36.7.0*255" // 63.33 W 31 | OBIScodeP2 = "1-0:56.7.0*255" // 30.14 W 32 | OBIScodeP3 = "1-0:76.7.0*255" // 188.56 W 33 | OBIScodeV1 = "1-0:32.7.0*255" // 233.8 V 34 | OBIScodeV2 = "1-0:52.7.0*255" // 236.1 V 35 | OBIScodeV3 = "1-0:72.7.0*255" // 235.1 V 36 | ) 37 | 38 | var ( 39 | obisExpress = regexp.MustCompile(OBISRegex) 40 | valueExpress = regexp.MustCompile(ValueRegex) 41 | ) 42 | 43 | type ObisParser struct { 44 | msgSeparator string 45 | lineValidator *regexp.Regexp 46 | valueParser *regexp.Regexp 47 | ctx context.Context 48 | } 49 | 50 | func NewObisParser(ctx context.Context) *ObisParser { 51 | return &ObisParser{ 52 | msgSeparator: OBISMsgSeparator, 53 | lineValidator: obisExpress, 54 | valueParser: valueExpress, 55 | ctx: ctx, 56 | } 57 | } 58 | 59 | // reader is read until io.EOF 60 | // 61 | // MeasurementProcessor is called in a new Goroutine 62 | func (parser ObisParser) Parse(reader io.Reader, mp MeasurementProcessor) { 63 | scanner := bufio.NewScanner(reader) 64 | var msg []string 65 | for scanner.Scan() { 66 | line := scanner.Text() 67 | if line == parser.msgSeparator { 68 | measurement, err := parseObis(msg) 69 | go mp(measurement, err) 70 | 71 | msg = []string{} 72 | } else if obisExpress.MatchString(line) { 73 | msg = append(msg, line) 74 | } 75 | 76 | select { 77 | case <-parser.ctx.Done(): 78 | return 79 | default: 80 | } 81 | } 82 | } 83 | 84 | func parseObis(msg []string) (Measurement, error) { 85 | if len(msg) < 12 { 86 | return Measurement{}, errors.Errorf("\nReading too short %v:\n%v", len(msg), strings.Join(msg, "\n")) 87 | } 88 | 89 | measurement := Measurement{ 90 | Created: time.Now(), 91 | MeterID: 1, // use a real id if you have more then 1 meter 92 | } 93 | 94 | for _, v := range msg { 95 | matches := obisExpress.FindStringSubmatch(v)[1:] 96 | if len(matches) < 2 { 97 | continue 98 | } 99 | 100 | key := matches[0] 101 | value := matches[1] 102 | if valueExpress.MatchString(value) { 103 | values := valueExpress.FindStringSubmatch(value)[1:] 104 | num, err := strconv.ParseFloat(values[0], 64) 105 | if err != nil { 106 | log.Println("Parse float", err) 107 | continue 108 | } 109 | mapValues(key, num, &measurement) 110 | } 111 | } 112 | 113 | return measurement, nil 114 | } 115 | 116 | func mapValues(obis string, value float64, m *Measurement) { 117 | switch obis { 118 | case OBIScodeTotalConsumptionNegative: 119 | m.TotalKwhNeg = value 120 | case OBIScodeTotalConsumptionPositive: 121 | m.TotalKwhPos = value 122 | case OBIScodeT1ConsumptionPositive: 123 | m.TotalT1KwhPos = value 124 | case OBIScodeT2ConsumptionPositive: 125 | m.TotalT2KwhPos = value 126 | case OBIScodePt: 127 | m.PTotal = value 128 | case OBIScodeP1: 129 | m.P1 = value 130 | case OBIScodeP2: 131 | m.P2 = value 132 | case OBIScodeP3: 133 | m.P3 = value 134 | case OBIScodeV1: 135 | m.V1 = value 136 | case OBIScodeV2: 137 | m.V2 = value 138 | case OBIScodeV3: 139 | m.V3 = value 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /obis_parser_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "github.com/stretchr/testify/assert" 7 | "sync" 8 | "testing" 9 | ) 10 | 11 | const invalidMsg = ` 12 | .0*255(228.3*V) 13 | 1-0:96.5.0*255(001C0104) 14 | 0-0:96.8.0*255(00F1AD31) 15 | ! 16 | ` 17 | const singleValidMsg = ` 18 | /EZB23092834 19 | 20 | 1-0:0.0.0*255(1EBZ0100183277) 21 | 1-0:96.1.0*255(1EBZ0100183277) 22 | 1-0:1.8.0*255(002236.29107286*kWh) 23 | 1-0:1.8.1*255(001236.29107286*kWh) 24 | 1-0:1.8.2*255(002136.29107286*kWh) 25 | 1-0:2.8.0*255(002130.29107286*kWh) 26 | 1-0:16.7.0*255(000550.25*W) 27 | 1-0:36.7.0*255(000322.14*W) 28 | 1-0:56.7.0*255(000052.55*W) 29 | 1-0:76.7.0*255(000175.56*W) 30 | 1-0:32.7.0*255(227.6*V) 31 | 1-0:52.7.0*255(229.3*V) 32 | 1-0:72.7.0*255(228.3*V) 33 | 1-0:96.5.0*255(001C0104) 34 | 0-0:96.8.0*255(00F1AD31) 35 | ! 36 | ` 37 | 38 | func TestObisParser_ParseValid(t *testing.T) { 39 | as := assert.New(t) 40 | 41 | parser := NewObisParser(context.Background()) 42 | 43 | wg := sync.WaitGroup{} 44 | wg.Add(1) 45 | 46 | parser.Parse(bytes.NewReader([]byte(singleValidMsg)), func(measurement Measurement, err error) { 47 | as.NoError(err) 48 | as.Equal(2130.29107286, measurement.TotalKwhNeg) 49 | as.Equal(2236.29107286, measurement.TotalKwhPos) 50 | as.Equal(1236.29107286, measurement.TotalT1KwhPos) 51 | as.Equal(2136.29107286, measurement.TotalT2KwhPos) 52 | as.Equal(550.25, measurement.PTotal) 53 | as.Equal(322.14, measurement.P1) 54 | as.Equal(52.55, measurement.P2) 55 | as.Equal(175.56, measurement.P3) 56 | as.Equal(227.6, measurement.V1) 57 | as.Equal(229.3, measurement.V2) 58 | as.Equal(228.3, measurement.V3) 59 | wg.Done() 60 | }) 61 | 62 | wg.Wait() 63 | } 64 | 65 | func TestObisParser_ParseInvalid(t *testing.T) { 66 | as := assert.New(t) 67 | 68 | parser := NewObisParser(context.Background()) 69 | 70 | wg := sync.WaitGroup{} 71 | wg.Add(1) 72 | 73 | parser.Parse(bytes.NewReader([]byte(invalidMsg)), func(measurement Measurement, err error) { 74 | as.Error(err) 75 | wg.Done() 76 | }) 77 | 78 | wg.Wait() 79 | } 80 | -------------------------------------------------------------------------------- /persist.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/pkg/errors" 6 | "github.com/prometheus/client_golang/prometheus" 7 | "log" 8 | "sort" 9 | "time" 10 | 11 | "github.com/jmoiron/sqlx" 12 | _ "github.com/lib/pq" 13 | ) 14 | 15 | type MeasurementPersister interface { 16 | Flush(measurements []Measurement) error 17 | } 18 | 19 | type NoOpWriter bool 20 | 21 | func (w NoOpWriter) Flush(measurements []Measurement) error { 22 | if w { 23 | log.Printf("Not persisting %v items\n", len(measurements)) 24 | } 25 | return nil 26 | } 27 | 28 | type PostgresWriter struct { 29 | retryC chan []Measurement 30 | db *sqlx.DB 31 | } 32 | 33 | // CREATE TABLE meter_data ( 34 | // created_at timestamp PRIMARY KEY NOT NULL, 35 | // meter_id integer NOT NULL, 36 | // total_kwh_neg decimal NOT NULL, 37 | // total_kwh_pos decimal NOT NULL, 38 | // total_kwh_t1_pos decimal NOT NULL, 39 | // total_kwh_t2_pos decimal NOT NULL, 40 | // total_p decimal NOT NULL, 41 | // p1 decimal NOT NULL, 42 | // p2 decimal NOT NULL, 43 | // p3 decimal NOT NULL, 44 | // v1 decimal NOT NULL, 45 | // v2 decimal NOT NULL, 46 | // v3 decimal NOT NULL 47 | // ); 48 | 49 | const insert_meter_data = `INSERT INTO meter_data( created_at, meter_id, total_kwh_neg, total_kwh_pos, total_kwh_t1_pos, total_kwh_t2_pos, total_p, p1, p2, p3, v1, v2, v3) 50 | VALUES(:created_at, :meter_id, :total_kwh_neg, :total_kwh_pos, :total_kwh_t1_pos, :total_kwh_t2_pos, :total_p, :p1, :p2, :p3, :v1, :v2, :v3);` 51 | 52 | type PostgresConfig struct { 53 | User string 54 | Password string 55 | Host string 56 | Port string 57 | DbName string 58 | } 59 | 60 | func (pc PostgresConfig) String() string { 61 | return fmt.Sprintf( 62 | `user=%v password=%v host=%v port=%v dbname=%v sslmode=disable`, 63 | pc.User, pc.Password, pc.Host, pc.Port, pc.DbName) 64 | } 65 | 66 | func NewPostgresWriter(conf PostgresConfig) *PostgresWriter { 67 | db := sqlx.MustConnect("postgres", conf.String()) 68 | return &PostgresWriter{ 69 | retryC: retry, 70 | db: db, 71 | } 72 | } 73 | 74 | func (p *PostgresWriter) Flush(measurements []Measurement) error { 75 | if len(measurements) == 0 { 76 | return errors.New("No data in slice to flush") 77 | } 78 | 79 | observer := prometheus.NewTimer(flushTime) 80 | defer observer.ObserveDuration() 81 | 82 | t := time.Now() 83 | // TODO flush to DB and write to retry chan on error 84 | 85 | sort.Slice(measurements, func(i, j int) bool { 86 | return measurements[i].Created.Before(measurements[j].Created) 87 | }) 88 | 89 | exec := func() error { 90 | tx, err := p.db.Beginx() 91 | if err != nil { 92 | return err 93 | } 94 | defer tx.Rollback() 95 | 96 | insert, err := tx.PrepareNamed(insert_meter_data) 97 | if err != nil { 98 | return err 99 | } 100 | defer insert.Close() 101 | 102 | for _, m := range measurements { 103 | _, err := insert.Exec(m) 104 | if err != nil { 105 | return err 106 | } 107 | } 108 | 109 | return tx.Commit() 110 | }() 111 | 112 | if err := exec; err != nil { 113 | log.Println(err) 114 | go func() { p.retryC <- measurements }() 115 | return err 116 | } 117 | 118 | log.Printf("PostgresWriter: Flushed %v items to db in %v\n", len(measurements), time.Since(t)) 119 | return nil 120 | } 121 | -------------------------------------------------------------------------------- /persist_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | "math/rand" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func TestPostgresWriter_Flush(t *testing.T) { 11 | rand.Seed(time.Now().Unix()) 12 | meterId := rand.Intn(10000) 13 | var m []Measurement 14 | createdAt := time.Now() 15 | for i := 0; i < 100; i++ { 16 | m = append(m, Measurement{ 17 | Created: createdAt.Add(time.Microsecond * time.Duration(i)), 18 | MeterID: meterId, 19 | TotalKwhNeg: rand.Float64(), 20 | TotalKwhPos: rand.Float64(), 21 | TotalT1KwhPos: rand.Float64(), 22 | TotalT2KwhPos: rand.Float64(), 23 | PTotal: rand.Float64(), 24 | P1: rand.Float64(), 25 | P2: rand.Float64(), 26 | P3: rand.Float64(), 27 | V1: rand.Float64(), 28 | V2: rand.Float64(), 29 | V3: rand.Float64(), 30 | }) 31 | } 32 | 33 | writer := NewPostgresWriter(PostgresConfig{ 34 | User: "postgres", 35 | Password: "root", 36 | Host: "localhost", 37 | Port: "5432", 38 | DbName: "db", 39 | }) 40 | _, err := writer.db.Exec("DELETE from meter_data where meter_id = $1", meterId) 41 | assert.NoError(t, err) 42 | 43 | err = writer.Flush(m) 44 | 45 | assert.NoError(t, err) 46 | 47 | var nr int 48 | err = writer.db.Get(&nr, "SELECT count(*) from meter_data where meter_id = $1;", meterId) 49 | assert.NoError(t, err) 50 | assert.Equal(t, len(m), nr) 51 | 52 | _, err = writer.db.Exec("DELETE from meter_data where meter_id = $1", meterId) 53 | assert.NoError(t, err) 54 | } 55 | -------------------------------------------------------------------------------- /prom.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/prometheus/client_golang/prometheus" 7 | "github.com/prometheus/client_golang/prometheus/promhttp" 8 | ) 9 | 10 | var ( 11 | voltage *prometheus.GaugeVec 12 | power *prometheus.GaugeVec 13 | totalPower prometheus.Gauge 14 | consumption *prometheus.GaugeVec 15 | flushTime prometheus.Summary 16 | flushBuffer prometheus.Gauge 17 | ) 18 | 19 | const ( 20 | Phase = "phase" 21 | Unit = "unit" 22 | Obis = "obis" 23 | ) 24 | 25 | func initProm() { 26 | voltage = prometheus.NewGaugeVec(prometheus.GaugeOpts{ 27 | Name: "smart_meter_voltage", 28 | Help: "Current Voltage of each phase", 29 | ConstLabels: prometheus.Labels{ 30 | Unit: "V", 31 | }, 32 | }, []string{ 33 | Phase, 34 | Obis, 35 | }) 36 | power = prometheus.NewGaugeVec(prometheus.GaugeOpts{ 37 | Name: "smart_meter_power", 38 | Help: "Current power of each phase", 39 | ConstLabels: prometheus.Labels{ 40 | Unit: "W", 41 | }, 42 | }, []string{ 43 | Phase, 44 | Obis, 45 | }) 46 | 47 | consumption = prometheus.NewGaugeVec(prometheus.GaugeOpts{ 48 | Name: "smart_meter_current_total_consumption", 49 | Help: "Current total value ", 50 | ConstLabels: prometheus.Labels{ 51 | Unit: "kWh", 52 | }, 53 | }, []string{ 54 | Obis, 55 | "type", 56 | "counter", 57 | }) 58 | 59 | totalPower = prometheus.NewGauge(prometheus.GaugeOpts{ 60 | Name: "smart_meter_current_total_power", 61 | Help: "Current total power for all phases", 62 | ConstLabels: prometheus.Labels{ 63 | Unit: "W", 64 | Obis: OBIScodePt}, 65 | }) 66 | 67 | flushTime = prometheus.NewSummary(prometheus.SummaryOpts{ 68 | Name: "smart_meter_persister_flush_duration", 69 | Help: "Duration for flushing to DB", 70 | }) 71 | 72 | flushBuffer = prometheus.NewGauge(prometheus.GaugeOpts{ 73 | Name: "smart_meter_persister_flush_buffer", 74 | Help: "Curent items waitng for flushing", 75 | }) 76 | 77 | prometheus.MustRegister(voltage) 78 | prometheus.MustRegister(power) 79 | prometheus.MustRegister(consumption) 80 | prometheus.MustRegister(totalPower) 81 | prometheus.MustRegister(flushTime) 82 | prometheus.MustRegister(flushBuffer) 83 | 84 | http.Handle("/metrics", promhttp.Handler()) 85 | } 86 | 87 | func meter(m Measurement) { 88 | consumption.WithLabelValues(OBIScodeTotalConsumptionNegative, "negative", "total").Set(m.TotalKwhNeg) 89 | consumption.WithLabelValues(OBIScodeTotalConsumptionPositive, "positive", "total").Set(m.TotalKwhPos) 90 | consumption.WithLabelValues(OBIScodeT1ConsumptionPositive, "positive", "t1").Set(m.TotalT1KwhPos) 91 | consumption.WithLabelValues(OBIScodeT2ConsumptionPositive, "positive", "t2").Set(m.TotalT2KwhPos) 92 | totalPower.Set(m.PTotal) 93 | power.WithLabelValues("all", OBIScodePt).Set(m.PTotal) 94 | power.WithLabelValues("1", OBIScodeP1).Set(m.P1) 95 | power.WithLabelValues("2", OBIScodeP2).Set(m.P2) 96 | power.WithLabelValues("3", OBIScodeP3).Set(m.P3) 97 | voltage.WithLabelValues("1", OBIScodeV1).Set(m.V1) 98 | voltage.WithLabelValues("2", OBIScodeV2).Set(m.V2) 99 | voltage.WithLabelValues("3", OBIScodeV3).Set(m.V3) 100 | } 101 | -------------------------------------------------------------------------------- /smartmeter.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=SmartMeter 3 | After=multi-user.target 4 | 5 | [Service] 6 | TimeoutStartSec=0 7 | Restart=always 8 | RestartSec=5 9 | ExecStart=/usr/bin/smartmeter 10 | 11 | [Install] 12 | WantedBy=multi-user.target 13 | --------------------------------------------------------------------------------