├── .editorconfig ├── .gitignore ├── .travis.yml ├── CONTRIBUTING.md ├── Gopkg.lock ├── Gopkg.toml ├── LICENSE ├── Makefile ├── README.md ├── assets ├── css │ ├── app.css │ └── bootstrap.min.css ├── index.html └── js │ ├── app.js │ ├── bootstrap.min.js │ ├── d3-format.min.js │ ├── jquery-3.2.1.min.js │ ├── tether.min.js │ └── vue.min.js ├── build.sh ├── cmd ├── sdm630 │ └── main.go ├── sdm630_httpd │ └── main.go ├── sdm630_logger │ ├── database.go │ └── main.go ├── sdm630_monitor │ └── main.go └── sdm_detect │ └── main.go ├── datagram.go ├── datagram_test.go ├── embed.json ├── go.mod ├── http.go ├── iec.go ├── img ├── SDM630-MODBUS.jpg ├── USB-RS485-Adaptor.jpg ├── openhab.png ├── realtimeview.png └── wiring.jpg ├── internal └── meters │ ├── dzg.go │ ├── janitza.go │ ├── meter.go │ ├── sbc.go │ ├── sdm.go │ └── transform.go ├── measurementcache.go ├── mockclient.go ├── modbus.go ├── mqtt.go ├── scheduler.go ├── snips.go ├── socket.go ├── status.go ├── tools.go └── version.go /.editorconfig: -------------------------------------------------------------------------------- 1 | ; indicate this is the root of the project 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | 7 | end_of_line = LF 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | indent_style = tab 12 | indent_size = 4 13 | 14 | [*.css] 15 | indent_size = 2 16 | 17 | [*.js] 18 | indent_size = 2 19 | 20 | [*.html] 21 | indent_size = 2 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Editor files 2 | *~ 3 | *.sw[op] 4 | 5 | # gb-compiled binaries 6 | bin 7 | pkg 8 | *.zip 9 | 10 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 11 | *.o 12 | *.a 13 | *.so 14 | 15 | # Folders 16 | _obj 17 | _test 18 | 19 | # Architecture specific extensions/prefixes 20 | *.[568vq] 21 | [568vq].out 22 | 23 | *.cgo1.go 24 | *.cgo2.c 25 | _cgo_defun.c 26 | _cgo_gotypes.go 27 | _cgo_export.* 28 | 29 | _testmain.go 30 | 31 | *.exe 32 | *.test 33 | *.prof 34 | 35 | release/ 36 | vendor/ 37 | embeddedassets.go 38 | 39 | go.sum 40 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: go 3 | go: 4 | - 1.8 5 | - 1.9 6 | - "1.10" 7 | - "master" 8 | go_import_path: github.com/gonium/gosdm630 9 | env: 10 | - "PATH=/home/travis/gopath/bin:$PATH" 11 | before_install: 12 | - echo $TRAVIS_GO_VERSION 13 | - go get -u github.com/golang/dep/cmd/dep 14 | install: 15 | - make dep 16 | script: 17 | - make 18 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | So you want to contribute? Yay, I really appreciate this! Please read 4 | the following notes to make life easy for both you and me. 5 | 6 | Please note: This is the first version of a contributor guide. I don't 7 | know whether this will work ;-) 8 | 9 | ## Did you find a bug? 10 | 11 | 1. Please check the [issues on 12 | github](https://github.com/gonium/gosdm630/issues) if the bug was 13 | already reported. If so: feel free to add a comment if you think it 14 | helps. 15 | 16 | 2. Not previously reported? Please [open an issue](https://github.com/gonium/gosdm630/issues). Please describe how I can reproduce the issue (what environment, commandline parameters, attached devices, ...). 17 | 18 | ## Do you want to contribute code? 19 | For small changes (typos etc.), just open a pull request. For anything 20 | bigger, please open an issue first. Describe what you want to do 21 | (bugfix, implement new feature, solve existing issue) and give a quick 22 | overview on how you would like to implement it. I hope to reduce 23 | frustration for both you and me if we agree on a solution strategy 24 | before writing code. 25 | 26 | In any way: Your code should be [formatted using 27 | ``gofmt``](https://blog.golang.org/go-fmt-your-code). 28 | 29 | ## Do you want to contribute documentation? 30 | 31 | All documentation lives within this repository. If you spot an error or 32 | want to improve the documentation, please create a pull request. For 33 | small changes just create the pull request directly. For big changes 34 | ("This sucks, I want to rewrite it") create an issue first. 35 | -------------------------------------------------------------------------------- /Gopkg.lock: -------------------------------------------------------------------------------- 1 | # This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. 2 | 3 | 4 | [[projects]] 5 | digest = "1:bc48c4b7cee5cd1c679325461a1dccc3ffcf31841599fc56f8839f8862a4b3ea" 6 | name = "github.com/aprice/embed" 7 | packages = [ 8 | "cmd/embed", 9 | "generator", 10 | "loader", 11 | ] 12 | pruneopts = "UT" 13 | revision = "e0f543275bf966271e2c3b3ab70265f635f0394a" 14 | 15 | [[projects]] 16 | digest = "1:cbc96f2553a9e55f2c2644fe5a3b5471fabc2f4223d714da5084391c19db5058" 17 | name = "github.com/boltdb/bolt" 18 | packages = ["."] 19 | pruneopts = "UT" 20 | revision = "e9cf4fae01b5a8ff89d0ec6b32f0d9c9f79aefdd" 21 | 22 | [[projects]] 23 | digest = "1:184008c955d6a3226b700c13ed0e875a7a051a759618f9bccba9b5c99f17faa5" 24 | name = "github.com/eclipse/paho.mqtt.golang" 25 | packages = [ 26 | ".", 27 | "packets", 28 | ] 29 | pruneopts = "UT" 30 | revision = "36d01c2b4cbeb3d2a12063e4880ce30800af9560" 31 | version = "v1.1.1" 32 | 33 | [[projects]] 34 | digest = "1:aa43a01d4dd8f0e4f6fe5093bf8942bae94a67b654c1aa06e44e73ac802ce7e7" 35 | name = "github.com/goburrow/modbus" 36 | packages = ["."] 37 | pruneopts = "UT" 38 | revision = "606c02f4eef527a1d4cf7d8733d5fd7ba34f91d8" 39 | version = "v0.1.0" 40 | 41 | [[projects]] 42 | digest = "1:b172b7327e9a217f141749a0dc18f675c42fb146eab9c774d84ab6f784af4c09" 43 | name = "github.com/goburrow/serial" 44 | packages = ["."] 45 | pruneopts = "UT" 46 | revision = "1b57be761cd3a66492333e9367a23c0869f9cc2e" 47 | 48 | [[projects]] 49 | digest = "1:c79fb010be38a59d657c48c6ba1d003a8aa651fa56b579d959d74573b7dff8e1" 50 | name = "github.com/gorilla/context" 51 | packages = ["."] 52 | pruneopts = "UT" 53 | revision = "08b5f424b9271eedf6f9f0ce86cb9396ed337a42" 54 | version = "v1.1.1" 55 | 56 | [[projects]] 57 | digest = "1:8baf93d757bba6572bc6dca6539ea9a991af708df46065898a9e74dcffece65a" 58 | name = "github.com/gorilla/handlers" 59 | packages = ["."] 60 | pruneopts = "UT" 61 | revision = "a4d79d4487c2430a17d9dc8a1f74d1a6ed6908ca" 62 | 63 | [[projects]] 64 | digest = "1:708ca7f70df972672d96e0a205bf6b2025cb35872d04a250c65cdb9632a851dc" 65 | name = "github.com/gorilla/mux" 66 | packages = ["."] 67 | pruneopts = "UT" 68 | revision = "757bef944d0f21880861c2dd9c871ca543023cba" 69 | 70 | [[projects]] 71 | digest = "1:43dd08a10854b2056e615d1b1d22ac94559d822e1f8b6fcc92c1a1057e85188e" 72 | name = "github.com/gorilla/websocket" 73 | packages = ["."] 74 | pruneopts = "UT" 75 | revision = "ea4d1f681babbce9545c9c5f3d5194a789c89f5b" 76 | version = "v1.2.0" 77 | 78 | [[projects]] 79 | branch = "master" 80 | digest = "1:7e3e7c9bd13732904b328975e4002d00423a1e6c17f743f284e10f8c88ad246d" 81 | name = "github.com/jcuga/golongpoll" 82 | packages = ["."] 83 | pruneopts = "UT" 84 | revision = "6f70b008d155ed04693bcb8ebe0b693820bebee5" 85 | 86 | [[projects]] 87 | branch = "master" 88 | digest = "1:0e1e5f960c58fdc677212fcc70e55042a0084d367623e51afbdb568963832f5d" 89 | name = "github.com/nu7hatch/gouuid" 90 | packages = ["."] 91 | pruneopts = "UT" 92 | revision = "179d4d0c4d8d407a32af483c2354df1d2c91e6c3" 93 | 94 | [[projects]] 95 | digest = "1:c1b1102241e7f645bc8e0c22ae352e8f0dc6484b6cb4d132fa9f24174e0119e2" 96 | name = "github.com/spf13/pflag" 97 | packages = ["."] 98 | pruneopts = "UT" 99 | revision = "298182f68c66c05229eb03ac171abe6e309ee79a" 100 | version = "v1.0.3" 101 | 102 | [[projects]] 103 | digest = "1:169c7b0614bf9d98cb3b3c5fd350e1adef5de0b5459ccda914e7aeac466b74a6" 104 | name = "github.com/tdewolff/minify" 105 | packages = [ 106 | ".", 107 | "css", 108 | "html", 109 | "js", 110 | "svg", 111 | ] 112 | pruneopts = "UT" 113 | revision = "8d72a4127ae33b755e95bffede9b92e396267ce2" 114 | version = "v2.3.5" 115 | 116 | [[projects]] 117 | digest = "1:294ecd8d9ea7f3f0b5a801e4eff088df0000260a403c77c8f776e557fb82a2a3" 118 | name = "github.com/tdewolff/parse" 119 | packages = [ 120 | ".", 121 | "buffer", 122 | "css", 123 | "html", 124 | "js", 125 | "strconv", 126 | "svg", 127 | "xml", 128 | ] 129 | pruneopts = "UT" 130 | revision = "d739d6fccb0971177e06352fea02d3552625efb1" 131 | version = "v2.3.3" 132 | 133 | [[projects]] 134 | branch = "master" 135 | digest = "1:fb877057547b048c22cc2e1464f04251568d65111628dd0cc2384ed4647e2157" 136 | name = "golang.org/x/net" 137 | packages = [ 138 | "internal/socks", 139 | "proxy", 140 | "websocket", 141 | ] 142 | pruneopts = "UT" 143 | revision = "1e491301e022f8f977054da4c2d852decd59571f" 144 | 145 | [[projects]] 146 | branch = "master" 147 | digest = "1:fcf2b18db0af0f60d58e71ae8ed15555d46fdca3fc2529f65a14cef30ced981f" 148 | name = "golang.org/x/sys" 149 | packages = ["unix"] 150 | pruneopts = "UT" 151 | revision = "9527bec2660bd847c050fda93a0f0c6dee0800bb" 152 | 153 | [[projects]] 154 | digest = "1:01d3c3dfb7ef9da24cd00cbd5b9e2c147fa7a3f44a83028bc737da9cac7ed60b" 155 | name = "gopkg.in/urfave/cli.v1" 156 | packages = ["."] 157 | pruneopts = "UT" 158 | revision = "0bdeddeeb0f650497d603c4ad7b20cfe685682f6" 159 | version = "v1.19.1" 160 | 161 | [solve-meta] 162 | analyzer-name = "dep" 163 | analyzer-version = 1 164 | input-imports = [ 165 | "github.com/aprice/embed/cmd/embed", 166 | "github.com/aprice/embed/loader", 167 | "github.com/boltdb/bolt", 168 | "github.com/eclipse/paho.mqtt.golang", 169 | "github.com/goburrow/modbus", 170 | "github.com/gorilla/handlers", 171 | "github.com/gorilla/mux", 172 | "github.com/gorilla/websocket", 173 | "github.com/jcuga/golongpoll", 174 | "gopkg.in/urfave/cli.v1", 175 | ] 176 | solver-name = "gps-cdcl" 177 | solver-version = 1 178 | -------------------------------------------------------------------------------- /Gopkg.toml: -------------------------------------------------------------------------------- 1 | # Gopkg.toml example 2 | # 3 | # Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md 4 | # for detailed Gopkg.toml documentation. 5 | # 6 | # required = ["github.com/user/thing/cmd/thing"] 7 | # ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] 8 | # 9 | # [[constraint]] 10 | # name = "github.com/user/project" 11 | # version = "1.0.0" 12 | # 13 | # [[constraint]] 14 | # name = "github.com/user/project2" 15 | # branch = "dev" 16 | # source = "github.com/myfork/project2" 17 | # 18 | # [[override]] 19 | # name = "github.com/x/y" 20 | # version = "2.4.0" 21 | # 22 | # [prune] 23 | # non-go = false 24 | # go-tests = true 25 | # unused-packages = true 26 | 27 | 28 | [[constraint]] 29 | name = "github.com/eclipse/paho.mqtt.golang" 30 | version = "1.1.1" 31 | 32 | [[constraint]] 33 | name = "github.com/goburrow/modbus" 34 | version = "0.1" 35 | 36 | [[constraint]] 37 | name = "github.com/gorilla/websocket" 38 | version = "1.2.0" 39 | 40 | [[constraint]] 41 | branch = "master" 42 | name = "github.com/jcuga/golongpoll" 43 | 44 | [[constraint]] 45 | name = "gopkg.in/urfave/cli.v1" 46 | version = "1.19.1" 47 | 48 | [prune] 49 | go-tests = true 50 | unused-packages = true 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015-2017, Mathias Dalheimer 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of gosdm630 nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | 29 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PWD := $(patsubst %/,%,$(dir $(abspath $(lastword $(MAKEFILE_LIST))))) 2 | BIN := $(PWD)/bin 3 | BUILD := GOBIN=$(BIN) go install ./... 4 | GOPATH := $(shell go env GOPATH) 5 | 6 | all: build 7 | 8 | build: assets binaries 9 | 10 | binaries: 11 | @echo "Building for host platform" 12 | $(BUILD) 13 | @echo "Created binaries:" 14 | @ls -1 bin 15 | 16 | assets: 17 | @echo "Generating embedded assets" 18 | $(GOPATH)/bin/embed http.go 19 | 20 | release: test clean assets 21 | ./build.sh 22 | 23 | test: 24 | @echo "Running testsuite" 25 | env GO111MODULE=on go test 26 | 27 | clean: 28 | rm -rf bin/ pkg/ *.zip 29 | 30 | dep: 31 | @echo "Installing vendor dependencies" 32 | dep ensure 33 | 34 | @echo "Installing embed tool" 35 | env GO111MODULE=on go get github.com/aprice/embed/cmd/embed 36 | env GO111MODULE=on go install github.com/aprice/embed/cmd/embed 37 | 38 | .PHONY: all build binaries assets release test clean dep 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # An HTTP interface to MODBUS smart meters 2 | 3 | This project provides a http interface to smart 4 | meters with a MODBUS interface. Beside the EASTRON SDM series, the 5 | software also supports the Janitza B23 DIN-rail meters. The meters 6 | provide all measured values over an RS485 connection. The software reads 7 | the measurements and wraps them into a HTTP interface, making it very 8 | easy to integrate it into your home automation system. Both a REST-style 9 | API and a streaming API are available. 10 | 11 | **NOTE**: `gosdm` is not longer actively developed and has been archived. 12 | The development of `gosdm` is continued at [volkszaehler/mbmd](https://github.com/volkszaehler/mbmd) under the name `mbmd` which stands for ModBus Measurement Daemon. 13 | `mbmd` adds support for additional meters and grid inverters. 14 | 15 | ## Supported Devices 16 | 17 | The meters have slightly different capabilities. The EASTRON SDM630 offers 18 | a lot of features, while the smaller devices only support basic 19 | features. This table gives you an overview (please note: check the 20 | manuals for yourself, I could be wrong): 21 | 22 | | Meter | Phases | Voltage | Current | Power | Power Factor | Total Import | Total Export | Per-phase Import/Export | Line/Neutral THD | 23 | |---|---|---|---|---|---|---|---|---|---| 24 | | SDM120 | 1 | + | + | + | + | + | + | - | - | 25 | | SDM220 | 1 | + | + | + | + | + | + | - | - | 26 | | SDM220 | 1 | + | + | + | + | + | + | - | - | 27 | | SDM530 | 3 | + | + | + | + | + | + | - | - | 28 | | SDM630 v1 | 3 | + | + | + | + | + | + | + | + | 29 | | SDM630 v2 | 3 | + | + | + | + | + | + | + | + | 30 | | Janitza B23-312 | 3 | + | + | + | + | + | + | - | - | 31 | | DZG DVH4013 | 3 | + | + | - | - | + | + | - | - | 32 | | SBC ALE3 | 3 | + | + | + | + | + | + | - | - | 33 | 34 | Please note that voltage, current, power and power factor are always 35 | reported for each connected phase. 36 | 37 | * SDM120: Cheap and small (1TE), but communication parameters can only be set over MODBUS, 38 | which is currently not supported by this project. You can use e.g. 39 | [SDM120C](https://github.com/gianfrdp/SDM120C) to change parameters. 40 | * SDM220, SDM230: More comfortable (2TE), can be configured using the builtin display and 41 | button. 42 | * SDM530: Very big (7TE) - takes up a lot of space, but all connections are 43 | on the underside of the meter. 44 | * SDM630 v1 and v2, both MID and non-MID. Compact (4TE) and with lots 45 | of features. Can be configured for 1P2 (single phase with neutral), 3P3 46 | (three phase without neutral) and 3P4 (three phase with neutral) 47 | systems. 48 | * Janitza B23-312: These meters have a higher update rate than the Eastron 49 | devices, but they are more expensive. The -312 variant is the one with a MODBUS interface. 50 | * DZG DVH4013: This meter does not provide raw phase power measurements 51 | and only aggregated import/export measurements. The meter is only 52 | partially implemented and not recommended. If you want to use it: By 53 | default, the meter communicates using 9600 8E1 (comset 5). The meter ID 54 | is derived from the serial number: take the last two numbers of the 55 | serial number (top right of the device), e.g. 23, and add one (24). 56 | Assume this is a hexadecimal number and convert it to decimal (36). Use 57 | this as the meter ID. 58 | * SBC ALE3: This compact Saia Burgess Controls meter is comparable to the SDM630: 59 | two tariffs, both import and export depending on meter version and compact (4TE). 60 | It's often used with Viessmann heat pumps. 61 | 62 | Some of my test devices have been provided by [B+G 63 | E-Tech](http://bg-etech.de/) - please consider to buy your meter from 64 | them! 65 | 66 | # Table of Contents 67 | 68 | * [Installation](#installation) 69 | * [Hardware installation](#hardware-installation) 70 | * [Software installation](#software-installation) 71 | * [Running](#running) 72 | * [Installation on the Raspberry Pi](#installation-on-the-raspberry-pi) 73 | * [Detecting connected meters](#detecting-connected-meters) 74 | * [API](#api) 75 | * [Rest API](#rest-api) 76 | * [Streaming API](#streaming-api) 77 | * [OpenHAB integration](#openhab-integration) 78 | * [How does it look like in OpenHAB?](#how-does-it-look-like-in-openhab) 79 | 80 | 81 | # Installation 82 | 83 | The installation consists of a hardware and a software part. 84 | Make sure you buy/fetch the following things before starting: 85 | 86 | * A supported Modbus/RTU smart meter. 87 | * A USB RS485 adaptor. I use a homegrown one, please see [my 88 | USB-ISO-RS485 project](https://github.com/gonium/usb-iso-rs485) 89 | * Some cables to connect the adapter to the SDM630 (for testing, I use 90 | an old speaker cable I had sitting on my workbench, for the permanent 91 | installation, a shielded CAT5 cable seems adequate) 92 | 93 | 94 | ## Hardware installation 95 | 96 | ![SDM630 in my test setup](img/SDM630-MODBUS.jpg) 97 | 98 | First, you should integrate the meter into your fuse box. Please ask a 99 | professional to do this for you - I don't want you to hurt yourself! 100 | Refer to the meter installation manual on how to do this. You need to 101 | set the MODBUS communication parameters to ``9600 8N1``. 102 | After this you need to connect a RS485 adaptor to the meter. This is 103 | how I did the wiring: 104 | 105 | ![USB-SDM630 wiring](img/wiring.jpg) 106 | 107 | You can try to use a cheap USB-RS485 adaptor, or you can [build your own 108 | isolated adaptor](https://github.com/gonium/usb-iso-rs485). I did my 109 | first experiments with a [Digitus USB-RS485 110 | adaptor](http://www.digitus.info/en/products/accessories/adapter-and-converter/r-usb-serial-adapter-usb-20-da-70157/) 111 | which comes with a handy terminal block. I mounted the [bias 112 | network](https://en.wikipedia.org/wiki/RS-485) directly on the terminal 113 | block: 114 | 115 | ![bias network](img/USB-RS485-Adaptor.jpg) 116 | 117 | Since then, I tested various adaptors: 118 | 119 | * Supercheap adaptors from China: No ground connection, one worked fine, 120 | another one was unstable 121 | * Industrial adaptors like the [Meilhaus RedCOM 122 | USB-COMi-SI](https://www.meilhaus.de/usb-comi-si.htm) or the [ADAM 123 | 4561](http://www.advantech.com/products/gf-5u7m/adam-4561/mod_92dc04b1-c0fe-4f2b-baf6-5c27e79900c6) 124 | isolate the RS-485 bus from the USB line and work extremely reliable. 125 | But they are really expensive. 126 | 127 | I started to develop [my own isolated 128 | adaptor](https://github.com/gonium/usb-iso-rs485). Please check this 129 | link for more information. 130 | 131 | 132 | ## Software installation 133 | 134 | ### Using the precompiled binaries 135 | 136 | You can use the [precompiled releases](https://github.com/gonium/gosdm630/releases) if you like. Just 137 | download the right binary for your platform and unzip. 138 | 139 | ### Installing the software from source 140 | 141 | You need a working [Golang installation](http://golang.org), the [dep 142 | package management tool](https://github.com/golang/dep) and 143 | [Embed](http://github.com/aprice/embed) in order to compile your binary. 144 | Please install the Go compiler first. Then clone this repository: 145 | 146 | git clone https://github.com/gonium/gosdm630.git 147 | 148 | If you have ``make`` installed you can use the ``Makefile`` to install the tools: 149 | 150 | $ cd gosdm630 151 | $ make dep 152 | Installing embed tool 153 | Installing dep tool 154 | 155 | You can then build the software using the ``Makefile``: 156 | 157 | $ make 158 | Generating embedded assets 159 | Generation complete in 109.907612ms 160 | Building for host platform 161 | Created binaries: 162 | sdm630 163 | sdm630 164 | sdm630_logger 165 | sdm630_monitor 166 | sdm_detect 167 | 168 | As you can see two sets of binaries are built: 169 | 170 | * ``bin/sdm630_{...}`` is the software built for the host platform 171 | * ``bin/sdm630_{...}-linux-arm`` is the same for the Raspberry Pi. 172 | 173 | If you want to build for all platforms you can use 174 | 175 | $ make release 176 | 177 | or, for a single platform like the Raspberry Pi binary, use 178 | 179 | $ GOOS=linux GOARCH=arm GOARM=5 make build 180 | 181 | 182 | ## Running 183 | 184 | Now fire up the software: 185 | 186 | ```` 187 | $ ./bin/sdm630 --help 188 | NAME: 189 | sdm - SDM modbus daemon 190 | 191 | USAGE: 192 | sdm630 [global options] command [command options] [arguments...] 193 | 194 | COMMANDS: 195 | help, h Shows a list of commands or help for one command 196 | 197 | GLOBAL OPTIONS: 198 | --serialadapter value, -s value path to serial RTU device (default: "/dev/ttyUSB0") 199 | --comset value, -c value which communication parameter set to use. Valid sets are 200 | 1: 2400 baud, 8N1 201 | 2: 9600 baud, 8N1 202 | 3: 19200 baud, 8N1 203 | 4: 2400 baud, 8E1 204 | 5: 9600 baud, 8E1 205 | 6: 19200 baud, 8E1 206 | (default: 2) 207 | --device_list value, -d value MODBUS device type and ID to query, separated by comma. 208 | Valid types are: 209 | "SDM" for Eastron SDM meters 210 | "JANITZA" for Janitza B-Series DIN-Rail meters 211 | "DZG" for the DZG Metering GmbH DVH4013 DIN-Rail meter 212 | Example: -d JANITZA:1,SDM:22,DZG:23 (default: "SDM:1") 213 | --unique_id_format value, -f value Unique ID format. 214 | Example: -f Instrument%d 215 | The %d is replaced by the device ID (default: "Instrument%d") 216 | --verbose, -v print verbose messages 217 | --url value, -u value the URL the server should respond on (default: ":8080") 218 | --broker value, -b value MQTT: The broker URI. ex: tcp://10.10.1.1:1883 219 | --topic value, -t value MQTT: The topic name to/from which to publish/subscribe (optional) (default: "sdm630") 220 | --user value MQTT: The User (optional) 221 | --password value MQTT: The password (optional) 222 | --clientid value, -i value MQTT: The ClientID (optional) (default: "sdm630") 223 | --rate value, -r value MQTT: The maximum update rate (default 0, i.e. unlimited) (after a push we will ignore more data from same device andchannel for this time) (default: 0) 224 | --clean, -l MQTT: Set Clean Session (default false) 225 | --qos value, -q value MQTT: The Quality of Service 0,1,2 (default 0) (default: 0) 226 | --help, -h show help 227 | ```` 228 | 229 | A typical invocation looks like this: 230 | 231 | $ ./bin/sdm630 -s /dev/ttyUSB0 -d janitza:26,sdm:1 232 | 2017/01/25 16:34:26 Connecting to RTU via /dev/ttyUSB0 233 | 2017/01/25 16:34:26 Starting API at :8080 234 | 235 | This call queries a Janitza B23 meter with ID 26 and an Eastron SDM 236 | meter at ID 1. It . If you use the ``-v`` commandline switch you can see 237 | modbus traffic and the current readings on the command line. At 238 | [http://localhost:8080](http://localhost:8080) you can see an embedded 239 | web page that updates itself with the latest values: 240 | 241 | ![realtime view of incoming measurements](img/realtimeview.png) 242 | 243 | 244 | ## Installation on the Raspberry Pi 245 | 246 | You simply copy the binary from the ``bin`` subdirectory to the RPi 247 | and start it. I usually put the binary into ``/usr/local/bin`` and 248 | rename it to ``sdm630``. The following sytemd unit can be used to 249 | start the service (put this into ``/etc/systemd/system``): 250 | 251 | [Unit] 252 | Description=SDM630 via HTTP API 253 | After=syslog.target 254 | [Service] 255 | ExecStart=/usr/local/bin/sdm630 -s /dev/ttyAMA0 256 | Restart=always 257 | [Install] 258 | WantedBy=multi-user.target 259 | 260 | You might need to adjust the ``-s`` parameter depending on where your 261 | RS485 adapter is connected. Then, use 262 | 263 | # systemctl start sdm630 264 | 265 | to test your installation. If you're satisfied use 266 | 267 | # systemctl enable sdm630 268 | 269 | to start the service at boot time automatically. 270 | 271 | *WARNING:* If you use an FTDI-based USB-RS485 adaptor you might see the 272 | Raspberry Pi becoming unreachable after a while. This is most likely not 273 | an issue with your RS485-USB adaptor or this software, but because of [a 274 | bug in the Raspberry Pi kernel](https://github.com/raspberrypi/linux/issues/1187). 275 | As mentioned there, add the following parameter to your 276 | ``/boot/cmdline.txt``: 277 | 278 | dwc_otg.speed=1 279 | 280 | This switches the internal ``dwc`` USB hub of the Raspberry Pi to 281 | USB1.1. While this reduces the available USB speed, the device now works 282 | reliably. 283 | 284 | 285 | ## Detecting connected meters 286 | 287 | MODBUS/RTU does not provide a mechanism to discover devices. There is no 288 | reliable way to detect all attached devices. The ``sdm_detect`` tool 289 | attempts to read the L1 voltage from all valid device IDs and reports 290 | which one replied correctly: 291 | 292 | ```` 293 | ./bin/sdm_detect 294 | 2017/06/21 10:22:34 Starting bus scan 295 | 2017/06/21 10:22:35 Device 1: n/a 296 | ... 297 | 2017/07/27 16:16:39 Device 21: SDM type device found, L1 voltage: 234.86 298 | 2017/07/27 16:16:40 Device 22: n/a 299 | 2017/07/27 16:16:40 Device 23: n/a 300 | 2017/07/27 16:16:40 Device 24: n/a 301 | 2017/07/27 16:16:40 Device 25: n/a 302 | 2017/07/27 16:16:40 Device 26: Janitza type device found, L1 voltage: 235.10 303 | ... 304 | 2017/07/27 16:17:25 Device 247: n/a 305 | 2017/07/27 16:17:25 Found 2 active devices: 306 | 2017/07/27 16:17:25 * slave address 21: type SDM 307 | 2017/07/27 16:17:25 * slave address 26: type JANITZA 308 | 2017/07/27 16:17:25 WARNING: This lists only the devices that responded to a known L1 voltage request. Devices with different function code definitions might not be detected. 309 | ```` 310 | 311 | 312 | # API 313 | 314 | ## Rest API 315 | 316 | GoSDM provides a convenient REST API. Supported endpoints are: 317 | 318 | * `/last/{ID}` current data for device 319 | * `/minuteavg/{ID}` averaged data for device 320 | * `/status` daemon status 321 | 322 | Both device APIs can also be called without the device id to return data for all connected devices. 323 | 324 | The "GET /last/{ID}"-call simply returns the last measurements of the device with 325 | the Modbus ID {ID}: 326 | 327 | ```` 328 | $ curl localhost:8080/last/11 329 | { 330 | "Timestamp": "2017-03-27T15:15:09.243729874+02:00", 331 | "Unix": 1490620509, 332 | "ModbusDeviceId": 11, 333 | "Power": { 334 | "L1": 0, 335 | "L2": -45.28234100341797, 336 | "L3": 0 337 | }, 338 | "Voltage": { 339 | "L1": 233.1257781982422, 340 | "L2": 233.12904357910156, 341 | "L3": 0 342 | }, 343 | "Current": { 344 | "L1": 0, 345 | "L2": 0.19502629339694977, 346 | "L3": 0 347 | }, 348 | "Cosphi": { 349 | "L1": 1, 350 | "L2": -0.9995147585868835, 351 | "L3": 1 352 | }, 353 | "Import": { 354 | "L1": 0.16599999368190765, 355 | "L2": 0.10999999940395355, 356 | "L3": 0.0010000000474974513 357 | }, 358 | "TotalImport": 0.2770000100135803, 359 | "Export": { 360 | "L1": 0, 361 | "L2": 0.3019999861717224, 362 | "L3": 0 363 | }, 364 | "TotalExport": 0.3019999861717224, 365 | "THD": { 366 | "VoltageNeutral": { 367 | "L1": 0, 368 | "L2": 0, 369 | "L3": 0 370 | }, 371 | "AvgVoltageNeutral": 0 372 | } 373 | } 374 | ```` 375 | 376 | The "GET /minuteavg"-call returns the average measurements over the last 377 | minute: 378 | 379 | ```` 380 | $ curl localhost:8080/minuteavg/11 381 | { 382 | "Timestamp": "2017-03-27T15:19:06.470316939+02:00", 383 | "Unix": 1490620746, 384 | "ModbusDeviceId": 11, 385 | "Power": { 386 | "L1": 0, 387 | "L2": -45.333974165794174, 388 | "L3": 0 389 | }, 390 | ... 391 | } 392 | ```` 393 | 394 | ### Monitoring 395 | 396 | The `/status` endpoint provides the following information: 397 | 398 | $ curl http://localhost:8080/status 399 | { 400 | "Starttime": "2017-01-25T16:35:50.839829945+01:00", 401 | "UptimeSeconds": 65587.177092186, 402 | "Goroutines": 11, 403 | "Memory": { 404 | "Alloc": 1568344, 405 | "HeapAlloc": 1568344 406 | }, 407 | "Modbus": { 408 | "TotalModbusRequests": 1979122, 409 | "ModbusRequestRatePerMinute": 1810.5264666764785, 410 | "TotalModbusErrors": 738, 411 | "ModbusErrorRatePerMinute": 0.6751319688261972 412 | }, 413 | "ConfiguredMeters": [ 414 | { 415 | "Id": 26, 416 | "Type": "JANITZA", 417 | "Status": "available" 418 | } 419 | ] 420 | } 421 | 422 | This is a snapshot of a process running over night, along with the error 423 | statistics during that timeframe. The process queries continuously, 424 | the cabling is not a shielded, twisted wire but something that I had laying 425 | around. With proper cabling the error rate should be lower, though. 426 | 427 | 428 | ## Streaming API 429 | 430 | GoSDM supports both websockets and long polling to transfer status and 431 | meter updates to connected clients. 432 | 433 | Data read from the smart meter can be observed by clients in realtime: 434 | as soon as a new value is available, you will be notified. 435 | 436 | ### Websocket API 437 | 438 | Websocket API is available on `/ws`. All connected clients receive status and meter 439 | updates for all connected meters without further subscription. 440 | 441 | ### Long polling API 442 | 443 | **NOTE** Usage of the long polling API is discouraged for performance reasons. The long polling is only supported with `sdm630_http`, not with the newer `sdm630`. 444 | 445 | We're using [HTTP Long Polling as described in RFC6202](https://tools.ietf.org/html/rfc6202) 446 | for the data transfer. This essentially means that you can connect to an HTTP endpoint. The server 447 | will accept the connection and send you the new values as soon as they 448 | are available. Then, you either reconnect or use the same TCP connection 449 | for the next request. If you want to get all values, you can do the 450 | following: 451 | 452 | $ while true; do curl --silent "http://localhost:8080/firehose?timeout=45&category=meterupdates" | jq; done 453 | 454 | This requests the last values in a loop with curl and pipes the result 455 | through jq. Of course this also closes the connection after each reply, 456 | so this is rather costly. In production you can leave the connection 457 | intact and reuse it. A resulting reading looks like this: 458 | 459 | ````json 460 | { 461 | "events": [ 462 | { 463 | "timestamp": 1490605909544, 464 | "category": "all", 465 | "data": { 466 | "DeviceId": 12, 467 | "Value": 0.054999999701976776, 468 | "IEC61850": "TotkWhExportPhsB", 469 | "Description": "L2 Export (kWh)", 470 | "ReadTimestamp": "2017-03-27T11:11:49.544236817+02:00" 471 | } 472 | } 473 | ] 474 | } 475 | 476 | ```` 477 | 478 | Please note that the ``events`` structure is formatted by the long 479 | polling library we use. The ``data`` element contains the information 480 | just read from the MODBUS device. Events are emitted as soon as they are 481 | received over the serial connection. 482 | 483 | In addition, you can also use the firehose to receive status updates: 484 | 485 | ```` 486 | $ while true; do curl --silent "http://localhost:8080/firehose?timeout=45&category=statusupdate" | jq; done 487 | ```` 488 | 489 | responds each second with the current status, e.g. 490 | 491 | ````json 492 | { 493 | "events": [ 494 | { 495 | "timestamp": 1501163437772, 496 | "category": "statusupdate", 497 | "data": { 498 | "Starttime": "2017-07-27T10:21:04.790877012+02:00", 499 | "UptimeSeconds": 10.000907389, 500 | "Goroutines": 22, 501 | "Memory": { 502 | "Alloc": 3605376, 503 | "HeapAlloc": 3605376 504 | }, 505 | "Modbus": { 506 | "TotalModbusRequests": 325, 507 | "ModbusRequestRatePerMinute": 1943.823619582965, 508 | "TotalModbusErrors": 0, 509 | "ModbusErrorRatePerMinute": 0 510 | }, 511 | "ConfiguredMeters": [ 512 | { 513 | "Id": 26, 514 | "Type": "JANITZA", 515 | "Status": "available" 516 | } 517 | ] 518 | } 519 | } 520 | ] 521 | } 522 | 523 | ```` 524 | 525 | ### Stream Utilities 526 | 527 | We provide a simple command line utility to monitor single devices. If you 528 | run 529 | 530 | $ ./bin/sdm630_monitor -d sdm:23 -u localhost:8080 531 | 532 | it will connect to the firehose and print power readings for device 23. 533 | Please note that this is all it does, the monitor can serve as a 534 | starting point for your own experiments. 535 | 536 | If you want to log data in the highest possible resolution you can use 537 | the ``sdm630_logger`` command: 538 | 539 | ```` 540 | $ sdm630_logger record -s 120 -f log.db 541 | ```` 542 | 543 | This will connect to the ``sdm630`` process on localhost and 544 | serialize all measurements into ``log.db``. Received values will be 545 | cached for 120 seconds and then written in bulk. We use 546 | [BoltDB](https://github.com/boltdb/bolt) for data storage in order to 547 | minimize runtime dependencies. You can use the ``inspect`` subcommand to 548 | get some information about the database: 549 | 550 | ```` 551 | $ ./bin/sdm630_logger inspect -f log.db 552 | Found 529 records: 553 | * First recorded on 2017-03-22 11:17:39.911271769 +0100 CET 554 | * Last recorded on 2017-03-22 11:39:10.099236381 +0100 CET 555 | ```` 556 | 557 | If you want to export the dataset to TSV, you can use the ``export`` 558 | subcommand: 559 | 560 | ```` 561 | ./bin/sdm630_logger export -t log.tsv -f log.db 562 | 2017/03/27 11:22:23 Exported 529 records. 563 | ```` 564 | 565 | The ``sdm630_logger`` tool is still under development and lacks certain 566 | features: 567 | 568 | * The storage functions are rather inefficient and require a lot of 569 | storage. 570 | * The TSV export currently only exports the power readings. 571 | 572 | 573 | # OpenHAB integration 574 | 575 | *Please note: The following integration guide was written for OpenHAB 576 | 1.8. We currently do not have an OpenHAB 2.x instructions, but would 577 | appreciate any contributions.* 578 | 579 | It is very easy to translate this into OpenHAB items. I run the SDM630 580 | software on a Raspberry Pi with the IP ``192.168.1.44``. My items look 581 | like this: 582 | 583 | Group Power_Chart 584 | Number Power_L1 "Strombezug L1 [%.1f W]" (Power, Power_Chart) { http="<[http://192.168.1.44:8080/last/1:60000:JS(SDM630GetL1Power.js)]" } 585 | 586 | I'm using the http plugin to call the ``/last/1`` endpoint every 60 587 | seconds. Then, I feed the result into a JSON transform stored in 588 | ``SDM630GetL1Power.js``. The contents of 589 | ``transform/SDM630GetL1Power.js`` looks like this: 590 | 591 | JSON.parse(input).Power.L1; 592 | 593 | Just repeat these lines for each measurement you want to track. Finally, 594 | my sitemap contains the following lines: 595 | 596 | Chart item=Power_Chart period=D refresh=1800 597 | 598 | This draws a chart of all items in the ``Power_Chart`` group. 599 | 600 | ## How does it look like in OpenHAB? 601 | 602 | I use [OpenHAB 1.8](http://openhab.org) to record various measurements at 603 | home. In the classic ui, this is how one of the graphs looks like: 604 | 605 | ![OpenHAB interface screenshot](img/openhab.png) 606 | 607 | Everything is in German, but the "Verlauf Strombezug" graph shows my 608 | power consumption for three phases. I have a SDM630 installed in my 609 | distribution cabinet. A serial connection links it to a Raspberry Pi 610 | (RPi). 611 | This is where this piece of software runs and exposes the measurements 612 | via a RESTful API. OpenHAB connects to it and stores the values, just as 613 | it does with other sensors in my home. 614 | -------------------------------------------------------------------------------- /assets/css/app.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding-top: 5rem; 3 | } 4 | .container > div { 5 | padding-top: 3rem; 6 | padding-bottom: 3rem; 7 | } 8 | /* bigger container for smaller viewports */ 9 | @media (max-width: 768px) { 10 | .container { 11 | width: 100%!important; 12 | max-width: 9999px!important; 13 | } 14 | .container > div { 15 | padding-top: 0.5rem; 16 | padding-bottom: 0.5rem; 17 | } 18 | } 19 | table { 20 | text-align: left; 21 | } 22 | /* omit first th/td per row */ 23 | table th + th, table td + td { 24 | text-align: right; 25 | } 26 | -------------------------------------------------------------------------------- /assets/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | GoSDM630 7 | 8 | 9 | 10 | 11 | 12 | 13 | 39 | 40 |
41 |
42 |

