Sonos coordinator widget

I just updated my installation to 4.2.2 and the widget works as expected. Are there any supicious entries in your logfile?

nothing I am aware of, but the widget also doesn’t appear even in the widget editor:

btw I have the rules file in: /etc/openhab/automation/js/sonos_coordinator_rule.js

@L_B
I would have a look into openhab.log once more regarding messages on RINCON and sonos_coordinator_rule.js entries.
Even if in your case the proxyitems still show up which could mean, the script did not unload, it might show issues there. At least for me it pointed to problems.

Not sure if I can properly recall every detail or in correct order and not claiming you face anything similar, but:
I had this widget working on first try in OH3 back then (only favorites never worked) but after upgrading to OH4 it caused me quite a lot of pain to get it finally working again.
In my case it threw java exceptions claiming (some) items would already exist. Removing and re-adding the script did not fix it.
But at that time also RR4Dj persistance (was my default) was working with constantly growing delays, so it might have made it worse here or could have been the primary reason. I ended up cleaning the database from postponed proxyitems not to cause them to be recovered upon startup.
And after switching to influxdb as default persistance (to fix the massive delays) even the favorites started working for the first time.

Hi silchez,

I am total rookie using openHAB. I am pretty impressed of your “Sonos coordinator widget”.
After downloading the js-file and copying it to /etc/openhab/automation/js, unfortunately, the widget is not configured as it should. From 5 Sonos speakers only one is found and the favorite list is also empty. Is there anything I missed to do?
Thanks for your help in advance.
Florian
P.S.: I am using openHAB 3.4.2 on a RasPI 4.

Hello, i love this widget & zero conf rule but it stopped working in version 5.1. of openhab. as i could find out with AI this is due to some changes in how JS is handled. but i couldnt fix it. has someone the same experience and can help?

@sichelz can you help?

I used Gemini to fix the script. It seems to work for me, use it at your own risk ofcourse :wink:

your code goes here/**
 * Sonos Coordinator Script - Fixed for openHAB 4.x
 */

const { rules, triggers, items, time, osgi } = require('openhab');


const osgiService = osgi || (typeof osgi !== 'undefined' ? osgi : null);

if (!osgiService) {
    console.error("CRITICAL ERROR: Kan OSGi service niet laden. Zorg dat de 'JavaScript Scripting' add-on up-to-date is.");
}

const controllerGroupName = "SonosControllerGroup";
const proxyItemTagName = "SonosProxyItem";
const coordinatorProxyItemTagName = "SonosCoordinatorProxyItem";
const zoneVolumeProxyItemTagName = "ZoneVolume_SonosProxyItem";
const groupSwitcherItemTagName = "GroupSwitcher_SonosProxyItem";
const zoneMuteProxyItemTagName = "ZoneMute_SonosProxyItem";

const channelIds = {
    playerChannelId: "control",
    albumChannelId: "currentalbum",
    artistChannelId: "currentartist",
    coverArtChannelId: "currentalbumart",
    coverArtChannelUrlId: "currentalbumarturl",
    titleChannelId: "currenttitle",
    masterChannelId: "coordinator",
    localMasterChannelId: "localcoordinator",
    volumeChannelId: "volume",
    zoneNameChannelId: "zonename",
    addChannelId: "add",
    removeChannelId: "remove",
    standaloneChannelId: "standalone",
    trackChannelId: "currenttrack",
    muteChannelId: "mute",
    favoriteChannelId: "favorite"
};


let channelLinkRegistry, thingRegistry, managedLinkProvider;
try {
    channelLinkRegistry = osgiService.getService("org.openhab.core.thing.link.ItemChannelLinkRegistry");
    thingRegistry = osgiService.getService("org.openhab.core.thing.ThingRegistry");
    managedLinkProvider = osgiService.getService("org.openhab.core.thing.link.ManagedItemChannelLinkProvider");
} catch (e) {
    console.error("Fout bij ophalen OSGi services: " + e);
}

const ItemChannelLink = Java.type("org.openhab.core.thing.link.ItemChannelLink");

const constants = {
    sonosIdentifierString: "RINCON_"
}

var createItemChannelLink = function (itemName, channel) {
    if (!managedLinkProvider) return;
    // console.log("Linking item " + itemName + " to channel " + channel.getUID());
    var link = new ItemChannelLink(itemName, channel.getUID());
    managedLinkProvider.add(link);
}

var getChannelUidFromItem = function (item) {
    if (!channelLinkRegistry) return undefined;
    let itemObj = (typeof item === 'string') ? items.getItem(item) : item;
    if (!itemObj) return undefined;
    
    let foundChannels = Array.from(channelLinkRegistry.getBoundChannels(itemObj.name));
    return foundChannels.find(channel => channel.getThingUID().getId().includes(constants.sonosIdentifierString));
}

var getItemBoundToChannel = function (thingUidString, channelIdString) {
    // Zoek in items met de proxy tag om performance te sparen
    var foundItems = items.getItemsByTag(proxyItemTagName);
    var foundItem = foundItems.find(item => {
        let channelUid = getChannelUidFromItem(item);
        if (channelUid === undefined) return false;
        return channelUid.getThingUID().getId() === thingUidString && channelUid.getId() === channelIdString;
    });
    return foundItem || null;
}

var getAllSonosThings = function () {
    if (!thingRegistry) return [];
    let foundThings = Array.from(thingRegistry.getAll());
    return foundThings.filter(thing => thing.getUID().getId().startsWith(constants.sonosIdentifierString));
}

var itemExists = function (itemName) {
    try {
        return items.getItem(itemName) !== null;
    } catch (e) {
        return false;
    }
}

class SonosCoordinator {

    constructor() {
        if (itemExists(controllerGroupName)) {
            this.group = items.getItem(controllerGroupName);
        } else {
            this.group = items.addItem({
                name: controllerGroupName,
                type: "Group",
                tags: [proxyItemTagName]
            });
        }

        this.allSonosThings = getAllSonosThings().map(thing => new SonosThing(thing));

        var coordinatorTriggerArray = [];
        var volumeTriggerArray = [];
        var zoneVolumeTriggerArray = [];
        var groupSwitchTriggerArray = [];
        var muteTriggerArray = [];
        var zoneMuteTriggerArray = [];

        this.allSonosThings.forEach(sonosThing => {
            if (sonosThing.allThingItemNames[channelIds.localMasterChannelId]) coordinatorTriggerArray.push(triggers.ItemStateChangeTrigger(sonosThing.allThingItemNames[channelIds.localMasterChannelId]));
            if (sonosThing.allThingItemNames[channelIds.masterChannelId]) coordinatorTriggerArray.push(triggers.ItemStateChangeTrigger(sonosThing.allThingItemNames[channelIds.masterChannelId]));
            if (sonosThing.allThingItemNames[channelIds.muteChannelId]) muteTriggerArray.push(triggers.ItemStateChangeTrigger(sonosThing.allThingItemNames[channelIds.muteChannelId]));
            if (sonosThing.allThingItemNames[channelIds.volumeChannelId]) volumeTriggerArray.push(triggers.ItemStateChangeTrigger(sonosThing.allThingItemNames[channelIds.volumeChannelId]));

            zoneVolumeTriggerArray.push(triggers.ItemCommandTrigger(sonosThing.zoneVolumeItemName));
            groupSwitchTriggerArray.push(triggers.ItemCommandTrigger(sonosThing.groupSwitcherItemName));
            zoneMuteTriggerArray.push(triggers.ItemCommandTrigger(sonosThing.zoneMuteItemName));
        });

        this.updatingItems = {};

        if (!itemExists(coordinatorProxyItemTagName)) {
            items.addItem({
                name: coordinatorProxyItemTagName,
                type: "String",
                groups: [controllerGroupName],
                tags: [proxyItemTagName, coordinatorProxyItemTagName]
            });
        }

        setTimeout(() => this.updateCoordinatorProxyItems(), 5000);

        rules.JSRule({
            name: "Sonos: Coordinator Changed",
            description: "Refreshes proxy items when coordinator changes",
            triggers: coordinatorTriggerArray,
            execute: data => {
                this.updateCoordinatorProxyItems();
            }
        });

        rules.JSRule({
            name: "Sonos: Zone Volume Command",
            description: "Adjusts group volume",
            triggers: zoneVolumeTriggerArray,
            execute: data => {
                var changedThing = this.getSonosThingFromItemName(data.itemName);
                if (changedThing) this.onZoneVolumeChanged(changedThing);
            }
        });

        rules.JSRule({
            name: "Sonos: Volume Item Command",
            description: "Adjusts zone volume based on single volume change",
            triggers: volumeTriggerArray,
            execute: data => {
                var changedThing = this.getSonosThingFromItemName(data.itemName);
                if (changedThing) this.onVolumeChanged(changedThing);
            }
        });

        rules.JSRule({
            name: "Sonos: Group Switch Command",
            description: "Rearranges sonos group",
            triggers: groupSwitchTriggerArray,
            execute: data => {
                var changedThing = this.getSonosThingFromItemName(data.itemName);
                if (changedThing) this.onGroupSwitched(changedThing);
            }
        });

        rules.JSRule({
            name: "Sonos: Mute Item Command",
            description: "Syncs mute state",
            triggers: muteTriggerArray,
            execute: data => {
                var changedThing = this.getSonosThingFromItemName(data.itemName);
                if (changedThing) this.onMuteChanged(changedThing);
            }
        });

        rules.JSRule({
            name: "Sonos: Zone Mute Command",
            description: "Syncs zone mute state",
            triggers: zoneMuteTriggerArray,
            execute: data => {
                var changedThing = this.getSonosThingFromItemName(data.itemName);
                if (changedThing) this.onGroupMuteChanged(changedThing);
            }
        });
    }

