| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737 |
- import { useState, useEffect, useCallback } from 'react'
- import { Link } from 'react-router-dom'
- import { toast } from 'sonner'
- import { Button } from '@/components/ui/button'
- import {
- Card,
- CardContent,
- CardDescription,
- CardHeader,
- CardTitle,
- } from '@/components/ui/card'
- import { Label } from '@/components/ui/label'
- import { Separator } from '@/components/ui/separator'
- import { Switch } from '@/components/ui/switch'
- import { Slider } from '@/components/ui/slider'
- import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
- } from '@/components/ui/select'
- import { Input } from '@/components/ui/input'
- import { ColorPicker } from '@/components/ui/color-picker'
- // Types
- interface LedConfig {
- provider: 'none' | 'wled' | 'dw_leds'
- wled_ip?: string
- num_leds?: number
- gpio_pin?: number
- }
- interface DWLedsStatus {
- connected: boolean
- power_on: boolean
- brightness: number
- speed: number
- intensity: number
- current_effect: number
- current_palette: number
- num_leds: number
- gpio_pin: number
- colors: string[]
- error?: string
- }
- interface EffectSettings {
- effect_id: number
- palette_id: number
- speed: number
- intensity: number
- color1: string
- color2: string
- color3: string
- }
- export function LEDPage() {
- const [ledConfig, setLedConfig] = useState<LedConfig | null>(null)
- const [isLoading, setIsLoading] = useState(true)
- // DW LEDs state
- const [dwStatus, setDwStatus] = useState<DWLedsStatus | null>(null)
- const [effects, setEffects] = useState<[number, string][]>([])
- const [palettes, setPalettes] = useState<[number, string][]>([])
- const [brightness, setBrightness] = useState(35)
- const [speed, setSpeed] = useState(128)
- const [intensity, setIntensity] = useState(128)
- const [selectedEffect, setSelectedEffect] = useState('')
- const [selectedPalette, setSelectedPalette] = useState('')
- const [color1, setColor1] = useState('#ff0000')
- const [color2, setColor2] = useState('#000000')
- const [color3, setColor3] = useState('#0000ff')
- // Effect automation state
- const [idleEffect, setIdleEffect] = useState<EffectSettings | null>(null)
- const [playingEffect, setPlayingEffect] = useState<EffectSettings | null>(null)
- const [idleTimeoutEnabled, setIdleTimeoutEnabled] = useState(false)
- const [idleTimeoutMinutes, setIdleTimeoutMinutes] = useState(30)
- // Fetch LED configuration
- useEffect(() => {
- const fetchConfig = async () => {
- try {
- const response = await fetch('/get_led_config')
- const data = await response.json()
- // Map backend response fields to our interface
- setLedConfig({
- provider: data.provider || 'none',
- wled_ip: data.wled_ip,
- num_leds: data.dw_led_num_leds,
- gpio_pin: data.dw_led_gpio_pin,
- })
- } catch (error) {
- console.error('Error fetching LED config:', error)
- } finally {
- setIsLoading(false)
- }
- }
- fetchConfig()
- }, [])
- // Initialize DW LEDs when provider is dw_leds
- useEffect(() => {
- if (ledConfig?.provider === 'dw_leds') {
- fetchDWLedsStatus()
- fetchEffectsAndPalettes()
- fetchEffectSettings()
- fetchIdleTimeout()
- }
- }, [ledConfig])
- const fetchDWLedsStatus = async () => {
- try {
- const response = await fetch('/api/dw_leds/status')
- const data = await response.json()
- setDwStatus(data)
- if (data.connected) {
- setBrightness(data.brightness || 35)
- setSpeed(data.speed || 128)
- setIntensity(data.intensity || 128)
- setSelectedEffect(String(data.current_effect || 0))
- setSelectedPalette(String(data.current_palette || 0))
- if (data.colors) {
- setColor1(data.colors[0] || '#ff0000')
- setColor2(data.colors[1] || '#000000')
- setColor3(data.colors[2] || '#0000ff')
- }
- }
- } catch (error) {
- console.error('Error fetching DW LEDs status:', error)
- }
- }
- const fetchEffectsAndPalettes = async () => {
- try {
- const [effectsRes, palettesRes] = await Promise.all([
- fetch('/api/dw_leds/effects'),
- fetch('/api/dw_leds/palettes'),
- ])
- const effectsData = await effectsRes.json()
- const palettesData = await palettesRes.json()
- if (effectsData.effects) {
- const sorted = [...effectsData.effects].sort((a, b) => a[1].localeCompare(b[1]))
- setEffects(sorted)
- }
- if (palettesData.palettes) {
- const sorted = [...palettesData.palettes].sort((a, b) => a[1].localeCompare(b[1]))
- setPalettes(sorted)
- }
- } catch (error) {
- console.error('Error fetching effects/palettes:', error)
- }
- }
- const fetchEffectSettings = async () => {
- try {
- const response = await fetch('/api/dw_leds/get_effect_settings')
- const data = await response.json()
- setIdleEffect(data.idle_effect || null)
- setPlayingEffect(data.playing_effect || null)
- } catch (error) {
- console.error('Error fetching effect settings:', error)
- }
- }
- const fetchIdleTimeout = async () => {
- try {
- const response = await fetch('/api/dw_leds/idle_timeout')
- const data = await response.json()
- setIdleTimeoutEnabled(data.enabled || false)
- setIdleTimeoutMinutes(data.minutes || 30)
- } catch (error) {
- console.error('Error fetching idle timeout:', error)
- }
- }
- const handlePowerToggle = async () => {
- try {
- const response = await fetch('/api/dw_leds/power', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ state: 2 }), // Toggle
- })
- const data = await response.json()
- if (data.connected) {
- toast.success(`Power ${data.power_on ? 'ON' : 'OFF'}`)
- await fetchDWLedsStatus()
- } else {
- toast.error(data.error || 'Failed to toggle power')
- }
- } catch (error) {
- toast.error('Failed to toggle power')
- }
- }
- const handleBrightnessChange = useCallback(async (value: number[]) => {
- setBrightness(value[0])
- }, [])
- const handleBrightnessCommit = async (value: number[]) => {
- try {
- const response = await fetch('/api/dw_leds/brightness', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ value: value[0] }),
- })
- const data = await response.json()
- if (data.connected) {
- toast.success(`Brightness: ${value[0]}%`)
- }
- } catch (error) {
- toast.error('Failed to set brightness')
- }
- }
- const handleSpeedChange = useCallback((value: number[]) => {
- setSpeed(value[0])
- }, [])
- const handleSpeedCommit = async (value: number[]) => {
- try {
- await fetch('/api/dw_leds/speed', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ speed: value[0] }),
- })
- toast.success(`Speed: ${value[0]}`)
- } catch (error) {
- toast.error('Failed to set speed')
- }
- }
- const handleIntensityChange = useCallback((value: number[]) => {
- setIntensity(value[0])
- }, [])
- const handleIntensityCommit = async (value: number[]) => {
- try {
- await fetch('/api/dw_leds/intensity', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ intensity: value[0] }),
- })
- toast.success(`Intensity: ${value[0]}`)
- } catch (error) {
- toast.error('Failed to set intensity')
- }
- }
- const handleEffectChange = async (value: string) => {
- setSelectedEffect(value)
- try {
- const response = await fetch('/api/dw_leds/effect', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ effect_id: parseInt(value) }),
- })
- const data = await response.json()
- if (data.connected) {
- toast.success('Effect changed')
- if (data.power_on !== undefined) {
- setDwStatus((prev) => prev ? { ...prev, power_on: data.power_on } : null)
- }
- }
- } catch (error) {
- toast.error('Failed to set effect')
- }
- }
- const handlePaletteChange = async (value: string) => {
- setSelectedPalette(value)
- try {
- const response = await fetch('/api/dw_leds/palette', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ palette_id: parseInt(value) }),
- })
- const data = await response.json()
- if (data.connected) {
- toast.success('Palette changed')
- }
- } catch (error) {
- toast.error('Failed to set palette')
- }
- }
- const handleColorChange = async (slot: 1 | 2 | 3, value: string) => {
- if (slot === 1) setColor1(value)
- else if (slot === 2) setColor2(value)
- else setColor3(value)
- // Debounce color changes
- try {
- const hexToRgb = (hex: string) => {
- const r = parseInt(hex.slice(1, 3), 16)
- const g = parseInt(hex.slice(3, 5), 16)
- const b = parseInt(hex.slice(5, 7), 16)
- return [r, g, b]
- }
- const payload: Record<string, number[]> = {}
- payload[`color${slot}`] = hexToRgb(value)
- await fetch('/api/dw_leds/colors', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(payload),
- })
- } catch (error) {
- console.error('Failed to set color:', error)
- }
- }
- const saveCurrentEffectSettings = async (type: 'idle' | 'playing') => {
- try {
- const settings = {
- type,
- effect_id: parseInt(selectedEffect) || 0,
- palette_id: parseInt(selectedPalette) || 0,
- speed,
- intensity,
- color1,
- color2,
- color3,
- }
- await fetch('/api/dw_leds/save_effect_settings', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(settings),
- })
- toast.success(`${type.charAt(0).toUpperCase() + type.slice(1)} effect saved`)
- await fetchEffectSettings()
- } catch (error) {
- toast.error(`Failed to save ${type} effect`)
- }
- }
- const clearEffectSettings = async (type: 'idle' | 'playing') => {
- try {
- await fetch('/api/dw_leds/clear_effect_settings', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ type }),
- })
- toast.success(`${type.charAt(0).toUpperCase() + type.slice(1)} effect cleared`)
- await fetchEffectSettings()
- } catch (error) {
- toast.error(`Failed to clear ${type} effect`)
- }
- }
- const saveIdleTimeout = async (enabled?: boolean, minutes?: number) => {
- const finalEnabled = enabled !== undefined ? enabled : idleTimeoutEnabled
- const finalMinutes = minutes !== undefined ? minutes : idleTimeoutMinutes
- try {
- await fetch('/api/dw_leds/idle_timeout', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ enabled: finalEnabled, minutes: finalMinutes }),
- })
- toast.success(`Idle timeout ${finalEnabled ? 'enabled' : 'disabled'}`)
- } catch (error) {
- toast.error('Failed to save idle timeout')
- }
- }
- const handleIdleTimeoutToggle = async (checked: boolean) => {
- setIdleTimeoutEnabled(checked)
- await saveIdleTimeout(checked, idleTimeoutMinutes)
- }
- const formatEffectSettings = (settings: EffectSettings | null) => {
- if (!settings) return 'Not configured'
- const effectName = effects.find((e) => e[0] === settings.effect_id)?.[1] || settings.effect_id
- const paletteName = palettes.find((p) => p[0] === settings.palette_id)?.[1] || settings.palette_id
- return `${effectName} | ${paletteName} | Speed: ${settings.speed} | Intensity: ${settings.intensity}`
- }
- // Loading state
- if (isLoading) {
- return (
- <div className="flex items-center justify-center min-h-[60vh]">
- <span className="material-icons-outlined animate-spin text-4xl text-muted-foreground">
- sync
- </span>
- </div>
- )
- }
- // Not configured state
- if (!ledConfig || ledConfig.provider === 'none') {
- return (
- <div className="flex flex-col items-center justify-center min-h-[60vh] gap-6 text-center px-4">
- <div className="p-4 rounded-full bg-muted">
- <span className="material-icons-outlined text-5xl text-muted-foreground">
- lightbulb
- </span>
- </div>
- <div className="space-y-2">
- <h1 className="text-2xl font-bold">LED Controller Not Configured</h1>
- <p className="text-muted-foreground max-w-md">
- Configure your LED controller (WLED or DW LEDs) in the Settings page to control your lights.
- </p>
- </div>
- <Button asChild className="gap-2">
- <Link to="/settings?section=led">
- <span className="material-icons-outlined">settings</span>
- Go to Settings
- </Link>
- </Button>
- </div>
- )
- }
- // WLED iframe view
- if (ledConfig.provider === 'wled' && ledConfig.wled_ip) {
- return (
- <div className="flex flex-col w-full h-[calc(100vh-180px)] py-4">
- <iframe
- src={`http://${ledConfig.wled_ip}`}
- className="w-full h-full rounded-lg border border-border"
- title="WLED Control"
- />
- </div>
- )
- }
- // DW LEDs control panel
- return (
- <div className="flex flex-col w-full max-w-5xl mx-auto gap-6 py-6 px-4">
- {/* Page Header */}
- <div className="space-y-1">
- <h1 className="text-3xl font-bold tracking-tight">LED Control</h1>
- <p className="text-muted-foreground">DW LEDs - GPIO controlled LED strip</p>
- </div>
- <Separator />
- {/* Main Control Grid - 2 columns on large screens */}
- <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
- {/* Left Column - Primary Controls */}
- <div className="lg:col-span-2 space-y-6">
- {/* Power & Status Card */}
- <Card>
- <CardContent className="pt-6">
- <div className="flex flex-col sm:flex-row items-center gap-6">
- {/* Power Button - Large and prominent */}
- <div className="flex flex-col items-center gap-3">
- <button
- onClick={handlePowerToggle}
- className={`w-24 h-24 rounded-full flex items-center justify-center transition-all shadow-lg ${
- dwStatus?.power_on
- ? 'bg-green-500 hover:bg-green-600 shadow-green-500/30'
- : 'bg-muted hover:bg-muted/80'
- }`}
- >
- <span className={`material-icons text-4xl ${dwStatus?.power_on ? 'text-white' : 'text-muted-foreground'}`}>
- power_settings_new
- </span>
- </button>
- <span className={`text-sm font-medium ${dwStatus?.power_on ? 'text-green-600' : 'text-muted-foreground'}`}>
- {dwStatus?.power_on ? 'ON' : 'OFF'}
- </span>
- </div>
- {/* Status & Brightness */}
- <div className="flex-1 w-full space-y-4">
- {/* Connection Status */}
- <div className={`flex items-center gap-2 text-sm ${dwStatus?.connected ? 'text-green-600' : 'text-destructive'}`}>
- <span className="material-icons-outlined text-base">
- {dwStatus?.connected ? 'check_circle' : 'error'}
- </span>
- {dwStatus?.connected
- ? `${dwStatus.num_leds} LEDs on GPIO ${dwStatus.gpio_pin}`
- : 'Not connected'}
- </div>
- {/* Brightness Slider */}
- <div className="space-y-2">
- <div className="flex justify-between">
- <Label className="flex items-center gap-2">
- <span className="material-icons-outlined text-base text-muted-foreground">brightness_6</span>
- Brightness
- </Label>
- <span className="text-sm font-medium">{brightness}%</span>
- </div>
- <Slider
- value={[brightness]}
- onValueChange={handleBrightnessChange}
- onValueCommit={handleBrightnessCommit}
- max={100}
- step={1}
- />
- </div>
- </div>
- </div>
- </CardContent>
- </Card>
- {/* Effects Card */}
- <Card>
- <CardHeader className="pb-3">
- <CardTitle className="text-lg flex items-center gap-2">
- <span className="material-icons-outlined text-muted-foreground">auto_awesome</span>
- Effects & Palettes
- </CardTitle>
- </CardHeader>
- <CardContent className="space-y-6">
- {/* Effect & Palette Selects */}
- <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
- <div className="space-y-2">
- <Label>Effect</Label>
- <Select value={selectedEffect} onValueChange={handleEffectChange}>
- <SelectTrigger>
- <SelectValue placeholder="Select effect..." />
- </SelectTrigger>
- <SelectContent>
- {effects.map(([id, name]) => (
- <SelectItem key={id} value={String(id)}>
- {name}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- </div>
- <div className="space-y-2">
- <Label>Palette</Label>
- <Select value={selectedPalette} onValueChange={handlePaletteChange}>
- <SelectTrigger>
- <SelectValue placeholder="Select palette..." />
- </SelectTrigger>
- <SelectContent>
- {palettes.map(([id, name]) => (
- <SelectItem key={id} value={String(id)}>
- {name}
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- </div>
- </div>
- {/* Speed and Intensity in styled boxes */}
- <div className="grid grid-cols-2 gap-4">
- <div className="p-4 bg-muted/50 rounded-lg space-y-3">
- <div className="flex justify-between items-center">
- <Label className="flex items-center gap-2">
- <span className="material-icons-outlined text-base text-muted-foreground">speed</span>
- Speed
- </Label>
- <span className="text-sm font-medium">{speed}</span>
- </div>
- <Slider
- value={[speed]}
- onValueChange={handleSpeedChange}
- onValueCommit={handleSpeedCommit}
- max={255}
- step={1}
- />
- </div>
- <div className="p-4 bg-muted/50 rounded-lg space-y-3">
- <div className="flex justify-between items-center">
- <Label className="flex items-center gap-2">
- <span className="material-icons-outlined text-base text-muted-foreground">tungsten</span>
- Intensity
- </Label>
- <span className="text-sm font-medium">{intensity}</span>
- </div>
- <Slider
- value={[intensity]}
- onValueChange={handleIntensityChange}
- onValueCommit={handleIntensityCommit}
- max={255}
- step={1}
- />
- </div>
- </div>
- </CardContent>
- </Card>
- </div>
- {/* Right Column - Colors & Quick Settings */}
- <div className="flex flex-col gap-6">
- {/* Colors Card */}
- <Card className="flex-1 flex flex-col">
- <CardHeader className="pb-3">
- <CardTitle className="text-lg flex items-center gap-2">
- <span className="material-icons-outlined text-muted-foreground">palette</span>
- Colors
- </CardTitle>
- </CardHeader>
- <CardContent className="flex-1 flex items-center justify-center">
- <div className="flex justify-around w-full">
- <div className="flex flex-col items-center gap-2">
- <ColorPicker
- value={color1}
- onChange={(color) => handleColorChange(1, color)}
- />
- <span className="text-xs text-muted-foreground">Primary</span>
- </div>
- <div className="flex flex-col items-center gap-2">
- <ColorPicker
- value={color2}
- onChange={(color) => handleColorChange(2, color)}
- />
- <span className="text-xs text-muted-foreground">Secondary</span>
- </div>
- <div className="flex flex-col items-center gap-2">
- <ColorPicker
- value={color3}
- onChange={(color) => handleColorChange(3, color)}
- />
- <span className="text-xs text-muted-foreground">Accent</span>
- </div>
- </div>
- </CardContent>
- </Card>
- {/* Auto Turn Off */}
- <Card className="flex-1 flex flex-col">
- <CardHeader className="pb-3">
- <CardTitle className="text-lg flex items-center gap-2">
- <span className="material-icons-outlined text-muted-foreground">schedule</span>
- Auto Turn Off
- </CardTitle>
- </CardHeader>
- <CardContent className="flex-1 flex flex-col justify-center space-y-4">
- <div className="flex items-center justify-between">
- <span className="text-sm text-muted-foreground">Enable timeout</span>
- <Switch
- checked={idleTimeoutEnabled}
- onCheckedChange={handleIdleTimeoutToggle}
- />
- </div>
- {idleTimeoutEnabled && (
- <div className="flex items-center gap-2">
- <Input
- type="number"
- value={idleTimeoutMinutes}
- onChange={(e) => setIdleTimeoutMinutes(parseInt(e.target.value) || 30)}
- min={1}
- max={1440}
- className="w-20"
- />
- <span className="text-sm text-muted-foreground flex-1">minutes</span>
- <Button size="sm" onClick={() => saveIdleTimeout()}>
- Save
- </Button>
- </div>
- )}
- </CardContent>
- </Card>
- </div>
- </div>
- {/* Automation Settings - Full Width */}
- <Card>
- <CardHeader className="pb-3">
- <CardTitle className="text-lg flex items-center gap-2">
- <span className="material-icons-outlined text-muted-foreground">smart_toy</span>
- Effect Automation
- </CardTitle>
- <CardDescription>
- Save current settings to automatically apply when table state changes
- </CardDescription>
- </CardHeader>
- <CardContent>
- <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
- {/* Playing Effect */}
- <div className="p-4 bg-muted/50 rounded-lg space-y-3">
- <div className="flex items-center justify-between">
- <div className="flex items-center gap-2">
- <span className="material-icons text-green-600">play_circle</span>
- <span className="font-medium">While Playing</span>
- </div>
- </div>
- <div className="text-xs text-muted-foreground p-2 bg-background rounded border min-h-[40px]">
- {formatEffectSettings(playingEffect)}
- </div>
- <div className="flex gap-2">
- <Button
- size="sm"
- onClick={() => saveCurrentEffectSettings('playing')}
- className="flex-1 gap-1"
- >
- <span className="material-icons text-sm">save</span>
- Save Current
- </Button>
- <Button
- size="sm"
- variant="outline"
- onClick={() => clearEffectSettings('playing')}
- >
- Clear
- </Button>
- </div>
- </div>
- {/* Idle Effect */}
- <div className="p-4 bg-muted/50 rounded-lg space-y-3">
- <div className="flex items-center justify-between">
- <div className="flex items-center gap-2">
- <span className="material-icons text-blue-600">bedtime</span>
- <span className="font-medium">When Idle</span>
- </div>
- </div>
- <div className="text-xs text-muted-foreground p-2 bg-background rounded border min-h-[40px]">
- {formatEffectSettings(idleEffect)}
- </div>
- <div className="flex gap-2">
- <Button
- size="sm"
- onClick={() => saveCurrentEffectSettings('idle')}
- className="flex-1 gap-1"
- >
- <span className="material-icons text-sm">save</span>
- Save Current
- </Button>
- <Button
- size="sm"
- variant="outline"
- onClick={() => clearEffectSettings('idle')}
- >
- Clear
- </Button>
- </div>
- </div>
- </div>
- </CardContent>
- </Card>
- </div>
- )
- }
|