@homebridge-plugins/homebridge-wemo 7.0.0

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.
Files changed (41) hide show
  1. package/CHANGELOG.md +809 -0
  2. package/LICENSE +21 -0
  3. package/README.md +66 -0
  4. package/config.schema.json +814 -0
  5. package/eslint.config.js +50 -0
  6. package/lib/connection/http.js +174 -0
  7. package/lib/connection/upnp.js +155 -0
  8. package/lib/device/coffee.js +167 -0
  9. package/lib/device/crockpot.js +380 -0
  10. package/lib/device/dimmer.js +280 -0
  11. package/lib/device/heater.js +416 -0
  12. package/lib/device/humidifier.js +379 -0
  13. package/lib/device/index.js +39 -0
  14. package/lib/device/insight.js +353 -0
  15. package/lib/device/lightswitch.js +154 -0
  16. package/lib/device/link-bulb.js +467 -0
  17. package/lib/device/link-hub.js +63 -0
  18. package/lib/device/maker-garage.js +426 -0
  19. package/lib/device/maker-switch.js +202 -0
  20. package/lib/device/motion.js +148 -0
  21. package/lib/device/outlet.js +148 -0
  22. package/lib/device/purifier.js +468 -0
  23. package/lib/device/simulation/purifier-insight.js +357 -0
  24. package/lib/device/simulation/purifier.js +159 -0
  25. package/lib/device/simulation/switch-insight.js +315 -0
  26. package/lib/device/simulation/switch.js +154 -0
  27. package/lib/fakegato/LICENSE +21 -0
  28. package/lib/fakegato/fakegato-history.js +814 -0
  29. package/lib/fakegato/fakegato-storage.js +108 -0
  30. package/lib/fakegato/fakegato-timer.js +125 -0
  31. package/lib/fakegato/uuid.js +27 -0
  32. package/lib/homebridge-ui/public/index.html +315 -0
  33. package/lib/homebridge-ui/server.js +10 -0
  34. package/lib/index.js +8 -0
  35. package/lib/platform.js +1423 -0
  36. package/lib/utils/colour.js +135 -0
  37. package/lib/utils/constants.js +129 -0
  38. package/lib/utils/eve-chars.js +130 -0
  39. package/lib/utils/functions.js +52 -0
  40. package/lib/utils/lang-en.js +125 -0
  41. package/package.json +70 -0