Realtime measurements

43 |
44 |

${ message }

45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 |
Meter ${ idx }L1L2L3Total
Voltage (V)${ meter.VolLocPhsA }${ meter.VolLocPhsB }${ meter.VolLocPhsC }-
Current (A)${ meter.AmpLocPhsA }${ meter.AmpLocPhsB }${ meter.AmpLocPhsB }${ (parseFloat(meter.AmpLocPhsA) + parseFloat(meter.AmpLocPhsB) + parseFloat(meter.AmpLocPhsB)).toFixed(2) }
Power (W)${ meter.WLocPhsA }${ meter.WLocPhsB }${ meter.WLocPhsB }${ (parseFloat(meter.WLocPhsA) + parseFloat(meter.WLocPhsB) + parseFloat(meter.WLocPhsB)).toFixed(2) }
Import (kWh)${ meter.TotkWhImportPhsA }${ meter.TotkWhImportPhsB }${ meter.TotkWhImportPhsB }${ meter.TotkWhImport }
Export (kWh)${ meter.TotkWhExportPhsA }${ meter.TotkWhExportPhsB }${ meter.TotkWhExportPhsB }${ meter.TotkWhExport }
Power Factor${ meter.AngLocPhsA }${ meter.AngLocPhsB }${ meter.AngLocPhsB }-
THD Voltage (%)${ meter.ThdVolPhsA }${ meter.ThdVolPhsB }${ meter.ThdVolPhsB }${ meter.ThdVol }
Frequency (Hz)---${ meter.Frequency }
114 |
115 |
116 |
117 |

Current Meter Status

118 |
119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 |
Meter IDTypeStatus
${ meter.Id }${ meter.Type }${ meter.Status }
135 |
136 |
137 | 138 |
139 |

About GoSDM630

140 |

GoSDM630 is an interface for the Eastron SDM/Modbus Smart Meter series. Please 141 | refer to the documentation 142 | for more information.

143 |

This installation runs GoSDM630 version {{.SoftwareVersion}} (compiled with {{.GolangVersion}})

