Skip to content
This repository has been archived by the owner on Oct 1, 2024. It is now read-only.

Bugfixes and new feature -- currently testing at home #40

Draft
wants to merge 12 commits into
base: master
Choose a base branch
from
5 changes: 5 additions & 0 deletions config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,11 @@
"type": "string",
"required": false
},
"use_eco_mode": {
"title": "Use Energy Saver for cooling (when available)",
"type": "boolean",
"required": false
},
"refresh_interval": {
"title": "Refresh interval (requires Homebridge restart)",
"type": "number",
Expand Down
138 changes: 128 additions & 10 deletions src/characteristic/abstractCharacteristic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,32 @@ import type {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
Characteristic as HomebridgeCharacteristic,
CharacteristicValue,
CharacteristicChange,
CharacteristicGetCallback,
CharacteristicSetCallback,
WithUUID,
} from 'homebridge'

import { HomebridgeLgThinqPlatform } from '../platform'
import type { LgAirConditionerPlatformAccessory } from '../platformAccessory'
import type { GetDeviceResponse } from '../thinq/apiTypes'

type Unpacked<T> = T extends (infer U)[] ? U : T

export default abstract class AbstractCharacteristic<
State extends CharacteristicValue,
ApiValue extends string | number,
Characteristic extends WithUUID<{
new (): HomebridgeCharacteristic
}> /** Comes from this.platform.Characteristic.____ */
> {
private platform: HomebridgeLgThinqPlatform
private service: Service
private deviceId: string
protected platform: HomebridgeLgThinqPlatform
protected service: Service
protected device: LgAirConditionerPlatformAccessory
protected characteristic: Characteristic /** Comes from this.platform.Characteristic.____ */

private cachedState?: State
characteristic: Characteristic /** Comes from this.platform.Characteristic.____ */

private apiCommand: 'Set' | 'Operation'
private apiDataKey: keyof GetDeviceResponse['result']['snapshot']

Expand All @@ -34,14 +40,14 @@ export default abstract class AbstractCharacteristic<
constructor(
platform: HomebridgeLgThinqPlatform,
service: Service,
deviceId: string,
device: LgAirConditionerPlatformAccessory,
characteristic: Characteristic,
apiCommand: 'Set' | 'Operation',
apiDataKey: keyof GetDeviceResponse['result']['snapshot'],
) {
this.platform = platform
this.service = service
this.deviceId = deviceId
this.device = device
this.characteristic = characteristic
this.apiCommand = apiCommand
this.apiDataKey = apiDataKey
Expand All @@ -56,6 +62,12 @@ export default abstract class AbstractCharacteristic<
.getCharacteristic(this.characteristic)
.on(CharacteristicEventTypes.SET, this.handleSet.bind(this))
}

if (this.handleChange) {
this.service
.getCharacteristic(this.characteristic)
.on(CharacteristicEventTypes.CHANGE, this.handleChange.bind(this))
}
}

/** Transform Homebridge state to what the ThinQ API expects */
Expand All @@ -66,21 +78,36 @@ export default abstract class AbstractCharacteristic<
*/
abstract getApiValueFromState(state: State): ApiValue

getUUID(): string {
return this.characteristic.UUID
}

/** Take in an updated device snapshot */
handleUpdatedSnapshot(snapshot: GetDeviceResponse['result']['snapshot']) {
handleUpdatedSnapshot(
snapshot: Unpacked<GetDeviceResponse['result']['snapshot']>,
) {
try {
this.logDebug('HandleSnapshot for ' + this.characteristic.name)

const apiValue = snapshot[this.apiDataKey] as ApiValue
this.logDebug('handleUpdatedSnapshot', apiValue)
jeffmaki marked this conversation as resolved.
Show resolved Hide resolved

this.cachedState = this.getStateFromApiValue(apiValue)
this.service.updateCharacteristic(this.characteristic, this.cachedState)
} catch (error) {
this.logError('Error parsing state', error.toString())
}
}

/** Handle a "change" command from Homebridge to update this characteristic */
handleChange?(value: CharacteristicChange) {
this.logDebug('Triggered CHANGE:', value.newValue)
jeffmaki marked this conversation as resolved.
Show resolved Hide resolved
}

/** Handle a "set" command from Homebridge to update this characteristic */
handleSet?(value: CharacteristicValue, callback: CharacteristicSetCallback) {
this.logDebug('Triggered SET:', value)
jeffmaki marked this conversation as resolved.
Show resolved Hide resolved

if (!this.thinqApi) {
this.logError('API not initialized yet')
return
Expand All @@ -92,9 +119,9 @@ export default abstract class AbstractCharacteristic<
)
this.logDebug('targetState', targetState)

// The air conditioner will make a sound every time this API is called.
// To avoid unnecessary chimes, we'll optimistically skip sending the API call.
if (targetState === this.cachedState) {
// The air conditioner will make a sound every time this API is called.
// To avoid unnecessary chimes, we'll optimistically skip sending the API call.
this.logDebug('State equals cached state. Skipping.', targetState)
callback(null, targetState)
return
Expand All @@ -103,14 +130,22 @@ export default abstract class AbstractCharacteristic<
const apiValue = this.getApiValueFromState(targetState)

this.thinqApi
.sendCommand(this.deviceId, this.apiCommand, this.apiDataKey, apiValue)
.sendCommand(
this.device.getDeviceId() || '',
this.apiCommand,
this.apiDataKey,
apiValue,
)
.then(() => {
this.cachedState = targetState
callback(null, targetState)
})
.catch((error) => {
this.logError('Failed to set state', targetState, error.toString())
jeffmaki marked this conversation as resolved.
Show resolved Hide resolved
callback(error)

// put UI back to where it was before
this.device.updateCharacteristics(true)
jeffmaki marked this conversation as resolved.
Show resolved Hide resolved
})
}

Expand All @@ -134,4 +169,87 @@ export default abstract class AbstractCharacteristic<
...parameters,
)
}

deviceUsesFahrenheit(): boolean {
return this.device.getDevice().countryCode.startsWith('US')
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a bit confused -- is this necessary? My understanding was both the LG and HomeKit APIs operate in Celsius, and it's just a matter of what HomeKit/LG display on screen that differs

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it necessary? Can't say with units that display celsius. So this was my attempt to limit my changes to only Fahrenheit units, and if we find it's needed with celsius units, we can change. But somebody else would have to test that (someone who has a celsius unit)

}

roundHalf(r: number): number {
return Math.round(r * 2) / 2
}

/*
LG Air Conditioners use a lookup table to go between Celsius and Farenheit, which we request from the
LG servers as part of the GetDashboard call. There are two tables, CelToFah and FahToCel, which are *not*
inverses of each other (unfortunately).

These tables are the same as the units use, so if you want your results to match what they physically display,
you need to use the lookup tables.

HomeKit's API uses Celsius as its standard unit, regardless of whether the user sees Farenheit on the UI or not--that is,
conversion happens on the iPhone.

Thus, to ensure the temperatures on the app and the unit match, we have to convert to Farenheit,
then back to Celsius for HomeKit, the latter conversion using the classic (5/9) - 32 math.

We do all this only if the user sees Farenheit, as I *think* this issue is moot if the units show celsius? A non-US user to confirm.
*/
getHomeKitCelsiusForLGAPICelsius(_celsius: number): number {
if (!this.deviceUsesFahrenheit()) {
return _celsius
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there harm in always performing the conversion? I believe both Celsius and Fahrenheit users will see these discrepancies due to rounding differences, right?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See above

}

const LGCelsius = this.roundHalf(_celsius)
const LGCelsiusToF: Partial<Record<
string,
number
>> = this.device.getModelInfo().Value.TempCelToFah.value_mapping
const LGFarenheit = LGCelsiusToF[LGCelsius]

if (LGFarenheit === undefined) {
this.logError(
'getHomeKitCelsiusForLGAPICelsius input temperature ' +
_celsius +
' was not found in LG mapping table',
)
return _celsius
}

const HKCelsius = this.roundHalf((LGFarenheit - 32) * (5 / 9))
this.logDebug(
'getHomeKitCelsiusForLGAPICelsius in=' + _celsius + ' out=' + HKCelsius,
)
return HKCelsius
}

// inverse of the above
getLGAPICelsiusForHomeKitCelsius(_celsius: number): number {
if (!this.deviceUsesFahrenheit()) {
return _celsius
}

const HKCelsiusInFarenheit: number = Math.round(_celsius * (9 / 5) + 32)
const LGCelsiusToF = this.device.getModelInfo().Value.TempCelToFah
.value_mapping

for (const LGCelsius in LGCelsiusToF) {
const LGFarenheit = LGCelsiusToF[LGCelsius]

if (LGFarenheit === HKCelsiusInFarenheit) {
this.logDebug(
'getLGAPICelsiusForHomeKitCelsius in=' +
_celsius +
' out=' +
LGCelsius,
)

return Number(LGCelsius)
}
}

this.logError(
'Value ' + _celsius + " wasn't found in the LG mapping table.",
)
return _celsius
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import type { Service, Characteristic } from 'homebridge'
import type {
Service,
Characteristic,
CharacteristicValue,
CharacteristicSetCallback,
} from 'homebridge'

import type { GetDeviceResponse } from '../thinq/apiTypes'
import { LgAirConditionerPlatformAccessory } from '../platformAccessory'
import { HomebridgeLgThinqPlatform } from '../platform'
import AbstractCharacteristic from './abstractCharacteristic'

Expand All @@ -10,6 +15,9 @@ type ApiValue = number /** temperature in celcius */

type Mode = 'cool' | 'heat'

const TEMPERATURE_MAX_VALUE_C = 30
const TEMPERATURE_MIN_VALUE_C = 16

/**
* The air conditioner will report a single API "target temperature", while Homekit
* supports a target temperature for both heat & cool simultaneously.
Expand All @@ -22,58 +30,62 @@ export default class AbstractSplithresholdCharacteristic extends AbstractCharact
typeof Characteristic.CoolingThresholdTemperature
> {
mode: Mode
localPlatform: HomebridgeLgThinqPlatform
localService: Service

constructor(
platform: HomebridgeLgThinqPlatform,
service: Service,
deviceId: string,
device: LgAirConditionerPlatformAccessory,
mode: Mode,
) {
super(
platform,
service,
deviceId,
device,
mode === 'cool'
? platform.Characteristic.CoolingThresholdTemperature
: platform.Characteristic.HeatingThresholdTemperature,
'Set',
'airState.tempState.target',
)

this.mode = mode
service
.getCharacteristic(this.characteristic)
// min/max as defined in product manual
.setProps({ minValue: 16, maxValue: 30, minStep: 0.5 })
// Usually these would be private, but this is a special characteristic
// that needs these
this.localPlatform = platform
this.localService = service
.setProps({
minValue: TEMPERATURE_MIN_VALUE_C,
maxValue: TEMPERATURE_MAX_VALUE_C,
minStep: 0.5,
})
}

// Override default handleUpdatedSnapshot() to ignore based on mode
handleUpdatedSnapshot(snapshot: GetDeviceResponse['result']['snapshot']) {
const targetState = this.localService.getCharacteristic(
this.localPlatform.Characteristic.TargetHeaterCoolerState,
).value
const requiredState =
this.mode === 'cool'
? this.localPlatform.Characteristic.TargetHeaterCoolerState.COOL
: this.localPlatform.Characteristic.TargetHeaterCoolerState.HEAT
if (targetState !== requiredState) {
this.logDebug(
`Target state is not "${this.mode}", ignoring snapshot update`,
)
handleSet?(value: CharacteristicValue, callback: CharacteristicSetCallback) {
if (this.device.lockTemperature) {
// updating from cache when we're locked puts things back to where they were, essentially preventing edits
this.device.updateCharacteristics(true)
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be a potentially stale snapshot. I'd prefer we ignore the request or augment the handleSet / callback usage for this characteristic alone

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, but then the user can change the temperature to something the AC won't accept, and the temperature will just revert back next refresh. Which could be 10 minutes from now. I didn't think that was great either...

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I sort of think of it as one action -> one failure (ignored input) being better than (potentially) one action -> stale UI due to cache -> in-sync UI moments later. It's unfortunate that HomeKit doesn't support this set of modes appropriately but I think it could be more confusing to flash the user with a stale UI for this one case

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it. You should try disabling the "lockTemperature" flag and run the plugin to see what happens without it--it's not great. Basically you still get what you could be construed as a cached update: the temperature stays where the user set it, then snaps back to what the AC is actually at next refresh, potentially 10 minutes later. So my thinking here is that the user thinks they set the temperature to something preferable, then later realize the update didn't work. This would happen 100% of the time, whereas the cache being stale would be only when the connection between Thinq and the plugin is down, which I assumed was less than that. So TL;DR: this approach shows the correct state of the AC more often than any alternative.

I agree the real fix would be on the HomeKit side, but alas, can't find any fix there. Know anyone at Apple to ask what they would do in this case? Maybe there's something we don't know here...

} else {
if (super.handleSet) {
super.handleSet(value, callback)
}
}
super.handleUpdatedSnapshot(snapshot)
}

getStateFromApiValue(apiValue: ApiValue): State {
return apiValue
// if we're "locked", i.e. in auto mode, show the full range of temperature to
// show the user that we're not heating/cooling to any set point
if (this.device.lockTemperature) {
this.logDebug('Returning locked temperature values')
if (this.mode === 'cool') {
return TEMPERATURE_MAX_VALUE_C
} else {
return TEMPERATURE_MIN_VALUE_C
}
} else {
return this.getHomeKitCelsiusForLGAPICelsius(apiValue)
}
}

getApiValueFromState(state: State): ApiValue {
return state
return this.getLGAPICelsiusForHomeKitCelsius(state)
}
}
6 changes: 4 additions & 2 deletions src/characteristic/activeCharacteristic.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { Service, Characteristic } from 'homebridge'

import { HomebridgeLgThinqPlatform } from '../platform'
import { LgAirConditionerPlatformAccessory } from '../platformAccessory'

import AbstractCharacteristic from './abstractCharacteristic'

type State =
Expand All @@ -17,12 +19,12 @@ export default class ActiveCharacteristic extends AbstractCharacteristic<
constructor(
platform: HomebridgeLgThinqPlatform,
service: Service,
deviceId: string,
device: LgAirConditionerPlatformAccessory,
) {
super(
platform,
service,
deviceId,
device,
platform.Characteristic.Active,
'Operation',
'airState.operation',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { Service } from 'homebridge'
import type { LgAirConditionerPlatformAccessory } from '../platformAccessory'

import { HomebridgeLgThinqPlatform } from '../platform'
import AbstractSplithresholdCharacteristic from './abstractSplitTemperatureThresholdCharacteristic'
Expand All @@ -7,8 +8,8 @@ export default class CoolingThresholdCharacteristic extends AbstractSplithreshol
constructor(
platform: HomebridgeLgThinqPlatform,
service: Service,
deviceId: string,
device: LgAirConditionerPlatformAccessory,
) {
super(platform, service, deviceId, 'cool')
super(platform, service, device, 'cool')
}
}
Loading