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 +21 -0
- package/README.md +97 -2
- package/admin/arylic.png +0 -0
- package/admin/i18n/de.json +10 -0
- package/admin/i18n/en.json +10 -0
- package/admin/jsonConfig.json +72 -0
- package/io-package.json +127 -0
- package/lib/device.js +264 -0
- package/lib/fields.js +100 -0
- package/lib/transport.js +81 -0
- package/main.js +157 -0
- package/package.json +55 -4
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
|
-
|
|
1
|
+

|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
# ioBroker.arylic
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/iobroker.arylic)
|
|
6
|
+
[](https://www.npmjs.com/package/iobroker.arylic)
|
|
7
|
+

|
|
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.
|
package/admin/arylic.png
ADDED
|
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
|
+
}
|
package/io-package.json
ADDED
|
@@ -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 };
|
package/lib/transport.js
ADDED
|
@@ -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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|