diff --git a/debian/control b/debian/control index 8f0b01db..9058a0ba 100644 --- a/debian/control +++ b/debian/control @@ -705,6 +705,22 @@ Description: nymea.io plugin for senic This package will install the nymea.io plugin for senic +Package: nymea-plugin-sonos +Architecture: any +Depends: ${shlibs:Depends}, + ${misc:Depends}, + nymea-plugins-translations, +Replaces: guh-plugin-sonos +Description: nymea.io plugin for Sonos smart speakers + The nymea daemon is a plugin based IoT (Internet of Things) server. The + server works like a translator for devices, things and services and + allows them to interact. + With the powerful rule engine you are able to connect any device available + in the system and create individual scenes and behaviors for your environment. + . + This package will install the nymea.io plugin for Sonos smart speakers + + Package: nymea-plugin-snapd Architecture: any Depends: ${shlibs:Depends}, @@ -821,6 +837,7 @@ Depends: nymea-plugin-awattar, nymea-plugin-wemo, nymea-plugin-elgato, nymea-plugin-senic, + nymea-plugin-sonos, nymea-plugin-keba, Replaces: guh-plugins Description: Plugins for nymea IoT server - the default plugin collection diff --git a/debian/nymea-plugin-sonos.install.in b/debian/nymea-plugin-sonos.install.in new file mode 100644 index 00000000..b5a16a91 --- /dev/null +++ b/debian/nymea-plugin-sonos.install.in @@ -0,0 +1 @@ +usr/lib/@DEB_HOST_MULTIARCH@/nymea/plugins/libnymea_devicepluginsonos.so diff --git a/nymea-plugins.pro b/nymea-plugins.pro index c5d3f1ae..4954e159 100644 --- a/nymea-plugins.pro +++ b/nymea-plugins.pro @@ -41,6 +41,7 @@ PLUGIN_DIRS = \ serialportcommander \ simulation \ snapd \ + sonos \ tasmota \ tcpcommander \ texasinstruments \ diff --git a/sonos/devicepluginsonos.cpp b/sonos/devicepluginsonos.cpp new file mode 100644 index 00000000..04b08355 --- /dev/null +++ b/sonos/devicepluginsonos.cpp @@ -0,0 +1,653 @@ +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * * + * Copyright (C) 2019 Bernhard Trinnes . * + * * + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#include "devicepluginsonos.h" +#include "devices/device.h" +#include "network/networkaccessmanager.h" +#include "plugininfo.h" +#include "types/mediabrowseritem.h" + + +#include +#include +#include +#include + +DevicePluginSonos::DevicePluginSonos() +{ +} + + +DevicePluginSonos::~DevicePluginSonos() +{ + if (m_pluginTimer5sec) + hardwareManager()->pluginTimerManager()->unregisterTimer(m_pluginTimer5sec); + if (m_pluginTimer60sec) + hardwareManager()->pluginTimerManager()->unregisterTimer(m_pluginTimer60sec); +} + + +void DevicePluginSonos::setupDevice(DeviceSetupInfo *info) +{ + Device *device = info->device(); + + if (device->deviceClassId() == sonosConnectionDeviceClassId) { + Sonos *sonos; + if (m_setupSonosConnections.keys().contains(device->id())) { + //Fresh device setup, has already a fresh access token + qCDebug(dcSonos()) << "Sonos OAuth setup complete"; + sonos = m_setupSonosConnections.take(device->id()); + connect(sonos, &Sonos::connectionChanged, this, &DevicePluginSonos::onConnectionChanged); + connect(sonos, &Sonos::householdIdsReceived, this, &DevicePluginSonos::onHouseholdIdsReceived); + connect(sonos, &Sonos::groupsReceived, this, &DevicePluginSonos::onGroupsReceived); + connect(sonos, &Sonos::playBackStatusReceived, this, &DevicePluginSonos::onPlayBackStatusReceived); + connect(sonos, &Sonos::metadataStatusReceived, this, &DevicePluginSonos::onMetadataStatusReceived); + connect(sonos, &Sonos::volumeReceived, this, &DevicePluginSonos::onVolumeReceived); + connect(sonos, &Sonos::actionExecuted, this, &DevicePluginSonos::onActionExecuted); + connect(sonos, &Sonos::authenticationStatusChanged, this, &DevicePluginSonos::onAuthenticationStatusChanged); + + connect(sonos, &Sonos::authenticationStatusChanged, info, [info](bool authenticated){ + if (authenticated) { + info->finish(Device::DeviceErrorNoError); + } else { + info->finish(Device::DeviceErrorAuthenticationFailure); + } + }); + + m_sonosConnections.insert(device, sonos); + return info->finish(Device::DeviceErrorNoError); + } else { + //device loaded from the device database, needs a new access token; + pluginStorage()->beginGroup(device->id().toString()); + QByteArray refreshToken = pluginStorage()->value("refresh_token").toByteArray(); + pluginStorage()->endGroup(); + + if (refreshToken.isEmpty()) { + info->finish(Device::DeviceErrorAuthenticationFailure); + return; + } + + sonos = new Sonos(hardwareManager()->networkManager(), "0a8f6d44-d9d1-4474-bcfa-cfb41f8b66e8", "3095ce48-0c5d-47ce-a1f4-6005c7b8fdb5", this); + connect(sonos, &Sonos::connectionChanged, this, &DevicePluginSonos::onConnectionChanged); + connect(sonos, &Sonos::householdIdsReceived, this, &DevicePluginSonos::onHouseholdIdsReceived); + connect(sonos, &Sonos::groupsReceived, this, &DevicePluginSonos::onGroupsReceived); + connect(sonos, &Sonos::playBackStatusReceived, this, &DevicePluginSonos::onPlayBackStatusReceived); + connect(sonos, &Sonos::metadataStatusReceived, this, &DevicePluginSonos::onMetadataStatusReceived); + connect(sonos, &Sonos::volumeReceived, this, &DevicePluginSonos::onVolumeReceived); + connect(sonos, &Sonos::actionExecuted, this, &DevicePluginSonos::onActionExecuted); + connect(sonos, &Sonos::authenticationStatusChanged, this, &DevicePluginSonos::onAuthenticationStatusChanged); + connect(sonos, &Sonos::favoritesReceived, this, &DevicePluginSonos::onFavoritesReceived); + sonos->getAccessTokenFromRefreshToken(refreshToken); + m_sonosConnections.insert(device, sonos); + return info->finish(Device::DeviceErrorNoError); + } + } + + if (device->deviceClassId() == sonosGroupDeviceClassId) { + return info->finish(Device::DeviceErrorNoError); + } + + qCWarning(dcSonos()) << "Unhandled device class id in setupDevice" << device->deviceClassId(); +} + +void DevicePluginSonos::startPairing(DevicePairingInfo *info) +{ + if (info->deviceClassId() == sonosConnectionDeviceClassId) { + + Sonos *sonos = new Sonos(hardwareManager()->networkManager(), "0a8f6d44-d9d1-4474-bcfa-cfb41f8b66e8", "3095ce48-0c5d-47ce-a1f4-6005c7b8fdb5", this); + QUrl url = sonos->getLoginUrl(QUrl("https://127.0.0.1:8888")); + qCDebug(dcSonos()) << "Sonos url:" << url; + info->setOAuthUrl(url); + info->finish(Device::DeviceErrorNoError); + m_setupSonosConnections.insert(info->deviceId(), sonos); + return; + } + + qCWarning(dcSonos()) << "Unhandled pairing metod!"; + info->finish(Device::DeviceErrorCreationMethodNotSupported); +} + +void DevicePluginSonos::confirmPairing(DevicePairingInfo *info, const QString &username, const QString &secret) +{ + Q_UNUSED(username) + + if (info->deviceClassId() == sonosConnectionDeviceClassId) { + qCDebug(dcSonos()) << "Redirect url is" << secret; + QUrl url(secret); + QUrlQuery query(url); + QByteArray authorizationCode = query.queryItemValue("code").toLocal8Bit(); + QByteArray state = query.queryItemValue("state").toLocal8Bit(); + //TODO evaluate state if it equals the given state + + Sonos *sonos = m_setupSonosConnections.value(info->deviceId()); + + if (!sonos) { + qWarning(dcSonos()) << "No sonos connection found for device:" << info->deviceName(); + m_setupSonosConnections.remove(info->deviceId()); + sonos->deleteLater(); + info->finish(Device::DeviceErrorHardwareFailure); + return; + } + sonos->getAccessTokenFromAuthorizationCode(authorizationCode); + connect(sonos, &Sonos::authenticationStatusChanged, info, [this, info, sonos](bool authenticated){ + if(!authenticated) { + qWarning(dcSonos()) << "Authentication process failed" << info->deviceName(); + m_setupSonosConnections.remove(info->deviceId()); + sonos->deleteLater(); + info->finish(Device::DeviceErrorSetupFailed, QT_TR_NOOP("Authentication failed. Please try again.")); + return; + } + QByteArray accessToken = sonos->accessToken(); + QByteArray refreshToken = sonos->refreshToken(); + qCDebug(dcSonos()) << "Token:" << accessToken << refreshToken; + + pluginStorage()->beginGroup(info->deviceId().toString()); + pluginStorage()->setValue("refresh_token", refreshToken); + pluginStorage()->endGroup(); + + info->finish(Device::DeviceErrorNoError); + }); + return; + } + qCWarning(dcSonos()) << "Invalid deviceclassId -> no pairing possible with this device"; + info->finish(Device::DeviceErrorDeviceClassNotFound); +} + +void DevicePluginSonos::postSetupDevice(Device *device) +{ + if (!m_pluginTimer5sec) { + m_pluginTimer5sec = hardwareManager()->pluginTimerManager()->registerTimer(5); + connect(m_pluginTimer5sec, &PluginTimer::timeout, this, [this]() { + + foreach (Device *connectionDevice, myDevices().filterByDeviceClassId(sonosConnectionDeviceClassId)) { + Sonos *sonos = m_sonosConnections.value(connectionDevice); + if (!sonos) { + qWarning(dcSonos()) << "No sonos connection found to device" << connectionDevice->name(); + continue; + } + foreach (Device *groupDevice, myDevices().filterByParentDeviceId(connectionDevice->id())) { + if (groupDevice->deviceClassId() == sonosGroupDeviceClassId) { + //get playback status of each group + QString groupId = groupDevice->paramValue(sonosGroupDeviceGroupIdParamTypeId).toString(); + sonos->getGroupPlaybackStatus(groupId); + sonos->getGroupMetadataStatus(groupId); + sonos->getGroupVolume(groupId); + } + } + } + }); + } + + if (!m_pluginTimer60sec) { + m_pluginTimer60sec = hardwareManager()->pluginTimerManager()->registerTimer(60); + connect(m_pluginTimer60sec, &PluginTimer::timeout, this, [this]() { + foreach (Device *device, myDevices().filterByDeviceClassId(sonosConnectionDeviceClassId)) { + Sonos *sonos = m_sonosConnections.value(device); + if (!sonos) { + qWarning(dcSonos()) << "No sonos connection found to device" << device->name(); + continue; + } + //get groups for each household in order to add or remove groups + sonos->getHouseholds(); + } + }); + } + + if (device->deviceClassId() == sonosConnectionDeviceClassId) { + Sonos *sonos = m_sonosConnections.value(device); + sonos->getHouseholds(); + } + + if (device->deviceClassId() == sonosGroupDeviceClassId) { + Device *parentDevice = myDevices().findById(device->parentId()); + Sonos *sonos = m_sonosConnections.value(parentDevice); + if (!sonos) { + return; + } + QString groupId = device->paramValue(sonosGroupDeviceGroupIdParamTypeId).toString(); + sonos->getGroupPlaybackStatus(groupId); + sonos->getGroupMetadataStatus(groupId); + sonos->getGroupVolume(groupId); + } +} + + +void DevicePluginSonos::startMonitoringAutoDevices() +{ + foreach (Device *device, myDevices()) { + if (device->deviceClassId() == sonosGroupDeviceClassId) { + return; + } + } +} + +void DevicePluginSonos::deviceRemoved(Device *device) +{ + qCDebug(dcSonos) << "Delete " << device->name(); + if (myDevices().empty()) { + hardwareManager()->pluginTimerManager()->unregisterTimer(m_pluginTimer5sec); + hardwareManager()->pluginTimerManager()->unregisterTimer(m_pluginTimer60sec); + m_pluginTimer5sec = nullptr; + m_pluginTimer60sec = nullptr; + } +} + + +void DevicePluginSonos::executeAction(DeviceActionInfo *info) +{ + Device *device = info->device(); + Action action = info->action(); + + if (device->deviceClassId() == sonosGroupDeviceClassId) { + Sonos *sonos = m_sonosConnections.value(myDevices().findById(device->parentId())); + QString groupId = device->paramValue(sonosGroupDeviceGroupIdParamTypeId).toString(); + + if (!sonos) { + qWarning(dcSonos()) << "Action cannot be executed: Sonos connection not available"; + return info->finish(Device::DeviceErrorHardwareNotAvailable, QT_TR_NOOP("Sonos device is not available.")); + } + + if (action.actionTypeId() == sonosGroupPlayActionTypeId) { + m_pendingActions.insert(sonos->groupPlay(groupId), QPointer(info)); + return; + } + + if (action.actionTypeId() == sonosGroupShuffleActionTypeId) { + bool shuffle = action.param(sonosGroupShuffleActionShuffleParamTypeId).value().toBool(); + m_pendingActions.insert(sonos->groupSetShuffle(groupId, shuffle), QPointer(info)); + return; + } + + if (action.actionTypeId() == sonosGroupRepeatActionTypeId) { + if (action.param(sonosGroupRepeatActionRepeatParamTypeId).value().toString() == "None") { + m_pendingActions.insert(sonos->groupSetRepeat(groupId, Sonos::RepeatModeNone), QPointer(info)); + } else if (action.param(sonosGroupRepeatActionRepeatParamTypeId).value().toString() == "One") { + m_pendingActions.insert(sonos->groupSetRepeat(groupId, Sonos::RepeatModeOne), QPointer(info)); + } else if (action.param(sonosGroupRepeatActionRepeatParamTypeId).value().toString() == "All") { + m_pendingActions.insert(sonos->groupSetRepeat(groupId, Sonos::RepeatModeAll), QPointer(info)); + } else { + return info->finish(Device::DeviceErrorHardwareFailure); + } + return; + } + + if (action.actionTypeId() == sonosGroupPauseActionTypeId) { + m_pendingActions.insert(sonos->groupPause(groupId), QPointer(info)); + return; + } + + if (action.actionTypeId() == sonosGroupStopActionTypeId) { + m_pendingActions.insert(sonos->groupPause(groupId), QPointer(info)); + return; + } + + if (action.actionTypeId() == sonosGroupMuteActionTypeId) { + bool mute = action.param(sonosGroupMuteActionMuteParamTypeId).value().toBool(); + m_pendingActions.insert(sonos->setGroupMute(groupId, mute), QPointer(info)); + return; + } + + + if (action.actionTypeId() == sonosGroupVolumeActionTypeId) { + int volume = action.param(sonosGroupVolumeActionVolumeParamTypeId).value().toInt(); + m_pendingActions.insert(sonos->setGroupVolume(groupId, volume), QPointer(info)); + return; + } + + if (action.actionTypeId() == sonosGroupSkipNextActionTypeId) { + m_pendingActions.insert(sonos->groupSkipToNextTrack(groupId), QPointer(info)); + return; + } + + if (action.actionTypeId() == sonosGroupSkipBackActionTypeId) { + m_pendingActions.insert(sonos->groupSkipToPreviousTrack(groupId), QPointer(info)); + return; + } + + if (action.actionTypeId() == sonosGroupPlaybackStatusActionTypeId) { + QString playbackStatus = action.param(sonosGroupPlaybackStatusActionPlaybackStatusParamTypeId).value().toString(); + if (playbackStatus == "Playing") { + m_pendingActions.insert(sonos->groupPlay(groupId), QPointer(info)); + } else if(playbackStatus == "Stopped") { + m_pendingActions.insert(sonos->groupPause(groupId), QPointer(info)); + } else if(playbackStatus == "Paused") { + m_pendingActions.insert(sonos->groupPause(groupId), QPointer(info)); + } + return; + } + return info->finish(Device::DeviceErrorActionTypeNotFound); + } + info->finish(Device::DeviceErrorDeviceClassNotFound); +} + +void DevicePluginSonos::browseDevice(BrowseResult *result) +{ + Device *parentDevice = myDevices().findById(result->device()->parentId()); + Sonos *sonosConnection = m_sonosConnections.value(parentDevice); + if (!sonosConnection) { + result->finish(Device::DeviceErrorHardwareNotAvailable); + return; + } + + qDebug(dcSonos()) << "Browse Device" << result->itemId(); + QString householdId = result->device()->paramValue(sonosGroupDeviceHouseholdIdParamTypeId).toString(); + if (result->itemId().isEmpty()){ + BrowserItem item; + item.setId(m_browseFavoritesPrefix); + item.setIcon(BrowserItem::BrowserIconFavorites); + item.setExecutable(false); + item.setBrowsable(true); + item.setDisplayName("Favorites"); + result->addItem(item); + result->finish(Device::DeviceErrorNoError); + } else if (result->itemId() == m_browseFavoritesPrefix) { + QUuid requestId = sonosConnection->getFavorites(householdId); + m_pendingBrowseResult.insert(requestId, result); + connect(result, &BrowseResult::aborted,[requestId, this](){m_pendingBrowseResult.remove(requestId);}); + } else { + //TODO add media browsing + result->finish(Device::DeviceErrorItemNotFound); + } +} + +void DevicePluginSonos::browserItem(BrowserItemResult *result) +{ + Device *parentDevice = myDevices().findById(result->device()->parentId()); + Sonos *sonosConnection = m_sonosConnections.value(parentDevice); + if (!sonosConnection) { + result->finish(Device::DeviceErrorHardwareNotAvailable); + return; + } + + qCDebug(dcSonos()) << "Browser Item" << result->itemId(); + QString householdId = result->device()->paramValue(sonosGroupDeviceHouseholdIdParamTypeId).toString(); + if (result->itemId().startsWith(m_browseFavoritesPrefix)) { + QUuid requestId = sonosConnection->getFavorites(householdId); + m_pendingBrowserItemResult.insert(requestId, result); + connect(result, &BrowserItemResult::aborted, [requestId, this](){m_pendingBrowserItemResult.remove(requestId);}); + } else { + //TODO add media browsing + result->finish(Device::DeviceErrorItemNotFound); + } +} + +void DevicePluginSonos::executeBrowserItem(BrowserActionInfo *info) +{ + Device *parentDevice = myDevices().findById(info->device()->parentId()); + Sonos *sonosConnection = m_sonosConnections.value(parentDevice); + if (!sonosConnection) + return; + + QString groupId = info->device()->paramValue(sonosGroupDeviceGroupIdParamTypeId).toString(); + if (info->browserAction().itemId().startsWith(m_browseFavoritesPrefix)) { + QString favoriteId = info->browserAction().itemId().remove(m_browseFavoritesPrefix); + favoriteId.remove('/'); + QUuid requestId = sonosConnection->loadFavorite(groupId, favoriteId); + m_pendingBrowserExecution.insert(requestId, info); + connect(info, &BrowserActionInfo::aborted,[requestId, this](){m_pendingBrowserExecution.remove(requestId);}); + } else { + //TODO add media browsing + info->finish(Device::DeviceErrorItemNotFound); + } +} + +void DevicePluginSonos::onConnectionChanged(bool connected) +{ + Sonos *sonos = static_cast(sender()); + Device *device = m_sonosConnections.key(sonos); + if (!device) + return; + device->setStateValue(sonosConnectionConnectedStateTypeId, connected); + + foreach (Device *groupDevice, myDevices().filterByParentDeviceId(device->id())) { + groupDevice->setStateValue(sonosGroupConnectedStateTypeId, connected); + } +} + +void DevicePluginSonos::onAuthenticationStatusChanged(bool authenticated) +{ + Sonos *sonosConnection = static_cast(sender()); + Device *device = m_sonosConnections.key(sonosConnection); + if (!device) + return; + + device->setStateValue(sonosConnectionLoggedInStateTypeId, authenticated); + if (!authenticated) { + //refresh access token needs to be refreshed + pluginStorage()->beginGroup(device->id().toString()); + QByteArray refreshToken = pluginStorage()->value("refresh_token").toByteArray(); + pluginStorage()->endGroup(); + sonosConnection->getAccessTokenFromRefreshToken(refreshToken); + } +} + +void DevicePluginSonos::onHouseholdIdsReceived(QList householdIds) +{ + Sonos *sonos = static_cast(sender()); + foreach(QString householdId, householdIds) { + sonos->getGroups(householdId); + sonos->getPlaylists(householdId); + } +} + +void DevicePluginSonos::onFavoritesReceived(QUuid requestId, const QString &householdId, QList favorites) +{ + Q_UNUSED(householdId) + + if (m_pendingBrowseResult.contains(requestId)) { + BrowseResult *result = m_pendingBrowseResult.take(requestId); + if (!result) + return; + + foreach(Sonos::FavoriteObject favorite, favorites) { + MediaBrowserItem item; + item.setId(result->itemId() + "/" + favorite.id); + item.setExecutable(true); + item.setBrowsable(false); + if (!favorite.imageUrl.isEmpty()) { + item.setThumbnail(favorite.imageUrl); + } else { + item.setIcon(BrowserItem::BrowserIconFavorites); + } + item.setDisplayName(favorite.name); + item.setDescription(favorite.description); + result->addItem(item); + qDebug(dcSonos()) << "Favorite: " << favorite.name << favorite.description; + } + result->finish(Device::DeviceErrorNoError); + + } else if (m_pendingBrowserItemResult.contains(requestId)) { + BrowserItemResult *result = m_pendingBrowserItemResult.take(requestId); + if (!result) + return; + QString favoriteId = result->itemId().remove(m_browseFavoritesPrefix); + favoriteId.remove('/'); + + foreach(Sonos::FavoriteObject favorite, favorites) { + if (favorite.id == favoriteId) { + MediaBrowserItem item; + item.setId(result->itemId()); + item.setExecutable(true); + item.setBrowsable(false); + if (!favorite.imageUrl.isEmpty()) { + item.setThumbnail(favorite.imageUrl); + } else { + item.setIcon(BrowserItem::BrowserIconFavorites); + } + item.setDisplayName(favorite.name); + item.setDescription(favorite.description); + result->finish(item); + return; + } + } + } +} + +void DevicePluginSonos::onPlaylistsReceived(const QString &householdId, QList playlists) +{ + Sonos *sonos = static_cast(sender()); + + foreach(Sonos::PlaylistObject playlist, playlists) { + qDebug(dcSonos()) << "Playlist: " << playlist.name << playlist.type << playlist.trackCount; + sonos->getPlaylist(householdId, playlist.id); //Get the playlist details + } +} + +void DevicePluginSonos::onPlaylistSummaryReceived(const QString &householdId, Sonos::PlaylistSummaryObject playlistSummary) +{ + Q_UNUSED(householdId); + qDebug(dcSonos()) << "Playlist summary received: " << playlistSummary.name; + foreach(Sonos::PlaylistTrackObject track, playlistSummary.tracks) { + qDebug(dcSonos()) << "---- Track: " << track.name << track.album << track.artist; + } +} + +void DevicePluginSonos::onGroupsReceived(const QString &householdId, QList groupObjects) +{ + Sonos *sonos = static_cast(sender()); + Device *parentDevice = m_sonosConnections.key(sonos); + if (!parentDevice) + return; + + QList deviceDescriptors; + foreach(Sonos::GroupObject groupObject, groupObjects) { + Device *groupDevice = myDevices().findByParams(ParamList() << Param(sonosGroupDeviceGroupIdParamTypeId, groupObject.groupId)); + if (groupDevice) { + if (groupDevice->name() != groupObject.displayName) { + qDebug(dcSonos()) << "Updating group name" << groupDevice->name() << "to" << groupObject.displayName; + groupDevice->setName(groupObject.displayName); + } + } else { + //new device, add to the system + DeviceDescriptor deviceDescriptor(sonosGroupDeviceClassId, groupObject.displayName, "Sonos Group", parentDevice->id()); + ParamList params; + params.append(Param(sonosGroupDeviceGroupIdParamTypeId, groupObject.groupId)); + params.append(Param(sonosGroupDeviceHouseholdIdParamTypeId, householdId)); + deviceDescriptor.setParams(params); + deviceDescriptors.append(deviceDescriptor); + } + } + + if (!deviceDescriptors.isEmpty()) + emit autoDevicesAppeared(deviceDescriptors); + + //delete auto devices + foreach(Device *groupDevice, myDevices().filterByParentDeviceId(parentDevice->id())) { + QString groupId = groupDevice->paramValue(sonosGroupDeviceGroupIdParamTypeId).toString(); + bool deviceRemoved = true; + foreach (Sonos::GroupObject groupObject, groupObjects) { + if(groupObject.groupId == groupId) { + deviceRemoved = false; + } + } + if (deviceRemoved) { + emit autoDeviceDisappeared(groupDevice->id()); + } + } + +} + +void DevicePluginSonos::onPlayBackStatusReceived(const QString &groupId, Sonos::PlayBackObject playBack) +{ + Device *device = myDevices().findByParams(ParamList() << Param(sonosGroupDeviceGroupIdParamTypeId, groupId)); + if (!device) + return; + + device->setStateValue(sonosGroupShuffleStateTypeId, playBack.playMode.shuffle); + + if (playBack.playMode.repeatOne) { + device->setStateValue(sonosGroupRepeatStateTypeId, "One"); + } else if (playBack.playMode.repeat) { + device->setStateValue(sonosGroupRepeatStateTypeId, "All"); + } else { + device->setStateValue(sonosGroupRepeatStateTypeId, "None"); + } + + switch (playBack.playbackState) { + case Sonos::PlayBackStateIdle: + device->setStateValue(sonosGroupPlaybackStatusStateTypeId, "Stopped"); + break; + case Sonos::PlayBackStatePause: + device->setStateValue(sonosGroupPlaybackStatusStateTypeId, "Paused"); + break; + case Sonos::PlayBackStateBuffering: + case Sonos::PlayBackStatePlaying: + device->setStateValue(sonosGroupPlaybackStatusStateTypeId, "Playing"); + break; + } +} + +void DevicePluginSonos::onMetadataStatusReceived(const QString &groupId, Sonos::MetadataStatus metaDataStatus) +{ + Device *device = myDevices().findByParams(ParamList() << Param(sonosGroupDeviceGroupIdParamTypeId, groupId)); + if (!device) + return; + + device->setStateValue(sonosGroupTitleStateTypeId, metaDataStatus.currentItem.track.name); + device->setStateValue(sonosGroupArtistStateTypeId, metaDataStatus.currentItem.track.artist.name); + device->setStateValue(sonosGroupCollectionStateTypeId, metaDataStatus.currentItem.track.album.name); + if (!metaDataStatus.currentItem.track.imageUrl.isEmpty()){ + device->setStateValue(sonosGroupArtworkStateTypeId, metaDataStatus.currentItem.track.imageUrl); + } else { + device->setStateValue(sonosGroupArtworkStateTypeId, metaDataStatus.container.imageUrl); + } +} + +void DevicePluginSonos::onVolumeReceived(const QString &groupId, Sonos::VolumeObject groupVolume) +{ + Device *device = myDevices().findByParams(ParamList() << Param(sonosGroupDeviceGroupIdParamTypeId, groupId)); + if (!device) + return; + + device->setStateValue(sonosGroupVolumeStateTypeId, groupVolume.volume); + device->setStateValue(sonosGroupMuteStateTypeId, groupVolume.muted); +} + +void DevicePluginSonos::onActionExecuted(QUuid sonosActionId, bool success) +{ + if (m_pendingActions.contains(sonosActionId)) { + QPointer info = m_pendingActions.value(sonosActionId); + if (info.isNull()) { + qCWarning(dcSonos()) << "DeviceActionInfo has disappeared. Did it time out?"; + return; + } + if (success) { + info->finish(Device::DeviceErrorNoError); + } else { + info->finish(Device::DeviceErrorHardwareFailure); + } + } + + if (m_pendingBrowserExecution.contains(sonosActionId)) { + BrowserActionInfo *info = m_pendingBrowserExecution.value(sonosActionId); + if (!info) { + qCWarning(dcSonos()) << "BrowseActionInfo has disappeared. Did it time out?"; + return; + } + + if (success) { + info->finish(Device::DeviceErrorNoError); + } else { + info->finish(Device::DeviceErrorHardwareFailure); + } + } +} diff --git a/sonos/devicepluginsonos.h b/sonos/devicepluginsonos.h new file mode 100644 index 00000000..a23cf3f7 --- /dev/null +++ b/sonos/devicepluginsonos.h @@ -0,0 +1,90 @@ +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * * + * Copyright (C) 2019 Bernhard Trinnes . * + * * + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#ifndef DEVICEPLUGINSONOS_H +#define DEVICEPLUGINSONOS_H + +#include "devices/deviceplugin.h" +#include "plugintimer.h" +#include "sonos.h" + +#include +#include + +class DevicePluginSonos : public DevicePlugin +{ + Q_OBJECT + Q_PLUGIN_METADATA(IID "io.nymea.DevicePlugin" FILE "devicepluginsonos.json") + Q_INTERFACES(DevicePlugin) + +public: + explicit DevicePluginSonos(); + ~DevicePluginSonos() override; + + void setupDevice(DeviceSetupInfo *info) override; + void startPairing(DevicePairingInfo *info) override; + void confirmPairing(DevicePairingInfo *info, const QString &username, const QString &secret) override; + + void postSetupDevice(Device *device) override; + void startMonitoringAutoDevices() override; + void deviceRemoved(Device *device) override; + void executeAction(DeviceActionInfo *info) override; + + void browseDevice(BrowseResult *result) override; + void browserItem(BrowserItemResult *result) override; + void executeBrowserItem(BrowserActionInfo *info) override; + +private: + PluginTimer *m_pluginTimer5sec = nullptr; + PluginTimer *m_pluginTimer60sec = nullptr; + + QHash m_setupSonosConnections; + QHash m_sonosConnections; + QList m_householdIds; + + QByteArray m_sonosConnectionAccessToken; + QByteArray m_sonosConnectionRefreshToken; + + QHash > m_pendingActions; + QHash m_pendingBrowseResult; + QHash m_pendingBrowserItemResult; + QHash m_pendingBrowserExecution; + + const QString m_browseFavoritesPrefix = "/favorites"; + +private slots: + void onConnectionChanged(bool connected); + void onAuthenticationStatusChanged(bool authenticated); + + void onHouseholdIdsReceived(QList householdIds); + void onFavoritesReceived(QUuid requestId, const QString &householdId, QList favorites); + void onPlaylistsReceived(const QString &householdId, QList playlists); + void onPlaylistSummaryReceived(const QString &householdId, Sonos::PlaylistSummaryObject playlistSummary); + + void onGroupsReceived(const QString &householdId, QList groupIds); + void onPlayBackStatusReceived(const QString &groupId, Sonos::PlayBackObject playBack); + void onMetadataStatusReceived(const QString &groupId, Sonos::MetadataStatus metaDataStatus); + void onVolumeReceived(const QString &groupId, Sonos::VolumeObject groupVolume); + void onActionExecuted(QUuid actionId, bool success); +}; + +#endif // DEVICEPLUGINSONOS_H diff --git a/sonos/devicepluginsonos.json b/sonos/devicepluginsonos.json new file mode 100644 index 00000000..c357fd9c --- /dev/null +++ b/sonos/devicepluginsonos.json @@ -0,0 +1,223 @@ +{ + "id": "cdb07719-c445-4fa5-9c7a-564ee02a4412", + "name": "Sonos", + "displayName": "Sonos", + "vendors": [ + { + + "id": "30a60752-d06f-4ec9-a4e1-9810a5d22fa3", + "name": "sonos", + "displayName": "Sonos", + "deviceClasses": [ + { + "id": "22df416d-7732-44f1-b6b9-e41296211178", + "name": "sonosConnection", + "displayName": "Sonos connection", + "interfaces": ["account", "gateway"], + "createMethods": ["user"], + "setupMethod": "oauth", + "paramTypes": [ + ], + "stateTypes": [ + { + "id": "5aa4360c-61de-47d0-a72e-a19d57712e1c", + "name": "connected", + "displayName": "Connected", + "displayNameEvent": "Connected changed", + "defaultValue": false, + "type": "bool", + "cached": false + }, + { + "id": "48b5c1bf-7df0-45d0-9ba3-290fc3acddc3", + "name": "loggedIn", + "displayName": "Logged in", + "displayNameEvent": "Logged in changed", + "defaultValue": true, + "type": "bool" + }, + { + "id": "fb993eab-f1b5-44dd-9b99-041faec5a3b9", + "name": "userDisplayName", + "displayName": "User name", + "displayNameEvent": "User name changed", + "type": "QString", + "defaultValue": "" + } + ] + }, + { + "id": "72d9332b-2b25-4136-87a6-e534eae4cc80", + "name": "sonosGroup", + "displayName": "Sonos group", + "interfaces": ["extendedvolumecontroller", "mediametadataprovider", "shufflerepeat", "connectable"], + "createMethods": ["auto"], + "browsable": true, + "paramTypes": [ + { + "id": "defc44cd-2ffb-4af1-b348-d6a3474c7515", + "name": "groupId", + "displayName": "Group id", + "type" : "QString" + }, + { + "id": "f8a5d3d8-fad9-441d-b345-3484524490a0", + "name": "householdId", + "displayName": "Household id", + "type" : "QString" + } + ], + "stateTypes": [ + { + "id": "09dfbd40-c97c-4a20-9ecd-f80e389a4864", + "name": "connected", + "displayName": "Connected", + "displayNameEvent": "Connected changed", + "defaultValue": false, + "type": "bool" + }, + { + "id": "bc98cdb0-4d0e-48ca-afc7-922e49bb7813", + "name": "mute", + "displayName": "Mute", + "displayNameEvent": "Mute changed", + "displayNameAction": "Set mute", + "type": "bool", + "defaultValue": true, + "writable": true + }, + { + "id": "9dfe5d78-4c3f-497c-bab1-bb9fdf7e93a9", + "name": "volume", + "displayName": "Volume", + "displayNameEvent": "Volume changed", + "displayNameAction": "Set volume", + "unit": "Percentage", + "type": "int", + "minValue": 0, + "maxValue": 100, + "defaultValue": 50, + "writable": true + }, + { + "id": "2dd512b7-40c2-488e-8d4f-6519edaa6f74", + "name": "playbackStatus", + "displayName": "Playback status", + "type": "QString", + "possibleValues": ["Playing", "Paused", "Stopped"], + "defaultValue": "Stopped", + "displayNameEvent": "Playback status changed", + "displayNameAction": "Set playback status", + "writable": true + }, + { + "id": "f2209fec-cceb-46ad-8189-4caf42166e6b", + "type": "QString", + "name": "title", + "displayName": "Title", + "displayNameEvent": "Title changed", + "defaultValue": "" + }, + { + "id": "8cb920a3-3bf1-4231-92d4-8ac27e7b3d65", + "type": "QString", + "name": "artist", + "displayName": "Artist", + "displayNameEvent": "Artist changed", + "defaultValue": "" + }, + { + "id": "ce399eec-9f6a-4903-9916-0e90e38b255e", + "type": "QString", + "name": "collection", + "displayName": "Collection", + "displayNameEvent": "Collection changed", + "defaultValue": "" + }, + { + "id": "44304c82-c2f6-433b-b62b-815382617d0b", + "type": "QString", + "name": "artwork", + "displayName": "Artwork", + "displayNameEvent": "Artwork changed", + "defaultValue": "" + }, + { + "id": "5913aa2a-629d-4de5-bf44-a4a1f130c118", + "type": "bool", + "name": "shuffle", + "displayName": "Shuffle", + "displayNameEvent": "Shuffle changed", + "displayNameAction": "Set shuffle", + "defaultValue": false, + "writable": true + }, + { + "id": "bc02c28e-3f5d-4de4-b9b5-c0b1576c6e7e", + "type": "QString", + "name": "repeat", + "displayName": "Repeat", + "displayNameEvent": "Repeat changed", + "displayNameAction": "Set repeat", + "possibleValues": ["None", "One", "All"], + "defaultValue": "None", + "writable": true + } + ], + "eventTypes": [ + { + "id": "2535a1eb-7643-4874-98f6-b027fdff6311", + "name": "onPlayerPlay", + "displayName": "Group play" + }, + { + "id": "99498b1c-e9c0-480a-9e91-662ee79ba976", + "name": "onPlayerPause", + "displayName": "Group pause" + }, + { + "id": "a02ce255-3abb-435d-a92e-7f99c952ecb2", + "name": "onPlayerStop", + "displayName": "Group stop" + } + ], + "actionTypes": [ + { + "id": "a180807d-1265-4831-9d86-a421767418dd", + "name": "skipBack", + "displayName": "Skip back" + }, + { + "id": "7e70b47b-7e79-4521-be34-04a3c427e5b1", + "name": "fastRewind", + "displayName": "Rewind" + }, + { + "id": "ae3cbe03-ee3e-410e-abbd-efabc2402198", + "name": "stop", + "displayName": "Stop" + }, + { + "id": "4d2ee668-a2e3-4795-8b96-0c800b703b46", + "name": "play", + "displayName": "Play" + }, + { + "id": "3cf341cb-fe63-40bc-a450-9678d18e91e3", + "name": "pause", + "displayName": "Pause" + }, + { + "id": "85d7126a-b123-4a28-aeb4-d84bcfb4d14f", + "name": "skipNext", + "displayName": "Skip Next" + } + ], + "browserItemActionTypes": [ + ] + } + ] + } + ] +} + diff --git a/sonos/sonos.cpp b/sonos/sonos.cpp new file mode 100644 index 00000000..30f9a857 --- /dev/null +++ b/sonos/sonos.cpp @@ -0,0 +1,1623 @@ +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * * + * Copyright (C) 2019 Bernhard Trinnes * + * * + * This file is part of nymea. * + * * + * This library is free software; you can redistribute it and/or * + * modify it under the terms of the GNU Lesser General Public * + * License as published by the Free Software Foundation; either * + * version 2.1 of the License, or (at your option) any later version. * + * * + * This library is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * + * Lesser General Public License for more details. * + * * + * You should have received a copy of the GNU Lesser General Public * + * License along with this library; If not, see * + * . * + * * + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#include "sonos.h" +#include "extern-plugininfo.h" + +#include +#include +#include +#include + +Sonos::Sonos(NetworkAccessManager *networkmanager, const QByteArray &clientKey, const QByteArray &clientSecret, QObject *parent) : + QObject(parent), + m_clientKey(clientKey), + m_clientSecret(clientSecret), + m_networkManager(networkmanager) +{ + if(!m_tokenRefreshTimer) { + m_tokenRefreshTimer = new QTimer(this); + m_tokenRefreshTimer->setSingleShot(true); + connect(m_tokenRefreshTimer, &QTimer::timeout, this, &Sonos::onRefreshTimeout); + } +} + +QUrl Sonos::getLoginUrl(const QUrl &redirectUrl) +{ + if (m_clientKey.isEmpty()) { + qWarning(dcSonos()) << "Client key not defined!"; + return QUrl(""); + } + + if (redirectUrl.isEmpty()){ + qWarning(dcSonos()) << "No redirect uri defined!"; + } + m_redirectUri = QUrl::toPercentEncoding(redirectUrl.toString()); + + QUrl url("https://api.sonos.com/login/v3/oauth"); + QUrlQuery queryParams; + queryParams.addQueryItem("client_id", m_clientKey); + queryParams.addQueryItem("redirect_uri", m_redirectUri); + queryParams.addQueryItem("response_type", "code"); + queryParams.addQueryItem("scope", "playback-control-all"); + queryParams.addQueryItem("state", QUuid::createUuid().toString()); + url.setQuery(queryParams); + + return url; +} + +QByteArray Sonos::accessToken() +{ + return m_accessToken; +} + +QByteArray Sonos::refreshToken() +{ + return m_refreshToken; +} + +void Sonos::getHouseholds() +{ + QNetworkRequest request; + request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, "application/json"); + request.setRawHeader("Authorization", "Bearer " + m_accessToken); + request.setRawHeader("X-Sonos-Api-Key", m_clientKey); + request.setUrl(QUrl(m_baseControlUrl + "/households")); + QNetworkReply *reply = m_networkManager->get(request); + qDebug(dcSonos()) << "Sending request" << request.url() << request.rawHeaderList() << request.rawHeader("Authorization"); + connect(reply, &QNetworkReply::finished, this, [reply, this] { + reply->deleteLater(); + int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + // Check HTTP status code + if (status != 200 || reply->error() != QNetworkReply::NoError) { + if (reply->error() == QNetworkReply::HostNotFoundError) { + emit connectionChanged(false); + } + if (status == 400 || status == 401) { + emit authenticationStatusChanged(false); + } + qCWarning(dcSonos()) << "Request error:" << status << reply->errorString(); + return; + } + emit connectionChanged(true); + emit authenticationStatusChanged(true); + + QJsonParseError error; + QJsonDocument data = QJsonDocument::fromJson(reply->readAll(), &error); + if (error.error != QJsonParseError::NoError) { + qDebug(dcSonos()) << "Household ID: Recieved invalide JSON object"; + return; + } + QList households; + foreach (const QVariant &variant, data.toVariant().toMap().value("households").toList()) { + QVariantMap obj = variant.toMap(); + //qDebug(dcSonos()) << "Household ID received:" << obj["id"].toString(); + households.append(obj["id"].toString()); + } + emit householdIdsReceived(households); + }); +} + + +QUuid Sonos::loadFavorite(const QString &groupId, const QString &favouriteId) +{ + QNetworkRequest request; + request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, "application/json"); + request.setRawHeader("Authorization", "Bearer " + m_accessToken); + request.setRawHeader("X-Sonos-Api-Key", m_clientKey); + request.setUrl(QUrl(m_baseControlUrl + "/groups/" + groupId + "/favorites")); + QUuid actionId = QUuid::createUuid(); + + QJsonObject object; + object.insert("favoriteId", favouriteId); + object.insert("playOnCompletion", true); + QJsonDocument doc(object); + qDebug(dcSonos()) << "Sending request" << doc.toJson(); + + QNetworkReply *reply = m_networkManager->post(request, doc.toJson(QJsonDocument::Compact)); + connect(reply, &QNetworkReply::finished, this, [reply, actionId, this] { + reply->deleteLater(); + int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + // Check HTTP status code + if (status != 200 || reply->error() != QNetworkReply::NoError) { + if (reply->error() == QNetworkReply::HostNotFoundError) { + emit connectionChanged(false); + } + if (status == 400 || status == 401) { + emit authenticationStatusChanged(false); + } + emit actionExecuted(actionId, false); + qCWarning(dcSonos()) << "Request error:" << status << reply->errorString(); + return; + } + emit connectionChanged(true); + emit authenticationStatusChanged(true); + emit actionExecuted(actionId, true); + }); + return actionId; +} + +QUuid Sonos::getFavorites(const QString &householdId) +{ + QNetworkRequest request; + request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, "application/json"); + request.setRawHeader("Authorization", "Bearer " + m_accessToken); + request.setRawHeader("X-Sonos-Api-Key", m_clientKey); + request.setUrl(QUrl(m_baseControlUrl + "/households/" + householdId + "/favorites")); + QUuid requestId = QUuid::createUuid(); + + QNetworkReply *reply = m_networkManager->get(request); + connect(reply, &QNetworkReply::finished, this, [reply, requestId, householdId, this] { + reply->deleteLater(); + int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + // Check HTTP status code + if (status != 200 || reply->error() != QNetworkReply::NoError) { + if (reply->error() == QNetworkReply::HostNotFoundError) { + emit connectionChanged(false); + } + if (status == 400 || status == 401) { + emit authenticationStatusChanged(false); + } + qCWarning(dcSonos()) << "Request error:" << status << reply->errorString(); + return; + } + emit connectionChanged(true); + emit authenticationStatusChanged(true); + + QJsonParseError error; + QJsonDocument data = QJsonDocument::fromJson(reply->readAll(), &error); + if (error.error != QJsonParseError::NoError) { + qCWarning(dcSonos()) << "Invalid json received from server"; + return; + } + + if (!data.toVariant().toMap().contains("items")) + return; + + QVariantList array = data.toVariant().toMap().value("items").toList(); + //qDebug(dcSonos()) << "Favorites received:" << data.toJson(); + + QList favorites; + foreach (const QVariant &variant, array) { + QVariantMap itemObject = variant.toMap(); + FavoriteObject favorite; + favorite.id = itemObject["id"].toString(); + favorite.name = itemObject["name"].toString(); + favorite.description = itemObject["description"].toString(); + favorite.imageUrl = itemObject["imageUrl"].toString(); + favorites.append(favorite); + } + emit favoritesReceived(requestId, householdId, favorites); + }); + return requestId; +} + + +void Sonos::getGroups(const QString &householdId) +{ + QNetworkRequest request; + request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, "application/json"); + request.setRawHeader("Authorization", "Bearer " + m_accessToken); + request.setRawHeader("X-Sonos-Api-Key", m_clientKey); + request.setUrl(QUrl(m_baseControlUrl + "/households/" + householdId + "/groups")); + QNetworkReply *reply = m_networkManager->get(request); + connect(reply, &QNetworkReply::finished, this, [reply, householdId, this] { + reply->deleteLater(); + int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + // Check HTTP status code + if (status != 200 || reply->error() != QNetworkReply::NoError) { + if (reply->error() == QNetworkReply::HostNotFoundError) { + emit connectionChanged(false); + } + if (status == 400 || status == 401) { + emit authenticationStatusChanged(false); + } + qCWarning(dcSonos()) << "Request error:" << status << reply->errorString(); + return; + } + emit connectionChanged(true); + emit authenticationStatusChanged(true); + + //qDebug(dcSonos()) << "Received response from Sonos" << reply->readAll(); + QJsonParseError error; + QJsonDocument data = QJsonDocument::fromJson(reply->readAll(), &error); + if (error.error != QJsonParseError::NoError) + return; + + if (!data.toVariant().toMap().contains("groups")) + return; + + QVariantList array = data.toVariant().toMap().value("groups").toList(); + QList groupObjects; + foreach (const QVariant &value, array) { + QVariantMap obj = value.toMap(); + //qDebug(dcSonos()) << "Group ID received:" << obj["id"].toString(); + GroupObject group; + group.groupId = obj["id"].toString(); + group.displayName = obj["name"].toString(); + group.CoordinatorId = obj["coordinatorId"].toString(); + group.playbackState = obj["playbackState"].toString(); + QVariantList players = obj.value("playerIds").toList(); + foreach (const QVariant &value, players) { + group.playerIds.append(value.toByteArray()); + } + groupObjects.append(group); + } + emit groupsReceived(householdId, groupObjects); + }); +} + +void Sonos::getGroupVolume(const QString &groupId) +{ + QNetworkRequest request; + request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, "application/json"); + request.setRawHeader("Authorization", "Bearer " + m_accessToken); + request.setRawHeader("X-Sonos-Api-Key", m_clientKey); + request.setUrl(QUrl(m_baseControlUrl + "/groups/" + groupId + "/groupVolume")); + QNetworkReply *reply = m_networkManager->get(request); + connect(reply, &QNetworkReply::finished, this, [reply, groupId, this] { + reply->deleteLater(); + int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + // Check HTTP status code + if (status != 200 || reply->error() != QNetworkReply::NoError) { + if (reply->error() == QNetworkReply::HostNotFoundError) { + emit connectionChanged(false); + } + if (status == 400 || status == 401) { + emit authenticationStatusChanged(false); + } + qCWarning(dcSonos()) << "Request error:" << status << reply->errorString(); + return; + } + emit connectionChanged(true); + emit authenticationStatusChanged(true); + + //qDebug(dcSonos()) << "Received response from Sonos" << reply->readAll(); + QJsonParseError error; + QJsonDocument data = QJsonDocument::fromJson(reply->readAll(), &error); + if (error.error != QJsonParseError::NoError) { + qCWarning(dcSonos()) << "JSON Parse error" << error.errorString(); + return; + } + + VolumeObject volume; + + QVariantMap variant = data.toVariant().toMap(); + volume.volume = variant["volume"].toInt(); + volume.muted = variant["muted"].toBool(); + volume.fixed = variant["fixed"].toBool(); + + emit volumeReceived(groupId, volume); + }); +} + +QUuid Sonos::setGroupVolume(const QString &groupId, int volume) +{ + QNetworkRequest request; + request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, "application/json"); + request.setRawHeader("Authorization", "Bearer " + m_accessToken); + request.setRawHeader("X-Sonos-Api-Key", m_clientKey); + request.setUrl(QUrl(m_baseControlUrl + "/groups/" + groupId + "/groupVolume")); + QUuid actionId = QUuid::createUuid(); + + QJsonObject object; + object.insert("volume", volume); + QJsonDocument doc(object); + qDebug(dcSonos()) << "Set volume:" << groupId << doc.toJson(QJsonDocument::Compact); + + QNetworkReply *reply = m_networkManager->post(request, doc.toJson(QJsonDocument::Compact)); + connect(reply, &QNetworkReply::finished, this, [reply, actionId, groupId, this] { + reply->deleteLater(); + int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + // Check HTTP status code + if (status != 200 || reply->error() != QNetworkReply::NoError) { + if (reply->error() == QNetworkReply::HostNotFoundError) { + emit connectionChanged(false); + } + if (status == 400 || status == 401) { + emit authenticationStatusChanged(false); + } + emit actionExecuted(actionId, false); + qCWarning(dcSonos()) << "Request error:" << status << reply->errorString(); + return; + } + emit connectionChanged(true); + emit authenticationStatusChanged(true); + emit actionExecuted(actionId, true); + getGroupVolume(groupId); + }); + return actionId; +} + +QUuid Sonos::setGroupMute(const QString &groupId, bool mute) +{ + QNetworkRequest request; + request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, "application/json"); + request.setRawHeader("Authorization", "Bearer " + m_accessToken); + request.setRawHeader("X-Sonos-Api-Key", m_clientKey); + request.setUrl(QUrl(m_baseControlUrl + "/groups/" + groupId + "/groupVolume/mute")); + QUuid actionId = QUuid::createUuid(); + + QJsonObject object; + object.insert("muted", mute); + QJsonDocument doc(object); + + qDebug(dcSonos()) << "Set mute:" << groupId << doc.toJson(QJsonDocument::Compact); + + QNetworkReply *reply = m_networkManager->post(request, doc.toJson(QJsonDocument::Compact)); + connect(reply, &QNetworkReply::finished, this, [reply, actionId, groupId, this] { + reply->deleteLater(); + int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + // Check HTTP status code + if (status != 200 || reply->error() != QNetworkReply::NoError) { + if (reply->error() == QNetworkReply::HostNotFoundError) { + emit connectionChanged(false); + } + if (status == 400 || status == 401) { + emit authenticationStatusChanged(false); + } + emit actionExecuted(actionId, false); + qCWarning(dcSonos()) << "Request error:" << status << reply->errorString(); + return; + } + emit connectionChanged(true); + emit authenticationStatusChanged(true); + emit actionExecuted(actionId, true); + getGroupVolume(groupId); + }); + return actionId; +} + +QUuid Sonos::setGroupRelativeVolume(const QString &groupId, int volumeDelta) +{ + QNetworkRequest request; + request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, "application/json"); + request.setRawHeader("Authorization", "Bearer " + m_accessToken); + request.setRawHeader("X-Sonos-Api-Key", m_clientKey); + request.setUrl(QUrl(m_baseControlUrl + "/groups/" + groupId + "/groupVolume/relative")); + QUuid actionId = QUuid::createUuid(); + + QJsonObject object; + object.insert("volumeDelta", QJsonValue::fromVariant(volumeDelta)); + QJsonDocument doc(object); + + qDebug(dcSonos()) << "Relative volume:" << groupId << volumeDelta; + + QNetworkReply *reply = m_networkManager->post(request, doc.toJson(QJsonDocument::Compact)); + connect(reply, &QNetworkReply::finished, this, [reply, actionId, groupId, this] { + reply->deleteLater(); + int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + // Check HTTP status code + if (status != 200 || reply->error() != QNetworkReply::NoError) { + if (reply->error() == QNetworkReply::HostNotFoundError) { + emit connectionChanged(false); + } + if (status == 400 || status == 401) { + emit authenticationStatusChanged(false); + } + emit actionExecuted(actionId, false); + qCWarning(dcSonos()) << "Request error:" << status << reply->errorString(); + return; + } + emit connectionChanged(true); + emit authenticationStatusChanged(true); + emit actionExecuted(actionId, true); + getGroupVolume(groupId); + }); + return actionId; +} + +void Sonos::getGroupPlaybackStatus(const QString &groupId) +{ + QNetworkRequest request; + request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, "application/json"); + request.setRawHeader("Authorization", "Bearer " + m_accessToken); + request.setRawHeader("X-Sonos-Api-Key", m_clientKey); + request.setUrl(QUrl(m_baseControlUrl + "/groups/" + groupId + "/playback")); + QNetworkReply *reply = m_networkManager->get(request); + connect(reply, &QNetworkReply::finished, this, [reply, this, groupId] { + reply->deleteLater(); + int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + // Check HTTP status code + if (status != 200 || reply->error() != QNetworkReply::NoError) { + if (reply->error() == QNetworkReply::HostNotFoundError) { + emit connectionChanged(false); + } + if (status == 400 || status == 401) { + emit authenticationStatusChanged(false); + } + qCWarning(dcSonos()) << "Request error:" << status << reply->errorString(); + return; + } + emit connectionChanged(true); + emit authenticationStatusChanged(true); + + QJsonDocument data = QJsonDocument::fromJson(reply->readAll()); + if (!data.isObject()) + return; + + PlayBackObject playBack; + QJsonObject object = data.object(); + playBack.itemId = object["itemId"].toString(); + playBack.positionMillis = object["positionMillis"].toInt(); + playBack.previousItemId = object["previousItemId"].toInt(); + playBack.previousPositionMillis = object["previousPositionMillis"].toInt(); + QString playBackState = object["playbackState"].toString(); + if (playBackState.contains("BUFFERING")) { + playBack.playbackState = PlayBackStateBuffering; + } else if (playBackState.contains("IDLE")) { + playBack.playbackState = PlayBackStateIdle; + } else if (playBackState.contains("PAUSE")) { + playBack.playbackState = PlayBackStatePause; + } else if (playBackState.contains("PLAYING")) { + playBack.playbackState = PlayBackStatePlaying; + } + playBack.isDucking = object["isDucking"].toBool(); + playBack.queueVersion = object["queueVersion"].toString(); + if (object.contains("playModes")) { + PlayMode playMode; + QJsonObject playModeObject = object["playModes"].toObject(); + playMode.repeat = playModeObject["repeat"].toBool(); + playMode.repeatOne = playModeObject["repeatOne"].toBool(); + playMode.crossfade = playModeObject["crossfade"].toBool(); + playMode.shuffle = playModeObject["shuffle"].toBool(); + playBack.playMode = playMode; + } + emit playBackStatusReceived(groupId, playBack); + }); +} + +QUuid Sonos::groupLoadLineIn(const QString &groupId) +{ + qDebug(dcSonos()) << "Load line in:" << groupId; + QNetworkRequest request; + request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, "application/json"); + request.setRawHeader("Authorization", "Bearer " + m_accessToken); + request.setRawHeader("X-Sonos-Api-Key", m_clientKey); + request.setUrl(QUrl(m_baseControlUrl + "/groups/" + groupId + "/playback/lineIn")); + QUuid actionId = QUuid::createUuid(); + + QNetworkReply *reply = m_networkManager->post(request, ""); + connect(reply, &QNetworkReply::finished, this, [reply, actionId, groupId, this] { + reply->deleteLater(); + int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + // Check HTTP status code + if (status != 200 || reply->error() != QNetworkReply::NoError) { + if (reply->error() == QNetworkReply::HostNotFoundError) { + emit connectionChanged(false); + } + if (status == 400 || status == 401) { + emit authenticationStatusChanged(false); + } + emit actionExecuted(actionId, false); + qCWarning(dcSonos()) << "Request error:" << status << reply->errorString(); + return; + } + emit connectionChanged(true); + emit authenticationStatusChanged(true); + emit actionExecuted(actionId, true); + getGroupVolume(groupId); + }); + return actionId; +} + +QUuid Sonos::groupPlay(const QString &groupId) +{ + QNetworkRequest request; + request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, "application/json"); + request.setRawHeader("Authorization", "Bearer " + m_accessToken); + request.setRawHeader("X-Sonos-Api-Key", m_clientKey); + request.setUrl(QUrl(m_baseControlUrl + "/groups/" + groupId + "/playback/play")); + QUuid actionId = QUuid::createUuid(); + + qDebug(dcSonos()) << "Play:" << groupId; + + QNetworkReply *reply = m_networkManager->post(request, ""); + connect(reply, &QNetworkReply::finished, this, [reply, actionId, groupId, this] { + reply->deleteLater(); + int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + // Check HTTP status code + if (status != 200 || reply->error() != QNetworkReply::NoError) { + if (reply->error() == QNetworkReply::HostNotFoundError) { + emit connectionChanged(false); + } + if (status == 400 || status == 401) { + emit authenticationStatusChanged(false); + } + emit actionExecuted(actionId, false); + qCWarning(dcSonos()) << "Request error:" << status << reply->errorString(); + return; + } + emit connectionChanged(true); + emit authenticationStatusChanged(true); + emit actionExecuted(actionId, true); + getGroupPlaybackStatus(groupId); + }); + return actionId; +} + +QUuid Sonos::groupPause(const QString &groupId) +{ + QNetworkRequest request; + request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, "application/json"); + request.setRawHeader("Authorization", "Bearer " + m_accessToken); + request.setRawHeader("X-Sonos-Api-Key", m_clientKey); + request.setUrl(QUrl(m_baseControlUrl + "/groups/" + groupId + "/playback/pause")); + QUuid actionId = QUuid::createUuid(); + + qDebug(dcSonos()) << "Pause:" << groupId; + + QNetworkReply *reply = m_networkManager->post(request, ""); + connect(reply, &QNetworkReply::finished, this, [reply, actionId, groupId, this] { + reply->deleteLater(); + int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + // Check HTTP status code + if (status != 200 || reply->error() != QNetworkReply::NoError) { + if (reply->error() == QNetworkReply::HostNotFoundError) { + emit connectionChanged(false); + } + if (status == 400 || status == 401) { + emit authenticationStatusChanged(false); + } + emit actionExecuted(actionId, false); + qCWarning(dcSonos()) << "Request error:" << status << reply->errorString(); + return; + } + emit connectionChanged(true); + emit authenticationStatusChanged(true); + emit actionExecuted(actionId, true); + getGroupPlaybackStatus(groupId); + }); + return actionId; +} + +QUuid Sonos::groupSeek(const QString &groupId, int possitionMillis) +{ + Q_UNUSED(groupId) + QNetworkRequest request; + request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, "application/json"); + request.setRawHeader("Authorization", "Bearer " + m_accessToken); + request.setRawHeader("X-Sonos-Api-Key", m_clientKey); + request.setUrl(QUrl(m_baseControlUrl + "/groups/" + groupId + "/playback/seek")); + QUuid actionId = QUuid::createUuid(); + + QJsonObject object; + object.insert("positionMillis", QJsonValue::fromVariant(possitionMillis)); + QJsonDocument doc(object); + + QNetworkReply *reply = m_networkManager->post(request, doc.toJson(QJsonDocument::Compact)); + connect(reply, &QNetworkReply::finished, this, [reply, actionId, this] { + reply->deleteLater(); + int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + // Check HTTP status code + if (status != 200 || reply->error() != QNetworkReply::NoError) { + if (reply->error() == QNetworkReply::HostNotFoundError) { + emit connectionChanged(false); + } + if (status == 400 || status == 401) { + emit authenticationStatusChanged(false); + } + emit actionExecuted(actionId, false); + qCWarning(dcSonos()) << "Request error:" << status << reply->errorString(); + return; + } + emit connectionChanged(true); + emit authenticationStatusChanged(true); + emit actionExecuted(actionId, true); + }); + return actionId; +} + +QUuid Sonos::groupSeekRelative(const QString &groupId, int deltaMillis) +{ + QNetworkRequest request; + request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, "application/json"); + request.setRawHeader("Authorization", "Bearer " + m_accessToken); + request.setRawHeader("X-Sonos-Api-Key", m_clientKey); + request.setUrl(QUrl(m_baseControlUrl + "/groups/" + groupId + "/playback/seekRelative")); + QUuid actionId = QUuid::createUuid(); + + QJsonObject object; + object.insert("deltaMillis", QJsonValue::fromVariant(deltaMillis)); + QJsonDocument doc(object); + + QNetworkReply *reply = m_networkManager->post(request, doc.toJson(QJsonDocument::Compact)); + connect(reply, &QNetworkReply::finished, this, [reply, actionId, this] { + reply->deleteLater(); + int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + // Check HTTP status code + if (status != 200 || reply->error() != QNetworkReply::NoError) { + if (reply->error() == QNetworkReply::HostNotFoundError) { + emit connectionChanged(false); + } + if (status == 400 || status == 401) { + emit authenticationStatusChanged(false); + } + emit actionExecuted(actionId, false); + qCWarning(dcSonos()) << "Request error:" << status << reply->errorString(); + return; + } + emit connectionChanged(true); + emit authenticationStatusChanged(true); + emit actionExecuted(actionId, true); + }); + return actionId; +} + +QUuid Sonos::groupSetPlayModes(const QString &groupId, PlayMode playMode) +{ + QNetworkRequest request; + request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, "application/json"); + request.setRawHeader("Authorization", "Bearer " + m_accessToken); + request.setRawHeader("X-Sonos-Api-Key", m_clientKey); + request.setUrl(QUrl(m_baseControlUrl + "/groups/" + groupId + "/playback/playMode")); + QUuid actionId = QUuid::createUuid(); + + QJsonObject object; + QJsonObject playModesObject; + playModesObject["repeat"] = playMode.repeat; + playModesObject["repeatOne"] = playMode.repeatOne; + playModesObject["crossfade"] = playMode.crossfade; + playModesObject["shuffle"] = playMode.shuffle; + object.insert("playModes", playModesObject); + QJsonDocument doc(object); + + QNetworkReply *reply = m_networkManager->post(request, doc.toJson(QJsonDocument::Compact)); + connect(reply, &QNetworkReply::finished, this, [reply, actionId, groupId, this] { + reply->deleteLater(); + int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + // Check HTTP status code + if (status != 200 || reply->error() != QNetworkReply::NoError) { + if (reply->error() == QNetworkReply::HostNotFoundError) { + emit connectionChanged(false); + } + if (status == 400 || status == 401) { + emit authenticationStatusChanged(false); + } + emit actionExecuted(actionId, false); + qCWarning(dcSonos()) << "Request error:" << status << reply->errorString(); + return; + } + emit connectionChanged(true); + emit authenticationStatusChanged(true); + emit actionExecuted(actionId, true); + getGroupPlaybackStatus(groupId); + }); + return actionId; +} + +QUuid Sonos::groupSetShuffle(const QString &groupId, bool shuffle) +{ + QNetworkRequest request; + request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, "application/json"); + request.setRawHeader("Authorization", "Bearer " + m_accessToken); + request.setRawHeader("X-Sonos-Api-Key", m_clientKey); + request.setUrl(QUrl(m_baseControlUrl + "/groups/" + groupId + "/playback/playMode")); + QUuid actionId = QUuid::createUuid(); + + QJsonObject object; + QJsonObject playModesObject; + playModesObject["shuffle"] = shuffle; + object.insert("playModes", playModesObject); + QJsonDocument doc(object); + + QNetworkReply *reply = m_networkManager->post(request, doc.toJson(QJsonDocument::Compact)); + connect(reply, &QNetworkReply::finished, this, [reply, actionId, groupId, this] { + reply->deleteLater(); + int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + // Check HTTP status code + if (status != 200 || reply->error() != QNetworkReply::NoError) { + if (reply->error() == QNetworkReply::HostNotFoundError) { + emit connectionChanged(false); + } + if (status == 400 || status == 401) { + emit authenticationStatusChanged(false); + } + emit actionExecuted(actionId, false); + qCWarning(dcSonos()) << "Request error:" << status << reply->errorString(); + return; + } + emit connectionChanged(true); + emit authenticationStatusChanged(true); + emit actionExecuted(actionId, true); + getGroupPlaybackStatus(groupId); + }); + return actionId; +} + +QUuid Sonos::groupSetRepeat(const QString &groupId, RepeatMode repeatMode) +{ + QNetworkRequest request; + request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, "application/json"); + request.setRawHeader("Authorization", "Bearer " + m_accessToken); + request.setRawHeader("X-Sonos-Api-Key", m_clientKey); + request.setUrl(QUrl(m_baseControlUrl + "/groups/" + groupId + "/playback/playMode")); + QUuid actionId = QUuid::createUuid(); + + QJsonObject object; + QJsonObject playModesObject; + if (repeatMode == RepeatModeAll) { + qDebug(dcSonos()) << "Setting repeat mode all"; + playModesObject["repeat"] = true; + playModesObject["repeatOne"] = false; + } else if (repeatMode == RepeatModeOne) { + qDebug(dcSonos()) << "Setting repeat mode one"; + playModesObject["repeat"] = false; + playModesObject["repeatOne"] = true; + } else if (repeatMode == RepeatModeNone) { + qDebug(dcSonos()) << "Setting repeat mode none"; + playModesObject["repeat"] = false; + playModesObject["repeatOne"] = false; + } + object.insert("playModes", playModesObject); + QJsonDocument doc(object); + + QNetworkReply *reply = m_networkManager->post(request, doc.toJson(QJsonDocument::Compact)); + connect(reply, &QNetworkReply::finished, this, [reply, actionId, groupId, this] { + reply->deleteLater(); + int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + // Check HTTP status code + if (status != 200 || reply->error() != QNetworkReply::NoError) { + if (reply->error() == QNetworkReply::HostNotFoundError) { + emit connectionChanged(false); + } + if (status == 400 || status == 401) { + emit authenticationStatusChanged(false); + } + emit actionExecuted(actionId, false); + qCWarning(dcSonos()) << "Request error:" << status << reply->errorString(); + return; + } + emit connectionChanged(true); + emit authenticationStatusChanged(true); + emit actionExecuted(actionId, true); + getGroupPlaybackStatus(groupId); + }); + return actionId; +} + +QUuid Sonos::groupSetCrossfade(const QString &groupId, bool crossfade) +{ + QNetworkRequest request; + request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, "application/json"); + request.setRawHeader("Authorization", "Bearer " + m_accessToken); + request.setRawHeader("X-Sonos-Api-Key", m_clientKey); + request.setUrl(QUrl(m_baseControlUrl + "/groups/" + groupId + "/playback/playMode")); + QUuid actionId = QUuid::createUuid(); + + QJsonObject object; + QJsonObject playModesObject; + playModesObject["crossfade"] = crossfade; + object.insert("playModes", playModesObject); + QJsonDocument doc(object); + + QNetworkReply *reply = m_networkManager->post(request, doc.toJson(QJsonDocument::Compact)); + connect(reply, &QNetworkReply::finished, this, [reply, actionId, groupId, this] { + reply->deleteLater(); + int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + // Check HTTP status code + if (status != 200 || reply->error() != QNetworkReply::NoError) { + if (reply->error() == QNetworkReply::HostNotFoundError) { + emit connectionChanged(false); + } + if (status == 400 || status == 401) { + emit authenticationStatusChanged(false); + } + emit actionExecuted(actionId, false); + qCWarning(dcSonos()) << "Request error:" << status << reply->errorString(); + return; + } + emit connectionChanged(true); + emit authenticationStatusChanged(true); + emit actionExecuted(actionId, true); + getGroupPlaybackStatus(groupId); + }); + return actionId; +} + +QUuid Sonos::groupSkipToNextTrack(const QString &groupId) +{ + QNetworkRequest request; + request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, "application/json"); + request.setRawHeader("Authorization", "Bearer " + m_accessToken); + request.setRawHeader("X-Sonos-Api-Key", m_clientKey); + request.setUrl(QUrl(m_baseControlUrl + "/groups/" + groupId + "/playback/skipToNextTrack")); + QUuid actionId = QUuid::createUuid(); + + QNetworkReply *reply = m_networkManager->post(request, ""); + connect(reply, &QNetworkReply::finished, this, [reply, actionId, groupId, this] { + reply->deleteLater(); + int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + // Check HTTP status code + if (status != 200 || reply->error() != QNetworkReply::NoError) { + if (reply->error() == QNetworkReply::HostNotFoundError) { + emit connectionChanged(false); + } + if (status == 400 || status == 401) { + emit authenticationStatusChanged(false); + } + emit actionExecuted(actionId, false); + qCWarning(dcSonos()) << "Request error:" << status << reply->errorString(); + return; + } + emit connectionChanged(true); + emit authenticationStatusChanged(true); + emit actionExecuted(actionId, true); + getGroupMetadataStatus(groupId); + }); + return actionId; +} + +QUuid Sonos::groupSkipToPreviousTrack(const QString &groupId) +{ + QNetworkRequest request; + request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, "application/json"); + request.setRawHeader("Authorization", "Bearer " + m_accessToken); + request.setRawHeader("X-Sonos-Api-Key", m_clientKey); + request.setUrl(QUrl(m_baseControlUrl + "/groups/" + groupId + "/playback/skipToPreviousTrack")); + QUuid actionId = QUuid::createUuid(); + + QNetworkReply *reply = m_networkManager->post(request, ""); + connect(reply, &QNetworkReply::finished, this, [reply, actionId, groupId, this] { + reply->deleteLater(); + int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + // Check HTTP status code + if (status != 200 || reply->error() != QNetworkReply::NoError) { + if (reply->error() == QNetworkReply::HostNotFoundError) { + emit connectionChanged(false); + } + if (status == 400 || status == 401) { + emit authenticationStatusChanged(false); + } + emit actionExecuted(actionId, false); + qCWarning(dcSonos()) << "Request error:" << status << reply->errorString(); + return; + } + emit connectionChanged(true); + emit authenticationStatusChanged(true); + emit actionExecuted(actionId, true); + getGroupMetadataStatus(groupId); + }); + return actionId; +} + +QUuid Sonos::groupTogglePlayPause(const QString &groupId) +{ + QNetworkRequest request; + request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, "application/json"); + request.setRawHeader("Authorization", "Bearer " + m_accessToken); + request.setRawHeader("X-Sonos-Api-Key", m_clientKey); + request.setUrl(QUrl(m_baseControlUrl + "/groups/" + groupId + "/playback/togglePlayPause")); + QUuid actionId = QUuid::createUuid(); + + QNetworkReply *reply = m_networkManager->post(request, ""); + connect(reply, &QNetworkReply::finished, this, [reply, actionId, groupId, this] { + reply->deleteLater(); + int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + // Check HTTP status code + if (status != 200 || reply->error() != QNetworkReply::NoError) { + if (reply->error() == QNetworkReply::HostNotFoundError) { + emit connectionChanged(false); + } + if (status == 400 || status == 401) { + emit authenticationStatusChanged(false); + } + emit actionExecuted(actionId, false); + qCWarning(dcSonos()) << "Request error:" << status << reply->errorString(); + return; + } + emit connectionChanged(true); + emit authenticationStatusChanged(true); + emit actionExecuted(actionId, true); + getGroupPlaybackStatus(groupId); + }); + return actionId; +} + +void Sonos::getGroupMetadataStatus(const QString &groupId) +{ + QNetworkRequest request; + request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, "application/json"); + request.setRawHeader("Authorization", "Bearer " + m_accessToken); + request.setRawHeader("X-Sonos-Api-Key", m_clientKey); + request.setUrl(QUrl(m_baseControlUrl + "/groups/" + groupId + "/playbackMetadata")); + QNetworkReply *reply = m_networkManager->get(request); + connect(reply, &QNetworkReply::finished, this, [reply, groupId, this] { + reply->deleteLater(); + int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + // Check HTTP status code + if (status != 200 || reply->error() != QNetworkReply::NoError) { + if (reply->error() == QNetworkReply::HostNotFoundError) { + emit connectionChanged(false); + } + if (status == 400 || status == 401) { + emit authenticationStatusChanged(false); + } + qCWarning(dcSonos()) << "Request error:" << status << reply->errorString(); + return; + } + emit connectionChanged(true); + emit authenticationStatusChanged(true); + + QJsonDocument data = QJsonDocument::fromJson(reply->readAll()); + if (!data.isObject()) + return; + + MetadataStatus metaDataStatus; + QJsonObject object = data.object(); + + if (object.contains("container")) { + ContainerObject container; + QJsonObject containerObject = object["container"].toObject(); + container.name = containerObject["name"].toString(); + container.type = containerObject["type"].toString(); + container.imageUrl = containerObject["imageUrl"].toString(); + if (containerObject.contains("service")) { + ServiceObject service; + QJsonObject serviceObject = containerObject.value("artist").toObject(); + service.name = serviceObject["name"].toString(); + container.service = service; + } + if (containerObject.contains("id")) { + //TODO parse ID + } + metaDataStatus.container = container; + } + + if (object.contains("currentItem")) { + QJsonObject currentItemObject = object["currentItem"].toObject(); + ItemObject currentItem; + if (currentItemObject.contains("track")) { + TrackObject track; + QJsonObject trackObject = currentItemObject["track"].toObject(); + + if (trackObject.contains("artist")) { + ArtistObject artist; + QJsonObject artistObject = trackObject["artist"].toObject(); + artist.name = artistObject["name"].toString(); + //qDebug(dcSonos()) << "Track object contains artist" << artist.name; + track.artist = artist; + } + if (trackObject.contains("album")) { + AlbumObject album; + QJsonObject albumObject = trackObject["album"].toObject(); + album.name = albumObject["name"].toString(); + //qDebug(dcSonos()) << "Track object contains album" << album.name; + track.album = album; + } + if (trackObject.contains("service")) { + ServiceObject service; + QJsonObject serviceObject = trackObject["service"].toObject(); + service.name = serviceObject["name"].toString(); + //qDebug(dcSonos()) << "Track object contains service" << service.name; + track.service = service; + } + if (trackObject.contains("id")) { + //TODO parse id + } + + track.type = trackObject["type"].toString(); + track.name = trackObject["name"].toString(); + track.imageUrl = trackObject["imageUrl"].toString(); + track.trackNumber = trackObject["trackNumber"].toInt(); + track.durationMillis = trackObject["durationMillis"].toInt(); + + currentItem.track = track; + } + metaDataStatus.currentItem = currentItem; + } + + if (object.contains("nextItem")) { + ItemObject nextItem; + QJsonObject nextItemObject = object["nextItem"].toObject(); + if (nextItemObject.contains("track")) { + TrackObject track; + QJsonObject trackObject = nextItemObject.value("track").toObject(); + + if (trackObject.contains("artist")) { + ArtistObject artist; + QJsonObject artistObject = trackObject.value("artist").toObject(); + artist.name = artistObject["name"].toString(); + //qDebug(dcSonos()) << "Track object contains artist" << artist.name; + track.artist = artist; + } + if (trackObject.contains("album")) { + AlbumObject album; + QJsonObject albumObject = trackObject.value("album").toObject(); + album.name = albumObject["name"].toString(); + //qDebug(dcSonos()) << "Track object contains album" << album.name; + track.album = album; + } + if (trackObject.contains("service")) { + ServiceObject service; + QJsonObject serviceObject = trackObject.value("service").toObject(); + service.name = serviceObject["name"].toString(); + //qDebug(dcSonos()) << "Track object contains service" << service.name; + track.service = service; + } + if (trackObject.contains("id")) { + //TODO parse id + } + track.type = trackObject["type"].toString(); + track.name = trackObject["name"].toString(); + track.imageUrl = trackObject["imageUrl"].toString(); + track.trackNumber = trackObject["trackNumber"].toInt(); + track.durationMillis = trackObject["durationMillis"].toInt(); + + nextItem.track = track; + } + metaDataStatus.nextItem = nextItem; + } + emit metadataStatusReceived(groupId, metaDataStatus); + }); +} + +void Sonos::getPlayerVolume(const QByteArray &playerId) +{ + QNetworkRequest request; + request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, "application/json"); + request.setRawHeader("Authorization", "Bearer " + m_accessToken); + request.setRawHeader("X-Sonos-Api-Key", m_clientKey); + request.setUrl(QUrl(m_baseControlUrl + "/players/" + playerId + "/playerVolume")); + QNetworkReply *reply = m_networkManager->get(request); + connect(reply, &QNetworkReply::finished, this, [reply, playerId, this] { + reply->deleteLater(); + int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + // Check HTTP status code + if (status != 200 || reply->error() != QNetworkReply::NoError) { + if (reply->error() == QNetworkReply::HostNotFoundError) { + emit connectionChanged(false); + } + if (status == 400 || status == 401) { + emit authenticationStatusChanged(false); + } + qCWarning(dcSonos()) << "Request error:" << status << reply->errorString(); + return; + } + emit connectionChanged(true); + emit authenticationStatusChanged(true); + + //qDebug(dcSonos()) << "Received response from Sonos" << reply->readAll(); + QJsonParseError error; + QJsonDocument data = QJsonDocument::fromJson(reply->readAll(), &error); + if (error.error != QJsonParseError::NoError) { + qCWarning(dcSonos()) << "Json parse error" << error.errorString(); + return; + } + + VolumeObject volume; + QVariantMap variant = data.toVariant().toMap(); + volume.volume = variant["volume"].toInt(); + volume.muted = variant["muted"].toBool(); + volume.fixed = variant["fixed"].toBool(); + emit playerVolumeReceived(playerId, volume); + }); +} + +QUuid Sonos::setPlayerVolume(const QByteArray &playerId, int volume) +{ + QNetworkRequest request; + request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, "application/json"); + request.setRawHeader("Authorization", "Bearer " + m_accessToken); + request.setRawHeader("X-Sonos-Api-Key", m_clientKey); + request.setUrl(QUrl(m_baseControlUrl + "/players/" + playerId + "/playerVolume")); + QUuid actionId = QUuid::createUuid(); + + qDebug(dcSonos()) << "Setting volume:" << playerId << volume; + + QJsonObject object; + object.insert("volume", QJsonValue::fromVariant(volume)); + QJsonDocument doc(object); + + QNetworkReply *reply = m_networkManager->post(request, doc.toJson(QJsonDocument::Compact)); + connect(reply, &QNetworkReply::finished, this, [reply, actionId, playerId, this] { + reply->deleteLater(); + int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + // Check HTTP status code + if (status != 200 || reply->error() != QNetworkReply::NoError) { + if (reply->error() == QNetworkReply::HostNotFoundError) { + emit connectionChanged(false); + } + if (status == 400 || status == 401) { + emit authenticationStatusChanged(false); + } + emit actionExecuted(actionId, false); + qCWarning(dcSonos()) << "Request error:" << status << reply->errorString(); + return; + } + emit connectionChanged(true); + emit authenticationStatusChanged(true); + emit actionExecuted(actionId, true); + getPlayerVolume(playerId); + }); + return actionId; +} + +QUuid Sonos::setPlayerRelativeVolume(const QByteArray &playerId, int volumeDelta) +{ + QNetworkRequest request; + request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, "application/json"); + request.setRawHeader("Authorization", "Bearer " + m_accessToken); + request.setRawHeader("X-Sonos-Api-Key", m_clientKey); + request.setUrl(QUrl(m_baseControlUrl + "/players/" + playerId + "/playerVolume/relative")); + QUuid actionId = QUuid::createUuid(); + + QJsonObject object; + object.insert("volumeDelta", QJsonValue::fromVariant(volumeDelta)); + QJsonDocument doc(object); + + QNetworkReply *reply = m_networkManager->post(request, doc.toJson(QJsonDocument::Compact)); + connect(reply, &QNetworkReply::finished, this, [reply, actionId, playerId, this] { + reply->deleteLater(); + int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + // Check HTTP status code + if (status != 200 || reply->error() != QNetworkReply::NoError) { + if (reply->error() == QNetworkReply::HostNotFoundError) { + emit connectionChanged(false); + } + if (status == 400 || status == 401) { + emit authenticationStatusChanged(false); + } + emit actionExecuted(actionId, false); + qCWarning(dcSonos()) << "Request error:" << status << reply->errorString(); + return; + } + emit connectionChanged(true); + emit authenticationStatusChanged(true); + emit actionExecuted(actionId, true); + getPlayerVolume(playerId); + }); + return actionId; +} + +QUuid Sonos::setPlayerMute(const QByteArray &playerId, bool mute) +{ + QNetworkRequest request; + request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, "application/json"); + request.setRawHeader("Authorization", "Bearer " + m_accessToken); + request.setRawHeader("X-Sonos-Api-Key", m_clientKey); + request.setUrl(QUrl(m_baseControlUrl + "/players/" + playerId + "/playerVolume")); + QUuid actionId = QUuid::createUuid(); + + QJsonObject object; + object.insert("muted", QJsonValue::fromVariant(mute)); + QJsonDocument doc(object); + + QNetworkReply *reply = m_networkManager->post(request, doc.toJson(QJsonDocument::Compact)); + connect(reply, &QNetworkReply::finished, this, [reply, actionId, playerId, this] { + reply->deleteLater(); + int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + // Check HTTP status code + if (status != 200 || reply->error() != QNetworkReply::NoError) { + if (reply->error() == QNetworkReply::HostNotFoundError) { + emit connectionChanged(false); + } + if (status == 400 || status == 401) { + emit authenticationStatusChanged(false); + } + emit actionExecuted(actionId, false); + qCWarning(dcSonos()) << "Request error:" << status << reply->errorString(); + return; + } + emit connectionChanged(true); + emit authenticationStatusChanged(true); + emit actionExecuted(actionId, true); + getPlayerVolume(playerId); + }); + return actionId; +} + +void Sonos::getPlaylist(const QString &householdId, const QString &playlistId) +{ + QNetworkRequest request; + request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, "application/json"); + request.setRawHeader("Authorization", "Bearer " + m_accessToken); + request.setRawHeader("X-Sonos-Api-Key", m_clientKey); + request.setUrl(QUrl(m_baseControlUrl + "/households/" + householdId + "/playlists/getPlaylist")); + + + QJsonObject object; + object["playlistId"] = playlistId; + QJsonDocument doc(object); + + QNetworkReply *reply = m_networkManager->post(request, doc.toJson(QJsonDocument::Compact)); + connect(reply, &QNetworkReply::finished, this, [reply, householdId, this] { + reply->deleteLater(); + int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + // Check HTTP status code + if (status != 200 || reply->error() != QNetworkReply::NoError) { + if (reply->error() == QNetworkReply::HostNotFoundError) { + emit connectionChanged(false); + } + if (status == 400 || status == 401) { + emit authenticationStatusChanged(false); + } + qCWarning(dcSonos()) << "Request error:" << status << reply->errorString(); + return; + } + emit connectionChanged(true); + emit authenticationStatusChanged(true); + + //qDebug(dcSonos()) << "Received response from Sonos" << reply->readAll(); + QJsonParseError error; + QJsonDocument data = QJsonDocument::fromJson(reply->readAll(), &error); + if (error.error != QJsonParseError::NoError) { + qCWarning(dcSonos()) << "Json parse error" << error.errorString(); + return; + } + + QVariantMap variant = data.toVariant().toMap(); + + if (!variant.contains("tracks")) + return; + + PlaylistSummaryObject playlist; + QVariantList array = variant["tracks"].toList(); + foreach (const QVariant &value, array) { + QVariantMap itemObject = value.toMap(); + PlaylistTrackObject track; + track.name = itemObject["name"].toString(); + track.album = itemObject["album"].toString(); + track.artist= itemObject["artist"].toString(); + playlist.tracks.append(track); + } + emit playlistSummaryReceived(householdId, playlist); + }); +} + +void Sonos::getPlaylists(const QString &householdId) +{ + QNetworkRequest request; + request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, "application/json"); + request.setRawHeader("Authorization", "Bearer " + m_accessToken); + request.setRawHeader("X-Sonos-Api-Key", m_clientKey); + request.setUrl(QUrl(m_baseControlUrl + "/households/" + householdId + "/playlists")); + QNetworkReply *reply = m_networkManager->get(request); + connect(reply, &QNetworkReply::finished, this, [reply, householdId, this] { + reply->deleteLater(); + int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + // Check HTTP status code + if (status != 200 || reply->error() != QNetworkReply::NoError) { + if (reply->error() == QNetworkReply::HostNotFoundError) { + emit connectionChanged(false); + } + if (status == 400 || status == 401) { + emit authenticationStatusChanged(false); + } + qCWarning(dcSonos()) << "Request error:" << status << reply->errorString(); + return; + } + emit connectionChanged(true); + emit authenticationStatusChanged(true); + + //qDebug(dcSonos()) << "Received response from Sonos" << reply->readAll(); + QJsonParseError error; + QJsonDocument data = QJsonDocument::fromJson(reply->readAll(), &error); + if (!data.isObject()) { + qCWarning(dcSonos()) << "Json parse error:" << error.errorString(); + return; + } + + if (!data.toVariant().toMap().contains("playlists")) + return; + + QVariantList array = data.toVariant().toMap().value("playlists").toList(); + QList playlists; + foreach (const QVariant &value, array) { + QVariantMap itemObject = value.toMap(); + PlaylistObject playlist; + playlist.id = itemObject["id"].toString(); + playlist.name = itemObject["name"].toString(); + playlist.type = itemObject["type"].toString(); + playlist.trackCount = itemObject["trackCount"].toString(); + playlists.append(playlist); + } + emit playlistsReceived(householdId, playlists); + }); +} + +QUuid Sonos::loadPlaylist(const QString &groupId, const QString &playlistId) +{ + QNetworkRequest request; + request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, "application/json"); + request.setRawHeader("Authorization", "Bearer " + m_accessToken); + request.setRawHeader("X-Sonos-Api-Key", m_clientKey); + request.setUrl(QUrl(m_baseControlUrl + "/groups/" + groupId + "/playlists")); + QUuid actionId = QUuid::createUuid(); + + QJsonObject object; + object["playlistId"] = playlistId; + QJsonDocument doc(object); + + QNetworkReply *reply = m_networkManager->post(request, doc.toJson(QJsonDocument::Compact)); + connect(reply, &QNetworkReply::finished, this, [reply, actionId, this] { + reply->deleteLater(); + int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + // Check HTTP status code + if (status != 200 || reply->error() != QNetworkReply::NoError) { + if (reply->error() == QNetworkReply::HostNotFoundError) { + emit connectionChanged(false); + } + if (status == 400 || status == 401) { + emit authenticationStatusChanged(false); + } + emit actionExecuted(actionId, false); + qCWarning(dcSonos()) << "Request error:" << status << reply->errorString(); + return; + } + emit connectionChanged(true); + emit authenticationStatusChanged(true); + emit actionExecuted(actionId, true); + }); + return actionId; +} + +void Sonos::getPlayerSettings(const QString &playerId) +{ + QNetworkRequest request; + request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, "application/json"); + request.setRawHeader("Authorization", "Bearer " + m_accessToken); + request.setRawHeader("X-Sonos-Api-Key", m_clientKey); + request.setUrl(QUrl(m_baseControlUrl + "/players/" + playerId + "/settings/player")); + QNetworkReply *reply = m_networkManager->get(request); + connect(reply, &QNetworkReply::finished, this, [reply, playerId, this] { + reply->deleteLater(); + int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + // Check HTTP status code + if (status != 200 || reply->error() != QNetworkReply::NoError) { + if (reply->error() == QNetworkReply::HostNotFoundError) { + emit connectionChanged(false); + } + if (status == 400 || status == 401) { + emit authenticationStatusChanged(false); + } + qCWarning(dcSonos()) << "Request error:" << status << reply->errorString(); + return; + } + emit connectionChanged(true); + emit authenticationStatusChanged(true); + + QJsonParseError error; + QJsonDocument jsonDoc = QJsonDocument::fromJson(reply->readAll(), &error); + if (error.error != QJsonParseError::NoError) { + qCWarning(dcSonos()) << "Json parse error" << error.errorString(); + return; + } + + QVariantMap data = jsonDoc.toVariant().toMap(); + PlayerSettingsObject playerSettings; + playerSettings.monoMode = data["monoMode"].toBool(); + playerSettings.volumeMode = data["volumeMode"].toString(); + playerSettings.wifiDisabled = data["wifiDisable"].toBool(); + playerSettings.volumeScalingFactor = data["wifiDisable"].toDouble(); + emit playerSettingsRecieved(playerId, playerSettings); + }); +} + +QUuid Sonos::setPlayerSettings(const QString &playerId, PlayerSettingsObject settings) +{ + QNetworkRequest request; + request.setHeader(QNetworkRequest::KnownHeaders::ContentTypeHeader, "application/json"); + request.setRawHeader("Authorization", "Bearer " + m_accessToken); + request.setRawHeader("X-Sonos-Api-Key", m_clientKey); + request.setUrl(QUrl(m_baseControlUrl + "/players/" + playerId + "/settings/player")); + QUuid actionId = QUuid::createUuid(); + + QJsonObject object; + object["volumeMode"] = settings.volumeMode; + object["volumeScalingFactor"] = settings.volumeScalingFactor; + object["monoMode"] = settings.monoMode; + object["wifiDisable"] = settings.wifiDisabled; + QJsonDocument doc(object); + + QNetworkReply *reply = m_networkManager->post(request, doc.toJson(QJsonDocument::Compact)); + connect(reply, &QNetworkReply::finished, this, [reply, actionId, playerId, this] { + reply->deleteLater(); + int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + + // Check HTTP status code + if (status != 200 || reply->error() != QNetworkReply::NoError) { + if (reply->error() == QNetworkReply::HostNotFoundError) { + emit connectionChanged(false); + } + if (status == 400 || status == 401) { + emit authenticationStatusChanged(false); + } + emit actionExecuted(actionId, false); + qCWarning(dcSonos()) << "Request error:" << status << reply->errorString(); + return; + } + emit connectionChanged(true); + emit authenticationStatusChanged(true); + emit actionExecuted(actionId, true); + getPlayerSettings(playerId); + }); + return actionId; +} + +void Sonos::onRefreshTimeout() +{ + qCDebug(dcSonos) << "Refresh authentication token"; + getAccessTokenFromRefreshToken(m_refreshToken); +} + + +void Sonos::getAccessTokenFromRefreshToken(const QByteArray &refreshToken) +{ + if (refreshToken.isEmpty()) { + qWarning(dcSonos()) << "No refresh token given!"; + emit authenticationStatusChanged(false); + return; + } + + QUrl url(m_baseAuthorizationUrl); + QUrlQuery query; + query.clear(); + query.addQueryItem("grant_type", "refresh_token"); + query.addQueryItem("refresh_token", refreshToken); + url.setQuery(query); + + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded; charset=UTF-8"); + + QByteArray auth = QByteArray(m_clientKey + ':' + m_clientSecret).toBase64(QByteArray::Base64Encoding | QByteArray::KeepTrailingEquals); + request.setRawHeader("Authorization", QString("Basic %1").arg(QString(auth)).toUtf8()); + + QNetworkReply *reply = m_networkManager->post(request, QByteArray()); + connect(reply, &QNetworkReply::finished, this, [this, reply](){ + reply->deleteLater(); + + QJsonDocument jsonDoc = QJsonDocument::fromJson(reply->readAll()); + int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + if (status != 200 || reply->error() != QNetworkReply::NoError) { + if(jsonDoc.toVariant().toMap().contains("error_description")) { + qWarning(dcSonos()) << "Access token error:" << jsonDoc.toVariant().toMap().value("error_description").toString(); + } + emit authenticationStatusChanged(false); + return; + } + if(!jsonDoc.toVariant().toMap().contains("access_token")) { + emit authenticationStatusChanged(false); + return; + } + m_accessToken = jsonDoc.toVariant().toMap().value("access_token").toByteArray(); + + if (jsonDoc.toVariant().toMap().contains("expires_in")) { + int expireTime = jsonDoc.toVariant().toMap().value("expires_in").toInt(); + qCDebug(dcSonos()) << "Access token expires at" << QDateTime::currentDateTime().addSecs(expireTime).toString(); + if (!m_tokenRefreshTimer) { + qWarning(dcSonos()) << "Access token refresh timer not initialized"; + return; + } + m_tokenRefreshTimer->start((expireTime - 20) * 1000); + } + emit authenticationStatusChanged(true);; + }); +} + +void Sonos::getAccessTokenFromAuthorizationCode(const QByteArray &authorizationCode) +{ + // Obtaining access token + if(authorizationCode.isEmpty()) + qWarning(dcSonos) << "No auhtorization code given!"; + if(m_clientKey.isEmpty()) + qWarning(dcSonos) << "Client key not set!"; + if(m_clientSecret.isEmpty()) + qWarning(dcSonos) << "Client secret not set!"; + + QUrl url = QUrl(m_baseAuthorizationUrl); + QUrlQuery query; + query.clear(); + query.addQueryItem("grant_type", "authorization_code"); + query.addQueryItem("code", authorizationCode); + query.addQueryItem("redirect_uri", m_redirectUri); + url.setQuery(query); + + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded;charset=utf-8"); + + QByteArray auth = QByteArray(m_clientKey + ':' + m_clientSecret).toBase64(QByteArray::Base64Encoding | QByteArray::KeepTrailingEquals); + request.setRawHeader("Authorization", QString("Basic %1").arg(QString(auth)).toUtf8()); + + QNetworkReply *reply = m_networkManager->post(request, QByteArray()); + connect(reply, &QNetworkReply::finished, this, [this, reply](){ + reply->deleteLater(); + QJsonDocument jsonDoc = QJsonDocument::fromJson(reply->readAll()); + + int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + switch (status){ + case 400: + if(!jsonDoc.toVariant().toMap().contains("error")) { + if(jsonDoc.toVariant().toMap().value("error").toString() == "invalid_client") { + qWarning(dcSonos()) << "Client token provided doesn’t correspond to client that generated auth code."; + } + if(jsonDoc.toVariant().toMap().value("error").toString() == "invalid_redirect_uri") { + qWarning(dcSonos()) << "Missing redirect_uri parameter."; + } + if(jsonDoc.toVariant().toMap().value("error").toString() == "invalid_code") { + qWarning(dcSonos()) << "Expired authorization code."; + } + } + return; + case 401: + qWarning(dcSonos()) << "Client does not have permission to use this API."; + return; + case 405: + qWarning(dcSonos()) << "Wrong HTTP method used."; + return; + default: + break; + } + qCDebug(dcSonos()) << "Sonos accessToken reply:" << this << reply->error() << reply->errorString() << jsonDoc.toJson(); + if(!jsonDoc.toVariant().toMap().contains("access_token") || !jsonDoc.toVariant().toMap().contains("refresh_token") ) { + emit authenticationStatusChanged(false); + return; + } + qCDebug(dcSonos()) << "Access token:" << jsonDoc.toVariant().toMap().value("access_token").toString(); + m_accessToken = jsonDoc.toVariant().toMap().value("access_token").toByteArray(); + + qCDebug(dcSonos()) << "Refresh token:" << jsonDoc.toVariant().toMap().value("refresh_token").toString(); + m_refreshToken = jsonDoc.toVariant().toMap().value("refresh_token").toByteArray(); + + if (jsonDoc.toVariant().toMap().contains("expires_in")) { + int expireTime = jsonDoc.toVariant().toMap().value("expires_in").toInt(); + qCDebug(dcSonos()) << "expires at" << QDateTime::currentDateTime().addSecs(expireTime).toString(); + if (!m_tokenRefreshTimer) { + qWarning(dcSonos()) << "Token refresh timer not initialized"; + emit authenticationStatusChanged(false); + return; + } + m_tokenRefreshTimer->start((expireTime - 20) * 1000); + } + emit authenticationStatusChanged(true); + }); +} diff --git a/sonos/sonos.h b/sonos/sonos.h new file mode 100644 index 00000000..cb21b678 --- /dev/null +++ b/sonos/sonos.h @@ -0,0 +1,299 @@ +/* * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * + * * + * Copyright (C) 2019 Bernhard Trinnes * + * * + * This file is part of nymea. * + * * + * This library is free software; you can redistribute it and/or * + * modify it under the terms of the GNU Lesser General Public * + * License as published by the Free Software Foundation; either * + * version 2.1 of the License, or (at your option) any later version. * + * * + * This library is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * + * Lesser General Public License for more details. * + * * + * You should have received a copy of the GNU Lesser General Public * + * License along with this library; If not, see * + * . * + * * + * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * */ + +#ifndef SONOS_H +#define SONOS_H + +#include +#include + +#include "network/networkaccessmanager.h" +#include "devices/device.h" + +class Sonos : public QObject +{ + Q_OBJECT +public: + + enum RepeatMode { + RepeatModeOne, + RepeatModeAll, + RepeatModeNone + }; + + enum PlayBackState { + PlayBackStateBuffering, + PlayBackStateIdle, + PlayBackStatePause, + PlayBackStatePlaying + }; + + struct PlayMode { + bool repeat; + bool repeatOne; + bool shuffle; + bool crossfade; + }; + + /* Represents a Sonos household.*/ + struct GroupObject { + QString CoordinatorId; //Player acting as the group coordinator for the group + QString groupId; //The ID of the group. + QString playbackState; //The playback state corresponding to the group. + QList playerIds; //The IDs of the primary players in the group. + QString displayName; //The display name for the group, such as “Living Room” or “Kitchen + 2”. + }; + + struct VolumeObject { + int volume; //Group volume as an integer between 0 and 100, inclusive. + bool muted; //A value indicating whether or not the group is muted + bool fixed; //A value indicating whether or not the group volume is fixed or changeable. + }; + + /* + * The music service identifier or a pseudo-service identifier in the case of local library. */ + struct ServiceObject + { + QString id; + QString name; + QString imageUrl; + }; + + /* + * Describes a Sonos favorite in the household. + * You can see favorites in the My Sonos tab in the app. The following are not considered */ + struct FavoriteObject { + QString id; + QString name; + QString description; + QString imageUrl; + ServiceObject service; + }; + + struct PlaylistObject + { + QString id; + QString name; + QString type; + QString trackCount; + }; + + struct PlayerSettingsObject + { + QString volumeMode; + double volumeScalingFactor; + bool monoMode; + bool wifiDisabled; + }; + + + /* The music object identifier for the item in a music service. + * This identifies the content within a music service, the music service, + * and the account associated with the content. */ + struct MusicObjectId { + QString serviceId; + QString objectId; + QString accountId; + }; + + struct TrackPoliciesObject { + bool canCrossfade; + bool canResume; + bool canSeek; + bool canSkip; + bool canSkipBack; + bool canSkipToItem; + bool isVisible; + }; + + /* The artist of the track. */ + struct ArtistObject + { + QString name; + QString imageUrl; + MusicObjectId id; + // tags enum + }; + + struct AlbumObject + { + QString name; + ArtistObject artist; + }; + + struct ContainerObject + { + QString name; + QString type; + MusicObjectId id; + ServiceObject service; + QString imageUrl; + //tags enum + }; + + struct PlayBackObject { + QString itemId; + bool isDucking; + PlayBackState playbackState; + PlayMode playMode; + uint positionMillis; + QString previousItemId; + uint previousPositionMillis; + QString queueVersion; + }; + + /* + * A single music track or audio file. Tracks are identified by type, + * which determines the key values for the object types included. + * The following fields are shared by all types of tracks. */ + struct TrackObject { + QString type; + QString name; + QString imageUrl; + int trackNumber; + bool canCrossfade; + bool canSkip; + int durationMillis; + ArtistObject artist; + AlbumObject album; + ServiceObject service; + }; + + /* An item in a queue. Used for cloud queue tracks and radio stations that have track-like data + * for the currently playing content. For example, the currentItem and nextItem parameters in the + * metadataStatus event are item object types.*/ + struct ItemObject { + QString itemId; + TrackObject track; + bool deleted; + TrackPoliciesObject policies; + }; + + struct MetadataStatus { + ContainerObject container; + ItemObject currentItem; + ItemObject nextItem; + }; + + struct PlaylistTrackObject { + QString name; + QString artist; + QString album; + }; + + struct PlaylistSummaryObject { + QString id; + QString name; + QString type; + QList tracks; + }; + + explicit Sonos(NetworkAccessManager *networkManager, const QByteArray &clientId, const QByteArray &clientSecret, QObject *parent = nullptr); + + QUrl getLoginUrl(const QUrl &redirectUrl); + QByteArray accessToken(); + QByteArray refreshToken(); + void getAccessTokenFromRefreshToken(const QByteArray &refreshToken); + void getAccessTokenFromAuthorizationCode(const QByteArray &authorizationCode); + + void getHouseholds(); + QUuid getFavorites(const QString &householdId); + void getGroups(const QString &householdId); + + QUuid loadFavorite(const QString &groupId, const QString &faveriteId); + + //Group volume + void getGroupVolume(const QString &groupId); //Get the volume and mute state of a group. + //Group volume actions + QUuid setGroupVolume(const QString &groupId, int volume); //Set group volume to a specific level and unmute the group if muted. + QUuid setGroupMute(const QString &groupId, bool mute); //Mute and unmute the group. + QUuid setGroupRelativeVolume(const QString &groupId, int volumeDelta); //Increase or decrease group volume. + + //group playback + void getGroupPlaybackStatus(const QString &groupId); + + //Group playback actions + QUuid groupLoadLineIn(const QString &groupId); + QUuid groupPlay(const QString &groupId); + QUuid groupPause(const QString &groupId); + QUuid groupSeek(const QString &groupId, int possitionMillis); + QUuid groupSeekRelative(const QString &groupId, int deltaMillis); + QUuid groupSetPlayModes(const QString &groupId, PlayMode playMode); + QUuid groupSetShuffle(const QString &groupId, bool shuffle); + QUuid groupSetRepeat(const QString &groupId, RepeatMode repeatMode); + QUuid groupSetCrossfade(const QString &groupId, bool crossfade); + QUuid groupSkipToNextTrack(const QString &groupId); + QUuid groupSkipToPreviousTrack(const QString &groupId); + QUuid groupTogglePlayPause(const QString &groupId); + + //playbackMetadata + void getGroupMetadataStatus(const QString &groupId); + + // playerVolume + void getPlayerVolume(const QByteArray &playerId); + QUuid setPlayerVolume(const QByteArray &playerId, int volume); + QUuid setPlayerRelativeVolume(const QByteArray &playerId, int volumeDelta); + QUuid setPlayerMute(const QByteArray &playerId, bool mute); + + //Playlists API namespace + void getPlaylists(const QString &householdId); + void getPlaylist(const QString &householdId, const QString &playlistId); + QUuid loadPlaylist(const QString &groupId, const QString &playlistId); + + //Settings + void getPlayerSettings(const QString &playerId); + QUuid setPlayerSettings(const QString &playerId, PlayerSettingsObject settings); + +private: + QByteArray m_baseAuthorizationUrl = "https://api.sonos.com/login/v3/oauth/access"; + QByteArray m_baseControlUrl = "https://api.ws.sonos.com/control/api/v1"; + QByteArray m_clientKey; + QByteArray m_clientSecret; + + QByteArray m_accessToken; + QByteArray m_refreshToken; + QByteArray m_redirectUri; + + NetworkAccessManager *m_networkManager = nullptr; + QTimer *m_tokenRefreshTimer = nullptr; +private slots: + void onRefreshTimeout(); + +signals: + void connectionChanged(bool connected); + void authenticationStatusChanged(bool authenticated); + + void householdIdsReceived(QList householdIds); + void favoritesReceived(QUuid requestId, const QString &householdId, QList favorites); + void playlistsReceived(const QString &householdId, QList playlists); + void groupsReceived(const QString &householdId, QList groups); + void playlistSummaryReceived(const QString &householdId, PlaylistSummaryObject playlistSummary); + + void playBackStatusReceived(const QString &groupId, PlayBackObject playBack); + void metadataStatusReceived(const QString &groupId, MetadataStatus metaDataStatus); + void volumeReceived(const QString &groupId, VolumeObject groupVolume); + + void playerVolumeReceived(const QString &playerId, VolumeObject playerVolume); + void playerSettingsRecieved(const QString &playerId, PlayerSettingsObject playerSettings); + void actionExecuted(QUuid actionId, bool success); +}; +#endif // SONOS_H diff --git a/sonos/sonos.pro b/sonos/sonos.pro new file mode 100644 index 00000000..ee83ccea --- /dev/null +++ b/sonos/sonos.pro @@ -0,0 +1,13 @@ +include(../plugins.pri) + +QT += network + +TARGET = $$qtLibraryTarget(nymea_devicepluginsonos) + +SOURCES += \ + devicepluginsonos.cpp \ + sonos.cpp \ + +HEADERS += \ + devicepluginsonos.h \ + sonos.h \