import { type JrpcRequest, type JrpcResponse, newJrpcRequestId } from "@eva/jrpc"
import { ZIGBEE_DEVICE, type ZigbeeDeviceName } from "@eva/zigbee"
import { useQuery } from "@tanstack/react-query"
import Chart from "chart.js/auto"
import { useStore } from "jotai"
import { useEffect, useLayoutEffect, useRef, useState } from "react"
import { beszelSystemsQuery } from "./beszel"
import cn from "./components/lib/cn"
import { Tile } from "./components/tile"
import {
LightControlTile,
type LightSceneConfig,
LightSceneTile,
brightnessStepAtoms,
brightnessToStep,
stepToBrightness,
} from "./light-control"
import { StatusSeverity, TubeLine, formatLineName, tflDisruptionsQuery } from "./tfl"
import {
DEFAULT_LATITUDE,
DEFAULT_LONGITUDE,
currentWeatherQuery,
dailyForecastQuery,
getWeatherIcon,
weatherDescriptionQuery,
} from "./weather"
function App() {
const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:"
const wsHost = import.meta.env.VITE_API_HOST || window.location.host
const websocket = useRef(new WebSocket(`${wsProtocol}//${wsHost}/api/zigbee`))
const store = useStore()
useEffect(() => {
const ws = websocket.current
ws.onopen = () => {
console.log("WebSocket connected")
}
ws.onerror = (error) => {
console.error("WebSocket error:", error)
}
ws.onclose = () => {
console.log("WebSocket disconnected")
}
ws.onmessage = (event) => {
const data = JSON.parse(event.data) as JrpcRequest | JrpcResponse
if ("method" in data) {
switch (data.method) {
case "showDeviceState": {
const { deviceName, state } = data.params
const brightnessStepAtom = store.get(brightnessStepAtoms)[deviceName]
store.set(brightnessStepAtom, brightnessToStep(state.brightness))
}
}
}
}
return () => {
if (ws.readyState === WebSocket.OPEN) {
ws.close()
}
}
}, [store])
function setBrightnessStep(deviceName: ZigbeeDeviceName, step: number) {
const ws = websocket.current
if (ws.readyState !== WebSocket.OPEN) {
console.warn("WebSocket is not open. Current state:", ws.readyState)
return
}
const brightness = stepToBrightness(step)
const req: JrpcRequest<"setDeviceState"> = {
id: newJrpcRequestId(),
jsonrpc: "2.0",
method: "setDeviceState",
params: {
deviceName,
state: step === 0 ? { state: "OFF", brightness: 0 } : { state: "ON", brightness },
},
}
ws.send(JSON.stringify(req))
}
function setScene(scene: LightSceneConfig) {
const ws = websocket.current
for (const [deviceName, state] of Object.entries(scene.deviceStates)) {
const req: JrpcRequest<"setDeviceState"> = {
id: newJrpcRequestId(),
jsonrpc: "2.0",
method: "setDeviceState",
params: {
deviceName: deviceName as ZigbeeDeviceName,
state,
},
}
ws.send(JSON.stringify(req))
}
}
return (
{
setScene(scene)
}}
/>
{
setBrightnessStep(ZIGBEE_DEVICE.livingRoomFloorLamp, step)
}}
/>
{
setBrightnessStep(ZIGBEE_DEVICE.deskLamp, step)
}}
/>
)
}
function DateTimeTile() {
const [time, setTime] = useState(new Date())
const formattedDate = time.toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
})
const formattedTime = time.toLocaleTimeString("en-US", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})
useEffect(() => {
const interval = setInterval(() => {
setTime(new Date())
}, 1000)
return () => clearInterval(interval)
}, [])
return (
{formattedDate}
{formattedTime}
)
}
function WeatherTile() {
const {
data: currentWeatherData,
isLoading: isLoadingCurrentWeather,
error: errorCurrentWeather,
} = useQuery({
...currentWeatherQuery(DEFAULT_LATITUDE, DEFAULT_LONGITUDE),
refetchInterval: 5 * 60 * 1000, // 5 minutes
refetchIntervalInBackground: true,
})
const {
data: dailyForecastData,
isLoading: isLoadingDailyForecast,
error: errorDailyForecast,
} = useQuery({
...dailyForecastQuery(DEFAULT_LATITUDE, DEFAULT_LONGITUDE),
refetchInterval: 5 * 60 * 1000, // 5 minutes
refetchIntervalInBackground: true,
})
const {
data: weatherDescriptionData,
isLoading: isLoadingWeatherDescription,
error: errorWeatherDescription,
} = useQuery({
...weatherDescriptionQuery(DEFAULT_LATITUDE, DEFAULT_LONGITUDE),
refetchInterval: 60 * 60 * 1000, // 1 hour
refetchIntervalInBackground: true,
})
const isLoading = isLoadingCurrentWeather || isLoadingDailyForecast
const error = errorCurrentWeather || errorDailyForecast
if (isLoading) {
return (
Loading weather
)
}
if (error || !currentWeatherData?.currentWeather) {
return (
Error loading weather
{error?.message ?? "Unknown error"}
)
}
const currentWeather = currentWeatherData.currentWeather
const temperature = Math.round(currentWeather.temperature)
const lowTemp = Math.round(dailyForecastData?.forecastDaily?.days[0].temperatureMin ?? 0)
const highTemp = Math.round(dailyForecastData?.forecastDaily?.days[0].temperatureMax ?? 0)
const percentage = lowTemp && highTemp ? (temperature - lowTemp) / (highTemp - lowTemp) : 0
const highlightIndexStart = Math.floor((1 - percentage) * 23)
const WeatherIcon = getWeatherIcon(currentWeather.conditionCode)
let weatherDescriptionContent: string
if (isLoadingWeatherDescription) {
weatherDescriptionContent = "Loading weather description"
} else if (errorWeatherDescription) {
weatherDescriptionContent = `Error: ${errorWeatherDescription.message}`
} else if (!weatherDescriptionData?.description) {
weatherDescriptionContent = "No weather description available"
} else {
weatherDescriptionContent = weatherDescriptionData.description
}
return (
H:{highTemp}°
L:{lowTemp}°
{Array.from({ length: 24 }).map((_, index) => {
if (index === highlightIndexStart) {
return (
key={index}
className={cn("w-10 bg-teal-500 dark:bg-teal-400 h-[2px]")}
/>
)
}
return (
key={index}
className={cn(
"w-4",
index >= highlightIndexStart
? "bg-teal-500 dark:bg-teal-400 w-8 h-[2px]"
: "bg-neutral-400 w-4 h-[1px]",
)}
/>
)
})}
{weatherDescriptionContent}
)
}
function TFLTile({ className }: { className?: string }) {
const linesIDontCareAbout = [
TubeLine.WaterlooCity,
TubeLine.Windrush,
TubeLine.Lioness,
TubeLine.Lioness,
TubeLine.Tram,
TubeLine.Mildmay,
]
const {
data: tflData,
isLoading: isLoadingTFL,
error: errorTFL,
} = useQuery({
...tflDisruptionsQuery(),
select: (data) => {
data.disruptions.sort((a, b) => {
if (a.lineName.match(/northern/i)) return -1
if (b.lineName.match(/northern/i)) return 1
return a.statusSeverity - b.statusSeverity
})
data.disruptions = data.disruptions.filter((disruption) => !linesIDontCareAbout.includes(disruption.lineId))
return data
},
refetchInterval: 5 * 60 * 1000, // 5 minutes
refetchIntervalInBackground: true,
})
if (isLoadingTFL) {
return (
Loading tube status
)
}
if (errorTFL) {
return (
Error loading from TfL
{errorTFL?.message}
)
}
if (!tflData) {
return (
No TfL data available
)
}
return (
{tflData.goodService.includes("Northern") && (
)}
{tflData.disruptions.map((disruption) => (
))}
)
}
function TFLDistruptionItem({ lineId, reason, severity }: { lineId: TubeLine; reason: string; severity: number }) {
const lineName = formatLineName(lineId)
let lineStyleClass: string
switch (lineId) {
case "bakerloo":
lineStyleClass = "bg-amber-700"
break
case "central":
lineStyleClass = "bg-red-600"
break
case "circle":
lineStyleClass = "bg-yellow-400 text-neutral-900"
break
case "district":
lineStyleClass = "bg-green-600"
break
case "hammersmith-city":
lineStyleClass = "bg-pink-400"
break
case "jubilee":
lineStyleClass = "bg-slate-500"
break
case "metropolitan":
lineStyleClass = "bg-purple-800"
break
case "northern":
lineStyleClass = "bg-black"
break
case "piccadilly":
lineStyleClass = "bg-blue-900"
break
case "victoria":
lineStyleClass = "bg-sky-500"
break
case "waterloo-city":
lineStyleClass = "bg-teal-500"
break
case "london-overground":
lineStyleClass = "bg-orange-500"
break
case "dlr":
lineStyleClass = "bg-teal-600"
break
case "elizabeth":
lineStyleClass = "bg-purple-600"
break
case "tram":
lineStyleClass = "bg-green-500"
break
default:
lineStyleClass = "bg-gray-500"
break
}
let statusBorderClass: string
switch (severity) {
case StatusSeverity.GoodService:
statusBorderClass = "border-green-500"
break
case StatusSeverity.MinorDelays:
statusBorderClass = "border-yellow-500"
break
case StatusSeverity.Suspended:
statusBorderClass = "border-red-600"
break
case StatusSeverity.PartSuspended:
statusBorderClass = "border-red-500"
break
case StatusSeverity.PlannedClosure:
statusBorderClass = "border-orange-600"
break
case StatusSeverity.PartClosure:
statusBorderClass = "border-yellow-500"
break
case StatusSeverity.SevereDelays:
statusBorderClass = "border-red-500"
break
case StatusSeverity.ReducedService:
statusBorderClass = "border-orange-500"
break
case StatusSeverity.BusService:
statusBorderClass = "border-blue-500"
break
case StatusSeverity.Information:
statusBorderClass = "border-blue-400"
break
case StatusSeverity.ServiceClosed:
statusBorderClass = "border-red-700"
break
default:
statusBorderClass = "border-gray-400"
break
}
return (
<>
{reason}
>
)
}
function SystemTile({
className,
systemName,
displayName,
}: { className?: string; systemName: string; displayName: string }) {
const { data } = useQuery({
...beszelSystemsQuery(),
refetchInterval: 1000,
refetchIntervalInBackground: true,
})
const chartRef = useRef
(null)
const beszelSystemsData = data?.systems.find((system) => system.name === systemName)
const onCanvasRef = (elem: HTMLCanvasElement | null) => {
if (!elem || chartRef.current) return
const fillGradient = elem?.getContext("2d")?.createLinearGradient(0, 0, 0, elem.height)
fillGradient?.addColorStop(0, "#2dd4bf")
fillGradient?.addColorStop(0.5, "rgba(45, 212, 191, 0)")
fillGradient?.addColorStop(1, "rgba(45, 212, 191, 0)")
chartRef.current = new Chart(elem, {
type: "line",
data: {
labels: Array.from({ length: 20 }, (_, index) => index),
datasets: [
{
data: Array.from({ length: 20 }, (_, __) => null),
fill: true,
backgroundColor: fillGradient,
borderColor: "#2dd4bf",
tension: 0.1,
},
],
},
options: {
responsive: true,
scales: {
x: { display: false },
y: { display: false, min: 0, max: 100 },
},
maintainAspectRatio: false,
elements: {
point: { radius: 0 },
line: {
backgroundColor: "rgba(255, 255, 255, 0.5)",
},
},
plugins: {
legend: {
display: false,
},
},
},
})
}
useLayoutEffect(() => {
const cpu = beszelSystemsData?.info.cpu
if (!chartRef.current || cpu === undefined) return
const dataset = chartRef.current.data.datasets[0]
const nextData = Array.from({ length: 20 }, (_, i) => {
if (i === 19) {
return null
}
return dataset.data[i + 1]
})
nextData[19] = cpu
dataset.data = nextData
chartRef.current.update()
})
if (!beszelSystemsData) {
return (
No system status available
)
}
return (
CPU
{beszelSystemsData.info.cpu.toFixed(0).padStart(3, "0")}
RAM
{beszelSystemsData.info.ram.toFixed(0).padStart(3, "0")}
DSK
{beszelSystemsData.info.disk.toFixed(0).padStart(3, "0")}
)
}
export default App