feat: Added partial support for AC-V1-1 thermostat (#74)

##  Add support for AC-V1-1 Devices

This PR follows https://github.com/bourquep/mysa-js-sdk/pull/156 and
should be merged once the `mysa-js-sdk` is updated and we probably need
to bump the version in here too?

### What Changed
- Added detection for AC model devices (`isAC`) and extended mode
handling:
- Supports additional modes: `cool`, `dry`, `fan_only`, and `auto`
(alongside `heat` and `off`).
- Supports fans speed for fan compatible modes with 4 speeds as
advertised on the Mysa app

** Note:  I'm no typescript expert so code might not look the best **

### Testing
- Verified mode changes and temperature updates for both heat-only and
AC devices.
- Verified fan speed changes for AC devices
- Verified action changes for both heat-only and AC devices
- Tested interactions from both Mysa app and HA


https://github.com/user-attachments/assets/ada413dc-c681-49de-af09-7b21826be8f3

### Next Steps
- Add full AC-V1 support:
  - Swing / vane control

---------

Co-authored-by: Pascal Bourque <pascal@cosmos.moi>
This commit is contained in:
remiolivier
2025-11-03 03:45:41 -08:00
committed by GitHub
parent 2ff6d00999
commit c24a0da4d1
2 changed files with 119 additions and 34 deletions

View File

@@ -25,12 +25,12 @@ home automation platforms.
## Supported hardware ## Supported hardware
| Model Number | Description | Supported | | Model Number | Description | Supported |
| ------------ | --------------------------------------------------------- | -------------------------------------------------------------------- | | ------------ | --------------------------------------------------------- | ----------------------------------------------------------------------- |
| `BB-V1-X` | Mysa Smart Thermostat for Electric Baseboard Heaters V1 | ✅ Tested and working | | `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` | 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 | | `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 | | `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) | | `AC-V1-X` | Mysa Smart Thermostat for Mini-Split Heat Pumps & AC | ⚠️ Partially working, in progress; missing swing and position functions |
## Disclaimer ## Disclaimer

View File