    onZoneVolumeChanged(sonosThing) {
        if (this.wasOwnUpdate(sonosThing.zoneVolumeItemName)) return;
        if (!sonosThing.isZoneCoordinator()) return;

        var allGroupedThings = this.allSonosThings.filter(st => st.getMasterId() === sonosThing.thing.getUID().getId());
        if (allGroupedThings.length === 0) return;

        var groupedAvgVolume = 0.0;
        allGroupedThings.forEach(gt => groupedAvgVolume += gt.getVolume());
        groupedAvgVolume = groupedAvgVolume / allGroupedThings.length;

        var delta = Math.round(sonosThing.getZoneVolume() - groupedAvgVolume);
        if (delta === 0) return;

        allGroupedThings.forEach(st => {
            var newValue = st.getVolume() + delta;
            if (newValue > 100.0) newValue = 100.0;
            if (newValue < 0.0) newValue = 0.0;
            this.setNewVolume(st.allThingItemNames[channelIds.volumeChannelId], newValue)
        });
    }

    onVolumeChanged(sonosThing) {
        if (this.wasOwnUpdate(sonosThing.allThingItemNames[channelIds.volumeChannelId])) return;
        
        var masterId = sonosThing.getMasterId();
        var masterThing = this.allSonosThings.find(st => st.thing.getUID().getId() === masterId);
        
        if (!masterThing) return;

        var newVolume = 0.0;
        var allSonosThingsInGroup = this.allSonosThings.filter(st => st.getMasterId() === masterId);
        
        if (allSonosThingsInGroup.length === 0) return;

        allSonosThingsInGroup.forEach(st => newVolume += st.getVolume());
        newVolume = Math.round(newVolume / allSonosThingsInGroup.length);
        
        this.setNewVolume(masterThing.zoneVolumeItemName, newVolume);
    }

    onGroupSwitched(sonosThing) {
        var groupConfigurationString = items.getItem(sonosThing.groupSwitcherItemName).state;
        if (!sonosThing.isZoneCoordinator()) return;

        try {
            var groupConfiguration = JSON.parse(groupConfigurationString);
            Object.keys(groupConfiguration).forEach(configKey => {
                var groupThing = this.allSonosThings.find(st => st.thing.getUID().getId() === configKey);
                if (!groupThing) return;

                if (groupConfiguration[configKey]) {
                    // ADD to group
                    // console.log("Adding " + groupThing.thing.getLabel() + " to " + sonosThing.thing.getLabel());
                    items.getItem(sonosThing.allThingItemNames[channelIds.addChannelId]).sendCommand(groupThing.thing.getUID().getId());
                } else {
                    // REMOVE from group
                    // console.log("Removing " + groupThing.thing.getLabel() + " from " + sonosThing.thing.getLabel());
                    items.getItem(groupThing.allThingItemNames[channelIds.standaloneChannelId]).sendCommand("ON");
                }
            });
        } catch (e) {
            console.error("Error parsing group config: " + e);
        }
    }

    onMuteChanged(sonosThing) {
        if (this.wasOwnUpdate(sonosThing.allThingItemNames[channelIds.muteChannelId])) return;
        
        var masterThing = this.allSonosThings.find(st => st.thing.getUID().getId() === sonosThing.getMasterId());
        if (!masterThing) return;

        var muteValue = this.getZoneMuteState(masterThing);
        this.setMute(masterThing.zoneMuteItemName, muteValue);
    }

    getZoneMuteState(sonosThing) {
        var unmutedThingFound = false;
        this.getAllGroupedSonosThings(sonosThing).forEach(gt => {
            if (unmutedThingFound) return;
            if (items.getItem(gt.allThingItemNames[channelIds.muteChannelId]).state === "OFF") {
                unmutedThingFound = true;
            }
        });
        return unmutedThingFound ? "OFF" : "ON";
    }

    onGroupMuteChanged(sonosThing) {
        if (!sonosThing.isZoneCoordinator()) return;
        if (this.wasOwnUpdate(sonosThing.zoneMuteItemName)) return;

        var groupedThings = this.getAllGroupedSonosThings(sonosThing);
        var muteState = items.getItem(sonosThing.zoneMuteItemName).state;
        
        groupedThings.forEach(gt => {
            var muteItemName = gt.allThingItemNames[channelIds.muteChannelId];
            this.setMute(muteItemName, muteState);
        });
    }

    setMute(itemName, muteValue) {
        var item = items.getItem(itemName);
        if (item.state === muteValue) return;
        
        this.updatingItems[itemName] = muteValue;
        item.sendCommand(muteValue);
    }

    getSonosThingFromItemName(itemName) {
        return this.allSonosThings.find(sonosThing => 
            Object.keys(sonosThing.allThingItemNames).some(key => sonosThing.allThingItemNames[key] === itemName) || 
            sonosThing.groupSwitcherItemName === itemName || 
            sonosThing.zoneVolumeItemName === itemName || 
            sonosThing.zoneMuteItemName === itemName
        );
    }

    wasOwnUpdate(itemName) {
        if (this.updatingItems.hasOwnProperty(itemName)) {
            delete this.updatingItems[itemName];
            return true;
        }
        return false;
    }

    setNewVolume(itemName, newVolume) {
        var item = items.getItem(itemName);
        var currentVol = parseFloat(item.state);
        if (currentVol === newVolume) return;
        
        this.updatingItems[itemName] = newVolume;
        item.sendCommand(newVolume.toString());
    }

    getAllGroupedSonosThings(sonosThing) {
        if (!sonosThing.isZoneCoordinator()) {
            return [sonosThing];
        }
        return this.allSonosThings.filter(st => st.getMasterId() === sonosThing.thing.getUID().getId() || st.thing.getUID().getId() === sonosThing.thing.getUID().getId());
    }

