Add DDP (Distributed Display Protocol) controller support

This commit is contained in:
Wolfieee Wolf
2025-07-02 00:27:00 +10:00
committed by Adam Honse
parent 570cc16c98
commit a31c4f5254
8 changed files with 930 additions and 0 deletions

View File

@@ -0,0 +1,309 @@
/*---------------------------------------------------------*\
| DDPController.cpp |
| |
| Driver for DDP protocol devices |
| |
| This file is part of the OpenRGB project |
| SPDX-License-Identifier: GPL-2.0-only |
\*---------------------------------------------------------*/
#include "DDPController.h"
#include "LogManager.h"
#include <cstring>
#include <algorithm>
DDPController::DDPController(const std::vector<DDPDevice>& device_list)
{
devices = device_list;
unique_endpoints = NULL;
num_endpoints = 0;
sequence_number = 0;
keepalive_time_ms = 1000;
keepalive_thread_run = false;
InitializeNetPorts();
if(!devices.empty())
{
keepalive_thread_run = true;
keepalive_thread = std::thread(&DDPController::KeepaliveThreadFunction, this);
}
}
DDPController::~DDPController()
{
keepalive_thread_run = false;
if(keepalive_thread.joinable())
{
keepalive_thread.join();
}
CloseNetPorts();
if(unique_endpoints != NULL)
{
delete[] unique_endpoints;
}
}
bool DDPController::InitializeNetPorts()
{
if(devices.empty())
{
return true;
}
num_endpoints = 0;
for(unsigned int dev_idx = 0; dev_idx < devices.size(); dev_idx++)
{
bool found = false;
for(unsigned int ep_idx = 0; ep_idx < num_endpoints; ep_idx++)
{
if(strcmp(unique_endpoints[ep_idx].ip, devices[dev_idx].ip.c_str()) == 0 &&
unique_endpoints[ep_idx].port == devices[dev_idx].port)
{
found = true;
break;
}
}
if(!found)
{
num_endpoints++;
}
}
unique_endpoints = new DDPEndpoint[num_endpoints];
unsigned int endpoint_count = 0;
for(unsigned int dev_idx = 0; dev_idx < devices.size(); dev_idx++)
{
bool found = false;
for(unsigned int ep_idx = 0; ep_idx < endpoint_count; ep_idx++)
{
if(strcmp(unique_endpoints[ep_idx].ip, devices[dev_idx].ip.c_str()) == 0 &&
unique_endpoints[ep_idx].port == devices[dev_idx].port)
{
found = true;
break;
}
}
if(!found)
{
strncpy(unique_endpoints[endpoint_count].ip, devices[dev_idx].ip.c_str(), 15);
unique_endpoints[endpoint_count].ip[15] = '\0';
unique_endpoints[endpoint_count].port = devices[dev_idx].port;
endpoint_count++;
}
}
for(unsigned int ep_idx = 0; ep_idx < num_endpoints; ep_idx++)
{
net_port* port = new net_port();
char port_str[16];
snprintf(port_str, 16, "%d", unique_endpoints[ep_idx].port);
if(port->udp_client(unique_endpoints[ep_idx].ip, port_str))
{
udp_ports.push_back(port);
}
else
{
udp_ports.push_back(NULL);
}
}
return true;
}
void DDPController::CloseNetPorts()
{
for(unsigned int port_idx = 0; port_idx < udp_ports.size(); port_idx++)
{
if(udp_ports[port_idx] != NULL)
{
delete udp_ports[port_idx];
}
}
udp_ports.clear();
}
int DDPController::GetPortIndex(const DDPDevice& device)
{
for(unsigned int ep_idx = 0; ep_idx < num_endpoints; ep_idx++)
{
if(strcmp(unique_endpoints[ep_idx].ip, device.ip.c_str()) == 0 &&
unique_endpoints[ep_idx].port == device.port)
{
return (int)ep_idx;
}
}
return -1;
}
void DDPController::UpdateLEDs(const std::vector<unsigned int>& colors)
{
if(udp_ports.empty()) return;
{
std::lock_guard<std::mutex> lock(last_update_mutex);
last_colors = colors;
last_update_time = std::chrono::steady_clock::now();
}
unsigned int color_index = 0;
for(unsigned int dev_idx = 0; dev_idx < devices.size(); dev_idx++)
{
if(color_index >= colors.size()) break;
unsigned int bytes_per_pixel = 3;
unsigned int total_bytes = devices[dev_idx].num_leds * bytes_per_pixel;
std::vector<unsigned char> device_data(total_bytes);
for(unsigned int led_idx = 0; led_idx < devices[dev_idx].num_leds && (color_index + led_idx) < colors.size(); led_idx++)
{
unsigned int color = colors[color_index + led_idx];
unsigned char r = color & 0xFF;
unsigned char g = (color >> 8) & 0xFF;
unsigned char b = (color >> 16) & 0xFF;
unsigned int pixel_offset = led_idx * bytes_per_pixel;
device_data[pixel_offset + 0] = r;
device_data[pixel_offset + 1] = g;
device_data[pixel_offset + 2] = b;
}
unsigned int max_data_per_packet = DDP_MAX_DATA_SIZE;
unsigned int bytes_sent = 0;
while(bytes_sent < total_bytes)
{
unsigned int chunk_size = (max_data_per_packet < (total_bytes - bytes_sent)) ? max_data_per_packet : (total_bytes - bytes_sent);
if(!SendDDPPacket(devices[dev_idx], device_data.data() + bytes_sent, (unsigned short)chunk_size, bytes_sent))
break;
bytes_sent += chunk_size;
}
color_index += devices[dev_idx].num_leds;
}
sequence_number++;
}
bool DDPController::SendDDPPacket(const DDPDevice& device, const unsigned char* data, unsigned short length, unsigned int offset)
{
int port_index = GetPortIndex(device);
if(port_index < 0 || port_index >= (int)udp_ports.size())
{
return false;
}
if(udp_ports[port_index] == NULL)
{
net_port* port = new net_port();
char port_str[16];
snprintf(port_str, 16, "%d", unique_endpoints[port_index].port);
if(port->udp_client(unique_endpoints[port_index].ip, port_str))
{
udp_ports[port_index] = port;
}
else
{
delete port;
return false;
}
}
std::vector<unsigned char> packet(DDP_HEADER_SIZE + length);
ddp_header* header = (ddp_header*)packet.data();
header->flags = DDP_FLAG_VER_1 | DDP_FLAG_PUSH;
header->sequence = sequence_number & 0x0F;
header->data_type = 1;
header->dest_id = 1;
header->data_offset = htonl(offset);
header->data_length = htons(length);
memcpy(packet.data() + DDP_HEADER_SIZE, data, length);
int bytes_sent = udp_ports[port_index]->udp_write((char*)packet.data(), (int)packet.size());
return bytes_sent == (int)packet.size();
}
void DDPController::SetKeepaliveTime(unsigned int time_ms)
{
keepalive_time_ms = time_ms;
}
void DDPController::KeepaliveThreadFunction()
{
while(keepalive_thread_run)
{
std::this_thread::sleep_for(std::chrono::milliseconds(100));
if(keepalive_time_ms == 0)
continue;
std::vector<unsigned int> colors_to_send;
bool should_send = false;
{
std::lock_guard<std::mutex> lock(last_update_mutex);
std::chrono::steady_clock::time_point now = std::chrono::steady_clock::now();
long long time_since_update = std::chrono::duration_cast<std::chrono::milliseconds>(now - last_update_time).count();
if(time_since_update >= keepalive_time_ms && !last_colors.empty())
{
colors_to_send = last_colors;
should_send = true;
last_update_time = now;
}
}
if(should_send)
{
unsigned int color_index = 0;
for(unsigned int dev_idx = 0; dev_idx < devices.size(); dev_idx++)
{
if(color_index >= colors_to_send.size()) break;
unsigned int bytes_per_pixel = 3;
unsigned int total_bytes = devices[dev_idx].num_leds * bytes_per_pixel;
std::vector<unsigned char> device_data(total_bytes);
for(unsigned int led_idx = 0; led_idx < devices[dev_idx].num_leds && (color_index + led_idx) < colors_to_send.size(); led_idx++)
{
unsigned int color = colors_to_send[color_index + led_idx];
unsigned char r = color & 0xFF;
unsigned char g = (color >> 8) & 0xFF;
unsigned char b = (color >> 16) & 0xFF;
unsigned int pixel_offset = led_idx * bytes_per_pixel;
device_data[pixel_offset + 0] = r;
device_data[pixel_offset + 1] = g;
device_data[pixel_offset + 2] = b;
}
unsigned int max_data_per_packet = DDP_MAX_DATA_SIZE;
unsigned int bytes_sent = 0;
while(bytes_sent < total_bytes)
{
unsigned int chunk_size = (max_data_per_packet < (total_bytes - bytes_sent)) ? max_data_per_packet : (total_bytes - bytes_sent);
if(!SendDDPPacket(devices[dev_idx], device_data.data() + bytes_sent, (unsigned short)chunk_size, bytes_sent))
break;
bytes_sent += chunk_size;
}
color_index += devices[dev_idx].num_leds;
}
}
}
}

