Files
ledgrab/server/src/ledgrab/templates/modals/add-device.html
T
alexei.dolgolyov 8f1140abad feat(devices): standalone DDP target type
Promotes the existing DDP packet layer (previously WLED-internal) to a
first-class device type so any DDP-speaking receiver (Pixelblaze,
ESPixelStick, xLights/Falcon endpoints, generic firmware) can be driven
directly without WLED in the path.

Backend:
- New DDPLEDClient wraps the DDPClient transport as a proper LEDClient
  with supports_fast_send=True (synchronous UDP push on the hot loop).
- New DDPDeviceProvider — no native discovery, manual LED count,
  capabilities = {manual_led_count, health_check}.
- DDPConfig joins the typed config union; Device storage gains
  ddp_port / ddp_destination_id / ddp_color_order fields with safe
  defaults (0/1/1 -> port 4048, destination 1=display, RGB byte order).
- URL scheme: ddp://host[:port] or bare host[:port] (default 4048).
- Health check resolves the host via async DNS; UDP has no reply
  channel so reachability is best-effort by design.
- 29 new tests in test_ddp_led_client.py cover URL parsing, packet
  hot path (brightness, list/numpy input shapes, fast vs async send),
  provider validate/discover/capabilities, config round-trip via
  Device.to_config() and to_dict/from_dict.

Frontend:
- 'ddp' in DEVICE_TYPE_KEYS (next to 'dmx'), paper-plane icon.
- isDdpDevice predicate + per-type field show/hide in the create &
  settings modals.
- Color-order picker uses IconSelect (project rule bans plain select).
- Locale strings added in en/ru/zh.

Note: this commit also carries two pre-existing in-flight hunks that
were intermixed in the same files and could not be split out
non-interactively:
- api/routes/devices.py: URL-scheme inference for bare WLED hosts,
  safer error messages, exception-isolated parallel discovery.
- storage/device_store.py: secret_box helpers + at-rest encryption of
  Hue / BLE-Govee / MQTT credentials.
Both are independent of DDP and intentional per the user.
2026-05-16 01:26:45 +03:00

392 lines
29 KiB
HTML