    updateCoordinatorProxyItems() {
        var allCoordinators = this.allSonosThings.filter(sonosThing => sonosThing.isZoneCoordinator());
        var coordinatorArray = [];

        allCoordinators.forEach(sonosCoordinator => {
            var groupedSonosThings = this.getAllGroupedSonosThings(sonosCoordinator);

            var coordinatorProxyItem = {};
            coordinatorProxyItem.id = sonosCoordinator.thing.getUID().getId();
            
            // Items
            coordinatorProxyItem.zoneVolumeItemName = sonosCoordinator.zoneVolumeItemName;
            coordinatorProxyItem.zoneMuteItemName = sonosCoordinator.zoneMuteItemName;
            coordinatorProxyItem.groupSwitcherItemName = sonosCoordinator.groupSwitcherItemName;
            
            coordinatorProxyItem.artistItemName = sonosCoordinator.allThingItemNames[channelIds.artistChannelId];
            coordinatorProxyItem.titelItemName = sonosCoordinator.allThingItemNames[channelIds.titleChannelId];
            coordinatorProxyItem.albumItemName = sonosCoordinator.allThingItemNames[channelIds.albumChannelId];
            coordinatorProxyItem.coverArtItemName = sonosCoordinator.allThingItemNames[channelIds.coverArtChannelId];
            coordinatorProxyItem.coverArtUrlItemName = sonosCoordinator.allThingItemNames[channelIds.coverArtChannelUrlId];
            coordinatorProxyItem.playerItemName = sonosCoordinator.allThingItemNames[channelIds.playerChannelId];
            coordinatorProxyItem.trackItemName = sonosCoordinator.allThingItemNames[channelIds.trackChannelId];
            coordinatorProxyItem.muteItemName = sonosCoordinator.allThingItemNames[channelIds.muteChannelId];
            coordinatorProxyItem.favoriteItemName = sonosCoordinator.allThingItemNames[channelIds.favoriteChannelId];

            var zoneItemNames = [];
            var volumeItemsInformation = [];
            var groupedItemsInformation = [];
            var groupedThingDeleteVars = [];
            var groupVolume = 0.0;

            groupedThingDeleteVars.push(sonosCoordinator.thing.getUID().getId() + "_group");
            groupedThingDeleteVars.push(sonosCoordinator.thing.getUID().getId() + "_volume");
            groupedThingDeleteVars.push(sonosCoordinator.thing.getUID().getId() + "_favorite");

            groupedSonosThings.forEach(gt => {
                let zoneNameItem = items.getItem(gt.allThingItemNames[channelIds.zoneNameChannelId]);
                if(zoneNameItem) zoneItemNames.push(zoneNameItem.state);
                
                var gtVolumeInformation = {};
                gtVolumeInformation.zoneItemName = gt.allThingItemNames[channelIds.zoneNameChannelId];
                gtVolumeInformation.volumeItemName = gt.allThingItemNames[channelIds.volumeChannelId];
                gtVolumeInformation.muteItemName = gt.allThingItemNames[channelIds.muteChannelId];
                volumeItemsInformation.push(gtVolumeInformation);

                groupVolume += gt.getVolume();
            });

            if (groupedSonosThings.length > 0)
                groupVolume = Math.round(groupVolume / groupedSonosThings.length);

            items.getItem(sonosCoordinator.zoneVolumeItemName).postUpdate(groupVolume.toString());
            items.getItem(sonosCoordinator.zoneMuteItemName).postUpdate(this.getZoneMuteState(sonosCoordinator));

            var allOtherSonosItems = this.allSonosThings.filter(ast => {
                return ast.thing.getUID().getId() !== sonosCoordinator.thing.getUID().getId();
            });

            var idCounter = 0;
            allOtherSonosItems.forEach(aosi => {
                var groupedItemInformation = {};
                groupedItemInformation.isInGroup = groupedSonosThings.some(gst => aosi.thing.getUID().getId() === gst.thing.getUID().getId());
                
                let otherZoneItem = items.getItem(aosi.allThingItemNames[channelIds.zoneNameChannelId]);
                groupedItemInformation.name = otherZoneItem ? otherZoneItem.state : "Unknown";
                
                groupedItemInformation.thingUid = aosi.thing.getUID().getId();
                groupedItemInformation.id = "itemInfo_" + idCounter;
                groupedItemsInformation.push(groupedItemInformation);
                groupedThingDeleteVars.push(aosi.thing.getUID().getId());
                idCounter++;
            });

            coordinatorProxyItem.zoneNames = zoneItemNames.join(" + ");
            coordinatorProxyItem.volumeInformation = volumeItemsInformation;
            coordinatorProxyItem.groupedItemsInformation = groupedItemsInformation;
            coordinatorProxyItem.groupedThingDeleteVars = groupedThingDeleteVars;
            coordinatorArray.push(coordinatorProxyItem);
        });

        items.getItem(coordinatorProxyItemTagName).sendCommand(JSON.stringify(coordinatorArray));
    }
}

class SonosThing {
    constructor(thing) {
        this.thing = thing;
        this.allThingItemNames = {};

        Object.keys(channelIds).forEach(key => {
            var channelId = channelIds[key];
            var channel = thing.getChannel(channelId);
            if(!channel) return; 

            var cleanThingId = thing.getUID().getId().replace(/:/g, "_").replace(/-/g, "_");
            var proxyItemName = cleanThingId + "_" + channelId + "_" + proxyItemTagName;

            if (itemExists(proxyItemName)) {
                this.allThingItemNames[channelId] = proxyItemName;
            } else {
                var itemType = channel.getAcceptedItemType() || "String";
                items.addItem({
                    name: proxyItemName,
                    type: itemType,
                    groups: [controllerGroupName],
                    tags: [proxyItemTagName]
                });
                createItemChannelLink(proxyItemName, channel);
                this.allThingItemNames[channelId] = proxyItemName;
            }
        });

        var cleanThingId = thing.getUID().getId().replace(/:/g, "_").replace(/-/g, "_");
        
        this.zoneVolumeItemName = cleanThingId + "_" + zoneVolumeProxyItemTagName;
        if (!itemExists(this.zoneVolumeItemName)) {
            items.addItem({ name: this.zoneVolumeItemName, type: "Dimmer", tags: [proxyItemTagName, zoneVolumeProxyItemTagName] });
        }

        this.groupSwitcherItemName = cleanThingId + "_" + groupSwitcherItemTagName;
        if (!itemExists(this.groupSwitcherItemName)) {
            items.addItem({ name: this.groupSwitcherItemName, type: "String", tags: [proxyItemTagName, groupSwitcherItemTagName] });
        }

        this.zoneMuteItemName = cleanThingId + "_" + zoneMuteProxyItemTagName;
        if (!itemExists(this.zoneMuteItemName)) {
            items.addItem({ name: this.zoneMuteItemName, type: "Switch", tags: [proxyItemTagName, zoneMuteProxyItemTagName] });
        }
    }

    isZoneCoordinator() {
        var thingName = this.allThingItemNames[channelIds.localMasterChannelId];
        if(!thingName) return false;
        var localMasterItem = items.getItem(thingName);
        return localMasterItem.state === "ON";
    }

    getMasterId() {
        var item = items.getItem(this.allThingItemNames[channelIds.masterChannelId]);
        if(!item) return this.thing.getUID().getId();

        var masterString = item.state;
        if (!masterString || masterString === "NULL" || masterString === "UNDEF") {
             return this.thing.getUID().getId();
        }
        if (!masterString.includes(constants.sonosIdentifierString)) {
            return this.thing.getUID().getId();
        }
        return masterString;
    }

    getVolume() {
        var item = items.getItem(this.allThingItemNames[channelIds.volumeChannelId]);
        if(!item) return 0.0;
        var volume = parseFloat(item.state);
        return isNaN(volume) ? 0.0 : volume;
    }

    getZoneVolume() {
        var zoneVolume = parseFloat(items.getItem(this.zoneVolumeItemName).state);
        return isNaN(zoneVolume) ? 0.0 : zoneVolume;
    }
}

console.log("Sonos Modern Coordinator Rule: Starting...");
new SonosCoordinator();

hi @stevendp thanks for that but for me it’s still not working. do you have openhab 5.1 running? in your top comment in the code it says it is fixed for openhab 4.x?

i tried some times with gemini and didnt get it running…

Yes, I’m using 5.1 as well. Check your openhab log what error it’s giving.

Hey guys, sorry for the late reply. I’ll have a look into the script next week. For now, are there any log files with errors of the script which you could provide me?

hi @sichelz i will try to look into the logs tomorrow and post it. acutally i am not a dev and not sure in which log part i should look as it seems the js is totally not loaded. so i dont have problems in the interface as in the interface nothing is loaded. the proxy items are in general not generated. Gemini says in 5.1. there where changes in the core with which it is not possible any more to autocreate items or so. so the error in the log will be something like xyz deprecated or so … but i will see what ich can find.

and thanks that you can look into it. your widget and JS is in daily great use at home! :slight_smile: dont know how i could make it without …

