├── LICENSE ├── README.md ├── ccu.go ├── contrib ├── prometheus │ └── hmgo.rules └── wireshark │ └── homematic.lua ├── go.mod ├── go.sum ├── grafana_screenshot.png ├── internal ├── bidcos │ └── bidcos.go ├── gpio │ └── reset.go ├── hm │ ├── heating │ │ ├── heating.go │ │ ├── heating_test.go │ │ └── infoevent.go │ ├── hm.go │ ├── power │ │ ├── power.go │ │ ├── power_test.go │ │ └── powerevent.go │ └── thermal │ │ ├── infoevent.go │ │ ├── thermal.go │ │ ├── thermal_test.go │ │ ├── thermalcontrolevent.go │ │ └── weatherevent.go ├── serial │ └── serial.go └── uartgw │ ├── escaping.go │ └── uartgw.go └── status.go /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 | -------------------------------------------------------------------------------- /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. -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /grafana_screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stapelberg/hmgo/e3ed70e98d55335959eae2d0fc131bbfc49fc61e/grafana_screenshot.png -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/power/powerevent.go: -------------------------------------------------------------------------------- 1 | package power 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 = "hmpower" 13 | 14 | var ( 15 | powerEventBoot = prometheus.NewGaugeVec( 16 | prometheus.GaugeOpts{ 17 | Namespace: prometheusNamespace, 18 | Name: "PowerEventBoot", 19 | Help: "booted state (bool)", 20 | }, 21 | []string{"address", "name"}) 22 | 23 | powerEventEnergyCounter = prometheus.NewGaugeVec( 24 | prometheus.GaugeOpts{ 25 | Namespace: prometheusNamespace, 26 | Name: "PowerEventEnergyCounter", 27 | Help: "energy counter in Wh", 28 | }, 29 | []string{"address", "name"}) 30 | 31 | powerEventPower = prometheus.NewGaugeVec( 32 | prometheus.GaugeOpts{ 33 | Namespace: prometheusNamespace, 34 | Name: "PowerEventPower", 35 | Help: "power in W", 36 | }, 37 | []string{"address", "name"}) 38 | 39 | powerEventCurrent = prometheus.NewGaugeVec( 40 | prometheus.GaugeOpts{ 41 | Namespace: prometheusNamespace, 42 | Name: "PowerEventCurrent", 43 | Help: "current in mA", 44 | }, 45 | []string{"address", "name"}) 46 | 47 | powerEventVoltage = prometheus.NewGaugeVec( 48 | prometheus.GaugeOpts{ 49 | Namespace: prometheusNamespace, 50 | Name: "PowerEventVoltage", 51 | Help: "voltage in V", 52 | }, 53 | []string{"address", "name"}) 54 | 55 | powerEventFrequency = prometheus.NewGaugeVec( 56 | prometheus.GaugeOpts{ 57 | Namespace: prometheusNamespace, 58 | Name: "PowerEventFrequency", 59 | Help: "frequency in Hz", 60 | }, 61 | []string{"address", "name"}) 62 | ) 63 | 64 | func init() { 65 | prometheus.MustRegister(powerEventBoot) 66 | prometheus.MustRegister(powerEventEnergyCounter) 67 | prometheus.MustRegister(powerEventPower) 68 | prometheus.MustRegister(powerEventCurrent) 69 | prometheus.MustRegister(powerEventVoltage) 70 | prometheus.MustRegister(powerEventFrequency) 71 | } 72 | 73 | type PowerEvent struct { 74 | Boot bool 75 | EnergyCounter float64 // in Wh 76 | Power float64 // in W 77 | Current float64 // in mA 78 | Voltage float64 // in V 79 | Frequency float64 // in Hz 80 | } 81 | 82 | var peTmpl = template.Must(template.New("powerevent").Parse(` 83 | 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 | -------------------------------------------------------------------------------- /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/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<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 | -------------------------------------------------------------------------------- /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/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/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/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 | -------------------------------------------------------------------------------- /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 | 21 | 29 | 30 | {{ end }} 31 |
{{ $serial }} 22 | {{ $dev }}
23 | {{ range $idx, $event := $dev.MostRecentEvents }} 24 |
    25 | {{ $event.HTML }} 26 |
27 | {{ end }} 28 |
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 | --------------------------------------------------------------------------------