iobroker.androidtvremote 0.0.0-stage → 0.2.7
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 +21 -0
- package/README.md +172 -2
- package/admin/androidtvremote.png +0 -0
- package/admin/jsonConfig.json +67 -0
- package/io-package.json +109 -0
- package/main.js +567 -0
- package/package.json +39 -4
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,173 @@
|
|
|
1
|
-
|
|
1
|
+

|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
# ioBroker.androidtvremote
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/iobroker.androidtvremote)
|
|
6
|
+
[](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.7 (2026-10-09)
|
|
141
|
+
|
|
142
|
+
- Added automated GitHub Actions release workflow
|
|
143
|
+
- Prepared npm publishing through Trusted Publishing
|
|
144
|
+
- Updated release documentation and changelog
|
|
145
|
+
- No changes to adapter runtime behavior
|
|
146
|
+
|
|
147
|
+
### 0.2.6 (2026-10-08)
|
|
148
|
+
|
|
149
|
+
- Improved pairing credential persistence
|
|
150
|
+
- Pairing certificates are no longer automatically deleted on `unpaired` events
|
|
151
|
+
- Pairing information can still be removed manually using `control.forgetPairing`
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
### 0.2.5 (2026-10-07)
|
|
155
|
+
|
|
156
|
+
- Improved Remote v2 connection handling
|
|
157
|
+
- Persistent Remote v2 session instead of periodically forcing reconnects
|
|
158
|
+
- Failed initial connections are detected and retried automatically
|
|
159
|
+
- Protocol ping handling is left to the Android TV Remote v2 library
|
|
160
|
+
- Improved reconnect behavior after temporary network/device unavailability
|
|
161
|
+
- Android TV / Google TV power, volume and current-app states
|
|
162
|
+
- Friendly application-name mapping
|
|
163
|
+
- Optional Google Cast media metadata
|
|
164
|
+
- Remote keys, text input, app links and power control
|
|
165
|
+
- Secure pairing with locally stored certificate
|
|
166
|
+
|
|
167
|
+
## License
|
|
168
|
+
|
|
169
|
+
MIT License
|
|
170
|
+
|
|
171
|
+
Copyright (c) 2026 Henrik Schönhofen (backfisch88)
|
|
172
|
+
|
|
173
|
+
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
|
+
}
|
package/io-package.json
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
{
|
|
2
|
+
"common": {
|
|
3
|
+
"name": "androidtvremote",
|
|
4
|
+
"version": "0.2.7",
|
|
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.7": {
|
|
75
|
+
"en": "Added automated GitHub Actions release workflow for GitHub and npm publishing.",
|
|
76
|
+
"de": "Automatischen GitHub-Actions-Release-Workflow für Veröffentlichungen auf GitHub und npm eingerichtet."
|
|
77
|
+
},
|
|
78
|
+
"0.2.6": {
|
|
79
|
+
"en": "Improved pairing credential persistence. Credentials are no longer deleted automatically on unpaired events.",
|
|
80
|
+
"de": "Verbesserte Speicherung der Pairing-Zugangsdaten. Bei Unpaired-Ereignissen werden Zertifikate nicht mehr automatisch gelöscht."
|
|
81
|
+
},
|
|
82
|
+
"0.2.5": {
|
|
83
|
+
"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.",
|
|
84
|
+
"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.",
|
|
85
|
+
"ru": "Первый выпуск с поддержкой Android TV Remote v2, безопасным сопряжением и дистанционным управлением.",
|
|
86
|
+
"pt": "Versão inicial com suporte ao Android TV Remote v2, emparelhamento seguro e controlo remoto.",
|
|
87
|
+
"nl": "Eerste release met Android TV Remote v2-ondersteuning, veilige koppeling en afstandsbediening.",
|
|
88
|
+
"fr": "Première version avec prise en charge d’Android TV Remote v2, appairage sécurisé et télécommande.",
|
|
89
|
+
"it": "Prima versione con supporto Android TV Remote v2, associazione sicura e controllo remoto.",
|
|
90
|
+
"es": "Versión inicial con compatibilidad con Android TV Remote v2, emparejamiento seguro y control remoto.",
|
|
91
|
+
"pl": "Pierwsza wersja z obsługą Android TV Remote v2, bezpiecznym parowaniem i zdalnym sterowaniem.",
|
|
92
|
+
"uk": "Перший випуск із підтримкою Android TV Remote v2, безпечним сполученням і дистанційним керуванням.",
|
|
93
|
+
"zh-cn": "首个版本,支持 Android TV Remote v2、安全配对和远程控制。"
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
},
|
|
97
|
+
"native": {
|
|
98
|
+
"host": "",
|
|
99
|
+
"deviceName": "ioBroker",
|
|
100
|
+
"pairingPort": 6467,
|
|
101
|
+
"remotePort": 6466,
|
|
102
|
+
"reconnectSeconds": 15,
|
|
103
|
+
"debugProtocol": false,
|
|
104
|
+
"enableCast": true,
|
|
105
|
+
"castPollSeconds": 5
|
|
106
|
+
},
|
|
107
|
+
"objects": [],
|
|
108
|
+
"instanceObjects": []
|
|
109
|
+
}
|
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.7",
|
|
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
|
+
}
|