@sichelz on startup i get the following error in the log when initializing the js:

2026-01-14 20:47:47.254 [ERROR] [pting.file.sonos_coordinator_rule.js] - Failed to execute script: Cannot add element, because an element with same UID (RINCON_542A1B5C672A01400_Entfernen_SonosProxyItem -> sonos:SYMFONISK:RINCON_542A1B5C672A01400:remove) already exists.
        at org.openhab.core.common.registry.AbstractManagedProvider.add(AbstractManagedProvider.java:61)
        at com.oracle.truffle.host.HostMethodDesc$SingleMethod$MHBase.invokeHandle(HostMethodDesc.java:372)
        at com.oracle.truffle.host.GuestToHostCodeCache$GuestToHostInvokeHandle.executeImpl(GuestToHostCodeCache.java:88)
        at com.oracle.truffle.host.GuestToHostRootNode.execute(GuestToHostRootNode.java:80)
        at com.oracle.truffle.api.impl.DefaultCallTarget.call(DefaultCallTarget.java:118)
        ... 203 more
2026-01-14 20:47:47.254 [ERROR] [ipt.internal.ScriptEngineManagerImpl] - Error during evaluation of script '/etc/openhab/automation/js/sonos_coordinator_rule.js': java.lang.IllegalArgumentException: Cannot add element, because an element with same UID (RINCON_542A1B5C672A01400_Entfernen_SonosProxyItem -> sonos:SYMFONISK:RINCON_542A1B5C672A01400:remove) already exists.
2026-01-14 20:47:47.254 [WARN ] [ort.loader.AbstractScriptFileWatcher] - Script loading error, ignoring file '/etc/openhab/automation/js/sonos_coordinator_rule.js'

hope that helps, the problem is only after update to 5.1. now i am running 5.1.1. i have also manually configured some sonos items but as i understood your script that is not a problem and was also not a problem before the update. let me know if i should test something else or send other logs.

It could be, that the problem is indeed, that you have manually configured a sonos item. Could you try to remove the items temporarly and see if the situation improves?
Nervertheless, normally the script should detect whether an item is already bound to a channel. I have to take a look if this is somehow not working anymore.

@sichelz unfortunately i can not do that as i have several other rules and implementations that need their sonos items. so i can not solely rely on the autogenerated items by the js.

as far as i could find out there where changes in the javascript scripting automation in openhab in version 5.1. that included some core chanages that prohibit the autogeneration of items. but it was an “AI chat” so definitely possible there is some halucination.

i try to create some other widget that works without your js but it will never be so comfortable as you built it but needs all those items added to my widget so still hope you can fix it or reproduce the problem. another one wrote here he had problems too so i guess its broken with 5.1. in general.

After upgrading to 5.1.1 I also had some issues. Hereby the updated js code, for me this works:

/**
 * Sonos Coordinator Script - Final Fix (Clean Names)
 */

const { rules, triggers, items, time, osgi } = require('openhab');

// Veiligheidscheck
const osgiService = osgi || (typeof osgi !== 'undefined' ? osgi : null);
if (!osgiService) {
    console.error("CRITICAL ERROR: Kan OSGi service niet laden.");
}

const controllerGroupName = "SonosControllerGroup";
const proxyItemTagName = "SonosProxyItem";
const coordinatorProxyItemTagName = "SonosCoordinatorProxyItem";
const zoneVolumeProxyItemTagName = "ZoneVolume_SonosProxyItem";
const groupSwitcherItemTagName = "GroupSwitcher_SonosProxyItem";
const zoneMuteProxyItemTagName = "ZoneMute_SonosProxyItem";

const channelIds = {
    playerChannelId: "control",
    albumChannelId: "currentalbum",
    artistChannelId: "currentartist",
    coverArtChannelId: "currentalbumart",
    coverArtChannelUrlId: "currentalbumarturl",
    titleChannelId: "currenttitle",
    masterChannelId: "coordinator",
    localMasterChannelId: "localcoordinator",
    volumeChannelId: "volume",
    zoneNameChannelId: "zonename",
    addChannelId: "add",
    removeChannelId: "remove",
    standaloneChannelId: "standalone",
    trackChannelId: "currenttrack",
    muteChannelId: "mute",
    favoriteChannelId: "favorite"
};

// Services ophalen
let channelLinkRegistry, thingRegistry, managedLinkProvider;
try {
    channelLinkRegistry = osgiService.getService("org.openhab.core.thing.link.ItemChannelLinkRegistry");
    thingRegistry = osgiService.getService("org.openhab.core.thing.ThingRegistry");
    managedLinkProvider = osgiService.getService("org.openhab.core.thing.link.ManagedItemChannelLinkProvider");
} catch (e) {
    console.error("Fout bij ophalen OSGi services: " + e);
}

const ItemChannelLink = Java.type("org.openhab.core.thing.link.ItemChannelLink");

const constants = {
    sonosIdentifierString: "RINCON_"
}

// Veilige Link Creatie
var createItemChannelLink = function (itemName, channel) {
    if (!managedLinkProvider) return;
    var link = new ItemChannelLink(itemName, channel.getUID());
    try {
        managedLinkProvider.add(link);
    } catch (e) {
        // Link bestaat al, negeren.
    }
}

var getChannelUidFromItem = function (item) {
    if (!channelLinkRegistry) return undefined;
    let itemObj = (typeof item === 'string') ? items.getItem(item) : item;
    if (!itemObj) return undefined;
    
    let foundChannels = Array.from(channelLinkRegistry.getBoundChannels(itemObj.name));
    return foundChannels.find(channel => channel.getThingUID().getId().includes(constants.sonosIdentifierString));
}

var getAllSonosThings = function () {
    if (!thingRegistry) return [];
    let foundThings = Array.from(thingRegistry.getAll());
    return foundThings.filter(thing => thing.getUID().getId().startsWith(constants.sonosIdentifierString));
}

var itemExists = function (itemName) {
    try {
        return items.getItem(itemName) !== null;
    } catch (e) {
        return false;
    }
}

class SonosCoordinator {

