diff --git a/de_web_plugin.cpp b/de_web_plugin.cpp index 7d27fb19ad..e5a3cee876 100644 --- a/de_web_plugin.cpp +++ b/de_web_plugin.cpp @@ -16066,6 +16066,10 @@ int DeRestPlugin::handleHttpRequest(const QHttpRequestHeader &hdr, QTcpSocket *s { ret = d->handleScenesApi(req, rsp); } + else if (apiModule == QLatin1String("hue-scenes")) + { + ret = d->handleHueScenesApi(req, rsp); + } else if (apiModule == QLatin1String("sensors")) { ret = d->handleSensorsApi(req, rsp); diff --git a/de_web_plugin_private.h b/de_web_plugin_private.h index f4b6cd7831..c270e7aa1c 100644 --- a/de_web_plugin_private.h +++ b/de_web_plugin_private.h @@ -808,7 +808,8 @@ enum TaskType TaskTuyaRequest = 41, TaskXmasLightStrip = 42, TaskSimpleMetering = 43, - TaskHueEffect = 44 + TaskHueEffect = 44, + TaskHueManufacturerSpecific = 45 }; enum XmasLightStripMode @@ -1058,6 +1059,11 @@ class DeRestPluginPrivate : public QObject int modifyScene(const ApiRequest &req, ApiResponse &rsp); int deleteScene(const ApiRequest &req, ApiResponse &rsp); + // REST API hue-scenes + int handleHueScenesApi(const ApiRequest &req, ApiResponse &rsp); + int playHueDynamicScene(const ApiRequest &req, ApiResponse &rsp); + int modifyHueScene(const ApiRequest &req, ApiResponse &rsp); + bool groupToMap(const ApiRequest &req, const Group *group, QVariantMap &map); // REST API schedules @@ -1432,9 +1438,18 @@ public Q_SLOTS: // Advanced features of Hue lights. QStringList getHueEffectNames(quint64 effectBitmap, bool colorloop); QStringList getHueGradientStyleNames(quint16 styleBitmap); + bool isHueEffectLight(const LightNode *lightNode); + bool isMappableToManufacturerSpecific(const QVariantMap &map); bool addTaskHueEffect(TaskItem &task, QString &effect); bool validateHueGradient(const ApiRequest &req, ApiResponse &rsp, QVariantMap &gradient, quint16 styleBitmap); bool addTaskHueGradient(TaskItem &task, QVariantMap &gradient); + bool validateHueLightState(ApiResponse &rsp, const LightNode *lightNode, QVariantMap &map, QList &validatedParameters); + bool validateHueDynamicScenePalette(ApiResponse &rsp, const Scene *scene, QVariantMap &map, QList &validatedParameters); + bool addTaskHueManufacturerSpecificSetState(TaskItem &task, const QVariantMap &items); + bool addTaskHueManufacturerSpecificAddScene(TaskItem &task, const quint16 groupId, const quint8 sceneId, const QVariantMap &items); + bool addTaskHueDynamicSceneRecall(TaskItem &task, const quint16 groupId, const quint8 sceneId, const QVariantMap &palette); + int setHueLightState(const ApiRequest &req, ApiResponse &rsp, TaskItem &taskRef, QVariantMap &map); + int setHueSceneLightState(const ApiRequest &req, ApiResponse &rsp, TaskItem &taskRef, QVariantMap &map); // Merry Christmas! bool isXmasLightStrip(const LightNode *lightNode); diff --git a/hue.cpp b/hue.cpp index f6eef06651..2a9b9e2947 100644 --- a/hue.cpp +++ b/hue.cpp @@ -9,12 +9,35 @@ #define HUE_EFFECTS_CLUSTER_ID 0xFC03 +// Constants for 'timed_effect duration' +#define RESOLUTION_01s_BASE 0xFC +#define RESOLUTION_05s_BASE 0xCC +#define RESOLUTION_15s_BASE 0xA5 +#define RESOLUTION_01m_BASE 0x79 +#define RESOLUTION_05m_BASE 0x4A + +#define RESOLUTION_01s (1 * 10) // 01s. +#define RESOLUTION_05s (5 * 10) // 05s. +#define RESOLUTION_15s (15 * 10) // 15s. +#define RESOLUTION_01m (1 * 60 * 10) // 01min. +#define RESOLUTION_05m (5 * 60 * 100) // 05min. + +#define RESOLUTION_01s_LIMIT (60 * 10) // 01min. +#define RESOLUTION_05s_LIMIT (5 * 60 * 10) // 05min. +#define RESOLUTION_15s_LIMIT (15 * 60 * 10) // 15min. +#define RESOLUTION_01m_LIMIT (60 * 60 * 10) // 60min. +#define RESOLUTION_05m_LIMIT (6 * 60 * 60 * 10) // 06hrs. + +// List of 'state' keys that can be mapped into the '0xfc03' cluster's '0x00' command. +QList supportedStateKeys = {"on", "bri", "ct", "xy", "transitiontime", "effect", "effect_duration", "effect_speed"}; + struct code { quint8 value; QString name; }; code effects[] = { + { 0x00, QLatin1String("none") }, { 0x01, QLatin1String("candle") }, { 0x02, QLatin1String("fire") }, { 0x03, QLatin1String("prism") }, @@ -41,6 +64,39 @@ quint8 effectNameToValue(QString &effectName) return 0xFF; } +/*! Test if a LightNode is a Philips Hue light that supports effects. + \param lightNode - the light node to test + */ +bool DeRestPluginPrivate::isHueEffectLight(const LightNode *lightNode) +{ + return lightNode != nullptr && + lightNode->manufacturerCode() == VENDOR_PHILIPS && + lightNode->item(RCapColorEffects); +} + +/*! Test whether all the items in a request can be mapped into an '0xfc03' cluster command. + \param map - the map to test + */ +bool DeRestPluginPrivate::isMappableToManufacturerSpecific(const QVariantMap &map) +{ + const QList keyList = map.keys(); + for (const QString &key : keyList) + { + // Ensure 'effect' is not 'colorloop' - that's handled through the ZCL + if ((key == "effect") && (map[key].toString() == "colorloop")) + { + return false; + } + + if (!supportedStateKeys.contains(key)) + { + return false; + } + } + + return true; +} + /*! Return a list of effect names corresponding to the bitmap of supported effects. \param effectBitmap - the bitmap with supported effects (from 0x0011) @@ -101,6 +157,161 @@ QStringList DeRestPluginPrivate::getHueGradientStyleNames(quint16 styleBitmap) const double maxX = 0.7347; const double maxY = 0.8431; +// MARK: - Stream Helpers + +void streamPoint(QDataStream &stream, double x, double y) +{ + const quint16 rawX = (x >= maxX) ? 4095 : floor(x * 4095 / maxX); + const quint16 rawY = (y >= maxY) ? 4095 : floor(y * 4095 / maxY); + stream << (quint8) (rawX & 0x0FF); + stream << (quint8) (((rawX & 0xF00) >> 8) | ((rawY & 0x00F) << 4)); + stream << (quint8) ((rawY & 0xFF0) >> 4); +} + +void streamHueManufacturerSpecificState(QDataStream &stream, const QVariantMap &items) +{ + // Set payload contents + quint16 payloadBitmask = 0x00; + if (items.contains("on")) { payloadBitmask |= (1 << 0); } + if (items.contains("bri")) { payloadBitmask |= (1 << 1); } + if (items.contains("ct")) { payloadBitmask |= (1 << 2); } + if (items.contains("xy")) { payloadBitmask |= (1 << 3); } + if (items.contains("transitiontime")) { payloadBitmask |= (1 << 4); } + if (items.contains("effect")) { payloadBitmask |= (1 << 5); } + if (items.contains("gradient")) { payloadBitmask |= (1 << 6); } + // 'effect_duration' and 'effect_speed' share the same bit flag. + if (items.contains("effect_duration")) { payloadBitmask |= (1 << 7); } + if (items.contains("effect_speed")) { payloadBitmask |= (1 << 7); } + stream << (quint16)payloadBitmask; + + // !!!: The order the items are processed in is important + + if (items.contains("on")) + { + stream << (quint8)(items["on"].toBool() ? 0x01 : 0x00); + } + + if (items.contains("bri")) + { + const quint8 bri = items["bri"].toUInt(); + stream << (quint8)(bri > 0xFE ? 0xFE : bri); + } + + if (items.contains("ct")) + { + stream << (quint16)(items["ct"].toUInt()); + } + + if (items.contains("xy")) + { + const QVariantList xy = items["xy"].toList(); + quint16 colorX = static_cast(xy[0].toDouble() * 65535.0); + quint16 colorY = static_cast(xy[1].toDouble() * 65535.0); + + if (colorX > 65279) { colorX = 65279; } + else if (colorX == 0) { colorX = 1; } + + if (colorY > 65279) { colorY = 65279; } + else if (colorY == 0) { colorY = 1; } + + stream << (quint32)((colorY << 16) + colorX); + } + + if (items.contains("transitiontime")) + { + const quint8 tt = items["transitiontime"].toUInt(); + stream << (quint16)(tt > 0xFFFE ? 0xFFFE : tt); + } + + if (items.contains("effect")) + { + QString e = items["effect"].toString(); + stream << (quint8)(effectNameToValue(e)); + } + + if (items.contains("effect_duration")) + { + const uint ed = items["effect_duration"].toUInt(); + + const uint resolutionBase = (ed == 0) ? 0 : + (ed < RESOLUTION_01s_LIMIT) ? RESOLUTION_01s_BASE : + (ed < RESOLUTION_05s_LIMIT) ? RESOLUTION_05s_BASE : + (ed < RESOLUTION_15s_LIMIT) ? RESOLUTION_15s_BASE : + (ed < RESOLUTION_01m_LIMIT) ? RESOLUTION_01m_BASE : + (ed < RESOLUTION_05m_LIMIT) ? RESOLUTION_05m_BASE : 0; + + const uint resolution = (ed == 0) ? 1 : + (ed < RESOLUTION_01s_LIMIT) ? RESOLUTION_01s : + (ed < RESOLUTION_05s_LIMIT) ? RESOLUTION_05s : + (ed < RESOLUTION_15s_LIMIT) ? RESOLUTION_15s : + (ed < RESOLUTION_01m_LIMIT) ? RESOLUTION_01m : + (ed < RESOLUTION_05m_LIMIT) ? RESOLUTION_05m : 1; + + const quint8 effectDuration = resolutionBase - (ed / resolution); + stream << (quint8)(effectDuration); + } + else if (items.contains("effect_speed")) + { + const double es = items["effect_speed"].toDouble(); + quint8 effectSpeed = static_cast(es * 254.0); + stream << (quint8)(effectSpeed); + } +} + +void streamHueManufacturerSpecificPalette(QDataStream &stream, const QVariantMap &palette) +{ + // Set payload contents + quint8 payloadBitmask = 0x00; + if (palette.contains("transitiontime")) { payloadBitmask |= 0x09; } + if (palette.contains("bri")) { payloadBitmask |= 0x0A; } + if (palette.contains("xy")) { payloadBitmask |= 0x0C; } + stream << (quint8)payloadBitmask; + + // !!!: The order the items are processed in is important + + if (palette.contains("transitiontime")) + { + const quint8 tt = palette["transitiontime"].toUInt(); + stream << (quint16)(tt > 0xFFFE ? 0xFFFE : tt); + } + + if (palette.contains("bri")) + { + const quint8 bri = palette["bri"].toUInt(); + stream << (quint8)(bri > 0xFE ? 0xFE : bri); + } + + if (palette.contains("xy")) + { + QVariantList colors = palette["xy"].toList(); + QVariantList color; + + const quint8 nColors = colors.length(); + stream << (quint8) (1 + 3 * (nColors + 1)); + stream << (quint8) (nColors << 4); + + stream << (quint8) 0x00; + stream << (quint8) 0x00; + stream << (quint8) 0x00; + + // Palette Colors + for (auto &color : colors) + { + QVariantList xy = color.toList(); + streamPoint(stream, xy[0].toDouble(), xy[1].toDouble()); + } + } + + if (palette.contains("effect_speed")) + { + const double es = palette["effect_speed"].toDouble(); + quint8 effectSpeed = static_cast(es * 254.0); + stream << (quint8)(effectSpeed); + } +} + +// MARK: - Tasks + /*! Add a Hue effect task to the queue. \param task - the task item @@ -149,6 +360,203 @@ bool DeRestPluginPrivate::addTaskHueEffect(TaskItem &task, QString &effectName) return addTask(task); } +/*! Add a Hue gradient task to the queue. + + \param task - the task item + \param gradient - the gradient + \return true - on success + false - on error + */ +bool DeRestPluginPrivate::addTaskHueGradient(TaskItem &task, QVariantMap &gradient) +{ + task.taskType = TaskHueGradient; + task.req.setClusterId(HUE_EFFECTS_CLUSTER_ID); + task.req.setProfileId(HA_PROFILE_ID); + + task.zclFrame.payload().clear(); + task.zclFrame.setSequenceNumber(zclSeq++); + task.zclFrame.setCommandId(0x00); + task.zclFrame.setManufacturerCode(VENDOR_PHILIPS); + task.zclFrame.setFrameControl(deCONZ::ZclFCClusterCommand | + deCONZ::ZclFCManufacturerSpecific | + deCONZ::ZclFCDirectionClientToServer | + deCONZ::ZclFCDisableDefaultResponse); + + QVariantList points = gradient["points"].toList(); + QVariantList point; + quint8 style = 0xFF; + for (auto &s: styles) + { + if (gradient["style"] == s.name) + { + style = s.value; + break; + } + } + + { // payload + QDataStream stream(&task.zclFrame.payload(), QIODevice::WriteOnly); + stream.setByteOrder(QDataStream::LittleEndian); + + stream << (quint16) 0x0150; // set gradient + stream << (quint16) 0x0004; // transitiontime + + const quint8 nPoints = points.length(); + stream << (quint8) (1 + 3 * (nPoints + 1)); + stream << (quint8) (nPoints << 4); + stream << (quint8) style; + stream << (quint8) 0; + stream << (quint8) 0; + for (auto &point : points) + { + QVariantList xy = point.toList(); + streamPoint(stream, xy[0].toDouble(), xy[1].toDouble()); + } + stream << (quint8) ((gradient["segments"].toUInt() << 3) | gradient["color_adjustment"].toUInt()); + stream << (quint8) ((gradient["offset"].toUInt() << 3)| gradient["offset_adjustment"].toUInt()); + } + + { // ZCL frame + task.req.asdu().clear(); // cleanup old request data if there is any + QDataStream stream(&task.req.asdu(), QIODevice::WriteOnly); + stream.setByteOrder(QDataStream::LittleEndian); + task.zclFrame.writeToStream(stream); + } + return addTask(task); +} + +/*! Add a Hue Manufacturer Specific '0xfc03' '0x00' task to the queue. + + \param task - the task item + \param items - the list of items in the payload + \return true - on success + false - on error + */ +bool DeRestPluginPrivate::addTaskHueManufacturerSpecificSetState(TaskItem &task, const QVariantMap &items) +{ + task.taskType = TaskHueManufacturerSpecific; + task.req.setClusterId(HUE_EFFECTS_CLUSTER_ID); + task.req.setProfileId(HA_PROFILE_ID); + + task.zclFrame.payload().clear(); + task.zclFrame.setSequenceNumber(zclSeq++); + task.zclFrame.setCommandId(0x00); + task.zclFrame.setManufacturerCode(VENDOR_PHILIPS); + task.zclFrame.setFrameControl(deCONZ::ZclFCClusterCommand | + deCONZ::ZclFCManufacturerSpecific | + deCONZ::ZclFCDirectionClientToServer | + deCONZ::ZclFCDisableDefaultResponse); + + { // Payload + QDataStream stream(&task.zclFrame.payload(), QIODevice::WriteOnly); + stream.setByteOrder(QDataStream::LittleEndian); + + streamHueManufacturerSpecificState(stream, items); + } + + { // ZCL frame + task.req.asdu().clear(); // cleanup old request data if there is any + QDataStream stream(&task.req.asdu(), QIODevice::WriteOnly); + stream.setByteOrder(QDataStream::LittleEndian); + task.zclFrame.writeToStream(stream); + } + + return addTask(task); +} + +/*! Add a Hue Manufacturer Specific '0x0005' '0x02' task to the queue. + + \param task - the task item + \param groupId - the id of the scene's parent group + \param sceneId - the id of the scene to modify + \param payloadItems - the contents in the payload + \param items - the list of items in the payload + \return true - on success + false - on error + */ +bool DeRestPluginPrivate::addTaskHueManufacturerSpecificAddScene(TaskItem &task, const quint16 groupId, const quint8 sceneId, const QVariantMap &items) +{ + task.taskType = TaskHueManufacturerSpecific; + task.req.setClusterId(SCENE_CLUSTER_ID); + task.req.setProfileId(HA_PROFILE_ID); + + task.zclFrame.payload().clear(); + task.zclFrame.setSequenceNumber(zclSeq++); + task.zclFrame.setCommandId(0x02); + task.zclFrame.setManufacturerCode(VENDOR_PHILIPS); + task.zclFrame.setFrameControl(deCONZ::ZclFCClusterCommand | + deCONZ::ZclFCManufacturerSpecific | + deCONZ::ZclFCDirectionClientToServer | + deCONZ::ZclFCDisableDefaultResponse); + + { // Payload + QDataStream stream(&task.zclFrame.payload(), QIODevice::WriteOnly); + stream.setByteOrder(QDataStream::LittleEndian); + + // Group and Scene IDs + stream << (quint16) groupId; + stream << (quint8) sceneId; + + streamHueManufacturerSpecificState(stream, items); + } + + { // ZCL frame + task.req.asdu().clear(); // cleanup old request data if there is any + QDataStream stream(&task.req.asdu(), QIODevice::WriteOnly); + stream.setByteOrder(QDataStream::LittleEndian); + task.zclFrame.writeToStream(stream); + } + + return addTask(task); +} + +/*! Add a Play Hue Dynamic Scene task to the queue. + + \param task - the task item + \param groupId - the id of the scene's parent group + \param sceneId - the id of the scene to recall + \param palette - the list of palette items in the payload + \return true - on success + false - on error + */ +bool DeRestPluginPrivate::addTaskHueDynamicSceneRecall(TaskItem &task, const quint16 groupId, const quint8 sceneId, const QVariantMap &palette) +{ + task.taskType = TaskHueManufacturerSpecific; + task.req.setClusterId(SCENE_CLUSTER_ID); + task.req.setProfileId(HA_PROFILE_ID); + + task.zclFrame.payload().clear(); + task.zclFrame.setSequenceNumber(zclSeq++); + task.zclFrame.setCommandId(0x00); + task.zclFrame.setManufacturerCode(VENDOR_PHILIPS); + task.zclFrame.setFrameControl(deCONZ::ZclFCClusterCommand | + deCONZ::ZclFCManufacturerSpecific | + deCONZ::ZclFCDirectionClientToServer | + deCONZ::ZclFCDisableDefaultResponse); + + { // Payload + QDataStream stream(&task.zclFrame.payload(), QIODevice::WriteOnly); + stream.setByteOrder(QDataStream::LittleEndian); + + // Group and Scene IDs + stream << (quint16) groupId; + stream << (quint8) sceneId; + + streamHueManufacturerSpecificPalette(stream, palette); + } + + { // ZCL frame + task.req.asdu().clear(); // cleanup old request data if there is any + QDataStream stream(&task.req.asdu(), QIODevice::WriteOnly); + stream.setByteOrder(QDataStream::LittleEndian); + task.zclFrame.writeToStream(stream); + } + + return addTask(task); +} + +// MARK: - Validation Helpers + bool DeRestPluginPrivate::validateHueGradient(const ApiRequest &req, ApiResponse &rsp, QVariantMap &gradient, quint16 styleBitmap = 0x0001) { QString id = req.path[3]; @@ -261,76 +669,388 @@ bool DeRestPluginPrivate::validateHueGradient(const ApiRequest &req, ApiResponse return ok; } -void streamPoint(QDataStream &stream, double x, double y) +bool DeRestPluginPrivate::validateHueLightState(ApiResponse &rsp, const LightNode *lightNode, QVariantMap &map, QList &validatedParameters) { - const quint16 rawX = (x >= maxX) ? 4095 : floor(x * 4095 / maxX); - const quint16 rawY = (y >= maxY) ? 4095 : floor(y * 4095 / maxY); - stream << (quint8) (rawX & 0x0FF); - stream << (quint8) (((rawX & 0xF00) >> 8) | ((rawY & 0x00F) << 4)); - stream << (quint8) ((rawY & 0xFF0) >> 4); + bool ok = false; + bool hasErrors = false; + + for (QVariantMap::const_iterator p = map.begin(); p != map.end(); p++) + { + bool paramOk = false; + bool valueOk = false; + QString param = p.key(); + + if (param == "on" && lightNode->item(RStateOn)) + { + paramOk = true; + if (map[param].type() == QVariant::Bool) + { + valueOk = true; + validatedParameters.append(param); + } + } + else if (param == "bri" && lightNode->item(RStateBri)) + { + paramOk = true; + if (map[param].type() == QVariant::Double) + { + const uint bri = map[param].toUInt(&ok); + if (ok && bri <= 0xFF) + { + // Clamp to 254 + valueOk = true; + validatedParameters.append(param); + map["bri"] = bri > 0xFE ? 0xFE : bri; + } + } + } + else if (param == "ct" && lightNode->item(RStateCt)) + { + paramOk = true; + if (map[param].type() == QVariant::Double) + { + const quint16 ctMin = lightNode->toNumber(RCapColorCtMin); + const quint16 ctMax = lightNode->toNumber(RCapColorCtMax); + const uint ct = map[param].toUInt(&ok); + if (ok && ct <= 0xFFFF) + { + // Clamp between ctMin and ctMax + valueOk = true; + validatedParameters.append(param); + map["ct"] = (ctMin < 500 && ct < ctMin) ? ctMin : (ctMax > ctMin && ct > ctMax) ? ctMax : ct; + } + } + } + else if (param == "xy" && lightNode->item(RStateX) && lightNode->item(RStateY)) + { + paramOk = true; + if (map[param].type() == QVariant::List) + { + QVariantList xy = map["xy"].toList(); + if (xy[0].type() == QVariant::Double && xy[1].type() == QVariant::Double) + { + const double x = xy[0].toDouble(&ok); + const double y = ok ? xy[1].toDouble(&ok) : 0; + if (ok && x >= 0.0 && x <= 1.0 && y >= 0.0 && y <= 1.0) + { + // Clamp to 0.9961 + valueOk = true; + validatedParameters.append(param); + QVariantList xy; + xy.append(x > 0.9961 ? 0.9961 : x); + xy.append(y > 0.9961 ? 0.9961 : y); + map["xy"] = xy; + } + } + } + } + else if (param == "transitiontime") + { + paramOk = true; + if (map[param].type() == QVariant::Double) + { + const uint tt = map[param].toUInt(&ok); + if (ok && tt <= 0xFFFF) + { + valueOk = true; + validatedParameters.append(param); + } + } + } + else if (param == "effect" && lightNode->item(RStateEffect)) + { + paramOk = true; + if (map[param].type() == QVariant::String) + { + QString e = map[param].toString(); + QStringList effectList = getHueEffectNames(lightNode->item(RCapColorEffects)->toNumber(), false); + if (effectList.indexOf(e) >= 0) + { + valueOk = true; + validatedParameters.append(param); + } + } + } + else if (param == "effect_duration") + { + paramOk = true; + if (map[param].type() == QVariant::Double) + { + const uint ed = map[param].toUInt(&ok); + if (ok && ed <= 216000) + { + valueOk = true; + validatedParameters.append(param); + } + } + } + else if (param == "effect_speed") + { + paramOk = true; + if (map[param].type() == QVariant::Double) + { + const double es = map[param].toDouble(&ok); + if (ok && es >= 0.0 && es <= 1.0) + { + valueOk = true; + validatedParameters.append(param); + } + } + } + + if (!paramOk) + { + hasErrors = true; + rsp.list.append(errorToMap(ERR_PARAMETER_NOT_AVAILABLE, QString("/lights/%1/state").arg(lightNode->id()), QString("parameter, %1, not available").arg(param))); + } + else if (!valueOk) + { + hasErrors = true; + rsp.list.append(errorToMap(ERR_INVALID_VALUE, QString("/lights/%1/state").arg(lightNode->id()), QString("invalid value, %1, for parameter, %2").arg(map[param].toString()).arg(param))); + } + } + + return !hasErrors; } -/*! Add a Hue gradient task to the queue. +bool DeRestPluginPrivate::validateHueDynamicScenePalette(ApiResponse &rsp, const Scene *scene, QVariantMap &map, QList &validatedParameters) +{ + bool ok = false; + bool hasErrors = false; - \param task - the task item - \param gradient - the gradient - \return true - on success - false - on error - */ -bool DeRestPluginPrivate::addTaskHueGradient(TaskItem &task, QVariantMap &gradient) + for (QVariantMap::const_iterator p = map.begin(); p != map.end(); p++) + { + bool paramOk = false; + bool valueOk = false; + QString param = p.key(); + + if (param == "bri") + { + paramOk = true; + if (map[param].type() == QVariant::Double) + { + const uint bri = map[param].toUInt(&ok); + if (ok && bri <= 0xFF) + { + // Clamp to 254 + valueOk = true; + validatedParameters.append(param); + map["bri"] = bri > 0xFE ? 0xFE : bri; + } + } + } + else if (param == "xy") + { + paramOk = true; + ok = true; + if (map[param].type() == QVariant::List) + { + bool check = false; + QVariantList colors = map["xy"].toList(); + + int i = -1; + for (auto &color : colors) + { + i++; + if (color.type() != QVariant::List) + { + rsp.list.append(errorToMap(ERR_INVALID_VALUE, QString("/groups/%1/scenes/%2").arg(QString(scene->groupAddress)).arg(QString(scene->id)), QString("invalid value, %1, for parameter, xy/%2").arg(color.toString()).arg(i))); + ok = false; + continue; + } + QVariantList &xy = *reinterpret_cast(color.data()); // Create reference instead of copy + if (xy.length() != 2) + { + rsp.list.append(errorToMap(ERR_INVALID_VALUE, QString("/groups/%1/scenes/%2").arg(QString(scene->groupAddress)).arg(QString(scene->id)), QString("invalid length, %1, for parameter, gradient/points/%2").arg(xy.length()).arg(i))); + ok = false; + continue; + } + double x = xy[0].toDouble(&check); + if (!check || x < 0 || x > 1) + { + rsp.list.append(errorToMap(ERR_INVALID_VALUE, QString("/groups/%1/scenes/%2").arg(QString(scene->groupAddress)).arg(QString(scene->id)), QString("invalid value, %1, for parameter, gradient/points/%2/0").arg(xy[0].toString()).arg(i))); + ok = false; + } + if (x > maxX) + xy[0] = maxX; // This is why we needed a reference + double y = xy[1].toDouble(&check); + if (!check || y < 0 || y > 1) + { + rsp.list.append(errorToMap(ERR_INVALID_VALUE, QString("/groups/%1/scenes/%2").arg(QString(scene->groupAddress)).arg(QString(scene->id)), QString("invalid value, %1, for parameter, gradient/points/%2/1").arg(xy[1].toString()).arg(i))); + ok = false; + } + if (y > maxY) + xy[1] = maxY; // This is why we needed a reference + } + + if (ok) + { + valueOk = true; + validatedParameters.append(param); + } + } + } + else if (param == "transitiontime") + { + paramOk = true; + if (map[param].type() == QVariant::Double) + { + const uint tt = map[param].toUInt(&ok); + if (ok && tt <= 0xFFFF) + { + valueOk = true; + validatedParameters.append(param); + } + } + } + else if (param == "effect_speed") + { + paramOk = true; + if (map[param].type() == QVariant::Double) + { + const double es = map[param].toDouble(&ok); + if (ok && es >= 0.0 && es <= 1.0) + { + valueOk = true; + validatedParameters.append(param); + } + } + } + + if (!paramOk) + { + hasErrors = true; + rsp.list.append(errorToMap(ERR_PARAMETER_NOT_AVAILABLE, QString("/groups/%1/scenes/%2").arg(QString(scene->groupAddress)).arg(QString(scene->id)), QString("parameter, %1, not available").arg(param))); + } + else if (!valueOk) + { + hasErrors = true; + rsp.list.append(errorToMap(ERR_INVALID_VALUE, QString("/groups/%1/scenes/%2").arg(QString(scene->groupAddress)).arg(QString(scene->id)), QString("invalid value, %1, for parameter, %2").arg(map[param].toString()).arg(param))); + } + } + + return !hasErrors; +} + +// MARK: - Light State + +int DeRestPluginPrivate::setHueLightState(const ApiRequest &req, ApiResponse &rsp, TaskItem &taskRef, QVariantMap &map) { - task.taskType = TaskHueGradient; - task.req.setClusterId(HUE_EFFECTS_CLUSTER_ID); - task.req.setProfileId(HA_PROFILE_ID); + bool ok; + QList validatedParameters; - task.zclFrame.payload().clear(); - task.zclFrame.setSequenceNumber(zclSeq++); - task.zclFrame.setCommandId(0x00); - task.zclFrame.setManufacturerCode(VENDOR_PHILIPS); - task.zclFrame.setFrameControl(deCONZ::ZclFCClusterCommand | - deCONZ::ZclFCManufacturerSpecific | - deCONZ::ZclFCDirectionClientToServer | - deCONZ::ZclFCDisableDefaultResponse); + bool hasErrors = validateHueLightState(rsp, taskRef.lightNode, map, validatedParameters); + ok = addTaskHueManufacturerSpecificSetState(taskRef, map); - QVariantList points = gradient["points"].toList(); - QVariantList point; - quint8 style = 0xFF; - for (auto &s: styles) + if (ok) { - if (gradient["style"] == s.name) + if ((map.contains("on")) && (validatedParameters.contains("on"))) { - style = s.value; - break; + const bool targetOn = map["on"].toBool(); + + QVariantMap rspItem; + QVariantMap rspItemState; + rspItemState[QString("/lights/%1/state/on").arg(taskRef.lightNode->id())] = targetOn; + rspItem["success"] = rspItemState; + rsp.list.append(rspItem); + + taskRef.lightNode->setValue(RStateOn, targetOn); } - } - { // payload - QDataStream stream(&task.zclFrame.payload(), QIODevice::WriteOnly); - stream.setByteOrder(QDataStream::LittleEndian); + if ((map.contains("bri")) && (validatedParameters.contains("bri"))) + { + const uint targetBri = map["bri"].toUInt(); - stream << (quint16) 0x0150; // set gradient - stream << (quint16) 0x0004; // transitiontime + QVariantMap rspItem; + QVariantMap rspItemState; + rspItemState[QString("/lights/%1/state/bri").arg(taskRef.lightNode->id())] = targetBri; + rspItem["success"] = rspItemState; + rsp.list.append(rspItem); - const quint8 nPoints = points.length(); - stream << (quint8) (1 + 3 * (nPoints + 1)); - stream << (quint8) (nPoints << 4); - stream << (quint8) style; - stream << (quint8) 0; - stream << (quint8) 0; - for (auto &point : points) + taskRef.lightNode->setValue(RStateBri, targetBri); + } + + if ((map.contains("ct")) && (validatedParameters.contains("ct"))) { - QVariantList xy = point.toList(); - streamPoint(stream, xy[0].toDouble(), xy[1].toDouble()); + const uint targetCt = map["ct"].toUInt(); + + QVariantMap rspItem; + QVariantMap rspItemState; + rspItemState[QString("/lights/%1/state/ct").arg(taskRef.lightNode->id())] = targetCt; + rspItem["success"] = rspItemState; + rsp.list.append(rspItem); + + taskRef.lightNode->setValue(RStateCt, targetCt); + taskRef.lightNode->setValue(RStateColorMode, QString("ct")); } - stream << (quint8) ((gradient["segments"].toUInt() << 3) | gradient["color_adjustment"].toUInt()); - stream << (quint8) ((gradient["offset"].toUInt() << 3)| gradient["offset_adjustment"].toUInt()); - } - { // ZCL frame - task.req.asdu().clear(); // cleanup old request data if there is any - QDataStream stream(&task.req.asdu(), QIODevice::WriteOnly); - stream.setByteOrder(QDataStream::LittleEndian); - task.zclFrame.writeToStream(stream); + if ((map.contains("xy")) && (validatedParameters.contains("xy"))) + { + QVariantList xy = map["xy"].toList(); + const double targetX = xy[0].toDouble(); + const double targetY = xy[1].toDouble(); + + QVariantMap rspItem; + QVariantMap rspItemState; + rspItemState[QString("/lights/%1/state/xy").arg(taskRef.lightNode->id())] = xy; + rspItem["success"] = rspItemState; + rsp.list.append(rspItem); + + taskRef.lightNode->setValue(RStateX, targetX * 65535); + taskRef.lightNode->setValue(RStateY, targetY * 65535); + taskRef.lightNode->setValue(RStateColorMode, QString("xy")); + } + + if ((map.contains("transitiontime")) && (validatedParameters.contains("transitiontime"))) + { + const uint tt = map["transitiontime"].toUInt(); + + QVariantMap rspItem; + QVariantMap rspItemState; + rspItemState[QString("/lights/%1/state/transitiontime").arg(taskRef.lightNode->id())] = tt; + rspItem["success"] = rspItemState; + rsp.list.append(rspItem); + } + + if ((map.contains("effect")) && (validatedParameters.contains("effect"))) + { + QString effect = map["effect"].toString(); + + QVariantMap rspItem; + QVariantMap rspItemState; + rspItemState[QString("/lights/%1/state/effect").arg(taskRef.lightNode->id())] = effect; + rspItem["success"] = rspItemState; + rsp.list.append(rspItem); + + taskRef.lightNode->setValue(RStateEffect, effect); + taskRef.lightNode->setValue(RStateColorMode, QString("effect")); + } + + if ((map.contains("effect_duration")) && (validatedParameters.contains("effect_duration"))) + { + const uint ed = map["effect_duration"].toUInt(); + + QVariantMap rspItem; + QVariantMap rspItemState; + rspItemState[QString("/lights/%1/state/effect_duration").arg(taskRef.lightNode->id())] = ed; + rspItem["success"] = rspItemState; + rsp.list.append(rspItem); + } + + if ((map.contains("effect_speed")) && (validatedParameters.contains("effect_speed"))) + { + const double es = map["effect_speed"].toDouble(); + + QVariantMap rspItem; + QVariantMap rspItemState; + rspItemState[QString("/lights/%1/state/effect_speed").arg(taskRef.lightNode->id())] = es; + rspItem["success"] = rspItemState; + rsp.list.append(rspItem); + } } - return addTask(task); + + rsp.httpStatus = HttpStatusOk; + rsp.etag = taskRef.lightNode->etag; + + return REQ_READY_SEND; } diff --git a/rest_hue_scenes.cpp b/rest_hue_scenes.cpp new file mode 100644 index 0000000000..a205d338e7 --- /dev/null +++ b/rest_hue_scenes.cpp @@ -0,0 +1,323 @@ +/* + * Handle Hue-specific Dynamic Scenes. + */ + +#include "de_web_plugin.h" +#include "de_web_plugin_private.h" + +/*! Scenes REST API broker. + \param req - request data + \param rsp - response data + \return REQ_READY_SEND + REQ_NOT_HANDLED + */ +int DeRestPluginPrivate::handleHueScenesApi(const ApiRequest &req, ApiResponse &rsp) +{ + if (req.path[2] != QLatin1String("hue-scenes")) + { + return REQ_NOT_HANDLED; + } + + // PUT /api//hue-scenes/groups//scenes//play + else if ((req.path.size() == 8) && (req.hdr.method() == "PUT") && (req.path[5] == "scenes") && (req.path[7] == "play")) + { + return playHueDynamicScene(req, rsp); + } + // PUT, PATCH /api//hue-scenes/groups//scenes//lights//state + else if ((req.path.size() == 10) && (req.hdr.method() == "PUT" || req.hdr.method() == "PATCH") && (req.path[5] == "scenes") && (req.path[7] == "lights") && (req.path[9] == "state")) + { + return modifyHueScene(req, rsp); + } + + return REQ_NOT_HANDLED; +} + +/*! PUT /api//groups//scenes//play + \return REQ_READY_SEND + REQ_NOT_HANDLED + */ +int DeRestPluginPrivate::playHueDynamicScene(const ApiRequest &req, ApiResponse &rsp) +{ + bool ok; + QVariantMap rspItem; + QVariantMap rspItemState; + QVariant var = Json::parse(req.content, ok); + QVariantMap map = var.toMap(); + const QString &gid = req.path[4]; + const QString &sid = req.path[6]; + Scene *scene = nullptr; + Group *group = getGroupForId(gid); + rsp.httpStatus = HttpStatusOk; + + if (req.sock) + { + userActivity(); + } + + if (!isInNetwork()) + { + rsp.list.append(errorToMap(ERR_NOT_CONNECTED, QString("/hue-scenes/groups/%1/scenes/%2").arg(gid).arg(sid), "not connected")); + rsp.httpStatus = HttpStatusServiceUnavailable; + return REQ_READY_SEND; + } + + if (!group || (group->state() != Group::StateNormal)) + { + rsp.httpStatus = HttpStatusNotFound; + rsp.list.append(errorToMap(ERR_RESOURCE_NOT_AVAILABLE, QString("/hue-scenes/groups/%1/scenes/%2").arg(gid).arg(sid), QString("resource, /groups/%1/scenes/%2, not available").arg(gid).arg(sid))); + return REQ_READY_SEND; + } + + // check if scene exists + uint8_t sceneId = 0; + ok = false; + if (sid == QLatin1String("next") || sid == QLatin1String("prev")) + { + ResourceItem *item = group->item(RActionScene); + DBG_Assert(item != 0); + uint lastSceneId = 0; + if (item && !item->toString().isEmpty()) + { + lastSceneId = item->toString().toUInt(&ok); + } + + int idx = -1; + std::vector scenes; // available scenes + + for (const Scene &s : group->scenes) + { + if (s.state != Scene::StateNormal) + { + continue; + } + + if (lastSceneId == s.id) + { + idx = scenes.size(); // remember current index + } + scenes.emplace_back(s.id); + } + + if (scenes.size() == 1) + { + ok = true; + sceneId = scenes[0]; + } + else if (scenes.size() > 1) + { + ok = true; + if (idx == -1) // not found + { + idx = 0; // use first + } + else if (sid[0] == 'p') // prev + { + if (idx > 0) { idx--; } + else { idx = scenes.size() - 1; } // jump to last scene + } + else // next + { + if (idx < int(scenes.size() - 1)) { idx++; } + else { idx = 0; } // jump to first scene + } + DBG_Assert(idx >= 0 && idx < int(scenes.size())); + sceneId = scenes[idx]; + } + // else ok == false + } + else + { + sceneId = sid.toUInt(&ok); + } + + scene = ok ? group->getScene(sceneId) : nullptr; + + if (!scene || (scene->state != Scene::StateNormal)) + { + rsp.httpStatus = HttpStatusNotFound; + rsp.list.append(errorToMap(ERR_RESOURCE_NOT_AVAILABLE, QString("/hue-scenes/groups/%1/scenes/%2").arg(gid).arg(sid), QString("resource, /groups/%1/scenes/%2, not available").arg(gid).arg(sid))); + return REQ_READY_SEND; + } + + TaskItem taskRef; + taskRef.req.setDstEndpoint(0xFF); + taskRef.req.setDstAddressMode(deCONZ::ApsGroupAddress); + taskRef.req.dstAddress().setGroup(group->address()); + taskRef.req.setSrcEndpoint(getSrcEndpoint(0, taskRef.req)); + + if (!callScene(group, sceneId)) + { + rsp.httpStatus = HttpStatusServiceUnavailable; + rsp.list.append(errorToMap(ERR_BRIDGE_BUSY, QString("/hue-scenes/groups/%1/scenes/%2").arg(gid).arg(sid), QString("gateway busy"))); + return REQ_READY_SEND; + } + + QList validatedParameters; + if (!validateHueDynamicScenePalette(rsp, scene, map, validatedParameters)) + { + rsp.httpStatus = HttpStatusBadRequest; + return REQ_READY_SEND; + } + + if (!addTaskHueDynamicSceneRecall(taskRef, group->address(), scene->id, map)) + { + rsp.httpStatus = HttpStatusServiceUnavailable; + rsp.list.append(errorToMap(ERR_BRIDGE_BUSY, QString("/hue-scenes/groups/%1/scenes/%2").arg(gid).arg(sid), QString("gateway busy"))); + return REQ_READY_SEND; + } + + { + const QString scid = QString::number(sceneId); + ResourceItem *item = group->item(RActionScene); + if (item && item->toString() != scid) + { + item->setValue(scid); + updateGroupEtag(group); + Event e(RGroups, RActionScene, group->id(), item); + enqueueEvent(e); + } + } + + // TODO: Verify that the group's and lights' states update after the recall + // This call is meant to check the state of the lights have changed to match the + // recalled scene. Apparently, IKEA lights tend to misbehave. This might not be + // needed with Philips Hue lights that support effects. + //recallSceneCheckGroupChanges(this, group, scene); + + updateEtag(gwConfigEtag); + + rspItemState["id"] = sid; + rspItem["success"] = rspItemState; + rsp.list.append(rspItem); + rsp.httpStatus = HttpStatusOk; + + processTasks(); + + return REQ_READY_SEND; +} + +/*! PUT, PATCH /api//hue-scenes/groups//scenes//lights//state + \return REQ_READY_SEND + REQ_NOT_HANDLED + */ +int DeRestPluginPrivate::modifyHueScene(const ApiRequest &req, ApiResponse &rsp) +{ + bool ok; + QVariantMap rspItem; + QVariantMap rspItemState; + QVariant var = Json::parse(req.content, ok); + QVariantMap map = var.toMap(); + QString gid = req.path[4]; + QString sid = req.path[6]; + QString lid = req.path[8]; + Group *group = getGroupForId(gid); + Scene scene; + LightNode *light = getLightNodeForId(lid); + rsp.httpStatus = HttpStatusOk; + + userActivity(); + + if (!isInNetwork()) + { + rsp.list.append(errorToMap(ERR_NOT_CONNECTED, QString("/hue-scenes/groups/%1/scenes/%2/lights/%3/state").arg(gid).arg(sid).arg(lid), "Not connected")); + rsp.httpStatus = HttpStatusServiceUnavailable; + return REQ_READY_SEND; + } + + if (!ok || map.isEmpty()) + { + rsp.list.append(errorToMap(ERR_INVALID_JSON, QString("/hue-scenes/groups/%1/scenes/%2/lights/%3/state").arg(gid).arg(sid).arg(lid), QString("body contains invalid JSON"))); + rsp.httpStatus = HttpStatusBadRequest; + return REQ_READY_SEND; + } + + if (!group || (group->state() == Group::StateDeleted)) + { + rsp.httpStatus = HttpStatusNotFound; + rsp.list.append(errorToMap(ERR_RESOURCE_NOT_AVAILABLE, QString("/hue-scenes/groups/%1/scenes/%2/lights/%3/state").arg(gid).arg(sid).arg(lid), QString("resource, /groups/%1, not available").arg(gid))); + return REQ_READY_SEND; + } + + if (!light || (light->state() == LightNode::StateDeleted) || !light->isAvailable()) + { + rsp.httpStatus = HttpStatusNotFound; + rsp.list.append(errorToMap(ERR_RESOURCE_NOT_AVAILABLE, QString("/hue-scenes/groups/%1/scenes/%2/lights/%3/state").arg(gid).arg(sid).arg(lid), QString("resource, /lights/%1, not available").arg(lid))); + return REQ_READY_SEND; + } + + std::vector::iterator i = group->scenes.begin(); + std::vector::iterator end = group->scenes.end(); + + bool foundScene = false; + bool foundLightState = false; + + for ( ;i != end; ++i) + { + if (QString::number(i->id) == sid && i->state != Scene::StateDeleted) + { + foundScene = true; + scene = *i; + + std::vector::iterator l = i->lights().begin(); + std::vector::iterator lend = i->lights().end(); + + for ( ;l != lend; ++l) + { + if (l->lid() == lid) + { + foundLightState = true; + break; + } + } + + break; + } + } + + if (!foundScene) + { + rsp.httpStatus = HttpStatusNotFound; + rsp.list.append(errorToMap(ERR_RESOURCE_NOT_AVAILABLE, QString("/groups/%1/scenes/%2/lights/%3/state").arg(gid).arg(sid).arg(lid), QString("resource, /scenes/%1, not available").arg(sid))); + return REQ_READY_SEND; + } + + if (!foundLightState) + { + rsp.httpStatus = HttpStatusBadRequest; + rsp.list.append(errorToMap(ERR_RESOURCE_NOT_AVAILABLE, QString("/groups/%1/scenes/%2/lights/%3/state").arg(gid).arg(sid).arg(lid), QString("Light %1 is not available in scene.").arg(lid))); + return REQ_READY_SEND; + } + + QList validatedParameters; + if (!validateHueLightState(rsp, light, map, validatedParameters)) + { + rsp.httpStatus = HttpStatusBadRequest; + return REQ_READY_SEND; + } + + TaskItem taskRef; + taskRef.lightNode = getLightNodeForId(lid); + taskRef.req.dstAddress() = taskRef.lightNode->address(); + taskRef.req.setTxOptions(deCONZ::ApsTxAcknowledgedTransmission); + taskRef.req.setDstEndpoint(taskRef.lightNode->haEndpoint().endpoint()); + taskRef.req.setSrcEndpoint(getSrcEndpoint(taskRef.lightNode, taskRef.req)); + taskRef.req.setDstAddressMode(deCONZ::ApsExtAddress); + + if (!addTaskHueManufacturerSpecificAddScene(taskRef, group->address(), scene.id, map)) + { + rsp.httpStatus = HttpStatusServiceUnavailable; + rsp.list.append(errorToMap(ERR_BRIDGE_BUSY, QString("/hue-scenes/groups/%1/scenes/%2/lights/%3/state").arg(gid).arg(sid).arg(lid), QString("gateway busy"))); + return REQ_READY_SEND; + } + + updateGroupEtag(group); + + queSaveDb(DB_SCENES, DB_SHORT_SAVE_DELAY); + + rspItemState["id"] = sid; + rspItem["success"] = rspItemState; + rsp.list.append(rspItem); + rsp.httpStatus = HttpStatusOk; + + return REQ_READY_SEND; +} diff --git a/rest_lights.cpp b/rest_lights.cpp index 04379d432f..7b13e6b159 100644 --- a/rest_lights.cpp +++ b/rest_lights.cpp @@ -720,6 +720,12 @@ int DeRestPluginPrivate::setLightState(const ApiRequest &req, ApiResponse &rsp) { return setWindowCoveringState(req, rsp, taskRef, map); } + else if (isHueEffectLight(taskRef.lightNode) && isMappableToManufacturerSpecific(map)) + { + // Philips Hue lights that support the '0xfc03' cluster + // can be controlled in a manufacturer-specific way. + return setHueLightState(req, rsp, taskRef, map); + } else if (isXmasLightStrip(taskRef.lightNode)) { return setXmasLightStripState(req, rsp, taskRef, map); diff --git a/rest_rules.cpp b/rest_rules.cpp index 4c4da1e48b..d22e8a25a7 100644 --- a/rest_rules.cpp +++ b/rest_rules.cpp @@ -1389,6 +1389,14 @@ void DeRestPluginPrivate::triggerRule(Rule &rule) } triggered = true; } + else if (path[2] == QLatin1String("hue-scenes")) + { + if (handleHueScenesApi(req, rsp) == REQ_NOT_HANDLED) + { + return; + } + triggered = true; + } else if (path[2] == QLatin1String("sensors")) { if (handleSensorsApi(req, rsp) == REQ_NOT_HANDLED)