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:
+42
-1
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
@@ -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'))}">←</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'))}">→</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, '&')
|
||||
.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<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
@@ -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;
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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": "Калибровка успешно сохранена."
|
||||
}
|
||||
|
||||
@@ -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": "校准已成功保存。"
|
||||
}
|
||||
|
||||
@@ -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">✕</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">✕</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">✓</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user