    constructor() {
        this.group = items.replaceItem({
            name: controllerGroupName,
            type: "Group",
            tags: [proxyItemTagName]
        });

        this.allSonosThings = getAllSonosThings().map(thing => new SonosThing(thing));

        var coordinatorTriggerArray = [];
        var volumeTriggerArray = [];
        var zoneVolumeTriggerArray = [];
        var groupSwitchTriggerArray = [];
        var muteTriggerArray = [];
        var zoneMuteTriggerArray = [];
        var zoneNameTriggerArray = [];

        this.allSonosThings.forEach(sonosThing => {
            if (sonosThing.allThingItemNames[channelIds.localMasterChannelId]) coordinatorTriggerArray.push(triggers.ItemStateChangeTrigger(sonosThing.allThingItemNames[channelIds.localMasterChannelId]));
            if (sonosThing.allThingItemNames[channelIds.masterChannelId]) coordinatorTriggerArray.push(triggers.ItemStateChangeTrigger(sonosThing.allThingItemNames[channelIds.masterChannelId]));
            if (sonosThing.allThingItemNames[channelIds.muteChannelId]) muteTriggerArray.push(triggers.ItemStateChangeTrigger(sonosThing.allThingItemNames[channelIds.muteChannelId]));
            if (sonosThing.allThingItemNames[channelIds.volumeChannelId]) volumeTriggerArray.push(triggers.ItemStateChangeTrigger(sonosThing.allThingItemNames[channelIds.volumeChannelId]));
            
            if (sonosThing.allThingItemNames[channelIds.zoneNameChannelId]) {
                zoneNameTriggerArray.push(triggers.ItemStateChangeTrigger(sonosThing.allThingItemNames[channelIds.zoneNameChannelId]));
            }

            zoneVolumeTriggerArray.push(triggers.ItemCommandTrigger(sonosThing.zoneVolumeItemName));
            groupSwitchTriggerArray.push(triggers.ItemCommandTrigger(sonosThing.groupSwitcherItemName));
            zoneMuteTriggerArray.push(triggers.ItemCommandTrigger(sonosThing.zoneMuteItemName));
        });

        this.updatingItems = {};

        items.replaceItem({
            name: coordinatorProxyItemTagName,
            type: "String",
            groups: [controllerGroupName],
            tags: [proxyItemTagName, coordinatorProxyItemTagName]
        });

        setTimeout(() => this.updateCoordinatorProxyItems(), 5000);

        rules.JSRule({
            name: "Sonos: Coordinator Changed",
            description: "Refreshes proxy items when coordinator changes",
            triggers: coordinatorTriggerArray,
            execute: data => { this.updateCoordinatorProxyItems(); }
        });

        rules.JSRule({
            name: "Sonos: Zone Name Changed",
            description: "Updates proxies when a zone name updates",
            triggers: zoneNameTriggerArray,
            execute: data => { this.updateCoordinatorProxyItems(); }
        });

        rules.JSRule({
            name: "Sonos: Zone Volume Command",
            description: "Adjusts group volume",
            triggers: zoneVolumeTriggerArray,
            execute: data => {
                var changedThing = this.getSonosThingFromItemName(data.itemName);
                if (changedThing) this.onZoneVolumeChanged(changedThing);
            }
        });

        rules.JSRule({
            name: "Sonos: Volume Item Command",
            description: "Adjusts zone volume based on single volume change",
            triggers: volumeTriggerArray,
            execute: data => {
                var changedThing = this.getSonosThingFromItemName(data.itemName);
                if (changedThing) this.onVolumeChanged(changedThing);
            }
        });

        rules.JSRule({
            name: "Sonos: Group Switch Command",
            description: "Rearranges sonos group",
            triggers: groupSwitchTriggerArray,
            execute: data => {
                var changedThing = this.getSonosThingFromItemName(data.itemName);
                if (changedThing) this.onGroupSwitched(changedThing, data.receivedCommand.toString());
            }
        });

        rules.JSRule({
            name: "Sonos: Mute Item Command",
            description: "Syncs mute state",
            triggers: muteTriggerArray,
            execute: data => {
                var changedThing = this.getSonosThingFromItemName(data.itemName);
                if (changedThing) this.onMuteChanged(changedThing);
            }
        });

        rules.JSRule({
            name: "Sonos: Zone Mute Command",
            description: "Syncs zone mute state",
            triggers: zoneMuteTriggerArray,
            execute: data => {
                var changedThing = this.getSonosThingFromItemName(data.itemName);
                if (changedThing) this.onGroupMuteChanged(changedThing);
            }
        });
    }

    onZoneVolumeChanged(sonosThing) {
        if (this.wasOwnUpdate(sonosThing.zoneVolumeItemName)) return;
        if (!sonosThing.isZoneCoordinator()) return;

        var allGroupedThings = this.allSonosThings.filter(st => st.getMasterId() === sonosThing.thing.getUID().getId());
        if (allGroupedThings.length === 0) return;

        var groupedAvgVolume = 0.0;
        allGroupedThings.forEach(gt => groupedAvgVolume += gt.getVolume());
        groupedAvgVolume = groupedAvgVolume / allGroupedThings.length;

        var delta = Math.round(sonosThing.getZoneVolume() - groupedAvgVolume);
        if (delta === 0) return;

        allGroupedThings.forEach(st => {
            var newValue = st.getVolume() + delta;
            if (newValue > 100.0) newValue = 100.0;
            if (newValue < 0.0) newValue = 0.0;
            this.setNewVolume(st.allThingItemNames[channelIds.volumeChannelId], newValue)
        });
    }

    onVolumeChanged(sonosThing) {
        if (this.wasOwnUpdate(sonosThing.allThingItemNames[channelIds.volumeChannelId])) return;
        
        var masterId = sonosThing.getMasterId();
        var masterThing = this.allSonosThings.find(st => st.thing.getUID().getId() === masterId);
        
        if (!masterThing) return;

        var newVolume = 0.0;
        var allSonosThingsInGroup = this.allSonosThings.filter(st => st.getMasterId() === masterId);
        
        if (allSonosThingsInGroup.length === 0) return;

        allSonosThingsInGroup.forEach(st => newVolume += st.getVolume());
        newVolume = Math.round(newVolume / allSonosThingsInGroup.length);
        
        this.setNewVolume(masterThing.zoneVolumeItemName, newVolume);
    }

    onGroupSwitched(sonosThing, groupConfigurationString) {
        if (!sonosThing.isZoneCoordinator()) return;

        try {
            var groupConfiguration = JSON.parse(groupConfigurationString);
            Object.keys(groupConfiguration).forEach(configKey => {
                var groupThing = this.allSonosThings.find(st => st.thing.getUID().getId() === configKey);
                if (!groupThing) return;

                if (groupConfiguration[configKey]) {
                    items.getItem(sonosThing.allThingItemNames[channelIds.addChannelId]).sendCommand(groupThing.thing.getUID().getId());
                } else {
                    items.getItem(groupThing.allThingItemNames[channelIds.standaloneChannelId]).sendCommand("ON");
                }
            });
        } catch (e) {
            console.error("Error parsing group config: " + e);
        }
    }

    onMuteChanged(sonosThing) {
        if (this.wasOwnUpdate(sonosThing.allThingItemNames[channelIds.muteChannelId])) return;
        
        var masterThing = this.allSonosThings.find(st => st.thing.getUID().getId() === sonosThing.getMasterId());
        if (!masterThing) return;

        var muteValue = this.getZoneMuteState(masterThing);
        this.setMute(masterThing.zoneMuteItemName, muteValue);
    }

    getZoneMuteState(sonosThing) {
        var unmutedThingFound = false;
        this.getAllGroupedSonosThings(sonosThing).forEach(gt => {
            if (unmutedThingFound) return;
            if (items.getItem(gt.allThingItemNames[channelIds.muteChannelId]).state === "OFF") {
                unmutedThingFound = true;
            }
        });
        return unmutedThingFound ? "OFF" : "ON";
    }

    onGroupMuteChanged(sonosThing) {
        if (!sonosThing.isZoneCoordinator()) return;
        if (this.wasOwnUpdate(sonosThing.zoneMuteItemName)) return;

        var groupedThings = this.getAllGroupedSonosThings(sonosThing);
        var muteState = items.getItem(sonosThing.zoneMuteItemName).state;
        
        groupedThings.forEach(gt => {
            var muteItemName = gt.allThingItemNames[channelIds.muteChannelId];
            this.setMute(muteItemName, muteState);
        });
    }

    setMute(itemName, muteValue) {
        var item = items.getItem(itemName);
        if (item.state === muteValue) return;
        
        this.updatingItems[itemName] = muteValue;
        item.sendCommand(muteValue);
    }

    getSonosThingFromItemName(itemName) {
        return this.allSonosThings.find(sonosThing => 
            Object.keys(sonosThing.allThingItemNames).some(key => sonosThing.allThingItemNames[key] === itemName) || 
            sonosThing.groupSwitcherItemName === itemName || 
            sonosThing.zoneVolumeItemName === itemName || 
            sonosThing.zoneMuteItemName === itemName
        );
    }

    wasOwnUpdate(itemName) {
        if (this.updatingItems.hasOwnProperty(itemName)) {
            delete this.updatingItems[itemName];
            return true;
        }
        return false;
    }

    setNewVolume(itemName, newVolume) {
        var item = items.getItem(itemName);
        var currentVol = parseFloat(item.state);
        if (currentVol === newVolume) return;
        
        this.updatingItems[itemName] = newVolume;
        item.sendCommand(newVolume.toString());
    }

    getAllGroupedSonosThings(sonosThing) {
        if (!sonosThing.isZoneCoordinator()) {
            return [sonosThing];
        }
        return this.allSonosThings.filter(st => st.getMasterId() === sonosThing.thing.getUID().getId() || st.thing.getUID().getId() === sonosThing.thing.getUID().getId());
    }