View File

@@ -0,0 +1,97 @@
/*---------------------------------------------------------*\
| DDPController.h |
| |
| Driver for DDP protocol devices |
| |
| This file is part of the OpenRGB project |
| SPDX-License-Identifier: GPL-2.0-only |
\*---------------------------------------------------------*/
#pragma once
#include <string>
#include <vector>
#include <cstdint>
#include <thread>
#include <chrono>
#include <atomic>
#include <mutex>
#include "net_port.h"
#define DDP_DEFAULT_PORT 4048
#define DDP_HEADER_SIZE 10
#define DDP_HEADER_SIZE_TC 14
#define DDP_VERSION 1
#define DDP_MAX_PACKET_SIZE 1450
#define DDP_MAX_DATA_SIZE 1440
#define DDP_FLAG_VER_MASK 0xC0
#define DDP_FLAG_VER_1 0x40
#define DDP_FLAG_TIMECODE 0x10
#define DDP_FLAG_STORAGE 0x08
#define DDP_FLAG_REPLY 0x04
#define DDP_FLAG_QUERY 0x02
#define DDP_FLAG_PUSH 0x01
#define DDP_TYPE_RGB8 0x0B
#define DDP_TYPE_RGB_SIMPLE 1
#pragma pack(push, 1)
struct ddp_header
{
unsigned char flags;
unsigned char sequence;
unsigned char data_type;
unsigned char dest_id;
unsigned int data_offset;
unsigned short data_length;
};
#pragma pack(pop)
struct DDPDevice
{
std::string name;
std::string ip;
unsigned short port;
unsigned int num_leds;
};
struct DDPEndpoint
{
char ip[16];
unsigned short port;
};
class DDPController
{
public:
DDPController(const std::vector<DDPDevice>& devices);
~DDPController();
void UpdateLEDs(const std::vector<unsigned int>& colors);
void SetKeepaliveTime(unsigned int time_ms);
private:
std::vector<DDPDevice> devices;
std::vector<net_port*> udp_ports;
DDPEndpoint* unique_endpoints;
unsigned int num_endpoints;
unsigned char sequence_number;
std::atomic<bool> keepalive_thread_run;
std::thread keepalive_thread;
std::mutex last_update_mutex;
std::chrono::steady_clock::time_point last_update_time;
std::vector<unsigned int> last_colors;
unsigned int keepalive_time_ms;
bool InitializeNetPorts();
void CloseNetPorts();
int GetPortIndex(const DDPDevice& device);
bool SendDDPPacket(const DDPDevice& device,
const unsigned char* data,
unsigned short length,
unsigned int offset = 0);
void KeepaliveThreadFunction();
};