144 |
145 |
146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | -------------------------------------------------------------------------------- /assets/js/app.js: -------------------------------------------------------------------------------- 1 | var meterapp = new Vue({ 2 | el: '#meters', 3 | delimiters: ['${', '}'], 4 | data: { 5 | meters: {}, 6 | message: 'Loading...' 7 | } 8 | }) 9 | 10 | var timeapp = new Vue({ 11 | el: '#time', 12 | delimiters: ['${', '}'], 13 | data: { 14 | time: 'n/a', 15 | date: 'n/a' 16 | } 17 | }) 18 | 19 | var statusapp = new Vue({ 20 | el: '#status', 21 | delimiters: ['${', '}'], 22 | data: { 23 | meterstatus: {} 24 | } 25 | }) 26 | 27 | var fixed = d3.format(".2f") 28 | var si = d3.format("~s") 29 | 30 | $().ready(function () { 31 | connectSocket(); 32 | }); 33 | 34 | function convert_date(unixtimestamp){ 35 | var date = new Date(unixtimestamp); 36 | var day = "0" + date.getDate(); 37 | var month = "0" + date.getMonth(); 38 | var year = date.getFullYear(); 39 | return year + '/' + month.substr(-2) + '/' + day.substr(-2); 40 | } 41 | 42 | function convert_time(unixtimestamp){ 43 | var date = new Date(unixtimestamp); 44 | var hours = date.getHours(); 45 | var minutes = "0" + date.getMinutes(); 46 | var seconds = "0" + date.getSeconds(); 47 | return hours + ':' + minutes.substr(-2) + ':' + seconds.substr(-2); 48 | } 49 | 50 | function statusUpdate(meter) { 51 | var meterid = meter["Id"] 52 | var metertype = meter["Type"] 53 | var meterstatus = meter["Status"] 54 | 55 | // update data table 56 | var datadict = statusapp.meterstatus[meterid] 57 | if (!datadict) { 58 | // this is the first time we touch this meter, create an 59 | // empty dict 60 | var datadict = {} 61 | } 62 | 63 | datadict["Id"] = meter["Id"] 64 | datadict["Type"] = meter["Type"] 65 | datadict["Status"] = meter["Status"] 66 | 67 | // make update reactive, see 68 | // https://vuejs.org/v2/guide/reactivity.html#Change-Detection-Caveats 69 | Vue.set(statusapp.meterstatus, meterid, datadict) 70 | } 71 | 72 | function meterUpdate(data) { 73 | timeapp.time = convert_time(data["Timestamp"]) 74 | timeapp.date = convert_date(data["Timestamp"]) 75 | 76 | // extract the last update 77 | var id = data["DeviceId"] 78 | var iec61850 = data["IEC61850"] 79 | var reading = fixed(data["Value"]) 80 | // put into statusline 81 | meterapp.message = "Received #" + id + " / " + iec61850 + ": " + si(reading) 82 | // update data table 83 | var datadict = meterapp.meters[id] 84 | if (!datadict) { 85 | // this is the first time we touch this meter, create an 86 | // empty dict 87 | var datadict = {} 88 | } 89 | datadict[iec61850] = reading 90 | // make update reactive, see 91 | // https://vuejs.org/v2/guide/reactivity.html#Change-Detection-Caveats 92 | Vue.set(meterapp.meters, id, datadict) 93 | } 94 | 95 | function processMessage(data) { 96 | if (data.Modbus) { 97 | if (data.ConfiguredMeters && data.ConfiguredMeters.length) { 98 | statusUpdate(data.ConfiguredMeters[0]); 99 | } 100 | } 101 | else if (data.DeviceId) { 102 | meterUpdate(data); 103 | } 104 | } 105 | 106 | function connectSocket() { 107 | var ws, loc = window.location; 108 | var protocol = loc.protocol == "https:" ? "wss:" : "ws:" 109 | 110 | ws = new WebSocket(protocol + "//" + loc.hostname + (loc.port ? ":" + loc.port : "") + "/ws"); 111 | 112 | ws.onerror = function(evt) { 113 | // console.warn("Connection error"); 114 | ws.close(); 115 | } 116 | ws.onclose = function (evt) { 117 | // console.warn("Connection closed"); 118 | window.setTimeout(connectSocket, 100); 119 | }; 120 | ws.onmessage = function (evt) { 121 | var json = JSON.parse(evt.data); 122 | processMessage(json); 123 | }; 124 | } 125 | -------------------------------------------------------------------------------- /assets/js/bootstrap.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap v4.1.1 (https://getbootstrap.com/) 3 | * Copyright 2011-2018 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors) 4 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/master/LICENSE) 5 | */ 6 | !function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports,require("jquery"),require("popper.js")):"function"==typeof define&&define.amd?define(["exports","jquery","popper.js"],e):e(t.bootstrap={},t.jQuery,t.Popper)}(this,function(t,e,c){"use strict";function i(t,e){for(var n=0;nthis._items.length-1||t<0))if(this._isSliding)P(this._element).one(Q.SLID,function(){return e.to(t)});else{if(n===t)return this.pause(),void this.cycle();var i=ndocument.documentElement.clientHeight;!this._isBodyOverflowing&&t&&(this._element.style.paddingLeft=this._scrollbarWidth+"px"),this._isBodyOverflowing&&!t&&(this._element.style.paddingRight=this._scrollbarWidth+"px")},t._resetAdjustments=function(){this._element.style.paddingLeft="",this._element.style.paddingRight=""},t._checkScrollbar=function(){var t=document.body.getBoundingClientRect();this._isBodyOverflowing=t.left+t.right
',trigger:"hover focus",title:"",delay:0,html:!(_e={AUTO:"auto",TOP:"top",RIGHT:"right",BOTTOM:"bottom",LEFT:"left"}),selector:!(de={animation:"boolean",template:"string",title:"(string|element|function)",trigger:"string",delay:"(number|object)",html:"boolean",selector:"(string|boolean)",placement:"(string|function)",offset:"(number|string)",container:"(string|element|boolean)",fallbackPlacement:"(string|array)",boundary:"(string|element)"}),placement:"top",offset:0,container:!1,fallbackPlacement:"flip",boundary:"scrollParent"},pe="out",ve={HIDE:"hide"+he,HIDDEN:"hidden"+he,SHOW:(me="show")+he,SHOWN:"shown"+he,INSERTED:"inserted"+he,CLICK:"click"+he,FOCUSIN:"focusin"+he,FOCUSOUT:"focusout"+he,MOUSEENTER:"mouseenter"+he,MOUSELEAVE:"mouseleave"+he},Ee="fade",ye="show",Te=".tooltip-inner",Ce=".arrow",Ie="hover",Ae="focus",De="click",be="manual",Se=function(){function i(t,e){if("undefined"==typeof c)throw new TypeError("Bootstrap tooltips require Popper.js (https://popper.js.org)");this._isEnabled=!0,this._timeout=0,this._hoverState="",this._activeTrigger={},this._popper=null,this.element=t,this.config=this._getConfig(e),this.tip=null,this._setListeners()}var t=i.prototype;return t.enable=function(){this._isEnabled=!0},t.disable=function(){this._isEnabled=!1},t.toggleEnabled=function(){this._isEnabled=!this._isEnabled},t.toggle=function(t){if(this._isEnabled)if(t){var e=this.constructor.DATA_KEY,n=oe(t.currentTarget).data(e);n||(n=new this.constructor(t.currentTarget,this._getDelegateConfig()),oe(t.currentTarget).data(e,n)),n._activeTrigger.click=!n._activeTrigger.click,n._isWithActiveTrigger()?n._enter(null,n):n._leave(null,n)}else{if(oe(this.getTipElement()).hasClass(ye))return void this._leave(null,this);this._enter(null,this)}},t.dispose=function(){clearTimeout(this._timeout),oe.removeData(this.element,this.constructor.DATA_KEY),oe(this.element).off(this.constructor.EVENT_KEY),oe(this.element).closest(".modal").off("hide.bs.modal"),this.tip&&oe(this.tip).remove(),this._isEnabled=null,this._timeout=null,this._hoverState=null,(this._activeTrigger=null)!==this._popper&&this._popper.destroy(),this._popper=null,this.element=null,this.config=null,this.tip=null},t.show=function(){var e=this;if("none"===oe(this.element).css("display"))throw new Error("Please use show on visible elements");var t=oe.Event(this.constructor.Event.SHOW);if(this.isWithContent()&&this._isEnabled){oe(this.element).trigger(t);var n=oe.contains(this.element.ownerDocument.documentElement,this.element);if(t.isDefaultPrevented()||!n)return;var i=this.getTipElement(),r=Cn.getUID(this.constructor.NAME);i.setAttribute("id",r),this.element.setAttribute("aria-describedby",r),this.setContent(),this.config.animation&&oe(i).addClass(Ee);var s="function"==typeof this.config.placement?this.config.placement.call(this,i,this.element):this.config.placement,o=this._getAttachment(s);this.addAttachmentClass(o);var a=!1===this.config.container?document.body:oe(this.config.container);oe(i).data(this.constructor.DATA_KEY,this),oe.contains(this.element.ownerDocument.documentElement,this.tip)||oe(i).appendTo(a),oe(this.element).trigger(this.constructor.Event.INSERTED),this._popper=new c(this.element,i,{placement:o,modifiers:{offset:{offset:this.config.offset},flip:{behavior:this.config.fallbackPlacement},arrow:{element:Ce},preventOverflow:{boundariesElement:this.config.boundary}},onCreate:function(t){t.originalPlacement!==t.placement&&e._handlePopperPlacementChange(t)},onUpdate:function(t){e._handlePopperPlacementChange(t)}}),oe(i).addClass(ye),"ontouchstart"in document.documentElement&&oe(document.body).children().on("mouseover",null,oe.noop);var l=function(){e.config.animation&&e._fixTransition();var t=e._hoverState;e._hoverState=null,oe(e.element).trigger(e.constructor.Event.SHOWN),t===pe&&e._leave(null,e)};if(oe(this.tip).hasClass(Ee)){var h=Cn.getTransitionDurationFromElement(this.tip);oe(this.tip).one(Cn.TRANSITION_END,l).emulateTransitionEnd(h)}else l()}},t.hide=function(t){var e=this,n=this.getTipElement(),i=oe.Event(this.constructor.Event.HIDE),r=function(){e._hoverState!==me&&n.parentNode&&n.parentNode.removeChild(n),e._cleanTipClass(),e.element.removeAttribute("aria-describedby"),oe(e.element).trigger(e.constructor.Event.HIDDEN),null!==e._popper&&e._popper.destroy(),t&&t()};if(oe(this.element).trigger(i),!i.isDefaultPrevented()){if(oe(n).removeClass(ye),"ontouchstart"in document.documentElement&&oe(document.body).children().off("mouseover",null,oe.noop),this._activeTrigger[De]=!1,this._activeTrigger[Ae]=!1,this._activeTrigger[Ie]=!1,oe(this.tip).hasClass(Ee)){var s=Cn.getTransitionDurationFromElement(n);oe(n).one(Cn.TRANSITION_END,r).emulateTransitionEnd(s)}else r();this._hoverState=""}},t.update=function(){null!==this._popper&&this._popper.scheduleUpdate()},t.isWithContent=function(){return Boolean(this.getTitle())},t.addAttachmentClass=function(t){oe(this.getTipElement()).addClass(ue+"-"+t)},t.getTipElement=function(){return this.tip=this.tip||oe(this.config.template)[0],this.tip},t.setContent=function(){var t=oe(this.getTipElement());this.setElementContent(t.find(Te),this.getTitle()),t.removeClass(Ee+" "+ye)},t.setElementContent=function(t,e){var n=this.config.html;"object"==typeof e&&(e.nodeType||e.jquery)?n?oe(e).parent().is(t)||t.empty().append(e):t.text(oe(e).text()):t[n?"html":"text"](e)},t.getTitle=function(){var t=this.element.getAttribute("data-original-title");return t||(t="function"==typeof this.config.title?this.config.title.call(this.element):this.config.title),t},t._getAttachment=function(t){return _e[t.toUpperCase()]},t._setListeners=function(){var i=this;this.config.trigger.split(" ").forEach(function(t){if("click"===t)oe(i.element).on(i.constructor.Event.CLICK,i.config.selector,function(t){return i.toggle(t)});else if(t!==be){var e=t===Ie?i.constructor.Event.MOUSEENTER:i.constructor.Event.FOCUSIN,n=t===Ie?i.constructor.Event.MOUSELEAVE:i.constructor.Event.FOCUSOUT;oe(i.element).on(e,i.config.selector,function(t){return i._enter(t)}).on(n,i.config.selector,function(t){return i._leave(t)})}oe(i.element).closest(".modal").on("hide.bs.modal",function(){return i.hide()})}),this.config.selector?this.config=h({},this.config,{trigger:"manual",selector:""}):this._fixTitle()},t._fixTitle=function(){var t=typeof this.element.getAttribute("data-original-title");(this.element.getAttribute("title")||"string"!==t)&&(this.element.setAttribute("data-original-title",this.element.getAttribute("title")||""),this.element.setAttribute("title",""))},t._enter=function(t,e){var n=this.constructor.DATA_KEY;(e=e||oe(t.currentTarget).data(n))||(e=new this.constructor(t.currentTarget,this._getDelegateConfig()),oe(t.currentTarget).data(n,e)),t&&(e._activeTrigger["focusin"===t.type?Ae:Ie]=!0),oe(e.getTipElement()).hasClass(ye)||e._hoverState===me?e._hoverState=me:(clearTimeout(e._timeout),e._hoverState=me,e.config.delay&&e.config.delay.show?e._timeout=setTimeout(function(){e._hoverState===me&&e.show()},e.config.delay.show):e.show())},t._leave=function(t,e){var n=this.constructor.DATA_KEY;(e=e||oe(t.currentTarget).data(n))||(e=new this.constructor(t.currentTarget,this._getDelegateConfig()),oe(t.currentTarget).data(n,e)),t&&(e._activeTrigger["focusout"===t.type?Ae:Ie]=!1),e._isWithActiveTrigger()||(clearTimeout(e._timeout),e._hoverState=pe,e.config.delay&&e.config.delay.hide?e._timeout=setTimeout(function(){e._hoverState===pe&&e.hide()},e.config.delay.hide):e.hide())},t._isWithActiveTrigger=function(){for(var t in this._activeTrigger)if(this._activeTrigger[t])return!0;return!1},t._getConfig=function(t){return"number"==typeof(t=h({},this.constructor.Default,oe(this.element).data(),"object"==typeof t&&t?t:{})).delay&&(t.delay={show:t.delay,hide:t.delay}),"number"==typeof t.title&&(t.title=t.title.toString()),"number"==typeof t.content&&(t.content=t.content.toString()),Cn.typeCheckConfig(ae,t,this.constructor.DefaultType),t},t._getDelegateConfig=function(){var t={};if(this.config)for(var e in this.config)this.constructor.Default[e]!==this.config[e]&&(t[e]=this.config[e]);return t},t._cleanTipClass=function(){var t=oe(this.getTipElement()),e=t.attr("class").match(fe);null!==e&&0

'}),He=h({},Nn.DefaultType,{content:"(string|element|function)"}),We="fade",xe=".popover-header",Ue=".popover-body",Ke={HIDE:"hide"+ke,HIDDEN:"hidden"+ke,SHOW:(Me="show")+ke,SHOWN:"shown"+ke,INSERTED:"inserted"+ke,CLICK:"click"+ke,FOCUSIN:"focusin"+ke,FOCUSOUT:"focusout"+ke,MOUSEENTER:"mouseenter"+ke,MOUSELEAVE:"mouseleave"+ke},Fe=function(t){var e,n;function i(){return t.apply(this,arguments)||this}n=t,(e=i).prototype=Object.create(n.prototype),(e.prototype.constructor=e).__proto__=n;var r=i.prototype;return r.isWithContent=function(){return this.getTitle()||this._getContent()},r.addAttachmentClass=function(t){we(this.getTipElement()).addClass(Le+"-"+t)},r.getTipElement=function(){return this.tip=this.tip||we(this.config.template)[0],this.tip},r.setContent=function(){var t=we(this.getTipElement());this.setElementContent(t.find(xe),this.getTitle());var e=this._getContent();"function"==typeof e&&(e=e.call(this.element)),this.setElementContent(t.find(Ue),e),t.removeClass(We+" "+Me)},r._getContent=function(){return this.element.getAttribute("data-content")||this.config.content},r._cleanTipClass=function(){var t=we(this.getTipElement()),e=t.attr("class").match(je);null!==e&&0=this._offsets[r]&&("undefined"==typeof this._offsets[r+1]||t1?e[0]+e.slice(2):e,+t.slice(r+1)]}function r(t){return(t=n(Math.abs(t)))?t[1]:NaN}var e,i=/^(?:(.)?([<>=^]))?([+\-( ])?([$#])?(0)?(\d+)?(,)?(\.\d+)?(~)?([a-z%])?$/i;function o(t){return new a(t)}function a(t){if(!(n=i.exec(t)))throw new Error("invalid format: "+t);var n;this.fill=n[1]||" ",this.align=n[2]||">",this.sign=n[3]||"-",this.symbol=n[4]||"",this.zero=!!n[5],this.width=n[6]&&+n[6],this.comma=!!n[7],this.precision=n[8]&&+n[8].slice(1),this.trim=!!n[9],this.type=n[10]||""}function u(t,r){var e=n(t,r);if(!e)return t+"";var i=e[0],o=e[1];return o<0?"0."+new Array(-o).join("0")+i:i.length>o+1?i.slice(0,o+1)+"."+i.slice(o+1):i+new Array(o-i.length+2).join("0")}o.prototype=a.prototype,a.prototype.toString=function(){return this.fill+this.align+this.sign+this.symbol+(this.zero?"0":"")+(null==this.width?"":Math.max(1,0|this.width))+(this.comma?",":"")+(null==this.precision?"":"."+Math.max(0,0|this.precision))+(this.trim?"~":"")+this.type};var s={"%":function(t,n){return(100*t).toFixed(n)},b:function(t){return Math.round(t).toString(2)},c:function(t){return t+""},d:function(t){return Math.round(t).toString(10)},e:function(t,n){return t.toExponential(n)},f:function(t,n){return t.toFixed(n)},g:function(t,n){return t.toPrecision(n)},o:function(t){return Math.round(t).toString(8)},p:function(t,n){return u(100*t,n)},r:u,s:function(t,r){var i=n(t,r);if(!i)return t+"";var o=i[0],a=i[1],u=a-(e=3*Math.max(-8,Math.min(8,Math.floor(a/3))))+1,s=o.length;return u===s?o:u>s?o+new Array(u-s+1).join("0"):u>0?o.slice(0,u)+"."+o.slice(u):"0."+new Array(1-u).join("0")+n(t,Math.max(0,r+u-1))[0]},X:function(t){return Math.round(t).toString(16).toUpperCase()},x:function(t){return Math.round(t).toString(16)}};function c(t){return t}var f,h=["y","z","a","f","p","n","µ","m","","k","M","G","T","P","E","Z","Y"];function l(t){var n,i,a=t.grouping&&t.thousands?(n=t.grouping,i=t.thousands,function(t,r){for(var e=t.length,o=[],a=0,u=n[0],s=0;e>0&&u>0&&(s+u+1>r&&(u=Math.max(1,r-s)),o.push(t.substring(e-=u,e+u)),!((s+=u+1)>r));)u=n[a=(a+1)%n.length];return o.reverse().join(i)}):c,u=t.currency,f=t.decimal,l=t.numerals?function(t){return function(n){return n.replace(/[0-9]/g,function(n){return t[+n]})}}(t.numerals):c,m=t.percent||"%";function p(t){var n=(t=o(t)).fill,r=t.align,i=t.sign,c=t.symbol,p=t.zero,g=t.width,d=t.comma,M=t.precision,x=t.trim,y=t.type;"n"===y?(d=!0,y="g"):s[y]||(null==M&&(M=12),x=!0,y="g"),(p||"0"===n&&"="===r)&&(p=!0,n="0",r="=");var b="$"===c?u[0]:"#"===c&&/[boxX]/.test(y)?"0"+y.toLowerCase():"",v="$"===c?u[1]:/[%p]/.test(y)?m:"",w=s[y],j=/[defgprs%]/.test(y);function k(t){var o,u,s,c=b,m=v;if("c"===y)m=w(t)+m,t="";else{var k=(t=+t)<0;if(t=w(Math.abs(t),M),x&&(t=function(t){t:for(var n,r=t.length,e=1,i=-1;e0){if(!+t[e])break t;i=0}}return i>0?t.slice(0,i)+t.slice(n+1):t}(t)),k&&0==+t&&(k=!1),c=(k?"("===i?i:"-":"-"===i||"("===i?"":i)+c,m=("s"===y?h[8+e/3]:"")+m+(k&&"("===i?")":""),j)for(o=-1,u=t.length;++o(s=t.charCodeAt(o))||s>57){m=(46===s?f+t.slice(o+1):t.slice(o))+m,t=t.slice(0,o);break}}d&&!p&&(t=a(t,1/0));var S=c.length+t.length+m.length,P=S>1)+c+t+m+P.slice(S);break;default:t=P+c+t+m}return l(t)}return M=null==M?6:/[gprs]/.test(y)?Math.max(1,Math.min(21,M)):Math.max(0,Math.min(20,M)),k.toString=function(){return t+""},k}return{format:p,formatPrefix:function(t,n){var e=p(((t=o(t)).type="f",t)),i=3*Math.max(-8,Math.min(8,Math.floor(r(n)/3))),a=Math.pow(10,-i),u=h[8+i/3];return function(t){return e(a*t)+u}}}}function m(n){return f=l(n),t.format=f.format,t.formatPrefix=f.formatPrefix,f}m({decimal:".",thousands:",",grouping:[3],currency:["$",""]}),t.formatDefaultLocale=m,t.formatLocale=l,t.formatSpecifier=o,t.precisionFixed=function(t){return Math.max(0,-r(Math.abs(t)))},t.precisionPrefix=function(t,n){return Math.max(0,3*Math.max(-8,Math.min(8,Math.floor(r(n)/3)))-r(Math.abs(t)))},t.precisionRound=function(t,n){return t=Math.abs(t),n=Math.abs(n)-t,Math.max(0,r(n)-r(t))+1},Object.defineProperty(t,"__esModule",{value:!0})}); 3 | -------------------------------------------------------------------------------- /assets/js/tether.min.js: -------------------------------------------------------------------------------- 1 | !function(t,e){"function"==typeof define&&define.amd?define(e):"object"==typeof exports?module.exports=e(require,exports,module):t.Tether=e()}(this,function(t,e,o){"use strict";function n(t,e){if(!(t instanceof e))throw new TypeError("Cannot call a class as a function")}function i(t){var e=t.getBoundingClientRect(),o={};for(var n in e)o[n]=e[n];if(t.ownerDocument!==document){var r=t.ownerDocument.defaultView.frameElement;if(r){var s=i(r);o.top+=s.top,o.bottom+=s.top,o.left+=s.left,o.right+=s.left}}return o}function r(t){var e=getComputedStyle(t)||{},o=e.position,n=[];if("fixed"===o)return[t];for(var i=t;(i=i.parentNode)&&i&&1===i.nodeType;){var r=void 0;try{r=getComputedStyle(i)}catch(s){}if("undefined"==typeof r||null===r)return n.push(i),n;var a=r,f=a.overflow,l=a.overflowX,h=a.overflowY;/(auto|scroll)/.test(f+h+l)&&("absolute"!==o||["relative","absolute","fixed"].indexOf(r.position)>=0)&&n.push(i)}return n.push(t.ownerDocument.body),t.ownerDocument!==document&&n.push(t.ownerDocument.defaultView),n}function s(){A&&document.body.removeChild(A),A=null}function a(t){var e=void 0;t===document?(e=document,t=document.documentElement):e=t.ownerDocument;var o=e.documentElement,n=i(t),r=P();return n.top-=r.top,n.left-=r.left,"undefined"==typeof n.width&&(n.width=document.body.scrollWidth-n.left-n.right),"undefined"==typeof n.height&&(n.height=document.body.scrollHeight-n.top-n.bottom),n.top=n.top-o.clientTop,n.left=n.left-o.clientLeft,n.right=e.body.clientWidth-n.width-n.left,n.bottom=e.body.clientHeight-n.height-n.top,n}function f(t){return t.offsetParent||document.documentElement}function l(){var t=document.createElement("div");t.style.width="100%",t.style.height="200px";var e=document.createElement("div");h(e.style,{position:"absolute",top:0,left:0,pointerEvents:"none",visibility:"hidden",width:"200px",height:"150px",overflow:"hidden"}),e.appendChild(t),document.body.appendChild(e);var o=t.offsetWidth;e.style.overflow="scroll";var n=t.offsetWidth;o===n&&(n=e.clientWidth),document.body.removeChild(e);var i=o-n;return{width:i,height:i}}function h(){var t=arguments.length<=0||void 0===arguments[0]?{}:arguments[0],e=[];return Array.prototype.push.apply(e,arguments),e.slice(1).forEach(function(e){if(e)for(var o in e)({}).hasOwnProperty.call(e,o)&&(t[o]=e[o])}),t}function u(t,e){if("undefined"!=typeof t.classList)e.split(" ").forEach(function(e){e.trim()&&t.classList.remove(e)});else{var o=new RegExp("(^| )"+e.split(" ").join("|")+"( |$)","gi"),n=c(t).replace(o," ");g(t,n)}}function d(t,e){if("undefined"!=typeof t.classList)e.split(" ").forEach(function(e){e.trim()&&t.classList.add(e)});else{u(t,e);var o=c(t)+(" "+e);g(t,o)}}function p(t,e){if("undefined"!=typeof t.classList)return t.classList.contains(e);var o=c(t);return new RegExp("(^| )"+e+"( |$)","gi").test(o)}function c(t){return t.className instanceof t.ownerDocument.defaultView.SVGAnimatedString?t.className.baseVal:t.className}function g(t,e){t.setAttribute("class",e)}function m(t,e,o){o.forEach(function(o){-1===e.indexOf(o)&&p(t,o)&&u(t,o)}),e.forEach(function(e){p(t,e)||d(t,e)})}function n(t,e){if(!(t instanceof e))throw new TypeError("Cannot call a class as a function")}function v(t,e){if("function"!=typeof e&&null!==e)throw new TypeError("Super expression must either be null or a function, not "+typeof e);t.prototype=Object.create(e&&e.prototype,{constructor:{value:t,enumerable:!1,writable:!0,configurable:!0}}),e&&(Object.setPrototypeOf?Object.setPrototypeOf(t,e):t.__proto__=e)}function y(t,e){var o=arguments.length<=2||void 0===arguments[2]?1:arguments[2];return t+o>=e&&e>=t-o}function b(){return"undefined"!=typeof performance&&"undefined"!=typeof performance.now?performance.now():+new Date}function w(){for(var t={top:0,left:0},e=arguments.length,o=Array(e),n=0;e>n;n++)o[n]=arguments[n];return o.forEach(function(e){var o=e.top,n=e.left;"string"==typeof o&&(o=parseFloat(o,10)),"string"==typeof n&&(n=parseFloat(n,10)),t.top+=o,t.left+=n}),t}function C(t,e){return"string"==typeof t.left&&-1!==t.left.indexOf("%")&&(t.left=parseFloat(t.left,10)/100*e.width),"string"==typeof t.top&&-1!==t.top.indexOf("%")&&(t.top=parseFloat(t.top,10)/100*e.height),t}function O(t,e){return"scrollParent"===e?e=t.scrollParents[0]:"window"===e&&(e=[pageXOffset,pageYOffset,innerWidth+pageXOffset,innerHeight+pageYOffset]),e===document&&(e=e.documentElement),"undefined"!=typeof e.nodeType&&!function(){var t=e,o=a(e),n=o,i=getComputedStyle(e);if(e=[n.left,n.top,o.width+n.left,o.height+n.top],t.ownerDocument!==document){var r=t.ownerDocument.defaultView;e[0]+=r.pageXOffset,e[1]+=r.pageYOffset,e[2]+=r.pageXOffset,e[3]+=r.pageYOffset}$.forEach(function(t,o){t=t[0].toUpperCase()+t.substr(1),"Top"===t||"Left"===t?e[o]+=parseFloat(i["border"+t+"Width"]):e[o]-=parseFloat(i["border"+t+"Width"])})}(),e}var E=function(){function t(t,e){for(var o=0;o1?o-1:0),i=1;o>i;i++)n[i-1]=arguments[i];for(;e16?(e=Math.min(e-16,250),void(o=setTimeout(i,250))):void("undefined"!=typeof t&&b()-t<10||(null!=o&&(clearTimeout(o),o=null),t=b(),D(),e=b()-t))};"undefined"!=typeof window&&"undefined"!=typeof window.addEventListener&&["resize","scroll","touchmove"].forEach(function(t){window.addEventListener(t,n)})}();var X={center:"center",left:"right",right:"left"},F={middle:"middle",top:"bottom",bottom:"top"},H={top:0,left:0,middle:"50%",center:"50%",bottom:"100%",right:"100%"},N=function(t,e){var o=t.left,n=t.top;return"auto"===o&&(o=X[e.left]),"auto"===n&&(n=F[e.top]),{left:o,top:n}},U=function(t){var e=t.left,o=t.top;return"undefined"!=typeof H[t.left]&&(e=H[t.left]),"undefined"!=typeof H[t.top]&&(o=H[t.top]),{left:e,top:o}},V=function(t){var e=t.split(" "),o=B(e,2),n=o[0],i=o[1];return{top:n,left:i}},R=V,q=function(t){function e(t){var o=this;n(this,e),z(Object.getPrototypeOf(e.prototype),"constructor",this).call(this),this.position=this.position.bind(this),L.push(this),this.history=[],this.setOptions(t,!1),x.modules.forEach(function(t){"undefined"!=typeof t.initialize&&t.initialize.call(o)}),this.position()}return v(e,t),E(e,[{key:"getClass",value:function(){var t=arguments.length<=0||void 0===arguments[0]?"":arguments[0],e=this.options.classes;return"undefined"!=typeof e&&e[t]?this.options.classes[t]:this.options.classPrefix?this.options.classPrefix+"-"+t:t}},{key:"setOptions",value:function(t){var e=this,o=arguments.length<=1||void 0===arguments[1]?!0:arguments[1],n={offset:"0 0",targetOffset:"0 0",targetAttachment:"auto auto",classPrefix:"tether"};this.options=h(n,t);var i=this.options,s=i.element,a=i.target,f=i.targetModifier;if(this.element=s,this.target=a,this.targetModifier=f,"viewport"===this.target?(this.target=document.body,this.targetModifier="visible"):"scroll-handle"===this.target&&(this.target=document.body,this.targetModifier="scroll-handle"),["element","target"].forEach(function(t){if("undefined"==typeof e[t])throw new Error("Tether Error: Both element and target must be defined");"undefined"!=typeof e[t].jquery?e[t]=e[t][0]:"string"==typeof e[t]&&(e[t]=document.querySelector(e[t]))}),d(this.element,this.getClass("element")),this.options.addTargetClasses!==!1&&d(this.target,this.getClass("target")),!this.options.attachment)throw new Error("Tether Error: You must provide an attachment");this.targetAttachment=R(this.options.targetAttachment),this.attachment=R(this.options.attachment),this.offset=V(this.options.offset),this.targetOffset=V(this.options.targetOffset),"undefined"!=typeof this.scrollParents&&this.disable(),"scroll-handle"===this.targetModifier?this.scrollParents=[this.target]:this.scrollParents=r(this.target),this.options.enabled!==!1&&this.enable(o)}},{key:"getTargetBounds",value:function(){if("undefined"==typeof this.targetModifier)return a(this.target);if("visible"===this.targetModifier){if(this.target===document.body)return{top:pageYOffset,left:pageXOffset,height:innerHeight,width:innerWidth};var t=a(this.target),e={height:t.height,width:t.width,top:t.top,left:t.left};return e.height=Math.min(e.height,t.height-(pageYOffset-t.top)),e.height=Math.min(e.height,t.height-(t.top+t.height-(pageYOffset+innerHeight))),e.height=Math.min(innerHeight,e.height),e.height-=2,e.width=Math.min(e.width,t.width-(pageXOffset-t.left)),e.width=Math.min(e.width,t.width-(t.left+t.width-(pageXOffset+innerWidth))),e.width=Math.min(innerWidth,e.width),e.width-=2,e.topo.clientWidth||[n.overflow,n.overflowX].indexOf("scroll")>=0||this.target!==document.body,r=0;i&&(r=15);var s=t.height-parseFloat(n.borderTopWidth)-parseFloat(n.borderBottomWidth)-r,e={width:15,height:.975*s*(s/o.scrollHeight),left:t.left+t.width-parseFloat(n.borderLeftWidth)-15},f=0;408>s&&this.target===document.body&&(f=-11e-5*Math.pow(s,2)-.00727*s+22.58),this.target!==document.body&&(e.height=Math.max(e.height,24));var l=this.target.scrollTop/(o.scrollHeight-s);return e.top=l*(s-e.height-f)+t.top+parseFloat(n.borderTopWidth),this.target===document.body&&(e.height=Math.max(e.height,24)),e}}},{key:"clearCache",value:function(){this._cache={}}},{key:"cache",value:function(t,e){return"undefined"==typeof this._cache&&(this._cache={}),"undefined"==typeof this._cache[t]&&(this._cache[t]=e.call(this)),this._cache[t]}},{key:"enable",value:function(){var t=this,e=arguments.length<=0||void 0===arguments[0]?!0:arguments[0];this.options.addTargetClasses!==!1&&d(this.target,this.getClass("enabled")),d(this.element,this.getClass("enabled")),this.enabled=!0,this.scrollParents.forEach(function(e){e!==t.target.ownerDocument&&e.addEventListener("scroll",t.position)}),e&&this.position()}},{key:"disable",value:function(){var t=this;u(this.target,this.getClass("enabled")),u(this.element,this.getClass("enabled")),this.enabled=!1,"undefined"!=typeof this.scrollParents&&this.scrollParents.forEach(function(e){e.removeEventListener("scroll",t.position)})}},{key:"destroy",value:function(){var t=this;this.disable(),L.forEach(function(e,o){e===t&&L.splice(o,1)}),0===L.length&&s()}},{key:"updateAttachClasses",value:function(t,e){var o=this;t=t||this.attachment,e=e||this.targetAttachment;var n=["left","top","bottom","right","middle","center"];"undefined"!=typeof this._addAttachClasses&&this._addAttachClasses.length&&this._addAttachClasses.splice(0,this._addAttachClasses.length),"undefined"==typeof this._addAttachClasses&&(this._addAttachClasses=[]);var i=this._addAttachClasses;t.top&&i.push(this.getClass("element-attached")+"-"+t.top),t.left&&i.push(this.getClass("element-attached")+"-"+t.left),e.top&&i.push(this.getClass("target-attached")+"-"+e.top),e.left&&i.push(this.getClass("target-attached")+"-"+e.left);var r=[];n.forEach(function(t){r.push(o.getClass("element-attached")+"-"+t),r.push(o.getClass("target-attached")+"-"+t)}),M(function(){"undefined"!=typeof o._addAttachClasses&&(m(o.element,o._addAttachClasses,r),o.options.addTargetClasses!==!1&&m(o.target,o._addAttachClasses,r),delete o._addAttachClasses)})}},{key:"position",value:function(){var t=this,e=arguments.length<=0||void 0===arguments[0]?!0:arguments[0];if(this.enabled){this.clearCache();var o=N(this.targetAttachment,this.attachment);this.updateAttachClasses(this.attachment,o);var n=this.cache("element-bounds",function(){return a(t.element)}),i=n.width,r=n.height;if(0===i&&0===r&&"undefined"!=typeof this.lastSize){var s=this.lastSize;i=s.width,r=s.height}else this.lastSize={width:i,height:r};var h=this.cache("target-bounds",function(){return t.getTargetBounds()}),u=h,d=C(U(this.attachment),{width:i,height:r}),p=C(U(o),u),c=C(this.offset,{width:i,height:r}),g=C(this.targetOffset,u);d=w(d,c),p=w(p,g);for(var m=h.left+p.left-d.left,v=h.top+p.top-d.top,y=0;yT.innerWidth&&(S=this.cache("scrollbar-size",l),E.viewport.bottom-=S.height),A.body.scrollHeight>T.innerHeight&&(S=this.cache("scrollbar-size",l),E.viewport.right-=S.width),(-1===["","static"].indexOf(A.body.style.position)||-1===["","static"].indexOf(A.body.parentElement.style.position))&&(E.page.bottom=A.body.scrollHeight-v-r,E.page.right=A.body.scrollWidth-m-i),"undefined"!=typeof this.options.optimizations&&this.options.optimizations.moveElement!==!1&&"undefined"==typeof this.targetModifier&&!function(){var e=t.cache("target-offsetparent",function(){return f(t.target)}),o=t.cache("target-offsetparent-bounds",function(){return a(e)}),n=getComputedStyle(e),i=o,r={};if(["Top","Left","Bottom","Right"].forEach(function(t){r[t.toLowerCase()]=parseFloat(n["border"+t+"Width"])}),o.right=A.body.scrollWidth-o.left-i.width+r.right,o.bottom=A.body.scrollHeight-o.top-i.height+r.bottom,E.page.top>=o.top+r.top&&E.page.bottom>=o.bottom&&E.page.left>=o.left+r.left&&E.page.right>=o.right){var s=e.scrollTop,l=e.scrollLeft;E.offset={top:E.page.top-o.top+s-r.top,left:E.page.left-o.left+l-r.left}}}(),this.move(E),this.history.unshift(E),this.history.length>3&&this.history.pop(),e&&_(),!0}}},{key:"move",value:function(t){var e=this;if("undefined"!=typeof this.element.parentNode){var o={};for(var n in t){o[n]={};for(var i in t[n]){for(var r=!1,s=0;s=0){var c=a.split(" "),m=B(c,2);u=m[0],h=m[1]}else h=u=a;var b=O(e,r);("target"===u||"both"===u)&&(ob[3]&&"bottom"===v.top&&(o-=d,v.top="top")),"together"===u&&("top"===v.top&&("bottom"===y.top&&ob[3]&&o-(s-d)>=b[1]&&(o-=s-d,v.top="bottom",y.top="bottom")),"bottom"===v.top&&("top"===y.top&&o+s>b[3]?(o-=d,v.top="top",o-=s,y.top="bottom"):"bottom"===y.top&&ob[3]&&"top"===y.top?(o-=s,y.top="bottom"):ob[2]&&"right"===v.left&&(n-=p,v.left="left")),"together"===h&&(nb[2]&&"right"===v.left?"left"===y.left?(n-=p,v.left="left",n-=f,y.left="right"):"right"===y.left&&(n-=p,v.left="left",n+=f,y.left="left"):"center"===v.left&&(n+f>b[2]&&"left"===y.left?(n-=f,y.left="right"):nb[3]&&"top"===y.top&&(o-=s,y.top="bottom")),("element"===h||"both"===h)&&(nb[2]&&("left"===y.left?(n-=f,y.left="right"):"center"===y.left&&(n-=f/2,y.left="right"))),"string"==typeof l?l=l.split(",").map(function(t){return t.trim()}):l===!0&&(l=["top","left","right","bottom"]),l=l||[];var w=[],C=[];o=0?(o=b[1],w.push("top")):C.push("top")),o+s>b[3]&&(l.indexOf("bottom")>=0?(o=b[3]-s,w.push("bottom")):C.push("bottom")),n=0?(n=b[0],w.push("left")):C.push("left")),n+f>b[2]&&(l.indexOf("right")>=0?(n=b[2]-f,w.push("right")):C.push("right")),w.length&&!function(){var t=void 0;t="undefined"!=typeof e.options.pinnedClass?e.options.pinnedClass:e.getClass("pinned"),g.push(t),w.forEach(function(e){g.push(t+"-"+e)})}(),C.length&&!function(){var t=void 0;t="undefined"!=typeof e.options.outOfBoundsClass?e.options.outOfBoundsClass:e.getClass("out-of-bounds"),g.push(t),C.forEach(function(e){g.push(t+"-"+e)})}(),(w.indexOf("left")>=0||w.indexOf("right")>=0)&&(y.left=v.left=!1),(w.indexOf("top")>=0||w.indexOf("bottom")>=0)&&(y.top=v.top=!1),(v.top!==i.top||v.left!==i.left||y.top!==e.attachment.top||y.left!==e.attachment.left)&&(e.updateAttachClasses(y,v),e.trigger("update",{attachment:y,targetAttachment:v}))}),M(function(){e.options.addTargetClasses!==!1&&m(e.target,g,c),m(e.element,g,c)}),{top:o,left:n}}});var j=x.Utils,a=j.getBounds,m=j.updateClasses,M=j.defer;x.modules.push({position:function(t){var e=this,o=t.top,n=t.left,i=this.cache("element-bounds",function(){return a(e.element)}),r=i.height,s=i.width,f=this.getTargetBounds(),l=o+r,h=n+s,u=[];o<=f.bottom&&l>=f.top&&["left","right"].forEach(function(t){var e=f[t];(e===n||e===h)&&u.push(t)}),n<=f.right&&h>=f.left&&["top","bottom"].forEach(function(t){var e=f[t];(e===o||e===l)&&u.push(t)});var d=[],p=[],c=["left","top","right","bottom"];return d.push(this.getClass("abutted")),c.forEach(function(t){d.push(e.getClass("abutted")+"-"+t)}),u.length&&p.push(this.getClass("abutted")),u.forEach(function(t){p.push(e.getClass("abutted")+"-"+t)}),M(function(){e.options.addTargetClasses!==!1&&m(e.target,p,d),m(e.element,p,d)}),!0}});var B=function(){function t(t,e){var o=[],n=!0,i=!1,r=void 0;try{for(var s,a=t[Symbol.iterator]();!(n=(s=a.next()).done)&&(o.push(s.value),!e||o.length!==e);n=!0);}catch(f){i=!0,r=f}finally{try{!n&&a["return"]&&a["return"]()}finally{if(i)throw r}}return o}return function(e,o){if(Array.isArray(e))return e;if(Symbol.iterator in Object(e))return t(e,o);throw new TypeError("Invalid attempt to destructure non-iterable instance")}}();return x.modules.push({position:function(t){var e=t.top,o=t.left;if(this.options.shift){var n=this.options.shift;"function"==typeof this.options.shift&&(n=this.options.shift.call(this,{top:e,left:o}));var i=void 0,r=void 0;if("string"==typeof n){n=n.split(" "),n[1]=n[1]||n[0];var s=n,a=B(s,2);i=a[0],r=a[1],i=parseFloat(i,10),r=parseFloat(r,10)}else i=n.top,r=n.left;return e+=i,o+=r,{top:e,left:o}}}}),I}); -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | RELEASE=$(pwd)/release 4 | BIN=$(pwd)/bin 5 | CMDS=$(go list -f '{{if (eq .Name "main")}}{{.ImportPath}}{{end}}' ./...) 6 | 7 | mkdir -p $RELEASE 8 | mkdir -p $BIN 9 | if ls *.zip 1>/dev/null 2>&1; then rm *.zip; fi 10 | 11 | function build { 12 | GOOS=$1 13 | GOARCH=$2 14 | GOARM=$3 15 | 16 | cd $BIN 17 | if [ ! -z "$(ls -A .)" ]; then rm *; fi 18 | for i in $CMDS; do 19 | # echo GOOS=$GOOS GOARCH=$GOARCH GOARM=$GOARM go build $i 20 | GOOS=$GOOS GOARCH=$GOARCH GOARM=$GOARM GO111MODULE=on go build $i 21 | done 22 | cd .. 23 | 24 | zip $RELEASE/sdm630-$GOOS-$GOARCH $BIN/* 25 | } 26 | 27 | echo "Building for ..." 28 | echo "... Linux/32bit" 29 | build linux 386 30 | echo "... Linux/64bit" 31 | build linux amd64 32 | echo "... Raspberry Pi/Linux" 33 | build linux arm 5 34 | echo "... Mac OS/64bit" 35 | build darwin amd64 36 | echo "... Windows/32bit" 37 | build windows 386 38 | echo "... Windows/64bit" 39 | build windows amd64 40 | -------------------------------------------------------------------------------- /cmd/sdm630/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "strconv" 7 | "strings" 8 | "time" 9 | 10 | . "github.com/gonium/gosdm630" 11 | . "github.com/gonium/gosdm630/internal/meters" 12 | "gopkg.in/urfave/cli.v1" 13 | ) 14 | 15 | const ( 16 | DEFAULT_METER_STORE_SECONDS = 120 * time.Second 17 | ) 18 | 19 | func main() { 20 | app := cli.NewApp() 21 | app.Name = "sdm" 22 | app.Usage = "SDM modbus daemon" 23 | app.Version = RELEASEVERSION 24 | app.HideVersion = true 25 | app.Flags = []cli.Flag{ 26 | // general 27 | cli.StringFlag{ 28 | Name: "serialadapter, s", 29 | Value: "/dev/ttyUSB0", 30 | Usage: "path to serial RTU device", 31 | }, 32 | cli.IntFlag{ 33 | Name: "comset, c", 34 | Value: ModbusComset9600_8N1, 35 | Usage: `which communication parameter set to use. Valid sets are 36 | ` + strconv.Itoa(ModbusComset2400_8N1) + `: 2400 baud, 8N1 37 | ` + strconv.Itoa(ModbusComset9600_8N1) + `: 9600 baud, 8N1 38 | ` + strconv.Itoa(ModbusComset19200_8N1) + `: 19200 baud, 8N1 39 | ` + strconv.Itoa(ModbusComset2400_8E1) + `: 2400 baud, 8E1 40 | ` + strconv.Itoa(ModbusComset9600_8E1) + `: 9600 baud, 8E1 41 | ` + strconv.Itoa(ModbusComset19200_8E1) + `: 19200 baud, 8E1 42 | `, 43 | }, 44 | cli.StringFlag{ 45 | Name: "device_list, d", 46 | Value: "SDM:1", 47 | Usage: `MODBUS device type and ID to query, separated by comma. 48 | Valid types are: 49 | "SDM" for Eastron SDM meters 50 | "JANITZA" for Janitza B-Series meters 51 | "DZG" for the DZG Metering GmbH DVH4013 meters 52 | "SBC" for the Saia Burgess Controls ALE3 meters 53 | Example: -d JANITZA:1,SDM:22,DZG:23`, 54 | }, 55 | cli.StringFlag{ 56 | Name: "unique_id_format, f", 57 | Value: "Meter#%d", 58 | Usage: `Unique ID format. 59 | Example: -f Meter#%d 60 | The %d is replaced by the device ID`, 61 | }, 62 | cli.BoolFlag{ 63 | Name: "verbose, v", 64 | Usage: "print verbose messages", 65 | }, 66 | 67 | // http api 68 | cli.StringFlag{ 69 | Name: "url, u", 70 | Value: ":8080", 71 | Usage: "the URL the server should respond on", 72 | }, 73 | 74 | // mqtt api 75 | cli.StringFlag{ 76 | Name: "broker, b", 77 | Value: "", 78 | Usage: "MQTT: Broker URI. ex: tcp://10.10.1.1:1883", 79 | // Destination: &mqttBroker, 80 | }, 81 | cli.StringFlag{ 82 | Name: "topic, t", 83 | Value: "sdm630", 84 | Usage: "MQTT: Topic name to/from which to publish/subscribe (optional)", 85 | // Destination: &mqttTopic, 86 | }, 87 | cli.StringFlag{ 88 | Name: "user", 89 | Value: "", 90 | Usage: "MQTT: User (optional)", 91 | // Destination: &mqttUser, 92 | }, 93 | cli.StringFlag{ 94 | Name: "password", 95 | Value: "", 96 | Usage: "MQTT: Password (optional)", 97 | // Destination: &mqttPassword, 98 | }, 99 | cli.StringFlag{ 100 | Name: "clientid, i", 101 | Value: "sdm630", 102 | Usage: "MQTT: ClientID (optional)", 103 | // Destination: &mqttClientID, 104 | }, 105 | cli.IntFlag{ 106 | Name: "rate, r", 107 | Value: 0, 108 | Usage: "MQTT: Maximum update rate (default 0, i.e. unlimited) (after a push we will ignore more data from same device and channel for this time)", 109 | // Destination: &mqttRate, 110 | }, 111 | cli.BoolFlag{ 112 | Name: "clean, l", 113 | Usage: "MQTT: Set Clean Session (default false)", 114 | // Destination: &mqttCleanSession, 115 | }, 116 | cli.IntFlag{ 117 | Name: "qos, q", 118 | Value: 0, 119 | Usage: "MQTT: Quality of Service 0,1,2 (default 0)", 120 | // Destination: &mqttQos, 121 | }, 122 | } 123 | 124 | app.Action = func(c *cli.Context) { 125 | // Set unique ID format 126 | UniqueIdFormat = c.String("unique_id_format") 127 | 128 | // Parse the device_list parameter 129 | deviceslice := strings.Split(c.String("device_list"), ",") 130 | meters := make(map[uint8]*Meter) 131 | for _, meterdef := range deviceslice { 132 | splitdef := strings.Split(meterdef, ":") 133 | if len(splitdef) != 2 { 134 | log.Fatalf("Cannot parse device definition %s. See -h for help.", meterdef) 135 | } 136 | metertype, devid := splitdef[0], splitdef[1] 137 | id, err := strconv.Atoi(devid) 138 | if err != nil { 139 | log.Fatalf("Error parsing device id %s: %s. See -h for help.", meterdef, err.Error()) 140 | } 141 | meter, err := NewMeterByType(metertype, uint8(id)) 142 | if err != nil { 143 | log.Fatalf("Unknown meter type %s for device %d. See -h for help.", metertype, id) 144 | } 145 | meters[uint8(id)] = meter 146 | } 147 | 148 | // create ModbusEngine with status 149 | status := NewStatus(meters) 150 | qe := NewModbusEngine( 151 | c.String("serialadapter"), 152 | c.Int("comset"), 153 | c.Bool("verbose"), 154 | status, 155 | ) 156 | 157 | // scheduler and meter data channel 158 | scheduler, snips := SetupScheduler(meters, qe) 159 | go scheduler.Run() 160 | 161 | // tee that broadcasts meter messages to multiple recipients 162 | tee := NewQuerySnipBroadcaster(snips) 163 | go tee.Run() 164 | 165 | // longpoll firehose 166 | var firehose *Firehose 167 | if false { 168 | firehose = NewFirehose( 169 | tee.Attach(), 170 | status, 171 | c.Bool("verbose")) 172 | go firehose.Run() 173 | } 174 | 175 | // websocket hub 176 | hub := NewSocketHub(tee.Attach(), status) 177 | go hub.Run() 178 | 179 | // MQTT client 180 | if c.String("broker") != "" { 181 | mqtt := NewMqttClient( 182 | tee.Attach(), 183 | c.String("broker"), 184 | c.String("topic"), 185 | c.String("user"), 186 | c.String("password"), 187 | c.String("clientid"), 188 | c.Int("qos"), 189 | c.Int("rate"), 190 | c.Bool("clean"), 191 | c.Bool("verbose")) 192 | go mqtt.Run() 193 | } 194 | 195 | // MeasurementCache for REST API 196 | mc := NewMeasurementCache( 197 | meters, 198 | tee.Attach(), 199 | scheduler, 200 | DEFAULT_METER_STORE_SECONDS, 201 | c.Bool("verbose"), 202 | ) 203 | go mc.Consume() 204 | 205 | Run_httpd( 206 | mc, 207 | firehose, 208 | hub, 209 | status, 210 | c.String("url"), 211 | ) 212 | } 213 | 214 | app.Run(os.Args) 215 | } 216 | -------------------------------------------------------------------------------- /cmd/sdm630_httpd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "strconv" 7 | "strings" 8 | "time" 9 | 10 | . "github.com/gonium/gosdm630" 11 | . "github.com/gonium/gosdm630/internal/meters" 12 | "gopkg.in/urfave/cli.v1" 13 | ) 14 | 15 | const ( 16 | DEFAULT_METER_STORE_SECONDS = 120 * time.Second 17 | ) 18 | 19 | func main() { 20 | app := cli.NewApp() 21 | app.Name = "sdm630_httpd" 22 | app.Usage = "SDM630 power measurements via HTTP." 23 | app.Version = RELEASEVERSION 24 | app.HideVersion = true 25 | app.Flags = []cli.Flag{ 26 | cli.StringFlag{ 27 | Name: "serialadapter, s", 28 | Value: "/dev/ttyUSB0", 29 | Usage: "path to serial RTU device", 30 | }, 31 | cli.IntFlag{ 32 | Name: "comset, c", 33 | Value: ModbusComset9600_8N1, 34 | Usage: `which communication parameter set to use. Valid sets are 35 | ` + strconv.Itoa(ModbusComset2400_8N1) + `: 2400 baud, 8N1 36 | ` + strconv.Itoa(ModbusComset9600_8N1) + `: 9600 baud, 8N1 37 | ` + strconv.Itoa(ModbusComset19200_8N1) + `: 19200 baud, 8N1 38 | ` + strconv.Itoa(ModbusComset2400_8E1) + `: 2400 baud, 8E1 39 | ` + strconv.Itoa(ModbusComset9600_8E1) + `: 9600 baud, 8E1 40 | ` + strconv.Itoa(ModbusComset19200_8E1) + `: 19200 baud, 8E1 41 | `, 42 | }, 43 | cli.StringFlag{ 44 | Name: "url, u", 45 | Value: ":8080", 46 | Usage: "the URL the server should respond on", 47 | }, 48 | cli.BoolFlag{ 49 | Name: "verbose, v", 50 | Usage: "print verbose messages", 51 | }, 52 | cli.StringFlag{ 53 | Name: "device_list, d", 54 | Value: "SDM:1", 55 | Usage: `MODBUS device type and ID to query, separated by comma. 56 | Valid types are: 57 | "SDM" for Eastron SDM meters 58 | "JANITZA" for Janitza B-Series meters 59 | "DZG" for the DZG Metering GmbH DVH4013 meters 60 | "SBC" for the Saia Burgess Controls ALE3 meters 61 | Example: -d JANITZA:1,SDM:22,DZG:23`, 62 | }, 63 | cli.StringFlag{ 64 | Name: "unique_id_format, f", 65 | Value: "Instrument%d", 66 | Usage: `Unique ID format. 67 | Example: -f Instrument%d 68 | The %d is replaced by the device ID`, 69 | }, 70 | } 71 | 72 | app.Action = func(c *cli.Context) { 73 | // Set unique ID format 74 | UniqueIdFormat = c.String("unique_id_format") 75 | 76 | // Parse the device_list parameter 77 | deviceslice := strings.Split(c.String("device_list"), ",") 78 | meters := make(map[uint8]*Meter) 79 | for _, meterdef := range deviceslice { 80 | splitdef := strings.Split(meterdef, ":") 81 | if len(splitdef) != 2 { 82 | log.Fatalf("Cannot parse device definition %s. See -h for help.", meterdef) 83 | } 84 | metertype, devid := splitdef[0], splitdef[1] 85 | id, err := strconv.Atoi(devid) 86 | if err != nil { 87 | log.Fatalf("Error parsing device id %s: %s. See -h for help.", meterdef, err.Error()) 88 | } 89 | meter, err := NewMeterByType(metertype, uint8(id)) 90 | if err != nil { 91 | log.Fatalf("Unknown meter type %s for device %d. See -h for help.", metertype, id) 92 | } 93 | meters[uint8(id)] = meter 94 | } 95 | 96 | // create ModbusEngine with status 97 | status := NewStatus(meters) 98 | qe := NewModbusEngine( 99 | c.String("serialadapter"), 100 | c.Int("comset"), 101 | c.Bool("verbose"), 102 | status, 103 | ) 104 | 105 | // scheduler and meter data channel 106 | scheduler, snips := SetupScheduler(meters, qe) 107 | go scheduler.Run() 108 | 109 | // tee that broadcasts meter messages to multiple recipients 110 | tee := NewQuerySnipBroadcaster(snips) 111 | go tee.Run() 112 | 113 | // Longpoll firehose 114 | firehose := NewFirehose( 115 | tee.Attach(), 116 | status, 117 | c.Bool("verbose")) 118 | go firehose.Run() 119 | 120 | // websocket hub 121 | hub := NewSocketHub(tee.Attach(), status) 122 | go hub.Run() 123 | 124 | // MeasurementCache for REST API 125 | mc := NewMeasurementCache( 126 | meters, 127 | tee.Attach(), 128 | scheduler, 129 | DEFAULT_METER_STORE_SECONDS, 130 | c.Bool("verbose"), 131 | ) 132 | go mc.Consume() 133 | 134 | Run_httpd( 135 | mc, 136 | firehose, 137 | hub, 138 | status, 139 | c.String("url"), 140 | ) 141 | } 142 | 143 | app.Run(os.Args) 144 | } 145 | -------------------------------------------------------------------------------- /cmd/sdm630_logger/database.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "encoding/binary" 6 | "encoding/json" 7 | "fmt" 8 | "github.com/boltdb/bolt" 9 | "github.com/gonium/gosdm630" 10 | "io" 11 | "log" 12 | "os" 13 | "sync" 14 | "time" 15 | ) 16 | 17 | const ( 18 | DEFAULT_BUCKET = "records" 19 | ) 20 | 21 | type SnipDB struct { 22 | sync.Mutex 23 | databaseFile string 24 | snips []sdm630.QuerySnip 25 | } 26 | 27 | func NewSnipDB( 28 | dbfile string, 29 | ) *SnipDB { 30 | return &SnipDB{ 31 | databaseFile: dbfile, 32 | snips: []sdm630.QuerySnip{}, 33 | } 34 | } 35 | 36 | func (r *SnipDB) AddSnip(snip sdm630.QuerySnip) { 37 | r.Lock() 38 | defer r.Unlock() 39 | r.snips = append(r.snips, snip) 40 | } 41 | 42 | func (r *SnipDB) RunRecorder(sleepSeconds int) { 43 | // open database 44 | db, err := bolt.Open(r.databaseFile, 45 | 0600, 46 | &bolt.Options{Timeout: 1 * time.Second}, 47 | ) 48 | if err != nil { 49 | log.Fatal("Cannot open database, exiting. Error was: ", err.Error()) 50 | } 51 | defer db.Close() 52 | 53 | // now: sleep, then store recorded snips. 54 | for { 55 | time.Sleep(time.Duration(sleepSeconds) * time.Second) 56 | r.Lock() 57 | log.Printf("Cached %d measurements, storing.", len(r.snips)) 58 | // taken from https://github.com/boltdb/bolt#autoincrementing-integer-for-the-bucket 59 | err := db.Update(func(tx *bolt.Tx) error { 60 | // Retrieve the users bucket. 61 | // This should be created when the DB is first opened. 62 | b, err := tx.CreateBucketIfNotExists([]byte(DEFAULT_BUCKET)) 63 | if err != nil { 64 | return fmt.Errorf("Failed to create storage bucket, error was: %s", 65 | err.Error()) 66 | } 67 | for _, snip := range r.snips { 68 | // Generate ID for the reading. 69 | // This returns an error only if the Tx is closed or not writeable. 70 | // That can't happen in an Update() call so I ignore the error check. 71 | id, _ := b.NextSequence() 72 | buf, err := json.Marshal(snip) 73 | if err != nil { 74 | return fmt.Errorf("Failed to marshal data %s, error was: %s", 75 | snip.String(), err.Error()) 76 | } 77 | err = b.Put(itob(id), buf) 78 | if err != nil { 79 | return fmt.Errorf("Failed to add snip %s to bucket, error was: %s", 80 | snip.String(), err.Error()) 81 | } 82 | } 83 | return nil 84 | }) 85 | if err != nil { 86 | log.Fatalf("Failed to store records: %s", err.Error()) 87 | } else { 88 | // clear the cache 89 | r.snips = []sdm630.QuerySnip{} 90 | } 91 | r.Unlock() 92 | } 93 | } 94 | 95 | // itob returns an 8-byte big endian representation of v. 96 | func itob(v uint64) []byte { 97 | b := make([]byte, 8) 98 | binary.BigEndian.PutUint64(b, uint64(v)) 99 | return b 100 | } 101 | 102 | func (db *SnipDB) Inspect(w io.Writer) error { 103 | // open database 104 | blt, err := bolt.Open(db.databaseFile, 105 | 0600, 106 | &bolt.Options{ 107 | Timeout: 1 * time.Second, 108 | ReadOnly: true, 109 | }, 110 | ) 111 | if err != nil { 112 | return fmt.Errorf("Cannot open database, exiting. Error was: %s."+ 113 | " Is the database file in use by another process?", err.Error()) 114 | } 115 | defer blt.Close() 116 | 117 | firstSnipTime := time.Now() 118 | var lastSnipTime time.Time 119 | numSnips := 0 120 | 121 | err = blt.View(func(tx *bolt.Tx) error { 122 | b := tx.Bucket([]byte(DEFAULT_BUCKET)) 123 | if b == nil { 124 | return fmt.Errorf("No bucket found in database - empty?") 125 | } 126 | c := b.Cursor() 127 | 128 | for k, v := c.First(); k != nil; k, v = c.Next() { 129 | numSnips += 1 130 | var snip sdm630.QuerySnip 131 | err := json.Unmarshal(v, &snip) 132 | if err != nil { 133 | return fmt.Errorf("Failed to unmarshal snip %s, error was: %s", v, 134 | err.Error()) 135 | } 136 | if snip.ReadTimestamp.Before(firstSnipTime) { 137 | firstSnipTime = snip.ReadTimestamp 138 | } 139 | if snip.ReadTimestamp.After(lastSnipTime) { 140 | lastSnipTime = snip.ReadTimestamp 141 | } 142 | } 143 | return nil 144 | }) 145 | 146 | fmt.Fprintf(w, "Found %d records:\n", numSnips) 147 | fmt.Fprintf(w, "* First recorded on %s\n", firstSnipTime) 148 | fmt.Fprintf(w, "* Last recorded on %s\n", lastSnipTime) 149 | return err 150 | } 151 | 152 | func (db *SnipDB) ExportCSV(csvfile string) error { 153 | // open database 154 | blt, err := bolt.Open(db.databaseFile, 155 | 0600, 156 | &bolt.Options{ 157 | Timeout: 1 * time.Second, 158 | ReadOnly: true, 159 | }, 160 | ) 161 | if err != nil { 162 | return fmt.Errorf("Cannot open database, exiting. Error was: %s."+ 163 | " Is the database file in use by another process?", err.Error()) 164 | } 165 | defer blt.Close() 166 | 167 | f, err := os.Create(csvfile) 168 | if err != nil { 169 | return fmt.Errorf("Cannot write to csv file, exiting. Error was: "+ 170 | "%s.", err.Error()) 171 | } 172 | defer f.Close() 173 | w := bufio.NewWriter(f) 174 | 175 | fmt.Fprintf(w, "ID\tTime\tL1\tL2\tL3\n") 176 | numSnips := 0 177 | err = blt.View(func(tx *bolt.Tx) error { 178 | b := tx.Bucket([]byte(DEFAULT_BUCKET)) 179 | if b == nil { 180 | return fmt.Errorf("No bucket found in database - empty?") 181 | } 182 | c := b.Cursor() 183 | 184 | for k, v := c.First(); k != nil; k, v = c.Next() { 185 | numSnips += 1 186 | var snip sdm630.QuerySnip 187 | err := json.Unmarshal(v, &snip) 188 | if err != nil { 189 | return fmt.Errorf("Failed to unmarshal snip %s, error was: %s", v, 190 | err.Error()) 191 | } 192 | 193 | // TODO: Export all snips 194 | switch snip.IEC61850 { 195 | case "WLocPhsA": 196 | fmt.Fprintf(w, "%d\t%s\t%.2f\t\t\n", snip.DeviceId, 197 | snip.ReadTimestamp, snip.Value) 198 | case "WLocPhsB": 199 | fmt.Fprintf(w, "%d\t%s\t\t%.2f\t\n", snip.DeviceId, 200 | snip.ReadTimestamp, snip.Value) 201 | case "WLocPhsC": 202 | fmt.Fprintf(w, "%d\t%s\t\t\t%.2f\n", snip.DeviceId, 203 | snip.ReadTimestamp, snip.Value) 204 | default: 205 | continue 206 | } 207 | 208 | } 209 | return nil 210 | }) 211 | log.Printf("Exported %d records.", numSnips) 212 | w.Flush() 213 | return err 214 | } 215 | -------------------------------------------------------------------------------- /cmd/sdm630_logger/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "github.com/gonium/gosdm630" 7 | "gopkg.in/urfave/cli.v1" 8 | "io/ioutil" 9 | "log" 10 | "net" 11 | "net/http" 12 | "os" 13 | "time" 14 | ) 15 | 16 | const ( 17 | ERROR_WAITTIME_MS = 100 18 | ) 19 | 20 | // Copied from 21 | // https://github.com/jcuga/golongpoll/blob/master/events.go: 22 | type lpEvent struct { 23 | // Timestamp is milliseconds since epoch to match javascrits Date.getTime() 24 | Timestamp int64 `json:"timestamp"` 25 | Category string `json:"category"` 26 | // NOTE: Data can be anything that is able to passed to json.Marshal() 27 | Data sdm630.QuerySnip `json:"data"` 28 | } 29 | 30 | // eventResponse is the json response that carries longpoll events. 31 | type eventResponse struct { 32 | Events *[]lpEvent `json:"events"` 33 | } 34 | 35 | func main() { 36 | app := cli.NewApp() 37 | app.Name = "sdm630_logger" 38 | app.Usage = "SDM630 Logger" 39 | app.Version = sdm630.RELEASEVERSION 40 | app.HideVersion = true 41 | // Global flags 42 | app.Flags = []cli.Flag{ 43 | cli.BoolFlag{ 44 | Name: "verbose, v", 45 | Usage: "print verbose messages", 46 | }, 47 | } 48 | app.Commands = []cli.Command{ 49 | { 50 | Name: "record", 51 | Aliases: []string{"r"}, 52 | Usage: "Record all measurements", 53 | Flags: []cli.Flag{ 54 | cli.StringFlag{ 55 | Name: "url, u", 56 | Value: "localhost:8080", 57 | Usage: "the URL of the server we should connect to", 58 | }, 59 | cli.StringFlag{ 60 | Name: "category, c", 61 | Value: "meterupdate", 62 | Usage: "the firehose category to subscribe to", 63 | }, 64 | cli.StringFlag{ 65 | Name: "dbfile, f", 66 | Value: "log.db", 67 | Usage: "the database file to record to", 68 | }, 69 | cli.IntFlag{ 70 | Name: "timeout, t", 71 | Value: 45, 72 | Usage: "timeout value in seconds", 73 | }, 74 | cli.IntFlag{ 75 | Name: "sleeptime, s", 76 | Value: 60 * 2, 77 | Usage: "seconds to sleep between writes to disk", 78 | }, 79 | }, 80 | Action: func(c *cli.Context) { 81 | db := NewSnipDB(c.String("dbfile")) 82 | go db.RunRecorder(c.Int("sleeptime")) 83 | endpointUrl := 84 | fmt.Sprintf("http://%s/firehose?timeout=%d&category=%s", 85 | c.String("url"), c.Int("timeout"), c.String("category")) 86 | if c.GlobalBool("verbose") { 87 | log.Printf("recorder startup - will connect to %s", endpointUrl) 88 | } 89 | client := &http.Client{ 90 | Timeout: time.Duration(c.Int("timeout")) * time.Second, 91 | Transport: &http.Transport{ 92 | // 0 means: no limit. 93 | MaxIdleConns: 0, 94 | MaxIdleConnsPerHost: 0, 95 | IdleConnTimeout: 0, 96 | Dial: (&net.Dialer{ 97 | Timeout: 30 * time.Second, 98 | KeepAlive: time.Minute, 99 | }).Dial, 100 | TLSHandshakeTimeout: 10 * time.Second, 101 | DisableKeepAlives: false, 102 | }, 103 | } 104 | for { 105 | resp, err := client.Get(endpointUrl) 106 | if err != nil { 107 | log.Println("Failed to read from endpoint: ", err.Error()) 108 | // TODO: Exponential backoff 109 | time.Sleep(ERROR_WAITTIME_MS * time.Millisecond) 110 | continue 111 | } 112 | rawevents, err := ioutil.ReadAll(resp.Body) 113 | if err != nil { 114 | log.Println("Failed to process message: ", err.Error()) 115 | continue 116 | } else { 117 | // handle the events. 118 | var events eventResponse 119 | err := json.Unmarshal(rawevents, &events) 120 | if err != nil { 121 | log.Println("Failed to decode JSON events: ", err.Error()) 122 | continue 123 | } 124 | for _, event := range *events.Events { 125 | snip := event.Data 126 | if c.GlobalBool("verbose") { 127 | log.Printf("%s: device %d, %s: %.2f", snip.ReadTimestamp, 128 | snip.DeviceId, snip.IEC61850, snip.Value) 129 | } 130 | db.AddSnip(snip) 131 | } 132 | 133 | } 134 | if resp.Body != nil { 135 | resp.Body.Close() 136 | } 137 | } 138 | }, 139 | }, 140 | { 141 | Name: "export", 142 | Aliases: []string{"e"}, 143 | Usage: "export all measurements from a database", 144 | Flags: []cli.Flag{ 145 | cli.StringFlag{ 146 | Name: "dbfile, f", 147 | Value: "log.db", 148 | Usage: "the database file that contains all stored readings", 149 | }, 150 | cli.StringFlag{ 151 | Name: "tsv, t", 152 | Value: "log.tsv", 153 | Usage: "the TSV file to export to", 154 | }, 155 | }, 156 | Action: func(c *cli.Context) { 157 | if c.GlobalBool("verbose") { 158 | log.Printf("exporter startup") 159 | } 160 | if c.GlobalBool("verbose") { 161 | log.Printf("Exporting database %s into TSV file %s", 162 | c.String("dbfile"), c.String("tsv")) 163 | } 164 | db := NewSnipDB(c.String("dbfile")) 165 | err := db.ExportCSV(c.String("tsv")) 166 | if err != nil { 167 | log.Fatalf("%s", err.Error()) 168 | } 169 | 170 | }, 171 | }, 172 | { 173 | Name: "inspect", 174 | Aliases: []string{"i"}, 175 | Usage: "inspect a recorded database", 176 | Flags: []cli.Flag{ 177 | cli.StringFlag{ 178 | Name: "dbfile, f", 179 | Value: "log.db", 180 | Usage: "the database file to record to", 181 | }, 182 | }, 183 | Action: func(c *cli.Context) { 184 | if c.GlobalBool("verbose") { 185 | log.Printf("Inspecting database %s", c.String("dbfile")) 186 | } 187 | db := NewSnipDB(c.String("dbfile")) 188 | err := db.Inspect(os.Stdout) 189 | if err != nil { 190 | log.Fatalf("%s", err.Error()) 191 | } 192 | }, 193 | }, 194 | } 195 | app.Run(os.Args) 196 | } 197 | -------------------------------------------------------------------------------- /cmd/sdm630_monitor/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "github.com/gonium/gosdm630" 7 | "gopkg.in/urfave/cli.v1" 8 | "io/ioutil" 9 | "log" 10 | "net" 11 | "net/http" 12 | "os" 13 | "time" 14 | ) 15 | 16 | // Copied from 17 | // https://github.com/jcuga/golongpoll/blob/master/events.go: 18 | type lpEvent struct { 19 | // Timestamp is milliseconds since epoch to match javascrits Date.getTime() 20 | Timestamp int64 `json:"timestamp"` 21 | Category string `json:"category"` 22 | // NOTE: Data can be anything that is able to passed to json.Marshal() 23 | Data sdm630.QuerySnip `json:"data"` 24 | } 25 | 26 | // eventResponse is the json response that carries longpoll events. 27 | type eventResponse struct { 28 | Events *[]lpEvent `json:"events"` 29 | } 30 | 31 | func main() { 32 | app := cli.NewApp() 33 | app.Name = "sdm630_monitor" 34 | app.Usage = "SDM630 monitor" 35 | app.Version = sdm630.RELEASEVERSION 36 | app.HideVersion = true 37 | app.Flags = []cli.Flag{ 38 | cli.StringFlag{ 39 | Name: "url, u", 40 | Value: "localhost:8080", 41 | Usage: "the URL of the server we should connect to", 42 | }, 43 | cli.StringFlag{ 44 | Name: "category, c", 45 | Value: "meterupdate", 46 | Usage: "the firehose category to subscribe to", 47 | }, 48 | cli.IntFlag{ 49 | Name: "timeout, t", 50 | Value: 45, 51 | Usage: "timeout value in seconds", 52 | }, 53 | cli.IntFlag{ 54 | Name: "device, d", 55 | Usage: "specify the MODBUS id of the device to monitor", 56 | }, 57 | cli.BoolFlag{ 58 | Name: "verbose, v", 59 | Usage: "print verbose messages", 60 | }, 61 | } 62 | app.Action = func(c *cli.Context) { 63 | if !c.IsSet("device") { 64 | log.Fatal("No device id given -- aborting. See --help for more information") 65 | } 66 | endpointUrl := 67 | fmt.Sprintf("http://%s/firehose?timeout=%d&category=%s", 68 | c.String("url"), c.Int("timeout"), c.String("category")) 69 | if c.Bool("verbose") { 70 | log.Printf("Client startup - will connect to %s", endpointUrl) 71 | } 72 | client := &http.Client{ 73 | Timeout: time.Duration(c.Int("timeout")) * time.Second, 74 | Transport: &http.Transport{ 75 | // 0 means: no limit. 76 | MaxIdleConns: 0, 77 | MaxIdleConnsPerHost: 0, 78 | IdleConnTimeout: 0, 79 | Dial: (&net.Dialer{ 80 | Timeout: 30 * time.Second, 81 | KeepAlive: time.Minute, 82 | }).Dial, 83 | TLSHandshakeTimeout: 10 * time.Second, 84 | DisableKeepAlives: false, 85 | }, 86 | } 87 | for { 88 | resp, err := client.Get(endpointUrl) 89 | if err != nil { 90 | log.Fatal("Failed to read from endpoint: ", err.Error()) 91 | } 92 | rawevents, err := ioutil.ReadAll(resp.Body) 93 | if err != nil { 94 | log.Fatal("Failed to process message: ", err.Error()) 95 | } else { 96 | // handle the events. 97 | var events eventResponse 98 | err := json.Unmarshal(rawevents, &events) 99 | if err != nil { 100 | log.Fatal("Failed to decode JSON events: ", err.Error()) 101 | } 102 | for _, event := range *events.Events { 103 | snip := event.Data 104 | if snip.DeviceId == uint8(c.Int("device")) { 105 | if snip.IEC61850 == "WLocPhsA" { 106 | log.Printf("Device %d: L1 %.2f W", snip.DeviceId, snip.Value) 107 | } 108 | if snip.IEC61850 == "WLocPhsB" { 109 | log.Printf("Device %d: L2 %.2f W", snip.DeviceId, snip.Value) 110 | } 111 | if snip.IEC61850 == "WLocPhsC" { 112 | log.Printf("Device %d: L3 %.2f W", snip.DeviceId, snip.Value) 113 | } 114 | } 115 | } 116 | 117 | } 118 | if resp.Body != nil { 119 | resp.Body.Close() 120 | } 121 | } 122 | } 123 | app.Run(os.Args) 124 | } 125 | -------------------------------------------------------------------------------- /cmd/sdm_detect/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/gonium/gosdm630" 5 | "gopkg.in/urfave/cli.v1" 6 | "os" 7 | "strconv" 8 | ) 9 | 10 | func main() { 11 | app := cli.NewApp() 12 | app.Name = "sdm_detect" 13 | app.Usage = "Attempts to detect available SDM devices." 14 | app.Version = sdm630.RELEASEVERSION 15 | app.HideVersion = true 16 | app.Flags = []cli.Flag{ 17 | cli.StringFlag{ 18 | Name: "serialadapter, s", 19 | Value: "/dev/ttyUSB0", 20 | Usage: "path to serial RTU device", 21 | }, 22 | cli.IntFlag{ 23 | Name: "comset, c", 24 | Value: sdm630.ModbusComset9600_8N1, 25 | Usage: `which communication parameter set to use. Valid sets are 26 | ` + strconv.Itoa(sdm630.ModbusComset2400_8N1) + `: 2400 baud, 8N1 27 | ` + strconv.Itoa(sdm630.ModbusComset9600_8N1) + `: 9600 baud, 8N1 28 | ` + strconv.Itoa(sdm630.ModbusComset19200_8N1) + `: 19200 baud, 8N1 29 | `, 30 | }, 31 | cli.BoolFlag{ 32 | Name: "verbose, v", 33 | Usage: "print verbose messages", 34 | }, 35 | } 36 | app.Action = func(c *cli.Context) { 37 | status := sdm630.NewStatus(nil) 38 | qe := sdm630.NewModbusEngine( 39 | c.String("serialadapter"), 40 | c.Int("comset"), 41 | c.Bool("verbose"), 42 | status, 43 | ) 44 | 45 | qe.Scan() 46 | } 47 | 48 | app.Run(os.Args) 49 | } 50 | -------------------------------------------------------------------------------- /datagram.go: -------------------------------------------------------------------------------- 1 | package sdm630 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "math" 7 | "time" 8 | ) 9 | 10 | // UniqueIdFormat is a format string for unique ID generation. 11 | // It expects one %d conversion specifier, 12 | // which will be replaced with the device ID. 13 | // The UniqueIdFormat can be changed on program startup, 14 | // before any additional goroutines are started. 15 | var UniqueIdFormat string = "Instrument%d" 16 | 17 | // Readings combines readings of all measurements into one data structure 18 | type Readings struct { 19 | UniqueId string 20 | Timestamp time.Time 21 | Unix int64 22 | DeviceId uint8 `json:"ModbusDeviceId"` 23 | Power ThreePhaseReadings 24 | Voltage ThreePhaseReadings 25 | Current ThreePhaseReadings 26 | Cosphi ThreePhaseReadings 27 | Import ThreePhaseReadings 28 | TotalImport *float64 29 | Export ThreePhaseReadings 30 | TotalExport *float64 31 | THD THDInfo 32 | Frequency *float64 33 | } 34 | 35 | type THDInfo struct { 36 | // Current ThreePhaseReadings 37 | // AvgCurrent float64 38 | VoltageNeutral ThreePhaseReadings 39 | AvgVoltageNeutral *float64 40 | } 41 | 42 | type ThreePhaseReadings struct { 43 | L1 *float64 44 | L2 *float64 45 | L3 *float64 46 | } 47 | 48 | // F2fp helper converts float64 to *float64 49 | func F2fp(x float64) *float64 { 50 | if math.IsNaN(x) { 51 | return nil 52 | } 53 | return &x 54 | } 55 | 56 | // Fp2f helper converts *float64 to float64, correctly handles uninitialized 57 | // variables 58 | func Fp2f(x *float64) float64 { 59 | if x == nil { 60 | // this is not initialized yet - return NaN 61 | return math.NaN() 62 | } 63 | return *x 64 | } 65 | 66 | func (r *Readings) String() string { 67 | fmtString := "%s " + 68 | "L1: %.1fV %.2fA %.0fW %.2fcos | " + 69 | "L2: %.1fV %.2fA %.0fW %.2fcos | " + 70 | "L3: %.1fV %.2fA %.0fW %.2fcos | " + 71 | "%.1fHz" 72 | return fmt.Sprintf(fmtString, 73 | r.UniqueId, 74 | Fp2f(r.Voltage.L1), 75 | Fp2f(r.Current.L1), 76 | Fp2f(r.Power.L1), 77 | Fp2f(r.Cosphi.L1), 78 | Fp2f(r.Voltage.L2), 79 | Fp2f(r.Current.L2), 80 | Fp2f(r.Power.L2), 81 | Fp2f(r.Cosphi.L2), 82 | Fp2f(r.Voltage.L3), 83 | Fp2f(r.Current.L3), 84 | Fp2f(r.Power.L3), 85 | Fp2f(r.Cosphi.L3), 86 | Fp2f(r.Frequency), 87 | ) 88 | } 89 | 90 | // IsOlderThan returns true if the reading is older than the given timestamp. 91 | func (r *Readings) IsOlderThan(ts time.Time) (retval bool) { 92 | return r.Timestamp.Before(ts) 93 | } 94 | 95 | func tpAdd(lhs ThreePhaseReadings, rhs ThreePhaseReadings) ThreePhaseReadings { 96 | res := ThreePhaseReadings{ 97 | L1: F2fp(Fp2f(lhs.L1) + Fp2f(rhs.L1)), 98 | L2: F2fp(Fp2f(lhs.L2) + Fp2f(rhs.L2)), 99 | L3: F2fp(Fp2f(lhs.L3) + Fp2f(rhs.L3)), 100 | } 101 | return res 102 | } 103 | 104 | /* 105 | * Adds two readings. The individual values are added except for 106 | * the time: the latter of the two times is copied over to the result 107 | */ 108 | func (lhs *Readings) add(rhs *Readings) (*Readings, error) { 109 | if lhs.DeviceId != rhs.DeviceId { 110 | return &Readings{}, fmt.Errorf( 111 | "Cannot add readings of different devices - got IDs %d and %d", 112 | lhs.DeviceId, rhs.DeviceId) 113 | } 114 | 115 | res := &Readings{ 116 | UniqueId: lhs.UniqueId, 117 | DeviceId: lhs.DeviceId, 118 | Voltage: tpAdd(lhs.Voltage, rhs.Voltage), 119 | Current: tpAdd(lhs.Current, rhs.Current), 120 | Power: tpAdd(lhs.Power, rhs.Power), 121 | Cosphi: tpAdd(lhs.Cosphi, rhs.Cosphi), 122 | Import: tpAdd(lhs.Import, rhs.Import), 123 | TotalImport: F2fp(Fp2f(lhs.TotalImport) + 124 | Fp2f(rhs.TotalImport)), 125 | Export: tpAdd(lhs.Export, rhs.Export), 126 | TotalExport: F2fp(Fp2f(lhs.TotalExport) + 127 | Fp2f(rhs.TotalExport)), 128 | THD: THDInfo{ 129 | VoltageNeutral: tpAdd(lhs.THD.VoltageNeutral, rhs.THD.VoltageNeutral), 130 | AvgVoltageNeutral: F2fp(Fp2f(lhs.THD.AvgVoltageNeutral) + 131 | Fp2f(rhs.THD.AvgVoltageNeutral)), 132 | }, 133 | Frequency: F2fp(Fp2f(lhs.Frequency) + 134 | Fp2f(rhs.Frequency)), 135 | } 136 | 137 | if lhs.Timestamp.After(rhs.Timestamp) { 138 | res.Timestamp = lhs.Timestamp 139 | res.Unix = lhs.Unix 140 | } else { 141 | res.Timestamp = rhs.Timestamp 142 | res.Unix = rhs.Unix 143 | } 144 | 145 | return res, nil 146 | } 147 | 148 | func tpDivide(lhs ThreePhaseReadings, scaler float64) ThreePhaseReadings { 149 | res := ThreePhaseReadings{ 150 | L1: F2fp(Fp2f(lhs.L1) / scaler), 151 | L2: F2fp(Fp2f(lhs.L2) / scaler), 152 | L3: F2fp(Fp2f(lhs.L3) / scaler), 153 | } 154 | return res 155 | } 156 | 157 | /* 158 | * Divide a reading by an integer. The individual values are divided except 159 | * for the time: it is simply copied over to the result 160 | */ 161 | func (lhs *Readings) divide(scaler float64) *Readings { 162 | res := &Readings{ 163 | Timestamp: lhs.Timestamp, 164 | Unix: lhs.Unix, 165 | DeviceId: lhs.DeviceId, 166 | UniqueId: lhs.UniqueId, 167 | 168 | Voltage: tpDivide(lhs.Voltage, scaler), 169 | Current: tpDivide(lhs.Current, scaler), 170 | Power: tpDivide(lhs.Power, scaler), 171 | Cosphi: tpDivide(lhs.Cosphi, scaler), 172 | Import: tpDivide(lhs.Import, scaler), 173 | TotalImport: F2fp(Fp2f(lhs.TotalImport) / scaler), 174 | Export: tpDivide(lhs.Export, scaler), 175 | TotalExport: F2fp(Fp2f(lhs.TotalExport) / scaler), 176 | THD: THDInfo{ 177 | VoltageNeutral: tpDivide(lhs.THD.VoltageNeutral, scaler), 178 | AvgVoltageNeutral: F2fp(Fp2f(lhs.THD.AvgVoltageNeutral) / scaler), 179 | }, 180 | Frequency: F2fp(Fp2f(lhs.Frequency) / scaler), 181 | } 182 | return res 183 | } 184 | 185 | // MergeSnip adds the values represented by the QuerySnip to the 186 | // Readings and updates the current time stamp 187 | func (r *Readings) MergeSnip(q QuerySnip) { 188 | r.Timestamp = q.ReadTimestamp 189 | r.Unix = r.Timestamp.Unix() 190 | switch q.IEC61850 { 191 | case "VolLocPhsA": 192 | r.Voltage.L1 = &q.Value 193 | case "VolLocPhsB": 194 | r.Voltage.L2 = &q.Value 195 | case "VolLocPhsC": 196 | r.Voltage.L3 = &q.Value 197 | case "AmpLocPhsA": 198 | r.Current.L1 = &q.Value 199 | case "AmpLocPhsB": 200 | r.Current.L2 = &q.Value 201 | case "AmpLocPhsC": 202 | r.Current.L3 = &q.Value 203 | case "WLocPhsA": 204 | r.Power.L1 = &q.Value 205 | case "WLocPhsB": 206 | r.Power.L2 = &q.Value 207 | case "WLocPhsC": 208 | r.Power.L3 = &q.Value 209 | case "AngLocPhsA": 210 | r.Cosphi.L1 = &q.Value 211 | case "AngLocPhsB": 212 | r.Cosphi.L2 = &q.Value 213 | case "AngLocPhsC": 214 | r.Cosphi.L3 = &q.Value 215 | case "TotkWhImportPhsA": 216 | r.Import.L1 = &q.Value 217 | case "TotkWhImportPhsB": 218 | r.Import.L2 = &q.Value 219 | case "TotkWhImportPhsC": 220 | r.Import.L3 = &q.Value 221 | case "TotkWhImport": 222 | r.TotalImport = &q.Value 223 | case "TotkWhExportPhsA": 224 | r.Export.L1 = &q.Value 225 | case "TotkWhExportPhsB": 226 | r.Export.L2 = &q.Value 227 | case "TotkWhExportPhsC": 228 | r.Export.L3 = &q.Value 229 | case "TotkWhExport": 230 | r.TotalExport = &q.Value 231 | // case OpCodeL1THDCurrent: 232 | // r.THD.Current.L1 = &q.Value 233 | // case OpCodeL2THDCurrent: 234 | // r.THD.Current.L2 = &q.Value 235 | // case OpCodeL3THDCurrent: 236 | // r.THD.Current.L3 = &q.Value 237 | // case OpCodeAvgTHDCurrent: 238 | // r.THD.AvgCurrent = &q.Value 239 | case "ThdVolPhsA": 240 | r.THD.VoltageNeutral.L1 = &q.Value 241 | case "ThdVolPhsB": 242 | r.THD.VoltageNeutral.L2 = &q.Value 243 | case "ThdVolPhsC": 244 | r.THD.VoltageNeutral.L3 = &q.Value 245 | case "ThdVol": 246 | r.THD.AvgVoltageNeutral = &q.Value 247 | case "Freq": 248 | r.Frequency = &q.Value 249 | default: 250 | log.Fatalf("Cannot merge unknown IEC: %+v", q) 251 | } 252 | } 253 | 254 | // ReadingSlice is a type alias for a slice of readings. 255 | type ReadingSlice []Readings 256 | 257 | // NotOlderThan creates a new ReadingSlice of latest data 258 | func (r ReadingSlice) NotOlderThan(ts time.Time) (res ReadingSlice) { 259 | res = ReadingSlice{} 260 | for _, reading := range r { 261 | if !reading.IsOlderThan(ts) { 262 | res = append(res, reading) 263 | } 264 | } 265 | return res 266 | } 267 | 268 | // Average calculates average across a ReadingSlice 269 | func (r *ReadingSlice) Average() (*Readings, error) { 270 | var avg *Readings 271 | var err error 272 | 273 | for idx, r := range *r { 274 | if idx == 0 { 275 | // This is the first element - initialize our accumulator 276 | avg = &r 277 | } else { 278 | avg, err = r.add(avg) 279 | if err != nil { 280 | return nil, err 281 | } 282 | } 283 | } 284 | 285 | return avg.divide(float64(len(*r))), nil 286 | } 287 | -------------------------------------------------------------------------------- /datagram_test.go: -------------------------------------------------------------------------------- 1 | package sdm630 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | // . "github.com/gonium/gosdm630" 8 | . "github.com/gonium/gosdm630/internal/meters" 9 | ) 10 | 11 | func TestQuerySnipMerge(t *testing.T) { 12 | r := Readings{ 13 | Timestamp: time.Now(), 14 | Unix: time.Now().Unix(), 15 | DeviceId: 1, 16 | UniqueId: "Instrument1", 17 | Power: ThreePhaseReadings{ 18 | L1: F2fp(1.0), L2: F2fp(2.0), L3: F2fp(3.0), 19 | }, 20 | Voltage: ThreePhaseReadings{ 21 | L1: F2fp(1.0), L2: F2fp(2.0), L3: F2fp(3.0), 22 | }, 23 | Current: ThreePhaseReadings{ 24 | L1: F2fp(4.0), L2: F2fp(5.0), L3: F2fp(6.0), 25 | }, 26 | Cosphi: ThreePhaseReadings{ 27 | L1: F2fp(7.0), L2: F2fp(8.0), L3: F2fp(9.0), 28 | }, 29 | Import: ThreePhaseReadings{ 30 | L1: F2fp(10.0), L2: F2fp(11.0), L3: F2fp(12.0), 31 | }, 32 | Export: ThreePhaseReadings{ 33 | L1: F2fp(13.0), L2: F2fp(14.0), L3: F2fp(15.0), 34 | }, 35 | } 36 | 37 | setvalue := float64(230.0) 38 | var sniptests = []struct { 39 | snip QuerySnip 40 | param func(Readings) float64 41 | }{ 42 | { 43 | QuerySnip{ 44 | DeviceId: 1, Value: setvalue, 45 | Operation: Operation{ 46 | OpCode: OpCodeSDML1Voltage, 47 | IEC61850: "VolLocPhsA", 48 | }, 49 | }, 50 | func(r Readings) float64 { return Fp2f(r.Voltage.L1) }, 51 | }, 52 | { 53 | QuerySnip{ 54 | DeviceId: 1, Value: setvalue, 55 | Operation: Operation{ 56 | OpCode: OpCodeSDML2Voltage, 57 | IEC61850: "VolLocPhsB", 58 | }, 59 | }, 60 | func(r Readings) float64 { return Fp2f(r.Voltage.L2) }, 61 | }, 62 | { 63 | QuerySnip{ 64 | DeviceId: 1, Value: setvalue, 65 | Operation: Operation{ 66 | OpCode: OpCodeSDML3Voltage, 67 | IEC61850: "VolLocPhsC", 68 | }, 69 | }, 70 | func(r Readings) float64 { return Fp2f(r.Voltage.L3) }, 71 | }, 72 | { 73 | QuerySnip{ 74 | DeviceId: 1, Value: setvalue, 75 | Operation: Operation{ 76 | OpCode: OpCodeSDML1Current, 77 | IEC61850: "AmpLocPhsA", 78 | }, 79 | }, 80 | func(r Readings) float64 { return Fp2f(r.Current.L1) }, 81 | }, 82 | { 83 | QuerySnip{ 84 | DeviceId: 1, Value: setvalue, 85 | Operation: Operation{ 86 | OpCode: OpCodeSDML2Current, 87 | IEC61850: "AmpLocPhsB", 88 | }, 89 | }, 90 | func(r Readings) float64 { return Fp2f(r.Current.L2) }, 91 | }, 92 | { 93 | QuerySnip{ 94 | DeviceId: 1, Value: setvalue, 95 | Operation: Operation{ 96 | OpCode: OpCodeSDML3Current, 97 | IEC61850: "AmpLocPhsC", 98 | }, 99 | }, 100 | func(r Readings) float64 { return Fp2f(r.Current.L3) }, 101 | }, 102 | { 103 | QuerySnip{ 104 | DeviceId: 1, Value: setvalue, 105 | Operation: Operation{ 106 | OpCode: OpCodeSDML1Power, 107 | IEC61850: "WLocPhsA", 108 | }, 109 | }, 110 | func(r Readings) float64 { return Fp2f(r.Power.L1) }, 111 | }, 112 | { 113 | QuerySnip{ 114 | DeviceId: 1, Value: setvalue, 115 | Operation: Operation{ 116 | OpCode: OpCodeSDML2Power, 117 | IEC61850: "WLocPhsB", 118 | }, 119 | }, 120 | func(r Readings) float64 { return Fp2f(r.Power.L2) }, 121 | }, 122 | { 123 | QuerySnip{ 124 | DeviceId: 1, Value: setvalue, 125 | Operation: Operation{ 126 | OpCode: OpCodeSDML3Power, 127 | IEC61850: "WLocPhsC", 128 | }, 129 | }, 130 | func(r Readings) float64 { return Fp2f(r.Power.L3) }, 131 | }, 132 | { 133 | QuerySnip{ 134 | DeviceId: 1, Value: setvalue, 135 | Operation: Operation{ 136 | OpCode: OpCodeSDML1Cosphi, 137 | IEC61850: "AngLocPhsA", 138 | }, 139 | }, 140 | func(r Readings) float64 { return Fp2f(r.Cosphi.L1) }, 141 | }, 142 | { 143 | QuerySnip{ 144 | DeviceId: 1, Value: setvalue, 145 | Operation: Operation{ 146 | OpCode: OpCodeSDML2Cosphi, 147 | IEC61850: "AngLocPhsB", 148 | }, 149 | }, 150 | func(r Readings) float64 { return Fp2f(r.Cosphi.L2) }, 151 | }, 152 | { 153 | QuerySnip{ 154 | DeviceId: 1, Value: setvalue, 155 | Operation: Operation{ 156 | OpCode: OpCodeSDML3Cosphi, 157 | IEC61850: "AngLocPhsC", 158 | }, 159 | }, 160 | func(r Readings) float64 { return Fp2f(r.Cosphi.L3) }, 161 | }, 162 | { 163 | QuerySnip{ 164 | DeviceId: 1, Value: setvalue, 165 | Operation: Operation{ 166 | OpCode: OpCodeSDML1Import, 167 | IEC61850: "TotkWhImportPhsA", 168 | }, 169 | }, 170 | func(r Readings) float64 { return Fp2f(r.Import.L1) }, 171 | }, 172 | { 173 | QuerySnip{ 174 | DeviceId: 1, Value: setvalue, 175 | Operation: Operation{ 176 | OpCode: OpCodeSDML2Import, 177 | IEC61850: "TotkWhImportPhsB", 178 | }, 179 | }, 180 | func(r Readings) float64 { return Fp2f(r.Import.L2) }, 181 | }, 182 | { 183 | QuerySnip{ 184 | DeviceId: 1, Value: setvalue, 185 | Operation: Operation{ 186 | OpCode: OpCodeSDML3Import, 187 | IEC61850: "TotkWhImportPhsC", 188 | }, 189 | }, 190 | func(r Readings) float64 { return Fp2f(r.Import.L3) }, 191 | }, 192 | { 193 | QuerySnip{ 194 | DeviceId: 1, Value: setvalue, 195 | Operation: Operation{ 196 | OpCode: OpCodeSDML1Export, 197 | IEC61850: "TotkWhExportPhsA", 198 | }, 199 | }, 200 | func(r Readings) float64 { return Fp2f(r.Export.L1) }, 201 | }, 202 | { 203 | QuerySnip{ 204 | DeviceId: 1, Value: setvalue, 205 | Operation: Operation{ 206 | OpCode: OpCodeSDML2Export, 207 | IEC61850: "TotkWhExportPhsB", 208 | }, 209 | }, 210 | func(r Readings) float64 { return Fp2f(r.Export.L2) }, 211 | }, 212 | { 213 | QuerySnip{ 214 | DeviceId: 1, Value: setvalue, 215 | Operation: Operation{ 216 | OpCode: OpCodeSDML3Export, 217 | IEC61850: "TotkWhExportPhsC", 218 | }, 219 | }, 220 | func(r Readings) float64 { return Fp2f(r.Export.L3) }, 221 | }, 222 | } 223 | 224 | for _, test := range sniptests { 225 | r.MergeSnip(test.snip) 226 | if test.param(r) != setvalue { 227 | t.Errorf("Merge of querysnip failed: Expected %.2f, got %.2f", 228 | setvalue, test.param(r)) 229 | } 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /embed.json: -------------------------------------------------------------------------------- 1 | { 2 | "RootPath": "assets", 3 | "Recurse": true, 4 | "IncludePattern": "", 5 | "ExcludePattern": "(^\\.|\\.go$)", 6 | "OutputPath": "embeddedassets.go", 7 | "BuildConstraints": "", 8 | "PackageName": "sdm630", 9 | "DevOutputPath": "", 10 | "DevBuildConstraints": "", 11 | "MinifyTypes": { 12 | "\\.html?$": "text/html", 13 | "\\.css$": "text/css", 14 | "\\.js$": "application/javascript" 15 | }, 16 | "CompressPattern": "\\.(js|css|html)$", 17 | "NoCompressPattern": "\\.(pe?g|png|gif|woff2?|eot|ttf|ico)$", 18 | "OverrideModDate": false 19 | } 20 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/gonium/gosdm630 2 | 3 | require ( 4 | github.com/aprice/embed v0.0.0-20171223211210-e0f543275bf966271e2c3b3ab70265f635f0394a 5 | github.com/boltdb/bolt v1.3.1 6 | github.com/eclipse/paho.mqtt.golang v1.1.1 7 | github.com/goburrow/modbus v0.1.0 8 | github.com/goburrow/serial v0.1.0 9 | github.com/gorilla/context v1.1.1 10 | github.com/gorilla/handlers v1.4.0 11 | github.com/gorilla/mux v1.6.2 12 | github.com/gorilla/websocket v1.4.0 13 | github.com/jcuga/golongpoll v0.0.0-20160821025152-6f70b008d155 14 | github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d 15 | github.com/spf13/pflag v1.0.3 // indirect 16 | github.com/tdewolff/minify v2.3.5+incompatible // indirect 17 | github.com/tdewolff/parse v2.3.3+incompatible // indirect 18 | golang.org/x/net v0.0.0-20181003013248-f5e5bdd77824 19 | golang.org/x/sys v0.0.0-20181004145325-8469e314837c 20 | gopkg.in/urfave/cli.v1 v1.20.0 21 | ) 22 | -------------------------------------------------------------------------------- /http.go: -------------------------------------------------------------------------------- 1 | package sdm630 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "html/template" 7 | "log" 8 | "net/http" 9 | "runtime" 10 | "strconv" 11 | "time" 12 | 13 | "github.com/gorilla/handlers" 14 | "github.com/gorilla/mux" 15 | "github.com/jcuga/golongpoll" 16 | ) 17 | 18 | const ( 19 | SECONDS_BETWEEN_STATUSUPDATE = 1 20 | ) 21 | 22 | // Generate the embedded assets using https://github.com/aprice/embed 23 | //go:generate go run github.com/aprice/embed/cmd/embed -c "embed.json" 24 | 25 | func MkIndexHandler(mc *MeasurementCache) func(http.ResponseWriter, *http.Request) { 26 | loader := GetEmbeddedContent() 27 | mainTemplate, err := loader.GetContents("/index.html") 28 | if err != nil { 29 | log.Fatal("Failed to load embedded template: " + err.Error()) 30 | } 31 | t, err := template.New("gosdm630").Parse(string(mainTemplate)) 32 | if err != nil { 33 | log.Fatal("Failed to create main page template: ", err.Error()) 34 | } 35 | 36 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 37 | w.Header().Set("Content-Type", "text/html; charset=UTF-8") 38 | w.WriteHeader(http.StatusOK) 39 | data := struct { 40 | SoftwareVersion string 41 | GolangVersion string 42 | }{ 43 | SoftwareVersion: RELEASEVERSION, 44 | GolangVersion: runtime.Version(), 45 | } 46 | err := t.Execute(w, data) 47 | if err != nil { 48 | log.Fatal("Failed to render main page: ", err.Error()) 49 | } 50 | }) 51 | } 52 | 53 | func MkLastAllValuesHandler(mc *MeasurementCache) func(http.ResponseWriter, *http.Request) { 54 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 55 | w.WriteHeader(http.StatusOK) 56 | ids := mc.GetSortedIDs() 57 | lasts := ReadingSlice{} 58 | for _, id := range ids { 59 | reading, err := mc.GetCurrent(id) 60 | if err != nil { 61 | // Skip this meter, it will simply not be displayed 62 | continue 63 | //w.WriteHeader(http.StatusBadRequest) 64 | //fmt.Fprintf(w, err.Error()) 65 | //return 66 | } 67 | lasts = append(lasts, *reading) 68 | } 69 | if len(lasts) == 0 { 70 | w.WriteHeader(http.StatusBadRequest) 71 | fmt.Fprintf(w, "All meters are inactive.") 72 | return 73 | } 74 | if err := json.NewEncoder(w).Encode(lasts); err != nil { 75 | log.Printf("Failed to create JSON representation of measurements: %s", err.Error()) 76 | } 77 | }) 78 | } 79 | 80 | func MkLastSingleValuesHandler(mc *MeasurementCache) func(http.ResponseWriter, *http.Request) { 81 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 82 | vars := mux.Vars(r) 83 | id, err := strconv.Atoi(vars["id"]) 84 | if err != nil { 85 | w.WriteHeader(http.StatusBadRequest) 86 | return 87 | } 88 | last, err := mc.GetCurrent(byte(id)) 89 | if err != nil { 90 | w.WriteHeader(http.StatusBadRequest) 91 | fmt.Fprintf(w, err.Error()) 92 | return 93 | } 94 | w.WriteHeader(http.StatusOK) 95 | if err := json.NewEncoder(w).Encode(last); err != nil { 96 | log.Printf("Failed to create JSON representation of measurement %s", last.String()) 97 | } 98 | }) 99 | } 100 | 101 | func MkLastMinuteAvgSingleHandler(mc *MeasurementCache) func(http.ResponseWriter, *http.Request) { 102 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 103 | vars := mux.Vars(r) 104 | id, err := strconv.Atoi(vars["id"]) 105 | if err != nil { 106 | w.WriteHeader(http.StatusBadRequest) 107 | return 108 | } 109 | avg, err := mc.GetMinuteAvg(byte(id)) 110 | if err != nil { 111 | w.WriteHeader(http.StatusBadRequest) 112 | fmt.Fprintf(w, err.Error()) 113 | return 114 | } 115 | w.WriteHeader(http.StatusOK) 116 | if err := json.NewEncoder(w).Encode(avg); err != nil { 117 | log.Printf("Failed to create JSON representation of measurement %s", avg.String()) 118 | } 119 | }) 120 | } 121 | 122 | func MkLastMinuteAvgAllHandler(mc *MeasurementCache) func(http.ResponseWriter, *http.Request) { 123 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 124 | w.WriteHeader(http.StatusOK) 125 | ids := mc.GetSortedIDs() 126 | avgs := ReadingSlice{} 127 | for _, id := range ids { 128 | reading, err := mc.GetMinuteAvg(id) 129 | if err != nil { 130 | // Skip this meter, it will simply not be displayed 131 | continue 132 | } 133 | avgs = append(avgs, *reading) 134 | } 135 | if len(avgs) == 0 { 136 | w.WriteHeader(http.StatusBadRequest) 137 | fmt.Fprintf(w, "All meters are inactive.") 138 | return 139 | } 140 | if err := json.NewEncoder(w).Encode(avgs); err != nil { 141 | log.Printf("Failed to create JSON representation of measurements: %s", err.Error()) 142 | } 143 | }) 144 | } 145 | 146 | func MkStatusHandler(s *Status) func(http.ResponseWriter, *http.Request) { 147 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 148 | w.WriteHeader(http.StatusOK) 149 | s.Update() 150 | if err := json.NewEncoder(w).Encode(s); err != nil { 151 | log.Printf("Failed to create JSON representation of measurements: %s", err.Error()) 152 | } 153 | }) 154 | } 155 | 156 | func MkSocketHandler(hub *SocketHub) func(http.ResponseWriter, *http.Request) { 157 | return func(w http.ResponseWriter, r *http.Request) { 158 | ServeWebsocket(hub, w, r) 159 | } 160 | } 161 | 162 | type Firehose struct { 163 | lpManager *golongpoll.LongpollManager 164 | in QuerySnipChannel 165 | statstream chan string 166 | } 167 | 168 | func NewFirehose(inChannel QuerySnipChannel, status *Status, verbose bool) *Firehose { 169 | options := golongpoll.Options{} 170 | // see https://github.com/jcuga/golongpoll/blob/master/longpoll.go#L81 171 | //options := golongpoll.Options{ 172 | // LoggingEnabled: false, 173 | // MaxLongpollTimeoutSeconds: 60, 174 | // MaxEventBufferSize: 250, 175 | // EventTimeToLiveSeconds: 60, 176 | // DeleteEventAfterFirstRetrieval: false, 177 | //} 178 | if verbose { 179 | options.LoggingEnabled = true 180 | } 181 | manager, err := golongpoll.StartLongpoll(options) 182 | if err != nil { 183 | log.Fatalf("Failed to create firehose longpoll manager: %q", err) 184 | } 185 | // Attach a goroutine that will push meter status information 186 | // periodically 187 | var statusstream = make(chan string) 188 | go func() { 189 | for { 190 | time.Sleep(SECONDS_BETWEEN_STATUSUPDATE * time.Second) 191 | status.Update() 192 | if bytes, err := json.Marshal(status); err == nil { 193 | statusstream <- string(bytes) 194 | } 195 | } 196 | }() 197 | return &Firehose{ 198 | lpManager: manager, 199 | in: inChannel, 200 | statstream: statusstream, 201 | } 202 | } 203 | 204 | func (f *Firehose) Run() { 205 | for { 206 | select { 207 | case snip := <-f.in: 208 | f.lpManager.Publish("meterupdate", snip) 209 | case statupdate := <-f.statstream: 210 | f.lpManager.Publish("statusupdate", statupdate) 211 | } 212 | } 213 | } 214 | 215 | func (f *Firehose) GetHandler() func(w http.ResponseWriter, r *http.Request) { 216 | return f.lpManager.SubscriptionHandler 217 | } 218 | 219 | // serveJson decorates handler with required headers 220 | func serveJson(f http.HandlerFunc) http.HandlerFunc { 221 | return func(w http.ResponseWriter, r *http.Request) { 222 | w.Header().Set("Content-Type", "application/json; charset=UTF-8") 223 | w.Header().Set("Access-Control-Allow-Origin", "*") 224 | f(w, r) 225 | } 226 | } 227 | 228 | func Run_httpd( 229 | mc *MeasurementCache, 230 | firehose *Firehose, 231 | hub *SocketHub, 232 | s *Status, 233 | url string, 234 | ) { 235 | log.Printf("Starting API at %s", url) 236 | 237 | router := mux.NewRouter().StrictSlash(true) 238 | 239 | // static 240 | router.HandleFunc("/", MkIndexHandler(mc)) 241 | router.PathPrefix("/js/").Handler(GetEmbeddedContent()) 242 | router.PathPrefix("/css/").Handler(GetEmbeddedContent()) 243 | 244 | // api 245 | router.HandleFunc("/last", serveJson(MkLastAllValuesHandler(mc))) 246 | router.HandleFunc("/last/{id:[0-9]+}", serveJson(MkLastSingleValuesHandler(mc))) 247 | router.HandleFunc("/minuteavg", serveJson(MkLastMinuteAvgAllHandler(mc))) 248 | router.HandleFunc("/minuteavg/{id:[0-9]+}", serveJson(MkLastMinuteAvgSingleHandler(mc))) 249 | router.HandleFunc("/status", serveJson(MkStatusHandler(s))) 250 | 251 | // longpoll 252 | if firehose != nil { 253 | router.HandleFunc("/firehose", firehose.GetHandler()) 254 | } 255 | 256 | // websocket 257 | router.HandleFunc("/ws", MkSocketHandler(hub)) 258 | 259 | srv := http.Server{ 260 | Addr: url, 261 | Handler: handlers.CompressHandler(router), 262 | ReadTimeout: time.Minute, 263 | WriteTimeout: time.Minute, 264 | } 265 | 266 | srv.SetKeepAlivesEnabled(true) 267 | log.Fatal(srv.ListenAndServe()) 268 | } 269 | -------------------------------------------------------------------------------- /iec.go: -------------------------------------------------------------------------------- 1 | package sdm630 2 | 3 | import ( 4 | "log" 5 | ) 6 | 7 | var iec = map[string]string{ 8 | "AmpLocPhsA": "L1 Current (A)", 9 | "AmpLocPhsB": "L2 Current (A)", 10 | "AmpLocPhsC": "L3 Current (A)", 11 | "AngLocPhsA": "L1 Cosphi", 12 | "AngLocPhsB": "L2 Cosphi", 13 | "AngLocPhsC": "L3 Cosphi", 14 | "Freq": "Frequency of supply voltages", 15 | "ThdVol": "Average voltage to neutral THD (%)", 16 | "ThdVolPhsA": "L1 Voltage to neutral THD (%)", 17 | "ThdVolPhsB": "L2 Voltage to neutral THD (%)", 18 | "ThdVolPhsC": "L3 Voltage to neutral THD (%)", 19 | "TotkWhExport": "Total Export (kWh)", 20 | "TotkWhExportPhsA": "L1 Export (kWh)", 21 | "TotkWhExportPhsB": "L2 Export (kWh)", 22 | "TotkWhExportPhsC": "L3 Export (kWh)", 23 | "TotkWhImport": "Total Import (kWh)", 24 | "TotkWhImportPhsA": "L1 Import (kWh)", 25 | "TotkWhImportPhsB": "L2 Import (kWh)", 26 | "TotkWhImportPhsC": "L3 Import (kWh)", 27 | "VolLocPhsA": "L1 Voltage (V)", 28 | "VolLocPhsB": "L2 Voltage (V)", 29 | "VolLocPhsC": "L3 Voltage (V)", 30 | "WLocPhsA": "L1 Power (W)", 31 | "WLocPhsB": "L2 Power (W)", 32 | "WLocPhsC": "L3 Power (W)", 33 | } 34 | 35 | func GetIecDescription(key string) string { 36 | description, ok := iec[key] 37 | if !ok { 38 | log.Fatalf("Undefined IEC code %s", key) 39 | } 40 | return description 41 | } 42 | -------------------------------------------------------------------------------- /img/SDM630-MODBUS.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gonium/gosdm630/4715ca6d5e7a1cc3fc35062229237e94a0fe512d/img/SDM630-MODBUS.jpg -------------------------------------------------------------------------------- /img/USB-RS485-Adaptor.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gonium/gosdm630/4715ca6d5e7a1cc3fc35062229237e94a0fe512d/img/USB-RS485-Adaptor.jpg -------------------------------------------------------------------------------- /img/openhab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gonium/gosdm630/4715ca6d5e7a1cc3fc35062229237e94a0fe512d/img/openhab.png -------------------------------------------------------------------------------- /img/realtimeview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gonium/gosdm630/4715ca6d5e7a1cc3fc35062229237e94a0fe512d/img/realtimeview.png -------------------------------------------------------------------------------- /img/wiring.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gonium/gosdm630/4715ca6d5e7a1cc3fc35062229237e94a0fe512d/img/wiring.jpg -------------------------------------------------------------------------------- /internal/meters/dzg.go: -------------------------------------------------------------------------------- 1 | package meters 2 | 3 | const ( 4 | METERTYPE_DZG = "DZG" 5 | 6 | /*** 7 | * Opcodes for DZG DVH4014. 8 | * See "User Manual DVH4013", not public. 9 | */ 10 | OpCodeDZGTotalImportPower = 0x0000 11 | OpCodeDZGTotalExportPower = 0x0002 12 | OpCodeDZGL1Voltage = 0x0004 13 | OpCodeDZGL2Voltage = 0x0006 14 | OpCodeDZGL3Voltage = 0x0008 15 | OpCodeDZGL1Current = 0x000A 16 | OpCodeDZGL2Current = 0x000C 17 | OpCodeDZGL3Current = 0x000E 18 | OpCodeDZGL1Import = 0x4020 19 | OpCodeDZGL2Import = 0x4040 20 | OpCodeDZGL3Import = 0x4060 21 | OpCodeDZGTotalImport = 0x4000 22 | OpCodeDZGL1Export = 0x4120 23 | OpCodeDZGL2Export = 0x4140 24 | OpCodeDZGL3Export = 0x4160 25 | OpCodeDZGTotalExport = 0x4100 26 | ) 27 | 28 | type DZGProducer struct { 29 | } 30 | 31 | func NewDZGProducer() *DZGProducer { 32 | return &DZGProducer{} 33 | } 34 | 35 | func (p *DZGProducer) GetMeterType() string { 36 | return METERTYPE_DZG 37 | } 38 | 39 | func (p *DZGProducer) snip(opcode uint16, iec string, scaler ...float64) Operation { 40 | transform := RTU32ToFloat64 // default conversion 41 | if len(scaler) > 0 { 42 | transform = MakeRTU32ScaledIntToFloat64(scaler[0]) 43 | } 44 | 45 | return Operation{ 46 | FuncCode: ReadHoldingReg, 47 | OpCode: opcode, 48 | ReadLen: 2, 49 | IEC61850: iec, 50 | Transform: transform, 51 | } 52 | } 53 | 54 | func (p *DZGProducer) Probe() Operation { 55 | return p.snip(OpCodeDZGL1Voltage, "VolLocPhsA", 100) 56 | } 57 | 58 | func (p *DZGProducer) Produce() (res []Operation) { 59 | res = append(res, p.snip(OpCodeDZGL1Voltage, "VolLocPhsA", 100)) 60 | res = append(res, p.snip(OpCodeDZGL2Voltage, "VolLocPhsB", 100)) 61 | res = append(res, p.snip(OpCodeDZGL3Voltage, "VolLocPhsC", 100)) 62 | 63 | res = append(res, p.snip(OpCodeDZGL1Current, "AmpLocPhsA", 1000)) 64 | res = append(res, p.snip(OpCodeDZGL2Current, "AmpLocPhsB", 1000)) 65 | res = append(res, p.snip(OpCodeDZGL3Current, "AmpLocPhsC", 1000)) 66 | 67 | res = append(res, p.snip(OpCodeDZGL1Import, "TotkWhImportPhsA")) 68 | res = append(res, p.snip(OpCodeDZGL2Import, "TotkWhImportPhsB")) 69 | res = append(res, p.snip(OpCodeDZGL3Import, "TotkWhImportPhsC")) 70 | res = append(res, p.snip(OpCodeDZGTotalImport, "TotkWhImport", 1000)) 71 | 72 | res = append(res, p.snip(OpCodeDZGL1Export, "TotkWhExportPhsA")) 73 | res = append(res, p.snip(OpCodeDZGL2Export, "TotkWhExportPhsB")) 74 | res = append(res, p.snip(OpCodeDZGL3Export, "TotkWhExportPhsC")) 75 | res = append(res, p.snip(OpCodeDZGTotalExport, "TotkWhExport", 1000)) 76 | 77 | return res 78 | } 79 | -------------------------------------------------------------------------------- /internal/meters/janitza.go: -------------------------------------------------------------------------------- 1 | package meters 2 | 3 | const ( 4 | METERTYPE_JANITZA = "JANITZA" 5 | 6 | /*** 7 | * Opcodes for Janitza B23. 8 | * See https://www.janitza.de/betriebsanleitungen.html?file=files/download/manuals/current/B-Series/MID-Energy-Meters-Product-Manual.pdf 9 | */ 10 | OpCodeJanitzaL1Voltage = 0x4A38 11 | OpCodeJanitzaL2Voltage = 0x4A3A 12 | OpCodeJanitzaL3Voltage = 0x4A3C 13 | OpCodeJanitzaL1Current = 0x4A44 14 | OpCodeJanitzaL2Current = 0x4A46 15 | OpCodeJanitzaL3Current = 0x4A48 16 | OpCodeJanitzaL1Power = 0x4A4C 17 | OpCodeJanitzaL2Power = 0x4A4E 18 | OpCodeJanitzaL3Power = 0x4A50 19 | OpCodeJanitzaL1Import = 0x4A76 20 | OpCodeJanitzaL2Import = 0x4A78 21 | OpCodeJanitzaL3Import = 0x4A7A 22 | OpCodeJanitzaTotalImport = 0x4A7C 23 | OpCodeJanitzaL1Export = 0x4A7E 24 | OpCodeJanitzaL2Export = 0x4A80 25 | OpCodeJanitzaL3Export = 0x4A82 26 | OpCodeJanitzaTotalExport = 0x4A84 27 | OpCodeJanitzaL1Cosphi = 0x4A64 28 | OpCodeJanitzaL2Cosphi = 0x4A66 29 | OpCodeJanitzaL3Cosphi = 0x4A68 30 | ) 31 | 32 | type JanitzaProducer struct { 33 | } 34 | 35 | func NewJanitzaProducer() *JanitzaProducer { 36 | return &JanitzaProducer{} 37 | } 38 | 39 | func (p *JanitzaProducer) GetMeterType() string { 40 | return METERTYPE_JANITZA 41 | } 42 | 43 | func (p *JanitzaProducer) snip(opcode uint16, iec string) Operation { 44 | return Operation{ 45 | FuncCode: ReadHoldingReg, 46 | OpCode: opcode, 47 | ReadLen: 2, 48 | IEC61850: iec, 49 | Transform: RTU32ToFloat64, 50 | } 51 | } 52 | 53 | func (p *JanitzaProducer) Probe() Operation { 54 | return p.snip(OpCodeJanitzaL1Voltage, "VolLocPhsA") 55 | } 56 | 57 | func (p *JanitzaProducer) Produce() (res []Operation) { 58 | res = append(res, p.snip(OpCodeJanitzaL1Voltage, "VolLocPhsA")) 59 | res = append(res, p.snip(OpCodeJanitzaL2Voltage, "VolLocPhsB")) 60 | res = append(res, p.snip(OpCodeJanitzaL3Voltage, "VolLocPhsC")) 61 | 62 | res = append(res, p.snip(OpCodeJanitzaL1Current, "AmpLocPhsA")) 63 | res = append(res, p.snip(OpCodeJanitzaL2Current, "AmpLocPhsB")) 64 | res = append(res, p.snip(OpCodeJanitzaL3Current, "AmpLocPhsC")) 65 | 66 | res = append(res, p.snip(OpCodeJanitzaL1Power, "WLocPhsA")) 67 | res = append(res, p.snip(OpCodeJanitzaL2Power, "WLocPhsB")) 68 | res = append(res, p.snip(OpCodeJanitzaL3Power, "WLocPhsC")) 69 | 70 | res = append(res, p.snip(OpCodeJanitzaL1Cosphi, "AngLocPhsA")) 71 | res = append(res, p.snip(OpCodeJanitzaL2Cosphi, "AngLocPhsB")) 72 | res = append(res, p.snip(OpCodeJanitzaL3Cosphi, "AngLocPhsC")) 73 | 74 | res = append(res, p.snip(OpCodeJanitzaL1Import, "TotkWhImportPhsA")) 75 | res = append(res, p.snip(OpCodeJanitzaL2Import, "TotkWhImportPhsB")) 76 | res = append(res, p.snip(OpCodeJanitzaL3Import, "TotkWhImportPhsC")) 77 | res = append(res, p.snip(OpCodeJanitzaTotalImport, "TotkWhImport")) 78 | 79 | res = append(res, p.snip(OpCodeJanitzaL1Export, "TotkWhExportPhsA")) 80 | res = append(res, p.snip(OpCodeJanitzaL2Export, "TotkWhExportPhsB")) 81 | res = append(res, p.snip(OpCodeJanitzaL3Export, "TotkWhExportPhsC")) 82 | res = append(res, p.snip(OpCodeJanitzaTotalExport, "TotkWhExport")) 83 | return res 84 | } 85 | -------------------------------------------------------------------------------- /internal/meters/meter.go: -------------------------------------------------------------------------------- 1 | package meters 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "strings" 7 | "sync" 8 | ) 9 | 10 | const ( 11 | ReadInputReg = 4 12 | ReadHoldingReg = 3 13 | ) 14 | 15 | type Operation struct { 16 | FuncCode uint8 17 | OpCode uint16 18 | ReadLen uint16 19 | IEC61850 string 20 | Transform RTUTransform `json:"-"` 21 | } 22 | 23 | type MeterState uint8 24 | 25 | const ( 26 | AVAILABLE MeterState = iota // The device responds (initial state) 27 | UNAVAILABLE // The device does not respond 28 | ) 29 | 30 | func (ms MeterState) String() string { 31 | if ms == AVAILABLE { 32 | return "available" 33 | } else { 34 | return "unavailable" 35 | } 36 | } 37 | 38 | type Meter struct { 39 | DeviceId uint8 40 | Producer Producer 41 | state MeterState 42 | mux sync.Mutex // syncs the meter state variable 43 | } 44 | 45 | // Producer is the interface that produces query snips which represent 46 | // modbus operations 47 | type Producer interface { 48 | GetMeterType() string 49 | Produce() []Operation 50 | Probe() Operation 51 | } 52 | 53 | // NewMeterByType meter factory 54 | func NewMeterByType(typeid string, devid uint8) (*Meter, error) { 55 | var p Producer 56 | typeid = strings.ToUpper(typeid) 57 | 58 | switch typeid { 59 | case METERTYPE_SDM: 60 | p = NewSDMProducer() 61 | case METERTYPE_JANITZA: 62 | p = NewJanitzaProducer() 63 | case METERTYPE_DZG: 64 | log.Println(`WARNING: The DZG DVH 4013 does not report the same 65 | measurements as the other meters. Only limited functionality is 66 | implemented.`) 67 | p = NewDZGProducer() 68 | case METERTYPE_SBC: 69 | log.Println(`WARNING: The SBC ALE3 does not report the same 70 | measurements as the other meters. Only limited functionality is 71 | implemented.`) 72 | p = NewSBCProducer() 73 | default: 74 | return nil, fmt.Errorf("Unknown meter type %s", typeid) 75 | } 76 | 77 | return NewMeter(devid, p), nil 78 | } 79 | 80 | func NewMeter(devid uint8, producer Producer) *Meter { 81 | return &Meter{ 82 | Producer: producer, 83 | DeviceId: devid, 84 | state: AVAILABLE, 85 | } 86 | } 87 | 88 | func (m *Meter) UpdateState(newstate MeterState) { 89 | m.mux.Lock() 90 | defer m.mux.Unlock() 91 | m.state = newstate 92 | } 93 | 94 | func (m *Meter) GetState() MeterState { 95 | m.mux.Lock() 96 | defer m.mux.Unlock() 97 | return m.state 98 | } 99 | -------------------------------------------------------------------------------- /internal/meters/sbc.go: -------------------------------------------------------------------------------- 1 | package meters 2 | 3 | const ( 4 | METERTYPE_SBC = "SBC" 5 | 6 | /*** 7 | * Opcodes for Saia Burgess ALE3 8 | * http://datenblatt.stark-elektronik.de/saia_burgess/DE_DS_Energymeter-ALE3-with-Modbus.pdf 9 | */ 10 | OpCodeSaiaTotalImport = 28 - 1 // double, scaler 100 11 | OpCodeSaiaPartialImport = 30 - 1 // double, scaler 100 12 | OpCodeSaiaTotalExport = 32 - 1 // double, scaler 100 13 | OpCodeSaiaPartialExport = 34 - 1 // double, scaler 100 14 | 15 | OpCodeSaiaL1Voltage = 36 - 1 16 | OpCodeSaiaL1Current = 37 - 1 // scaler 10 17 | OpCodeSaiaL1Power = 38 - 1 // scaler 100 18 | OpCodeSaiaL1ReactivePower = 39 - 1 // scaler 100 19 | OpCodeSaiaL1Cosphi = 40 - 1 // scaler 100 20 | 21 | OpCodeSaiaL2Voltage = 41 - 1 22 | OpCodeSaiaL2Current = 42 - 1 // scaler 10 23 | OpCodeSaiaL2Power = 43 - 1 // scaler 100 24 | OpCodeSaiaL2ReactivePower = 44 - 1 // scaler 100 25 | OpCodeSaiaL2Cosphi = 45 - 1 // scaler 100 26 | 27 | OpCodeSaiaL3Voltage = 46 - 1 28 | OpCodeSaiaL3Current = 47 - 1 // scaler 10 29 | OpCodeSaiaL3Power = 48 - 1 // scaler 100 30 | OpCodeSaiaL3ReactivePower = 49 - 1 // scaler 100 31 | OpCodeSaiaL3Cosphi = 50 - 1 // scaler 100 32 | 33 | OpCodeSaiaTotalPower = 51 - 1 // scaler 100 34 | OpCodeSaiaTotalReactivePower = 52 - 1 // scaler 100 35 | ) 36 | 37 | type SBCProducer struct { 38 | } 39 | 40 | func NewSBCProducer() *SBCProducer { 41 | return &SBCProducer{} 42 | } 43 | 44 | func (p *SBCProducer) GetMeterType() string { 45 | return METERTYPE_SBC 46 | } 47 | 48 | func (p *SBCProducer) snip(opcode uint16, iec string, readlen uint16) Operation { 49 | return Operation{ 50 | FuncCode: ReadHoldingReg, 51 | OpCode: opcode, 52 | ReadLen: readlen, 53 | IEC61850: iec, 54 | } 55 | } 56 | 57 | // op16 creates modbus operation for single register 58 | func (p *SBCProducer) snip16(opcode uint16, iec string, scaler ...float64) Operation { 59 | snip := p.snip(opcode, iec, 1) 60 | 61 | snip.Transform = RTU16ToFloat64 // default conversion 62 | if len(scaler) > 0 { 63 | snip.Transform = MakeRTU16ScaledIntToFloat64(scaler[0]) 64 | } 65 | 66 | return snip 67 | } 68 | 69 | // op32 creates modbus operation for double register 70 | func (p *SBCProducer) snip32(opcode uint16, iec string, scaler ...float64) Operation { 71 | snip := p.snip(opcode, iec, 2) 72 | 73 | snip.Transform = RTU32ToFloat64 // default conversion 74 | if len(scaler) > 0 { 75 | snip.Transform = MakeRTU32ScaledIntToFloat64(scaler[0]) 76 | } 77 | 78 | return snip 79 | } 80 | 81 | func (p *SBCProducer) Probe() Operation { 82 | return p.snip16(OpCodeSaiaL1Voltage, "VolLocPhsA") 83 | } 84 | 85 | func (p *SBCProducer) Produce() (res []Operation) { 86 | res = append(res, p.snip16(OpCodeSaiaL1Voltage, "VolLocPhsA")) 87 | res = append(res, p.snip16(OpCodeSaiaL2Voltage, "VolLocPhsB")) 88 | res = append(res, p.snip16(OpCodeSaiaL3Voltage, "VolLocPhsC")) 89 | 90 | res = append(res, p.snip16(OpCodeSaiaL1Current, "AmpLocPhsA", 10)) 91 | res = append(res, p.snip16(OpCodeSaiaL2Current, "AmpLocPhsB", 10)) 92 | res = append(res, p.snip16(OpCodeSaiaL3Current, "AmpLocPhsC", 10)) 93 | 94 | res = append(res, p.snip16(OpCodeSaiaL1Power, "WLocPhsA", 100)) 95 | res = append(res, p.snip16(OpCodeSaiaL2Power, "WLocPhsB", 100)) 96 | res = append(res, p.snip16(OpCodeSaiaL3Power, "WLocPhsC", 100)) 97 | 98 | res = append(res, p.snip16(OpCodeSaiaL1Cosphi, "AngLocPhsA", 100)) 99 | res = append(res, p.snip16(OpCodeSaiaL2Cosphi, "AngLocPhsB", 100)) 100 | res = append(res, p.snip16(OpCodeSaiaL3Cosphi, "AngLocPhsC", 100)) 101 | 102 | // res = append(res, p.snip16(OpCodeSaiaTotalPower, "WLoc", 100)) 103 | 104 | res = append(res, p.snip32(OpCodeSaiaTotalImport, "TotkWhImport", 100)) 105 | res = append(res, p.snip32(OpCodeSaiaTotalExport, "TotkWhExport", 100)) 106 | 107 | return res 108 | } 109 | -------------------------------------------------------------------------------- /internal/meters/sdm.go: -------------------------------------------------------------------------------- 1 | package meters 2 | 3 | const ( 4 | METERTYPE_SDM = "SDM" 5 | 6 | /*** 7 | * Opcodes as defined by Eastron. 8 | * See http://bg-etech.de/download/manual/SDM630Register.pdf 9 | * Please note that this is the superset of all SDM devices - some 10 | * opcodes might not work on some devicep. 11 | */ 12 | OpCodeSDML1Voltage = 0x0000 13 | OpCodeSDML2Voltage = 0x0002 14 | OpCodeSDML3Voltage = 0x0004 15 | OpCodeSDML1Current = 0x0006 16 | OpCodeSDML2Current = 0x0008 17 | OpCodeSDML3Current = 0x000A 18 | OpCodeSDML1Power = 0x000C 19 | OpCodeSDML2Power = 0x000E 20 | OpCodeSDML3Power = 0x0010 21 | OpCodeSDML1Import = 0x015a 22 | OpCodeSDML2Import = 0x015c 23 | OpCodeSDML3Import = 0x015e 24 | OpCodeSDMTotalImport = 0x0048 25 | OpCodeSDML1Export = 0x0160 26 | OpCodeSDML2Export = 0x0162 27 | OpCodeSDML3Export = 0x0164 28 | OpCodeSDMTotalExport = 0x004a 29 | OpCodeSDML1Cosphi = 0x001e 30 | OpCodeSDML2Cosphi = 0x0020 31 | OpCodeSDML3Cosphi = 0x0022 32 | //OpCodeL1THDCurrent = 0x00F0 33 | //OpCodeL2THDCurrent = 0x00F2 34 | //OpCodeL3THDCurrent = 0x00F4 35 | //OpCodeAvgTHDCurrent = 0x00Fa 36 | OpCodeSDML1THDVoltageNeutral = 0x00ea 37 | OpCodeSDML2THDVoltageNeutral = 0x00ec 38 | OpCodeSDML3THDVoltageNeutral = 0x00ee 39 | OpCodeSDMAvgTHDVoltageNeutral = 0x00F8 40 | OpCodeSDMFrequency = 0x0046 41 | ) 42 | 43 | type SDMProducer struct { 44 | } 45 | 46 | func NewSDMProducer() *SDMProducer { 47 | return &SDMProducer{} 48 | } 49 | 50 | func (p *SDMProducer) GetMeterType() string { 51 | return METERTYPE_SDM 52 | } 53 | 54 | func (p *SDMProducer) snip(opcode uint16, iec string) Operation { 55 | return Operation{ 56 | FuncCode: ReadInputReg, 57 | OpCode: opcode, 58 | ReadLen: 2, 59 | IEC61850: iec, 60 | Transform: RTU32ToFloat64, 61 | } 62 | } 63 | 64 | func (p *SDMProducer) Probe() Operation { 65 | return p.snip(OpCodeSDML1Voltage, "VolLocPhsA") 66 | } 67 | 68 | func (p *SDMProducer) Produce() (res []Operation) { 69 | res = append(res, p.snip(OpCodeSDML1Voltage, "VolLocPhsA")) 70 | res = append(res, p.snip(OpCodeSDML2Voltage, "VolLocPhsB")) 71 | res = append(res, p.snip(OpCodeSDML3Voltage, "VolLocPhsC")) 72 | res = append(res, p.snip(OpCodeSDML1Current, "AmpLocPhsA")) 73 | res = append(res, p.snip(OpCodeSDML2Current, "AmpLocPhsB")) 74 | res = append(res, p.snip(OpCodeSDML3Current, "AmpLocPhsC")) 75 | 76 | res = append(res, p.snip(OpCodeSDML1Power, "WLocPhsA")) 77 | res = append(res, p.snip(OpCodeSDML2Power, "WLocPhsB")) 78 | res = append(res, p.snip(OpCodeSDML3Power, "WLocPhsC")) 79 | 80 | res = append(res, p.snip(OpCodeSDML1Cosphi, "AngLocPhsA")) 81 | res = append(res, p.snip(OpCodeSDML2Cosphi, "AngLocPhsB")) 82 | res = append(res, p.snip(OpCodeSDML3Cosphi, "AngLocPhsC")) 83 | 84 | res = append(res, p.snip(OpCodeSDML1Import, "TotkWhImportPhsA")) 85 | res = append(res, p.snip(OpCodeSDML2Import, "TotkWhImportPhsB")) 86 | res = append(res, p.snip(OpCodeSDML3Import, "TotkWhImportPhsC")) 87 | res = append(res, p.snip(OpCodeSDMTotalImport, "TotkWhImport")) 88 | 89 | res = append(res, p.snip(OpCodeSDML1Export, "TotkWhExportPhsA")) 90 | res = append(res, p.snip(OpCodeSDML2Export, "TotkWhExportPhsB")) 91 | res = append(res, p.snip(OpCodeSDML3Export, "TotkWhExportPhsC")) 92 | res = append(res, p.snip(OpCodeSDMTotalExport, "TotkWhExport")) 93 | 94 | res = append(res, p.snip(OpCodeSDML1THDVoltageNeutral, "ThdVolPhsA")) 95 | res = append(res, p.snip(OpCodeSDML2THDVoltageNeutral, "ThdVolPhsB")) 96 | res = append(res, p.snip(OpCodeSDML3THDVoltageNeutral, "ThdVolPhsC")) 97 | res = append(res, p.snip(OpCodeSDMAvgTHDVoltageNeutral, "ThdVol")) 98 | 99 | res = append(res, p.snip(OpCodeSDMFrequency, "Freq")) 100 | 101 | return res 102 | } 103 | -------------------------------------------------------------------------------- /internal/meters/transform.go: -------------------------------------------------------------------------------- 1 | package meters 2 | 3 | import ( 4 | "encoding/binary" 5 | "math" 6 | ) 7 | 8 | // RTUTransform functions convert RTU bytes to meaningful data types. 9 | type RTUTransform func([]byte) float64 10 | 11 | // RTU32ToFloat64 converts 32 bit readings 12 | func RTU32ToFloat64(b []byte) float64 { 13 | bits := binary.BigEndian.Uint32(b) 14 | f := math.Float32frombits(bits) 15 | return float64(f) 16 | } 17 | 18 | // RTU16ToFloat64 converts 16 bit readings 19 | func RTU16ToFloat64(b []byte) float64 { 20 | u := binary.BigEndian.Uint16(b) 21 | return float64(u) 22 | } 23 | 24 | func rtuScaledInt32ToFloat64(b []byte, scalar float64) float64 { 25 | unscaled := float64(binary.BigEndian.Uint32(b)) 26 | f := unscaled / scalar 27 | return float64(f) 28 | } 29 | 30 | // MakeRTU32ScaledIntToFloat64 creates a 32 bit scaled reading transform 31 | func MakeRTU32ScaledIntToFloat64(scalar float64) RTUTransform { 32 | return RTUTransform(func(b []byte) float64 { 33 | return rtuScaledInt32ToFloat64(b, scalar) 34 | }) 35 | } 36 | 37 | func rtuScaledInt16ToFloat64(b []byte, scalar float64) float64 { 38 | unscaled := float64(binary.BigEndian.Uint16(b)) 39 | f := unscaled / scalar 40 | return float64(f) 41 | } 42 | 43 | // MakeRTU16ScaledIntToFloat64 creates a 16 bit scaled reading transform 44 | func MakeRTU16ScaledIntToFloat64(scalar float64) RTUTransform { 45 | return RTUTransform(func(b []byte) float64 { 46 | return rtuScaledInt16ToFloat64(b, scalar) 47 | }) 48 | } 49 | -------------------------------------------------------------------------------- /measurementcache.go: -------------------------------------------------------------------------------- 1 | package sdm630 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "sort" 7 | "sync" 8 | "time" 9 | 10 | . "github.com/gonium/gosdm630/internal/meters" 11 | ) 12 | 13 | type MeasurementCache struct { 14 | in QuerySnipChannel 15 | meters map[uint8]MeasurementCacheItem 16 | maxAge time.Duration 17 | verbose bool 18 | } 19 | 20 | type MeasurementCacheItem struct { 21 | *Meter 22 | *MeterReadings 23 | } 24 | 25 | func NewMeasurementCache( 26 | meters map[uint8]*Meter, 27 | inChannel QuerySnipChannel, 28 | scheduler *MeterScheduler, 29 | maxAge time.Duration, 30 | isVerbose bool, 31 | ) *MeasurementCache { 32 | items := make(map[uint8]MeasurementCacheItem) 33 | 34 | for _, meter := range meters { 35 | items[meter.DeviceId] = MeasurementCacheItem{ 36 | meter, 37 | NewMeterReadings(meter.DeviceId, maxAge), 38 | } 39 | } 40 | 41 | cache := &MeasurementCache{ 42 | in: inChannel, 43 | meters: items, 44 | maxAge: maxAge, 45 | verbose: isVerbose, 46 | } 47 | 48 | scheduler.SetCache(cache) 49 | return cache 50 | } 51 | 52 | func (mc *MeasurementCache) Consume() { 53 | for { 54 | snip := <-mc.in 55 | devid := snip.DeviceId 56 | // Search corresponding meter 57 | if meter, ok := mc.meters[devid]; ok { 58 | // add the snip to the cache 59 | meter.AddSnip(snip) 60 | if mc.verbose { 61 | log.Printf("%s\r\n", meter.Current.String()) 62 | } 63 | } else { 64 | log.Fatal("Snip for unknown meter received - this should not happen.") 65 | } 66 | } 67 | } 68 | 69 | // Purge removes accumulated data for specified device 70 | func (mc *MeasurementCache) Purge(deviceId byte) error { 71 | if meter, ok := mc.meters[deviceId]; ok { 72 | meter.Purge(deviceId) 73 | return nil 74 | } 75 | 76 | return fmt.Errorf("Device with id %d does not exist.", deviceId) 77 | } 78 | 79 | func (mc *MeasurementCache) GetSortedIDs() []byte { 80 | var keys ByteSlice 81 | for k, _ := range mc.meters { 82 | keys = append(keys, k) 83 | } 84 | sort.Sort(keys) 85 | return keys 86 | } 87 | 88 | func (mc *MeasurementCache) GetCurrent(deviceId byte) (*Readings, error) { 89 | if meter, ok := mc.meters[deviceId]; ok { 90 | if meter.GetState() == AVAILABLE { 91 | return &meter.Current, nil 92 | } 93 | return nil, fmt.Errorf("Device %d is not available.", deviceId) 94 | } 95 | return nil, fmt.Errorf("Device %d does not exist.", deviceId) 96 | } 97 | 98 | func (mc *MeasurementCache) GetMinuteAvg(deviceId byte) (*Readings, error) { 99 | if meter, ok := mc.meters[deviceId]; ok { 100 | if meter.GetState() == AVAILABLE { 101 | measurements := meter.Historic 102 | lastminute := measurements.NotOlderThan(time.Now().Add(-1 * time.Minute)) 103 | 104 | res, err := lastminute.Average() 105 | if err != nil { 106 | return nil, err 107 | } 108 | 109 | if mc.verbose { 110 | log.Printf("Averaging over %d measurements:\r\n%s\r\n", 111 | len(measurements), res.String()) 112 | } 113 | return res, nil 114 | } 115 | return nil, fmt.Errorf("Device %d is not available.", deviceId) 116 | } 117 | return nil, fmt.Errorf("Device %d does not exist.", deviceId) 118 | } 119 | 120 | // ByteSlice attaches the methods of sort.Interface to []byte, sorting in increasing order. 121 | type ByteSlice []byte 122 | 123 | func (s ByteSlice) Len() int { return len(s) } 124 | func (s ByteSlice) Less(i, j int) bool { return s[i] < s[j] } 125 | func (s ByteSlice) Swap(i, j int) { s[i], s[j] = s[j], s[i] } 126 | 127 | type MeterReadings struct { 128 | Historic ReadingSlice 129 | Current Readings 130 | mux sync.Mutex 131 | } 132 | 133 | func NewMeterReadings(devid uint8, maxAge time.Duration) *MeterReadings { 134 | res := &MeterReadings{ 135 | Historic: ReadingSlice{}, 136 | Current: Readings{ 137 | UniqueId: fmt.Sprintf(UniqueIdFormat, devid), 138 | DeviceId: devid, 139 | }, 140 | } 141 | go func(mr *MeterReadings) { 142 | for { 143 | time.Sleep(maxAge) 144 | mr.mux.Lock() 145 | mr.Historic = mr.Historic.NotOlderThan(time.Now().Add(-1 * maxAge)) 146 | mr.mux.Unlock() 147 | } 148 | }(res) 149 | return res 150 | } 151 | 152 | func (mr *MeterReadings) Purge(devid uint8) { 153 | mr.mux.Lock() 154 | defer mr.mux.Unlock() 155 | 156 | mr.Historic = ReadingSlice{} 157 | mr.Current = Readings{ 158 | UniqueId: fmt.Sprintf(UniqueIdFormat, devid), 159 | DeviceId: devid, 160 | } 161 | } 162 | 163 | func (mr *MeterReadings) AddSnip(snip QuerySnip) { 164 | mr.mux.Lock() 165 | defer mr.mux.Unlock() 166 | 167 | // 1. Merge the snip to the last values. 168 | reading := mr.Current 169 | reading.MergeSnip(snip) 170 | 171 | // 2. store it 172 | mr.Current = reading 173 | mr.Historic = append(mr.Historic, reading) 174 | } 175 | -------------------------------------------------------------------------------- /mockclient.go: -------------------------------------------------------------------------------- 1 | package sdm630 2 | 3 | import ( 4 | "errors" 5 | "math/rand" 6 | "time" 7 | ) 8 | 9 | type MockClient struct { 10 | errorRate int32 11 | responseTime time.Duration 12 | } 13 | 14 | func NewMockClient(errorRate int32) *MockClient { 15 | return &MockClient{ 16 | errorRate: errorRate, 17 | responseTime: 10 * time.Millisecond, 18 | } 19 | } 20 | 21 | func (c *MockClient) fail() bool { 22 | if c.errorRate > 0 { 23 | return rand.Int31n(100) < c.errorRate 24 | } 25 | return false 26 | } 27 | 28 | func (c *MockClient) random(quantity uint16) (results []byte, err error) { 29 | bytes := make([]byte, quantity*2) 30 | rand.Read(bytes) 31 | return bytes, nil 32 | } 33 | 34 | func (c *MockClient) read(quantity uint16) (results []byte, err error) { 35 | time.Sleep(c.responseTime) 36 | if c.fail() { 37 | return nil, errors.New("Failed") 38 | } 39 | return c.random(quantity) 40 | } 41 | 42 | func (c *MockClient) ReadInputRegisters(address, quantity uint16) (results []byte, err error) { 43 | return c.read(quantity) 44 | } 45 | 46 | func (c *MockClient) ReadHoldingRegisters(address, quantity uint16) (results []byte, err error) { 47 | return c.read(quantity) 48 | } 49 | 50 | func (c *MockClient) ReadCoils(address, quantity uint16) (results []byte, err error) { 51 | panic("Not implemented") 52 | } 53 | 54 | func (c *MockClient) ReadDiscreteInputs(address, quantity uint16) (results []byte, err error) { 55 | panic("Not implemented") 56 | } 57 | 58 | func (c *MockClient) MaskWriteRegister(address, andMask, orMask uint16) (results []byte, err error) { 59 | panic("Not implemented") 60 | } 61 | 62 | func (c *MockClient) ReadFIFOQueue(address uint16) (results []byte, err error) { 63 | panic("Not implemented") 64 | } 65 | 66 | func (c *MockClient) WriteSingleCoil(address, value uint16) (results []byte, err error) { 67 | panic("Not implemented") 68 | } 69 | 70 | func (c *MockClient) WriteMultipleCoils(address, quantity uint16, value []byte) (results []byte, err error) { 71 | panic("Not implemented") 72 | } 73 | 74 | func (c *MockClient) WriteSingleRegister(address, value uint16) (results []byte, err error) { 75 | panic("Not implemented") 76 | } 77 | 78 | func (c *MockClient) WriteMultipleRegisters(address, quantity uint16, value []byte) (results []byte, err error) { 79 | panic("Not implemented") 80 | } 81 | 82 | func (c *MockClient) ReadWriteMultipleRegisters(readAddress, readQuantity, writeAddress, writeQuantity uint16, value []byte) (results []byte, err error) { 83 | panic("Not implemented") 84 | } 85 | -------------------------------------------------------------------------------- /modbus.go: -------------------------------------------------------------------------------- 1 | package sdm630 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "time" 8 | 9 | "github.com/goburrow/modbus" 10 | . "github.com/gonium/gosdm630/internal/meters" 11 | ) 12 | 13 | const ( 14 | MaxRetryCount = 5 15 | ) 16 | 17 | const ( 18 | _ = iota 19 | ModbusComset2400_8N1 20 | ModbusComset9600_8N1 21 | ModbusComset19200_8N1 22 | ModbusComset2400_8E1 23 | ModbusComset9600_8E1 24 | ModbusComset19200_8E1 25 | ) 26 | 27 | type ModbusEngine struct { 28 | client modbus.Client 29 | handler *modbus.RTUClientHandler 30 | verbose bool 31 | status *Status 32 | } 33 | 34 | func NewRTUClient(rtuDevice string, comset int, verbose bool) *modbus.RTUClientHandler { 35 | // Modbus RTU/ASCII 36 | rtuclient := modbus.NewRTUClientHandler(rtuDevice) 37 | 38 | rtuclient.Parity = "N" 39 | rtuclient.DataBits = 8 40 | rtuclient.StopBits = 1 41 | 42 | switch comset { 43 | case ModbusComset2400_8N1: 44 | rtuclient.BaudRate = 2400 45 | case ModbusComset9600_8N1: 46 | rtuclient.BaudRate = 9600 47 | case ModbusComset19200_8N1: 48 | rtuclient.BaudRate = 19200 49 | case ModbusComset2400_8E1: 50 | rtuclient.BaudRate = 2400 51 | rtuclient.Parity = "E" 52 | case ModbusComset9600_8E1: 53 | rtuclient.BaudRate = 9600 54 | rtuclient.Parity = "E" 55 | case ModbusComset19200_8E1: 56 | rtuclient.BaudRate = 19200 57 | rtuclient.Parity = "E" 58 | default: 59 | log.Fatal("Invalid communication set specified. See -h for help.") 60 | } 61 | 62 | rtuclient.Timeout = 300 * time.Millisecond 63 | if verbose { 64 | rtuclient.Logger = log.New(os.Stdout, "RTUClientHandler: ", log.LstdFlags) 65 | log.Printf("Connecting to RTU via %s, %d %d%s%d\r\n", rtuDevice, 66 | rtuclient.BaudRate, rtuclient.DataBits, rtuclient.Parity, 67 | rtuclient.StopBits) 68 | } 69 | 70 | err := rtuclient.Connect() 71 | if err != nil { 72 | log.Fatal("Failed to connect: ", err) 73 | } 74 | defer rtuclient.Close() 75 | 76 | return rtuclient 77 | } 78 | 79 | func NewModbusEngine( 80 | rtuDevice string, 81 | comset int, 82 | verbose bool, 83 | status *Status, 84 | ) *ModbusEngine { 85 | var rtuclient *modbus.RTUClientHandler 86 | var mbclient modbus.Client 87 | 88 | if rtuDevice == "simulation" { 89 | rtuclient = &modbus.RTUClientHandler{} 90 | mbclient = NewMockClient(50) // 50% error rate for testing 91 | } else { 92 | rtuclient = NewRTUClient(rtuDevice, comset, verbose) 93 | mbclient = modbus.NewClient(rtuclient) 94 | } 95 | 96 | return &ModbusEngine{ 97 | client: mbclient, 98 | handler: rtuclient, 99 | verbose: verbose, 100 | status: status, 101 | } 102 | } 103 | 104 | func (q *ModbusEngine) query(snip QuerySnip) (retval []byte, err error) { 105 | q.status.IncreaseRequestCounter() 106 | 107 | // update the slave id in the handler 108 | q.handler.SlaveId = snip.DeviceId 109 | 110 | if snip.ReadLen <= 0 { 111 | log.Fatalf("Invalid meter operation %v.", snip) 112 | } 113 | 114 | switch snip.FuncCode { 115 | case ReadInputReg: 116 | retval, err = q.client.ReadInputRegisters(snip.OpCode, snip.ReadLen) 117 | case ReadHoldingReg: 118 | retval, err = q.client.ReadHoldingRegisters(snip.OpCode, snip.ReadLen) 119 | default: 120 | log.Fatalf("Unknown function code %d - cannot query device.", 121 | snip.FuncCode) 122 | } 123 | 124 | if err != nil && q.verbose { 125 | log.Printf("Failed to retrieve opcode 0x%x, error was: %s\r\n", snip.OpCode, err.Error()) 126 | } 127 | 128 | return retval, err 129 | } 130 | 131 | func (q *ModbusEngine) Transform( 132 | inputStream QuerySnipChannel, 133 | controlStream ControlSnipChannel, 134 | outputStream QuerySnipChannel, 135 | ) { 136 | var previousDeviceId uint8 137 | for { 138 | PROCESS_READINGS: 139 | snip := <-inputStream 140 | // The SDM devices need to have a little pause between querying 141 | // different devices. 142 | if previousDeviceId != snip.DeviceId { 143 | time.Sleep(time.Duration(100) * time.Millisecond) 144 | } 145 | previousDeviceId = snip.DeviceId 146 | 147 | for retryCount := 0; retryCount < MaxRetryCount; retryCount++ { 148 | reading, err := q.query(snip) 149 | if err == nil { 150 | // convert bytes to value 151 | snip.Value = snip.Transform(reading) 152 | snip.ReadTimestamp = time.Now() 153 | outputStream <- snip 154 | 155 | // signal ok 156 | successSnip := ControlSnip{ 157 | Type: CONTROLSNIP_OK, 158 | Message: "OK", 159 | DeviceId: snip.DeviceId, 160 | } 161 | controlStream <- successSnip 162 | 163 | goto PROCESS_READINGS 164 | } else { 165 | q.status.IncreaseReconnectCounter() 166 | log.Printf("Device %d failed to respond - retry attempt %d of %d", 167 | snip.DeviceId, retryCount+1, MaxRetryCount) 168 | time.Sleep(time.Duration(100) * time.Millisecond) 169 | } 170 | } 171 | 172 | // signal error 173 | errorSnip := ControlSnip{ 174 | Type: CONTROLSNIP_ERROR, 175 | Message: fmt.Sprintf("Device %d did not respond.", snip.DeviceId), 176 | DeviceId: snip.DeviceId, 177 | } 178 | controlStream <- errorSnip 179 | } 180 | } 181 | 182 | func (q *ModbusEngine) Scan() { 183 | type DeviceInfo struct { 184 | DeviceId uint8 185 | MeterType string 186 | } 187 | 188 | var deviceId uint8 189 | deviceList := make([]DeviceInfo, 0) 190 | oldtimeout := q.handler.Timeout 191 | q.handler.Timeout = 50 * time.Millisecond 192 | log.Printf("Starting bus scan") 193 | 194 | producers := []Producer{ 195 | NewSDMProducer(), 196 | NewJanitzaProducer(), 197 | NewDZGProducer(), 198 | } 199 | 200 | SCAN: 201 | // loop over all valid slave adresses 202 | for deviceId = 1; deviceId <= 247; deviceId++ { 203 | // give the bus some time to recover before querying the next device 204 | time.Sleep(time.Duration(40) * time.Millisecond) 205 | 206 | for _, producer := range producers { 207 | operation := producer.Probe() 208 | snip := NewQuerySnip(deviceId, operation) 209 | 210 | value, err := q.query(snip) 211 | if err == nil { 212 | log.Printf("Device %d: %s type device found, %s: %.2f\r\n", 213 | deviceId, 214 | producer.GetMeterType(), 215 | GetIecDescription(snip.IEC61850), 216 | snip.Transform(value)) 217 | dev := DeviceInfo{ 218 | DeviceId: deviceId, 219 | MeterType: producer.GetMeterType(), 220 | } 221 | deviceList = append(deviceList, dev) 222 | continue SCAN 223 | } 224 | } 225 | 226 | log.Printf("Device %d: n/a\r\n", deviceId) 227 | } 228 | 229 | // restore timeout to old value 230 | q.handler.Timeout = oldtimeout 231 | log.Printf("Found %d active devices:\r\n", len(deviceList)) 232 | for _, device := range deviceList { 233 | log.Printf("* slave address %d: type %s\r\n", device.DeviceId, 234 | device.MeterType) 235 | } 236 | log.Println("WARNING: This lists only the devices that responded to " + 237 | "a known probe request. Devices with different " + 238 | "function code definitions might not be detected.") 239 | } 240 | -------------------------------------------------------------------------------- /mqtt.go: -------------------------------------------------------------------------------- 1 | package sdm630 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "time" 7 | 8 | MQTT "github.com/eclipse/paho.mqtt.golang" 9 | ) 10 | 11 | type MqttClient struct { 12 | client MQTT.Client 13 | mqttTopic string 14 | mqttRate int 15 | mqttQos int 16 | in QuerySnipChannel 17 | verbose bool 18 | } 19 | 20 | // Run MQTT client publisher 21 | func (mqttClient *MqttClient) Run() { 22 | mqttRateMap := make(map[string]int64) 23 | 24 | for { 25 | snip := <-mqttClient.in 26 | if mqttClient.verbose { 27 | log.Printf("MQTT: got meter data (device %d: data: %s, value: %.3f W, desc: %s)", 28 | snip.DeviceId, 29 | snip.IEC61850, 30 | snip.Value, 31 | GetIecDescription(snip.IEC61850)) 32 | } 33 | 34 | uniqueID := fmt.Sprintf(UniqueIdFormat, snip.DeviceId) 35 | topic := fmt.Sprintf("%s/%s/%s", mqttClient.mqttTopic, uniqueID, snip.IEC61850) 36 | 37 | t := mqttRateMap[topic] 38 | now := time.Now() 39 | if mqttClient.mqttRate == 0 || now.Unix() > t { 40 | message := fmt.Sprintf("%.3f", snip.Value) 41 | token := mqttClient.client.Publish(topic, byte(mqttClient.mqttQos), true, message) 42 | if mqttClient.verbose { 43 | log.Printf("MQTT: push %s, message: %s", topic, message) 44 | } 45 | if token.Wait() && token.Error() != nil { 46 | log.Fatal("MQTT: Error connecting, trying to reconnect: ", token.Error()) 47 | } 48 | mqttRateMap[topic] = now.Unix() + int64(mqttClient.mqttRate) 49 | } else { 50 | if mqttClient.verbose { 51 | log.Printf("MQTT: skipped %s, rate to high", topic) 52 | } 53 | } 54 | } 55 | } 56 | 57 | func NewMqttClient( 58 | in QuerySnipChannel, 59 | mqttBroker string, 60 | mqttTopic string, 61 | mqttUser string, 62 | mqttPassword string, 63 | mqttClientID string, 64 | mqttQos int, 65 | mqttRate int, 66 | mqttCleanSession bool, 67 | verbose bool, 68 | ) *MqttClient { 69 | mqttOpts := MQTT.NewClientOptions() 70 | mqttOpts.AddBroker(mqttBroker) 71 | mqttOpts.SetUsername(mqttUser) 72 | mqttOpts.SetPassword(mqttPassword) 73 | mqttOpts.SetClientID(mqttClientID) 74 | mqttOpts.SetCleanSession(mqttCleanSession) 75 | mqttOpts.SetAutoReconnect(true) 76 | 77 | topic := fmt.Sprintf("%s/status", mqttTopic) 78 | message := fmt.Sprintf("disconnected") 79 | mqttOpts.SetWill(topic, message, byte(mqttQos), true) 80 | 81 | log.Printf("Connecting MQTT at %s", mqttBroker) 82 | if verbose { 83 | log.Printf("\tclientid: %s\n", mqttClientID) 84 | log.Printf("\tuser: %s\n", mqttUser) 85 | if mqttPassword != "" { 86 | log.Printf("\tpassword: ****\n") 87 | } 88 | log.Printf("\ttopic: %s\n", mqttTopic) 89 | log.Printf("\tqos: %d\n", mqttQos) 90 | log.Printf("\tcleansession: %v\n", mqttCleanSession) 91 | } 92 | 93 | mqttClient := MQTT.NewClient(mqttOpts) 94 | if token := mqttClient.Connect(); token.Wait() && token.Error() != nil { 95 | log.Fatal("MQTT: error connecting: ", token.Error()) 96 | panic(token.Error()) 97 | } 98 | if verbose { 99 | log.Println("MQTT: connected") 100 | } 101 | 102 | // notify connection 103 | message = fmt.Sprintf("connected") 104 | token := mqttClient.Publish(topic, byte(mqttQos), true, message) 105 | if verbose { 106 | log.Printf("MQTT: push %s, message: %s", topic, message) 107 | } 108 | if token.Wait() && token.Error() != nil { 109 | log.Fatal("MQTT: Error connecting, trying to reconnect: ", token.Error()) 110 | } 111 | 112 | return &MqttClient{ 113 | in: in, 114 | client: mqttClient, 115 | mqttTopic: mqttTopic, 116 | mqttRate: mqttRate, 117 | mqttQos: mqttQos, 118 | verbose: verbose, 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /scheduler.go: -------------------------------------------------------------------------------- 1 | package sdm630 2 | 3 | import ( 4 | "log" 5 | "time" 6 | 7 | . "github.com/gonium/gosdm630/internal/meters" 8 | ) 9 | 10 | type MeterScheduler struct { 11 | out QuerySnipChannel 12 | control ControlSnipChannel 13 | meters map[uint8]*Meter 14 | mc *MeasurementCache 15 | } 16 | 17 | func NewMeterScheduler( 18 | out QuerySnipChannel, 19 | control ControlSnipChannel, 20 | devices map[uint8]*Meter, 21 | ) *MeterScheduler { 22 | return &MeterScheduler{ 23 | out: out, 24 | meters: devices, 25 | control: control, 26 | } 27 | } 28 | 29 | // SetupScheduler creates a scheduler and its wiring 30 | func SetupScheduler(meters map[uint8]*Meter, qe *ModbusEngine) (*MeterScheduler, QuerySnipChannel) { 31 | // Create Channels that link the goroutines 32 | var scheduler2queryengine = make(QuerySnipChannel) 33 | var queryengine2scheduler = make(ControlSnipChannel) 34 | var queryengine2tee = make(QuerySnipChannel) 35 | 36 | scheduler := NewMeterScheduler( 37 | scheduler2queryengine, 38 | queryengine2scheduler, 39 | meters, 40 | ) 41 | 42 | go qe.Transform( 43 | scheduler2queryengine, // input 44 | queryengine2scheduler, // error 45 | queryengine2tee, // output 46 | ) 47 | 48 | return scheduler, queryengine2tee 49 | } 50 | 51 | func (q *MeterScheduler) SetCache(mc *MeasurementCache) { 52 | q.mc = mc 53 | } 54 | 55 | func (q *MeterScheduler) produceSnips(out QuerySnipChannel) { 56 | for { 57 | for _, meter := range q.meters { 58 | operations := meter.Producer.Produce() 59 | for _, operation := range operations { 60 | // Check if meter is still valid 61 | if meter.GetState() != UNAVAILABLE { 62 | snip := NewQuerySnip(meter.DeviceId, operation) 63 | q.out <- snip 64 | } 65 | } 66 | } 67 | } 68 | } 69 | 70 | func (q *MeterScheduler) supervisor() { 71 | for { 72 | for _, meter := range q.meters { 73 | if meter.GetState() == UNAVAILABLE { 74 | log.Printf("Attempting to ping unavailable device %d", meter.DeviceId) 75 | // inject probe snip - the re-enabling logic is in Run() 76 | operation := meter.Producer.Probe() 77 | snip := NewQuerySnip(meter.DeviceId, operation) 78 | q.out <- snip 79 | } 80 | } 81 | time.Sleep(1 * time.Minute) 82 | } 83 | } 84 | 85 | func (q *MeterScheduler) Run() { 86 | source := make(QuerySnipChannel) 87 | 88 | go q.supervisor() 89 | go q.produceSnips(source) 90 | 91 | for { 92 | select { 93 | case snip := <-source: 94 | q.out <- snip 95 | case controlSnip := <-q.control: 96 | switch controlSnip.Type { 97 | case CONTROLSNIP_ERROR: 98 | // search meter and deactivate it... 99 | log.Printf("Device %d failed terminally due to: %s", 100 | controlSnip.DeviceId, controlSnip.Message) 101 | if meter, ok := q.meters[controlSnip.DeviceId]; ok { 102 | state := meter.GetState() 103 | meter.UpdateState(UNAVAILABLE) 104 | if state == AVAILABLE && q.mc != nil { 105 | // purge cache if present 106 | q.mc.Purge(meter.DeviceId) 107 | } 108 | } else { 109 | log.Fatal("Internal device id mismatch - this should not happen!") 110 | } 111 | case CONTROLSNIP_OK: 112 | // search meter and reactivate it... 113 | if meter, ok := q.meters[controlSnip.DeviceId]; ok { 114 | if meter.GetState() != AVAILABLE { 115 | log.Printf("Reactivating device %d", controlSnip.DeviceId) 116 | meter.UpdateState(AVAILABLE) 117 | } 118 | } else { 119 | log.Fatal("Internal device id mismatch - this should not happen!") 120 | } 121 | default: 122 | log.Fatal("Received unknown control snip - something weird happened.") 123 | } 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /snips.go: -------------------------------------------------------------------------------- 1 | package sdm630 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "math" 7 | "sync" 8 | "time" 9 | 10 | . "github.com/gonium/gosdm630/internal/meters" 11 | ) 12 | 13 | // QuerySnip represents modbus query operations 14 | type QuerySnip struct { 15 | DeviceId uint8 16 | Operation `json:"-"` 17 | Value float64 18 | ReadTimestamp time.Time 19 | } 20 | 21 | func NewQuerySnip(deviceId uint8, operation Operation) QuerySnip { 22 | snip := QuerySnip{ 23 | DeviceId: deviceId, 24 | Operation: operation, 25 | Value: math.NaN(), 26 | } 27 | return snip 28 | } 29 | 30 | // String representation 31 | func (q *QuerySnip) String() string { 32 | return fmt.Sprintf("DevID: %d, FunCode: %d, Opcode: %x, IEC: %s, Value: %.3f", 33 | q.DeviceId, q.FuncCode, q.OpCode, q.IEC61850, q.Value) 34 | } 35 | 36 | // MarshalJSON converts QuerySnip to json, replacing ReadTimestamp with unix time representation 37 | func (q *QuerySnip) MarshalJSON() ([]byte, error) { 38 | return json.Marshal(struct { 39 | DeviceId uint8 40 | Value float64 41 | IEC61850 string 42 | Description string 43 | Timestamp int64 44 | }{ 45 | DeviceId: q.DeviceId, 46 | Value: q.Value, 47 | IEC61850: q.IEC61850, 48 | Description: GetIecDescription(q.IEC61850), 49 | Timestamp: q.ReadTimestamp.UnixNano() / 1e6, 50 | }) 51 | } 52 | 53 | type QuerySnipChannel chan QuerySnip 54 | 55 | // QuerySnipBroadcaster acts as hub for broadcating QuerySnips 56 | // to multiple recipients 57 | type QuerySnipBroadcaster struct { 58 | in QuerySnipChannel 59 | recipients []QuerySnipChannel 60 | mux sync.Mutex // guard recipients 61 | } 62 | 63 | // NewQuerySnipBroadcaster creates QuerySnipBroadcaster 64 | func NewQuerySnipBroadcaster(in QuerySnipChannel) *QuerySnipBroadcaster { 65 | return &QuerySnipBroadcaster{ 66 | in: in, 67 | recipients: make([]QuerySnipChannel, 0), 68 | } 69 | } 70 | 71 | // Run executes the broadcaster 72 | func (b *QuerySnipBroadcaster) Run() { 73 | for { 74 | s := <-b.in 75 | b.mux.Lock() 76 | for _, recipient := range b.recipients { 77 | recipient <- s 78 | } 79 | b.mux.Unlock() 80 | } 81 | } 82 | 83 | // Attach creates and attaches a QuerySnipChannel to the broadcaster 84 | func (b *QuerySnipBroadcaster) Attach() QuerySnipChannel { 85 | channel := make(QuerySnipChannel) 86 | 87 | b.mux.Lock() 88 | b.recipients = append(b.recipients, channel) 89 | b.mux.Unlock() 90 | 91 | return channel 92 | } 93 | 94 | // ControlSnip wraps control information like query success or failure. 95 | type ControlSnip struct { 96 | Type ControlSnipType 97 | Message string 98 | DeviceId uint8 99 | } 100 | 101 | type ControlSnipType uint8 102 | 103 | const ( 104 | CONTROLSNIP_OK ControlSnipType = iota 105 | CONTROLSNIP_ERROR 106 | ) 107 | 108 | type ControlSnipChannel chan ControlSnip 109 | -------------------------------------------------------------------------------- /socket.go: -------------------------------------------------------------------------------- 1 | // Copyright 2013 The Gorilla WebSocket Authors. All rights reserved. 2 | // Use of this source code is governed by a BSD-style 3 | // license that can be found in the LICENSE file. 4 | 5 | package sdm630 6 | 7 | import ( 8 | "encoding/json" 9 | "log" 10 | "net/http" 11 | "time" 12 | 13 | "github.com/gorilla/websocket" 14 | ) 15 | 16 | const ( 17 | // Time allowed to write a message to the peer. 18 | socketWriteWait = 10 * time.Second 19 | ) 20 | 21 | var upgrader = websocket.Upgrader{ 22 | ReadBufferSize: 1024, 23 | WriteBufferSize: 1024, 24 | } 25 | 26 | // Client is a middleman between the websocket connection and the hub. 27 | type Client struct { 28 | hub *SocketHub 29 | 30 | // The websocket connection. 31 | conn *websocket.Conn 32 | 33 | // Buffered channel of outbound messages. 34 | send chan []byte 35 | } 36 | 37 | // writePump pumps messages from the hub to the websocket connection. 38 | func (c *Client) writePump() { 39 | defer func() { 40 | c.conn.Close() 41 | }() 42 | for { 43 | select { 44 | case msg := <-c.send: 45 | c.conn.SetWriteDeadline(time.Now().Add(socketWriteWait)) 46 | if err := c.conn.WriteMessage(websocket.TextMessage, msg); err != nil { 47 | return 48 | } 49 | } 50 | } 51 | } 52 | 53 | // ServeWebsocket handles websocket requests from the peer. 54 | func ServeWebsocket(hub *SocketHub, w http.ResponseWriter, r *http.Request) { 55 | conn, err := upgrader.Upgrade(w, r, nil) 56 | if err != nil { 57 | log.Println(err) 58 | return 59 | } 60 | client := &Client{hub: hub, conn: conn, send: make(chan []byte, 256)} 61 | client.hub.register <- client 62 | 63 | // run writing to client in goroutine 64 | go client.writePump() 65 | } 66 | 67 | // SocketHub maintains the set of active clients and broadcasts messages to the 68 | // clients. 69 | type SocketHub struct { 70 | // Registered clients. 71 | clients map[*Client]bool 72 | 73 | // Register requests from the clients. 74 | register chan *Client 75 | 76 | // Unregister requests from clients. 77 | unregister chan *Client 78 | 79 | // meter data stream 80 | in QuerySnipChannel 81 | 82 | // status stream 83 | statusStream chan *Status 84 | } 85 | 86 | func NewSocketHub(inChannel QuerySnipChannel, status *Status) *SocketHub { 87 | // Attach a goroutine that will push meter status information 88 | // periodically 89 | var statusstream = make(chan *Status) 90 | go func() { 91 | for { 92 | time.Sleep(SECONDS_BETWEEN_STATUSUPDATE * time.Second) 93 | status.Update() 94 | statusstream <- status 95 | } 96 | }() 97 | 98 | return &SocketHub{ 99 | register: make(chan *Client), 100 | unregister: make(chan *Client), 101 | clients: make(map[*Client]bool), 102 | in: inChannel, 103 | statusStream: statusstream, 104 | } 105 | } 106 | 107 | func (h *SocketHub) Broadcast(i interface{}) { 108 | if len(h.clients) > 0 { 109 | message, err := json.Marshal(i) 110 | if err != nil { 111 | log.Fatal(err) 112 | } 113 | 114 | for client := range h.clients { 115 | select { 116 | case client.send <- message: 117 | default: 118 | close(client.send) 119 | delete(h.clients, client) 120 | } 121 | } 122 | } 123 | } 124 | 125 | func (h *SocketHub) Run() { 126 | for { 127 | select { 128 | case client := <-h.register: 129 | h.clients[client] = true 130 | case client := <-h.unregister: 131 | if _, ok := h.clients[client]; ok { 132 | delete(h.clients, client) 133 | close(client.send) 134 | } 135 | case obj := <-h.in: 136 | // make sure to pass a pointer or MarshalJSON won't work 137 | h.Broadcast(&obj) 138 | case obj := <-h.statusStream: 139 | h.Broadcast(obj) 140 | } 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /status.go: -------------------------------------------------------------------------------- 1 | package sdm630 2 | 3 | import ( 4 | "encoding/json" 5 | "runtime" 6 | "sync" 7 | "time" 8 | 9 | . "github.com/gonium/gosdm630/internal/meters" 10 | ) 11 | 12 | type MemoryStatus struct { 13 | Alloc uint64 14 | HeapAlloc uint64 15 | } 16 | 17 | type ModbusStatus struct { 18 | Requests uint64 19 | RequestsPerMinute float64 20 | Errors uint64 21 | ErrorsPerMinute float64 22 | } 23 | 24 | func CurrentMemoryStatus() MemoryStatus { 25 | var mem runtime.MemStats 26 | runtime.ReadMemStats(&mem) 27 | return MemoryStatus{ 28 | Alloc: mem.Alloc, 29 | HeapAlloc: mem.HeapAlloc, 30 | } 31 | } 32 | 33 | type Status struct { 34 | Starttime time.Time 35 | UptimeSeconds float64 36 | Goroutines int 37 | Memory MemoryStatus 38 | Modbus ModbusStatus 39 | ConfiguredMeters []MeterStatus 40 | metermap map[uint8]*Meter 41 | mux sync.RWMutex `json:"-"` 42 | } 43 | 44 | type MeterStatus struct { 45 | Id uint8 46 | Type string 47 | Status string 48 | } 49 | 50 | func NewStatus(metermap map[uint8]*Meter) *Status { 51 | return &Status{ 52 | Memory: CurrentMemoryStatus(), 53 | Starttime: time.Now(), 54 | Goroutines: runtime.NumGoroutine(), 55 | UptimeSeconds: 1, 56 | Modbus: ModbusStatus{ 57 | Requests: 0, 58 | RequestsPerMinute: 0, 59 | Errors: 0, 60 | ErrorsPerMinute: 0, 61 | }, 62 | ConfiguredMeters: nil, 63 | metermap: metermap, 64 | } 65 | } 66 | 67 | func (s *Status) IncreaseRequestCounter() { 68 | s.mux.Lock() 69 | defer s.mux.Unlock() 70 | s.Modbus.Requests++ 71 | } 72 | 73 | func (s *Status) IncreaseReconnectCounter() { 74 | s.mux.Lock() 75 | defer s.mux.Unlock() 76 | s.Modbus.Errors++ 77 | } 78 | 79 | func (s *Status) Update() { 80 | s.mux.Lock() 81 | defer s.mux.Unlock() 82 | 83 | s.Memory = CurrentMemoryStatus() 84 | s.Goroutines = runtime.NumGoroutine() 85 | s.UptimeSeconds = time.Since(s.Starttime).Seconds() 86 | s.Modbus.ErrorsPerMinute = float64(s.Modbus.Errors) / (s.UptimeSeconds / 60) 87 | s.Modbus.RequestsPerMinute = float64(s.Modbus.Requests) / (s.UptimeSeconds / 60) 88 | 89 | var confmeters []MeterStatus 90 | for id, meter := range s.metermap { 91 | ms := MeterStatus{ 92 | Id: id, 93 | Type: meter.Producer.GetMeterType(), 94 | Status: meter.GetState().String(), 95 | } 96 | 97 | confmeters = append(confmeters, ms) 98 | } 99 | s.ConfiguredMeters = confmeters 100 | } 101 | 102 | // MarshalJSON will syncronize access to the status object 103 | // see http://choly.ca/post/go-json-marshalling/ for avoiding infinite loop 104 | func (s *Status) MarshalJSON() ([]byte, error) { 105 | s.mux.RLock() 106 | defer s.mux.RUnlock() 107 | 108 | type Alias Status 109 | return json.Marshal(&struct{ *Alias }{Alias: (*Alias)(s)}) 110 | } 111 | -------------------------------------------------------------------------------- /tools.go: -------------------------------------------------------------------------------- 1 | // +build tools 2 | 3 | package sdm630 4 | 5 | import ( 6 | _ "github.com/aprice/embed/cmd/embed" 7 | _ "github.com/aprice/embed/loader" 8 | ) 9 | -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | package sdm630 2 | 3 | const ( 4 | RELEASEVERSION = "0.7.0" 5 | ) 6 | --------------------------------------------------------------------------------