iobroker.yamaha 2.9.2 → 2.10.0
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/README.md +16 -6
- package/admin/i18n/de.json +3 -2
- package/admin/i18n/en.json +3 -2
- package/admin/i18n/es.json +3 -2
- package/admin/i18n/fr.json +3 -2
- package/admin/i18n/it.json +3 -2
- package/admin/i18n/nl.json +3 -2
- package/admin/i18n/pl.json +3 -2
- package/admin/i18n/pt.json +3 -2
- package/admin/i18n/ru.json +3 -2
- package/admin/i18n/uk.json +3 -2
- package/admin/i18n/zh-cn.json +3 -2
- package/admin/jsonConfig.json +13 -0
- package/admin/yamaha.svg +5 -4
- package/build/device-management.js +17 -22
- package/build/device-management.js.map +2 -2
- package/build/lib/attempt-device.js +8 -6
- package/build/lib/attempt-device.js.map +2 -2
- package/build/lib/catalog/value-coerce.js +15 -0
- package/build/lib/catalog/value-coerce.js.map +2 -2
- package/build/lib/device-controller.js +6 -5
- package/build/lib/device-controller.js.map +2 -2
- package/build/lib/device-type.js +47 -11
- package/build/lib/device-type.js.map +2 -2
- package/build/lib/lifecycle/device-supervisor.js +25 -2
- package/build/lib/lifecycle/device-supervisor.js.map +2 -2
- package/build/lib/types.js.map +1 -1
- package/build/lib/xml/command-mapper.js +5 -1
- package/build/lib/xml/command-mapper.js.map +2 -2
- package/build/lib/xml/device-controller.js +31 -5
- package/build/lib/xml/device-controller.js.map +2 -2
- package/build/lib/ynca/catalog.js +5 -1
- package/build/lib/ynca/catalog.js.map +2 -2
- package/build/lib/ynca/ynca-client.js +19 -1
- package/build/lib/ynca/ynca-client.js.map +2 -2
- package/build/lib/yxc/command-mapper.js +5 -1
- package/build/lib/yxc/command-mapper.js.map +2 -2
- package/build/lib/yxc/device-controller.js +90 -1
- package/build/lib/yxc/device-controller.js.map +2 -2
- package/build/lib/yxc/http-client.js +15 -2
- package/build/lib/yxc/http-client.js.map +2 -2
- package/build/lib/yxc/push-receiver.js +15 -3
- package/build/lib/yxc/push-receiver.js.map +2 -2
- package/build/lib/yxc/push.js +26 -1
- package/build/lib/yxc/push.js.map +2 -2
- package/build/main.js +95 -22
- package/build/main.js.map +2 -2
- package/io-package.json +16 -14
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -74,6 +74,22 @@ Details on all settings, the object tree and the ports the adapter uses are in t
|
|
|
74
74
|
Placeholder for the next version (at the beginning of the line):
|
|
75
75
|
### **WORK IN PROGRESS**
|
|
76
76
|
-->
|
|
77
|
+
### 2.10.0 (2026-09-15)
|
|
78
|
+
|
|
79
|
+
- (krobipd) Fixed: A receiver the search found is searched for again after it moved to another address — until now that only worked for receivers found at start-up
|
|
80
|
+
- (krobipd) Fixed: A receiver that is unplugged or switched off at the mains now shows as disconnected within about 90 seconds instead of staying green for many minutes
|
|
81
|
+
- (krobipd) Fixed: A MusicCast device that stops answering a command is checked right away and shown as disconnected — until now that took up to 15 minutes
|
|
82
|
+
- (krobipd) Fixed: On receivers without live updates, a value you write is confirmed as soon as the receiver took it, instead of up to five minutes later
|
|
83
|
+
- (krobipd) Fixed: A zone name you changed on an older receiver stays after a reconnect — until now the previous name came back
|
|
84
|
+
- (krobipd) Fixed: Deleting a device from its card while it is still connecting no longer leaves parts of its object tree behind
|
|
85
|
+
- (krobipd) Fixed: Writing false, off or 0 to a switch datapoint now switches it off — until now any text, even the word false, switched it on
|
|
86
|
+
- (krobipd) Improved: The history of a datapoint only records values the receiver actually changed — a restart or a lost connection no longer adds identical entries
|
|
87
|
+
- (krobipd) Improved: MusicCast live updates now start on their own once a port another program held at start-up becomes free — before, only a restart helped
|
|
88
|
+
- (krobipd) New: Device pictograms in the object tree and on the device cards — receiver, stereo receiver, speaker, soundbar or CD system, readable in every theme, also for a device that is off
|
|
89
|
+
- (krobipd) Changed: The device card shows a speaker symbol; with the percent switch on it also shows the current volume as a percentage. The pencil and magnifier markers are gone
|
|
90
|
+
- (krobipd) Fixed: The adapter logo is readable in the Admin's dark themes as well — until now its dark strokes vanished on a dark background
|
|
91
|
+
- (krobipd) Changed: The instance settings show the fixed MusicCast event port, so the Admin warns when a second instance on the same host would take it
|
|
92
|
+
|
|
77
93
|
### 2.9.2 (2026-09-12)
|
|
78
94
|
|
|
79
95
|
- (krobipd) New: The device card shows a 0–100 % badge while that receiver's volume is in percent, so you can tell the two scales apart at a glance
|
|
@@ -105,12 +121,6 @@ Details on all settings, the object tree and the ports the adapter uses are in t
|
|
|
105
121
|
- (krobipd) Changed: After this update every receiver is asked about its abilities once more, so the first start takes a little longer than usual
|
|
106
122
|
- (krobipd) Fixed: A DAB receiver no longer logs a warning on every tuner poll — the frequency datapoint was limited to the FM band while the receiver reported DAB frequencies
|
|
107
123
|
|
|
108
|
-
### 2.7.2 (2026-09-09)
|
|
109
|
-
|
|
110
|
-
- (krobipd) Fixed: The volume readout now follows the scale the receiver is actually showing, so a receiver set to numbers no longer reports them as decibels.
|
|
111
|
-
- (krobipd) New: Inputs now appear under the names the receiver carries for them, so a socket named “Apple TV” reads that way instead of HDMI1.
|
|
112
|
-
- (krobipd) Changed: After this update every receiver is asked about its abilities once more, so the first start takes a little longer than usual.
|
|
113
|
-
|
|
114
124
|
[Older changelogs can be found there](CHANGELOG_OLD.md)
|
|
115
125
|
|
|
116
126
|
## History
|
package/admin/i18n/de.json
CHANGED
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
"invalidName": "Dieser Name kann nicht verwendet werden",
|
|
16
16
|
"networkInterfaceLabel": "Netzwerk-Interface",
|
|
17
17
|
"networkInterfaceTooltip": "Netzwerk-Interface für LAN-Discovery und -Steuerung. Standard (0.0.0.0 = alle Interfaces) beibehalten, außer dieser Host hat mehrere Netzwerk-Interfaces.",
|
|
18
|
+
"pushPortLabel": "MusicCast-Ereignisport",
|
|
19
|
+
"pushPortTooltip": "UDP-Port, an den MusicCast-Geräte ihre Änderungen melden. Vom Protokoll festgelegt; eine zweite Instanz auf diesem Host kann ihn nicht mitbenutzen.",
|
|
18
20
|
"dmAdd": "Yamaha-Gerät hinzufügen",
|
|
19
21
|
"dmEdit": "Bearbeiten",
|
|
20
22
|
"dmDelete": "Löschen",
|
|
@@ -608,6 +610,5 @@
|
|
|
608
610
|
"discoveryAuto": "Automatisch (solange die Liste leer ist)",
|
|
609
611
|
"discoveryAlways": "Immer",
|
|
610
612
|
"discoveryNever": "Nie",
|
|
611
|
-
"
|
|
612
|
-
"sourceDiscovered": "Im Netzwerk gefunden"
|
|
613
|
+
"volumeDeviceScale": "Lautstärke in der Skala des Receivers"
|
|
613
614
|
}
|
package/admin/i18n/en.json
CHANGED
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
"invalidName": "This name cannot be used",
|
|
16
16
|
"networkInterfaceLabel": "Network Interface",
|
|
17
17
|
"networkInterfaceTooltip": "Select the network interface for LAN discovery and control. Keep the default (0.0.0.0 = all interfaces) unless this host has multiple network interfaces.",
|
|
18
|
+
"pushPortLabel": "MusicCast event port",
|
|
19
|
+
"pushPortTooltip": "UDP port on which MusicCast devices push their changes. Fixed by the protocol; a second instance on this host cannot share it.",
|
|
18
20
|
"dmAdd": "Add Yamaha device",
|
|
19
21
|
"dmEdit": "Edit",
|
|
20
22
|
"dmDelete": "Delete",
|
|
@@ -608,6 +610,5 @@
|
|
|
608
610
|
"discoveryAuto": "Automatically (while the list is empty)",
|
|
609
611
|
"discoveryAlways": "Always",
|
|
610
612
|
"discoveryNever": "Never",
|
|
611
|
-
"
|
|
612
|
-
"sourceDiscovered": "Found on the network"
|
|
613
|
+
"volumeDeviceScale": "Volume in the receiver's own scale"
|
|
613
614
|
}
|
package/admin/i18n/es.json
CHANGED
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
"invalidName": "Este nombre no se puede utilizar",
|
|
16
16
|
"networkInterfaceLabel": "Interfaz de red",
|
|
17
17
|
"networkInterfaceTooltip": "Selecciona la interfaz de red para el descubrimiento y control LAN. Mantén el valor predeterminado (0.0.0.0 = todas) salvo que este host tenga varias interfaces.",
|
|
18
|
+
"pushPortLabel": "Puerto de eventos MusicCast",
|
|
19
|
+
"pushPortTooltip": "Puerto UDP en el que los dispositivos MusicCast envían sus cambios. Fijado por el protocolo; una segunda instancia en este host no puede compartirlo.",
|
|
18
20
|
"dmAdd": "Añadir dispositivo Yamaha",
|
|
19
21
|
"dmEdit": "Editar",
|
|
20
22
|
"dmDelete": "Eliminar",
|
|
@@ -608,6 +610,5 @@
|
|
|
608
610
|
"discoveryAuto": "Automático (mientras la lista esté vacía)",
|
|
609
611
|
"discoveryAlways": "Siempre",
|
|
610
612
|
"discoveryNever": "Nunca",
|
|
611
|
-
"
|
|
612
|
-
"sourceDiscovered": "Encontrado en la red"
|
|
613
|
+
"volumeDeviceScale": "Volumen en la escala propia del receptor"
|
|
613
614
|
}
|
package/admin/i18n/fr.json
CHANGED
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
"invalidName": "Ce nom ne peut pas être utilisé",
|
|
16
16
|
"networkInterfaceLabel": "Interface réseau",
|
|
17
17
|
"networkInterfaceTooltip": "Sélectionnez l'interface réseau pour la découverte et le contrôle LAN. Conservez la valeur par défaut (0.0.0.0 = toutes) sauf si cet hôte possède plusieurs interfaces.",
|
|
18
|
+
"pushPortLabel": "Port d'événements MusicCast",
|
|
19
|
+
"pushPortTooltip": "Port UDP sur lequel les appareils MusicCast envoient leurs changements. Fixé par le protocole ; une seconde instance sur cet hôte ne peut pas le partager.",
|
|
18
20
|
"dmAdd": "Ajouter un appareil Yamaha",
|
|
19
21
|
"dmEdit": "Modifier",
|
|
20
22
|
"dmDelete": "Supprimer",
|
|
@@ -608,6 +610,5 @@
|
|
|
608
610
|
"discoveryAuto": "Automatique (tant que la liste est vide)",
|
|
609
611
|
"discoveryAlways": "Toujours",
|
|
610
612
|
"discoveryNever": "Jamais",
|
|
611
|
-
"
|
|
612
|
-
"sourceDiscovered": "Trouvé sur le réseau"
|
|
613
|
+
"volumeDeviceScale": "Volume dans l'échelle propre du récepteur"
|
|
613
614
|
}
|
package/admin/i18n/it.json
CHANGED
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
"invalidName": "Questo nome non può essere utilizzato",
|
|
16
16
|
"networkInterfaceLabel": "Interfaccia di rete",
|
|
17
17
|
"networkInterfaceTooltip": "Seleziona l'interfaccia di rete per il rilevamento e il controllo LAN. Mantieni il valore predefinito (0.0.0.0 = tutte) a meno che questo host non abbia più interfacce.",
|
|
18
|
+
"pushPortLabel": "Porta eventi MusicCast",
|
|
19
|
+
"pushPortTooltip": "Porta UDP su cui i dispositivi MusicCast inviano le loro modifiche. Fissata dal protocollo; una seconda istanza su questo host non può condividerla.",
|
|
18
20
|
"dmAdd": "Aggiungi dispositivo Yamaha",
|
|
19
21
|
"dmEdit": "Modifica",
|
|
20
22
|
"dmDelete": "Elimina",
|
|
@@ -608,6 +610,5 @@
|
|
|
608
610
|
"discoveryAuto": "Automatico (finché l'elenco è vuoto)",
|
|
609
611
|
"discoveryAlways": "Sempre",
|
|
610
612
|
"discoveryNever": "Mai",
|
|
611
|
-
"
|
|
612
|
-
"sourceDiscovered": "Trovato in rete"
|
|
613
|
+
"volumeDeviceScale": "Volume nella scala propria del ricevitore"
|
|
613
614
|
}
|
package/admin/i18n/nl.json
CHANGED
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
"invalidName": "Deze naam kan niet worden gebruikt",
|
|
16
16
|
"networkInterfaceLabel": "Netwerkinterface",
|
|
17
17
|
"networkInterfaceTooltip": "Selecteer de netwerkinterface voor LAN-detectie en -besturing. Behoud de standaard (0.0.0.0 = alle interfaces), tenzij deze host meerdere netwerkinterfaces heeft.",
|
|
18
|
+
"pushPortLabel": "MusicCast-gebeurtenispoort",
|
|
19
|
+
"pushPortTooltip": "UDP-poort waarop MusicCast-apparaten hun wijzigingen melden. Vastgelegd door het protocol; een tweede instantie op deze host kan hem niet delen.",
|
|
18
20
|
"dmAdd": "Yamaha-apparaat toevoegen",
|
|
19
21
|
"dmEdit": "Bewerken",
|
|
20
22
|
"dmDelete": "Verwijderen",
|
|
@@ -608,6 +610,5 @@
|
|
|
608
610
|
"discoveryAuto": "Automatisch (zolang de lijst leeg is)",
|
|
609
611
|
"discoveryAlways": "Altijd",
|
|
610
612
|
"discoveryNever": "Nooit",
|
|
611
|
-
"
|
|
612
|
-
"sourceDiscovered": "Gevonden in het netwerk"
|
|
613
|
+
"volumeDeviceScale": "Volume in de eigen schaal van de receiver"
|
|
613
614
|
}
|
package/admin/i18n/pl.json
CHANGED
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
"invalidName": "Ta nazwa nie może być użyta",
|
|
16
16
|
"networkInterfaceLabel": "Interfejs sieciowy",
|
|
17
17
|
"networkInterfaceTooltip": "Wybierz interfejs sieciowy do wykrywania i sterowania LAN. Pozostaw domyślny (0.0.0.0 = wszystkie), chyba że ten host ma wiele interfejsów.",
|
|
18
|
+
"pushPortLabel": "Port zdarzeń MusicCast",
|
|
19
|
+
"pushPortTooltip": "Port UDP, na który urządzenia MusicCast wysyłają swoje zmiany. Ustalony przez protokół; druga instancja na tym hoście nie może go współdzielić.",
|
|
18
20
|
"dmAdd": "Dodaj urządzenie Yamaha",
|
|
19
21
|
"dmEdit": "Edytuj",
|
|
20
22
|
"dmDelete": "Usuń",
|
|
@@ -608,6 +610,5 @@
|
|
|
608
610
|
"discoveryAuto": "Automatycznie (dopóki lista jest pusta)",
|
|
609
611
|
"discoveryAlways": "Zawsze",
|
|
610
612
|
"discoveryNever": "Nigdy",
|
|
611
|
-
"
|
|
612
|
-
"sourceDiscovered": "Znalezione w sieci"
|
|
613
|
+
"volumeDeviceScale": "Głośność we własnej skali amplitunera"
|
|
613
614
|
}
|
package/admin/i18n/pt.json
CHANGED
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
"invalidName": "Este nome não pode ser usado",
|
|
16
16
|
"networkInterfaceLabel": "Interface de rede",
|
|
17
17
|
"networkInterfaceTooltip": "Selecione a interface de rede para descoberta e controlo LAN. Mantenha o padrão (0.0.0.0 = todas) a menos que este host tenha várias interfaces.",
|
|
18
|
+
"pushPortLabel": "Porta de eventos MusicCast",
|
|
19
|
+
"pushPortTooltip": "Porta UDP na qual os dispositivos MusicCast enviam as suas alterações. Fixada pelo protocolo; uma segunda instância neste host não a pode partilhar.",
|
|
18
20
|
"dmAdd": "Adicionar dispositivo Yamaha",
|
|
19
21
|
"dmEdit": "Editar",
|
|
20
22
|
"dmDelete": "Eliminar",
|
|
@@ -608,6 +610,5 @@
|
|
|
608
610
|
"discoveryAuto": "Automático (enquanto a lista estiver vazia)",
|
|
609
611
|
"discoveryAlways": "Sempre",
|
|
610
612
|
"discoveryNever": "Nunca",
|
|
611
|
-
"
|
|
612
|
-
"sourceDiscovered": "Encontrado na rede"
|
|
613
|
+
"volumeDeviceScale": "Volume na escala própria do receptor"
|
|
613
614
|
}
|
package/admin/i18n/ru.json
CHANGED
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
"invalidName": "Это имя нельзя использовать",
|
|
16
16
|
"networkInterfaceLabel": "Сетевой интерфейс",
|
|
17
17
|
"networkInterfaceTooltip": "Выберите сетевой интерфейс для обнаружения и управления по LAN. Оставьте значение по умолчанию (0.0.0.0 = все интерфейсы), если у хоста нет нескольких интерфейсов.",
|
|
18
|
+
"pushPortLabel": "Порт событий MusicCast",
|
|
19
|
+
"pushPortTooltip": "UDP-порт, на который устройства MusicCast отправляют свои изменения. Задан протоколом; второй экземпляр на этом хосте не может его использовать.",
|
|
18
20
|
"dmAdd": "Добавить устройство Yamaha",
|
|
19
21
|
"dmEdit": "Изменить",
|
|
20
22
|
"dmDelete": "Удалить",
|
|
@@ -608,6 +610,5 @@
|
|
|
608
610
|
"discoveryAuto": "Автоматически (пока список пуст)",
|
|
609
611
|
"discoveryAlways": "Всегда",
|
|
610
612
|
"discoveryNever": "Никогда",
|
|
611
|
-
"
|
|
612
|
-
"sourceDiscovered": "Найдено в сети"
|
|
613
|
+
"volumeDeviceScale": "Громкость в собственной шкале ресивера"
|
|
613
614
|
}
|
package/admin/i18n/uk.json
CHANGED
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
"invalidName": "Це ім'я не можна використовувати",
|
|
16
16
|
"networkInterfaceLabel": "Мережевий інтерфейс",
|
|
17
17
|
"networkInterfaceTooltip": "Виберіть мережевий інтерфейс для виявлення та керування по LAN. Залиште типове значення (0.0.0.0 = всі інтерфейси), якщо хост не має кількох інтерфейсів.",
|
|
18
|
+
"pushPortLabel": "Порт подій MusicCast",
|
|
19
|
+
"pushPortTooltip": "UDP-порт, на який пристрої MusicCast надсилають свої зміни. Визначений протоколом; другий екземпляр на цьому хості не може його використовувати.",
|
|
18
20
|
"dmAdd": "Додати пристрій Yamaha",
|
|
19
21
|
"dmEdit": "Редагувати",
|
|
20
22
|
"dmDelete": "Видалити",
|
|
@@ -608,6 +610,5 @@
|
|
|
608
610
|
"discoveryAuto": "Автоматично (доки список порожній)",
|
|
609
611
|
"discoveryAlways": "Завжди",
|
|
610
612
|
"discoveryNever": "Ніколи",
|
|
611
|
-
"
|
|
612
|
-
"sourceDiscovered": "Знайдено в мережі"
|
|
613
|
+
"volumeDeviceScale": "Гучність у власній шкалі ресивера"
|
|
613
614
|
}
|
package/admin/i18n/zh-cn.json
CHANGED
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
"invalidName": "此名称不可用",
|
|
16
16
|
"networkInterfaceLabel": "网络接口",
|
|
17
17
|
"networkInterfaceTooltip": "选择用于局域网发现和控制的网络接口。除非此主机有多个网络接口,否则请保留默认值(0.0.0.0 = 所有接口)。",
|
|
18
|
+
"pushPortLabel": "MusicCast 事件端口",
|
|
19
|
+
"pushPortTooltip": "MusicCast 设备向此 UDP 端口推送其变化。由协议固定;同一主机上的第二个实例无法共用。",
|
|
18
20
|
"dmAdd": "添加 Yamaha 设备",
|
|
19
21
|
"dmEdit": "编辑",
|
|
20
22
|
"dmDelete": "删除",
|
|
@@ -608,6 +610,5 @@
|
|
|
608
610
|
"discoveryAuto": "自动(列表为空时)",
|
|
609
611
|
"discoveryAlways": "始终",
|
|
610
612
|
"discoveryNever": "从不",
|
|
611
|
-
"
|
|
612
|
-
"sourceDiscovered": "网络中发现"
|
|
613
|
+
"volumeDeviceScale": "音量采用接收机自身的刻度"
|
|
613
614
|
}
|
package/admin/jsonConfig.json
CHANGED
|
@@ -81,6 +81,19 @@
|
|
|
81
81
|
"lg": 6,
|
|
82
82
|
"xl": 6
|
|
83
83
|
},
|
|
84
|
+
"port": {
|
|
85
|
+
"type": "port",
|
|
86
|
+
"label": "pushPortLabel",
|
|
87
|
+
"tooltip": "pushPortTooltip",
|
|
88
|
+
"disabled": true,
|
|
89
|
+
"min": 41100,
|
|
90
|
+
"max": 41100,
|
|
91
|
+
"xs": 12,
|
|
92
|
+
"sm": 12,
|
|
93
|
+
"md": 6,
|
|
94
|
+
"lg": 6,
|
|
95
|
+
"xl": 6
|
|
96
|
+
},
|
|
84
97
|
"xmlPollInterval": {
|
|
85
98
|
"newLine": true,
|
|
86
99
|
"type": "number",
|
package/admin/yamaha.svg
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none" stroke="#
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="none" stroke="#78869a" stroke-width="22" stroke-linecap="round" stroke-linejoin="round">
|
|
2
2
|
<title>ioBroker.yamaha</title>
|
|
3
|
-
<!--
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
<!-- ONE fixed colour, never currentColor: the logo is embedded as <img>, which inherits
|
|
4
|
+
nothing. A media query would follow the OPERATING SYSTEM's scheme, not the Admin's
|
|
5
|
+
theme (the two disagree all the time), so the colour has to read on both: #78869a is
|
|
6
|
+
3.7:1 on white and 3.6–5.1:1 on the Admin's dark backgrounds (#303030 … #121212). -->
|
|
6
7
|
<circle cx="256" cy="256" r="234" />
|
|
7
8
|
<path d="M236,104 L236,212 L256,246 L276,212 L276,104" transform="rotate(0 256 256)" />
|
|
8
9
|
<path d="M236,104 L236,212 L256,246 L276,212 L276,104" transform="rotate(120 256 256)" />
|
|
@@ -177,20 +177,12 @@ class YamahaDeviceManagement extends import_dm_utils.DeviceManagement {
|
|
|
177
177
|
status: {
|
|
178
178
|
connection: { stateId: `${base}.info.connection`, mapping: { true: "connected", false: "disconnected" } }
|
|
179
179
|
},
|
|
180
|
-
// No icon on the transports:
|
|
181
|
-
//
|
|
182
|
-
// colour carries it; `hideIfEmpty` shows
|
|
180
|
+
// No icon on the transports: dm-gui-components renders exactly 17 `fa-*` names (its own
|
|
181
|
+
// whitelist, `getFaIcon`) and inline `data:image/svg+xml` URLs — anything else is a "?".
|
|
182
|
+
// The transport label as text plus a green "on" colour carries it; `hideIfEmpty` shows
|
|
183
|
+
// only the protocols this device is connected over. Own glyphs go in as data URLs, drawn
|
|
184
|
+
// with `currentColor` so they take the indicator's colour (see device-type.ts).
|
|
183
185
|
indicators: [
|
|
184
|
-
// Where the address came from. The adapter treats the two differently — only a device
|
|
185
|
-
// the search found is looked for again when it drops off — so the card says which it is.
|
|
186
|
-
{
|
|
187
|
-
id: "device-source",
|
|
188
|
-
value: true,
|
|
189
|
-
icon: card.source === "manual" ? "fa-pencil" : "fa-search",
|
|
190
|
-
tooltip: (0, import_i18n.t)(card.source === "manual" ? "sourceManual" : "sourceDiscovered"),
|
|
191
|
-
color: "primary",
|
|
192
|
-
order: 10
|
|
193
|
-
},
|
|
194
186
|
...import_device_management_helpers.TRANSPORTS.map((tr) => ({
|
|
195
187
|
id: `transport-${tr.id}`,
|
|
196
188
|
value: { stateId: `${base}.info.transports.${tr.id}` },
|
|
@@ -200,17 +192,20 @@ class YamahaDeviceManagement extends import_dm_utils.DeviceManagement {
|
|
|
200
192
|
})),
|
|
201
193
|
// The percent setting is SET in the edit dialog, but it has to be READABLE at a glance —
|
|
202
194
|
// otherwise the only way to find out what a receiver's volume datapoints carry is to open
|
|
203
|
-
// a dialog.
|
|
204
|
-
//
|
|
205
|
-
//
|
|
195
|
+
// a dialog. With percent on, the glyph is a speaker with a percent sign and the main
|
|
196
|
+
// zone's live volume stands under it as "42 %"; in the device's own scale the plain
|
|
197
|
+
// speaker stands alone. Always shown (`hideIfEmpty: false` — 0 % is a value), and given
|
|
198
|
+
// a colour for BOTH states so the glyph does not grey out at the bottom of the scale.
|
|
199
|
+
// Deliberately not clickable: the dialog stays the one place that sets it.
|
|
206
200
|
{
|
|
207
|
-
id: "volume
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
201
|
+
id: "volume",
|
|
202
|
+
icon: (0, import_device_type.volumeIndicatorIcon)(percent),
|
|
203
|
+
value: percent ? { stateId: `${base}.volume` } : true,
|
|
204
|
+
...percent ? { showValue: true, unit: "%" } : {},
|
|
205
|
+
hideIfEmpty: false,
|
|
206
|
+
color: "primary",
|
|
211
207
|
colorOn: "primary",
|
|
212
|
-
tooltip: (0, import_i18n.t)("volumeAsPercent"),
|
|
213
|
-
hideIfEmpty: true,
|
|
208
|
+
tooltip: (0, import_i18n.t)(percent ? "volumeAsPercent" : "volumeDeviceScale"),
|
|
214
209
|
order: 20
|
|
215
210
|
}
|
|
216
211
|
],
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../src/device-management.ts"],
|
|
4
|
-
"sourcesContent": ["import {\n DeviceManagement,\n type ActionContext,\n type DeviceInfo,\n type DeviceLoadContext,\n type InstanceDetails,\n} from \"@iobroker/dm-utils\";\nimport { t } from \"./lib/i18n\";\nimport { iconForModel } from \"./lib/device-type\";\nimport { readDiscovered, readIgnored, writeDiscovered, writeIgnored } from \"./lib/discovered-store\";\nimport { discoveredStoreDeps, ignoredStoreDeps } from \"./lib/discovered-store-deps\";\nimport type { DeviceRecord } from \"./lib/types\";\nimport { unionDevices } from \"./lib/pure-helpers\";\nimport {\n TRANSPORTS,\n buildDeviceForm,\n findClash,\n rowId,\n type CardDevice,\n type ManualRow,\n} from \"./device-management-helpers\";\n\n/** The one adapter method this backend needs beyond the plain ioBroker surface. */\ninterface DeviceOwner {\n /** Stop supervising a device and delete its object tree. */\n removeDevice(deviceId: string): Promise<void>;\n /** Switch one device's volume datapoints to percent (or back) and rebuild them at once. */\n setVolumePercent(deviceId: string, on: boolean): Promise<void>;\n}\n\n/**\n * ioBroker device-manager backend: the Yamaha receivers as cards showing the live model,\n * the IP, and which protocols (YNCA/MusicCast/XML) are connected right now, with a manual\n * add-by-IP dialog. The card list follows the running set \u2014 the manual `native.devices`\n * table when it is filled, otherwise the auto-discovered devices \u2014 so it matches exactly\n * what the adapter runs. \"Yamaha\" is never a card line: it is the whole adapter.\n */\nexport class YamahaDeviceManagement extends DeviceManagement {\n /** The instance object id whose `native` holds the manual device table. */\n private get objId(): string {\n return `system.adapter.${this.adapter.namespace}`;\n }\n\n /** The running adapter, for the one action that has to reach into it (delete a device). */\n private get owner(): DeviceOwner | undefined {\n const candidate = this.adapter as unknown as Partial<DeviceOwner>;\n return typeof candidate.removeDevice === \"function\" && typeof candidate.setVolumePercent === \"function\"\n ? (candidate as DeviceOwner)\n : undefined;\n }\n\n /** Read the manual device table (`native.devices`) as raw rows, keeping the name. */\n private async readManual(): Promise<ManualRow[]> {\n const obj = await this.adapter.getForeignObjectAsync(this.objId);\n const devices = (obj?.native as { devices?: unknown } | undefined)?.devices;\n if (!Array.isArray(devices)) {\n return [];\n }\n return devices.filter(\n (d): d is ManualRow => !!d && typeof (d as ManualRow).ip === \"string\" && (d as ManualRow).ip.length > 0,\n );\n }\n\n /**\n * Persist the manual device table; writing `native.*` restarts the adapter with the new set.\n *\n * @param rows the manual rows to store\n */\n private async writeManual(rows: ManualRow[]): Promise<void> {\n await this.adapter.extendForeignObjectAsync(this.objId, { native: { devices: rows } });\n }\n\n /**\n * Whether one device's volume datapoints read 0\u2013100 %, for the edit dialog to prefill.\n *\n * @param deviceId the id-safe device id\n * @returns true when this device is set to percent\n */\n private async volumeAsPercentOf(deviceId: string): Promise<boolean> {\n const node = await this.adapter.getForeignObjectAsync(`${this.adapter.namespace}.${deviceId}`);\n return (node?.native as { volumeAsPercent?: unknown } | undefined)?.volumeAsPercent === true;\n }\n\n /**\n * Set one device's percent switch. The value lives at the DEVICE object \u2014 one place the card,\n * the dialog and the running adapter all read, and writing it restarts nothing (an instance\n * object's native would).\n *\n * @param deviceId the id-safe device id\n * @param on whether its volume datapoints should read 0\u2013100 %\n */\n private async applyVolumePercent(deviceId: string, on: boolean): Promise<void> {\n const id = `${this.adapter.namespace}.${deviceId}`;\n const existing = await this.adapter.getForeignObjectAsync(id);\n const owner = this.owner;\n if (existing && owner) {\n // The adapter is running this device: it writes the value AND rebuilds the volume\n // datapoints on the spot, so the change is visible without a restart.\n await owner.setVolumePercent(deviceId, on);\n return;\n }\n // A device just added through the dialog has no object yet \u2014 seed the shape\n // `ensureDeviceHeader` completes on the next start, so the answer has somewhere to live.\n await this.adapter.extendForeignObjectAsync(id, {\n type: \"device\",\n common: { name: deviceId },\n native: { volumeAsPercent: on },\n });\n }\n\n /**\n * The running device set as cards: the manual table AND the discovery store, exactly the\n * union the adapter itself runs (`unionDevices`), so the list matches what is live.\n *\n * @returns the cards, each tagged with where its address came from\n */\n private async cards(): Promise<CardDevice[]> {\n const names = new Map<string, string>();\n const manualRecords: DeviceRecord[] = [];\n for (const row of await this.readManual()) {\n const id = rowId(row);\n // \"info\" is the adapter's own channel \u2014 a device may never claim it.\n if (id === \"info\" || names.has(id)) {\n continue;\n }\n names.set(id, row.name && row.name.length > 0 ? row.name : row.ip);\n manualRecords.push({ id, ip: row.ip });\n }\n const discovered = await readDiscovered(discoveredStoreDeps(this.adapter));\n return unionDevices(manualRecords, discovered).map(device => ({\n id: device.id,\n ip: device.ip,\n name: names.get(device.id) ?? device.id,\n source: device.source ?? \"discovered\",\n }));\n }\n\n /**\n * Populate the manager with one card per running device.\n *\n * @param context the load context\n */\n protected async loadDevices(context: DeviceLoadContext<string>): Promise<void> {\n for (const card of await this.cards()) {\n // Model and object are independent reads \u2014 fetch them together so a card with\n // several devices does not add up their round-trips.\n const [model, node] = await Promise.all([\n this.adapter.getForeignStateAsync(`${this.adapter.namespace}.${card.id}.info.model`),\n this.adapter.getForeignObjectAsync(`${this.adapter.namespace}.${card.id}`),\n ]);\n // The card title follows the device object's name, not the table entry. On an\n // instance upgraded from the previous adapter the table entry is the receiver's\n // ip \u2014 the object carries the readable name the adapter learned from the device.\n // The table entry itself must stay put: the object id is derived from it, and\n // changing that would move the whole tree.\n const label = typeof node?.common?.name === \"string\" ? node.common.name : undefined;\n // The percent answer comes from the object read above \u2014 no extra round-trip for the badge.\n const percent = (node?.native as { volumeAsPercent?: unknown } | undefined)?.volumeAsPercent === true;\n context.addDevice(\n this.toDeviceInfo(\n label && label !== card.id ? { ...card, name: label } : card,\n typeof model?.val === \"string\" ? model.val : undefined,\n percent,\n ),\n );\n }\n }\n\n /**\n * Build one device card: the live model, the IP as the identifier line, a connection\n * status, and one indicator per connected protocol (hidden while that protocol is not\n * connected). The live values read from the device's own `info.*` states. Every card can be\n * edited, a discovered one included: that is how a found receiver is given the fixed address\n * the user just assigned it (see {@link editDevice}).\n *\n * @param card the running device\n * @param model the device's reported model name, for the device-class icon\n * @param percent whether this device's volume datapoints read 0\u2013100 %, for the badge\n * @returns the card descriptor\n */\n private toDeviceInfo(card: CardDevice, model?: string, percent = false): DeviceInfo<string> {\n const base = `${this.adapter.namespace}.${card.id}`;\n const del = {\n id: \"delete\",\n icon: \"delete\",\n description: t(\"dmDelete\"),\n handler: async (id: string, ctx: ActionContext): Promise<{ refresh: \"devices\" }> => this.deleteDevice(id, ctx),\n };\n const edit = {\n id: \"edit\",\n icon: \"edit\",\n description: t(\"dmEdit\"),\n handler: async (id: string, ctx: ActionContext): Promise<{ refresh: \"devices\" }> => this.editDevice(id, ctx),\n };\n return {\n id: card.id,\n name: card.name,\n icon: iconForModel(model),\n model: { stateId: `${base}.info.model` },\n identifier: card.ip,\n status: {\n connection: { stateId: `${base}.info.connection`, mapping: { true: \"connected\", false: \"disconnected\" } },\n },\n // No icon on the transports: the indicator icon accepts only a reserved/`fa-*`/`data:`/URL\n // name, so a plain \"wifi\" rendered as a \"?\". The transport label as text plus a green \"on\"\n // colour carries it; `hideIfEmpty` shows only the protocols this device is connected over.\n indicators: [\n // Where the address came from. The adapter treats the two differently \u2014 only a device\n // the search found is looked for again when it drops off \u2014 so the card says which it is.\n {\n id: \"device-source\",\n value: true,\n icon: card.source === \"manual\" ? \"fa-pencil\" : \"fa-search\",\n tooltip: t(card.source === \"manual\" ? \"sourceManual\" : \"sourceDiscovered\"),\n color: \"primary\",\n order: 10,\n },\n ...TRANSPORTS.map(tr => ({\n id: `transport-${tr.id}`,\n value: { stateId: `${base}.info.transports.${tr.id}` },\n text: tr.label,\n colorOn: \"ok\" as const,\n hideIfEmpty: true,\n })),\n // The percent setting is SET in the edit dialog, but it has to be READABLE at a glance \u2014\n // otherwise the only way to find out what a receiver's volume datapoints carry is to open\n // a dialog. Shown only while it is on (`hideIfEmpty`), so a card in the default state\n // stays as quiet as before; deliberately not clickable, the dialog stays the one place\n // that sets it.\n {\n id: \"volume-percent\",\n value: percent,\n icon: \"fa-percent\",\n text: \"0\u2013100 %\",\n colorOn: \"primary\" as const,\n tooltip: t(\"volumeAsPercent\"),\n hideIfEmpty: true,\n order: 20,\n },\n ],\n // Edit on every card: a device the search found can be given the fixed address the user\n // just assigned it, which makes it a manual device (see editDevice).\n // The percent switch is NOT a second control on the card: it lives in the edit dialog,\n // next to name and address, because it is a decision about the device and not something\n // flipped in passing. 2.9.1 had it in both places \u2014 the card control showed the wrong\n // position while the dialog showed the right one, and two ways to set one value is one\n // too many (krobi 2026-09-12: \"f\u00FCr was hast du den das doppelt gemoppelt?\").\n actions: [edit, del],\n };\n }\n\n /**\n * The \"+ add\" action above the list and the label of the identifier line (the IP).\n *\n * @returns the instance action descriptor\n */\n protected getInstanceInfo(): InstanceDetails {\n return {\n apiVersion: \"v3\",\n identifierLabel: t(\"ipLabel\"),\n actions: [{ id: \"add\", icon: \"add\", description: t(\"dmAdd\"), handler: async ctx => this.addDevice(ctx) }],\n };\n }\n\n /**\n * Manual add: show the name+IP form, then append the device to `native.devices` (which\n * restarts the adapter and switches it to manual mode).\n *\n * @param context the action context\n * @returns a directive to reload the manager\n */\n private async addDevice(context: ActionContext): Promise<{ refresh: boolean }> {\n const manual = await this.readManual();\n const data = await context.showForm(buildDeviceForm(manual.map(r => r.ip)), { title: t(\"dmAdd\") });\n if (data && typeof data.ip === \"string\" && data.ip.trim()) {\n const row: ManualRow = { name: typeof data.name === \"string\" ? data.name.trim() : \"\", ip: data.ip.trim() };\n const clash = findClash(manual, row, -1);\n if (clash) {\n await context.showMessage(clash);\n return { refresh: true };\n }\n manual.push(row);\n // Adding a device by hand undoes an earlier delete of the same id \u2014 otherwise the\n // exclusion would silently outlive the decision that created it.\n const ignoredDeps = ignoredStoreDeps(this.adapter);\n const ignored = await readIgnored(ignoredDeps);\n const id = rowId(row);\n if (ignored.includes(id)) {\n await writeIgnored(\n ignoredDeps,\n ignored.filter(entry => entry !== id),\n );\n }\n await this.writeManual(manual);\n // Written down right away, so the device starts with the answer the user gave instead of\n // inheriting whatever the instance-wide switch of 2.8.0 was left on.\n await this.applyVolumePercent(id, data.volumeAsPercent === true);\n }\n return { refresh: true };\n }\n\n /**\n * Edit one device: its display name and its address. Offered on every card.\n *\n * Two rules make this safe, and both were learned from what the adapter does elsewhere:\n *\n * 1. **The object id never changes.** It is derived from the table row's name, and a changed\n * id would leave the whole object tree behind for `cleanupStaleObjects` to delete \u2014\n * history and VIS bindings with it. The row therefore carries the id as its name, and what\n * the user typed becomes the DISPLAY name at the device object, where `nextDeviceLabel`\n * already defends a user's own name against every name the device reports.\n * 2. **A discovered device whose address changes MOVES into the table.** The user gave the\n * receiver a fixed address and entered it; that is what a manual device is. Copying it\n * instead would lose the edit at the next search \u2014 `mergeDiscovered` carries the found\n * address onto a known id.\n *\n * @param cardId the card id (= the object-tree device id)\n * @param context the action context\n * @returns a directive to reload the list\n */\n private async editDevice(cardId: string, context: ActionContext): Promise<{ refresh: \"devices\" }> {\n const cards = await this.cards();\n const card = cards.find(entry => entry.id === cardId);\n if (!card) {\n return { refresh: \"devices\" };\n }\n // Prefill what the CARD shows, by the same rule loadDevices titles it: the device object's\n // name when it carries one, otherwise the table entry.\n const node = await this.adapter.getForeignObjectAsync(`${this.adapter.namespace}.${cardId}`);\n const shownName =\n typeof node?.common?.name === \"string\" && node.common.name !== cardId ? node.common.name : card.name;\n const percent = await this.volumeAsPercentOf(cardId);\n const data = await context.showForm(\n buildDeviceForm(cards.filter(entry => entry.id !== cardId).map(entry => entry.ip)),\n { title: t(\"dmEditTitle\"), data: { name: shownName, ip: card.ip, volumeAsPercent: percent } },\n );\n if (!data || typeof data.ip !== \"string\" || !data.ip.trim()) {\n return { refresh: \"devices\" };\n }\n const ip = data.ip.trim();\n const name = typeof data.name === \"string\" ? data.name.trim() : \"\";\n const manual = await this.readManual();\n const index = manual.findIndex(entry => rowId(entry) === cardId);\n const row: ManualRow = { name: cardId, ip };\n const clash = findClash(manual, row, index);\n if (clash) {\n await context.showMessage(clash);\n return { refresh: \"devices\" };\n }\n if (index >= 0) {\n manual[index] = row;\n await this.writeManual(manual);\n } else if (ip !== card.ip) {\n const store = discoveredStoreDeps(this.adapter);\n const discovered = await readDiscovered(store);\n await writeDiscovered(\n store,\n discovered.filter((entry: DeviceRecord) => entry.id !== cardId),\n );\n manual.push(row);\n await this.writeManual(manual);\n }\n if (name !== shownName) {\n await this.adapter.extendForeignObjectAsync(`${this.adapter.namespace}.${cardId}`, {\n common: { name: name || cardId },\n });\n }\n if ((data.volumeAsPercent === true) !== percent) {\n await this.applyVolumePercent(cardId, data.volumeAsPercent === true);\n }\n return { refresh: \"devices\" };\n }\n\n /**\n * Delete a device after confirmation, from whichever source it came from: a manual card\n * from `native.devices`, a discovered card from the discovery store \u2014 the latter so the\n * standby-protection merge does not resurrect it on the next start.\n *\n * @param cardId the card id (= the object-tree device id)\n * @param context the action context\n * @returns a directive to reload the list\n */\n private async deleteDevice(cardId: string, context: ActionContext): Promise<{ refresh: \"devices\" }> {\n const manual = await this.readManual();\n // Which store this card lives in, not which store is non-empty: with a mixed set a filled\n // table no longer means every card is manual, and the old shape silently did nothing when a\n // discovered card was deleted next to a typed one.\n const index = manual.findIndex(r => rowId(r) === cardId);\n if (index >= 0) {\n const confirmed = await context.showConfirmation(t(\"dmDeleteConfirm\", manual[index].name || manual[index].ip));\n if (confirmed) {\n manual.splice(index, 1);\n await this.writeManual(manual);\n }\n return { refresh: \"devices\" };\n }\n const store = discoveredStoreDeps(this.adapter);\n const discovered = await readDiscovered(store);\n const remaining = discovered.filter((d: DeviceRecord) => d.id !== cardId);\n if (remaining.length !== discovered.length) {\n const confirmed = await context.showConfirmation(t(\"dmDeleteConfirm\", cardId));\n if (confirmed) {\n await writeDiscovered(store, remaining);\n // Writing this file restarts nothing, so the delete has to do the two things a restart\n // would otherwise do much later: stop talking to the device and take its tree away.\n await this.owner?.removeDevice(cardId);\n // And it has to STAY deleted \u2014 without this the next network search finds the receiver\n // and puts the card straight back.\n const ignoredDeps = ignoredStoreDeps(this.adapter);\n await writeIgnored(ignoredDeps, [...(await readIgnored(ignoredDeps)), cardId]);\n }\n }\n return { refresh: \"devices\" };\n }\n}\n"],
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,sBAMO;AACP,kBAAkB;AAClB,
|
|
4
|
+
"sourcesContent": ["import {\n DeviceManagement,\n type ActionContext,\n type DeviceInfo,\n type DeviceLoadContext,\n type InstanceDetails,\n} from \"@iobroker/dm-utils\";\nimport { t } from \"./lib/i18n\";\nimport { iconForModel, volumeIndicatorIcon } from \"./lib/device-type\";\nimport { readDiscovered, readIgnored, writeDiscovered, writeIgnored } from \"./lib/discovered-store\";\nimport { discoveredStoreDeps, ignoredStoreDeps } from \"./lib/discovered-store-deps\";\nimport type { DeviceRecord } from \"./lib/types\";\nimport { unionDevices } from \"./lib/pure-helpers\";\nimport {\n TRANSPORTS,\n buildDeviceForm,\n findClash,\n rowId,\n type CardDevice,\n type ManualRow,\n} from \"./device-management-helpers\";\n\n/** The one adapter method this backend needs beyond the plain ioBroker surface. */\ninterface DeviceOwner {\n /** Stop supervising a device and delete its object tree. */\n removeDevice(deviceId: string): Promise<void>;\n /** Switch one device's volume datapoints to percent (or back) and rebuild them at once. */\n setVolumePercent(deviceId: string, on: boolean): Promise<void>;\n}\n\n/**\n * ioBroker device-manager backend: the Yamaha receivers as cards showing the live model,\n * the IP, and which protocols (YNCA/MusicCast/XML) are connected right now, with a manual\n * add-by-IP dialog. The card list follows the running set \u2014 the manual `native.devices`\n * table when it is filled, otherwise the auto-discovered devices \u2014 so it matches exactly\n * what the adapter runs. \"Yamaha\" is never a card line: it is the whole adapter.\n */\nexport class YamahaDeviceManagement extends DeviceManagement {\n /** The instance object id whose `native` holds the manual device table. */\n private get objId(): string {\n return `system.adapter.${this.adapter.namespace}`;\n }\n\n /** The running adapter, for the one action that has to reach into it (delete a device). */\n private get owner(): DeviceOwner | undefined {\n const candidate = this.adapter as unknown as Partial<DeviceOwner>;\n return typeof candidate.removeDevice === \"function\" && typeof candidate.setVolumePercent === \"function\"\n ? (candidate as DeviceOwner)\n : undefined;\n }\n\n /** Read the manual device table (`native.devices`) as raw rows, keeping the name. */\n private async readManual(): Promise<ManualRow[]> {\n const obj = await this.adapter.getForeignObjectAsync(this.objId);\n const devices = (obj?.native as { devices?: unknown } | undefined)?.devices;\n if (!Array.isArray(devices)) {\n return [];\n }\n return devices.filter(\n (d): d is ManualRow => !!d && typeof (d as ManualRow).ip === \"string\" && (d as ManualRow).ip.length > 0,\n );\n }\n\n /**\n * Persist the manual device table; writing `native.*` restarts the adapter with the new set.\n *\n * @param rows the manual rows to store\n */\n private async writeManual(rows: ManualRow[]): Promise<void> {\n await this.adapter.extendForeignObjectAsync(this.objId, { native: { devices: rows } });\n }\n\n /**\n * Whether one device's volume datapoints read 0\u2013100 %, for the edit dialog to prefill.\n *\n * @param deviceId the id-safe device id\n * @returns true when this device is set to percent\n */\n private async volumeAsPercentOf(deviceId: string): Promise<boolean> {\n const node = await this.adapter.getForeignObjectAsync(`${this.adapter.namespace}.${deviceId}`);\n return (node?.native as { volumeAsPercent?: unknown } | undefined)?.volumeAsPercent === true;\n }\n\n /**\n * Set one device's percent switch. The value lives at the DEVICE object \u2014 one place the card,\n * the dialog and the running adapter all read, and writing it restarts nothing (an instance\n * object's native would).\n *\n * @param deviceId the id-safe device id\n * @param on whether its volume datapoints should read 0\u2013100 %\n */\n private async applyVolumePercent(deviceId: string, on: boolean): Promise<void> {\n const id = `${this.adapter.namespace}.${deviceId}`;\n const existing = await this.adapter.getForeignObjectAsync(id);\n const owner = this.owner;\n if (existing && owner) {\n // The adapter is running this device: it writes the value AND rebuilds the volume\n // datapoints on the spot, so the change is visible without a restart.\n await owner.setVolumePercent(deviceId, on);\n return;\n }\n // A device just added through the dialog has no object yet \u2014 seed the shape\n // `ensureDeviceHeader` completes on the next start, so the answer has somewhere to live.\n await this.adapter.extendForeignObjectAsync(id, {\n type: \"device\",\n common: { name: deviceId },\n native: { volumeAsPercent: on },\n });\n }\n\n /**\n * The running device set as cards: the manual table AND the discovery store, exactly the\n * union the adapter itself runs (`unionDevices`), so the list matches what is live.\n *\n * @returns the cards, each tagged with where its address came from\n */\n private async cards(): Promise<CardDevice[]> {\n const names = new Map<string, string>();\n const manualRecords: DeviceRecord[] = [];\n for (const row of await this.readManual()) {\n const id = rowId(row);\n // \"info\" is the adapter's own channel \u2014 a device may never claim it.\n if (id === \"info\" || names.has(id)) {\n continue;\n }\n names.set(id, row.name && row.name.length > 0 ? row.name : row.ip);\n manualRecords.push({ id, ip: row.ip });\n }\n const discovered = await readDiscovered(discoveredStoreDeps(this.adapter));\n return unionDevices(manualRecords, discovered).map(device => ({\n id: device.id,\n ip: device.ip,\n name: names.get(device.id) ?? device.id,\n source: device.source ?? \"discovered\",\n }));\n }\n\n /**\n * Populate the manager with one card per running device.\n *\n * @param context the load context\n */\n protected async loadDevices(context: DeviceLoadContext<string>): Promise<void> {\n for (const card of await this.cards()) {\n // Model and object are independent reads \u2014 fetch them together so a card with\n // several devices does not add up their round-trips.\n const [model, node] = await Promise.all([\n this.adapter.getForeignStateAsync(`${this.adapter.namespace}.${card.id}.info.model`),\n this.adapter.getForeignObjectAsync(`${this.adapter.namespace}.${card.id}`),\n ]);\n // The card title follows the device object's name, not the table entry. On an\n // instance upgraded from the previous adapter the table entry is the receiver's\n // ip \u2014 the object carries the readable name the adapter learned from the device.\n // The table entry itself must stay put: the object id is derived from it, and\n // changing that would move the whole tree.\n const label = typeof node?.common?.name === \"string\" ? node.common.name : undefined;\n // The percent answer comes from the object read above \u2014 no extra round-trip for the badge.\n const percent = (node?.native as { volumeAsPercent?: unknown } | undefined)?.volumeAsPercent === true;\n context.addDevice(\n this.toDeviceInfo(\n label && label !== card.id ? { ...card, name: label } : card,\n typeof model?.val === \"string\" ? model.val : undefined,\n percent,\n ),\n );\n }\n }\n\n /**\n * Build one device card: the live model, the IP as the identifier line, a connection\n * status, and one indicator per connected protocol (hidden while that protocol is not\n * connected). The live values read from the device's own `info.*` states. Every card can be\n * edited, a discovered one included: that is how a found receiver is given the fixed address\n * the user just assigned it (see {@link editDevice}).\n *\n * @param card the running device\n * @param model the device's reported model name, for the device-class icon\n * @param percent whether this device's volume datapoints read 0\u2013100 %, for the badge\n * @returns the card descriptor\n */\n private toDeviceInfo(card: CardDevice, model?: string, percent = false): DeviceInfo<string> {\n const base = `${this.adapter.namespace}.${card.id}`;\n const del = {\n id: \"delete\",\n icon: \"delete\",\n description: t(\"dmDelete\"),\n handler: async (id: string, ctx: ActionContext): Promise<{ refresh: \"devices\" }> => this.deleteDevice(id, ctx),\n };\n const edit = {\n id: \"edit\",\n icon: \"edit\",\n description: t(\"dmEdit\"),\n handler: async (id: string, ctx: ActionContext): Promise<{ refresh: \"devices\" }> => this.editDevice(id, ctx),\n };\n return {\n id: card.id,\n name: card.name,\n icon: iconForModel(model),\n model: { stateId: `${base}.info.model` },\n identifier: card.ip,\n status: {\n connection: { stateId: `${base}.info.connection`, mapping: { true: \"connected\", false: \"disconnected\" } },\n },\n // No icon on the transports: dm-gui-components renders exactly 17 `fa-*` names (its own\n // whitelist, `getFaIcon`) and inline `data:image/svg+xml` URLs \u2014 anything else is a \"?\".\n // The transport label as text plus a green \"on\" colour carries it; `hideIfEmpty` shows\n // only the protocols this device is connected over. Own glyphs go in as data URLs, drawn\n // with `currentColor` so they take the indicator's colour (see device-type.ts).\n indicators: [\n ...TRANSPORTS.map(tr => ({\n id: `transport-${tr.id}`,\n value: { stateId: `${base}.info.transports.${tr.id}` },\n text: tr.label,\n colorOn: \"ok\" as const,\n hideIfEmpty: true,\n })),\n // The percent setting is SET in the edit dialog, but it has to be READABLE at a glance \u2014\n // otherwise the only way to find out what a receiver's volume datapoints carry is to open\n // a dialog. With percent on, the glyph is a speaker with a percent sign and the main\n // zone's live volume stands under it as \"42 %\"; in the device's own scale the plain\n // speaker stands alone. Always shown (`hideIfEmpty: false` \u2014 0 % is a value), and given\n // a colour for BOTH states so the glyph does not grey out at the bottom of the scale.\n // Deliberately not clickable: the dialog stays the one place that sets it.\n {\n id: \"volume\",\n icon: volumeIndicatorIcon(percent),\n value: percent ? { stateId: `${base}.volume` } : true,\n ...(percent ? { showValue: true, unit: \"%\" } : {}),\n hideIfEmpty: false,\n color: \"primary\" as const,\n colorOn: \"primary\" as const,\n tooltip: t(percent ? \"volumeAsPercent\" : \"volumeDeviceScale\"),\n order: 20,\n },\n ],\n // Edit on every card: a device the search found can be given the fixed address the user\n // just assigned it, which makes it a manual device (see editDevice).\n // The percent switch is NOT a second control on the card: it lives in the edit dialog,\n // next to name and address, because it is a decision about the device and not something\n // flipped in passing. 2.9.1 had it in both places \u2014 the card control showed the wrong\n // position while the dialog showed the right one, and two ways to set one value is one\n // too many (krobi 2026-09-12: \"f\u00FCr was hast du den das doppelt gemoppelt?\").\n actions: [edit, del],\n };\n }\n\n /**\n * The \"+ add\" action above the list and the label of the identifier line (the IP).\n *\n * @returns the instance action descriptor\n */\n protected getInstanceInfo(): InstanceDetails {\n return {\n apiVersion: \"v3\",\n identifierLabel: t(\"ipLabel\"),\n actions: [{ id: \"add\", icon: \"add\", description: t(\"dmAdd\"), handler: async ctx => this.addDevice(ctx) }],\n };\n }\n\n /**\n * Manual add: show the name+IP form, then append the device to `native.devices` (which\n * restarts the adapter and switches it to manual mode).\n *\n * @param context the action context\n * @returns a directive to reload the manager\n */\n private async addDevice(context: ActionContext): Promise<{ refresh: boolean }> {\n const manual = await this.readManual();\n const data = await context.showForm(buildDeviceForm(manual.map(r => r.ip)), { title: t(\"dmAdd\") });\n if (data && typeof data.ip === \"string\" && data.ip.trim()) {\n const row: ManualRow = { name: typeof data.name === \"string\" ? data.name.trim() : \"\", ip: data.ip.trim() };\n const clash = findClash(manual, row, -1);\n if (clash) {\n await context.showMessage(clash);\n return { refresh: true };\n }\n manual.push(row);\n // Adding a device by hand undoes an earlier delete of the same id \u2014 otherwise the\n // exclusion would silently outlive the decision that created it.\n const ignoredDeps = ignoredStoreDeps(this.adapter);\n const ignored = await readIgnored(ignoredDeps);\n const id = rowId(row);\n if (ignored.includes(id)) {\n await writeIgnored(\n ignoredDeps,\n ignored.filter(entry => entry !== id),\n );\n }\n await this.writeManual(manual);\n // Written down right away, so the device starts with the answer the user gave instead of\n // inheriting whatever the instance-wide switch of 2.8.0 was left on.\n await this.applyVolumePercent(id, data.volumeAsPercent === true);\n }\n return { refresh: true };\n }\n\n /**\n * Edit one device: its display name and its address. Offered on every card.\n *\n * Two rules make this safe, and both were learned from what the adapter does elsewhere:\n *\n * 1. **The object id never changes.** It is derived from the table row's name, and a changed\n * id would leave the whole object tree behind for `cleanupStaleObjects` to delete \u2014\n * history and VIS bindings with it. The row therefore carries the id as its name, and what\n * the user typed becomes the DISPLAY name at the device object, where `nextDeviceLabel`\n * already defends a user's own name against every name the device reports.\n * 2. **A discovered device whose address changes MOVES into the table.** The user gave the\n * receiver a fixed address and entered it; that is what a manual device is. Copying it\n * instead would lose the edit at the next search \u2014 `mergeDiscovered` carries the found\n * address onto a known id.\n *\n * @param cardId the card id (= the object-tree device id)\n * @param context the action context\n * @returns a directive to reload the list\n */\n private async editDevice(cardId: string, context: ActionContext): Promise<{ refresh: \"devices\" }> {\n const cards = await this.cards();\n const card = cards.find(entry => entry.id === cardId);\n if (!card) {\n return { refresh: \"devices\" };\n }\n // Prefill what the CARD shows, by the same rule loadDevices titles it: the device object's\n // name when it carries one, otherwise the table entry.\n const node = await this.adapter.getForeignObjectAsync(`${this.adapter.namespace}.${cardId}`);\n const shownName =\n typeof node?.common?.name === \"string\" && node.common.name !== cardId ? node.common.name : card.name;\n const percent = await this.volumeAsPercentOf(cardId);\n const data = await context.showForm(\n buildDeviceForm(cards.filter(entry => entry.id !== cardId).map(entry => entry.ip)),\n { title: t(\"dmEditTitle\"), data: { name: shownName, ip: card.ip, volumeAsPercent: percent } },\n );\n if (!data || typeof data.ip !== \"string\" || !data.ip.trim()) {\n return { refresh: \"devices\" };\n }\n const ip = data.ip.trim();\n const name = typeof data.name === \"string\" ? data.name.trim() : \"\";\n const manual = await this.readManual();\n const index = manual.findIndex(entry => rowId(entry) === cardId);\n const row: ManualRow = { name: cardId, ip };\n const clash = findClash(manual, row, index);\n if (clash) {\n await context.showMessage(clash);\n return { refresh: \"devices\" };\n }\n if (index >= 0) {\n manual[index] = row;\n await this.writeManual(manual);\n } else if (ip !== card.ip) {\n const store = discoveredStoreDeps(this.adapter);\n const discovered = await readDiscovered(store);\n await writeDiscovered(\n store,\n discovered.filter((entry: DeviceRecord) => entry.id !== cardId),\n );\n manual.push(row);\n await this.writeManual(manual);\n }\n if (name !== shownName) {\n await this.adapter.extendForeignObjectAsync(`${this.adapter.namespace}.${cardId}`, {\n common: { name: name || cardId },\n });\n }\n if ((data.volumeAsPercent === true) !== percent) {\n await this.applyVolumePercent(cardId, data.volumeAsPercent === true);\n }\n return { refresh: \"devices\" };\n }\n\n /**\n * Delete a device after confirmation, from whichever source it came from: a manual card\n * from `native.devices`, a discovered card from the discovery store \u2014 the latter so the\n * standby-protection merge does not resurrect it on the next start.\n *\n * @param cardId the card id (= the object-tree device id)\n * @param context the action context\n * @returns a directive to reload the list\n */\n private async deleteDevice(cardId: string, context: ActionContext): Promise<{ refresh: \"devices\" }> {\n const manual = await this.readManual();\n // Which store this card lives in, not which store is non-empty: with a mixed set a filled\n // table no longer means every card is manual, and the old shape silently did nothing when a\n // discovered card was deleted next to a typed one.\n const index = manual.findIndex(r => rowId(r) === cardId);\n if (index >= 0) {\n const confirmed = await context.showConfirmation(t(\"dmDeleteConfirm\", manual[index].name || manual[index].ip));\n if (confirmed) {\n manual.splice(index, 1);\n await this.writeManual(manual);\n }\n return { refresh: \"devices\" };\n }\n const store = discoveredStoreDeps(this.adapter);\n const discovered = await readDiscovered(store);\n const remaining = discovered.filter((d: DeviceRecord) => d.id !== cardId);\n if (remaining.length !== discovered.length) {\n const confirmed = await context.showConfirmation(t(\"dmDeleteConfirm\", cardId));\n if (confirmed) {\n await writeDiscovered(store, remaining);\n // Writing this file restarts nothing, so the delete has to do the two things a restart\n // would otherwise do much later: stop talking to the device and take its tree away.\n await this.owner?.removeDevice(cardId);\n // And it has to STAY deleted \u2014 without this the next network search finds the receiver\n // and puts the card straight back.\n const ignoredDeps = ignoredStoreDeps(this.adapter);\n await writeIgnored(ignoredDeps, [...(await readIgnored(ignoredDeps)), cardId]);\n }\n }\n return { refresh: \"devices\" };\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -19,7 +19,8 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
19
19
|
var attempt_device_exports = {};
|
|
20
20
|
__export(attempt_device_exports, {
|
|
21
21
|
attemptDevice: () => attemptDevice,
|
|
22
|
-
connectTransports: () => connectTransports
|
|
22
|
+
connectTransports: () => connectTransports,
|
|
23
|
+
partnerClient: () => partnerClient
|
|
23
24
|
});
|
|
24
25
|
module.exports = __toCommonJS(attempt_device_exports);
|
|
25
26
|
var import_ynca_client = require("./ynca/ynca-client");
|
|
@@ -37,6 +38,9 @@ var import_util = require("./util");
|
|
|
37
38
|
const TRANSPORT_RECONNECT_BASE_MS = 1e3;
|
|
38
39
|
const TRANSPORT_RECONNECT_MAX_MS = 6e4;
|
|
39
40
|
const COMMAND_SPACING_MS = { ynca: 100, yxc: 0, xml: 0 };
|
|
41
|
+
function partnerClient(ownIp, knownDeviceIps, ip) {
|
|
42
|
+
return ip !== ownIp && knownDeviceIps.has(ip) ? new import_http_client.YamahaYxcClient(ip) : void 0;
|
|
43
|
+
}
|
|
40
44
|
async function connectTransports(deviceId, attempts, deps) {
|
|
41
45
|
var _a, _b, _c;
|
|
42
46
|
const results = await Promise.all(
|
|
@@ -110,10 +114,7 @@ function attemptDevice(device, deps) {
|
|
|
110
114
|
yxc.bind(
|
|
111
115
|
new import_device_controller2.YxcDeviceController(device.id, {
|
|
112
116
|
client: new import_http_client.YamahaYxcClient(device.ip, void 0, gate),
|
|
113
|
-
|
|
114
|
-
// itself. The partner's own gate belongs to its own connection, so this one-off client
|
|
115
|
-
// stays ungated (a single link call, not a stream of commands).
|
|
116
|
-
clientFor: (ip) => ip !== device.ip && deps.knownDeviceIps.has(ip) ? new import_http_client.YamahaYxcClient(ip) : void 0,
|
|
117
|
+
clientFor: (ip) => partnerClient(device.ip, deps.knownDeviceIps, ip),
|
|
117
118
|
registerPush: (onPush) => deps.registerPush(device.ip, onPush),
|
|
118
119
|
pushActive: deps.pushActive,
|
|
119
120
|
probeMemory: deps.probeMemory,
|
|
@@ -166,6 +167,7 @@ function attemptDevice(device, deps) {
|
|
|
166
167
|
// Annotate the CommonJS export names for ESM import in node:
|
|
167
168
|
0 && (module.exports = {
|
|
168
169
|
attemptDevice,
|
|
169
|
-
connectTransports
|
|
170
|
+
connectTransports,
|
|
171
|
+
partnerClient
|
|
170
172
|
});
|
|
171
173
|
//# sourceMappingURL=attempt-device.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/lib/attempt-device.ts"],
|
|
4
|
-
"sourcesContent": ["import { YncaClient } from \"./ynca/ynca-client\";\nimport { YncaDeviceController } from \"./device-controller\";\nimport { YxcDeviceController } from \"./yxc/device-controller\";\nimport { YamahaYxcClient } from \"./yxc/http-client\";\nimport { XmlDeviceController } from \"./xml/device-controller\";\nimport { XmlClient } from \"./xml/xml-client\";\nimport { MultiTransportHandle, type ConnectableTransport } from \"./lifecycle/multi-transport-handle\";\nimport { TransportConnectionAdapter } from \"./lifecycle/transport-connection-adapter\";\nimport { ReconnectStrategy } from \"./lifecycle/reconnect-strategy\";\nimport { CommandGate } from \"./lifecycle/command-gate\";\nimport type { ReachabilityDedup } from \"./lifecycle/reachability-dedup\";\nimport type { YncaSubunitCache } from \"./ynca/subunit-cache\";\nimport type { ProbeMemory } from \"./lifecycle/probe-memory\";\nimport type { Transport } from \"./catalog/owner-policy\";\nimport { readyLine } from \"./ready-line\";\nimport { errorMessage } from \"./util\";\nimport type { ConnectionHandle, ControllerLog } from \"./controller\";\nimport type { ObjectDef } from \"./catalog/types\";\nimport type { DeviceRecord } from \"./types\";\n\n// Re-exported so existing importers (tests) keep resolving it from here.\nexport type { ConnectableTransport };\n\n/** Per-transport reconnect backoff bounds \u2014 same shape as the device supervisor's. */\nconst TRANSPORT_RECONNECT_BASE_MS = 1000;\nconst TRANSPORT_RECONNECT_MAX_MS = 60000;\n\n/**\n * Minimum spacing between two commands, per transport. YNCA's 100 ms is Yamaha's\n * specification (`ynca-python` protocol.py: \"YNCA spec specifies that there should be at\n * least 100 milliseconds between commands\"). The HTTP transports have no documented\n * spacing \u2014 0 ms, but they still run through a gate, which serialises them so an embedded\n * device never faces a burst of parallel requests.\n */\nconst COMMAND_SPACING_MS: Readonly<Record<Transport, number>> = { ynca: 100, yxc: 0, xml: 0 };\n\n/** The adapter-bound callbacks {@link attemptDevice} drives \u2014 injected so it needs no adapter. */\nexport interface AttemptDeps {\n /** Adapter log. */\n log: ControllerLog;\n /** Create or update an object in the device tree. */\n upsertObject(id: string, def: ObjectDef): Promise<void>;\n /** Write a state value with ack (device-originated). */\n setStateAck(id: string, value: boolean | number | string): void;\n /** Adapter-managed timers (YNCA pacing + per-transport reconnects). */\n timers: {\n /** Schedule a one-shot timer. */\n schedule(handler: () => void, ms: number): ioBroker.Timeout | undefined;\n /** Cancel a scheduled timer. */\n cancel(handle: ioBroker.Timeout | undefined): void;\n };\n /** Register a YXC push handler for a device IP; returns a function that unregisters it. */\n registerPush(ip: string, onPush: (event: unknown) => void): () => void;\n /** Whether the shared push receiver is listening (decides how much the keepalive polls). */\n pushActive?(): boolean;\n /** Schedule a repeating keepalive; returns a function that cancels it. */\n scheduleKeepalive(handler: () => void, ms: number): () => void;\n /** How often to poll an XML/YNC device for state (ms). */\n xmlPollIntervalMs: number;\n /** Report the transports that are live after every change \u2014 the id-safe names (\"ynca\"/\"yxc\"/\"xml\"). */\n onTransports?(names: string[]): void;\n /** Report the name the device carries for itself (MusicCast), for the device object's label. */\n onDeviceName?(name: string): void;\n /** IPs of all configured devices, so a MusicCast group can resolve a client device by IP. */\n knownDeviceIps: Set<string>;\n /** Dedup for the \"no reachable transport\" warning \u2014 see {@link ConnectDeps.reachability}. */\n reachability?: ReachabilityDedup;\n /** Datapoint-group gate for the YNCA sweep \u2014 a disabled group's functions are never fetched. */\n isEntryEnabled?(id: string): boolean;\n /** Per-device cache of the YNCA AVAIL probe, held by the caller across reconnects. */\n yncaSubunitCache?: YncaSubunitCache;\n /** Per-device memory for device answers that stay constant while it runs (held by the caller). */\n probeMemory?: ProbeMemory;\n}\n\n/** One transport to try: its name and a factory building a FRESH connectable (also for reconnects). */\nexport interface TransportAttempt {\n /** The transport this attempt stands for. */\n transport: Transport;\n /** Build a fresh connectable transport (a controller behind its adapter). */\n build(): ConnectableTransport;\n}\n\n/** The adapter callbacks {@link connectTransports} drives to build and hold the unified tree. */\nexport interface ConnectDeps {\n /** Adapter log. */\n log: ControllerLog;\n /** Create or update an object in the device tree. */\n upsertObject(id: string, def: ObjectDef): Promise<void>;\n /** Report the transports that are live after every change \u2014 the id-safe names (\"ynca\"/\"yxc\"/\"xml\"). */\n onTransports?(names: string[]): void;\n /**\n * Dedup for the \"no reachable transport\" warning: without it every retry warns\n * again for as long as the device stays offline (nut2 `failedUps` pattern \u2014 first\n * failure warns, repeats stay at debug until the device answers again).\n */\n reachability?: ReachabilityDedup;\n /** Timers for the per-transport reconnect loops (absent in tests \u2192 no per-transport retry). */\n timers?: {\n /** Schedule a one-shot timer. */\n schedule(handler: () => void, ms: number): ioBroker.Timeout | undefined;\n /** Cancel a scheduled timer. */\n cancel(handle: ioBroker.Timeout | undefined): void;\n };\n}\n\n/**\n * Bring every answering transport online on ONE object tree. All candidates connect IN\n * PARALLEL (a YNCA connect timeout or long sweep no longer delays YXC/XML); the ones that\n * answer are handed to a single {@link MultiTransportHandle}, which unifies their catalogs\n * (each capability owned by exactly one transport \u2014 most-modern-but-lossless, see the\n * object-tree coordinator), routes user writes to the owner, and reconnects a single\n * dropped transport on its own while the others keep running. Ownership is resolved by\n * rank over the connected set, so the connect order/timing never changes it. A transport\n * that does not answer, or whose connect throws, is closed and left out. Returns the\n * handle over the live set, or null when no transport answered (the device is offline\n * this attempt).\n *\n * @param deviceId the id-safe device id\n * @param attempts the transports to try\n * @param deps the adapter callbacks (upsert + log + timers)\n * @returns a connection handle over the live transports, or null when none connected\n */\nexport async function connectTransports(\n deviceId: string,\n attempts: readonly TransportAttempt[],\n deps: ConnectDeps,\n): Promise<ConnectionHandle | null> {\n const results = await Promise.all(\n attempts.map(async attempt => {\n const conn = attempt.build();\n try {\n if (await conn.connect()) {\n return conn;\n }\n } catch (e) {\n deps.log.debug(`${deviceId}/${conn.transport}: transport did not connect (${errorMessage(e)})`);\n }\n conn.close();\n return null;\n }),\n );\n const live = results.filter((conn): conn is ConnectableTransport => conn !== null);\n if (live.length === 0) {\n const level = deps.reachability?.reportUnreachable() ?? \"warn\";\n deps.log[level](`${deviceId}: no reachable transport (YNCA/YXC/XML)`);\n return null;\n }\n deps.reachability?.reportReachable();\n const rebuilds = new Map(attempts.map(attempt => [attempt.transport, attempt.build] as const));\n const handle = new MultiTransportHandle(deviceId, live, {\n upsertObject: deps.upsertObject,\n log: deps.log,\n onTransports: deps.onTransports,\n rebuild: deps.timers ? transport => rebuilds.get(transport)!() : undefined,\n schedule: deps.timers ? (cb, ms) => deps.timers!.schedule(cb, ms) : undefined,\n cancel: deps.timers ? handle_ => deps.timers!.cancel(handle_ as ioBroker.Timeout | undefined) : undefined,\n backoffFactory: () => new ReconnectStrategy(TRANSPORT_RECONNECT_BASE_MS, TRANSPORT_RECONNECT_MAX_MS),\n });\n try {\n await handle.start();\n } catch (e) {\n // Building the unified tree failed (e.g. object creation errored): close every live\n // transport before rethrowing, or the supervisor's retry would leak sockets and timers \u2014\n // fatal for YNCA, where the receiver allows only ONE connection and a zombie socket\n // would block every future attempt until the adapter restarts.\n handle.close();\n throw e;\n }\n // One summary line instead of three per-transport \"ready\" lines; each controller logs its\n // own readiness at debug level for diagnostics.\n deps.log.info(\n readyLine(\n deviceId,\n live.map(conn => conn.transport),\n ),\n );\n return handle;\n}\n\n/**\n * Bring one device online across ALL its transports. Every transport that answers \u2014 YNCA (amp\n * control over a held TCP connection), YXC (MusicCast, push + poll), XML/YNC (pre-2010) \u2014 is built\n * behind a {@link TransportConnectionAdapter} and connected in parallel on one object tree, so a\n * MusicCast AVR gets YNCA base control AND the YXC-exclusive richness (multiroom, equalizer, album\n * art) instead of one transport winning and hiding the rest. Each transport is described by a\n * factory, so the handle can rebuild and reconnect a single dropped transport while the others\n * keep running. Returns a {@link ConnectionHandle} over the live set, or null when no transport\n * answers this attempt.\n *\n * @param device the configured device record\n * @param deps the adapter-bound callbacks\n * @returns a connection handle, or null when no transport connected\n */\nexport function attemptDevice(device: DeviceRecord, deps: AttemptDeps): Promise<ConnectionHandle | null> {\n const { log, upsertObject, setStateAck, timers } = deps;\n /**\n * A fresh command gate for one transport connection. EVERY command of that transport\n * goes through it: user writes, the init sweep, the keepalive and browsing alike. It is\n * also the connection's shutdown signal \u2014 closing it empties the queue and ends every\n * pending wait, so a stopped adapter leaves nothing running.\n *\n * One gate per device AND transport, not one for the adapter: the spacing is a property\n * of the device connection, so a shared gate would let one receiver's 19-second sweep\n * block another receiver's button press.\n *\n * @param transport the transport the gate belongs to\n * @returns the gate\n */\n const gateFor = (transport: Transport): CommandGate =>\n new CommandGate({ minSpacingMs: COMMAND_SPACING_MS[transport], timers });\n\n // 1) YNCA \u2014 amp control over a held TCP connection; a socket drop is the genuine gone-signal.\n const buildYnca = (): ConnectableTransport => {\n const ynca = new TransportConnectionAdapter(\"ynca\", device.id, setStateAck);\n const gate = gateFor(\"ynca\");\n ynca.bind(\n new YncaDeviceController(device.id, {\n client: new YncaClient(device.ip, timers, gate),\n gate,\n upsertObject: ynca.interceptUpsert,\n setStateAck: ynca.interceptSetStateAck,\n log,\n isEntryEnabled: deps.isEntryEnabled,\n subunitCache: deps.yncaSubunitCache,\n probeMemory: deps.probeMemory,\n }),\n );\n return ynca;\n };\n\n // 2) YXC \u2014 MusicCast; polled + push. Drop reported after a run of failed keepalive polls.\n const buildYxc = (): ConnectableTransport => {\n const yxc = new TransportConnectionAdapter(\"yxc\", device.id, setStateAck);\n const gate = gateFor(\"yxc\");\n yxc.bind(\n new YxcDeviceController(device.id, {\n client: new YamahaYxcClient(device.ip, undefined, gate),\n // Resolve another configured device's client for a multiroom link \u2014 never this device\n // itself. The partner's own gate belongs to its own connection, so this one-off client\n // stays ungated (a single link call, not a stream of commands).\n clientFor: ip => (ip !== device.ip && deps.knownDeviceIps.has(ip) ? new YamahaYxcClient(ip) : undefined),\n registerPush: onPush => deps.registerPush(device.ip, onPush),\n pushActive: deps.pushActive,\n probeMemory: deps.probeMemory,\n scheduleKeepalive: deps.scheduleKeepalive,\n upsertObject: yxc.interceptUpsert,\n setStateAck: yxc.interceptSetStateAck,\n reportDeviceName: deps.onDeviceName,\n log,\n gate,\n }),\n );\n return yxc;\n };\n\n // 3) XML/YNC \u2014 pre-2010 receivers; polled. A drop is reported after a run of failed polls.\n const buildXml = (): ConnectableTransport => {\n const xml = new TransportConnectionAdapter(\"xml\", device.id, setStateAck);\n const gate = gateFor(\"xml\");\n xml.bind(\n new XmlDeviceController(\n device.id,\n {\n client: new XmlClient(device.ip, undefined, gate),\n scheduleKeepalive: deps.scheduleKeepalive,\n upsertObject: xml.interceptUpsert,\n setStateAck: xml.interceptSetStateAck,\n log,\n gate,\n probeMemory: deps.probeMemory,\n },\n deps.xmlPollIntervalMs,\n ),\n );\n return xml;\n };\n\n return connectTransports(\n device.id,\n [\n { transport: \"ynca\", build: buildYnca },\n { transport: \"yxc\", build: buildYxc },\n { transport: \"xml\", build: buildXml },\n ],\n {\n upsertObject,\n log,\n onTransports: deps.onTransports,\n reachability: deps.reachability,\n timers: deps.timers,\n },\n );\n}\n"],
|
|
5
|
-
"mappings": ";;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,yBAA2B;AAC3B,+BAAqC;AACrC,IAAAA,4BAAoC;AACpC,yBAAgC;AAChC,IAAAA,4BAAoC;AACpC,wBAA0B;AAC1B,oCAAgE;AAChE,0CAA2C;AAC3C,gCAAkC;AAClC,0BAA4B;AAK5B,wBAA0B;AAC1B,kBAA6B;AAS7B,MAAM,8BAA8B;AACpC,MAAM,6BAA6B;AASnC,MAAM,qBAA0D,EAAE,MAAM,KAAK,KAAK,GAAG,KAAK,EAAE;
|
|
4
|
+
"sourcesContent": ["import { YncaClient } from \"./ynca/ynca-client\";\nimport { YncaDeviceController } from \"./device-controller\";\nimport { YxcDeviceController } from \"./yxc/device-controller\";\nimport { YamahaYxcClient } from \"./yxc/http-client\";\nimport { XmlDeviceController } from \"./xml/device-controller\";\nimport { XmlClient } from \"./xml/xml-client\";\nimport { MultiTransportHandle, type ConnectableTransport } from \"./lifecycle/multi-transport-handle\";\nimport { TransportConnectionAdapter } from \"./lifecycle/transport-connection-adapter\";\nimport { ReconnectStrategy } from \"./lifecycle/reconnect-strategy\";\nimport { CommandGate } from \"./lifecycle/command-gate\";\nimport type { ReachabilityDedup } from \"./lifecycle/reachability-dedup\";\nimport type { YncaSubunitCache } from \"./ynca/subunit-cache\";\nimport type { ProbeMemory } from \"./lifecycle/probe-memory\";\nimport type { Transport } from \"./catalog/owner-policy\";\nimport { readyLine } from \"./ready-line\";\nimport { errorMessage } from \"./util\";\nimport type { ConnectionHandle, ControllerLog } from \"./controller\";\nimport type { ObjectDef } from \"./catalog/types\";\nimport type { DeviceRecord } from \"./types\";\n\n// Re-exported so existing importers (tests) keep resolving it from here.\nexport type { ConnectableTransport };\n\n/** Per-transport reconnect backoff bounds \u2014 same shape as the device supervisor's. */\nconst TRANSPORT_RECONNECT_BASE_MS = 1000;\nconst TRANSPORT_RECONNECT_MAX_MS = 60000;\n\n/**\n * Minimum spacing between two commands, per transport. YNCA's 100 ms is Yamaha's\n * specification (`ynca-python` protocol.py: \"YNCA spec specifies that there should be at\n * least 100 milliseconds between commands\"). The HTTP transports have no documented\n * spacing \u2014 0 ms, but they still run through a gate, which serialises them so an embedded\n * device never faces a burst of parallel requests.\n */\nconst COMMAND_SPACING_MS: Readonly<Record<Transport, number>> = { ynca: 100, yxc: 0, xml: 0 };\n\n/** The adapter-bound callbacks {@link attemptDevice} drives \u2014 injected so it needs no adapter. */\nexport interface AttemptDeps {\n /** Adapter log. */\n log: ControllerLog;\n /** Create or update an object in the device tree. */\n upsertObject(id: string, def: ObjectDef): Promise<void>;\n /** Write a state value with ack (device-originated). */\n setStateAck(id: string, value: boolean | number | string): void;\n /** Adapter-managed timers (YNCA pacing + per-transport reconnects). */\n timers: {\n /** Schedule a one-shot timer. */\n schedule(handler: () => void, ms: number): ioBroker.Timeout | undefined;\n /** Cancel a scheduled timer. */\n cancel(handle: ioBroker.Timeout | undefined): void;\n };\n /** Register a YXC push handler for a device IP; returns a function that unregisters it. */\n registerPush(ip: string, onPush: (event: unknown) => void): () => void;\n /** Whether the shared push receiver is listening (decides how much the keepalive polls). */\n pushActive?(): boolean;\n /** Schedule a repeating keepalive; returns a function that cancels it. */\n scheduleKeepalive(handler: () => void, ms: number): () => void;\n /** How often to poll an XML/YNC device for state (ms). */\n xmlPollIntervalMs: number;\n /** Report the transports that are live after every change \u2014 the id-safe names (\"ynca\"/\"yxc\"/\"xml\"). */\n onTransports?(names: string[]): void;\n /** Report the name the device carries for itself (MusicCast), for the device object's label. */\n onDeviceName?(name: string): void;\n /** IPs of all configured devices, so a MusicCast group can resolve a client device by IP. */\n knownDeviceIps: Set<string>;\n /** Dedup for the \"no reachable transport\" warning \u2014 see {@link ConnectDeps.reachability}. */\n reachability?: ReachabilityDedup;\n /** Datapoint-group gate for the YNCA sweep \u2014 a disabled group's functions are never fetched. */\n isEntryEnabled?(id: string): boolean;\n /** Per-device cache of the YNCA AVAIL probe, held by the caller across reconnects. */\n yncaSubunitCache?: YncaSubunitCache;\n /** Per-device memory for device answers that stay constant while it runs (held by the caller). */\n probeMemory?: ProbeMemory;\n}\n\n/** One transport to try: its name and a factory building a FRESH connectable (also for reconnects). */\nexport interface TransportAttempt {\n /** The transport this attempt stands for. */\n transport: Transport;\n /** Build a fresh connectable transport (a controller behind its adapter). */\n build(): ConnectableTransport;\n}\n\n/** The adapter callbacks {@link connectTransports} drives to build and hold the unified tree. */\nexport interface ConnectDeps {\n /** Adapter log. */\n log: ControllerLog;\n /** Create or update an object in the device tree. */\n upsertObject(id: string, def: ObjectDef): Promise<void>;\n /** Report the transports that are live after every change \u2014 the id-safe names (\"ynca\"/\"yxc\"/\"xml\"). */\n onTransports?(names: string[]): void;\n /**\n * Dedup for the \"no reachable transport\" warning: without it every retry warns\n * again for as long as the device stays offline (nut2 `failedUps` pattern \u2014 first\n * failure warns, repeats stay at debug until the device answers again).\n */\n reachability?: ReachabilityDedup;\n /** Timers for the per-transport reconnect loops (absent in tests \u2192 no per-transport retry). */\n timers?: {\n /** Schedule a one-shot timer. */\n schedule(handler: () => void, ms: number): ioBroker.Timeout | undefined;\n /** Cancel a scheduled timer. */\n cancel(handle: ioBroker.Timeout | undefined): void;\n };\n}\n\n/**\n * Resolve another configured device's client for a multiroom link \u2014 never this device\n * itself, and only an address this instance runs. The partner's own gate belongs to its own\n * connection, so this one-off client stays ungated (a single link call, not a stream of\n * commands).\n *\n * @param ownIp the address of the device asking\n * @param knownDeviceIps every address this instance runs\n * @param ip the address the link names\n * @returns a client for the partner, or undefined when the address is not a partner\n */\nexport function partnerClient(\n ownIp: string,\n knownDeviceIps: ReadonlySet<string>,\n ip: string,\n): YamahaYxcClient | undefined {\n return ip !== ownIp && knownDeviceIps.has(ip) ? new YamahaYxcClient(ip) : undefined;\n}\n\n/**\n * Bring every answering transport online on ONE object tree. All candidates connect IN\n * PARALLEL (a YNCA connect timeout or long sweep no longer delays YXC/XML); the ones that\n * answer are handed to a single {@link MultiTransportHandle}, which unifies their catalogs\n * (each capability owned by exactly one transport \u2014 most-modern-but-lossless, see the\n * object-tree coordinator), routes user writes to the owner, and reconnects a single\n * dropped transport on its own while the others keep running. Ownership is resolved by\n * rank over the connected set, so the connect order/timing never changes it. A transport\n * that does not answer, or whose connect throws, is closed and left out. Returns the\n * handle over the live set, or null when no transport answered (the device is offline\n * this attempt).\n *\n * @param deviceId the id-safe device id\n * @param attempts the transports to try\n * @param deps the adapter callbacks (upsert + log + timers)\n * @returns a connection handle over the live transports, or null when none connected\n */\nexport async function connectTransports(\n deviceId: string,\n attempts: readonly TransportAttempt[],\n deps: ConnectDeps,\n): Promise<ConnectionHandle | null> {\n const results = await Promise.all(\n attempts.map(async attempt => {\n const conn = attempt.build();\n try {\n if (await conn.connect()) {\n return conn;\n }\n } catch (e) {\n deps.log.debug(`${deviceId}/${conn.transport}: transport did not connect (${errorMessage(e)})`);\n }\n conn.close();\n return null;\n }),\n );\n const live = results.filter((conn): conn is ConnectableTransport => conn !== null);\n if (live.length === 0) {\n const level = deps.reachability?.reportUnreachable() ?? \"warn\";\n deps.log[level](`${deviceId}: no reachable transport (YNCA/YXC/XML)`);\n return null;\n }\n deps.reachability?.reportReachable();\n const rebuilds = new Map(attempts.map(attempt => [attempt.transport, attempt.build] as const));\n const handle = new MultiTransportHandle(deviceId, live, {\n upsertObject: deps.upsertObject,\n log: deps.log,\n onTransports: deps.onTransports,\n rebuild: deps.timers ? transport => rebuilds.get(transport)!() : undefined,\n schedule: deps.timers ? (cb, ms) => deps.timers!.schedule(cb, ms) : undefined,\n cancel: deps.timers ? handle_ => deps.timers!.cancel(handle_ as ioBroker.Timeout | undefined) : undefined,\n backoffFactory: () => new ReconnectStrategy(TRANSPORT_RECONNECT_BASE_MS, TRANSPORT_RECONNECT_MAX_MS),\n });\n try {\n await handle.start();\n } catch (e) {\n // Building the unified tree failed (e.g. object creation errored): close every live\n // transport before rethrowing, or the supervisor's retry would leak sockets and timers \u2014\n // fatal for YNCA, where the receiver allows only ONE connection and a zombie socket\n // would block every future attempt until the adapter restarts.\n handle.close();\n throw e;\n }\n // One summary line instead of three per-transport \"ready\" lines; each controller logs its\n // own readiness at debug level for diagnostics.\n deps.log.info(\n readyLine(\n deviceId,\n live.map(conn => conn.transport),\n ),\n );\n return handle;\n}\n\n/**\n * Bring one device online across ALL its transports. Every transport that answers \u2014 YNCA (amp\n * control over a held TCP connection), YXC (MusicCast, push + poll), XML/YNC (pre-2010) \u2014 is built\n * behind a {@link TransportConnectionAdapter} and connected in parallel on one object tree, so a\n * MusicCast AVR gets YNCA base control AND the YXC-exclusive richness (multiroom, equalizer, album\n * art) instead of one transport winning and hiding the rest. Each transport is described by a\n * factory, so the handle can rebuild and reconnect a single dropped transport while the others\n * keep running. Returns a {@link ConnectionHandle} over the live set, or null when no transport\n * answers this attempt.\n *\n * @param device the configured device record\n * @param deps the adapter-bound callbacks\n * @returns a connection handle, or null when no transport connected\n */\nexport function attemptDevice(device: DeviceRecord, deps: AttemptDeps): Promise<ConnectionHandle | null> {\n const { log, upsertObject, setStateAck, timers } = deps;\n /**\n * A fresh command gate for one transport connection. EVERY command of that transport\n * goes through it: user writes, the init sweep, the keepalive and browsing alike. It is\n * also the connection's shutdown signal \u2014 closing it empties the queue and ends every\n * pending wait, so a stopped adapter leaves nothing running.\n *\n * One gate per device AND transport, not one for the adapter: the spacing is a property\n * of the device connection, so a shared gate would let one receiver's 19-second sweep\n * block another receiver's button press.\n *\n * @param transport the transport the gate belongs to\n * @returns the gate\n */\n const gateFor = (transport: Transport): CommandGate =>\n new CommandGate({ minSpacingMs: COMMAND_SPACING_MS[transport], timers });\n\n // 1) YNCA \u2014 amp control over a held TCP connection; a socket drop is the genuine gone-signal.\n const buildYnca = (): ConnectableTransport => {\n const ynca = new TransportConnectionAdapter(\"ynca\", device.id, setStateAck);\n const gate = gateFor(\"ynca\");\n ynca.bind(\n new YncaDeviceController(device.id, {\n client: new YncaClient(device.ip, timers, gate),\n gate,\n upsertObject: ynca.interceptUpsert,\n setStateAck: ynca.interceptSetStateAck,\n log,\n isEntryEnabled: deps.isEntryEnabled,\n subunitCache: deps.yncaSubunitCache,\n probeMemory: deps.probeMemory,\n }),\n );\n return ynca;\n };\n\n // 2) YXC \u2014 MusicCast; polled + push. Drop reported after a run of failed keepalive polls.\n const buildYxc = (): ConnectableTransport => {\n const yxc = new TransportConnectionAdapter(\"yxc\", device.id, setStateAck);\n const gate = gateFor(\"yxc\");\n yxc.bind(\n new YxcDeviceController(device.id, {\n client: new YamahaYxcClient(device.ip, undefined, gate),\n clientFor: ip => partnerClient(device.ip, deps.knownDeviceIps, ip),\n registerPush: onPush => deps.registerPush(device.ip, onPush),\n pushActive: deps.pushActive,\n probeMemory: deps.probeMemory,\n scheduleKeepalive: deps.scheduleKeepalive,\n upsertObject: yxc.interceptUpsert,\n setStateAck: yxc.interceptSetStateAck,\n reportDeviceName: deps.onDeviceName,\n log,\n gate,\n }),\n );\n return yxc;\n };\n\n // 3) XML/YNC \u2014 pre-2010 receivers; polled. A drop is reported after a run of failed polls.\n const buildXml = (): ConnectableTransport => {\n const xml = new TransportConnectionAdapter(\"xml\", device.id, setStateAck);\n const gate = gateFor(\"xml\");\n xml.bind(\n new XmlDeviceController(\n device.id,\n {\n client: new XmlClient(device.ip, undefined, gate),\n scheduleKeepalive: deps.scheduleKeepalive,\n upsertObject: xml.interceptUpsert,\n setStateAck: xml.interceptSetStateAck,\n log,\n gate,\n probeMemory: deps.probeMemory,\n },\n deps.xmlPollIntervalMs,\n ),\n );\n return xml;\n };\n\n return connectTransports(\n device.id,\n [\n { transport: \"ynca\", build: buildYnca },\n { transport: \"yxc\", build: buildYxc },\n { transport: \"xml\", build: buildXml },\n ],\n {\n upsertObject,\n log,\n onTransports: deps.onTransports,\n reachability: deps.reachability,\n timers: deps.timers,\n },\n );\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": ["import_device_controller"]
|
|
7
7
|
}
|