View File

@@ -0,0 +1,92 @@
/*---------------------------------------------------------*\
| DDPControllerDetect.cpp |
| |
| Detector for DDP devices |
| |
| This file is part of the OpenRGB project |
| SPDX-License-Identifier: GPL-2.0-only |
\*---------------------------------------------------------*/
#include <string>
#include <vector>
#include "Detector.h"
#include "RGBController.h"
#include "RGBController_DDP.h"
#include "SettingsManager.h"
#include "LogManager.h"
#include "nlohmann/json.hpp"
using json = nlohmann::json;
void DetectDDPControllers()
{
json ddp_settings;
std::vector<std::vector<DDPDevice>> device_lists;
DDPDevice dev;
ddp_settings = ResourceManager::get()->GetSettingsManager()->GetSettings("DDPDevices");
if(ddp_settings.contains("devices"))
{
for(unsigned int device_idx = 0; device_idx < ddp_settings["devices"].size(); device_idx++)
{
dev.name = "";
dev.ip = "";
dev.port = DDP_DEFAULT_PORT;
dev.num_leds = 0;
if(ddp_settings["devices"][device_idx].contains("name"))
dev.name = ddp_settings["devices"][device_idx]["name"];
if(ddp_settings["devices"][device_idx].contains("ip"))
dev.ip = ddp_settings["devices"][device_idx]["ip"];
if(ddp_settings["devices"][device_idx].contains("port"))
dev.port = ddp_settings["devices"][device_idx]["port"];
if(ddp_settings["devices"][device_idx].contains("num_leds"))
dev.num_leds = ddp_settings["devices"][device_idx]["num_leds"];
if(dev.name.empty())
dev.name = "DDP Device " + std::to_string(device_idx + 1);
if(dev.ip.empty())
{
continue;
}
if(dev.num_leds == 0)
{
continue;
}
bool device_added_to_existing_list = false;
for(unsigned int list_idx = 0; list_idx < device_lists.size(); list_idx++)
{
for(unsigned int existing_device_idx = 0; existing_device_idx < device_lists[list_idx].size(); existing_device_idx++)
{
if(dev.ip == device_lists[list_idx][existing_device_idx].ip &&
dev.port == device_lists[list_idx][existing_device_idx].port)
{
device_lists[list_idx].push_back(dev);
device_added_to_existing_list = true;
break;
}
}
if(device_added_to_existing_list)
break;
}
if(!device_added_to_existing_list)
{
std::vector<DDPDevice> new_list;
new_list.push_back(dev);
device_lists.push_back(new_list);
}
}
for(unsigned int list_idx = 0; list_idx < device_lists.size(); list_idx++)
{
RGBController_DDP* rgb_controller = new RGBController_DDP(device_lists[list_idx]);
ResourceManager::get()->RegisterRGBController(rgb_controller);
}
}
}
REGISTER_DETECTOR("DDP", DetectDDPControllers);

