iobroker.arylic 0.0.0-stage → 0.0.2

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 Timo Schneider <timo@schnti.de>
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,98 @@
1
- # Temporary Holding Version
1
+ ![Logo](admin/arylic.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.arylic
4
+
5
+ [![NPM version](https://img.shields.io/npm/v/iobroker.arylic.svg)](https://www.npmjs.com/package/iobroker.arylic)
6
+ [![Downloads](https://img.shields.io/npm/dm/iobroker.arylic.svg)](https://www.npmjs.com/package/iobroker.arylic)
7
+ ![Test and Release](https://github.com/schnti/ioBroker.arylic/workflows/Test%20and%20Release/badge.svg)
8
+
9
+ Controls Arylic amplifiers over the network — the settings that neither AirPlay nor the web interface
10
+ offer: a maximum volume, tone, input, LEDs, standby.
11
+
12
+ Playback stays with AirPlay, DLNA or Music Assistant. This adapter does not play anything.
13
+
14
+ ## Supported devices
15
+
16
+ | Device | Status |
17
+ | --- | --- |
18
+ | Arylic LA50 | tested |
19
+ | Up2Stream Mini/Pro/Amp V3 and other LinkPlay-based devices | not supported yet — see below |
20
+
21
+ The LA50 forwards plain text on TCP port 2018 to its amplifier chip, in the format of the
22
+ [Arylic UART API](https://developer.arylic.com/uartapi/). The LinkPlay-based Up2Stream boards accept
23
+ the same commands, but wrapped (`MCU+PAS+RAKOIT:…&`) in their TCP API. The adapter is built so that
24
+ this can be added as a second transport — if you own such a device and can test, please open an issue.
25
+
26
+ ## Finding devices
27
+
28
+ With **Find devices automatically** on, the adapter listens for AirPlay announcements (mDNS) carrying
29
+ `manufacturer=Arylic`. The serial number is the device's object id, so a new IP address is picked up
30
+ without any change. ioBroker must be in the same network segment; in Docker, use host networking.
31
+
32
+ Devices in another subnet can be entered by IP address.
33
+
34
+ ## States
35
+
36
+ Per device, below `arylic.0.<serial>`:
37
+
38
+ | State | Command | Writable | Notes |
39
+ | --- | --- | --- | --- |
40
+ | `volume` | `VOL` | yes | 0–100 |
41
+ | `maxVolume` | `MXV` | yes | 30–100, upper limit for every source; kept after a factory reset |
42
+ | `mute` | `MUT` | yes | |
43
+ | `source` | `SRC` | yes | one of `sources` |
44
+ | `bass`, `mid`, `treble` | `BAS`, `MID`, `TRE` | yes | −10…10 dB |
45
+ | `led` | `LED` | yes | |
46
+ | `beep` | `BEP` | yes | confirmation tone |
47
+ | `name` | `NAM` | yes | device name, also the AirPlay name |
48
+ | `playing` | `PLA` | no | |
49
+ | `sources` | `LST` | no | available inputs |
50
+ | `standby` | `SYS:STANDBY` | button | ⚠️ see below |
51
+ | `info.connected` | | no | TCP connection to the device |
52
+ | `info.ip` | `IPA` | no | |
53
+ | `info.mcuVersion` | `VER` | no | amplifier chip firmware |
54
+ | `info.model`, `info.serial`, `info.firmware` | | no | from the mDNS announcement |
55
+
56
+ A write is a command (`ack=false`). The device's answer sets the value again with `ack=true` — if it
57
+ stays `ack=false`, the device did not accept the command.
58
+
59
+ ⚠️ **Standby:** Nothing over the network wakes an LA50 from standby — the web interface is gone as
60
+ well, and the UART API has no wake command. Only the IR remote brings it back.
61
+
62
+ ## Changelog
63
+
64
+ <!--
65
+ Placeholder for the next version (at the beginning of the line):
66
+ ### **WORK IN PROGRESS**
67
+ -->
68
+ ### 0.0.2 (2026-10-13)
69
+
70
+ - (Timo Schneider) published through GitHub Actions (npm trusted publishing)
71
+
72
+ ### 0.0.1 (2026-10-13)
73
+
74
+ - (Timo Schneider) initial release
75
+
76
+ ## License
77
+
78
+ MIT License
79
+
80
+ Copyright (c) 2026 Timo Schneider <timo@schnti.de>
81
+
82
+ Permission is hereby granted, free of charge, to any person obtaining a copy
83
+ of this software and associated documentation files (the "Software"), to deal
84
+ in the Software without restriction, including without limitation the rights
85
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
86
+ copies of the Software, and to permit persons to whom the Software is
87
+ furnished to do so, subject to the following conditions:
88
+
89
+ The above copyright notice and this permission notice shall be included in all
90
+ copies or substantial portions of the Software.
91
+
92
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
93
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
94
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
95
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
96
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
97
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
98
+ SOFTWARE.
Binary file
@@ -0,0 +1,10 @@
1
+ {
2
+ "Find devices automatically": "Geräte automatisch finden",
3
+ "Arylic devices announce themselves via mDNS (AirPlay). Needs ioBroker in the same network segment; in Docker use host networking.": "Arylic-Geräte melden sich per mDNS (AirPlay). ioBroker muss im selben Netzsegment sein; in Docker Host-Netzwerk verwenden.",
4
+ "Poll interval": "Abfrageintervall",
5
+ "The device reports changes on its own; the poll catches anything missed.": "Das Gerät meldet Änderungen selbst; die Abfrage fängt Verpasstes auf.",
6
+ "Devices": "Geräte",
7
+ "Only needed for devices discovery cannot find, e.g. in another subnet.": "Nur nötig für Geräte, die die Suche nicht findet, z. B. in einem anderen Subnetz.",
8
+ "Name": "Name",
9
+ "IP address": "IP-Adresse"
10
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "Find devices automatically": "Find devices automatically",
3
+ "Arylic devices announce themselves via mDNS (AirPlay). Needs ioBroker in the same network segment; in Docker use host networking.": "Arylic devices announce themselves via mDNS (AirPlay). Needs ioBroker in the same network segment; in Docker use host networking.",
4
+ "Poll interval": "Poll interval",
5
+ "The device reports changes on its own; the poll catches anything missed.": "The device reports changes on its own; the poll catches anything missed.",
6
+ "Devices": "Devices",
7
+ "Only needed for devices discovery cannot find, e.g. in another subnet.": "Only needed for devices discovery cannot find, e.g. in another subnet.",
8
+ "Name": "Name",
9
+ "IP address": "IP address"
10
+ }
@@ -0,0 +1,72 @@
1
+ {
2
+ "i18n": true,
3
+ "type": "panel",
4
+ "items": {
5
+ "discovery": {
6
+ "type": "checkbox",
7
+ "label": "Find devices automatically",
8
+ "help": "Arylic devices announce themselves via mDNS (AirPlay). Needs ioBroker in the same network segment; in Docker use host networking.",
9
+ "xs": 12,
10
+ "sm": 12,
11
+ "md": 6,
12
+ "lg": 6,
13
+ "xl": 6
14
+ },
15
+ "pollInterval": {
16
+ "type": "number",
17
+ "label": "Poll interval",
18
+ "help": "The device reports changes on its own; the poll catches anything missed.",
19
+ "unit": "s",
20
+ "min": 10,
21
+ "max": 3600,
22
+ "default": 60,
23
+ "xs": 12,
24
+ "sm": 12,
25
+ "md": 6,
26
+ "lg": 6,
27
+ "xl": 6
28
+ },
29
+ "devicesHeader": {
30
+ "type": "header",
31
+ "text": "Devices",
32
+ "size": 3,
33
+ "newLine": true
34
+ },
35
+ "devicesHelp": {
36
+ "type": "staticText",
37
+ "text": "Only needed for devices discovery cannot find, e.g. in another subnet.",
38
+ "xs": 12,
39
+ "sm": 12,
40
+ "md": 12,
41
+ "lg": 12,
42
+ "xl": 12
43
+ },
44
+ "devices": {
45
+ "type": "table",
46
+ "newLine": true,
47
+ "xs": 12,
48
+ "sm": 12,
49
+ "md": 12,
50
+ "lg": 12,
51
+ "xl": 12,
52
+ "items": [
53
+ {
54
+ "type": "text",
55
+ "attr": "name",
56
+ "title": "Name",
57
+ "width": "40%",
58
+ "validator": "data.name && data.name.length > 0",
59
+ "validatorNoSaveOnError": true
60
+ },
61
+ {
62
+ "type": "text",
63
+ "attr": "host",
64
+ "title": "IP address",
65
+ "width": "60%",
66
+ "validator": "data.host && data.host.length > 0",
67
+ "validatorNoSaveOnError": true
68
+ }
69
+ ]
70
+ }
71
+ }
72
+ }
@@ -0,0 +1,127 @@
1
+ {
2
+ "common": {
3
+ "name": "arylic",
4
+ "version": "0.0.2",
5
+ "news": {
6
+ "0.0.2": {
7
+ "en": "published through GitHub Actions (npm trusted publishing)",
8
+ "de": "veröffentlicht über GitHub Actions (npm trusted publishing)",
9
+ "ru": "опубликовано через GitHub Actions (надежная публикация npm)",
10
+ "pt": "publicado no GitHub Actions (publicação confiável npm)",
11
+ "nl": "gepubliceerd via GitHub Actions (npm trusted publishing)",
12
+ "fr": "publié via GitHub Actions (npm Trusted Publishing)",
13
+ "it": "pubblicato tramite GitHub Actions (npm trusted publishing)",
14
+ "es": "publicado a través de GitHub Actions (npm trusted publishing)",
15
+ "pl": "opublikowane za pośrednictwem GitHub Actions (zaufane publikowanie npm)",
16
+ "uk": "опубліковано через дії GitHub (довірена публікація npm)",
17
+ "zh-cn": "通过 GitHub Actions(npm 信任发布)发布"
18
+ },
19
+ "0.0.1": {
20
+ "en": "initial release",
21
+ "de": "Erstveröffentlichung",
22
+ "ru": "первоначальный выпуск",
23
+ "pt": "lançamento inicial",
24
+ "nl": "eerste release",
25
+ "fr": "version initiale",
26
+ "it": "prima versione",
27
+ "es": "versión inicial",
28
+ "pl": "pierwsze wydanie",
29
+ "uk": "початковий випуск",
30
+ "zh-cn": "首次发布"
31
+ }
32
+ },
33
+ "titleLang": {
34
+ "en": "Arylic amplifiers",
35
+ "de": "Arylic-Verstärker",
36
+ "ru": "Усилители Arylic",
37
+ "pt": "Amplificadores Arylic",
38
+ "nl": "Arylic-versterkers",
39
+ "fr": "Amplificateurs Arylic",
40
+ "it": "Amplificatori Arylic",
41
+ "es": "Amplificadores Arylic",
42
+ "pl": "Wzmacniacze Arylic",
43
+ "uk": "Підсилювачі Arylic",
44
+ "zh-cn": "Arylic 功放"
45
+ },
46
+ "desc": {
47
+ "en": "Controls Arylic amplifiers (LA50) over the network: volume limit, tone, input, LED, standby",
48
+ "de": "Steuert Arylic-Verstärker (LA50) über das Netzwerk: Lautstärkebegrenzung, Klang, Eingang, LED, Standby",
49
+ "ru": "Управление усилителями Arylic (LA50) по сети: ограничение громкости, тембр, вход, светодиод, режим ожидания",
50
+ "pt": "Controla amplificadores Arylic (LA50) pela rede: limite de volume, tom, entrada, LED, standby",
51
+ "nl": "Bedient Arylic-versterkers (LA50) via het netwerk: volumebegrenzing, klank, ingang, LED, stand-by",
52
+ "fr": "Contrôle les amplificateurs Arylic (LA50) via le réseau : limite de volume, tonalité, entrée, LED, veille",
53
+ "it": "Controlla gli amplificatori Arylic (LA50) via rete: limite di volume, toni, ingresso, LED, standby",
54
+ "es": "Controla amplificadores Arylic (LA50) por la red: límite de volumen, tono, entrada, LED, espera",
55
+ "pl": "Steruje wzmacniaczami Arylic (LA50) przez sieć: limit głośności, barwa, wejście, LED, czuwanie",
56
+ "uk": "Керування підсилювачами Arylic (LA50) через мережу: обмеження гучності, тембр, вхід, світлодіод, режим очікування",
57
+ "zh-cn": "通过网络控制 Arylic 功放(LA50):音量上限、音调、输入、LED、待机"
58
+ },
59
+ "authors": [
60
+ "Timo Schneider <timo@schnti.de>"
61
+ ],
62
+ "keywords": [
63
+ "arylic",
64
+ "LA50",
65
+ "amplifier",
66
+ "audio"
67
+ ],
68
+ "licenseInformation": {
69
+ "type": "free",
70
+ "license": "MIT"
71
+ },
72
+ "platform": "Javascript/Node.js",
73
+ "icon": "arylic.png",
74
+ "enabled": true,
75
+ "extIcon": "https://raw.githubusercontent.com/schnti/ioBroker.arylic/main/admin/arylic.png",
76
+ "readme": "https://github.com/schnti/ioBroker.arylic/blob/main/README.md",
77
+ "loglevel": "info",
78
+ "tier": 3,
79
+ "mode": "daemon",
80
+ "type": "multimedia",
81
+ "compact": true,
82
+ "connectionType": "local",
83
+ "dataSource": "push",
84
+ "adminUI": {
85
+ "config": "json"
86
+ },
87
+ "dependencies": [
88
+ {
89
+ "js-controller": ">=6.0.11"
90
+ }
91
+ ],
92
+ "globalDependencies": [
93
+ {
94
+ "admin": ">=7.4.10"
95
+ }
96
+ ]
97
+ },
98
+ "native": {
99
+ "discovery": true,
100
+ "pollInterval": 60,
101
+ "devices": []
102
+ },
103
+ "objects": [],
104
+ "instanceObjects": [
105
+ {
106
+ "_id": "info",
107
+ "type": "channel",
108
+ "common": {
109
+ "name": "Information"
110
+ },
111
+ "native": {}
112
+ },
113
+ {
114
+ "_id": "info.connection",
115
+ "type": "state",
116
+ "common": {
117
+ "role": "indicator.connected",
118
+ "name": "At least one device connected",
119
+ "type": "boolean",
120
+ "read": true,
121
+ "write": false,
122
+ "def": false
123
+ },
124
+ "native": {}
125
+ }
126
+ ]
127
+ }
package/lib/device.js ADDED
@@ -0,0 +1,264 @@
1
+ 'use strict';
2
+
3
+ const { RawTcpTransport } = require('./transport');
4
+ const { KINDS, FIELDS, BY_ID, parseMessage, commandFor } = require('./fields');
5
+
6
+ const ANSWER_MS = 1500;
7
+ const RECONNECT_MIN_MS = 10 * 1000;
8
+ const RECONNECT_MAX_MS = 5 * 60 * 1000;
9
+
10
+ const ON_CONNECT = FIELDS.filter(f => f.kind !== 'button').map(f => f.code);
11
+ const ON_POLL = FIELDS.filter(f => f.kind !== 'button' && !f.once).map(f => f.code);
12
+
13
+ /**
14
+ * One amplifier: keeps the connection, reads all values on connect and on every
15
+ * poll, and turns writes from ioBroker into commands.
16
+ *
17
+ * Restart-safe: nothing is remembered, every (re)connect reads the device.
18
+ * A write (ack=false) is a command; the device's answer sets the value again
19
+ * with ack=true — if no answer comes, the command was not accepted.
20
+ */
21
+ class ArylicDevice {
22
+ /**
23
+ * @param {ioBroker.Adapter} adapter adapter instance
24
+ * @param {object} options device options
25
+ * @param {string} options.id object id below the instance
26
+ * @param {string} options.name display name
27
+ * @param {string} options.host IP address or host name
28
+ * @param {number} options.pollMs poll interval in ms
29
+ * @param {(device: ArylicDevice) => void} options.onConnectionChange called when connected changes
30
+ */
31
+ constructor(adapter, { id, name, host, pollMs, onConnectionChange }) {
32
+ this.adapter = adapter;
33
+ this.id = id;
34
+ this.name = name;
35
+ this.host = host;
36
+ // Set by discovery; matches a device whose address changed.
37
+ this.serial = null;
38
+ this.pollMs = pollMs;
39
+ this.onConnectionChange = onConnectionChange;
40
+
41
+ this.transport = null;
42
+ this.connected = false;
43
+ this.stopped = false;
44
+ this.delay = RECONNECT_MIN_MS;
45
+ this.reconnectTimer = null;
46
+ this.pollTimer = null;
47
+ this.waiting = null;
48
+ this.waitTimer = null;
49
+ // Pending commands by code: a newer write replaces a queued one, a read
50
+ // is skipped if that code is already queued.
51
+ this.queue = new Map();
52
+ }
53
+
54
+ /** Creates or updates the device's objects. */
55
+ async createObjects() {
56
+ await this.adapter.extendObjectAsync(this.id, {
57
+ type: 'device',
58
+ common: { name: this.name },
59
+ native: { host: this.host },
60
+ });
61
+ await this.adapter.extendObjectAsync(`${this.id}.info`, {
62
+ type: 'channel',
63
+ common: { name: 'Information' },
64
+ native: {},
65
+ });
66
+ for (const field of FIELDS) {
67
+ const kind = KINDS[field.kind];
68
+ await this.adapter.extendObjectAsync(`${this.id}.${field.id}`, {
69
+ type: 'state',
70
+ common: {
71
+ name: field.code,
72
+ type: kind.type,
73
+ def: kind.def,
74
+ read: field.kind !== 'button',
75
+ write: field.write,
76
+ ...field.common,
77
+ },
78
+ native: { code: field.code },
79
+ });
80
+ }
81
+ await this.adapter.extendObjectAsync(`${this.id}.info.connected`, {
82
+ type: 'state',
83
+ common: {
84
+ name: 'Connected',
85
+ type: 'boolean',
86
+ role: 'indicator.reachable',
87
+ read: true,
88
+ write: false,
89
+ def: false,
90
+ },
91
+ native: {},
92
+ });
93
+ }
94
+
95
+ /**
96
+ * Sets read-only information that comes from outside the UART API (mDNS).
97
+ *
98
+ * @param {Record<string, string>} info e.g. { model, serial, firmware }
99
+ */
100
+ async setInfo(info) {
101
+ for (const [key, val] of Object.entries(info)) {
102
+ if (val === undefined) {
103
+ continue;
104
+ }
105
+ await this.adapter.extendObjectAsync(`${this.id}.info.${key}`, {
106
+ type: 'state',
107
+ common: { name: key, type: 'string', role: 'text', read: true, write: false },
108
+ native: {},
109
+ });
110
+ await this.adapter.setStateAsync(`${this.id}.info.${key}`, val, true);
111
+ }
112
+ }
113
+
114
+ start() {
115
+ this.stopped = false;
116
+ this.connect();
117
+ }
118
+
119
+ stop() {
120
+ this.stopped = true;
121
+ this.adapter.clearTimeout(this.reconnectTimer);
122
+ this.adapter.clearTimeout(this.waitTimer);
123
+ this.adapter.clearInterval(this.pollTimer);
124
+ if (this.transport) {
125
+ this.transport.destroy();
126
+ this.transport = null;
127
+ }
128
+ this.setConnected(false);
129
+ }
130
+
131
+ /**
132
+ * Follows an address change (mDNS) by reconnecting right away.
133
+ *
134
+ * @param {string} host new IP address
135
+ */
136
+ setHost(host) {
137
+ if (host === this.host) {
138
+ return;
139
+ }
140
+ this.adapter.log.info(`${this.name}: address changed ${this.host} → ${host}`);
141
+ this.host = host;
142
+ this.stop();
143
+ this.delay = RECONNECT_MIN_MS;
144
+ this.start();
145
+ }
146
+
147
+ connect() {
148
+ const transport = new RawTcpTransport(this.host);
149
+ this.transport = transport;
150
+
151
+ transport.on('connect', () => {
152
+ this.adapter.log.info(`${this.name}: connected to ${this.host}`);
153
+ this.delay = RECONNECT_MIN_MS;
154
+ this.setConnected(true);
155
+ ON_CONNECT.forEach(code => this.send(code));
156
+ this.pollTimer = this.adapter.setInterval(() => ON_POLL.forEach(code => this.send(code)), this.pollMs);
157
+ });
158
+ transport.on('message', message => this.handle(message));
159
+ transport.on('error', err => this.adapter.log.debug(`${this.name}: ${err.message}`));
160
+ transport.on('close', () => {
161
+ this.adapter.clearInterval(this.pollTimer);
162
+ this.queue.clear();
163
+ this.done();
164
+ this.setConnected(false);
165
+ if (this.stopped) {
166
+ return;
167
+ }
168
+ this.reconnectTimer = this.adapter.setTimeout(() => this.connect(), this.delay);
169
+ this.delay = Math.min(this.delay * 2, RECONNECT_MAX_MS);
170
+ });
171
+ transport.connect();
172
+ }
173
+
174
+ /**
175
+ * @param {boolean} connected new connection state
176
+ */
177
+ setConnected(connected) {
178
+ if (connected === this.connected) {
179
+ return;
180
+ }
181
+ this.connected = connected;
182
+ this.adapter.setState(`${this.id}.info.connected`, connected, true);
183
+ if (!connected && !this.stopped) {
184
+ this.adapter.log.warn(`${this.name}: connection to ${this.host} lost`);
185
+ }
186
+ this.onConnectionChange(this);
187
+ }
188
+
189
+ /**
190
+ * Queues a read (`text` undefined) or a write.
191
+ *
192
+ * @param {string} code UART command
193
+ * @param {string} [text] full command text for a write
194
+ */
195
+ send(code, text) {
196
+ if (text !== undefined) {
197
+ this.queue.set(code, text);
198
+ } else if (!this.queue.has(code)) {
199
+ this.queue.set(code, code);
200
+ }
201
+ this.pump();
202
+ }
203
+
204
+ // ⚠️ One command at a time: sent in a burst, the MCU drops some of the answers.
205
+ // The next one goes out when the answer arrives, or after ANSWER_MS without one.
206
+ pump() {
207
+ if (this.waiting || !this.queue.size || !this.transport) {
208
+ return;
209
+ }
210
+ const [code, text] = this.queue.entries().next().value;
211
+ this.queue.delete(code);
212
+ if (!this.transport.send(text)) {
213
+ this.queue.clear();
214
+ return;
215
+ }
216
+ this.waiting = code;
217
+ this.waitTimer = this.adapter.setTimeout(() => this.done(), ANSWER_MS);
218
+ }
219
+
220
+ done() {
221
+ this.adapter.clearTimeout(this.waitTimer);
222
+ this.waiting = null;
223
+ this.pump();
224
+ }
225
+
226
+ /**
227
+ * @param {string} message one message from the device, without `;`
228
+ */
229
+ handle(message) {
230
+ const parsed = parseMessage(message);
231
+ if (!parsed) {
232
+ return;
233
+ }
234
+ if (parsed.code === this.waiting) {
235
+ this.done();
236
+ }
237
+ // Everything else (NET, CFG, UPG, …) is chatter between the firmware and the MCU.
238
+ if (parsed.field) {
239
+ this.adapter.setState(`${this.id}.${parsed.field.id}`, parsed.value, true);
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Turns a write from ioBroker into a command.
245
+ *
246
+ * @param {string} stateId state id below the device, e.g. `volume`
247
+ * @param {ioBroker.StateValue} val written value
248
+ */
249
+ write(stateId, val) {
250
+ const field = BY_ID.get(stateId);
251
+ if (!field || !field.write) {
252
+ return;
253
+ }
254
+ const text = commandFor(field, val);
255
+ if (text) {
256
+ this.send(field.code, text);
257
+ }
258
+ if (field.kind === 'button') {
259
+ this.adapter.setState(`${this.id}.${field.id}`, false, true);
260
+ }
261
+ }
262
+ }
263
+
264
+ module.exports = { ArylicDevice };
package/lib/fields.js ADDED
@@ -0,0 +1,100 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Commands of the Arylic UART API (https://developer.arylic.com/uartapi/).
5
+ * `CODE;` reads, `CODE:value;` sets, the device answers `CODE:value;` and also
6
+ * pushes changes on its own.
7
+ */
8
+
9
+ // How a value is read from and written to the device, per kind.
10
+ // `button` fields are never read; a write sends `code:value` and springs back.
11
+ const KINDS = {
12
+ int: {
13
+ type: 'number',
14
+ def: 0,
15
+ parse: raw => parseInt(raw, 10),
16
+ format: val => String(Math.round(Number(val))),
17
+ },
18
+ bool: {
19
+ type: 'boolean',
20
+ def: false,
21
+ parse: raw => raw === '1',
22
+ format: val => (val ? '1' : '0'),
23
+ },
24
+ text: {
25
+ type: 'string',
26
+ def: '',
27
+ parse: raw => raw,
28
+ format: val => String(val),
29
+ },
30
+ // UTF-8 text sent as hex digits, e.g. the device name.
31
+ hex: {
32
+ type: 'string',
33
+ def: '',
34
+ parse: raw => Buffer.from(raw, 'hex').toString('utf8'),
35
+ format: val => Buffer.from(String(val), 'utf8').toString('hex').toUpperCase(),
36
+ },
37
+ button: {
38
+ type: 'boolean',
39
+ def: false,
40
+ },
41
+ };
42
+
43
+ // id: state below the device. once: read on connect only, the value does not
44
+ // change at runtime.
45
+ const FIELDS = [
46
+ { code: 'VOL', id: 'volume', kind: 'int', write: true, common: { role: 'level.volume', min: 0, max: 100 } },
47
+ { code: 'MXV', id: 'maxVolume', kind: 'int', write: true, common: { role: 'level.volume.max', min: 30, max: 100 } },
48
+ { code: 'MUT', id: 'mute', kind: 'bool', write: true, common: { role: 'media.mute' } },
49
+ { code: 'SRC', id: 'source', kind: 'text', write: true, common: { role: 'media.input' } },
50
+ { code: 'BAS', id: 'bass', kind: 'int', write: true, common: { role: 'level', min: -10, max: 10, unit: 'dB' } },
51
+ { code: 'MID', id: 'mid', kind: 'int', write: true, common: { role: 'level', min: -10, max: 10, unit: 'dB' } },
52
+ { code: 'TRE', id: 'treble', kind: 'int', write: true, common: { role: 'level', min: -10, max: 10, unit: 'dB' } },
53
+ { code: 'LED', id: 'led', kind: 'bool', write: true, common: { role: 'switch' } },
54
+ { code: 'BEP', id: 'beep', kind: 'bool', write: true, common: { role: 'switch' } },
55
+ { code: 'NAM', id: 'name', kind: 'hex', write: true, common: { role: 'info.name' } },
56
+ { code: 'PLA', id: 'playing', kind: 'bool', write: false, common: { role: 'media.state' } },
57
+ { code: 'LST', id: 'sources', kind: 'text', write: false, once: true, common: { role: 'text' } },
58
+ { code: 'IPA', id: 'info.ip', kind: 'text', write: false, common: { role: 'info.ip' } },
59
+ { code: 'VER', id: 'info.mcuVersion', kind: 'text', write: false, once: true, common: { role: 'info.firmware' } },
60
+ // ⚠️ Nothing over the network wakes the LA50 up again (no web UI, no command) — only the IR remote.
61
+ { code: 'SYS', id: 'standby', kind: 'button', value: 'STANDBY', write: true, common: { role: 'button' } },
62
+ ];
63
+
64
+ const BY_CODE = new Map(FIELDS.filter(f => f.kind !== 'button').map(f => [f.code, f]));
65
+ const BY_ID = new Map(FIELDS.map(f => [f.id, f]));
66
+
67
+ /**
68
+ * Splits one message such as `VOL:30` into its field and parsed value.
69
+ *
70
+ * @param {string} message one message without the trailing `;`
71
+ * @returns {{ code: string, field?: object, value?: any } | null} null if it is no `CODE:value` message
72
+ */
73
+ function parseMessage(message) {
74
+ const sep = message.indexOf(':');
75
+ if (sep < 0) {
76
+ return null;
77
+ }
78
+ const code = message.slice(0, sep);
79
+ const field = BY_CODE.get(code);
80
+ if (!field) {
81
+ return { code };
82
+ }
83
+ return { code, field, value: KINDS[field.kind].parse(message.slice(sep + 1)) };
84
+ }
85
+
86
+ /**
87
+ * Builds the command text for a write to a field, without the trailing `;`.
88
+ *
89
+ * @param {object} field entry of FIELDS
90
+ * @param {any} val value written by the user
91
+ * @returns {string | null} null for a button that was not pressed
92
+ */
93
+ function commandFor(field, val) {
94
+ if (field.kind === 'button') {
95
+ return val ? `${field.code}:${field.value}` : null;
96
+ }
97
+ return `${field.code}:${KINDS[field.kind].format(val)}`;
98
+ }
99
+
100
+ module.exports = { KINDS, FIELDS, BY_CODE, BY_ID, parseMessage, commandFor };
@@ -0,0 +1,81 @@
1
+ 'use strict';
2
+
3
+ const net = require('node:net');
4
+ const { EventEmitter } = require('node:events');
5
+
6
+ /**
7
+ * Raw UART API over TCP: the LA50 forwards plain text on port 2018 to its
8
+ * amplifier MCU and back. Messages are separated by `;`.
9
+ *
10
+ * Other Arylic devices (Up2Stream, LinkPlay platform) wrap the same commands as
11
+ * `MCU+PAS+RAKOIT:{message}&` in their TCP API — that would be a second class
12
+ * with the same events and methods.
13
+ *
14
+ * Events: `connect`, `message` (string, without `;`), `close`, `error`.
15
+ */
16
+ class RawTcpTransport extends EventEmitter {
17
+ static DEFAULT_PORT = 2018;
18
+
19
+ /**
20
+ * @param {string} host IP address or host name
21
+ * @param {number} [port] TCP port of the passthrough
22
+ */
23
+ constructor(host, port = RawTcpTransport.DEFAULT_PORT) {
24
+ super();
25
+ this.host = host;
26
+ this.port = port;
27
+ this.socket = null;
28
+ this.buffer = '';
29
+ }
30
+
31
+ connect() {
32
+ this.buffer = '';
33
+ const socket = net.createConnection({ host: this.host, port: this.port });
34
+ this.socket = socket;
35
+ socket.setEncoding('utf8');
36
+ socket.on('connect', () => this.emit('connect'));
37
+ socket.on('data', chunk => this.receive(chunk));
38
+ socket.on('error', err => this.emit('error', err));
39
+ socket.on('close', () => this.emit('close'));
40
+ }
41
+
42
+ /**
43
+ * Splits incoming text at `;`; an incomplete rest waits for the next chunk.
44
+ *
45
+ * @param {string} chunk text as received from the socket
46
+ */
47
+ receive(chunk) {
48
+ this.buffer += chunk;
49
+ const parts = this.buffer.split(';');
50
+ this.buffer = parts.pop();
51
+ for (const part of parts) {
52
+ const message = part.trim();
53
+ if (message) {
54
+ this.emit('message', message);
55
+ }
56
+ }
57
+ }
58
+
59
+ /**
60
+ * @param {string} text command without the trailing `;`
61
+ * @returns {boolean} false if there is no open connection
62
+ */
63
+ send(text) {
64
+ if (!this.socket || this.socket.destroyed) {
65
+ return false;
66
+ }
67
+ this.socket.write(`${text};`);
68
+ return true;
69
+ }
70
+
71
+ /** Closes the connection without emitting `close`. */
72
+ destroy() {
73
+ if (this.socket) {
74
+ this.socket.removeAllListeners('close');
75
+ this.socket.destroy();
76
+ this.socket = null;
77
+ }
78
+ }
79
+ }
80
+
81
+ module.exports = { RawTcpTransport };
package/main.js ADDED
@@ -0,0 +1,157 @@
1
+ 'use strict';
2
+
3
+ const utils = require('@iobroker/adapter-core');
4
+ const { Bonjour } = require('bonjour-service');
5
+ const { ArylicDevice } = require('./lib/device');
6
+
7
+ class Arylic extends utils.Adapter {
8
+ /**
9
+ * @param {Partial<utils.AdapterOptions>} [options] adapter options
10
+ */
11
+ constructor(options = {}) {
12
+ super({ ...options, name: 'arylic' });
13
+ /** @type {Map<string, ArylicDevice>} */
14
+ this.devices = new Map();
15
+ this.bonjour = null;
16
+ this.on('ready', this.onReady.bind(this));
17
+ this.on('stateChange', this.onStateChange.bind(this));
18
+ this.on('unload', this.onUnload.bind(this));
19
+ }
20
+
21
+ async onReady() {
22
+ await this.setState('info.connection', false, true);
23
+ this.pollMs = Math.max(10, Number(this.config.pollInterval) || 60) * 1000;
24
+
25
+ for (const entry of this.config.devices || []) {
26
+ if (!entry.host) {
27
+ continue;
28
+ }
29
+ const id = this.toId(entry.name || entry.host);
30
+ await this.addDevice(id, entry.name || entry.host, entry.host);
31
+ }
32
+
33
+ if (this.config.discovery) {
34
+ this.startDiscovery();
35
+ }
36
+ if (!this.devices.size && !this.config.discovery) {
37
+ this.log.warn('No devices configured and discovery is off');
38
+ }
39
+
40
+ this.subscribeStates('*');
41
+ }
42
+
43
+ /**
44
+ * Arylic devices announce themselves as AirPlay receivers with
45
+ * `manufacturer=Arylic` and their serial number in the TXT record.
46
+ */
47
+ startDiscovery() {
48
+ this.bonjour = new Bonjour();
49
+ const browser = this.bonjour.find({ type: 'airplay' });
50
+ browser.on('up', service => {
51
+ this.onServiceUp(service).catch(err => this.log.error(`Discovery: ${err.message}`));
52
+ });
53
+ this.log.info('Discovery started (mDNS, _airplay._tcp)');
54
+ }
55
+
56
+ /**
57
+ * @param {import('bonjour-service').Service} service announced AirPlay service
58
+ */
59
+ async onServiceUp(service) {
60
+ const txt = service.txt || {};
61
+ if (String(txt.manufacturer).toLowerCase() !== 'arylic') {
62
+ return;
63
+ }
64
+ const host = (service.addresses || []).find(a => /^\d+\.\d+\.\d+\.\d+$/.test(a)) || service.referer?.address;
65
+ if (!host) {
66
+ return;
67
+ }
68
+ const info = { model: txt.model, serial: txt.serialNumber, firmware: txt.fv };
69
+
70
+ // A manually configured device with this address takes precedence.
71
+ const known = [...this.devices.values()].find(d => d.serial === txt.serialNumber || d.host === host);
72
+ if (known) {
73
+ known.serial = txt.serialNumber;
74
+ known.setHost(host);
75
+ await known.setInfo(info);
76
+ return;
77
+ }
78
+ if (!txt.serialNumber) {
79
+ return;
80
+ }
81
+ this.log.info(`Discovered ${txt.model || 'Arylic'} "${service.name}" at ${host}`);
82
+ const device = await this.addDevice(this.toId(txt.serialNumber), service.name, host);
83
+ device.serial = txt.serialNumber;
84
+ await device.setInfo(info);
85
+ }
86
+
87
+ /**
88
+ * @param {string} id object id below the instance
89
+ * @param {string} name display name
90
+ * @param {string} host IP address or host name
91
+ * @returns {Promise<ArylicDevice>} the started device
92
+ */
93
+ async addDevice(id, name, host) {
94
+ const device = new ArylicDevice(this, {
95
+ id,
96
+ name,
97
+ host,
98
+ pollMs: this.pollMs,
99
+ onConnectionChange: () => this.updateConnection(),
100
+ });
101
+ this.devices.set(id, device);
102
+ await device.createObjects();
103
+ device.start();
104
+ return device;
105
+ }
106
+
107
+ updateConnection() {
108
+ const connected = [...this.devices.values()].some(d => d.connected);
109
+ this.setState('info.connection', connected, true);
110
+ }
111
+
112
+ /**
113
+ * @param {string} text device name, host or serial number
114
+ * @returns {string} usable as an object id segment
115
+ */
116
+ toId(text) {
117
+ return String(text)
118
+ .toLowerCase()
119
+ .replace(this.FORBIDDEN_CHARS, '_')
120
+ .replace(/[.\s]/g, '_');
121
+ }
122
+
123
+ /**
124
+ * @param {string} id full state id
125
+ * @param {ioBroker.State | null | undefined} state new state
126
+ */
127
+ onStateChange(id, state) {
128
+ if (!state || state.ack) {
129
+ return;
130
+ }
131
+ const [deviceId, ...rest] = id.slice(this.namespace.length + 1).split('.');
132
+ const device = this.devices.get(deviceId);
133
+ if (device) {
134
+ device.write(rest.join('.'), state.val);
135
+ }
136
+ }
137
+
138
+ /**
139
+ * @param {() => void} callback must be called at the end
140
+ */
141
+ onUnload(callback) {
142
+ try {
143
+ this.devices.forEach(d => d.stop());
144
+ if (this.bonjour) {
145
+ this.bonjour.destroy();
146
+ }
147
+ } finally {
148
+ callback();
149
+ }
150
+ }
151
+ }
152
+
153
+ if (require.main !== module) {
154
+ module.exports = options => new Arylic(options);
155
+ } else {
156
+ new Arylic();
157
+ }
package/package.json CHANGED
@@ -1,6 +1,57 @@
1
1
  {
2
2
  "name": "iobroker.arylic",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.2",
4
+ "description": "Control Arylic amplifiers (LA50) over the network: volume limit, tone, input, standby",
5
+ "author": {
6
+ "name": "Timo Schneider",
7
+ "email": "timo@schnti.de"
8
+ },
9
+ "homepage": "https://github.com/schnti/ioBroker.arylic",
10
+ "license": "MIT",
11
+ "keywords": [
12
+ "ioBroker",
13
+ "arylic",
14
+ "LA50",
15
+ "amplifier",
16
+ "audio",
17
+ "multimedia"
18
+ ],
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/schnti/ioBroker.arylic.git"
22
+ },
23
+ "bugs": {
24
+ "url": "https://github.com/schnti/ioBroker.arylic/issues"
25
+ },
26
+ "engines": {
27
+ "node": ">=22"
28
+ },
29
+ "main": "main.js",
30
+ "files": [
31
+ "admin/",
32
+ "lib/",
33
+ "io-package.json",
34
+ "LICENSE",
35
+ "main.js"
36
+ ],
37
+ "scripts": {
38
+ "test:unit": "mocha \"test/unit/**/*.test.js\"",
39
+ "test:package": "mocha test/package --exit",
40
+ "test:integration": "mocha test/integration --exit",
41
+ "test": "npm run test:unit && npm run test:package",
42
+ "translate": "translate-adapter",
43
+ "release": "release-script"
44
+ },
45
+ "dependencies": {
46
+ "@iobroker/adapter-core": "^3.4.3",
47
+ "bonjour-service": "^1.4.4"
48
+ },
49
+ "devDependencies": {
50
+ "@alcalzone/release-script": "^5.2.1",
51
+ "@alcalzone/release-script-plugin-iobroker": "^5.2.0",
52
+ "@alcalzone/release-script-plugin-license": "^5.2.2",
53
+ "@iobroker/adapter-dev": "^1.5.0",
54
+ "@iobroker/testing": "^6.3.1",
55
+ "mocha": "^12.0.3"
56
+ }
57
+ }