iobroker.androidtvremote 0.0.0-stage → 0.2.6

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Henrik Schönhofen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,159 @@
1
- # Temporary Holding Version
1
+ ![Logo](admin/androidtvremote.png)
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ # ioBroker.androidtvremote
4
+
5
+ [![NPM version](https://img.shields.io/npm/v/iobroker.androidtvremote.svg)](https://www.npmjs.com/package/iobroker.androidtvremote)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
+
8
+ Unofficial Android TV / Google TV remote control adapter for ioBroker using the Android TV Remote v2 protocol.
9
+
10
+ The adapter maintains a persistent connection to an Android TV or Google TV device and provides remote control, power state, volume information, currently active application and optional Google Cast media metadata.
11
+
12
+ ## Features
13
+
14
+ - Android TV Remote v2 protocol
15
+ - No ADB or wireless debugging required
16
+ - Secure pairing with Android TV / Google TV
17
+ - Pairing certificate stored locally after initial pairing
18
+ - Persistent Remote v2 connection
19
+ - Automatic reconnect after connection loss
20
+ - Power state
21
+ - Volume and mute state
22
+ - Currently active Android application
23
+ - Friendly names for many common streaming apps
24
+ - Unknown application package detection
25
+ - Remote key commands
26
+ - Text input
27
+ - App/deep-link launching
28
+ - Power control
29
+ - Manual reconnect
30
+ - Pairing reset
31
+ - Optional Google Cast session and media metadata
32
+
33
+ ## Requirements
34
+
35
+ - ioBroker
36
+ - Node.js 22 or newer
37
+ - Android TV or Google TV device supporting the Android TV Remote v2 protocol
38
+ - ioBroker host and Android TV / Google TV device must be reachable over the network
39
+
40
+ Default Android TV Remote v2 ports:
41
+
42
+ | Function | Port |
43
+ |----------|------|
44
+ | Remote | 6466 |
45
+ | Pairing | 6467 |
46
+
47
+ ## Installation
48
+
49
+ Install the adapter in ioBroker and create an instance.
50
+
51
+ Enter the IP address or hostname of the Android TV / Google TV device in the adapter configuration.
52
+
53
+ ## Pairing
54
+
55
+ On the first connection, the Android TV / Google TV device displays a pairing code.
56
+
57
+ Enter this code into:
58
+
59
+ androidtvremote.0.control.pairingCode
60
+
61
+ After successful pairing, the certificate is stored locally by the adapter and normally no further pairing is required.
62
+
63
+ To remove the stored pairing information and pair again, trigger:
64
+
65
+ androidtvremote.0.control.forgetPairing
66
+
67
+ ## Configuration
68
+
69
+ | Setting | Description | Default |
70
+ |---------|-------------|---------|
71
+ | IP address / hostname | Android TV / Google TV device | |
72
+ | Pairing name | Name shown on the TV during pairing | ioBroker |
73
+ | Pairing port | Android TV Remote v2 pairing port | 6467 |
74
+ | Remote port | Android TV Remote v2 remote-control port | 6466 |
75
+ | Reconnect interval | Delay before reconnecting after a failed connection | 15 s |
76
+ | Protocol debug | Enable additional protocol logging | disabled |
77
+ | Cast media information | Enable optional Google Cast monitoring | enabled |
78
+ | Cast polling interval | Interval for checking Cast media information | 5 s |
79
+
80
+ ## Datapoints
81
+
82
+ ### Information
83
+
84
+ info.connection
85
+ info.pairingRequired
86
+ info.powered
87
+ info.currentAppId
88
+ info.currentApp
89
+ info.currentAppUnknown
90
+ info.volume.level
91
+ info.volume.maximum
92
+ info.volume.muted
93
+
94
+ `info.currentAppId` contains the original Android package name.
95
+
96
+ `info.currentApp` provides a human-readable application name for many common applications, including Netflix, YouTube, Prime Video, ARD, ZDF, RTL+, MagentaTV, Joyn, Zattoo, waipu.tv, DAZN, Spotify, KiKA, WOW, Disney+ and Apple TV.
97
+
98
+ Unknown package names are additionally written to `info.currentAppUnknown` so that the application mapping can easily be extended.
99
+
100
+ ### Controls
101
+
102
+ control.pairingCode
103
+ control.key
104
+ control.text
105
+ control.appLink
106
+ control.power
107
+ control.reconnect
108
+ control.forgetPairing
109
+
110
+ ### Google Cast
111
+
112
+ When Cast monitoring is enabled:
113
+
114
+ info.cast.connection
115
+ info.cast.appId
116
+ info.cast.appName
117
+ info.media.title
118
+ info.media.subtitle
119
+ info.media.playerState
120
+ info.media.contentId
121
+
122
+ ## Media metadata
123
+
124
+ Android TV Remote v2 itself provides information about the currently active Android application, but does not expose complete media metadata for every native Android TV application.
125
+
126
+ The optional Cast integration can provide title, subtitle and playback state when an active Google Cast receiver session exposes this information.
127
+
128
+ Therefore, media metadata may not be available for applications that are running natively on the Android TV device.
129
+
130
+ ## Connection handling
131
+
132
+ The adapter keeps the Android TV Remote v2 connection open instead of periodically disconnecting and rebuilding it.
133
+
134
+ Protocol ping requests are handled by the underlying Remote v2 library.
135
+
136
+ If the initial connection fails or an established connection is lost, the adapter retries the connection using the configured reconnect interval.
137
+
138
+ ## Changelog
139
+
140
+ ### 0.2.5 (2026-10-07)
141
+
142
+ - Improved Remote v2 connection handling
143
+ - Persistent Remote v2 session instead of periodically forcing reconnects
144
+ - Failed initial connections are detected and retried automatically
145
+ - Protocol ping handling is left to the Android TV Remote v2 library
146
+ - Improved reconnect behavior after temporary network/device unavailability
147
+ - Android TV / Google TV power, volume and current-app states
148
+ - Friendly application-name mapping
149
+ - Optional Google Cast media metadata
150
+ - Remote keys, text input, app links and power control
151
+ - Secure pairing with locally stored certificate
152
+
153
+ ## License
154
+
155
+ MIT License
156
+
157
+ Copyright (c) 2026 Henrik Schönhofen (backfisch88)
158
+
159
+ See [LICENSE](LICENSE) for the full license text.
Binary file
@@ -0,0 +1,67 @@
1
+ {
2
+ "type": "panel",
3
+ "items": {
4
+ "host": {
5
+ "type": "text",
6
+ "label": "IP-Adresse / Hostname",
7
+ "help": "IP-Adresse oder Hostname des Android TV / Google TV",
8
+ "sm": 6,
9
+ "newLine": true
10
+ },
11
+ "deviceName": {
12
+ "type": "text",
13
+ "label": "Anzeigename beim Pairing",
14
+ "default": "ioBroker",
15
+ "sm": 6
16
+ },
17
+ "pairingPort": {
18
+ "type": "number",
19
+ "label": "Pairing-Port",
20
+ "default": 6467,
21
+ "min": 1,
22
+ "max": 65535,
23
+ "sm": 4,
24
+ "newLine": true
25
+ },
26
+ "remotePort": {
27
+ "type": "number",
28
+ "label": "Remote-Port",
29
+ "default": 6466,
30
+ "min": 1,
31
+ "max": 65535,
32
+ "sm": 4
33
+ },
34
+ "reconnectSeconds": {
35
+ "type": "number",
36
+ "label": "Reconnect nach Sekunden",
37
+ "default": 15,
38
+ "min": 5,
39
+ "max": 3600,
40
+ "sm": 4
41
+ },
42
+ "debugProtocol": {
43
+ "type": "checkbox",
44
+ "label": "Protokoll-Debug aktivieren",
45
+ "default": false,
46
+ "newLine": true
47
+ },
48
+ "hint": {
49
+ "type": "staticText",
50
+ "text": "Beim ersten Start zeigt der Fernseher einen Pairing-Code. Diesen unter Objekte → androidtvremote.X → control → pairingCode eintragen. Danach wird das Zertifikat lokal gespeichert."
51
+ },
52
+ "enableCast": {
53
+ "type": "checkbox",
54
+ "label": "Cast-Medieninfos aktivieren",
55
+ "default": true,
56
+ "newLine": true
57
+ },
58
+ "castPollSeconds": {
59
+ "type": "number",
60
+ "label": "Cast-Abfrage alle Sekunden",
61
+ "default": 5,
62
+ "min": 3,
63
+ "max": 60,
64
+ "sm": 4
65
+ }
66
+ }
67
+ }
@@ -0,0 +1,105 @@
1
+ {
2
+ "common": {
3
+ "name": "androidtvremote",
4
+ "version": "0.2.6",
5
+ "titleLang": {
6
+ "en": "Android TV Remote",
7
+ "de": "Android TV Remote",
8
+ "ru": "Android TV Remote",
9
+ "pt": "Android TV Remote",
10
+ "nl": "Android TV Remote",
11
+ "fr": "Android TV Remote",
12
+ "it": "Android TV Remote",
13
+ "es": "Android TV Remote",
14
+ "pl": "Android TV Remote",
15
+ "uk": "Android TV Remote",
16
+ "zh-cn": "Android TV Remote"
17
+ },
18
+ "desc": {
19
+ "en": "Control Android TV / Google TV via Android TV Remote v2 protocol without ADB",
20
+ "de": "Android TV / Google TV über das Android TV Remote v2 Protokoll ohne ADB steuern",
21
+ "ru": "Управление Android TV / Google TV через протокол Android TV Remote v2 без ADB",
22
+ "pt": "Controle Android TV / Google TV através do protocolo Android TV Remote v2 sem ADB",
23
+ "nl": "Bedien Android TV / Google TV via het Android TV Remote v2-protocol zonder ADB",
24
+ "fr": "Contrôlez Android TV / Google TV via le protocole Android TV Remote v2 sans ADB",
25
+ "it": "Controlla Android TV / Google TV tramite il protocollo Android TV Remote v2 senza ADB",
26
+ "es": "Controla Android TV / Google TV mediante el protocolo Android TV Remote v2 sin ADB",
27
+ "pl": "Sterowanie Android TV / Google TV przez protokół Android TV Remote v2 bez ADB",
28
+ "uk": "Керування Android TV / Google TV через протокол Android TV Remote v2 без ADB",
29
+ "zh-cn": "通过 Android TV Remote v2 协议控制 Android TV / Google TV,无需 ADB"
30
+ },
31
+ "authors": [
32
+ "Henrik Schönhofen <henrik.schoenhofen@icloud.com>"
33
+ ],
34
+ "license": "MIT",
35
+ "platform": "Javascript/Node.js",
36
+ "mode": "daemon",
37
+ "enabled": true,
38
+ "messagebox": true,
39
+ "compact": true,
40
+ "connectionType": "local",
41
+ "dataSource": "push",
42
+ "adminUI": {
43
+ "config": "json"
44
+ },
45
+ "dataFolder": "androidtvremote.%INSTANCE%",
46
+ "dependencies": [
47
+ {
48
+ "js-controller": ">=6.0.11"
49
+ }
50
+ ],
51
+ "globalDependencies": [
52
+ {
53
+ "admin": ">=7.7.22"
54
+ }
55
+ ],
56
+ "icon": "androidtvremote.png",
57
+ "keywords": [
58
+ "android tv",
59
+ "google tv",
60
+ "remote",
61
+ "androidtv",
62
+ "multimedia"
63
+ ],
64
+ "licenseInformation": {
65
+ "license": "MIT",
66
+ "type": "free"
67
+ },
68
+ "extIcon": "https://raw.githubusercontent.com/backfisch88/ioBroker.androidtvremote/main/admin/androidtvremote.png",
69
+ "readme": "https://github.com/backfisch88/ioBroker.androidtvremote/blob/main/README.md",
70
+ "loglevel": "info",
71
+ "tier": 3,
72
+ "type": "multimedia",
73
+ "news": {
74
+ "0.2.6": {
75
+ "en": "Improved pairing credential persistence. Credentials are no longer deleted automatically on unpaired events.",
76
+ "de": "Verbesserte Speicherung der Pairing-Zugangsdaten. Bei Unpaired-Ereignissen werden Zertifikate nicht mehr automatisch gelöscht."
77
+ },
78
+ "0.2.5": {
79
+ "en": "Initial release with Android TV Remote v2 support, secure pairing, power and volume states, current app detection, remote control commands and optional Google Cast media information.",
80
+ "de": "Erste Veröffentlichung mit Android TV Remote v2, sicherem Pairing, Power- und Lautstärkestatus, Erkennung der aktuellen App, Fernbedienungsbefehlen und optionalen Google-Cast-Medieninformationen.",
81
+ "ru": "Первый выпуск с поддержкой Android TV Remote v2, безопасным сопряжением и дистанционным управлением.",
82
+ "pt": "Versão inicial com suporte ao Android TV Remote v2, emparelhamento seguro e controlo remoto.",
83
+ "nl": "Eerste release met Android TV Remote v2-ondersteuning, veilige koppeling en afstandsbediening.",
84
+ "fr": "Première version avec prise en charge d’Android TV Remote v2, appairage sécurisé et télécommande.",
85
+ "it": "Prima versione con supporto Android TV Remote v2, associazione sicura e controllo remoto.",
86
+ "es": "Versión inicial con compatibilidad con Android TV Remote v2, emparejamiento seguro y control remoto.",
87
+ "pl": "Pierwsza wersja z obsługą Android TV Remote v2, bezpiecznym parowaniem i zdalnym sterowaniem.",
88
+ "uk": "Перший випуск із підтримкою Android TV Remote v2, безпечним сполученням і дистанційним керуванням.",
89
+ "zh-cn": "首个版本,支持 Android TV Remote v2、安全配对和远程控制。"
90
+ }
91
+ }
92
+ },
93
+ "native": {
94
+ "host": "",
95
+ "deviceName": "ioBroker",
96
+ "pairingPort": 6467,
97
+ "remotePort": 6466,
98
+ "reconnectSeconds": 15,
99
+ "debugProtocol": false,
100
+ "enableCast": true,
101
+ "castPollSeconds": 5
102
+ },
103
+ "objects": [],
104
+ "instanceObjects": []
105
+ }
package/main.js ADDED
@@ -0,0 +1,567 @@
1
+ 'use strict';
2
+
3
+ const utils = require('@iobroker/adapter-core');
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+ const { Client: CastClient, DefaultMediaReceiver } = require('castv2-client');
7
+
8
+ class AndroidTvRemoteAdapter extends utils.Adapter {
9
+ constructor(options = {}) {
10
+ super({ ...options, name: 'androidtvremote' });
11
+
12
+ this.remote = null;
13
+ this.remoteModule = null;
14
+ this.reconnectTimer = null;
15
+ this.lastRemoteReady = 0;
16
+ this.stopping = false;
17
+ this.connecting = false;
18
+ this.pairingRequested = false;
19
+ this.dataDir = '';
20
+ this.credentialsFile = '';
21
+ this.lastPowered = null;
22
+ this.castClient = null;
23
+ this.castPlayer = null;
24
+ this.castTimer = null;
25
+ this.castConnecting = false;
26
+
27
+ this.on('ready', this.onReady.bind(this));
28
+ this.on('stateChange', this.onStateChange.bind(this));
29
+ this.on('unload', this.onUnload.bind(this));
30
+ }
31
+
32
+ async onReady() {
33
+ this.stopping = false;
34
+ await this.setStateAsync('info.connection', false, true);
35
+ await this.ensureObjects();
36
+ this.subscribeStates('control.*');
37
+
38
+ if (!this.config.host || !String(this.config.host).trim()) {
39
+ this.log.error('Bitte zuerst die IP-Adresse bzw. den Hostnamen des Android-TV-Geräts konfigurieren.');
40
+ return;
41
+ }
42
+
43
+ this.dataDir = utils.getAbsoluteInstanceDataDir(this);
44
+ this.credentialsFile = path.join(this.dataDir, 'credentials.json');
45
+ await fs.promises.mkdir(this.dataDir, { recursive: true });
46
+
47
+ try {
48
+ this.remoteModule = await import('@kud/androidtv-remote');
49
+ } catch (error) {
50
+ this.log.error(`Android-TV-Remote-Bibliothek konnte nicht geladen werden: ${this.errorText(error)}`);
51
+ return;
52
+ }
53
+
54
+ await this.connect();
55
+ this.startCastMonitor();
56
+ }
57
+
58
+ async ensureObjects() {
59
+ const defs = {
60
+ 'info.connection': { name: 'Connected', type: 'boolean', role: 'indicator.connected', read: true, write: false, def: false },
61
+ 'info.lastRemoteReady': { name: 'Last remote connection', type: 'number', role: 'value.time', read: true, write: false, def: 0 },
62
+ 'info.pairingRequired': { name: 'Pairing required', type: 'boolean', role: 'indicator', read: true, write: false, def: false },
63
+ 'info.powered': { name: 'Powered', type: 'boolean', role: 'indicator.power', read: true, write: false, def: false },
64
+ 'info.currentAppId': { name: 'Current app package', type: 'string', role: 'text', read: true, write: false, def: '' },
65
+ 'info.currentApp': { name: 'Current app', type: 'string', role: 'text', read: true, write: false, def: '' },
66
+ 'info.currentAppUnknown': { name: 'Unknown current app package', type: 'string', role: 'text', read: true, write: false, def: '' },
67
+ 'info.cast.connection': { name: 'Cast connected', type: 'boolean', role: 'indicator.connected', read: true, write: false, def: false },
68
+ 'info.cast.appId': { name: 'Cast app ID', type: 'string', role: 'text', read: true, write: false, def: '' },
69
+ 'info.cast.appName': { name: 'Cast app name', type: 'string', role: 'text', read: true, write: false, def: '' },
70
+ 'info.media.title': { name: 'Media title', type: 'string', role: 'media.title', read: true, write: false, def: '' },
71
+ 'info.media.subtitle': { name: 'Media subtitle', type: 'string', role: 'text', read: true, write: false, def: '' },
72
+ 'info.media.playerState': { name: 'Media player state', type: 'string', role: 'media.state', read: true, write: false, def: '' },
73
+ 'info.media.contentId': { name: 'Media content ID', type: 'string', role: 'text', read: true, write: false, def: '' },
74
+ 'info.volume.level': { name: 'Volume level', type: 'number', role: 'level.volume', read: true, write: false, def: 0 },
75
+ 'info.volume.maximum': { name: 'Maximum volume', type: 'number', role: 'value', read: true, write: false, def: 0 },
76
+ 'info.volume.muted': { name: 'Muted', type: 'boolean', role: 'indicator.mute', read: true, write: false, def: false },
77
+ 'control.pairingCode': { name: 'Pairing code', type: 'string', role: 'text', read: true, write: true, def: '' },
78
+ 'control.key': { name: 'Remote key', type: 'string', role: 'text', read: true, write: true, def: '' },
79
+ 'control.text': { name: 'Send text', type: 'string', role: 'text', read: true, write: true, def: '' },
80
+ 'control.appLink': { name: 'Open app/deep link', type: 'string', role: 'text.url', read: true, write: true, def: '' },
81
+ 'control.power': { name: 'Power target / toggle', type: 'boolean', role: 'switch.power', read: true, write: true, def: false },
82
+ 'control.reconnect': { name: 'Reconnect', type: 'boolean', role: 'button', read: true, write: true, def: false },
83
+ 'control.forgetPairing': { name: 'Forget pairing', type: 'boolean', role: 'button', read: true, write: true, def: false }
84
+ };
85
+
86
+ for (const [id, common] of Object.entries(defs)) {
87
+ await this.setObjectNotExistsAsync(id, { type: 'state', common, native: {} });
88
+ }
89
+ }
90
+
91
+ async loadCertificate() {
92
+ try {
93
+ const raw = await fs.promises.readFile(this.credentialsFile, 'utf8');
94
+ const parsed = JSON.parse(raw);
95
+ if (parsed && typeof parsed.key === 'string' && typeof parsed.cert === 'string') {
96
+ return parsed;
97
+ }
98
+ } catch (error) {
99
+ if (error && error.code !== 'ENOENT') {
100
+ this.log.warn(`Pairing-Zertifikat konnte nicht gelesen werden: ${this.errorText(error)}`);
101
+ }
102
+ }
103
+ return null;
104
+ }
105
+
106
+ async saveCertificate(cert) {
107
+ if (!cert || !cert.key || !cert.cert) return;
108
+ const tmp = `${this.credentialsFile}.tmp`;
109
+ await fs.promises.writeFile(tmp, JSON.stringify(cert, null, 2), { mode: 0o600 });
110
+ await fs.promises.rename(tmp, this.credentialsFile);
111
+ try { await fs.promises.chmod(this.credentialsFile, 0o600); } catch (_) {}
112
+ }
113
+
114
+ async deleteCertificate() {
115
+ try {
116
+ await fs.promises.unlink(this.credentialsFile);
117
+ } catch (error) {
118
+ if (error && error.code !== 'ENOENT') throw error;
119
+ }
120
+ }
121
+
122
+ async connect() {
123
+ if (this.stopping || this.connecting || !this.remoteModule) return;
124
+ this.connecting = true;
125
+ this.clearReconnect();
126
+
127
+ try {
128
+ if (this.remote) {
129
+ try { this.remote.stop(); } catch (_) {}
130
+ this.remote = null;
131
+ }
132
+
133
+ const cert = await this.loadCertificate();
134
+ const options = {
135
+ pairing_port: Number(this.config.pairingPort) || 6467,
136
+ remote_port: Number(this.config.remotePort) || 6466,
137
+ service_name: String(this.config.deviceName || 'ioBroker'),
138
+ debug: !!this.config.debugProtocol
139
+ };
140
+ if (cert) options.cert = cert;
141
+
142
+ const { createAndroidRemote } = this.remoteModule;
143
+ this.remote = createAndroidRemote(String(this.config.host).trim(), options);
144
+ this.attachRemoteEvents(this.remote);
145
+
146
+ this.log.info(`Verbinde mit Android TV ${this.config.host}:${options.remote_port} ...`);
147
+ const started = await this.remote.start();
148
+ if (!started) {
149
+ await this.setStateAsync('info.connection', false, true);
150
+ this.log.info(`Android TV derzeit nicht erreichbar – neuer Versuch in ${Math.max(5, Number(this.config.reconnectSeconds) || 15)} Sekunden.`);
151
+ if (this.remote) {
152
+ try { this.remote.stop(); } catch (_) {}
153
+ this.remote = null;
154
+ }
155
+ this.scheduleReconnect();
156
+ }
157
+ } catch (error) {
158
+ await this.setStateAsync('info.connection', false, true);
159
+ this.log.warn(`Verbindung fehlgeschlagen: ${this.errorText(error)}`);
160
+ this.scheduleReconnect();
161
+ } finally {
162
+ this.connecting = false;
163
+ }
164
+ }
165
+
166
+ attachRemoteEvents(remote) {
167
+ remote.on('secret', async () => {
168
+ this.pairingRequested = true;
169
+ await this.setStateAsync('info.pairingRequired', true, true);
170
+ await this.setStateAsync('info.connection', false, true);
171
+ this.log.info('Pairing erforderlich: Code vom Fernseher in control.pairingCode eintragen.');
172
+ });
173
+
174
+ remote.on('ready', async () => {
175
+ this.pairingRequested = false;
176
+ await this.setStateAsync('info.pairingRequired', false, true);
177
+ await this.setStateAsync('info.connection', true, true);
178
+ this.lastRemoteReady = Date.now();
179
+ await this.setStateAsync('info.lastRemoteReady', this.lastRemoteReady, true);
180
+ try {
181
+ await this.saveCertificate(remote.getCertificate());
182
+ } catch (error) {
183
+ this.log.warn(`Pairing-Zertifikat konnte nicht gespeichert werden: ${this.errorText(error)}`);
184
+ }
185
+ this.log.info('Android TV Remote verbunden.');
186
+ });
187
+
188
+ remote.on('powered', async powered => {
189
+ this.lastPowered = !!powered;
190
+ await this.setStateAsync('info.powered', !!powered, true);
191
+ await this.setStateAsync('control.power', !!powered, true);
192
+ });
193
+
194
+ remote.on('volume', async volume => {
195
+ if (!volume) return;
196
+ await this.setStateAsync('info.volume.level', Number(volume.level) || 0, true);
197
+ await this.setStateAsync('info.volume.maximum', Number(volume.maximum) || 0, true);
198
+ await this.setStateAsync('info.volume.muted', !!volume.muted, true);
199
+ });
200
+
201
+ remote.on('current_app', async app => {
202
+ const appId = typeof app === 'string'
203
+ ? app.trim()
204
+ : String(app?.packageName || app?.package || app?.app || '').trim();
205
+
206
+ if (!appId) return;
207
+
208
+ const mapped = this.appNameMap()[appId];
209
+ await this.setStateAsync('info.currentAppId', appId, true);
210
+ await this.setStateAsync('info.currentApp', mapped || this.friendlyAppName(appId), true);
211
+ await this.setStateAsync('info.currentAppUnknown', mapped ? '' : appId, true);
212
+ });
213
+
214
+ remote.on('unpaired', async () => {
215
+ await this.setStateAsync('info.connection', false, true);
216
+ await this.setStateAsync('info.pairingRequired', true, true);
217
+ this.pairingRequested = true;
218
+
219
+ // Gespeicherte Credentials hier NICHT löschen.
220
+ // Ein temporärer Verbindungs-/Authentifizierungsfehler darf nicht
221
+ // dazu führen, dass ein funktionierendes Pairing dauerhaft
222
+ // verloren geht. Credentials werden ausschließlich über
223
+ // control.forgetPairing bewusst gelöscht.
224
+ this.log.warn(
225
+ 'Android TV meldet "unpaired". Gespeichertes Pairing bleibt erhalten. ' +
226
+ 'Falls ein neues Pairing wirklich erforderlich ist, control.forgetPairing verwenden.'
227
+ );
228
+ });
229
+
230
+ remote.on('error', async error => {
231
+ if (this.stopping) return;
232
+ await this.setStateAsync('info.connection', false, true);
233
+ this.log.warn(`Android-TV-Protokollfehler: ${this.errorText(error)}`);
234
+ this.scheduleReconnect();
235
+ });
236
+ }
237
+
238
+ scheduleReconnect() {
239
+ if (this.stopping || this.reconnectTimer || this.pairingRequested) return;
240
+ const seconds = Math.max(5, Number(this.config.reconnectSeconds) || 15);
241
+ this.reconnectTimer = this.setTimeout(() => {
242
+ this.reconnectTimer = null;
243
+ void this.connect();
244
+ }, seconds * 1000);
245
+ }
246
+
247
+ clearReconnect() {
248
+ if (this.reconnectTimer) {
249
+ this.clearTimeout(this.reconnectTimer);
250
+ this.reconnectTimer = null;
251
+ }
252
+ }
253
+
254
+ async onStateChange(id, state) {
255
+ if (!state || state.ack) return;
256
+ const prefix = `${this.namespace}.`;
257
+ if (!id.startsWith(prefix)) return;
258
+ const rel = id.slice(prefix.length);
259
+
260
+ try {
261
+ switch (rel) {
262
+ case 'control.pairingCode': {
263
+ const code = String(state.val || '').trim();
264
+ if (!code) return;
265
+ if (!this.remote || !this.pairingRequested) {
266
+ this.log.warn('Aktuell wartet der Adapter auf keinen Pairing-Code.');
267
+ } else {
268
+ const accepted = this.remote.sendCode(code);
269
+ if (!accepted) this.log.warn('Pairing-Code konnte nicht gesendet werden.');
270
+ }
271
+ await this.setStateAsync(rel, '', true);
272
+ break;
273
+ }
274
+
275
+ case 'control.key': {
276
+ const key = String(state.val || '').trim();
277
+ if (key) this.sendKey(key);
278
+ await this.setStateAsync(rel, '', true);
279
+ break;
280
+ }
281
+
282
+ case 'control.text': {
283
+ const text = String(state.val ?? '');
284
+ if (text && this.requireRemote()) this.remote.sendText(text);
285
+ await this.setStateAsync(rel, '', true);
286
+ break;
287
+ }
288
+
289
+ case 'control.appLink': {
290
+ const link = String(state.val || '').trim();
291
+ if (link && this.requireRemote()) this.remote.sendAppLink(link);
292
+ await this.setStateAsync(rel, '', true);
293
+ break;
294
+ }
295
+
296
+ case 'control.power': {
297
+ const desired = !!state.val;
298
+ if (this.requireRemote()) {
299
+ if (this.lastPowered === null || desired !== this.lastPowered) {
300
+ this.remote.sendPower();
301
+ }
302
+ }
303
+ await this.setStateAsync(rel, desired, true);
304
+ break;
305
+ }
306
+
307
+ case 'control.reconnect': {
308
+ await this.setStateAsync(rel, false, true);
309
+ await this.setStateAsync('info.connection', false, true);
310
+ this.pairingRequested = false;
311
+ await this.connect();
312
+ break;
313
+ }
314
+
315
+ case 'control.forgetPairing': {
316
+ await this.setStateAsync(rel, false, true);
317
+ await this.deleteCertificate();
318
+ this.pairingRequested = false;
319
+ if (this.remote) {
320
+ try { this.remote.stop(); } catch (_) {}
321
+ this.remote = null;
322
+ }
323
+ await this.setStateAsync('info.connection', false, true);
324
+ await this.setStateAsync('info.pairingRequired', false, true);
325
+ this.log.info('Gespeichertes Pairing wurde gelöscht. Neues Pairing wird gestartet.');
326
+ await this.connect();
327
+ break;
328
+ }
329
+ }
330
+ } catch (error) {
331
+ this.log.error(`Befehl ${rel} fehlgeschlagen: ${this.errorText(error)}`);
332
+ }
333
+ }
334
+
335
+
336
+
337
+ appNameMap() {
338
+ return {
339
+ // Launcher / System
340
+ 'com.google.android.apps.tv.launcherx': 'Google TV Startbildschirm',
341
+ 'com.google.android.tvlauncher': 'Android TV Startbildschirm',
342
+ 'com.google.android.apps.tv.launcher': 'Android TV Startbildschirm',
343
+ 'com.android.tv.settings': 'Einstellungen',
344
+ 'com.android.vending': 'Google Play Store',
345
+ 'com.google.android.videos': 'Google TV',
346
+
347
+ // Streaming / TV
348
+ 'com.netflix.ninja': 'Netflix',
349
+ 'com.google.android.youtube.tv': 'YouTube',
350
+ 'com.google.android.youtube': 'YouTube',
351
+ 'com.amazon.amazonvideo.livingroom': 'Prime Video',
352
+ 'de.swr.avp.ard.tv': 'ARD Mediathek',
353
+ 'de.swr.avp.ard': 'ARD Mediathek',
354
+ 'com.zdf.android.mediathek': 'ZDF',
355
+ 'de.zdf.android.mediathek': 'ZDF',
356
+ 'de.rtli.tvnow': 'RTL+',
357
+ 'de.telekom.magentatv.tv': 'MagentaTV',
358
+ 'de.telekom.magentatv.mobile': 'MagentaTV',
359
+ 'de.telekom.magentatv.atv': 'MagentaTV (1. Generation)',
360
+ 'de.prosiebensat1digital.seventv': 'Joyn',
361
+ 'com.zattoo.player': 'Zattoo',
362
+ 'de.exaring.waipu': 'waipu.tv',
363
+ 'com.dazn': 'DAZN',
364
+ 'de.sky.online': 'WOW',
365
+ 'com.disney.disneyplus': 'Disney+',
366
+ 'com.disneyplus': 'Disney+',
367
+ 'com.apple.atve.androidtv.appletv': 'Apple TV',
368
+ 'com.apple.atve.sony.appletv': 'Apple TV',
369
+
370
+ // Musik
371
+ 'com.google.android.youtube.tvmusic': 'YouTube Music',
372
+ 'com.google.android.apps.youtube.music': 'YouTube Music',
373
+ 'com.spotify.tv.android': 'Spotify',
374
+ 'com.spotify.music': 'Spotify',
375
+
376
+ // Kinder / Media
377
+ 'de.kika.player.androidtv': 'KiKA',
378
+ 'com.plexapp.android': 'Plex',
379
+ 'org.xbmc.kodi': 'Kodi',
380
+ 'org.videolan.vlc': 'VLC',
381
+ 'tv.twitch.android.app': 'Twitch'
382
+ };
383
+ }
384
+
385
+ friendlyAppName(appId) {
386
+ const id = String(appId || '').trim();
387
+ if (!id) return '';
388
+ const known = this.appNameMap();
389
+ if (known[id]) return known[id];
390
+
391
+ // Lesbarer Fallback, Paket-ID bleibt zusätzlich in currentAppUnknown erhalten.
392
+ const parts = id.split('.').filter(Boolean);
393
+ const raw = parts.length ? parts[parts.length - 1] : id;
394
+ return raw.replace(/[_-]+/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
395
+ }
396
+
397
+ startCastMonitor() {
398
+ if (this.config.enableCast === false) return;
399
+ const interval = Math.max(3, Number(this.config.castPollSeconds) || 5);
400
+ const run = async () => {
401
+ if (!this.stopping) await this.pollCast();
402
+ };
403
+ void run();
404
+ this.castTimer = this.setInterval(run, interval * 1000);
405
+ }
406
+
407
+ stopCastMonitor() {
408
+ if (this.castTimer) {
409
+ this.clearInterval(this.castTimer);
410
+ this.castTimer = null;
411
+ }
412
+ if (this.castClient) {
413
+ try { this.castClient.close(); } catch (_) {}
414
+ this.castClient = null;
415
+ this.castPlayer = null;
416
+ }
417
+ }
418
+
419
+ async pollCast() {
420
+ if (this.castConnecting || !this.config.host) return;
421
+ this.castConnecting = true;
422
+ try {
423
+ await this.ensureCastConnected();
424
+ const sessions = await this.castGetSessions();
425
+ if (!Array.isArray(sessions) || sessions.length === 0) {
426
+ await this.clearMediaStates();
427
+ return;
428
+ }
429
+
430
+ const session = sessions[0];
431
+ await this.setStateAsync('info.cast.appId', String(session.appId || ''), true);
432
+ await this.setStateAsync('info.cast.appName', String(session.displayName || ''), true);
433
+
434
+ const status = await this.castJoinAndGetStatus(session);
435
+ if (!status) return;
436
+ const media = status.media || {};
437
+ const metadata = media.metadata || {};
438
+ const title = metadata.title || metadata.seriesTitle || metadata.albumName || '';
439
+ const subtitle = metadata.subtitle || metadata.artist || metadata.studio || '';
440
+ await this.setStateAsync('info.media.title', String(title || ''), true);
441
+ await this.setStateAsync('info.media.subtitle', String(subtitle || ''), true);
442
+ await this.setStateAsync('info.media.playerState', String(status.playerState || ''), true);
443
+ await this.setStateAsync('info.media.contentId', String(media.contentId || ''), true);
444
+ } catch (error) {
445
+ await this.setStateAsync('info.cast.connection', false, true);
446
+ this.castPlayer = null;
447
+ if (this.config.debugProtocol) this.log.debug(`Cast-Abfrage: ${this.errorText(error)}`);
448
+ if (this.castClient) {
449
+ try { this.castClient.close(); } catch (_) {}
450
+ this.castClient = null;
451
+ }
452
+ } finally {
453
+ this.castConnecting = false;
454
+ }
455
+ }
456
+
457
+ ensureCastConnected() {
458
+ if (this.castClient) return Promise.resolve();
459
+ return new Promise((resolve, reject) => {
460
+ const client = new CastClient();
461
+ let settled = false;
462
+ const fail = err => {
463
+ if (settled) return;
464
+ settled = true;
465
+ try { client.close(); } catch (_) {}
466
+ reject(err instanceof Error ? err : new Error(String(err)));
467
+ };
468
+ client.once('error', fail);
469
+ client.connect(String(this.config.host).trim(), () => {
470
+ if (settled) return;
471
+ settled = true;
472
+ client.removeListener('error', fail);
473
+ client.on('error', () => {
474
+ void this.setStateAsync('info.cast.connection', false, true);
475
+ if (this.castClient === client) this.castClient = null;
476
+ });
477
+ this.castClient = client;
478
+ void this.setStateAsync('info.cast.connection', true, true);
479
+ resolve();
480
+ });
481
+ });
482
+ }
483
+
484
+ castGetSessions() {
485
+ return new Promise((resolve, reject) => {
486
+ this.castClient.getSessions((err, sessions) => err ? reject(err) : resolve(sessions || []));
487
+ });
488
+ }
489
+
490
+ castJoinAndGetStatus(session) {
491
+ return new Promise((resolve, reject) => {
492
+ this.castClient.join(session, DefaultMediaReceiver, (err, player) => {
493
+ if (err) return reject(err);
494
+ this.castPlayer = player;
495
+ player.getStatus((statusErr, status) => {
496
+ if (statusErr) return reject(statusErr);
497
+ resolve(status || null);
498
+ });
499
+ });
500
+ });
501
+ }
502
+
503
+ async clearMediaStates() {
504
+ await this.setStateAsync('info.cast.appId', '', true);
505
+ await this.setStateAsync('info.cast.appName', '', true);
506
+ await this.setStateAsync('info.media.title', '', true);
507
+ await this.setStateAsync('info.media.subtitle', '', true);
508
+ await this.setStateAsync('info.media.playerState', '', true);
509
+ await this.setStateAsync('info.media.contentId', '', true);
510
+ }
511
+
512
+ sendKey(input) {
513
+ if (!this.requireRemote()) return;
514
+ const { RemoteKeyCode } = this.remoteModule;
515
+ const normalized = input.trim().toUpperCase().replace(/[ -]+/g, '_');
516
+ const aliases = {
517
+ UP: 'KEYCODE_DPAD_UP', DOWN: 'KEYCODE_DPAD_DOWN', LEFT: 'KEYCODE_DPAD_LEFT', RIGHT: 'KEYCODE_DPAD_RIGHT',
518
+ OK: 'KEYCODE_DPAD_CENTER', ENTER: 'KEYCODE_DPAD_CENTER', CENTER: 'KEYCODE_DPAD_CENTER', SELECT: 'KEYCODE_DPAD_CENTER',
519
+ HOME: 'KEYCODE_HOME', BACK: 'KEYCODE_BACK', MENU: 'KEYCODE_MENU', POWER: 'KEYCODE_POWER',
520
+ PLAY_PAUSE: 'KEYCODE_MEDIA_PLAY_PAUSE', PLAY: 'KEYCODE_MEDIA_PLAY', PAUSE: 'KEYCODE_MEDIA_PAUSE', STOP: 'KEYCODE_MEDIA_STOP',
521
+ NEXT: 'KEYCODE_MEDIA_NEXT', PREVIOUS: 'KEYCODE_MEDIA_PREVIOUS', REWIND: 'KEYCODE_MEDIA_REWIND', FAST_FORWARD: 'KEYCODE_MEDIA_FAST_FORWARD',
522
+ VOLUME_UP: 'KEYCODE_VOLUME_UP', VOLUP: 'KEYCODE_VOLUME_UP', VOLUME_DOWN: 'KEYCODE_VOLUME_DOWN', VOLDOWN: 'KEYCODE_VOLUME_DOWN', MUTE: 'KEYCODE_VOLUME_MUTE'
523
+ };
524
+ const enumName = normalized.startsWith('KEYCODE_') ? normalized : (aliases[normalized] || `KEYCODE_${normalized}`);
525
+ const code = RemoteKeyCode[enumName];
526
+ if (code === undefined) {
527
+ this.log.warn(`Unbekannte Remote-Taste: ${input} (${enumName})`);
528
+ return;
529
+ }
530
+ this.remote.sendKey(code);
531
+ }
532
+
533
+ requireRemote() {
534
+ if (!this.remote) {
535
+ this.log.warn('Android TV ist nicht verbunden.');
536
+ return false;
537
+ }
538
+ return true;
539
+ }
540
+
541
+ errorText(error) {
542
+ return error instanceof Error ? error.message : String(error);
543
+ }
544
+
545
+ async onUnload(callback) {
546
+ try {
547
+ this.stopping = true;
548
+ this.clearReconnect();
549
+ this.stopCastMonitor();
550
+ await this.setStateAsync('info.connection', false, true);
551
+ if (this.remote) {
552
+ try { this.remote.stop(); } catch (_) {}
553
+ this.remote = null;
554
+ }
555
+ } catch (_) {
556
+ // ignore during shutdown
557
+ } finally {
558
+ callback();
559
+ }
560
+ }
561
+ }
562
+
563
+ if (require.main !== module) {
564
+ module.exports = options => new AndroidTvRemoteAdapter(options);
565
+ } else {
566
+ new AndroidTvRemoteAdapter();
567
+ }
package/package.json CHANGED
@@ -1,6 +1,41 @@
1
1
  {
2
2
  "name": "iobroker.androidtvremote",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.6",
4
+ "description": "ioBroker adapter for Android TV / Google TV using the Android TV Remote v2 protocol",
5
+ "author": {
6
+ "name": "Henrik Schönhofen",
7
+ "email": "henrik.schoenhofen@icloud.com"
8
+ },
9
+ "homepage": "https://github.com/backfisch88/ioBroker.androidtvremote",
10
+ "license": "MIT",
11
+ "keywords": [
12
+ "iobroker",
13
+ "android tv",
14
+ "google tv",
15
+ "remote",
16
+ "androidtvremote2"
17
+ ],
18
+ "main": "main.js",
19
+ "files": [
20
+ "admin/",
21
+ "io-package.json",
22
+ "main.js",
23
+ "README.md",
24
+ "LICENSE"
25
+ ],
26
+ "engines": {
27
+ "node": ">=22"
28
+ },
29
+ "dependencies": {
30
+ "@iobroker/adapter-core": "^3.4.3",
31
+ "@kud/androidtv-remote": "^0.1.2",
32
+ "castv2-client": "^1.2.0"
33
+ },
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/backfisch88/ioBroker.androidtvremote.git"
37
+ },
38
+ "bugs": {
39
+ "url": "https://github.com/backfisch88/ioBroker.androidtvremote/issues"
40
+ }
41
+ }