View File

@@ -0,0 +1,136 @@
/*---------------------------------------------------------*\
| RGBController_DDP.cpp |
| |
| RGBController for DDP devices |
| |
| This file is part of the OpenRGB project |
| SPDX-License-Identifier: GPL-2.0-only |
\*---------------------------------------------------------*/
#include "RGBController_DDP.h"
/**------------------------------------------------------------------*\
@name DDP Devices
@category LEDStrip
@type Network
@save :x:
@direct :white_check_mark:
@effects :x:
@detectors DetectDDPControllers
@comment
\*-------------------------------------------------------------------*/
RGBController_DDP::RGBController_DDP(std::vector<DDPDevice> device_list)
{
devices = device_list;
name = "DDP Device Group";
type = DEVICE_TYPE_LEDSTRIP;
description = "Distributed Display Protocol Device";
location = "DDP: ";
if(devices.size() == 1)
name = devices[0].name;
else if(!devices[0].ip.empty())
name += " (" + devices[0].ip + ")";
if(!devices[0].ip.empty())
location += devices[0].ip + ":" + std::to_string(devices[0].port);
mode Direct;
Direct.name = "Direct";
Direct.value = 0;
Direct.flags = MODE_FLAG_HAS_PER_LED_COLOR | MODE_FLAG_HAS_BRIGHTNESS;
Direct.color_mode = MODE_COLORS_PER_LED;
Direct.brightness_min = 0;
Direct.brightness_max = 100;
Direct.brightness = 100;
modes.push_back(Direct);
controller = new DDPController(devices);
SetupZones();
}
RGBController_DDP::~RGBController_DDP()
{
delete controller;
}
void RGBController_DDP::SetupZones()
{
for(unsigned int zone_idx = 0; zone_idx < devices.size(); zone_idx++)
{
zone led_zone;
led_zone.name = devices[zone_idx].name;
led_zone.type = ZONE_TYPE_LINEAR;
led_zone.leds_min = devices[zone_idx].num_leds;
led_zone.leds_max = devices[zone_idx].num_leds;
led_zone.leds_count = devices[zone_idx].num_leds;
led_zone.matrix_map = NULL;
zones.push_back(led_zone);
}
for(unsigned int zone_idx = 0; zone_idx < zones.size(); zone_idx++)
{
for(unsigned int led_idx = 0; led_idx < zones[zone_idx].leds_count; led_idx++)
{
led new_led;
new_led.name = zones[zone_idx].name + " LED " + std::to_string(led_idx + 1);
new_led.value = 0;
leds.push_back(new_led);
}
}
SetupColors();
}
void RGBController_DDP::ResizeZone(int /*zone*/, int /*new_size*/)
{
}
void RGBController_DDP::DeviceUpdateLEDs()
{
std::vector<unsigned int> brightness_adjusted_colors;
brightness_adjusted_colors.reserve(colors.size());
float brightness_scale = (float)modes[active_mode].brightness / 100.0f;
for(unsigned int color_idx = 0; color_idx < colors.size(); color_idx++)
{
unsigned int color = colors[color_idx];
unsigned char r = color & 0xFF;
unsigned char g = (color >> 8) & 0xFF;
unsigned char b = (color >> 16) & 0xFF;
r = (unsigned char)(r * brightness_scale);
g = (unsigned char)(g * brightness_scale);
b = (unsigned char)(b * brightness_scale);
unsigned int adjusted_color = r | (g << 8) | (b << 16);
brightness_adjusted_colors.push_back(adjusted_color);
}
controller->UpdateLEDs(brightness_adjusted_colors);
}
void RGBController_DDP::UpdateZoneLEDs(int /*zone*/)
{
DeviceUpdateLEDs();
}
void RGBController_DDP::UpdateSingleLED(int /*led*/)
{
DeviceUpdateLEDs();
}
void RGBController_DDP::DeviceUpdateMode()
{
}
void RGBController_DDP::SetCustomMode()
{
active_mode = 0;
}
void RGBController_DDP::SetKeepaliveTime(unsigned int time_ms)
{
if(controller != nullptr)
{
controller->SetKeepaliveTime(time_ms);
}
}

