-
Notifications
You must be signed in to change notification settings - Fork 533
Expand file tree
/
Copy pathattribute_emitters.lua
More file actions
393 lines (343 loc) · 15.4 KB
/
attribute_emitters.lua
File metadata and controls
393 lines (343 loc) · 15.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
local capabilities = require "st.capabilities"
local log = require "log"
local st_utils = require "st.utils"
-- trick to fix the VS Code Lua Language Server typechecking
---@type fun(val: any?, name: string?, multi_line: boolean?): string
st_utils.stringify_table = st_utils.stringify_table
local Consts = require "consts"
local Fields = require "fields"
local HueColorUtils = require "utils.cie_utils"
local HueDeviceTypes = require "hue_device_types"
local utils = require "utils"
local syncCapabilityId = "samsungim.hueSyncMode"
local hueSyncMode = capabilities[syncCapabilityId]
---@class AttributeEmitters
local AttributeEmitters = {}
---@type { [HueDeviceTypes]: fun(device: HueDevice, ...)}
local device_type_emitter_map = {}
---@param light_device HueChildDevice
---@param light_repr HueLightInfo
local function _emit_light_events_inner(light_device, light_repr)
if light_device ~= nil then
if light_device:get_field(Fields.IS_ONLINE) ~= true then
return
end
if light_repr.mode then
light_device:emit_event(hueSyncMode.mode(light_repr.mode))
end
if light_repr.on and light_repr.on.on then
light_device:emit_event(capabilities.switch.switch.on())
light_device:set_field(Fields.SWITCH_STATE, "on", {persist = true})
elseif light_repr.on and not light_repr.on.on then
light_device:emit_event(capabilities.switch.switch.off())
light_device:set_field(Fields.SWITCH_STATE, "off", {persist = true})
end
if light_repr.dimming then
local adjusted_level = st_utils.round(st_utils.clamp_value(light_repr.dimming.brightness, 1, 100))
if utils.is_nan(adjusted_level) then
light_device.log.warn(
string.format(
"Non numeric value %s computed for switchLevel Attribute Event, ignoring.",
adjusted_level
)
)
else
light_device:emit_event(capabilities.switchLevel.level(adjusted_level))
end
end
if light_repr.color_temperature then
local mirek = Consts.DEFAULT_MIN_MIREK
if light_repr.color_temperature.mirek_valid then
mirek = light_repr.color_temperature.mirek
end
local mirek_schema = light_repr.color_temperature.mirek_schema or {
mirek_minimum = Consts.DEFAULT_MIN_MIREK,
mirek_maximum = Consts.DEFAULT_MAX_MIREK
}
-- See note in `src/handlers/lifecycle_handlers/light.lua` about min/max relationship
-- if the below is not intuitive.
local color_temp_range = light_device:get_latest_state("main", capabilities.colorTemperature.ID, capabilities.colorTemperature.colorTemperatureRange.NAME);
local min_kelvin = (color_temp_range and color_temp_range.minimum)
local api_min_kelvin = math.floor(utils.mirek_to_kelvin(mirek_schema.mirek_maximum, Consts.KELVIN_STEP_SIZE) or Consts.MIN_TEMP_KELVIN_COLOR_AMBIANCE)
local max_kelvin = (color_temp_range and color_temp_range.maximum)
local api_max_kelvin = math.floor(utils.mirek_to_kelvin(mirek_schema.mirek_minimum, Consts.KELVIN_STEP_SIZE) or Consts.MAX_TEMP_KELVIN)
local update_range = false
if min_kelvin ~= api_min_kelvin then
update_range = true
min_kelvin = api_min_kelvin
light_device:set_field(Fields.MIN_KELVIN, min_kelvin, { persist = true })
end
if max_kelvin ~= api_max_kelvin then
update_range = true
max_kelvin = api_max_kelvin
light_device:set_field(Fields.MAX_KELVIN, max_kelvin, { persist = true })
end
if update_range then
light_device.log.debug(st_utils.stringify_table({ minimum = min_kelvin, maximum = max_kelvin }, "updating color temp range"));
light_device:emit_event(capabilities.colorTemperature.colorTemperatureRange({ minimum = min_kelvin, maximum = max_kelvin }))
else
light_device.log.debug(st_utils.stringify_table(color_temp_range, "color temp range unchanged"));
end
-- local min = or Consts.MIN_TEMP_KELVIN_WHITE_AMBIANCE
local kelvin = math.floor(
st_utils.clamp_value(utils.mirek_to_kelvin(mirek), min_kelvin, max_kelvin)
)
if utils.is_nan(kelvin) then
light_device.log.warn(
string.format(
"Non numeric value %s computed for colorTemperature Attribute Event, ignoring.",
kelvin
)
)
else
local last_kelvin_setpoint = light_device:get_field(Fields.COLOR_TEMP_SETPOINT)
if
last_kelvin_setpoint ~= nil and
last_kelvin_setpoint >= utils.mirek_to_kelvin(mirek + 1) and
last_kelvin_setpoint <= utils.mirek_to_kelvin(mirek - 1)
then
kelvin = last_kelvin_setpoint;
end
light_device:emit_event(capabilities.colorTemperature.colorTemperature(kelvin))
end
light_device:set_field(Fields.COLOR_TEMP_SETPOINT, nil);
end
if light_repr.color then
light_device:set_field(Fields.GAMUT, light_repr.color.gamut, { persist = true })
local r, g, b = HueColorUtils.safe_xy_to_rgb(light_repr.color.xy, light_repr.color.gamut)
local hue, sat, _ = st_utils.rgb_to_hsv(r, g, b)
-- We sent a command where hue == 100 and wrapped the value to 0, reverse that here
if light_device:get_field(Fields.WRAPPED_HUE) == true and (hue + .05 >= 1 or hue - .05 <= 0) then
hue = 1
light_device:set_field(Fields.WRAPPED_HUE, false)
end
local adjusted_hue = st_utils.clamp_value(st_utils.round(hue * 100), 0, 100)
local adjusted_sat = st_utils.clamp_value(st_utils.round(sat * 100), 0, 100)
if utils.is_nan(adjusted_hue) then
light_device.log.warn(
string.format(
"Non numeric value %s computed for colorControl.hue Attribute Event, ignoring.",
adjusted_hue
)
)
else
light_device:emit_event(capabilities.colorControl.hue(adjusted_hue))
light_device:set_field(Fields.COLOR_HUE, adjusted_hue, {persist = true})
end
if utils.is_nan(adjusted_sat) then
light_device.log.warn(
string.format(
"Non numeric value %s computed for colorControl.saturation Attribute Event, ignoring.",
adjusted_sat
)
)
else
light_device:emit_event(capabilities.colorControl.saturation(adjusted_sat))
light_device:set_field(Fields.COLOR_SATURATION, adjusted_sat, {persist = true})
end
end
end
end
function AttributeEmitters.connectivity_update(child_device, zigbee_status)
if child_device == nil or (child_device and child_device.id == nil) then
log.warn("Tried to emit attribute events for a device that has been deleted")
return
end
if zigbee_status == nil then
log.error_with({ hub_logs = true },
string.format("nil zigbee_status sent to connectivity_update for %s",
(child_device and (child_device.label or child_device.id)) or "unknown device"))
return
end
if zigbee_status.status == "connected" then
child_device.log.info_with({hub_logs=true}, "Device zigbee status event, marking device online")
child_device:online()
child_device:set_field(Fields.IS_ONLINE, true)
child_device.driver:inject_capability_command(child_device, {
capability = capabilities.refresh.ID,
command = capabilities.refresh.commands.refresh.NAME,
args = {}
})
elseif zigbee_status.status == "connectivity_issue" then
child_device.log.info_with({hub_logs=true}, "Device zigbee status event, marking device offline")
child_device:set_field(Fields.IS_ONLINE, false)
child_device:offline()
end
end
function AttributeEmitters.emit_button_attribute_events(button_device, button_info)
if button_device == nil or (button_device and button_device.id == nil) then
log.warn("Tried to emit attribute events for a device that has been deleted")
return
end
if button_info == nil then
log.error_with({ hub_logs = true },
string.format("nil button info sent to emit_button_attribute_events for %s",
(button_device and (button_device.label or button_device.id)) or "unknown device"))
return
end
if button_info.power_state and type(button_info.power_state.battery_level) == "number" then
log.debug("emit power")
button_device:emit_event(
capabilities.battery.battery(
st_utils.clamp_value(button_info.power_state.battery_level, 0, 100)
)
)
end
-- Handle relative_rotary events (e.g. from the Hue Tap Dial)
local rotary_report = button_info.relative_rotary and button_info.relative_rotary.rotary_report
if rotary_report and rotary_report.rotation then
local rotation = rotary_report.rotation
local direction_sign = (rotation.direction == "clock_wise") and 1 or -1
-- Scale: 1000 steps = one full rotation = 100 units. Including direction_sign, translate to an integer, scaled between [-100, 100]
local rotate_amount = st_utils.round(st_utils.clamp_value(direction_sign * ((rotation.steps / 1000) * 100), -100, 100))
log.debug(string.format("emit knob rotateAmount: %s", rotate_amount))
if rotate_amount ~= 0 then
button_device:emit_event(
capabilities.knob.rotateAmount(rotate_amount, { state_change = true })
)
end
end
local button_idx_map = button_device:get_field(Fields.BUTTON_INDEX_MAP)
if not button_idx_map then
log.error(
string.format(
"Button ID to Button Index map lost, " ..
"cannot find componenet to emit attribute event on for button [%s]",
(button_device and button_device.lable) or "unknown button"
)
)
return
end
local idx = button_idx_map[button_info.id] or 1
local component_idx
if idx == 1 then
component_idx = "main"
else
component_idx = string.format("button%s", idx)
end
local button_report = (button_info.button and button_info.button.button_report) or { event = "" }
if button_report.event == "long_press" and not button_device:get_field("button_held") then
button_device:set_field("button_held", true)
button_device.profile.components[component_idx]:emit_event(
capabilities.button.button.held({state_change = true})
)
end
if button_report.event == "long_release" and button_device:get_field("button_held") then
button_device:set_field("button_held", false)
end
if button_report.event == "short_release" and not button_device:get_field("button_held") then
button_device.profile.components[component_idx]:emit_event(
capabilities.button.button.pushed({state_change = true})
)
end
end
function AttributeEmitters.emit_contact_sensor_attribute_events(sensor_device, sensor_info)
if sensor_device == nil or (sensor_device and sensor_device.id == nil) then
log.warn("Tried to emit attribute events for a device that has been deleted")
return
end
if sensor_info == nil then
log.error_with({ hub_logs = true },
string.format("nil sensor_info sent to emit_contact_sensor_attribute_events for %s",
(sensor_device and (sensor_device.label or sensor_device.id)) or "unknown device"))
return
end
if sensor_info.power_state and type(sensor_info.power_state.battery_level) == "number" then
log.debug("emit power")
sensor_device:emit_event(capabilities.battery.battery(st_utils.clamp_value(sensor_info.power_state.battery_level, 0, 100)))
end
if sensor_info.tamper_reports then
log.debug("emit tamper")
local tampered = false
for _, tamper in ipairs(sensor_info.tamper_reports) do
if tamper.state == "tampered" then
tampered = true
break
end
end
if tampered then
sensor_device:emit_event(capabilities.tamperAlert.tamper.detected())
else
sensor_device:emit_event(capabilities.tamperAlert.tamper.clear())
end
end
if sensor_info.contact_report then
log.debug("emit contact")
if sensor_info.contact_report.state == "contact" then
sensor_device:emit_event(capabilities.contactSensor.contact.closed())
else
sensor_device:emit_event(capabilities.contactSensor.contact.open())
end
end
end
function AttributeEmitters.emit_motion_sensor_attribute_events(sensor_device, sensor_info)
if sensor_device == nil or (sensor_device and sensor_device.id == nil) then
log.warn("Tried to emit attribute events for a device that has been deleted")
return
end
if sensor_info == nil then
log.error_with({ hub_logs = true },
string.format("nil sensor_info sent to emit_motion_sensor_attribute_events for %s",
(sensor_device and (sensor_device.label or sensor_device.id)) or "unknown device"))
return
end
if sensor_info.power_state and type(sensor_info.power_state.battery_level) == "number" then
log.debug("emit power")
sensor_device:emit_event(capabilities.battery.battery(st_utils.clamp_value(sensor_info.power_state.battery_level, 0, 100)))
end
if sensor_info.temperature and sensor_info.temperature.temperature_valid then
log.debug("emit temp")
sensor_device:emit_event(capabilities.temperatureMeasurement.temperature({
value = sensor_info.temperature.temperature,
unit = "C"
}))
end
if sensor_info.light and sensor_info.light.light_level_valid then
log.debug("emit light")
-- From the Hue docs: Light level in 10000*log10(lux) +1
local raw_light_level = sensor_info.light.light_level
-- Convert from the Hue value to lux
local lux = st_utils.round(10^((raw_light_level - 1) / 10000))
sensor_device:emit_event(capabilities.illuminanceMeasurement.illuminance(lux))
end
if sensor_info.motion and sensor_info.motion.motion_valid then
log.debug("emit motion")
if sensor_info.motion.motion then
sensor_device:emit_event(capabilities.motionSensor.motion.active())
else
sensor_device:emit_event(capabilities.motionSensor.motion.inactive())
end
end
end
---@param light_device HueChildDevice
---@param light_repr table
function AttributeEmitters.emit_light_attribute_events(light_device, light_repr)
if light_device == nil or (light_device and light_device.id == nil) then
log.warn("Tried to emit light status event for device that has been deleted")
return
end
if light_repr == nil then
log.error_with({ hub_logs = true },
string.format("nil light_repr sent to emit_light_attribute_events for %s",
(light_device and (light_device.label or light_device.id)) or "unknown device"))
return
end
local success, maybe_err = pcall(_emit_light_events_inner, light_device, light_repr)
if not success then
log.error_with({ hub_logs = true }, string.format("Failed to invoke emit light status handler. Reason: %s", maybe_err))
end
end
local function noop_event_emitter(device, ...)
local label = (device and device.label) or "Unknown Device Name"
local device_type = (device and utils.determine_device_type(device)) or "Unknown Device Type"
log.warn(string.format("Tried to find attribute event emitter for device [%s] of unsupported type [%s], ignoring", label, device_type))
end
function AttributeEmitters.emitter_for_device_type(device_type)
return device_type_emitter_map[device_type] or noop_event_emitter
end
-- TODO: Generalize this like the other handlers, and maybe even separate out non-primary services
device_type_emitter_map[HueDeviceTypes.BUTTON] = AttributeEmitters.emit_button_attribute_events
device_type_emitter_map[HueDeviceTypes.CONTACT] = AttributeEmitters.emit_contact_sensor_attribute_events
device_type_emitter_map[HueDeviceTypes.LIGHT] = AttributeEmitters.emit_light_attribute_events
device_type_emitter_map[HueDeviceTypes.MOTION] = AttributeEmitters.emit_motion_sensor_attribute_events
return AttributeEmitters