<!-- Add Device Modal -->
<div id="add-device-modal" class="modal" role="dialog" aria-modal="true" aria-labelledby="add-device-modal-title">
<div class="modal-content">
<div class="modal-header">
<h2 id="add-device-modal-title" data-i18n="devices.add">Add New Device</h2>
<div class="modal-header-actions">
<button type="button" class="modal-header-btn" id="scan-network-btn" onclick="scanForDevices()" data-i18n-title="device.scan" title="Auto Discovery"><svg class="icon" viewBox="0 0 24 24"><path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/></svg></button>
<button class="modal-close-btn" onclick="closeAddDeviceModal()" title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div>
</div>
<div class="modal-body">
<div id="discovery-section" class="discovery-section" style="display: none;">
<div id="discovery-loading" class="discovery-loading" style="display: none;">
<span class="discovery-spinner"></span>
</div>
<div id="discovery-list" class="discovery-list"></div>
<div id="discovery-empty" style="display: none;">
<small data-i18n="device.scan.empty">No devices found</small>
</div>
<hr class="modal-divider">
</div>
<form id="add-device-form">
<!-- ── 01 · IDENTITY ───────────────────────────────── -->
<section class="ds-section" data-ds-key="identity" data-ch="signal">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.identity">Identity</span>
<span class="ds-section-index" aria-hidden="true">01</span>
</div>
<div class="ds-section-body">
<div class="form-group" id="device-type-group">
<div class="label-row">
<label for="device-type" data-i18n="device.type">Device Type:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.type.hint">Select the type of LED controller</small>
<select id="device-type" onchange="onDeviceTypeChanged()">
<option value="wled">WLED</option>
<option value="adalight">Adalight</option>
<option value="ambiled">AmbiLED</option>
<option value="mqtt">MQTT</option>
<option value="ws">WebSocket</option>
<option value="openrgb">OpenRGB</option>
<option value="dmx">DMX</option>
<option value="ddp">DDP</option>
<option value="espnow">ESP-NOW</option>
<option value="hue">Philips Hue</option>
<option value="ble">BLE LED Controller</option>
<option value="usbhid">USB HID</option>
<option value="spi">SPI Direct</option>
<option value="chroma">Razer Chroma</option>
<option value="gamesense">SteelSeries</option>
<option value="mock">Mock</option>
<option value="group">Group</option>
</select>
</div>
<div class="form-group ds-name-group">
<label for="device-name" data-i18n="device.name">Device Name:</label>
<input type="text" id="device-name" data-i18n-placeholder="device.name.placeholder" placeholder="Living Room TV" required>
</div>
</div>
</section>
<!-- ── 02 · CONNECTION ─────────────────────────────── -->
<section class="ds-section" data-ds-key="connection" data-ch="cyan">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.connection">Connection</span>
<span class="ds-section-index" aria-hidden="true">02</span>
</div>
<div class="ds-section-body">
<div class="form-group" id="device-url-group">
<div class="label-row">
<label for="device-url" id="device-url-label" data-i18n="device.url">URL:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" id="device-url-hint" data-i18n="device.url.hint">IP address or hostname of the device (e.g. http://192.168.1.100)</small>
<input type="text" id="device-url" data-i18n-placeholder="device.url.placeholder" placeholder="http://192.168.1.100" required>
</div>
<div class="form-group" id="device-serial-port-group" style="display: none;">
<div class="label-row">
<label for="device-serial-port" id="device-serial-port-label" data-i18n="device.serial_port">Serial Port:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.serial_port.hint">Select the COM port of the Adalight device</small>
<select id="device-serial-port" onfocus="onSerialPortFocus()"></select>
</div>
<div class="form-group" id="device-zone-group" style="display: none;">
<div class="label-row">
<label data-i18n="device.openrgb.zone">Zones:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.openrgb.zone.hint">Select which LED zones to control (leave all unchecked for all zones)</small>
<div id="device-zone-list" class="zone-checkbox-list"></div>
</div>
<div class="form-group" id="device-zone-mode-group" style="display: none;">
<div class="label-row">
<label data-i18n="device.openrgb.mode">Zone mode:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.openrgb.mode.hint">Combined treats all zones as one continuous LED strip. Separate renders each zone independently with the full effect.</small>
<div class="zone-mode-radios">
<label class="zone-mode-option">
<input type="radio" name="device-zone-mode" value="combined" checked>
<span data-i18n="device.openrgb.mode.combined">Combined strip</span>
</label>
<label class="zone-mode-option">
<input type="radio" name="device-zone-mode" value="separate">
<span data-i18n="device.openrgb.mode.separate">Independent zones</span>
</label>
</div>
</div>
<div class="form-group" id="device-led-count-group" style="display: none;">
<div class="label-row">
<label for="device-led-count" data-i18n="device.led_count">LED Count:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.led_count_manual.hint">Number of LEDs on the strip (must match your Arduino sketch)</small>
<input type="number" id="device-led-count" min="1" max="10000" placeholder="60" oninput="updateBaudFpsHint()">
</div>
<div class="form-group" id="device-baud-rate-group" style="display: none;">
<div class="label-row">
<label for="device-baud-rate" data-i18n="device.baud_rate">Baud Rate:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.baud_rate.hint">Serial communication speed. Higher = more FPS but requires matching Arduino sketch.</small>
<select id="device-baud-rate" onchange="updateBaudFpsHint()">
<option value="115200">115200</option>
<option value="230400">230400</option>
<option value="460800">460800</option>
<option value="500000">500000</option>
<option value="921600">921600</option>
<option value="1000000">1000000</option>
<option value="1500000">1500000</option>
<option value="2000000">2000000</option>
</select>
<small id="baud-fps-hint" class="fps-hint" style="display:none"></small>
</div>
<div class="form-group" id="device-led-type-group" style="display: none;">
<div class="label-row">
<label for="device-led-type" data-i18n="device.led_type">LED Type:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.led_type.hint">RGB (3 channels) or RGBW (4 channels with dedicated white)</small>
<select id="device-led-type">
<option value="rgb">RGB</option>
<option value="rgbw">RGBW</option>
</select>
</div>
<div class="form-group" id="device-send-latency-group" style="display: none;">
<div class="label-row">
<label for="device-send-latency" data-i18n="device.send_latency">Send Latency (ms):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.send_latency.hint">Simulated network/serial delay per frame in milliseconds</small>
<input type="number" id="device-send-latency" min="0" max="5000" value="0">
</div>
<div class="form-group" id="device-dmx-protocol-group" style="display: none;">
<div class="label-row">
<label for="device-dmx-protocol" data-i18n="device.dmx_protocol">DMX Protocol:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.dmx_protocol.hint">Art-Net uses UDP port 6454, sACN (E1.31) uses UDP port 5568</small>
<select id="device-dmx-protocol">
<option value="artnet">Art-Net</option>
<option value="sacn">sACN (E1.31)</option>
</select>
</div>
<div class="form-group" id="device-dmx-start-universe-group" style="display: none;">
<div class="label-row">
<label for="device-dmx-start-universe" data-i18n="device.dmx_start_universe">Start Universe:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.dmx_start_universe.hint">First DMX universe (0-32767). Multiple universes are used automatically for >170 LEDs.</small>
<input type="number" id="device-dmx-start-universe" min="0" max="32767" value="0">
</div>
<div class="form-group" id="device-dmx-start-channel-group" style="display: none;">
<div class="label-row">
<label for="device-dmx-start-channel" data-i18n="device.dmx_start_channel">Start Channel:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.dmx_start_channel.hint">First DMX channel within the universe (1-512)</small>
<input type="number" id="device-dmx-start-channel" min="1" max="512" value="1">
</div>
<!-- DDP fields -->
<div class="form-group" id="device-ddp-port-group" style="display: none;">
<div class="label-row">
<label for="device-ddp-port" data-i18n="device.ddp_port">DDP Port:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.ddp_port.hint">UDP port (0 = protocol default 4048).</small>
<input type="number" id="device-ddp-port" min="0" max="65535" value="0">
</div>
<div class="form-group" id="device-ddp-destination-id-group" style="display: none;">
<div class="label-row">
<label for="device-ddp-destination-id" data-i18n="device.ddp_destination_id">Destination ID:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.ddp_destination_id.hint">DDP destination identifier (1 = display).</small>
<input type="number" id="device-ddp-destination-id" min="0" max="255" value="1">
</div>
<div class="form-group" id="device-ddp-color-order-group" style="display: none;">
<div class="label-row">
<label for="device-ddp-color-order" data-i18n="device.ddp_color_order">Color Order:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.ddp_color_order.hint">Channel byte order on the wire. Most DDP receivers expect RGB.</small>
<select id="device-ddp-color-order">
<option value="0">GRB</option>
<option value="1" selected>RGB</option>
<option value="2">BRG</option>
<option value="3">RBG</option>
<option value="4">BGR</option>
<option value="5">GBR</option>
</select>
</div>
<!-- ESP-NOW fields -->
<div class="form-group" id="device-espnow-peer-mac-group" style="display: none;">
<div class="label-row">
<label for="device-espnow-peer-mac" data-i18n="device.espnow.peer_mac">Peer MAC:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.espnow.peer_mac.hint">MAC address of the remote ESP32 receiver (e.g. AA:BB:CC:DD:EE:FF)</small>
<input type="text" id="device-espnow-peer-mac" placeholder="AA:BB:CC:DD:EE:FF" pattern="^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$">
</div>
<div class="form-group" id="device-espnow-channel-group" style="display: none;">
<div class="label-row">
<label for="device-espnow-channel" data-i18n="device.espnow.channel">WiFi Channel:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.espnow.channel.hint">WiFi channel (1-14). Must match the receiver's channel.</small>
<input type="number" id="device-espnow-channel" min="1" max="14" value="1">
</div>
<!-- Philips Hue fields -->
<div class="form-group" id="device-hue-username-group" style="display: none;">
<div class="label-row">
<label for="device-hue-username" data-i18n="device.hue.username">Bridge Username:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.hue.username.hint">Hue bridge application key from pairing</small>
<input type="text" id="device-hue-username" placeholder="Hue application key">
</div>
<div class="form-group" id="device-hue-client-key-group" style="display: none;">
<div class="label-row">
<label for="device-hue-client-key" data-i18n="device.hue.client_key">Client Key:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.hue.client_key.hint">Entertainment API client key (hex string from pairing)</small>
<input type="text" id="device-hue-client-key" placeholder="Hex client key">
</div>
<div class="form-group" id="device-hue-group-id-group" style="display: none;">
<div class="label-row">
<label for="device-hue-group-id" data-i18n="device.hue.group_id">Entertainment Group:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.hue.group_id.hint">Entertainment configuration ID from your Hue bridge</small>
<input type="text" id="device-hue-group-id" placeholder="Entertainment group ID">
</div>
<!-- BLE LED Controller fields -->
<div class="form-group" id="device-ble-family-group" style="display: none;">
<div class="label-row">
<label for="device-ble-family" data-i18n="device.ble.family">Protocol Family:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.ble.family.hint">Which BLE protocol your controller speaks. Match the phone app you normally use.</small>
<select id="device-ble-family">
<option value="sp110e">SP110E / SP108E</option>
<option value="triones">Triones / HappyLighting / LEDnet</option>
<option value="zengge">Zengge / iLightsIn</option>
<option value="govee">Govee (experimental)</option>
</select>
</div>
<div class="form-group" id="device-ble-govee-key-group" style="display: none;">
<div class="label-row">
<label for="device-ble-govee-key" data-i18n="device.ble.govee_key">Govee AES Key (hex):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.ble.govee_key.hint">Optional. Newer Govee firmware needs a per-model AES key — leave blank for older firmware.</small>
<input type="text" id="device-ble-govee-key"
data-i18n-placeholder="device.ble.govee_key.placeholder"
placeholder="32 hex digits, e.g. 0102…1f20">
</div>
<!-- SPI Direct fields -->
<div class="form-group" id="device-spi-speed-group" style="display: none;">
<div class="label-row">
<label for="device-spi-speed" data-i18n="device.spi.speed">SPI Speed (Hz):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.spi.speed.hint">SPI clock speed. 800000 Hz for WS2812, 2400000 Hz for APA102.</small>
<input type="number" id="device-spi-speed" min="100000" max="4000000" value="800000">
</div>
<div class="form-group" id="device-spi-led-type-group" style="display: none;">
<div class="label-row">
<label for="device-spi-led-type" data-i18n="device.spi.led_type">LED Chipset:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.spi.led_type.hint">Type of addressable LED strip connected to the GPIO/SPI pin</small>
<select id="device-spi-led-type">
<option value="WS2812B">WS2812B</option>
<option value="WS2812">WS2812</option>
<option value="WS2811">WS2811</option>
<option value="SK6812">SK6812 (RGB)</option>
<option value="SK6812_RGBW">SK6812 (RGBW)</option>
</select>
</div>
<!-- Razer Chroma fields -->
<div class="form-group" id="device-chroma-device-type-group" style="display: none;">
<div class="label-row">
<label for="device-chroma-device-type" data-i18n="device.chroma.device_type">Peripheral Type:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.chroma.device_type.hint">Which Razer peripheral to control</small>
<select id="device-chroma-device-type">
<option value="chromalink">ChromaLink (5 zones)</option>
<option value="keyboard">Keyboard (132 keys)</option>
<option value="mouse">Mouse (30 LEDs)</option>
<option value="mousepad">Mousepad (15 LEDs)</option>
<option value="headset">Headset (5 zones)</option>
<option value="keypad">Keypad (20 keys)</option>
</select>
</div>
<!-- Group device fields -->
<div class="form-group" id="device-group-children-group" style="display: none;">
<div class="label-row">
<label data-i18n="device.group.children">Child Devices:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.group.children.hint">Select devices to include in this group (order matters for sequence mode)</small>
<div id="device-group-children-list" class="group-children-list"></div>
<button type="button" class="btn btn-sm btn-secondary" id="device-group-add-child-btn" onclick="addGroupChild()" data-i18n="device.group.add_child">+ Add Device</button>
</div>
<div class="form-group" id="device-group-mode-group" style="display: none;">
<div class="label-row">
<label data-i18n="device.group.mode">Group Mode:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.group.mode.hint">Sequence concatenates LEDs end-to-end. Independent mirrors the full strip to each device.</small>
<select id="device-group-mode-select">
<option value="sequence">Sequence</option>
<option value="independent">Independent</option>
</select>
</div>
<!-- SteelSeries GameSense fields -->
<div class="form-group" id="device-gamesense-device-type-group" style="display: none;">
<div class="label-row">
<label for="device-gamesense-device-type" data-i18n="device.gamesense.device_type">Peripheral Type:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.gamesense.device_type.hint">Which SteelSeries peripheral to control</small>
<select id="device-gamesense-device-type">
<option value="keyboard">Keyboard</option>
<option value="mouse">Mouse</option>
<option value="headset">Headset</option>
<option value="mousepad">Mousepad</option>
<option value="indicator">Indicator</option>
</select>
</div>
</div>
</section>
<!-- ── 03 · OUTPUT ─────────────────────────────────── -->
<section class="ds-section" data-ds-key="output" data-ch="amber">
<div class="ds-section-header">
<span class="ds-section-dot" aria-hidden="true"></span>
<span class="ds-section-title" data-i18n="settings.section.output">Output</span>
<span class="ds-section-index" aria-hidden="true">03</span>
</div>
<div class="ds-section-body">
<div class="form-group" id="device-cspt-group">
<div class="label-row">
<label for="device-css-processing-template" data-i18n="device.css_processing_template">Strip Processing Template:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="device.css_processing_template.hint">Default processing template applied to all color strip outputs on this device</small>
<select id="device-css-processing-template">
<option value=""></option>
</select>
</div>
</div>
</section>
<div id="add-device-error" class="error-message" style="display: none;"></div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeAddDeviceModal()" title="Cancel" data-i18n-aria-label="aria.cancel">&#x2715;</button>
<button class="btn btn-icon btn-primary" onclick="document.getElementById('add-device-form').requestSubmit()" title="Add Device" data-i18n-aria-label="aria.save">&#x2713;</button>
</div>
</div>
</div>