package pingvin import ( "bufio" "fmt" "log" "os" "strconv" "strings" "sync" "time" "github.com/goburrow/modbus" "github.com/prometheus/client_golang/prometheus" ) // single coil data type pingvinCoil struct { Address int `json:"address"` Symbol string `json:"symbol"` Value bool `json:"value"` Description string `json:"description"` Reserved bool `json:"reserved"` PromDesc *prometheus.Desc `json:"-"` } // unit modbus data type Pingvin struct { Coils []*pingvinCoil Registers []*pingvinRegister Status *pingvinStatus buslock *sync.Mutex handler *modbus.RTUClientHandler modbusclient modbus.Client firstReadDone bool Debug PingvinLogger } // single register data type pingvinRegister struct { Address int `json:"address"` Symbol string `json:"symbol"` Value int `json:"value"` Bitfield string `json:"bitfield"` Type string `json:"type"` Description string `json:"description"` Reserved bool `json:"reserved"` Multiplier int `json:"multiplier"` PromDesc *prometheus.Desc `json:"-"` } type pingvinMeasurements struct { Roomtemp1 float32 `json:"room_temp1"` // Room temperature at panel 1 SupplyHeated float32 `json:"supply_heated"` // Temperature of supply air after heating SupplyHrc float32 `json:"supply_hrc"` // Temperature of supply air after heat recovery SupplyIntake float32 `json:"supply_intake"` // Temperature of outside air at device SupplyIntake24h float32 `json:"supply_intake_24h"` // 24h avg of outside air humidity SupplyHum float32 `json:"supply_hum"` // Supply air humidity Watertemp float32 `json:"watertemp"` // Heater element return water temperature ExtractIntake float32 `json:"extract_intake"` // Temperature of extract air ExtractHrc float32 `json:"extract_hrc"` // Temperature of extract air after heat recovery ExtractHum float32 `json:"extract_hum"` // Relative humidity of extract air ExtractHum48h float32 `json:"extract_hum_48h"` // 48h avg extract air humidity } type pingvinStatus struct { HeaterPct int `json:"heater_pct"` // After heater valve position HrcPct int `json:"hrc_pct"` // Heat recovery turn speed TempSetting float32 `json:"temp_setting"` // Requested room temperature FanPct int `json:"fan_pct"` // Circulation fan setting Measurements pingvinMeasurements `json:"measurements"` // Measurements HrcEffIn int `json:"hrc_efficiency_in"` // Calculated HRC efficiency, intake HrcEffEx int `json:"hrc_efficiency_ex"` // Calculated HRC efficiency, extract OpMode string `json:"op_mode"` // Current operating mode, text representation Uptime string `json:"uptime"` // Unit uptime SystemTime string `json:"system_time"` // Time and date in unit Coils []*pingvinCoil `json:"coils"` } type PingvinLogger struct { dbg bool } var ( // Mutually exclusive coils // Thanks to https://github.com/Jalle19/eda-modbus-bridge // 1 = Away mode // 2 = Away long mode // 3 = Overpressure // 6 = Max heating // 7 = Max cooling // 10 = Manual boost // 40 = Eco mode // Only one of these should be enabled at a time mutexcoils = []uint16{1, 2, 3, 6, 7, 10, 40} ) func (logger *PingvinLogger) Println(msg ...any) { if logger.dbg { log.Println(msg...) } } func newCoil(address string, symbol string, description string) *pingvinCoil { addr, err := strconv.Atoi(address) if err != nil { log.Fatal("newCoil: Atoi: ", err) } reserved := symbol == "-" && description == "-" if !reserved { promdesc := strings.ToLower(symbol) zpadaddr := fmt.Sprintf("%02d", addr) promdesc = strings.Replace(promdesc, "_", "_"+zpadaddr+"_", 1) return &pingvinCoil{addr, symbol, false, description, reserved, prometheus.NewDesc( prometheus.BuildFQName("", "pingvin", promdesc), description, nil, nil, ), } } return &pingvinCoil{addr, symbol, false, description, reserved, nil} } func newRegister(address, symbol, typ, multiplier, description string) *pingvinRegister { addr, err := strconv.Atoi(address) if err != nil { log.Fatal("newRegister: Atoi(address): ", err) } multipl := 1 if len(multiplier) > 0 { multipl, err = strconv.Atoi(multiplier) if err != nil { log.Fatal("newRegister: Atoi(multiplier): ", err) } } reserved := symbol == "Reserved" && description == "Reserved" if !reserved { promdesc := strings.ToLower(symbol) zpadaddr := fmt.Sprintf("%03d", addr) promdesc = strings.Replace(promdesc, "_", "_"+zpadaddr+"_", 1) return &pingvinRegister{ addr, symbol, 0, "0000000000000000", typ, description, reserved, multipl, prometheus.NewDesc( prometheus.BuildFQName("", "pingvin", promdesc), description, nil, nil, ), } } return &pingvinRegister{addr, symbol, 0, "0000000000000000", typ, description, reserved, multipl, nil} } // read a CSV file containing data for coils or registers func readCsvLines(file string) [][]string { delim := ";" data := [][]string{} csv, err := os.Open(file) if err != nil { log.Fatal(err) } defer csv.Close() scanner := bufio.NewScanner(csv) for scanner.Scan() { elements := strings.Split(scanner.Text(), delim) data = append(data, elements) } if err := scanner.Err(); err != nil { log.Fatal(err) } return data } // Create modbus.Handler, store it in p.handler, // connect the handler and create p.modbusclient (modbus.Client) func (p *Pingvin) createModbusClient(serial string) { // TODO: read configuration from file, mostly hardcoded for now log.Println("Connecting to serial console on", serial) p.handler = modbus.NewRTUClientHandler(serial) p.handler.BaudRate = 19200 p.handler.DataBits = 8 p.handler.Parity = "N" p.handler.StopBits = 1 p.handler.SlaveId = 1 p.handler.Timeout = 1500 * time.Millisecond err := p.handler.Connect() if err != nil { log.Fatal("createModbusClient: p.handler.Connect: ", err) } p.Debug.Println("Handler connected") p.modbusclient = modbus.NewClient(p.handler) } func (p *Pingvin) Quit() { err := p.handler.Close() if err != nil { log.Println("ERROR: Quit:", err) } } // Update all coil values func (p *Pingvin) updateCoils() { var results []byte var err error for retries := 1; retries <= 5; retries++ { p.Debug.Println("Reading coils, attempt", retries) p.buslock.Lock() results, err = p.modbusclient.ReadCoils(0, uint16(len(p.Coils))) p.buslock.Unlock() if len(results) > 0 { break } else if retries == 4 { log.Println("ERROR: updateCoils: client.Readcoils: ", err) return } if err != nil { log.Printf("WARNING updateCoils: client.ReadCoils attempt %d: %s\n", retries, err) } time.Sleep(100 * time.Millisecond) } // modbus.ReadCoils returns a byte array, with the first byte's bits representing coil values 0-7, // second byte coils 8-15 etc. // Within each byte, LSB represents the lowest n coil while MSB is the highest // e.g. reading the first 8 coils might return a byte array of length 1, with the following: // [4], which is 00000100, meaning all other coils are 0 except coil #2 (3rd coil) // k := 0 // pingvinCoil index for i := 0; i < len(results); i++ { // loop through the byte array for j := 0; j < 8; j++ { // Here we loop through each bit in the byte, shifting right // and checking if the LSB after the shift is 1 with a bitwise AND // A coil value of 1 means on/true/yes, so == 1 returns the bool value // for each coil p.Coils[k].Value = (results[i] >> j & 0x1) == 1 k++ } } } // Read a single holding register, stores value in p.Registers // Returns integer value of register func (p *Pingvin) ReadRegister(addr uint16) (int, error) { p.buslock.Lock() results, err := p.modbusclient.ReadHoldingRegisters(addr, 1) p.buslock.Unlock() if err != nil { //log.Println("ERROR: ReadRegister:", err) return p.Registers[addr].Value, err } if p.Registers[addr].Type == "uint16" { p.Registers[addr].Value = int(uint16(results[0]) << 8) p.Registers[addr].Value += int(uint16(results[1])) } else if p.Registers[addr].Type == "int16" { p.Registers[addr].Value = int(int16(results[0]) << 8) p.Registers[addr].Value += int(int16(results[1])) } return p.Registers[addr].Value, nil } // Update a single holding register func (p *Pingvin) WriteRegister(addr uint16, value uint16) (uint16, error) { p.buslock.Lock() _, err := p.modbusclient.WriteSingleRegister(addr, value) p.buslock.Unlock() if err != nil { log.Println("ERROR: WriteRegister:", err) return 0, err } val, err := p.ReadRegister(addr) if err != nil { log.Println("ERROR: WriteRegister:", err) return 0, err } if val == int(value) { log.Printf("Wrote register %d to value %d (%s: %s)", addr, p.Registers[addr].Value, p.Registers[addr].Symbol, p.Registers[addr].Description) return value, nil } return 0, fmt.Errorf("Failed to write register") } // Update all holding register values func (p *Pingvin) updateRegisters() { var err error regs := len(p.Registers) k := 0 // modbus.ReadHoldingRegisters can read 125 regs at a time, so first we loop // until all the values are fethed, increasing the value of k for each register // When there are less than 125 registers to go, it's the last pass for k < regs { r := 125 if regs-k < 125 { r = regs - k } var results []byte for retries := 1; retries <= 5; retries++ { p.Debug.Println("Reading registers, attempt", retries, "k:", k) p.buslock.Lock() results, err = p.modbusclient.ReadHoldingRegisters(uint16(k), uint16(r)) p.buslock.Unlock() if len(results) > 0 { break } else if retries == 4 { log.Printf("ERROR: updateRegisters: max retries reached, giving up. client.ReadHoldingRegisters: %v", err) log.Printf("ERROR: error occurred when reading registers %d - %d", k, k+r-1) if !p.firstReadDone { panic("FATAL: Error on initial read") } return } else if err != nil { log.Printf("WARNING: updateRegisters: client.ReadHoldingRegisters attempt %d: %s", retries, err) } time.Sleep(200 * time.Millisecond) } p.firstReadDone = true // The values represent 16 bit integers, but modbus works with bytes // Each even byte of the returned []byte is the 8 MSBs of a new 16-bit // value, so for each even byte in the reponse slice we bitshift the byte // left by 8, then add the odd byte as is to the shifted 16-bit value msb := true value := int16(0) uvalue := uint16(0) for i := 0; i < len(results); i++ { if msb { value = int16(results[i]) << 8 uvalue = uint16(results[i]) << 8 } else { value += int16(results[i]) uvalue += uint16(results[i]) if p.Registers[k].Type == "int16" { p.Registers[k].Value = int(value) } if p.Registers[k].Type == "uint16" || p.Registers[k].Type == "enumeration" { p.Registers[k].Value = int(uvalue) } if p.Registers[k].Type == "bitfield" { p.Registers[k].Value = int(value) // p.Registers[k].Bitfield = fmt.Sprintf("%16b", uvalue) p.Registers[k].Bitfield = "" for i := 16; i >= 0; i-- { x := 0 if p.Registers[k].Value>>i&0x1 == 1 { x = 1 } p.Registers[k].Bitfield = fmt.Sprintf("%s%s", p.Registers[k].Bitfield, strconv.Itoa(x)) } } k++ } msb = !msb } } } // Wrapper function for updating coils, registers and populating // p.Status for Home Assistant func (p *Pingvin) Update() { p.updateCoils() p.updateRegisters() p.populateStatus() } // Read single coil func (p *Pingvin) ReadCoil(n uint16) (err error) { var results []byte for retries := 1; retries <= 5; retries++ { p.buslock.Lock() results, err = p.modbusclient.ReadCoils(n, 1) p.buslock.Unlock() if len(results) > 0 && err == nil { break } else if retries == 4 { //log.Println("ERROR ReadCoil: client.ReadCoils: ", err) return } else if err != nil { log.Printf("WARNING: ReadCoil: client.ReadCoils attempt %d: %s", retries, err) } time.Sleep(100 * time.Millisecond) } p.Coils[n].Value = results[0] == 1 return } // Force a single coil func (p *Pingvin) WriteCoil(n uint16, val bool) bool { if val { _ = p.checkMutexCoils(n) //, p.handler) } var value uint16 = 0 if val { value = 0xff00 } p.buslock.Lock() results, err := p.modbusclient.WriteSingleCoil(n, value) p.buslock.Unlock() if err != nil { log.Println("ERROR: WriteCoil: ", err) } if (val && results[0] != 255) && (!val && results[0] != 0) { log.Println("ERROR: WriteCoil: failed to write coil") return false } err = p.ReadCoil(n) if err != nil { log.Printf("ERROR WriteCoil: p.ReadCoil: %s", err) } log.Printf("Wrote coil %d to value %v (%s: %s)", n, p.Coils[n].Value, p.Coils[n].Symbol, p.Coils[n].Description) return true } // Force multiple coils func (p *Pingvin) WriteCoils(startaddr uint16, quantity uint16, vals []bool) error { p.updateCoils() coilslice := p.Coils[startaddr:(startaddr + quantity)] if len(coilslice) != len(vals) { return fmt.Errorf("ERROR: WriteCoils: vals ([]bool) is not the correct length") } // Convert slice of booleans to byte slice // representing individual bits // modbus.NewClient.WriteMultipleCoils wants the individual // bits in each byte "inverted", e.g. if you want to set 16 coils // with values 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, the // byte array needs to be [0x01,0x80] or [0b00000001, 0b10000000] bits := make([]byte, (len(coilslice)+7)/8) for i, coil := range coilslice { if coil.Value || vals[i] { // i/8 integer division, returns 0 for 0-7 etc. // i%8 loops through 0-7 // If coil.Value or vals[i] is true, set i%8 + 1 least significant bit // to 1 in bits[i/8] // e.g. coil[19]: (i/8 = 2, i%8 = 3) // -> bits[2] = (bits[2] | 0b00000001 << 3) // -> bits[2] = bits[2] | 0b00001000 // -> 4th least sign. bit is set to 1 bits[i/8] |= 0x01 << uint(i%8) } if !vals[i] { // bits contains the current values. If vals[i] is false, // the bit should be set to 0 // ^(1 << 3) = ^0b00001000 = 0b11110111 // 0b10101010 &| ^(1 << 3) // 0b10101010 // AND 0b11110111 // -> 0b10100010 bits[i/8] &= ^(1 << uint(i%8)) } p.Debug.Println("index:", i/8, "value:", bits[i/8], "shift:", i%8) } p.Debug.Println(bits) p.buslock.Lock() results, err := p.modbusclient.WriteMultipleCoils(startaddr, quantity, bits) p.buslock.Unlock() if err != nil { log.Println("ERROR: WriteCoils: ", err) return err } log.Println(results) return nil } // Some of the coils are mutually exclusive, and can only be 1 one at a time. // Check if coil is one of them and force all of them to 0 if so func (p *Pingvin) checkMutexCoils(addr uint16) error { //, handler *modbus.RTUClientHandler) error { for _, mutexcoil := range mutexcoils { if mutexcoil == addr { for _, n := range mutexcoils { if p.Coils[n].Value { p.buslock.Lock() _, err := p.modbusclient.WriteSingleCoil(n, 0) p.buslock.Unlock() if err != nil { log.Println("ERROR: checkMutexCoils:", err) return err } } } return nil } } return nil } // populate p.Status struct for Home Assistant func (p *Pingvin) populateStatus() { p.Status = &pingvinStatus{} hpct := p.Registers[49].Value / p.Registers[49].Multiplier if hpct > 100 { p.Status.HeaterPct = hpct - 100 p.Status.HrcPct = 100 } else { p.Status.HeaterPct = 0 p.Status.HrcPct = hpct } p.Status.TempSetting = float32(p.Registers[135].Value) / float32(p.Registers[135].Multiplier) p.Status.FanPct = p.Registers[774].Value / p.Registers[774].Multiplier p.Status.Measurements.Roomtemp1 = float32(p.Registers[1].Value) / float32(p.Registers[1].Multiplier) p.Status.Measurements.SupplyHeated = float32(p.Registers[8].Value) / float32(p.Registers[8].Multiplier) p.Status.Measurements.SupplyHrc = float32(p.Registers[7].Value) / float32(p.Registers[7].Multiplier) p.Status.Measurements.SupplyIntake = float32(p.Registers[6].Value) / float32(p.Registers[6].Multiplier) p.Status.Measurements.SupplyIntake24h = float32(p.Registers[134].Value) / float32(p.Registers[134].Multiplier) p.Status.Measurements.SupplyHum = float32(p.Registers[36].Value) / float32(p.Registers[36].Multiplier) p.Status.Measurements.Watertemp = float32(p.Registers[12].Value) / float32(p.Registers[12].Multiplier) p.Status.Measurements.ExtractIntake = float32(p.Registers[10].Value) / float32(p.Registers[10].Multiplier) p.Status.Measurements.ExtractHrc = float32(p.Registers[9].Value) / float32(p.Registers[9].Multiplier) p.Status.Measurements.ExtractHum = float32(p.Registers[13].Value) / float32(p.Registers[13].Multiplier) p.Status.Measurements.ExtractHum48h = float32(p.Registers[35].Value) / float32(p.Registers[35].Multiplier) p.Status.HrcEffIn = p.Registers[29].Value / p.Registers[29].Multiplier p.Status.HrcEffEx = p.Registers[30].Value / p.Registers[30].Multiplier p.Status.OpMode = parseStatus(p.Registers[44].Value) // TODO: Alarms, n of alarms // TODO: Uptime & date in separate functions p.Status.Coils = p.Coils } // Parse readable status from integer (bitfield) value func parseStatus(value int) string { val := int16(value) pingvinStatuses := []string{ "Max cooling", "Max heating", "Stopped by alarm", "Stopped by user", "Away", "reserved", "Adaptive", "CO2 boost", "RH boost", "Manual boost", "Overpressure", "Cooker hood mode", "Central vac mode", "Electric heater cooloff", "Summer night cooling", "HRC defrost", } for i := 0; i < 15; i++ { if val>>i&0x1 == 1 { return pingvinStatuses[i] } } return "Normal" } // Change temperature setpoint (register 135) // action can be up, down or a value. // If value, the value can be the raw register value (200-300), // a decimal degree value (20.0 - 23.0), or full degrees (20-30) // Temperature must be between 20 and 30 deg Celsius, otherwise // returns an error func (p *Pingvin) Temperature(action string) error { temperature := 0 if action == "up" { temperature = p.Registers[135].Value + 1*p.Registers[135].Multiplier p.Debug.Println("Raising temperature to", temperature) } else if action == "down" { temperature = p.Registers[135].Value - 1*p.Registers[135].Multiplier p.Debug.Println("Lowering temperature to", temperature) } else { t, err := strconv.Atoi(action) if err != nil { p.Debug.Println(err) tfloat, err := strconv.ParseFloat(action, 32) if err != nil { p.Debug.Println(err) return err } t = int(tfloat * float64(p.Registers[135].Multiplier)) } if t <= 30 && t >= 20 { temperature = 10 * t } else { temperature = t } p.Debug.Println("Setting temperature to", temperature) } if temperature > 300 || temperature < 200 { return fmt.Errorf("Temperature setpoint must be between 200 and 300") } p.Debug.Println("Writing register 135 to", temperature) res, err := p.WriteRegister(135, uint16(temperature)) if err != nil { return err } p.Debug.Println("Temperature changed to", res) return nil } func (p *Pingvin) Monitor(interval int) { for { time.Sleep(time.Duration(interval) * time.Second) p.Debug.Println("Updating values") p.Update() } } // Implements prometheus.Describe() func (p *Pingvin) Describe(ch chan<- *prometheus.Desc) { for _, hreg := range p.Registers { if !hreg.Reserved { ch <- hreg.PromDesc } } for _, coil := range p.Coils { if !coil.Reserved { ch <- coil.PromDesc } } } // Implements prometheus.Collect() func (p *Pingvin) Collect(ch chan<- prometheus.Metric) { for _, hreg := range p.Registers { if !hreg.Reserved { ch <- prometheus.MustNewConstMetric( hreg.PromDesc, prometheus.GaugeValue, float64(hreg.Value)/float64(hreg.Multiplier), ) } } for _, coil := range p.Coils { val := 0 if coil.Value { val = 1 } if !coil.Reserved { ch <- prometheus.MustNewConstMetric( coil.PromDesc, prometheus.GaugeValue, float64(val), ) } } } // create a Pingvin struct, read coils and registers from CSVs func New(serial string, debug bool) *Pingvin { pingvin := Pingvin{} pingvin.Debug.dbg = debug pingvin.buslock = &sync.Mutex{} pingvin.createModbusClient(serial) log.Println("Parsing coil data...") coilData := readCsvLines("coils.csv") for i := 0; i < len(coilData); i++ { pingvin.Coils = append(pingvin.Coils, newCoil(coilData[i][0], coilData[i][1], coilData[i][2])) } log.Println("Parsed", len(pingvin.Coils), "coils") log.Println("Parsing register data...") registerData := readCsvLines("registers.csv") for i := 0; i < len(registerData); i++ { pingvin.Registers = append(pingvin.Registers, newRegister(registerData[i][0], registerData[i][1], registerData[i][2], registerData[i][3], registerData[i][6])) } log.Println("Parsed", len(pingvin.Registers), "registers") return &pingvin }