    updateCoordinatorProxyItems() {
        var allCoordinators = this.allSonosThings.filter(sonosThing => sonosThing.isZoneCoordinator());
        var coordinatorArray = [];

        allCoordinators.forEach(sonosCoordinator => {
            var groupedSonosThings = this.getAllGroupedSonosThings(sonosCoordinator);

            var coordinatorProxyItem = {};
            coordinatorProxyItem.id = sonosCoordinator.thing.getUID().getId();
            
            // Items
            coordinatorProxyItem.zoneVolumeItemName = sonosCoordinator.zoneVolumeItemName;
            coordinatorProxyItem.zoneMuteItemName = sonosCoordinator.zoneMuteItemName;
            coordinatorProxyItem.groupSwitcherItemName = sonosCoordinator.groupSwitcherItemName;
            
            coordinatorProxyItem.artistItemName = sonosCoordinator.allThingItemNames[channelIds.artistChannelId];
            coordinatorProxyItem.titelItemName = sonosCoordinator.allThingItemNames[channelIds.titleChannelId];
            coordinatorProxyItem.albumItemName = sonosCoordinator.allThingItemNames[channelIds.albumChannelId];
            coordinatorProxyItem.coverArtItemName = sonosCoordinator.allThingItemNames[channelIds.coverArtChannelId];
            coordinatorProxyItem.coverArtUrlItemName = sonosCoordinator.allThingItemNames[channelIds.coverArtChannelUrlId];
            coordinatorProxyItem.playerItemName = sonosCoordinator.allThingItemNames[channelIds.playerChannelId];
            coordinatorProxyItem.trackItemName = sonosCoordinator.allThingItemNames[channelIds.trackChannelId];
            coordinatorProxyItem.muteItemName = sonosCoordinator.allThingItemNames[channelIds.muteChannelId];
            coordinatorProxyItem.favoriteItemName = sonosCoordinator.allThingItemNames[channelIds.favoriteChannelId];

            var zoneItemNames = [];
            var volumeItemsInformation = [];
            var groupedItemsInformation = [];
            var groupedThingDeleteVars = [];
            var groupVolume = 0.0;

            groupedThingDeleteVars.push(sonosCoordinator.thing.getUID().getId() + "_group");
            groupedThingDeleteVars.push(sonosCoordinator.thing.getUID().getId() + "_volume");
            groupedThingDeleteVars.push(sonosCoordinator.thing.getUID().getId() + "_favorite");

            groupedSonosThings.forEach(gt => {
                let zoneNameItem = items.getItem(gt.allThingItemNames[channelIds.zoneNameChannelId]);
                let zoneName = "Unknown";
                
                
                if (zoneNameItem) {
                    if (zoneNameItem.state !== "NULL" && zoneNameItem.state !== "UNDEF") {
                        zoneName = zoneNameItem.state;
                    } else {
                        zoneName = gt.thing.getLabel(); 
                    }
                }
                
             
                var match = zoneName.match(/.*\((.*)\)$/);
                if (match && match[1]) {
                    zoneName = match[1];
                }

               
                if (zoneNameItem && zoneNameItem.state !== zoneName) {
                    zoneNameItem.postUpdate(zoneName);
                }
                
                zoneItemNames.push(zoneName);
                
                var gtVolumeInformation = {};
                gtVolumeInformation.zoneItemName = gt.allThingItemNames[channelIds.zoneNameChannelId];
                gtVolumeInformation.zoneName = zoneName; 
                gtVolumeInformation.volumeItemName = gt.allThingItemNames[channelIds.volumeChannelId];
                gtVolumeInformation.muteItemName = gt.allThingItemNames[channelIds.muteChannelId];
                volumeItemsInformation.push(gtVolumeInformation);

                groupVolume += gt.getVolume();
            });

            if (groupedSonosThings.length > 0)
                groupVolume = Math.round(groupVolume / groupedSonosThings.length);

            items.getItem(sonosCoordinator.zoneVolumeItemName).postUpdate(groupVolume.toString());
            items.getItem(sonosCoordinator.zoneMuteItemName).postUpdate(this.getZoneMuteState(sonosCoordinator));

            var allOtherSonosItems = this.allSonosThings.filter(ast => {
                return ast.thing.getUID().getId() !== sonosCoordinator.thing.getUID().getId();
            });

            var idCounter = 0;
            allOtherSonosItems.forEach(aosi => {
                var groupedItemInformation = {};
                groupedItemInformation.isInGroup = groupedSonosThings.some(gst => aosi.thing.getUID().getId() === gst.thing.getUID().getId());
                
                // Dezelfde logica voor groepen lijst
                let otherZoneItem = items.getItem(aosi.allThingItemNames[channelIds.zoneNameChannelId]);
                let otherName = "Unknown";

                if (otherZoneItem) {
                    if (otherZoneItem.state !== "NULL" && otherZoneItem.state !== "UNDEF") {
                        otherName = otherZoneItem.state;
                    } else {
                        otherName = aosi.thing.getLabel();
                    }
                }

                // CLEANUP ook hier
                var match = otherName.match(/.*\((.*)\)$/);
                if (match && match[1]) {
                    otherName = match[1];
                }

                if (otherZoneItem && otherZoneItem.state !== otherName) {
                    otherZoneItem.postUpdate(otherName);
                }

                groupedItemInformation.name = otherName;
                groupedItemInformation.thingUid = aosi.thing.getUID().getId();
                groupedItemInformation.id = "itemInfo_" + idCounter;
                groupedItemsInformation.push(groupedItemInformation);
                groupedThingDeleteVars.push(aosi.thing.getUID().getId());
                idCounter++;
            });

            coordinatorProxyItem.zoneNames = zoneItemNames.join(" + ");
            coordinatorProxyItem.volumeInformation = volumeItemsInformation;
            coordinatorProxyItem.groupedItemsInformation = groupedItemsInformation;
            coordinatorProxyItem.groupedThingDeleteVars = groupedThingDeleteVars;
            coordinatorArray.push(coordinatorProxyItem);
        });

        items.getItem(coordinatorProxyItemTagName).sendCommand(JSON.stringify(coordinatorArray));
    }
}

class SonosThing {
    constructor(thing) {
        this.thing = thing;
        this.allThingItemNames = {};

        Object.keys(channelIds).forEach(key => {
            var channelId = channelIds[key];
            var channel = thing.getChannel(channelId);
            if(!channel) return; 

            var cleanThingId = thing.getUID().getId().replace(/:/g, "_").replace(/-/g, "_");
            var proxyItemName = cleanThingId + "_" + channelId + "_" + proxyItemTagName;

            var itemType = channel.getAcceptedItemType() || "String";
            items.replaceItem({
                name: proxyItemName,
                type: itemType,
                groups: [controllerGroupName],
                tags: [proxyItemTagName]
            });
            createItemChannelLink(proxyItemName, channel);
            
            this.allThingItemNames[channelId] = proxyItemName;
        });

        var cleanThingId = thing.getUID().getId().replace(/:/g, "_").replace(/-/g, "_");
        
        this.zoneVolumeItemName = cleanThingId + "_" + zoneVolumeProxyItemTagName;
        items.replaceItem({ name: this.zoneVolumeItemName, type: "Dimmer", tags: [proxyItemTagName, zoneVolumeProxyItemTagName] });

        this.groupSwitcherItemName = cleanThingId + "_" + groupSwitcherItemTagName;
        items.replaceItem({ name: this.groupSwitcherItemName, type: "String", tags: [proxyItemTagName, groupSwitcherItemTagName] });

        this.zoneMuteItemName = cleanThingId + "_" + zoneMuteProxyItemTagName;
        items.replaceItem({ name: this.zoneMuteItemName, type: "Switch", tags: [proxyItemTagName, zoneMuteProxyItemTagName] });
    }

    isZoneCoordinator() {
        var thingName = this.allThingItemNames[channelIds.localMasterChannelId];
        if(!thingName) return false;
        var localMasterItem = items.getItem(thingName);
        return localMasterItem.state === "ON";
    }

    getMasterId() {
        var item = items.getItem(this.allThingItemNames[channelIds.masterChannelId]);
        if(!item) return this.thing.getUID().getId();

        var masterString = item.state;
        if (!masterString || masterString === "NULL" || masterString === "UNDEF") {
            return this.thing.getUID().getId();
        }
        if (!masterString.includes(constants.sonosIdentifierString)) {
            return this.thing.getUID().getId();
        }
        return masterString;
    }

