package kn import ( "encoding/json" "fmt" "koolnova2mqtt/watcher" "log" "strconv" ) // MqttClient defines the expected MQTT client pub sub interface type MqttClient interface { Publish(topic string, qos byte, retained bool, payload string) error Subscribe(topic string, callback func(message string)) error } // Config defines de Modbus<>MQTT bridge configuration type Config struct { ModuleName string // name of the module the modbus interface is connected to SlaveID byte // SlaveID of the module in the bus TopicPrefix string // MQTT topic prefix to publish information HassPrefix string // Home Assistant sensor discovery prefix Mqtt MqttClient // MQTT client Modbus watcher.Modbus // Modbus client } // Bridge bridges Modbus and MQTT protocols type Bridge struct { Config // embedded configuration zw *watcher.Watcher // watcher to detect register changes in zones sysw *watcher.Watcher // watcher to detect register changes in system registers zones []*Zone // List of present zones in this module sys *SysDriver } // getActiveZones returns the list of active zones in this module func getActiveZones(w Watcher) ([]*Zone, error) { var zones []*Zone for n := 0; n < NUM_ZONES; n++ { zone := newZone(&ZoneConfig{ ZoneNumber: n + 1, Watcher: w, }) isPresent := zone.isPresent() if isPresent { zones = append(zones, zone) } } return zones, nil } // NewBridge returns a new Modbus<>MQTT bridge func NewBridge(config *Config) *Bridge { b := &Bridge{ Config: *config, } return b } // Start starts the bridge, publishes the configuration to Home Assistant topics and publishes // the current state. Call Start() every time MQTT gets disconnected. func (b *Bridge) Start() error { // Define a watcher to watch the zone registers zw := watcher.New(&watcher.Config{ Address: FIRST_ZONE_REGISTER, Quantity: TOTAL_ZONE_REGISTERS, SlaveID: b.SlaveID, Modbus: b.Modbus, }) // Define a watcher to watch the system registers sysw := watcher.New(&watcher.Config{ Address: FIRST_SYS_REGISTER, Quantity: TOTAL_SYS_REGISTERS, SlaveID: b.SlaveID, Modbus: b.Modbus, }) b.zw = zw b.sysw = sysw sys := NewSys(&SysConfig{ Watcher: b.sysw, }) b.sys = sys log.Printf("Starting bridge for %s\n", b.ModuleName) err := b.poll() if err != nil { return err } // Get Active zones zones, err := getActiveZones(b.zw) b.zones = zones log.Printf("%d zones are present in %s\n", len(zones), b.ModuleName) // Downsize watched range to the required number of registers b.zw.Resize(len(zones) * REG_PER_ZONE) holdModeTopic := b.getSysTopic("holdMode") holdModeSetTopic := holdModeTopic + "/set" // configure publishing when modbus registers change for _, zone := range zones { zone := zone currentTempTopic := b.getZoneTopic(zone.ZoneNumber, "currentTemp") targetTempTopic := b.getZoneTopic(zone.ZoneNumber, "targetTemp") targetTempSetTopic := targetTempTopic + "/set" fanModeTopic := b.getZoneTopic(zone.ZoneNumber, "fanMode") fanModeSetTopic := fanModeTopic + "/set" hvacModeTopic := b.getZoneTopic(zone.ZoneNumber, "hvacMode") hvacModeSetTopic := hvacModeTopic + "/set" // In HA there is three HVAC modes: "cool", "heat" and "off". Therefore, // publish "OFF" if we detect the REG_ENABLED change is off // Otherwise, publish "heat" or "cool" depending on REG_MODE zone.OnEnabledChange = func() { hvacModeTopic := b.getZoneTopic(zone.ZoneNumber, "hvacMode") var mode string if zone.isOn() { mode = sys.HVACMode() } else { mode = HVAC_MODE_OFF } b.Mqtt.Publish(hvacModeTopic, 0, true, mode) } // if the current temperature changes, forward value to the // correspondig MQTT topic zone.OnCurrentTempChange = func(currentTemp float32) { b.Mqtt.Publish(currentTempTopic, 0, true, fmt.Sprintf("%g", currentTemp)) } // if the target temperature changes, publish it to MQTT // this is fired when the target is set over MQTT or via a thermostat zone.OnTargetTempChange = func(targetTemp float32) { b.Mqtt.Publish(targetTempTopic, 0, true, fmt.Sprintf("%g", targetTemp)) } // Publish changes to the fan mode zone.OnFanModeChange = func(fanMode FanMode) { b.Mqtt.Publish(fanModeTopic, 0, true, FanMode2Str(fanMode)) } // Subscribe to target temperature set topic in MQTT err = b.Mqtt.Subscribe(targetTempSetTopic, func(message string) { targetTemp, err := strconv.ParseFloat(message, 32) if err != nil { log.Printf("Error parsing targetTemperature in topic %s: %s", targetTempSetTopic, err) return } err = zone.setTargetTemperature(float32(targetTemp)) if err != nil { log.Printf("Cannot set target temperature to %g in zone %d", targetTemp, zone.ZoneNumber) } }) if err != nil { return err } // Subscribe to fan mode set topic in MQTT err = b.Mqtt.Subscribe(fanModeSetTopic, func(message string) { fm, err := Str2FanMode(message) if err != nil { log.Printf("Unknown fan mode %q in message to zone %d", message, zone.ZoneNumber) } err = zone.setFanMode(fm) if err != nil { log.Printf("Cannot set fan mode to %s in zone %d", message, zone.ZoneNumber) } }) if err != nil { return err } // Subscribe to HVAC Mode set topic in MQTT err = b.Mqtt.Subscribe(hvacModeSetTopic, func(message string) { // If user sets the Home Assistant HVAC mode to off, turn off this zone if message == HVAC_MODE_OFF { err := zone.setOn(false) // turn zone off (REG_ENABLED) if err != nil { log.Printf("Cannot set zone %d to off", zone.ZoneNumber) } return } // Translate HA HVAC mode to Koolnova's knMode := sys.GetSystemKNMode() knMode = ApplyHvacMode(knMode, message) err = sys.SetSystemKNMode(knMode) if err != nil { log.Printf("Cannot set knmode mode to %x in zone %d", knMode, zone.ZoneNumber) } err := zone.setOn(true) if err != nil { log.Printf("Cannot set zone %d to on", zone.ZoneNumber) return } }) if err != nil { return err } // Subscribe to changes in hold mode: err = b.Mqtt.Subscribe(holdModeSetTopic, func(message string) { // Translate HA's hold mode to Koolnova's knMode := sys.GetSystemKNMode() knMode = ApplyHoldMode(knMode, message) err := sys.SetSystemKNMode(knMode) if err != nil { log.Printf("Cannot set knmode mode to %x in zone %d", knMode, zone.ZoneNumber) } }) if err != nil { return err } // Define a Home Assistant thermostat name := fmt.Sprintf("%s_zone%d", b.ModuleName, zone.ZoneNumber) b.publishComponent(HA_COMPONENT_CLIMATE, fmt.Sprintf("zone%d", zone.ZoneNumber), map[string]interface{}{ "name": name, "current_temperature_topic": currentTempTopic, "precision": 0.1, "temperature_state_topic": targetTempTopic, "temperature_command_topic": targetTempSetTopic, "temperature_unit": "C", "temp_step": 0.5, "unique_id": name, "min_temp": 15, "max_temp": 35, "modes": []string{HVAC_MODE_COOL, HVAC_MODE_HEAT, HVAC_MODE_OFF}, "mode_state_topic": hvacModeTopic, "mode_command_topic": hvacModeSetTopic, "fan_modes": []string{"auto", "low", "medium", "high"}, "fan_mode_state_topic": fanModeTopic, "fan_mode_command_topic": fanModeSetTopic, "hold_modes": []string{HOLD_MODE_UNDERFLOOR_ONLY, HOLD_MODE_FAN_ONLY, HOLD_MODE_UNDERFLOOR_AND_FAN}, "hold_state_topic": holdModeTopic, "hold_command_topic": holdModeSetTopic, }) // Define a current temperature sensor: name = fmt.Sprintf("%s_zone%d_temp", b.ModuleName, zone.ZoneNumber) b.publishComponent(HA_COMPONENT_SENSOR, fmt.Sprintf("zone%d_temp", zone.ZoneNumber), map[string]interface{}{ "name": name, "device_class": "temperature", "state_topic": currentTempTopic, "unit_of_measurement": "ºC", "unique_id": name, }) // Define a target temperature sensor: name = fmt.Sprintf("%s_zone%d_target_temp", b.ModuleName, zone.ZoneNumber) b.publishComponent(HA_COMPONENT_SENSOR, fmt.Sprintf("zone%d_target_temp", zone.ZoneNumber), map[string]interface{}{ "name": name, "device_class": "temperature", "state_topic": targetTempTopic, "unit_of_measurement": "ºC", "unique_id": name, }) } // Publish changes to system registers: sys.OnACAirflowChange = func(ac ACMachine) { airflow := sys.GetAirflow(ac) b.Mqtt.Publish(b.getACTopic(ac, "airflow"), 0, true, strconv.Itoa(airflow)) } sys.OnACTargetTempChange = func(ac ACMachine) { targetTemp := sys.GetMachineTargetTemp(ac) b.Mqtt.Publish(b.getACTopic(ac, "targetTemp"), 0, true, fmt.Sprintf("%g", targetTemp)) } sys.OnACTargetFanModeChange = func(ac ACMachine) { targetAirflow := sys.GetTargetFanMode(ac) b.Mqtt.Publish(b.getACTopic(ac, "fanMode"), 0, true, FanMode2Str(targetAirflow)) } sys.OnEfficiencyChange = func() { efficiency := sys.GetEfficiency() b.Mqtt.Publish(b.getSysTopic("efficiency"), 0, true, strconv.Itoa(efficiency)) } sys.OnSystemEnabledChange = func() { enabled := sys.GetSystemEnabled() b.Mqtt.Publish(b.getSysTopic("enabled"), 0, true, fmt.Sprintf("%t", enabled)) b.publishHvacMode() } sys.OnKnModeChange = func() { b.publishHvacMode() b.Mqtt.Publish(holdModeTopic, 0, true, sys.HoldMode()) } // Trigger a callback on all registers so the MQTT broker is updated on connect: b.zw.TriggerCallbacks() b.sysw.TriggerCallbacks() // Publish one-off static information b.Mqtt.Publish(b.getSysTopic("serialBaud"), 0, true, strconv.Itoa(sys.GetBaudRate())) b.Mqtt.Publish(b.getSysTopic("serialParity"), 0, true, sys.GetParity()) b.Mqtt.Publish(b.getSysTopic("slaveId"), 0, true, strconv.Itoa(sys.GetSlaveID())) return nil } // poll polls modbus for changes func (b *Bridge) poll() error { err := b.zw.Poll() if err != nil { log.Printf("Timeout polling %s zone registers: %s\n", b.ModuleName, err) return err } err = b.sysw.Poll() if err != nil { log.Printf("Timeout polling %s system registers: %s\n", b.ModuleName, err) return err } return nil } // Tick must be invoked periodically to refesh registers from modbus // it also samples temperature and calculates a moving average of read temperatures func (b *Bridge) Tick() error { err := b.poll() if err != nil { return err } for _, z := range b.zones { z.sampleTemperature() } return nil } func (b *Bridge) getZoneTopic(zoneNum int, subtopic string) string { return fmt.Sprintf("%s/%s/zone%d/%s", b.TopicPrefix, b.ModuleName, zoneNum, subtopic) } func (b *Bridge) getSysTopic(subtopic string) string { return fmt.Sprintf("%s/%s/sys/%s", b.TopicPrefix, b.ModuleName, subtopic) } func (b *Bridge) getACTopic(ac ACMachine, subtopic string) string { return b.getSysTopic(fmt.Sprintf("ac%d/%s", ac, subtopic)) } func (b *Bridge) publishHvacMode() { for _, zone := range b.zones { if zone.isOn() { hvacModeTopic := b.getZoneTopic(zone.ZoneNumber, "hvacMode") mode := b.sys.HVACMode() b.Mqtt.Publish(hvacModeTopic, 0, true, mode) } } } // publishComponent publishes a Home Assistant component configuration for autodiscovery func (b *Bridge) publishComponent(component, ObjectID string, config map[string]interface{}) { configJSON, _ := json.Marshal(config) b.Mqtt.Publish(fmt.Sprintf("%s/%s/%s/%s/config", b.HassPrefix, component, b.ModuleName, ObjectID), 0, true, string(configJSON)) }