From bde7cc3f56b9e17f651dd7fd83ca88f49e8b58d3 Mon Sep 17 00:00:00 2001 From: Gilmar Passos Date: Sat, 5 Sep 2026 16:14:45 +1000 Subject: [PATCH] Add Nanoleaf Essentials and HD lightstrip support. The existing driver only understood panelLayout. Essentials strips expose LED count on /length and stream RGB over UDP v2, so they can be used like other HyperHDR LED devices. Co-authored-by: Cursor --- include/led-drivers/net/DriverNetNanoleaf.h | 12 +- sources/led-drivers/net/DriverNetNanoleaf.cpp | 672 +++++++++--------- tests/nanoleaf/Test-NanoleafStream.ps1 | 350 +++++++++ 3 files changed, 697 insertions(+), 337 deletions(-) create mode 100644 tests/nanoleaf/Test-NanoleafStream.ps1 diff --git a/include/led-drivers/net/DriverNetNanoleaf.h b/include/led-drivers/net/DriverNetNanoleaf.h index 2dc81f965..fc7b3622f 100644 --- a/include/led-drivers/net/DriverNetNanoleaf.h +++ b/include/led-drivers/net/DriverNetNanoleaf.h @@ -30,8 +30,16 @@ class DriverNetNanoleaf : public ProviderUdp private: bool initRestAPI(const QString& hostname, int port, const QString& token); bool initLedsConfiguration(); + bool loadPanelIds(const QJsonArray& positionData); + bool loadLightstripIds(int ledCount); + bool applyConfiguredLedRange(); + int queryNumLeds(); + int writeStreamBatch(const std::vector& ledValues, int startIndex, int count, int& ledCounter); QJsonDocument changeToExternalControlMode(); + bool applyStreamMasterBrightness(); QString getOnOffRequest(bool isOn) const; + ColorRgb candyColor(const ColorRgb& color) const; + bool resolveApiEndpoint(const QString& host, QString& apiHost, int& apiPort) const; std::unique_ptr _restApi; @@ -49,9 +57,11 @@ class DriverNetNanoleaf : public ProviderUdp ushort _extControlVersion; int _panelLedCount; + bool _isLightstrip; + int _streamLedsPerDatagram; + int _streamBatchGapMs; QVector _panelIds; static bool isRegistered; }; - diff --git a/sources/led-drivers/net/DriverNetNanoleaf.cpp b/sources/led-drivers/net/DriverNetNanoleaf.cpp index 39b665234..5e538d5f0 100644 --- a/sources/led-drivers/net/DriverNetNanoleaf.cpp +++ b/sources/led-drivers/net/DriverNetNanoleaf.cpp @@ -3,13 +3,12 @@ #include // Qt includes -#include -#include +#include #include //std includes -#include -#include +#include +#include // Constants namespace { @@ -18,12 +17,13 @@ namespace { // Configuration settings const char CONFIG_ADDRESS[] = "host"; - //const char CONFIG_PORT[] = "port"; const char CONFIG_AUTH_TOKEN[] = "token"; const char CONFIG_PANEL_ORDER_TOP_DOWN[] = "panelOrderTopDown"; const char CONFIG_PANEL_ORDER_LEFT_RIGHT[] = "panelOrderLeftRight"; const char CONFIG_PANEL_START_POS[] = "panelStartPos"; + const char CONFIG_STREAM_LEDS_PER_DATAGRAM[] = "streamLedsPerDatagram"; + const char CONFIG_STREAM_BATCH_GAP_MS[] = "streamBatchGapMs"; // Panel configuration settings const char PANEL_LAYOUT[] = "layout"; @@ -31,7 +31,6 @@ namespace { const char PANEL_ID[] = "panelId"; const char PANEL_POSITIONDATA[] = "positionData"; const char PANEL_SHAPE_TYPE[] = "shapeType"; - //const char PANEL_ORIENTATION[] = "0"; const char PANEL_POS_X[] = "x"; const char PANEL_POS_Y[] = "y"; @@ -46,31 +45,34 @@ namespace { const char DEV_DATA_MODEL[] = "model"; const char DEV_DATA_MANUFACTURER[] = "manufacturer"; const char DEV_DATA_FIRMWAREVERSION[] = "firmwareVersion"; + const char DEV_DATA_NUM_LEDS[] = "numLEDs"; // Nanoleaf Stream Control elements - //const char STREAM_CONTROL_IP[] = "streamControlIpAddr"; const char STREAM_CONTROL_PORT[] = "streamControlPort"; - //const char STREAM_CONTROL_PROTOCOL[] = "streamControlProtocol"; - const quint16 STREAM_CONTROL_DEFAULT_PORT = 60222; //Fixed port for Canvas; + const quint16 STREAM_CONTROL_DEFAULT_PORT = 60222; // Nanoleaf OpenAPI URLs const int API_DEFAULT_PORT = 16021; const char API_BASE_PATH[] = "/api/v1/%1/"; const char API_ROOT[] = ""; - //const char API_EXT_MODE_STRING_V1[] = "{\"write\" : {\"command\" : \"display\", \"animType\" : \"extControl\"}}"; const char API_EXT_MODE_STRING_V2[] = "{\"write\" : {\"command\" : \"display\", \"animType\" : \"extControl\", \"extControlVersion\" : \"v2\"}}"; const char API_STATE[] = "state"; const char API_PANELLAYOUT[] = "panelLayout"; const char API_EFFECT[] = "effects"; + const char API_LENGTH[] = "length"; + const char API_IDENTIFY[] = "identify"; - //Nanoleaf Control data stream + // Nanoleaf Control data stream (v2) const int STREAM_FRAME_PANEL_NUM_SIZE = 2; const int STREAM_FRAME_PANEL_INFO_SIZE = 8; + // Ethernet MTU 1500 minus IP(20) and UDP(8). Batch only when a full frame would exceed this. + const int STREAM_MAX_UDP_PAYLOAD = 1472; + const int STREAM_MAX_LEDS_PER_DATAGRAM = (STREAM_MAX_UDP_PAYLOAD - STREAM_FRAME_PANEL_NUM_SIZE) / STREAM_FRAME_PANEL_INFO_SIZE; // Nanoleaf ssdp services const char SSDP_ID[] = "ssdp:all"; const char SSDP_FILTER_HEADER[] = "ST"; - const char SSDP_CANVAS[] = "nanoleaf:nl29"; + const char SSDP_NANOLEAF[] = "nanoleaf:nl*"; const char SSDP_LIGHTPANELS[] = "nanoleaf_aurora:light"; } //End of constants @@ -98,268 +100,280 @@ DriverNetNanoleaf::DriverNetNanoleaf(const QJsonObject& deviceConfig) , _leftRight(true) , _startPos(0) , _endPos(0) - , _extControlVersion(EXTCTRLVER_V2), - _panelLedCount(0) + , _extControlVersion(EXTCTRLVER_V2) + , _panelLedCount(0) + , _isLightstrip(false) + , _streamLedsPerDatagram(STREAM_MAX_LEDS_PER_DATAGRAM) + , _streamBatchGapMs(0) { } -bool DriverNetNanoleaf::initLedsConfiguration() +bool DriverNetNanoleaf::resolveApiEndpoint(const QString& host, QString& apiHost, int& apiPort) const { - bool isInitOK = true; + if (host.isEmpty()) + return false; - //Get Nanoleaf device details and configuration + const QStringList addressparts = host.split(':', Qt::SkipEmptyParts); + apiHost = addressparts[0]; + apiPort = (addressparts.size() > 1) ? addressparts[1].toInt() : API_DEFAULT_PORT; + return !apiHost.isEmpty(); +} - // Read Panel count and panel Ids - _restApi->setPath(API_ROOT); +int DriverNetNanoleaf::queryNumLeds() +{ + if (_restApi == nullptr) + return 0; + + _restApi->setPath(API_LENGTH); httpResponse response = _restApi->get(); if (response.error()) { - this->setInError(response.getErrorReason()); - isInitOK = false; + Debug(_log, "GET /length failed: {:s}", (response.getErrorReason())); + return 0; } - else - { - QJsonObject jsonAllPanelInfo = response.getBody().object(); - QString deviceName = jsonAllPanelInfo[DEV_DATA_NAME].toString(); - _deviceModel = jsonAllPanelInfo[DEV_DATA_MODEL].toString(); - QString deviceManufacturer = jsonAllPanelInfo[DEV_DATA_MANUFACTURER].toString(); - _deviceFirmwareVersion = jsonAllPanelInfo[DEV_DATA_FIRMWAREVERSION].toString(); - - Debug(_log, "Name : {:s}", (deviceName)); - Debug(_log, "Model : {:s}", (_deviceModel)); - Debug(_log, "Manufacturer : {:s}", (deviceManufacturer)); - Debug(_log, "FirmwareVersion: {:s}", (_deviceFirmwareVersion)); + return response.getBody().object()[DEV_DATA_NUM_LEDS].toInt(0); +} - // Get panel details from /panelLayout/layout - QJsonObject jsonPanelLayout = jsonAllPanelInfo[API_PANELLAYOUT].toObject(); - QJsonObject jsonLayout = jsonPanelLayout[PANEL_LAYOUT].toObject(); +bool DriverNetNanoleaf::applyConfiguredLedRange() +{ + const int configuredLedCount = this->getLedCount(); + _endPos = _startPos + configuredLedCount - 1; - int panelNum = jsonLayout[PANEL_NUM].toInt(); - const QJsonArray positionData = jsonLayout[PANEL_POSITIONDATA].toArray(); + Debug(_log, "Sort Top>Down : {:d}", _topDown); + Debug(_log, "Sort Left>Right: {:d}", _leftRight); + Debug(_log, "Start Panel Pos: {:d}", _startPos); + Debug(_log, "End Panel Pos : {:d}", _endPos); + Debug(_log, "Hardware LEDs : {:d}", _panelLedCount); - std::map> panelMap; + if (_panelLedCount < configuredLedCount) + { + this->setInError(QString("Not enough panels [%1] for configured LEDs [%2] found!") + .arg(_panelLedCount) + .arg(configuredLedCount)); + return false; + } - // Loop over all children. - for (auto value : positionData) - { - QJsonObject panelObj = value.toObject(); + if (_panelLedCount > configuredLedCount) + { + Info(_log, "{:s}: More panels [{:d}] than configured LEDs [{:d}].", (this->getActiveDeviceType()), _panelLedCount, configuredLedCount); + } - int panelId = panelObj[PANEL_ID].toInt(); - int panelX = panelObj[PANEL_POS_X].toInt(); - int panelY = panelObj[PANEL_POS_Y].toInt(); - int panelshapeType = panelObj[PANEL_SHAPE_TYPE].toInt(); - //int panelOrientation = panelObj[PANEL_ORIENTATION].toInt(); + if (_endPos >= _panelLedCount) + { + this->setInError(QString("Start panel [%1] out of range. Start panel position can be max [%2] given [%3] panel available!") + .arg(_startPos).arg(_panelLedCount - configuredLedCount).arg(_panelLedCount)); + return false; + } - DebugIf(verbose, _log, "Panel [{:d}] ({:d},{:d}) - Type: [{:d}]", panelId, panelX, panelY, panelshapeType); + return true; +} - // Skip Rhythm panels - if (panelshapeType != RHYTM) - { - panelMap[panelY][panelX] = panelId; - } - else - { // Reset non support/required features - Info(_log, "Rhythm panel skipped."); - } - } +bool DriverNetNanoleaf::loadLightstripIds(int ledCount) +{ + _isLightstrip = true; + _panelIds.clear(); + _panelIds.reserve(ledCount); - // Travers panels top down - for (auto posY = panelMap.crbegin(); posY != panelMap.crend(); ++posY) - { - // Sort panels left to right - if (_leftRight) - { - for (auto posX = posY->second.cbegin(); posX != posY->second.cend(); ++posX) - { - DebugIf(verbose3, _log, "panelMap[{:d}][{:d}]={:d}", posY->first, posX->first, posX->second); - - if (_topDown) - { - _panelIds.push_back(posX->second); - } - else - { - _panelIds.push_front(posX->second); - } - } - } - else - { - // Sort panels right to left - for (auto posX = posY->second.crbegin(); posX != posY->second.crend(); ++posX) - { - DebugIf(verbose3, _log, "panelMap[{:d}][{:d}]={:d}", posY->first, posX->first, posX->second); - - if (_topDown) - { - _panelIds.push_back(posX->second); - } - else - { - _panelIds.push_front(posX->second); - } - } - } - } + // Essentials OpenAPI: LED id == index in 0..N-1 + if (_leftRight) + { + for (int i = 0; i < ledCount; ++i) + _panelIds.push_back(i); + } + else + { + for (int i = ledCount - 1; i >= 0; --i) + _panelIds.push_back(i); + } - this->_panelLedCount = _panelIds.size(); - _devConfig["hardwareLedCount"] = _panelLedCount; + _panelLedCount = _panelIds.size(); + _devConfig["hardwareLedCount"] = _panelLedCount; + return applyConfiguredLedRange(); +} - Debug(_log, "PanelsNum : {:d}", panelNum); - Debug(_log, "PanelLedCount : {:d}", _panelLedCount); +bool DriverNetNanoleaf::loadPanelIds(const QJsonArray& positionData) +{ + _isLightstrip = false; + std::map> panelMap; - // Check. if enough panels were found. - int configuredLedCount = this->getLedCount(); - _endPos = _startPos + configuredLedCount - 1; + for (const auto& value : positionData) + { + const QJsonObject panelObj = value.toObject(); + const int panelId = panelObj[PANEL_ID].toInt(); + const int panelX = panelObj[PANEL_POS_X].toInt(); + const int panelY = panelObj[PANEL_POS_Y].toInt(); + const int panelshapeType = panelObj[PANEL_SHAPE_TYPE].toInt(); - Debug(_log, "Sort Top>Down : {:d}", _topDown); - Debug(_log, "Sort Left>Right: {:d}", _leftRight); - Debug(_log, "Start Panel Pos: {:d}", _startPos); - Debug(_log, "End Panel Pos : {:d}", _endPos); + DebugIf(verbose, _log, "Panel [{:d}] ({:d},{:d}) - Type: [{:d}]", panelId, panelX, panelY, panelshapeType); - if (_panelLedCount < configuredLedCount) + if (panelshapeType != RHYTM) { - QString errorReason = QString("Not enough panels [%1] for configured LEDs [%2] found!") - .arg(_panelLedCount) - .arg(configuredLedCount); - this->setInError(errorReason); - isInitOK = false; + panelMap[panelY][panelX] = panelId; } else { - if (_panelLedCount > this->getLedCount()) + Info(_log, "Rhythm panel skipped."); + } + } + + for (auto posY = panelMap.crbegin(); posY != panelMap.crend(); ++posY) + { + if (_leftRight) + { + for (auto posX = posY->second.cbegin(); posX != posY->second.cend(); ++posX) { - Info(_log, "{:s}: More panels [{:d}] than configured LEDs [{:d}].", (this->getActiveDeviceType()), _panelLedCount, configuredLedCount); + if (_topDown) + _panelIds.push_back(posX->second); + else + _panelIds.push_front(posX->second); } - - // Check, if start position + number of configured LEDs is greater than number of panels available - if (_endPos >= _panelLedCount) + } + else + { + for (auto posX = posY->second.crbegin(); posX != posY->second.crend(); ++posX) { - QString errorReason = QString("Start panel [%1] out of range. Start panel position can be max [%2] given [%3] panel available!") - .arg(_startPos).arg(_panelLedCount - configuredLedCount).arg(_panelLedCount); - - this->setInError(errorReason); - isInitOK = false; + if (_topDown) + _panelIds.push_back(posX->second); + else + _panelIds.push_front(posX->second); } } } - return isInitOK; + + _panelLedCount = _panelIds.size(); + _devConfig["hardwareLedCount"] = _panelLedCount; + return applyConfiguredLedRange(); } -bool DriverNetNanoleaf::init(QJsonObject deviceConfig) +bool DriverNetNanoleaf::initLedsConfiguration() { - // Overwrite non supported/required features - setRefreshTime(0); + _panelIds.clear(); + _isLightstrip = false; - if (deviceConfig["refreshTime"].toInt(0) > 0) + _restApi->setPath(API_ROOT); + httpResponse response = _restApi->get(); + if (response.error()) { - Info(_log, "Device Nanoleaf does not require setting refresh time. Refresh time is ignored."); + this->setInError(response.getErrorReason()); + return false; } - DebugIf(verbose, _log, "deviceConfig: [{:s}]", QString(QJsonDocument(_devConfig).toJson(QJsonDocument::Compact)).toUtf8().constData()); + const QJsonObject deviceInfo = response.getBody().object(); + _deviceModel = deviceInfo[DEV_DATA_MODEL].toString(); + _deviceFirmwareVersion = deviceInfo[DEV_DATA_FIRMWAREVERSION].toString(); - bool isInitOK = false; + Debug(_log, "Name : {:s}", (deviceInfo[DEV_DATA_NAME].toString())); + Debug(_log, "Model : {:s}", (_deviceModel)); + Debug(_log, "Manufacturer : {:s}", (deviceInfo[DEV_DATA_MANUFACTURER].toString())); + Debug(_log, "FirmwareVersion: {:s}", (_deviceFirmwareVersion)); - if (LedDevice::init(deviceConfig)) + const QJsonObject jsonLayout = deviceInfo[API_PANELLAYOUT].toObject()[PANEL_LAYOUT].toObject(); + const QJsonArray positionData = jsonLayout[PANEL_POSITIONDATA].toArray(); + + if (!positionData.isEmpty()) { - int configuredLedCount = this->getLedCount(); - Debug(_log, "DeviceType : {:s}", (this->getActiveDeviceType())); - Debug(_log, "LedCount : {:d}", configuredLedCount); - Debug(_log, "RefreshTime : {:d}", this->getRefreshTime()); + Debug(_log, "PanelsNum : {:d}", jsonLayout[PANEL_NUM].toInt()); + return loadPanelIds(positionData); + } - // Read panel organisation configuration - if (deviceConfig[CONFIG_PANEL_ORDER_TOP_DOWN].isString()) - { - _topDown = deviceConfig[CONFIG_PANEL_ORDER_TOP_DOWN].toString().toInt() == 0; - } - else - { - _topDown = deviceConfig[CONFIG_PANEL_ORDER_TOP_DOWN].toInt() == 0; - } + // No panel layout: Essentials/lightstrip OpenAPI uses GET /length + const int numLeds = queryNumLeds(); + if (numLeds <= 0) + { + this->setInError("Device has no panelLayout and GET /length returned no LEDs"); + return false; + } - if (deviceConfig[CONFIG_PANEL_ORDER_LEFT_RIGHT].isString()) - { - _leftRight = deviceConfig[CONFIG_PANEL_ORDER_LEFT_RIGHT].toString().toInt() == 0; - } - else - { - _leftRight = deviceConfig[CONFIG_PANEL_ORDER_LEFT_RIGHT].toInt() == 0; - } + Info(_log, "Nanoleaf lightstrip API (model {:s}): {:d} LEDs from /length", (_deviceModel), numLeds); + return loadLightstripIds(numLeds); +} - _startPos = deviceConfig[CONFIG_PANEL_START_POS].toInt(0); +bool DriverNetNanoleaf::init(QJsonObject deviceConfig) +{ + DebugIf(verbose, _log, "deviceConfig: [{:s}]", QString(QJsonDocument(_devConfig).toJson(QJsonDocument::Compact)).toUtf8().constData()); - // TODO: Allow to handle port dynamically + if (!LedDevice::init(deviceConfig)) + return false; - //Set hostname as per configuration and_defaultHost default port - _hostname = deviceConfig[CONFIG_ADDRESS].toString(); - _apiPort = API_DEFAULT_PORT; - _authToken = deviceConfig[CONFIG_AUTH_TOKEN].toString(); + Debug(_log, "DeviceType : {:s}", (this->getActiveDeviceType())); + Debug(_log, "LedCount : {:d}", this->getLedCount()); + Debug(_log, "RefreshTime : {:d}", this->getRefreshTime()); - //If host not configured the init failed - if (_hostname.isEmpty()) - { - this->setInError("No target hostname nor IP defined"); - isInitOK = false; - } - else - { - if (initRestAPI(_hostname, _apiPort, _authToken)) - { - // Read LedDevice configuration and validate against device configuration - if (initLedsConfiguration()) - { - // Set UDP streaming host and port - _devConfig["host"] = _hostname; - _devConfig["port"] = STREAM_CONTROL_DEFAULT_PORT; - - isInitOK = ProviderUdp::init(_devConfig); - Debug(_log, "Hostname/IP : {:s}", (_hostname)); - Debug(_log, "Port : {:d}", _port); - } - } - } + if (deviceConfig[CONFIG_PANEL_ORDER_TOP_DOWN].isString()) + _topDown = deviceConfig[CONFIG_PANEL_ORDER_TOP_DOWN].toString().toInt() == 0; + else + _topDown = deviceConfig[CONFIG_PANEL_ORDER_TOP_DOWN].toInt() == 0; + + if (deviceConfig[CONFIG_PANEL_ORDER_LEFT_RIGHT].isString()) + _leftRight = deviceConfig[CONFIG_PANEL_ORDER_LEFT_RIGHT].toString().toInt() == 0; + else + _leftRight = deviceConfig[CONFIG_PANEL_ORDER_LEFT_RIGHT].toInt() == 0; + + _startPos = deviceConfig[CONFIG_PANEL_START_POS].toInt(0); + + _hostname = deviceConfig[CONFIG_ADDRESS].toString(); + _apiPort = API_DEFAULT_PORT; + _authToken = deviceConfig[CONFIG_AUTH_TOKEN].toString(); + + if (_hostname.isEmpty()) + { + this->setInError("No target hostname nor IP defined"); + return false; } + + if (!initRestAPI(_hostname, _apiPort, _authToken) || !initLedsConfiguration()) + return false; + + if (_isLightstrip) + { + // Default: one datagram with every LED (matches HyperHDR full-frame output). + // Override streamLedsPerDatagram if a controller cannot take a full frame. + const int defaultLedsPerDatagram = std::max(1, _panelLedCount); + _streamLedsPerDatagram = std::max(1, deviceConfig[CONFIG_STREAM_LEDS_PER_DATAGRAM].toInt(defaultLedsPerDatagram)); + _streamBatchGapMs = std::max(0, deviceConfig[CONFIG_STREAM_BATCH_GAP_MS].toInt(0)); + Info(_log, "Lightstrip stream: {:d} LEDs/datagram, {:d}ms batch gap (refresh follows smoothing)", + _streamLedsPerDatagram, _streamBatchGapMs); + } + + _devConfig["host"] = _hostname; + _devConfig["port"] = STREAM_CONTROL_DEFAULT_PORT; + + const bool isInitOK = ProviderUdp::init(_devConfig); + Debug(_log, "Hostname/IP : {:s}", (_hostname)); + Debug(_log, "Port : {:d}", _port); return isInitOK; } bool DriverNetNanoleaf::initRestAPI(const QString& hostname, int port, const QString& token) { - bool isInitOK = false; - if (_restApi == nullptr) - { _restApi = std::make_unique(hostname, port); + else + _restApi->updateHost(hostname, port); - //Base-path is api-path + authentication token - _restApi->setBasePath(QString(API_BASE_PATH).arg(token)); - - isInitOK = true; - } - return isInitOK; + _restApi->setBasePath(QString(API_BASE_PATH).arg(token)); + return true; } int DriverNetNanoleaf::open() { - int retval = -1; _isDeviceReady = false; - QJsonDocument responseDoc = changeToExternalControlMode(); - // Resolve port for Light Panels - QJsonObject jsonStreamControllInfo = responseDoc.object(); - if (!jsonStreamControllInfo.isEmpty()) + const QJsonObject streamInfo = changeToExternalControlMode().object(); + if (streamInfo.contains(STREAM_CONTROL_PORT)) { - //Set default streaming port - _port = static_cast(jsonStreamControllInfo[STREAM_CONTROL_PORT].toInt()); + const int streamPort = streamInfo[STREAM_CONTROL_PORT].toInt(); + if (streamPort > 0) + _port = static_cast(streamPort); } - if (ProviderUdp::open() == 0) - { - // Everything is OK, device is ready - _isDeviceReady = true; - retval = 0; - } - return retval; + if (ProviderUdp::open() != 0) + return -1; + + applyStreamMasterBrightness(); + _isDeviceReady = true; + return 0; } QJsonDocument DriverNetNanoleaf::changeToExternalControlMode() @@ -367,15 +381,52 @@ QJsonDocument DriverNetNanoleaf::changeToExternalControlMode() Debug(_log, "Set Nanoleaf to External Control (UDP) streaming mode"); if (_restApi == nullptr) - return QJsonDocument(); + return {}; _extControlVersion = EXTCTRLVER_V2; - //Enable UDP Mode v2 - _restApi->setPath(API_EFFECT); - httpResponse response = _restApi->put(API_EXT_MODE_STRING_V2); + return _restApi->put(API_EXT_MODE_STRING_V2).getBody(); +} + +bool DriverNetNanoleaf::applyStreamMasterBrightness() +{ + if (_restApi == nullptr) + return false; - return response.getBody(); + // Essentials global brightness multiplies extControl / screen-mirror output. + _restApi->setPath(API_STATE); + httpResponse response = _restApi->put(QString("{\"brightness\":{\"value\":100}}")); + if (response.error()) + { + Warning(_log, "Could not set Nanoleaf brightness to 100: {:s}", (response.getErrorReason())); + return false; + } + + Info(_log, "Nanoleaf stream master brightness set to 100"); + return true; +} + +ColorRgb DriverNetNanoleaf::candyColor(const ColorRgb& color) const +{ + if (color.red == 0 && color.green == 0 && color.blue == 0) + return color; + + uint16_t hue = 0; + uint8_t sat = 0; + uint8_t val = 0; + ColorRgb::rgb2hsv(color.red, color.green, color.blue, hue, sat, val); + + // Neon saturation: keep hue, pull S toward 255. + sat = static_cast(sat + static_cast(255 - sat) * 3 / 5); + + // Lift midtones so TV-content greys still punch on the strip. + const float v = val / 255.0f; + const int lifted = static_cast(std::lround(255.0f * std::pow(v, 0.62f) * 1.12f)); + val = static_cast(std::clamp(lifted, 0, 255)); + + ColorRgb out; + ColorRgb::hsv2rgb(hue, sat, val, out.red, out.green, out.blue); + return out; } QJsonObject DriverNetNanoleaf::discover(const QJsonObject& /*params*/) @@ -383,61 +434,34 @@ QJsonObject DriverNetNanoleaf::discover(const QJsonObject& /*params*/) QJsonObject devicesDiscovered; devicesDiscovered.insert("ledDeviceType", _activeDeviceType); - QJsonArray deviceList; - - // Discover Nanoleaf Devices SSDPDiscover discover; + discover.setSearchFilter(QString("%1|%2").arg(SSDP_NANOLEAF, SSDP_LIGHTPANELS), SSDP_FILTER_HEADER); - // Search for Canvas and Light-Panels - QString searchTargetFilter = QString("%1|%2").arg(SSDP_CANVAS, SSDP_LIGHTPANELS); - - discover.setSearchFilter(searchTargetFilter, SSDP_FILTER_HEADER); - QString searchTarget = SSDP_ID; - - if (discover.discoverServices(searchTarget) > 0) - { + QJsonArray deviceList; + if (discover.discoverServices(SSDP_ID) > 0) deviceList = discover.getServicesDiscoveredJson(); - } devicesDiscovered.insert("devices", deviceList); Debug(_log, "devicesDiscovered: [{:s}]", QString(QJsonDocument(devicesDiscovered).toJson(QJsonDocument::Compact)).toUtf8().constData()); - return devicesDiscovered; } - void DriverNetNanoleaf::identify(const QJsonObject& params) { Debug(_log, "params: [{:s}]", QString(QJsonDocument(params).toJson(QJsonDocument::Compact)).toUtf8().constData()); - QString host = params["host"].toString(""); - if (!host.isEmpty()) - { - QString authToken = params["token"].toString(""); - - // Resolve hostname and port (or use default API port) - QStringList addressparts = host.split(':', Qt::SkipEmptyParts); - QString apiHost = addressparts[0]; - int apiPort; - - if (addressparts.size() > 1) - { - apiPort = addressparts[1].toInt(); - } - else - { - apiPort = API_DEFAULT_PORT; - } + QString apiHost; + int apiPort = API_DEFAULT_PORT; + if (!resolveApiEndpoint(params["host"].toString(), apiHost, apiPort)) + return; - initRestAPI(apiHost, apiPort, authToken); - _restApi->setPath("identify"); + initRestAPI(apiHost, apiPort, params["token"].toString()); + _restApi->setPath(API_IDENTIFY); - // Perform request - httpResponse response = _restApi->put(); - if (response.error()) - { - Warning(_log, "{:s} identification failed with error: '{:s}'", (_activeDeviceType), (response.getErrorReason())); - } + httpResponse response = _restApi->put(); + if (response.error()) + { + Warning(_log, "{:s} identification failed with error: '{:s}'", (_activeDeviceType), (response.getErrorReason())); } } @@ -446,49 +470,43 @@ QJsonObject DriverNetNanoleaf::getProperties(const QJsonObject& params) Debug(_log, "params: [{:s}]", QString(QJsonDocument(params).toJson(QJsonDocument::Compact)).toUtf8().constData()); QJsonObject properties; - // Get Nanoleaf device properties - QString host = params["host"].toString(""); - if (!host.isEmpty()) - { - QString authToken = params["token"].toString(""); - QString filter = params["filter"].toString(""); - - // Resolve hostname and port (or use default API port) - QStringList addressparts = host.split(':', Qt::SkipEmptyParts); - QString apiHost = addressparts[0]; - int apiPort; + QString apiHost; + int apiPort = API_DEFAULT_PORT; + if (!resolveApiEndpoint(params["host"].toString(), apiHost, apiPort)) + return properties; - if (addressparts.size() > 1) - { - apiPort = addressparts[1].toInt(); - } - else - { - apiPort = API_DEFAULT_PORT; - } + initRestAPI(apiHost, apiPort, params["token"].toString()); + _restApi->setPath(params["filter"].toString()); - initRestAPI(apiHost, apiPort, authToken); - _restApi->setPath(filter); + httpResponse response = _restApi->get(); + if (response.error()) + { + Warning(_log, "{:s} get properties failed with error: '{:s}'", (_activeDeviceType), (response.getErrorReason())); + } - // Perform request - httpResponse response = _restApi->get(); - if (response.error()) + QJsonObject props = response.getBody().object(); + const QJsonArray positionData = props[API_PANELLAYOUT].toObject()[PANEL_LAYOUT].toObject()[PANEL_POSITIONDATA].toArray(); + if (positionData.isEmpty()) + { + const int numLeds = queryNumLeds(); + if (numLeds > 0) { - Warning(_log, "{:s} get properties failed with error: '{:s}'", (_activeDeviceType), (response.getErrorReason())); + props[DEV_DATA_NUM_LEDS] = numLeds; + props["hardwareLedCount"] = numLeds; } - - properties.insert("properties", response.getBody().object()); - - Debug(_log, "properties: [{:s}]", QString(QJsonDocument(properties).toJson(QJsonDocument::Compact)).toUtf8().constData()); } + + properties.insert("properties", props); + Debug(_log, "properties: [{:s}]", QString(QJsonDocument(properties).toJson(QJsonDocument::Compact)).toUtf8().constData()); return properties; } - QString DriverNetNanoleaf::getOnOffRequest(bool isOn) const { - QString state = isOn ? STATE_VALUE_TRUE : STATE_VALUE_FALSE; - return QString("{\"%1\":{\"%2\":%3}}").arg(STATE_ON, STATE_ONOFF_VALUE, state); + if (isOn) + return QString("{\"%1\":{\"%2\":%3},\"brightness\":{\"value\":100}}").arg(STATE_ON, STATE_ONOFF_VALUE, STATE_VALUE_TRUE); + + return QString("{\"%1\":{\"%2\":%3}}").arg(STATE_ON, STATE_ONOFF_VALUE, STATE_VALUE_FALSE); } bool DriverNetNanoleaf::powerOn() @@ -496,8 +514,6 @@ bool DriverNetNanoleaf::powerOn() if (_isDeviceReady) { changeToExternalControlMode(); - - //Power-on Nanoleaf device _restApi->setPath(API_STATE); _restApi->put(getOnOffRequest(true)); } @@ -508,86 +524,70 @@ bool DriverNetNanoleaf::powerOff() { if (_isDeviceReady) { - //Power-off the Nanoleaf device physically _restApi->setPath(API_STATE); _restApi->put(getOnOffRequest(false)); } return true; } - - -int DriverNetNanoleaf::writeFiniteColors(const std::vector& ledValues) +int DriverNetNanoleaf::writeStreamBatch(const std::vector& ledValues, int startIndex, int count, int& ledCounter) { - int retVal = 0; - - // - // nPanels 2B - // panelID 2B - // 3B - // 1B - // tranitionTime 2B - // - // Note: Nanoleaf Light Panels (Aurora) now support External Control V2 (tested with FW 3.2.0) - - int udpBufferSize = STREAM_FRAME_PANEL_NUM_SIZE + _panelLedCount * STREAM_FRAME_PANEL_INFO_SIZE; - QByteArray udpbuffer; - udpbuffer.resize(udpBufferSize); + udpbuffer.resize(STREAM_FRAME_PANEL_NUM_SIZE + count * STREAM_FRAME_PANEL_INFO_SIZE); int i = 0; - - // Set number of panels - qToBigEndian(static_cast(_panelLedCount), udpbuffer.data() + i); + qToBigEndian(static_cast(count), udpbuffer.data() + i); i += 2; - ColorRgb color; + // Panels keep the original 100ms transition; lightstrips use 0 for video sync + const quint16 transitionTime = _isLightstrip ? 0 : 1; - //Maintain LED counter independent from PanelCounter - int ledCounter = 0; - for (int panelCounter = 0; panelCounter < _panelLedCount; panelCounter++) + for (int j = 0; j < count; ++j) { - int panelID = _panelIds[panelCounter]; + const int panelCounter = startIndex + j; + ColorRgb color = ColorRgb::BLACK; - // Set panels configured - if (panelCounter >= _startPos && panelCounter <= _endPos) { - color = static_cast(ledValues.at(ledCounter)); - ++ledCounter; - } - else + if (panelCounter >= _startPos && panelCounter <= _endPos && ledCounter < static_cast(ledValues.size())) { - // Set panels not configured to black; - color = ColorRgb::BLACK; - DebugIf(verbose3, _log, "[{:d}] >= panelLedCount [{:d}] => Set to BLACK", panelCounter, _panelLedCount); + color = ledValues[static_cast(ledCounter)]; + if (_isLightstrip) + color = candyColor(color); + ++ledCounter; } - // Set panelID - qToBigEndian(static_cast(panelID), udpbuffer.data() + i); + qToBigEndian(static_cast(_panelIds[panelCounter]), udpbuffer.data() + i); i += 2; - - // Set panel's color LEDs udpbuffer[i++] = static_cast(color.red); udpbuffer[i++] = static_cast(color.green); udpbuffer[i++] = static_cast(color.blue); + udpbuffer[i++] = 0; + qToBigEndian(transitionTime, udpbuffer.data() + i); + i += 2; + } - // Set white LED - udpbuffer[i++] = 0; // W not set manually + return writeBytes(udpbuffer); +} - // Set transition time - unsigned char tranitionTime = 1; // currently fixed at value 1 which corresponds to 100ms - qToBigEndian(static_cast(tranitionTime), udpbuffer.data() + i); - i += 2; +int DriverNetNanoleaf::writeFiniteColors(const std::vector& ledValues) +{ + // v2 stream frame: nLeds(2B) + [id(2B) RGBW(4B) transition(2B)] * n + // Write rate is owned by HyperHDR smoothing / LedDevice refresh, not this driver. - DebugIf(verbose3, _log, "[{:d}] Color: {{{:d},{:d},{:d}}}", panelCounter, color.red, color.green, color.blue); - } + const int batchSize = _isLightstrip ? _streamLedsPerDatagram : _panelLedCount; + int retVal = 0; + int ledCounter = 0; - if (verbose3) + for (int index = 0; index < _panelLedCount; index += batchSize) { - Debug(_log, "UDP-Address [{:s}], UDP-Port [{:d}], udpBufferSize[{:d}], Bytes to send [{:d}]", (_address.toString()), _port, udpBufferSize, i); - Debug(_log, "packet: [{:s}]", (toHex(udpbuffer, 64))); + if (index > 0 && _streamBatchGapMs > 0) + QThread::msleep(static_cast(_streamBatchGapMs)); + + const int count = std::min(batchSize, _panelLedCount - index); + retVal = writeStreamBatch(ledValues, index, count, ledCounter); + if (retVal < 0) + return retVal; } - retVal = writeBytes(udpbuffer); return retVal; } diff --git a/tests/nanoleaf/Test-NanoleafStream.ps1 b/tests/nanoleaf/Test-NanoleafStream.ps1 new file mode 100644 index 000000000..9ad15f04d --- /dev/null +++ b/tests/nanoleaf/Test-NanoleafStream.ps1 @@ -0,0 +1,350 @@ +#requires -Version 5.1 +<# +.SYNOPSIS + Probe how much UDP stream traffic a Nanoleaf Essentials/HD controller can take + before it freezes. + +.DESCRIPTION + Reads LED count from GET /length (no hardcoded strip size), enables extControl, + then sweeps leds-per-datagram, inter-batch gap, and frame rate. + + After each test the script GETs /state. If that hangs or fails, the controller + likely froze and you will need to power-cycle it. + +.PARAMETER HostName + Controller IP or hostname. + +.PARAMETER Token + OpenAPI token. Falls back to $env:NANOLEAF_TOKEN. + +.EXAMPLE + $env:NANOLEAF_TOKEN = 'your-token' + .\Test-NanoleafStream.ps1 -HostName 192.168.86.154 + +.EXAMPLE + .\Test-NanoleafStream.ps1 -HostName 192.168.86.154 -Token 'your-token' -SingleTest -LedsPerDatagram 150 -BatchGapMs 10 -FrameRateHz 20 +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$HostName, + + [string]$Token = $env:NANOLEAF_TOKEN, + + [int]$ApiPort = 16021, + [int]$UdpPort = 60222, + + [int]$SecondsPerTest = 4, + + [int[]]$LedsPerDatagramList = @(50, 75, 100, 125, 150, 183, 300), + [int[]]$BatchGapMsList = @(0, 5, 10, 20), + [int[]]$FrameRateHzList = @(10, 20, 30, 60), + + [switch]$SingleTest, + [switch]$Realtime, + [int]$LedsPerDatagram = 150, + [int]$BatchGapMs = 10, + [int]$FrameRateHz = 20 +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +if ([string]::IsNullOrWhiteSpace($Token)) +{ + throw "Token is required. Pass -Token or set `$env:NANOLEAF_TOKEN." +} + +function Get-ApiBase +{ + return "http://${HostName}:${ApiPort}/api/v1/$Token" +} + +function Invoke-NanoleafGet +{ + param( + [string]$Path = '', + [int]$TimeoutSec = 3 + ) + + $uri = (Get-ApiBase).TrimEnd('/') + $(if ($Path) { "/$Path" } else { '/' }) + return Invoke-RestMethod -Method Get -Uri $uri -TimeoutSec $TimeoutSec +} + +function Invoke-NanoleafPut +{ + param( + [string]$Path, + [string]$Body, + [int]$TimeoutSec = 5 + ) + + $uri = (Get-ApiBase).TrimEnd('/') + "/$Path" + return Invoke-RestMethod -Method Put -Uri $uri -ContentType 'application/json' -Body $Body -TimeoutSec $TimeoutSec +} + +function Test-DeviceAlive +{ + try + { + $null = Invoke-NanoleafGet -Path 'state/on' -TimeoutSec 2 + return $true + } + catch + { + return $false + } +} + +function Convert-HsvToRgb +{ + param( + [double]$Hue, + [double]$Saturation = 1.0, + [double]$Value = 1.0 + ) + + $h = (($Hue % 360) + 360) % 360 + $c = $Value * $Saturation + $hp = $h / 60.0 + $x = $c * (1.0 - [Math]::Abs(($hp % 2) - 1.0)) + $m = $Value - $c + + $r = 0.0; $g = 0.0; $b = 0.0 + if ($hp -lt 1) { $r = $c; $g = $x } + elseif ($hp -lt 2) { $r = $x; $g = $c } + elseif ($hp -lt 3) { $g = $c; $b = $x } + elseif ($hp -lt 4) { $g = $x; $b = $c } + elseif ($hp -lt 5) { $r = $x; $b = $c } + else { $r = $c; $b = $x } + + return [byte[]]@( + [byte][Math]::Round(($r + $m) * 255), + [byte][Math]::Round(($g + $m) * 255), + [byte][Math]::Round(($b + $m) * 255) + ) +} + +function New-StreamFrame +{ + param( + [int[]]$Ids, + [byte[][]]$Rgb, + [uint16]$Transition = 0 + ) + + $count = $Ids.Length + $buf = New-Object byte[] (2 + ($count * 8)) + $buf[0] = [byte](($count -shr 8) -band 0xFF) + $buf[1] = [byte]($count -band 0xFF) + $offset = 2 + + for ($i = 0; $i -lt $count; $i++) + { + $id = $Ids[$i] + $buf[$offset++] = [byte](($id -shr 8) -band 0xFF) + $buf[$offset++] = [byte]($id -band 0xFF) + $buf[$offset++] = $Rgb[$i][0] + $buf[$offset++] = $Rgb[$i][1] + $buf[$offset++] = $Rgb[$i][2] + $buf[$offset++] = 0 + $buf[$offset++] = [byte](($Transition -shr 8) -band 0xFF) + $buf[$offset++] = [byte]($Transition -band 0xFF) + } + + return ,$buf +} + +function Send-StreamFrame +{ + param( + [System.Net.Sockets.UdpClient]$Udp, + [int]$NumLeds, + [int]$LedsPerDatagram, + [int]$BatchGapMs, + [int]$FrameIndex + ) + + # Every LED is addressed every frame, the same way HyperHDR sends a full layout. + # A moving rainbow makes it obvious that the whole strip is live, not a small window. + $hueShift = ($FrameIndex * 8) % 360 + + for ($start = 0; $start -lt $NumLeds; $start += $LedsPerDatagram) + { + $count = [Math]::Min($LedsPerDatagram, $NumLeds - $start) + $ids = New-Object int[] $count + $rgb = New-Object 'byte[][]' $count + + for ($i = 0; $i -lt $count; $i++) + { + $led = $start + $i + $ids[$i] = $led + $hue = $hueShift + (360.0 * $led / $NumLeds) + $rgb[$i] = Convert-HsvToRgb -Hue $hue + } + + $packet = New-StreamFrame -Ids $ids -Rgb $rgb -Transition 0 + [void]$Udp.Send($packet, $packet.Length) + + if (($start + $count) -lt $NumLeds -and $BatchGapMs -gt 0) + { + Start-Sleep -Milliseconds $BatchGapMs + } + } +} + +function Invoke-StreamTrial +{ + param( + [System.Net.Sockets.UdpClient]$Udp, + [int]$NumLeds, + [int]$LedsPerDatagram, + [int]$BatchGapMs, + [int]$FrameRateHz, + [int]$Seconds + ) + + $intervalMs = [Math]::Max(1, [int](1000 / $FrameRateHz)) + $frames = [int]($Seconds * 1000 / $intervalMs) + $sw = [System.Diagnostics.Stopwatch]::StartNew() + + for ($f = 0; $f -lt $frames; $f++) + { + Send-StreamFrame -Udp $Udp -NumLeds $NumLeds -LedsPerDatagram $LedsPerDatagram -BatchGapMs $BatchGapMs -FrameIndex $f + $target = ($f + 1) * $intervalMs + $remain = $target - [int]$sw.ElapsedMilliseconds + if ($remain -gt 0) + { + Start-Sleep -Milliseconds $remain + } + } + + $alive = Test-DeviceAlive + [pscustomobject]@{ + LedsPerDatagram = $LedsPerDatagram + PacketsPerFrame = [Math]::Ceiling($NumLeds / [double]$LedsPerDatagram) + BytesPerPacket = 2 + ([Math]::Min($LedsPerDatagram, $NumLeds) * 8) + BatchGapMs = $BatchGapMs + FrameRateHz = $FrameRateHz + Seconds = $Seconds + Alive = $alive + Result = $(if ($alive) { 'OK' } else { 'FROZEN' }) + } +} + +Write-Host "Querying device at ${HostName}:${ApiPort} ..." +$info = Invoke-NanoleafGet +$length = Invoke-NanoleafGet -Path 'length' +$numLeds = [int]$length.numLEDs + +Write-Host ("Name={0} Model={1} FW={2} LEDs={3}" -f $info.name, $info.model, $info.firmwareVersion, $numLeds) +if ($numLeds -le 0) +{ + throw "GET /length returned no LEDs." +} + +Write-Host "Enabling extControl v2 ..." +Invoke-NanoleafPut -Path 'effects' -Body '{"write":{"command":"display","animType":"extControl","extControlVersion":"v2"}}' | Out-Null +Start-Sleep -Milliseconds 200 + +$udp = New-Object System.Net.Sockets.UdpClient +$udp.Connect($HostName, $UdpPort) + +$results = New-Object System.Collections.Generic.List[object] + +try +{ + Write-Host "Warmup: 50 LEDs/datagram, 10ms gap, 10 Hz ..." + $warmup = Invoke-StreamTrial -Udp $udp -NumLeds $numLeds -LedsPerDatagram 50 -BatchGapMs 10 -FrameRateHz 10 -Seconds 2 + $results.Add($warmup) + if (-not $warmup.Alive) + { + Write-Warning "Device froze during warmup. Power-cycle the controller and retry with a smaller first batch." + $results | Format-Table -AutoSize + return + } + + if ($Realtime) + { + Write-Host ("Realtime: addressing all {0} LEDs in one datagram, {1} Hz, {2}s" -f $numLeds, $FrameRateHz, $SecondsPerTest) + $results.Add((Invoke-StreamTrial -Udp $udp -NumLeds $numLeds -LedsPerDatagram $numLeds -BatchGapMs 0 -FrameRateHz $FrameRateHz -Seconds $SecondsPerTest)) + } + elseif ($SingleTest) + { + Write-Host ("Single test: {0} LEDs/datagram, {1}ms gap, {2} Hz, {3}s" -f $LedsPerDatagram, $BatchGapMs, $FrameRateHz, $SecondsPerTest) + $results.Add((Invoke-StreamTrial -Udp $udp -NumLeds $numLeds -LedsPerDatagram $LedsPerDatagram -BatchGapMs $BatchGapMs -FrameRateHz $FrameRateHz -Seconds $SecondsPerTest)) + } + else + { + Write-Host "`n=== Phase 1: max LEDs per datagram (10 Hz, 10ms gap) ===" + $bestBatch = 50 + foreach ($batch in $LedsPerDatagramList) + { + Write-Host (" Trying {0} LEDs/datagram ..." -f $batch) + $row = Invoke-StreamTrial -Udp $udp -NumLeds $numLeds -LedsPerDatagram $batch -BatchGapMs 10 -FrameRateHz 10 -Seconds $SecondsPerTest + $results.Add($row) + if (-not $row.Alive) + { + Write-Warning "Device stopped responding at $batch LEDs/datagram. Stop the sweep and power-cycle if needed." + break + } + $bestBatch = $batch + } + + if ((Test-DeviceAlive)) + { + Write-Host "`n=== Phase 2: inter-batch gap at $bestBatch LEDs/datagram, 10 Hz ===" + $bestGap = 10 + foreach ($gap in $BatchGapMsList) + { + Write-Host (" Trying {0}ms gap ..." -f $gap) + $row = Invoke-StreamTrial -Udp $udp -NumLeds $numLeds -LedsPerDatagram $bestBatch -BatchGapMs $gap -FrameRateHz 10 -Seconds $SecondsPerTest + $results.Add($row) + if (-not $row.Alive) + { + Write-Warning "Device stopped responding at ${gap}ms gap." + break + } + $bestGap = $gap + } + } + + if ((Test-DeviceAlive)) + { + Write-Host "`n=== Phase 3: frame rate at $bestBatch LEDs/datagram, ${bestGap}ms gap ===" + foreach ($hz in $FrameRateHzList) + { + Write-Host (" Trying {0} Hz ..." -f $hz) + $row = Invoke-StreamTrial -Udp $udp -NumLeds $numLeds -LedsPerDatagram $bestBatch -BatchGapMs $bestGap -FrameRateHz $hz -Seconds $SecondsPerTest + $results.Add($row) + if (-not $row.Alive) + { + Write-Warning "Device stopped responding at ${hz} Hz." + break + } + } + } + } +} +finally +{ + $udp.Close() +} + +Write-Host "`n=== Results ===" +$results | Format-Table -AutoSize + +$ok = @($results | Where-Object { $_.Alive -and $_.LedsPerDatagram }) +if ($ok.Count -gt 0) +{ + $safe = $ok | Sort-Object FrameRateHz -Descending | Select-Object -First 1 + Write-Host "Suggested HyperHDR device JSON extras (tune from a passing row):" + Write-Host (' "streamLedsPerDatagram": {0},' -f $safe.LedsPerDatagram) + Write-Host (' "streamBatchGapMs": {0},' -f $safe.BatchGapMs) + Write-Host (' "streamMinIntervalMs": {0}' -f [int](1000 / [Math]::Max(1, $safe.FrameRateHz))) +} + +if (-not (Test-DeviceAlive)) +{ + Write-Warning "Controller is not answering HTTP. Power-cycle it before using HyperHDR." +}