iobroker.squeezeboxrpc
Version:
ioBroker Logitech Squeezebox Adapter over JSON/RPC-Protocol
1,260 lines (1,242 loc) • 51.5 kB
JavaScript
'use strict';
const dgram = require('dgram');
const { ioUtil } = require('./ioUtil');
const SqueezeServerNew = require(`${__dirname}/squeezenode/squeezeserver`);
const ioSBPlayer = require(`${__dirname}/iosbplayer`);
/**
* IoSbServer is a constructor function that initializes and manages a server
* for interacting with SqueezeBox devices. It sets up various server states,
* handles server and player discovery, manages favorites, and communicates
* with the SqueezeBox server via telnet and HTTP requests.
*
* @param adapter - An instance of the adapter to manage states and configurations.
*/
function IoSbServer(adapter) {
this.ioUtil = new ioUtil(adapter, adapter.config.outputserverdebug, adapter.config.outputserversilly);
this.sbServerStatus = {
lastscan: {
name: 'LastScan',
read: true,
write: false,
type: 'string',
role: 'value',
exist: false,
},
version: {
name: 'Version',
read: true,
write: false,
type: 'string',
role: 'value',
exist: false,
},
uuid: {
name: 'uuid',
read: true,
write: false,
type: 'string',
role: 'value',
exist: false,
},
name: {
name: 'Name',
read: true,
write: false,
type: 'string',
role: 'value',
exist: false,
},
mac: {
name: 'mac',
read: true,
write: false,
type: 'string',
role: 'info.mac',
exist: false,
},
'info total albums': {
name: 'TotalAlbums',
read: true,
write: false,
type: 'number',
role: 'value',
exist: false,
},
'info total artists': {
name: 'TotalArtists',
read: true,
write: false,
type: 'number',
role: 'value',
exist: false,
},
'info total genres': {
name: 'TotalGenres',
read: true,
write: false,
type: 'number',
role: 'value',
exist: false,
},
'info total songs': {
name: 'TotalSongs',
read: true,
write: false,
type: 'number',
role: 'value',
exist: false,
},
'info total duration': {
name: 'TotalDuration',
read: true,
write: false,
type: 'number',
role: 'media.duration',
exist: false,
},
'player count': {
name: 'PlayerCount',
read: true,
write: false,
type: 'number',
role: 'value',
exist: false,
},
'sn player count': {
name: 'PlayerCountSN',
read: true,
write: false,
type: 'number',
role: 'value',
exist: false,
},
'other player count': {
name: 'PlayerCountOther',
read: true,
write: false,
type: 'number',
role: 'value',
exist: false,
},
syncgroups: {
name: 'SyncGroups',
read: true,
write: false,
type: 'string',
role: 'value',
exist: false,
},
otherServers: {
name: 'otherServers',
read: true,
write: true,
type: 'string',
role: 'value',
exist: false,
},
getFavorites: {
name: 'getFavorites',
read: true,
write: true,
type: 'boolean',
role: 'button',
def: false,
exist: false,
},
};
this.sbFavoritesState = {
name: {
name: 'Name',
read: true,
write: false,
type: 'string',
role: 'value',
exist: false,
},
type: {
name: 'type',
read: true,
write: false,
type: 'string',
role: 'value',
exist: false,
},
id: {
name: 'id',
read: true,
write: false,
type: 'string',
role: 'value',
exist: false,
},
hasitems: {
name: 'hasitems',
read: true,
write: false,
type: 'number',
role: 'value',
exist: false,
},
url: {
name: 'url',
read: true,
write: false,
type: 'string',
role: 'media.url',
exist: false,
},
image: {
name: 'image',
read: true,
write: false,
type: 'string',
role: 'url.icon',
exist: false,
},
isaudio: {
name: 'isaudio',
read: true,
write: false,
type: 'number',
role: 'value',
exist: false,
},
};
this.ServerStatePath = 'Server';
this.FavoritesStatePath = 'Favorites';
this.PlayersStatePath = 'Players';
this.FORBIDDEN_CHARS = /[^\d\w_]+/gm;
//test the regex: https://regex101.com/r/Ed0WhH/1
this.currentStates = {};
this.players = [];
this.observers = [];
this.adapter = adapter;
this.adapter.setState('info.connection', false, true);
this.adapter.subscribeStates('*');
this.sbServer =
adapter.config.username != ''
? new SqueezeServerNew(
`http://${this.adapter.config.server}`,
Number.parseInt(this.adapter.config.port),
adapter.config.username,
adapter.config.password,
)
: new SqueezeServerNew(`http://${this.adapter.config.server}`, Number.parseInt(this.adapter.config.port));
this.log = {};
this.errmax = 5;
this.errcnt = -1;
this.connected = 0;
this.firstStart = true;
this.server = null;
this.telnet = null;
this.otherserver = [];
/**
* Initialization function of the Server object.
*
* @async
* @description
* This function is called when the adapter object is created and
* initializes the IoSbServer object. It sets up the connection state
* and starts the server state and favorites observers.
*/
this.init = async function () {
this.ioUtil.logdebug('server init');
this.setState('connection', true, 'info');
this.doObserverServer();
this.doObserverFavorites();
this.doTelnet();
};
/**
* Observes the server status at regular intervals.
*
* @description
* Logs the execution of the observer, retrieves the current server status,
* and schedules the next execution of the observer based on the configured
* server refresh interval in seconds.
*/
this.doObserverServer = function () {
this.ioUtil.logdebug('doObserverServer');
this.getServerstatus();
this.setTimeout('serverstatus', this.doObserverServer.bind(this), this.adapter.config.serverrefresh * 1000);
};
/**
* Processes incoming messages and executes corresponding commands.
*
* @param msg - The message object containing command and optional data.
* @description Logs the received message and executes specific functions based on the command.
* For 'discoverlms', it initiates server discovery. For 'cmdGeneral', it sends a general command
* to the LMS server.
*/
this.processMessages = function (msg) {
this.ioUtil.logdebug(`processMessages ${JSON.stringify(msg)}`);
if (msg.command === 'discoverlms') {
this.ioUtil.logdebug('send discoverlms');
this.discoverLMS(msg.command, msg.message, msg.from, msg.callback);
}
if (msg.command === 'cmdGeneral') {
this.ioUtil.logdebug('send cmdGeneral');
this.sendCmdGeneral(msg.command, msg.message, msg.from, msg.callback);
}
};
/**
* Processes a general command message to the Logitech Media Server.
*
* @param command - The command identifier, should be 'cmdGeneral'.
* @param message - The message object containing a playerid and a cmdArray.
* @param from - The sender of the message.
* @param callback - The callback function to be called with the result.
* @description
* Logs the received message and executes a request to the LMS server
* with the provided playerid and cmdArray. If the request is successful,
* it sends the result to the callback function. If an error occurs, it
* sends an error message to the callback function.
*/
this.sendCmdGeneral = async (command, message, from, callback) => {
this.ioUtil.logdebug(`sendCmdGeneral ${JSON.stringify(message)}`);
let error = '';
if (typeof message === 'object') {
let data;
const playerid = message.playerid || '';
const cmd = message.cmdArray;
if (!Array.isArray(cmd)) {
error += 'cmdArray is not an array';
}
if (error == '') {
try {
data = await this.requestAsync(playerid, cmd);
} catch {
error = 'Problem with Server request';
}
}
if (callback && error == '') {
this.adapter.sendTo(from, command, data, callback);
} else {
this.adapter.sendTo(from, command, `error: ${error}`, callback);
}
}
};
this.sanitizePlayername = playername => playername.replace(this.FORBIDDEN_CHARS, '_');
/**
* Returns a list of other Logitech Media Servers discovered by the current
* instance of the adapter. This function is called from the Admin interface
*
* @param command - The command identifier, should be 'discoverlms'.
* @param message - The message object containing no data.
* @param from - The adapter instance ID that sent the message.
* @param callback - The callback function to send the response to.
* @description
* Logs the received message, processes the discovered servers and sends a
* response back to the adapter instance that sent the message.
*/
this.discoverLMS = function (command, message, from, callback) {
this.ioUtil.logdebug(`discoverLMS ${JSON.stringify(message)}`);
const data = [];
for (const [, srv] of Object.entries(this.otherserver)) {
data.push({ label: `${srv.NAME}/${srv.ADDRESS}`, value: srv.ADDRESS });
}
this.adapter.sendTo(from, command, [{ value: '', label: '---' }, ...data], callback);
};
/**
* Sets a timeout for a specified callback function and stores it in the observers object.
*
* @param id - Unique identifier for the timeout, used to manage and clear the timeout.
* @param callback - The function to execute after the specified time delay.
* @param time - The delay in milliseconds before executing the callback.
*/
this.setTimeout = function (id, callback, time) {
this.clearTimeout(id);
this.observers[id] = setTimeout(callback.bind(this), time);
};
/**
* Sets an interval for a specified callback function and stores it in the observers object.
*
* @param id - Unique identifier for the interval, used to manage and clear the interval.
* @param callback - The function to execute after the specified time delay.
* @param time - The delay in milliseconds before executing the callback.
*/
this.setInterval = function (id, callback, time) {
this.clearInterval(id);
this.observers[id] = setInterval(callback.bind(this), time);
};
/**
* Clears an interval that was previously set using setInterval.
*
* @param id - Unique identifier for the interval to clear.
*/
this.clearInterval = function (id) {
if (this.observers[id]) {
clearInterval(this.observers[id]);
}
delete this.observers[id];
};
this.clearTimeout = function (id) {
if (this.observers[id]) {
clearTimeout(this.observers[id]);
}
delete this.observers[id];
};
/**
* Observes the favorites of the player and updates the favorites datapoints.
*
* If the user has set the "Use Favorites" option in the adapter settings, the
* favorites are queried from the Logitech Media Server and the datapoints are
* updated using the setFavorites method.
*
* If the user has not set the "Use Favorites" option, the favorites are deleted
* and the function is called again after a certain time using the setTimeout
* method.
*/
this.doObserverFavorites = function () {
this.ioUtil.logdebug('doObserverFavorites');
this.delFavorites();
if (this.adapter.config.usefavorites) {
this.setFavorites();
return;
}
this.setTimeout(
'favorites',
this.doObserverFavorites.bind(this),
this.adapter.config.favoriterefresh * 60 * 1000,
);
};
/**
* Gets a list of other Logitech Media Servers discovered by the current
* instance.
*
* If the user has set the "Use Discovery" option in the adapter settings,
* a UDP socket is created and bound to a port. The socket listens for
* incoming messages and processes them to detect other Logitech Media
* Servers. For each detected server, a datapoint is created with the
* server's IP address as the name and the server's configuration as the
* value.
*
* If the user has not set the "Use Discovery" option, the datapoints for
* other servers are deleted and the function is exited.
*
*/
this.getDiscoverServers = () => {
this.ioUtil.logdebug('getDiscoverServers');
if (!this.adapter.config.usediscovery) {
this.delState(this.ServerStatePath, this.sbServerStatus['otherServers'].name, () => {
this.delObject(this.ServerStatePath, this.sbServerStatus['otherServers'].name);
});
return;
}
this.server = dgram.createSocket({ type: 'udp4', reuseAddr: true });
const broadcastPort = 3483;
this.server.bind(broadcastPort, '0.0.0.0', () => {
this.server && this.server.setBroadcast(true);
});
this.server.on('message', async (data, rinfo) => {
this.ioUtil.logsilly(`getDiscoverServers: Message resceived ${escape(data.toString())}`);
if (data.toString().charAt(0) == 'E') {
let msg = data.toString();
msg = msg.substr(1);
const srv = {};
let len = msg.length;
let tag, len2, val;
while (len > 0) {
tag = msg.substr(0, 4);
len2 = msg.charCodeAt(4);
val = msg.substr(5, len2);
msg = msg.substr(len2 + 5);
len = len - len2 - 5;
srv[tag] = val;
}
srv['ADDRESS'] = rinfo.address;
srv['TIMESTAMP'] = Date.now();
this.otherserver[srv['ADDRESS']] = srv;
const state = {};
Object.assign(state, this.sbServerStatus['otherServers']);
state.name = srv['ADDRESS'].replace(/\./g, '-');
state.def = JSON.stringify(srv);
await this.createFolder(this.sbServerStatus['otherServers'].name, this.ServerStatePath);
this.createObject(state, this.ServerStatePath, this.sbServerStatus['otherServers'].name, () => {
this.setState(
srv['ADDRESS'].replace(/\./g, '-'),
JSON.stringify(srv),
this.ServerStatePath,
this.sbServerStatus['otherServers'].name,
false,
);
});
this.ioUtil.logdebug(
`Autodiscover: Server found ${srv['NAME']} IP: ${srv['ADDRESS']} Port ${srv['JSON']} UUID ${
srv['UUID']
}`,
);
}
});
this.server.on('error', err => {
this.ioUtil.logerror(`Error with Server discovery ${err.message}`);
if (err.message.includes('EADDRINUSE')) {
this.ioUtil.loginfo("use 'lsof -i -P' to check for ports used.");
}
try {
this.server &&
this.server.close(() => {
this.ioUtil.loginfo('Server discovery deactivated.');
this.server = null;
});
} catch (err) {
this.ioUtil.logerror(`server.close() ${err.message}`);
}
});
setTimeout(this.doDiscoverServerSearch, 1000);
};
/**
* Called periodically to send a broadcast message to find other servers,
* and remove old entries from the list.
*
*/
this.doDiscoverServerSearch = () => {
this.ioUtil.logdebug('doDiscoverServerSearch');
const msg = Buffer.from('eIPAD\0NAME\0JSON\0UUID\0VERS');
const broadcastAddress = '255.255.255.255';
const broadcastPort = 3483;
this.getStates('*', this.ServerStatePath, this.sbServerStatus['otherServers'].name, (err, states) => {
for (const id in states) {
const srv = JSON.parse(states[id]?.val || '{}');
if ((Date.now() - srv.TIMESTAMP) / 1000 > 60) {
this.delObject(id);
this.ioUtil.logdebug(
`Autodiscover: Server removed ${srv['NAME']} IP: ${srv['ADDRESS']} Port ${
srv['JSON']
} UUID ${srv['UUID']}`,
);
}
}
});
if (!this.server) {
return;
}
this.server.send(msg, 0, msg.length, broadcastPort, broadcastAddress, err => {
if (err) {
this.ioUtil.logerror(err);
}
});
this.setTimeout(
'discover',
this.doDiscoverServerSearch.bind(this),
this.adapter.config.discoveryrefresh * 1000,
);
};
/**
* Close the UDP server for discovery mode.
*
*/
this.doDiscoverServerClose = function () {
this.ioUtil.logdebug('doDiscoverServerClose');
this.server && this.server.close();
};
/**
* Connect to the LMS server via Telnet, login and listen for player events.
*
*/
this.doTelnet = () => {
this.ioUtil.logdebug('doTelnet');
if (!this.adapter.config.usetelnet) {
return;
}
const net = require('net');
this.telnet = net.createConnection(
{ host: this.adapter.config.server, port: this.adapter.config.telnetport },
() => {
this.ioUtil.logdebug('doTelnet connected to server!');
this.telnet.write(`login ${this.adapter.config.username} ${this.adapter.config.password}\r\n`);
this.telnet.write('listen 1\r\n');
},
);
this.telnet.on('data', data => {
this.ioUtil.logdebug(`doTelnet received Data: ${data.toString()}`);
//console.log(decodeURIComponent(data.toString()));
const regex = /^((?:[0-9A-Fa-f]{2}[:-]){5}(?:[0-9A-Fa-f]{2}))\s*(.*)/gm;
let m;
const cmdQueue = decodeURIComponent(data.toString()).split('\r\n');
for (const cmd of cmdQueue) {
if ((m = regex.exec(cmd)) !== null) {
if (m[2] == 'client reconnect') {
this.ioUtil.logdebug(`doTelnet received reconnect for : ${m[1]}`);
this.getServerstatus();
}
if (m[2] == 'client disconnect') {
this.ioUtil.logdebug(`doTelnet received disconnect for : ${m[1]}`);
this.getServerstatus();
}
if (m[2] == 'client new') {
this.ioUtil.logdebug(`doTelnet received disconnect for : ${m[1]}`);
this.getServerstatus();
}
if (m[2] == 'power 0') {
this.ioUtil.logdebug(`doTelnet received power off : ${m[1]}`);
this.getServerstatus();
}
if (m[2] == 'power 1') {
this.ioUtil.logdebug(`doTelnet received power on : ${m[1]}`);
this.getServerstatus();
}
}
}
});
this.telnet.on('end', () => {
this.ioUtil.logdebug('doTelnet disconnected from server');
});
this.telnet.on('timeout', () => {
this.ioUtil.logdebug('doTelnet Socket timeout, closing');
this.telnet.close();
});
this.telnet.on('error', err => {
this.ioUtil.logdebug(`doTelnet Socket error: ${err.message}`);
});
this.telnet.on('close', err => {
this.ioUtil.logdebug(`doTelnet Socket closed ${err.message}`);
});
};
/**
* Closes the Telnet connection by ending the socket connection with the server.
* Logs the action for debugging purposes.
*/
this.doTelnetClose = function () {
this.ioUtil.logdebug('doTelnetClose');
this.telnet.end();
};
/**
* Closes all active connections by invoking the appropriate closing
* methods for Telnet and Discovery servers based on the adapter configuration.
* Logs the action for debugging purposes.
*/
this.closeConnections = function () {
this.ioUtil.logdebug('closeConnections');
if (this.adapter.config.usetelnet) {
this.doTelnetClose();
}
if (this.adapter.config.usediscovery) {
this.doDiscoverServerClose();
}
};
/**
* Retrieves the current server status and sync group information.
*
* @description
* Logs the initiation of the server status retrieval process. Sends a request
* for the server status with a specified range and another request for the
* synchronization groups. The results are processed by respective callback
* functions.
*/
this.getServerstatus = function () {
this.ioUtil.logdebug('getServerstatus');
this.request('', ['serverstatus', '0', '888'], this.doServerstatus.bind(this));
this.request('', ['syncgroups', '?'], this.doSyncgroups.bind(this));
};
/**
* Processes the result of a syncgroups request.
*
* @description
* Takes the result of a syncgroups request, sanitizes the player names
* and creates or updates the respective state object. The state object
* value is set to the JSON string of the syncgroups_loop array.
* Logs the action for debugging purposes.
* @param result The result of the syncgroups request.
*/
this.doSyncgroups = function (result) {
this.ioUtil.logdebug('doSyncgroups');
if (result.result && result.result.syncgroups_loop) {
const key = 'syncgroups';
const stateTemplate = this.sbServerStatus[key];
if (result.result?.syncgroups_loop?.[0]?.sync_member_names) {
result.result.syncgroups_loop[0].sync_member_names =
result.result?.syncgroups_loop?.[0]?.sync_member_names
.split(',')
.map(el => this.sanitizePlayername(el));
}
this.createObject(stateTemplate, this.ServerStatePath, null, () =>
this.setState(
this.sbServerStatus[key].name,
JSON.stringify(result.result.syncgroups_loop),
this.ServerStatePath,
),
);
}
};
/**
* Processes the result of a serverstatus request.
*
* @description
* Takes the result of a serverstatus request, checks if the server status
* states are up to date, creates or updates the respective state objects
* and sets their values to the respective values in the result. If the
* result contains a players_loop array, it checks if new players have been
* added and if so, creates or updates the respective state objects and
* sets their values. It also checks if any players have been removed and
* if so, removes the respective state objects.
* Logs the action for debugging purposes.
* @param result The result of the serverstatus request.
*/
this.doServerstatus = function (result) {
this.ioUtil.logdebug('doServerstatus');
this.checkServerstatusStates(result);
for (const key in this.sbServerStatus) {
if (Object.prototype.hasOwnProperty.call(result.result, key)) {
const stateTemplate = this.sbServerStatus[key];
this.createObject(stateTemplate, this.ServerStatePath, null, () =>
this.setState(
this.sbServerStatus[key].name,
this.convertState(this.sbServerStatus[key], result.result[key]),
this.ServerStatePath,
),
);
}
}
this.checkNewPlayer(result.result.players_loop);
this.checkPlayer(result.result.players_loop);
};
/**
* Retrieves and sets the favorites from the Logitech Media Server (LMS).
*
* @description
* Logs the initiation of the favorites setting process. Sends a request to
* retrieve the list of favorites from the LMS and processes the result to
* update the favorites data points.
*/
this.setFavorites = async function () {
this.ioUtil.logdebug('setFavorites');
this.getFavoritesLMS('', result => this.setFavoritesDP(result));
};
this.delFavorites = async function () {
this.ioUtil.logdebug('delFavorites');
let favobj = await this.getObjects(this.FavoritesStatePath);
favobj = favobj.rows.filter(obj => obj.value.type === 'folder');
for (let i = 0; i < favobj.length; i++) {
await this.adapter.delForeignObjectAsync(favobj[i].id);
}
};
/**
* Retrieves the list of favorites from the Logitech Media Server (LMS).
*
* @description
* Logs the initiation of the favorites retrieval process. Sends a request to
* retrieve the list of favorites from the LMS and processes the result by
* calling the callback function with the result.
* @param id The ID of the favorite to retrieve. If not provided,
* retrieves all favorites.
* @param callback The callback function to be called with the
* result of the request.
*/
this.getFavoritesLMS = function (id, callback) {
this.ioUtil.logdebug('getFavoritesLMS');
this.request(
'',
['favorites', 'items', '0', '888', 'want_url:1', `item_id:${id}`],
result => callback(result.result.loop_loop), //.bind(this);
);
};
/**
* Processes the list of favorites retrieved from the LMS.
*
* @description
* Logs the initiation of the favorites setting process. Creates the
* necessary state paths and creates the objects for each favorite.
* @param favorites The list of favorites retrieved from the LMS.
*/
this.setFavoritesDP = async function (favorites) {
this.ioUtil.logdebug('setFavoritesDP');
await this.createFolder(this.FavoritesStatePath);
for (const favkey in favorites) {
const favorite = favorites[favkey];
const oid = this.getFavId(favorite['id'], false);
const id = this.getFavId(favorite['id']);
for (const key in this.sbFavoritesState) {
if (Object.prototype.hasOwnProperty.call(favorite, key)) {
if (key == 'id') {
favorite['id'] = oid;
}
if (key == 'image') {
let result = `http://${this.adapter.config.server}:${
this.adapter.config.port
}/html/images/favorites.png`;
if (/music\/(.*)\/cover.png/gm.test(favorite[key])) {
result = `http://${this.adapter.config.server}:${this.adapter.config.port}/${
favorite[key]
}`;
} else if (/imageproxy\/(.*)\/[^/]/gm.test(favorite[key])) {
const m = /imageproxy\/(.*)\/[^/]/gm.exec(favorite[key]);
if (m && m[1]) {
result = decodeURIComponent(m[1] || '');
}
} else if (favorite[key] === 'html/images/radio.png') {
result = `http://${this.adapter.config.server}:${
this.adapter.config.port
}/html/images/radio.png`;
} else if (favorite[key] === 'html/images/cover.png') {
result = `http://${this.adapter.config.server}:${
this.adapter.config.port
}/html/images/cover.png`;
} else if (favorite[key] === 'html/images/favorites.png') {
result = `http://${this.adapter.config.server}:${
this.adapter.config.port
}/html/images/favorites.png`;
}
favorite[key] = result;
}
const stateTemplate = this.sbFavoritesState[key];
await this.createFolder(id, this.FavoritesStatePath);
this.createObject(stateTemplate, this.FavoritesStatePath, id, () =>
this.setState(
this.sbFavoritesState[key].name,
this.convertState(this.sbFavoritesState[key], favorite[key]),
this.FavoritesStatePath,
id,
false,
),
);
}
}
if (favorite['hasitems'] > 0) {
this.getFavoritesLMS(oid, result => this.setFavoritesDP(result));
}
}
};
/**
* Extracts and optionally replaces periods in the favorite ID.
*
* @param id - The original favorite ID.
* @param [replace] - Whether to replace periods with hyphens.
* @returns - The processed favorite ID with optional replacements.
*/
this.getFavId = function (id, replace = true) {
let ret;
if (id.indexOf('.') == 8) {
ret = id.substr(9);
} else {
ret = id;
}
if (replace) {
ret = ret.replace(/\./g, '-');
}
return ret;
};
/**
* Checks if the server status datapoints exist and creates them if not.
*
* Iterates over the sbServerStatus object and checks if the datapoint exists.
* If not, it creates the datapoint using createObject. Additionally, it
* checks and creates the datapoints for getFavorites and syncgroups.
*
* @param result - The result of the serverstatus request.
*/
this.checkServerstatusStates = async function (result) {
this.ioUtil.logdebug('checkServerstatusStates');
await this.createDevice(this.ServerStatePath);
for (const key in this.sbServerStatus) {
if (Object.prototype.hasOwnProperty.call(result.result, key)) {
const stateTemplate = this.sbServerStatus[key];
if (!this.currentStates[stateTemplate.name]) {
if (!stateTemplate.exist) {
this.sbServerStatus[key] = this.createObject(stateTemplate, this.ServerStatePath);
}
}
}
}
let key = 'getFavorites';
let stateTemplate = this.sbServerStatus[key];
if (!stateTemplate.exist) {
this.sbServerStatus[key] = this.createObject(stateTemplate, this.ServerStatePath);
}
key = 'syncgroups';
stateTemplate = this.sbServerStatus[key];
if (!stateTemplate.exist) {
this.sbServerStatus[key] = this.createObject(stateTemplate, this.ServerStatePath);
}
};
/**
* Checks for new players in the playersdata array.
*
* @description
* Iterates over the playersdata array and checks if each player is already
* present in the current list of players. If a player is not found, it
* creates a new ioSBPlayer object for the player and adds it to the list.
* Logs the action for debugging purposes.
* @param playersdata The array containing player data to be checked.
*/
this.checkNewPlayer = function (playersdata) {
this.ioUtil.logdebug('checkNewPlayer');
for (const key in playersdata) {
if (!Object.prototype.hasOwnProperty.call(this.players, playersdata[key].playerid)) {
this.players[playersdata[key].playerid] = new ioSBPlayer(this, playersdata[key]);
}
}
};
/**
* Checks if any player connections have changed.
*
* @description
* Iterates over the playersdata array and checks if each player's connection
* state has changed. If a player's connection state has changed, it either
* connects or disconnects the ioSBPlayer object for the player. Logs the action
* for debugging purposes.
* @param playersdata The array containing player data to be checked.
*/
this.checkPlayer = function (playersdata) {
this.ioUtil.logdebug('checkPlayer');
this.checkTPE2();
for (const key in playersdata) {
if (this.players[playersdata[key].playerid].connected != playersdata[key].connected) {
if (playersdata[key].connected == 0) {
this.players[playersdata[key].playerid].disconnect();
}
if (playersdata[key].connected == 1) {
this.players[playersdata[key].playerid].connect();
}
}
}
};
/**
* Checks the value of the LMS preference "useTPE2AsAlbumArtist"
* and sets the TPE2Handling of all players accordingly.
*
* @description
* This function requests the value of the LMS preference "useTPE2AsAlbumArtist"
* and sets the TPE2Handling of all ioSBPlayer objects to the value of the
* preference. This preference determines if the album artist should be taken
* from the TPE2 tag of a song instead of the TPE1 tag.
*/
this.checkTPE2 = function () {
// eslint-disable-next-line prettier/prettier
this.request(
'',
['pref', 'useTPE2AsAlbumArtist', '?'],
(result) => {
if (result.ok) {
if (Object.keys(result.result).length > 0) {
const value = result.result[Object.keys(result.result)[0]];
for (const key in this.players) {
if (Object.prototype.hasOwnProperty.call(this.players, key)) {
// console.log("setTPE2Handling", key, value);
this.players[key].setTPE2Handling(parseInt(value));
}
}
}
}
});
};
/**
* Handles state changes for server, favorites, and players.
*
* @param id - The state identifier, which is expected to be in dot-separated format.
* @param state - The new state object; can be null if the state was deleted.
*
* The function logs the state change, splits the id into parts, and invokes the
* appropriate state change handler based on the first part of the id.
* It returns early if id, state, or state.ack is falsy.
*/
this.stateChange = function (id, state) {
this.ioUtil.logsilly('stateChange');
// Warning, state can be null if it was deleted
if (!id || !state || state.ack) {
return;
}
const idParts = id.split('.');
idParts.shift();
idParts.shift();
if (idParts[0] == this.ServerStatePath) {
this.doServerStateChange(idParts, state);
}
if (idParts[0] == this.FavoritesStatePath) {
this.doFavoritesStateChange(idParts, state);
}
if (idParts[0] == this.PlayersStatePath) {
this.doPlayersStateChange(idParts, state);
}
};
/**
* Handles state changes for the server, such as changes to the favorites list.
*
* @param idParts - The state identifier split into parts.
* @param state - The new state object; can be null if the state was deleted.
*
* The function logs the state change, splits the id into parts, and invokes the
* appropriate state change handler based on the first part of the id.
* It returns early if id, state, or state.ack is falsy.
*/
this.doServerStateChange = function (idParts, state) {
this.ioUtil.logdebug('doServerStateChange');
idParts.shift();
if (idParts[0] == 'getFavorites') {
if (state.val == true) {
this.delFavorites();
this.setFavorites();
}
}
};
/**
* Handles state changes for favorites.
*
* @param idParts - The state identifier split into parts.
* @param state - The new state object; can be null if the state was deleted.
*
* Logs the state change and shifts the idParts array. Returns early if the state is truthy.
*/
this.doFavoritesStateChange = function (idParts, state) {
this.ioUtil.logdebug('doFavoritesStateChange');
idParts.shift();
if (state) {
return;
}
};
/**
* Handles state changes for a player.
*
* @param idParts - The state identifier split into parts.
* @param state - The new state object; can be null if the state was deleted.
*
* Shifts the idParts array and finds the player with the name in the first
* part of the idParts array. If a player is found, it calls the player's
* doStateChange method with the remaining idParts and state.
*/
this.doPlayersStateChange = function (idParts, state) {
this.ioUtil.logsilly('doPlayersStateChange');
idParts.shift();
const player = this.findPlayerByName(idParts[0]);
if (player) {
player.doStateChange(idParts, state);
}
};
/**
* Finds a player with a given name.
*
* @param name - The name of the player.
* @returns The player object if found; otherwise, undefined.
*/
this.findPlayerByName = function (name) {
for (const key in this.players) {
if (this.players[key].playername == name) {
return this.players[key];
}
}
};
/**
* Makes a request to the Logitech Media Server.
*
* @param playerid - The playerid of the player to request from, or '' for the server.
* @param params - The parameters to pass to the request.
* @param callback - The function to call with the result of the request.
*
* If the request is successful, sets the connected flag and clears the error counter.
* If the request fails, decrements the error counter and calls disconnect if the counter reaches 0.
* If the request fails and the firstStart flag is set, calls disconnect.
*/
this.request = (playerid, params, callback) => {
this.ioUtil.logsilly('request');
this.sbServer.request(playerid, params, result => {
if (result.ok) {
this.connected = 1;
this.errcnt = -1;
this.firstStart = false;
if (callback) {
callback(result);
}
} else {
if (this.firstStart) {
this.disconnect();
}
if (this.errcnt == -1) {
this.errcnt = this.errmax;
}
this.errcnt--;
if (this.errcnt == 0) {
this.errcnt = -1;
this.disconnect();
}
}
});
};
/**
* Asynchronous method to make a request to the Logitech Media Server.
*
* @param playerid - The player ID to request from, or an empty string for the server.
* @param params - The parameters to pass to the request.
* @returns A promise that resolves with the result of the request.
*
* Logs the request attempt and uses a promise to handle the asynchronous operation.
* If the request is successful, sets the connected flag, clears the error counter,
* and resolves the promise with the result.
* If the request fails, manages error counters, may call disconnect,
* and rejects the promise if the error threshold is reached.
*/
this.requestAsync = async (playerid, params) => {
this.ioUtil.logsilly('requestAsync');
return new Promise((resolve, reject) => {
this.sbServer.request(playerid, params, result => {
if (result.ok) {
this.connected = 1;
this.errcnt = -1;
this.firstStart = false;
resolve(result);
} else {
if (this.firstStart) {
this.disconnect();
}
if (this.errcnt == -1) {
this.errcnt = this.errmax;
}
this.errcnt--;
if (this.errcnt == 0) {
this.errcnt = -1;
this.disconnect();
}
reject();
}
});
});
};
/**
* Disconnects the server and its players.
*
* @description
* Logs the disconnection event, updates the server's state to disconnected,
* and clears related timeouts. It checks for server observers and performs
* necessary state updates. Iterates over all players and disconnects them.
* Sets an interval to periodically check the server status.
*/
this.disconnect = () => {
this.ioUtil.logdebug('Server disconnect');
this.firstStart = false;
this.connected = 0;
this.clearTimeout('serverstatus');
this.clearTimeout('favorites');
if (this.connected == 0 && this.observers['checkserver']) {
return;
}
this.setState('connection', false, 'info');
for (const key in this.players) {
this.players[key].disconnect();
}
this.setInterval('checkserver', this.doCheckServer.bind(this), 10 * 1000);
};
/**
* Connects the server and its players.
*
* @description
* Logs the connection event, clears the check server interval,
* and sets the server connection state to true.
* Calls init() to initialize players and other data.
*/
this.connect = () => {
this.ioUtil.logdebug('Server connect');
this.clearInterval('checkserver');
this.setState('connection', true, 'info');
this.connected = 1;
this.init();
};
/**
* Checks the server status and attempts to reconnect if necessary.
*
* @description
* Logs the server check action for debugging purposes. Sends a serverstatus
* request to the Logitech Media Server. If the request is successful, it
* initiates a server connection.
*/
this.doCheckServer = () => {
this.ioUtil.logdebug('doCheckServer');
this.request('', ['serverstatus', '0', '888'], result => {
if (result.ok) {
this.connect();
}
});
};
/**
* Sets the state of an object in the ioBroker system.
*
* @param name - The name of the state to set.
* @param value - The value to set the state to.
* @param [level1path] - The first level path to prepend to the state name, can be null or empty.
* @param [level2path] - The second level path to prepend to the state name, can be null or empty.
* @param [check] - If true, only sets the state if the current value differs from the new value.
* @param [callback] - Optional callback function, called when the state is set in the adapter.
*/
this.setState = function (name, value, level1path, level2path, check, callback) {
name = (level1path ? `${level1path}.` : '') + (level2path ? `${level2path}.` : '') + name;
if (this.currentStates[name] !== value && check) {
this.currentStates[name] = value;
this.ioUtil.logsilly(`setState name: ${name} value: ${value}`);
this.adapter.setState(name, value, true, callback);
} else {
this.currentStates[name] = value;
this.ioUtil.logsilly(`setState name: ${name} value: ${value}`);
this.adapter.setState(name, value, true, callback);
}
};
this.getState = function (name, level1path = false, level2path = false, callback) {
name = (level1path ? `${level1path}.` : '') + (level2path ? `${level2path}.` : '') + name;
this.ioUtil.logsilly(`getState ${name}`);
this.adapter.getState(name, callback);
};
this.delObject = function (name, level1path, level2path, callback) {
name = (level1path ? `${level1path}.` : '') + (level2path ? `${level2path}.` : '') + name;
this.ioUtil.logsilly(`delObject ${name}`);
delete this.currentStates[name];
this.adapter.delObject(name, callback);
};
this.delState = function (name, level1path, level2path, callback) {
name = (level1path ? `${level1path}.` : '') + (level2path ? `${level2path}.` : '') + name;
this.ioUtil.logsilly(`delObject ${name}`);
delete this.currentStates[name];
this.adapter.delState(name, callback);
};
this.getStates = function (pattern, level1path, level2path, callback) {
const name = (level1path ? `${level1path}.` : '') + (level2path ? `${level2path}.` : '') + pattern;
this.adapter.getStates(name, callback);
};
this.getObjects = async function (level1path, level2path) {
const name = `${this.adapter.namespace}.${level1path ? `${level1path}.` : ''}${level2path ? level2path : ''}`;
return await this.adapter.getObjectListAsync({
startkey: name,
endkey: `${name}\u9999`,
});
};
this.createObject = function (stateTemplate, level1path, level2path, callback) {
const name = (level1path ? `${level1path}.` : '') + (level2path ? `${level2path}.` : '') + stateTemplate.name;
this.ioUtil.logsilly(`createObject ${name}`);
this.adapter.getObject(name, (err, obj) => {
const newobj = {
type: 'state',
common: stateTemplate,
native: {},
};
if (!obj) {
callback ? this.adapter.setObject(name, newobj, callback) : this.adapter.setObject(name, newobj);
} else {
if (callback) {
callback();
}
}
});
stateTemplate.exist = true;
return stateTemplate;
};
this.createState = function (stateTemplate, level1path = false, level2path = false, callback) {
const name = (level1path ? `${level1path}.` : '') + (level2path ? `${level2path}.` : '') + stateTemplate.name;
this.ioUtil.logsilly(`Create state ${name}`);
this.adapter.createState(level1path, level2path, stateTemplate.name, stateTemplate, callback);
stateTemplate.exist = true;
return stateTemplate;
};
this.createFolder = async function (foldername, level1path, level2path) {
const id = (level1path ? `${level1path}.` : '') + (level2path ? `${level2path}.` : '') + foldername;
this.ioUtil.logsilly(`createFolder ${id}`);
if (await this.existsObjectAsync(id)) {
this.ioUtil.logsilly(`Folder exists: ${id}`);
} else {
const obj = {
type: 'folder',
common: {
name: foldername,
},
native: {},
};
this.adapter.setObject(id, obj);
}
};
this.createDevice = async function (devicename, level1path, level2path) {
const id = (level1path ? `${level1path}.` : '') + (level2path ? `${level2path}.` : '') + devicename;
this.ioUtil.logsilly(`createDevice ${id}`);