refactor: replace type-dispatch if/elif chains with registry patterns and handler maps
Some checks failed
Lint & Test / test (push) Failing after 30s
Some checks failed
Lint & Test / test (push) Failing after 30s
Backend: add registry dicts (_CONDITION_MAP, _VALUE_SOURCE_MAP, _PICTURE_SOURCE_MAP) and per-subclass from_dict() methods to eliminate ~300 lines of if/elif in factory functions. Convert automation engine dispatch (condition eval, match_mode, match_type, deactivation_mode) to dict-based lookup. Frontend: extract CSS_CARD_RENDERERS, CSS_SECTION_MAP, CSS_TYPE_SETUP, CONDITION_PILL_RENDERERS, and PICTURE_SOURCE_CARD_RENDERERS handler maps to replace scattered type-check chains in color-strips.ts, automations.ts, and streams.ts.
This commit is contained in:
@@ -42,40 +42,37 @@ router = APIRouter()
|
|||||||
# ===== Helpers =====
|
# ===== Helpers =====
|
||||||
|
|
||||||
def _condition_from_schema(s: ConditionSchema) -> Condition:
|
def _condition_from_schema(s: ConditionSchema) -> Condition:
|
||||||
if s.condition_type == "always":
|
_SCHEMA_TO_CONDITION = {
|
||||||
return AlwaysCondition()
|
"always": lambda: AlwaysCondition(),
|
||||||
if s.condition_type == "application":
|
"application": lambda: ApplicationCondition(
|
||||||
return ApplicationCondition(
|
|
||||||
apps=s.apps or [],
|
apps=s.apps or [],
|
||||||
match_type=s.match_type or "running",
|
match_type=s.match_type or "running",
|
||||||
)
|
),
|
||||||
if s.condition_type == "time_of_day":
|
"time_of_day": lambda: TimeOfDayCondition(
|
||||||
return TimeOfDayCondition(
|
|
||||||
start_time=s.start_time or "00:00",
|
start_time=s.start_time or "00:00",
|
||||||
end_time=s.end_time or "23:59",
|
end_time=s.end_time or "23:59",
|
||||||
)
|
),
|
||||||
if s.condition_type == "system_idle":
|
"system_idle": lambda: SystemIdleCondition(
|
||||||
return SystemIdleCondition(
|
|
||||||
idle_minutes=s.idle_minutes if s.idle_minutes is not None else 5,
|
idle_minutes=s.idle_minutes if s.idle_minutes is not None else 5,
|
||||||
when_idle=s.when_idle if s.when_idle is not None else True,
|
when_idle=s.when_idle if s.when_idle is not None else True,
|
||||||
)
|
),
|
||||||
if s.condition_type == "display_state":
|
"display_state": lambda: DisplayStateCondition(
|
||||||
return DisplayStateCondition(
|
|
||||||
state=s.state or "on",
|
state=s.state or "on",
|
||||||
)
|
),
|
||||||
if s.condition_type == "mqtt":
|
"mqtt": lambda: MQTTCondition(
|
||||||
return MQTTCondition(
|
|
||||||
topic=s.topic or "",
|
topic=s.topic or "",
|
||||||
payload=s.payload or "",
|
payload=s.payload or "",
|
||||||
match_mode=s.match_mode or "exact",
|
match_mode=s.match_mode or "exact",
|
||||||
)
|
),
|
||||||
if s.condition_type == "webhook":
|
"webhook": lambda: WebhookCondition(
|
||||||
return WebhookCondition(
|
|
||||||
token=s.token or secrets.token_hex(16),
|
token=s.token or secrets.token_hex(16),
|
||||||
)
|
),
|
||||||
if s.condition_type == "startup":
|
"startup": lambda: StartupCondition(),
|
||||||
return StartupCondition()
|
}
|
||||||
raise ValueError(f"Unknown condition type: {s.condition_type}")
|
factory = _SCHEMA_TO_CONDITION.get(s.condition_type)
|
||||||
|
if factory is None:
|
||||||
|
raise ValueError(f"Unknown condition type: {s.condition_type}")
|
||||||
|
return factory()
|
||||||
|
|
||||||
|
|
||||||
def _condition_to_schema(c: Condition) -> ConditionSchema:
|
def _condition_to_schema(c: Condition) -> ConditionSchema:
|
||||||
|
|||||||
@@ -205,21 +205,20 @@ class AutomationEngine:
|
|||||||
fullscreen_procs: Set[str],
|
fullscreen_procs: Set[str],
|
||||||
idle_seconds: Optional[float], display_state: Optional[str],
|
idle_seconds: Optional[float], display_state: Optional[str],
|
||||||
) -> bool:
|
) -> bool:
|
||||||
if isinstance(condition, (AlwaysCondition, StartupCondition)):
|
dispatch = {
|
||||||
return True
|
AlwaysCondition: lambda c: True,
|
||||||
if isinstance(condition, ApplicationCondition):
|
StartupCondition: lambda c: True,
|
||||||
return self._evaluate_app_condition(condition, running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs)
|
ApplicationCondition: lambda c: self._evaluate_app_condition(c, running_procs, topmost_proc, topmost_fullscreen, fullscreen_procs),
|
||||||
if isinstance(condition, TimeOfDayCondition):
|
TimeOfDayCondition: lambda c: self._evaluate_time_of_day(c),
|
||||||
return self._evaluate_time_of_day(condition)
|
SystemIdleCondition: lambda c: self._evaluate_idle(c, idle_seconds),
|
||||||
if isinstance(condition, SystemIdleCondition):
|
DisplayStateCondition: lambda c: self._evaluate_display_state(c, display_state),
|
||||||
return self._evaluate_idle(condition, idle_seconds)
|
MQTTCondition: lambda c: self._evaluate_mqtt(c),
|
||||||
if isinstance(condition, DisplayStateCondition):
|
WebhookCondition: lambda c: self._webhook_states.get(c.token, False),
|
||||||
return self._evaluate_display_state(condition, display_state)
|
}
|
||||||
if isinstance(condition, MQTTCondition):
|
handler = dispatch.get(type(condition))
|
||||||
return self._evaluate_mqtt(condition)
|
if handler is None:
|
||||||
if isinstance(condition, WebhookCondition):
|
return False
|
||||||
return self._webhook_states.get(condition.token, False)
|
return handler(condition)
|
||||||
return False
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _evaluate_time_of_day(condition: TimeOfDayCondition) -> bool:
|
def _evaluate_time_of_day(condition: TimeOfDayCondition) -> bool:
|
||||||
@@ -253,16 +252,18 @@ class AutomationEngine:
|
|||||||
value = self._mqtt_service.get_last_value(condition.topic)
|
value = self._mqtt_service.get_last_value(condition.topic)
|
||||||
if value is None:
|
if value is None:
|
||||||
return False
|
return False
|
||||||
if condition.match_mode == "exact":
|
matchers = {
|
||||||
return value == condition.payload
|
"exact": lambda: value == condition.payload,
|
||||||
if condition.match_mode == "contains":
|
"contains": lambda: condition.payload in value,
|
||||||
return condition.payload in value
|
"regex": lambda: bool(re.search(condition.payload, value)),
|
||||||
if condition.match_mode == "regex":
|
}
|
||||||
try:
|
matcher = matchers.get(condition.match_mode)
|
||||||
return bool(re.search(condition.payload, value))
|
if matcher is None:
|
||||||
except re.error:
|
return False
|
||||||
return False
|
try:
|
||||||
return False
|
return matcher()
|
||||||
|
except re.error:
|
||||||
|
return False
|
||||||
|
|
||||||
def _evaluate_app_condition(
|
def _evaluate_app_condition(
|
||||||
self,
|
self,
|
||||||
@@ -277,19 +278,21 @@ class AutomationEngine:
|
|||||||
|
|
||||||
apps_lower = [a.lower() for a in condition.apps]
|
apps_lower = [a.lower() for a in condition.apps]
|
||||||
|
|
||||||
if condition.match_type == "fullscreen":
|
match_handlers = {
|
||||||
return any(app in fullscreen_procs for app in apps_lower)
|
"fullscreen": lambda: any(app in fullscreen_procs for app in apps_lower),
|
||||||
|
"topmost_fullscreen": lambda: (
|
||||||
if condition.match_type == "topmost_fullscreen":
|
topmost_proc is not None
|
||||||
if topmost_proc is None or not topmost_fullscreen:
|
and topmost_fullscreen
|
||||||
return False
|
and any(app == topmost_proc for app in apps_lower)
|
||||||
return any(app == topmost_proc for app in apps_lower)
|
),
|
||||||
|
"topmost": lambda: (
|
||||||
if condition.match_type == "topmost":
|
topmost_proc is not None
|
||||||
if topmost_proc is None:
|
and any(app == topmost_proc for app in apps_lower)
|
||||||
return False
|
),
|
||||||
return any(app == topmost_proc for app in apps_lower)
|
}
|
||||||
|
handler = match_handlers.get(condition.match_type)
|
||||||
|
if handler is not None:
|
||||||
|
return handler()
|
||||||
# Default: "running"
|
# Default: "running"
|
||||||
return any(app in running_procs for app in apps_lower)
|
return any(app in running_procs for app in apps_lower)
|
||||||
|
|
||||||
@@ -352,38 +355,10 @@ class AutomationEngine:
|
|||||||
deactivation_mode = automation.deactivation_mode if automation else "none"
|
deactivation_mode = automation.deactivation_mode if automation else "none"
|
||||||
|
|
||||||
if deactivation_mode == "revert":
|
if deactivation_mode == "revert":
|
||||||
snapshot = self._pre_activation_snapshots.pop(automation_id, None)
|
await self._deactivate_revert(automation_id)
|
||||||
if snapshot and self._target_store:
|
|
||||||
from wled_controller.core.scenes.scene_activator import apply_scene_state
|
|
||||||
status, errors = await apply_scene_state(
|
|
||||||
snapshot, self._target_store, self._manager,
|
|
||||||
)
|
|
||||||
if errors:
|
|
||||||
logger.warning(f"Automation {automation_id} revert errors: {errors}")
|
|
||||||
else:
|
|
||||||
logger.info(f"Automation {automation_id} deactivated (reverted to previous state)")
|
|
||||||
else:
|
|
||||||
logger.warning(f"Automation {automation_id}: no snapshot available for revert")
|
|
||||||
|
|
||||||
elif deactivation_mode == "fallback_scene":
|
elif deactivation_mode == "fallback_scene":
|
||||||
fallback_id = automation.deactivation_scene_preset_id if automation else None
|
await self._deactivate_fallback(automation_id, automation)
|
||||||
if fallback_id and self._scene_preset_store and self._target_store:
|
|
||||||
try:
|
|
||||||
fallback = self._scene_preset_store.get_preset(fallback_id)
|
|
||||||
from wled_controller.core.scenes.scene_activator import apply_scene_state
|
|
||||||
status, errors = await apply_scene_state(
|
|
||||||
fallback, self._target_store, self._manager,
|
|
||||||
)
|
|
||||||
if errors:
|
|
||||||
logger.warning(f"Automation {automation_id} fallback errors: {errors}")
|
|
||||||
else:
|
|
||||||
logger.info(f"Automation {automation_id} deactivated (fallback scene '{fallback.name}' applied)")
|
|
||||||
except ValueError:
|
|
||||||
logger.warning(f"Automation {automation_id}: fallback scene {fallback_id} not found")
|
|
||||||
else:
|
|
||||||
logger.info(f"Automation {automation_id} deactivated (no fallback scene configured)")
|
|
||||||
else:
|
else:
|
||||||
# "none" mode — just clear active state
|
|
||||||
logger.info(f"Automation {automation_id} deactivated")
|
logger.info(f"Automation {automation_id} deactivated")
|
||||||
|
|
||||||
self._last_deactivated[automation_id] = datetime.now(timezone.utc)
|
self._last_deactivated[automation_id] = datetime.now(timezone.utc)
|
||||||
@@ -391,6 +366,40 @@ class AutomationEngine:
|
|||||||
# Clean up any leftover snapshot
|
# Clean up any leftover snapshot
|
||||||
self._pre_activation_snapshots.pop(automation_id, None)
|
self._pre_activation_snapshots.pop(automation_id, None)
|
||||||
|
|
||||||
|
async def _deactivate_revert(self, automation_id: str) -> None:
|
||||||
|
"""Revert to pre-activation snapshot."""
|
||||||
|
snapshot = self._pre_activation_snapshots.pop(automation_id, None)
|
||||||
|
if snapshot and self._target_store:
|
||||||
|
from wled_controller.core.scenes.scene_activator import apply_scene_state
|
||||||
|
status, errors = await apply_scene_state(
|
||||||
|
snapshot, self._target_store, self._manager,
|
||||||
|
)
|
||||||
|
if errors:
|
||||||
|
logger.warning(f"Automation {automation_id} revert errors: {errors}")
|
||||||
|
else:
|
||||||
|
logger.info(f"Automation {automation_id} deactivated (reverted to previous state)")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Automation {automation_id}: no snapshot available for revert")
|
||||||
|
|
||||||
|
async def _deactivate_fallback(self, automation_id: str, automation) -> None:
|
||||||
|
"""Activate fallback scene on deactivation."""
|
||||||
|
fallback_id = automation.deactivation_scene_preset_id if automation else None
|
||||||
|
if fallback_id and self._scene_preset_store and self._target_store:
|
||||||
|
try:
|
||||||
|
fallback = self._scene_preset_store.get_preset(fallback_id)
|
||||||
|
from wled_controller.core.scenes.scene_activator import apply_scene_state
|
||||||
|
status, errors = await apply_scene_state(
|
||||||
|
fallback, self._target_store, self._manager,
|
||||||
|
)
|
||||||
|
if errors:
|
||||||
|
logger.warning(f"Automation {automation_id} fallback errors: {errors}")
|
||||||
|
else:
|
||||||
|
logger.info(f"Automation {automation_id} deactivated (fallback scene '{fallback.name}' applied)")
|
||||||
|
except ValueError:
|
||||||
|
logger.warning(f"Automation {automation_id}: fallback scene {fallback_id} not found")
|
||||||
|
else:
|
||||||
|
logger.info(f"Automation {automation_id} deactivated (no fallback scene configured)")
|
||||||
|
|
||||||
def _fire_event(self, automation_id: str, action: str) -> None:
|
def _fire_event(self, automation_id: str, action: str) -> None:
|
||||||
try:
|
try:
|
||||||
self._manager.fire_event({
|
self._manager.fire_event({
|
||||||
|
|||||||
@@ -206,6 +206,29 @@ function renderAutomations(automations: any, sceneMap: any) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ConditionPillRenderer = (c: any) => string;
|
||||||
|
|
||||||
|
const CONDITION_PILL_RENDERERS: Record<string, ConditionPillRenderer> = {
|
||||||
|
always: (c) => `<span class="stream-card-prop">${ICON_OK} ${t('automations.condition.always')}</span>`,
|
||||||
|
startup: (c) => `<span class="stream-card-prop">${ICON_START} ${t('automations.condition.startup')}</span>`,
|
||||||
|
application: (c) => {
|
||||||
|
const apps = (c.apps || []).join(', ');
|
||||||
|
const matchLabel = t('automations.condition.application.match_type.' + (c.match_type || 'running'));
|
||||||
|
return `<span class="stream-card-prop stream-card-prop-full">${t('automations.condition.application')}: ${apps} (${matchLabel})</span>`;
|
||||||
|
},
|
||||||
|
time_of_day: (c) => `<span class="stream-card-prop">${ICON_CLOCK} ${t('automations.condition.time_of_day')}: ${c.start_time || '00:00'} – ${c.end_time || '23:59'}</span>`,
|
||||||
|
system_idle: (c) => {
|
||||||
|
const mode = c.when_idle !== false ? t('automations.condition.system_idle.when_idle') : t('automations.condition.system_idle.when_active');
|
||||||
|
return `<span class="stream-card-prop">${ICON_TIMER} ${c.idle_minutes || 5}m (${mode})</span>`;
|
||||||
|
},
|
||||||
|
display_state: (c) => {
|
||||||
|
const stateLabel = t('automations.condition.display_state.' + (c.state || 'on'));
|
||||||
|
return `<span class="stream-card-prop">${ICON_MONITOR} ${t('automations.condition.display_state')}: ${stateLabel}</span>`;
|
||||||
|
},
|
||||||
|
mqtt: (c) => `<span class="stream-card-prop stream-card-prop-full">${ICON_RADIO} ${t('automations.condition.mqtt')}: ${escapeHtml(c.topic || '')} = ${escapeHtml(c.payload || '*')}</span>`,
|
||||||
|
webhook: (c) => `<span class="stream-card-prop">🔗 ${t('automations.condition.webhook')}</span>`,
|
||||||
|
};
|
||||||
|
|
||||||
function createAutomationCard(automation: Automation, sceneMap = new Map()) {
|
function createAutomationCard(automation: Automation, sceneMap = new Map()) {
|
||||||
const statusClass = !automation.enabled ? 'disabled' : automation.is_active ? 'active' : 'inactive';
|
const statusClass = !automation.enabled ? 'disabled' : automation.is_active ? 'active' : 'inactive';
|
||||||
const statusText = !automation.enabled ? t('automations.status.disabled') : automation.is_active ? t('automations.status.active') : t('automations.status.inactive');
|
const statusText = !automation.enabled ? t('automations.status.disabled') : automation.is_active ? t('automations.status.active') : t('automations.status.inactive');
|
||||||
@@ -215,35 +238,8 @@ function createAutomationCard(automation: Automation, sceneMap = new Map()) {
|
|||||||
condPills = `<span class="stream-card-prop">${t('automations.conditions.empty')}</span>`;
|
condPills = `<span class="stream-card-prop">${t('automations.conditions.empty')}</span>`;
|
||||||
} else {
|
} else {
|
||||||
const parts = automation.conditions.map(c => {
|
const parts = automation.conditions.map(c => {
|
||||||
if (c.condition_type === 'always') {
|
const renderer = CONDITION_PILL_RENDERERS[c.condition_type];
|
||||||
return `<span class="stream-card-prop">${ICON_OK} ${t('automations.condition.always')}</span>`;
|
return renderer ? renderer(c) : `<span class="stream-card-prop">${c.condition_type}</span>`;
|
||||||
}
|
|
||||||
if (c.condition_type === 'startup') {
|
|
||||||
return `<span class="stream-card-prop">${ICON_START} ${t('automations.condition.startup')}</span>`;
|
|
||||||
}
|
|
||||||
if (c.condition_type === 'application') {
|
|
||||||
const apps = (c.apps || []).join(', ');
|
|
||||||
const matchLabel = t('automations.condition.application.match_type.' + (c.match_type || 'running'));
|
|
||||||
return `<span class="stream-card-prop stream-card-prop-full">${t('automations.condition.application')}: ${apps} (${matchLabel})</span>`;
|
|
||||||
}
|
|
||||||
if (c.condition_type === 'time_of_day') {
|
|
||||||
return `<span class="stream-card-prop">${ICON_CLOCK} ${t('automations.condition.time_of_day')}: ${c.start_time || '00:00'} – ${c.end_time || '23:59'}</span>`;
|
|
||||||
}
|
|
||||||
if (c.condition_type === 'system_idle') {
|
|
||||||
const mode = c.when_idle !== false ? t('automations.condition.system_idle.when_idle') : t('automations.condition.system_idle.when_active');
|
|
||||||
return `<span class="stream-card-prop">${ICON_TIMER} ${c.idle_minutes || 5}m (${mode})</span>`;
|
|
||||||
}
|
|
||||||
if (c.condition_type === 'display_state') {
|
|
||||||
const stateLabel = t('automations.condition.display_state.' + (c.state || 'on'));
|
|
||||||
return `<span class="stream-card-prop">${ICON_MONITOR} ${t('automations.condition.display_state')}: ${stateLabel}</span>`;
|
|
||||||
}
|
|
||||||
if (c.condition_type === 'mqtt') {
|
|
||||||
return `<span class="stream-card-prop stream-card-prop-full">${ICON_RADIO} ${t('automations.condition.mqtt')}: ${escapeHtml(c.topic || '')} = ${escapeHtml(c.payload || '*')}</span>`;
|
|
||||||
}
|
|
||||||
if (c.condition_type === 'webhook') {
|
|
||||||
return `<span class="stream-card-prop">🔗 ${t('automations.condition.webhook')}</span>`;
|
|
||||||
}
|
|
||||||
return `<span class="stream-card-prop">${c.condition_type}</span>`;
|
|
||||||
});
|
});
|
||||||
const logicLabel = automation.condition_logic === 'and' ? t('automations.logic.and') : t('automations.logic.or');
|
const logicLabel = automation.condition_logic === 'and' ? t('automations.logic.and') : t('automations.logic.or');
|
||||||
condPills = parts.join(`<span class="automation-logic-label">${logicLabel}</span>`);
|
condPills = parts.join(`<span class="automation-logic-label">${logicLabel}</span>`);
|
||||||
|
|||||||
@@ -158,46 +158,62 @@ function _ensureCSSTypeIconSelect() {
|
|||||||
|
|
||||||
/* ── Type-switch helper ───────────────────────────────────────── */
|
/* ── Type-switch helper ───────────────────────────────────────── */
|
||||||
|
|
||||||
|
const CSS_SECTION_MAP: Record<string, string> = {
|
||||||
|
'picture': 'css-editor-picture-section',
|
||||||
|
'picture_advanced': 'css-editor-picture-section',
|
||||||
|
'static': 'css-editor-static-section',
|
||||||
|
'color_cycle': 'css-editor-color-cycle-section',
|
||||||
|
'gradient': 'css-editor-gradient-section',
|
||||||
|
'effect': 'css-editor-effect-section',
|
||||||
|
'composite': 'css-editor-composite-section',
|
||||||
|
'mapped': 'css-editor-mapped-section',
|
||||||
|
'audio': 'css-editor-audio-section',
|
||||||
|
'api_input': 'css-editor-api-input-section',
|
||||||
|
'notification': 'css-editor-notification-section',
|
||||||
|
'daylight': 'css-editor-daylight-section',
|
||||||
|
'candlelight': 'css-editor-candlelight-section',
|
||||||
|
'processed': 'css-editor-processed-section',
|
||||||
|
};
|
||||||
|
|
||||||
|
const CSS_ALL_SECTION_IDS = [...new Set(Object.values(CSS_SECTION_MAP))];
|
||||||
|
|
||||||
|
const CSS_TYPE_SETUP: Record<string, () => void> = {
|
||||||
|
processed: () => _populateProcessedSelectors(),
|
||||||
|
effect: () => { _ensureEffectTypeIconSelect(); _ensureEffectPaletteIconSelect(); onEffectTypeChange(); },
|
||||||
|
audio: () => { _ensureAudioVizIconSelect(); _ensureAudioPaletteIconSelect(); onAudioVizChange(); _loadAudioSources(); },
|
||||||
|
gradient: () => { _ensureGradientPresetIconSelect(); _ensureGradientEasingIconSelect(); requestAnimationFrame(() => gradientRenderAll()); },
|
||||||
|
notification: () => { ensureNotificationEffectIconSelect(); ensureNotificationFilterModeIconSelect(); },
|
||||||
|
candlelight: () => _ensureCandleTypeIconSelect(),
|
||||||
|
composite: () => compositeRenderList(),
|
||||||
|
mapped: () => _mappedRenderList(),
|
||||||
|
};
|
||||||
|
|
||||||
export function onCSSTypeChange() {
|
export function onCSSTypeChange() {
|
||||||
const type = (document.getElementById('css-editor-type') as HTMLInputElement).value;
|
const type = (document.getElementById('css-editor-type') as HTMLInputElement).value;
|
||||||
// Sync icon-select trigger display
|
// Sync icon-select trigger display
|
||||||
if (_cssTypeIconSelect) _cssTypeIconSelect.setValue(type);
|
if (_cssTypeIconSelect) _cssTypeIconSelect.setValue(type);
|
||||||
const isPictureType = type === 'picture' || type === 'picture_advanced';
|
|
||||||
(document.getElementById('css-editor-picture-section') as HTMLElement).style.display = isPictureType ? '' : 'none';
|
// Hide all type-specific sections, then show the active one
|
||||||
|
CSS_ALL_SECTION_IDS.forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.style.display = 'none';
|
||||||
|
});
|
||||||
|
const activeSection = CSS_SECTION_MAP[type];
|
||||||
|
if (activeSection) {
|
||||||
|
const el = document.getElementById(activeSection);
|
||||||
|
if (el) el.style.display = '';
|
||||||
|
}
|
||||||
|
|
||||||
// Hide picture source dropdown for advanced (sources are per-line in calibration)
|
// Hide picture source dropdown for advanced (sources are per-line in calibration)
|
||||||
const psGroup = document.getElementById('css-editor-picture-source-group') as HTMLElement | null;
|
const psGroup = document.getElementById('css-editor-picture-source-group') as HTMLElement | null;
|
||||||
if (psGroup) psGroup.style.display = (type === 'picture') ? '' : 'none';
|
if (psGroup) psGroup.style.display = (type === 'picture') ? '' : 'none';
|
||||||
(document.getElementById('css-editor-static-section') as HTMLElement).style.display = type === 'static' ? '' : 'none';
|
|
||||||
(document.getElementById('css-editor-color-cycle-section') as HTMLElement).style.display = type === 'color_cycle' ? '' : 'none';
|
|
||||||
(document.getElementById('css-editor-gradient-section') as HTMLElement).style.display = type === 'gradient' ? '' : 'none';
|
|
||||||
(document.getElementById('css-editor-effect-section') as HTMLElement).style.display = type === 'effect' ? '' : 'none';
|
|
||||||
(document.getElementById('css-editor-composite-section') as HTMLElement).style.display = type === 'composite' ? '' : 'none';
|
|
||||||
(document.getElementById('css-editor-mapped-section') as HTMLElement).style.display = type === 'mapped' ? '' : 'none';
|
|
||||||
(document.getElementById('css-editor-audio-section') as HTMLElement).style.display = type === 'audio' ? '' : 'none';
|
|
||||||
(document.getElementById('css-editor-api-input-section') as HTMLElement).style.display = type === 'api_input' ? '' : 'none';
|
|
||||||
(document.getElementById('css-editor-notification-section') as HTMLElement).style.display = type === 'notification' ? '' : 'none';
|
|
||||||
(document.getElementById('css-editor-daylight-section') as HTMLElement).style.display = type === 'daylight' ? '' : 'none';
|
|
||||||
(document.getElementById('css-editor-candlelight-section') as HTMLElement).style.display = type === 'candlelight' ? '' : 'none';
|
|
||||||
(document.getElementById('css-editor-processed-section') as HTMLElement).style.display = type === 'processed' ? '' : 'none';
|
|
||||||
|
|
||||||
|
const isPictureType = type === 'picture' || type === 'picture_advanced';
|
||||||
if (isPictureType) _ensureInterpolationIconSelect();
|
if (isPictureType) _ensureInterpolationIconSelect();
|
||||||
if (type === 'processed') _populateProcessedSelectors();
|
|
||||||
if (type === 'effect') {
|
// Run type-specific setup
|
||||||
_ensureEffectTypeIconSelect();
|
const setupFn = CSS_TYPE_SETUP[type];
|
||||||
_ensureEffectPaletteIconSelect();
|
if (setupFn) setupFn();
|
||||||
onEffectTypeChange();
|
|
||||||
}
|
|
||||||
if (type === 'audio') {
|
|
||||||
_ensureAudioVizIconSelect();
|
|
||||||
_ensureAudioPaletteIconSelect();
|
|
||||||
onAudioVizChange();
|
|
||||||
}
|
|
||||||
if (type === 'gradient') { _ensureGradientPresetIconSelect(); _ensureGradientEasingIconSelect(); }
|
|
||||||
if (type === 'notification') {
|
|
||||||
ensureNotificationEffectIconSelect();
|
|
||||||
ensureNotificationFilterModeIconSelect();
|
|
||||||
}
|
|
||||||
if (type === 'candlelight') _ensureCandleTypeIconSelect();
|
|
||||||
|
|
||||||
// Animation section — shown for static/gradient only
|
// Animation section — shown for static/gradient only
|
||||||
const animSection = document.getElementById('css-editor-animation-section') as HTMLElement;
|
const animSection = document.getElementById('css-editor-animation-section') as HTMLElement;
|
||||||
@@ -226,16 +242,6 @@ export function onCSSTypeChange() {
|
|||||||
(document.getElementById('css-editor-clock-group') as HTMLElement).style.display = clockTypes.includes(type) ? '' : 'none';
|
(document.getElementById('css-editor-clock-group') as HTMLElement).style.display = clockTypes.includes(type) ? '' : 'none';
|
||||||
if (clockTypes.includes(type)) _populateClockDropdown();
|
if (clockTypes.includes(type)) _populateClockDropdown();
|
||||||
|
|
||||||
if (type === 'audio') {
|
|
||||||
_loadAudioSources();
|
|
||||||
} else if (type === 'composite') {
|
|
||||||
compositeRenderList();
|
|
||||||
} else if (type === 'mapped') {
|
|
||||||
_mappedRenderList();
|
|
||||||
} else if (type === 'gradient') {
|
|
||||||
requestAnimationFrame(() => gradientRenderAll());
|
|
||||||
}
|
|
||||||
|
|
||||||
_autoGenerateCSSName();
|
_autoGenerateCSSName();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -912,56 +918,44 @@ function _resetAudioState() {
|
|||||||
|
|
||||||
/* ── Card ─────────────────────────────────────────────────────── */
|
/* ── Card ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
export function createColorStripCard(source: ColorStripSource, pictureSourceMap: Record<string, any>, audioSourceMap: Record<string, any>) {
|
type CardPropsRenderer = (source: ColorStripSource, opts: {
|
||||||
const isStatic = source.source_type === 'static';
|
clockBadge: string;
|
||||||
const isGradient = source.source_type === 'gradient';
|
animBadge: string;
|
||||||
const isColorCycle = source.source_type === 'color_cycle';
|
audioSourceMap: Record<string, any>;
|
||||||
const isEffect = source.source_type === 'effect';
|
pictureSourceMap: Record<string, any>;
|
||||||
const isComposite = source.source_type === 'composite';
|
}) => string;
|
||||||
const isMapped = source.source_type === 'mapped';
|
|
||||||
const isAudio = source.source_type === 'audio';
|
|
||||||
const isApiInput = source.source_type === 'api_input';
|
|
||||||
const isNotification = source.source_type === 'notification';
|
|
||||||
const isPictureAdvanced = source.source_type === 'picture_advanced';
|
|
||||||
|
|
||||||
// Clock crosslink badge (replaces speed badge when clock is assigned)
|
const NON_PICTURE_TYPES = new Set([
|
||||||
const clockObj = source.clock_id ? _cachedSyncClocks.find(c => c.id === source.clock_id) : null;
|
'static', 'gradient', 'color_cycle', 'effect', 'composite', 'mapped',
|
||||||
const clockBadge = clockObj
|
'audio', 'api_input', 'notification', 'daylight', 'candlelight', 'processed',
|
||||||
? `<span class="stream-card-prop stream-card-link" title="${t('color_strip.clock')}" onclick="event.stopPropagation(); navigateToCard('streams','sync','sync-clocks','data-id','${source.clock_id}')">${ICON_CLOCK} ${escapeHtml(clockObj.name)}</span>`
|
]);
|
||||||
: source.clock_id ? `<span class="stream-card-prop">${ICON_CLOCK} ${source.clock_id}</span>` : '';
|
|
||||||
|
|
||||||
const anim = (isStatic || isGradient) && source.animation && source.animation.enabled ? source.animation : null;
|
const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
|
||||||
const animBadge = anim
|
static: (source, { clockBadge, animBadge }) => {
|
||||||
? `<span class="stream-card-prop" title="${t('color_strip.animation')}">${ICON_SPARKLES} ${t('color_strip.animation.type.' + anim.type) || anim.type}</span>`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
let propsHtml;
|
|
||||||
if (isStatic) {
|
|
||||||
const hexColor = rgbArrayToHex(source.color!);
|
const hexColor = rgbArrayToHex(source.color!);
|
||||||
propsHtml = `
|
return `
|
||||||
<span class="stream-card-prop" title="${t('color_strip.static_color')}">
|
<span class="stream-card-prop" title="${t('color_strip.static_color')}">
|
||||||
<span style="display:inline-block;width:14px;height:14px;background:${hexColor};border:1px solid #888;border-radius:2px;vertical-align:middle;margin-right:4px"></span>${hexColor.toUpperCase()}
|
<span style="display:inline-block;width:14px;height:14px;background:${hexColor};border:1px solid #888;border-radius:2px;vertical-align:middle;margin-right:4px"></span>${hexColor.toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
${animBadge}
|
${animBadge}
|
||||||
${clockBadge}
|
${clockBadge}
|
||||||
`;
|
`;
|
||||||
} else if (isColorCycle) {
|
},
|
||||||
|
color_cycle: (source, { clockBadge }) => {
|
||||||
const colors = source.colors || [];
|
const colors = source.colors || [];
|
||||||
const swatches = colors.slice(0, 8).map((c: any) =>
|
const swatches = colors.slice(0, 8).map((c: any) =>
|
||||||
`<span style="display:inline-block;width:12px;height:12px;background:${rgbArrayToHex(c)};border:1px solid #888;border-radius:2px;margin-right:2px"></span>`
|
`<span style="display:inline-block;width:12px;height:12px;background:${rgbArrayToHex(c)};border:1px solid #888;border-radius:2px;margin-right:2px"></span>`
|
||||||
).join('');
|
).join('');
|
||||||
propsHtml = `
|
return `
|
||||||
<span class="stream-card-prop">${swatches}</span>
|
<span class="stream-card-prop">${swatches}</span>
|
||||||
${clockBadge}
|
${clockBadge}
|
||||||
`;
|
`;
|
||||||
} else if (isGradient) {
|
},
|
||||||
|
gradient: (source, { clockBadge, animBadge }) => {
|
||||||
const stops = source.stops || [];
|
const stops = source.stops || [];
|
||||||
const sortedStops = [...stops].sort((a, b) => a.position - b.position);
|
const sortedStops = [...stops].sort((a, b) => a.position - b.position);
|
||||||
let cssGradient = '';
|
let cssGradient = '';
|
||||||
if (sortedStops.length >= 2) {
|
if (sortedStops.length >= 2) {
|
||||||
// Build CSS stops that mirror the interpolation algorithm:
|
|
||||||
// for each stop emit its primary color, then immediately emit color_right
|
|
||||||
// at the same position to produce a hard edge (bidirectional stop).
|
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
sortedStops.forEach(s => {
|
sortedStops.forEach(s => {
|
||||||
const pct = Math.round(s.position * 100);
|
const pct = Math.round(s.position * 100);
|
||||||
@@ -970,39 +964,43 @@ export function createColorStripCard(source: ColorStripSource, pictureSourceMap:
|
|||||||
});
|
});
|
||||||
cssGradient = `linear-gradient(to right, ${parts.join(', ')})`;
|
cssGradient = `linear-gradient(to right, ${parts.join(', ')})`;
|
||||||
}
|
}
|
||||||
propsHtml = `
|
return `
|
||||||
${cssGradient ? `<span style="flex:1 1 100%;height:12px;background:${cssGradient};border-radius:3px;border:1px solid rgba(128,128,128,0.3)"></span>` : ''}
|
${cssGradient ? `<span style="flex:1 1 100%;height:12px;background:${cssGradient};border-radius:3px;border:1px solid rgba(128,128,128,0.3)"></span>` : ''}
|
||||||
<span class="stream-card-prop">${ICON_PALETTE} ${stops.length} ${t('color_strip.gradient.stops_count')}</span>
|
<span class="stream-card-prop">${ICON_PALETTE} ${stops.length} ${t('color_strip.gradient.stops_count')}</span>
|
||||||
${animBadge}
|
${animBadge}
|
||||||
${clockBadge}
|
${clockBadge}
|
||||||
`;
|
`;
|
||||||
} else if (isEffect) {
|
},
|
||||||
|
effect: (source, { clockBadge }) => {
|
||||||
const effectLabel = t('color_strip.effect.' + (source.effect_type || 'fire')) || source.effect_type || 'fire';
|
const effectLabel = t('color_strip.effect.' + (source.effect_type || 'fire')) || source.effect_type || 'fire';
|
||||||
const paletteLabel = source.palette ? (t('color_strip.palette.' + source.palette) || source.palette) : '';
|
const paletteLabel = source.palette ? (t('color_strip.palette.' + source.palette) || source.palette) : '';
|
||||||
propsHtml = `
|
return `
|
||||||
<span class="stream-card-prop">${ICON_FPS} ${escapeHtml(effectLabel)}</span>
|
<span class="stream-card-prop">${ICON_FPS} ${escapeHtml(effectLabel)}</span>
|
||||||
${paletteLabel ? `<span class="stream-card-prop" title="${t('color_strip.effect.palette')}">${ICON_PALETTE} ${escapeHtml(paletteLabel)}</span>` : ''}
|
${paletteLabel ? `<span class="stream-card-prop" title="${t('color_strip.effect.palette')}">${ICON_PALETTE} ${escapeHtml(paletteLabel)}</span>` : ''}
|
||||||
${clockBadge}
|
${clockBadge}
|
||||||
`;
|
`;
|
||||||
} else if (isComposite) {
|
},
|
||||||
|
composite: (source) => {
|
||||||
const layerCount = (source.layers || []).length;
|
const layerCount = (source.layers || []).length;
|
||||||
const enabledCount = (source.layers || []).filter((l: any) => l.enabled !== false).length;
|
const enabledCount = (source.layers || []).filter((l: any) => l.enabled !== false).length;
|
||||||
propsHtml = `
|
return `
|
||||||
<span class="stream-card-prop">${ICON_LINK} ${enabledCount}/${layerCount} ${t('color_strip.composite.layers_count')}</span>
|
<span class="stream-card-prop">${ICON_LINK} ${enabledCount}/${layerCount} ${t('color_strip.composite.layers_count')}</span>
|
||||||
${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">${ICON_LED} ${source.led_count}</span>` : ''}
|
${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">${ICON_LED} ${source.led_count}</span>` : ''}
|
||||||
`;
|
`;
|
||||||
} else if (isMapped) {
|
},
|
||||||
|
mapped: (source) => {
|
||||||
const zoneCount = (source.zones || []).length;
|
const zoneCount = (source.zones || []).length;
|
||||||
propsHtml = `
|
return `
|
||||||
<span class="stream-card-prop">${ICON_MAP_PIN} ${zoneCount} ${t('color_strip.mapped.zones_count')}</span>
|
<span class="stream-card-prop">${ICON_MAP_PIN} ${zoneCount} ${t('color_strip.mapped.zones_count')}</span>
|
||||||
${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">${ICON_LED} ${source.led_count}</span>` : ''}
|
${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">${ICON_LED} ${source.led_count}</span>` : ''}
|
||||||
`;
|
`;
|
||||||
} else if (isAudio) {
|
},
|
||||||
|
audio: (source, { audioSourceMap }) => {
|
||||||
const vizLabel = t('color_strip.audio.viz.' + (source.visualization_mode || 'spectrum')) || source.visualization_mode || 'spectrum';
|
const vizLabel = t('color_strip.audio.viz.' + (source.visualization_mode || 'spectrum')) || source.visualization_mode || 'spectrum';
|
||||||
const vizMode = source.visualization_mode || 'spectrum';
|
const vizMode = source.visualization_mode || 'spectrum';
|
||||||
const showPalette = (vizMode === 'spectrum' || vizMode === 'beat_pulse') && source.palette;
|
const showPalette = (vizMode === 'spectrum' || vizMode === 'beat_pulse') && source.palette;
|
||||||
const audioPaletteLabel = showPalette ? (t('color_strip.palette.' + source.palette) || source.palette) : '';
|
const audioPaletteLabel = showPalette ? (t('color_strip.palette.' + source.palette) || source.palette) : '';
|
||||||
propsHtml = `
|
return `
|
||||||
<span class="stream-card-prop">${ICON_MUSIC} ${escapeHtml(vizLabel)}</span>
|
<span class="stream-card-prop">${ICON_MUSIC} ${escapeHtml(vizLabel)}</span>
|
||||||
${audioPaletteLabel ? `<span class="stream-card-prop" title="${t('color_strip.audio.palette')}">${ICON_PALETTE} ${escapeHtml(audioPaletteLabel)}</span>` : ''}
|
${audioPaletteLabel ? `<span class="stream-card-prop" title="${t('color_strip.audio.palette')}">${ICON_PALETTE} ${escapeHtml(audioPaletteLabel)}</span>` : ''}
|
||||||
${source.audio_source_id ? (() => {
|
${source.audio_source_id ? (() => {
|
||||||
@@ -1013,21 +1011,23 @@ export function createColorStripCard(source: ColorStripSource, pictureSourceMap:
|
|||||||
})() : ''}
|
})() : ''}
|
||||||
${source.mirror ? `<span class="stream-card-prop">🪞</span>` : ''}
|
${source.mirror ? `<span class="stream-card-prop">🪞</span>` : ''}
|
||||||
`;
|
`;
|
||||||
} else if (isApiInput) {
|
},
|
||||||
|
api_input: (source) => {
|
||||||
const fbColor = rgbArrayToHex(source.fallback_color || [0, 0, 0]);
|
const fbColor = rgbArrayToHex(source.fallback_color || [0, 0, 0]);
|
||||||
const timeoutVal = (source.timeout ?? 5.0).toFixed(1);
|
const timeoutVal = (source.timeout ?? 5.0).toFixed(1);
|
||||||
propsHtml = `
|
return `
|
||||||
<span class="stream-card-prop" title="${t('color_strip.api_input.fallback_color')}">
|
<span class="stream-card-prop" title="${t('color_strip.api_input.fallback_color')}">
|
||||||
<span style="display:inline-block;width:14px;height:14px;background:${fbColor};border:1px solid #888;border-radius:2px;vertical-align:middle;margin-right:4px"></span>${fbColor.toUpperCase()}
|
<span style="display:inline-block;width:14px;height:14px;background:${fbColor};border:1px solid #888;border-radius:2px;vertical-align:middle;margin-right:4px"></span>${fbColor.toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
<span class="stream-card-prop" title="${t('color_strip.api_input.timeout')}">${ICON_TIMER} ${timeoutVal}s</span>
|
<span class="stream-card-prop" title="${t('color_strip.api_input.timeout')}">${ICON_TIMER} ${timeoutVal}s</span>
|
||||||
`;
|
`;
|
||||||
} else if (isNotification) {
|
},
|
||||||
|
notification: (source) => {
|
||||||
const effectLabel = t('color_strip.notification.effect.' + (source.notification_effect || 'flash')) || source.notification_effect || 'flash';
|
const effectLabel = t('color_strip.notification.effect.' + (source.notification_effect || 'flash')) || source.notification_effect || 'flash';
|
||||||
const durationVal = source.duration_ms || 1500;
|
const durationVal = source.duration_ms || 1500;
|
||||||
const defColor = source.default_color || '#FFFFFF';
|
const defColor = source.default_color || '#FFFFFF';
|
||||||
const appCount = source.app_colors ? Object.keys(source.app_colors).length : 0;
|
const appCount = source.app_colors ? Object.keys(source.app_colors).length : 0;
|
||||||
propsHtml = `
|
return `
|
||||||
<span class="stream-card-prop" title="${t('color_strip.notification.effect')}">${ICON_BELL} ${escapeHtml(effectLabel)}</span>
|
<span class="stream-card-prop" title="${t('color_strip.notification.effect')}">${ICON_BELL} ${escapeHtml(effectLabel)}</span>
|
||||||
<span class="stream-card-prop" title="${t('color_strip.notification.duration')}">${ICON_TIMER} ${durationVal}ms</span>
|
<span class="stream-card-prop" title="${t('color_strip.notification.duration')}">${ICON_TIMER} ${durationVal}ms</span>
|
||||||
<span class="stream-card-prop" title="${t('color_strip.notification.default_color')}">
|
<span class="stream-card-prop" title="${t('color_strip.notification.default_color')}">
|
||||||
@@ -1035,73 +1035,96 @@ export function createColorStripCard(source: ColorStripSource, pictureSourceMap:
|
|||||||
</span>
|
</span>
|
||||||
${appCount > 0 ? `<span class="stream-card-prop">${ICON_PALETTE} ${appCount} ${t('color_strip.notification.app_count')}</span>` : ''}
|
${appCount > 0 ? `<span class="stream-card-prop">${ICON_PALETTE} ${appCount} ${t('color_strip.notification.app_count')}</span>` : ''}
|
||||||
`;
|
`;
|
||||||
} else if (source.source_type === 'daylight') {
|
},
|
||||||
|
daylight: (source, { clockBadge }) => {
|
||||||
const useRealTime = source.use_real_time;
|
const useRealTime = source.use_real_time;
|
||||||
const speedVal = (source.speed ?? 1.0).toFixed(1);
|
const speedVal = (source.speed ?? 1.0).toFixed(1);
|
||||||
propsHtml = `
|
return `
|
||||||
<span class="stream-card-prop">${useRealTime ? '🕐 ' + t('color_strip.daylight.real_time') : '⏩ ' + speedVal + 'x'}</span>
|
<span class="stream-card-prop">${useRealTime ? '🕐 ' + t('color_strip.daylight.real_time') : '⏩ ' + speedVal + 'x'}</span>
|
||||||
${clockBadge}
|
${clockBadge}
|
||||||
`;
|
`;
|
||||||
} else if (source.source_type === 'candlelight') {
|
},
|
||||||
|
candlelight: (source, { clockBadge }) => {
|
||||||
const hexColor = rgbArrayToHex(source.color || [255, 147, 41]);
|
const hexColor = rgbArrayToHex(source.color || [255, 147, 41]);
|
||||||
const numCandles = source.num_candles ?? 3;
|
const numCandles = source.num_candles ?? 3;
|
||||||
propsHtml = `
|
return `
|
||||||
<span class="stream-card-prop" title="${t('color_strip.candlelight.color')}">
|
<span class="stream-card-prop" title="${t('color_strip.candlelight.color')}">
|
||||||
<span style="display:inline-block;width:14px;height:14px;background:${hexColor};border:1px solid #888;border-radius:2px;vertical-align:middle;margin-right:4px"></span>${hexColor.toUpperCase()}
|
<span style="display:inline-block;width:14px;height:14px;background:${hexColor};border:1px solid #888;border-radius:2px;vertical-align:middle;margin-right:4px"></span>${hexColor.toUpperCase()}
|
||||||
</span>
|
</span>
|
||||||
<span class="stream-card-prop">${numCandles} ${t('color_strip.candlelight.num_candles')}</span>
|
<span class="stream-card-prop">${numCandles} ${t('color_strip.candlelight.num_candles')}</span>
|
||||||
${clockBadge}
|
${clockBadge}
|
||||||
`;
|
`;
|
||||||
} else if (source.source_type === 'processed') {
|
},
|
||||||
|
processed: (source) => {
|
||||||
const inputSrc = ((colorStripSourcesCache.data || []) as any[]).find(s => s.id === source.input_source_id);
|
const inputSrc = ((colorStripSourcesCache.data || []) as any[]).find(s => s.id === source.input_source_id);
|
||||||
const inputName = inputSrc?.name || source.input_source_id || '—';
|
const inputName = inputSrc?.name || source.input_source_id || '—';
|
||||||
const tplName = source.processing_template_id
|
const tplName = source.processing_template_id
|
||||||
? (_cachedCSPTemplates.find(t => t.id === source.processing_template_id)?.name || source.processing_template_id)
|
? (_cachedCSPTemplates.find(t => t.id === source.processing_template_id)?.name || source.processing_template_id)
|
||||||
: '—';
|
: '—';
|
||||||
propsHtml = `
|
return `
|
||||||
<span class="stream-card-prop" title="${t('color_strip.processed.input')}">${ICON_LINK_SOURCE} ${escapeHtml(inputName)}</span>
|
<span class="stream-card-prop" title="${t('color_strip.processed.input')}">${ICON_LINK_SOURCE} ${escapeHtml(inputName)}</span>
|
||||||
<span class="stream-card-prop" title="${t('color_strip.processed.template')}">${ICON_SPARKLES} ${escapeHtml(tplName)}</span>
|
<span class="stream-card-prop" title="${t('color_strip.processed.template')}">${ICON_SPARKLES} ${escapeHtml(tplName)}</span>
|
||||||
`;
|
`;
|
||||||
} else if (isPictureAdvanced) {
|
},
|
||||||
|
picture_advanced: (source, { pictureSourceMap }) => {
|
||||||
const cal = source.calibration ?? {} as Partial<import('../types.ts').Calibration>;
|
const cal = source.calibration ?? {} as Partial<import('../types.ts').Calibration>;
|
||||||
const lines = cal.lines || [];
|
const lines = cal.lines || [];
|
||||||
const totalLeds = lines.reduce((s: any, l: any) => s + (l.led_count || 0), 0);
|
const totalLeds = lines.reduce((s: any, l: any) => s + (l.led_count || 0), 0);
|
||||||
const ledCount = (source.led_count > 0) ? source.led_count : totalLeds;
|
const ledCount = (source.led_count > 0) ? source.led_count : totalLeds;
|
||||||
// Collect unique picture source names
|
|
||||||
const psIds: any[] = [...new Set(lines.map((l: any) => l.picture_source_id).filter(Boolean))];
|
const psIds: any[] = [...new Set(lines.map((l: any) => l.picture_source_id).filter(Boolean))];
|
||||||
const psNames = psIds.map((id: any) => {
|
const psNames = psIds.map((id: any) => {
|
||||||
const ps = pictureSourceMap && pictureSourceMap[id];
|
const ps = pictureSourceMap && pictureSourceMap[id];
|
||||||
return ps ? ps.name : id;
|
return ps ? ps.name : id;
|
||||||
});
|
});
|
||||||
propsHtml = `
|
return `
|
||||||
<span class="stream-card-prop" title="${t('calibration.advanced.lines_title')}">${ICON_MAP_PIN} ${lines.length} ${t('calibration.advanced.lines_title').toLowerCase()}</span>
|
<span class="stream-card-prop" title="${t('calibration.advanced.lines_title')}">${ICON_MAP_PIN} ${lines.length} ${t('calibration.advanced.lines_title').toLowerCase()}</span>
|
||||||
${ledCount ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">${ICON_LED} ${ledCount}</span>` : ''}
|
${ledCount ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">${ICON_LED} ${ledCount}</span>` : ''}
|
||||||
${psNames.length ? `<span class="stream-card-prop stream-card-prop-full" title="${t('color_strip.picture_source')}">${ICON_LINK_SOURCE} ${escapeHtml(psNames.join(', '))}</span>` : ''}
|
${psNames.length ? `<span class="stream-card-prop stream-card-prop-full" title="${t('color_strip.picture_source')}">${ICON_LINK_SOURCE} ${escapeHtml(psNames.join(', '))}</span>` : ''}
|
||||||
`;
|
`;
|
||||||
} else {
|
},
|
||||||
const ps = pictureSourceMap && pictureSourceMap[source.picture_source_id!];
|
};
|
||||||
const srcName = ps ? ps.name : source.picture_source_id || '—';
|
|
||||||
const cal = source.calibration ?? {} as Partial<import('../types.ts').Calibration>;
|
/** Fallback renderer for picture-type sources (plain picture). */
|
||||||
const calLeds = (cal.leds_top || 0) + (cal.leds_right || 0) + (cal.leds_bottom || 0) + (cal.leds_left || 0);
|
function _renderPictureCardProps(source: ColorStripSource, pictureSourceMap: Record<string, any>): string {
|
||||||
const ledCount = (source.led_count > 0) ? source.led_count : calLeds;
|
const ps = pictureSourceMap && pictureSourceMap[source.picture_source_id!];
|
||||||
let psSubTab = 'raw', psSection = 'raw-streams';
|
const srcName = ps ? ps.name : source.picture_source_id || '—';
|
||||||
if (ps) {
|
const cal = source.calibration ?? {} as Partial<import('../types.ts').Calibration>;
|
||||||
if (ps.stream_type === 'static_image') { psSubTab = 'static_image'; psSection = 'static-streams'; }
|
const calLeds = (cal.leds_top || 0) + (cal.leds_right || 0) + (cal.leds_bottom || 0) + (cal.leds_left || 0);
|
||||||
else if (ps.stream_type === 'processed') { psSubTab = 'processed'; psSection = 'proc-streams'; }
|
const ledCount = (source.led_count > 0) ? source.led_count : calLeds;
|
||||||
}
|
let psSubTab = 'raw', psSection = 'raw-streams';
|
||||||
propsHtml = `
|
if (ps) {
|
||||||
<span class="stream-card-prop stream-card-prop-full${ps ? ' stream-card-link' : ''}" title="${t('color_strip.picture_source')}"${ps ? ` onclick="event.stopPropagation(); navigateToCard('streams','${psSubTab}','${psSection}','data-stream-id','${source.picture_source_id}')"` : ''}>${ICON_LINK_SOURCE} ${escapeHtml(srcName)}</span>
|
if (ps.stream_type === 'static_image') { psSubTab = 'static_image'; psSection = 'static-streams'; }
|
||||||
${ledCount ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">${ICON_LED} ${ledCount}</span>` : ''}
|
else if (ps.stream_type === 'processed') { psSubTab = 'processed'; psSection = 'proc-streams'; }
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
return `
|
||||||
|
<span class="stream-card-prop stream-card-prop-full${ps ? ' stream-card-link' : ''}" title="${t('color_strip.picture_source')}"${ps ? ` onclick="event.stopPropagation(); navigateToCard('streams','${psSubTab}','${psSection}','data-stream-id','${source.picture_source_id}')"` : ''}>${ICON_LINK_SOURCE} ${escapeHtml(srcName)}</span>
|
||||||
|
${ledCount ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">${ICON_LED} ${ledCount}</span>` : ''}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createColorStripCard(source: ColorStripSource, pictureSourceMap: Record<string, any>, audioSourceMap: Record<string, any>) {
|
||||||
|
// Clock crosslink badge (replaces speed badge when clock is assigned)
|
||||||
|
const clockObj = source.clock_id ? _cachedSyncClocks.find(c => c.id === source.clock_id) : null;
|
||||||
|
const clockBadge = clockObj
|
||||||
|
? `<span class="stream-card-prop stream-card-link" title="${t('color_strip.clock')}" onclick="event.stopPropagation(); navigateToCard('streams','sync','sync-clocks','data-id','${source.clock_id}')">${ICON_CLOCK} ${escapeHtml(clockObj.name)}</span>`
|
||||||
|
: source.clock_id ? `<span class="stream-card-prop">${ICON_CLOCK} ${source.clock_id}</span>` : '';
|
||||||
|
|
||||||
|
const isAnimatable = source.source_type === 'static' || source.source_type === 'gradient';
|
||||||
|
const anim = isAnimatable && source.animation && source.animation.enabled ? source.animation : null;
|
||||||
|
const animBadge = anim
|
||||||
|
? `<span class="stream-card-prop" title="${t('color_strip.animation')}">${ICON_SPARKLES} ${t('color_strip.animation.type.' + anim.type) || anim.type}</span>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const renderer = CSS_CARD_RENDERERS[source.source_type];
|
||||||
|
const propsHtml = renderer
|
||||||
|
? renderer(source, { clockBadge, animBadge, audioSourceMap, pictureSourceMap })
|
||||||
|
: _renderPictureCardProps(source, pictureSourceMap);
|
||||||
|
|
||||||
const icon = getColorStripIcon(source.source_type);
|
const icon = getColorStripIcon(source.source_type);
|
||||||
const isDaylight = source.source_type === 'daylight';
|
const isNotification = source.source_type === 'notification';
|
||||||
const isCandlelight = source.source_type === 'candlelight';
|
const isPictureKind = !NON_PICTURE_TYPES.has(source.source_type);
|
||||||
const isProcessed = source.source_type === 'processed';
|
|
||||||
const isPictureKind = (!isStatic && !isGradient && !isColorCycle && !isEffect && !isComposite && !isMapped && !isAudio && !isApiInput && !isNotification && !isDaylight && !isCandlelight && !isProcessed);
|
|
||||||
const calibrationBtn = isPictureKind
|
const calibrationBtn = isPictureKind
|
||||||
? `<button class="btn btn-icon btn-secondary" onclick="${isPictureAdvanced ? `showAdvancedCalibration('${source.id}')` : `showCSSCalibration('${source.id}')`}" title="${t('calibration.title')}">${ICON_CALIBRATION}</button>`
|
? `<button class="btn btn-icon btn-secondary" onclick="${source.source_type === 'picture_advanced' ? `showAdvancedCalibration('${source.id}')` : `showCSSCalibration('${source.id}')`}" title="${t('calibration.title')}">${ICON_CALIBRATION}</button>`
|
||||||
: '';
|
: '';
|
||||||
const overlayBtn = isPictureKind
|
const overlayBtn = isPictureKind
|
||||||
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); toggleCSSOverlay('${source.id}')" title="${t('overlay.toggle')}">${ICON_OVERLAY}</button>`
|
? `<button class="btn btn-icon btn-secondary" onclick="event.stopPropagation(); toggleCSSOverlay('${source.id}')" title="${t('overlay.toggle')}">${ICON_OVERLAY}</button>`
|
||||||
|
|||||||
@@ -276,6 +276,54 @@ const _streamSectionMap = {
|
|||||||
sync: [csSyncClocks],
|
sync: [csSyncClocks],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type StreamCardRenderer = (stream: any) => string;
|
||||||
|
|
||||||
|
const PICTURE_SOURCE_CARD_RENDERERS: Record<string, StreamCardRenderer> = {
|
||||||
|
raw: (stream) => {
|
||||||
|
let capTmplName = '';
|
||||||
|
if (stream.capture_template_id) {
|
||||||
|
const capTmpl = _cachedCaptureTemplates.find(t => t.id === stream.capture_template_id);
|
||||||
|
if (capTmpl) capTmplName = escapeHtml(capTmpl.name);
|
||||||
|
}
|
||||||
|
return `<div class="stream-card-props">
|
||||||
|
<span class="stream-card-prop" title="${t('streams.display')}">${ICON_MONITOR} ${stream.display_index ?? 0}</span>
|
||||||
|
<span class="stream-card-prop" title="${t('streams.target_fps')}">${ICON_FPS} ${stream.target_fps ?? 30}</span>
|
||||||
|
${capTmplName ? `<span class="stream-card-prop stream-card-link" title="${t('streams.capture_template')}" onclick="event.stopPropagation(); navigateToCard('streams','raw_templates','raw-templates','data-template-id','${stream.capture_template_id}')">${ICON_TEMPLATE} ${capTmplName}</span>` : ''}
|
||||||
|
</div>`;
|
||||||
|
},
|
||||||
|
processed: (stream) => {
|
||||||
|
const sourceStream = _cachedStreams.find(s => s.id === stream.source_stream_id);
|
||||||
|
const sourceName = sourceStream ? escapeHtml(sourceStream.name) : (stream.source_stream_id || '-');
|
||||||
|
const sourceSubTab = sourceStream ? (sourceStream.stream_type === 'static_image' ? 'static_image' : 'raw') : 'raw';
|
||||||
|
const sourceSection = sourceStream ? (sourceStream.stream_type === 'static_image' ? 'static-streams' : 'raw-streams') : 'raw-streams';
|
||||||
|
let ppTmplName = '';
|
||||||
|
if (stream.postprocessing_template_id) {
|
||||||
|
const ppTmpl = _cachedPPTemplates.find(p => p.id === stream.postprocessing_template_id);
|
||||||
|
if (ppTmpl) ppTmplName = escapeHtml(ppTmpl.name);
|
||||||
|
}
|
||||||
|
return `<div class="stream-card-props">
|
||||||
|
<span class="stream-card-prop stream-card-link" title="${t('streams.source')}" onclick="event.stopPropagation(); navigateToCard('streams','${sourceSubTab}','${sourceSection}','data-stream-id','${stream.source_stream_id}')">${ICON_LINK_SOURCE} ${sourceName}</span>
|
||||||
|
${ppTmplName ? `<span class="stream-card-prop stream-card-link" title="${t('streams.pp_template')}" onclick="event.stopPropagation(); navigateToCard('streams','proc_templates','proc-templates','data-pp-template-id','${stream.postprocessing_template_id}')">${ICON_TEMPLATE} ${ppTmplName}</span>` : ''}
|
||||||
|
</div>`;
|
||||||
|
},
|
||||||
|
static_image: (stream) => {
|
||||||
|
const src = stream.image_source || '';
|
||||||
|
return `<div class="stream-card-props">
|
||||||
|
<span class="stream-card-prop stream-card-prop-full" title="${escapeHtml(src)}">${ICON_WEB} ${escapeHtml(src)}</span>
|
||||||
|
</div>`;
|
||||||
|
},
|
||||||
|
video: (stream) => {
|
||||||
|
const url = stream.url || '';
|
||||||
|
const shortUrl = url.length > 40 ? url.slice(0, 37) + '...' : url;
|
||||||
|
return `<div class="stream-card-props">
|
||||||
|
<span class="stream-card-prop stream-card-prop-full" title="${escapeHtml(url)}">${ICON_WEB} ${escapeHtml(shortUrl)}</span>
|
||||||
|
<span class="stream-card-prop" title="${t('streams.target_fps')}">${ICON_FPS} ${stream.target_fps ?? 30}</span>
|
||||||
|
${stream.loop !== false ? `<span class="stream-card-prop">↻</span>` : ''}
|
||||||
|
${stream.playback_speed && stream.playback_speed !== 1.0 ? `<span class="stream-card-prop">${stream.playback_speed}×</span>` : ''}
|
||||||
|
</div>`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
function renderPictureSourcesList(streams: any) {
|
function renderPictureSourcesList(streams: any) {
|
||||||
const container = document.getElementById('streams-list')!;
|
const container = document.getElementById('streams-list')!;
|
||||||
const activeTab = localStorage.getItem('activeStreamTab') || 'raw';
|
const activeTab = localStorage.getItem('activeStreamTab') || 'raw';
|
||||||
@@ -283,47 +331,8 @@ function renderPictureSourcesList(streams: any) {
|
|||||||
const renderStreamCard = (stream: any) => {
|
const renderStreamCard = (stream: any) => {
|
||||||
const typeIcon = getPictureSourceIcon(stream.stream_type);
|
const typeIcon = getPictureSourceIcon(stream.stream_type);
|
||||||
|
|
||||||
let detailsHtml = '';
|
const renderer = PICTURE_SOURCE_CARD_RENDERERS[stream.stream_type];
|
||||||
if (stream.stream_type === 'raw') {
|
const detailsHtml = renderer ? renderer(stream) : '';
|
||||||
let capTmplName = '';
|
|
||||||
if (stream.capture_template_id) {
|
|
||||||
const capTmpl = _cachedCaptureTemplates.find(t => t.id === stream.capture_template_id);
|
|
||||||
if (capTmpl) capTmplName = escapeHtml(capTmpl.name);
|
|
||||||
}
|
|
||||||
detailsHtml = `<div class="stream-card-props">
|
|
||||||
<span class="stream-card-prop" title="${t('streams.display')}">${ICON_MONITOR} ${stream.display_index ?? 0}</span>
|
|
||||||
<span class="stream-card-prop" title="${t('streams.target_fps')}">${ICON_FPS} ${stream.target_fps ?? 30}</span>
|
|
||||||
${capTmplName ? `<span class="stream-card-prop stream-card-link" title="${t('streams.capture_template')}" onclick="event.stopPropagation(); navigateToCard('streams','raw_templates','raw-templates','data-template-id','${stream.capture_template_id}')">${ICON_TEMPLATE} ${capTmplName}</span>` : ''}
|
|
||||||
</div>`;
|
|
||||||
} else if (stream.stream_type === 'processed') {
|
|
||||||
const sourceStream = _cachedStreams.find(s => s.id === stream.source_stream_id);
|
|
||||||
const sourceName = sourceStream ? escapeHtml(sourceStream.name) : (stream.source_stream_id || '-');
|
|
||||||
const sourceSubTab = sourceStream ? (sourceStream.stream_type === 'static_image' ? 'static_image' : 'raw') : 'raw';
|
|
||||||
const sourceSection = sourceStream ? (sourceStream.stream_type === 'static_image' ? 'static-streams' : 'raw-streams') : 'raw-streams';
|
|
||||||
let ppTmplName = '';
|
|
||||||
if (stream.postprocessing_template_id) {
|
|
||||||
const ppTmpl = _cachedPPTemplates.find(p => p.id === stream.postprocessing_template_id);
|
|
||||||
if (ppTmpl) ppTmplName = escapeHtml(ppTmpl.name);
|
|
||||||
}
|
|
||||||
detailsHtml = `<div class="stream-card-props">
|
|
||||||
<span class="stream-card-prop stream-card-link" title="${t('streams.source')}" onclick="event.stopPropagation(); navigateToCard('streams','${sourceSubTab}','${sourceSection}','data-stream-id','${stream.source_stream_id}')">${ICON_LINK_SOURCE} ${sourceName}</span>
|
|
||||||
${ppTmplName ? `<span class="stream-card-prop stream-card-link" title="${t('streams.pp_template')}" onclick="event.stopPropagation(); navigateToCard('streams','proc_templates','proc-templates','data-pp-template-id','${stream.postprocessing_template_id}')">${ICON_TEMPLATE} ${ppTmplName}</span>` : ''}
|
|
||||||
</div>`;
|
|
||||||
} else if (stream.stream_type === 'static_image') {
|
|
||||||
const src = stream.image_source || '';
|
|
||||||
detailsHtml = `<div class="stream-card-props">
|
|
||||||
<span class="stream-card-prop stream-card-prop-full" title="${escapeHtml(src)}">${ICON_WEB} ${escapeHtml(src)}</span>
|
|
||||||
</div>`;
|
|
||||||
} else if (stream.stream_type === 'video') {
|
|
||||||
const url = stream.url || '';
|
|
||||||
const shortUrl = url.length > 40 ? url.slice(0, 37) + '...' : url;
|
|
||||||
detailsHtml = `<div class="stream-card-props">
|
|
||||||
<span class="stream-card-prop stream-card-prop-full" title="${escapeHtml(url)}">${ICON_WEB} ${escapeHtml(shortUrl)}</span>
|
|
||||||
<span class="stream-card-prop" title="${t('streams.target_fps')}">${ICON_FPS} ${stream.target_fps ?? 30}</span>
|
|
||||||
${stream.loop !== false ? `<span class="stream-card-prop">↻</span>` : ''}
|
|
||||||
${stream.playback_speed && stream.playback_speed !== 1.0 ? `<span class="stream-card-prop">${stream.playback_speed}×</span>` : ''}
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return wrapCard({
|
return wrapCard({
|
||||||
type: 'template-card',
|
type: 'template-card',
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import List, Optional
|
from typing import Dict, List, Optional, Type
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -16,25 +16,12 @@ class Condition:
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, data: dict) -> "Condition":
|
def from_dict(cls, data: dict) -> "Condition":
|
||||||
"""Factory: dispatch to the correct subclass."""
|
"""Factory: dispatch to the correct subclass via registry."""
|
||||||
ct = data.get("condition_type", "")
|
ct = data.get("condition_type", "")
|
||||||
if ct == "always":
|
subcls = _CONDITION_MAP.get(ct)
|
||||||
return AlwaysCondition.from_dict(data)
|
if subcls is None:
|
||||||
if ct == "application":
|
raise ValueError(f"Unknown condition type: {ct}")
|
||||||
return ApplicationCondition.from_dict(data)
|
return subcls.from_dict(data)
|
||||||
if ct == "time_of_day":
|
|
||||||
return TimeOfDayCondition.from_dict(data)
|
|
||||||
if ct == "system_idle":
|
|
||||||
return SystemIdleCondition.from_dict(data)
|
|
||||||
if ct == "display_state":
|
|
||||||
return DisplayStateCondition.from_dict(data)
|
|
||||||
if ct == "mqtt":
|
|
||||||
return MQTTCondition.from_dict(data)
|
|
||||||
if ct == "webhook":
|
|
||||||
return WebhookCondition.from_dict(data)
|
|
||||||
if ct == "startup":
|
|
||||||
return StartupCondition.from_dict(data)
|
|
||||||
raise ValueError(f"Unknown condition type: {ct}")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -190,6 +177,18 @@ class StartupCondition(Condition):
|
|||||||
return cls()
|
return cls()
|
||||||
|
|
||||||
|
|
||||||
|
_CONDITION_MAP: Dict[str, Type[Condition]] = {
|
||||||
|
"always": AlwaysCondition,
|
||||||
|
"application": ApplicationCondition,
|
||||||
|
"time_of_day": TimeOfDayCondition,
|
||||||
|
"system_idle": SystemIdleCondition,
|
||||||
|
"display_state": DisplayStateCondition,
|
||||||
|
"mqtt": MQTTCondition,
|
||||||
|
"webhook": WebhookCondition,
|
||||||
|
"startup": StartupCondition,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Automation:
|
class Automation:
|
||||||
"""Automation that activates a scene preset based on conditions."""
|
"""Automation that activates a scene preset based on conditions."""
|
||||||
|
|||||||
@@ -104,216 +104,53 @@ class ColorStripSource:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def from_dict(data: dict) -> "ColorStripSource":
|
def from_dict(data: dict) -> "ColorStripSource":
|
||||||
"""Factory: dispatch to the correct subclass based on source_type."""
|
"""Factory: dispatch to the correct subclass based on source_type."""
|
||||||
source_type: str = data.get("source_type", "picture") or "picture"
|
source_type = data.get("source_type", "picture") or "picture"
|
||||||
sid: str = data["id"]
|
subcls = _SOURCE_TYPE_MAP.get(source_type, PictureColorStripSource)
|
||||||
name: str = data["name"]
|
return subcls.from_dict(data)
|
||||||
description: str | None = data.get("description")
|
|
||||||
|
|
||||||
clock_id: str | None = data.get("clock_id")
|
|
||||||
tags: list = data.get("tags", [])
|
|
||||||
|
|
||||||
raw_created = data.get("created_at")
|
def _parse_css_common(data: dict) -> dict:
|
||||||
created_at: datetime = (
|
"""Extract fields common to all ColorStripSource types."""
|
||||||
datetime.fromisoformat(raw_created)
|
raw_created = data.get("created_at")
|
||||||
if isinstance(raw_created, str)
|
created_at = (
|
||||||
else raw_created if isinstance(raw_created, datetime)
|
datetime.fromisoformat(raw_created)
|
||||||
else datetime.now(timezone.utc)
|
if isinstance(raw_created, str)
|
||||||
)
|
else raw_created if isinstance(raw_created, datetime)
|
||||||
raw_updated = data.get("updated_at")
|
else datetime.now(timezone.utc)
|
||||||
updated_at: datetime = (
|
)
|
||||||
datetime.fromisoformat(raw_updated)
|
raw_updated = data.get("updated_at")
|
||||||
if isinstance(raw_updated, str)
|
updated_at = (
|
||||||
else raw_updated if isinstance(raw_updated, datetime)
|
datetime.fromisoformat(raw_updated)
|
||||||
else datetime.now(timezone.utc)
|
if isinstance(raw_updated, str)
|
||||||
)
|
else raw_updated if isinstance(raw_updated, datetime)
|
||||||
|
else datetime.now(timezone.utc)
|
||||||
|
)
|
||||||
|
return dict(
|
||||||
|
id=data["id"],
|
||||||
|
name=data["name"],
|
||||||
|
description=data.get("description"),
|
||||||
|
clock_id=data.get("clock_id"),
|
||||||
|
tags=data.get("tags", []),
|
||||||
|
created_at=created_at,
|
||||||
|
updated_at=updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
calibration_data = data.get("calibration")
|
|
||||||
calibration = (
|
|
||||||
calibration_from_dict(calibration_data)
|
|
||||||
if calibration_data
|
|
||||||
else CalibrationConfig(layout="clockwise", start_position="bottom_left")
|
|
||||||
)
|
|
||||||
|
|
||||||
if source_type == "static":
|
def _parse_picture_fields(data: dict) -> dict:
|
||||||
raw_color = data.get("color")
|
"""Extract fields shared by picture-type color strip sources."""
|
||||||
color = (
|
calibration_data = data.get("calibration")
|
||||||
raw_color if isinstance(raw_color, list) and len(raw_color) == 3
|
calibration = (
|
||||||
else [255, 255, 255]
|
calibration_from_dict(calibration_data)
|
||||||
)
|
if calibration_data
|
||||||
return StaticColorStripSource(
|
else CalibrationConfig(layout="clockwise", start_position="bottom_left")
|
||||||
id=sid, name=name, source_type="static",
|
)
|
||||||
created_at=created_at, updated_at=updated_at, description=description,
|
return dict(
|
||||||
clock_id=clock_id, tags=tags, color=color,
|
fps=data.get("fps") or 30,
|
||||||
animation=data.get("animation"),
|
smoothing=data["smoothing"] if data.get("smoothing") is not None else 0.3,
|
||||||
)
|
interpolation_mode=data.get("interpolation_mode") or "average",
|
||||||
|
calibration=calibration,
|
||||||
if source_type == "gradient":
|
led_count=data.get("led_count") or 0,
|
||||||
raw_stops = data.get("stops")
|
)
|
||||||
stops = raw_stops if isinstance(raw_stops, list) else []
|
|
||||||
return GradientColorStripSource(
|
|
||||||
id=sid, name=name, source_type="gradient",
|
|
||||||
created_at=created_at, updated_at=updated_at, description=description,
|
|
||||||
clock_id=clock_id, tags=tags, stops=stops,
|
|
||||||
animation=data.get("animation"),
|
|
||||||
easing=data.get("easing") or "linear",
|
|
||||||
gradient_id=data.get("gradient_id"),
|
|
||||||
)
|
|
||||||
|
|
||||||
if source_type == "color_cycle":
|
|
||||||
raw_colors = data.get("colors")
|
|
||||||
colors = raw_colors if isinstance(raw_colors, list) else []
|
|
||||||
return ColorCycleColorStripSource(
|
|
||||||
id=sid, name=name, source_type="color_cycle",
|
|
||||||
created_at=created_at, updated_at=updated_at, description=description,
|
|
||||||
clock_id=clock_id, tags=tags, colors=colors,
|
|
||||||
)
|
|
||||||
|
|
||||||
if source_type == "composite":
|
|
||||||
return CompositeColorStripSource(
|
|
||||||
id=sid, name=name, source_type="composite",
|
|
||||||
created_at=created_at, updated_at=updated_at, description=description,
|
|
||||||
clock_id=clock_id, tags=tags, layers=data.get("layers") or [],
|
|
||||||
led_count=data.get("led_count") or 0,
|
|
||||||
)
|
|
||||||
|
|
||||||
if source_type == "mapped":
|
|
||||||
return MappedColorStripSource(
|
|
||||||
id=sid, name=name, source_type="mapped",
|
|
||||||
created_at=created_at, updated_at=updated_at, description=description,
|
|
||||||
clock_id=clock_id, tags=tags, zones=data.get("zones") or [],
|
|
||||||
led_count=data.get("led_count") or 0,
|
|
||||||
)
|
|
||||||
|
|
||||||
if source_type == "audio":
|
|
||||||
raw_color = data.get("color")
|
|
||||||
color = raw_color if isinstance(raw_color, list) and len(raw_color) == 3 else [0, 255, 0]
|
|
||||||
raw_peak = data.get("color_peak")
|
|
||||||
color_peak = raw_peak if isinstance(raw_peak, list) and len(raw_peak) == 3 else [255, 0, 0]
|
|
||||||
return AudioColorStripSource(
|
|
||||||
id=sid, name=name, source_type="audio",
|
|
||||||
created_at=created_at, updated_at=updated_at, description=description,
|
|
||||||
clock_id=clock_id, tags=tags, visualization_mode=data.get("visualization_mode") or "spectrum",
|
|
||||||
audio_source_id=data.get("audio_source_id") or "",
|
|
||||||
sensitivity=float(data.get("sensitivity") or 1.0),
|
|
||||||
smoothing=float(data.get("smoothing") or 0.3),
|
|
||||||
palette=data.get("palette") or "rainbow",
|
|
||||||
gradient_id=data.get("gradient_id"),
|
|
||||||
color=color,
|
|
||||||
color_peak=color_peak,
|
|
||||||
led_count=data.get("led_count") or 0,
|
|
||||||
mirror=bool(data.get("mirror", False)),
|
|
||||||
)
|
|
||||||
|
|
||||||
if source_type == "effect":
|
|
||||||
raw_color = data.get("color")
|
|
||||||
color = (
|
|
||||||
raw_color if isinstance(raw_color, list) and len(raw_color) == 3
|
|
||||||
else [255, 80, 0]
|
|
||||||
)
|
|
||||||
return EffectColorStripSource(
|
|
||||||
id=sid, name=name, source_type="effect",
|
|
||||||
created_at=created_at, updated_at=updated_at, description=description,
|
|
||||||
clock_id=clock_id, tags=tags, effect_type=data.get("effect_type") or "fire",
|
|
||||||
palette=data.get("palette") or "fire",
|
|
||||||
gradient_id=data.get("gradient_id"),
|
|
||||||
color=color,
|
|
||||||
intensity=float(data.get("intensity") or 1.0),
|
|
||||||
scale=float(data.get("scale") or 1.0),
|
|
||||||
mirror=bool(data.get("mirror", False)),
|
|
||||||
custom_palette=data.get("custom_palette"),
|
|
||||||
)
|
|
||||||
|
|
||||||
if source_type == "api_input":
|
|
||||||
raw_fallback = data.get("fallback_color")
|
|
||||||
fallback_color = (
|
|
||||||
raw_fallback if isinstance(raw_fallback, list) and len(raw_fallback) == 3
|
|
||||||
else [0, 0, 0]
|
|
||||||
)
|
|
||||||
return ApiInputColorStripSource(
|
|
||||||
id=sid, name=name, source_type="api_input",
|
|
||||||
created_at=created_at, updated_at=updated_at, description=description,
|
|
||||||
clock_id=clock_id, tags=tags,
|
|
||||||
fallback_color=fallback_color,
|
|
||||||
timeout=float(data.get("timeout") or 5.0),
|
|
||||||
)
|
|
||||||
|
|
||||||
elif source_type == "notification":
|
|
||||||
raw_app_colors = data.get("app_colors")
|
|
||||||
app_colors = raw_app_colors if isinstance(raw_app_colors, dict) else {}
|
|
||||||
raw_app_filter_list = data.get("app_filter_list")
|
|
||||||
app_filter_list = raw_app_filter_list if isinstance(raw_app_filter_list, list) else []
|
|
||||||
return NotificationColorStripSource(
|
|
||||||
id=sid, name=name, source_type="notification",
|
|
||||||
created_at=created_at, updated_at=updated_at, description=description,
|
|
||||||
clock_id=clock_id, tags=tags,
|
|
||||||
notification_effect=data.get("notification_effect") or "flash",
|
|
||||||
duration_ms=int(data.get("duration_ms") or 1500),
|
|
||||||
default_color=data.get("default_color") or "#FFFFFF",
|
|
||||||
app_colors=app_colors,
|
|
||||||
app_filter_mode=data.get("app_filter_mode") or "off",
|
|
||||||
app_filter_list=app_filter_list,
|
|
||||||
os_listener=bool(data.get("os_listener", False)),
|
|
||||||
)
|
|
||||||
|
|
||||||
if source_type == "daylight":
|
|
||||||
return DaylightColorStripSource(
|
|
||||||
id=sid, name=name, source_type="daylight",
|
|
||||||
created_at=created_at, updated_at=updated_at, description=description,
|
|
||||||
clock_id=clock_id, tags=tags,
|
|
||||||
speed=float(data.get("speed") or 1.0),
|
|
||||||
use_real_time=bool(data.get("use_real_time", False)),
|
|
||||||
latitude=float(data.get("latitude") or 50.0),
|
|
||||||
)
|
|
||||||
|
|
||||||
if source_type == "processed":
|
|
||||||
return ProcessedColorStripSource(
|
|
||||||
id=sid, name=name, source_type="processed",
|
|
||||||
created_at=created_at, updated_at=updated_at, description=description,
|
|
||||||
clock_id=clock_id, tags=tags,
|
|
||||||
input_source_id=data.get("input_source_id") or "",
|
|
||||||
processing_template_id=data.get("processing_template_id") or "",
|
|
||||||
)
|
|
||||||
|
|
||||||
if source_type == "candlelight":
|
|
||||||
raw_color = data.get("color")
|
|
||||||
color = (
|
|
||||||
raw_color if isinstance(raw_color, list) and len(raw_color) == 3
|
|
||||||
else [255, 147, 41]
|
|
||||||
)
|
|
||||||
return CandlelightColorStripSource(
|
|
||||||
id=sid, name=name, source_type="candlelight",
|
|
||||||
created_at=created_at, updated_at=updated_at, description=description,
|
|
||||||
clock_id=clock_id, tags=tags,
|
|
||||||
color=color,
|
|
||||||
intensity=float(data.get("intensity") or 1.0),
|
|
||||||
num_candles=int(data.get("num_candles") or 3),
|
|
||||||
speed=float(data.get("speed") or 1.0),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Shared picture-type field extraction
|
|
||||||
_picture_kwargs = dict(
|
|
||||||
tags=tags,
|
|
||||||
fps=data.get("fps") or 30,
|
|
||||||
smoothing=data["smoothing"] if data.get("smoothing") is not None else 0.3,
|
|
||||||
interpolation_mode=data.get("interpolation_mode") or "average",
|
|
||||||
calibration=calibration,
|
|
||||||
led_count=data.get("led_count") or 0,
|
|
||||||
)
|
|
||||||
|
|
||||||
if source_type == "picture_advanced":
|
|
||||||
return AdvancedPictureColorStripSource(
|
|
||||||
id=sid, name=name, source_type="picture_advanced",
|
|
||||||
created_at=created_at, updated_at=updated_at, description=description,
|
|
||||||
clock_id=clock_id, **_picture_kwargs,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Default: "picture" type (simple 4-edge calibration)
|
|
||||||
return PictureColorStripSource(
|
|
||||||
id=sid, name=name, source_type=source_type,
|
|
||||||
created_at=created_at, updated_at=updated_at, description=description,
|
|
||||||
clock_id=clock_id, picture_source_id=data.get("picture_source_id") or "",
|
|
||||||
**_picture_kwargs,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _picture_base_to_dict(source, d: dict) -> dict:
|
def _picture_base_to_dict(source, d: dict) -> dict:
|
||||||
@@ -367,6 +204,17 @@ class PictureColorStripSource(ColorStripSource):
|
|||||||
d["picture_source_id"] = self.picture_source_id
|
d["picture_source_id"] = self.picture_source_id
|
||||||
return _picture_base_to_dict(self, d)
|
return _picture_base_to_dict(self, d)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "PictureColorStripSource":
|
||||||
|
common = _parse_css_common(data)
|
||||||
|
pic = _parse_picture_fields(data)
|
||||||
|
return cls(
|
||||||
|
**common,
|
||||||
|
source_type=data.get("source_type", "picture") or "picture",
|
||||||
|
picture_source_id=data.get("picture_source_id") or "",
|
||||||
|
**pic,
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
|
def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
|
||||||
created_at: datetime, updated_at: datetime,
|
created_at: datetime, updated_at: datetime,
|
||||||
@@ -419,6 +267,12 @@ class AdvancedPictureColorStripSource(ColorStripSource):
|
|||||||
d = super().to_dict()
|
d = super().to_dict()
|
||||||
return _picture_base_to_dict(self, d)
|
return _picture_base_to_dict(self, d)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "AdvancedPictureColorStripSource":
|
||||||
|
common = _parse_css_common(data)
|
||||||
|
pic = _parse_picture_fields(data)
|
||||||
|
return cls(**common, source_type="picture_advanced", **pic)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
|
def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
|
||||||
created_at: datetime, updated_at: datetime,
|
created_at: datetime, updated_at: datetime,
|
||||||
@@ -459,6 +313,15 @@ class StaticColorStripSource(ColorStripSource):
|
|||||||
d["animation"] = self.animation
|
d["animation"] = self.animation
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "StaticColorStripSource":
|
||||||
|
common = _parse_css_common(data)
|
||||||
|
color = _validate_rgb(data.get("color"), [255, 255, 255])
|
||||||
|
return cls(
|
||||||
|
**common, source_type="static",
|
||||||
|
color=color, animation=data.get("animation"),
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
|
def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
|
||||||
created_at: datetime, updated_at: datetime,
|
created_at: datetime, updated_at: datetime,
|
||||||
@@ -509,6 +372,18 @@ class GradientColorStripSource(ColorStripSource):
|
|||||||
d["gradient_id"] = self.gradient_id
|
d["gradient_id"] = self.gradient_id
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "GradientColorStripSource":
|
||||||
|
common = _parse_css_common(data)
|
||||||
|
raw_stops = data.get("stops")
|
||||||
|
return cls(
|
||||||
|
**common, source_type="gradient",
|
||||||
|
stops=raw_stops if isinstance(raw_stops, list) else [],
|
||||||
|
animation=data.get("animation"),
|
||||||
|
easing=data.get("easing") or "linear",
|
||||||
|
gradient_id=data.get("gradient_id"),
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
|
def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
|
||||||
created_at: datetime, updated_at: datetime,
|
created_at: datetime, updated_at: datetime,
|
||||||
@@ -559,6 +434,15 @@ class ColorCycleColorStripSource(ColorStripSource):
|
|||||||
d["colors"] = [list(c) for c in self.colors]
|
d["colors"] = [list(c) for c in self.colors]
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "ColorCycleColorStripSource":
|
||||||
|
common = _parse_css_common(data)
|
||||||
|
raw_colors = data.get("colors")
|
||||||
|
return cls(
|
||||||
|
**common, source_type="color_cycle",
|
||||||
|
colors=raw_colors if isinstance(raw_colors, list) else [],
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
|
def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
|
||||||
created_at: datetime, updated_at: datetime,
|
created_at: datetime, updated_at: datetime,
|
||||||
@@ -611,6 +495,22 @@ class EffectColorStripSource(ColorStripSource):
|
|||||||
d["custom_palette"] = self.custom_palette
|
d["custom_palette"] = self.custom_palette
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "EffectColorStripSource":
|
||||||
|
common = _parse_css_common(data)
|
||||||
|
color = _validate_rgb(data.get("color"), [255, 80, 0])
|
||||||
|
return cls(
|
||||||
|
**common, source_type="effect",
|
||||||
|
effect_type=data.get("effect_type") or "fire",
|
||||||
|
palette=data.get("palette") or "fire",
|
||||||
|
gradient_id=data.get("gradient_id"),
|
||||||
|
color=color,
|
||||||
|
intensity=float(data.get("intensity") or 1.0),
|
||||||
|
scale=float(data.get("scale") or 1.0),
|
||||||
|
mirror=bool(data.get("mirror", False)),
|
||||||
|
custom_palette=data.get("custom_palette"),
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
|
def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
|
||||||
created_at: datetime, updated_at: datetime,
|
created_at: datetime, updated_at: datetime,
|
||||||
@@ -687,6 +587,24 @@ class AudioColorStripSource(ColorStripSource):
|
|||||||
d["mirror"] = self.mirror
|
d["mirror"] = self.mirror
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "AudioColorStripSource":
|
||||||
|
common = _parse_css_common(data)
|
||||||
|
color = _validate_rgb(data.get("color"), [0, 255, 0])
|
||||||
|
color_peak = _validate_rgb(data.get("color_peak"), [255, 0, 0])
|
||||||
|
return cls(
|
||||||
|
**common, source_type="audio",
|
||||||
|
visualization_mode=data.get("visualization_mode") or "spectrum",
|
||||||
|
audio_source_id=data.get("audio_source_id") or "",
|
||||||
|
sensitivity=float(data.get("sensitivity") or 1.0),
|
||||||
|
smoothing=float(data.get("smoothing") or 0.3),
|
||||||
|
palette=data.get("palette") or "rainbow",
|
||||||
|
gradient_id=data.get("gradient_id"),
|
||||||
|
color=color, color_peak=color_peak,
|
||||||
|
led_count=data.get("led_count") or 0,
|
||||||
|
mirror=bool(data.get("mirror", False)),
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
|
def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
|
||||||
created_at: datetime, updated_at: datetime,
|
created_at: datetime, updated_at: datetime,
|
||||||
@@ -756,6 +674,15 @@ class CompositeColorStripSource(ColorStripSource):
|
|||||||
d["led_count"] = self.led_count
|
d["led_count"] = self.led_count
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "CompositeColorStripSource":
|
||||||
|
common = _parse_css_common(data)
|
||||||
|
return cls(
|
||||||
|
**common, source_type="composite",
|
||||||
|
layers=data.get("layers") or [],
|
||||||
|
led_count=data.get("led_count") or 0,
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
|
def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
|
||||||
created_at: datetime, updated_at: datetime,
|
created_at: datetime, updated_at: datetime,
|
||||||
@@ -797,6 +724,15 @@ class MappedColorStripSource(ColorStripSource):
|
|||||||
d["led_count"] = self.led_count
|
d["led_count"] = self.led_count
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "MappedColorStripSource":
|
||||||
|
common = _parse_css_common(data)
|
||||||
|
return cls(
|
||||||
|
**common, source_type="mapped",
|
||||||
|
zones=data.get("zones") or [],
|
||||||
|
led_count=data.get("led_count") or 0,
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
|
def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
|
||||||
created_at: datetime, updated_at: datetime,
|
created_at: datetime, updated_at: datetime,
|
||||||
@@ -837,6 +773,16 @@ class ApiInputColorStripSource(ColorStripSource):
|
|||||||
d["timeout"] = self.timeout
|
d["timeout"] = self.timeout
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "ApiInputColorStripSource":
|
||||||
|
common = _parse_css_common(data)
|
||||||
|
fallback_color = _validate_rgb(data.get("fallback_color"), [0, 0, 0])
|
||||||
|
return cls(
|
||||||
|
**common, source_type="api_input",
|
||||||
|
fallback_color=fallback_color,
|
||||||
|
timeout=float(data.get("timeout") or 5.0),
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
|
def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
|
||||||
created_at: datetime, updated_at: datetime,
|
created_at: datetime, updated_at: datetime,
|
||||||
@@ -890,6 +836,22 @@ class NotificationColorStripSource(ColorStripSource):
|
|||||||
d["os_listener"] = self.os_listener
|
d["os_listener"] = self.os_listener
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "NotificationColorStripSource":
|
||||||
|
common = _parse_css_common(data)
|
||||||
|
raw_app_colors = data.get("app_colors")
|
||||||
|
raw_app_filter_list = data.get("app_filter_list")
|
||||||
|
return cls(
|
||||||
|
**common, source_type="notification",
|
||||||
|
notification_effect=data.get("notification_effect") or "flash",
|
||||||
|
duration_ms=int(data.get("duration_ms") or 1500),
|
||||||
|
default_color=data.get("default_color") or "#FFFFFF",
|
||||||
|
app_colors=raw_app_colors if isinstance(raw_app_colors, dict) else {},
|
||||||
|
app_filter_mode=data.get("app_filter_mode") or "off",
|
||||||
|
app_filter_list=raw_app_filter_list if isinstance(raw_app_filter_list, list) else [],
|
||||||
|
os_listener=bool(data.get("os_listener", False)),
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
|
def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
|
||||||
created_at: datetime, updated_at: datetime,
|
created_at: datetime, updated_at: datetime,
|
||||||
@@ -957,6 +919,16 @@ class DaylightColorStripSource(ColorStripSource):
|
|||||||
d["longitude"] = self.longitude
|
d["longitude"] = self.longitude
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "DaylightColorStripSource":
|
||||||
|
common = _parse_css_common(data)
|
||||||
|
return cls(
|
||||||
|
**common, source_type="daylight",
|
||||||
|
speed=float(data.get("speed") or 1.0),
|
||||||
|
use_real_time=bool(data.get("use_real_time", False)),
|
||||||
|
latitude=float(data.get("latitude") or 50.0),
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
|
def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
|
||||||
created_at: datetime, updated_at: datetime,
|
created_at: datetime, updated_at: datetime,
|
||||||
@@ -1010,6 +982,18 @@ class CandlelightColorStripSource(ColorStripSource):
|
|||||||
d["candle_type"] = self.candle_type
|
d["candle_type"] = self.candle_type
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "CandlelightColorStripSource":
|
||||||
|
common = _parse_css_common(data)
|
||||||
|
color = _validate_rgb(data.get("color"), [255, 147, 41])
|
||||||
|
return cls(
|
||||||
|
**common, source_type="candlelight",
|
||||||
|
color=color,
|
||||||
|
intensity=float(data.get("intensity") or 1.0),
|
||||||
|
num_candles=int(data.get("num_candles") or 3),
|
||||||
|
speed=float(data.get("speed") or 1.0),
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
|
def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
|
||||||
created_at: datetime, updated_at: datetime,
|
created_at: datetime, updated_at: datetime,
|
||||||
@@ -1066,6 +1050,15 @@ class ProcessedColorStripSource(ColorStripSource):
|
|||||||
d["processing_template_id"] = self.processing_template_id
|
d["processing_template_id"] = self.processing_template_id
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "ProcessedColorStripSource":
|
||||||
|
common = _parse_css_common(data)
|
||||||
|
return cls(
|
||||||
|
**common, source_type="processed",
|
||||||
|
input_source_id=data.get("input_source_id") or "",
|
||||||
|
processing_template_id=data.get("processing_template_id") or "",
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
|
def create_from_kwargs(cls, *, id: str, name: str, source_type: str,
|
||||||
created_at: datetime, updated_at: datetime,
|
created_at: datetime, updated_at: datetime,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import List, Optional
|
from typing import Dict, List, Optional, Type
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -54,61 +54,35 @@ class PictureSource:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def from_dict(data: dict) -> "PictureSource":
|
def from_dict(data: dict) -> "PictureSource":
|
||||||
"""Factory: dispatch to the correct subclass based on stream_type."""
|
"""Factory: dispatch to the correct subclass based on stream_type."""
|
||||||
stream_type: str = data.get("stream_type", "raw") or "raw"
|
stream_type = data.get("stream_type", "raw") or "raw"
|
||||||
sid: str = data["id"]
|
subcls = _PICTURE_SOURCE_MAP.get(stream_type, ScreenCapturePictureSource)
|
||||||
name: str = data["name"]
|
return subcls.from_dict(data)
|
||||||
description: str | None = data.get("description")
|
|
||||||
tags: list = data.get("tags", [])
|
|
||||||
|
|
||||||
raw_created = data.get("created_at")
|
|
||||||
created_at: datetime = (
|
|
||||||
datetime.fromisoformat(raw_created)
|
|
||||||
if isinstance(raw_created, str)
|
|
||||||
else raw_created if isinstance(raw_created, datetime)
|
|
||||||
else datetime.now(timezone.utc)
|
|
||||||
)
|
|
||||||
raw_updated = data.get("updated_at")
|
|
||||||
updated_at: datetime = (
|
|
||||||
datetime.fromisoformat(raw_updated)
|
|
||||||
if isinstance(raw_updated, str)
|
|
||||||
else raw_updated if isinstance(raw_updated, datetime)
|
|
||||||
else datetime.now(timezone.utc)
|
|
||||||
)
|
|
||||||
|
|
||||||
if stream_type == "processed":
|
def _parse_common_fields(data: dict) -> dict:
|
||||||
return ProcessedPictureSource(
|
"""Extract common fields shared by all picture source types."""
|
||||||
id=sid, name=name, stream_type=stream_type,
|
raw_created = data.get("created_at")
|
||||||
created_at=created_at, updated_at=updated_at, description=description, tags=tags,
|
created_at = (
|
||||||
source_stream_id=data.get("source_stream_id") or "",
|
datetime.fromisoformat(raw_created)
|
||||||
postprocessing_template_id=data.get("postprocessing_template_id") or "",
|
if isinstance(raw_created, str)
|
||||||
)
|
else raw_created if isinstance(raw_created, datetime)
|
||||||
elif stream_type == "static_image":
|
else datetime.now(timezone.utc)
|
||||||
return StaticImagePictureSource(
|
)
|
||||||
id=sid, name=name, stream_type=stream_type,
|
raw_updated = data.get("updated_at")
|
||||||
created_at=created_at, updated_at=updated_at, description=description, tags=tags,
|
updated_at = (
|
||||||
image_source=data.get("image_source") or "",
|
datetime.fromisoformat(raw_updated)
|
||||||
)
|
if isinstance(raw_updated, str)
|
||||||
elif stream_type == "video":
|
else raw_updated if isinstance(raw_updated, datetime)
|
||||||
return VideoCaptureSource(
|
else datetime.now(timezone.utc)
|
||||||
id=sid, name=name, stream_type=stream_type,
|
)
|
||||||
created_at=created_at, updated_at=updated_at, description=description, tags=tags,
|
return dict(
|
||||||
url=data.get("url") or "",
|
id=data["id"],
|
||||||
loop=data.get("loop", True),
|
name=data["name"],
|
||||||
playback_speed=data.get("playback_speed", 1.0),
|
description=data.get("description"),
|
||||||
start_time=data.get("start_time"),
|
tags=data.get("tags", []),
|
||||||
end_time=data.get("end_time"),
|
created_at=created_at,
|
||||||
resolution_limit=data.get("resolution_limit"),
|
updated_at=updated_at,
|
||||||
clock_id=data.get("clock_id"),
|
)
|
||||||
target_fps=data.get("target_fps") or 30,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
return ScreenCapturePictureSource(
|
|
||||||
id=sid, name=name, stream_type=stream_type,
|
|
||||||
created_at=created_at, updated_at=updated_at, description=description, tags=tags,
|
|
||||||
display_index=data.get("display_index") or 0,
|
|
||||||
capture_template_id=data.get("capture_template_id") or "",
|
|
||||||
target_fps=data.get("target_fps") or 30,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -126,6 +100,17 @@ class ScreenCapturePictureSource(PictureSource):
|
|||||||
d["target_fps"] = self.target_fps
|
d["target_fps"] = self.target_fps
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "ScreenCapturePictureSource":
|
||||||
|
common = _parse_common_fields(data)
|
||||||
|
return cls(
|
||||||
|
**common,
|
||||||
|
stream_type=data.get("stream_type", "raw") or "raw",
|
||||||
|
display_index=data.get("display_index") or 0,
|
||||||
|
capture_template_id=data.get("capture_template_id") or "",
|
||||||
|
target_fps=data.get("target_fps") or 30,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class ProcessedPictureSource(PictureSource):
|
class ProcessedPictureSource(PictureSource):
|
||||||
@@ -140,6 +125,16 @@ class ProcessedPictureSource(PictureSource):
|
|||||||
d["postprocessing_template_id"] = self.postprocessing_template_id
|
d["postprocessing_template_id"] = self.postprocessing_template_id
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "ProcessedPictureSource":
|
||||||
|
common = _parse_common_fields(data)
|
||||||
|
return cls(
|
||||||
|
**common,
|
||||||
|
stream_type="processed",
|
||||||
|
source_stream_id=data.get("source_stream_id") or "",
|
||||||
|
postprocessing_template_id=data.get("postprocessing_template_id") or "",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class StaticImagePictureSource(PictureSource):
|
class StaticImagePictureSource(PictureSource):
|
||||||
@@ -152,6 +147,15 @@ class StaticImagePictureSource(PictureSource):
|
|||||||
d["image_source"] = self.image_source
|
d["image_source"] = self.image_source
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "StaticImagePictureSource":
|
||||||
|
common = _parse_common_fields(data)
|
||||||
|
return cls(
|
||||||
|
**common,
|
||||||
|
stream_type="static_image",
|
||||||
|
image_source=data.get("image_source") or "",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class VideoCaptureSource(PictureSource):
|
class VideoCaptureSource(PictureSource):
|
||||||
@@ -177,3 +181,29 @@ class VideoCaptureSource(PictureSource):
|
|||||||
d["clock_id"] = self.clock_id
|
d["clock_id"] = self.clock_id
|
||||||
d["target_fps"] = self.target_fps
|
d["target_fps"] = self.target_fps
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "VideoCaptureSource":
|
||||||
|
common = _parse_common_fields(data)
|
||||||
|
return cls(
|
||||||
|
**common,
|
||||||
|
stream_type="video",
|
||||||
|
url=data.get("url") or "",
|
||||||
|
loop=data.get("loop", True),
|
||||||
|
playback_speed=data.get("playback_speed", 1.0),
|
||||||
|
start_time=data.get("start_time"),
|
||||||
|
end_time=data.get("end_time"),
|
||||||
|
resolution_limit=data.get("resolution_limit"),
|
||||||
|
clock_id=data.get("clock_id"),
|
||||||
|
target_fps=data.get("target_fps") or 30,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -- Source type registry --
|
||||||
|
# Maps stream_type string to its subclass for factory dispatch.
|
||||||
|
_PICTURE_SOURCE_MAP: Dict[str, Type[PictureSource]] = {
|
||||||
|
"raw": ScreenCapturePictureSource,
|
||||||
|
"processed": ProcessedPictureSource,
|
||||||
|
"static_image": StaticImagePictureSource,
|
||||||
|
"video": VideoCaptureSource,
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ parameters like brightness. Six types:
|
|||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import List, Optional
|
from typing import Dict, List, Optional, Type
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -59,88 +59,35 @@ class ValueSource:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def from_dict(data: dict) -> "ValueSource":
|
def from_dict(data: dict) -> "ValueSource":
|
||||||
"""Factory: dispatch to the correct subclass based on source_type."""
|
"""Factory: dispatch to the correct subclass based on source_type."""
|
||||||
source_type: str = data.get("source_type", "static") or "static"
|
source_type = data.get("source_type", "static") or "static"
|
||||||
sid: str = data["id"]
|
subcls = _VALUE_SOURCE_MAP.get(source_type, StaticValueSource)
|
||||||
name: str = data["name"]
|
return subcls.from_dict(data)
|
||||||
description: str | None = data.get("description")
|
|
||||||
tags: list = data.get("tags", [])
|
|
||||||
|
|
||||||
raw_created = data.get("created_at")
|
|
||||||
created_at: datetime = (
|
|
||||||
datetime.fromisoformat(raw_created)
|
|
||||||
if isinstance(raw_created, str)
|
|
||||||
else raw_created if isinstance(raw_created, datetime)
|
|
||||||
else datetime.now(timezone.utc)
|
|
||||||
)
|
|
||||||
raw_updated = data.get("updated_at")
|
|
||||||
updated_at: datetime = (
|
|
||||||
datetime.fromisoformat(raw_updated)
|
|
||||||
if isinstance(raw_updated, str)
|
|
||||||
else raw_updated if isinstance(raw_updated, datetime)
|
|
||||||
else datetime.now(timezone.utc)
|
|
||||||
)
|
|
||||||
|
|
||||||
if source_type == "animated":
|
def _parse_common_fields(data: dict) -> dict:
|
||||||
return AnimatedValueSource(
|
"""Extract common fields shared by all value source types."""
|
||||||
id=sid, name=name, source_type="animated",
|
raw_created = data.get("created_at")
|
||||||
created_at=created_at, updated_at=updated_at, description=description, tags=tags,
|
created_at = (
|
||||||
waveform=data.get("waveform") or "sine",
|
datetime.fromisoformat(raw_created)
|
||||||
speed=float(data.get("speed") or 10.0),
|
if isinstance(raw_created, str)
|
||||||
min_value=float(data.get("min_value") or 0.0),
|
else raw_created if isinstance(raw_created, datetime)
|
||||||
max_value=float(data["max_value"]) if data.get("max_value") is not None else 1.0,
|
else datetime.now(timezone.utc)
|
||||||
)
|
)
|
||||||
|
raw_updated = data.get("updated_at")
|
||||||
if source_type == "audio":
|
updated_at = (
|
||||||
return AudioValueSource(
|
datetime.fromisoformat(raw_updated)
|
||||||
id=sid, name=name, source_type="audio",
|
if isinstance(raw_updated, str)
|
||||||
created_at=created_at, updated_at=updated_at, description=description, tags=tags,
|
else raw_updated if isinstance(raw_updated, datetime)
|
||||||
audio_source_id=data.get("audio_source_id") or "",
|
else datetime.now(timezone.utc)
|
||||||
mode=data.get("mode") or "rms",
|
)
|
||||||
sensitivity=float(data.get("sensitivity") or 1.0),
|
return dict(
|
||||||
smoothing=float(data.get("smoothing") or 0.3),
|
id=data["id"],
|
||||||
min_value=float(data.get("min_value") or 0.0),
|
name=data["name"],
|
||||||
max_value=float(data["max_value"]) if data.get("max_value") is not None else 1.0,
|
description=data.get("description"),
|
||||||
auto_gain=bool(data.get("auto_gain", False)),
|
tags=data.get("tags", []),
|
||||||
)
|
created_at=created_at,
|
||||||
|
updated_at=updated_at,
|
||||||
if source_type == "adaptive_time":
|
)
|
||||||
return AdaptiveValueSource(
|
|
||||||
id=sid, name=name, source_type="adaptive_time",
|
|
||||||
created_at=created_at, updated_at=updated_at, description=description, tags=tags,
|
|
||||||
schedule=data.get("schedule") or [],
|
|
||||||
min_value=float(data.get("min_value") or 0.0),
|
|
||||||
max_value=float(data["max_value"]) if data.get("max_value") is not None else 1.0,
|
|
||||||
)
|
|
||||||
|
|
||||||
if source_type == "adaptive_scene":
|
|
||||||
return AdaptiveValueSource(
|
|
||||||
id=sid, name=name, source_type="adaptive_scene",
|
|
||||||
created_at=created_at, updated_at=updated_at, description=description, tags=tags,
|
|
||||||
picture_source_id=data.get("picture_source_id") or "",
|
|
||||||
scene_behavior=data.get("scene_behavior") or "complement",
|
|
||||||
sensitivity=float(data.get("sensitivity") or 1.0),
|
|
||||||
smoothing=float(data.get("smoothing") or 0.3),
|
|
||||||
min_value=float(data.get("min_value") or 0.0),
|
|
||||||
max_value=float(data["max_value"]) if data.get("max_value") is not None else 1.0,
|
|
||||||
)
|
|
||||||
|
|
||||||
if source_type == "daylight":
|
|
||||||
return DaylightValueSource(
|
|
||||||
id=sid, name=name, source_type="daylight",
|
|
||||||
created_at=created_at, updated_at=updated_at, description=description, tags=tags,
|
|
||||||
speed=float(data.get("speed") or 1.0),
|
|
||||||
use_real_time=bool(data.get("use_real_time", False)),
|
|
||||||
latitude=float(data.get("latitude") or 50.0),
|
|
||||||
min_value=float(data.get("min_value") or 0.0),
|
|
||||||
max_value=float(data["max_value"]) if data.get("max_value") is not None else 1.0,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Default: "static" type
|
|
||||||
return StaticValueSource(
|
|
||||||
id=sid, name=name, source_type="static",
|
|
||||||
created_at=created_at, updated_at=updated_at, description=description, tags=tags,
|
|
||||||
value=float(data["value"]) if data.get("value") is not None else 1.0,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -157,6 +104,15 @@ class StaticValueSource(ValueSource):
|
|||||||
d["value"] = self.value
|
d["value"] = self.value
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "StaticValueSource":
|
||||||
|
common = _parse_common_fields(data)
|
||||||
|
return cls(
|
||||||
|
**common,
|
||||||
|
source_type="static",
|
||||||
|
value=float(data["value"]) if data.get("value") is not None else 1.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AnimatedValueSource(ValueSource):
|
class AnimatedValueSource(ValueSource):
|
||||||
@@ -179,6 +135,18 @@ class AnimatedValueSource(ValueSource):
|
|||||||
d["max_value"] = self.max_value
|
d["max_value"] = self.max_value
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "AnimatedValueSource":
|
||||||
|
common = _parse_common_fields(data)
|
||||||
|
return cls(
|
||||||
|
**common,
|
||||||
|
source_type="animated",
|
||||||
|
waveform=data.get("waveform") or "sine",
|
||||||
|
speed=float(data.get("speed") or 10.0),
|
||||||
|
min_value=float(data.get("min_value") or 0.0),
|
||||||
|
max_value=float(data["max_value"]) if data.get("max_value") is not None else 1.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AudioValueSource(ValueSource):
|
class AudioValueSource(ValueSource):
|
||||||
@@ -207,6 +175,21 @@ class AudioValueSource(ValueSource):
|
|||||||
d["auto_gain"] = self.auto_gain
|
d["auto_gain"] = self.auto_gain
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "AudioValueSource":
|
||||||
|
common = _parse_common_fields(data)
|
||||||
|
return cls(
|
||||||
|
**common,
|
||||||
|
source_type="audio",
|
||||||
|
audio_source_id=data.get("audio_source_id") or "",
|
||||||
|
mode=data.get("mode") or "rms",
|
||||||
|
sensitivity=float(data.get("sensitivity") or 1.0),
|
||||||
|
smoothing=float(data.get("smoothing") or 0.3),
|
||||||
|
min_value=float(data.get("min_value") or 0.0),
|
||||||
|
max_value=float(data["max_value"]) if data.get("max_value") is not None else 1.0,
|
||||||
|
auto_gain=bool(data.get("auto_gain", False)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AdaptiveValueSource(ValueSource):
|
class AdaptiveValueSource(ValueSource):
|
||||||
@@ -236,6 +219,22 @@ class AdaptiveValueSource(ValueSource):
|
|||||||
d["max_value"] = self.max_value
|
d["max_value"] = self.max_value
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "AdaptiveValueSource":
|
||||||
|
common = _parse_common_fields(data)
|
||||||
|
source_type = data.get("source_type", "adaptive_time")
|
||||||
|
return cls(
|
||||||
|
**common,
|
||||||
|
source_type=source_type,
|
||||||
|
schedule=data.get("schedule") or [],
|
||||||
|
picture_source_id=data.get("picture_source_id") or "",
|
||||||
|
scene_behavior=data.get("scene_behavior") or "complement",
|
||||||
|
sensitivity=float(data.get("sensitivity") or 1.0),
|
||||||
|
smoothing=float(data.get("smoothing") or 0.3),
|
||||||
|
min_value=float(data.get("min_value") or 0.0),
|
||||||
|
max_value=float(data["max_value"]) if data.get("max_value") is not None else 1.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class DaylightValueSource(ValueSource):
|
class DaylightValueSource(ValueSource):
|
||||||
@@ -259,3 +258,28 @@ class DaylightValueSource(ValueSource):
|
|||||||
d["min_value"] = self.min_value
|
d["min_value"] = self.min_value
|
||||||
d["max_value"] = self.max_value
|
d["max_value"] = self.max_value
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "DaylightValueSource":
|
||||||
|
common = _parse_common_fields(data)
|
||||||
|
return cls(
|
||||||
|
**common,
|
||||||
|
source_type="daylight",
|
||||||
|
speed=float(data.get("speed") or 1.0),
|
||||||
|
use_real_time=bool(data.get("use_real_time", False)),
|
||||||
|
latitude=float(data.get("latitude") or 50.0),
|
||||||
|
min_value=float(data.get("min_value") or 0.0),
|
||||||
|
max_value=float(data["max_value"]) if data.get("max_value") is not None else 1.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# -- Source type registry --
|
||||||
|
# Maps source_type string to its subclass for factory dispatch.
|
||||||
|
_VALUE_SOURCE_MAP: Dict[str, Type[ValueSource]] = {
|
||||||
|
"static": StaticValueSource,
|
||||||
|
"animated": AnimatedValueSource,
|
||||||
|
"audio": AudioValueSource,
|
||||||
|
"adaptive_time": AdaptiveValueSource,
|
||||||
|
"adaptive_scene": AdaptiveValueSource,
|
||||||
|
"daylight": DaylightValueSource,
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user