├── grafana_screenshot.png
├── contrib
├── prometheus
│ └── hmgo.rules
└── wireshark
│ └── homematic.lua
├── internal
├── serial
│ └── serial.go
├── hm
│ ├── heating
│ │ ├── heating.go
│ │ ├── heating_test.go
│ │ └── infoevent.go
│ ├── power
│ │ ├── power.go
│ │ ├── power_test.go
│ │ └── powerevent.go
│ ├── thermal
│ │ ├── weatherevent.go
│ │ ├── thermal_test.go
│ │ ├── thermalcontrolevent.go
│ │ ├── thermal.go
│ │ └── infoevent.go
│ └── hm.go
├── uartgw
│ ├── escaping.go
│ └── uartgw.go
├── gpio
│ └── reset.go
└── bidcos
│ └── bidcos.go
├── status.go
├── go.mod
├── README.md
├── LICENSE
├── go.sum
└── ccu.go
/grafana_screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stapelberg/hmgo/HEAD/grafana_screenshot.png
--------------------------------------------------------------------------------
/contrib/prometheus/hmgo.rules:
--------------------------------------------------------------------------------
1 | ALERT BidCoSDeviceUnreachable
2 | IF (time() - hm_LastContact{address=~"390f17|3906eb|3906da|390f27"}) > (60 * 60)
3 | FOR 15m
4 | LABELS {
5 | job = "hmgo",
6 | }
7 | ANNOTATIONS {
8 | summary = "BidCoS device unreachable",
9 | description = "Did the battery run out?",
10 | }
11 |
12 | ALERT BidCoSBatteryLow
13 | IF hmthermal_InfoEventBatteryState < 2.5
14 | FOR 4h
15 | LABELS {
16 | job = "hmgo",
17 | }
18 | ANNOTATIONS {
19 | summary = "BidCoS battery low (< 2.5V)",
20 | description = "Did the battery run out?",
21 | }
22 |
--------------------------------------------------------------------------------
/internal/serial/serial.go:
--------------------------------------------------------------------------------
1 | //+build linux
2 |
3 | // Package serial configures serial ports.
4 | package serial
5 |
6 | import (
7 | "syscall"
8 | "unsafe"
9 | )
10 |
11 | // Configure configures fd as a 115200 baud 8N1 serial port.
12 | func Configure(fd uintptr) error {
13 | var termios syscall.Termios
14 | if _, _, err := syscall.Syscall(syscall.SYS_IOCTL, fd, uintptr(syscall.TCGETS), uintptr(unsafe.Pointer(&termios))); err != 0 {
15 | return err
16 | }
17 |
18 | termios.Iflag = 0
19 | termios.Oflag = 0
20 | termios.Lflag = 0
21 | termios.Ispeed = syscall.B115200
22 | termios.Ospeed = syscall.B115200
23 | termios.Cflag = syscall.B115200 | syscall.CS8 | syscall.CREAD
24 |
25 | // Block on a zero read (instead of returning EOF)
26 | termios.Cc[syscall.VMIN] = 1
27 | termios.Cc[syscall.VTIME] = 0
28 |
29 | if _, _, err := syscall.Syscall(syscall.SYS_IOCTL, fd, syscall.TCSETS, uintptr(unsafe.Pointer(&termios))); err != 0 {
30 | return err
31 | }
32 |
33 | return nil
34 | }
35 |
--------------------------------------------------------------------------------
/internal/hm/heating/heating.go:
--------------------------------------------------------------------------------
1 | package heating
2 |
3 | import (
4 | "sync"
5 |
6 | "github.com/stapelberg/hmgo/internal/hm"
7 | )
8 |
9 | // channels
10 | const (
11 | ClimateControlReceiver = 0x02
12 | )
13 |
14 | // Thermostat represents a HM-CC-RT-DN heating thermostat. Its manual
15 | // can be found at
16 | // http://www.eq-3.de/Downloads/eq3/downloads_produktkatalog/homematic/bda/HM-CC-RT-DN_UM_GE_eQ-3_web.pdf
17 | type Thermostat struct {
18 | hm.StandardDevice
19 |
20 | latestInfoEvent *InfoEvent
21 | latestMu sync.RWMutex
22 | }
23 |
24 | func (t *Thermostat) HomeMaticType() string { return "heating" }
25 |
26 | func NewThermostat(sd hm.StandardDevice) *Thermostat {
27 | sd.NumChannels = 6
28 | return &Thermostat{StandardDevice: sd}
29 | }
30 |
31 | func (t *Thermostat) MostRecentEvents() []hm.Event {
32 | var result []hm.Event
33 | t.latestMu.RLock()
34 | defer t.latestMu.RUnlock()
35 |
36 | if t.latestInfoEvent != nil {
37 | result = append(result, t.latestInfoEvent)
38 | }
39 |
40 | return result
41 | }
42 |
--------------------------------------------------------------------------------
/status.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "bytes"
5 | "html/template"
6 | "io"
7 | "net/http"
8 |
9 | "github.com/stapelberg/hmgo/internal/hm"
10 | )
11 |
12 | const statusTmplContents = `
13 |
14 |
hmgo
15 |
16 | Devices
17 |
18 | {{ range $serial, $dev := .Devices }}
19 |
20 | | {{ $serial }} |
21 |
22 | {{ $dev }}
23 | {{ range $idx, $event := $dev.MostRecentEvents }}
24 |
25 | {{ $event.HTML }}
26 |
27 | {{ end }}
28 | |
29 |
30 | {{ end }}
31 |
32 | `
33 |
34 | var statusTmpl = template.Must(template.New("status").Parse(statusTmplContents))
35 |
36 | func handleStatus(w http.ResponseWriter, r *http.Request, devs map[string]hm.Device) {
37 | var buf bytes.Buffer
38 |
39 | if err := statusTmpl.Execute(&buf, struct {
40 | Devices map[string]hm.Device
41 | }{
42 | Devices: devs,
43 | }); err != nil {
44 | http.Error(w, err.Error(), http.StatusInternalServerError)
45 | return
46 | }
47 |
48 | io.Copy(w, &buf)
49 | }
50 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/stapelberg/hmgo
2 |
3 | go 1.24
4 |
5 | require (
6 | github.com/gokrazy/gokrazy v0.0.0-20250417142646-cc835e3b7f24
7 | github.com/prometheus/client_golang v1.22.0
8 | github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1
9 | golang.org/x/sys v0.33.0
10 | )
11 |
12 | require (
13 | github.com/beorn7/perks v1.0.1 // indirect
14 | github.com/cespare/xxhash/v2 v2.3.0 // indirect
15 | github.com/gokrazy/internal v0.0.0-20240629150625-a0f1dee26ef5 // indirect
16 | github.com/google/renameio/v2 v2.0.0 // indirect
17 | github.com/kenshaw/evdev v0.1.0 // indirect
18 | github.com/mdlayher/watchdog v0.0.0-20201005150459-8bdc4f41966b // indirect
19 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
20 | github.com/prometheus/client_model v0.6.1 // indirect
21 | github.com/prometheus/common v0.62.0 // indirect
22 | github.com/prometheus/procfs v0.15.1 // indirect
23 | github.com/spf13/pflag v1.0.5 // indirect
24 | github.com/vishvananda/netlink v1.1.0 // indirect
25 | github.com/vishvananda/netns v0.0.0-20211101163701-50045581ed74 // indirect
26 | google.golang.org/protobuf v1.36.5 // indirect
27 | )
28 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## overview
2 |
3 | You’re looking at a minimal implementation of a central control unit
4 | for BidCoS-based home automation devices such as the eQ-3 “HomeMatic”
5 | line of products.
6 |
7 | The program does not keep any state and does not read any
8 | configuration. Instead, the specific use-case the author needed and
9 | devices the author owns are hard-coded. In a way, you can think of it
10 | as a home automation “configured” in Go, coming with its own low-level
11 | libraries.
12 |
13 | ## contributions
14 |
15 | Note that this project does not accept new features, neither feature
16 | requests nor feature contributions. Documentation or code corrections
17 | on the other hand are very welcome.
18 |
19 | The code is published in the hope that it will be useful to someone,
20 | perhaps understanding/implementing the BidCoS protocol themselves :).
21 |
22 | If you’d like to see this become an active open source project, please
23 | fork and maintain it, and I’ll gladly add a reference to your version.
24 |
25 | ## details
26 |
27 | This code interacts with the following HomeMatic devices:
28 | * HM-MOD-RPI-PCB (wireless transceiver)
29 | * HM-CC-RT-DN (heating valve drivers)
30 | * HM-TC-IT-WM-W-EU (thermostats)
31 | * HM-ES-PMSw1-Pl (power switch)
32 |
33 | All implemented properties of BidCoS events are exposed as
34 | [prometheus](https://prometheus.io/) metrics.
35 |
36 | AES encryption is not supported, because I don’t see the need for my
37 | use-case.
--------------------------------------------------------------------------------
/internal/hm/heating/heating_test.go:
--------------------------------------------------------------------------------
1 | package heating_test
2 |
3 | import (
4 | "fmt"
5 | "testing"
6 |
7 | "github.com/stapelberg/hmgo/internal/bidcos"
8 | "github.com/stapelberg/hmgo/internal/hm"
9 | "github.com/stapelberg/hmgo/internal/hm/heating"
10 | )
11 |
12 | type testGateway struct {
13 | }
14 |
15 | func (t *testGateway) Read(p []byte) (n int, err error) {
16 | return 0, fmt.Errorf("reading not supported")
17 | }
18 |
19 | func (t *testGateway) Write(p []byte) (n int, err error) {
20 | return 0, fmt.Errorf("writing not supported")
21 | }
22 |
23 | func (t *testGateway) Confirm() error {
24 | return nil
25 | }
26 |
27 | func TestDecodeInfoEvent(t *testing.T) {
28 | gw := testGateway{}
29 | bcs, err := bidcos.NewSender(&gw, [3]byte{0xfd, 0xee, 0xdd})
30 | if err != nil {
31 | t.Fatal(err)
32 | }
33 | ts := heating.NewThermostat(hm.StandardDevice{BCS: bcs, Addr: [3]byte{0xaa, 0xbb, 0xcc}})
34 | ie, err := ts.DecodeInfoEvent([]byte{0x0a, 0xb0, 0xe2, 0x08, 0x00, 0x00})
35 | if err != nil {
36 | t.Fatal(err)
37 | }
38 | t.Logf("info event: %+v", ie)
39 | if got, want := ie.SetTemperature, 22.0; got != want {
40 | t.Fatalf("unexpected set temperature: got %v, want %v", got, want)
41 | }
42 | if got, want := ie.ActualTemperature, 22.6; got != want {
43 | t.Fatalf("unexpected actual temperature: got %v, want %v", got, want)
44 | }
45 | if got, want := ie.BatteryState, 2.3; got != want {
46 | t.Fatalf("unexpected battery state: got %v, want %v", got, want)
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/internal/hm/power/power.go:
--------------------------------------------------------------------------------
1 | package power
2 |
3 | import (
4 | "sync"
5 |
6 | "github.com/stapelberg/hmgo/internal/bidcos"
7 | "github.com/stapelberg/hmgo/internal/hm"
8 | )
9 |
10 | const (
11 | PowerMeter = 0x02
12 | ChannelSwitch = 0x01
13 | ChannelMaster = 0x05
14 |
15 | On = 0xc8
16 | Off = 0x00
17 | )
18 |
19 | const (
20 | MaintenanceChannel = iota
21 | SwitchChannel
22 | ConditionPowermeterChannel
23 | ConditionPowerChannel
24 | ConditionCurrentChannel
25 | ConditionVoltageChannel
26 | ConditionFrequencyChannel
27 | )
28 |
29 | type PowerSwitch struct {
30 | hm.StandardDevice
31 |
32 | latestPowerEvent *PowerEvent
33 | latestMu sync.RWMutex
34 | }
35 |
36 | func (ps *PowerSwitch) HomeMaticType() string { return "power" }
37 |
38 | func NewPowerSwitch(sd hm.StandardDevice) *PowerSwitch {
39 | sd.NumChannels = 6
40 | return &PowerSwitch{StandardDevice: sd}
41 | }
42 |
43 | func (ps *PowerSwitch) MostRecentEvents() []hm.Event {
44 | var result []hm.Event
45 | ps.latestMu.RLock()
46 | defer ps.latestMu.RUnlock()
47 |
48 | if ps.latestPowerEvent != nil {
49 | result = append(result, ps.latestPowerEvent)
50 | }
51 |
52 | return result
53 | }
54 |
55 | func (ps *PowerSwitch) LevelSet(channel, state, onTime byte) error {
56 | return ps.BCS.WritePacket(&bidcos.Packet{
57 | Flags: bidcos.DefaultFlags,
58 | Cmd: 0x11, // LevelSet
59 | Dest: ps.Addr,
60 | Payload: []byte{
61 | 0x02, // subtype
62 | channel,
63 | state,
64 | 0x00, // constant
65 | onTime,
66 | },
67 | })
68 | }
69 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2017 Michael Stapelberg. All rights reserved.
2 |
3 | Redistribution and use in source and binary forms, with or without
4 | modification, are permitted provided that the following conditions are
5 | met:
6 |
7 | * Redistributions of source code must retain the above copyright
8 | notice, this list of conditions and the following disclaimer.
9 | * Redistributions in binary form must reproduce the above
10 | copyright notice, this list of conditions and the following disclaimer
11 | in the documentation and/or other materials provided with the
12 | distribution.
13 | * Neither the name of Michael Stapelberg nor the names of
14 | contributors may be used to endorse or promote products derived from
15 | this software without specific prior written permission.
16 |
17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
18 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
19 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
20 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
21 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
22 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
23 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
24 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
25 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
26 | (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 |
--------------------------------------------------------------------------------
/internal/uartgw/escaping.go:
--------------------------------------------------------------------------------
1 | package uartgw
2 |
3 | import "io"
4 |
5 | // escapingWriter escapes 0xfd for the UARTGW
6 | type escapingWriter struct {
7 | w io.Writer
8 | }
9 |
10 | func (ew *escapingWriter) Write(p []byte) (n int, err error) {
11 | if len(p) == 0 {
12 | return 0, nil
13 | }
14 |
15 | // Twice as long: in the worst case, every byte needs to be escaped.
16 | escaped := make([]byte, 0, len(p)*2)
17 | for _, b := range p {
18 | // 0xfd (frame delimiter) must be escaped within a frame.
19 | // 0xfc introduces an escaped byte, so bytes which happen to
20 | // be 0xfc need to be escaped as well.
21 | if b == 0xfd || b == 0xfc {
22 | escaped = append(escaped, 0xfc, b&0x7f)
23 | } else {
24 | escaped = append(escaped, b)
25 | }
26 | }
27 | n, err = ew.w.Write(escaped)
28 | if err != nil {
29 | return n, err
30 | }
31 | return len(p), nil
32 | }
33 |
34 | type unescapingReader struct {
35 | r io.Reader
36 | }
37 |
38 | func (uer *unescapingReader) Read(p []byte) (n int, err error) {
39 | if len(p) == 0 {
40 | return 0, nil
41 | }
42 |
43 | raw := make([]byte, len(p))
44 | n, err = uer.r.Read(raw)
45 | if err != nil {
46 | return n, err
47 | }
48 | var escapeByte bool
49 | idx := 0
50 | for _, b := range raw[:n] {
51 | if b == 0xfc {
52 | escapeByte = true
53 | continue
54 | }
55 | if escapeByte {
56 | b |= 0x80
57 | escapeByte = false
58 | }
59 | p[idx] = b
60 | idx++
61 | }
62 |
63 | // We cannot end on an escaped byte because the escape state would
64 | // not be carried over into the next read call. Force a read.
65 | if escapeByte {
66 | last := make([]byte, 1)
67 | n, err = uer.r.Read(last)
68 | if err != nil {
69 | return idx, err
70 | }
71 | p[idx] = last[0] | 0x80
72 | idx++
73 | }
74 | return idx, nil
75 | }
76 |
--------------------------------------------------------------------------------
/internal/hm/power/power_test.go:
--------------------------------------------------------------------------------
1 | package power_test
2 |
3 | import (
4 | "fmt"
5 | "testing"
6 |
7 | "github.com/stapelberg/hmgo/internal/bidcos"
8 | "github.com/stapelberg/hmgo/internal/hm"
9 | "github.com/stapelberg/hmgo/internal/hm/power"
10 | )
11 |
12 | type testGateway struct {
13 | }
14 |
15 | func (t *testGateway) Read(p []byte) (n int, err error) {
16 | return 0, fmt.Errorf("reading not supported")
17 | }
18 |
19 | func (t *testGateway) Write(p []byte) (n int, err error) {
20 | return 0, fmt.Errorf("writing not supported")
21 | }
22 |
23 | func (t *testGateway) Confirm() error {
24 | return nil
25 | }
26 |
27 | func TestDecodePowerEvent(t *testing.T) {
28 | gw := testGateway{}
29 | bcs, err := bidcos.NewSender(&gw, [3]byte{0xfd, 0xee, 0xdd})
30 | if err != nil {
31 | t.Fatal(err)
32 | }
33 | ps := power.NewPowerSwitch(hm.StandardDevice{BCS: bcs, Addr: [3]byte{0xaa, 0xbb, 0xcc}})
34 |
35 | // payload captured measuring a Raspberry Pi 3 :)
36 | pe, err := ps.DecodePowerEvent([]byte{128, 3, 138, 0, 0, 187, 0, 16, 9, 8, 255})
37 | if err != nil {
38 | t.Fatal(err)
39 | }
40 |
41 | if got, want := pe.Boot, true; got != want {
42 | t.Fatalf("unexpected boot: got %v, want %v", got, want)
43 | }
44 | if got, want := pe.EnergyCounter, 90.6; got != want {
45 | t.Fatalf("unexpected energy counter: got %v, want %v", got, want)
46 | }
47 | if got, want := pe.Power, 1.87; got != want {
48 | t.Fatalf("unexpected power: got %v, want %v", got, want)
49 | }
50 | if got, want := pe.Current, 16.0; got != want {
51 | t.Fatalf("unexpected current: got %v, want %v", got, want)
52 | }
53 | if got, want := pe.Voltage, 231.2; got != want {
54 | t.Fatalf("unexpected voltage: got %v, want %v", got, want)
55 | }
56 | if got, want := pe.Frequency, 52.55; got != want {
57 | t.Fatalf("unexpected frequency: got %v, want %v", got, want)
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/internal/hm/thermal/weatherevent.go:
--------------------------------------------------------------------------------
1 | package thermal
2 |
3 | import (
4 | "bytes"
5 | "fmt"
6 | "html/template"
7 |
8 | "github.com/prometheus/client_golang/prometheus"
9 | )
10 |
11 | var (
12 | weatherEventTemperature = prometheus.NewGaugeVec(
13 | prometheus.GaugeOpts{
14 | Namespace: prometheusNamespace,
15 | Name: "WeatherEventTemperature",
16 | Help: "Temperature in degC",
17 | },
18 | []string{"address", "name"})
19 |
20 | weatherEventHumidity = prometheus.NewGaugeVec(
21 | prometheus.GaugeOpts{
22 | Namespace: prometheusNamespace,
23 | Name: "WeatherEventHumidity",
24 | Help: "Humidity in percentage points",
25 | },
26 | []string{"address", "name"})
27 | )
28 |
29 | func init() {
30 | prometheus.MustRegister(weatherEventTemperature)
31 | prometheus.MustRegister(weatherEventHumidity)
32 | }
33 |
34 | type WeatherEvent struct {
35 | Temperature float64 // in degC
36 | Humidity uint64 // in percentage points
37 | }
38 |
39 | var weTmpl = template.Must(template.New("weatherevent").Parse(`
40 | Weather:
41 | Temperature: {{ .Temperature }} ℃
42 | Humidity: {{ .Humidity }}%
43 | `))
44 |
45 | func (we *WeatherEvent) HTML() template.HTML {
46 | var buf bytes.Buffer
47 | if err := weTmpl.Execute(&buf, we); err != nil {
48 | return template.HTML(template.HTMLEscapeString(err.Error()))
49 | }
50 | return template.HTML(buf.String())
51 | }
52 |
53 | func (tc *ThermalControl) DecodeWeatherEvent(payload []byte) (*WeatherEvent, error) {
54 | // c.f. in rftypes/tc.xml
55 | if got, want := len(payload), 3; got != want {
56 | return nil, fmt.Errorf("unexpected payload size: got %d, want %d", got, want)
57 | }
58 | we := &WeatherEvent{
59 | Temperature: float64((int16(payload[0])<<8|int16(payload[1]))&0x3FFF) / 10,
60 | Humidity: uint64(payload[2]),
61 | }
62 |
63 | weatherEventTemperature.With(prometheus.Labels{"name": tc.Name(), "address": tc.AddrHex()}).Set(we.Temperature)
64 | weatherEventHumidity.With(prometheus.Labels{"name": tc.Name(), "address": tc.AddrHex()}).Set(float64(we.Humidity))
65 |
66 | tc.latestMu.Lock()
67 | defer tc.latestMu.Unlock()
68 | tc.latestWeatherEvent = we
69 | return we, nil
70 | }
71 |
--------------------------------------------------------------------------------
/internal/gpio/reset.go:
--------------------------------------------------------------------------------
1 | // Package gpio configures and toggles GPIO pins using /sys/class/gpio.
2 | package gpio
3 |
4 | import (
5 | "fmt"
6 | "os"
7 | "syscall"
8 | "time"
9 | "unsafe"
10 |
11 | "golang.org/x/sys/unix"
12 | )
13 |
14 | const (
15 | GPIOHANDLE_REQUEST_OUTPUT = 0x2
16 | GPIO_GET_LINEHANDLE_IOCTL = 0xc16cb403
17 | GPIOHANDLE_SET_LINE_VALUES_IOCTL = 0xc040b409
18 | )
19 |
20 | type gpiohandlerequest struct {
21 | Lineoffsets [64]uint32
22 | Flags uint32
23 | DefaultValues [64]uint8
24 | ConsumerLabel [32]byte
25 | Lines uint32
26 | Fd uintptr
27 | }
28 |
29 | type gpiohandledata struct {
30 | Values [64]uint8
31 | }
32 |
33 | // ResetUARTGW resets the UARTGW whose reset pin is connected to pin
34 | // by holding the pin low for 150ms, flushing the pending UART data,
35 | // then setting the pin high again.
36 | func ResetUARTGW(uartfd uintptr) error {
37 | f, err := os.Open("/dev/gpiochip0")
38 | if err != nil {
39 | return err
40 | }
41 | defer f.Close()
42 |
43 | handlereq := gpiohandlerequest{
44 | Lineoffsets: [64]uint32{18},
45 | Flags: GPIOHANDLE_REQUEST_OUTPUT,
46 | DefaultValues: [64]uint8{1},
47 | ConsumerLabel: [32]byte{'h', 'm', 'g', 'o'},
48 | Lines: 1,
49 | }
50 | if _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(f.Fd()), GPIO_GET_LINEHANDLE_IOCTL, uintptr(unsafe.Pointer(&handlereq))); errno != 0 {
51 | return fmt.Errorf("GPIO_GET_LINEHANDLE_IOCTL: %v", errno)
52 | }
53 |
54 | // Turn off device
55 | if _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(handlereq.Fd), GPIOHANDLE_SET_LINE_VALUES_IOCTL, uintptr(unsafe.Pointer(&gpiohandledata{
56 | Values: [64]uint8{0},
57 | }))); errno != 0 {
58 | return fmt.Errorf("GPIOHANDLE_SET_LINE_VALUES_IOCTL: %v", errno)
59 | }
60 | time.Sleep(150 * time.Millisecond)
61 |
62 | // Flush all data in the input buffer
63 | if _, _, err := syscall.Syscall(syscall.SYS_IOCTL, uartfd, unix.TCFLSH, uintptr(syscall.TCIFLUSH)); err != 0 {
64 | return fmt.Errorf("TCFLSH: %v", err)
65 | }
66 |
67 | // Turn on device
68 | if _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(handlereq.Fd), GPIOHANDLE_SET_LINE_VALUES_IOCTL, uintptr(unsafe.Pointer(&gpiohandledata{
69 | Values: [64]uint8{1},
70 | }))); errno != 0 {
71 | return fmt.Errorf("GPIOHANDLE_SET_LINE_VALUES_IOCTL: %v", errno)
72 | }
73 |
74 | return nil
75 | }
76 |
--------------------------------------------------------------------------------
/internal/hm/thermal/thermal_test.go:
--------------------------------------------------------------------------------
1 | package thermal_test
2 |
3 | import (
4 | "fmt"
5 | "testing"
6 |
7 | "github.com/stapelberg/hmgo/internal/bidcos"
8 | "github.com/stapelberg/hmgo/internal/hm"
9 | "github.com/stapelberg/hmgo/internal/hm/thermal"
10 | )
11 |
12 | type testGateway struct {
13 | }
14 |
15 | func (t *testGateway) Read(p []byte) (n int, err error) {
16 | return 0, fmt.Errorf("reading not supported")
17 | }
18 |
19 | func (t *testGateway) Write(p []byte) (n int, err error) {
20 | return 0, fmt.Errorf("writing not supported")
21 | }
22 |
23 | func (t *testGateway) Confirm() error {
24 | return nil
25 | }
26 |
27 | func TestDecodeWeatherEvent(t *testing.T) {
28 | gw := testGateway{}
29 | bcs, err := bidcos.NewSender(&gw, [3]byte{0xfd, 0xee, 0xdd})
30 | if err != nil {
31 | t.Fatal(err)
32 | }
33 | tc := thermal.NewThermalControl(hm.StandardDevice{BCS: bcs, Addr: [3]byte{0xaa, 0xbb, 0xcc}})
34 | we, err := tc.DecodeWeatherEvent([]byte{0, 253, 57})
35 | if err != nil {
36 | t.Fatal(err)
37 | }
38 | if got, want := we.Temperature, 25.3; got != want {
39 | t.Fatalf("unexpected temperature: got %v, want %v", got, want)
40 | }
41 | if got, want := we.Humidity, uint64(57); got != want {
42 | t.Fatalf("unexpected humidity: got %v, want %v", got, want)
43 | }
44 | }
45 |
46 | func TestDecodeThermalControlEvent(t *testing.T) {
47 | gw := testGateway{}
48 | bcs, err := bidcos.NewSender(&gw, [3]byte{0xfd, 0xee, 0xdd})
49 | if err != nil {
50 | t.Fatal(err)
51 | }
52 | tc := thermal.NewThermalControl(hm.StandardDevice{BCS: bcs, Addr: [3]byte{0xaa, 0xbb, 0xcc}})
53 | tce, err := tc.DecodeThermalControlEvent([]byte{200, 215, 65})
54 | if err != nil {
55 | t.Fatal(err)
56 | }
57 | if got, want := tce.SetTemperature, 25.0; got != want {
58 | t.Fatalf("unexpected set temperature: got %v, want %v", got, want)
59 | }
60 | if got, want := tce.ActualTemperature, 21.5; got != want {
61 | t.Fatalf("unexpected actual temperature: got %v, want %v", got, want)
62 | }
63 | if got, want := tce.ActualHumidity, 65.0; got != want {
64 | t.Fatalf("unexpected humidity: got %v, want %v", got, want)
65 | }
66 | }
67 |
68 | func TestDecodeInfoEvent(t *testing.T) {
69 | gw := testGateway{}
70 | bcs, err := bidcos.NewSender(&gw, [3]byte{0xfd, 0xee, 0xdd})
71 | if err != nil {
72 | t.Fatal(err)
73 | }
74 | tc := thermal.NewThermalControl(hm.StandardDevice{BCS: bcs, Addr: [3]byte{0xaa, 0xbb, 0xcc}})
75 | ie, err := tc.DecodeInfoEvent([]byte{0x0b, 0xb0, 0xdf, 0x0e, 0x00})
76 | if err != nil {
77 | t.Fatal(err)
78 | }
79 | t.Logf("info event: %+v", ie)
80 | if got, want := ie.SetTemperature, 22.0; got != want {
81 | t.Fatalf("unexpected set temperature: got %v, want %v", got, want)
82 | }
83 | if got, want := ie.ActualTemperature, 22.3; got != want {
84 | t.Fatalf("unexpected actual temperature: got %v, want %v", got, want)
85 | }
86 | if got, want := ie.BatteryState, 2.9; got != want {
87 | t.Fatalf("unexpected battery state: got %v, want %v", got, want)
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/internal/hm/thermal/thermalcontrolevent.go:
--------------------------------------------------------------------------------
1 | package thermal
2 |
3 | import (
4 | "bytes"
5 | "fmt"
6 | "html/template"
7 |
8 | "github.com/prometheus/client_golang/prometheus"
9 | "github.com/stapelberg/hmgo/internal/hm"
10 | )
11 |
12 | var (
13 | thermalControlEventSetTemperature = prometheus.NewGaugeVec(
14 | prometheus.GaugeOpts{
15 | Namespace: prometheusNamespace,
16 | Name: "ThermalControlEventSetTemperature",
17 | Help: "Temperature in degC",
18 | },
19 | []string{"address", "name"})
20 |
21 | thermalControlEventActualTemperature = prometheus.NewGaugeVec(
22 | prometheus.GaugeOpts{
23 | Namespace: prometheusNamespace,
24 | Name: "ThermalControlEventActualTemperature",
25 | Help: "Temperature in degC",
26 | },
27 | []string{"address", "name"})
28 |
29 | thermalControlEventActualHumidity = prometheus.NewGaugeVec(
30 | prometheus.GaugeOpts{
31 | Namespace: prometheusNamespace,
32 | Name: "ThermalControlEventActualHumidity",
33 | Help: "Humidity in percentage points",
34 | },
35 | []string{"address", "name"})
36 | )
37 |
38 | func init() {
39 | prometheus.MustRegister(thermalControlEventSetTemperature)
40 | prometheus.MustRegister(thermalControlEventActualTemperature)
41 | prometheus.MustRegister(thermalControlEventActualHumidity)
42 | }
43 |
44 | type ThermalControlEvent struct {
45 | SetTemperature float64 // in degC
46 | ActualTemperature float64 // in degC
47 | ActualHumidity float64 // in percentage points
48 | }
49 |
50 | var tceTmpl = template.Must(template.New("thermalcontrolevent").Parse(`
51 | ThermalControl:
52 | Target temperature: {{ .SetTemperature }} ℃
53 | Current temperature: {{ .ActualTemperature }} ℃
54 | Humidity: {{ .ActualHumidity }}%
55 | `))
56 |
57 | func (tce *ThermalControlEvent) HTML() template.HTML {
58 | var buf bytes.Buffer
59 | if err := tceTmpl.Execute(&buf, tce); err != nil {
60 | return template.HTML(template.HTMLEscapeString(err.Error()))
61 | }
62 | return template.HTML(buf.String())
63 | }
64 |
65 | func (tc *ThermalControl) DecodeThermalControlEvent(payload []byte) (*ThermalControlEvent, error) {
66 | // c.f. in rftypes/tc.xml
67 | if got, want := len(payload), 3; got != want {
68 | return nil, fmt.Errorf("unexpected payload size: got %d, want %d", got, want)
69 | }
70 | tce := &ThermalControlEvent{
71 | SetTemperature: float64((uint64(payload[0])>>2)&hm.Mask6Bit) / 2,
72 | ActualTemperature: float64((int64(payload[0])&hm.Mask2Bit)|int64(payload[1])) / 10,
73 | ActualHumidity: float64(payload[2]),
74 | }
75 |
76 | thermalControlEventSetTemperature.With(prometheus.Labels{"name": tc.Name(), "address": tc.AddrHex()}).Set(tce.SetTemperature)
77 | thermalControlEventActualTemperature.With(prometheus.Labels{"name": tc.Name(), "address": tc.AddrHex()}).Set(tce.ActualTemperature)
78 | thermalControlEventActualHumidity.With(prometheus.Labels{"name": tc.Name(), "address": tc.AddrHex()}).Set(float64(tce.ActualHumidity))
79 |
80 | tc.latestMu.Lock()
81 | defer tc.latestMu.Unlock()
82 | tc.latestThermalControlEvent = tce
83 | return tce, nil
84 | }
85 |
--------------------------------------------------------------------------------
/contrib/wireshark/homematic.lua:
--------------------------------------------------------------------------------
1 | local p_bidcos = Proto("bidcos", "BidCos");
2 |
3 | local f_typ = ProtoField.uint8("bidcos.typ", "Type", base.DEC, { [5] = "APP_RECV" })
4 | local f_status = ProtoField.uint8("bidcos.status", "Status", base.DEC);
5 | local f_info = ProtoField.uint8("bidcos.info", "Info", base.DEC);
6 | local f_rssi = ProtoField.uint8("bidcos.rssi", "RSSI", base.DEC);
7 | local f_mnr = ProtoField.uint8("bidcos.mnr", "Message Counter", base.DEC);
8 | local f_flags = ProtoField.uint8("bidcos.flags", "Flags", base.DEC);
9 | local cmds = {
10 | [0x00] = "Device Info",
11 | [0x01] = "Configuration",
12 | -- TODO: 0x02 has subtypes
13 | [0x03] = "AESreply",
14 | [0x04] = "AESkey",
15 | [0x10] = "Information",
16 | [0x11] = "SET",
17 | [0x12] = "HAVE_DATA",
18 | [0x3e] = "Switch",
19 | [0x3f] = "Timestamp",
20 | [0x40] = "Remote",
21 | [0x41] = "Sensor",
22 | [0x53] = "Water sensor",
23 | [0x54] = "Gas sensor",
24 | [0x58] = "Climate event",
25 | [0x5a] = "Thermal control",
26 | [0x5e] = "Power event",
27 | [0x5f] = "Power event",
28 | [0x70] = "Weather event",
29 | [0xca] = "Firmware",
30 | [0xcb] = "RF configuration",
31 | }
32 | local f_cmd = ProtoField.uint8("bidcos.cmd", "Cmd", base.DEC, cmds);
33 | local f_src = ProtoField.uint24("bidcos.src", "Src", base.DEC);
34 | local f_dst = ProtoField.uint24("bidcos.dst", "Dest", base.DEC);
35 | local f_payload = ProtoField.new("bidcos.payload", "payload", ftypes.BYTES);
36 |
37 | p_bidcos.fields = { f_typ, f_status, f_info, f_rssi, f_mnr, f_flags, f_cmd, f_src, f_dst, f_payload }
38 |
39 | function p_bidcos.dissector(buf, pkt, tree)
40 | local subtree = tree:add(p_bidcos, buf(0))
41 | subtree:add(f_typ, buf(0,1))
42 | subtree:add(f_status, buf(1,1))
43 | subtree:add(f_info, buf(2,1))
44 | subtree:add(f_rssi, buf(3,1))
45 | subtree:add(f_mnr, buf(4,1))
46 | subtree:add(f_flags, buf(5,1))
47 | subtree:add(f_cmd, buf(6,1))
48 | subtree:add(f_src, buf(7,3))
49 | subtree:add(f_dst, buf(10,3))
50 | rest = buf:len()-13-2
51 | if rest > 0 then
52 | subtree:add(f_payload, buf(13, rest))
53 | end
54 | end
55 |
56 | local p_hm = Proto("homematic", "HM-UARTGW");
57 |
58 | local f_frame = ProtoField.uint8("homematic.frame", "Frame delimiter", base.DEC, { [253] = "valid frame" })
59 | local f_len = ProtoField.uint16("homematic.len", "Packet length", base.DEC)
60 | local f_dest = ProtoField.uint8("homematic.dest", "Destination", base.DEC, { [1] = "APP" })
61 | local f_devcnt = ProtoField.uint8("homematic.cnt", "Message Counter", base.DEC)
62 |
63 | p_hm.fields = { f_frame, f_len, f_dest, f_devcnt }
64 |
65 | function p_hm.dissector(buf, pkt, tree)
66 | local subtree = tree:add(p_hm, buf(0))
67 | subtree:add(f_frame, buf(0,1))
68 | subtree:add(f_len, buf(1,2))
69 | subtree:add(f_dest, buf(3,1))
70 | subtree:add(f_devcnt, buf(4,1))
71 |
72 | local dissector = Dissector.get("bidcos")
73 | if dissector ~= nil then
74 | dissector:call(buf(5):tvb(), pkt, tree)
75 | end
76 | end
77 |
78 | local wtap_encap_table = DissectorTable.get("wtap_encap")
79 | local udp_encap_table = DissectorTable.get("udp.port")
80 |
81 | wtap_encap_table:add(wtap.USER15, p_hm)
82 | wtap_encap_table:add(wtap.USER12, p_hm)
83 | udp_encap_table:add(6080, p_hm)
84 |
--------------------------------------------------------------------------------
/internal/hm/thermal/thermal.go:
--------------------------------------------------------------------------------
1 | package thermal
2 |
3 | import (
4 | "sync"
5 | "time"
6 |
7 | "github.com/stapelberg/hmgo/internal/hm"
8 | )
9 |
10 | const prometheusNamespace = "hmthermal"
11 |
12 | // channels
13 | const (
14 | ThermalControlTransmit = 0x02
15 | )
16 |
17 | const (
18 | WeekdayMask = 1<> 8) | ((uint16(temperature*2.0) & hm.Mask6Bit) << 1))
93 | result[(2*i)+1] = byte(((uint16(endtime/5) & 0x00FF) >> 0))
94 | }
95 |
96 | return result
97 | }
98 |
99 | // programOffsets maps weekdays to device memory location offsets
100 | var programOffsets = map[time.Weekday]int{
101 | time.Saturday: 20,
102 | time.Sunday: 46,
103 | time.Monday: 72,
104 | time.Tuesday: 98,
105 | time.Wednesday: 124,
106 | time.Thursday: 150,
107 | time.Friday: 176,
108 | }
109 |
110 | func (tc *ThermalControl) SetPrograms(mem []byte, programs []Program) {
111 | for _, day := range []time.Weekday{
112 | time.Saturday,
113 | time.Sunday,
114 | time.Monday,
115 | time.Tuesday,
116 | time.Wednesday,
117 | time.Thursday,
118 | time.Friday,
119 | } {
120 | for _, pg := range programs {
121 | if 1<Power:
84 | Booted: {{ .Boot }}
85 | EnergyCounter: {{ .EnergyCounter }} Wh
86 | Power: {{ .Power }} W
87 | Current {{ .Current }} mA
88 | Voltage: {{ .Voltage }} V
89 | Frequency: {{ .Frequency }} Hz
90 | `))
91 |
92 | func (pe *PowerEvent) HTML() template.HTML {
93 | var buf bytes.Buffer
94 | if err := peTmpl.Execute(&buf, pe); err != nil {
95 | return template.HTML(template.HTMLEscapeString(err.Error()))
96 | }
97 | return template.HTML(buf.String())
98 | }
99 |
100 | func (ps *PowerSwitch) DecodePowerEvent(payload []byte) (*PowerEvent, error) {
101 | // c.f. in rftypes/es2.xml
102 | if got, want := len(payload), 11; got != want {
103 | return nil, fmt.Errorf("unexpected payload size: got %d, want %d", got, want)
104 | }
105 | pe := &PowerEvent{
106 | Boot: ((payload[0] >> 7) & hm.Mask1Bit) == 1,
107 | EnergyCounter: float64(((uint64(payload[0])&hm.Mask7Bit)<<16)|(uint64(payload[1])<<8)|uint64(payload[2])) / 10,
108 | Power: float64((uint64(payload[3])<<16)|(uint64(payload[4])<<8)|uint64(payload[5])) / 100,
109 | Current: float64((uint64(payload[6]) << 8) | uint64(payload[7])),
110 | Voltage: float64(uint64(payload[8])<<8|uint64(payload[9])) / 10,
111 | Frequency: float64(int64(payload[10]))/100 + 50,
112 | }
113 |
114 | var boot float64
115 | if pe.Boot {
116 | boot = 1
117 | }
118 | powerEventBoot.With(prometheus.Labels{"name": ps.Name(), "address": ps.AddrHex()}).Set(boot)
119 | powerEventEnergyCounter.With(prometheus.Labels{"name": ps.Name(), "address": ps.AddrHex()}).Set(pe.EnergyCounter)
120 | powerEventPower.With(prometheus.Labels{"name": ps.Name(), "address": ps.AddrHex()}).Set(pe.Power)
121 | powerEventCurrent.With(prometheus.Labels{"name": ps.Name(), "address": ps.AddrHex()}).Set(pe.Current)
122 | powerEventVoltage.With(prometheus.Labels{"name": ps.Name(), "address": ps.AddrHex()}).Set(pe.Voltage)
123 | powerEventFrequency.With(prometheus.Labels{"name": ps.Name(), "address": ps.AddrHex()}).Set(pe.Frequency)
124 |
125 | ps.latestMu.Lock()
126 | defer ps.latestMu.Unlock()
127 | ps.latestPowerEvent = pe
128 | return pe, nil
129 | }
130 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
2 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
3 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
4 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
5 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
6 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
7 | github.com/gokrazy/gokrazy v0.0.0-20250417142646-cc835e3b7f24 h1:hYrLk2BuZ0TKFxp+TQPOHj6k3eTBm+UYcsRuOnjE7sA=
8 | github.com/gokrazy/gokrazy v0.0.0-20250417142646-cc835e3b7f24/go.mod h1:0UPC4KuPLfHR3WMaqGrhT6Y90s6QkzI2g8bzLtpCneE=
9 | github.com/gokrazy/internal v0.0.0-20240629150625-a0f1dee26ef5 h1:XDklMxV0pE5jWiNaoo5TzvWfqdoiRRScmr4ZtDzE4Uw=
10 | github.com/gokrazy/internal v0.0.0-20240629150625-a0f1dee26ef5/go.mod h1:t3ZirVhcs9bH+fPAJuGh51rzT7sVCZ9yfXvszf0ZjF0=
11 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
12 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
13 | github.com/google/renameio/v2 v2.0.0 h1:UifI23ZTGY8Tt29JbYFiuyIU3eX+RNFtUwefq9qAhxg=
14 | github.com/google/renameio/v2 v2.0.0/go.mod h1:BtmJXm5YlszgC+TD4HOEEUFgkJP3nLxehU6hfe7jRt4=
15 | github.com/kenshaw/evdev v0.1.0 h1:wmtceEOFfilChgdNT+c/djPJ2JineVsQ0N14kGzFRUo=
16 | github.com/kenshaw/evdev v0.1.0/go.mod h1:B/fErKCihUyEobz0mjn2qQbHgyJKFQAxkXSvkeeA/Wo=
17 | github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
18 | github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
19 | github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
20 | github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
21 | github.com/mdlayher/watchdog v0.0.0-20201005150459-8bdc4f41966b h1:7tUBfsEEBWfFeHOB7CUfoOamak+Gx/BlirfXyPk1WjI=
22 | github.com/mdlayher/watchdog v0.0.0-20201005150459-8bdc4f41966b/go.mod h1:bmoJUS6qOA3uKFvF3KVuhf7mU1KQirzQMeHXtPyKEqg=
23 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
24 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
25 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
26 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
27 | github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
28 | github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
29 | github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
30 | github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
31 | github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
32 | github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
33 | github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
34 | github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
35 | github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1 h1:NVK+OqnavpyFmUiKfUMHrpvbCi2VFoWTrcpI7aDaJ2I=
36 | github.com/sigurn/crc16 v0.0.0-20240131213347-83fcde1e29d1/go.mod h1:9/etS5gpQq9BJsJMWg1wpLbfuSnkm8dPF6FdW2JXVhA=
37 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
38 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
39 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
40 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
41 | github.com/vishvananda/netlink v1.1.0 h1:1iyaYNBLmP6L0220aDnYQpo1QEV4t4hJ+xEEhhJH8j0=
42 | github.com/vishvananda/netlink v1.1.0/go.mod h1:cTgwzPIzzgDAYoQrMm0EdrjRUBkTqKYppBueQtXaqoE=
43 | github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df/go.mod h1:JP3t17pCcGlemwknint6hfoeCVQrEMVwxRLRjXpq+BU=
44 | github.com/vishvananda/netns v0.0.0-20211101163701-50045581ed74 h1:gga7acRE695APm9hlsSMoOoE65U4/TcqNj90mc69Rlg=
45 | github.com/vishvananda/netns v0.0.0-20211101163701-50045581ed74/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0=
46 | golang.org/x/sys v0.0.0-20190606203320-7fc4e5ec1444/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
47 | golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
48 | golang.org/x/sys v0.0.0-20201005065044-765f4ea38db3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
49 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
50 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
51 | google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
52 | google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
53 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
54 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
55 |
--------------------------------------------------------------------------------
/internal/bidcos/bidcos.go:
--------------------------------------------------------------------------------
1 | // Package bidcos implements the HomeMatic BidCoS (bidirectional
2 | // communication standard) radio protocol.
3 | package bidcos
4 |
5 | import (
6 | "fmt"
7 | "io"
8 | "sync"
9 | )
10 |
11 | // cmd is top-level (e.g. SET), frames usually specify a subtype (e.g. MANU_MODE_SET)
12 |
13 | // BidCoS commands
14 | const (
15 | DeviceInfo byte = iota
16 | Config
17 | Ack
18 | Info = 0x10
19 | ClimateEvent = 0x58
20 | ThermalControl = 0x5a
21 | PowerEventCyclic = 0x5e
22 | PowerEvent = 0x5f
23 | WeatherEvent = 0x70
24 | Timestamp = 0x3f
25 | )
26 |
27 | // BidCoS Config subcommands
28 | const (
29 | _ byte = iota
30 | ConfigPeerAdd
31 | ConfigPeerRemove
32 | ConfigPeerListReq
33 | ConfigParamReq
34 | ConfigStart
35 | ConfigEnd
36 | ConfigWriteIndexSeq
37 | ConfigWriteIndexPairs
38 | ConfigSerialReq
39 | ConfigPairSerial
40 | _
41 | _
42 | _
43 | ConfigStatusRequest
44 | )
45 |
46 | // BidCoS Info subcommands
47 | const (
48 | InfoSerial byte = iota
49 | InfoPeerList
50 | InfoParamResponsePairs
51 | InfoParamResponseSeq
52 | InfoParamChange
53 | _
54 | InfoActuatorStatus
55 | InfoTemp = 0x0a
56 | )
57 |
58 | // Packet flags
59 | const (
60 | // Wake up the destination device from power-save mode.
61 | WakeUp byte = 1 << iota
62 | // Device is awake, send messages now.
63 | WakeMeUp
64 | // Send message to all devices.
65 | Broadcast
66 | _
67 | // Wake up the destination device from power-save mode.
68 | Burst
69 | // Bi-directional, i.e. response expected.
70 | BiDi
71 | // Packet was repeated (not seen in the wild).
72 | Repeated
73 | // Packet can be repeated (always set).
74 | RepeatEnable
75 | )
76 |
77 | const DefaultFlags = RepeatEnable | BiDi
78 |
79 | // Packet is a BidCoS packet.
80 | type Packet struct {
81 | status uint8
82 | info uint8
83 | rssi uint8
84 | Msgcnt uint8
85 | Flags uint8 // see Packet flags above
86 | Cmd uint8 // see BidCoS commands above
87 | Source [3]byte
88 | Dest [3]byte
89 | Payload []byte // at most 17 bytes
90 | }
91 |
92 | var messageCounter struct {
93 | counter byte
94 | sync.RWMutex
95 | }
96 |
97 | func (p *Packet) Encode() []byte {
98 | // c.f. https://svn.fhem.de/trac/browser/trunk/fhem/FHEM/00_HMUARTLGW.pm?rev=13367#L1464
99 | // c.f. https://github.com/Homegear/Homegear-HomeMaticBidCoS/blob/5255288954f3da42e12fa72a06963b99089d323f/src/PhysicalInterfaces/Hm-Mod-Rpi-Pcb.cpp#L858
100 | messageCounter.Lock()
101 | defer messageCounter.Unlock()
102 | cnt := p.Msgcnt
103 | if cnt == 0 {
104 | cnt = messageCounter.counter
105 | // The Homematic CCU2 increments its message counter by 9 between
106 | // each message. My guess is that the resulting pattern has better
107 | // radio characteristics.
108 | messageCounter.counter += 9
109 | }
110 | var burst byte
111 | if p.Flags&0x10 == 0x10 {
112 | burst = 0x01
113 | }
114 | res := []byte{
115 | 0x00, // status
116 | 0x00, // info
117 | burst,
118 | cnt,
119 | p.Flags,
120 | p.Cmd,
121 | }
122 | res = append(res, p.Source[:]...)
123 | res = append(res, p.Dest[:]...)
124 | res = append(res, p.Payload...)
125 | return res
126 | }
127 |
128 | func Decode(b []byte) (*Packet, error) {
129 | if got, want := len(b), 12; got < want {
130 | return nil, fmt.Errorf("too short for a bidcos packet: got %d, want >= %d", got, want)
131 | }
132 |
133 | // TODO(later): decode RSSI, see Homegear-HomeMaticBidCoS/src/BidCoSPacket.cpp
134 |
135 | return &Packet{
136 | status: b[0],
137 | info: b[1],
138 | rssi: b[2],
139 | Msgcnt: b[3], // hg: “message counter”
140 | Flags: b[4], // hg: “control byte”
141 | Cmd: b[5], // hg: “message type”
142 | Source: [3]byte{b[6], b[7], b[8]}, // hg: “senderAddress”
143 | Dest: [3]byte{b[9], b[10], b[11]}, // hg: “destinationAddress”
144 | Payload: b[12:],
145 | }, nil
146 | }
147 |
148 | type Gateway interface {
149 | io.ReadWriter
150 | Confirm() error
151 | }
152 |
153 | // Sender is a convenience wrapper around a Gateway which fills in the
154 | // BidCoS source address for outgoing packets, automatically confirms
155 | // outgoing packets and decodes incoming packets.
156 | type Sender struct {
157 | Gateway Gateway
158 | Addr [3]byte
159 | }
160 |
161 | func NewSender(gw Gateway, addr [3]byte) (*Sender, error) {
162 | if got, want := len(addr), 3; got != want {
163 | return nil, fmt.Errorf("unexpected address length: got %d, want %d", got, want)
164 | }
165 | return &Sender{
166 | Gateway: gw,
167 | Addr: addr,
168 | }, nil
169 | }
170 |
171 | func (s *Sender) ReadPacket() (*Packet, error) {
172 | // 17 byte BidCoS maximum observed payload + 12 bytes fixed BidCoS overhead
173 | var buf [17 + 12]byte
174 | n, err := s.Gateway.Read(buf[:])
175 | if err != nil {
176 | return nil, err
177 | }
178 | return Decode(buf[:n])
179 | }
180 |
181 | func (s *Sender) WritePacket(pkt *Packet) error {
182 | pkt.Source = s.Addr
183 | //log.Printf("writing bidcos packet %+v", pkt)
184 | _, err := s.Gateway.Write(pkt.Encode())
185 | if err != nil {
186 | return err
187 | }
188 | return s.Gateway.Confirm()
189 | }
190 |
--------------------------------------------------------------------------------
/internal/hm/thermal/infoevent.go:
--------------------------------------------------------------------------------
1 | package thermal
2 |
3 | import (
4 | "bytes"
5 | "fmt"
6 | "html/template"
7 |
8 | "github.com/prometheus/client_golang/prometheus"
9 | "github.com/stapelberg/hmgo/internal/hm"
10 | )
11 |
12 | var (
13 | infoEventSetTemperature = prometheus.NewGaugeVec(
14 | prometheus.GaugeOpts{
15 | Namespace: prometheusNamespace,
16 | Name: "InfoEventSetTemperature",
17 | Help: "target temperature in degC",
18 | },
19 | []string{"address", "name"})
20 |
21 | infoEventActualTemperature = prometheus.NewGaugeVec(
22 | prometheus.GaugeOpts{
23 | Namespace: prometheusNamespace,
24 | Name: "InfoEventActualTemperature",
25 | Help: "current temperature in degC",
26 | },
27 | []string{"address", "name"})
28 |
29 | infoEventLowbatReporting = prometheus.NewGaugeVec(
30 | prometheus.GaugeOpts{
31 | Namespace: prometheusNamespace,
32 | Name: "InfoEventLowbatReporting",
33 | Help: "low battery as bool",
34 | },
35 | []string{"address", "name"})
36 |
37 | infoEventCommunicationReporting = prometheus.NewGaugeVec(
38 | prometheus.GaugeOpts{
39 | Namespace: prometheusNamespace,
40 | Name: "InfoEventCommunicationReporting",
41 | Help: "communication reporting as bool",
42 | },
43 | []string{"address", "name"})
44 |
45 | infoEventWindowOpenReporting = prometheus.NewGaugeVec(
46 | prometheus.GaugeOpts{
47 | Namespace: prometheusNamespace,
48 | Name: "InfoEventWindowOpenReporting",
49 | Help: "window open reporting as bool",
50 | },
51 | []string{"address", "name"})
52 |
53 | infoEventBatteryState = prometheus.NewGaugeVec(
54 | prometheus.GaugeOpts{
55 | Namespace: prometheusNamespace,
56 | Name: "InfoEventBatteryState",
57 | Help: "battery state in V",
58 | },
59 | []string{"address", "name", "hmtype"})
60 |
61 | infoEventControl = prometheus.NewGaugeVec(
62 | prometheus.GaugeOpts{
63 | Namespace: prometheusNamespace,
64 | Name: "InfoEventControl",
65 | Help: "control mode",
66 | },
67 | []string{"address", "name"})
68 |
69 | infoEventBoostState = prometheus.NewGaugeVec(
70 | prometheus.GaugeOpts{
71 | Namespace: prometheusNamespace,
72 | Name: "InfoEventBoostState",
73 | Help: "boost state",
74 | },
75 | []string{"address", "name"})
76 | )
77 |
78 | func init() {
79 | prometheus.MustRegister(infoEventSetTemperature)
80 | prometheus.MustRegister(infoEventActualTemperature)
81 | prometheus.MustRegister(infoEventLowbatReporting)
82 | prometheus.MustRegister(infoEventCommunicationReporting)
83 | prometheus.MustRegister(infoEventWindowOpenReporting)
84 | prometheus.MustRegister(infoEventBatteryState)
85 | prometheus.MustRegister(infoEventControl)
86 | prometheus.MustRegister(infoEventBoostState)
87 | }
88 |
89 | type ControlMode uint
90 |
91 | const (
92 | AutoMode ControlMode = iota
93 | ManuMode
94 | PartyMode
95 | BoostMode
96 | )
97 |
98 | func (cm ControlMode) String() string {
99 | switch cm {
100 | case AutoMode:
101 | return "Auto"
102 | case ManuMode:
103 | return "Manu"
104 | case PartyMode:
105 | return "Party"
106 | case BoostMode:
107 | return "Boost"
108 | default:
109 | return fmt.Sprintf("unknown mode (%d dec, %x hex)", uint(cm), uint(cm))
110 | }
111 | }
112 |
113 | type InfoEvent struct {
114 | SetTemperature float64 // in degC
115 | ActualTemperature float64 // in degC
116 | LowbatReporting bool
117 | CommunicationReporting bool
118 | WindowOpenReporting bool
119 | BatteryState float64 // in V
120 | Control ControlMode
121 | BoostState uint64
122 | // Not using party mode, so ignoring the remaining fields.
123 | }
124 |
125 | var ieTmpl = template.Must(template.New("infoevent").Parse(`
126 | Info:
127 | Target temperature: {{ .SetTemperature }} ℃
128 | Current temperature: {{ .ActualTemperature }} ℃
129 | Low battery: {{ .LowbatReporting }}
130 | TODO: Communication: {{ .CommunicationReporting }}
131 | Window open: {{ .WindowOpenReporting }}
132 | Battery state: {{ .BatteryState }} V
133 | Control: {{ .Control }}
134 | Boost state: {{ .BoostState }}
135 | `))
136 |
137 | func (ie *InfoEvent) HTML() template.HTML {
138 | var buf bytes.Buffer
139 | if err := ieTmpl.Execute(&buf, ie); err != nil {
140 | return template.HTML(template.HTMLEscapeString(err.Error()))
141 | }
142 | return template.HTML(buf.String())
143 | }
144 |
145 | func (tc *ThermalControl) DecodeInfoEvent(payload []byte) (*InfoEvent, error) {
146 | // c.f. in rftypes/tc.xml
147 | if got, want := len(payload), 5; got != want {
148 | return nil, fmt.Errorf("unexpected payload size: got %d, want %d", got, want)
149 | }
150 | ie := &InfoEvent{
151 | SetTemperature: float64((uint64(payload[1])>>2)&hm.Mask6Bit) / 2,
152 | ActualTemperature: float64((int64(payload[1])&hm.Mask2Bit)|int64(payload[2])) / 10,
153 | LowbatReporting: (payload[3] >> 7) == 1,
154 | CommunicationReporting: (payload[3] >> 6) == 1,
155 | WindowOpenReporting: (payload[3] >> 5) == 1,
156 | BatteryState: float64(payload[3]&hm.Mask5Bit)/10 + 1.5,
157 | Control: ControlMode((payload[4] >> 6) & hm.Mask2Bit),
158 | BoostState: uint64(payload[4] & hm.Mask6Bit),
159 | }
160 |
161 | infoEventSetTemperature.With(prometheus.Labels{"name": tc.Name(), "address": tc.AddrHex()}).Set(ie.SetTemperature)
162 | infoEventActualTemperature.With(prometheus.Labels{"name": tc.Name(), "address": tc.AddrHex()}).Set(ie.ActualTemperature)
163 | infoEventLowbatReporting.With(prometheus.Labels{"name": tc.Name(), "address": tc.AddrHex()}).Set(boolToFloat64(ie.LowbatReporting))
164 | infoEventCommunicationReporting.With(prometheus.Labels{"name": tc.Name(), "address": tc.AddrHex()}).Set(boolToFloat64(ie.CommunicationReporting))
165 | infoEventWindowOpenReporting.With(prometheus.Labels{"name": tc.Name(), "address": tc.AddrHex()}).Set(boolToFloat64(ie.WindowOpenReporting))
166 | infoEventBatteryState.With(prometheus.Labels{"name": tc.Name(), "address": tc.AddrHex(), "hmtype": "thermal"}).Set(ie.BatteryState)
167 | infoEventControl.With(prometheus.Labels{"name": tc.Name(), "address": tc.AddrHex()}).Set(float64(ie.Control))
168 | infoEventBoostState.With(prometheus.Labels{"name": tc.Name(), "address": tc.AddrHex()}).Set(float64(ie.BoostState))
169 |
170 | tc.latestMu.Lock()
171 | defer tc.latestMu.Unlock()
172 | tc.latestInfoEvent = ie
173 | return ie, nil
174 | }
175 |
176 | func boolToFloat64(val bool) float64 {
177 | var converted float64
178 | if val {
179 | converted = 1
180 | }
181 | return converted
182 | }
183 |
--------------------------------------------------------------------------------
/internal/hm/heating/infoevent.go:
--------------------------------------------------------------------------------
1 | package heating
2 |
3 | import (
4 | "bytes"
5 | "fmt"
6 | "html/template"
7 |
8 | "github.com/prometheus/client_golang/prometheus"
9 | "github.com/stapelberg/hmgo/internal/hm"
10 | )
11 |
12 | const prometheusNamespace = "hmheating"
13 |
14 | var (
15 | infoEventSetTemperature = prometheus.NewGaugeVec(
16 | prometheus.GaugeOpts{
17 | Namespace: prometheusNamespace,
18 | Name: "InfoEventSetTemperature",
19 | Help: "target temperature in degC",
20 | },
21 | []string{"address", "name"})
22 |
23 | infoEventActualTemperature = prometheus.NewGaugeVec(
24 | prometheus.GaugeOpts{
25 | Namespace: prometheusNamespace,
26 | Name: "InfoEventActualTemperature",
27 | Help: "current temperature in degC",
28 | },
29 | []string{"address", "name"})
30 |
31 | infoEventFault = prometheus.NewGaugeVec(
32 | prometheus.GaugeOpts{
33 | Namespace: prometheusNamespace,
34 | Name: "InfoEventFault",
35 | Help: "fault as bool",
36 | },
37 | []string{"address", "name", "fault"})
38 |
39 | infoEventBatteryState = prometheus.NewGaugeVec(
40 | prometheus.GaugeOpts{
41 | Namespace: prometheusNamespace,
42 | Name: "InfoEventBatteryState",
43 | Help: "battery state in V",
44 | },
45 | []string{"address", "name", "hmtype"})
46 |
47 | infoEventValveState = prometheus.NewGaugeVec(
48 | prometheus.GaugeOpts{
49 | Namespace: prometheusNamespace,
50 | Name: "InfoEventValveState",
51 | Help: "valve state in percentage points",
52 | },
53 | []string{"address", "name"})
54 |
55 | infoEventControl = prometheus.NewGaugeVec(
56 | prometheus.GaugeOpts{
57 | Namespace: prometheusNamespace,
58 | Name: "InfoEventControl",
59 | Help: "control mode",
60 | },
61 | []string{"address", "name", "mode"})
62 |
63 | infoEventBoostState = prometheus.NewGaugeVec(
64 | prometheus.GaugeOpts{
65 | Namespace: prometheusNamespace,
66 | Name: "InfoEventBoostState",
67 | Help: "boost state in minutes",
68 | },
69 | []string{"address", "name"})
70 | )
71 |
72 | func init() {
73 | prometheus.MustRegister(infoEventSetTemperature)
74 | prometheus.MustRegister(infoEventActualTemperature)
75 | prometheus.MustRegister(infoEventFault)
76 | prometheus.MustRegister(infoEventBatteryState)
77 | prometheus.MustRegister(infoEventValveState)
78 | prometheus.MustRegister(infoEventControl)
79 | prometheus.MustRegister(infoEventBoostState)
80 | }
81 |
82 | type ControlMode uint
83 |
84 | const (
85 | AutoMode ControlMode = iota
86 | ManuMode
87 | PartyMode
88 | BoostMode
89 | )
90 |
91 | type FaultReporting uint
92 |
93 | const (
94 | NoFault FaultReporting = iota
95 | ValveTight
96 | AdjustingRangeTooLarge
97 | AdjustingRangeTooSmall
98 | CommunicationError
99 | _
100 | Lowbat
101 | ValveErrorPosition
102 | )
103 |
104 | func (fr FaultReporting) String() string {
105 | switch fr {
106 | case NoFault:
107 | return "none"
108 | case ValveTight:
109 | return "valve tight"
110 | case AdjustingRangeTooLarge:
111 | return "adjusting range too large"
112 | case AdjustingRangeTooSmall:
113 | return "adjusting range too small"
114 | case CommunicationError:
115 | return "communication error"
116 | case Lowbat:
117 | return "low battery"
118 | case ValveErrorPosition:
119 | return "valve error position"
120 | default:
121 | return fmt.Sprintf("unknown fault (%d dec, %x hex)", uint(fr), uint(fr))
122 | }
123 | }
124 |
125 | type InfoEvent struct {
126 | SetTemperature float64 // in degC
127 | ActualTemperature float64 // in degC
128 | Fault FaultReporting
129 | BatteryState float64 // in V
130 | ValveState uint64 // in percentage points
131 | Control ControlMode
132 | BoostState uint64 // in minutes
133 | // Not using party mode, so ignoring the remaining fields.
134 | }
135 |
136 | var ieTmpl = template.Must(template.New("infoevent").Parse(`
137 | Info:
138 | Target temperature: {{ .SetTemperature }} ℃
139 | Current temperature: {{ .ActualTemperature }} ℃
140 | Fault: {{ .Fault }}
141 | Battery state: {{ .BatteryState }} V
142 | Valve state: {{ .ValveState }}%
143 | Control: {{ .Control }}
144 | Boost state: {{ .BoostState }} minutes
145 | `))
146 |
147 | func (ie *InfoEvent) HTML() template.HTML {
148 | var buf bytes.Buffer
149 | if err := ieTmpl.Execute(&buf, ie); err != nil {
150 | return template.HTML(template.HTMLEscapeString(err.Error()))
151 | }
152 | return template.HTML(buf.String())
153 | }
154 |
155 | func (t *Thermostat) DecodeInfoEvent(payload []byte) (*InfoEvent, error) {
156 | // c.f. in rftypes/cc.xml
157 | if got, want := len(payload), 6; got != want {
158 | return nil, fmt.Errorf("unexpected payload size: got %d, want %d", got, want)
159 | }
160 | ie := &InfoEvent{
161 | SetTemperature: float64((uint64(payload[1])>>2)&hm.Mask6Bit) / 2,
162 | ActualTemperature: float64((int64(payload[1])&hm.Mask2Bit)|int64(payload[2])) / 10,
163 | Fault: FaultReporting((payload[3] >> 5) & hm.Mask3Bit),
164 | BatteryState: float64(payload[3]&hm.Mask5Bit)/10 + 1.5,
165 | ValveState: uint64(payload[4] & hm.Mask7Bit),
166 | Control: ControlMode((payload[5] >> 6) & hm.Mask2Bit),
167 | BoostState: uint64(payload[5] & hm.Mask6Bit),
168 | }
169 |
170 | infoEventSetTemperature.With(prometheus.Labels{"name": t.Name(), "address": t.AddrHex()}).Set(ie.SetTemperature)
171 | infoEventActualTemperature.With(prometheus.Labels{"name": t.Name(), "address": t.AddrHex()}).Set(ie.ActualTemperature)
172 |
173 | infoEventFault.With(prometheus.Labels{"name": t.Name(), "address": t.AddrHex(), "fault": "valvetight"}).Set(boolToFloat64(ie.Fault == ValveTight))
174 | infoEventFault.With(prometheus.Labels{"name": t.Name(), "address": t.AddrHex(), "fault": "adjustingrangetoosmall"}).Set(boolToFloat64(ie.Fault == AdjustingRangeTooSmall))
175 | infoEventFault.With(prometheus.Labels{"name": t.Name(), "address": t.AddrHex(), "fault": "adjustingrangetoolarge"}).Set(boolToFloat64(ie.Fault == AdjustingRangeTooLarge))
176 | infoEventFault.With(prometheus.Labels{"name": t.Name(), "address": t.AddrHex(), "fault": "communicationerror"}).Set(boolToFloat64(ie.Fault == CommunicationError))
177 | infoEventFault.With(prometheus.Labels{"name": t.Name(), "address": t.AddrHex(), "fault": "lowbat"}).Set(boolToFloat64(ie.Fault == Lowbat))
178 | infoEventFault.With(prometheus.Labels{"name": t.Name(), "address": t.AddrHex(), "fault": "valveerrorposition"}).Set(boolToFloat64(ie.Fault == ValveErrorPosition))
179 |
180 | infoEventBatteryState.With(prometheus.Labels{"name": t.Name(), "address": t.AddrHex(), "hmtype": "heating"}).Set(ie.BatteryState)
181 | infoEventValveState.With(prometheus.Labels{"name": t.Name(), "address": t.AddrHex()}).Set(float64(ie.ValveState))
182 |
183 | infoEventControl.With(prometheus.Labels{"name": t.Name(), "address": t.AddrHex(), "mode": "manu"}).Set(boolToFloat64(ie.Control == ManuMode))
184 | infoEventControl.With(prometheus.Labels{"name": t.Name(), "address": t.AddrHex(), "mode": "party"}).Set(boolToFloat64(ie.Control == PartyMode))
185 | infoEventControl.With(prometheus.Labels{"name": t.Name(), "address": t.AddrHex(), "mode": "boost"}).Set(boolToFloat64(ie.Control == BoostMode))
186 |
187 | infoEventBoostState.With(prometheus.Labels{"name": t.Name(), "address": t.AddrHex()}).Set(float64(ie.BoostState))
188 |
189 | t.latestMu.Lock()
190 | defer t.latestMu.Unlock()
191 | t.latestInfoEvent = ie
192 | return ie, nil
193 | }
194 |
195 | func boolToFloat64(val bool) float64 {
196 | var converted float64
197 | if val {
198 | converted = 1
199 | }
200 | return converted
201 | }
202 |
--------------------------------------------------------------------------------
/internal/hm/hm.go:
--------------------------------------------------------------------------------
1 | package hm
2 |
3 | import (
4 | "bytes"
5 | "fmt"
6 | "html/template"
7 | "log"
8 | "sync"
9 |
10 | "github.com/stapelberg/hmgo/internal/bidcos"
11 | )
12 |
13 | const (
14 | Mask1Bit = 0x1
15 | Mask2Bit = 0x3
16 | Mask3Bit = 0x7
17 | Mask4Bit = 0xF
18 | Mask5Bit = 0x1F
19 | Mask6Bit = 0x3F
20 | Mask7Bit = 0x7F
21 | Mask8Bit = 0xFF
22 | )
23 |
24 | type Device interface {
25 | Channels() int
26 | Pair() error
27 | MostRecentEvents() []Event
28 | AddrHex() string
29 | Name() string
30 | HomeMaticType() string
31 | }
32 |
33 | type Event interface {
34 | HTML() template.HTML
35 | }
36 |
37 | // FullyQualifiedChannel identifies a channel at a specific
38 | // BidCoS-addressed peer.
39 | type FullyQualifiedChannel struct {
40 | Peer [3]byte
41 | Channel byte
42 | }
43 |
44 | // StandardDevice encapsulates behavior shared by all HomeMatic
45 | // devices.
46 | type StandardDevice struct {
47 | BCS *bidcos.Sender
48 | Addr [3]byte
49 | addrHex string
50 | HumanName string
51 |
52 | once sync.Once
53 |
54 | // msgcnt is incremented and accessed via count()
55 | msgcnt byte
56 |
57 | NumChannels int
58 | }
59 |
60 | func (sd *StandardDevice) Name() string {
61 | return sd.HumanName
62 | }
63 |
64 | func (sd *StandardDevice) Channels() int {
65 | return sd.NumChannels
66 | }
67 |
68 | func (sd *StandardDevice) count() byte {
69 | result := sd.msgcnt
70 | sd.msgcnt += 9
71 | return result
72 | }
73 |
74 | func (sd *StandardDevice) AddrHex() string {
75 | sd.once.Do(func() {
76 | sd.addrHex = fmt.Sprintf("%x", sd.Addr)
77 | })
78 | return sd.addrHex
79 | }
80 |
81 | func (sd *StandardDevice) String() string {
82 | return fmt.Sprintf("[BidCoS:%s]", sd.AddrHex())
83 | }
84 |
85 | func (sd *StandardDevice) ConfigStart(channel, paramlist byte) error {
86 | return sd.BCS.WritePacket(&bidcos.Packet{
87 | Msgcnt: sd.count(),
88 | Flags: bidcos.DefaultFlags,
89 | Cmd: bidcos.Config,
90 | Dest: sd.Addr,
91 | Payload: []byte{
92 | channel,
93 | bidcos.ConfigStart,
94 | 0, 0, 0, // peer address
95 | 0, // peer channel
96 | paramlist,
97 | },
98 | })
99 | }
100 |
101 | func (sd *StandardDevice) ConfigWriteIndex(channel byte, kv []byte) error {
102 | return sd.BCS.WritePacket(&bidcos.Packet{
103 | Msgcnt: sd.count(),
104 | Flags: bidcos.DefaultFlags,
105 | Cmd: bidcos.Config,
106 | Dest: sd.Addr,
107 | Payload: append([]byte{
108 | channel,
109 | bidcos.ConfigWriteIndexPairs,
110 | }, kv...),
111 | })
112 | }
113 |
114 | func (sd *StandardDevice) ConfigEnd(channel byte) error {
115 | return sd.BCS.WritePacket(&bidcos.Packet{
116 | Msgcnt: sd.count(),
117 | Flags: bidcos.DefaultFlags,
118 | Cmd: bidcos.Config,
119 | Dest: sd.Addr,
120 | Payload: []byte{
121 | channel,
122 | bidcos.ConfigEnd,
123 | },
124 | })
125 | }
126 |
127 | func (sd *StandardDevice) Pair() error {
128 | if err := sd.ConfigStart(0, 0); err != nil {
129 | return err
130 | }
131 | if err := sd.ConfigWriteIndex(0, []byte{
132 | 0x02, 0x01, // internal keys not visible
133 | 0x0a, sd.BCS.Addr[0],
134 | 0x0b, sd.BCS.Addr[1],
135 | 0x0c, sd.BCS.Addr[2],
136 | }); err != nil {
137 | return err
138 | }
139 | return sd.ConfigEnd(0)
140 | }
141 |
142 | func (sd *StandardDevice) ConfigParamReq(channel, paramlist byte) error {
143 | return sd.BCS.WritePacket(&bidcos.Packet{
144 | Msgcnt: sd.count(),
145 | Flags: bidcos.DefaultFlags | bidcos.Burst,
146 | Cmd: bidcos.Config,
147 | Dest: sd.Addr,
148 | Payload: []byte{
149 | channel, // channel
150 | bidcos.ConfigParamReq,
151 | 0, 0, 0, // peer address
152 | 0, // peer channel
153 | paramlist, // param list
154 | },
155 | })
156 | }
157 |
158 | func (sd *StandardDevice) ConfigPeerListReq(channel byte) error {
159 | return sd.BCS.WritePacket(&bidcos.Packet{
160 | Msgcnt: sd.count(),
161 | Flags: bidcos.DefaultFlags | bidcos.Burst,
162 | Cmd: bidcos.Config,
163 | Dest: sd.Addr,
164 | Payload: []byte{
165 | channel, // channel
166 | bidcos.ConfigPeerListReq,
167 | },
168 | })
169 | }
170 |
171 | func (sd *StandardDevice) ConfigPeerAdd(channel byte, peerAddr [3]byte, peerChannel byte) error {
172 | return sd.BCS.WritePacket(&bidcos.Packet{
173 | Msgcnt: sd.count(),
174 | Flags: bidcos.DefaultFlags | bidcos.Burst,
175 | Cmd: bidcos.Config,
176 | Dest: sd.Addr,
177 | Payload: []byte{
178 | channel, // channel
179 | bidcos.ConfigPeerAdd,
180 | peerAddr[0], peerAddr[1], peerAddr[2],
181 | peerChannel, // peer channel a
182 | 0x00, // peer channel b
183 | },
184 | })
185 | }
186 |
187 | func (sd *StandardDevice) ConfigPeerRemove(channel byte, peerAddr [3]byte, peerChannel byte) error {
188 | return sd.BCS.WritePacket(&bidcos.Packet{
189 | Msgcnt: sd.count(),
190 | Flags: bidcos.DefaultFlags | bidcos.Burst,
191 | Cmd: bidcos.Config,
192 | Dest: sd.Addr,
193 | Payload: []byte{
194 | channel, // channel
195 | bidcos.ConfigPeerRemove,
196 | peerAddr[0], peerAddr[1], peerAddr[2],
197 | peerChannel, // peer channel a
198 | 0x00, // peer channel b
199 | },
200 | })
201 | }
202 |
203 | // LoadConfig is a convenience function to load the device parameters
204 | // in paramlist of channel into mem.
205 | func (sd *StandardDevice) LoadConfig(mem []byte, channel, paramlist byte) error {
206 | if err := sd.ConfigParamReq(channel, paramlist); err != nil {
207 | return err
208 | }
209 | ReadConfig:
210 | for {
211 | pkt, err := sd.BCS.ReadPacket()
212 | if err != nil {
213 | return err
214 | }
215 | if !bytes.Equal(pkt.Source[:], sd.Addr[:]) {
216 | log.Printf("dropping BidCoS packet from different device: %+v", pkt)
217 | continue
218 | }
219 | p := pkt.Payload // for convenience
220 | switch p[0] {
221 | case bidcos.InfoParamResponsePairs:
222 | if bytes.Equal(p[1:], []byte{0x00, 0x00}) {
223 | break ReadConfig
224 | }
225 | // idx/val byte pairs
226 | for i := 1; i < len(p); i += 2 {
227 | mem[p[i]] = p[i+1]
228 | }
229 |
230 | case bidcos.InfoParamResponseSeq:
231 | if p[1] == 0x00 {
232 | break ReadConfig
233 | }
234 | for i := 0; i < len(p)-2; i++ {
235 | mem[p[1]+byte(i)] = p[2+i]
236 | }
237 |
238 | default:
239 | return fmt.Errorf("unexpected ConfigParamReq reply: %x", p)
240 | }
241 | }
242 | return nil
243 | }
244 |
245 | // EnsureConfigured is a convenience function.
246 | func (sd *StandardDevice) EnsureConfigured(channel, paramlist byte, cb func([]byte) error) error {
247 | // config memory is indexed using a byte, i.e. capped at 256
248 | devmem := make([]byte, 256)
249 | if err := sd.LoadConfig(devmem, channel, paramlist); err != nil {
250 | return err
251 | }
252 |
253 | target := make([]byte, len(devmem))
254 | copy(target, devmem)
255 |
256 | if err := cb(target); err != nil {
257 | return err
258 | }
259 |
260 | var pairs []byte
261 | for i := 0; i < len(devmem); i++ {
262 | if devmem[i] != target[i] {
263 | pairs = append(pairs, byte(i), target[i])
264 | }
265 | }
266 |
267 | if len(pairs) == 0 {
268 | return nil
269 | }
270 |
271 | // BidCoS frames have a maximum length of 16 bytes. A
272 | // ConfigWriteIndex packet has 2 bytes overhead, so we send
273 | // key/value pairs in blocks of 14 bytes each.
274 | pkts := len(pairs) / 14
275 | if len(pairs)%14 > 0 {
276 | pkts++
277 | }
278 |
279 | log.Printf("need to update: %x", pairs)
280 |
281 | if err := sd.ConfigStart(channel, paramlist); err != nil {
282 | return err
283 | }
284 | for i := 0; i < pkts; i++ {
285 | offset := 14 * i
286 | rest := len(pairs) - offset
287 | if rest > 14 {
288 | rest = 14
289 | }
290 | log.Printf("sending packet %d: %x", i, pairs[offset:offset+rest])
291 | if err := sd.ConfigWriteIndex(0, pairs[offset:offset+rest]); err != nil {
292 | return err
293 | }
294 | }
295 | if err := sd.ConfigEnd(channel); err != nil {
296 | return err
297 | }
298 |
299 | return nil
300 | }
301 |
302 | var endOfPeerList = []byte{0x00, 0x00, 0x00, 0x00}
303 |
304 | func (sd *StandardDevice) EnsurePeeredWith(channel byte, dest FullyQualifiedChannel) error {
305 | if err := sd.ConfigPeerListReq(channel); err != nil {
306 | return err
307 | }
308 |
309 | var peers []FullyQualifiedChannel
310 |
311 | ReadPeers:
312 | for {
313 | pkt, err := sd.BCS.ReadPacket()
314 | if err != nil {
315 | return err
316 | }
317 |
318 | if !bytes.Equal(pkt.Source[:], sd.Addr[:]) {
319 | log.Printf("dropping BidCoS packet from different device: %+v", pkt)
320 | continue
321 | }
322 |
323 | if pkt.Payload[0] != 0x01 /* INFO_PEER_LIST */ {
324 | return fmt.Errorf("unexpected payload: %x", pkt.Payload[0])
325 | }
326 |
327 | list := pkt.Payload[1:]
328 | for i := 0; i < len(list)/4; i++ {
329 | off := 4 * i
330 | if bytes.Equal(list[off:off+4], endOfPeerList) {
331 | break ReadPeers
332 | }
333 | var p FullyQualifiedChannel
334 | copy(p.Peer[:], list[off:off+3])
335 | p.Channel = list[off+3]
336 | peers = append(peers, p)
337 | }
338 | }
339 |
340 | log.Printf("%v has existing peers %+v", sd, peers)
341 | if len(peers) > 1 {
342 | // TODO(later): unpeer everything
343 | return fmt.Errorf("unpeering not yet implemented")
344 | }
345 | if len(peers) == 1 {
346 | existing := peers[0]
347 | if bytes.Equal(existing.Peer[:], dest.Peer[:]) {
348 | return nil
349 | }
350 |
351 | log.Printf("removing existing peer %v", existing)
352 | if err := sd.ConfigPeerRemove(channel, existing.Peer, existing.Channel); err != nil {
353 | return err
354 | }
355 |
356 | pkt, err := sd.BCS.ReadPacket()
357 | if err != nil {
358 | return err
359 | }
360 | if got, want := pkt.Cmd, byte(0x10); got != want {
361 | return fmt.Errorf("unexpected response command: got %x, want %x", got, want)
362 | }
363 | if got, want := len(pkt.Payload), 1; got < want {
364 | return fmt.Errorf("unexpected response payload length: got %d, want >= %d", got, want)
365 | }
366 | if got, want := pkt.Payload[0], byte(0x00); got != want {
367 | return fmt.Errorf("unexpected acknowledgement status: got %x, want %x", got, want)
368 | }
369 |
370 | // fallthrough to add the peer
371 | }
372 |
373 | log.Printf("adding peer %v", dest)
374 | if err := sd.ConfigPeerAdd(channel, dest.Peer, dest.Channel); err != nil {
375 | return err
376 | }
377 |
378 | pkt, err := sd.BCS.ReadPacket()
379 | if err != nil {
380 | return err
381 | }
382 | if got, want := pkt.Cmd, byte(0x02); got != want {
383 | return fmt.Errorf("unexpected response command: got %x, want %x", got, want)
384 | }
385 | if got, want := len(pkt.Payload), 1; got < want {
386 | return fmt.Errorf("unexpected response payload length: got %d, want >= %d", got, want)
387 | }
388 | if got, want := pkt.Payload[0], byte(0x00); got != want {
389 | return fmt.Errorf("unexpected acknowledgement status: got %x, want %x", got, want)
390 | }
391 |
392 | return nil
393 | }
394 |
--------------------------------------------------------------------------------
/ccu.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "encoding/binary"
5 | "flag"
6 | "fmt"
7 | "log"
8 | "net/http"
9 | _ "net/http/pprof"
10 | "os"
11 | "sync"
12 | "syscall"
13 | "time"
14 |
15 | "golang.org/x/sys/unix"
16 |
17 | "github.com/gokrazy/gokrazy"
18 | "github.com/prometheus/client_golang/prometheus"
19 | "github.com/prometheus/client_golang/prometheus/promhttp"
20 | "github.com/stapelberg/hmgo/internal/bidcos"
21 | "github.com/stapelberg/hmgo/internal/gpio"
22 | "github.com/stapelberg/hmgo/internal/hm"
23 | "github.com/stapelberg/hmgo/internal/hm/heating"
24 | "github.com/stapelberg/hmgo/internal/hm/power"
25 | "github.com/stapelberg/hmgo/internal/hm/thermal"
26 | "github.com/stapelberg/hmgo/internal/serial"
27 | "github.com/stapelberg/hmgo/internal/uartgw"
28 | )
29 |
30 | // prometheus metrics
31 | var (
32 | lastContact = prometheus.NewGaugeVec(
33 | prometheus.GaugeOpts{
34 | Namespace: "hm",
35 | Name: "LastContact",
36 | Help: "Last device contact as UNIX timestamps, i.e. seconds since the epoch",
37 | },
38 | []string{"address", "name", "hmtype"})
39 |
40 | packetsDecoded = prometheus.NewCounterVec(
41 | prometheus.CounterOpts{
42 | Namespace: "hm",
43 | Name: "PacketsDecoded",
44 | Help: "number of BidCoS packets successfully decoded",
45 | },
46 | []string{"type"})
47 | )
48 |
49 | func init() {
50 | prometheus.MustRegister(lastContact)
51 | prometheus.MustRegister(packetsDecoded)
52 | }
53 |
54 | // flags
55 | var (
56 | serialPort = flag.String("serial_port",
57 | "/dev/serial0",
58 | "path to a serial port to communicate with the HM-MOD-RPI-PCB")
59 |
60 | listenAddress = flag.String("listen",
61 | ":8013",
62 | "host:port to listen on")
63 | )
64 |
65 | func overrideWinter(program []thermal.Program) []thermal.Program {
66 | month := time.Now().Month()
67 | if month == time.May ||
68 | month == time.June ||
69 | month == time.July ||
70 | month == time.August {
71 | return program // no change during summer
72 | }
73 | log.Printf("initial program: %+v", program)
74 | for i, prog := range program {
75 | for ii, entry := range prog.Endtimes {
76 | if entry.Endtime > uint64((6 * time.Hour).Minutes()) {
77 | prog.Endtimes[ii].Temperature = 24.0 // cannot be reached, i.e. heat permanently
78 | }
79 | }
80 | program[i] = prog
81 | }
82 | log.Printf("modified program: %+v", program)
83 | return program
84 | }
85 |
86 | func main() {
87 | flag.Parse()
88 |
89 | gokrazy.WaitForClock()
90 |
91 | // TODO(later): drop privileges (only need network + serial port)
92 |
93 | log.Printf("opening serial port %s", *serialPort)
94 |
95 | uart, err := os.OpenFile(*serialPort, os.O_EXCL|os.O_RDWR|unix.O_NOCTTY|unix.O_NONBLOCK, 0600)
96 | if err != nil {
97 | log.Fatal(err)
98 | }
99 | if err := serial.Configure(uintptr(uart.Fd())); err != nil {
100 | log.Fatal(err)
101 | }
102 |
103 | log.Printf("resetting HM-MOD-RPI-PCB via GPIO")
104 |
105 | // Reset the HM-MOD-RPI-PCB to ensure we are starting in a
106 | // known-good state.
107 | if err := gpio.ResetUARTGW(uintptr(uart.Fd())); err != nil {
108 | log.Fatal(err)
109 | }
110 |
111 | // Re-enable blocking syscalls, which are required by the Go
112 | // standard library.
113 | if err := syscall.SetNonblock(int(uart.Fd()), false); err != nil {
114 | log.Fatal(err)
115 | }
116 |
117 | hmid := [3]byte{0xfd, 0xb0, 0x2c}
118 | gw, err := uartgw.NewUARTGW(uart, hmid, time.Now())
119 | if err != nil {
120 | log.Fatal(err)
121 | }
122 |
123 | log.Printf("initialized UARTGW %s (firmware %s)", gw.SerialNumber, gw.FirmwareVersion)
124 |
125 | // TODO(later): implement support for the UARTGW acknowledging commands as “pending”.
126 |
127 | // TODO(later): add deadline for reading a frame
128 |
129 | bcs, err := bidcos.NewSender(gw, hmid)
130 | if err != nil {
131 | log.Fatal(err)
132 | }
133 |
134 | // map from src addr to device
135 | byAddr := make(map[[3]byte]hm.Device)
136 | bySerial := make(map[string]hm.Device)
137 |
138 | // for convenience
139 | device := func(addr [3]byte, name string) hm.StandardDevice {
140 | return hm.StandardDevice{
141 | BCS: bcs,
142 | Addr: addr,
143 | HumanName: name,
144 | }
145 | }
146 |
147 | avr := power.NewPowerSwitch(device([3]byte{0x40, 0xc2, 0xa8}, "avr"))
148 |
149 | thermalBad := thermal.NewThermalControl(device([3]byte{0x39, 0x0f, 0x17}, "Bad"))
150 | thermalWohnzimmer := thermal.NewThermalControl(device([3]byte{0x39, 0x06, 0xeb}, "Wohnzimmer"))
151 | thermalSchlafzimmer := thermal.NewThermalControl(device([3]byte{0x39, 0x06, 0xda}, "Schlafzimmer"))
152 | thermalLea := thermal.NewThermalControl(device([3]byte{0x39, 0x0f, 0x27}, "Lea"))
153 |
154 | thermostatBad := heating.NewThermostat(device([3]byte{0x73, 0xf7, 0xee}, "Bad"))
155 | thermostatWohnzimmer := heating.NewThermostat(device([3]byte{0x38, 0xf5, 0x9c}, "Wohnzimmer"))
156 | thermostatSchlafzimmer := heating.NewThermostat(device([3]byte{0x38, 0xe8, 0xe3}, "Schlafzimmer"))
157 | thermostatLea := heating.NewThermostat(device([3]byte{0x38, 0xe8, 0xef}, "Lea"))
158 |
159 | bySerial["MEQ0090662"] = thermalBad
160 | bySerial["MEQ0089016"] = thermalWohnzimmer
161 | bySerial["MEQ0088999"] = thermalSchlafzimmer
162 | bySerial["MEQ0090675"] = thermalLea
163 |
164 | bySerial["MEQ0059922"] = thermostatWohnzimmer
165 | bySerial["REQ1905196"] = thermostatBad
166 | bySerial["MEQ0059220"] = thermostatSchlafzimmer
167 | bySerial["MEQ0059216"] = thermostatLea
168 |
169 | bySerial["MEQ1341845"] = avr
170 |
171 | byAddr[thermalBad.Addr] = thermalBad
172 | byAddr[thermalWohnzimmer.Addr] = thermalWohnzimmer
173 | byAddr[thermalSchlafzimmer.Addr] = thermalSchlafzimmer
174 | byAddr[thermalLea.Addr] = thermalLea
175 |
176 | byAddr[thermostatWohnzimmer.Addr] = thermostatWohnzimmer
177 | byAddr[thermostatBad.Addr] = thermostatBad
178 | byAddr[thermostatSchlafzimmer.Addr] = thermostatSchlafzimmer
179 | byAddr[thermostatLea.Addr] = thermostatLea
180 |
181 | byAddr[avr.Addr] = avr
182 |
183 | // Explicitly reset the prometheus metric for last contact so that
184 | // all devices have an entry.
185 | for _, dev := range byAddr {
186 | lastContact.With(prometheus.Labels{"name": dev.Name(), "address": dev.AddrHex(), "hmtype": dev.HomeMaticType()}).Set(0)
187 | }
188 |
189 | for addr, dev := range byAddr {
190 | log.Printf("adding peer %x", addr[:])
191 | if err := gw.AddPeer(addr[:], dev.Channels()); err != nil {
192 | log.Fatal(err)
193 | }
194 | }
195 |
196 | log.Printf("reading program configuration of %v", thermalWohnzimmer)
197 | if err := thermalWohnzimmer.EnsureConfigured(0 /* channel */, 7 /* plist */, func(mem []byte) error {
198 | thermalWohnzimmer.SetPrograms(mem, overrideWinter([]thermal.Program{
199 | {
200 | DayMask: thermal.WeekdayMask,
201 | Endtimes: [13]thermal.ProgramEntry{
202 | { /* 00:00- */ uint64((6 * time.Hour).Minutes()), 17.0},
203 | { /* 06:00- */ uint64((10 * time.Hour).Minutes()), 22.0},
204 | { /* 10:00- */ uint64((17 * time.Hour).Minutes()), 17.0},
205 | { /* 17:00- */ uint64((23 * time.Hour).Minutes()), 22.0},
206 | },
207 | },
208 | {
209 | DayMask: thermal.WeekendMask,
210 | Endtimes: [13]thermal.ProgramEntry{
211 | { /* 00:00- */ uint64((6 * time.Hour).Minutes()), 17.0},
212 | { /* 06:00- */ uint64((23 * time.Hour).Minutes()), 22.0},
213 | },
214 | },
215 | }))
216 | return nil
217 | }); err != nil {
218 | log.Fatal(err)
219 | }
220 |
221 | log.Printf("reading program configuration of %v", thermalBad)
222 | if err := thermalBad.EnsureConfigured(0 /* channel */, 7 /* plist */, func(mem []byte) error {
223 |
224 | const valveOffsetOffset = 11
225 | const valveMaxOffset = 12
226 | log.Printf("valve offset: %d", mem[valveOffsetOffset])
227 | mem[valveOffsetOffset] = 100 & hm.Mask7Bit
228 | log.Printf("valve max: %d", mem[valveMaxOffset])
229 |
230 | thermalBad.SetPrograms(mem, overrideWinter([]thermal.Program{
231 | {
232 | DayMask: thermal.WeekdayMask,
233 | Endtimes: [13]thermal.ProgramEntry{
234 | { /* 00:00- */ uint64((6 * time.Hour).Minutes()), 17.0},
235 | { /* 06:00- */ uint64((10 * time.Hour).Minutes()), 22.0},
236 | { /* 10:00- */ uint64((17 * time.Hour).Minutes()), 17.0},
237 | { /* 17:00- */ uint64((23 * time.Hour).Minutes()), 22.0},
238 | },
239 | },
240 | {
241 | DayMask: thermal.WeekendMask,
242 | Endtimes: [13]thermal.ProgramEntry{
243 | { /* 00:00- */ uint64((6 * time.Hour).Minutes()), 17.0},
244 | { /* 06:00- */ uint64((23 * time.Hour).Minutes()), 22.0},
245 | },
246 | },
247 | }))
248 | return nil
249 | }); err != nil {
250 | log.Fatal(err)
251 | }
252 |
253 | log.Printf("reading program configuration of %v", thermalSchlafzimmer)
254 | if err := thermalSchlafzimmer.EnsureConfigured(0 /* channel */, 7 /* plist */, func(mem []byte) error {
255 | thermalSchlafzimmer.SetPrograms(mem, overrideWinter([]thermal.Program{
256 | {
257 | DayMask: thermal.WeekdayMask,
258 | Endtimes: [13]thermal.ProgramEntry{
259 | { /* 00:00- */ uint64((6 * time.Hour).Minutes()), 17.0},
260 | { /* 06:00- */ uint64((10 * time.Hour).Minutes()), 22.0},
261 | { /* 10:00- */ uint64((17 * time.Hour).Minutes()), 17.0},
262 | { /* 17:00- */ uint64((23 * time.Hour).Minutes()), 22.0},
263 | },
264 | },
265 | {
266 | DayMask: thermal.WeekendMask,
267 | Endtimes: [13]thermal.ProgramEntry{
268 | { /* 00:00- */ uint64((6 * time.Hour).Minutes()), 17.0},
269 | { /* 06:00- */ uint64((23 * time.Hour).Minutes()), 22.0},
270 | },
271 | },
272 | }))
273 | return nil
274 | }); err != nil {
275 | log.Fatal(err)
276 | }
277 |
278 | log.Printf("reading program configuration of %v", thermalLea)
279 | if err := thermalLea.EnsureConfigured(0 /* channel */, 7 /* plist */, func(mem []byte) error {
280 | thermalLea.SetPrograms(mem, []thermal.Program{
281 | {
282 | DayMask: thermal.WeekdayMask,
283 | Endtimes: [13]thermal.ProgramEntry{
284 | { /* 00:00- */ uint64((6 * time.Hour).Minutes()), 24.0},
285 | { /* 06:00- */ uint64((10 * time.Hour).Minutes()), 24.0},
286 | { /* 10:00- */ uint64((17 * time.Hour).Minutes()), 24.0},
287 | { /* 17:00- */ uint64((23 * time.Hour).Minutes()), 24.0},
288 | },
289 | },
290 | {
291 | DayMask: thermal.WeekendMask,
292 | Endtimes: [13]thermal.ProgramEntry{
293 | { /* 00:00- */ uint64((6 * time.Hour).Minutes()), 24.0},
294 | { /* 06:00- */ uint64((23 * time.Hour).Minutes()), 24.0},
295 | },
296 | },
297 | })
298 | return nil
299 | }); err != nil {
300 | log.Fatal(err)
301 | }
302 |
303 | log.Printf("ensuring %v is peered with %v", thermalWohnzimmer, thermostatWohnzimmer)
304 | if err := thermalWohnzimmer.EnsurePeeredWith(
305 | thermal.ThermalControlTransmit,
306 | hm.FullyQualifiedChannel{
307 | Peer: thermostatWohnzimmer.Addr,
308 | Channel: heating.ClimateControlReceiver,
309 | }); err != nil {
310 | log.Fatal(err)
311 | }
312 | log.Printf("ensuring %v is peered with %v", thermostatWohnzimmer, thermalWohnzimmer)
313 | if err := thermostatWohnzimmer.EnsurePeeredWith(
314 | heating.ClimateControlReceiver,
315 | hm.FullyQualifiedChannel{
316 | Peer: thermalWohnzimmer.Addr,
317 | Channel: thermal.ThermalControlTransmit,
318 | }); err != nil {
319 | log.Fatal(err)
320 | }
321 |
322 | log.Printf("ensuring %v is peered with %v", thermalBad, thermostatBad)
323 | if err := thermalBad.EnsurePeeredWith(
324 | thermal.ThermalControlTransmit,
325 | hm.FullyQualifiedChannel{
326 | Peer: thermostatBad.Addr,
327 | Channel: heating.ClimateControlReceiver,
328 | }); err != nil {
329 | log.Fatal(err)
330 | }
331 | log.Printf("ensuring %v is peered with %v", thermostatBad, thermalBad)
332 | if err := thermostatBad.EnsurePeeredWith(
333 | heating.ClimateControlReceiver,
334 | hm.FullyQualifiedChannel{
335 | Peer: thermalBad.Addr,
336 | Channel: thermal.ThermalControlTransmit,
337 | }); err != nil {
338 | log.Fatal(err)
339 | }
340 |
341 | log.Printf("ensuring %v is peered with %v", thermalSchlafzimmer, thermostatSchlafzimmer)
342 | if err := thermalSchlafzimmer.EnsurePeeredWith(
343 | thermal.ThermalControlTransmit,
344 | hm.FullyQualifiedChannel{
345 | Peer: thermostatSchlafzimmer.Addr,
346 | Channel: heating.ClimateControlReceiver,
347 | }); err != nil {
348 | log.Fatal(err)
349 | }
350 | log.Printf("ensuring %v is peered with %v", thermostatSchlafzimmer, thermalSchlafzimmer)
351 | if err := thermostatSchlafzimmer.EnsurePeeredWith(
352 | heating.ClimateControlReceiver,
353 | hm.FullyQualifiedChannel{
354 | Peer: thermalSchlafzimmer.Addr,
355 | Channel: thermal.ThermalControlTransmit,
356 | }); err != nil {
357 | log.Fatal(err)
358 | }
359 |
360 | log.Printf("ensuring %v is peered with %v", thermalLea, thermostatLea)
361 | if err := thermalLea.EnsurePeeredWith(
362 | thermal.ThermalControlTransmit,
363 | hm.FullyQualifiedChannel{
364 | Peer: thermostatLea.Addr,
365 | Channel: heating.ClimateControlReceiver,
366 | }); err != nil {
367 | log.Fatal(err)
368 | }
369 | log.Printf("ensuring %v is peered with %v", thermostatLea, thermalLea)
370 | if err := thermostatLea.EnsurePeeredWith(
371 | heating.ClimateControlReceiver,
372 | hm.FullyQualifiedChannel{
373 | Peer: thermalLea.Addr,
374 | Channel: thermal.ThermalControlTransmit,
375 | }); err != nil {
376 | log.Fatal(err)
377 | }
378 |
379 | var readMu sync.Mutex
380 |
381 | // Expose power on/off control on localhost
382 | localMux := http.NewServeMux()
383 | localMux.HandleFunc("/power/off", func(w http.ResponseWriter, r *http.Request) {
384 | readMu.Lock()
385 | defer readMu.Unlock()
386 | if err := avr.LevelSet(power.ChannelSwitch, power.Off, 0x00); err != nil {
387 | log.Printf("avr.LevelSet(power.Off): %v", err)
388 | http.Error(w, err.Error(), http.StatusInternalServerError)
389 | return
390 | }
391 | fmt.Fprintf(w, "OK")
392 | })
393 | localMux.HandleFunc("/power/on", func(w http.ResponseWriter, r *http.Request) {
394 | readMu.Lock()
395 | defer readMu.Unlock()
396 | if err := avr.LevelSet(power.ChannelSwitch, power.On, 0x00); err != nil {
397 | log.Printf("avr.LevelSet(power.On): %v", err)
398 | http.Error(w, err.Error(), http.StatusInternalServerError)
399 | return
400 | }
401 | fmt.Fprintf(w, "OK")
402 | })
403 | go http.ListenAndServe("localhost:8012", localMux)
404 |
405 | log.Printf("entering BidCoS packet handling main loop")
406 |
407 | http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { handleStatus(w, r, bySerial) })
408 | http.Handle("/metrics", promhttp.Handler())
409 | go http.ListenAndServe(*listenAddress, nil)
410 |
411 | t := time.Tick(1 * time.Hour)
412 | for {
413 | select {
414 | case <-t:
415 | if err := gw.SetTime(time.Now()); err != nil {
416 | log.Fatalf("setting time: %v", err)
417 | }
418 | default:
419 | }
420 |
421 | readMu.Lock()
422 | pkt, err := gw.ReadPacket()
423 | readMu.Unlock()
424 | if err != nil {
425 | log.Fatal(err)
426 | }
427 | if got, want := pkt.Cmd, uartgw.AppRecv; got != want {
428 | log.Fatalf("unexpected uartgw command in packet %+v: got %v, want %v", pkt, got, want)
429 | }
430 |
431 | bpkt, err := bidcos.Decode(pkt.Payload)
432 | if err != nil {
433 | log.Printf("skipping invalid bidcos packet: %v", err)
434 | continue
435 | }
436 |
437 | dev, ok := byAddr[bpkt.Source]
438 | if !ok {
439 | log.Printf("ignoring packet from unknown device [BidCoS:%x]", bpkt.Source)
440 | continue
441 | }
442 |
443 | lastContact.With(prometheus.Labels{"name": dev.Name(), "address": dev.AddrHex(), "hmtype": dev.HomeMaticType()}).Set(float64(time.Now().Unix()))
444 |
445 | switch bpkt.Cmd {
446 | default:
447 | log.Printf("unhandled BidCoS command from %x: %x", bpkt.Source, bpkt.Cmd)
448 |
449 | case bidcos.Timestamp:
450 | log.Printf("TODO: device %x requested timestamp", bpkt.Source)
451 |
452 | case bidcos.WeatherEvent:
453 | switch d := dev.(type) {
454 | case *thermal.ThermalControl:
455 | // Decode the event to update the prometheus metrics.
456 | if _, err := d.DecodeWeatherEvent(bpkt.Payload); err != nil {
457 | log.Printf("decoding weather event packet from %v: %v", bpkt.Source, err)
458 | continue
459 | }
460 |
461 | packetsDecoded.With(prometheus.Labels{"type": "hmthermal_WeatherEvent"}).Inc()
462 |
463 | default:
464 | log.Printf("ignoring unexpected BidCoS thermal control packet from device %x", bpkt.Source)
465 | }
466 |
467 | case bidcos.ThermalControl:
468 | switch d := dev.(type) {
469 | case *thermal.ThermalControl:
470 | // Decode the event to update the prometheus metrics.
471 | if _, err := d.DecodeThermalControlEvent(bpkt.Payload); err != nil {
472 | log.Printf("decoding thermal control event packet from %v: %v", bpkt.Source, err)
473 | continue
474 | }
475 |
476 | packetsDecoded.With(prometheus.Labels{"type": "hmthermal_ThermalControlEvent"}).Inc()
477 |
478 | default:
479 | log.Printf("ignoring unexpected BidCoS thermal control event packet from device %x", bpkt.Source)
480 | }
481 |
482 | case bidcos.PowerEventCyclic:
483 | fallthrough
484 | case bidcos.PowerEvent:
485 | switch d := dev.(type) {
486 | case *power.PowerSwitch:
487 | // Decode the event to update the prometheus metrics.
488 | if _, err := d.DecodePowerEvent(bpkt.Payload); err != nil {
489 | log.Printf("decoding power event packet from %v: %v", bpkt.Source, err)
490 | continue
491 | }
492 |
493 | packetsDecoded.With(prometheus.Labels{"type": "hmpower_PowerEvent"}).Inc()
494 |
495 | default:
496 | log.Printf("ignoring unexpected BidCoS power event packet from device %x", bpkt.Source)
497 | }
498 |
499 | case bidcos.Info:
500 | switch d := dev.(type) {
501 | case *thermal.ThermalControl:
502 | if _, err := d.DecodeInfoEvent(bpkt.Payload); err != nil {
503 | log.Printf("decoding power event packet from %v: %v", bpkt.Source, err)
504 | continue
505 | }
506 |
507 | packetsDecoded.With(prometheus.Labels{"type": "hmthermal_InfoEvent"}).Inc()
508 |
509 | case *heating.Thermostat:
510 | if _, err := d.DecodeInfoEvent(bpkt.Payload); err != nil {
511 | log.Printf("decoding power event packet from %v: %v", bpkt.Source, err)
512 | continue
513 | }
514 |
515 | packetsDecoded.With(prometheus.Labels{"type": "hmheating_InfoEvent"}).Inc()
516 |
517 | default:
518 | log.Printf("ignoring unexpected BidCoS info packet from device %x", bpkt.Source)
519 | }
520 |
521 | case bidcos.DeviceInfo:
522 | // c.f. https://github.com/Homegear/Homegear-HomeMaticBidCoS/blob/5255288954f3da42e12fa72a06963b99089d323f/src/HomeMaticCentral.cpp#L2997
523 | // TODO: add PeeringRequest type and decode method
524 | log.Printf("configuring new peer")
525 | if got, want := len(bpkt.Payload), 13; got < want {
526 | log.Printf("unexpectedly short payload: got %d, want >= %d", got, want)
527 | continue
528 | }
529 | firmware := bpkt.Payload[0]
530 | typ := binary.BigEndian.Uint16(bpkt.Payload[1 : 1+2])
531 | serial := string(bpkt.Payload[3 : 3+10])
532 | // e.g. peer request (fw 18, typ 173, serial MEQ0089016)
533 | log.Printf("peer request (fw %x, typ %d, serial %s)", firmware, typ, serial)
534 | dev, ok := bySerial[serial]
535 | if !ok {
536 | log.Printf("serial %q not configured, not replying to peering request", serial)
537 | continue
538 | }
539 |
540 | if d, ok := byAddr[bpkt.Source]; !ok || d != dev {
541 | log.Printf("device with serial %q uses unconfigured BidCoS address %x, not replying to peering request", serial, bpkt.Source)
542 | continue
543 | }
544 |
545 | if err := gw.AddPeer(bpkt.Source[:], dev.Channels()); err != nil {
546 | log.Fatal(err)
547 | }
548 |
549 | log.Printf("peer added, starting config")
550 | if err := dev.Pair(); err != nil {
551 | log.Fatal(err)
552 | }
553 | log.Printf("config end")
554 | }
555 | }
556 | }
557 |
--------------------------------------------------------------------------------
/internal/uartgw/uartgw.go:
--------------------------------------------------------------------------------
1 | // Package uartgw implements communicating with a HM-MOD-RPI-PCB
2 | // HomeMatic gateway.
3 | /*
4 |
5 | The HM-MOD-RPI-PCB uses a frame-based protocol. When reading frames,
6 | 0xfc is an escape byte and needs to be replaced:
7 |
8 | 0xfc 0x7d represents 0xfd
9 | 0xfc 0x7c represents 0xfc
10 |
11 | This technique results in 0xfd always meaning “start of a frame”,
12 | which means we can re-synchronize on 0xfd after reading invalid data.
13 |
14 | Each frame has the following format:
15 |
16 | uint8 frame delimiter (always 0xfd)
17 | uint16 length (big endian)
18 | []byte packet
19 | uint16 crc (big endian)
20 |
21 | See the bidcosTable variable for the specific CRC16 parameters.
22 |
23 | Each packet has the following format:
24 |
25 | uint8 destination (see uartdest)
26 | uint8 message counter
27 | uint8 command (see uartcmd)
28 | []byte payload
29 |
30 | Note that command values depend on the state of the UARTGW, i.e. the
31 | same value means something different in bootloader state
32 | vs. application code state.
33 |
34 | */
35 | package uartgw
36 |
37 | import (
38 | "bytes"
39 | "encoding/binary"
40 | "fmt"
41 | "io"
42 | "log"
43 | "time"
44 |
45 | "github.com/sigurn/crc16"
46 | )
47 |
48 | type deviceState uint8
49 |
50 | type UARTGW struct {
51 | FirmwareVersion string
52 | SerialNumber string
53 |
54 | // HMID is the HomeMatic ID of the UARTGW and must not be changed.
55 | HMID [3]byte
56 |
57 | uart io.ReadWriter
58 | msgcnt uint8
59 | devstate uartdest
60 | }
61 |
62 | // NewUARTGW initializes a UARTGW which is expected to have just been reset.
63 | func NewUARTGW(uart io.ReadWriter, HMID [3]byte, now time.Time) (*UARTGW, error) {
64 | gw := &UARTGW{
65 | uart: uart,
66 | HMID: HMID,
67 | }
68 | return gw, gw.init(now)
69 | }
70 |
71 | type uartdest uint8
72 |
73 | const (
74 | OS uartdest = 0
75 | App = 1
76 | Dual = 254
77 | DualErr = 255
78 | )
79 |
80 | func (u uartdest) String() string {
81 | switch u {
82 | case OS:
83 | return "OS"
84 | case App:
85 | return "App"
86 | case Dual:
87 | return "Dual"
88 | case DualErr:
89 | return "DualErr"
90 | default:
91 | return ""
92 | }
93 | }
94 |
95 | type uartcmd uint8
96 |
97 | // c.f. https://svn.fhem.de/trac/browser/trunk/fhem/FHEM/00_HMUARTLGW.pm?rev=13367#L23
98 | // NOTE(stapelberg): I’m not convinced the mental model of these
99 | // constants is entirely correct. For example, we send OSGetSerial
100 | // while the device is in the App state, which doesn’t make sense to
101 | // me.
102 | const (
103 | // While UARTGW is in state Bootloader
104 | OSGetApp uartcmd = iota
105 | OSGetFirmware
106 | OSChangeApp
107 | OSAck
108 | OSUpdateFirmware
109 | OSNormalMode
110 | OSUpdateMode
111 | OSGetCredits
112 | OSEnableCredits
113 | OSEnableCSMACA
114 | OSGetSerial
115 | OSSetTime
116 |
117 | AppSetHMID
118 | AppGetHMID
119 | AppSend
120 | AppSetCurrentKey
121 | AppAck
122 | AppRecv
123 | AppAddPeer
124 | AppRemovePeer
125 | AppGetPeers
126 | AppPeerAddAES
127 | AppPeerRemoveAES
128 | AppSetTempKey
129 | AppSetPreviousKey
130 | AppDefaultHMID
131 |
132 | DualGetApp
133 | DualChangeApp
134 | )
135 |
136 | func (u *UARTGW) Command(cmd uint8) (uartcmd, error) {
137 | switch u.devstate {
138 | case OS:
139 | switch cmd {
140 | case 0x00:
141 | return OSGetApp, nil
142 | case 0x02:
143 | return OSGetFirmware, nil
144 | case 0x03:
145 | return OSChangeApp, nil
146 | case 0x04:
147 | return OSAck, nil
148 | case 0x05:
149 | return OSUpdateFirmware, nil
150 | case 0x06:
151 | return OSNormalMode, nil
152 | case 0x07:
153 | return OSUpdateMode, nil
154 | case 0x08:
155 | return OSGetCredits, nil
156 | case 0x09:
157 | return OSEnableCredits, nil
158 | case 0x0a:
159 | return OSEnableCSMACA, nil
160 | case 0x0b:
161 | return OSGetSerial, nil
162 | case 0x0e:
163 | return OSSetTime, nil
164 |
165 | default:
166 | return OSGetApp, fmt.Errorf("unknown command: %v (state %v)", cmd, u.devstate)
167 | }
168 |
169 | case App:
170 | switch cmd {
171 | case 0x00:
172 | // XXX: not sure if AppSetHMID can ever be received from
173 | // the device, but suddenly receiving 0x00 might mean the
174 | // coprocessor entered the bootloader again.
175 | return OSGetApp, nil
176 | //return AppSetHMID
177 | case 0x01:
178 | return AppGetHMID, nil
179 | case 0x02:
180 | return AppSend, nil
181 | case 0x03:
182 | return AppSetCurrentKey, nil
183 | case 0x04:
184 | return AppAck, nil
185 | case 0x05:
186 | return AppRecv, nil
187 | case 0x06:
188 | return AppAddPeer, nil
189 | case 0x07:
190 | return AppRemovePeer, nil
191 | case 0x08:
192 | return AppGetPeers, nil
193 | case 0x09:
194 | return AppPeerAddAES, nil
195 | case 0x0a:
196 | return AppPeerRemoveAES, nil
197 | case 0x0b:
198 | return AppSetTempKey, nil
199 | case 0x0f:
200 | return AppSetPreviousKey, nil
201 | case 0x10:
202 | return AppDefaultHMID, nil
203 |
204 | default:
205 | return OSGetApp, fmt.Errorf("unknown command: %v (state %v)", cmd, u.devstate)
206 | }
207 | }
208 | return OSGetApp, fmt.Errorf("unknown device state: %v", u.devstate)
209 | }
210 |
211 | func (c uartcmd) Byte() (byte, error) {
212 | switch c {
213 | case OSGetApp:
214 | return 0x00, nil
215 | case OSGetFirmware:
216 | return 0x02, nil
217 | case OSChangeApp:
218 | return 0x03, nil
219 | case OSAck:
220 | return 0x04, nil
221 | case OSUpdateFirmware:
222 | return 0x05, nil
223 | case OSNormalMode:
224 | return 0x06, nil
225 | case OSUpdateMode:
226 | return 0x07, nil
227 | case OSGetCredits:
228 | return 0x08, nil
229 | case OSEnableCredits:
230 | return 0x09, nil
231 | case OSEnableCSMACA:
232 | return 0x0a, nil
233 | case OSGetSerial:
234 | return 0x0b, nil
235 | case OSSetTime:
236 | return 0x0e, nil
237 |
238 | case AppSetHMID:
239 | return 0x00, nil
240 | case AppSend:
241 | return 0x02, nil
242 | case AppSetCurrentKey:
243 | return 0x03, nil
244 | case AppAck:
245 | return 0x04, nil
246 | case AppRecv:
247 | return 0x05, nil
248 | case AppAddPeer:
249 | return 0x06, nil
250 | case AppGetPeers:
251 | return 0x08, nil
252 | case AppPeerRemoveAES:
253 | return 0x0a, nil
254 | }
255 | return 0x00, fmt.Errorf("unknown command: %v", c)
256 | }
257 |
258 | func (c uartcmd) String() string {
259 | switch c {
260 | case OSGetApp:
261 | return "OSGetApp"
262 | case OSGetFirmware:
263 | return "OSGetFirmware"
264 | case OSChangeApp:
265 | return "OSChangeApp"
266 | case OSAck:
267 | return "OSAck"
268 | case OSUpdateFirmware:
269 | return "OSUpdateFirmware"
270 | case OSNormalMode:
271 | return "OSNormalMode"
272 | case OSUpdateMode:
273 | return "OSUpdateMode"
274 | case OSGetCredits:
275 | return "OSGetCredits"
276 |
277 | case AppAck:
278 | return "AppAck"
279 | case AppRecv:
280 | return "AppRecv"
281 |
282 | default:
283 | return fmt.Sprintf("", uint8(c))
284 | }
285 |
286 | }
287 |
288 | // Packet is a package received from the HM-MOD-RPI-PCB serial gateway (“UARTGW”).
289 | type Packet struct {
290 | Dst uartdest
291 | msgcnt uint8
292 | Cmd uartcmd
293 | Payload []byte
294 | }
295 |
296 | func (u Packet) String() string {
297 | return fmt.Sprintf("dest: %s\nmsgcnt: %d\ncmd: %s", u.Dst, u.msgcnt, u.Cmd)
298 | }
299 |
300 | var bidcosTable = crc16.MakeTable(crc16.Params{
301 | Poly: 0x8005,
302 | Init: 0xd77f,
303 | RefIn: false,
304 | RefOut: false,
305 | XorOut: 0x0000,
306 | Check: 0x0000,
307 | Name: "BidCoS",
308 | })
309 |
310 | func (u *UARTGW) ReadPacket() (*Packet, error) {
311 | var fullpkt bytes.Buffer
312 | r := io.TeeReader(&unescapingReader{r: u.uart}, &fullpkt)
313 |
314 | for {
315 | fullpkt.Reset()
316 |
317 | b := make([]byte, 1)
318 | if _, err := r.Read(b); err != nil {
319 | return nil, err
320 | }
321 | if b[0] != 0xfd {
322 | log.Printf("skipping non-frame-delimiter byte %x", b[0])
323 | continue
324 | }
325 |
326 | // Get packet length, read payload
327 | var length uint16
328 | if err := binary.Read(r, binary.BigEndian, &length); err != nil {
329 | return nil, err
330 | }
331 | var payload bytes.Buffer
332 | if _, err := io.CopyN(&payload, r, int64(length)); err != nil {
333 | return nil, err
334 | }
335 |
336 | // Calculate and verify checksum
337 | want := crc16.Checksum(fullpkt.Bytes(), bidcosTable)
338 | var got uint16
339 | if err := binary.Read(r, binary.BigEndian, &got); err != nil {
340 | return nil, err
341 | }
342 | if got != want {
343 | return nil, fmt.Errorf("unexpected checksum: got %x, want %x", got, want)
344 | }
345 |
346 | // Parse packet
347 | frame := payload.Bytes()
348 | cmd, err := u.Command(frame[2])
349 | if err != nil {
350 | return nil, err
351 | }
352 | // log.Printf("frame with length = %d, full = %x, content = %x, string = %s", length, fullpkt.Bytes(), frame, string(frame))
353 | return &Packet{
354 | Dst: uartdest(frame[0]),
355 | msgcnt: uint8(frame[1]),
356 | Cmd: cmd,
357 | Payload: frame[3:],
358 | }, nil
359 | }
360 | }
361 |
362 | func (u *UARTGW) WritePacket(pkt *Packet) error {
363 | var fullpkt bytes.Buffer
364 | w := io.MultiWriter(u.uart, &fullpkt)
365 | if _, err := w.Write([]byte{0xfd}); err != nil {
366 | return err
367 | }
368 | // Now that the frame is introduced, start escaping
369 | esc := escapingWriter{w: u.uart}
370 | w = io.MultiWriter(&esc, &fullpkt)
371 | length := uint16(3 + len(pkt.Payload))
372 | if err := binary.Write(w, binary.BigEndian, &length); err != nil {
373 | return err
374 | }
375 | cmd, err := pkt.Cmd.Byte()
376 | if err != nil {
377 | return err
378 | }
379 | if _, err := w.Write([]byte{byte(pkt.Dst), byte(u.msgcnt), cmd}); err != nil {
380 | return err
381 | }
382 | u.msgcnt++
383 | if _, err := w.Write(pkt.Payload); err != nil {
384 | return err
385 | }
386 | // log.Printf("wrote %x", fullpkt.Bytes())
387 | return binary.Write(&esc, binary.BigEndian, crc16.Checksum(fullpkt.Bytes(), bidcosTable))
388 | }
389 |
390 | func (u *UARTGW) init(now time.Time) error {
391 | // on the wire: FD000C000000436F5F4350555F424C7251
392 | pkt, err := u.ReadPacket()
393 | if err != nil {
394 | return err
395 | }
396 | if got, want := pkt.Cmd, OSGetApp; got != want {
397 | return fmt.Errorf("unexpected UARTGW packet cmd: got %v, want %v", got, want)
398 | }
399 | if got, want := string(pkt.Payload), "Co_CPU_BL"; got != want {
400 | return fmt.Errorf("unexpected UARTGW application: got %q, want %q", got, want)
401 | }
402 |
403 | if err := u.switchToApp(); err != nil {
404 | return fmt.Errorf("switching from bootloader to application: %v", err)
405 | }
406 |
407 | if err := u.getFirmwareVersion(); err != nil {
408 | return fmt.Errorf("getting firmware version: %v", err)
409 | }
410 |
411 | if err := u.enableCSMACA(); err != nil {
412 | return fmt.Errorf("enabling CSMA/CA: %v", err)
413 | }
414 |
415 | if err := u.getSerialNumber(); err != nil {
416 | return fmt.Errorf("getting serial number: %v", err)
417 | }
418 |
419 | if err := u.SetTime(now); err != nil {
420 | return fmt.Errorf("setting time: %v", err)
421 | }
422 |
423 | if err := u.setCurrentKey(); err != nil {
424 | return fmt.Errorf("setting current key: %v", err)
425 | }
426 |
427 | if err := u.setHMID(); err != nil {
428 | return fmt.Errorf("setting HMID: %v", err)
429 | }
430 |
431 | return nil
432 | }
433 |
434 | // switchToApp switches from bootloader to application code.
435 | func (u *UARTGW) switchToApp() error {
436 | // on the wire: FD0003000003180A
437 | if err := u.WritePacket(&Packet{Cmd: OSChangeApp}); err != nil {
438 | return err
439 | }
440 |
441 | // on the wire: FD000400000401993D
442 | pkt, err := u.ReadPacket()
443 | if err != nil {
444 | return err
445 | }
446 | if got, want := pkt.Cmd, OSAck; got != want {
447 | return fmt.Errorf("unexpected UARTGW packet cmd: got %v, want %v", got, want)
448 | }
449 |
450 | // on the wire: FD000D000000436F5F4350555F417070D831
451 | pkt, err = u.ReadPacket()
452 | if err != nil {
453 | return err
454 | }
455 |
456 | if got, want := pkt.Cmd, OSGetApp; got != want {
457 | return fmt.Errorf("unexpected UARTGW packet cmd: got %v, want %v", got, want)
458 | }
459 |
460 | if got, want := string(pkt.Payload), "Co_CPU_App"; got != want {
461 | return fmt.Errorf("unexpected UARTGW application: got %q, want %q", got, want)
462 | }
463 |
464 | u.devstate = App
465 |
466 | return nil
467 | }
468 |
469 | func (u *UARTGW) getFirmwareVersion() error {
470 | // on the wire: FD00030001021E0C
471 | if err := u.WritePacket(&Packet{Cmd: OSGetFirmware}); err != nil {
472 | return err
473 | }
474 |
475 | // on the wire: FD000A00010402010003010201AA8A
476 | pkt, err := u.ReadPacket()
477 | if err != nil {
478 | return err
479 | }
480 |
481 | if got, want := pkt.Cmd, AppAck; got != want {
482 | return fmt.Errorf("unexpected UARTGW packet cmd: got %v, want %v", got, want)
483 | }
484 |
485 | version := pkt.Payload[4:]
486 | u.FirmwareVersion = fmt.Sprintf("%d.%d.%d",
487 | uint8(version[0]),
488 | uint8(version[1]),
489 | uint8(version[2]))
490 |
491 | return nil
492 | }
493 |
494 | // enableCSMACA enables Carrier sense multiple access with collision avoidance
495 | func (u *UARTGW) enableCSMACA() error {
496 | // on the wire: FD000400020A003D10
497 | if err := u.WritePacket(&Packet{
498 | Cmd: OSEnableCSMACA,
499 | Payload: []byte{0x01}}); err != nil {
500 | return err
501 | }
502 |
503 | // on the wire: FD0004000204011916
504 | pkt, err := u.ReadPacket()
505 | if err != nil {
506 | return err
507 | }
508 |
509 | if got, want := pkt.Cmd, AppAck; got != want {
510 | return fmt.Errorf("unexpected UARTGW packet cmd: got %v, want %v", got, want)
511 | }
512 |
513 | return nil
514 | }
515 |
516 | func (u *UARTGW) getSerialNumber() error {
517 | // on the wire: FD000300030B9239
518 | if err := u.WritePacket(&Packet{Cmd: OSGetSerial}); err != nil {
519 | return err
520 | }
521 |
522 | // on the wire: FD000E000304024E4551313333303938306AB9
523 | pkt, err := u.ReadPacket()
524 | if err != nil {
525 | return err
526 | }
527 |
528 | if got, want := pkt.Cmd, AppAck; got != want {
529 | return fmt.Errorf("unexpected UARTGW packet cmd: got %v, want %v", got, want)
530 | }
531 |
532 | u.SerialNumber = string(pkt.Payload[1:])
533 |
534 | return nil
535 | }
536 |
537 | func (u *UARTGW) SetTime(now time.Time) error {
538 | // on the wire: FD000800040E58A7116300548E
539 | secsSinceEpoch := uint32(now.Unix())
540 | var timePayload bytes.Buffer
541 | if err := binary.Write(&timePayload, binary.BigEndian, secsSinceEpoch); err != nil {
542 | return err
543 | }
544 | _, offset := now.Zone()
545 | if _, err := timePayload.Write([]byte{byte(offset / 1800)}); err != nil {
546 | return err
547 | }
548 | if err := u.WritePacket(&Packet{Cmd: OSSetTime, Payload: timePayload.Bytes()}); err != nil {
549 | return err
550 | }
551 |
552 | // on the wire: FD000400040401196E
553 | pkt, err := u.ReadPacket()
554 | if err != nil {
555 | return err
556 | }
557 |
558 | if got, want := pkt.Cmd, AppAck; got != want {
559 | return fmt.Errorf("unexpected UARTGW packet cmd: got %v, want %v", got, want)
560 | }
561 |
562 | return nil
563 | }
564 |
565 | func (u *UARTGW) setCurrentKey() error {
566 | // on the wire: FD001401050300112233445566778899AABBCCDDEEFF024C6D
567 | keyPayload := []byte{
568 | 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF,
569 | 02, // key index
570 | }
571 | if err := u.WritePacket(&Packet{
572 | Dst: App,
573 | Cmd: AppSetCurrentKey,
574 | Payload: keyPayload,
575 | }); err != nil {
576 | return err
577 | }
578 |
579 | // on the wire: FD0004010504010D7A
580 | pkt, err := u.ReadPacket()
581 | if err != nil {
582 | return err
583 | }
584 |
585 | if got, want := pkt.Cmd, AppAck; got != want {
586 | return fmt.Errorf("unexpected UARTGW packet cmd: got %v, want %v", got, want)
587 | }
588 |
589 | return nil
590 | }
591 |
592 | func (u *UARTGW) setHMID() error {
593 | // on the wire: FD0006010600FC7DB02CD166
594 | if err := u.WritePacket(&Packet{
595 | Dst: App,
596 | Cmd: AppSetHMID,
597 | Payload: u.HMID[:],
598 | }); err != nil {
599 | return err
600 | }
601 |
602 | // on the wire: FD0004010604010D46
603 | pkt, err := u.ReadPacket()
604 | if err != nil {
605 | return err
606 | }
607 |
608 | if got, want := pkt.Cmd, AppAck; got != want {
609 | return fmt.Errorf("unexpected UARTGW packet cmd: got %v, want %v", got, want)
610 | }
611 |
612 | return nil
613 | }
614 |
615 | func (u *UARTGW) AddPeer(addr []byte, channels int) error {
616 | // Repeat the message twice because the CCU2 does that
617 | // (cargo-culted from homegear).
618 | for i := 0; i < 2; i++ {
619 | // Add peer / get peer config
620 | // on the wire: FD000901080640C2A8000000022E
621 | addPeerPayload := [6]byte{
622 | addr[0], addr[1], addr[2],
623 | 0x00, 0x00, 0x00,
624 | }
625 | if err := u.WritePacket(&Packet{
626 | Dst: App,
627 | Cmd: AppAddPeer,
628 | Payload: addPeerPayload[:],
629 | }); err != nil {
630 | return err
631 | }
632 |
633 | // on the wire: FD00100108040701010001FFFFFFFFFFFFFFFFCAAF
634 | pkt, err := u.ReadPacket()
635 | if err != nil {
636 | return err
637 | }
638 |
639 | if got, want := pkt.Cmd, AppAck; got != want {
640 | return fmt.Errorf("unexpected UARTGW packet cmd: got %v, want %v", got, want)
641 | }
642 | }
643 |
644 | // on the wire: FD000D010A0A40C2A8000102030405068B17
645 | removeAESPayload := make([]byte, 0, 3+channels)
646 | removeAESPayload = append(removeAESPayload, addr...)
647 | for i := 0; i < channels; i++ {
648 | removeAESPayload = append(removeAESPayload, byte(i))
649 | }
650 | if err := u.WritePacket(&Packet{
651 | Dst: App,
652 | Cmd: AppPeerRemoveAES,
653 | Payload: removeAESPayload,
654 | }); err != nil {
655 | return err
656 | }
657 |
658 | // on the wire: FD0004010A04010DB6
659 | pkt, err := u.ReadPacket()
660 | if err != nil {
661 | return err
662 | }
663 |
664 | if got, want := pkt.Cmd, AppAck; got != want {
665 | return fmt.Errorf("unexpected UARTGW packet cmd: got %v, want %v", got, want)
666 | }
667 |
668 | // Add peer / get peer config
669 | // on the wire: FD000901080640C2A8000000022E
670 | addPeerPayload := [6]byte{
671 | addr[0], addr[1], addr[2],
672 | 0x00, 0x00, 0x00,
673 | }
674 | if err := u.WritePacket(&Packet{
675 | Dst: App,
676 | Cmd: AppAddPeer,
677 | Payload: addPeerPayload[:],
678 | }); err != nil {
679 | return err
680 | }
681 |
682 | // on the wire: FD0010010B040701010001FFFFFFFFFFFFFFFFC9A5
683 | pkt, err = u.ReadPacket()
684 | if err != nil {
685 | return err
686 | }
687 |
688 | if got, want := pkt.Cmd, AppAck; got != want {
689 | return fmt.Errorf("unexpected UARTGW packet cmd: got %v, want %v", got, want)
690 | }
691 |
692 | // Set key index + wakeup
693 | // on the wire: FD0009010C0640C2A80000004236
694 | addPeerPayload = [6]byte{
695 | addr[0], addr[1], addr[2],
696 | 0x00, // key index 0, i.e. no encryption
697 | 0x00, // don’t wake up
698 | 0x00,
699 | }
700 | if err := u.WritePacket(&Packet{
701 | Dst: App,
702 | Cmd: AppAddPeer,
703 | Payload: addPeerPayload[:],
704 | }); err != nil {
705 | return err
706 | }
707 |
708 | // on the wire: FD0010010C040701010001FFFFFFFFFFFFFFFFCEB7
709 | pkt, err = u.ReadPacket()
710 | if err != nil {
711 | return err
712 | }
713 |
714 | if got, want := pkt.Cmd, AppAck; got != want {
715 | return fmt.Errorf("unexpected UARTGW packet cmd: got %v, want %v", got, want)
716 | }
717 |
718 | return nil
719 | }
720 |
721 | func (u *UARTGW) Confirm() error {
722 | for {
723 | pkt, err := u.ReadPacket()
724 | if err != nil {
725 | return err
726 | }
727 |
728 | //log.Printf("pkt for confirmation: %+v", pkt)
729 |
730 | // TODO(later): verify messagecounter
731 |
732 | if pkt.Cmd == AppRecv {
733 | log.Printf("dropping pkt=%+v, looking for confirmation", pkt)
734 | continue
735 | }
736 |
737 | if got, want := pkt.Cmd, AppAck; got != want {
738 | return fmt.Errorf("unexpected UARTGW packet cmd: got %v, want %v", got, want)
739 | }
740 |
741 | break
742 | }
743 | return nil
744 | }
745 |
746 | func (u *UARTGW) AppSend(payload []byte) error {
747 | return u.WritePacket(&Packet{
748 | Dst: App,
749 | Cmd: AppSend,
750 | Payload: payload,
751 | })
752 | }
753 |
754 | // Write implements io.Writer so that a UARTGW can be used by the
755 | // bidcos package.
756 | func (u *UARTGW) Write(p []byte) (n int, err error) {
757 | return len(p), u.AppSend(p)
758 | }
759 |
760 | // Write implements io.Reader so that a UARTGW can be used by the
761 | // bidcos package.
762 | func (u *UARTGW) Read(p []byte) (n int, err error) {
763 | pkt, err := u.ReadPacket()
764 | if err != nil {
765 | return 0, err
766 | }
767 | if got, want := pkt.Cmd, AppRecv; got != want {
768 | return 0, fmt.Errorf("unexpected UARTGW packet cmd: got %v, want %v", got, want)
769 | }
770 | if got, want := len(p), len(pkt.Payload); got < want {
771 | return 0, fmt.Errorf("buffer too short for packet: got %v, want >= %v", got, want)
772 | }
773 | return copy(p, pkt.Payload), nil
774 | }
775 |
--------------------------------------------------------------------------------