@@ -0,0 +1,50 @@
1
+ import { antfu } from '@antfu/eslint-config'
2
+
3
+ /** @type {typeof antfu} */
4
+ export default antfu(
5
+ {
6
+ ignores: [],
7
+ jsx: false,
8
+ rules: {
9
+ 'curly': ['error', 'multi-line'],
10
+ 'new-cap': 'off',
11
+ 'import/extensions': ['error', 'ignorePackages'],
12
+ 'import/order': 0,
13
+ 'jsdoc/check-alignment': 'warn',
14
+ 'jsdoc/check-line-alignment': 'warn',
15
+ 'jsdoc/require-returns-check': 0,
16
+ 'jsdoc/require-returns-description': 0,
17
+ 'no-undef': 'error',
18
+ 'perfectionist/sort-exports': 'error',
19
+ 'perfectionist/sort-imports': [
20
+ 'error',
21
+ {
22
+ groups: [
23
+ 'type',
24
+ 'internal-type',
25
+ 'builtin',
26
+ 'external',
27
+ 'internal',
28
+ ['parent-type', 'sibling-type', 'index-type'],
29
+ ['parent', 'sibling', 'index'],
30
+ 'object',
31
+ 'unknown',
32
+ ],
33
+ order: 'asc',
34
+ type: 'natural',
35
+ newlinesBetween: 'always',
36
+ },
37
+ ],
38
+ 'perfectionist/sort-named-exports': 'error',
39
+ 'perfectionist/sort-named-imports': 'error',
40
+ 'quotes': ['error', 'single'],
41
+ 'sort-imports': 0,
42
+ 'style/brace-style': ['error', '1tbs', { allowSingleLine: true }],
43
+ 'style/quote-props': ['error', 'consistent-as-needed'],
44
+ 'test/no-only-tests': 'error',
45
+ 'unicorn/no-useless-spread': 'error',
46
+ 'unused-imports/no-unused-vars': ['error', { caughtErrors: 'none' }],
47
+ },
48
+ typescript: false,
49
+ },
50
+ )
@@ -0,0 +1,174 @@
1
+ import axios from 'axios'
2
+ import PQueue from 'p-queue'
3
+ import { parseStringPromise } from 'xml2js'
4
+ import xmlbuilder from 'xmlbuilder'
5
+
6
+ import { decodeXML, hasProperty, parseError } from '../utils/functions.js'
7
+ import platformLang from '../utils/lang-en.js'
8
+
9
+ export default class {
10
+ constructor(platform) {
11
+ // Set up global vars from the platform
12
+ this.platform = platform
13
+ this.queue = new PQueue({
14
+ concurrency: 1,
15
+ interval: 250,
16
+ intervalCap: 1,
17
+ timeout: 9000,
18
+ throwOnTimeout: true,
19
+ })
20
+ }
21
+
22
+ async sendDeviceUpdate(accessory, serviceType, action, body) {
23
+ try {
24
+ return await this.queue.add(async () => {
25
+ // Check the device has this service (it should have)
26
+ if (
27
+ !accessory.context.serviceList[serviceType]
28
+ || !accessory.context.serviceList[serviceType].controlURL
29
+ ) {
30
+ throw new Error(platformLang.noService)
31
+ }
32
+
33
+ // Generate the XML to send to the device
34
+ const xml = xmlbuilder
35
+ .create('s:Envelope', {
36
+ version: '1.0',
37
+ encoding: 'utf-8',
38
+ allowEmpty: true,
39
+ })
40
+ .att('xmlns:s', 'http://schemas.xmlsoap.org/soap/envelope/')
41
+ .att('s:encodingStyle', 'http://schemas.xmlsoap.org/soap/encoding/')
42
+ .ele('s:Body')
43
+ .ele(`u:${action}`)
44
+ .att('xmlns:u', serviceType)
45
+
46
+ // Send the request to the device
47
+ const hostPort = `http://${accessory.context.ipAddress}:${accessory.context.port}`
48
+ const res = await axios({
49
+ url: hostPort + accessory.context.serviceList[serviceType].controlURL,
50
+ method: 'post',
51
+ headers: {
52
+ 'SOAPACTION': `"${serviceType}#${action}"`,
53
+ 'Content-Type': 'text/xml; charset="utf-8"',
54
+ },
55
+ data: (body ? xml.ele(body) : xml).end(),
56
+ timeout: 10000,
57
+ })
58
+
59
+ // Parse the response from the device
60
+ const xmlRes = res.data
61
+ const response = await parseStringPromise(xmlRes, {
62
+ explicitArray: false,
63
+ })
64
+
65
+ if (!accessory.context.httpOnline) {
66
+ this.platform.updateHTTPStatus(accessory, true)
67
+ }
68
+
69
+ // Return the parsed response
70
+ return response['s:Envelope']['s:Body'][`u:${action}Response`]
71
+ })
72
+ } catch (err) {
73
+ const eText = parseError(err)
74
+ if (['at Object.<anonymous>', 'EHOSTUNREACH'].some(el => eText.includes(el))) {
75
+ // Device disconnected from network
76
+ if (accessory.context.httpOnline) {
77
+ this.platform.updateHTTPStatus(accessory, false)
78
+ }
79
+ throw new Error(
80
+ eText.includes('EHOSTUNREACH') ? platformLang.timeoutUnreach : platformLang.timeout,
81
+ )
82
+ }
83
+ throw err
84
+ }
85
+ }
86
+
87
+ async receiveDeviceUpdate(accessory, body) {
88
+ try {
89
+ accessory.logDebug(`${platformLang.incKnown}:\n${body.trim()}`)
90
+
91
+ // Convert the XML to JSON
92
+ const json = await parseStringPromise(body, { explicitArray: false })
93
+
94
+ // Loop through the JSON for the necessary information
95
+
96
+ for (const prop in json['e:propertyset']['e:property']) {
97
+ if (hasProperty(json['e:propertyset']['e:property'], prop)) {
98
+ const data = json['e:propertyset']['e:property'][prop]
99
+ switch (prop) {
100
+ case 'BinaryState':
101
+ try {
102
+ accessory.control.receiveDeviceUpdate({
103
+ name: 'BinaryState',
104
+ value: Number.parseInt(data.substring(0, 1), 10),
105
+ })
106
+ } catch (err) {
107
+ accessory.logWarn(`${prop} ${platformLang.proEr} ${parseError(err)}`)
108
+ }
109
+ break
110
+ case 'Brightness':
111
+ try {
112
+ accessory.control.receiveDeviceUpdate({
113
+ name: 'Brightness',
114
+ value: Number.parseInt(data, 10),
115
+ })
116
+ } catch (err) {
117
+ accessory.logWarn(`${prop} ${platformLang.proEr} ${parseError(err)}`)
118
+ }
119
+ break
120
+ case 'InsightParams': {
121
+ try {
122
+ const params = data.split('|')
123
+ accessory.control.receiveDeviceUpdate({
124
+ name: 'InsightParams',
125
+ value: {
126
+ state: Number.parseInt(params[0], 10),
127
+ power: Number.parseInt(params[7], 10),
128
+ todayWm: Number.parseFloat(params[8]),
129
+ todayOnSeconds: Number.parseFloat(params[3]),
130
+ },
131
+ })
132
+ } catch (err) {
133
+ accessory.logWarn(`${prop} ${platformLang.proEr} ${parseError(err)}`)
134
+ }
135
+ break
136
+ }
137
+ case 'attributeList':
138
+ try {
139
+ const decoded = decodeXML(data)
140
+ const xml = `<attributeList>${decoded}</attributeList>`
141
+
142
+ const result = await parseStringPromise(xml, { explicitArray: true })
143
+ result.attributeList.attribute.forEach((attribute) => {
144
+ accessory.control.receiveDeviceUpdate({
145
+ name: attribute.name[0],
146
+ value: Number.parseInt(attribute.value[0], 10),
147
+ })
148
+ })
149
+ } catch (err) {
150
+ accessory.logWarn(`${prop} ${platformLang.proEr} ${parseError(err)}`)
151
+ }
152
+ break
153
+ case 'StatusChange':
154
+ try {
155
+ const xml = await parseStringPromise(data, { explicitArray: false })
156
+ accessory.control.receiveDeviceUpdate(xml.StateEvent.DeviceID._, {
157
+ name: xml.StateEvent.CapabilityId,
158
+ value: xml.StateEvent.Value,
159
+ })
160
+ } catch (err) {
161
+ accessory.logWarn(`${prop} ${platformLang.proEr} ${parseError(err)}`)
162
+ }
163
+ break
164
+ default:
165
+ return
166
+ }
167
+ }
168
+ }
169
+ } catch (err) {
170
+ // Catch any errors during this process
171
+ accessory.logWarn(`${platformLang.incFail} ${parseError(err)}`)
172
+ }
173
+ }
174
+ }
@@ -0,0 +1,155 @@
1
+ import { request } from 'node:http'
2
+
3
+ import platformConsts from '../utils/constants.js'
4
+ import { parseError } from '../utils/functions.js'
5
+ import platformLang from '../utils/lang-en.js'
6
+
7
+ export default class {
8
+ constructor(platform, accessory) {
9
+ // Set up global vars from the platform
10
+ this.platform = platform
11
+ this.upnpInterval = platform.config.upnpInterval
12
+ this.upnpIntervalMilli = this.upnpInterval * 1000
13
+
14
+ // Set up other variables we need
15
+ this.accessory = accessory
16
+ this.services = accessory.context.serviceList
17
+ this.subs = {}
18
+ }
19
+
20
+ startSubscriptions() {
21
+ // Subscribe to each of the services that the device supports, that the plugin uses
22
+ Object.keys(this.services)
23
+ .filter(el => platformConsts.servicesToSubscribe.includes(el))
24
+ .forEach((service) => {
25
+ // Subscript to the service
26
+ this.subs[service] = {}
27
+ this.subscribe(service)
28
+ })
29
+ }
30
+
31
+ stopSubscriptions() {
32
+ Object.entries(this.subs).forEach((entry) => {
33
+ const [serviceType, sub] = entry
34
+ if (sub.timeout) {
35
+ clearTimeout(sub.timeout)
36
+ }
37
+ if (sub.status) {
38
+ this.unsubscribe(serviceType)
39
+ }
40
+ })
41
+ this.accessory.logDebugWarn(platformLang.stoppedSubs)
42
+ }
43
+
44
+ subscribe(serviceType) {
45
+ try {
46
+ // Check to see an already sent request is still pending
47
+ if (this.subs[serviceType].status === 'PENDING') {
48
+ this.accessory.logDebug(`[${serviceType}] ${platformLang.subPending}`)
49
+ return
50
+ }
51
+
52
+ // Set up the options for the subscription request
53
+ const timeout = this.upnpInterval + 10
54
+ const options = {
55
+ host: this.accessory.context.ipAddress,
56
+ port: this.accessory.context.port,
57
+ path: this.services[serviceType].eventSubURL,
58
+ method: 'SUBSCRIBE',
59
+ headers: { TIMEOUT: `Second-${timeout}` },
60
+ }
61
+
62
+ // The remaining options depend on whether the subscription already exists
63
+ if (this.subs[serviceType].status) {
64
+ // Subscription already exists so renew
65
+ options.headers.SID = this.subs[serviceType].status
66
+ } else {
67
+ // Subscription doesn't exist yet to set up for new subscription
68
+ this.subs[serviceType].status = 'PENDING'
69
+ this.accessory.logDebug(`[${serviceType}] ${platformLang.subInit}`)
70
+ options.headers.CALLBACK = `<http://${this.accessory.context.cbURL}/${this.accessory.UUID}>`
71
+ options.headers.NT = 'upnp:event'
72
+ }
73
+
74
+ // Execute the subscription request
75
+ const req = request(options, (res) => {
76
+ if (res.statusCode === 200) {
77
+ // Subscription request successful
78
+ this.subs[serviceType].status = res.headers.sid
79
+
80
+ // Renew subscription after 150 seconds
81
+ this.subs[serviceType].timeout = setTimeout(
82
+ () => this.subscribe(serviceType),
83
+ this.upnpIntervalMilli,
84
+ )
85
+ } else {
86
+ // Subscription request failure
87
+ this.accessory.logDebugWarn(`[${serviceType}] ${platformLang.subError} [${res.statusCode}]`)
88
+ this.subs[serviceType].status = null
89
+
90
+ // Try to recover from a failed subscription after 10 seconds
91
+ this.subs[serviceType].timeout = setTimeout(() => this.subscribe(serviceType), 10000)
92
+ }
93
+ })
94
+
95
+ // Listen for errors on the subscription
96
+ req.removeAllListeners('error')
97
+ req.on('error', (err) => {
98
+ if (!err) {
99
+ return
100
+ }
101
+
102
+ // Stop the subscriptions
103
+ this.stopSubscriptions()
104
+
105
+ // Use the platform function to disable the upnp client for this accessory
106
+ this.platform.disableUPNP(this.accessory, err)
107
+ })
108
+ req.end()
109
+ } catch (err) {
110
+ // Catch any errors during the process
111
+ this.accessory.logDebugWarn(`[${serviceType}] ${platformLang.subscribeError} ${parseError(err)}`)
112
+ }
113
+ }
114
+
115
+ unsubscribe(serviceType) {
116
+ try {
117
+ // Check to see an already sent request is still pending
118
+ if (!this.subs[serviceType] || this.subs[serviceType].status === 'PENDING') {
119
+ return
120
+ }
121
+
122
+ // Set up the options for the subscription request
123
+ const options = {
124
+ host: this.accessory.context.ipAddress,
125
+ port: this.accessory.context.port,
126
+ path: this.services[serviceType].eventSubURL,
127
+ method: 'UNSUBSCRIBE',
128
+ headers: { SID: this.subs[serviceType].status },
129
+ }
130
+
131
+ // Execute the subscription request
132
+ const req = request(options, (res) => {
133
+ if (res.statusCode === 200) {
134
+ // Unsubscribed
135
+ this.accessory.logDebug(`[${serviceType}] unsubscribe successful`)
136
+ } else {
137
+ // Subscription request failure
138
+ this.accessory.logDebugWarn(`[${serviceType}] ${platformLang.unsubFail} [${res.statusCode}]`)
139
+ }
140
+ })
141
+
142
+ // Listen for errors on the subscription
143
+ req.removeAllListeners('error')
144
+ req.on('error', (err) => {
145
+ if (!err) {
146
+ return
147
+ }
148
+ this.accessory.logDebugWarn(`[${serviceType}] ${platformLang.unsubFail} [${err.message}]`)
149
+ })
150
+ req.end()
151
+ } catch (err) {
152
+ this.accessory.logDebugWarn(`[${serviceType}] ${platformLang.unsubFail} [${parseError(err)}]`)
153
+ }
154
+ }
155
+ }
@@ -0,0 +1,167 @@
1
+ import { Builder, parseStringPromise } from 'xml2js'
2
+
3
+ import { decodeXML, parseError } from '../utils/functions.js'
4
+ import platformLang from '../utils/lang-en.js'
5
+
6
+ export default class {
7
+ constructor(platform, accessory) {
8
+ // Set up variables from the platform
9
+ this.hapChar = platform.api.hap.Characteristic
10
+ this.hapErr = platform.api.hap.HapStatusError
11
+ this.hapServ = platform.api.hap.Service
12
+ this.platform = platform
13
+
14
+ // Set up variables from the accessory
15
+ this.accessory = accessory
16
+
17
+ // Add the switch service if it doesn't already exist
18
+ this.service = this.accessory.getService(this.hapServ.Switch)
19
+ || this.accessory.addService(this.hapServ.Switch)
20
+
21
+ // Add the set handler to the outlet on/off characteristic
22
+ this.service
23
+ .getCharacteristic(this.hapChar.On)
24
+ .removeOnSet()
25
+ .onSet(async value => this.internalModeUpdate(value))
26
+
27
+ // Output the customised options to the log
28
+ const opts = JSON.stringify({
29
+ })
30
+ platform.log('[%s] %s %s.', accessory.displayName, platformLang.devInitOpts, opts)
31
+
32
+ // Request a device update immediately
33
+ this.requestDeviceUpdate()
34
+
35
+ // Start a polling interval if the user has disabled upnp
36
+ if (this.accessory.context.connection === 'http') {
37
+ this.pollingInterval = setInterval(
38
+ () => this.requestDeviceUpdate(),
39
+ platform.config.pollingInterval * 1000,
40
+ )
41
+ }
42
+ }
43
+
44
+ receiveDeviceUpdate(attribute) {
45
+ // Log the receiving update if debug is enabled
46
+ this.accessory.logDebug(`${platformLang.recUpd} [${attribute.name}: ${JSON.stringify(attribute.value)}]`)
47
+
48
+ // Check which attribute we are getting
49
+ switch (attribute.name) {
50
+ case 'Mode':
51
+ this.externalModeUpdate(attribute.value)
52
+ break
53
+ default:
54
+ }
55
+ }
56
+
57
+ async sendDeviceUpdate(attributes) {
58
+ // Log the sending update if debug is enabled
59
+ this.accessory.log(`${platformLang.senUpd} ${JSON.stringify(attributes)}`)
60
+
61
+ // Generate the XML to send
62
+ const builder = new Builder({
63
+ rootName: 'attribute',
64
+ headless: true,
65
+ renderOpts: { pretty: false },
66
+ })
67
+ const xmlAttributes = Object.keys(attributes)
68
+ .map(attributeKey => builder.buildObject({
69
+ name: attributeKey,
70
+ value: attributes[attributeKey],
71
+ }))
72
+ .join('')
73
+
74
+ // Send the update
75
+ await this.platform.httpClient.sendDeviceUpdate(
76
+ this.accessory,
77
+ 'urn:Belkin:service:deviceevent:1',
78
+ 'SetAttributes',
79
+ {
80
+ attributeList: { '#text': xmlAttributes },
81
+ },
82
+ )
83
+ }
84
+
85
+ async requestDeviceUpdate() {
86
+ try {
87
+ // Request the update
88
+ const data = await this.platform.httpClient.sendDeviceUpdate(
89
+ this.accessory,
90
+ 'urn:Belkin:service:deviceevent:1',
91
+ 'GetAttributes',
92
+ )
93
+
94
+ // Parse the response
95
+ const decoded = decodeXML(data.attributeList)
96
+ const xml = `<attributeList>${decoded}</attributeList>`
97
+ const result = await parseStringPromise(xml, { explicitArray: false })
98
+ Object.keys(result.attributeList.attribute).forEach((key) => {
99
+ // Only send the required attributes to the receiveDeviceUpdate function
100
+ switch (result.attributeList.attribute[key].name) {
101
+ case 'Mode':
102
+ this.receiveDeviceUpdate({
103
+ name: result.attributeList.attribute[key].name,
104
+ value: Number.parseInt(result.attributeList.attribute[key].value, 10),
105
+ })
106
+ break
107
+ default:
108
+ }
109
+ })
110
+ } catch (err) {
111
+ const eText = parseError(err, [
112
+ platformLang.timeout,
113
+ platformLang.timeoutUnreach,
114
+ platformLang.noService,
115
+ ])
116
+ this.accessory.logDebugWarn(`${platformLang.rduErr} ${eText}`)
117
+ }
118
+ }
119
+
120
+ async internalModeUpdate(value) {
121
+ try {
122
+ // Coffee maker cannot be turned off remotely
123
+ if (!value) {
124
+ throw new Error('coffee maker cannot be turned off remotely')
125
+ }
126
+
127
+ // Send the update to turn ON
128
+ await this.sendDeviceUpdate({ Mode: 4 })
129
+
130
+ // Update the cache value and log the change if appropriate
131
+ this.cacheState = true
132
+ this.accessory.log(`${platformLang.curState} [on]`)
133
+ } catch (err) {
134
+ // Catch any errors
135
+ const eText = parseError(err, [platformLang.timeout, platformLang.timeoutUnreach])
136
+ this.accessory.logWarn(`${platformLang.cantCtl} ${eText}`)
137
+
138
+ // Throw a 'no response' error and set a timeout to revert this after 2 seconds
139
+ setTimeout(() => {
140
+ this.service.updateCharacteristic(this.hapChar.On, this.cacheState)
141
+ }, 2000)
142
+ throw new this.hapErr(-70402)
143
+ }
144
+ }
145
+
146
+ externalModeUpdate(value) {
147
+ try {
148
+ // Value of 4 means brewing (ON) otherwise (OFF)
149
+ value = value === 4
150
+
151
+ // Check to see if the cache value is different
152
+ if (value === this.cacheState) {
153
+ return
154
+ }
155
+
156
+ // Update the HomeKit characteristics
157
+ this.service.updateCharacteristic(this.hapChar.On, value)
158
+
159
+ // Update the cache value and log the change if appropriate
160
+ this.cacheState = value
161
+ this.accessory.log(`${platformLang.curState} [${value ? 'on' : 'off'}]`)
162
+ } catch (err) {
163
+ // Catch any errors
164
+ this.accessory.logWarn(`${platformLang.cantUpd} ${parseError(err)}`)
165
+ }
166
+ }
167
+ }