@homebridge-plugins/homebridge-ewelink 12.18.3 → 12.18.4-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,15 @@
2
2
 
3
3
  All notable changes to `@homebridge-plugins/homebridge-ewelink` will be documented in this file.
4
4
 
5
+ ## v12.18.4 (Pending Release)
6
+
7
+ ### Changed
8
+
9
+ - feat(zigbee): add the snzb-03pr2 presence sensor, with occupancy and light level
10
+ - feat(zigbee): add the snzb-09p indoor siren, as a switch that sounds the alarm
11
+ - chore(deps): dependency updates
12
+ - fix: match multi-channel updates by outlet, so a partial update changes the right channel (#804)
13
+
5
14
  ## v12.18.3 (2026-08-11)
6
15
 
7
16
  ### Changed
@@ -1,5 +1,5 @@
1
1
  import platformConsts from '../utils/constants.js'
2
- import { hasProperty, sleep } from '../utils/functions.js'
2
+ import { hasProperty, sleep, switchForChannel } from '../utils/functions.js'
3
3
 
4
4
  export default class {
5
5
  constructor(platform, accessory, devicesInHB) {
@@ -327,10 +327,13 @@ export default class {
327
327
  if (this.devicesInHB.has(uuid)) {
328
328
  const subAccessory = this.devicesInHB.get(uuid)
329
329
  const service = subAccessory.getService(this.hapServ.Outlet)
330
- if (params.switches && params.switches[i - 1]) {
330
+ // By `outlet` where the device gives it - a partial update holds only
331
+ // the channel that changed, at position 0 (#804)
332
+ const channelEntry = switchForChannel(params.switches, i - 1)
333
+ if (channelEntry) {
331
334
  const currentState = service.getCharacteristic(this.hapChar.On).value ? 'on' : 'off'
332
335
  if (subAccessory.context.isInched) {
333
- if (params.switches[i - 1].switch === 'on' && !subAccessory.context.ignoreUpdates) {
336
+ if (channelEntry.switch === 'on' && !subAccessory.context.ignoreUpdates) {
334
337
  subAccessory.context.ignoreUpdates = true
335
338
  setTimeout(() => {
336
339
  subAccessory.context.ignoreUpdates = false
@@ -349,28 +352,33 @@ export default class {
349
352
  }
350
353
  }
351
354
  } else {
352
- if (params.switches[i - 1].switch === 'on') {
355
+ if (channelEntry.switch === 'on') {
353
356
  primaryState = true
354
357
  }
355
- if (!params.updateSource || params.switches[i - 1].switch !== currentState) {
358
+ if (!params.updateSource || channelEntry.switch !== currentState) {
356
359
  service.updateCharacteristic(
357
360
  this.hapChar.On,
358
- params.switches[i - 1].switch === 'on',
361
+ channelEntry.switch === 'on',
359
362
  )
360
363
  subAccessory.eveService.addEntry({
361
- status: params.switches[i - 1].switch === 'on' ? 1 : 0,
364
+ status: channelEntry.switch === 'on' ? 1 : 0,
362
365
  })
363
366
  if (params.updateSource && this.enableLogging) {
364
367
  this.log(
365
368
  '[%s] %s [%s].',
366
369
  subAccessory.displayName,
367
370
  this.lang.curState,
368
- params.switches[i - 1].switch,
371
+ channelEntry.switch,
369
372
  )
370
373
  }
371
374
  }
372
375
  }
373
376
  }
377
+ // The master tile is on when ANY channel is on, so it has to read every
378
+ // channel's resulting state - a partial update names only one (#804)
379
+ if (service.getCharacteristic(this.hapChar.On).value) {
380
+ primaryState = true
381
+ }
374
382
  if (this.powerReadings) {
375
383
  let logger = false
376
384
  let power
@@ -1,5 +1,5 @@
1
1
  import platformConsts from '../utils/constants.js'
2
- import { hasProperty, sleep } from '../utils/functions.js'
2
+ import { hasProperty, sleep, switchForChannel } from '../utils/functions.js'
3
3
 
4
4
  export default class {
5
5
  constructor(platform, accessory, devicesInHB) {
@@ -322,10 +322,13 @@ export default class {
322
322
  if (this.devicesInHB.has(uuid)) {
323
323
  const subAccessory = this.devicesInHB.get(uuid)
324
324
  const service = subAccessory.getService(this.hapServ.Switch)
325
- if (params.switches && params.switches[i - 1]) {
325
+ // By `outlet` where the device gives it - a partial update holds only
326
+ // the channel that changed, at position 0 (#804)
327
+ const channelEntry = switchForChannel(params.switches, i - 1)
328
+ if (channelEntry) {
326
329
  const currentState = service.getCharacteristic(this.hapChar.On).value ? 'on' : 'off'
327
330
  if (subAccessory.context.isInched) {
328
- if (params.switches[i - 1].switch === 'on' && !subAccessory.context.ignoreUpdates) {
331
+ if (channelEntry.switch === 'on' && !subAccessory.context.ignoreUpdates) {
329
332
  subAccessory.context.ignoreUpdates = true
330
333
  setTimeout(() => {
331
334
  subAccessory.context.ignoreUpdates = false
@@ -344,28 +347,33 @@ export default class {
344
347
  }
345
348
  }
346
349
  } else {
347
- if (params.switches[i - 1].switch === 'on') {
350
+ if (channelEntry.switch === 'on') {
348
351
  primaryState = true
349
352
  }
350
- if (!params.updateSource || params.switches[i - 1].switch !== currentState) {
353
+ if (!params.updateSource || channelEntry.switch !== currentState) {
351
354
  service.updateCharacteristic(
352
355
  this.hapChar.On,
353
- params.switches[i - 1].switch === 'on',
356
+ channelEntry.switch === 'on',
354
357
  )
355
358
  subAccessory.eveService.addEntry({
356
- status: params.switches[i - 1].switch === 'on' ? 1 : 0,
359
+ status: channelEntry.switch === 'on' ? 1 : 0,
357
360
  })
358
361
  if (params.updateSource && this.enableLogging) {
359
362
  this.log(
360
363
  '[%s] %s [%s].',
361
364
  subAccessory.displayName,
362
365
  this.lang.curState,
363
- params.switches[i - 1].switch,
366
+ channelEntry.switch,
364
367
  )
365
368
  }
366
369
  }
367
370
  }
368
371
  }
372
+ // The master tile is on when ANY channel is on, so it has to read every
373
+ // channel's resulting state - a partial update names only one (#804)
374
+ if (service.getCharacteristic(this.hapChar.On).value) {
375
+ primaryState = true
376
+ }
369
377
  if (this.powerReadings) {
370
378
  let logger = false
371
379
  let power
@@ -8,6 +8,7 @@ import deviceSensorLeak from './sensor-leak.js'
8
8
  import deviceSensorMotion from './sensor-motion.js'
9
9
  import deviceSensorOccupancy from './sensor-occupancy.js'
10
10
  import deviceSensorSmoke from './sensor-smoke.js'
11
+ import deviceSiren from './siren.js'
11
12
  import deviceSwitchStateless from './switch-stateless.js'
12
13
  import deviceThermostat from './thermostat.js'
13
14
  import deviceZigbeeWaterValve from './zigbee-water-valve.js'
@@ -23,6 +24,7 @@ export default {
23
24
  deviceSensorMotion,
24
25
  deviceSensorOccupancy,
25
26
  deviceSensorSmoke,
27
+ deviceSiren,
26
28
  deviceSwitchStateless,
27
29
  deviceThermostat,
28
30
  deviceZigbeeWaterValve,
@@ -53,8 +53,9 @@ export default class {
53
53
  this.battService = this.accessory.getService(this.hapServ.Battery)
54
54
  || this.accessory.addService(this.hapServ.Battery)
55
55
 
56
- // SNZB-06P models also report a binary bright/dark light level (uiid 7016)
57
- if (accessory.context.eweUIID === 7016) {
56
+ // SNZB-06P (uiid 7016) also reports a binary bright/dark light level, and
57
+ // SNZB-03PR2 (uiid 7055) a numeric illumination reading
58
+ if ([7016, 7055].includes(accessory.context.eweUIID)) {
58
59
  this.lightService = this.accessory.getService(this.hapServ.LightSensor)
59
60
  || this.accessory.addService(this.hapServ.LightSensor)
60
61
  } else if (this.accessory.getService(this.hapServ.LightSensor)) {
@@ -90,6 +91,18 @@ export default class {
90
91
  }
91
92
  }
92
93
  }
94
+ if (this.lightService && hasProperty(params, 'illumination')) {
95
+ // Some models report a numeric light reading instead of a binary one.
96
+ // HomeKit accepts 0.0001 to 100000 lux, so keep it inside that
97
+ const newLux = Math.max(Math.min(Number(params.illumination), 100000), 0.0001)
98
+ if (Number.isFinite(newLux) && newLux !== this.cacheLux) {
99
+ this.cacheLux = newLux
100
+ this.lightService.updateCharacteristic(this.hapChar.CurrentAmbientLightLevel, this.cacheLux)
101
+ if (params.updateSource && this.enableLogging) {
102
+ this.log('[%s] %s [%s].', this.name, this.lang.curLightLevel, this.cacheLux)
103
+ }
104
+ }
105
+ }
93
106
  if (hasProperty(params, 'battery') && params.battery !== this.cacheBatt) {
94
107
  this.cacheBatt = params.battery
95
108
  this.cacheBattScaled = Math.max(Math.min(this.cacheBatt, 100), 0)
@@ -102,17 +115,17 @@ export default class {
102
115
  this.log('[%s] %s [%s%].', this.name, this.lang.curBatt, this.cacheBattScaled)
103
116
  }
104
117
  }
105
- if (
106
- hasProperty(params, 'human')
107
- && hasProperty(params, 'trigTime')
108
- && params.human !== this.cacheState
109
- ) {
118
+ if (hasProperty(params, 'human') && params.human !== this.cacheState) {
110
119
  this.cacheState = params.human
111
- const timeDiff = (Date.now() - params.trigTime) / 1000
120
+ // A report that carries a timestamp can be checked for staleness; the
121
+ // models that report live state without one are taken at face value
122
+ const isFresh = hasProperty(params, 'trigTime')
123
+ ? (Date.now() - params.trigTime) / 1000 < this.sensorTimeDifference
124
+ : true
112
125
  const occupancyDetected = !!(
113
126
  params.updateSource
114
127
  && params.human === 1
115
- && timeDiff < this.sensorTimeDifference
128
+ && isFresh
116
129
  )
117
130
  this.service.updateCharacteristic(this.hapChar.OccupancyDetected, occupancyDetected)
118
131
  if (occupancyDetected) {
@@ -0,0 +1,132 @@
1
+ import platformConsts from '../../utils/constants.js'
2
+ import { hasProperty } from '../../utils/functions.js'
3
+
4
+ // How long the HomeKit tile stays on after a trigger. The device runs its
5
+ // siren for whatever duration is configured in the eWeLink app and turns
6
+ // itself off without reporting that it has, so the tile resets itself rather
7
+ // than waiting for a state that never arrives.
8
+ const TILE_RESET_MS = 3000
9
+
10
+ export default class {
11
+ constructor(platform, accessory) {
12
+ // Set up variables from the platform
13
+ this.hapChar = platform.api.hap.Characteristic
14
+ this.hapErr = platform.api.hap.HapStatusError
15
+ this.hapServ = platform.api.hap.Service
16
+ this.lang = platform.lang
17
+ this.log = platform.log
18
+ this.platform = platform
19
+
20
+ // Set up variables from the accessory
21
+ this.name = accessory.displayName
22
+ this.accessory = accessory
23
+
24
+ // Set up custom variables for this device type
25
+ const deviceConf = platform.deviceConf[accessory.context.eweDeviceId] || {}
26
+ this.lowBattThreshold = deviceConf.lowBattThreshold
27
+ ? Math.min(deviceConf.lowBattThreshold, 100)
28
+ : platformConsts.defaultValues.lowBattThreshold
29
+
30
+ // Set the correct logging variables for this accessory
31
+ switch (deviceConf.overrideLogging) {
32
+ case 'standard':
33
+ this.enableLogging = true
34
+ this.enableDebugLogging = false
35
+ break
36
+ case 'debug':
37
+ this.enableLogging = true
38
+ this.enableDebugLogging = true
39
+ break
40
+ case 'disable':
41
+ this.enableLogging = false
42
+ this.enableDebugLogging = false
43
+ break
44
+ default:
45
+ this.enableLogging = !platform.config.disableDeviceLogging
46
+ this.enableDebugLogging = false
47
+ break
48
+ }
49
+
50
+ // Add the switch service if it doesn't already exist
51
+ this.service = this.accessory.getService(this.hapServ.Switch)
52
+ || this.accessory.addService(this.hapServ.Switch)
53
+ this.service.updateCharacteristic(this.hapChar.On, false)
54
+
55
+ // Add the battery service if it doesn't already exist
56
+ this.battService = this.accessory.getService(this.hapServ.Battery)
57
+ || this.accessory.addService(this.hapServ.Battery)
58
+
59
+ // Set up the handler for the switch
60
+ this.service.getCharacteristic(this.hapChar.On).onSet(async (value) => {
61
+ try {
62
+ if (!value) {
63
+ // The device stops on its own after its configured duration and
64
+ // offers no early-stop command, so an off is only the tile resetting
65
+ return
66
+ }
67
+
68
+ // The trigger rides on the device's own alarm settings, so send the
69
+ // last reported settings back with the trigger flag added - a bare
70
+ // flag could otherwise reset the volume and duration to defaults
71
+ const setting = { ...(this.accessory.context.cacheAlarmSetting || {}), test: true }
72
+ await this.platform.sendDeviceUpdate(this.accessory, { alarmSetting: setting })
73
+
74
+ if (this.enableLogging) {
75
+ this.log('[%s] %s [on].', this.name, this.lang.curState)
76
+ }
77
+
78
+ // Reset the tile shortly after - see TILE_RESET_MS
79
+ clearTimeout(this.resetTimer)
80
+ this.resetTimer = setTimeout(() => {
81
+ this.service.updateCharacteristic(this.hapChar.On, false)
82
+ }, TILE_RESET_MS)
83
+ } catch (err) {
84
+ this.platform.deviceUpdateError(this.accessory, err, false)
85
+ setTimeout(() => {
86
+ this.service.updateCharacteristic(this.hapChar.On, false)
87
+ }, 2000)
88
+ throw new this.hapErr(-70402)
89
+ }
90
+ })
91
+
92
+ // Output the customised options to the log
93
+ const normalLogging = this.enableLogging ? 'standard' : 'disable'
94
+ const opts = JSON.stringify({
95
+ logging: this.enableDebugLogging ? 'debug' : normalLogging,
96
+ lowBattThreshold: this.lowBattThreshold,
97
+ })
98
+ this.log('[%s] %s %s.', this.name, this.lang.devInitOpts, opts)
99
+ }
100
+
101
+ async externalUpdate(params) {
102
+ try {
103
+ // Keep the device's own alarm settings so a trigger can send them back
104
+ if (hasProperty(params, 'alarmSetting') && typeof params.alarmSetting === 'object') {
105
+ this.accessory.context.cacheAlarmSetting = { ...params.alarmSetting }
106
+ delete this.accessory.context.cacheAlarmSetting.test
107
+ }
108
+ if (hasProperty(params, 'battery') && params.battery !== this.cacheBatt) {
109
+ this.cacheBatt = params.battery
110
+ this.cacheBattScaled = Math.max(Math.min(this.cacheBatt, 100), 0)
111
+ this.battService.updateCharacteristic(this.hapChar.BatteryLevel, this.cacheBattScaled)
112
+ this.battService.updateCharacteristic(
113
+ this.hapChar.StatusLowBattery,
114
+ this.cacheBattScaled < this.lowBattThreshold ? 1 : 0,
115
+ )
116
+ if (params.updateSource && this.enableLogging) {
117
+ this.log('[%s] %s [%s%].', this.name, this.lang.curBatt, this.cacheBattScaled)
118
+ }
119
+ }
120
+ } catch (err) {
121
+ this.platform.deviceUpdateError(this.accessory, err, false)
122
+ }
123
+ }
124
+
125
+ destroy() {
126
+ clearTimeout(this.resetTimer)
127
+ }
128
+
129
+ markStatus(isOnline) {
130
+ this.isOnline = isOnline
131
+ }
132
+ }
@@ -33,6 +33,7 @@ const categoryToArray = {
33
33
  zbSensorContact: 'sensorDevices',
34
34
  zbSensorWater: 'sensorDevices',
35
35
  zbSensorSmoke: 'sensorDevices',
36
+ zbSiren: 'switchDevices',
36
37
  }
37
38
 
38
39
  class PluginUiServer extends HomebridgePluginUiServer {
package/lib/platform.js CHANGED
@@ -1729,6 +1729,16 @@ export default class {
1729
1729
  this.applyAccessoryLogging(accessory)
1730
1730
  accessory.control = new deviceTypes.zb.deviceSensorSmoke(this, accessory)
1731
1731
  /** */
1732
+ } else if (platformConsts.devices.zbSiren.includes(uiid)) {
1733
+ /**
1734
+ **************
1735
+ ZB SIREN
1736
+ ***************
1737
+ */
1738
+ accessory = devicesInHB.get(uuid) || this.addAccessory(device, `${device.deviceid}SWX`)
1739
+ this.applyAccessoryLogging(accessory)
1740
+ accessory.control = new deviceTypes.zb.deviceSiren(this, accessory)
1741
+ /** */
1732
1742
  } else if (platformConsts.devices.zbThermostat.includes(uiid)) {
1733
1743
  /**
1734
1744
  *******************
@@ -377,10 +377,11 @@ export default {
377
377
  zbMotor: [1514, 7006, 7034],
378
378
  zbSensorAmbient: [242, 1770, 1771, 7014, 7033, 7038],
379
379
  zbSensorMotion: [2026, 7002],
380
- zbSensorOccupancy: [7016],
380
+ zbSensorOccupancy: [7016, 7055],
381
381
  zbSensorContact: [3026, 7003],
382
382
  zbSensorWater: [4026, 7019],
383
383
  zbSensorSmoke: [5026],
384
+ zbSiren: [7056],
384
385
  zbThermostat: [7017],
385
386
  zbWaterValve: [7027, 7047],
386
387
  group: [5000],
@@ -604,6 +605,8 @@ export default {
604
605
  7038: 1, // SONOFF AirGuard TH Zigbee temperature and humidity sensor (#767)
605
606
  7040: 2, // SONOFF MINI DUO 2-gang Zigbee switch (#768)
606
607
  7047: 1, // SONOFF Zigbee Irrigation Valve (#778)
608
+ 7055: 1, // SNZB-03PR2 Zigbee presence sensor with light level
609
+ 7056: 1, // SNZB-09P Zigbee indoor siren
607
610
  20001: 1, // iHost sub-device single switch
608
611
  20004: 4, // iHost sub-device 4-channel switch
609
612
  20006: 1, // single-colour dimmable LED controller (#780)
@@ -24,6 +24,30 @@ function parseError(err, hideStack = []) {
24
24
  return toReturn
25
25
  }
26
26
 
27
+ /**
28
+ * The entry for one channel in a device's `switches` array.
29
+ *
30
+ * A full report lists every channel in order, so position used to be enough.
31
+ * But some devices (SmartWise two-channel switches, for one) send a partial
32
+ * update holding only the channel that changed - always at position 0, with
33
+ * its real channel in `outlet`. Reading by position then applied channel 2's
34
+ * change to channel 1 (#804). When the entries carry `outlet`, match on it;
35
+ * only a list without `outlet` numbers falls back to position.
36
+ *
37
+ * @param {Array<{outlet?: number, switch: string}>} switches the reported array
38
+ * @param {number} index the zero-based channel wanted
39
+ * @returns {{outlet?: number, switch: string}|undefined} that channel's entry
40
+ */
41
+ function switchForChannel(switches, index) {
42
+ if (!Array.isArray(switches)) {
43
+ return undefined
44
+ }
45
+ if (switches.some(entry => typeof entry?.outlet === 'number')) {
46
+ return switches.find(entry => entry?.outlet === index)
47
+ }
48
+ return switches[index]
49
+ }
50
+
27
51
  function sleep(ms) {
28
52
  return new Promise((resolve) => {
29
53
  setTimeout(resolve, ms)
@@ -36,4 +60,5 @@ export {
36
60
  parseDeviceId,
37
61
  parseError,
38
62
  sleep,
63
+ switchForChannel,
39
64
  }
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "displayName": "Homebridge eWeLink",
4
4
  "alias": "eWeLink",
5
5
  "type": "module",
6
- "version": "12.18.3",
6
+ "version": "12.18.4-beta.1",
7
7
  "description": "Homebridge plugin to integrate eWeLink devices into HomeKit.",
8
8
  "author": {
9
9
  "name": "bwp91",
@@ -66,8 +66,8 @@
66
66
  "changelog:sync": "node .github/scripts/sync-changelog.mjs"
67
67
  },
68
68
  "dependencies": {
69
- "@homebridge/plugin-ui-utils": "^2.2.5",
70
- "axios": "^1.19.0",
69
+ "@homebridge/plugin-ui-utils": "^2.2.6",
70
+ "axios": "^1.20.0",
71
71
  "node-persist": "^4.0.4",
72
72
  "p-queue": "^9.3.3",
73
73
  "websocket-as-promised": "^3.0.1",
@@ -75,7 +75,7 @@
75
75
  },
76
76
  "devDependencies": {
77
77
  "@antfu/eslint-config": "^9.3.0",
78
- "@vitest/coverage-v8": "^4.1.10",
79
- "vitest": "^4.1.10"
78
+ "@vitest/coverage-v8": "^4.1.11",
79
+ "vitest": "^4.1.11"
80
80
  }
81
81
  }