diff --git a/docs/CALIBRATION.md b/docs/CALIBRATION.md index ac40eda..9b230e9 100644 --- a/docs/CALIBRATION.md +++ b/docs/CALIBRATION.md @@ -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 diff --git a/server/src/ledgrab/static/css/components.css b/server/src/ledgrab/static/css/components.css index 3154a7b..2a00cc7 100644 --- a/server/src/ledgrab/static/css/components.css +++ b/server/src/ledgrab/static/css/components.css @@ -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; } +} + diff --git a/server/src/ledgrab/static/js/app.ts b/server/src/ledgrab/static/js/app.ts index 97e518b..8eb13fe 100644 --- a/server/src/ledgrab/static/js/app.ts +++ b/server/src/ledgrab/static/js/app.ts @@ -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, diff --git a/server/src/ledgrab/static/js/features/auto-calibration.ts b/server/src/ledgrab/static/js/features/auto-calibration.ts new file mode 100644 index 0000000..ca583c8 --- /dev/null +++ b/server/src/ledgrab/static/js/features/auto-calibration.ts @@ -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 { + 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 { + 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 = ` +
+
+ ${ICON_DEVICE} +
+
${_esc(t('autocal.device.title'))}
+
${_esc(t('autocal.device.desc'))}
+
+
+
+ + +
+ + +
`; + _populateDeviceSelect(); + _showError(_state?.errorMsg || ''); +} + +async function _populateDeviceSelect(): Promise { + 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[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 { + 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 = ` +
+
+ ${ICON_CALIBRATION} +
+
${_esc(t('autocal.corner.title'))}
+
${_esc(t('autocal.corner.desc'))}
+
+
+
+ + ${_esc(t('autocal.corner.led_index', { index: '0' }))} +
+
+ ${(['top_left', 'top_right', 'bottom_left', 'bottom_right'] as StartPosition[]).map(pos => + `` + ).join('')} +
+ + +
`; + _showError(s.errorMsg); +} + +export async function autoCalSetCorner(position: StartPosition): Promise { + 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 = ` +
+
+ ${ICON_ROTATE_CW} +
+
${_esc(t('autocal.direction.title', { step: String(advance) }))}
+
${_esc(t('autocal.direction.desc'))}
+
+
+
+ + +
+ + +
`; + _showError(_state.errorMsg); +} + +export async function autoCalSetDirection(layout: Layout): Promise { + 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 { + 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 `${i + 1}`; + }).join(''); + + const activeCornerLabel = isComplete ? '' : cornerLabels[collected - 1]; + + _opts.container.innerHTML = ` +
+
+ ${ICON_CALIBRATION} +
+
${_esc(isComplete ? t('autocal.corners.title', { remaining: '0' }) : t('autocal.corners.title', { remaining: String(4 - collected) }))}
+
${_esc( + isComplete + ? t('autocal.corners.desc_complete') + : t('autocal.corners.desc', { corner: activeCornerLabel }) + )}
+
+
+ +
+
${pips}
+
+ ${_esc(t('autocal.corners.index_label'))} + ${currentIndex} + / ${ledCount - 1} +
+
+ +
+ +
+
+
+
+ +
+ + ${isComplete ? '' : ` + `} + + + +
`; + _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 { + 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 { + 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 { + 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 { + 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 { + if (!_state || _state.busy || _state.cornerIndices.length !== 4) return; + _state.busy = true; + _state.errorMsg = ''; + _render(); + + try { + const solved = await apiPost('/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 = ` +
+
+ ${ICON_OK} +
+
${_esc(t('autocal.preview.title'))}
+
${_esc(t('autocal.preview.desc'))}
+
+
+ +
+
+ ${_esc(t('autocal.preview.start'))} + ${_esc(t(`autocal.position.${s.start_position}`))} +
+
+ ${_esc(t('calibration.direction'))} + ${dirIcon} ${_esc(dirLabel)} +
+
+ ${_esc(t('autocal.preview.top'))} + ${s.leds_top} +
+
+ ${_esc(t('autocal.preview.right'))} + ${s.leds_right} +
+
+ ${_esc(t('autocal.preview.bottom'))} + ${s.leds_bottom} +
+
+ ${_esc(t('autocal.preview.left'))} + ${s.leds_left} +
+
+ ${_esc(t('autocal.preview.total'))} + ${s.leds_top + s.leds_right + s.leds_bottom + s.leds_left} +
+
+ + + +
`; + _showError(_state.errorMsg); +} + +export async function autoCalSave(): Promise { + 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 { + const onCancel = _opts?.onCancel; + await unmountAutoCalibration(); + if (onCancel) onCancel(); +} + +// ── Session lifecycle ───────────────────────────────────────────────────── + +async function _startSession(): Promise { + if (!_state) return; + _state.busy = true; + _render(); + try { + const state = await apiPost('/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 { + if (!_state?.sessionActive) return; + try { + await apiPost('/calibration/session/stop', undefined, { + errorMessage: t('autocal.error.session_stop_failed'), + }); + } finally { + if (_state) _state.sessionActive = false; + } +} + +async function _setPosition(index: number): Promise { + if (!_state?.sessionActive) return; + await apiPost('/calibration/session/position', { + index, + window: 1, + }, { errorMessage: t('autocal.error.position_failed') }); +} + +// ── Utilities ───────────────────────────────────────────────────────────── + +function _delay(ms: number): Promise { + 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, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +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 { + // 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 { + 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 { + await _autoCalModal.close(); +} diff --git a/server/src/ledgrab/static/js/features/calibration.ts b/server/src/ledgrab/static/js/features/calibration.ts index 6339a60..63545d3 100644 --- a/server/src/ledgrab/static/js/features/calibration.ts +++ b/server/src/ledgrab/static/js/features/calibration.ts @@ -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 { + 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) { diff --git a/server/src/ledgrab/static/js/global.d.ts b/server/src/ledgrab/static/js/global.d.ts index 29722e9..3e0ba42 100644 --- a/server/src/ledgrab/static/js/global.d.ts +++ b/server/src/ledgrab/static/js/global.d.ts @@ -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; diff --git a/server/src/ledgrab/static/locales/en.json b/server/src/ledgrab/static/locales/en.json index d541c44..6c5aba1 100644 --- a/server/src/ledgrab/static/locales/en.json +++ b/server/src/ledgrab/static/locales/en.json @@ -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." } diff --git a/server/src/ledgrab/static/locales/ru.json b/server/src/ledgrab/static/locales/ru.json index dea0fa3..b9d13a1 100644 --- a/server/src/ledgrab/static/locales/ru.json +++ b/server/src/ledgrab/static/locales/ru.json @@ -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": "Калибровка успешно сохранена." } diff --git a/server/src/ledgrab/static/locales/zh.json b/server/src/ledgrab/static/locales/zh.json index df9e29f..9bd60bd 100644 --- a/server/src/ledgrab/static/locales/zh.json +++ b/server/src/ledgrab/static/locales/zh.json @@ -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": "校准已成功保存。" } diff --git a/server/src/ledgrab/templates/index.html b/server/src/ledgrab/templates/index.html index 25e1f45..706c385 100644 --- a/server/src/ledgrab/templates/index.html +++ b/server/src/ledgrab/templates/index.html @@ -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' %} diff --git a/server/src/ledgrab/templates/modals/auto-calibration.html b/server/src/ledgrab/templates/modals/auto-calibration.html new file mode 100644 index 0000000..1bdce4e --- /dev/null +++ b/server/src/ledgrab/templates/modals/auto-calibration.html @@ -0,0 +1,32 @@ + + diff --git a/server/src/ledgrab/templates/modals/calibration.html b/server/src/ledgrab/templates/modals/calibration.html index 7151587..2e45d5d 100644 --- a/server/src/ledgrab/templates/modals/calibration.html +++ b/server/src/ledgrab/templates/modals/calibration.html @@ -233,6 +233,13 @@