@mdjhnson/homebridge-yoto 0.1.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.
- package/LICENSE +22 -0
- package/README.md +131 -0
- package/config.schema.cjs +11 -0
- package/config.schema.json +231 -0
- package/homebridge-ui/public/client.js +339 -0
- package/homebridge-ui/public/index.html +125 -0
- package/homebridge-ui/server.js +247 -0
- package/index.js +16 -0
- package/lib/accessory.js +2350 -0
- package/lib/card-control-accessory.js +212 -0
- package/lib/card-controls.js +75 -0
- package/lib/platform.js +1019 -0
- package/lib/service-config.js +47 -0
- package/lib/settings.js +56 -0
- package/lib/shortcuts.js +131 -0
- package/lib/speaker-accessory.js +551 -0
- package/lib/sync-service-names.js +39 -0
- package/lib/television-accessory.js +987 -0
- package/lib/utils/error-format.js +29 -0
- package/lib/utils/get-boolean-setting.js +8 -0
- package/lib/utils/get-trimmed-string.js +7 -0
- package/lib/utils/listener-group.js +45 -0
- package/lib/utils/oauth.js +70 -0
- package/lib/utils/sanitize-name.js +49 -0
- package/lib/utils/set-device-volume.js +22 -0
- package/lib/utils/status-scope-fallback.js +48 -0
- package/lib/utils/token-config.js +59 -0
- package/lib/utils/volume.js +78 -0
- package/package.json +76 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** @import { PlatformConfig } from 'homebridge' */
|
|
2
|
+
|
|
3
|
+
import { getBooleanSetting } from './utils/get-boolean-setting.js'
|
|
4
|
+
import { serviceSchema } from '../config.schema.cjs'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @typedef {Object} PlaybackAccessoryConfig
|
|
8
|
+
* @property {boolean} playbackEnabled
|
|
9
|
+
* @property {boolean} volumeEnabled
|
|
10
|
+
* @property {boolean} smartSpeakerEnabled
|
|
11
|
+
* @property {boolean} televisionEnabled
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {PlatformConfig} config
|
|
16
|
+
* @returns {PlaybackAccessoryConfig}
|
|
17
|
+
*/
|
|
18
|
+
export function getPlaybackAccessoryConfig (config) {
|
|
19
|
+
const services = config && typeof config === 'object' ? config['services'] : undefined
|
|
20
|
+
const serviceConfig = typeof services === 'object' && services !== null
|
|
21
|
+
? /** @type {Record<string, unknown>} */ (services)
|
|
22
|
+
: {}
|
|
23
|
+
|
|
24
|
+
const playbackEnabled = getBooleanSetting(serviceConfig['playbackControls'], false)
|
|
25
|
+
const smartSpeakerEnabled = getBooleanSetting(serviceConfig['smartSpeaker'], false)
|
|
26
|
+
const televisionEnabled = getBooleanSetting(serviceConfig['television'], false)
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
playbackEnabled,
|
|
30
|
+
volumeEnabled: playbackEnabled,
|
|
31
|
+
smartSpeakerEnabled,
|
|
32
|
+
televisionEnabled,
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Whether shortcut controls are enabled (switches and TV inputs).
|
|
38
|
+
* @param {PlatformConfig} config
|
|
39
|
+
* @returns {boolean}
|
|
40
|
+
*/
|
|
41
|
+
export function getShortcutsEnabled (config) {
|
|
42
|
+
const services = config && typeof config === 'object' ? config['services'] : undefined
|
|
43
|
+
const serviceConfig = typeof services === 'object' && services !== null
|
|
44
|
+
? /** @type {Record<string, unknown>} */ (services)
|
|
45
|
+
: {}
|
|
46
|
+
return getBooleanSetting(serviceConfig['shortcuts'], serviceSchema.shortcuts.default)
|
|
47
|
+
}
|
package/lib/settings.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
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 = '@mdjhnson/homebridge-yoto'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Default OAuth Client ID from config schema
|
|
15
|
+
*/
|
|
16
|
+
export const DEFAULT_CLIENT_ID = configSchema.schema.properties.clientId.default
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* OAuth redirect URI registered on the Yoto developer app. Nothing listens here:
|
|
20
|
+
* the user copies the resulting address back into the plugin settings.
|
|
21
|
+
*/
|
|
22
|
+
export const OAUTH_REDIRECT_URI = 'http://127.0.0.1:8787/callback'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Client IDs from earlier versions that only support the retired device-code
|
|
26
|
+
* sign-in. Configs still using them are switched to DEFAULT_CLIENT_ID on sign-in.
|
|
27
|
+
*/
|
|
28
|
+
export const LEGACY_CLIENT_IDS = ['Y4HJ8BFqRQ24GQoLzgOzZ2KSqWmFG8LI']
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* OAuth scopes requested during sign-in. The Yoto developer app must have the
|
|
32
|
+
* same scopes enabled in the dashboard.
|
|
33
|
+
* @see https://yoto.dev/authentication/scopes/
|
|
34
|
+
*/
|
|
35
|
+
export const OAUTH_SCOPES = [
|
|
36
|
+
'offline_access', // refresh tokens
|
|
37
|
+
'family:devices:view', // list players and read status
|
|
38
|
+
'family:devices:manage', // nightlight, volume limits, Bluetooth settings
|
|
39
|
+
'family:devices:control', // MQTT playback and volume control
|
|
40
|
+
'family:library:view', // card titles for shortcut names
|
|
41
|
+
'user:content:view', // titles of Make Your Own cards
|
|
42
|
+
].join(' ')
|
|
43
|
+
|
|
44
|
+
/** Device defaults */
|
|
45
|
+
export const DEFAULT_MANUFACTURER = 'Yoto Inc.'
|
|
46
|
+
export const DEFAULT_MODEL = 'Yoto Player'
|
|
47
|
+
|
|
48
|
+
/** Battery threshold */
|
|
49
|
+
export const LOW_BATTERY_THRESHOLD = 20
|
|
50
|
+
|
|
51
|
+
/** Logging prefixes for debugging */
|
|
52
|
+
export const LOG_PREFIX = {
|
|
53
|
+
PLATFORM: '[Platform]',
|
|
54
|
+
ACCESSORY: '[Accessory]',
|
|
55
|
+
MQTT: '[MQTT]',
|
|
56
|
+
}
|
package/lib/shortcuts.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/** @import { YotoDeviceShortcuts } from 'yoto-nodejs-client/lib/api-endpoints/devices.js' */
|
|
2
|
+
/** @import { PlayableCard } from './card-controls.js' */
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A playable shortcut configured on a Yoto device.
|
|
6
|
+
* @typedef {Object} ShortcutEntry
|
|
7
|
+
* @property {string} id - Stable identifier derived from the content it plays
|
|
8
|
+
* @property {number} number - 1-based position, used for fallback labels
|
|
9
|
+
* @property {Array<'day' | 'night'>} modes - Modes this shortcut is configured in
|
|
10
|
+
* @property {string} cardId - Card to play
|
|
11
|
+
* @property {string} [chapterKey] - Chapter to start at
|
|
12
|
+
* @property {string} [trackKey] - Track to start at (may contain a date placeholder; see resolveShortcutKey)
|
|
13
|
+
* @property {string} [builtInName] - Name for Yoto's built-in shortcuts (Daily, Radio)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** @type {Array<'day' | 'night'>} */
|
|
17
|
+
const MODES = ['day', 'night']
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Names for Yoto's built-in shortcuts, keyed by chapter. These live on a system
|
|
21
|
+
* card whose title can't be looked up.
|
|
22
|
+
* @type {Record<string, string>}
|
|
23
|
+
*/
|
|
24
|
+
const BUILT_IN_SHORTCUT_NAMES = {
|
|
25
|
+
daily: 'Yoto Daily',
|
|
26
|
+
'radio-day': 'Yoto Radio',
|
|
27
|
+
'radio-night': 'Yoto Night Radio',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Replace date placeholders in shortcut chapter/track keys (e.g. Yoto Daily's
|
|
32
|
+
* `<yyyymmdd>` track) with the current local date.
|
|
33
|
+
* @param {string | undefined} key
|
|
34
|
+
* @param {Date} [now]
|
|
35
|
+
* @returns {string | undefined}
|
|
36
|
+
*/
|
|
37
|
+
export function resolveShortcutKey (key, now = new Date()) {
|
|
38
|
+
if (!key || !key.includes('<')) return key
|
|
39
|
+
const yyyy = String(now.getFullYear())
|
|
40
|
+
const mm = String(now.getMonth() + 1).padStart(2, '0')
|
|
41
|
+
const dd = String(now.getDate()).padStart(2, '0')
|
|
42
|
+
return key.replaceAll('<yyyymmdd>', `${yyyy}${mm}${dd}`)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Content to play for a shortcut, labelled for logs.
|
|
47
|
+
* @param {ShortcutEntry} entry
|
|
48
|
+
* @param {string} label
|
|
49
|
+
* @returns {PlayableCard}
|
|
50
|
+
*/
|
|
51
|
+
export function toPlayableCard (entry, label) {
|
|
52
|
+
return {
|
|
53
|
+
label,
|
|
54
|
+
cardId: entry.cardId,
|
|
55
|
+
...(entry.chapterKey !== undefined && { chapterKey: entry.chapterKey }),
|
|
56
|
+
...(entry.trackKey !== undefined && { trackKey: entry.trackKey }),
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* startCard() options for a playable card, with date placeholders resolved.
|
|
62
|
+
* @param {PlayableCard} card
|
|
63
|
+
* @param {Date} [now]
|
|
64
|
+
* @returns {{ cardId: string, chapterKey?: string, trackKey?: string }}
|
|
65
|
+
*/
|
|
66
|
+
export function getCardStartOptions (card, now = new Date()) {
|
|
67
|
+
const chapterKey = resolveShortcutKey(card.chapterKey, now)
|
|
68
|
+
const trackKey = resolveShortcutKey(card.trackKey, now)
|
|
69
|
+
return {
|
|
70
|
+
cardId: card.cardId,
|
|
71
|
+
...(chapterKey !== undefined && { chapterKey }),
|
|
72
|
+
...(trackKey !== undefined && { trackKey }),
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Extract playable shortcuts from device shortcut config.
|
|
78
|
+
*
|
|
79
|
+
* The same content configured in both day and night mode is returned once.
|
|
80
|
+
* Only `track-play` commands with a card ID are supported.
|
|
81
|
+
*
|
|
82
|
+
* @param {YotoDeviceShortcuts | null | undefined} shortcuts
|
|
83
|
+
* @returns {ShortcutEntry[]}
|
|
84
|
+
*/
|
|
85
|
+
export function getShortcutEntries (shortcuts) {
|
|
86
|
+
/** @type {Map<string, ShortcutEntry>} */
|
|
87
|
+
const entries = new Map()
|
|
88
|
+
|
|
89
|
+
for (const mode of MODES) {
|
|
90
|
+
const content = shortcuts?.modes?.[mode]?.content
|
|
91
|
+
if (!Array.isArray(content)) continue
|
|
92
|
+
|
|
93
|
+
for (const item of content) {
|
|
94
|
+
if (!item || item.cmd !== 'track-play') continue
|
|
95
|
+
const cardId = typeof item.params?.card === 'string' ? item.params.card.trim() : ''
|
|
96
|
+
if (!cardId) continue
|
|
97
|
+
|
|
98
|
+
const chapterKey = typeof item.params.chapter === 'string' && item.params.chapter ? item.params.chapter : undefined
|
|
99
|
+
const trackKey = typeof item.params.track === 'string' && item.params.track ? item.params.track : undefined
|
|
100
|
+
const id = [cardId, chapterKey ?? '', trackKey ?? ''].join(':')
|
|
101
|
+
|
|
102
|
+
const existing = entries.get(id)
|
|
103
|
+
if (existing) {
|
|
104
|
+
if (!existing.modes.includes(mode)) existing.modes.push(mode)
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const builtInName = chapterKey ? BUILT_IN_SHORTCUT_NAMES[chapterKey] : undefined
|
|
109
|
+
entries.set(id, {
|
|
110
|
+
id,
|
|
111
|
+
number: entries.size + 1,
|
|
112
|
+
modes: [mode],
|
|
113
|
+
cardId,
|
|
114
|
+
...(chapterKey !== undefined && { chapterKey }),
|
|
115
|
+
...(trackKey !== undefined && { trackKey }),
|
|
116
|
+
...(builtInName !== undefined && { builtInName }),
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return Array.from(entries.values())
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Signature used to detect when the set of shortcuts changes.
|
|
126
|
+
* @param {ShortcutEntry[]} entries
|
|
127
|
+
* @returns {string}
|
|
128
|
+
*/
|
|
129
|
+
export function getShortcutsSignature (entries) {
|
|
130
|
+
return entries.map(entry => entry.id).join('|')
|
|
131
|
+
}
|