Compare commits

...

26 Commits

Author SHA1 Message Date
bdd1cae5ae fix(dashboard): fix temperature gauge positioning when low temp is 0
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 1m6s
2025-11-19 10:34:47 +00:00
Ona
3af86d80c7 fix(backend): add token expiration handling for beszel auth
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 1m18s
- Track token expiry time to prevent using expired tokens
- Set token lifetime to 50 minutes (safe margin before 1hr expiry)
- Add comprehensive logging for auth events and errors
- Improve error responses with status text
- Fixes system tiles stopping after ~1 hour of operation

Co-authored-by: Ona <no-reply@ona.com>
2025-11-17 22:48:48 +00:00
8093f563f9 fix(dashboard): make kuromi wider
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 1m5s
2025-11-01 01:29:06 +00:00
0db96869e1 feat(dashboard): add kuromi animation
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 2m0s
2025-11-01 01:12:57 +00:00
96d59c763d fix(dashboard): light control text color
Co-authored-by: Ona <no-reply@ona.com>
2025-11-01 01:12:40 +00:00
b70d768eee fix(dashboard): northern line label color 2025-10-31 23:28:18 +00:00
7fcbf1398a feat(dashboard): add ram tracking to graph
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 1m2s
Co-authored-by: Ona <no-reply@ona.com>
2025-10-31 02:22:11 +00:00
17d6ee234d feat(dashboard): update system tile styling 2025-10-31 02:18:35 +00:00
ac4eaa83e0 feat(dashboard): update tfl tile styling 2025-10-31 02:15:11 +00:00
475e88bffb feat(dashboard): make brightness slider shorter
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 1m3s
2025-10-31 02:07:38 +00:00
9edda5808f feat(dashboard): auto light/dark mode
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 1m13s
impelemnt auto light/dark mode based on lat/lon

