diff --git a/README.md b/README.md index 59e2074..e3de7df 100644 --- a/README.md +++ b/README.md @@ -24,13 +24,13 @@ home automation platforms. ## Supported hardware -| Model Number | Description | Supported | -| ------------ | --------------------------------------------------------- | -------------------------------------------------------------------- | -| `BB-V1-X` | Mysa Smart Thermostat for Electric Baseboard Heaters V1 | ✅ Tested and working | -| `BB-V2-X` | Mysa Smart Thermostat for Electric Baseboard Heaters V2 | ⚠️ Partially working, in progress | -| `BB-V2-X-L` | Mysa Smart Thermostat LITE for Electric Baseboard Heaters | ⚠️ Partially working, in progress; does not report power consumption | -| `unknown` | Mysa Smart Thermostat for Electric In-Floor Heating | ⚠️ Should work but not tested | -| `AC-V1-X` | Mysa Smart Thermostat for Mini-Split Heat Pumps & AC | 🚫 Not supported (yet) | +| Model Number | Description | Supported | +| ------------ | --------------------------------------------------------- | ----------------------------------------------------------------------- | +| `BB-V1-X` | Mysa Smart Thermostat for Electric Baseboard Heaters V1 | ✅ Tested and working | +| `BB-V2-X` | Mysa Smart Thermostat for Electric Baseboard Heaters V2 | ⚠️ Partially working, in progress | +| `BB-V2-X-L` | Mysa Smart Thermostat LITE for Electric Baseboard Heaters | ⚠️ Partially working, in progress; does not report power consumption | +| `unknown` | Mysa Smart Thermostat for Electric In-Floor Heating | ⚠️ Should work but not tested | +| `AC-V1-X` | Mysa Smart Thermostat for Mini-Split Heat Pumps & AC | ⚠️ Partially working, in progress; missing swing and position functions | ## Disclaimer diff --git a/src/thermostat.ts b/src/thermostat.ts index 229ebe2..5b08893 100644 --- a/src/thermostat.ts +++ b/src/thermostat.ts @@ -30,9 +30,39 @@ import { OriginConfiguration, Sensor } from 'mqtt2ha'; -import { DeviceBase, FirmwareDevice, MysaApiClient, MysaDeviceMode, StateChange, Status } from 'mysa-js-sdk'; +import { + DeviceBase, + FirmwareDevice, + MysaApiClient, + MysaDeviceMode, + MysaFanSpeedMode, + StateChange, + Status +} from 'mysa-js-sdk'; import { version } from './options'; +type DeviceType = 'AC' | 'BB'; + +const HA_HEAT_ONLY_MODES: Partial[] = ['off', 'heat']; +const HA_AC_MODES: Partial[] = ['off', 'heat', 'cool', 'dry', 'fan_only', 'auto']; +const MYSA_RAW_MODE_TO_DEVICE_MODE: Partial> = { + 1: 'off', + 2: 'auto', + 3: 'heat', + 4: 'cool', + 5: 'fan_only', + 6: 'dry' +}; + +const FAN_SPEED_MODES: Partial[] = ['auto', 'low', 'medium', 'high', 'max']; +const MYSA_RAW_FAN_SPEED_TO_FAN_SPEED_MODE: Partial> = { + 1: 'auto', + 3: 'low', + 5: 'medium', + 7: 'high', + 8: 'max' +}; + export class Thermostat { private isStarted = false; private readonly mqttDevice: DeviceConfiguration; @@ -45,6 +75,8 @@ export class Thermostat { private readonly mysaStatusUpdateHandler = this.handleMysaStatusUpdate.bind(this); private readonly mysaStateChangeHandler = this.handleMysaStateChange.bind(this); + private readonly deviceType: DeviceType; + constructor( public readonly mysaApiClient: MysaApiClient, public readonly mysaDevice: DeviceBase, @@ -71,6 +103,9 @@ export class Thermostat { support_url: 'https://github.com/bourquep/mysa2mqtt' }; + const isAC = mysaDevice.Model.startsWith('AC'); + this.deviceType = isAC ? 'AC' : 'BB'; + this.mqttClimate = new Climate( { mqtt: this.mqttSettings, @@ -83,37 +118,54 @@ export class Thermostat { name: 'Thermostat', min_temp: mysaDevice.MinSetpoint, max_temp: mysaDevice.MaxSetpoint, - modes: ['off', 'heat'], // TODO: AC + modes: isAC ? HA_AC_MODES : HA_HEAT_ONLY_MODES, + fan_modes: isAC ? FAN_SPEED_MODES : undefined, precision: is_celsius ? 0.1 : 1.0, temp_step: is_celsius ? 0.5 : 1.0, temperature_unit: 'C', optimistic: true } }, - [ - 'action_topic', - 'current_humidity_topic', - 'current_temperature_topic', - 'mode_state_topic', - 'temperature_state_topic' - ], + isAC + ? [ + 'action_topic', + 'current_humidity_topic', + 'current_temperature_topic', + 'mode_state_topic', + 'temperature_state_topic', + 'fan_mode_state_topic' + ] + : [ + 'action_topic', + 'current_humidity_topic', + 'current_temperature_topic', + 'mode_state_topic', + 'temperature_state_topic' + ], async () => {}, - ['mode_command_topic', 'power_command_topic', 'temperature_command_topic'], + isAC + ? ['mode_command_topic', 'power_command_topic', 'temperature_command_topic', 'fan_mode_command_topic'] + : ['mode_command_topic', 'power_command_topic', 'temperature_command_topic'], async (topic, message) => { switch (topic) { - case 'mode_command_topic': - this.mysaApiClient.setDeviceState( - this.mysaDevice.Id, - undefined, - message === 'off' ? 'off' : message === 'heat' ? 'heat' : undefined - ); + case 'mode_command_topic': { + const messageAsMode = message as MysaDeviceMode; + const mode: MysaDeviceMode | undefined = isAC + ? HA_AC_MODES.includes(messageAsMode) + ? messageAsMode + : undefined + : HA_HEAT_ONLY_MODES.includes(messageAsMode) + ? messageAsMode + : undefined; + this.mysaApiClient.setDeviceState(this.mysaDevice.Id, undefined, mode); break; + } case 'power_command_topic': this.mysaApiClient.setDeviceState( this.mysaDevice.Id, undefined, - message === 'OFF' ? 'off' : message === 'ON' ? 'heat' : undefined + message === 'OFF' ? 'off' : message === 'ON' && !isAC ? 'heat' : undefined ); break; @@ -134,6 +186,13 @@ export class Thermostat { this.mysaApiClient.setDeviceState(this.mysaDevice.Id, temperature, undefined); } break; + + case 'fan_mode_command_topic': { + const messageAsMode = message as MysaFanSpeedMode; + const mode = FAN_SPEED_MODES.includes(messageAsMode) ? messageAsMode : undefined; + this.mysaApiClient.setDeviceState(this.mysaDevice.Id, undefined, undefined, mode); + break; + } } } ); @@ -200,13 +259,16 @@ export class Thermostat { try { const deviceStates = await this.mysaApiClient.getDeviceStates(); const state = deviceStates.DeviceStatesObj[this.mysaDevice.Id]; - const tstatMode = state.TstatMode?.v; this.mqttClimate.currentTemperature = state.CorrectedTemp?.v; this.mqttClimate.currentHumidity = state.Humidity?.v; - this.mqttClimate.currentMode = tstatMode === 1 ? 'off' : tstatMode === 3 ? 'heat' : undefined; + this.mqttClimate.currentMode = + MYSA_RAW_MODE_TO_DEVICE_MODE[state.TstatMode?.v as number] ?? this.mqttClimate.currentMode; + this.mqttClimate.currentFanMode = + MYSA_RAW_FAN_SPEED_TO_FAN_SPEED_MODE[state.FanSpeed?.v as number] ?? this.mqttClimate.currentFanMode; this.mqttClimate.currentAction = this.computeCurrentAction(undefined, state.Duty?.v); this.mqttClimate.targetTemperature = this.mqttClimate.currentMode !== 'off' ? state.SetPoint?.v : undefined; + await this.mqttClimate.writeConfig(); await this.mqttTemperature.setState( @@ -280,29 +342,52 @@ export class Thermostat { this.mqttClimate.currentMode = 'off'; this.mqttClimate.currentAction = 'off'; this.mqttClimate.targetTemperature = undefined; + this.mqttClimate.currentFanMode = undefined; break; case 'heat': - this.mqttClimate.currentMode = 'heat'; + case 'cool': + case 'auto': + this.mqttClimate.currentMode = state.mode; + if (this.deviceType === 'AC') { + this.mqttClimate.currentAction = this.computeCurrentAction(); + } this.mqttClimate.targetTemperature = state.setPoint; + this.mqttClimate.currentFanMode = state.fanSpeed; + break; + + case 'dry': + case 'fan_only': + this.mqttClimate.currentMode = state.mode; + this.mqttClimate.currentAction = this.computeCurrentAction(); + this.mqttClimate.currentFanMode = state.fanSpeed; break; } } private computeCurrentAction(current?: number, dutyCycle?: number): ClimateAction { - const mode: MysaDeviceMode | undefined = - this.mqttClimate.currentMode === 'heat' ? 'heat' : this.mqttClimate.currentMode === 'off' ? 'off' : undefined; + const currentModeAsMode = this.mqttClimate.currentMode as MysaDeviceMode; + const mode = HA_AC_MODES.includes(currentModeAsMode) ? currentModeAsMode : undefined; switch (mode) { case 'off': return 'off'; - case 'heat': - if (current != null) { - return current > 0 ? 'heating' : 'idle'; + switch (this.deviceType) { + case 'BB': + if (current != null) { + return current > 0 ? 'heating' : 'idle'; + } + return (dutyCycle ?? 0) > 0 ? 'heating' : 'idle'; + default: + return 'heating'; } - return (dutyCycle ?? 0) > 0 ? 'heating' : 'idle'; - + case 'cool': + return 'cooling'; + case 'fan_only': + return 'fan'; + case 'dry': + return 'drying'; default: return 'idle'; }