View File

@@ -0,0 +1,34 @@
/*---------------------------------------------------------*\
| RGBController_DDP.h |
| |
| RGBController for DDP devices |
| |
| This file is part of the OpenRGB project |
| SPDX-License-Identifier: GPL-2.0-only |
\*---------------------------------------------------------*/
#pragma once
#include <vector>
#include "RGBController.h"
#include "DDPController.h"
class RGBController_DDP : public RGBController
{
public:
RGBController_DDP(std::vector<DDPDevice> device_list);
~RGBController_DDP();
void SetupZones();
void ResizeZone(int zone, int new_size);
void DeviceUpdateLEDs();
void UpdateZoneLEDs(int zone);
void UpdateSingleLED(int led);
void DeviceUpdateMode();
void SetCustomMode();
void SetKeepaliveTime(unsigned int time_ms);
private:
std::vector<DDPDevice> devices;
DDPController* controller;
};

View File

@@ -0,0 +1,100 @@
/*---------------------------------------------------------*\
| DDPSettingsEntry.cpp |
| |
| User interface for OpenRGB DDP settings entry |
| |
| This file is part of the OpenRGB project |
| SPDX-License-Identifier: GPL-2.0-only |
\*---------------------------------------------------------*/
#include "DDPSettingsEntry.h"
#include "ui_DDPSettingsEntry.h"
#include "ManualDevicesTypeManager.h"
#include "nlohmann/json.hpp"
using json = nlohmann::json;
DDPSettingsEntry::DDPSettingsEntry(QWidget *parent) :
BaseManualDeviceEntry(parent),
ui(new Ui::DDPSettingsEntry)
{
ui->setupUi(this);
}
DDPSettingsEntry::~DDPSettingsEntry()
{
delete ui;
}
void DDPSettingsEntry::changeEvent(QEvent *event)
{
if(event->type() == QEvent::LanguageChange)
{
ui->retranslateUi(this);
}
}
void DDPSettingsEntry::loadFromSettings(const json& data)
{
if(data.contains("name"))
{
ui->NameEdit->setText(QString::fromStdString(data["name"]));
}
if(data.contains("ip"))
{
ui->IPEdit->setText(QString::fromStdString(data["ip"]));
}
if(data.contains("port"))
{
ui->PortSpinBox->setValue(data["port"]);
}
else
{
ui->PortSpinBox->setValue(4048);
}
if(data.contains("num_leds"))
{
ui->NumLedsSpinBox->setValue(data["num_leds"]);
}
if(data.contains("keepalive_time"))
{
ui->KeepaliveTimeSpinBox->setValue(data["keepalive_time"]);
}
}
json DDPSettingsEntry::saveSettings()
{
json result;
result["name"] = ui->NameEdit->text().toStdString();
result["ip"] = ui->IPEdit->text().toStdString();
result["port"] = ui->PortSpinBox->value();
result["num_leds"] = ui->NumLedsSpinBox->value();
if(ui->KeepaliveTimeSpinBox->value() > 0)
{
result["keepalive_time"] = ui->KeepaliveTimeSpinBox->value();
}
return result;
}
bool DDPSettingsEntry::isDataValid()
{
return !ui->IPEdit->text().isEmpty() && ui->NumLedsSpinBox->value() > 0;
}
static BaseManualDeviceEntry* SpawnDDPEntry(const json& data)
{
DDPSettingsEntry* entry = new DDPSettingsEntry;
entry->loadFromSettings(data);
return entry;
}
static const char* DDPDeviceName = QT_TRANSLATE_NOOP("ManualDevice", "DDP (Distributed Display Protocol)");
REGISTER_MANUAL_DEVICE_TYPE(DDPDeviceName, "DDPDevices", SpawnDDPEntry);

