@bharper/atv-js 0.2.3

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 (86) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +80 -0
  3. package/dist/airplay/auth.d.ts +24 -0
  4. package/dist/airplay/auth.d.ts.map +1 -0
  5. package/dist/airplay/auth.js +195 -0
  6. package/dist/airplay/auth.js.map +1 -0
  7. package/dist/bplist.d.ts +19 -0
  8. package/dist/bplist.d.ts.map +1 -0
  9. package/dist/bplist.js +141 -0
  10. package/dist/bplist.js.map +1 -0
  11. package/dist/companion/auth.d.ts +34 -0
  12. package/dist/companion/auth.d.ts.map +1 -0
  13. package/dist/companion/auth.js +119 -0
  14. package/dist/companion/auth.js.map +1 -0
  15. package/dist/companion/connection.d.ts +50 -0
  16. package/dist/companion/connection.d.ts.map +1 -0
  17. package/dist/companion/connection.js +170 -0
  18. package/dist/companion/connection.js.map +1 -0
  19. package/dist/companion/keyboard.d.ts +39 -0
  20. package/dist/companion/keyboard.d.ts.map +1 -0
  21. package/dist/companion/keyboard.js +127 -0
  22. package/dist/companion/keyboard.js.map +1 -0
  23. package/dist/companion/pairing_keepalive.d.ts +4 -0
  24. package/dist/companion/pairing_keepalive.d.ts.map +1 -0
  25. package/dist/companion/pairing_keepalive.js +68 -0
  26. package/dist/companion/pairing_keepalive.js.map +1 -0
  27. package/dist/companion/protocol.d.ts +64 -0
  28. package/dist/companion/protocol.d.ts.map +1 -0
  29. package/dist/companion/protocol.js +246 -0
  30. package/dist/companion/protocol.js.map +1 -0
  31. package/dist/companion/remote.d.ts +75 -0
  32. package/dist/companion/remote.d.ts.map +1 -0
  33. package/dist/companion/remote.js +142 -0
  34. package/dist/companion/remote.js.map +1 -0
  35. package/dist/crypto/chacha20.d.ts +27 -0
  36. package/dist/crypto/chacha20.d.ts.map +1 -0
  37. package/dist/crypto/chacha20.js +88 -0
  38. package/dist/crypto/chacha20.js.map +1 -0
  39. package/dist/crypto/hkdf.d.ts +6 -0
  40. package/dist/crypto/hkdf.d.ts.map +1 -0
  41. package/dist/crypto/hkdf.js +45 -0
  42. package/dist/crypto/hkdf.js.map +1 -0
  43. package/dist/index.d.ts +85 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +191 -0
  46. package/dist/index.js.map +1 -0
  47. package/dist/mdns.d.ts +20 -0
  48. package/dist/mdns.d.ts.map +1 -0
  49. package/dist/mdns.js +165 -0
  50. package/dist/mdns.js.map +1 -0
  51. package/dist/opack.d.ts +21 -0
  52. package/dist/opack.d.ts.map +1 -0
  53. package/dist/opack.js +350 -0
  54. package/dist/opack.js.map +1 -0
  55. package/dist/pairing/credentials.d.ts +23 -0
  56. package/dist/pairing/credentials.d.ts.map +1 -0
  57. package/dist/pairing/credentials.js +31 -0
  58. package/dist/pairing/credentials.js.map +1 -0
  59. package/dist/pairing/srp.d.ts +54 -0
  60. package/dist/pairing/srp.d.ts.map +1 -0
  61. package/dist/pairing/srp.js +221 -0
  62. package/dist/pairing/srp.js.map +1 -0
  63. package/dist/pairing/tlv.d.ts +26 -0
  64. package/dist/pairing/tlv.d.ts.map +1 -0
  65. package/dist/pairing/tlv.js +68 -0
  66. package/dist/pairing/tlv.js.map +1 -0
  67. package/examples/pair.ts +103 -0
  68. package/examples/remote.ts +212 -0
  69. package/package.json +33 -0
  70. package/src/airplay/auth.ts +207 -0
  71. package/src/bplist.ts +136 -0
  72. package/src/companion/auth.ts +141 -0
  73. package/src/companion/connection.ts +161 -0
  74. package/src/companion/keyboard.ts +155 -0
  75. package/src/companion/pairing_keepalive.ts +75 -0
  76. package/src/companion/protocol.ts +253 -0
  77. package/src/companion/remote.ts +151 -0
  78. package/src/crypto/chacha20.ts +93 -0
  79. package/src/crypto/hkdf.ts +18 -0
  80. package/src/index.ts +248 -0
  81. package/src/mdns.ts +198 -0
  82. package/src/opack.ts +299 -0
  83. package/src/pairing/credentials.ts +44 -0
  84. package/src/pairing/srp.ts +234 -0
  85. package/src/pairing/tlv.ts +64 -0
  86. package/tsconfig.json +19 -0
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Keyboard/text input via Companion protocol.
3
+ * Port of pyatv/protocols/companion/api.py text input handling +
4
+ * pyatv/protocols/companion/__init__.py CompanionKeyboard.
5
+ */
6
+
7
+ import { CompanionProtocol } from './protocol';
8
+ import { createRtiClearTextPayload, createRtiInputTextPayload, readArchiveProperties } from '../bplist';
9
+
10
+ export enum KeyboardFocusState {
11
+ Unknown = 'unknown',
12
+ Focused = 'focused',
13
+ Unfocused = 'unfocused',
14
+ }
15
+
16
+ export interface KeyboardState {
17
+ focusState: KeyboardFocusState;
18
+ }
19
+
20
+ /**
21
+ * Start a text input session. Returns the response containing _tiD if keyboard is focused.
22
+ */
23
+ async function textInputStart(protocol: CompanionProtocol): Promise<Record<string, unknown>> {
24
+ return protocol.sendCommand('_tiStart', {});
25
+ }
26
+
27
+ /**
28
+ * Stop a text input session.
29
+ */
30
+ async function textInputStop(protocol: CompanionProtocol): Promise<Record<string, unknown>> {
31
+ return protocol.sendCommand('_tiStop', {});
32
+ }
33
+
34
+ /**
35
+ * Execute a text input command: optionally clear existing text, then append new text.
36
+ * Returns the current text after the operation, or null if keyboard not focused.
37
+ */
38
+ export async function textInputCommand(
39
+ protocol: CompanionProtocol,
40
+ text: string,
41
+ clearPreviousInput: boolean,
42
+ ): Promise<string | null> {
43
+ // Restart session to get latest state
44
+ await textInputStop(protocol);
45
+ const response = await textInputStart(protocol);
46
+
47
+ const content = (response._c || {}) as Record<string, unknown>;
48
+ const tiData = content._tiD;
49
+
50
+ if (!tiData || !Buffer.isBuffer(tiData)) {
51
+ return null; // Keyboard not focused
52
+ }
53
+
54
+ // Extract session UUID and current text from NSKeyedArchiver data
55
+ const [sessionUuid, currentTextRaw] = readArchiveProperties(
56
+ tiData,
57
+ ['sessionUUID'],
58
+ ['documentState', 'docSt', 'contextBeforeInput'],
59
+ );
60
+
61
+ if (!sessionUuid || !Buffer.isBuffer(sessionUuid)) {
62
+ return null;
63
+ }
64
+
65
+ let currentText: string = typeof currentTextRaw === 'string' ? currentTextRaw : '';
66
+
67
+ // Clear text if requested
68
+ if (clearPreviousInput) {
69
+ protocol.sendEvent('_tiC', {
70
+ _tiV: 1,
71
+ _tiD: createRtiClearTextPayload(sessionUuid),
72
+ });
73
+ currentText = '';
74
+ }
75
+
76
+ // Append new text
77
+ if (text) {
78
+ protocol.sendEvent('_tiC', {
79
+ _tiV: 1,
80
+ _tiD: createRtiInputTextPayload(sessionUuid, text),
81
+ });
82
+ currentText += text;
83
+ }
84
+
85
+ return currentText;
86
+ }
87
+
88
+ /**
89
+ * Get the current text from a focused keyboard.
90
+ */
91
+ export async function getText(protocol: CompanionProtocol): Promise<string | null> {
92
+ return textInputCommand(protocol, '', false);
93
+ }
94
+
95
+ /**
96
+ * Set (replace) the text in a focused keyboard.
97
+ */
98
+ export async function setText(protocol: CompanionProtocol, text: string): Promise<void> {
99
+ await textInputCommand(protocol, text, true);
100
+ }
101
+
102
+ /**
103
+ * Check if keyboard is currently focused by starting a text input session.
104
+ */
105
+ export async function getKeyboardFocus(protocol: CompanionProtocol): Promise<boolean> {
106
+ await textInputStop(protocol);
107
+ const response = await textInputStart(protocol);
108
+ const content = (response._c || {}) as Record<string, unknown>;
109
+ return '_tiD' in content && content._tiD !== null && content._tiD !== undefined;
110
+ }
111
+
112
+ /**
113
+ * Register keyboard focus state listeners on a protocol instance.
114
+ * Uses polling since _tiStarted/_tiStopped events aren't reliably pushed.
115
+ * Calls the callback whenever focus state changes.
116
+ * Returns a function to stop the polling.
117
+ */
118
+ export function watchKeyboardFocus(
119
+ protocol: CompanionProtocol,
120
+ callback: (state: KeyboardFocusState) => void,
121
+ pollIntervalMs = 1000,
122
+ ): () => void {
123
+ let lastState: KeyboardFocusState = KeyboardFocusState.Unknown;
124
+ let running = true;
125
+
126
+ const poll = async () => {
127
+ if (!running) return;
128
+
129
+ try {
130
+ const focused = await getKeyboardFocus(protocol);
131
+ const newState = focused ? KeyboardFocusState.Focused : KeyboardFocusState.Unfocused;
132
+
133
+ if (newState !== lastState) {
134
+ lastState = newState;
135
+ callback(newState);
136
+ }
137
+ } catch {
138
+ // Connection may be closed, stop polling
139
+ running = false;
140
+ return;
141
+ }
142
+
143
+ if (running) {
144
+ setTimeout(poll, pollIntervalMs);
145
+ }
146
+ };
147
+
148
+ // Start polling
149
+ poll();
150
+
151
+ // Return stop function
152
+ return () => {
153
+ running = false;
154
+ };
155
+ }
@@ -0,0 +1,75 @@
1
+ import { CompanionConnection } from './connection';
2
+
3
+ const COMPANION_AGENT_IDLE_MS = 2 * 60 * 1000;
4
+
5
+ type Entry = {
6
+ connection: CompanionConnection;
7
+ lastUsed: number;
8
+ timer: NodeJS.Timeout;
9
+ };
10
+
11
+ const connectionCache = new Map<string, Entry>();
12
+
13
+ function connectionKey(host: string, port: number): string {
14
+ return `${host}:${port}`;
15
+ }
16
+
17
+ function scheduleCleanup(key: string, entry: Entry): void {
18
+ if (entry.timer) clearTimeout(entry.timer);
19
+ entry.lastUsed = Date.now();
20
+ entry.timer = setTimeout(() => {
21
+ if (Date.now() - entry.lastUsed >= COMPANION_AGENT_IDLE_MS) {
22
+ entry.connection.close();
23
+ connectionCache.delete(key);
24
+ }
25
+ }, COMPANION_AGENT_IDLE_MS);
26
+ if (typeof entry.timer.unref === 'function') entry.timer.unref();
27
+ }
28
+
29
+ export function getCompanionPairingConnection(host: string, port: number): CompanionConnection {
30
+ const key = connectionKey(host, port);
31
+ const existing = connectionCache.get(key);
32
+ if (existing) {
33
+ if (existing.connection.isConnected) {
34
+ scheduleCleanup(key, existing);
35
+ return existing.connection;
36
+ }
37
+ existing.connection.close();
38
+ connectionCache.delete(key);
39
+ }
40
+
41
+ const connection = new CompanionConnection(host, port);
42
+ const entry: Entry = {
43
+ connection,
44
+ lastUsed: Date.now(),
45
+ timer: setTimeout(() => {
46
+ connection.close();
47
+ connectionCache.delete(key);
48
+ }, COMPANION_AGENT_IDLE_MS),
49
+ };
50
+ if (typeof entry.timer.unref === 'function') entry.timer.unref();
51
+ connectionCache.set(key, entry);
52
+ return connection;
53
+ }
54
+
55
+ export function releaseCompanionPairingConnection(connection: CompanionConnection): void {
56
+ const key = connectionKey(connection.getHost(), connection.getPort());
57
+ const existing = connectionCache.get(key);
58
+ if (existing) {
59
+ scheduleCleanup(key, existing);
60
+ } else {
61
+ const entry: Entry = {
62
+ connection,
63
+ lastUsed: Date.now(),
64
+ timer: setTimeout(() => {
65
+ connection.close();
66
+ connectionCache.delete(key);
67
+ }, COMPANION_AGENT_IDLE_MS),
68
+ };
69
+ if (typeof entry.timer.unref === 'function') entry.timer.unref();
70
+ connectionCache.set(key, entry);
71
+ }
72
+
73
+ // Detach listener to avoid delivering stale events.
74
+ connection.setListener({ frameReceived: () => {}, connectionLost: () => {} });
75
+ }
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Companion protocol message dispatch layer.
3
+ * Port of pyatv/protocols/companion/protocol.py.
4
+ *
5
+ * Messages are OPACK-encoded with structure:
6
+ * { _i: identifier, _t: messageType, _c: content, _x: transactionId }
7
+ */
8
+
9
+ import { EventEmitter } from 'events';
10
+ import { CompanionConnection, FrameType, FrameListener } from './connection';
11
+ import * as opack from '../opack';
12
+ import { SRPAuthHandler } from '../pairing/srp';
13
+ import { HapCredentials, parseCredentials } from '../pairing/credentials';
14
+ import { CompanionPairVerifyProcedure, SRP_SALT, SRP_OUTPUT_INFO, SRP_INPUT_INFO } from './auth';
15
+
16
+ export enum MessageType {
17
+ Event = 1,
18
+ Request = 2,
19
+ Response = 3,
20
+ }
21
+
22
+ const DEFAULT_TIMEOUT = 5000;
23
+
24
+ interface PendingRequest {
25
+ resolve: (data: Record<string, unknown>) => void;
26
+ reject: (error: Error) => void;
27
+ timer: ReturnType<typeof setTimeout>;
28
+ }
29
+
30
+ export interface CompanionEventListener {
31
+ (data: Record<string, unknown>): void;
32
+ }
33
+
34
+ export class CompanionProtocol implements FrameListener {
35
+ connection: CompanionConnection;
36
+ private xid = Math.floor(Math.random() * 0x10000); // Random starting xid like pyatv
37
+ private pendingRequests = new Map<number, PendingRequest>();
38
+ private pendingAuth = new Map<number, PendingRequest>(); // keyed by frame type
39
+ private eventListeners = new Map<string, CompanionEventListener[]>();
40
+ private events = new EventEmitter();
41
+ private srp: SRPAuthHandler;
42
+ private credentialString: string | null;
43
+ private _onConnectionLost?: (error?: Error) => void;
44
+
45
+ constructor(connection: CompanionConnection, credentialString: string | null) {
46
+ this.connection = connection;
47
+ this.srp = new SRPAuthHandler();
48
+ this.credentialString = credentialString;
49
+ }
50
+
51
+ set onConnectionLost(handler: (error?: Error) => void) {
52
+ this._onConnectionLost = handler;
53
+ }
54
+
55
+ // ---- FrameListener interface ----
56
+
57
+ frameReceived(frameType: FrameType, payload: Buffer): void {
58
+ // Auth frames (PS_*, PV_*)
59
+ if (frameType >= FrameType.PS_Start && frameType <= FrameType.PV_Next) {
60
+ this.handleAuthFrame(frameType, payload);
61
+ return;
62
+ }
63
+
64
+ // OPACK frames
65
+ if (frameType === FrameType.E_OPACK || frameType === FrameType.U_OPACK || frameType === FrameType.P_OPACK) {
66
+ if (payload.length === 0) return;
67
+ const { value } = opack.unpack(payload);
68
+ this.handleOpack(frameType, value as Record<string, unknown>);
69
+ }
70
+ }
71
+
72
+ connectionLost(error?: Error): void {
73
+ // Reject all pending requests
74
+ for (const [, pending] of this.pendingRequests) {
75
+ clearTimeout(pending.timer);
76
+ pending.reject(new Error('Connection lost'));
77
+ }
78
+ this.pendingRequests.clear();
79
+
80
+ for (const [, pending] of this.pendingAuth) {
81
+ clearTimeout(pending.timer);
82
+ pending.reject(new Error('Connection lost'));
83
+ }
84
+ this.pendingAuth.clear();
85
+
86
+ if (this._onConnectionLost) this._onConnectionLost(error);
87
+ }
88
+
89
+ // ---- Auth frame handling ----
90
+
91
+ private handleAuthFrame(frameType: FrameType, payload: Buffer): void {
92
+ if (payload.length === 0) return;
93
+ const { value } = opack.unpack(payload);
94
+ const data = value as Record<string, unknown>;
95
+
96
+ // Auth responses come as the next frame type after the request
97
+ // PV_Start(5) → response comes as PV_Next(6), PS_Start(3) → PS_Next(4)
98
+ const pending = this.pendingAuth.get(frameType);
99
+ if (pending) {
100
+ clearTimeout(pending.timer);
101
+ this.pendingAuth.delete(frameType);
102
+ pending.resolve(data);
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Exchange an auth frame and wait for the response.
108
+ */
109
+ async exchangeAuth(
110
+ frameType: FrameType,
111
+ data: Record<string, unknown>,
112
+ timeout = DEFAULT_TIMEOUT,
113
+ ): Promise<Record<string, unknown>> {
114
+ // Response frame type: for PS_Start(3)→PS_Next(4), PV_Start(5)→PV_Next(6)
115
+ // For PS_Next(4)→PS_Next(4), PV_Next(6)→PV_Next(6)
116
+ let responseType: FrameType;
117
+ if (frameType === FrameType.PS_Start) {
118
+ responseType = FrameType.PS_Next;
119
+ } else if (frameType === FrameType.PV_Start) {
120
+ responseType = FrameType.PV_Next;
121
+ } else {
122
+ responseType = frameType; // PS_Next → PS_Next, PV_Next → PV_Next
123
+ }
124
+
125
+ return new Promise<Record<string, unknown>>((resolve, reject) => {
126
+ const timer = setTimeout(() => {
127
+ this.pendingAuth.delete(responseType);
128
+ reject(new Error(`Auth exchange timeout for frame type ${frameType}`));
129
+ }, timeout);
130
+
131
+ this.pendingAuth.set(responseType, { resolve, reject, timer });
132
+ this.connection.send(frameType, opack.pack(data));
133
+ });
134
+ }
135
+
136
+ // ---- OPACK message handling ----
137
+
138
+ private handleOpack(frameType: FrameType, data: Record<string, unknown>): void {
139
+ const messageType = data._t as number;
140
+
141
+ if (messageType === MessageType.Event) {
142
+ const eventName = data._i as string;
143
+ const content = (data._c || {}) as Record<string, unknown>;
144
+ const listeners = this.eventListeners.get(eventName);
145
+ if (listeners) {
146
+ for (const listener of listeners) {
147
+ try { listener(content); } catch {}
148
+ }
149
+ }
150
+ this.events.emit(eventName, content);
151
+ } else if (messageType === MessageType.Response) {
152
+ const xid = data._x as number;
153
+ const pending = this.pendingRequests.get(xid);
154
+ if (pending) {
155
+ clearTimeout(pending.timer);
156
+ this.pendingRequests.delete(xid);
157
+ pending.resolve(data);
158
+ }
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Send an OPACK request and wait for the response.
164
+ */
165
+ async exchangeOpack(
166
+ data: Record<string, unknown>,
167
+ frameType: FrameType = FrameType.E_OPACK,
168
+ timeout = DEFAULT_TIMEOUT,
169
+ ): Promise<Record<string, unknown>> {
170
+ const xid = this.xid++;
171
+ data._x = xid;
172
+
173
+ return new Promise<Record<string, unknown>>((resolve, reject) => {
174
+ const timer = setTimeout(() => {
175
+ this.pendingRequests.delete(xid);
176
+ reject(new Error(`OPACK exchange timeout for xid ${xid}`));
177
+ }, timeout);
178
+
179
+ this.pendingRequests.set(xid, { resolve, reject, timer });
180
+ this.connection.send(frameType, opack.pack(data));
181
+ });
182
+ }
183
+
184
+ /**
185
+ * Send an OPACK event (no response expected).
186
+ */
187
+ sendEvent(identifier: string, content: Record<string, unknown>): void {
188
+ const data: Record<string, unknown> = {
189
+ _i: identifier,
190
+ _t: MessageType.Event,
191
+ _c: content,
192
+ _x: this.xid++,
193
+ };
194
+ this.connection.send(FrameType.E_OPACK, opack.pack(data));
195
+ }
196
+
197
+ /**
198
+ * Send an OPACK request with standard message structure.
199
+ */
200
+ async sendCommand(
201
+ identifier: string,
202
+ content: Record<string, unknown> = {},
203
+ timeout = DEFAULT_TIMEOUT,
204
+ ): Promise<Record<string, unknown>> {
205
+ const data: Record<string, unknown> = {
206
+ _i: identifier,
207
+ _t: MessageType.Request,
208
+ _c: content,
209
+ };
210
+ return this.exchangeOpack(data, FrameType.E_OPACK, timeout);
211
+ }
212
+
213
+ /**
214
+ * Register a listener for a specific event name.
215
+ */
216
+ listenTo(eventName: string, listener: CompanionEventListener): void {
217
+ const existing = this.eventListeners.get(eventName) || [];
218
+ existing.push(listener);
219
+ this.eventListeners.set(eventName, existing);
220
+ }
221
+
222
+ /**
223
+ * Subscribe to device events.
224
+ */
225
+ async subscribeEvent(eventName: string): Promise<void> {
226
+ this.sendEvent('_interest', { _regEvents: [eventName] });
227
+ }
228
+
229
+ // ---- Connection lifecycle ----
230
+
231
+ /**
232
+ * Start the protocol: connect, verify credentials if available, enable encryption.
233
+ */
234
+ async start(): Promise<void> {
235
+ await this.connection.connect();
236
+
237
+ if (this.credentialString) {
238
+ const credentials = parseCredentials(this.credentialString);
239
+ await this.setupEncryption(credentials);
240
+ }
241
+ }
242
+
243
+ private async setupEncryption(credentials: HapCredentials): Promise<void> {
244
+ const verifier = new CompanionPairVerifyProcedure(this, this.srp, credentials);
245
+ await verifier.verifyCredentials();
246
+ const [outputKey, inputKey] = verifier.encryptionKeys();
247
+ this.connection.enableEncryption(outputKey, inputKey);
248
+ }
249
+
250
+ close(): void {
251
+ this.connection.close();
252
+ }
253
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Remote control via Companion protocol HID and media control commands.
3
+ * Port of pyatv/protocols/companion/api.py + __init__.py remote control.
4
+ */
5
+
6
+ import { CompanionProtocol } from './protocol';
7
+
8
+ /** HID command values matching pyatv's HidCommand enum. */
9
+ export enum HidCommand {
10
+ Up = 1,
11
+ Down = 2,
12
+ Left = 3,
13
+ Right = 4,
14
+ Menu = 5,
15
+ Select = 6,
16
+ Home = 7,
17
+ VolumeUp = 8,
18
+ VolumeDown = 9,
19
+ Siri = 10,
20
+ Screensaver = 11,
21
+ Sleep = 12,
22
+ Wake = 13,
23
+ PlayPause = 14,
24
+ ChannelIncrement = 15,
25
+ ChannelDecrement = 16,
26
+ Guide = 17,
27
+ PageUp = 18,
28
+ PageDown = 19,
29
+ }
30
+
31
+ /** Media control command values matching pyatv's MediaControlCommand enum. */
32
+ export enum MediaControlCommand {
33
+ Play = 1,
34
+ Pause = 2,
35
+ NextTrack = 3,
36
+ PreviousTrack = 4,
37
+ GetVolume = 5,
38
+ SetVolume = 6,
39
+ SkipBy = 7,
40
+ }
41
+
42
+ /** Named remote keys. */
43
+ export enum RemoteKey {
44
+ Up = 'up',
45
+ Down = 'down',
46
+ Left = 'left',
47
+ Right = 'right',
48
+ Select = 'select',
49
+ Menu = 'menu',
50
+ TopMenu = 'top_menu',
51
+ Home = 'home',
52
+ HomeHold = 'home_hold',
53
+ PlayPause = 'play_pause',
54
+ Next = 'next',
55
+ Previous = 'previous',
56
+ SkipForward = 'skip_forward',
57
+ SkipBackward = 'skip_backward',
58
+ VolumeUp = 'volume_up',
59
+ VolumeDown = 'volume_down',
60
+ Guide = 'guide',
61
+ }
62
+
63
+ const KEY_TO_HID: Record<string, HidCommand> = {
64
+ [RemoteKey.Up]: HidCommand.Up,
65
+ [RemoteKey.Down]: HidCommand.Down,
66
+ [RemoteKey.Left]: HidCommand.Left,
67
+ [RemoteKey.Right]: HidCommand.Right,
68
+ [RemoteKey.Select]: HidCommand.Select,
69
+ [RemoteKey.Menu]: HidCommand.Menu,
70
+ [RemoteKey.TopMenu]: HidCommand.Menu,
71
+ [RemoteKey.Home]: HidCommand.Home,
72
+ [RemoteKey.HomeHold]: HidCommand.Home,
73
+ [RemoteKey.PlayPause]: HidCommand.PlayPause,
74
+ [RemoteKey.VolumeUp]: HidCommand.VolumeUp,
75
+ [RemoteKey.VolumeDown]: HidCommand.VolumeDown,
76
+ [RemoteKey.Guide]: HidCommand.Guide,
77
+ };
78
+
79
+ /** Keys that are media control commands rather than HID. */
80
+ const KEY_TO_MEDIA: Record<string, MediaControlCommand> = {
81
+ [RemoteKey.Next]: MediaControlCommand.NextTrack,
82
+ [RemoteKey.Previous]: MediaControlCommand.PreviousTrack,
83
+ };
84
+
85
+ /** Keys that require a long press (hold down, delay, release). */
86
+ const LONG_PRESS_KEYS = new Set<string>([RemoteKey.HomeHold]);
87
+
88
+ const LONG_PRESS_DELAY_MS = 1000;
89
+
90
+ /**
91
+ * Send a media control command.
92
+ */
93
+ export async function sendMediaControl(
94
+ protocol: CompanionProtocol,
95
+ command: MediaControlCommand,
96
+ args?: Record<string, unknown>,
97
+ ): Promise<void> {
98
+ await protocol.sendCommand('_mcc', { _mcc: command, ...(args || {}) });
99
+ }
100
+
101
+ /**
102
+ * Send a remote control key press. Handles HID keys, media control keys,
103
+ * and long-press keys automatically.
104
+ */
105
+ export async function sendKeyPress(protocol: CompanionProtocol, key: RemoteKey | string): Promise<void> {
106
+ // Media control keys
107
+ const mediaCommand = KEY_TO_MEDIA[key];
108
+ if (mediaCommand !== undefined) {
109
+ await sendMediaControl(protocol, mediaCommand);
110
+ return;
111
+ }
112
+
113
+ const hidCommand = KEY_TO_HID[key];
114
+ if (hidCommand === undefined) {
115
+ throw new Error(`Unknown remote key: ${key}`);
116
+ }
117
+
118
+ if (LONG_PRESS_KEYS.has(key)) {
119
+ // Long press: hold down, wait, release
120
+ await protocol.sendCommand('_hidC', { _hBtS: 1, _hidC: hidCommand });
121
+ await new Promise((r) => setTimeout(r, LONG_PRESS_DELAY_MS));
122
+ await protocol.sendCommand('_hidC', { _hBtS: 2, _hidC: hidCommand });
123
+ return;
124
+ }
125
+
126
+ // Normal press: down + up
127
+ await protocol.sendCommand('_hidC', { _hBtS: 1, _hidC: hidCommand });
128
+ await protocol.sendCommand('_hidC', { _hBtS: 2, _hidC: hidCommand });
129
+ }
130
+
131
+ /**
132
+ * Send a HID button down event only (for long press behavior).
133
+ */
134
+ export async function sendKeyDown(protocol: CompanionProtocol, key: RemoteKey | string): Promise<void> {
135
+ const hidCommand = KEY_TO_HID[key];
136
+ if (hidCommand === undefined) {
137
+ throw new Error(`Unknown remote key: ${key}`);
138
+ }
139
+ await protocol.sendCommand('_hidC', { _hBtS: 1, _hidC: hidCommand });
140
+ }
141
+
142
+ /**
143
+ * Send a HID button up event only (to release a held button).
144
+ */
145
+ export async function sendKeyUp(protocol: CompanionProtocol, key: RemoteKey | string): Promise<void> {
146
+ const hidCommand = KEY_TO_HID[key];
147
+ if (hidCommand === undefined) {
148
+ throw new Error(`Unknown remote key: ${key}`);
149
+ }
150
+ await protocol.sendCommand('_hidC', { _hBtS: 2, _hidC: hidCommand });
151
+ }