Remove all migration logic, scroll tutorial targets into view, mock URL uses device ID
- Remove legacy migration code: profiles→automations key fallbacks, segments array
fallback, standby_interval compat, profile_id compat, wled→led type mapping,
legacy calibration field, audio CSS migration, default template migration,
loadTargets alias, wled sub-tab mapping
- Scroll tutorial step targets into view when off-screen
- Mock device URL changed from mock://{led_count} to mock://{device_id},
hide mock URL badge on device cards
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -57,11 +57,6 @@ value_source_store = ValueSourceStore(config.storage.value_sources_file)
|
|||||||
automation_store = AutomationStore(config.storage.automations_file)
|
automation_store = AutomationStore(config.storage.automations_file)
|
||||||
scene_preset_store = ScenePresetStore(config.storage.scene_presets_file)
|
scene_preset_store = ScenePresetStore(config.storage.scene_presets_file)
|
||||||
|
|
||||||
# Migrate embedded audio config from CSS entities to audio sources
|
|
||||||
audio_source_store.migrate_from_css(color_strip_store)
|
|
||||||
# Assign default audio template to multichannel sources that have none
|
|
||||||
audio_source_store.migrate_add_default_template(audio_template_store)
|
|
||||||
|
|
||||||
processor_manager = ProcessorManager(
|
processor_manager = ProcessorManager(
|
||||||
picture_source_store=picture_source_store,
|
picture_source_store=picture_source_store,
|
||||||
capture_template_store=template_store,
|
capture_template_store=template_store,
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ import {
|
|||||||
showAddDevice, closeAddDeviceModal, scanForDevices, handleAddDevice,
|
showAddDevice, closeAddDeviceModal, scanForDevices, handleAddDevice,
|
||||||
} from './features/device-discovery.js';
|
} from './features/device-discovery.js';
|
||||||
import {
|
import {
|
||||||
loadTargetsTab, loadTargets, switchTargetSubTab,
|
loadTargetsTab, switchTargetSubTab,
|
||||||
showTargetEditor, closeTargetEditorModal, forceCloseTargetEditorModal, saveTargetEditor,
|
showTargetEditor, closeTargetEditorModal, forceCloseTargetEditorModal, saveTargetEditor,
|
||||||
startTargetProcessing, stopTargetProcessing,
|
startTargetProcessing, stopTargetProcessing,
|
||||||
startTargetOverlay, stopTargetOverlay, deleteTarget,
|
startTargetOverlay, stopTargetOverlay, deleteTarget,
|
||||||
@@ -330,7 +330,6 @@ Object.assign(window, {
|
|||||||
|
|
||||||
// targets
|
// targets
|
||||||
loadTargetsTab,
|
loadTargetsTab,
|
||||||
loadTargets,
|
|
||||||
switchTargetSubTab,
|
switchTargetSubTab,
|
||||||
expandAllTargetSections, collapseAllTargetSections,
|
expandAllTargetSections, collapseAllTargetSections,
|
||||||
showTargetEditor,
|
showTargetEditor,
|
||||||
|
|||||||
@@ -337,8 +337,7 @@ export async function handleAddDevice(event) {
|
|||||||
|
|
||||||
let url;
|
let url;
|
||||||
if (isMockDevice(deviceType)) {
|
if (isMockDevice(deviceType)) {
|
||||||
const ledCount = document.getElementById('device-led-count')?.value || '60';
|
url = 'mock://';
|
||||||
url = `mock://${ledCount}`;
|
|
||||||
} else if (isSerialDevice(deviceType)) {
|
} else if (isSerialDevice(deviceType)) {
|
||||||
url = document.getElementById('device-serial-port').value;
|
url = document.getElementById('device-serial-port').value;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ class DeviceSettingsModal extends Modal {
|
|||||||
|
|
||||||
_getUrl() {
|
_getUrl() {
|
||||||
if (isMockDevice(this.deviceType)) {
|
if (isMockDevice(this.deviceType)) {
|
||||||
const ledCount = this.$('settings-led-count')?.value || '60';
|
const deviceId = this.$('settings-device-id')?.value || '';
|
||||||
return `mock://${ledCount}`;
|
return `mock://${deviceId}`;
|
||||||
}
|
}
|
||||||
if (isSerialDevice(this.deviceType)) {
|
if (isSerialDevice(this.deviceType)) {
|
||||||
return this.$('settings-serial-port').value;
|
return this.$('settings-serial-port').value;
|
||||||
@@ -83,7 +83,7 @@ export function createDeviceCard(device) {
|
|||||||
<div class="card-title">
|
<div class="card-title">
|
||||||
<span class="health-dot ${healthClass}" title="${healthTitle}"></span>
|
<span class="health-dot ${healthClass}" title="${healthTitle}"></span>
|
||||||
${device.name || device.id}
|
${device.name || device.id}
|
||||||
${device.url && device.url.startsWith('http') ? `<a class="device-url-badge" href="${device.url}" target="_blank" rel="noopener" title="${t('device.button.webui')}"><span class="device-url-text">${escapeHtml(device.url.replace(/^https?:\/\//, ''))}</span><span class="device-url-icon">${ICON_WEB}</span></a>` : (device.url && !device.url.startsWith('http') ? `<span class="device-url-badge"><span class="device-url-text">${escapeHtml(device.url)}</span></span>` : '')}
|
${device.url && device.url.startsWith('http') ? `<a class="device-url-badge" href="${device.url}" target="_blank" rel="noopener" title="${t('device.button.webui')}"><span class="device-url-text">${escapeHtml(device.url.replace(/^https?:\/\//, ''))}</span><span class="device-url-icon">${ICON_WEB}</span></a>` : (device.url && !device.url.startsWith('mock://') && !device.url.startsWith('http') ? `<span class="device-url-badge"><span class="device-url-text">${escapeHtml(device.url)}</span></span>` : '')}
|
||||||
${healthLabel}
|
${healthLabel}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -438,11 +438,6 @@ export async function saveTargetEditor() {
|
|||||||
|
|
||||||
// ===== TARGETS TAB (WLED devices + targets combined) =====
|
// ===== TARGETS TAB (WLED devices + targets combined) =====
|
||||||
|
|
||||||
export async function loadTargets() {
|
|
||||||
// Alias for backward compatibility
|
|
||||||
await loadTargetsTab();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function switchTargetSubTab(tabKey) {
|
export function switchTargetSubTab(tabKey) {
|
||||||
document.querySelectorAll('.target-sub-tab-btn').forEach(btn =>
|
document.querySelectorAll('.target-sub-tab-btn').forEach(btn =>
|
||||||
btn.classList.toggle('active', btn.dataset.targetSubTab === tabKey)
|
btn.classList.toggle('active', btn.dataset.targetSubTab === tabKey)
|
||||||
@@ -572,9 +567,7 @@ export async function loadTargetsTab() {
|
|||||||
const runningCount = targetsWithState.filter(t => t.state && t.state.processing).length;
|
const runningCount = targetsWithState.filter(t => t.state && t.state.processing).length;
|
||||||
updateTabBadge('targets', runningCount);
|
updateTabBadge('targets', runningCount);
|
||||||
|
|
||||||
// Backward compat: map stored "wled" sub-tab to "led"
|
const activeSubTab = localStorage.getItem('activeTargetSubTab') || 'led';
|
||||||
let activeSubTab = localStorage.getItem('activeTargetSubTab') || 'led';
|
|
||||||
if (activeSubTab === 'wled') activeSubTab = 'led';
|
|
||||||
|
|
||||||
const subTabs = [
|
const subTabs = [
|
||||||
{ key: 'led', icon: getTargetTypeIcon('led'), titleKey: 'targets.subtab.led', count: ledDevices.length + Object.keys(colorStripSourceMap).length + ledTargets.length },
|
{ key: 'led', icon: getTargetTypeIcon('led'), titleKey: 'targets.subtab.led', count: ledDevices.length + Object.keys(colorStripSourceMap).length + ledTargets.length },
|
||||||
|
|||||||
@@ -249,6 +249,12 @@ function showTutorialStep(index, direction = 1) {
|
|||||||
target.classList.add('tutorial-target');
|
target.classList.add('tutorial-target');
|
||||||
if (isFixed) target.style.zIndex = '10001';
|
if (isFixed) target.style.zIndex = '10001';
|
||||||
|
|
||||||
|
// Scroll target into view if off-screen
|
||||||
|
const preRect = target.getBoundingClientRect();
|
||||||
|
if (preRect.bottom > window.innerHeight || preRect.top < 0) {
|
||||||
|
target.scrollIntoView({ behavior: 'instant', block: 'center' });
|
||||||
|
}
|
||||||
|
|
||||||
const targetRect = target.getBoundingClientRect();
|
const targetRect = target.getBoundingClientRect();
|
||||||
const pad = 6;
|
const pad = 6;
|
||||||
let x, y, w, h;
|
let x, y, w, h;
|
||||||
|
|||||||
@@ -232,116 +232,3 @@ class AudioSourceStore:
|
|||||||
|
|
||||||
raise ValueError(f"Audio source {source_id} is not a valid audio source")
|
raise ValueError(f"Audio source {source_id} is not a valid audio source")
|
||||||
|
|
||||||
def resolve_mono_source(self, mono_id: str) -> Tuple[int, bool, str, Optional[str]]:
|
|
||||||
"""Backward-compatible wrapper for resolve_audio_source()."""
|
|
||||||
return self.resolve_audio_source(mono_id)
|
|
||||||
|
|
||||||
# ── Migration ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def migrate_from_css(self, color_strip_store) -> None:
|
|
||||||
"""One-time migration: extract audio config from existing CSS entities.
|
|
||||||
|
|
||||||
For each AudioColorStripSource that has old-style embedded audio fields
|
|
||||||
(audio_device_index, audio_loopback, audio_channel) but no audio_source_id:
|
|
||||||
1. Create a MultichannelAudioSource if one with matching config doesn't exist
|
|
||||||
2. Create a MonoAudioSource referencing it
|
|
||||||
3. Set audio_source_id on the CSS entity
|
|
||||||
4. Save both stores
|
|
||||||
"""
|
|
||||||
from wled_controller.storage.color_strip_source import AudioColorStripSource
|
|
||||||
|
|
||||||
migrated = 0
|
|
||||||
multichannel_cache: Dict[Tuple[int, bool], str] = {} # (dev, loopback) → id
|
|
||||||
|
|
||||||
# Index existing multichannel sources for dedup
|
|
||||||
for source in self._sources.values():
|
|
||||||
if isinstance(source, MultichannelAudioSource):
|
|
||||||
key = (source.device_index, source.is_loopback)
|
|
||||||
multichannel_cache[key] = source.id
|
|
||||||
|
|
||||||
for css in color_strip_store.get_all_sources():
|
|
||||||
if not isinstance(css, AudioColorStripSource):
|
|
||||||
continue
|
|
||||||
# Skip if already migrated
|
|
||||||
if getattr(css, "audio_source_id", None):
|
|
||||||
continue
|
|
||||||
# Skip if no old fields present
|
|
||||||
if not hasattr(css, "audio_device_index"):
|
|
||||||
continue
|
|
||||||
|
|
||||||
dev_idx = getattr(css, "audio_device_index", -1)
|
|
||||||
loopback = bool(getattr(css, "audio_loopback", True))
|
|
||||||
channel = getattr(css, "audio_channel", "mono") or "mono"
|
|
||||||
|
|
||||||
# Find or create multichannel source
|
|
||||||
mc_key = (dev_idx, loopback)
|
|
||||||
if mc_key in multichannel_cache:
|
|
||||||
mc_id = multichannel_cache[mc_key]
|
|
||||||
else:
|
|
||||||
device_label = "Loopback" if loopback else "Input"
|
|
||||||
mc_name = f"Audio Device {dev_idx} ({device_label})"
|
|
||||||
# Ensure unique name
|
|
||||||
suffix = 2
|
|
||||||
base_name = mc_name
|
|
||||||
while any(s.name == mc_name for s in self._sources.values()):
|
|
||||||
mc_name = f"{base_name} #{suffix}"
|
|
||||||
suffix += 1
|
|
||||||
|
|
||||||
mc_id = f"as_{uuid.uuid4().hex[:8]}"
|
|
||||||
now = datetime.utcnow()
|
|
||||||
mc_source = MultichannelAudioSource(
|
|
||||||
id=mc_id, name=mc_name, source_type="multichannel",
|
|
||||||
created_at=now, updated_at=now,
|
|
||||||
device_index=dev_idx, is_loopback=loopback,
|
|
||||||
)
|
|
||||||
self._sources[mc_id] = mc_source
|
|
||||||
multichannel_cache[mc_key] = mc_id
|
|
||||||
logger.info(f"Migration: created multichannel source '{mc_name}' ({mc_id})")
|
|
||||||
|
|
||||||
# Create mono source
|
|
||||||
channel_label = {"mono": "Mono", "left": "Left", "right": "Right"}.get(channel, channel)
|
|
||||||
mono_name = f"{css.name} - {channel_label}"
|
|
||||||
# Ensure unique name
|
|
||||||
suffix = 2
|
|
||||||
base_name = mono_name
|
|
||||||
while any(s.name == mono_name for s in self._sources.values()):
|
|
||||||
mono_name = f"{base_name} #{suffix}"
|
|
||||||
suffix += 1
|
|
||||||
|
|
||||||
mono_id = f"as_{uuid.uuid4().hex[:8]}"
|
|
||||||
now = datetime.utcnow()
|
|
||||||
mono_source = MonoAudioSource(
|
|
||||||
id=mono_id, name=mono_name, source_type="mono",
|
|
||||||
created_at=now, updated_at=now,
|
|
||||||
audio_source_id=mc_id, channel=channel,
|
|
||||||
)
|
|
||||||
self._sources[mono_id] = mono_source
|
|
||||||
logger.info(f"Migration: created mono source '{mono_name}' ({mono_id})")
|
|
||||||
|
|
||||||
# Update CSS entity
|
|
||||||
css.audio_source_id = mono_id
|
|
||||||
migrated += 1
|
|
||||||
|
|
||||||
if migrated > 0:
|
|
||||||
self._save()
|
|
||||||
color_strip_store._save()
|
|
||||||
logger.info(f"Migration complete: migrated {migrated} audio CSS entities")
|
|
||||||
else:
|
|
||||||
logger.debug("No audio CSS entities needed migration")
|
|
||||||
|
|
||||||
def migrate_add_default_template(self, audio_template_store) -> None:
|
|
||||||
"""Assign the default audio template to multichannel sources that have no template."""
|
|
||||||
default_id = audio_template_store.get_default_template_id()
|
|
||||||
if not default_id:
|
|
||||||
logger.debug("No default audio template — skipping template migration")
|
|
||||||
return
|
|
||||||
|
|
||||||
migrated = 0
|
|
||||||
for source in self._sources.values():
|
|
||||||
if isinstance(source, MultichannelAudioSource) and not source.audio_template_id:
|
|
||||||
source.audio_template_id = default_id
|
|
||||||
migrated += 1
|
|
||||||
|
|
||||||
if migrated > 0:
|
|
||||||
self._save()
|
|
||||||
logger.info(f"Assigned default audio template to {migrated} multichannel sources")
|
|
||||||
|
|||||||
@@ -28,8 +28,7 @@ class AutomationStore:
|
|||||||
with open(self.file_path, "r", encoding="utf-8") as f:
|
with open(self.file_path, "r", encoding="utf-8") as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
|
|
||||||
# Support both old "profiles" key and new "automations" key
|
automations_data = data.get("automations", {})
|
||||||
automations_data = data.get("automations", data.get("profiles", {}))
|
|
||||||
loaded = 0
|
loaded = 0
|
||||||
for auto_id, auto_dict in automations_data.items():
|
for auto_id, auto_dict in automations_data.items():
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -48,8 +48,6 @@ class Device:
|
|||||||
self.rgbw = rgbw
|
self.rgbw = rgbw
|
||||||
self.created_at = created_at or datetime.utcnow()
|
self.created_at = created_at or datetime.utcnow()
|
||||||
self.updated_at = updated_at or datetime.utcnow()
|
self.updated_at = updated_at or datetime.utcnow()
|
||||||
# Preserved from old JSON for migration — not written back
|
|
||||||
self._legacy_calibration = None
|
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
"""Convert device to dictionary."""
|
"""Convert device to dictionary."""
|
||||||
@@ -77,12 +75,8 @@ class Device:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: dict) -> "Device":
|
def from_dict(cls, data: dict) -> "Device":
|
||||||
"""Create device from dictionary.
|
"""Create device from dictionary."""
|
||||||
|
return cls(
|
||||||
Backward-compatible: reads legacy 'calibration' field and stores it
|
|
||||||
in _legacy_calibration for migration use only.
|
|
||||||
"""
|
|
||||||
device = cls(
|
|
||||||
device_id=data["id"],
|
device_id=data["id"],
|
||||||
name=data["name"],
|
name=data["name"],
|
||||||
url=data["url"],
|
url=data["url"],
|
||||||
@@ -98,17 +92,6 @@ class Device:
|
|||||||
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),
|
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Preserve old calibration for migration (never written back by to_dict)
|
|
||||||
calibration_data = data.get("calibration")
|
|
||||||
if calibration_data:
|
|
||||||
try:
|
|
||||||
from wled_controller.core.capture.calibration import calibration_from_dict
|
|
||||||
device._legacy_calibration = calibration_from_dict(calibration_data)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return device
|
|
||||||
|
|
||||||
|
|
||||||
class DeviceStore:
|
class DeviceStore:
|
||||||
"""Persistent storage for WLED devices."""
|
"""Persistent storage for WLED devices."""
|
||||||
@@ -196,6 +179,10 @@ class DeviceStore:
|
|||||||
"""Create a new device."""
|
"""Create a new device."""
|
||||||
device_id = f"device_{uuid.uuid4().hex[:8]}"
|
device_id = f"device_{uuid.uuid4().hex[:8]}"
|
||||||
|
|
||||||
|
# Mock devices use their device ID as the URL authority
|
||||||
|
if device_type == "mock":
|
||||||
|
url = f"mock://{device_id}"
|
||||||
|
|
||||||
device = Device(
|
device = Device(
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
name=name,
|
name=name,
|
||||||
|
|||||||
@@ -56,9 +56,8 @@ class PictureTarget:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: dict) -> "PictureTarget":
|
def from_dict(cls, data: dict) -> "PictureTarget":
|
||||||
"""Create from dictionary, dispatching to the correct subclass."""
|
"""Create from dictionary, dispatching to the correct subclass."""
|
||||||
target_type = data.get("target_type", "wled")
|
target_type = data.get("target_type", "led")
|
||||||
# "wled" and "led" both map to WledPictureTarget (backward compat)
|
if target_type == "led":
|
||||||
if target_type in ("wled", "led"):
|
|
||||||
from wled_controller.storage.wled_picture_target import WledPictureTarget
|
from wled_controller.storage.wled_picture_target import WledPictureTarget
|
||||||
return WledPictureTarget.from_dict(data)
|
return WledPictureTarget.from_dict(data)
|
||||||
if target_type == "key_colors":
|
if target_type == "key_colors":
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ class AutomationSnapshot:
|
|||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: dict) -> "AutomationSnapshot":
|
def from_dict(cls, data: dict) -> "AutomationSnapshot":
|
||||||
return cls(
|
return cls(
|
||||||
automation_id=data.get("automation_id", data.get("profile_id", "")),
|
automation_id=data.get("automation_id", ""),
|
||||||
enabled=data.get("enabled", True),
|
enabled=data.get("enabled", True),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -118,7 +118,7 @@ class ScenePreset:
|
|||||||
color=data.get("color", "#4fc3f7"),
|
color=data.get("color", "#4fc3f7"),
|
||||||
targets=[TargetSnapshot.from_dict(t) for t in data.get("targets", [])],
|
targets=[TargetSnapshot.from_dict(t) for t in data.get("targets", [])],
|
||||||
devices=[DeviceBrightnessSnapshot.from_dict(d) for d in data.get("devices", [])],
|
devices=[DeviceBrightnessSnapshot.from_dict(d) for d in data.get("devices", [])],
|
||||||
automations=[AutomationSnapshot.from_dict(a) for a in data.get("automations", data.get("profiles", []))],
|
automations=[AutomationSnapshot.from_dict(a) for a in data.get("automations", [])],
|
||||||
order=data.get("order", 0),
|
order=data.get("order", 0),
|
||||||
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
|
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
|
||||||
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),
|
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),
|
||||||
|
|||||||
@@ -105,26 +105,16 @@ class WledPictureTarget(PictureTarget):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: dict) -> "WledPictureTarget":
|
def from_dict(cls, data: dict) -> "WledPictureTarget":
|
||||||
"""Create from dictionary with backward compatibility."""
|
"""Create from dictionary."""
|
||||||
# New format: direct color_strip_source_id
|
|
||||||
if "color_strip_source_id" in data:
|
|
||||||
css_id = data["color_strip_source_id"]
|
|
||||||
# Old format: segments array — take first segment's css_id
|
|
||||||
elif "segments" in data:
|
|
||||||
segs = data["segments"]
|
|
||||||
css_id = segs[0].get("color_strip_source_id", "") if segs else ""
|
|
||||||
else:
|
|
||||||
css_id = ""
|
|
||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
id=data["id"],
|
id=data["id"],
|
||||||
name=data["name"],
|
name=data["name"],
|
||||||
target_type="led",
|
target_type="led",
|
||||||
device_id=data.get("device_id", ""),
|
device_id=data.get("device_id", ""),
|
||||||
color_strip_source_id=css_id,
|
color_strip_source_id=data.get("color_strip_source_id", ""),
|
||||||
brightness_value_source_id=data.get("brightness_value_source_id", ""),
|
brightness_value_source_id=data.get("brightness_value_source_id", ""),
|
||||||
fps=data.get("fps", 30),
|
fps=data.get("fps", 30),
|
||||||
keepalive_interval=data.get("keepalive_interval", data.get("standby_interval", 1.0)),
|
keepalive_interval=data.get("keepalive_interval", 1.0),
|
||||||
state_check_interval=data.get("state_check_interval", DEFAULT_STATE_CHECK_INTERVAL),
|
state_check_interval=data.get("state_check_interval", DEFAULT_STATE_CHECK_INTERVAL),
|
||||||
min_brightness_threshold=data.get("min_brightness_threshold", 0),
|
min_brightness_threshold=data.get("min_brightness_threshold", 0),
|
||||||
adaptive_fps=data.get("adaptive_fps", False),
|
adaptive_fps=data.get("adaptive_fps", False),
|
||||||
|
|||||||
Reference in New Issue
Block a user