feat(calibration): browser-driven auto edge-calibration UI (phase 3)

Reusable, chase-driven calibration flow that solves + saves the linear
CalibrationConfig with a few taps — no LED counting — and works in the
browser on desktop and Android (no Tkinter dependency).

- features/auto-calibration.ts: 5-step flow (start corner -> direction ->
  tap-to-mark-corners -> solved preview -> save). Drives the phase-1 session
  endpoints (session/position/solve) and persists via PUT /color-strip-
  sources/{id}. cornerIndices[0] is anchored to strip index 0 per the solver
  contract. unmountAutoCalibration() is the single cleanup gate — the
  calibration session is always stopped (device restored) on cancel, modal
  close, after save, AND on a mid-flow error, so the strip is never left dark
  or stuck.
- Public API mountAutoCalibration({container, cssId, deviceId, onComplete,
  onCancel}) for the phase-4 wizard to embed; showAutoCalibration() standalone.
- "Auto-calibrate" entry added to the existing calibration modal; standalone
  modal template; app.ts/global.d.ts exports; .autocal-* CSS matching the
  ds-section vocabulary; 43 autocal.* i18n keys in en/ru/zh; docs/CALIBRATION.md.

Part of the edge-calibration + first-run-wizard feature (Big Bang; intermediate
phase — full build/suite + test-writer gated at the final phase).
This commit is contained in:
2026-06-08 15:52:45 +03:00
parent 9dcd76d264
commit 9550688c1e
12 changed files with 1468 additions and 22 deletions
+42 -1
View File
@@ -54,7 +54,48 @@ When you attach a device, a default calibration is created:
}
```
## Custom Calibration
## Automatic Calibration
The easiest way to calibrate your strip is the **Auto-Calibrate** wizard, available directly
from the calibration modal. No LED counting required — just answer three questions and tap four
corners.
### Prerequisites
- A **Color Strip Source** (not a device-only target) associated with the strip.
- A **WLED device** connected and reachable by LedGrab.
### How to Start
1. Open the **Calibration** modal for your strip source (pencil icon → Calibration tab).
2. Click the **Auto-calibrate** button in the modal footer.
3. Follow the five-step wizard.
### Wizard Steps
| Step | What you do |
| ---- | ----------- |
| 1. Device | Select the WLED device that drives the strip. |
| 2. Start corner | LED #0 lights up on your device. Tap the corner where you see it. |
| 3. Direction | Sweep a few LEDs light up in sequence. Tap the direction they move. |
| 4. Mark corners | Use the step buttons to sweep to each remaining corner, then tap **Mark corner**. Repeat for all 4 corners. |
| 5. Preview & Save | Review the detected layout (start position, direction, LED counts per edge). Click **Save** to apply. |
### What Happens in the Background
- A calibration session takes exclusive control of the device for the duration of the wizard;
any previously running effect is paused and automatically restored when the wizard exits
(whether by saving, cancelling, or closing the modal).
- The solved `CalibrationConfig` is written directly to the Color Strip Source via the existing
PUT endpoint and takes effect immediately (no restart needed).
### Tips
- If LED #0 is hard to see, reduce ambient lighting briefly.
- The wizard works in the browser — desktop and Android TV app both supported.
- If you make a mistake in step 4, use **Step back** to re-mark the previous corner.
## Manual Calibration
### Step 1: Identify Your LED Layout
@@ -2283,3 +2283,371 @@ textarea:focus-visible {
.pair-ring-fg { transition: none; }
}
/* =========================================================
Auto-Calibration Wizard
========================================================= */
/* Step wrapper */
.autocal-step {
display: flex;
flex-direction: column;
gap: 20px;
}
.autocal-step-header {
display: flex;
align-items: center;
gap: 12px;
}
.autocal-step-icon {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 50%;
background: color-mix(in srgb, var(--primary-color) 15%, transparent);
color: var(--primary-color);
flex-shrink: 0;
}
.autocal-step-icon .icon { width: 18px; height: 18px; }
.autocal-step-icon--ok {
background: color-mix(in srgb, var(--success-color, #4caf50) 15%, transparent);
color: var(--success-color, #4caf50);
}
.autocal-step-title {
font-size: 1rem;
font-weight: 600;
color: var(--text-color);
margin: 0;
}
.autocal-step-desc {
font-size: 0.85rem;
color: var(--text-muted, var(--secondary-text-color));
line-height: 1.5;
margin: 0;
}
/* Corner selection grid (2x2) */
.autocal-corner-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.autocal-corner-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 16px 12px;
border: 1.5px solid var(--border-color);
border-radius: var(--radius-md, 8px);
background: var(--card-bg);
color: var(--text-color);
cursor: pointer;
transition: border-color 0.15s, background 0.15s, color 0.15s;
font-size: 0.82rem;
font-weight: 500;
text-align: center;
}
.autocal-corner-btn:hover {
border-color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 8%, var(--card-bg));
color: var(--primary-color);
}
.autocal-corner-btn:active {
background: color-mix(in srgb, var(--primary-color) 18%, var(--card-bg));
}
.autocal-corner-glyph {
font-size: 1.4rem;
line-height: 1;
}
/* Direction selection grid (1x2) */
.autocal-direction-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.autocal-direction-btn {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 18px 12px;
border: 1.5px solid var(--border-color);
border-radius: var(--radius-md, 8px);
background: var(--card-bg);
color: var(--text-color);
cursor: pointer;
transition: border-color 0.15s, background 0.15s, color 0.15s;
font-size: 0.82rem;
font-weight: 500;
text-align: center;
}
.autocal-direction-btn:hover {
border-color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 8%, var(--card-bg));
color: var(--primary-color);
}
.autocal-direction-btn .icon { width: 28px; height: 28px; }
.autocal-corner-btn[disabled], .autocal-direction-btn[disabled] { opacity: .5; cursor: default; pointer-events: none; }
/* LED indicator (live LED preview row) */
.autocal-led-indicator {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
background: color-mix(in srgb, var(--primary-color) 6%, var(--card-bg));
border: 1px solid color-mix(in srgb, var(--primary-color) 20%, var(--border-color));
border-radius: var(--radius-sm, 6px);
font-size: 0.8rem;
color: var(--text-muted, var(--secondary-text-color));
}
.autocal-led-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--border-color);
transition: background 0.2s;
flex-shrink: 0;
}
.autocal-led-dot--active {
background: var(--primary-color);
box-shadow: 0 0 6px color-mix(in srgb, var(--primary-color) 70%, transparent);
}
.autocal-led-index {
font-weight: 600;
color: var(--primary-color);
min-width: 28px;
text-align: right;
}
/* Corner marking progress (step 4) */
.autocal-corners-progress {
display: flex;
flex-direction: column;
gap: 14px;
}
.autocal-pips {
display: flex;
gap: 8px;
align-items: center;
}
.autocal-pip {
width: 28px;
height: 28px;
border-radius: 50%;
border: 2px solid var(--border-color);
background: var(--card-bg);
display: flex;
align-items: center;
justify-content: center;
font-size: 0.7rem;
font-weight: 700;
color: var(--text-muted, var(--secondary-text-color));
transition: border-color 0.2s, background 0.2s, color 0.2s;
flex-shrink: 0;
}
.autocal-pip--done {
border-color: var(--success-color, #4caf50);
background: color-mix(in srgb, var(--success-color, #4caf50) 15%, var(--card-bg));
color: var(--success-color, #4caf50);
}
.autocal-pip--active {
border-color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 15%, var(--card-bg));
color: var(--primary-color);
}
.autocal-index-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 10px;
font-size: 0.78rem;
font-weight: 600;
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
color: var(--primary-color);
}
/* LED sweep row */
.autocal-sweep-row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 0;
}
.autocal-led-track {
flex: 1;
height: 6px;
border-radius: 3px;
background: var(--border-color);
position: relative;
overflow: hidden;
}
.autocal-led-track-fill {
position: absolute;
left: 0;
top: 0;
bottom: 0;
background: var(--primary-color);
border-radius: 3px;
transition: width 0.1s linear;
}
.autocal-led-cursor {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--primary-color);
box-shadow: 0 0 8px color-mix(in srgb, var(--primary-color) 80%, transparent);
pointer-events: none;
}
.autocal-sweep-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1.5px solid var(--border-color);
border-radius: var(--radius-sm, 6px);
background: var(--card-bg);
color: var(--text-color);
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
flex-shrink: 0;
}
.autocal-sweep-btn:hover {
border-color: var(--primary-color);
color: var(--primary-color);
}
.autocal-sweep-btn .icon { width: 16px; height: 16px; }
.autocal-mark-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border: none;
border-radius: var(--radius-md, 8px);
background: var(--primary-color);
color: #fff;
cursor: pointer;
font-size: 0.85rem;
font-weight: 600;
transition: opacity 0.15s;
white-space: nowrap;
}
.autocal-mark-btn:hover { opacity: 0.88; }
.autocal-mark-btn .icon { width: 15px; height: 15px; }
/* Preview / solved grid */
.autocal-solved-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px 12px;
padding: 14px 16px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--radius-md, 8px);
font-size: 0.82rem;
}
.autocal-solved-item {
display: flex;
align-items: baseline;
gap: 6px;
}
.autocal-solved-item--wide {
grid-column: 1 / -1;
border-bottom: 1px solid var(--border-color);
padding-bottom: 8px;
margin-bottom: 4px;
}
.autocal-solved-key {
color: var(--text-muted, var(--secondary-text-color));
flex-shrink: 0;
min-width: 68px;
}
.autocal-solved-val {
font-weight: 600;
color: var(--text-color);
}
.autocal-led-count {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 1px 7px;
border-radius: 10px;
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
color: var(--primary-color);
font-size: 0.78rem;
font-weight: 700;
}
/* Footer row (wizard nav buttons) */
.autocal-footer {
display: flex;
align-items: center;
gap: 10px;
padding-top: 4px;
border-top: 1px solid var(--border-color);
flex-wrap: wrap;
}
.autocal-footer > .btn { min-width: 80px; }
.autocal-footer > .btn:first-child { margin-right: auto; }
/* Inline error */
.autocal-error {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 12px;
border-radius: var(--radius-sm, 6px);
background: color-mix(in srgb, var(--danger-color, #f44336) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--danger-color, #f44336) 30%, transparent);
color: var(--danger-color, #f44336);
font-size: 0.82rem;
line-height: 1.4;
}
.autocal-error .icon { width: 16px; height: 16px; flex-shrink: 0; margin-top: 1px; }
/* "Auto-calibrate" trigger button in calibration modal footer */
.autocal-trigger-btn {
display: inline-flex;
align-items: center;
gap: 6px;
}
.autocal-trigger-btn .icon { width: 14px; height: 14px; }
@media (prefers-reduced-motion: reduce) {
.autocal-led-track-fill,
.autocal-pip,
.autocal-led-dot,
.autocal-corner-btn,
.autocal-direction-btn { transition: none; }
}
+27
View File
@@ -203,12 +203,21 @@ import {
updateOffsetSkipLock, updateCalibrationPreview,
setStartPosition, toggleEdgeInputs, toggleDirection, toggleTestEdge,
showCSSCalibration, toggleCalibrationOverlay,
openAutoCalFromCalibration,
} from './features/calibration.ts';
import {
showAdvancedCalibration, closeAdvancedCalibration, saveAdvancedCalibration,
addCalibrationLine, removeCalibrationLine, selectCalibrationLine, moveCalibrationLine,
updateCalibrationLine, resetCalibrationView,
} from './features/advanced-calibration.ts';
import {
showAutoCalibration, closeAutoCalModal,
autoCalSelectDevice, autoCalSetCorner, autoCalSetDirection,
autoCalBackToCorner, autoCalBackToDirection,
autoCalSweepForward, autoCalSweepBack, autoCalMarkCorner,
autoCalSolve, autoCalSave, autoCalCancel,
mountAutoCalibration, unmountAutoCalibration,
} from './features/auto-calibration.ts';
// Layer 5.5: graph editor
import {
@@ -620,6 +629,24 @@ Object.assign(window, {
toggleTestEdge,
showCSSCalibration,
toggleCalibrationOverlay,
openAutoCalFromCalibration,
// auto-calibration wizard
showAutoCalibration,
closeAutoCalModal,
autoCalSelectDevice,
autoCalSetCorner,
autoCalSetDirection,
autoCalBackToCorner,
autoCalBackToDirection,
autoCalSweepForward,
autoCalSweepBack,
autoCalMarkCorner,
autoCalSolve,
autoCalSave,
autoCalCancel,
mountAutoCalibration,
unmountAutoCalibration,
// advanced calibration
showAdvancedCalibration,
@@ -0,0 +1,810 @@
/**
* Auto-Calibration flow — guided LED-chase corner-tap wizard.
*
* Exports `mountAutoCalibration` / `unmountAutoCalibration` so Phase 4's
* wizard can embed this as a step without modification.
*
* Flow:
* 1. Device selection (EntitySelect; skipped when deviceId supplied)
* 2. Start corner — light index 0; user taps which corner is lit → start_position
* 3. Direction — advance a few indices; user identifies direction → layout
* 4. Tap-to-mark-corners — dot sweeps; user taps NEXT at each physical corner
* (first tap = corner at index 0, per Phase 1 solver contract)
* 5. Preview & Save — POST /calibration/solve → summary → PUT CSS hot-reload
*
* Session contract (Phase 1 handoff):
* POST /api/v1/calibration/session → start (stops running target)
* POST /api/v1/calibration/session/position → advance chase pixel
* POST /api/v1/calibration/session/stop → ALWAYS call on exit / error
* POST /api/v1/calibration/solve → pure solver (no persist)
* PUT /api/v1/color-strip-sources/{id} → persist + hot-reload
*
* CRITICAL: the first corner tap corresponds to LED index 0 so the solver's
* `corner_indices[0] == 0` matches `solve_calibration`'s assumption that the
* start corner is at strip index 0 (Phase 1 review finding).
*/
import { apiPost, apiPut } from '../core/api-client.ts';
import { colorStripSourcesCache, devicesCache } from '../core/state.ts';
import { t } from '../core/i18n.ts';
import { showToast } from '../core/ui.ts';
import { Modal } from '../core/modal.ts';
import { EntitySelect } from '../core/entity-palette.ts';
import { renderDeviceIcon } from '../core/device-icons.ts';
import {
ICON_DEVICE, ICON_ROTATE_CW, ICON_ROTATE_CCW,
ICON_CALIBRATION, ICON_OK,
} from '../core/icons.ts';
// ── Types ─────────────────────────────────────────────────────────────────────
type StartPosition = 'top_left' | 'top_right' | 'bottom_left' | 'bottom_right';
type Layout = 'clockwise' | 'counterclockwise';
type AutoCalStep = 'device' | 'corner' | 'direction' | 'corners' | 'preview';
interface CalibrationSessionState {
active: boolean;
device_id: string | null;
led_count: number;
prior_target_id: string | null;
last_activity: string | null;
}
interface SolvedCalibration {
mode: 'simple';
layout: string;
start_position: string;
leds_top: number;
leds_right: number;
leds_bottom: number;
leds_left: number;
offset: number;
}
interface AutoCalState {
step: AutoCalStep;
cssId: string;
cssSourceType: string;
deviceId: string;
ledCount: number;
startPosition: StartPosition | null;
layout: Layout | null;
/** Strip indices of the 4 physical corners, in strip-walk order.
* cornerIndices[0] is ALWAYS 0 (start corner = LED index 0). */
cornerIndices: number[];
currentIndex: number;
sessionActive: boolean;
busy: boolean;
solved: SolvedCalibration | null;
errorMsg: string;
}
/** Options for `mountAutoCalibration()`. */
export interface AutoCalOptions {
/** DOM container element to render wizard steps into. */
container: HTMLElement;
/** Color-strip source ID being calibrated. */
cssId: string;
/** Pre-selected device ID; if supplied the device-picker step is skipped. */
deviceId?: string;
/** Called after successful save. */
onComplete?: () => void;
/** Called after user cancels (session already stopped before this fires). */
onCancel?: () => void;
}
// ── Module-level singleton ─────────────────────────────────────────────────
let _state: AutoCalState | null = null;
let _opts: AutoCalOptions | null = null;
let _deviceEntitySelect: EntitySelect | null = null;
// ── Public API ─────────────────────────────────────────────────────────────
/**
* Mount the auto-calibration flow into the given container.
*
* Phase 4 usage:
* ```ts
* await mountAutoCalibration({
* container: document.getElementById('wizard-body')!,
* cssId: sourceId,
* deviceId: inferredDeviceId, // optional
* onComplete: () => wizard.next(),
* onCancel: () => wizard.close(),
* });
* ```
* Call `unmountAutoCalibration()` when the containing modal closes to guarantee
* the calibration session is stopped.
*/
export async function mountAutoCalibration(opts: AutoCalOptions): Promise<void> {
await unmountAutoCalibration();
_opts = opts;
let cssSourceType = 'picture';
try {
const sources = await colorStripSourcesCache.fetch() as { id: string; source_type?: string }[];
const src = sources.find(s => s.id === opts.cssId);
if (src) cssSourceType = src.source_type || 'picture';
} catch { /* fallback */ }
_state = {
step: opts.deviceId ? 'corner' : 'device',
cssId: opts.cssId,
cssSourceType,
deviceId: opts.deviceId || '',
ledCount: 0,
startPosition: null,
layout: null,
cornerIndices: [],
currentIndex: 0,
sessionActive: false,
busy: false,
solved: null,
errorMsg: '',
};
_render();
if (opts.deviceId) {
_state.deviceId = opts.deviceId;
await _startSession();
}
}
/**
* Unmount: stop any active session, destroy widgets, clear container.
* Safe to call when nothing is mounted.
*/
export async function unmountAutoCalibration(): Promise<void> {
if (_deviceEntitySelect) { _deviceEntitySelect.destroy(); _deviceEntitySelect = null; }
if (_state?.sessionActive) {
await _stopSession().catch(() => { /* best effort */ });
}
if (_opts?.container) _opts.container.innerHTML = '';
_state = null;
_opts = null;
}
// ── Internal render ────────────────────────────────────────────────────────
function _render(): void {
if (!_opts || !_state) return;
switch (_state.step) {
case 'device': _renderDevice(); break;
case 'corner': _renderCorner(); break;
case 'direction': _renderDirection(); break;
case 'corners': _renderCorners(); break;
case 'preview': _renderPreview(); break;
}
}
// ── Step 1: Device picker ──────────────────────────────────────────────────
function _renderDevice(): void {
if (!_opts) return;
_opts.container.innerHTML = `
<div class="autocal-step" data-step="device">
<div class="autocal-step-header">
<span class="autocal-step-icon">${ICON_DEVICE}</span>
<div>
<div class="autocal-step-title">${_esc(t('autocal.device.title'))}</div>
<div class="autocal-step-desc">${_esc(t('autocal.device.desc'))}</div>
</div>
</div>
<div class="form-group" style="margin-top:16px;">
<label for="autocal-device-select">${_esc(t('autocal.device.label'))}</label>
<select id="autocal-device-select"></select>
</div>
<div id="autocal-error" class="autocal-error" style="display:none"></div>
<div class="autocal-footer">
<button class="btn btn-secondary" onclick="autoCalCancel()">${_esc(t('autocal.btn.cancel'))}</button>
<button class="btn btn-primary" onclick="autoCalSelectDevice()">${_esc(t('autocal.btn.next'))}</button>
</div>
</div>`;
_populateDeviceSelect();
_showError(_state?.errorMsg || '');
}
async function _populateDeviceSelect(): Promise<void> {
const sel = document.getElementById('autocal-device-select') as HTMLSelectElement | null;
if (!sel) return;
let devices: { id: string; name: string; led_count: number; icon?: string }[] = [];
try { devices = await devicesCache.fetch() as typeof devices; } catch { /* empty */ }
sel.innerHTML = '';
devices.forEach(d => {
const opt = document.createElement('option');
opt.value = d.id;
opt.textContent = d.name;
sel.appendChild(opt);
});
if (_deviceEntitySelect) { _deviceEntitySelect.destroy(); _deviceEntitySelect = null; }
if (devices.length > 0) {
_deviceEntitySelect = new EntitySelect({
target: sel,
getItems: () => devices.map(d => ({
value: d.id,
label: d.name,
icon: renderDeviceIcon(d.icon) || ICON_DEVICE,
desc: d.led_count ? `${d.led_count} LEDs` : '',
})),
placeholder: t('palette.search'),
} as ConstructorParameters<typeof EntitySelect>[0]);
}
// Auto-select LED-count-matched device
if (devices.length > 0 && _state) {
try {
const sources = await colorStripSourcesCache.fetch() as { id: string; led_count?: number }[];
const src = sources.find(s => s.id === _state!.cssId);
if (src?.led_count) {
const match = devices.find(d => d.led_count === src.led_count);
if (match) {
sel.value = match.id;
if (_deviceEntitySelect) _deviceEntitySelect.refresh();
}
}
} catch { /* fallback */ }
}
}
export async function autoCalSelectDevice(): Promise<void> {
if (!_state || _state.busy) return;
const sel = document.getElementById('autocal-device-select') as HTMLSelectElement | null;
if (!sel?.value) { _setError(t('autocal.error.no_device')); return; }
_state.deviceId = sel.value;
_state.step = 'corner';
_render();
await _startSession();
}
// ── Step 2: Start corner ──────────────────────────────────────────────────
function _renderCorner(): void {
if (!_opts) return;
const busy = _state?.busy ?? false;
const s = _state!;
_opts.container.innerHTML = `
<div class="autocal-step" data-step="corner">
<div class="autocal-step-header">
<span class="autocal-step-icon">${ICON_CALIBRATION}</span>
<div>
<div class="autocal-step-title">${_esc(t('autocal.corner.title'))}</div>
<div class="autocal-step-desc">${_esc(t('autocal.corner.desc'))}</div>
</div>
</div>
<div class="autocal-led-indicator">
<span class="autocal-led-dot ${busy ? '' : 'autocal-led-dot--active'}" aria-hidden="true"></span>
<span class="autocal-led-index">${_esc(t('autocal.corner.led_index', { index: '0' }))}</span>
</div>
<div class="autocal-corner-grid" ${busy ? 'aria-busy="true"' : ''}>
${(['top_left', 'top_right', 'bottom_left', 'bottom_right'] as StartPosition[]).map(pos =>
`<button class="autocal-corner-btn autocal-corner-btn--${pos.replace('_', '-')}"
onclick="autoCalSetCorner('${pos}')"
${busy ? 'disabled' : ''}
aria-label="${_esc(t(`autocal.position.${pos}`))}">
<span class="autocal-corner-glyph" aria-hidden="true"></span>
<span>${_esc(t(`autocal.position.${pos}`))}</span>
</button>`
).join('')}
</div>
<div id="autocal-error" class="autocal-error" style="display:none"></div>
<div class="autocal-footer">
<button class="btn btn-secondary" onclick="autoCalCancel()">${_esc(t('autocal.btn.cancel'))}</button>
</div>
</div>`;
_showError(s.errorMsg);
}
export async function autoCalSetCorner(position: StartPosition): Promise<void> {
if (!_state || _state.busy) return;
_state.startPosition = position;
_state.step = 'direction';
_state.busy = true;
_render();
try {
// LED is at index 0; advance to ~5% to show movement direction
await _setPosition(0);
await _delay(350);
const advance = Math.max(4, Math.round(_state.ledCount * 0.04));
await _setPosition(advance);
_state.busy = false;
} catch (err: unknown) {
_state.busy = false;
_state.errorMsg = _errMsg(err);
_state.step = 'corner'; // revert on error
}
_render();
}
// ── Step 3: Direction ─────────────────────────────────────────────────────
function _renderDirection(): void {
if (!_opts || !_state) return;
const busy = _state.busy;
const advance = Math.max(4, Math.round(_state.ledCount * 0.04));
_opts.container.innerHTML = `
<div class="autocal-step" data-step="direction">
<div class="autocal-step-header">
<span class="autocal-step-icon">${ICON_ROTATE_CW}</span>
<div>
<div class="autocal-step-title">${_esc(t('autocal.direction.title', { step: String(advance) }))}</div>
<div class="autocal-step-desc">${_esc(t('autocal.direction.desc'))}</div>
</div>
</div>
<div class="autocal-direction-grid">
<button class="autocal-direction-btn" onclick="autoCalSetDirection('clockwise')" ${busy ? 'disabled' : ''}>
${ICON_ROTATE_CW}
<span>${_esc(t('calibration.direction.clockwise'))}</span>
</button>
<button class="autocal-direction-btn" onclick="autoCalSetDirection('counterclockwise')" ${busy ? 'disabled' : ''}>
${ICON_ROTATE_CCW}
<span>${_esc(t('calibration.direction.counterclockwise'))}</span>
</button>
</div>
<div id="autocal-error" class="autocal-error" style="display:none"></div>
<div class="autocal-footer">
<button class="btn btn-secondary" onclick="autoCalCancel()">${_esc(t('autocal.btn.cancel'))}</button>
<button class="btn btn-ghost" onclick="autoCalBackToCorner()">${_esc(t('autocal.btn.back'))}</button>
</div>
</div>`;
_showError(_state.errorMsg);
}
export async function autoCalSetDirection(layout: Layout): Promise<void> {
if (!_state || _state.busy) return;
_state.layout = layout;
// corner_indices[0] MUST be 0 (Phase 1 solver contract: start corner = index 0)
_state.cornerIndices = [0];
_state.currentIndex = 0;
_state.step = 'corners';
_render();
await _setPosition(0).catch(() => { /* best effort */ });
}
export async function autoCalBackToCorner(): Promise<void> {
if (!_state || _state.busy) return;
_state.step = 'corner';
_state.startPosition = null;
_state.errorMsg = '';
_render();
await _setPosition(0).catch(() => { /* best effort */ });
}
// ── Step 4: Tap-to-mark corners ───────────────────────────────────────────
function _renderCorners(): void {
if (!_opts || !_state) return;
const { cornerIndices, currentIndex, ledCount, busy } = _state;
const collected = cornerIndices.length; // starts at 1 (index 0 already in)
const isComplete = collected >= 4;
const cornerLabels = _cornerLabels(_state.startPosition!, _state.layout!);
const pips = [0, 1, 2, 3].map(i => {
const done = i < collected;
const active = i === collected - 1;
return `<span class="autocal-pip ${done ? 'autocal-pip--done' : ''} ${active ? 'autocal-pip--active' : ''}"
aria-label="${cornerLabels[i]}">${i + 1}</span>`;
}).join('');
const activeCornerLabel = isComplete ? '' : cornerLabels[collected - 1];
_opts.container.innerHTML = `
<div class="autocal-step" data-step="corners">
<div class="autocal-step-header">
<span class="autocal-step-icon">${ICON_CALIBRATION}</span>
<div>
<div class="autocal-step-title">${_esc(isComplete ? t('autocal.corners.title', { remaining: '0' }) : t('autocal.corners.title', { remaining: String(4 - collected) }))}</div>
<div class="autocal-step-desc">${_esc(
isComplete
? t('autocal.corners.desc_complete')
: t('autocal.corners.desc', { corner: activeCornerLabel })
)}</div>
</div>
</div>
<div class="autocal-corners-progress">
<div class="autocal-pips">${pips}</div>
<div class="autocal-index-badge">
<span class="autocal-index-label">${_esc(t('autocal.corners.index_label'))}</span>
<span class="autocal-index-value">${currentIndex}</span>
<span class="autocal-index-total">/ ${ledCount - 1}</span>
</div>
</div>
<div class="autocal-sweep-row">
<button class="btn btn-ghost btn-sm autocal-sweep-btn" onclick="autoCalSweepBack()" ${busy || isComplete || currentIndex <= 0 ? 'disabled' : ''}
aria-label="${_esc(t('autocal.btn.step_back'))}">&#8592;</button>
<div class="autocal-led-track">
<div class="autocal-led-track-fill" style="width:${ledCount > 1 ? (currentIndex / (ledCount - 1)) * 100 : 0}%"></div>
<div class="autocal-led-cursor" style="left:${ledCount > 1 ? (currentIndex / (ledCount - 1)) * 100 : 0}%"></div>
</div>
<button class="btn btn-ghost btn-sm autocal-sweep-btn" onclick="autoCalSweepForward()" ${busy || isComplete || currentIndex >= ledCount - 1 ? 'disabled' : ''}
aria-label="${_esc(t('autocal.btn.step_fwd'))}">&#8594;</button>
</div>
${isComplete ? '' : `
<button class="btn btn-primary autocal-mark-btn" onclick="autoCalMarkCorner()" ${busy ? 'disabled' : ''}>
${_esc(t('autocal.btn.mark_corner', { n: String(collected), label: activeCornerLabel }))}
</button>`}
<div id="autocal-error" class="autocal-error" style="display:none"></div>
<div class="autocal-footer">
<button class="btn btn-secondary" onclick="autoCalCancel()">${_esc(t('autocal.btn.cancel'))}</button>
<button class="btn btn-ghost" onclick="autoCalBackToDirection()">${_esc(t('autocal.btn.back'))}</button>
${isComplete ? `<button class="btn btn-primary" onclick="autoCalSolve()">${_esc(t('autocal.btn.solve'))}</button>` : ''}
</div>
</div>`;
_showError(_state.errorMsg);
}
function _cornerLabels(startPos: StartPosition, layout: Layout): string[] {
const all: StartPosition[] = ['top_left', 'top_right', 'bottom_right', 'bottom_left'];
const si = all.indexOf(startPos);
let ordered: StartPosition[];
if (layout === 'clockwise') {
ordered = [all[si % 4], all[(si + 1) % 4], all[(si + 2) % 4], all[(si + 3) % 4]];
} else {
ordered = [all[si % 4], all[(si + 3) % 4], all[(si + 2) % 4], all[(si + 1) % 4]];
}
return ordered.map(c => t(`autocal.position.${c}`));
}
export async function autoCalSweepForward(): Promise<void> {
if (!_state || _state.busy || _state.cornerIndices.length >= 4) return;
const next = _state.currentIndex + 1;
if (next >= _state.ledCount) return;
_state.busy = true;
try {
await _setPosition(next);
_state.currentIndex = next;
_state.errorMsg = '';
} catch (err: unknown) {
_state.errorMsg = _errMsg(err);
} finally {
_state.busy = false;
_render();
}
}
export async function autoCalSweepBack(): Promise<void> {
if (!_state || _state.busy || _state.cornerIndices.length >= 4) return;
const prev = _state.currentIndex - 1;
// Clamp to one past the last marked corner index to preserve monotonic ordering.
const lastMarked = _state.cornerIndices.length > 0
? _state.cornerIndices[_state.cornerIndices.length - 1]
: -1;
if (prev < 0 || prev <= lastMarked) return;
_state.busy = true;
try {
await _setPosition(prev);
_state.currentIndex = prev;
_state.errorMsg = '';
} catch (err: unknown) {
_state.errorMsg = _errMsg(err);
} finally {
_state.busy = false;
_render();
}
}
export async function autoCalMarkCorner(): Promise<void> {
if (!_state || _state.busy || _state.cornerIndices.length >= 4) return;
_state.cornerIndices.push(_state.currentIndex);
if (_state.cornerIndices.length < 4) {
// Nudge forward so user can see the dot isn't stuck
const next = Math.min(_state.currentIndex + 1, _state.ledCount - 1);
_state.busy = true;
try {
await _setPosition(next);
_state.currentIndex = next;
} catch { /* best effort */ } finally {
_state.busy = false;
}
}
_render();
}
export async function autoCalBackToDirection(): Promise<void> {
if (!_state || _state.busy) return;
_state.step = 'direction';
_state.layout = null;
_state.cornerIndices = [];
_state.currentIndex = 0;
_state.errorMsg = '';
_render();
await _setPosition(0).catch(() => { /* best effort */ });
}
export async function autoCalSolve(): Promise<void> {
if (!_state || _state.busy || _state.cornerIndices.length !== 4) return;
_state.busy = true;
_state.errorMsg = '';
_render();
try {
const solved = await apiPost<SolvedCalibration>('/calibration/solve', {
device_id: _state.deviceId,
start_position: _state.startPosition,
layout: _state.layout,
corner_indices: _state.cornerIndices,
offset: 0,
}, { errorMessage: t('autocal.error.solve_failed') });
_state.solved = solved;
// Stop the chase session — device restored to prior target
await _stopSession();
_state.step = 'preview';
} catch (err: unknown) {
_state.errorMsg = _errMsg(err);
_state.busy = false;
_render();
return;
}
_state.busy = false;
_render();
}
// ── Step 5: Preview & Save ────────────────────────────────────────────────
function _renderPreview(): void {
if (!_opts || !_state?.solved) return;
const s = _state.solved;
const busy = _state.busy;
const dirLabel = s.layout === 'clockwise'
? t('calibration.direction.clockwise')
: t('calibration.direction.counterclockwise');
const dirIcon = s.layout === 'clockwise' ? ICON_ROTATE_CW : ICON_ROTATE_CCW;
_opts.container.innerHTML = `
<div class="autocal-step" data-step="preview">
<div class="autocal-step-header">
<span class="autocal-step-icon autocal-step-icon--ok">${ICON_OK}</span>
<div>
<div class="autocal-step-title">${_esc(t('autocal.preview.title'))}</div>
<div class="autocal-step-desc">${_esc(t('autocal.preview.desc'))}</div>
</div>
</div>
<div class="autocal-solved-grid">
<div class="autocal-solved-item autocal-solved-item--wide">
<span class="autocal-solved-key">${_esc(t('autocal.preview.start'))}</span>
<span class="autocal-solved-val">${_esc(t(`autocal.position.${s.start_position}`))}</span>
</div>
<div class="autocal-solved-item autocal-solved-item--wide">
<span class="autocal-solved-key">${_esc(t('calibration.direction'))}</span>
<span class="autocal-solved-val">${dirIcon} ${_esc(dirLabel)}</span>
</div>
<div class="autocal-solved-item">
<span class="autocal-solved-key">${_esc(t('autocal.preview.top'))}</span>
<span class="autocal-solved-val autocal-led-count">${s.leds_top}</span>
</div>
<div class="autocal-solved-item">
<span class="autocal-solved-key">${_esc(t('autocal.preview.right'))}</span>
<span class="autocal-solved-val autocal-led-count">${s.leds_right}</span>
</div>
<div class="autocal-solved-item">
<span class="autocal-solved-key">${_esc(t('autocal.preview.bottom'))}</span>
<span class="autocal-solved-val autocal-led-count">${s.leds_bottom}</span>
</div>
<div class="autocal-solved-item">
<span class="autocal-solved-key">${_esc(t('autocal.preview.left'))}</span>
<span class="autocal-solved-val autocal-led-count">${s.leds_left}</span>
</div>
<div class="autocal-solved-item autocal-solved-item--wide">
<span class="autocal-solved-key">${_esc(t('autocal.preview.total'))}</span>
<span class="autocal-solved-val autocal-led-count">${s.leds_top + s.leds_right + s.leds_bottom + s.leds_left}</span>
</div>
</div>
<div id="autocal-error" class="autocal-error" style="display:none"></div>
<div class="autocal-footer">
<button class="btn btn-secondary" onclick="autoCalCancel()">${_esc(t('autocal.btn.cancel'))}</button>
<button class="btn btn-primary" id="autocal-save-btn" onclick="autoCalSave()" ${busy ? 'disabled' : ''}>
${_esc(t('autocal.btn.save'))}
</button>
</div>
</div>`;
_showError(_state.errorMsg);
}
export async function autoCalSave(): Promise<void> {
if (!_state || _state.busy || !_state.solved) return;
_state.busy = true;
_state.errorMsg = '';
const btn = document.getElementById('autocal-save-btn');
if (btn) btn.setAttribute('disabled', 'true');
try {
const s = _state.solved;
await apiPut(`/color-strip-sources/${_state.cssId}`, {
source_type: _state.cssSourceType,
calibration: {
mode: 'simple',
layout: s.layout,
start_position: s.start_position,
leds_top: s.leds_top,
leds_right: s.leds_right,
leds_bottom: s.leds_bottom,
leds_left: s.leds_left,
offset: s.offset,
span_top_start: 0, span_top_end: 1,
span_right_start: 0, span_right_end: 1,
span_bottom_start: 0, span_bottom_end: 1,
span_left_start: 0, span_left_end: 1,
skip_leds_start: 0,
skip_leds_end: 0,
border_width: 10,
roi_x: 0, roi_y: 0, roi_width: 1, roi_height: 1,
},
}, { errorMessage: t('autocal.error.save_failed') });
colorStripSourcesCache.invalidate();
showToast(t('autocal.saved'), 'success');
const onComplete = _opts?.onComplete;
await unmountAutoCalibration();
if (onComplete) onComplete();
} catch (err: unknown) {
_state.busy = false;
_state.errorMsg = _errMsg(err);
if (btn) btn.removeAttribute('disabled');
_showError(_state.errorMsg);
}
}
// ── Cancel ────────────────────────────────────────────────────────────────
export async function autoCalCancel(): Promise<void> {
const onCancel = _opts?.onCancel;
await unmountAutoCalibration();
if (onCancel) onCancel();
}
// ── Session lifecycle ─────────────────────────────────────────────────────
async function _startSession(): Promise<void> {
if (!_state) return;
_state.busy = true;
_render();
try {
const state = await apiPost<CalibrationSessionState>('/calibration/session', {
device_id: _state.deviceId,
}, { errorMessage: t('autocal.error.session_start_failed') });
_state.sessionActive = true;
_state.ledCount = state.led_count;
_state.busy = false;
await _setPosition(0);
_state.errorMsg = '';
_render();
} catch (err: unknown) {
// Session may already be live (POST /calibration/session succeeded before _setPosition threw),
// so call _stopSession() to let the backend tear down cleanly instead of flipping the flag directly.
await _stopSession().catch(() => { /* best effort */ });
_state.busy = false;
_state.errorMsg = _errMsg(err);
_render();
}
}
async function _stopSession(): Promise<void> {
if (!_state?.sessionActive) return;
try {
await apiPost<CalibrationSessionState>('/calibration/session/stop', undefined, {
errorMessage: t('autocal.error.session_stop_failed'),
});
} finally {
if (_state) _state.sessionActive = false;
}
}
async function _setPosition(index: number): Promise<void> {
if (!_state?.sessionActive) return;
await apiPost<CalibrationSessionState>('/calibration/session/position', {
index,
window: 1,
}, { errorMessage: t('autocal.error.position_failed') });
}
// ── Utilities ─────────────────────────────────────────────────────────────
function _delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
function _errMsg(err: unknown): string {
if (err instanceof Error) return err.message;
return String(err);
}
function _esc(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function _showError(msg: string): void {
const el = document.getElementById('autocal-error');
if (!el) return;
el.textContent = msg;
el.style.display = msg ? 'block' : 'none';
}
function _setError(msg: string): void {
if (_state) _state.errorMsg = msg;
_showError(msg);
}
// ── Standalone modal management ───────────────────────────────────────────
//
// The standalone modal is the Phase 3 surface: opened from the calibration
// modal's "Auto-calibrate" button. Phase 4 wizard uses mountAutoCalibration()
// directly (no modal wrapper needed — the wizard is itself a modal).
class AutoCalModal extends Modal {
constructor() { super('auto-calibration-modal'); }
snapshotValues(): Record<string, string> {
// No dirty-check needed for a wizard flow; always allow close.
return {};
}
onForceClose(): void {
// Unmount the flow asynchronously (session stop is async)
unmountAutoCalibration().catch(() => { /* best effort */ });
}
}
const _autoCalModal = new AutoCalModal();
/**
* Open the auto-calibration wizard for a color-strip source.
*
* Called from calibration.ts "Auto-calibrate" button.
*
* @param cssId The color-strip source ID to calibrate.
* @param deviceId Optional pre-selected device; if omitted, the device picker
* step is shown.
*/
export async function showAutoCalibration(cssId: string, deviceId?: string): Promise<void> {
const container = document.getElementById('autocal-step-container');
if (!container) return;
// Store context on the hidden inputs for reference
const cssIdInput = document.getElementById('autocal-modal-css-id') as HTMLInputElement | null;
const deviceIdInput = document.getElementById('autocal-modal-device-id') as HTMLInputElement | null;
if (cssIdInput) cssIdInput.value = cssId;
if (deviceIdInput) deviceIdInput.value = deviceId || '';
_autoCalModal.open();
_autoCalModal.snapshot();
await mountAutoCalibration({
container,
cssId,
deviceId,
onComplete: () => {
_autoCalModal.forceClose();
// Reload calibration view if open
if (window.loadTargetsTab) window.loadTargetsTab();
},
onCancel: () => {
_autoCalModal.forceClose();
},
});
}
/** Close the auto-calibration modal (stops session). */
export async function closeAutoCalModal(): Promise<void> {
await _autoCalModal.close();
}
@@ -17,6 +17,7 @@ import { ICON_WARNING, ICON_ROTATE_CW, ICON_ROTATE_CCW, ICON_DEVICE } from '../c
import { renderDeviceIcon } from '../core/device-icons.ts';
import { EntitySelect } from '../core/entity-palette.ts';
import type { Calibration } from '../types.ts';
import { showAutoCalibration } from './auto-calibration.ts';
let _calTestDeviceEntitySelect: EntitySelect | null = null;
let _calTestDeviceList: any[] = [];
@@ -233,6 +234,33 @@ export async function closeCalibrationModal() {
calibModal.close();
}
/**
* Open the auto-calibration wizard for the currently-open calibration modal.
*
* Reads the CSS ID or device ID from the active calibration modal context,
* then launches the auto-cal modal. In CSS mode the test device (if selected)
* is offered as the default device; in device mode the device is known.
*/
export async function openAutoCalFromCalibration(): Promise<void> {
const cssId = (document.getElementById('calibration-css-id') as HTMLInputElement)?.value || '';
const deviceId = (document.getElementById('calibration-device-id') as HTMLInputElement)?.value || '';
if (cssId) {
// CSS calibration mode: try the already-selected test device as default
const testDeviceSelect = document.getElementById('calibration-test-device') as HTMLSelectElement | null;
const testDevice = testDeviceSelect?.value || undefined;
// Close the calibration modal so the auto-cal modal has focus
calibModal.forceClose();
await showAutoCalibration(cssId, testDevice);
} else if (deviceId) {
// Device calibration mode: not directly supported by auto-cal (which
// writes to a CSS), so show a toast explaining the constraint.
showToast(t('autocal.error.css_required'), 'error');
} else {
showToast(t('calibration.error.load_failed'), 'error');
}
}
/* ── CSS Calibration support ──────────────────────────────────── */
export async function showCSSCalibration(cssId: any) {
+18
View File
@@ -354,6 +354,24 @@ startTargetOverlay: (...args: any[]) => any;
toggleTestEdge: (...args: any[]) => any;
showCSSCalibration: (...args: any[]) => any;
toggleCalibrationOverlay: (...args: any[]) => any;
openAutoCalFromCalibration: (...args: any[]) => any;
// ─── Auto-Calibration wizard ───
showAutoCalibration: (...args: any[]) => any;
closeAutoCalModal: (...args: any[]) => any;
autoCalSelectDevice: (...args: any[]) => any;
autoCalSetCorner: (...args: any[]) => any;
autoCalSetDirection: (...args: any[]) => any;
autoCalBackToCorner: (...args: any[]) => any;
autoCalBackToDirection: (...args: any[]) => any;
autoCalSweepForward: (...args: any[]) => any;
autoCalSweepBack: (...args: any[]) => any;
autoCalMarkCorner: (...args: any[]) => any;
autoCalSolve: (...args: any[]) => any;
autoCalSave: (...args: any[]) => any;
autoCalCancel: (...args: any[]) => any;
mountAutoCalibration: (...args: any[]) => any;
unmountAutoCalibration: (...args: any[]) => any;
// ─── Advanced Calibration ───
showAdvancedCalibration: (...args: any[]) => any;
+47 -9
View File
@@ -2593,9 +2593,9 @@
"automations.rule.home_assistant.state": "State:",
"automations.rule.home_assistant.match_mode": "Match Mode:",
"automations.rule.home_assistant.hint": "Activate when a Home Assistant entity matches the specified state",
"automations.rule.ha.match_mode.exact.desc": "State must match exactly",
"automations.rule.ha.match_mode.contains.desc": "State must contain the text",
"automations.rule.ha.match_mode.regex.desc": "State must match the regex pattern",
"automations.rule.ha.match_mode.exact.desc": "State must match exactly",
"automations.rule.ha.match_mode.contains.desc": "State must contain the text",
"automations.rule.ha.match_mode.regex.desc": "State must match the regex pattern",
"color_strip.clock": "Sync Clock:",
"color_strip.clock.hint": "Link to a sync clock to synchronize animation timing across sources. Speed is controlled on the clock.",
"graph.title": "Graph",
@@ -2947,7 +2947,6 @@
"donation.about_donate": "Support development",
"donation.about_license": "MIT License",
"donation.about_author": "Created by",
"streams.group.game": "Game Integration",
"tree.group.game": "Game",
"game_integration.section_title": "Game Integrations",
@@ -3006,7 +3005,6 @@
"game_integration.auto_setup.game_not_found": "Game installation not found",
"game_integration.auto_setup.token_generated": "Auth token was automatically generated",
"game_integration.auto_setup.save_first": "Save the integration first before running auto setup",
"color_strip.type.game_event": "Game Event",
"color_strip.type.game_event.desc": "LED effects triggered by game events",
"color_strip.game_event.integration": "Game Integration:",
@@ -3016,7 +3014,6 @@
"color_strip.game_event.event_mappings": "Event Mappings:",
"color_strip.game_event.event_mappings.hint": "Override or add event-to-effect mappings for this source. These supplement the integration-level mappings.",
"color_strip.game_event.error.no_integration": "Please select a game integration.",
"color_strip.type.math_wave": "Math Wave",
"color_strip.type.math_wave.desc": "Mathematical wave generator with gradient color mapping",
"color_strip.math_wave.gradient": "Color Gradient:",
@@ -3036,7 +3033,6 @@
"color_strip.math_wave.phase": "Phase",
"color_strip.math_wave.offset": "Offset",
"color_strip.math_wave.error.no_waves": "Add at least one wave layer.",
"value_source.type.game_event": "Game Event",
"value_source.type.game_event.desc": "Game metrics (health, ammo, mana) as 0-1 values",
"value_source.game_event.integration": "Game Integration:",
@@ -3053,7 +3049,6 @@
"value_source.game_event.default_value.hint": "Output value when no events received within timeout.",
"value_source.game_event.timeout": "Timeout (s):",
"value_source.game_event.timeout.hint": "Seconds of silence before reverting to the default value.",
"audio_processing.title": "Audio Processing Templates",
"audio_processing.add": "Add Audio Processing Template",
"audio_processing.edit": "Edit Audio Processing Template",
@@ -3205,5 +3200,48 @@
"automations.rule.http_poll.operator.lt": "Less than",
"automations.rule.http_poll.operator.lt.desc": "Numeric comparison (<) — requires numeric output.",
"automations.rule.http_poll.operator.exists": "Exists",
"automations.rule.http_poll.operator.exists.desc": "Activates whenever a value is successfully extracted (ignores the value)."
"automations.rule.http_poll.operator.exists.desc": "Activates whenever a value is successfully extracted (ignores the value).",
"autocal.modal.title": "Auto-Calibrate Strip",
"autocal.trigger.label": "Auto-calibrate",
"autocal.trigger.hint": "Automatically detect LED positions by walking the strip",
"autocal.device.title": "Select Device",
"autocal.device.desc": "Choose the WLED/device that drives this LED strip. The strip will briefly light up during calibration.",
"autocal.device.label": "Device",
"autocal.error.no_device": "Please select a device to continue.",
"autocal.corner.title": "Start Corner",
"autocal.corner.desc": "Which corner is LED #0 (the very first LED of the strip)?",
"autocal.corner.led_index": "LED 0 position",
"autocal.direction.title": "Strip Direction — Step {step}",
"autocal.direction.desc": "Which direction does the strip run from the start corner?",
"autocal.corners.title": "Mark Corners — {remaining} remaining",
"autocal.corners.desc": "Sweep to the next corner then tap Mark. Corner: {corner}",
"autocal.corners.desc_complete": "All 4 corners marked! Review and continue.",
"autocal.corners.index_label": "LED index",
"autocal.preview.title": "Preview & Save",
"autocal.preview.desc": "Review the detected layout and save to the strip source.",
"autocal.preview.start": "Start corner",
"autocal.preview.top": "Top LEDs",
"autocal.preview.right": "Right LEDs",
"autocal.preview.bottom": "Bottom LEDs",
"autocal.preview.left": "Left LEDs",
"autocal.preview.total": "Total LEDs",
"autocal.position.top_left": "Top-left",
"autocal.position.top_right": "Top-right",
"autocal.position.bottom_left": "Bottom-left",
"autocal.position.bottom_right": "Bottom-right",
"autocal.btn.cancel": "Cancel",
"autocal.btn.next": "Next",
"autocal.btn.back": "Back",
"autocal.btn.step_back": "Step back",
"autocal.btn.step_fwd": "Step forward",
"autocal.btn.mark_corner": "Mark corner",
"autocal.btn.solve": "Solve",
"autocal.btn.save": "Save",
"autocal.error.session_start_failed": "Failed to start calibration session.",
"autocal.error.session_stop_failed": "Failed to stop calibration session.",
"autocal.error.position_failed": "Failed to move to LED position.",
"autocal.error.solve_failed": "Failed to solve calibration.",
"autocal.error.save_failed": "Failed to save calibration.",
"autocal.error.css_required": "Auto-calibration requires a Color Strip Source (not a device-only target).",
"autocal.saved": "Calibration saved successfully."
}
+44 -6
View File
@@ -2629,7 +2629,6 @@
"donation.about_donate": "Поддержать разработку",
"donation.about_license": "Лицензия MIT",
"donation.about_author": "Создатель —",
"streams.group.game": "Игровая интеграция",
"tree.group.game": "Игры",
"game_integration.section_title": "Игровые интеграции",
@@ -2688,7 +2687,6 @@
"game_integration.auto_setup.game_not_found": "Установка игры не найдена",
"game_integration.auto_setup.token_generated": "Токен авторизации был сгенерирован автоматически",
"game_integration.auto_setup.save_first": "Сначала сохраните интеграцию перед запуском автонастройки",
"color_strip.type.game_event": "Игровое событие",
"color_strip.type.game_event.desc": "LED-эффекты по игровым событиям",
"color_strip.game_event.integration": "Игровая интеграция:",
@@ -2698,7 +2696,6 @@
"color_strip.game_event.event_mappings": "Привязка событий:",
"color_strip.game_event.event_mappings.hint": "Переопределите или добавьте привязки событий к эффектам для этого источника.",
"color_strip.game_event.error.no_integration": "Выберите игровую интеграцию.",
"color_strip.type.math_wave": "Математическая волна",
"color_strip.type.math_wave.desc": "Генератор математических волн с цветовым градиентом",
"color_strip.math_wave.gradient": "Цветовой градиент:",
@@ -2718,7 +2715,6 @@
"color_strip.math_wave.phase": "Фаза",
"color_strip.math_wave.offset": "Смещение",
"color_strip.math_wave.error.no_waves": "Добавьте хотя бы один слой волны.",
"value_source.type.game_event": "Игровое событие",
"value_source.type.game_event.desc": "Игровые метрики (здоровье, патроны, мана) как значения 0-1",
"value_source.game_event.integration": "Игровая интеграция:",
@@ -2735,7 +2731,6 @@
"value_source.game_event.default_value.hint": "Выходное значение, когда события не поступают в пределах таймаута.",
"value_source.game_event.timeout": "Таймаут (с):",
"value_source.game_event.timeout.hint": "Секунды тишины до возврата к значению по умолчанию.",
"audio_processing.title": "Шаблоны обработки звука",
"audio_processing.add": "Добавить шаблон обработки звука",
"audio_processing.edit": "Редактировать шаблон обработки звука",
@@ -2887,5 +2882,48 @@
"automations.rule.http_poll.operator.lt": "Меньше",
"automations.rule.http_poll.operator.lt.desc": "Числовое сравнение (<) — нужно числовое значение.",
"automations.rule.http_poll.operator.exists": "Существует",
"automations.rule.http_poll.operator.exists.desc": "Срабатывает, когда значение успешно извлечено (само значение игнорируется)."
"automations.rule.http_poll.operator.exists.desc": "Срабатывает, когда значение успешно извлечено (само значение игнорируется).",
"autocal.modal.title": "Авто-калибровка полосы",
"autocal.trigger.label": "Авто-калибровка",
"autocal.trigger.hint": "Автоматически определить позиции светодиодов путём обхода полосы",
"autocal.device.title": "Выбор устройства",
"autocal.device.desc": "Выберите устройство WLED, управляющее этой LED-полосой. Во время калибровки полоса ненадолго загорится.",
"autocal.device.label": "Устройство",
"autocal.error.no_device": "Пожалуйста, выберите устройство для продолжения.",
"autocal.corner.title": "Начальный угол",
"autocal.corner.desc": "В каком углу находится светодиод №0 (самый первый светодиод полосы)?",
"autocal.corner.led_index": "Позиция LED 0",
"autocal.direction.title": "Направление полосы — шаг {step}",
"autocal.direction.desc": "В каком направлении идёт полоса от начального угла?",
"autocal.corners.title": "Отметьте углы — осталось {remaining}",
"autocal.corners.desc": "Переместитесь к следующему углу и нажмите «Отметить». Угол: {corner}",
"autocal.corners.desc_complete": "Все 4 угла отмечены! Проверьте и продолжите.",
"autocal.corners.index_label": "Индекс LED",
"autocal.preview.title": "Предпросмотр и сохранение",
"autocal.preview.desc": "Проверьте обнаруженную раскладку и сохраните в источник полосы.",
"autocal.preview.start": "Начальный угол",
"autocal.preview.top": "Верхних LED",
"autocal.preview.right": "Правых LED",
"autocal.preview.bottom": "Нижних LED",
"autocal.preview.left": "Левых LED",
"autocal.preview.total": "Всего LED",
"autocal.position.top_left": "Верхний левый",
"autocal.position.top_right": "Верхний правый",
"autocal.position.bottom_left": "Нижний левый",
"autocal.position.bottom_right": "Нижний правый",
"autocal.btn.cancel": "Отмена",
"autocal.btn.next": "Далее",
"autocal.btn.back": "Назад",
"autocal.btn.step_back": "Шаг назад",
"autocal.btn.step_fwd": "Шаг вперёд",
"autocal.btn.mark_corner": "Отметить угол",
"autocal.btn.solve": "Вычислить",
"autocal.btn.save": "Сохранить",
"autocal.error.session_start_failed": "Не удалось начать сеанс калибровки.",
"autocal.error.session_stop_failed": "Не удалось завершить сеанс калибровки.",
"autocal.error.position_failed": "Не удалось переместиться к позиции LED.",
"autocal.error.solve_failed": "Не удалось вычислить калибровку.",
"autocal.error.save_failed": "Не удалось сохранить калибровку.",
"autocal.error.css_required": "Авто-калибровка требует источника цветовой полосы (не только устройства).",
"autocal.saved": "Калибровка успешно сохранена."
}
+44 -6
View File
@@ -2623,7 +2623,6 @@
"donation.about_donate": "支持开发",
"donation.about_license": "MIT 许可证",
"donation.about_author": "作者:",
"streams.group.game": "游戏集成",
"tree.group.game": "游戏",
"game_integration.section_title": "游戏集成",
@@ -2682,7 +2681,6 @@
"game_integration.auto_setup.game_not_found": "未找到游戏安装",
"game_integration.auto_setup.token_generated": "授权令牌已自动生成",
"game_integration.auto_setup.save_first": "请先保存集成,然后再运行自动配置",
"color_strip.type.game_event": "游戏事件",
"color_strip.type.game_event.desc": "由游戏事件触发的LED效果",
"color_strip.game_event.integration": "游戏集成:",
@@ -2692,7 +2690,6 @@
"color_strip.game_event.event_mappings": "事件映射:",
"color_strip.game_event.event_mappings.hint": "为此源覆盖或添加事件到效果的映射。这些补充集成级别的映射。",
"color_strip.game_event.error.no_integration": "请选择游戏集成。",
"color_strip.type.math_wave": "数学波",
"color_strip.type.math_wave.desc": "使用渐变色映射的数学波形生成器",
"color_strip.math_wave.gradient": "颜色渐变:",
@@ -2712,7 +2709,6 @@
"color_strip.math_wave.phase": "相位",
"color_strip.math_wave.offset": "偏移",
"color_strip.math_wave.error.no_waves": "请至少添加一个波形层。",
"value_source.type.game_event": "游戏事件",
"value_source.type.game_event.desc": "游戏指标(生命值、弹药、法力)作为0-1值",
"value_source.game_event.integration": "游戏集成:",
@@ -2729,7 +2725,6 @@
"value_source.game_event.default_value.hint": "在超时时间内未收到事件时的输出值。",
"value_source.game_event.timeout": "超时(秒):",
"value_source.game_event.timeout.hint": "恢复到默认值前的静默秒数。",
"audio_processing.title": "音频处理模板",
"audio_processing.add": "添加音频处理模板",
"audio_processing.edit": "编辑音频处理模板",
@@ -2881,5 +2876,48 @@
"automations.rule.http_poll.operator.lt": "小于",
"automations.rule.http_poll.operator.lt.desc": "数值比较 (<) — 需要数值输出。",
"automations.rule.http_poll.operator.exists": "存在",
"automations.rule.http_poll.operator.exists.desc": "只要成功提取出值就激活(忽略值本身)。"
"automations.rule.http_poll.operator.exists.desc": "只要成功提取出值就激活(忽略值本身)。",
"autocal.modal.title": "自动校准灯带",
"autocal.trigger.label": "自动校准",
"autocal.trigger.hint": "通过逐一扫描灯带自动检测 LED 位置",
"autocal.device.title": "选择设备",
"autocal.device.desc": "选择驱动该 LED 灯带的 WLED 设备。校准过程中灯带会短暂亮起。",
"autocal.device.label": "设备",
"autocal.error.no_device": "请选择一个设备以继续。",
"autocal.corner.title": "起始角",
"autocal.corner.desc": "灯带第 0 颗 LED(最开始的一颗)位于哪个角?",
"autocal.corner.led_index": "LED 0 位置",
"autocal.direction.title": "灯带方向 — 步骤 {step}",
"autocal.direction.desc": "从起始角开始,灯带向哪个方向延伸?",
"autocal.corners.title": "标记角点 — 剩余 {remaining} 个",
"autocal.corners.desc": "移动到下一个角点后点击标记。当前角点:{corner}",
"autocal.corners.desc_complete": "已标记全部 4 个角点!请确认后继续。",
"autocal.corners.index_label": "LED 索引",
"autocal.preview.title": "预览并保存",
"autocal.preview.desc": "确认检测到的布局,然后保存到灯带源。",
"autocal.preview.start": "起始角",
"autocal.preview.top": "顶部 LED 数",
"autocal.preview.right": "右侧 LED 数",
"autocal.preview.bottom": "底部 LED 数",
"autocal.preview.left": "左侧 LED 数",
"autocal.preview.total": "LED 总数",
"autocal.position.top_left": "左上角",
"autocal.position.top_right": "右上角",
"autocal.position.bottom_left": "左下角",
"autocal.position.bottom_right": "右下角",
"autocal.btn.cancel": "取消",
"autocal.btn.next": "下一步",
"autocal.btn.back": "返回",
"autocal.btn.step_back": "后退一步",
"autocal.btn.step_fwd": "前进一步",
"autocal.btn.mark_corner": "标记角点",
"autocal.btn.solve": "求解",
"autocal.btn.save": "保存",
"autocal.error.session_start_failed": "无法启动校准会话。",
"autocal.error.session_stop_failed": "无法停止校准会话。",
"autocal.error.position_failed": "无法移动到 LED 位置。",
"autocal.error.solve_failed": "校准求解失败。",
"autocal.error.save_failed": "保存校准数据失败。",
"autocal.error.css_required": "自动校准需要颜色灯带源(不支持纯设备目标)。",
"autocal.saved": "校准已成功保存。"
}
+1
View File
@@ -224,6 +224,7 @@
{% include 'modals/calibration.html' %}
{% include 'modals/advanced-calibration.html' %}
{% include 'modals/auto-calibration.html' %}
{% include 'modals/device-settings.html' %}
{% include 'modals/icon-picker.html' %}
{% include 'modals/target-editor.html' %}
@@ -0,0 +1,32 @@
<!-- Auto-Calibration Modal — guided chase-tap wizard.
Opened from the calibration modal via the "Auto-calibrate" button.
All step rendering is done by auto-calibration.ts; this shell provides
the Modal frame and a container div that the TS mounts steps into.
Channel: signal (green) — same as the calibration modal's layout section.
Max-width kept narrower than the full calibration modal (560px). -->
<div id="auto-calibration-modal" class="modal" role="dialog" aria-modal="true"
aria-labelledby="autocal-modal-title">
<div class="modal-content" style="max-width:560px;">
<div class="modal-header">
<h2 id="autocal-modal-title">
<svg class="icon" viewBox="0 0 24 24">
<path d="M21.3 15.3a2.4 2.4 0 0 1 0 3.4l-2.6 2.6a2.4 2.4 0 0 1-3.4 0L2.7 8.7a2.41 2.41 0 0 1 0-3.4l2.6-2.6a2.41 2.41 0 0 1 3.4 0Z"/>
<path d="m14.5 12.5 2-2"/><path d="m11.5 9.5 2-2"/>
<path d="m8.5 6.5 2-2"/><path d="m17.5 15.5 2-2"/>
</svg>
<span data-i18n="autocal.modal.title">Auto-Calibrate Strip</span>
</h2>
<button class="modal-close-btn" onclick="closeAutoCalModal()"
title="Close" data-i18n-aria-label="aria.close">&#x2715;</button>
</div>
<div class="modal-body" style="padding: 20px 24px;">
<!-- Hidden context inputs -->
<input type="hidden" id="autocal-modal-css-id">
<input type="hidden" id="autocal-modal-device-id">
<!-- Step container: auto-calibration.ts mounts here -->
<div id="autocal-step-container"></div>
</div>
</div>
</div>
@@ -233,6 +233,13 @@
</div>
<div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeCalibrationModal()" title="Cancel" data-i18n-aria-label="aria.cancel">&#x2715;</button>
<button class="btn btn-secondary btn-sm autocal-trigger-btn" id="calibration-auto-cal-btn"
onclick="openAutoCalFromCalibration()"
data-i18n-title="autocal.trigger.hint"
title="Auto-calibrate">
<svg class="icon" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><path d="m4.93 4.93 14.14 14.14"/><path d="M12 8v4l2 2"/></svg>
<span data-i18n="autocal.trigger.label">Auto-calibrate</span>
</button>
<button class="btn btn-icon btn-primary" onclick="saveCalibration()" title="Save" data-i18n-aria-label="aria.save">&#x2713;</button>
</div>
</div>