    getVolume() {
        var item = items.getItem(this.allThingItemNames[channelIds.volumeChannelId]);
        if(!item) return 0.0;
        var volume = parseFloat(item.state);
        return isNaN(volume) ? 0.0 : volume;
    }

    getZoneVolume() {
        var zoneVolume = parseFloat(items.getItem(this.zoneVolumeItemName).state);
        return isNaN(zoneVolume) ? 0.0 : zoneVolume;
    }
}

console.log("Sonos Modern Coordinator Rule: Starting...");
new SonosCoordinator();

@stevendp thanks - now the items are also generated for me again. but i still have some rendering issue. the players are shown below each other und are not in the swiper element as before.

but i noticed i have in general a problem with the slider element in openhab 5.1.1. it seems broken - having nothing todo with the sonos stuff. is the slider working for you? however this is then part of the widget code i guess and js is now working… but still before trying to fix the widget code i thing the oh-swiper should work again.

would be glad to know if this swiper is working for you.

i created this issue regarding the swiper as it also happens in the official openhab demo: oh-swiper not working in 5.1.1 · Issue #3782 · openhab/openhab-webui · GitHub

For me the swiper is working Mathias. No issues on that level.

@stevendp do you use openhab 5.1.1. ? the issue i opened on github is also happening in the official demo. so i guess there is some problem…

Yes, also using 5.1.1.
I adapted the widget as well to make it smaller. This is the widget code I use:

uid: sonos_coordinator_widget_v1_2_ultra_compact-v2
tags:
  - marketplace:132732
props:
  parameters:
    - description: Text for the title caption
      label: Title caption
      name: titleCaption
      required: false
      type: TEXT
    - description: Text for the artist caption
      label: Artist caption
      name: artistCaption
      required: false
      type: TEXT
    - description: Text for the album caption
      label: Album caption
      name: albumCaption
      required: false
      type: TEXT
    - description: Text for the volume controls caption
      label: Volume controls caption
      name: volumeControlsCaption
      required: false
      type: TEXT
    - description: Text for the group configuration caption
      label: Group configuration caption
      name: groupConfigurationCaption
      required: false
      type: TEXT
    - description: Text for the favorite selection popup caption
      label: Favorite popup selection caption
      name: favoriteSelectionCaption
      required: false
      type: TEXT
  parameterGroups: []
timestamp: Jan 9, 2026, 10:09:06 PM
component: f7-card
config:
  style:
    background: transparent
    box-shadow: none
    margin: 0
    padding: 0
