wiim2mqtt 0.1.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/LICENSE +21 -0
- package/README.md +223 -0
- package/config.js +153 -0
- package/index.js +386 -0
- package/lib/api.js +249 -0
- package/lib/commands.js +332 -0
- package/lib/device.js +998 -0
- package/lib/didl.js +272 -0
- package/lib/hadiscovery.js +190 -0
- package/lib/install.js +251 -0
- package/lib/log.js +74 -0
- package/lib/payload.js +112 -0
- package/lib/upnp.js +535 -0
- package/package.json +60 -0
package/lib/didl.js
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure parsers for UPnP event payloads and track metadata as WiiM/LinkPlay devices send them.
|
|
3
|
+
*
|
|
4
|
+
* - GENA NOTIFY bodies: <e:propertyset><e:property><LastChange>…</LastChange></e:property>…
|
|
5
|
+
* - LastChange: <Event><InstanceID val="0"><TransportState val="PLAYING"/>…</InstanceID></Event>
|
|
6
|
+
* - Metadata: DIDL-Lite XML (dc:/upnp:/song: elements) or, on some firmware, JSON.
|
|
7
|
+
*
|
|
8
|
+
* Lenient on purpose: firmware 4.8.611135–4.8.612733 emitted song:* elements without a
|
|
9
|
+
* namespace declaration and titles may contain bare '&'.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {XMLParser} from 'fast-xml-parser';
|
|
13
|
+
|
|
14
|
+
const parser = new XMLParser({
|
|
15
|
+
ignoreAttributes: false,
|
|
16
|
+
attributeNamePrefix: '',
|
|
17
|
+
ignoreDeclaration: true,
|
|
18
|
+
removeNSPrefix: false,
|
|
19
|
+
parseTagValue: false,
|
|
20
|
+
parseAttributeValue: false,
|
|
21
|
+
trimValues: true,
|
|
22
|
+
processEntities: true,
|
|
23
|
+
htmlEntities: true,
|
|
24
|
+
isArray: (name) => name === 'e:property' || name === 'property' || name === 'item' || name === 'res',
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/** Escape bare '&' that would break XML parsing; keep well-formed entities. */
|
|
28
|
+
export function sanitizeXml(text) {
|
|
29
|
+
return String(text).replace(/&(?!(?:[a-zA-Z][a-zA-Z0-9]*|#\d+|#x[0-9a-fA-F]+);)/g, '&');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function unescape(text) {
|
|
33
|
+
return String(text)
|
|
34
|
+
.replace(/</g, '<')
|
|
35
|
+
.replace(/>/g, '>')
|
|
36
|
+
.replace(/"/g, '"')
|
|
37
|
+
.replace(/'/g, "'")
|
|
38
|
+
.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)))
|
|
39
|
+
.replace(/&#x([0-9a-fA-F]+);/g, (_, n) => String.fromCodePoint(parseInt(n, 16)))
|
|
40
|
+
.replace(/&/g, '&');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Find the first child whose local name (without namespace prefix) matches. */
|
|
44
|
+
function local(obj, name) {
|
|
45
|
+
if (!obj || typeof obj !== 'object') {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
for (const key of Object.keys(obj)) {
|
|
49
|
+
const idx = key.indexOf(':');
|
|
50
|
+
if ((idx === -1 ? key : key.slice(idx + 1)) === name) {
|
|
51
|
+
return obj[key];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function textOf(value) {
|
|
58
|
+
if (value === undefined || value === null) {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
if (Array.isArray(value)) {
|
|
62
|
+
return textOf(value[0]);
|
|
63
|
+
}
|
|
64
|
+
if (typeof value === 'object') {
|
|
65
|
+
return value['#text'] === undefined ? undefined : String(value['#text']);
|
|
66
|
+
}
|
|
67
|
+
return String(value);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function safeParse(xml) {
|
|
71
|
+
try {
|
|
72
|
+
return parser.parse(xml);
|
|
73
|
+
} catch {
|
|
74
|
+
try {
|
|
75
|
+
return parser.parse(sanitizeXml(xml));
|
|
76
|
+
} catch {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Parse a GENA NOTIFY body into {variable: value} (values are strings; LastChange stays
|
|
84
|
+
* a string for parseLastChange).
|
|
85
|
+
*/
|
|
86
|
+
export function parsePropertySet(xml) {
|
|
87
|
+
const doc = safeParse(xml);
|
|
88
|
+
const set = local(doc, 'propertyset');
|
|
89
|
+
const props = local(set, 'property');
|
|
90
|
+
const result = {};
|
|
91
|
+
for (const prop of Array.isArray(props) ? props : props ? [props] : []) {
|
|
92
|
+
for (const [key, value] of Object.entries(prop)) {
|
|
93
|
+
const name = key.includes(':') ? key.slice(key.indexOf(':') + 1) : key;
|
|
94
|
+
const text = textOf(value);
|
|
95
|
+
result[name] = text === undefined ? '' : text;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return result;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Parse an AVTransport / RenderingControl LastChange document into {Variable: value}.
|
|
103
|
+
* RenderingControl channel variables (Volume, Mute) only keep channel "Master".
|
|
104
|
+
* The misspelled LoopMpde is aliased to LoopMode.
|
|
105
|
+
*/
|
|
106
|
+
export function parseLastChange(xml) {
|
|
107
|
+
const doc = safeParse(String(xml));
|
|
108
|
+
const event = local(doc, 'Event');
|
|
109
|
+
const instance = local(event, 'InstanceID');
|
|
110
|
+
const inst = Array.isArray(instance) ? instance[0] : instance;
|
|
111
|
+
const result = {};
|
|
112
|
+
if (!inst || typeof inst !== 'object') {
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
115
|
+
for (const [key, raw] of Object.entries(inst)) {
|
|
116
|
+
if (key === 'val' || key === '#text') {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
const name = key.includes(':') ? key.slice(key.indexOf(':') + 1) : key;
|
|
120
|
+
const entries = Array.isArray(raw) ? raw : [raw];
|
|
121
|
+
for (const entry of entries) {
|
|
122
|
+
if (!entry || typeof entry !== 'object') {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (entry.channel !== undefined && entry.channel !== 'Master') {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (entry.val === undefined) {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
result[name === 'LoopMpde' ? 'LoopMode' : name] = String(entry.val);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const EMPTY_TRACK = Object.freeze({
|
|
138
|
+
title: '',
|
|
139
|
+
artist: '',
|
|
140
|
+
album: '',
|
|
141
|
+
albumArt: '',
|
|
142
|
+
quality: '',
|
|
143
|
+
sampleRate: null,
|
|
144
|
+
bitDepth: null,
|
|
145
|
+
bitrate: null,
|
|
146
|
+
originSource: '',
|
|
147
|
+
duration: null,
|
|
148
|
+
uri: '',
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
/** DIDL res@duration: H:MM:SS per spec, but WiiM sends plain milliseconds ("205000"). */
|
|
152
|
+
export function parseResDuration(value) {
|
|
153
|
+
if (value !== undefined && value !== null && /^\d+$/.test(String(value).trim())) {
|
|
154
|
+
return Math.round(Number(value) / 1000);
|
|
155
|
+
}
|
|
156
|
+
return parseDuration(value);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** "0:03:25.000" / "03:25" / "205" → seconds (integer) or null */
|
|
160
|
+
export function parseDuration(value) {
|
|
161
|
+
if (value === undefined || value === null || value === '') {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
const text = String(value).trim();
|
|
165
|
+
if (/^\d+$/.test(text)) {
|
|
166
|
+
return Number(text);
|
|
167
|
+
}
|
|
168
|
+
const parts = text.split(':').map((p) => Number(p));
|
|
169
|
+
if (parts.some((p) => !Number.isFinite(p))) {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
let seconds = 0;
|
|
173
|
+
for (const part of parts) {
|
|
174
|
+
seconds = seconds * 60 + part;
|
|
175
|
+
}
|
|
176
|
+
return Math.round(seconds);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function intOrNull(value) {
|
|
180
|
+
if (value === undefined || value === null || value === '') {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
const n = Number(value);
|
|
184
|
+
return Number.isFinite(n) ? n : null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Parse track metadata (DIDL-Lite XML or JSON) into a normalised track object.
|
|
189
|
+
* Returns null for empty / NOT_IMPLEMENTED metadata.
|
|
190
|
+
*/
|
|
191
|
+
export function parseMetadata(input) {
|
|
192
|
+
if (input === undefined || input === null) {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
const text = String(input).trim();
|
|
196
|
+
if (text === '' || text === 'NOT_IMPLEMENTED') {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
if (text.startsWith('{')) {
|
|
200
|
+
try {
|
|
201
|
+
return fromJson(JSON.parse(text));
|
|
202
|
+
} catch {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const doc = safeParse(unescapeIfEscaped(text));
|
|
207
|
+
const didl = local(doc, 'DIDL-Lite');
|
|
208
|
+
let item = local(didl, 'item');
|
|
209
|
+
if (Array.isArray(item)) {
|
|
210
|
+
item = item[0];
|
|
211
|
+
}
|
|
212
|
+
if (!item || typeof item !== 'object') {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
const get = (name) => {
|
|
216
|
+
const v = textOf(local(item, name));
|
|
217
|
+
return v === undefined ? '' : unescape(v);
|
|
218
|
+
};
|
|
219
|
+
const resList = local(item, 'res');
|
|
220
|
+
const res = Array.isArray(resList) ? resList[0] : resList;
|
|
221
|
+
const resText = textOf(res);
|
|
222
|
+
const resDuration = res && typeof res === 'object' ? res.duration : undefined;
|
|
223
|
+
return {
|
|
224
|
+
...EMPTY_TRACK,
|
|
225
|
+
title: get('title'),
|
|
226
|
+
artist: get('artist') || get('creator'),
|
|
227
|
+
album: get('album'),
|
|
228
|
+
albumArt: get('albumArtURI'),
|
|
229
|
+
quality: get('actualQuality') || get('quality'),
|
|
230
|
+
sampleRate: intOrNull(get('rate_hz')),
|
|
231
|
+
bitDepth: intOrNull(get('format_s')),
|
|
232
|
+
bitrate: intOrNull(get('bitrate')),
|
|
233
|
+
originSource: get('originSource'),
|
|
234
|
+
duration: parseResDuration(resDuration),
|
|
235
|
+
uri: resText === undefined ? '' : unescape(resText),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** LastChange values arrive XML-escaped inside the attribute; fast-xml-parser already unescapes attributes. */
|
|
240
|
+
function unescapeIfEscaped(text) {
|
|
241
|
+
return text.startsWith('<') ? unescape(text) : text;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function fromJson(obj) {
|
|
245
|
+
if (!obj || typeof obj !== 'object') {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
const pick = (...keys) => {
|
|
249
|
+
for (const key of keys) {
|
|
250
|
+
if (obj[key] !== undefined && obj[key] !== null && obj[key] !== '') {
|
|
251
|
+
return String(obj[key]);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return '';
|
|
255
|
+
};
|
|
256
|
+
return {
|
|
257
|
+
...EMPTY_TRACK,
|
|
258
|
+
title: pick('title', 'dc:title', 'Title'),
|
|
259
|
+
artist: pick('artist', 'upnp:artist', 'dc:creator', 'creator', 'Artist'),
|
|
260
|
+
album: pick('album', 'upnp:album', 'Album'),
|
|
261
|
+
albumArt: pick('albumArtURI', 'upnp:albumArtURI', 'albumArtUri', 'albumart', 'cover'),
|
|
262
|
+
quality: pick('actualQuality', 'song:actualQuality', 'quality'),
|
|
263
|
+
sampleRate: intOrNull(pick('rate_hz', 'song:rate_hz', 'sampleRate')),
|
|
264
|
+
bitDepth: intOrNull(pick('format_s', 'song:format_s', 'bitDepth')),
|
|
265
|
+
bitrate: intOrNull(pick('bitrate', 'song:bitrate', 'bitRate')),
|
|
266
|
+
originSource: pick('originSource', 'song:originSource'),
|
|
267
|
+
duration: parseDuration(pick('duration')),
|
|
268
|
+
uri: pick('res', 'uri', 'url'),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export {EMPTY_TRACK};
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Home Assistant MQTT discovery (device-based, HA >= 2024.11).
|
|
3
|
+
* https://www.home-assistant.io/integrations/mqtt/#device-discovery-payload
|
|
4
|
+
*
|
|
5
|
+
* HA has no MQTT media_player platform, so the streamer is exposed as a bundle of entities:
|
|
6
|
+
* number (volume), switches (mute, shuffle), selects (source, repeat, preset), buttons
|
|
7
|
+
* (play/pause/stop/next/prev/toggle), sensors (track + diagnostics), an image (album art)
|
|
8
|
+
* and an update entity (firmware).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {REPEAT_MODES, SWITCHABLE_SOURCES} from './commands.js';
|
|
12
|
+
|
|
13
|
+
const BUTTONS = [
|
|
14
|
+
{item: 'play', name: 'Play', icon: 'mdi:play'},
|
|
15
|
+
{item: 'pause', name: 'Pause', icon: 'mdi:pause'},
|
|
16
|
+
{item: 'stop', name: 'Stop', icon: 'mdi:stop'},
|
|
17
|
+
{item: 'next', name: 'Next', icon: 'mdi:skip-next'},
|
|
18
|
+
{item: 'prev', name: 'Previous', icon: 'mdi:skip-previous'},
|
|
19
|
+
{item: 'toggle', name: 'Play/Pause', icon: 'mdi:play-pause'},
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const SENSORS = [
|
|
23
|
+
{item: 'play_state', name: 'Play state', icon: 'mdi:play-pause'},
|
|
24
|
+
{item: 'title', name: 'Title', icon: 'mdi:music-note'},
|
|
25
|
+
{item: 'artist', name: 'Artist', icon: 'mdi:account-music'},
|
|
26
|
+
{item: 'album', name: 'Album', icon: 'mdi:album'},
|
|
27
|
+
{item: 'quality', name: 'Quality', icon: 'mdi:quality-high'},
|
|
28
|
+
{item: 'sample_rate', name: 'Sample rate', icon: 'mdi:sine-wave', unit: 'Hz'},
|
|
29
|
+
{item: 'bit_depth', name: 'Bit depth', icon: 'mdi:numeric'},
|
|
30
|
+
{item: 'bitrate', name: 'Bitrate', icon: 'mdi:speedometer', unit: 'kbit/s'},
|
|
31
|
+
{item: 'origin_source', name: 'Origin', icon: 'mdi:cloud-outline'},
|
|
32
|
+
{item: 'duration', name: 'Duration', icon: 'mdi:timer-outline', unit: 's'},
|
|
33
|
+
{item: 'group_role', name: 'Group role', icon: 'mdi:speaker-multiple'},
|
|
34
|
+
{item: 'device_name', name: 'Device name', category: 'diagnostic'},
|
|
35
|
+
{item: 'model', name: 'Model', category: 'diagnostic'},
|
|
36
|
+
{item: 'firmware', name: 'Firmware', category: 'diagnostic'},
|
|
37
|
+
{item: 'ip', name: 'IP address', category: 'diagnostic'},
|
|
38
|
+
{item: 'mac', name: 'MAC address', category: 'diagnostic'},
|
|
39
|
+
{item: 'rssi', name: 'Wi-Fi signal', category: 'diagnostic', unit: 'dBm', icon: 'mdi:wifi'},
|
|
40
|
+
{item: 'upnp', name: 'State source', category: 'diagnostic', icon: 'mdi:lan-connect'},
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Build the discovery payload.
|
|
45
|
+
* @param {object} input
|
|
46
|
+
* @param {string} input.name instance name / topic prefix
|
|
47
|
+
* @param {string} [input.prefix] discovery prefix (default "homeassistant")
|
|
48
|
+
* @param {(item: string) => *} input.get last known value of a friendly item
|
|
49
|
+
* @param {{name: string, version: string, homepage?: string}} input.pkg
|
|
50
|
+
* @param {boolean} [input.jsonPayloads] status payloads are {val, ts, lc} JSON
|
|
51
|
+
* @param {boolean} [input.albumArtData] album_art_data topic is published
|
|
52
|
+
* @returns {{topic: string, payload: object}}
|
|
53
|
+
*/
|
|
54
|
+
export function buildDiscovery({name, prefix = 'homeassistant', get, pkg, jsonPayloads = false, albumArtData = false}) {
|
|
55
|
+
const id = 'wiim2mqtt_' + String(name).replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
56
|
+
const status = (item) => `${name}/status/${item}`;
|
|
57
|
+
const set = (item) => `${name}/set/${item}`;
|
|
58
|
+
const valueTemplate = jsonPayloads ? '{{ value_json.val }}' : undefined;
|
|
59
|
+
|
|
60
|
+
const common = (item, extra) => ({
|
|
61
|
+
p: extra.p,
|
|
62
|
+
uniq_id: `${id}_${item}`,
|
|
63
|
+
name: extra.name,
|
|
64
|
+
...(extra.p !== 'button' && {stat_t: status(item)}),
|
|
65
|
+
...(valueTemplate && extra.p !== 'button' && extra.p !== 'image' && {val_tpl: valueTemplate}),
|
|
66
|
+
...(extra.icon && {ic: extra.icon}),
|
|
67
|
+
...(extra.category && {ent_cat: extra.category}),
|
|
68
|
+
...(extra.unit && {unit_of_meas: extra.unit}),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const components = {};
|
|
72
|
+
|
|
73
|
+
components.volume = {
|
|
74
|
+
...common('volume', {p: 'number', name: 'Volume', icon: 'mdi:volume-high'}),
|
|
75
|
+
cmd_t: set('volume'),
|
|
76
|
+
min: 0,
|
|
77
|
+
max: 100,
|
|
78
|
+
step: 1,
|
|
79
|
+
mode: 'slider',
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
components.mute = {
|
|
83
|
+
...common('mute', {p: 'switch', name: 'Mute', icon: 'mdi:volume-mute'}),
|
|
84
|
+
cmd_t: set('mute'),
|
|
85
|
+
pl_on: 'true',
|
|
86
|
+
pl_off: 'false',
|
|
87
|
+
stat_on: 'true',
|
|
88
|
+
stat_off: 'false',
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
components.shuffle = {
|
|
92
|
+
...common('shuffle', {p: 'switch', name: 'Shuffle', icon: 'mdi:shuffle'}),
|
|
93
|
+
cmd_t: set('shuffle'),
|
|
94
|
+
pl_on: 'true',
|
|
95
|
+
pl_off: 'false',
|
|
96
|
+
stat_on: 'true',
|
|
97
|
+
stat_off: 'false',
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
components.repeat = {
|
|
101
|
+
...common('repeat', {p: 'select', name: 'Repeat', icon: 'mdi:repeat'}),
|
|
102
|
+
cmd_t: set('repeat'),
|
|
103
|
+
options: REPEAT_MODES,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const sources =
|
|
107
|
+
Array.isArray(get('source_list')) && get('source_list').length ? get('source_list') : SWITCHABLE_SOURCES;
|
|
108
|
+
components.source = {
|
|
109
|
+
...common('source', {p: 'select', name: 'Source', icon: 'mdi:import'}),
|
|
110
|
+
cmd_t: set('source'),
|
|
111
|
+
options: sources,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const presets = get('preset_list');
|
|
115
|
+
if (Array.isArray(presets) && presets.length) {
|
|
116
|
+
components.preset = {
|
|
117
|
+
...common('preset', {p: 'select', name: 'Preset', icon: 'mdi:playlist-star'}),
|
|
118
|
+
uniq_id: `${id}_preset`,
|
|
119
|
+
cmd_t: set('preset'),
|
|
120
|
+
// presets are fire-and-forget: no state topic, HA shows the last chosen one
|
|
121
|
+
stat_t: undefined,
|
|
122
|
+
val_tpl: undefined,
|
|
123
|
+
options: presets.map((p) => p.name || `Preset ${p.number}`),
|
|
124
|
+
};
|
|
125
|
+
delete components.preset.stat_t;
|
|
126
|
+
delete components.preset.val_tpl;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
for (const button of BUTTONS) {
|
|
130
|
+
components['button_' + button.item] = {
|
|
131
|
+
...common('button_' + button.item, {p: 'button', name: button.name, icon: button.icon}),
|
|
132
|
+
cmd_t: set(button.item),
|
|
133
|
+
pl_prs: 'true',
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
for (const sensor of SENSORS) {
|
|
138
|
+
if (get(sensor.item) === undefined) {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
components[sensor.item] = common(sensor.item, {p: 'sensor', ...sensor});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
components.album_art = {
|
|
145
|
+
p: 'image',
|
|
146
|
+
uniq_id: `${id}_album_art`,
|
|
147
|
+
name: 'Album art',
|
|
148
|
+
ic: 'mdi:image-album',
|
|
149
|
+
...(albumArtData
|
|
150
|
+
? {image_topic: status('album_art_data'), cont_type: 'image/jpeg'}
|
|
151
|
+
: {url_topic: status('album_art'), ...(jsonPayloads && {url_tpl: '{{ value_json.val }}'})}),
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
if (get('firmware') !== undefined) {
|
|
155
|
+
components.update = {
|
|
156
|
+
p: 'update',
|
|
157
|
+
uniq_id: `${id}_update`,
|
|
158
|
+
name: 'Firmware',
|
|
159
|
+
ent_cat: 'diagnostic',
|
|
160
|
+
stat_t: status('firmware_update'),
|
|
161
|
+
val_tpl: jsonPayloads ? '{{ value_json.val | to_json }}' : undefined,
|
|
162
|
+
dev_cla: 'firmware',
|
|
163
|
+
};
|
|
164
|
+
if (!jsonPayloads) {
|
|
165
|
+
delete components.update.val_tpl;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const payload = {
|
|
170
|
+
dev: {
|
|
171
|
+
ids: [id, ...(get('uuid') ? [String(get('uuid'))] : [])],
|
|
172
|
+
name: name,
|
|
173
|
+
mf: 'WiiM',
|
|
174
|
+
...(get('model') && {mdl: String(get('model'))}),
|
|
175
|
+
...(get('firmware') && {sw: String(get('firmware'))}),
|
|
176
|
+
...(get('mac') && {cns: [['mac', String(get('mac')).toLowerCase()]]}),
|
|
177
|
+
...(get('ip') && {cu: `https://${get('ip')}`}),
|
|
178
|
+
},
|
|
179
|
+
o: {
|
|
180
|
+
name: pkg.name,
|
|
181
|
+
sw: pkg.version,
|
|
182
|
+
...(pkg.homepage && {url: pkg.homepage}),
|
|
183
|
+
},
|
|
184
|
+
avty: [{t: `${name}/connected`, avty_tpl: "{{ 'online' if (value | int(0)) >= 2 else 'offline' }}"}],
|
|
185
|
+
qos: 0,
|
|
186
|
+
cmps: components,
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
return {topic: `${prefix}/device/${id}/config`, payload};
|
|
190
|
+
}
|