homebridge-yoto 0.0.28 → 0.0.31

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.
@@ -0,0 +1,49 @@
1
+ /**
2
+ * @fileoverview Utility functions for the plugin
3
+ *
4
+ * Includes code adapted from `homebridge-plugin-utils`:
5
+ * - Source: https://github.com/hjdhjd/homebridge-plugin-utils/blob/main/src/util.ts
6
+ *
7
+ * ISC License
8
+ * ===========
9
+ *
10
+ * Copyright (c) 2017-2025, HJD https://github.com/hjdhjd
11
+ *
12
+ * Permission to use, copy, modify, and/or distribute this software for any purpose
13
+ * with or without fee is hereby granted, provided that the above copyright notice
14
+ * and this permission notice appear in all copies.
15
+ *
16
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
17
+ * REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
18
+ * FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
19
+ * INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
20
+ * OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
21
+ * TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
22
+ * THIS SOFTWARE.
23
+ */
24
+
25
+ /**
26
+ * Sanitize an accessory/service name according to HomeKit naming conventions.
27
+ *
28
+ * Starts and ends with a letter or number. Exception: may end with a period.
29
+ * May have the following special characters: -"',.#&.
30
+ * Must not include emojis.
31
+ *
32
+ * @param {string} name - The name to sanitize
33
+ * @returns {string} The HomeKit-sanitized version of the name
34
+ */
35
+ export function sanitizeName (name) {
36
+ return name
37
+ // Replace any disallowed char (including emojis) with a space.
38
+ .replace(/[^\p{L}\p{N}\-"'.,#&\s]/gu, ' ')
39
+ // Collapse multiple spaces to one.
40
+ .replace(/\s+/g, ' ')
41
+ // Trim spaces at the beginning and end of the string.
42
+ .trim()
43
+ // Strip any leading non-letter/number.
44
+ .replace(/^[^\p{L}\p{N}]+/u, '')
45
+ // Collapse two or more trailing periods into one.
46
+ .replace(/\.{2,}$/g, '.')
47
+ // Remove any other trailing char that's not letter/number/period.
48
+ .replace(/[^\p{L}\p{N}.]$/u, '')
49
+ }
@@ -0,0 +1,16 @@
1
+ import { configSchema } from '../config.schema.cjs'
2
+
3
+ /**
4
+ * This is the name of the platform that users will use to register the plugin in the Homebridge config.json
5
+ */
6
+ export const PLATFORM_NAME = 'Yoto'
7
+
8
+ /**
9
+ * This must match the name of your plugin as defined the package.json `name` property
10
+ */
11
+ export const PLUGIN_NAME = 'homebridge-yoto'
12
+
13
+ /**
14
+ * Default OAuth Client ID from config schema
15
+ */
16
+ export const DEFAULT_CLIENT_ID = configSchema.schema.properties.clientId.default
@@ -0,0 +1,34 @@
1
+ /** @import { Service, Characteristic } from 'homebridge' */
2
+ import { sanitizeName } from './sanitize-name.js'
3
+
4
+ /**
5
+ * Apply HomeKit-visible naming to a service.
6
+ *
7
+ * We set both `Name` and `ConfiguredName` on every service we manage so HomeKit tiles are consistently labeled.
8
+ *
9
+ * @param {object} params
10
+ * @param {Service} params.service
11
+ * @param {string} params.name
12
+ * @param {typeof Characteristic} params.Characteristic
13
+ * @returns {void}
14
+ */
15
+ export function syncServiceNames ({
16
+ Characteristic,
17
+ service,
18
+ name
19
+ }) {
20
+ const sanitizedName = sanitizeName(name)
21
+ service.displayName = sanitizedName
22
+
23
+ service.updateCharacteristic(Characteristic.Name, sanitizedName)
24
+
25
+ // Set ConfiguredName on all services, not just ones that say they support it.
26
+ // This is the only way to set the service name inside an accessory.
27
+ // const hasConfiguredNameCharacteristic = service.characteristics.some(c => c.UUID === Characteristic.ConfiguredName.UUID)
28
+ // const hasConfiguredNameOptional = service.optionalCharacteristics.some(c => c.UUID === Characteristic.ConfiguredName.UUID)
29
+ // if (!hasConfiguredNameCharacteristic && !hasConfiguredNameOptional) {
30
+ // service.addOptionalCharacteristic(Characteristic.ConfiguredName)
31
+ // }
32
+
33
+ service.updateCharacteristic(Characteristic.ConfiguredName, sanitizedName)
34
+ }
package/logo.png ADDED
Binary file
package/package.json CHANGED
@@ -1,28 +1,30 @@
1
1
  {
2
2
  "name": "homebridge-yoto",
3
3
  "description": "Control your Yoto players through Apple HomeKit with real-time MQTT updates",
4
- "version": "0.0.28",
4
+ "version": "0.0.31",
5
5
  "author": "Bret Comnes <bcomnes@gmail.com> (https://bret.io)",
6
6
  "bugs": {
7
7
  "url": "https://github.com/bcomnes/homebridge-yoto/issues"
8
8
  },
9
9
  "dependencies": {
10
- "homebridge-lib": "^7.2.0",
11
- "mqtt": "^5.14.1"
10
+ "@homebridge/plugin-ui-utils": "^2.1.2",
11
+ "color-convert": "3.1.3",
12
+ "yoto-nodejs-client": "^0.0.7"
12
13
  },
13
14
  "devDependencies": {
14
15
  "@types/node": "^25.0.0",
15
16
  "@voxpelli/tsconfig": "^16.1.0",
16
17
  "auto-changelog": "^2.0.0",
17
18
  "c8": "^10.0.0",
19
+ "eslint": "^9.39.2",
18
20
  "gh-release": "^7.0.0",
19
- "homebridge": "^1.6.1",
21
+ "homebridge": "^2.0.0-beta.66",
20
22
  "neostandard": "^0.12.0",
21
23
  "npm-run-all2": "^8.0.1",
22
24
  "typescript": "~5.9.3"
23
25
  },
24
26
  "engines": {
25
- "node": ">=20",
27
+ "node": ">=22",
26
28
  "npm": ">=10",
27
29
  "homebridge": "^1.8.0 || ^2.0.0-beta.0"
28
30
  },
@@ -40,22 +42,6 @@
40
42
  "type": "git",
41
43
  "url": "https://github.com/bcomnes/homebridge-yoto.git"
42
44
  },
43
- "scripts": {
44
- "prepublishOnly": "npm run build && git push --follow-tags && gh-release -y",
45
- "postpublish": "npm run clean",
46
- "test": "run-s test:*",
47
- "test:lint": "eslint",
48
- "test:tsc": "tsc",
49
- "test:node-test": "c8 node --test --test-reporter spec",
50
- "version": "run-s version:*",
51
- "version:changelog": "auto-changelog -p --template keepachangelog auto-changelog --breaking-pattern 'BREAKING CHANGE:'",
52
- "version:git": "git add CHANGELOG.md",
53
- "build": "npm run clean && run-p build:*",
54
- "build:declaration": "tsc -p declaration.tsconfig.json",
55
- "clean": "run-p clean:*",
56
- "clean:declarations-top": "rm -rf $(find . -maxdepth 1 -type f -name '*.d.ts*')",
57
- "clean:declarations-lib": "rm -rf $(find lib -type f -name '*.d.ts*' ! -name '*-types.d.ts')"
58
- },
59
45
  "funding": {
60
46
  "type": "individual",
61
47
  "url": "https://github.com/sponsors/bcomnes"
@@ -65,5 +51,14 @@
65
51
  "lcov",
66
52
  "text"
67
53
  ]
54
+ },
55
+ "scripts": {
56
+ "test": "run-s test:*",
57
+ "test:lint": "eslint",
58
+ "test:tsc": "tsc",
59
+ "test:node-test": "c8 node --test --test-reporter spec",
60
+ "version": "run-s version:*",
61
+ "version:changelog": "auto-changelog -p --template keepachangelog auto-changelog --breaking-pattern 'BREAKING CHANGE:'",
62
+ "version:git": "git add CHANGELOG.md"
68
63
  }
69
- }
64
+ }
@@ -0,0 +1,4 @@
1
+ strictDepBuilds: true
2
+
3
+ ignoredBuiltDependencies:
4
+ - unrs-resolver
@@ -1,15 +0,0 @@
1
- {
2
- "extends": "./tsconfig.json",
3
- "compilerOptions": {
4
- "declaration": true,
5
- "declarationMap": true,
6
- "noEmit": false,
7
- "emitDeclarationOnly": true
8
- },
9
- "exclude": [
10
- "**/*.test.js",
11
- "node_modules",
12
- "coverage",
13
- ".github"
14
- ]
15
- }
package/lib/auth.js DELETED
@@ -1,237 +0,0 @@
1
- /**
2
- * @fileoverview OAuth2 Device Authorization Flow implementation for Yoto API
3
- */
4
-
5
- /** @import { Logger } from 'homebridge' */
6
- /** @import { YotoApiTokenResponse, YotoApiDeviceCodeResponse } from './types.js' */
7
-
8
- import {
9
- YOTO_OAUTH_DEVICE_CODE_URL,
10
- YOTO_OAUTH_TOKEN_URL,
11
- OAUTH_CLIENT_ID,
12
- OAUTH_AUDIENCE,
13
- OAUTH_SCOPE,
14
- OAUTH_POLLING_INTERVAL,
15
- OAUTH_DEVICE_CODE_TIMEOUT,
16
- ERROR_MESSAGES,
17
- LOG_PREFIX
18
- } from './constants.js'
19
-
20
- /**
21
- * OAuth2 authentication handler for Yoto API
22
- */
23
- export class YotoAuth {
24
- /**
25
- * @param {Logger} log - Homebridge logger
26
- * @param {string} [clientId] - OAuth client ID (uses default if not provided)
27
- */
28
- constructor (log, clientId) {
29
- this.log = log
30
- this.clientId = clientId || OAUTH_CLIENT_ID
31
- }
32
-
33
- /**
34
- * Initiate device authorization flow
35
- * @returns {Promise<YotoApiDeviceCodeResponse>}
36
- */
37
- async initiateDeviceFlow () {
38
- this.log.debug(LOG_PREFIX.AUTH, 'Initiating device authorization flow...')
39
- this.log.debug(LOG_PREFIX.AUTH, `Client ID: ${this.clientId}`)
40
- this.log.debug(LOG_PREFIX.AUTH, `Endpoint: ${YOTO_OAUTH_DEVICE_CODE_URL}`)
41
- this.log.debug(LOG_PREFIX.AUTH, `Scope: ${OAUTH_SCOPE}`)
42
- this.log.debug(LOG_PREFIX.AUTH, `Audience: ${OAUTH_AUDIENCE}`)
43
-
44
- try {
45
- const params = new URLSearchParams({
46
- client_id: this.clientId,
47
- scope: OAUTH_SCOPE,
48
- audience: OAUTH_AUDIENCE
49
- })
50
- this.log.debug(LOG_PREFIX.AUTH, 'Request body:', params.toString())
51
-
52
- const response = await fetch(YOTO_OAUTH_DEVICE_CODE_URL, {
53
- method: 'POST',
54
- headers: {
55
- 'Content-Type': 'application/x-www-form-urlencoded'
56
- },
57
- body: params
58
- })
59
-
60
- this.log.debug(LOG_PREFIX.AUTH, `Response status: ${response.status}`)
61
- this.log.debug(LOG_PREFIX.AUTH, 'Response headers:', Object.fromEntries(response.headers.entries()))
62
-
63
- if (!response.ok) {
64
- const errorText = await response.text()
65
- this.log.error(LOG_PREFIX.AUTH, `Device code request failed with status ${response.status}`)
66
- this.log.error(LOG_PREFIX.AUTH, `Error response: ${errorText}`)
67
- throw new Error(`Device code request failed: ${response.status} ${errorText}`)
68
- }
69
-
70
- const data = /** @type {YotoApiDeviceCodeResponse} */ (await response.json())
71
-
72
- this.log.info(LOG_PREFIX.AUTH, '='.repeat(60))
73
- this.log.info(LOG_PREFIX.AUTH, 'YOTO AUTHENTICATION REQUIRED')
74
- this.log.info(LOG_PREFIX.AUTH, '='.repeat(60))
75
- this.log.info(LOG_PREFIX.AUTH, '')
76
- this.log.info(LOG_PREFIX.AUTH, `1. Visit: ${data.verification_uri}`)
77
- this.log.info(LOG_PREFIX.AUTH, `2. Enter code: ${data.user_code}`)
78
- this.log.info(LOG_PREFIX.AUTH, '')
79
- this.log.info(LOG_PREFIX.AUTH, `Or visit: ${data.verification_uri_complete}`)
80
- this.log.info(LOG_PREFIX.AUTH, '')
81
- this.log.info(LOG_PREFIX.AUTH, `Code expires in ${Math.floor(data.expires_in / 60)} minutes`)
82
- this.log.info(LOG_PREFIX.AUTH, '='.repeat(60))
83
-
84
- return data
85
- } catch (error) {
86
- this.log.error(LOG_PREFIX.AUTH, 'Failed to initiate device flow:', error)
87
- throw error
88
- }
89
- }
90
-
91
- /**
92
- * Poll for authorization completion
93
- * @param {string} deviceCode - Device code from initiation
94
- * @returns {Promise<YotoApiTokenResponse>}
95
- */
96
- async pollForAuthorization (deviceCode) {
97
- const startTime = Date.now()
98
- const timeout = OAUTH_DEVICE_CODE_TIMEOUT
99
-
100
- this.log.debug(LOG_PREFIX.AUTH, 'Waiting for user authorization...')
101
-
102
- while (Date.now() - startTime < timeout) {
103
- try {
104
- const params = new URLSearchParams({
105
- grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
106
- device_code: deviceCode,
107
- client_id: this.clientId,
108
- audience: OAUTH_AUDIENCE
109
- })
110
-
111
- const response = await fetch(YOTO_OAUTH_TOKEN_URL, {
112
- method: 'POST',
113
- headers: {
114
- 'Content-Type': 'application/x-www-form-urlencoded'
115
- },
116
- body: params
117
- })
118
-
119
- if (response.ok) {
120
- const tokenData = /** @type {YotoApiTokenResponse} */ (await response.json())
121
- this.log.info(LOG_PREFIX.AUTH, '✓ Authorization successful!')
122
- return tokenData
123
- }
124
-
125
- const errorData = /** @type {any} */ (await response.json().catch(() => ({})))
126
-
127
- // Handle specific OAuth errors
128
- if (errorData.error === 'authorization_pending') {
129
- // Still waiting for user to authorize
130
- await this.sleep(OAUTH_POLLING_INTERVAL)
131
- continue
132
- }
133
-
134
- if (errorData.error === 'slow_down') {
135
- // Server wants us to slow down polling
136
- await this.sleep(OAUTH_POLLING_INTERVAL * 2)
137
- continue
138
- }
139
-
140
- if (errorData.error === 'expired_token') {
141
- throw new Error('Device code expired. Please restart the authorization process.')
142
- }
143
-
144
- if (errorData.error === 'access_denied') {
145
- throw new Error('Authorization was denied by the user.')
146
- }
147
-
148
- // Unknown error
149
- throw new Error(`Authorization failed: ${errorData.error || response.statusText}`)
150
- } catch (error) {
151
- if (error instanceof Error && error.message.includes('expired')) {
152
- throw error
153
- }
154
- this.log.debug(LOG_PREFIX.AUTH, 'Polling error:', error)
155
- await this.sleep(OAUTH_POLLING_INTERVAL)
156
- }
157
- }
158
-
159
- throw new Error('Authorization timed out. Please try again.')
160
- }
161
-
162
- /**
163
- * Refresh access token using refresh token
164
- * @param {string} refreshToken - Refresh token
165
- * @returns {Promise<YotoApiTokenResponse>}
166
- */
167
- async refreshAccessToken (refreshToken) {
168
- this.log.debug(LOG_PREFIX.AUTH, 'Refreshing access token...')
169
-
170
- try {
171
- const params = new URLSearchParams({
172
- grant_type: 'refresh_token',
173
- refresh_token: refreshToken,
174
- client_id: this.clientId
175
- })
176
-
177
- const response = await fetch(YOTO_OAUTH_TOKEN_URL, {
178
- method: 'POST',
179
- headers: {
180
- 'Content-Type': 'application/x-www-form-urlencoded'
181
- },
182
- body: params
183
- })
184
-
185
- if (!response.ok) {
186
- const errorText = await response.text()
187
- throw new Error(`Token refresh failed: ${response.status} ${errorText}`)
188
- }
189
-
190
- const tokenData = /** @type {YotoApiTokenResponse} */ (await response.json())
191
- this.log.info(LOG_PREFIX.AUTH, '✓ Token refreshed successfully')
192
- return tokenData
193
- } catch (error) {
194
- this.log.error(LOG_PREFIX.AUTH, ERROR_MESSAGES.TOKEN_REFRESH_FAILED, error)
195
- throw error
196
- }
197
- }
198
-
199
- /**
200
- * Complete device authorization flow
201
- * @returns {Promise<YotoApiTokenResponse>}
202
- */
203
- async authorize () {
204
- const deviceCodeResponse = await this.initiateDeviceFlow()
205
- const tokenResponse = await this.pollForAuthorization(deviceCodeResponse.device_code)
206
- return tokenResponse
207
- }
208
-
209
- /**
210
- * Check if token is expired or expiring soon
211
- * @param {number} expiresAt - Token expiration timestamp (seconds since epoch)
212
- * @param {number} bufferSeconds - Seconds before expiry to consider expired
213
- * @returns {boolean}
214
- */
215
- isTokenExpired (expiresAt, bufferSeconds = 300) {
216
- const now = Math.floor(Date.now() / 1000)
217
- return expiresAt <= now + bufferSeconds
218
- }
219
-
220
- /**
221
- * Calculate token expiration timestamp
222
- * @param {number} expiresIn - Seconds until token expires
223
- * @returns {number} - Unix timestamp when token expires
224
- */
225
- calculateExpiresAt (expiresIn) {
226
- return Math.floor(Date.now() / 1000) + expiresIn
227
- }
228
-
229
- /**
230
- * Sleep for specified milliseconds
231
- * @param {number} ms - Milliseconds to sleep
232
- * @returns {Promise<void>}
233
- */
234
- sleep (ms) {
235
- return new Promise(resolve => setTimeout(resolve, ms))
236
- }
237
- }