slots:
  default:
    - component: oh-swiper
      config:
        navigation: true
        pagination: true
        style:
          width: 100%
      slots:
        slides:
          - component: oh-repeater
            config:
              for: sonosInfo
              fragment: true
              in: =JSON.parse(items['SonosCoordinatorProxyItem'].state)
              sourceType: array
            slots:
              default:
                - component: f7-swiper-slide
                  slots:
                    default:
                      - component: f7-card
                        config:
                          style:
                            background: white
                            border-radius: 12px
                            display: flex
                            flex-direction: column
                            height: 120px
                            margin: 2px
                            overflow: hidden
                            padding: 0px
                        slots:
                          default:
                            - component: div
                              config:
                                style:
                                  display: flex
                                  flex-direction: row
                                  height: 80px
                                  width: 100%
                              slots:
                                default:
                                  - component: div
                                    config:
                                      style:
                                        flex-shrink: 0
                                        height: 80px
                                        position: relative
                                        width: 80px
                                    slots:
                                      default:
                                        - component: oh-image
                                          config:
                                            item: =loop.sonosInfo.coverArtItemName
                                            style:
                                              border-bottom-right-radius: 12px
                                              height: 100%
                                              object-fit: cover
                                              width: 100%
                                            visible: =items[loop.sonosInfo.coverArtItemName].state !== 'UNDEF' &&
                                              items[loop.sonosInfo.coverArtItemName].state
                                              !== 'NULL'
                                  - component: div
                                    config:
                                      style:
                                        display: flex
                                        flex-direction: column
                                        flex-grow: 1
                                        justify-content: space-between
                                        overflow: hidden
                                        padding: 10px 15px 5px 5px
                                        position: relative
                                    slots:
                                      default:
                                        - component: Label
                                          config:
                                            style:
                                              color: "#aaa"
                                              font-size: 7px
                                              font-weight: 700
                                              letter-spacing: 0.5px
                                              max-width: 95%
                                              overflow: hidden
                                              position: absolute
                                              right: 5px
                                              text-align: right
                                              text-overflow: ellipsis
                                              text-transform: uppercase
                                              top: 3px
                                              white-space: nowrap
                                              z-index: 5
                                            text: =loop.sonosInfo.zoneNames
                                        - component: div
                                          config:
                                            style:
                                              padding-top: 3px
                                          slots:
                                            default:
                                              - component: Label
                                                config:
                                                  style:
                                                    color: "#333"
                                                    font-size: 14px
                                                    font-weight: 700
                                                    overflow: hidden
                                                    padding-right: 20px
                                                    text-overflow: ellipsis
                                                    white-space: nowrap
                                                  text: "=items[loop.sonosInfo.artistItemName].state.length > 1 ?
                                                    items[loop.sonosInfo.titelI\
                                                    temName].state :
                                                    items[loop.sonosInfo.trackI\
                                                    temName].state"
                                              - component: Label
                                                config:
                                                  style:
                                                    color: "#888"
                                                    font-size: 13px
                                                    margin-top: 2px
                                                    overflow: hidden
                                                    text-overflow: ellipsis
                                                    white-space: nowrap
                                                  text: "=items[loop.sonosInfo.artistItemName].state.length > 1 ?
                                                    items[loop.sonosInfo.artist\
                                                    ItemName].state : ''"
                                        - component: div
                                          config:
                                            style:
                                              align-items: center
                                              display: flex
                                              gap: 20px
                                              justify-content: flex-start
                                          slots:
                                            default:
                                              - component: oh-link
                                                config:
                                                  action: command
                                                  actionCommand: PREVIOUS
                                                  actionItem: =loop.sonosInfo.playerItemName
                                                  color: black
                                                  iconF7: backward_end_fill
                                                  iconSize: 16
                                              - component: oh-link
                                                config:
                                                  action: command
                                                  actionCommand: "=(items[loop.sonosInfo.playerItemName].state === 'PLAY') ?
                                                    'PAUSE' : 'PLAY'"
                                                  actionItem: =loop.sonosInfo.playerItemName
                                                  color: blue
                                                  iconF7: "=(items[loop.sonosInfo.playerItemName].state === 'PLAY') ?
                                                    'pause_circle_fill' :
                                                    'play_circle_fill'"
                                                  iconSize: 24
                                              - component: oh-link
                                                config:
                                                  action: command
                                                  actionCommand: NEXT
                                                  actionItem: =loop.sonosInfo.playerItemName
                                                  color: black
                                                  iconF7: forward_end_fill
                                                  iconSize: 16
                            - component: div
                              config:
                                style:
                                  align-items: center
                                  background: "#f9f9f9"
                                  display: grid
                                  grid-template-columns: 40px 1fr 120px
                                  height: 40px
                                  padding: 0 5px
                                  width: 100%
                              slots:
                                default:
                                  - component: div
                                    config:
                                      style:
                                        display: flex
                                        justify-content: center
                                    slots:
                                      default:
                                        - component: oh-link
                                          config:
                                            action: command
                                            actionCommand: "=items[loop.sonosInfo.zoneMuteItemName].state == 'ON' ? 'OFF' :
                                              'ON'"
                                            actionItem: =loop.sonosInfo.zoneMuteItemName
                                            iconF7: "=items[loop.sonosInfo.zoneMuteItemName].state == 'OFF' ?
                                              'speaker_3_fill' :
                                              'speaker_slash_fill' "
                                            style:
                                              color: "#666"
                                  - component: oh-slider
                                    config:
                                      item: =loop.sonosInfo.zoneVolumeItemName
                                      releaseOnly: true
                                      style:
                                        padding-left: 5px
                                        padding-right: 5px
                                        width: 90%
                                      unit: "%"
                                  - component: div
                                    config:
                                      style:
                                        display: flex
                                        gap: 15px
                                        justify-content: flex-end
                                        padding-right: 15px
                                    slots:
                                      default:
                                        - component: oh-link
                                          config:
                                            action: variable
                                            actionVariable: =loop.sonosInfo.id + '_volume'
                                            actionVariableValue: =loop.sonosInfo
                                            iconF7: speaker_3
                                            style:
                                              color: "#666"
                                              font-size: 20px
                                          slots:
                                            default:
                                              - component: f7-popup
                                                config:
                                                  closeByBackdropClick: false
                                                  opened: =vars[loop.sonosInfo.id + '_volume'] !== undefined
                                                  style:
                                                    height: auto
                                                slots:
                                                  default:
                                                    - component: f7-card
                                                      config:
                                                        title: "=props.volumeControlsCaption === undefined ? 'Volume controls' :
                                                          props.volumeControlsC\
                                                          aption"
                                                      slots:
                                                        default:
                                                          - component: oh-repeater
                                                            config:
                                                              for: volumeItem
                                                              fragment: true
                                                              in: =vars[loop.sonosInfo.id + '_volume'].volumeInformation
                                                              sourceType: array
                                                            slots:
                                                              default:
                                                                - component: f7-row
                                                                  slots:
                                                                    default:
                                                                      - component: f7-col
                                                                        config:
                                                                          class: justify-content-center
                                                                          style:
                                                                            width: 33%
                                                                        slots:
                                                                          default:
                                                                            - component: Label
                                                                              config:
                                                                                class: margin
                                                                                text: =items[loop.volumeItem.zoneItemName].state
                                                                      - component: f7-col
                                                                        config:
                                                                          class: display-flex justify-content-center align-items-center
                                                                          style:
                                                                            width: 66%
                                                                        slots:
                                                                          default:
                                                                            - component: f7-icon
                                                                              config:
                                                                                class: margin-horizontal
                                                                                f7: "=items[loop.volumeItem.muteItemName].state == 'OFF' ? 'speaker_3' :
                                                                                  'speaker_slash' "
                                                                                size: 30
                                                                              slots:
                                                                                default:
                                                                                  - component: oh-button
                                                                                    config:
                                                                                      action: command
                                                                                      actionCommand: "=items[loop.volumeItem.muteItemName].state == 'ON' ? 'OFF' :
                                                                                        'ON'"
                                                                                      actionItem: =loop.volumeItem.muteItemName
                                                                                      style:
                                                                                        height: 100%
                                                                                        position: absolute
                                                                                        top: 0px
                                                                                        width: 100%
                                                                            - component: oh-slider
                                                                              config:
                                                                                class: margin
                                                                                item: =loop.volumeItem.volumeItemName
                                                                                label: true
                                                                                releaseOnly: true
                                                                                unit: "%"
                                                          - component: oh-button
                                                            config:
                                                              clearVariable: =loop.sonosInfo.groupedThingDeleteVars
                                                              popupClose: true
                                                              text: OK
                                        - component: oh-link
                                          config:
                                            action: variable
                                            actionVariable: =loop.sonosInfo.id + '_group'
                                            actionVariableValue: =loop.sonosInfo
                                            iconF7: link
                                            style:
                                              color: "#666"
                                              font-size: 20px
                                          slots:
                                            default:
                                              - component: f7-popup
                                                config:
                                                  closeByBackdropClick: false
                                                  opened: =vars[loop.sonosInfo.id + '_group'] !== undefined
                                                  style:
                                                    height: auto
                                                    width: 300px
                                                slots:
                                                  default:
                                                    - component: f7-card
                                                      config:
                                                        title: Groepen
                                                      slots:
                                                        default:
                                                          - component: oh-repeater
                                                            config:
                                                              for: groupInfo
                                                              fragment: true
                                                              in: =vars[loop.sonosInfo.id + '_group'].groupedItemsInformation
                                                              sourceType: array
                                                            slots:
                                                              default:
                                                                - component: f7-row
                                                                  config:
                                                                    style:
                                                                      align-items: center
                                                                      padding: 5px
                                                                  slots:
                                                                    default:
                                                                      - component: oh-button
                                                                        config:
                                                                          action: variable
                                                                          actionVariable: =loop.groupInfo.thingUid
                                                                          actionVariableValue: "=vars[loop.groupInfo.thingUid] === undefined ?
                                                                            !loop.groupInfo.isI\
                                                                            nGroup :
                                                                            !vars[loop.groupInf\
                                                                            o.thingUid]"
                                                                          iconF7: "=vars[loop.groupInfo.thingUid] === undefined ?
                                                                            (loop.groupInfo.isInGroup
                                                                            ? 'checkmark_square'
                                                                            : 'square') :
                                                                            (vars[loop.groupInf\
                                                                            o.thingUid] ?
                                                                            'checkmark_square' :
                                                                            'square')"
                                                                      - component: Label
                                                                        config:
                                                                          style:
                                                                            margin-left: 10px
                                                                          text: =loop.groupInfo.name
                                                          - component: oh-button
                                                            config:
                                                              action: command
                                                              actionCommand: =JSON.stringify(vars)
                                                              actionItem: =loop.sonosInfo.groupSwitcherItemName
                                                              class: margin
                                                              clearVariable: =loop.sonosInfo.groupedThingDeleteVars
                                                              popupClose: true
                                                              text: OK
                                        - component: oh-link
                                          config:
                                            action: variable
                                            actionVariable: =loop.sonosInfo.id + '_favorite'
                                            actionVariableValue: =loop.sonosInfo
                                            iconF7: star
                                            style:
                                              color: "#666"
                                              font-size: 20px
                                          slots:
                                            default:
                                              - component: f7-popup
                                                config:
                                                  closeByBackdropClick: false
                                                  opened: =vars[loop.sonosInfo.id + '_favorite'] !== undefined
                                                  style:
                                                    height: auto
                                                slots:
                                                  default:
                                                    - component: f7-card
                                                      config:
                                                        style:
                                                          margin: 0
                                                          padding: 0
                                                      slots:
                                                        default:
                                                          - component: div
                                                            config:
                                                              style:
                                                                align-items: center
                                                                background: "#f9f9f9"
                                                                border-bottom: 1px solid
                                                                display: flex
                                                                justify-content: space-between
                                                                padding: 10px 15px
                                                            slots:
                                                              default:
                                                                - component: Label
                                                                  config:
                                                                    style:
                                                                      color: "#666"
                                                                      font-size: 16px
                                                                      font-weight: bold
                                                                    text: Favorieten
                                                                - component: oh-link
                                                                  config:
                                                                    action: variable
                                                                    actionVariable: =loop.sonosInfo.id + '_favorite'
                                                                    iconF7: multiply
                                                                    popupClose: true
                                                                    style:
                                                                      color: red
                                                                      font-size: 22px
                                                          - component: div
                                                            config:
                                                              style:
                                                                max-height: 350px
                                                                overflow-y: auto
                                                            slots:
                                                              default:
                                                                - component: oh-repeater
                                                                  config:
                                                                    for: currentFavorit
                                                                    fragment: true
                                                                    itemOptions: =loop.sonosInfo.favoriteItemName
                                                                    sourceType: itemCommandOptions
                                                                  slots:
                                                                    default:
                                                                      - component: oh-button
                                                                        config:
                                                                          action: command
                                                                          actionCommand: =loop.currentFavorit.command
                                                                          actionItem: =loop.sonosInfo.favoriteItemName
                                                                          actionVariable: =loop.sonosInfo.id + '_favorite'
                                                                          popupClose: true
                                                                          style:
                                                                            border-bottom: 1px solid
                                                                            height: 40px
                                                                            padding-left: 20px
                                                                            text-align: left
                                                                            width: 100%
                                                                          text: =loop.currentFavorit.label
                                                          - component: oh-button
                                                            config:
                                                              actionVariable: =loop.sonosInfo.id + '_favorite'
                                                              clearVariable: =loop.sonosInfo.groupedThingDeleteVars
                                                              popupClose: true
                                                              style:
                                                                color: gray
                                                                margin: 10px
                                                              text: Sluiten

@stevendp thanks for the widget code. for me with this too the two players are below each other and not in the swiper. looks like this for me. so i wait what happens with the oh-swiper bug. thanks anyways.