Co-authored-by: Ona <no-reply@ona.com>
2025-10-31 01:58:57 +00:00
30cd1c2815 feat(dashboard): reflect system up/down status
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 1m0s
Co-authored-by: Ona <no-reply@ona.com>
2025-10-31 01:45:32 +00:00
ee95be1bb3 feat(backend): expose system up/down status 2025-10-31 01:36:19 +00:00
9c46cdf9cf feat(dashboard): adjust scene label style
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 1m1s
2025-10-31 01:34:11 +00:00
4e47111a4d fix(dashboard): dont set brightness when sliding
Co-authored-by: Ona <no-reply@ona.com>
2025-10-31 01:33:57 +00:00
9624eab798 fix(dashboard: weather tile spacing
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 1m8s
2025-10-31 01:26:13 +00:00
1a2bea01d1 feat(dashboard): implement light scenes
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 1m9s
2025-10-30 02:49:17 +00:00
5db9d6b139 fix(dashboard): add left padding 2 weather summary 2025-10-30 01:51:28 +00:00
045cfb46ee fix(dashboard): brightness slider double thumb
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 1m8s
instead of storing brightness as 0-100%, store brightness "steps"
instead that equal to the number of bars where step 0 = off

Co-authored-by: Ona <no-reply@ona.com>
2025-10-30 01:33:02 +00:00
e168f3ad4a fix(dashboard): show device friendly names 2025-10-30 00:33:07 +00:00
411c5648df build: fix backend build issue
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 58s
should copy from deps stage when copying packages node_modules
2025-10-30 00:28:35 +00:00
60b4cd79a7 build: fix backend build issue
Some checks failed
Build and Publish Docker Image / build-and-push (push) Failing after 26s
forgot to also copy packages node_modules in backend build stage
2025-10-30 00:26:02 +00:00
ba176d2ec1 build: fix dashboard build issue
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 1m10s
forgot to copy packages node_modules when in dashboard build stage
2025-10-30 00:20:16 +00:00
fb1fa642af fix(dashboard): dont use crypto randomUUID
Some checks failed
Build and Publish Docker Image / build-and-push (push) Failing after 2m38s
use nanoid for jrpc request id generation instead

Co-authored-by: Ona <no-reply@ona.com>
2025-10-30 00:13:08 +00:00
4478cdbcb3 fix(dashboard): add more websocket related logs
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 1m8s
2025-10-29 23:10:25 +00:00
866fd0eacc feat(dashboard): improve brightness slider anim
Co-authored-by: Ona <no-reply@ona.com>
2025-10-29 23:09:51 +00:00
19 changed files with 1336 additions and 303 deletions

View File

@@ -28,6 +28,8 @@ ENV VITE_DEFAULT_LONGITUDE=${VITE_DEFAULT_LONGITUDE}
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/dashboard/node_modules ./apps/dashboard/node_modules
COPY --from=deps /app/packages/jrpc/node_modules ./packages/jrpc/node_modules
COPY --from=deps /app/packages/zigbee/node_modules ./packages/zigbee/node_modules
COPY apps/dashboard ./apps/dashboard
COPY packages/jrpc ./packages/jrpc
COPY packages/zigbee ./packages/zigbee
@@ -49,7 +51,9 @@ COPY apps/backend/package.json /app/apps/backend/
# Copy workspace packages
COPY packages/jrpc /app/packages/jrpc
COPY --from=deps /app/packages/jrpc/node_modules /app/packages/jrpc/node_modules
COPY packages/zigbee /app/packages/zigbee
COPY --from=deps /app/packages/zigbee/node_modules /app/packages/zigbee/node_modules
# Copy backend dependencies
COPY --from=deps /app/node_modules /app/node_modules

View File

@@ -8,6 +8,7 @@ beszel.use("*", beszelAuth())
interface BeszelSystemInfo {
name: string
status: "up" | "down"
info: {
cpu: number
ram: number
@@ -17,6 +18,7 @@ interface BeszelSystemInfo {
interface BeszelApiSystem {
name: string
status: "up" | "down"
info: {
cpu: number
mp: number // memory percentage
@@ -30,6 +32,7 @@ beszel.get("/systems", async (c) => {
const token = c.get("beszelToken")
if (!beszelHost) {
console.error("[Beszel API] BESZEL_HOST environment variable not set")
return c.json({ error: "BESZEL_HOST environment variable not set" }, 500)
}
@@ -40,10 +43,17 @@ beszel.get("/systems", async (c) => {
})
if (!response.ok) {
const errorText = await response.text()
console.error(
`[Beszel API] Failed to fetch systems: ${response.status} ${response.statusText}`,
errorText ? `- ${errorText}` : "",
)
return new Response(
JSON.stringify({
error: "Failed to fetch Beszel data",
status: response.status,
statusText: response.statusText,
}),
{
status: response.status,
@@ -56,6 +66,7 @@ beszel.get("/systems", async (c) => {
const systems: BeszelSystemInfo[] = data.items.map((system) => ({
name: system.name,
status: system.status,
info: {
cpu: system.info.cpu,
ram: system.info.mp,
@@ -63,12 +74,15 @@ beszel.get("/systems", async (c) => {
},
}))
console.log(`[Beszel API] Successfully fetched ${systems.length} systems`)
return c.json({
lastUpdated: new Date().toISOString(),
systems,
totalSystems: systems.length,
})
} catch (error) {
console.error("[Beszel API] Internal server error:", error)
return c.json({ error: "Internal server error", message: String(error) }, 500)
}
})

View File

@@ -6,12 +6,26 @@ interface BeszelAuthResponse {
export function beszelAuth(): MiddlewareHandler {
let cachedToken: string | null = null
let tokenExpiry: number | null = null
// Token lifetime: 50 minutes (tokens typically expire after 1 hour, refresh before that)
const TOKEN_LIFETIME_MS = 50 * 60 * 1000
const authenticate = async (): Promise<string> => {
if (cachedToken) {
const now = Date.now()
// Return cached token if it exists and hasn't expired
if (cachedToken && tokenExpiry && now < tokenExpiry) {
return cachedToken
}
// Log re-authentication for debugging
if (cachedToken && tokenExpiry && now >= tokenExpiry) {
console.log("[Beszel Auth] Token expired, re-authenticating...")
} else {
console.log("[Beszel Auth] Initial authentication...")
}
const beszelHost = process.env.BESZEL_HOST
const beszelEmail = process.env.BESZEL_EMAIL
const beszelPassword = process.env.BESZEL_PASSWORD
@@ -34,11 +48,16 @@ export function beszelAuth(): MiddlewareHandler {
})
if (!response.ok) {
const errorText = await response.text()
console.error(`[Beszel Auth] Authentication failed: ${response.status} - ${errorText}`)
throw new Error(`Beszel authentication failed: ${response.status}`)
}
const data = (await response.json()) as BeszelAuthResponse
cachedToken = data.token
tokenExpiry = now + TOKEN_LIFETIME_MS
console.log(`[Beszel Auth] Authentication successful, token valid until ${new Date(tokenExpiry).toISOString()}`)
return cachedToken
}
@@ -49,6 +68,7 @@ export function beszelAuth(): MiddlewareHandler {
c.set("beszelToken", token)
await next()
} catch (error) {
console.error("[Beszel Auth] Middleware error:", error)
return c.json({ error: "Authentication failed", message: String(error) }, 500)
}
}

View File

@@ -1,4 +1,4 @@
import type { JrpcRequest, JrpcResponse } from "@eva/jrpc"
import { type JrpcRequest, type JrpcResponse, newJrpcRequestId } from "@eva/jrpc"
import { ALL_ZIGBEE_DEVICE_NAMES, type ZigbeeDeviceName, type ZigbeeDeviceState } from "@eva/zigbee"
import type { WSContext } from "hono/ws"
import type { DeviceMessageListener, ZigbeeController } from "./controller"
@@ -8,15 +8,17 @@ export class WebSocketHandler {
constructor(private readonly controller: ZigbeeController) {}
handleWebsocketOpen(event: Event, ws: WSContext) {
handleWebsocketOpen(_event: Event, ws: WSContext) {
for (const device of ALL_ZIGBEE_DEVICE_NAMES) {
const l: DeviceMessageListener = (msg) => {
const state = msg as ZigbeeDeviceState
const state = msg as ZigbeeDeviceState<typeof device>
const request: JrpcRequest<"showDeviceState"> = {
id: crypto.randomUUID(),
id: newJrpcRequestId(),
jsonrpc: "2.0",
method: "showDeviceState",
params: { deviceName: device, state },
params: { deviceName: device, state } as {
[K in ZigbeeDeviceName]: { deviceName: K; state: ZigbeeDeviceState<K> }
}[ZigbeeDeviceName],
}
ws.send(JSON.stringify(request))
}

View File

@@ -1,13 +1,23 @@
import type { JrpcRequest, JrpcResponse } from "@eva/jrpc"
import { type JrpcRequest, type JrpcResponse, newJrpcRequestId } from "@eva/jrpc"
import { ZIGBEE_DEVICE, type ZigbeeDeviceName } from "@eva/zigbee"
import { useQuery } from "@tanstack/react-query"
import { useDrag } from "@use-gesture/react"
import Chart from "chart.js/auto"
import { atom, useAtomValue, useSetAtom, useStore } from "jotai"
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 { Kuromi } from "./kuromi"
import {
LightControlTile,
type LightSceneConfig,
LightSceneTile,
brightnessStepAtoms,
brightnessToStep,
stepToBrightness,
} from "./light-control"
import { StatusSeverity, TubeLine, formatLineName, tflDisruptionsQuery } from "./tfl"
import { useAutoTheme } from "./use-auto-theme"
import {
DEFAULT_LATITUDE,
DEFAULT_LONGITUDE,
@@ -17,55 +27,102 @@ import {
weatherDescriptionQuery,
} from "./weather"
const brightnessAtoms = atom({
[ZIGBEE_DEVICE.deskLamp]: atom(0),
[ZIGBEE_DEVICE.livingRoomFloorLamp]: atom(0),
})
const intermediateBrightnessAtoms = atom({
[ZIGBEE_DEVICE.deskLamp]: atom(-1),
[ZIGBEE_DEVICE.livingRoomFloorLamp]: atom(-1),
})
const kuromi = `
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⣀⣀⣀⣀⢠⠋⠉⠉⠒⠲⢤⣀⣠⡀
⠀⠀⠀⠀⠀⠀⣀⣀⣀⢀⡠⠖⠋⠉⠀⠀⠀⠀⠉⠉⠢⣄⠀⠀⠀⢀⠼⠤⠇
⠀⠀⠀⣀⠔⠊⠁⠀⢨⠏⠀⠀⠀⣠⣶⣶⣦⠀⠀⠀⠀⠀⠱⣄⡴⠃⠀⠀⠀⠀
⢸⣉⠿⣁⠀⠀⠀⢀⡇⠀⠀⠀⠀⢿⣽⣿⣼⡠⠤⢄⣀⠀⠀⢱⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠑⢦⡀⢸⠀⠀⠀⡠⠒⠒⠚⠛⠉⠀⢠⣀⡌⠳⡀⡌⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠉⠉⣆⠀⢰⠁⣀⣀⠀⠀⣀⠀⠈⡽⣧⢀⡷⠁⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⡤⢄⠀⠈⠢⣸⣄⢽⣞⡂⠀⠈⠁⣀⡜⠁⣩⡷⠿⠆⠀⠀⠀⠀⠀
⠀⠀⠀⠀⢯⣁⡸⠀⠀⠀⡬⣽⣿⡀⠙⣆⡸⠛⠠⢧⠀⡿⠯⠆⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⣀⡀⠀⠀⡤⠤⣵⠁⢸⣻⡤⠏⠀⠀⠀⠀⢹⠀⠀⠀⡊⠱⣀⠀⠀⠀
⠀⠀⢀⠜⠀⢘⠀⠀⠱⠲⢜⣢⣤⣧⠀⠀⠀⠀⠀⢴⠇⠀⠀⠀⠧⠠⠜⠀⠀⠀
⠀⠀⠘⠤⠤⠚⠀⠀⠀⠀⠀⠀⢸⠁⠁⠀⣀⠎⠀⠻⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀
⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠣⣀⣀⡴⠤⠄⠴⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀
`
function App() {
const websocket = useRef(new WebSocket(`ws://${import.meta.env.VITE_API_HOST}/api/zigbee`))
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()
useAutoTheme(DEFAULT_LATITUDE, DEFAULT_LONGITUDE)
useEffect(() => {
websocket.current.onmessage = (event) => {
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 brightnessAtom = store.get(brightnessAtoms)[deviceName]
store.set(brightnessAtom, Math.round((state.brightness / 254) * 100))
const brightnessStepAtom = store.get(brightnessStepAtoms)[deviceName]
store.set(brightnessStepAtom, brightnessToStep(state.brightness))
}
}
}
}
return () => {
if (websocket.current.readyState === WebSocket.OPEN) {
websocket.current.close()
if (ws.readyState === WebSocket.OPEN) {
ws.close()
}
}
}, [store])
function setBrightness(deviceName: ZigbeeDeviceName, brightness: number) {
const request: JrpcRequest<"setDeviceState"> = {
id: crypto.randomUUID(),
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:
brightness === 0
? { state: "OFF", brightness: 0 }
: { state: "ON", brightness: Math.round((brightness / 100) * 254) },
state: step === 0 ? { state: "OFF", brightness: 0 } : { state: "ON", brightness },
},
}
websocket.current.send(JSON.stringify(request))
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 (
@@ -79,40 +136,36 @@ function App() {
<SystemTile className="row-start-2 row-span-1" systemName="helian" displayName="Helian" />
<SystemTile className="row-start-2 row-span-1" systemName="akira" displayName="Akira" />
<LightControlTile
className="row-start-3 col-start-3 col-span-1"
deviceName={ZIGBEE_DEVICE.livingRoomFloorLamp}
onRequestBrightnessChange={(brightness) => {
setBrightness(ZIGBEE_DEVICE.livingRoomFloorLamp, brightness)
<LightSceneTile
className="row-start-3 col-start-3 col-span-1 row-span-2"
onSceneChange={(scene) => {
setScene(scene)
}}
/>
<LightControlTile
className="row-start-3 col-start-4 col-span-1"
deviceName={ZIGBEE_DEVICE.livingRoomFloorLamp}
onRequestBrightnessStepChange={(step) => {
setBrightnessStep(ZIGBEE_DEVICE.livingRoomFloorLamp, step)
}}
/>
<LightControlTile
className="row-start-4 col-start-4 col-span-1"
deviceName={ZIGBEE_DEVICE.deskLamp}
onRequestBrightnessChange={(brightness) => {
setBrightness(ZIGBEE_DEVICE.deskLamp, brightness)
onRequestBrightnessStepChange={(step) => {
setBrightnessStep(ZIGBEE_DEVICE.deskLamp, step)
}}
/>
<Tile className="row-start-4 col-span-2 row-span-3" />
<Tile className="row-start-5 col-start-3 col-span-2 row-span-1 flex items-center justify-center overflow-hidden">
<Kuromi />
</Tile>
</div>
</div>
)
}
function Tile({ children, className }: { children?: React.ReactNode; className?: string }) {
return (
<div
className={cn(
"relative rounded-xl bg-neutral-200 dark:bg-neutral-900 flex flex-col justify-end items-start",
className,
)}
>
{children}
</div>
)
}
function DateTimeTile() {
const [time, setTime] = useState(new Date())
@@ -198,7 +251,11 @@ function WeatherTile() {
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
// Calculate percentage: handle case where lowTemp might be 0 (falsy) by checking for valid numbers
const tempRange = highTemp - lowTemp
const percentage = tempRange !== 0 && !Number.isNaN(tempRange)
? Math.max(0, Math.min(1, (temperature - lowTemp) / tempRange))
: 0
const highlightIndexStart = Math.floor((1 - percentage) * 23)
const WeatherIcon = getWeatherIcon(currentWeather.conditionCode)
@@ -224,7 +281,7 @@ function WeatherTile() {
L:{lowTemp}°
</p>
</div>
<div className="flex flex-col space-y-2 flex-[1]">
<div className="flex flex-col space-y-2 flex-[2]">
{Array.from({ length: 24 }).map((_, index) => {
if (index === highlightIndexStart) {
return (
@@ -262,9 +319,9 @@ function WeatherTile() {
)
})}
</div>
<div className="flex flex-col justify-start h-full space-y-2 flex-[2]">
<div className="flex flex-col justify-start h-full space-y-2 flex-[3]">
<p
className={cn("text-3xl leading-none tracking-tight font-light", {
className={cn("text-3xl leading-none tracking-tight font-light pl-4", {
"text-red-400": errorWeatherDescription,
"animate-pulse": isLoadingWeatherDescription,
})}
@@ -340,7 +397,7 @@ function TFLTile({ className }: { className?: string }) {
return (
<Tile
className={cn(
"gap-x-1 pt-1 h-full col-span-2 row-span-1 grid grid-cols-[min-content_1fr] auto-rows-min overflow-y-auto",
"pt-1 h-full col-span-2 row-span-1 grid grid-cols-[min-content_1fr] auto-rows-min overflow-y-auto",
className,
)}
>
@@ -390,7 +447,7 @@ function TFLDistruptionItem({ lineId, reason, severity }: { lineId: TubeLine; re
lineStyleClass = "bg-purple-800"
break
case "northern":
lineStyleClass = "bg-black"
lineStyleClass = "bg-black text-neutral-200 dark:bg-neutral-200 dark:text-black"
break
case "piccadilly":
lineStyleClass = "bg-blue-900"
@@ -463,7 +520,7 @@ function TFLDistruptionItem({ lineId, reason, severity }: { lineId: TubeLine; re
<div className="h-full flex items-center justify-center px-2 py-0.5">
<p
className={cn(
"text-neutral-200 text-xl uppercase font-bold w-full text-center px-1 rounded-lg",
"text-neutral-200 text-sm uppercase w-full text-center px-1 rounded-lg",
lineStyleClass,
)}
>
@@ -499,22 +556,35 @@ function SystemTile({
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)")
const cpuFillGradient = elem?.getContext("2d")?.createLinearGradient(0, 0, 0, elem.height)
cpuFillGradient?.addColorStop(0, "#2dd4bf")
cpuFillGradient?.addColorStop(0.5, "rgba(45, 212, 191, 0)")
cpuFillGradient?.addColorStop(1, "rgba(45, 212, 191, 0)")
const ramFillGradient = elem?.getContext("2d")?.createLinearGradient(0, 0, 0, elem.height)
ramFillGradient?.addColorStop(0, "#a78bfa")
ramFillGradient?.addColorStop(0.5, "rgba(167, 139, 250, 0)")
ramFillGradient?.addColorStop(1, "rgba(167, 139, 250, 0)")
chartRef.current = new Chart(elem, {
type: "line",
data: {
labels: Array.from({ length: 20 }, (_, index) => index),
datasets: [
{
data: Array.from({ length: 20 }, (_, i) => null),
data: Array.from({ length: 20 }, (_, __) => null),
fill: true,
backgroundColor: fillGradient,
backgroundColor: cpuFillGradient,
borderColor: "#2dd4bf",
tension: 0.1,
},
{
data: Array.from({ length: 20 }, (_, __) => null),
fill: true,
backgroundColor: ramFillGradient,
borderColor: "#a78bfa",
tension: 0.1,
},
],
},
options: {
@@ -541,19 +611,30 @@ function SystemTile({
useLayoutEffect(() => {
const cpu = beszelSystemsData?.info.cpu
if (!chartRef.current || cpu === undefined) return
const ram = beszelSystemsData?.info.ram
if (!chartRef.current || cpu === undefined || ram === undefined) return
const dataset = chartRef.current.data.datasets[0]
const cpuDataset = chartRef.current.data.datasets[0]
const ramDataset = chartRef.current.data.datasets[1]
const nextData = Array.from({ length: 20 }, (_, i) => {
const nextCpuData = Array.from({ length: 20 }, (_, i) => {
if (i === 19) {
return null
}
return dataset.data[i + 1]
return cpuDataset.data[i + 1]
})
nextData[19] = cpu
nextCpuData[19] = cpu
dataset.data = nextData
const nextRamData = Array.from({ length: 20 }, (_, i) => {
if (i === 19) {
return null
}
return ramDataset.data[i + 1]
})
nextRamData[19] = ram
cpuDataset.data = nextCpuData
ramDataset.data = nextRamData
chartRef.current.update()
})
@@ -565,12 +646,36 @@ function SystemTile({
)
}
let systemStatusContent: React.ReactNode
switch (beszelSystemsData.status) {
case "up":
systemStatusContent = (
<div className="w-full flex-1 min-w-0 basis-0 relative mb-2">
<canvas ref={onCanvasRef} className="min-h-0 absolute top-0 left-0 w-full h-full" />
</div>
)
break
case "down":
systemStatusContent = (
<div className="w-full flex-1 flex items-center justify-center">
<p className="font-mono text-red-500 uppercase font-bold">System offline</p>
</div>
)
break
}
return (
<Tile className={cn("h-full flex flex-col justify-start items-start", className)}>
<div className="grid grid-cols-6 px-4 pt-3 w-full">
<div className="col-span-3 flex flex-row items-center space-x-2">
<p className="text-2xl">{displayName}</p>
<div className="size-2 border border-green-300 bg-green-500 rounded-full animate-pulse" />
<div className="grid grid-cols-6 px-3 pt-2 w-full">
<div className="col-span-3 self-start flex flex-row items-center space-x-2">
<p className="leading-none tracking-tight text-2xl">{displayName}</p>
<div
className={cn("size-2 border rounded-full", {
"animate-pulse border-green-300 bg-green-500": beszelSystemsData.status === "up",
"border-red-300 bg-red-500": beszelSystemsData.status === "down",
})}
/>
</div>
<div className="flex flex-col font-mono">
<p className="text-neutral-400 text-right leading-none">CPU</p>
@@ -585,191 +690,9 @@ function SystemTile({
<p className="text-right">{beszelSystemsData.info.disk.toFixed(0).padStart(3, "0")}</p>
</div>
</div>
<div className="w-full flex-1 min-w-0 basis-0 relative mb-2">
<canvas ref={onCanvasRef} className="min-h-0 absolute top-0 left-0 w-full h-full" />
</div>
{systemStatusContent}
</Tile>
)
}
function LightControlTile({
deviceName,
className,
onRequestBrightnessChange,
}: { deviceName: ZigbeeDeviceName; className?: string; onRequestBrightnessChange: (brightness: number) => void }) {
const BAR_COUNT = 44
const currentBrightness = useAtomValue(useAtomValue(brightnessAtoms)[deviceName])
const initialHighlightIndexStart = Math.floor((1 - currentBrightness / 100) * BAR_COUNT)
const touchContainerRef = useRef<HTMLDivElement | null>(null)
const barRefs = useRef<(HTMLDivElement | null)[]>(Array.from({ length: BAR_COUNT }, () => null))
const setIntermediateBrightness = useSetAtom(useAtomValue(intermediateBrightnessAtoms)[deviceName])
const store = useStore()
const bind = useDrag(({ xy: [x], first, last }) => {
if (!touchContainerRef.current) return
if (!first) {
touchContainerRef.current.dataset.active = "true"
}
if (last) {
delete touchContainerRef.current.dataset.active
for (let i = 0; i < BAR_COUNT; i++) {
const bar = barRefs.current[i]
if (!bar) continue
if (bar.dataset.touched === "true") {
bar.dataset.thumb = "true"
}
bar.dataset.touched = "false"
delete bar.dataset.touchProximity
}
const intermediateBrightness = store.get(store.get(intermediateBrightnessAtoms)[deviceName])
if (intermediateBrightness !== -1) {
onRequestBrightnessChange(intermediateBrightness)
setIntermediateBrightness(-1)
}
} else {
let touchedIndex = -1
for (let i = 0; i < BAR_COUNT; i++) {
const bar = barRefs.current[i]
if (!bar) continue
const barRect = bar.getBoundingClientRect()
delete bar.dataset.thumb
if (x > barRect.left - 2 && x < barRect.right + 2) {
touchedIndex = i
bar.dataset.touched = "true"
bar.dataset.highlighted = "true"
delete bar.dataset.touchProximity
const brightness = 1 - i / BAR_COUNT
setIntermediateBrightness(Math.round(brightness * 100))
if (barRefs.current[i - 1]) {
barRefs.current[i - 1]!.dataset.touchProximity = "close"
}
if (barRefs.current[i - 2]) {
barRefs.current[i - 2]!.dataset.touchProximity = "medium"
}
if (barRefs.current[i - 3]) {
barRefs.current[i - 3]!.dataset.touchProximity = "far"
}
} else if (barRect.left < x) {
bar.dataset.touched = "false"
bar.dataset.highlighted = "true"
if (touchedIndex >= 0) {
const diff = i - touchedIndex
if (diff === 1) {
bar.dataset.touchProximity = "close"
} else if (diff === 2) {
bar.dataset.touchProximity = "medium"
} else if (diff === 3) {
bar.dataset.touchProximity = "far"
} else {
delete bar.dataset.touchProximity
}
} else {
delete bar.dataset.touchProximity
}
} else if (barRect.right > x) {
bar.dataset.highlighted = "false"
bar.dataset.touched = "false"
if (touchedIndex >= 0) {
const diff = i - touchedIndex
if (diff === 1) {
bar.dataset.touchProximity = "close"
} else if (diff === 2) {
bar.dataset.touchProximity = "medium"
} else if (diff === 3) {
bar.dataset.touchProximity = "far"
} else {
delete bar.dataset.touchProximity
}
} else {
delete bar.dataset.touchProximity
}
} else {
bar.dataset.touched = "false"
bar.dataset.highlighted = "false"
delete bar.dataset.touchProximity
}
}
if (touchedIndex === -1) {
const firstElement = barRefs.current[barRefs.current.length - 1]
const lastElement = barRefs.current[0]
if (lastElement && x > lastElement.getBoundingClientRect().right) {
lastElement.dataset.thumb = "true"
setIntermediateBrightness(100)
} else if (firstElement && x < firstElement.getBoundingClientRect().left) {
setIntermediateBrightness(0)
}
}
}
})
return (
<Tile className={cn("h-full flex flex-col justify-start items-start", className)}>
<div
{...bind()}
ref={touchContainerRef}
className="group flex-1 flex flex-row-reverse justify-center items-center touch-none gap-x-1 w-full translate-y-6"
>
{Array.from({ length: BAR_COUNT }).map((_, index) => {
const highlighted = index >= initialHighlightIndexStart
return (
<div
data-highlighted={highlighted}
data-thumb={index === initialHighlightIndexStart}
data-touched={false}
ref={(ref) => {
barRefs.current[index] = ref
}}
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
key={index}
className="transition-all group-data-[active=true]:transition-none w-[2px] h-[2px] bg-neutral-400 rounded-full data-[highlighted=true]:h-2 data-[touch-proximity=close]:h-6 data-[touch-proximity=medium]:h-4 data-[touch-proximity=far]:h-2 data-[highlighted=true]:bg-teal-500 data-[touched=true]:h-8 data-[touched=true]:w-1 data-[thumb=true]:h-8"
/>
)
})}
</div>
<div className="px-4 pb-2 w-full flex flex-row items-center justify-center space-x-2">
<p className="tracking-tigher uppercase">Desk light</p>
<BrightnessLevelLabel deviceName={deviceName} />
</div>
</Tile>
)
}
function BrightnessLevelLabel({ deviceName }: { deviceName: ZigbeeDeviceName }) {
const currentBrightness = useAtomValue(useAtomValue(brightnessAtoms)[deviceName])
const intermediateBrightness = useAtomValue(useAtomValue(intermediateBrightnessAtoms)[deviceName])
const brightness = intermediateBrightness === -1 ? currentBrightness : intermediateBrightness
let label: string
if (brightness === 0) {
label = "OFF"
} else {
label = `${brightness}%`
}
return (
<p
className={cn(
"flex-1 text-right font-bold font-mono tracking-tigher",
brightness === 0 ? "text-neutral-400" : "text-teal-400",
)}
>
{label}
</p>
)
}
export default App

View File

@@ -0,0 +1,550 @@
[
[
" ",
" ",
" ",
" #*++*# ",
" *=.....= ",
" *-.......= ",
" %*.........* ",
" #-.........* ",
" #..........+ ",
" #..........* ",
" *+.........* ",
" %*........+# ",
" #*......=# ",
" #*+::=**# ",
" @%******# ",
" *******# ",
" %********# ",
" **********# ",
" ****..-****# ",
" #***-....+***# ",
" #***......=***# ",
" ***-.......:***# ",
" @***..........***% ",
" #**-...........*** ",
" ***.............+** ",
" **+..............=** ",
" %**................-** ",
" ***.................:** ",
" **-..................:** ",
" %**.....................** ",
" #*+.......:..............** ",
" **........-...............** ",
" **........=..-.............*# ",
" %*=........+.+...............+# ##*#% ",
" #*.......-+**.................+% *:...=*% ",
" *+.....:-+***-:................+# =.......*% ",
" *-........**+...................*% #........:* ",
" %*........+:*-:...................** :.........* ",
" *+.......=..=.:....................* ..........+# ",
" *:......:...-.......................* :.........+# ",
" %*...........:.......................-# %##+.........*@ ",
" %+.......:...:........................=% ###****+=--:*.........* ",
" *........:.............................+% *:---...............:*.......*% ",
" *........=..............................* +-.........................-*:...=*% ",
" +.......=+.......................:-=+++******##% @#****-:.............................:*#***# ",
" #:.....:-+++:................-+**+++==-----::--=+****#% %**+=...................................*% ",
" #.........+..............-**+--------------::.......:+***%% **.......................................* ",
" *.........:...........-*+::----------------::...........:+**% @*.......................................=# ",
" *.........:.........=+:.--------------------:..............:+*## #+.......................................* ",
" +.................+=.-----------------------:.................-**% *.......................................-* ",
" -................:.-------------------------::..................:** *.......................................*# ",
" #-...................:-----------------------::.....................*+.......................................* ",
" %:.....................::---------------------::....................:-......................................+# ",
" *........................:-------------------::....................=.......................................* ",
" *:.......+................::-----------------::...................+......................................+* ",
" #=.....+..................::=+++=-----------::...................+.....................................:* ",
" %*..:+..................=**#%%%#**+---------::........................................................+* ",
" #=+.................-*#%%%%%%%%%%*=-------:::......................................................=*% ",
" #+.................+*%%%%%%%%%%%%%%*-------::......................................................+* ",
" %*.................+*%%%%%%%%%%%%%%%%#------:::....................................:..:............+*# ",
" *.................+*%%%%%%%%%%%%%%%%%%*------:::................................-..-.-............:+* ",
" *:.................*%%%%%%%%%%%%%%%%%%%%+-----::::................................+.+=.............++% ",
" #=.................*%%%%%%%%%%%%%%%%%%%%%%=-----:::.................................**+............=+* ",
" #*..................*%%%%%%%%%%%%%%%%%%%%%%*------:::.............................-=****:...........++# ",
" *:.................:#%%%#%%%%%%%%%%%%%%%%%%*------::::...............................+*+...........++* ",
" #+..................=%%%=.#%%%%%%%%%%%%%%%%%#+::----::::.............................+.*.:.........=++# ",
" *...................=%%%...#%%%%%%%%%%%%%%%%%+:::----::::...........................-..+..........:++*% ",
" #+...................:%%=....%%%%%%%%%%%%%%%%%=.:::----::::.............................=..........+++# ",
" *.....................#%:....%%%%%%%%%%%%%%%%%...:::---:::::............................:.........+++* ",
" #*.....................*%*...%%%%%%%%%#=..%%%%%....:::---:::::...........................:........=+++# ",
" *:.....................:%%*=%%%%%%%%%%....-%%%%.....::::--::::::.........................:.......:++++ ",
" *.......................*%%%%%%%%%%%%#.....%%%*......::::--::::::................................++++# ",
" #+........................*%%%%%%%%%%%%....:%%%:.......::::--::::::..............................+++++ ",
" *:.........................*%%%%%%%%%%%:...%%%*.........::::--::::::............................+++++# ",
" *..............=**=........+%%%=%%%%%%%%#+%%%#:..........::::--:::::::.........................++++++ ",
" *...........:*@@@@@*-......*%%=:%.%%%%%%%%%%#=............::::--::::::::......................++++++* ##*% ",
" %=.........*@@@@@@@@@*:.....+*#.#%.%%%%%%%%%*=..............::::--::::::::....................+++++++% #**-.# ",
" #-.......*@@@@@@@@@@@@*:......-***.%%#*****+.................:::---:::::::::................-+++++++*#**=....* ",
" *......:*@@@@+@@@@@@@@@*-........+**%*.......................::::---::::::::::............-++++++++**+.......+ ",
" *......*@@@@@+#@@@@@@@@@*:.........:+*........................::::----::::::::::........+*+++++++++*.........- ",
" *.....*@@@@#@%=@@@@@@@@@@*:......................:+**=.........::::----:::::::::::........-+**++++*+..........# ",
" *....-@@@@@#++==@@@@@@@@@@*:..................=*#@@@@@**:.......::::----:::::::::::::........*#***##*:........+ ",
" *....@@@@@@@====+@@@@@@@@@@*:..............=@@@@@@@@@@@@*+......:::::-----:::=::::::::::.....* #*........- ",
" *...=@@@@@@@=++==+@@@@@@@@@@*:.......:=*%@@@@@@@@@@@@@@@@%*......::::------::+:::::::::::::::* *.........**# ",
" *...%@@@@@@+#@@%==*@@@@@@@@@@**=-:+**%@@@@@@@@@@@@@@@@@@@@@*:.....::::-------+:::::::::::::::* *.....*:..%@#* ",
" *...@@@@@@@+#*=====#@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@*-....:::::------+:::::::::::::::* %-.....**+.*@@%* ",
" *..-@@@@@@#+@*++====#@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@*:....::::-----+*+-:::::::::::::* *.....+%@@**@@@* ",
" *..=@@@@@@+++++++++==@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@*....:::::--+=-*-=+::::::::::::* @=.....*@@@@#@@@*% ",
" *..+@@@@@@++++++++++*@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@*....:::::---+*+-==-::::::::::* #*@*.....-*@@@@@@@@*% ",
" #..+@@@@@@++++++++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@+#@@@@%+....::::*-=***+-*---::::::::***%@#......*@@@@@@@@@* ",
" ..+@@@@@@++++++++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%++@@@@@@*:...:::-+********=----::::::*%@@+......=#@@@@@@@@@* ",
" ..=@@@@@@*++++++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@+===@%@@@@@@*....:::+-*****=*-------:::.*#+........*@@@@@@@@@%* ",
" +.-@@@@@@@+++++++*@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@*======%+@@@@@@#=...:::+:-***--+---------::+.........*@@@@@@@@@@* ",
" #..%@@@@@@%**+++*@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#======+==+%@@@@@@@*...::::+--*=-*-----------==.......-+@@@@@@@@@@@* ",
" -.*@@@@@@@@***@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@*++===+%@+==@@@@@@@@#:...::::*+*+*------------*+++++++++@@@@@@@@@@@* ",
" *.*@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@*+++++++@===@@@@@@@@@*...:::::-*--------------*%++++++#@@@@@@@@@@@*# ",
" #:=@@@@@@@@@@@@@@@@@@@@@@%%@@@@@@@@@@@@@@@@@*++++++++@++@@@@@@@@@*....:::::+--------------*@@*+#%@@@@@@@@@@@@#* ",
" *.*@@@@@@@@@@@@@@@@@@@@*##*@@@@@@@@@@@@@@@@#+++++++++++@@@@@@@@@#:...::=::+-------------+%@@@*#@@@@@@@@@@@@#* ",
" @=*@@@@@@@@@@@@@@@@@@@*#%%%%@@@@@@@@@@@@@@@%+++++++++++@@@@@@@@@@+...::=::+-------------*@@@@@#*@@@@@@@@@@*# ",
" *-#@@@@@@@@@@@@@@@@@@@*%%%#@@@@@@@@@@@@@@@@++++++++++%@@@@@@@@@@+....:#:::------------=#@@@@@@#*@@@@@@@@@* ",
" **@@@@@@@@@@@@@@@@@@@#*###@@@@@@@@@@@@@@@@++++++++++@@@@@@@@@@@*....=%#:::-----------*@@@@@@@@#*@@@@@@@@* ",
" #*#@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%++++++++*@@@@@@@@@@@*....#%%-::----------=#@@@@@@@@@#*@@@@@@%% ",
" #*@@@@@@@@@@@@@@@@@@@@@@@@@#*#@@@@@@@@@@@@**++++++@@@@@@@@@@@@*.....#-:::----------*%@@@@@@@@@@#*@@@@@*% ",
" **@@@@@@@@@@@@@@@*@@@@@@@%*%##@@@@@@@@@@@@%*****@@@@@@@@@@@@@*.....+:::::--------+#@@@@@@@@@@@@%*%@@@* ",
" **@@@@@@@@@@@@@@@*@@@@@@*%#%#@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@+.....=:::::-------=*@@@@@@@@@@@@@@@*#@%% ****% ",
" #**##**#@@@@@@@@@@**%@@*%###@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@=.....::::::-------*%@@@@@@@@@@@@@@@@%**%@@%@@@@**@ ",
" #*@@@@@@%*%@@@@@@@@@@#*****#@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@:......::::::-----*%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@*# ",
" @*@@@@@@@@@*#@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#.......::::::----*%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@* ",
" #%@@@@@@@@@@*@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@*.......::::::---+%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@* ",
" *@@@@@@@@@@@@*@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%........:::::::-*%@@%#*#@@@@@@@@@@@@@@@@@@@@@@@@@@@@@# ",
" *@@@@@@@@@@@@@%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@=........:::::::*%@@%*####@@@@@@@@@@@@@@@@@@@@@@@@@@@@# ",
" #%@@@@@@@@@@@@*@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#.........:::::=*#@@@*######@@@@@@@@@@@@@@@@@@@@@@@@@@@@ ",
" *@@@@@@@@@@@@#*%@@@@@@@@@@@@@@@@@@@@@@@*****#@@@@@@@@@@@@%...........:::*+..=*#*######@@@@@@@@@@@@@@@@@@@@@@@@@@@@ ",
" %#@@@@@@@@@@@%@#*#@@@@@@@@@@@@@@@@@@@**@@@@@%*%@@@@@@@@@%............:-+-......*######@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ ",
" ##@@@@@@@@@@@@@@%**%@@@@@@@@@@@@@@@*%@@@@@@@@*%@@@@@@@#:..........=+++........*#####*@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ ",
" %*@@@@@@@@@@@@@@@****#@@@@@@@@@@@*@@@@@@@@@@@*@@@@@%*.........-*+++:........*%*###*%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@# ",
" %*% @@@@*+++++*#@@@@@@@*%@@@@@@@@@@@*@@@%*:......-+*++++-........+#@@@***@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@* ",
" %*#@ @*+++++++++++**#%*@@@@@@@@@@@@*%**...:=+***+++++-.......-*#@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@# ",
" @% **###% *-=+++++++++++*@*@@@@@@@@@@@@@********+++++****++==++**%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@# ",
" *#*######*:....:=++++**%@@*@@@@@@@@@@@@@*+++++++++++=*@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@* ",
" %*######*.......-+**%@@@@@*@@@@@@@@@@@@#+++++++++-...#@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@* ",
" *######********##%*%@@@@@*@@@@@@@@@@@@*+==-........-%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@* ",
" %*#####* #* #*@@@@*@@@@@@@@@@@**............=@@@@@@@@@@@@@@@@@@@@@@@@#%@@@@@@@@@@@@@@@@@#@@@@@@@@@@@%# ",
" **###*@ %** *#@@#@@@@@@@@@%*@#*...........=@@@@@@@@@@@@@@@@@@@@@@%* #*%%@@@@@@@@@%%**% *%@@@@@@@@@*% ",
" #***#***# #*#@*@@@@@@@@@@@@#+..........=@@@@@@@@@@@@@@@@@@@@@## #***********# %*%@@@@@@@%* ",
" %%# %**@@@@@@@@@@@@@#*.........:%@@@@@@@@@@@@@@@@@@%*@ #**# **%@@@@#*@ ",
" %*@@@@@@@@@@@@@@*-........*@@@@@@@@@@@@@@@@%*% %****** ",
" #*@@@@@@@@@@@@@@**:......*#*#@@@@@@@@@@%**# ",
" #*@@@@@@@@@@@@@@%**-...:*####@@@@@%#**# ",
" #*@@@@@@@@@@@@@@@@#**+*#####*****#@ ",
" #**@@@@@@@@@@@@@%*****######% ",
" #***#%%@@%##*## #*###### ",
" %##**#%@ *#####* ",
" %*###*# ",
" %***# "
],
[
" ",
" ",
" ",
" ",
" ",
" ",
" #**#@ ",
" #*....:+ ",
" #+.......= ",
" *.........@ ",
" #=.........* ",
" *:.........+ ",
" *:.........* ",
" #=.........* ",
" *........-# ",
" #+......:* ",
" #*-..:+** ",
" ********% ",
" %*******% ",
" #********# ",
" *********** ",
" ****..:***** ",
" %***-....-**** ",
" ****.......+***% ",
" ***=........-***% ",
" ***...........***% ",
" ***............=**# ",
" @**=..............*** #**# ",
" #**................+** #=...-*# ",
" ***.................-**@ #.......*% ",
" **+...................**% .........* ",
" @**.....................+*@ -.........+% ",
" #**......................=*# -.........-* ",
" %*+........................*# =.........:* ",
" **-.......-.................*% *.........=# ",
" **........+..................+# ##***-........* ",
" **......-.*.=.................=# %##**+=....*.......+# ",
" *+.......***...................-* #*+=...........*=...:*# ",
" #*.......=****-..................-* %--.................=*****% ",
" #*........+**.....................:* #+=......................*% ",
" **........:*.-.....................:* =...........................* ",
" *=.......:.-........................:# @#**=.............................:* ",
" *.......:............................:# #**+-................................*# ",
" *.....................................-# #*+....................................* ",
" %+......................................*####*##*%% *+.....................................:* ",
" *-...............................:=****+++==----==+****## *......................................+% ",
" *........=....................+**+=-------::...........-+***%% *......................................* ",
" *.......:+:...............:+*+-------------:...............:+**# @=.....................................-* ",
" *.....:=+++:............=*=.---------------::..................=**%#:.....................................*# ",
" %+........+............*+.------------------::.....................+*......................................* ",
" #-........=..........+=.---------------------:......................*.....................................+* ",
" *:........:........+=.-----------------------::.....................*.....................................*# ",
" #:................+.--------------------------::....................*....................................=* ",
" #...................:-------------------------::....................=....................................+* ",
" *......................::---------------------:::.......................................................=*% ",
" #.........................::-------------------:::......................................:...............+* ",
" %...........................:::-=+++=-----------::......................................+..............++# ",
" *:........+.................-**#####**+--------:::...................................:.*.+............+*% ",
" #=......*.................**%%%%%%%%%%*=-------:::...................................***............++* ",
" %*:...*................=*%%%%%%%%%%%%%%*-------:::.................................:****:.........:++% ",
" %+.+................=*%%%%%%%%%%%%%%%%#------::::.................................-**=..........++*% ",
" *:...............-*%%%%%%%%%%%%%%%%%%#------::::................................+-=.=........:++* ",
" %+................*%%%%%%%%%%%%%%%%%%%%*------::::..............................-..=..:.......+++% ",
" @*................+#%%%%%%%%%%%%%%%%%%%%#+------::::................................=.........=++* ",
" *.................*%%%%%%%%%%%%%%%%%%%%%%*:-----:::::...............................=.........+++* ",
" %+................:*%%%%%%%%%%%%%%%%%%%%%%*=::----:::::..............................:........++++ ",
" *.................=%%%%*%%%%%%%%%%%%%%%%%%%+:::----:::::.....................................=+++* ",
" #+.................=%%%..=%%%%%%%%%%%%%%%%%%=.::::---::::::...................................++++% ",
" *..................=%%%....%%%%%%%%%%%%%%%%%-..::::---::::::.................................+++++ ",
" *+..................:%%+....%%%%%%%%%%%#%%%%%:....:::---:::::::..............................+++++* ",
" *....................*%+....%%%%%%%%=....%%%%......::::--:::::::............................=+++++* ",
" %*....................+%%...%%%%%%%%%.....%%%%.......::::---:::::::.........................-+++++* ",
" *-.....................#%%%%%%%%%%%%%.....%%%*........::::---:::::::.......................-++++++* ",
" *.......................%%%%%%%%%%%%%-....%%%-.........::::---::::::::....................=++++++*% ",
" #*.......................:#%%%%%%%%%%%%:..*%%*............::::--:::::::::.................++++++++* ",
" #+.........................*%%%%%%%%%%%%%%%%#-.............::::---:::::::::...........:-++++++++++# ##*% ",
" *:.........................*%%:+%+%%%%%%%%%*=...............::::---:::::::::::..........-****++++* #**:.# ",
" *.............-****=.......*%%.#%.%%%%%%%#*-.................::::---::::::::::::............*###*# %#**-....* ",
" *...........-#@@@@@#*......-**+%*.%%*****=...................:::::----::::::::::::::........* %#**+.......+ ",
"%*.........-@@@@@@@@@%*........:+***%*:........................:::::-----::::::::::::::::::::+ *+..........- ",
"#+.......-#@@@@@@@@@@@@*:..........-+*...........:*****=........:::::-----:::-:::::::::::::::+# %*+..........# ",
"%=......+*@@@*@@@@@@@@@@*-.....................+*@@@@@@%*+.......:::::------:::::::::::::::::-* #*:........+ ",
"#:.....+*@@@@*+@@@@@@@@@@*+..................#@@@@@@@@@@@%*-......::::--------::::::::::::::::* %*........- ",
"*......*@@@@%@+%@@@@@@@@@@**.............-*@@@@@@@@@@@@@@@@*+......::::-------=-::::::::::::::* *.........**# ",
"*.....+@@@@@+@==%@@@@@@@@@@#*.........=*@@@@@@@@@@@@@@@@@@@@%*.....:::::----+=*=*-::::::::::::* *.....*:..%@%* ",
"*.....@@@@@@+=@==#@@@@@@@@@@%*=....+*#@@@@@@@@@@@@@@@@@@@@@@@@*.....:::::--+-+*--*--::::::::::* %-.....**+.*@@%* ",
"#....#@@@@@@*=@===*@@@@@@@@@@@#***#@@@@@@@@@@@@@@@@@@@@@@@@@@@@*.....::::-+--***--+---::::::::* *.....+%@@**@@@* ",
"%....@@@@@@@+@@@+==+@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@*....:::::+=*******=----::::::* @=.....*@@@@%@@@*% ",
"#:...@@@@@@@++@=====+@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@+%@@@@%*....::-+*******+*--------::-* #*@*.....-#@@@@@@@@*% ",
"%=..-@@@@@@+#@@++++==+@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@*+@@@@@@#+....:::+:+***--*-----------* **%@#......*@@@@@@@@@* ",
" +..+@@@@@@+++++++++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@*==@@@@@@@@*:...:::-:-=**-=-----------=#*%@@+......=#@@@@@@@@@* ",
" *..+@@@@@@++++++++++%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@+====@+%@@@@@@*....:::+--*=++-----------+*%#+........*@@@@@@@@@%* ",
" *..+@@@@@@++++++++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@+===%===+@@@@@@@#=...:::::+*+-------------*-:.........*@@@@@@@@@@* ",
" %..=@@@@@@++++++++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@======@===@@@@@@@@@*....:::::---------------*=........-+@@@@@@@@@@@* ",
" ..:@@@@@@*++++++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@++====@@%==@@@@@@@@@%=...:::-::-------------=*++++++++++@@@@@@@@@@@* ",
" -..%@@@@@@+++++++*@@@@@@@@@@@@@@@@@@@@@@@@@@@@+++++++%+@+@@@@@@@@@@*....::-::-------------+%@%++++++#@@@@@@@@@@@*# ",
" ..*@@@@@@@***++*@@@@@@@@@@@@@@@@@@@@@@@@@@@@@+++++++*+#+@@@@@@@@@@*....::*:::------------*@@@@*+#%@@@@@@@@@@@@#* ",
" :.*@@@@@@@@#**@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@+++++++++++@@@@@@@@@@%-....:%:::-----------=#@@@@@*#@@@@@@@@@@@@#* ",
" #.=@@@@@@@@@@@@@@@@@@@@@@#*#@@@@@@@@@@@@@@@@@*++++++++++@@@@@@@@@@@+....%%%#+:----------*%@@@@@@#*@@@@@@@@@@*# ",
" +.*@@@@@@@@@@@@@@@@@@@@*#%%*@@@@@@@@@@@@@@@@%++++++++++@@@@@@@@@@@*....=%*:::---------=*@@@@@@@@#*@@@@@@@@@* ",
" #.+@@@@@@@@@@@@@@@@@@@@*%%%%@@@@@@@@@@@@@@@@@+++++++++*@@@@@@@@@@@*.....#:::::--------*@@@@@@@@@@#*@@@@@@@@* ",
" @*.#@@@@@@@@@@@@@@@@@@@%*%%%*@@@@@@@@@@@@@@@@%++++++++@@@@@@@@@@@@*.....-:::::-------+#@@@@@@@@@@@#*@@@@@@%% ",
" %++@@@@@@@@@@@@@@@@@@@@%***@@@@@@@@@@@@@@@@@@#*****+%@@@@@@@@@@@@*.....:::::::------*@@@@@@@@@@@@@%*@@@@@*% ",
" *=*@@@@@@@@@@@@@@@@@@@@@@@@@@@*#@@@@@@@@@@@@@@#**#@@@@@@@@@@@@@@*......::::::-----*%@@@@@@@@@@@@@@%*%@@@* ",
" *+%@@@@@@@@@@@@@@@@@@@@@@@@@*%%*@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@+......::::::----*%@@@@@@@@@@@@@@@@@*#@%% @****# ",
" **%**##**#@@@@@@@@#@@@@@@@##%%*@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@=......:::::::--+#@@@@@@@@@@@@@@@@@@@@**%@@%@@@@**% ",
" **@@@@@@%*%@@@@@@@#*@@@@@*%*%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@:.......::::::-=#@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@*# ",
" @*@@@@@@@@@*#@@@@@@@@***%*#**@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#........::::::=#@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@* ",
" #%@@@@@@@@@@*@@@@@@@@@@@%%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@:........:::::+%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@# ",
" *@@@@@@@@@@@@*@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%.........::::*%@@@@@%#*#@@@@@@@@@@@@@@@@@@@@@@@@@@@@@# ",
" *@@@@@@@@@@@@@%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@..........:::*%@@@@@%*####@@@@@@@@@@@@@@@@@@@@@@@@@@@@# ",
" #%@@@@@@@@@@@@*@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@:..........:...:*#@@@*######@@@@@@@@@@@@@@@@@@@@@@@@@@@@ ",
" *@@@@@@@@@@@@#@@@@@@@@@@@@@@@@@@@@@@@@@*****#@@@@@@@@@@@@*.........-+...-+..-*#*######@@@@@@@@@@@@@@@@@@@@@@@@@@@@ ",
" ##@@@@@@@@@@@%@@@@@@@@@@@@@@@@@@@@@@@**@@@@@%*%@@@@@@@@%*........++..-++=......*######@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ ",
" ##@@@@@@@@@@@*#@@@@@@@@@@@@@@@@@@@@*%@@@@@@@@*%@@@@@@%+......=*+-=++++........*#####*@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ ",
" %*@@@@@@@@@@@%**#@@@@@@@@@@@@@@@@*@@@@@@@@@@@*@@@@%*:....=**+++++++:........*%*###*%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@# ",
" %*% @@@%***%@@@@@@@@@@@*%@@@@@@@@@@@*@@%*=.:=***++++++++-........+#@@@***@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@* ",
" %*#% @*++++++**%@@@@@%*@@@@@@@@@@@@********++++++++++-.......=*%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@# ",
" @% **#### *--+++++++++++***@@@@@@@@@@@@@**+++++++++++****++==++**%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%# ",
" *#*#####%*:....:=++++**%@@*@@@@@@@@@@@@@*+++++++++++=*@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@* ",
" %*######*.......-+**%@@@@@*@@@@@@@@@@@@#+++++++++-...#@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@* ",
" *######********##%*%@@@@@*@@@@@@@@@@@@*+=--........-%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@* ",
" %*#####* #* #*@@@@*@@@@@@@@@@@**............=@@@@@@@@@@@@@@@@@@@@@@@@#%@@@@@@@@@@@@@@@@@#@@@@@@@@@@@%# ",
" **###*@ %** *#@@#@@@@@@@@@%*@#*...........=@@@@@@@@@@@@@@@@@@@@@@%* #*%%@@@@@@@@@%%**% *%@@@@@@@@@*% ",
" #***#***# #*#@*@@@@@@@@@@@@#+..........-@@@@@@@@@@@@@@@@@@@@@## #***********# %*%@@@@@@@%* ",
" %%* #**@@@@@@@@@@@@@#*.........:%@@@@@@@@@@@@@@@@@@%*@ #**# **%@@@@#*@ ",
" %*@@@@@@@@@@@@@@*-........*@@@@@@@@@@@@@@@@%*% %****** ",
" #*@@@@@@@@@@@@@@**:......*#*#@@@@@@@@@@%**# ",
" **@@@@@@@@@@@@@@%**=...:*####@@@@@%#**# ",
" #*@@@@@@@@@@@@@@@@#**+*#####*****#@ ",
" #**@@@@@@@@@@@@@%*****######% ",
" #***#%%@@%##*#% **###### ",
" %##**##@ *#####* ",
" %*###*# ",
" %***% "
],
[
" ",
" ",
" ",
" #*++*# ",
" *=.....= ",
" *-.......= ",
" %*.........* ",
" #-.........* ",
" #..........+ ",
" #..........* ",
" *+.........* ",
" %*........+# ",
" #*......=# ",
" #*+::=**# ",
" @%******# ",
" *******# ",
" %********# ",
" **********# ",
" ****..-****# ",
" #***-....+***# ",
" #***......=***# ",
" ***-.......:***# ",
" @***..........***% ",
" #**-...........*** ",
" ***.............+** ",
" **+..............=** ",
" %**................-** ",
" ***.................:** ",
" **-..................:** ",
" %**.....................** ",
" #*+.......:..............** ",
" **........-...............** ",
" **........=..-.............*# ",
" %*=........+.+...............+# ##*#% ",
" #*.......-+**.................+# *:...=*% ",
" *+.....:-+***-:................+# =.......*% ",
" *-........**+...................*% #........:* ",
" %*........+:*-:...................** :.........* ",
" *+.......=..=.:....................* ..........+# ",
" *:......:...-.......................* :.........+# ",
" %*...........:.......................-# %##+.........*@ ",
" %+.......:...:........................=% ###****+=--:*.........* ",
" *........:.............................+% *:---...............:*.......*% ",
" *........=..............................* +-.........................-*:...=*% ",
" +.......=+.......................:-=+++******##% @#****-:.............................:*#***# ",
" #:.....:-+++:................-+**+++==-----::--=+****#% %**+=...................................*% ",
" #.........+..............-**+--------------::.......:+***%% **.......................................* ",
" *.........:...........-*+::----------------::...........:+**% @*.......................................=# ",
" *.........:.........=+:.--------------------:..............:+*## #+.......................................* ",
" +.................+=.-----------------------:.................-**% *.......................................-* ",
" -................:.-------------------------::..................:** *.......................................*# ",
" #-...................:-----------------------::.....................*+.......................................* ",
" %:.....................::---------------------::....................:-......................................+# ",
" *........................:-------------------::....................=.......................................* ",
" *:.......+................::-----------------::...................+......................................+* ",
" #=.....+..................::=+++==----------::...................+.....................................:* ",
" %*..:+..................=**#%%%#**+---------::........................................................+* ",
" #=+.................-*#%%%%%%%%%%*=-------:::......................................................=*% ",
" #+.................+*%%%%%%%%%%%%%%*-------::......................................................+* ",
" %*.................+*%%%%%%%%%%%%%%%%#------:::....................................:..:............+*# ",
" *.................+*%%%%%%%%%%%%%%%%%%*------:::................................-..-.-............:+* ",
" *:.................*%%%%%%%%%%%%%%%%%%%%+-----::::................................+.+=.............++% ",
" #=.................*%%%%%%%%%%%%%%%%%%%%%%=-----:::.................................**+............=+* ",
" #*..................*%%%%%%%%%%%%%%%%%%%%%%*------:::.............................-=****-...........++# ",
" *:.................:#%%%#%%%%%%%%%%%%%%%%%%*------::::...............................+*+...........++* ",
" #+..................=%%%=.#%%%%%%%%%%%%%%%%%#+::----::::.............................+.*.:.........=++# ",
" *...................=%%%...#%%%%%%%%%%%%%%%%%+:::----::::...........................-..+..........:++*% ",
" #+...................:%%=....%%%%%%%%%%%%%%%%%=.:::----::::.............................=..........+++# ",
" *.....................#%:....%%%%%%%%%%%%%%%%%...:::---:::::............................:.........+++* ",
" #*.....................*%*...%%%%%%%%%#=..%%%%%....:::---:::::...........................:........=+++# ",
" *:.....................-%%*=%%%%%%%%%%....-%%%%.....::::--::::::.........................:.......:++++ ",
" *.......................*%%%%%%%%%%%%#.....%%%*......::::--::::::................................++++# ",
" #+........................*%%%%%%%%%%%%....:%%%:.......::::--::::::..............................+++++ ",
" *:.........................*%%%%%%%%%%%:...%%%*.........::::--::::::............................+++++# ",
" *..............=**=........+%%%=%%%%%%%%#+%%%#:..........::::--:::::::.........................++++++ ",
" *...........:*@@@@@*-......*%%=:%.%%%%%%%%%%#=............::::--::::::::......................++++++* ##*# ",
" %=.........*@@@@@@@@@*:.....+*#.#%.%%%%%%%%%*=..............::::--::::::::....................+++++++% #**-.# ",
" #-.......*@@@@@@@@@@@@*:......-***.%%#*****+.................:::---:::::::::................-+++++++*%**=....* ",
" *......:*@@@@+@@@@@@@@@*-........+**%*.......................::::---::::::::::............-++++++++**+.......+ ",
" *......*@@@@@+#@@@@@@@@@*:.........:+*........................::::----::::::::::........+*+++++++++*.........- ",
" *.....*@@@@#@%=@@@@@@@@@@*:......................:+**=.........::::----:::::::::::........-+**++++*+..........# ",
" *....-@@@@@#++==@@@@@@@@@@*:..................=*#@@@@@**:.......::::----:::::::::::::........*#***##*:........+ ",
" *....@@@@@@@====+@@@@@@@@@@*:..............=@@@@@@@@@@@@*+......:::::-----:::=::::::::::.....* #*........- ",
" *...=@@@@@@@=++==+@@@@@@@@@@*:.......:=*%@@@@@@@@@@@@@@@@%*......::::------::+:::::::::::::::* *.........**# ",
" *...%@@@@@@+#@@%==*@@@@@@@@@@**=-:+**%@@@@@@@@@@@@@@@@@@@@@*:.....::::-------+:::::::::::::::* *.....*:..%@#* ",
" *...@@@@@@@+#*=====#@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@*-....:::::------+:::::::::::::::* %-.....**+.*@@%* ",
" *..-@@@@@@#+@*++====#@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@*:....::::-----+*+-:::::::::::::* *.....+%@@**@@@* ",
" *..=@@@@@@+++++++++==@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@*....:::::--+=-*-=+::::::::::::* @=.....*@@@@#@@@*% ",
" *..+@@@@@@++++++++++*@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@*....:::::---+**-==-::::::::::* #*@*.....-*@@@@@@@@*% ",
" #..+@@@@@@++++++++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@+#@@@@%+....::::*-=***+-*---::::::::***%@#......*@@@@@@@@@* ",
" ..+@@@@@@++++++++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%++@@@@@@*:...:::-*********=----::::::*%@@+......=#@@@@@@@@@* ",
" ..=@@@@@@*++++++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@+===@%@@@@@@*....:::+-*****=*-------:::.*#+........*@@@@@@@@@%* ",
" +.-@@@@@@@+++++++*@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@*======%+@@@@@@#=...:::+:-***--+---------::+.........*@@@@@@@@@@* ",
" #..%@@@@@@%***++*@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#======+==+%@@@@@@@*...::::+--*=-*-----------==.......-+@@@@@@@@@@@* ",
" -.*@@@@@@@@***@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@*++===+%@+==@@@@@@@@#:...::::*+*+*------------*+++++++++@@@@@@@@@@@* ",
" *.*@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@*+++++++@===@@@@@@@@@*...:::::-*--------------*%++++++#@@@@@@@@@@@*# ",
" #:=@@@@@@@@@@@@@@@@@@@@@@%@@@@@@@@@@@@@@@@@@*++++++++@++@@@@@@@@@*....:::::+--------------*@@*+#%@@@@@@@@@@@@#* ",
" *.*@@@@@@@@@@@@@@@@@@@@*##*@@@@@@@@@@@@@@@@#+++++++++++@@@@@@@@@#:...::=::+-------------+%@@@*#@@@@@@@@@@@@#* ",
" @=*@@@@@@@@@@@@@@@@@@@*#%%%%@@@@@@@@@@@@@@@%+++++++++++@@@@@@@@@@+...::=::+-------------*@@@@@#*@@@@@@@@@@*# ",
" *-#@@@@@@@@@@@@@@@@@@@*%%%#@@@@@@@@@@@@@@@@++++++++++%@@@@@@@@@@+....:#:::------------=#@@@@@@#*@@@@@@@@@* ",
" **@@@@@@@@@@@@@@@@@@@#*###@@@@@@@@@@@@@@@@++++++++++@@@@@@@@@@@*....=%#:::-----------*@@@@@@@@#*@@@@@@@@# ",
" #*#@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%++++++++*@@@@@@@@@@@*....#%%-::----------=#@@@@@@@@@#*@@@@@@%% ",
" #*@@@@@@@@@@@@@@@@@@@@@@@@@#*#@@@@@@@@@@@@**++++++@@@@@@@@@@@@*.....#-:::----------*%@@@@@@@@@@#*@@@@@*% ",
" **@@@@@@@@@@@@@@@*@@@@@@@%*%##@@@@@@@@@@@@%*****@@@@@@@@@@@@@*.....+:::::--------+#@@@@@@@@@@@@%*%@@@* ",
" **@@@@@@@@@@@@@@@*@@@@@@*%#%#@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@+.....=:::::-------=*@@@@@@@@@@@@@@@*#@%# ****% ",
" #**##**#@@@@@@@@@@**%@@*%###@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@=.....::::::-------*%@@@@@@@@@@@@@@@@%**%@@%@@@@**@ ",
" #*@@@@@@%*%@@@@@@@@@@#*****#@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%:......::::::-----*%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@*# ",
" @*@@@@@@@@@*#@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#.......::::::----*%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@* ",
" #%@@@@@@@@@@*@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@*.......::::::---+%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@* ",
" *@@@@@@@@@@@@*@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%........:::::::-*%@@%#*#@@@@@@@@@@@@@@@@@@@@@@@@@@@@@# ",
" *@@@@@@@@@@@@@%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@=........:::::::*%@@%*####@@@@@@@@@@@@@@@@@@@@@@@@@@@@# ",
" #%@@@@@@@@@@@@*@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#.........:::::=*#@@@*######@@@@@@@@@@@@@@@@@@@@@@@@@@@@ ",
" *@@@@@@@@@@@@#*%@@@@@@@@@@@@@@@@@@@@@@@*****#@@@@@@@@@@@@%...........:::*+..=*#*######@@@@@@@@@@@@@@@@@@@@@@@@@@@@ ",
" %#@@@@@@@@@@@%@**#@@@@@@@@@@@@@@@@@@@**@@@@@%*%@@@@@@@@@%............:-+-......*######@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ ",
" ##@@@@@@@@@@@@@@%**%@@@@@@@@@@@@@@@*%@@@@@@@@*%@@@@@@@#:..........=+++........*#####*@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ ",
" #*@@@@@@@@@@@@@@@****#@@@@@@@@@@@*@@@@@@@@@@@*@@@@@%*.........-*+++:........*%*###*%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@# ",
" %*% @@@@*+++++*#%@@@@@@*%@@@@@@@@@@@*@@@%*:......-+*++++-........+#@@@***@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@* ",
" %*#@ @*+++++++++++**#%*@@@@@@@@@@@@*%**...:=+***+++++-.......-*#@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@# ",
" @% **###% *-=+++++++++++*%*@@@@@@@@@@@@@********+++++****++==++**%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%# ",
" *#*######*:....:=++++**%@@*@@@@@@@@@@@@@*+++++++++++=*@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@* ",
" %*######*.......-+**%@@@@@*@@@@@@@@@@@@#+++++++++-...#@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@* ",
" *######********##%*%@@@@@*@@@@@@@@@@@@*+==-........-%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@* ",
" %*#####* #* #*@@@@*@@@@@@@@@@@**............=@@@@@@@@@@@@@@@@@@@@@@@@#%@@@@@@@@@@@@@@@@@#@@@@@@@@@@@%# ",
" **###*@ %** *#@@#@@@@@@@@@%*@#*...........=@@@@@@@@@@@@@@@@@@@@@@%* #*%%@@@@@@@@@%%**% *%@@@@@@@@@*% ",
" #***#***# #*#@*@@@@@@@@@@@@#+..........=@@@@@@@@@@@@@@@@@@@@@## #***********# %*%@@@@@@@%* ",
" %%# %**@@@@@@@@@@@@@#*.........:%@@@@@@@@@@@@@@@@@@%*% #**# **%@@@@#*@ ",
" %*@@@@@@@@@@@@@@*-........*@@@@@@@@@@@@@@@@%*% %****** ",
" #*@@@@@@@@@@@@@@**:......*#*#@@@@@@@@@@%**# ",
" #*@@@@@@@@@@@@@@%**=...:*####@@@@@%#**# ",
" #*@@@@@@@@@@@@@@@@#**+*#####*****#@ ",
" #**@@@@@@@@@@@@@%*****######% ",
" #***#%%@@%##*## #*###### ",
" %##**#%@ *#####* ",
" %*###*# ",
" #***% "
],
[
" #**#% ",
" %*=...=# ",
" %*.......+ ",
" *:........# ",
" *.........- ",
" %+.........- ",
" #=.........- ",
" +.........+ ",
" *.........* ",
" #*.......*@ ",
" #*:...-*% ",
" #******# ",
" %******% ",
" ******** ",
" ********* ",
" #****-***** ",
" ****...+**** ",
" #***:....-***% ",
" ***+......:***# ",
" ***........:*** ",
" #**+.........:*** ",
" ***............**# ",
" #**=............:**% ",
" ***..............:**@ ",
" @**=...............:** ",
" %**.................-** ",
" **=..................-*# ",
" %**.......:............=*# ",
" #*=.......=.............=* ",
" **........+..............+*@ ",
" %*+.....-..+..-............+* ",
" **.......+.+.+..............*# ",
" *+........***................*# ",
" %*.......:****+:...............* ",
" *+........+**=.................:* ",
" *........=.---..................-# ",
" #*.......:..-..-..................+% ",
" *:..........-......................* ",
" *.......:...-.......................* ",
" #-.......-...-.......................=# %*# ",
" *........=...:........................* @*=-=** ",
" +.......:+.............................# -.....:*% ",
" #:.....:=+++:...........................-% ........:* ",
" *........:+..............................* =.........*%",
" *.........:.................-=++***********### -.........=#",
" #-......................:+*++-::------------=++***#% +.........-*",
" #....................=*+..------------------::....+**** %*=-*%%##*###*****.........+#",
" *.................-+:.:---------------------::.......:***% %##******::..........................*.........*%",
" *..................:------------------------::..........:**% #**+--..................................:*.......-* ",
" #...................:-----------------------::.............+*#- %*........................................+*.....=* ",
" *....................:----------------------:...............=*# *:........................................=#**++*# ",
" *.......::............:--------------------::................=*# @*.........................................* #@ ",
" *.....+................:------------------::..................+*%%=........................................+# ",
" %:..*..................::----------------::....................**.........................................*% ",
" %++.....................:====------------::....................*........................................+# ",
" #=...................:***###***=---------::...................:-........................................* ",
" *=...................**%%%%%%%%%#*=-------:::..................+........................................+# ",
" #+..................=*%%%%%%%%%%%%%#+-------::..................+........................................* ",
" #+..................+*%%%%%%%%%%%%%%%%+------:::.................:.......................................*% ",
" @*..................=*%%%%%%%%%%%%%%%%%%+------::........................................................-* ",
" @*...................*%%%%%%%%%%%%%%%%%%%%------:::...................................:...................*% ",
" *-..................+#%%%%%%%%%%%%%%%%%%%%%-----:::...................................:..................+* ",
" #+...................*%%%%%%%%%%%%%%%%%%%%%%*-----:::..................................-.................=* ",
" *....................*%%%:%%%%%%%%%%%%%%%%%%*=-----:::.................................=................:+* ",
" *=...................:#%%...%%%%%%%%%%%%%%%%%#+-----::::................................+..-.............+* ",
" %*....................:%%#...:%%%%%%%%%%%%%%%%%*:-----:::..............................:-*.+.............++# ",
" *-.....................#%-....%%%%%%%%%%%%%%%%%*::----::::..............................***.............-+* ",
" @*......................*%-...:%%%%%%%%%%%%%%%%%+:::----::::............................-***+-:..........++% ",
" #=......................+%%..:%%%%%%%%%%%%%%%%%%..:::----::::............................+*+............++* ",
" *........................%%%%%%%%%%%%%#....%%%%%...:::---:::::..........................:.*.=..........++*% ",
" %+........................:%%%%%%%%%%%%-....#%%%%....:::---:::::...........................+..-........=++* ",
" *:.........................+%%%%%%%%%%%:....#%%%......:::---:::::..........................=..........-++*@ ",
" *............=*#%%*+........*%%%%%%%%%%+....%%%#.......:::--::::::.........................-.........:+++% ",
" #*.........+@@@@@@@@@*......:#%%.%%%%%%%%-..-%%%=........:::--::::::........................:........:+++* ",
" #-......:*@@@@@@@@@@@%+.....+#%#.%++%%%%%%%%%%%+..........:::--::::::.......................:........++++% ",
" *......+*@@@@#@@@@@@@@#+.....-*+=%.*%%%%%%%%%#*...........::::--::::::..............................++++##*# ",
" *.....=#@@@@@+#@@@@@@@@*-......-**.%%%*#%%%#*=.............::::--::::::............................+++++*-.# ",
" *.....@@@@@#%%+@@@@@@@@@*:.......:**%*=-+++-................:::---::::::..........................+++++....* ",
" +....@@@@@@**+=*@@@@@@@@@*.........:**.......................:::---:::::::......................:+++++-....+ ",
" %=...+@@@@@@@====@@@@@@@@@@*..................................::::---:::::::....................=++++++.....- ",
" #-...@@@@@@@#=#===@@@@@@@@@#*......................::..........::::---:::::::..................++++++++......# ",
" *:..=@@@@@@@=%@@===@@@@@@@@@*=................-+**#%@#*=.......::::----::::::::..............=+++++++*.......+ ",
" *...*@@@@@@+*+@====*@@@@@@@@@*+.........-++%@@@@@@@@@@@%*:......::::----:::::::::.......-.-=++++++++++.......- ",
" *...%@@@@@@+@**+====%@@@@@@@@@%**+=***%@@@@@@@@@@@@@@@@@@*=......::::----::::::::::......-*+++++++++*.........**# ",
" %...@@@@@@%++++++++==@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@*+.....::::-----:::::::::::.......+*+++++**.....*:..%@#* ",
" ..:@@@@@@*++++++++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@*+.....::::-----::::::::::::......-******=.....**+.*@@%* ",
" ..-@@@@@@+++++++++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@*=....::::------::::::::::::::...-# @#*.....+%@@**@@@* ",
" ..-@@@@@@++++++++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@*-....::::-------:::::::::::::::-* @=.....*@@@@%@@@*# ",
" +.-@@@@@@#++++++++*@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@*....:::::-----=-::::::::::::::=##*@*.....-*@@@@@@@@*# ",
" +.:@@@@@@@++++++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%*....::::---++*++:::::::::::::=*%@%......*@@@@@@@@@*@ ",
" #..%@@@@@@#++++++@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@+*@@@@*-...:::::-+--*=-*-:::::::::::+%@+......=#@@@@@@@@@* ",
" +.*@@@@@@@@***#@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@+++@@@@@@*....::::+--***--+-::::::::::*+........*@@@@@@@@@@* ",
" *.*@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@*=====@#@@@@@#=...::::++****+-*--:::::::::*........*@@@@@@@@@@*@ ",
" %-+@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@*=========*+@@@@@@*...::::=********=---::::::.*......-+@@@@@@@@@@@* ",
" *-@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@+======+===+@@@@@@@*:...:::+:+***+-*-----:::::.*+++++++@@@@@@@@@@@* ",
" #=*@@@@@@@@@@@@@@@@@@@****@@@@@@@@@@@@@@@@@++++++#@@#=@@@@@@@@@*...:::+:-**=-==-------:::-++++++#@@@@@@@@@@@*# ",
" *+@@@@@@@@@@@@@@@@@@@*%%%*@@@@@@@@@@@@@@@@+++++++%++=@@@@@@@@@*...::::*-+*-=+----------:+%*+#%@@@@@@@@@@@@#* ",
" #+#@@@@@@@@@@@@@@@@@@*%%%%@@@@@@@@@@@@@@@@+++++++%#@+@@@@@@@@@*:...::::=***=------------*@@*#@@@@@@@@@@@@#* ",
" **@@@@@@@@@@@@@@@@@@#*%%#@@@@@@@@@@@@@@@@+++++++++++@@@@@@@@@@=...:::::----------------*@@@#*@@@@@@@@@@*% ",
" **@@@@@@@@@@@@@@@@@@@**@@@@@@@@@@@@@@@@@+++++++++++@@@@@@@@@@+...:::::---------------+%@@@@#*@@@@@@@@@* ",
" %*@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@+++++++++++@@@@@@@@@@*....::::---------------*@@@@@@#*@@@@@@@@* ",
" **@@@@@@@@@@@@@@*@@@@@@@@***@@@@@@@@@@@@+++++++++@@@@@@@@@@@*....:=::=-------------=#@@@@@@@#*@@@@@@%% ",
" #*@@@@@@@@@@@@@@*@@@@@@*#%#%@@@@@@@@@@@**++++++#@@@@@@@@@@@*....:%+::-------------*@@@@@@@@@%*@@@@@*# ",
" #*@@@@@@@@@@@@@@*%@@@#*%*%%@@@@@@@@@@@@****++#@@@@@@@@@@@@*...=%%%#-:-----------=#@@@@@@@@@@%*%@@@* ",
" #*@@@@@@@@@@@@@@#***%%*%%@@@@@@@@@@@@@@@%##@@@@@@@@@@@@@@+....:%=:::-----------*@@@@@@@@@@@@@*#@%% ****% ",
" %***#**#@@@@@@@@@@@@@@#*#%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@+.....#::::----------*%@@@@@@@@@@@@@@%**%@@%@@@@**% ",
" #*@@@@@@%*%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%:.....+:::::--------=*@@@@@@@@@@@@@@@@@@@@@@@@@@@@*% ",
" *@@@@@@@@@*#@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#......=:::::-------=*@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@* ",
" #%@@@@@@@@@@*@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@*......::::::-------*%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@# ",
" *@@@@@@@@@@@@*@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@%:......::::::------*%#*#@@@@@@@@@@@@@@@@@@@@@@@@@@@@@# ",
" *@@@@@@@@@@@@@**@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#.......:::::::----*%*####@@@@@@@@@@@@@@@@@@@@@@@@@@@@* ",
" #%@@@@@@@@@@@@*%**@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@-.......:::::::--=*%*######@@@@@@@@@@@@@@@@@@@@@@@@@@@@ ",
" *@@@@@@@@@@@@#@@%**@@@@@@@@@@@@@@@@@@@@*****#@@@@@@@@@@@@@:........:::::::-++*#*######@@@@@@@@@@@@@@@@@@@@@@@@@@@@ ",
" ##@@@@@@@@@@@%@@@%@#*%@@@@@@@@@@@@@@@**@@@@@%*%@@@@@@@@@@-.........:::::::*:...*######@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ ",
" ##@@@@@@@@@@@@@@@#@%+*#@@@@@@@@@@@@*%@@@@@@@@*%@@@@@@@@:..........:::::+=.....*#####*@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ ",
" %*@@@@@@@@@@@@@@@#*++++*%@@@@@@@@*%@@@@@@@@@@*@@@@@@%=...........:::.-......+%*###*%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@# ",
" #*# @@@@*++++++++*#%%@@*%@@@@@@@@@@@*%@@@%+.............-........+#@@@***@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@* ",
" %##% @*+++++++++++++***@@@@@@@@@@@@*%%**...........=*+.......-*#@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@# ",
" %% #*#### *-=+++++++++++*%#@@@@@@@@@@@@@*+-.....:-=+*****++===+**#@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@# ",
" *%*#####%*:....:=++++**%@@*@@@@@@@@@@@@@*********++++*@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@* ",
" %*######*.......-+**%@@@@@*@@@@@@@@@@@@#+++++++++=...#@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@* ",
" *######*********#%*%@@@@@*@@@@@@@@@@@@*++=-:.......-%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@* ",
" #*#####* #*@ #*@@@@*@@@@@@@@@@@**............=@@@@@@@@@@@@@@@@@@@@@@@@#%@@@@@@@@@@@@@@@@@#@@@@@@@@@@@%# ",
" **###*% %** *#@@#%@@@@@@@@%*@#*...........=@@@@@@@@@@@@@@@@@@@@@@%* #*%%@@@@@@@@@%%**% *%@@@@@@@@@*% ",
" #***#***# #*#@*@@@@@@@@@@@@#+..........-@@@@@@@@@@@@@@@@@@@@@%# %***********# #*%@@@@@@@%* ",
" @#% %**@@@@@@@@@@@@@#*.........:%@@@@@@@@@@@@@@@@@@%*% #%%# **%@@@@#*% ",
" %*@@@@@@@@@@@@@@*-........*@@@@@@@@@@@@@@@@%*% %****** ",
" #*@@@@@@@@@@@@@@**:......*#*#@@@@@@@@@@%**% ",
" **@@@@@@@@@@@@@@%**=...:*####@@@@@%#**# ",
" #*@@@@@@@@@@@@@@@@#**+*#####******% ",
" #**@@@@@@@@@@@@@%*****######% ",
" #***#%@@@%%#**# #*###### ",
" %*#**##% *#####* ",
" %*###*% ",
" #***% "
]
]

View File

@@ -5,11 +5,18 @@
import { queryOptions } from "@tanstack/react-query"
const API_BASE_URL = `http://${import.meta.env.VITE_API_HOST || "localhost:8000"}`
const getApiBaseUrl = () => {
const protocol = window.location.protocol
const host = import.meta.env.VITE_API_HOST || window.location.host
return `${protocol}//${host}`
}
const API_BASE_URL = getApiBaseUrl()
// System Info
export interface BeszelSystemInfo {
name: string
status: "up" | "down"
info: {
cpu: number
ram: number

View File

@@ -0,0 +1,14 @@
import cn from "./lib/cn"
export function Tile({ children, className }: { children?: React.ReactNode; className?: string }) {
return (
<div
className={cn(
"relative rounded-xl bg-neutral-200 dark:bg-neutral-900 flex flex-col justify-end items-start",
className,
)}
>
{children}
</div>
)
}

View File

@@ -10,3 +10,7 @@ body {
-moz-osx-font-smoothing: grayscale;
overscroll-behavior: none;
}
input[type="range"].brightness-slider {
@apply appearance-none w-full bg-transparent;
}

View File

@@ -0,0 +1,25 @@
import { useEffect, useState } from "react"
import kuromiFrames from "./assets/kuromi-frames.json"
export function Kuromi() {
const [frameIndex, setFrameIndex] = useState(0)
useEffect(() => {
const interval = setInterval(() => {
setFrameIndex((prev) => (prev + 1) % kuromiFrames.length)
}, 300)
return () => clearInterval(interval)
}, [])
const currentFrame = kuromiFrames[frameIndex]
return (
<pre className="leading-none tracking-[0.6em] select-none font-mono text-black dark:text-white scale-[5%]">
{currentFrame.map((line, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: frame lines don't have unique identifiers
<div key={index}>{line}</div>
))}
</pre>
)
}

View File

@@ -0,0 +1,359 @@
import { ZIGBEE_DEVICE, type ZigbeeDeviceName, ZigbeeDeviceState, type ZigbeeDeviceStates } from "@eva/zigbee"
import { useDrag } from "@use-gesture/react"
import { atom, useAtom, useAtomValue, useSetAtom, useStore } from "jotai"
import { CloudyIcon, LightbulbOffIcon, type LucideIcon, MoonStarIcon } from "lucide-react"
import { useEffect, useRef } from "react"
import cn from "./components/lib/cn"
import { Tile } from "./components/tile"
const LIGHT_CONTROL_TILE_SLIDER_BAR_COUNT = 36
// Store brightness as step (0-43) to match the 44 bars exactly
// Step 0 = OFF, Steps 1-43 map to bars 42-0
export const brightnessStepAtoms = atom({
[ZIGBEE_DEVICE.deskLamp]: atom(0),
[ZIGBEE_DEVICE.livingRoomFloorLamp]: atom(0),
})
export const intermediateBrightnessStepAtoms = atom({
[ZIGBEE_DEVICE.deskLamp]: atom(-1),
[ZIGBEE_DEVICE.livingRoomFloorLamp]: atom(-1),
})
const sceneAtom = atom<string | null>(null)
const DEVICE_FRIENDLY_NAMES = {
[ZIGBEE_DEVICE.deskLamp]: "Desk Lamp",
[ZIGBEE_DEVICE.livingRoomFloorLamp]: "Floor Lamp",
} as const
export type LightSceneConfig = {
id: string
name: string
icon: LucideIcon
deviceStates: Partial<ZigbeeDeviceStates>
}
const DEFAULT_SCENES: Record<string, LightSceneConfig> = {
"lights-off": {
id: "lights-off",
name: "Lights off",
icon: LightbulbOffIcon,
deviceStates: {
[ZIGBEE_DEVICE.deskLamp]: {
state: "OFF",
brightness: 0,
},
[ZIGBEE_DEVICE.livingRoomFloorLamp]: {
state: "OFF",
brightness: 0,
},
},
},
evening: {
id: "evening",
name: "Evening",
icon: MoonStarIcon,
deviceStates: {
[ZIGBEE_DEVICE.deskLamp]: {
state: "ON",
brightness: 127,
},
[ZIGBEE_DEVICE.livingRoomFloorLamp]: {
state: "ON",
brightness: 254,
},
},
},
gloomy: {
id: "gloomy",
name: "Gloomy",
icon: CloudyIcon,
deviceStates: {
[ZIGBEE_DEVICE.deskLamp]: {
state: "ON",
brightness: 50,
},
[ZIGBEE_DEVICE.livingRoomFloorLamp]: {
state: "ON",
brightness: 128,
},
},
},
} as const
// Convert brightness (0-254) to step (0-43)
// Step 0 = brightness 0, steps 1-43 map to brightness 1-254
export function brightnessToStep(brightness: number): number {
if (brightness === 0) return 0
// Map brightness 1-254 to steps 1-43
return Math.max(1, Math.round((brightness / 254) * (LIGHT_CONTROL_TILE_SLIDER_BAR_COUNT - 1)))
}
// Convert step (0-43) to brightness (0-254)
// Step 0 = brightness 0, steps 1-43 map to brightness 1-254
export function stepToBrightness(step: number): number {
if (step === 0) return 0
// Map steps 1-43 to brightness 1-254
return Math.max(1, Math.round((step / (LIGHT_CONTROL_TILE_SLIDER_BAR_COUNT - 1)) * 254))
}
export function LightControlTile({
deviceName,
className,
onRequestBrightnessStepChange,
}: { deviceName: ZigbeeDeviceName; className?: string; onRequestBrightnessStepChange: (step: number) => void }) {
const currentBrightnessStep = useAtomValue(useAtomValue(brightnessStepAtoms)[deviceName])
// Map step to bar index for thumb position
// Step 0 = OFF (no thumb shown, set to invalid index)
// Step 1-43 map to bars 42-0
const initialHighlightIndexStart =
currentBrightnessStep === 0
? LIGHT_CONTROL_TILE_SLIDER_BAR_COUNT - 1 // No thumb (index out of range, but no bars highlighted)
: LIGHT_CONTROL_TILE_SLIDER_BAR_COUNT - 1 - currentBrightnessStep
const touchContainerRef = useRef<HTMLDivElement | null>(null)
const barRefs = useRef<(HTMLDivElement | null)[]>(
Array.from({ length: LIGHT_CONTROL_TILE_SLIDER_BAR_COUNT }, () => null),
)
const setIntermediateBrightnessStep = useSetAtom(useAtomValue(intermediateBrightnessStepAtoms)[deviceName])
const setScene = useSetAtom(sceneAtom)
const store = useStore()
useEffect(() => {
const brightnessStepAtom = store.get(brightnessStepAtoms)[deviceName]
if (store.get(brightnessStepAtom) === currentBrightnessStep) {
setIntermediateBrightnessStep(-1)
}
}, [currentBrightnessStep, deviceName, setIntermediateBrightnessStep, store])
function requestBrightnessStepChange(step: number) {
onRequestBrightnessStepChange(step)
setScene(null)
}
const bind = useDrag(({ xy: [x], first, last }) => {
if (!touchContainerRef.current) return
if (!first) {
touchContainerRef.current.dataset.active = "true"
}
if (last) {
delete touchContainerRef.current.dataset.active
let thumbIndex = -1
for (let i = 0; i < LIGHT_CONTROL_TILE_SLIDER_BAR_COUNT; i++) {
const bar = barRefs.current[i]
if (!bar) continue
const barRect = bar.getBoundingClientRect()
if (x >= barRect.left - 2 && x < barRect.right + 2 && thumbIndex === -1) {
thumbIndex = i
bar.dataset.thumb = "true"
} else {
delete bar.dataset.thumb
}
delete bar.dataset.touched
delete bar.dataset.touchProximity
}
if (thumbIndex !== -1) {
// Map bar index to step: bar 42 -> step 1, bar 0 -> step 43
const step = LIGHT_CONTROL_TILE_SLIDER_BAR_COUNT - 1 - thumbIndex
requestBrightnessStepChange(step)
} else {
const firstElement = barRefs.current[barRefs.current.length - 1]
const lastElement = barRefs.current[0]
if (lastElement && x > lastElement.getBoundingClientRect().right) {
lastElement.dataset.thumb = "true"
setIntermediateBrightnessStep(LIGHT_CONTROL_TILE_SLIDER_BAR_COUNT - 1)
if (last) {
requestBrightnessStepChange(LIGHT_CONTROL_TILE_SLIDER_BAR_COUNT - 1)
}
} else if (firstElement && x < firstElement.getBoundingClientRect().left) {
firstElement.dataset.thumb = "true"
setIntermediateBrightnessStep(0)
if (last) {
requestBrightnessStepChange(0)
}
}
}
} else {
let touchedIndex = -1
for (let i = 0; i < LIGHT_CONTROL_TILE_SLIDER_BAR_COUNT; i++) {
const bar = barRefs.current[i]
if (!bar) continue
const barRect = bar.getBoundingClientRect()
delete bar.dataset.thumb
if (x >= barRect.left - 2 && x < barRect.right + 2 && touchedIndex === -1) {
touchedIndex = i
bar.dataset.touched = "true"
bar.dataset.highlighted = "false"
delete bar.dataset.touchProximity
const step = LIGHT_CONTROL_TILE_SLIDER_BAR_COUNT - i - 1
setIntermediateBrightnessStep(step)
if (barRefs.current[i - 1]) {
barRefs.current[i - 1]!.dataset.touchProximity = "close"
}
if (barRefs.current[i - 2]) {
barRefs.current[i - 2]!.dataset.touchProximity = "medium"
}
if (barRefs.current[i - 3]) {
barRefs.current[i - 3]!.dataset.touchProximity = "far"
}
} else if (barRect.left < x) {
if (bar.dataset.touched === "true") {
bar.dataset.prevTouched = "true"
} else {
delete bar.dataset.prevTouched
}
bar.dataset.touched = "false"
bar.dataset.highlighted = "true"
if (touchedIndex >= 0) {
const diff = i - touchedIndex
if (diff === 1) {
bar.dataset.touchProximity = "close"
} else if (diff === 2) {
bar.dataset.touchProximity = "medium"
} else if (diff === 3) {
bar.dataset.touchProximity = "far"
} else {
delete bar.dataset.touchProximity
}
} else {
delete bar.dataset.touchProximity
}
} else if (barRect.right > x) {
bar.dataset.highlighted = "false"
bar.dataset.touched = "false"
if (touchedIndex >= 0) {
const diff = i - touchedIndex
if (diff === 1) {
bar.dataset.touchProximity = "close"
} else if (diff === 2) {
bar.dataset.touchProximity = "medium"
} else if (diff === 3) {
bar.dataset.touchProximity = "far"
} else {
delete bar.dataset.touchProximity
}
} else {
delete bar.dataset.touchProximity
}
} else {
bar.dataset.touched = "false"
bar.dataset.highlighted = "false"
delete bar.dataset.touchProximity
}
}
if (touchedIndex === -1) {
const firstElement = barRefs.current[barRefs.current.length - 1]
const lastElement = barRefs.current[0]
if (lastElement && x > lastElement.getBoundingClientRect().right) {
lastElement.dataset.thumb = "true"
setIntermediateBrightnessStep(LIGHT_CONTROL_TILE_SLIDER_BAR_COUNT - 1)
} else if (firstElement && x < firstElement.getBoundingClientRect().left) {
firstElement.dataset.thumb = "true"
setIntermediateBrightnessStep(0)
}
}
}
})
return (
<Tile className={cn("h-full flex flex-col justify-start items-start", className)}>
<div
{...bind()}
ref={touchContainerRef}
className="group flex-1 flex flex-row-reverse justify-center items-center touch-none gap-x-1 w-full translate-y-4"
>
{Array.from({ length: LIGHT_CONTROL_TILE_SLIDER_BAR_COUNT }).map((_, index) => {
const highlighted = index > initialHighlightIndexStart
return (
<div
data-highlighted={highlighted}
data-thumb={index === initialHighlightIndexStart}
data-prev-touched={false}
data-touched={false}
ref={(ref) => {
barRefs.current[index] = ref
}}
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
key={index}
className="transition-all transition-75 w-[2px] h-[2px] bg-neutral-400 rounded-full data-[highlighted=true]:h-2 data-[touch-proximity=close]:h-6 data-[touch-proximity=medium]:h-4 data-[touch-proximity=far]:h-2 data-[highlighted=true]:bg-teal-500 data-[touched=true]:h-8 data-[touched=true]:w-1 data-[touched=true]:bg-teal-500 data-[touched=true]:transition-none data-[prev-touched=true]:transition-none data-[thumb=true]:h-8 data-[thumb=true]:bg-teal-500"
/>
)
})}
</div>
<div className="px-4 pb-2 w-full flex flex-row items-center justify-center space-x-2">
<p className="tracking-tigher uppercase">{DEVICE_FRIENDLY_NAMES[deviceName]}</p>
<BrightnessLevelLabel deviceName={deviceName} />
</div>
</Tile>
)
}
function BrightnessLevelLabel({ deviceName }: { deviceName: ZigbeeDeviceName }) {
const currentBrightnessStep = useAtomValue(useAtomValue(brightnessStepAtoms)[deviceName])
const intermediateBrightnessStep = useAtomValue(useAtomValue(intermediateBrightnessStepAtoms)[deviceName])
const step = intermediateBrightnessStep === -1 ? currentBrightnessStep : intermediateBrightnessStep
let label: string
if (step === 0) {
label = "OFF"
} else {
// Convert step to percentage: step 1 = ~2%, step 43 = 100%
const brightnessPercentage = Math.round((step / (LIGHT_CONTROL_TILE_SLIDER_BAR_COUNT - 1)) * 100)
label = `${brightnessPercentage}%`
}
return (
<p
className={cn(
"flex-1 text-right font-bold font-mono tracking-tigher",
step === 0 ? "text-neutral-400" : "text-teal-500 dark:text-teal-400",
)}
>
{label}
</p>
)
}
export function LightSceneTile({
className,
onSceneChange,
}: { className?: string; onSceneChange: (scene: LightSceneConfig) => void }) {
const [activeSceneId, setActiveSceneId] = useAtom(sceneAtom)
return (
<Tile className={cn("h-full flex flex-col justify-start items-start p-1 gap-1", className)}>
{Object.entries(DEFAULT_SCENES).map(([id, { icon: Icon, name }]) => (
<button
onClick={() => {
setActiveSceneId(id)
onSceneChange(DEFAULT_SCENES[id])
}}
key={id}
type="button"
className={cn(
"w-full gap-2 flex flex-row items-end justify-start h-full border tracking-tigher first:rounded-t-lg last:rounded-b-lg transition-all duration-150 active:transition-none",
activeSceneId === id
? "p-2 border-teal-500 text-teal-500 dark:text-teal-400 border-2 font-bold"
: "p-[9px] text-neutral-400 border-neutral-300 dark:border-neutral-800 active:shadow-inner active:bg-neutral-300 dark:active:bg-teal-500 active:text-neutral-900 font-lighter",
)}
>
<Icon size={16} strokeWidth={2} />
<p className="text-md tracking-none leading-none uppercase">{name}</p>
</button>
))}
</Tile>
)
}

View File

@@ -5,7 +5,13 @@
import { queryOptions } from "@tanstack/react-query"
const API_BASE_URL = `http://${import.meta.env.VITE_API_HOST || "localhost:8000"}`
const getApiBaseUrl = () => {
const protocol = window.location.protocol
const host = import.meta.env.VITE_API_HOST || window.location.host
return `${protocol}//${host}`
}
const API_BASE_URL = getApiBaseUrl()
// Disruption Summary
export interface DisruptionSummary {

View File

@@ -0,0 +1,110 @@
import { useEffect } from "react"
interface SunTimes {
sunrise: Date
sunset: Date
}
function calculateSunTimes(latitude: number, longitude: number, date: Date = new Date()): SunTimes {
const julianDay = getJulianDay(date)
const julianCentury = (julianDay - 2451545) / 36525
const geomMeanLongSun = (280.46646 + julianCentury * (36000.76983 + julianCentury * 0.0003032)) % 360
const geomMeanAnomSun = 357.52911 + julianCentury * (35999.05029 - 0.0001537 * julianCentury)
const eccentEarthOrbit = 0.016708634 - julianCentury * (0.000042037 + 0.0000001267 * julianCentury)
const sunEqOfCtr =
Math.sin(toRadians(geomMeanAnomSun)) * (1.914602 - julianCentury * (0.004817 + 0.000014 * julianCentury)) +
Math.sin(toRadians(2 * geomMeanAnomSun)) * (0.019993 - 0.000101 * julianCentury) +
Math.sin(toRadians(3 * geomMeanAnomSun)) * 0.000289
const sunTrueLong = geomMeanLongSun + sunEqOfCtr
const sunAppLong = sunTrueLong - 0.00569 - 0.00478 * Math.sin(toRadians(125.04 - 1934.136 * julianCentury))
const meanObliqEcliptic = 23 + (26 + (21.448 - julianCentury * (46.815 + julianCentury * (0.00059 - julianCentury * 0.001813))) / 60) / 60
const obliqCorr = meanObliqEcliptic + 0.00256 * Math.cos(toRadians(125.04 - 1934.136 * julianCentury))
const sunDeclin = toDegrees(Math.asin(Math.sin(toRadians(obliqCorr)) * Math.sin(toRadians(sunAppLong))))
const varY = Math.tan(toRadians(obliqCorr / 2)) * Math.tan(toRadians(obliqCorr / 2))
const eqOfTime =
4 *
toDegrees(
varY * Math.sin(2 * toRadians(geomMeanLongSun)) -
2 * eccentEarthOrbit * Math.sin(toRadians(geomMeanAnomSun)) +
4 * eccentEarthOrbit * varY * Math.sin(toRadians(geomMeanAnomSun)) * Math.cos(2 * toRadians(geomMeanLongSun)) -
0.5 * varY * varY * Math.sin(4 * toRadians(geomMeanLongSun)) -
1.25 * eccentEarthOrbit * eccentEarthOrbit * Math.sin(2 * toRadians(geomMeanAnomSun)),
)
const haSunrise = toDegrees(Math.acos(Math.cos(toRadians(90.833)) / (Math.cos(toRadians(latitude)) * Math.cos(toRadians(sunDeclin))) - Math.tan(toRadians(latitude)) * Math.tan(toRadians(sunDeclin))))
const solarNoon = (720 - 4 * longitude - eqOfTime) / 1440
const sunriseTime = solarNoon - (haSunrise * 4) / 1440
const sunsetTime = solarNoon + (haSunrise * 4) / 1440
const sunrise = new Date(date)
sunrise.setHours(0, 0, 0, 0)
sunrise.setMinutes(sunriseTime * 1440)
const sunset = new Date(date)
sunset.setHours(0, 0, 0, 0)
sunset.setMinutes(sunsetTime * 1440)
return { sunrise, sunset }
}
function getJulianDay(date: Date): number {
const year = date.getFullYear()
const month = date.getMonth() + 1
const day = date.getDate()
const hour = date.getHours()
const minute = date.getMinutes()
const second = date.getSeconds()
let a = Math.floor((14 - month) / 12)
let y = year + 4800 - a
let m = month + 12 * a - 3
let jdn = day + Math.floor((153 * m + 2) / 5) + 365 * y + Math.floor(y / 4) - Math.floor(y / 100) + Math.floor(y / 400) - 32045
return jdn + (hour - 12) / 24 + minute / 1440 + second / 86400
}
function toRadians(degrees: number): number {
return (degrees * Math.PI) / 180
}
function toDegrees(radians: number): number {
return (radians * 180) / Math.PI
}
function isDarkMode(latitude: number, longitude: number): boolean {
const now = new Date()
const { sunrise, sunset } = calculateSunTimes(latitude, longitude, now)
return now < sunrise || now > sunset
}
export function useAutoTheme(latitude: number, longitude: number) {
useEffect(() => {
const updateTheme = () => {
const shouldBeDark = isDarkMode(latitude, longitude)
if (shouldBeDark) {
document.documentElement.classList.add("dark")
} else {
document.documentElement.classList.remove("dark")
}
}
updateTheme()
const interval = setInterval(updateTheme, 60000)
return () => clearInterval(interval)
}, [latitude, longitude])
}

View File

@@ -26,7 +26,13 @@ import {
Wind,
} from "lucide-react"
const API_BASE_URL = `http://${import.meta.env.VITE_API_HOST || "localhost:3000"}`
const getApiBaseUrl = () => {
const protocol = window.location.protocol
const host = import.meta.env.VITE_API_HOST || window.location.host
return `${protocol}//${host}`
}
const API_BASE_URL = getApiBaseUrl()
export const DEFAULT_LATITUDE = Number(import.meta.env.VITE_DEFAULT_LATITUDE) || 37.7749
export const DEFAULT_LONGITUDE = Number(import.meta.env.VITE_DEFAULT_LONGITUDE) || -122.4194

View File

@@ -1,5 +1,6 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: 'class',
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",

View File

@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "monorepo",
@@ -54,6 +55,7 @@
"name": "@eva/jrpc",
"dependencies": {
"@eva/zigbee": "workspace:*",
"nanoid": "^5.1.6",
},
"devDependencies": {
"@types/bun": "latest",
@@ -473,7 +475,7 @@
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="],
"node-releases": ["node-releases@2.0.26", "", {}, "sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA=="],
@@ -637,6 +639,8 @@
"micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],

View File

@@ -1,49 +1,40 @@
import type { ZigbeeDeviceName, ZigbeeDeviceStates } from "@eva/zigbee"
import { nanoid } from "nanoid"
export type JrpcRequestId = string & { __brand: "JrpcRequestId" }
export type JrpcSchema = {
subscribeToDevice: {
Params: {
deviceName: ZigbeeDeviceName
}
Response: true
}
setDeviceState: {
Params: {
deviceName: ZigbeeDeviceName
state: unknown
}
Response: true
}
showDeviceState: {
Params: {
[key in ZigbeeDeviceName]: {
deviceName: key
state: ZigbeeDeviceStates[key]
}
}[ZigbeeDeviceName]
Response: true
}
subscribeToDevice(p: { deviceName: ZigbeeDeviceName }): true
unsubscribeFromDevice(p: { deviceName: ZigbeeDeviceName }): true
setDeviceState(p: { deviceName: ZigbeeDeviceName; state: unknown }): true
showDeviceState<DeviceName extends ZigbeeDeviceName>(
p: { [K in ZigbeeDeviceName]: { deviceName: K; state: ZigbeeDeviceStates[K] } }[DeviceName],
): ZigbeeDeviceStates[ZigbeeDeviceName]
}
export type JrpcRequest<Method extends keyof JrpcSchema = keyof JrpcSchema> = {
[M in keyof JrpcSchema]: {
id: string
id: JrpcRequestId
jsonrpc: "2.0"
method: M
params: JrpcSchema[M]["Params"]
params: Parameters<JrpcSchema[M]>[0]
}
}[Method]
export type JrpcResponse<Method extends keyof JrpcSchema = keyof JrpcSchema> = {
[M in keyof JrpcSchema]:
| {
id: string
id: JrpcRequestId
jsonrpc: "2.0"
result: JrpcSchema[M]["Response"]
result: ReturnType<JrpcSchema[M]>
}
| {
id: string
id: JrpcRequestId
jsonrpc: "2.0"
error: string
}
}[Method]
export function newJrpcRequestId(): JrpcRequestId {
return nanoid()
}

View File

@@ -3,7 +3,8 @@
"module": "index.ts",
"type": "module",
"dependencies": {
"@eva/zigbee": "workspace:*"
"@eva/zigbee": "workspace:*",
"nanoid": "^5.1.6"
},
"devDependencies": {
"@types/bun": "latest"

View File

@@ -4,7 +4,6 @@ export const ZIGBEE_DEVICE = {
deskLamp: "desk_lamp",
livingRoomFloorLamp: "living_room_floor_lamp",
} as const
export type ZigbeeDeviceName = (typeof ZIGBEE_DEVICE)[keyof typeof ZIGBEE_DEVICE]
export type ZigbeeDeviceStates = {
[ZIGBEE_DEVICE.deskLamp]: {
@@ -13,19 +12,12 @@ export type ZigbeeDeviceStates = {
}
[ZIGBEE_DEVICE.livingRoomFloorLamp]: {
brightness: number
level_config: {
on_level: "previous"
}
linkquality: number
state: "ON" | "OFF"
update: {
installed_version: number
latest_version: number
state: "available" | "idle"
}
}
}
export const ALL_ZIGBEE_DEVICE_NAMES: ZigbeeDeviceName[] = Object.values(ZIGBEE_DEVICE)
export type ZigbeeDeviceName = keyof ZigbeeDeviceStates
export type ZigbeeDeviceState = ZigbeeDeviceStates[keyof ZigbeeDeviceStates]
export type ZigbeeDeviceState<DeviceName extends ZigbeeDeviceName = ZigbeeDeviceName> = ZigbeeDeviceStates[DeviceName]
export const ALL_ZIGBEE_DEVICE_NAMES: ZigbeeDeviceName[] = Object.values(ZIGBEE_DEVICE)