feat: add weather source entity and weather-reactive CSS source type
Some checks failed
Lint & Test / test (push) Failing after 34s

New standalone WeatherSource entity with pluggable provider architecture
(Open-Meteo v1, free, no API key). Full CRUD, test endpoint, browser
geolocation, IconSelect provider picker, CardSection with test/clone/edit.

WeatherColorStripStream maps WMO weather codes to ambient color palettes
with temperature hue shifting and thunderstorm flash effects. Ref-counted
WeatherManager polls API and caches data per source.

CSS editor integration: weather type with EntitySelect source picker,
speed and temperature influence sliders. Backup/restore support.

i18n for en/ru/zh.
This commit is contained in:
2026-03-24 18:52:46 +03:00
parent 0723c5c68c
commit ef33935188
31 changed files with 1868 additions and 11 deletions

View File

@@ -26,6 +26,7 @@ const _colorStripTypeIcons = {
notification: _svg(P.bellRing),
daylight: _svg(P.sun),
candlelight: _svg(P.flame),
weather: _svg(P.cloudSun),
processed: _svg(P.sparkles),
};
const _valueSourceTypeIcons = {

View File

@@ -10,7 +10,7 @@ import { DataCache } from './cache.ts';
import type {
Device, OutputTarget, ColorStripSource, PatternTemplate,
ValueSource, AudioSource, PictureSource, ScenePreset,
SyncClock, Automation, Display, FilterDef, EngineInfo,
SyncClock, WeatherSource, Automation, Display, FilterDef, EngineInfo,
CaptureTemplate, PostprocessingTemplate, AudioTemplate,
ColorStripProcessingTemplate, FilterInstance, KeyColorRectangle,
} from '../types.ts';
@@ -225,6 +225,7 @@ export let _cachedValueSources: ValueSource[] = [];
// Sync clocks
export let _cachedSyncClocks: SyncClock[] = [];
export let _cachedWeatherSources: WeatherSource[] = [];
// Automations
export let _automationsCache: Automation[] | null = null;
@@ -282,6 +283,12 @@ export const syncClocksCache = new DataCache<SyncClock[]>({
});
syncClocksCache.subscribe(v => { _cachedSyncClocks = v; });
export const weatherSourcesCache = new DataCache<WeatherSource[]>({
endpoint: '/weather-sources',
extractData: json => json.sources || [],
});
weatherSourcesCache.subscribe(v => { _cachedWeatherSources = v; });
export const filtersCache = new DataCache<FilterDef[]>({
endpoint: '/filters',
extractData: json => json.filters || [],

View File

@@ -3,7 +3,7 @@
*/
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { _cachedSyncClocks, _cachedCSPTemplates, audioSourcesCache, streamsCache, colorStripSourcesCache, valueSourcesCache, csptCache, gradientsCache, GradientEntity } from '../core/state.ts';
import { _cachedSyncClocks, _cachedCSPTemplates, _cachedWeatherSources, audioSourcesCache, streamsCache, colorStripSourcesCache, valueSourcesCache, csptCache, gradientsCache, weatherSourcesCache, GradientEntity } from '../core/state.ts';
import { t } from '../core/i18n.ts';
import { showToast, showConfirm, desktopFocus } from '../core/ui.ts';
import { Modal } from '../core/modal.ts';
@@ -127,7 +127,7 @@ let _processedTemplateEntitySelect: any = null;
const CSS_TYPE_KEYS = [
'picture', 'picture_advanced', 'static', 'gradient', 'color_cycle',
'effect', 'composite', 'mapped', 'audio',
'api_input', 'notification', 'daylight', 'candlelight', 'processed',
'api_input', 'notification', 'daylight', 'candlelight', 'weather', 'processed',
];
function _buildCSSTypeItems() {
@@ -172,6 +172,7 @@ const CSS_SECTION_MAP: Record<string, string> = {
'notification': 'css-editor-notification-section',
'daylight': 'css-editor-daylight-section',
'candlelight': 'css-editor-candlelight-section',
'weather': 'css-editor-weather-section',
'processed': 'css-editor-processed-section',
};
@@ -184,6 +185,7 @@ const CSS_TYPE_SETUP: Record<string, () => void> = {
gradient: () => { _ensureGradientPresetIconSelect(); _ensureGradientEasingIconSelect(); requestAnimationFrame(() => gradientRenderAll()); },
notification: () => { ensureNotificationEffectIconSelect(); ensureNotificationFilterModeIconSelect(); },
candlelight: () => _ensureCandleTypeIconSelect(),
weather: () => { weatherSourcesCache.fetch().then(() => _populateWeatherSourceDropdown()); },
composite: () => compositeRenderList(),
mapped: () => _mappedRenderList(),
};
@@ -238,7 +240,7 @@ export function onCSSTypeChange() {
hasLedCount.includes(type) ? '' : 'none';
// Sync clock — shown for animated types
const clockTypes = ['static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight'];
const clockTypes = ['static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight', 'weather'];
(document.getElementById('css-editor-clock-group') as HTMLElement).style.display = clockTypes.includes(type) ? '' : 'none';
if (clockTypes.includes(type)) _populateClockDropdown();
@@ -272,6 +274,29 @@ export function onCSSClockChange() {
// No-op: speed sliders removed; speed is now clock-only
}
let _weatherSourceEntitySelect: any = null;
function _populateWeatherSourceDropdown() {
const sources = _cachedWeatherSources || [];
const sel = document.getElementById('css-editor-weather-source') as HTMLSelectElement;
const prev = sel.value;
sel.innerHTML = `<option value="">\u2014</option>` +
sources.map(s => `<option value="${s.id}">${escapeHtml(s.name)}</option>`).join('');
sel.value = prev || '';
if (_weatherSourceEntitySelect) _weatherSourceEntitySelect.destroy();
if (sources.length > 0) {
_weatherSourceEntitySelect = new EntitySelect({
target: sel,
getItems: () => (_cachedWeatherSources || []).map(s => ({
value: s.id,
label: s.name,
icon: getColorStripIcon('weather'),
})),
placeholder: t('palette.search'),
});
}
}
function _populateProcessedSelectors() {
const editingId = (document.getElementById('css-editor-id') as HTMLInputElement).value;
const allSources = (colorStripSourcesCache.data || []) as any[];
@@ -927,7 +952,7 @@ type CardPropsRenderer = (source: ColorStripSource, opts: {
const NON_PICTURE_TYPES = new Set([
'static', 'gradient', 'color_cycle', 'effect', 'composite', 'mapped',
'audio', 'api_input', 'notification', 'daylight', 'candlelight', 'processed',
'audio', 'api_input', 'notification', 'daylight', 'candlelight', 'weather', 'processed',
]);
const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
@@ -1055,6 +1080,17 @@ const CSS_CARD_RENDERERS: Record<string, CardPropsRenderer> = {
${clockBadge}
`;
},
weather: (source, { clockBadge }) => {
const speedVal = (source.speed ?? 1.0).toFixed(1);
const tempInfl = (source.temperature_influence ?? 0.5).toFixed(1);
const wsName = (_cachedWeatherSources || []).find((w: any) => w.id === source.weather_source_id)?.name || '—';
return `
<span class="stream-card-prop">${getColorStripIcon('weather')} ${escapeHtml(wsName)}</span>
<span class="stream-card-prop">⏩ ${speedVal}x</span>
<span class="stream-card-prop">🌡 ${tempInfl}</span>
${clockBadge}
`;
},
processed: (source) => {
const inputSrc = ((colorStripSourcesCache.data || []) as any[]).find(s => s.id === source.input_source_id);
const inputName = inputSrc?.name || source.input_source_id || '—';
@@ -1479,6 +1515,39 @@ const _typeHandlers: Record<string, { load: (...args: any[]) => any; reset: (...
};
},
},
weather: {
async load(css) {
await weatherSourcesCache.fetch();
_populateWeatherSourceDropdown();
(document.getElementById('css-editor-weather-source') as HTMLSelectElement).value = css.weather_source_id || '';
(document.getElementById('css-editor-weather-speed') as HTMLInputElement).value = css.speed ?? 1.0;
(document.getElementById('css-editor-weather-speed-val') as HTMLElement).textContent = parseFloat(css.speed ?? 1.0).toFixed(1);
(document.getElementById('css-editor-weather-temp-influence') as HTMLInputElement).value = css.temperature_influence ?? 0.5;
(document.getElementById('css-editor-weather-temp-val') as HTMLElement).textContent = parseFloat(css.temperature_influence ?? 0.5).toFixed(2);
},
async reset() {
await weatherSourcesCache.fetch();
_populateWeatherSourceDropdown();
(document.getElementById('css-editor-weather-source') as HTMLSelectElement).value = '';
(document.getElementById('css-editor-weather-speed') as HTMLInputElement).value = 1.0 as any;
(document.getElementById('css-editor-weather-speed-val') as HTMLElement).textContent = '1.0';
(document.getElementById('css-editor-weather-temp-influence') as HTMLInputElement).value = 0.5 as any;
(document.getElementById('css-editor-weather-temp-val') as HTMLElement).textContent = '0.50';
},
getPayload(name) {
const wsId = (document.getElementById('css-editor-weather-source') as HTMLSelectElement).value;
if (!wsId) {
cssEditorModal.showError(t('color_strip.weather.error.no_source'));
return null;
}
return {
name,
weather_source_id: wsId,
speed: parseFloat((document.getElementById('css-editor-weather-speed') as HTMLInputElement).value),
temperature_influence: parseFloat((document.getElementById('css-editor-weather-temp-influence') as HTMLInputElement).value),
};
},
},
processed: {
async load(css) {
await csptCache.fetch();
@@ -1726,7 +1795,7 @@ export async function saveCSSEditor() {
if (!cssId) payload.source_type = knownType ? sourceType : 'picture';
// Attach clock_id for animated types
const clockTypes = ['static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight'];
const clockTypes = ['static', 'gradient', 'color_cycle', 'effect', 'daylight', 'candlelight', 'weather'];
if (clockTypes.includes(sourceType)) {
const clockVal = (document.getElementById('css-editor-clock') as HTMLInputElement).value;
payload.clock_id = clockVal || null;

View File

@@ -23,6 +23,7 @@ import {
_cachedAudioSources,
_cachedValueSources,
_cachedSyncClocks,
_cachedWeatherSources,
_cachedAudioTemplates,
_cachedCSPTemplates,
_csptModalFilters, set_csptModalFilters,
@@ -34,7 +35,7 @@ import {
_sourcesLoading, set_sourcesLoading,
apiKey,
streamsCache, ppTemplatesCache, captureTemplatesCache,
audioSourcesCache, audioTemplatesCache, valueSourcesCache, syncClocksCache, filtersCache,
audioSourcesCache, audioTemplatesCache, valueSourcesCache, syncClocksCache, weatherSourcesCache, filtersCache,
colorStripSourcesCache,
csptCache, stripFiltersCache,
gradientsCache, GradientEntity,
@@ -49,6 +50,7 @@ import { TreeNav } from '../core/tree-nav.ts';
import { updateSubTabHash } from './tabs.ts';
import { createValueSourceCard } from './value-sources.ts';
import { createSyncClockCard, initSyncClockDelegation } from './sync-clocks.ts';
import { createWeatherSourceCard, initWeatherSourceDelegation } from './weather-sources.ts';
import { createColorStripCard } from './color-strips.ts';
import { initAudioSourceDelegation } from './audio-sources.ts';
import {
@@ -94,6 +96,7 @@ const _audioTemplateDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', ic
const _colorStripDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('color-strip-sources', colorStripSourcesCache, 'color_strip.deleted') }];
const _valueSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('value-sources', valueSourcesCache, 'value_source.deleted') }];
const _syncClockDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('sync-clocks', syncClocksCache, 'sync_clock.deleted') }];
const _weatherSourceDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('weather-sources', weatherSourcesCache, 'weather_source.deleted') }];
const _csptDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('color-strip-processing-templates', csptCache, 'templates.deleted') }];
// ── Card section instances ──
@@ -109,6 +112,7 @@ const csAudioTemplates = new CardSection('audio-templates', { titleKey: 'audio_t
const csColorStrips = new CardSection('color-strips', { titleKey: 'targets.section.color_strips', gridClass: 'templates-grid', addCardOnclick: "showCSSEditor()", keyAttr: 'data-css-id', emptyKey: 'section.empty.color_strips', bulkActions: _colorStripDeleteAction });
const csValueSources = new CardSection('value-sources', { titleKey: 'value_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showValueSourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.value_sources', bulkActions: _valueSourceDeleteAction });
const csSyncClocks = new CardSection('sync-clocks', { titleKey: 'sync_clock.group.title', gridClass: 'templates-grid', addCardOnclick: "showSyncClockModal()", keyAttr: 'data-id', emptyKey: 'section.empty.sync_clocks', bulkActions: _syncClockDeleteAction });
const csWeatherSources = new CardSection('weather-sources', { titleKey: 'weather_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showWeatherSourceModal()", keyAttr: 'data-id', emptyKey: 'section.empty.weather_sources', bulkActions: _weatherSourceDeleteAction });
const csCSPTemplates = new CardSection('css-proc-templates', { titleKey: 'css_processing.title', gridClass: 'templates-grid', addCardOnclick: "showAddCSPTModal()", keyAttr: 'data-cspt-id', emptyKey: 'section.empty.cspt', bulkActions: _csptDeleteAction });
const _gradientDeleteAction = [{ key: 'delete', labelKey: 'bulk.delete', icon: ICON_TRASH, style: 'danger', confirm: 'bulk.confirm_delete', handler: _bulkDeleteFactory('gradients', gradientsCache, 'gradient.deleted') }];
const csGradients = new CardSection('gradients', { titleKey: 'gradient.group.title', gridClass: 'templates-grid', addCardOnclick: "showGradientModal()", keyAttr: 'data-id', emptyKey: 'section.empty.gradients', bulkActions: _gradientDeleteAction });
@@ -220,6 +224,7 @@ export async function loadPictureSources() {
audioSourcesCache.fetch(),
valueSourcesCache.fetch(),
syncClocksCache.fetch(),
weatherSourcesCache.fetch(),
audioTemplatesCache.fetch(),
colorStripSourcesCache.fetch(),
csptCache.fetch(),
@@ -274,6 +279,7 @@ const _streamSectionMap = {
audio_templates: [csAudioTemplates],
value: [csValueSources],
sync: [csSyncClocks],
weather: [csWeatherSources],
};
type StreamCardRenderer = (stream: any) => string;
@@ -483,6 +489,7 @@ function renderPictureSourcesList(streams: any) {
{ key: 'audio_templates', icon: ICON_AUDIO_TEMPLATE, titleKey: 'streams.group.audio_templates', count: _cachedAudioTemplates.length },
{ key: 'value', icon: ICON_VALUE_SOURCE, titleKey: 'streams.group.value', count: _cachedValueSources.length },
{ key: 'sync', icon: ICON_CLOCK, titleKey: 'streams.group.sync', count: _cachedSyncClocks.length },
{ key: 'weather', icon: `<svg class="icon" viewBox="0 0 24 24">${P.cloudSun}</svg>`, titleKey: 'streams.group.weather', count: _cachedWeatherSources.length },
];
// Build tree navigation structure
@@ -533,6 +540,7 @@ function renderPictureSourcesList(streams: any) {
children: [
{ key: 'value', titleKey: 'streams.group.value', icon: ICON_VALUE_SOURCE, count: _cachedValueSources.length },
{ key: 'sync', titleKey: 'streams.group.sync', icon: ICON_CLOCK, count: _cachedSyncClocks.length },
{ key: 'weather', titleKey: 'streams.group.weather', icon: `<svg class="icon" viewBox="0 0 24 24">${P.cloudSun}</svg>`, count: _cachedWeatherSources.length },
]
}
];
@@ -663,6 +671,7 @@ function renderPictureSourcesList(streams: any) {
const gradientItems = csGradients.applySortOrder(gradients.map(g => ({ key: g.id, html: renderGradientCard(g) })));
const valueItems = csValueSources.applySortOrder(_cachedValueSources.map(s => ({ key: s.id, html: createValueSourceCard(s) })));
const syncClockItems = csSyncClocks.applySortOrder(_cachedSyncClocks.map(s => ({ key: s.id, html: createSyncClockCard(s) })));
const weatherSourceItems = csWeatherSources.applySortOrder(_cachedWeatherSources.map(s => ({ key: s.id, html: createWeatherSourceCard(s) })));
const csptItems = csCSPTemplates.applySortOrder(csptTemplates.map(t => ({ key: t.id, html: renderCSPTCard(t) })));
if (csRawStreams.isMounted()) {
@@ -681,6 +690,7 @@ function renderPictureSourcesList(streams: any) {
audio_templates: _cachedAudioTemplates.length,
value: _cachedValueSources.length,
sync: _cachedSyncClocks.length,
weather: _cachedWeatherSources.length,
});
csRawStreams.reconcile(rawStreamItems);
csRawTemplates.reconcile(rawTemplateItems);
@@ -696,6 +706,7 @@ function renderPictureSourcesList(streams: any) {
csVideoStreams.reconcile(videoItems);
csValueSources.reconcile(valueItems);
csSyncClocks.reconcile(syncClockItems);
csWeatherSources.reconcile(weatherSourceItems);
} else {
// First render: build full HTML
const panels = tabs.map(tab => {
@@ -711,16 +722,18 @@ function renderPictureSourcesList(streams: any) {
else if (tab.key === 'audio_templates') panelContent = csAudioTemplates.render(audioTemplateItems);
else if (tab.key === 'value') panelContent = csValueSources.render(valueItems);
else if (tab.key === 'sync') panelContent = csSyncClocks.render(syncClockItems);
else if (tab.key === 'weather') panelContent = csWeatherSources.render(weatherSourceItems);
else if (tab.key === 'video') panelContent = csVideoStreams.render(videoItems);
else panelContent = csStaticStreams.render(staticItems);
return `<div class="stream-tab-panel${tab.key === activeTab ? ' active' : ''}" id="stream-tab-${tab.key}">${panelContent}</div>`;
}).join('');
container.innerHTML = panels;
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csCSPTemplates, csColorStrips, csGradients, csAudioMulti, csAudioMono, csAudioTemplates, csStaticStreams, csVideoStreams, csValueSources, csSyncClocks]);
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csCSPTemplates, csColorStrips, csGradients, csAudioMulti, csAudioMono, csAudioTemplates, csStaticStreams, csVideoStreams, csValueSources, csSyncClocks, csWeatherSources]);
// Event delegation for card actions (replaces inline onclick handlers)
initSyncClockDelegation(container);
initWeatherSourceDelegation(container);
initAudioSourceDelegation(container);
// Render tree sidebar with expand/collapse buttons
@@ -738,6 +751,7 @@ function renderPictureSourcesList(streams: any) {
'audio-templates': 'audio_templates',
'value-sources': 'value',
'sync-clocks': 'sync',
'weather-sources': 'weather',
});
}
}

View File

@@ -0,0 +1,329 @@
/**
* Weather Sources — CRUD, test, cards.
*/
import { _cachedWeatherSources, weatherSourcesCache } from '../core/state.ts';
import { fetchWithAuth, escapeHtml } from '../core/api.ts';
import { t } from '../core/i18n.ts';
import { Modal } from '../core/modal.ts';
import { showToast, showConfirm } from '../core/ui.ts';
import { ICON_CLONE, ICON_EDIT, ICON_TEST } from '../core/icons.ts';
import * as P from '../core/icon-paths.ts';
import { IconSelect } from '../core/icon-select.ts';
import { wrapCard } from '../core/card-colors.ts';
import { TagInput, renderTagChips } from '../core/tag-input.ts';
import { loadPictureSources } from './streams.ts';
import type { WeatherSource } from '../types.ts';
const ICON_WEATHER = `<svg class="icon" viewBox="0 0 24 24">${P.cloudSun}</svg>`;
const _icon = (d: string) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
function _getProviderItems() {
return [
{ value: 'open_meteo', icon: _icon(P.cloudSun), label: 'Open-Meteo', desc: t('weather_source.provider.open_meteo.desc') },
];
}
// ── Modal ──
let _weatherSourceTagsInput: TagInput | null = null;
let _providerIconSelect: IconSelect | null = null;
class WeatherSourceModal extends Modal {
constructor() { super('weather-source-modal'); }
onForceClose() {
if (_weatherSourceTagsInput) { _weatherSourceTagsInput.destroy(); _weatherSourceTagsInput = null; }
if (_providerIconSelect) { _providerIconSelect.destroy(); _providerIconSelect = null; }
}
snapshotValues() {
return {
name: (document.getElementById('weather-source-name') as HTMLInputElement).value,
provider: (document.getElementById('weather-source-provider') as HTMLSelectElement).value,
latitude: (document.getElementById('weather-source-latitude') as HTMLInputElement).value,
longitude: (document.getElementById('weather-source-longitude') as HTMLInputElement).value,
interval: (document.getElementById('weather-source-interval') as HTMLInputElement).value,
description: (document.getElementById('weather-source-description') as HTMLInputElement).value,
tags: JSON.stringify(_weatherSourceTagsInput ? _weatherSourceTagsInput.getValue() : []),
};
}
}
const weatherSourceModal = new WeatherSourceModal();
// ── Show / Close ──
export async function showWeatherSourceModal(editData: WeatherSource | null = null): Promise<void> {
const isEdit = !!editData;
const titleKey = isEdit ? 'weather_source.edit' : 'weather_source.add';
document.getElementById('weather-source-modal-title')!.innerHTML = `${ICON_WEATHER} ${t(titleKey)}`;
(document.getElementById('weather-source-id') as HTMLInputElement).value = editData?.id || '';
(document.getElementById('weather-source-error') as HTMLElement).style.display = 'none';
if (isEdit) {
(document.getElementById('weather-source-name') as HTMLInputElement).value = editData.name || '';
(document.getElementById('weather-source-provider') as HTMLSelectElement).value = editData.provider || 'open_meteo';
(document.getElementById('weather-source-latitude') as HTMLInputElement).value = String(editData.latitude ?? 50.0);
(document.getElementById('weather-source-longitude') as HTMLInputElement).value = String(editData.longitude ?? 0.0);
(document.getElementById('weather-source-interval') as HTMLInputElement).value = String(editData.update_interval ?? 600);
document.getElementById('weather-source-interval-display')!.textContent = String(Math.round((editData.update_interval ?? 600) / 60));
(document.getElementById('weather-source-description') as HTMLInputElement).value = editData.description || '';
} else {
(document.getElementById('weather-source-name') as HTMLInputElement).value = '';
(document.getElementById('weather-source-provider') as HTMLSelectElement).value = 'open_meteo';
(document.getElementById('weather-source-latitude') as HTMLInputElement).value = '50.0';
(document.getElementById('weather-source-longitude') as HTMLInputElement).value = '0.0';
(document.getElementById('weather-source-interval') as HTMLInputElement).value = '600';
document.getElementById('weather-source-interval-display')!.textContent = '10';
(document.getElementById('weather-source-description') as HTMLInputElement).value = '';
}
// Tags
if (_weatherSourceTagsInput) { _weatherSourceTagsInput.destroy(); _weatherSourceTagsInput = null; }
_weatherSourceTagsInput = new TagInput(document.getElementById('weather-source-tags-container'), { placeholder: t('tags.placeholder') });
_weatherSourceTagsInput.setValue(isEdit ? (editData.tags || []) : []);
// Provider IconSelect
if (_providerIconSelect) { _providerIconSelect.destroy(); _providerIconSelect = null; }
_providerIconSelect = new IconSelect({
target: document.getElementById('weather-source-provider') as HTMLSelectElement,
items: _getProviderItems(),
columns: 1,
});
// Show/hide test button based on edit mode
const testBtn = document.getElementById('weather-source-test-btn');
if (testBtn) testBtn.style.display = isEdit ? '' : 'none';
weatherSourceModal.open();
weatherSourceModal.snapshot();
}
export async function closeWeatherSourceModal(): Promise<void> {
await weatherSourceModal.close();
}
// ── Save ──
export async function saveWeatherSource(): Promise<void> {
const id = (document.getElementById('weather-source-id') as HTMLInputElement).value;
const name = (document.getElementById('weather-source-name') as HTMLInputElement).value.trim();
const provider = (document.getElementById('weather-source-provider') as HTMLSelectElement).value;
const latitude = parseFloat((document.getElementById('weather-source-latitude') as HTMLInputElement).value) || 50.0;
const longitude = parseFloat((document.getElementById('weather-source-longitude') as HTMLInputElement).value) || 0.0;
const update_interval = parseInt((document.getElementById('weather-source-interval') as HTMLInputElement).value) || 600;
const description = (document.getElementById('weather-source-description') as HTMLInputElement).value.trim() || null;
if (!name) {
weatherSourceModal.showError(t('weather_source.error.name_required'));
return;
}
const payload = {
name, provider, latitude, longitude, update_interval, description,
tags: _weatherSourceTagsInput ? _weatherSourceTagsInput.getValue() : [],
};
try {
const method = id ? 'PUT' : 'POST';
const url = id ? `/weather-sources/${id}` : '/weather-sources';
const resp = await fetchWithAuth(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
showToast(t(id ? 'weather_source.updated' : 'weather_source.created'), 'success');
weatherSourceModal.forceClose();
weatherSourcesCache.invalidate();
await loadPictureSources();
} catch (e: any) {
if (e.isAuth) return;
weatherSourceModal.showError(e.message);
}
}
// ── Edit / Clone / Delete ──
export async function editWeatherSource(sourceId: string): Promise<void> {
try {
const resp = await fetchWithAuth(`/weather-sources/${sourceId}`);
if (!resp.ok) throw new Error(t('weather_source.error.load'));
const data = await resp.json();
await showWeatherSourceModal(data);
} catch (e: any) {
if (e.isAuth) return;
showToast(e.message, 'error');
}
}
export async function cloneWeatherSource(sourceId: string): Promise<void> {
try {
const resp = await fetchWithAuth(`/weather-sources/${sourceId}`);
if (!resp.ok) throw new Error(t('weather_source.error.load'));
const data = await resp.json();
delete data.id;
data.name = data.name + ' (copy)';
await showWeatherSourceModal(data);
} catch (e: any) {
if (e.isAuth) return;
showToast(e.message, 'error');
}
}
export async function deleteWeatherSource(sourceId: string): Promise<void> {
const confirmed = await showConfirm(t('weather_source.delete.confirm'));
if (!confirmed) return;
try {
const resp = await fetchWithAuth(`/weather-sources/${sourceId}`, { method: 'DELETE' });
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `HTTP ${resp.status}`);
}
showToast(t('weather_source.deleted'), 'success');
weatherSourcesCache.invalidate();
await loadPictureSources();
} catch (e: any) {
if (e.isAuth) return;
showToast(e.message, 'error');
}
}
// ── Test (fetch current weather) ──
export async function testWeatherSource(): Promise<void> {
const id = (document.getElementById('weather-source-id') as HTMLInputElement).value;
if (!id) return;
const testBtn = document.getElementById('weather-source-test-btn');
if (testBtn) testBtn.classList.add('loading');
try {
const resp = await fetchWithAuth(`/weather-sources/${id}/test`, { method: 'POST' });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
showToast(`${data.condition} | ${data.temperature.toFixed(1)}\u00B0C | ${data.wind_speed.toFixed(0)} km/h`, 'success');
} catch (e: any) {
if (e.isAuth) return;
showToast(e.message, 'error');
} finally {
if (testBtn) testBtn.classList.remove('loading');
}
}
// ── Geolocation ──
export function weatherSourceGeolocate(): void {
const btn = document.getElementById('weather-source-geolocate-btn');
if (!navigator.geolocation) {
showToast(t('weather_source.geo.not_supported'), 'error');
return;
}
if (btn) btn.classList.add('loading');
navigator.geolocation.getCurrentPosition(
(pos) => {
(document.getElementById('weather-source-latitude') as HTMLInputElement).value = pos.coords.latitude.toFixed(4);
(document.getElementById('weather-source-longitude') as HTMLInputElement).value = pos.coords.longitude.toFixed(4);
if (btn) btn.classList.remove('loading');
showToast(t('weather_source.geo.success'), 'success');
},
(err) => {
if (btn) btn.classList.remove('loading');
showToast(`${t('weather_source.geo.error')}: ${err.message}`, 'error');
},
{ timeout: 10000, maximumAge: 60000 }
);
}
// ── Card rendering ──
export function createWeatherSourceCard(source: WeatherSource) {
const intervalMin = Math.round(source.update_interval / 60);
const providerLabel = source.provider === 'open_meteo' ? 'Open-Meteo' : source.provider;
return wrapCard({
type: 'template-card',
dataAttr: 'data-id',
id: source.id,
removeOnclick: `deleteWeatherSource('${source.id}')`,
removeTitle: t('common.delete'),
content: `
<div class="template-card-header">
<div class="template-name">${ICON_WEATHER} ${escapeHtml(source.name)}</div>
</div>
<div class="stream-card-props">
<span class="stream-card-prop">${ICON_WEATHER} ${providerLabel}</span>
<span class="stream-card-prop" title="${source.latitude.toFixed(2)}, ${source.longitude.toFixed(2)}">
<svg class="icon" viewBox="0 0 24 24">${P.mapPin}</svg> ${source.latitude.toFixed(1)}, ${source.longitude.toFixed(1)}
</span>
<span class="stream-card-prop">
<svg class="icon" viewBox="0 0 24 24">${P.clock}</svg> ${intervalMin}min
</span>
</div>
${renderTagChips(source.tags)}
${source.description ? `<div class="template-config" style="opacity:0.7;">${escapeHtml(source.description)}</div>` : ''}`,
actions: `
<button class="btn btn-icon btn-secondary" data-action="test" title="${t('weather_source.test')}">${ICON_TEST}</button>
<button class="btn btn-icon btn-secondary" data-action="clone" title="${t('common.clone')}">${ICON_CLONE}</button>
<button class="btn btn-icon btn-secondary" data-action="edit" title="${t('common.edit')}">${ICON_EDIT}</button>`,
});
}
// ── Event delegation ──
const _weatherSourceActions: Record<string, (id: string) => void> = {
test: (id) => _testWeatherSourceFromCard(id),
clone: cloneWeatherSource,
edit: editWeatherSource,
};
async function _testWeatherSourceFromCard(sourceId: string): Promise<void> {
try {
const resp = await fetchWithAuth(`/weather-sources/${sourceId}/test`, { method: 'POST' });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
showToast(`${data.condition} | ${data.temperature.toFixed(1)}\u00B0C | ${data.wind_speed.toFixed(0)} km/h`, 'success');
} catch (e: any) {
if (e.isAuth) return;
showToast(e.message, 'error');
}
}
export function initWeatherSourceDelegation(container: HTMLElement): void {
container.addEventListener('click', (e: MouseEvent) => {
const btn = (e.target as HTMLElement).closest<HTMLElement>('[data-action]');
if (!btn) return;
const section = btn.closest<HTMLElement>('[data-card-section="weather-sources"]');
if (!section) return;
const card = btn.closest<HTMLElement>('[data-id]');
if (!card) return;
const action = btn.dataset.action;
const id = card.getAttribute('data-id');
if (!action || !id) return;
const handler = _weatherSourceActions[action];
if (handler) {
e.stopPropagation();
handler(id);
}
});
}
// ── Expose to global scope for HTML template onclick handlers ──
window.showWeatherSourceModal = showWeatherSourceModal;
window.closeWeatherSourceModal = closeWeatherSourceModal;
window.saveWeatherSource = saveWeatherSource;
window.editWeatherSource = editWeatherSource;
window.cloneWeatherSource = cloneWeatherSource;
window.deleteWeatherSource = deleteWeatherSource;
window.testWeatherSource = testWeatherSource;
window.weatherSourceGeolocate = weatherSourceGeolocate;

View File

@@ -223,6 +223,10 @@ export interface ColorStripSource {
// Processed
input_source_id?: string;
processing_template_id?: string;
// Weather
weather_source_id?: string;
temperature_influence?: number;
}
// ── Pattern Template ──────────────────────────────────────────
@@ -384,6 +388,25 @@ export interface SyncClock {
updated_at: string;
}
export interface WeatherSource {
id: string;
name: string;
provider: string;
provider_config: Record<string, any>;
latitude: number;
longitude: number;
update_interval: number;
description?: string;
tags: string[];
created_at: string;
updated_at: string;
}
export interface WeatherSourceListResponse {
sources: WeatherSource[];
count: number;
}
// ── Automation ────────────────────────────────────────────────
export type ConditionType =