@@ -30,9 +30,39 @@ import {
OriginConfiguration, OriginConfiguration,
Sensor Sensor
} from 'mqtt2ha'; } 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'; import { version } from './options';
type DeviceType = 'AC' | 'BB';
const HA_HEAT_ONLY_MODES: Partial<MysaDeviceMode>[] = ['off', 'heat'];
const HA_AC_MODES: Partial<MysaDeviceMode>[] = ['off', 'heat', 'cool', 'dry', 'fan_only', 'auto'];
const MYSA_RAW_MODE_TO_DEVICE_MODE: Partial<Record<number, MysaDeviceMode>> = {
1: 'off',
2: 'auto',
3: 'heat',
4: 'cool',
5: 'fan_only',
6: 'dry'
};
const FAN_SPEED_MODES: Partial<MysaFanSpeedMode>[] = ['auto', 'low', 'medium', 'high', 'max'];
const MYSA_RAW_FAN_SPEED_TO_FAN_SPEED_MODE: Partial<Record<number, MysaFanSpeedMode>> = {
1: 'auto',
3: 'low',
5: 'medium',
7: 'high',
8: 'max'
};
export class Thermostat { export class Thermostat {
private isStarted = false; private isStarted = false;
private readonly mqttDevice: DeviceConfiguration; private readonly mqttDevice: DeviceConfiguration;
@@ -45,6 +75,8 @@ export class Thermostat {
private readonly mysaStatusUpdateHandler = this.handleMysaStatusUpdate.bind(this); private readonly mysaStatusUpdateHandler = this.handleMysaStatusUpdate.bind(this);
private readonly mysaStateChangeHandler = this.handleMysaStateChange.bind(this); private readonly mysaStateChangeHandler = this.handleMysaStateChange.bind(this);
private readonly deviceType: DeviceType;
constructor( constructor(
public readonly mysaApiClient: MysaApiClient, public readonly mysaApiClient: MysaApiClient,
public readonly mysaDevice: DeviceBase, public readonly mysaDevice: DeviceBase,
@@ -71,6 +103,9 @@ export class Thermostat {
support_url: 'https://github.com/bourquep/mysa2mqtt' support_url: 'https://github.com/bourquep/mysa2mqtt'
}; };
const isAC = mysaDevice.Model.startsWith('AC');
this.deviceType = isAC ? 'AC' : 'BB';
this.mqttClimate = new Climate( this.mqttClimate = new Climate(
{ {
mqtt: this.mqttSettings, mqtt: this.mqttSettings,
@@ -83,14 +118,24 @@ export class Thermostat {
name: 'Thermostat', name: 'Thermostat',
min_temp: mysaDevice.MinSetpoint, min_temp: mysaDevice.MinSetpoint,
max_temp: mysaDevice.MaxSetpoint, 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, precision: is_celsius ? 0.1 : 1.0,
temp_step: is_celsius ? 0.5 : 1.0, temp_step: is_celsius ? 0.5 : 1.0,
temperature_unit: 'C', temperature_unit: 'C',
optimistic: true optimistic: true
} }
}, },
[ isAC
? [
'action_topic',
'current_humidity_topic',
'current_temperature_topic',
'mode_state_topic',
'temperature_state_topic',
'fan_mode_state_topic'
]
: [
'action_topic', 'action_topic',
'current_humidity_topic', 'current_humidity_topic',
'current_temperature_topic', 'current_temperature_topic',
@@ -98,22 +143,29 @@ export class Thermostat {
'temperature_state_topic' 'temperature_state_topic'
], ],
async () => {}, 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) => { async (topic, message) => {
switch (topic) { switch (topic) {
case 'mode_command_topic': case 'mode_command_topic': {
this.mysaApiClient.setDeviceState( const messageAsMode = message as MysaDeviceMode;
this.mysaDevice.Id, const mode: MysaDeviceMode | undefined = isAC
undefined, ? HA_AC_MODES.includes(messageAsMode)
message === 'off' ? 'off' : message === 'heat' ? 'heat' : undefined ? messageAsMode
); : undefined
: HA_HEAT_ONLY_MODES.includes(messageAsMode)
? messageAsMode
: undefined;
this.mysaApiClient.setDeviceState(this.mysaDevice.Id, undefined, mode);
break; break;
}
case 'power_command_topic': case 'power_command_topic':
this.mysaApiClient.setDeviceState( this.mysaApiClient.setDeviceState(
this.mysaDevice.Id, this.mysaDevice.Id,
undefined, undefined,
message === 'OFF' ? 'off' : message === 'ON' ? 'heat' : undefined message === 'OFF' ? 'off' : message === 'ON' && !isAC ? 'heat' : undefined
); );
break; break;
@@ -134,6 +186,13 @@ export class Thermostat {
this.mysaApiClient.setDeviceState(this.mysaDevice.Id, temperature, undefined); this.mysaApiClient.setDeviceState(this.mysaDevice.Id, temperature, undefined);
} }
break; 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 { try {
const deviceStates = await this.mysaApiClient.getDeviceStates(); const deviceStates = await this.mysaApiClient.getDeviceStates();
const state = deviceStates.DeviceStatesObj[this.mysaDevice.Id]; const state = deviceStates.DeviceStatesObj[this.mysaDevice.Id];
const tstatMode = state.TstatMode?.v;
this.mqttClimate.currentTemperature = state.CorrectedTemp?.v; this.mqttClimate.currentTemperature = state.CorrectedTemp?.v;
this.mqttClimate.currentHumidity = state.Humidity?.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.currentAction = this.computeCurrentAction(undefined, state.Duty?.v);
this.mqttClimate.targetTemperature = this.mqttClimate.currentMode !== 'off' ? state.SetPoint?.v : undefined; this.mqttClimate.targetTemperature = this.mqttClimate.currentMode !== 'off' ? state.SetPoint?.v : undefined;
await this.mqttClimate.writeConfig(); await this.mqttClimate.writeConfig();
await this.mqttTemperature.setState( await this.mqttTemperature.setState(
@@ -280,29 +342,52 @@ export class Thermostat {
this.mqttClimate.currentMode = 'off'; this.mqttClimate.currentMode = 'off';
this.mqttClimate.currentAction = 'off'; this.mqttClimate.currentAction = 'off';
this.mqttClimate.targetTemperature = undefined; this.mqttClimate.targetTemperature = undefined;
this.mqttClimate.currentFanMode = undefined;
break; break;
case 'heat': 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.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; break;
} }
} }
private computeCurrentAction(current?: number, dutyCycle?: number): ClimateAction { private computeCurrentAction(current?: number, dutyCycle?: number): ClimateAction {
const mode: MysaDeviceMode | undefined = const currentModeAsMode = this.mqttClimate.currentMode as MysaDeviceMode;
this.mqttClimate.currentMode === 'heat' ? 'heat' : this.mqttClimate.currentMode === 'off' ? 'off' : undefined; const mode = HA_AC_MODES.includes(currentModeAsMode) ? currentModeAsMode : undefined;
switch (mode) { switch (mode) {
case 'off': case 'off':
return 'off'; return 'off';
case 'heat': case 'heat':
switch (this.deviceType) {
case 'BB':
if (current != null) { if (current != null) {
return current > 0 ? 'heating' : 'idle'; return current > 0 ? 'heating' : 'idle';
} }
return (dutyCycle ?? 0) > 0 ? 'heating' : 'idle'; return (dutyCycle ?? 0) > 0 ? 'heating' : 'idle';
default:
return 'heating';
}
case 'cool':
return 'cooling';
case 'fan_only':
return 'fan';
case 'dry':
return 'drying';
default: default:
return 'idle'; return 'idle';
} }