View File

@@ -0,0 +1,35 @@
/*---------------------------------------------------------*\
| DDPSettingsEntry.h |
| |
| User interface for OpenRGB DDP settings entry |
| |
| This file is part of the OpenRGB project |
| SPDX-License-Identifier: GPL-2.0-only |
\*---------------------------------------------------------*/
#pragma once
#include "BaseManualDeviceEntry.h"
namespace Ui
{
class DDPSettingsEntry;
}
class DDPSettingsEntry : public BaseManualDeviceEntry
{
Q_OBJECT
public:
explicit DDPSettingsEntry(QWidget *parent = nullptr);
~DDPSettingsEntry();
void loadFromSettings(const json& data);
json saveSettings() override;
bool isDataValid() override;
private:
Ui::DDPSettingsEntry *ui;
private slots:
void changeEvent(QEvent *event) override;
};

View File

@@ -0,0 +1,127 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>DDPSettingsEntry</class>
<widget class="QWidget" name="DDPSettingsEntry">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>400</width>
<height>200</height>
</rect>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Maximum">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="windowTitle">
<string notr="true">DDP Settings Entry</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="1" column="0" colspan="2">
<widget class="QGroupBox" name="groupBox">
<property name="title">
<string>DDP Device</string>
</property>
<layout class="QGridLayout" name="gridLayout_2">
<item row="0" column="0">
<widget class="QLabel" name="IPLabel">
<property name="text">
<string>IP Address:</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QLineEdit" name="IPEdit">
<property name="placeholderText">
<string>192.168.1.100</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="NameLabel">
<property name="text">
<string>Name:</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QLineEdit" name="NameEdit">
<property name="placeholderText">
<string>Device Name</string>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="PortLabel">
<property name="text">
<string>Port:</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QSpinBox" name="PortSpinBox">
<property name="minimum">
<number>1</number>
</property>
<property name="maximum">
<number>65535</number>
</property>
<property name="value">
<number>4048</number>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QLabel" name="NumLedsLabel">
<property name="text">
<string>Number of LEDs:</string>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QSpinBox" name="NumLedsSpinBox">
<property name="minimum">
<number>1</number>
</property>
<property name="maximum">
<number>10000</number>
</property>
<property name="value">
<number>50</number>
</property>
</widget>
</item>
<item row="4" column="0">
<widget class="QLabel" name="KeepaliveTimeLabel">
<property name="text">
<string>Keepalive Time (ms):</string>
</property>
</widget>
</item>
<item row="4" column="1">
<widget class="QSpinBox" name="KeepaliveTimeSpinBox">
<property name="minimum">
<number>0</number>
</property>
<property name="maximum">
<number>10000</number>
</property>
<property name="value">
<number>1000</number>
</property>
<property name="specialValueText">
<string>Disabled</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>