feat: initial zigbee control implementation
Some checks failed
Build and Publish Docker Image / build-and-push (push) Failing after 27s
Some checks failed
Build and Publish Docker Image / build-and-push (push) Failing after 27s
Co-authored-by: Ona <no-reply@ona.com>
This commit is contained in:
@@ -1,18 +1,21 @@
|
||||
{
|
||||
"name": "backend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "bun run --watch src/index.ts",
|
||||
"build": "bun build src/index.ts --outdir dist --target bun",
|
||||
"start": "bun run dist/index.js",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"hono": "^4.6.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
"name": "@eva/backend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "bun run --watch src/index.ts",
|
||||
"build": "bun build src/index.ts --outdir dist --target bun",
|
||||
"start": "bun run dist/index.js",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"hono": "^4.6.14",
|
||||
"mqtt": "^5.14.1",
|
||||
"@eva/jrpc": "workspace:*",
|
||||
"@eva/zigbee": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
|
||||
4
apps/backend/src/env.d.ts
vendored
4
apps/backend/src/env.d.ts
vendored
@@ -8,5 +8,9 @@ declare namespace NodeJS {
|
||||
BESZEL_HOST?: string
|
||||
BESZEL_EMAIL?: string
|
||||
BESZEL_PASSWORD?: string
|
||||
MQTT_HOST: string
|
||||
MQTT_PORT: number
|
||||
MQTT_USERNAME: string
|
||||
MQTT_PASSWORD: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { Hono } from "hono"
|
||||
import { serveStatic, websocket } from "hono/bun"
|
||||
import { cors } from "hono/cors"
|
||||
import { logger } from "hono/logger"
|
||||
import { serveStatic } from "hono/bun"
|
||||
import weather from "./weather"
|
||||
import tfl from "./tfl"
|
||||
import beszel from "./beszel"
|
||||
import { createMqttClient } from "./mqtt"
|
||||
import tfl from "./tfl"
|
||||
import weather from "./weather"
|
||||
import zigbee from "./zigbee/routes"
|
||||
|
||||
const mqtt = await createMqttClient({
|
||||
host: process.env.MQTT_HOST,
|
||||
port: process.env.MQTT_PORT,
|
||||
username: process.env.MQTT_USERNAME,
|
||||
password: process.env.MQTT_PASSWORD,
|
||||
})
|
||||
|
||||
const app = new Hono()
|
||||
|
||||
@@ -24,6 +33,9 @@ app.route("/api/tfl", tfl)
|
||||
// Mount Beszel routes
|
||||
app.route("/api/beszel", beszel)
|
||||
|
||||
// Mount Zigbee routes
|
||||
app.route("/api/zigbee", zigbee(mqtt))
|
||||
|
||||
// Serve static files from dashboard build
|
||||
app.use("/*", serveStatic({ root: "../dashboard/dist" }))
|
||||
|
||||
@@ -33,4 +45,5 @@ app.get("*", serveStatic({ path: "../dashboard/dist/index.html" }))
|
||||
export default {
|
||||
port: 8000,
|
||||
fetch: app.fetch,
|
||||
websocket,
|
||||
}
|
||||
|
||||
6
apps/backend/src/jrpc.ts
Normal file
6
apps/backend/src/jrpc.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type JrpcRequest = {
|
||||
jsonrpc: "2.0"
|
||||
method: string
|
||||
params: unknown
|
||||
id: number
|
||||
}
|
||||
15
apps/backend/src/mqtt.ts
Normal file
15
apps/backend/src/mqtt.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import mqtt from "mqtt"
|
||||
|
||||
export async function createMqttClient({
|
||||
host,
|
||||
port,
|
||||
username,
|
||||
password,
|
||||
}: { host: string; port: number; username: string; password: string }) {
|
||||
return await mqtt.connectAsync({
|
||||
host,
|
||||
port,
|
||||
username,
|
||||
password,
|
||||
})
|
||||
}
|
||||
1
apps/backend/src/zigbee.ts
Normal file
1
apps/backend/src/zigbee.ts
Normal file
@@ -0,0 +1 @@
|
||||
const BASE_TOPIC = "nexus"
|
||||
50
apps/backend/src/zigbee/controller.ts
Normal file
50
apps/backend/src/zigbee/controller.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { ZigbeeDeviceName } from "@eva/zigbee"
|
||||
import type { MqttClient } from "mqtt"
|
||||
|
||||
export type DeviceMessageListener = (msg: unknown) => void
|
||||
|
||||
export class ZigbeeController {
|
||||
private deviceListeners: Map<string, DeviceMessageListener[]> = new Map()
|
||||
|
||||
constructor(
|
||||
private readonly baseTopic: string,
|
||||
private readonly mqtt: MqttClient,
|
||||
) {
|
||||
this.mqtt.on("message", (topic, message) => {
|
||||
const [baseTopic, deviceName] = topic.split("/")
|
||||
if (baseTopic !== this.baseTopic) {
|
||||
return
|
||||
}
|
||||
const listeners = this.deviceListeners.get(deviceName)
|
||||
if (listeners) {
|
||||
for (const listener of listeners) {
|
||||
listener(JSON.parse(message.toString()))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async subscribeToDevice(deviceName: ZigbeeDeviceName, listener: DeviceMessageListener): Promise<void> {
|
||||
await this.mqtt.publishAsync(`${this.baseTopic}/${deviceName}/get`, JSON.stringify({ state: {} }))
|
||||
await this.mqtt.subscribeAsync(`${this.baseTopic}/${deviceName}`)
|
||||
if (!this.deviceListeners.has(deviceName)) {
|
||||
this.deviceListeners.set(deviceName, [])
|
||||
}
|
||||
this.deviceListeners.get(deviceName)?.push(listener)
|
||||
}
|
||||
|
||||
async unsubscribeFromDevice(deviceName: ZigbeeDeviceName, listener: DeviceMessageListener): Promise<void> {
|
||||
await this.mqtt.unsubscribeAsync(`${this.baseTopic}/${deviceName}`)
|
||||
const listeners = this.deviceListeners.get(deviceName)
|
||||
if (listeners) {
|
||||
listeners.splice(listeners.indexOf(listener), 1)
|
||||
if (listeners.length === 0) {
|
||||
this.deviceListeners.delete(deviceName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async setDeviceState(deviceName: ZigbeeDeviceName, state: unknown): Promise<void> {
|
||||
await this.mqtt.publishAsync(`${this.baseTopic}/${deviceName}/set`, JSON.stringify(state))
|
||||
}
|
||||
}
|
||||
18
apps/backend/src/zigbee/middleware.ts
Normal file
18
apps/backend/src/zigbee/middleware.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { ZIGBEE_BASE_TOPIC } from "@eva/zigbee"
|
||||
import { createMiddleware } from "hono/factory"
|
||||
import type { MqttClient } from "mqtt"
|
||||
import { ZigbeeController } from "./controller"
|
||||
|
||||
export function zigbeeController(mqtt: MqttClient) {
|
||||
const controller = new ZigbeeController(ZIGBEE_BASE_TOPIC, mqtt)
|
||||
return createMiddleware((c, next) => {
|
||||
c.set("zigbeeController", controller)
|
||||
return next()
|
||||
})
|
||||
}
|
||||
|
||||
export type ZigbeeContext = {
|
||||
Variables: {
|
||||
zigbeeController: ZigbeeController
|
||||
}
|
||||
}
|
||||
36
apps/backend/src/zigbee/routes.ts
Normal file
36
apps/backend/src/zigbee/routes.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Hono } from "hono"
|
||||
import { upgradeWebSocket } from "hono/bun"
|
||||
import type { WSContext } from "hono/ws"
|
||||
import type { MqttClient } from "mqtt"
|
||||
import type { ZigbeeController } from "./controller"
|
||||
import { type ZigbeeContext, zigbeeController } from "./middleware"
|
||||
import { WebSocketHandler } from "./ws"
|
||||
|
||||
export function zigbee(mqtt: MqttClient) {
|
||||
const h = new Hono<ZigbeeContext>()
|
||||
|
||||
h.use("*", zigbeeController(mqtt))
|
||||
|
||||
h.get(
|
||||
"/",
|
||||
upgradeWebSocket((c) => {
|
||||
const controller = c.get("zigbeeController") as ZigbeeController
|
||||
const wsHandler = new WebSocketHandler(controller)
|
||||
return {
|
||||
onOpen: (event, ws) => {
|
||||
wsHandler.handleWebsocketOpen(event, ws)
|
||||
},
|
||||
onMessage: (event, ws) => {
|
||||
wsHandler.handleWebsocketMessage(event, ws)
|
||||
},
|
||||
onClose: (event, ws) => {
|
||||
wsHandler.handleWebsocketClose(ws)
|
||||
},
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
export default zigbee
|
||||
54
apps/backend/src/zigbee/ws.ts
Normal file
54
apps/backend/src/zigbee/ws.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { JrpcRequest, JrpcResponse } 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"
|
||||
|
||||
export class WebSocketHandler {
|
||||
private deviceListeners: Map<ZigbeeDeviceName, DeviceMessageListener> = new Map()
|
||||
|
||||
constructor(private readonly controller: ZigbeeController) {}
|
||||
|
||||
handleWebsocketOpen(event: Event, ws: WSContext) {
|
||||
for (const device of ALL_ZIGBEE_DEVICE_NAMES) {
|
||||
const l: DeviceMessageListener = (msg) => {
|
||||
const state = msg as ZigbeeDeviceState
|
||||
const request: JrpcRequest<"showDeviceState"> = {
|
||||
id: crypto.randomUUID(),
|
||||
jsonrpc: "2.0",
|
||||
method: "showDeviceState",
|
||||
params: { deviceName: device, state },
|
||||
}
|
||||
ws.send(JSON.stringify(request))
|
||||
}
|
||||
this.controller.subscribeToDevice(device, l)
|
||||
this.deviceListeners.set(device, l)
|
||||
}
|
||||
}
|
||||
|
||||
async handleWebsocketMessage(event: MessageEvent, ws: WSContext) {
|
||||
const message = JSON.parse(event.data) as JrpcRequest | JrpcResponse
|
||||
if ("method" in message) {
|
||||
await this.handleRequest(message, ws)
|
||||
}
|
||||
}
|
||||
|
||||
handleWebsocketClose(_ws: WSContext) {
|
||||
for (const [device, listener] of this.deviceListeners.entries()) {
|
||||
this.controller.unsubscribeFromDevice(device, listener)
|
||||
}
|
||||
}
|
||||
|
||||
private async handleRequest(message: JrpcRequest, ws: WSContext) {
|
||||
switch (message.method) {
|
||||
case "setDeviceState": {
|
||||
await this.controller.setDeviceState(message.params.deviceName, message.params.state)
|
||||
const response: JrpcResponse<"setDeviceState"> = {
|
||||
id: message.id,
|
||||
jsonrpc: "2.0",
|
||||
result: true,
|
||||
}
|
||||
ws.send(JSON.stringify(response))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
VITE_API_URL=http://localhost:3000
|
||||
VITE_API_HOST=localhost:3000
|
||||
VITE_DEFAULT_LATITUDE=37.7749
|
||||
VITE_DEFAULT_LONGITUDE=-122.4194
|
||||
|
||||
@@ -1,29 +1,32 @@
|
||||
{
|
||||
"name": "dashboard",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.62.7",
|
||||
"chart.js": "^4.5.1",
|
||||
"jotai": "^2.10.3",
|
||||
"lucide-react": "^0.546.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^6.0.1"
|
||||
}
|
||||
"name": "@eva/dashboard",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@eva/jrpc": "workspace:*",
|
||||
"@eva/zigbee": "workspace:*",
|
||||
"@tanstack/react-query": "^5.62.7",
|
||||
"@use-gesture/react": "^10.3.1",
|
||||
"chart.js": "^4.5.1",
|
||||
"jotai": "^2.10.3",
|
||||
"lucide-react": "^0.546.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^6.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { JrpcRequest, JrpcResponse } 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 { useEffect, useLayoutEffect, useRef, useState } from "react"
|
||||
import { beszelSystemsQuery } from "./beszel"
|
||||
import cn from "./components/lib/cn"
|
||||
@@ -13,16 +17,84 @@ 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),
|
||||
})
|
||||
|
||||
function App() {
|
||||
const websocket = useRef(new WebSocket(`ws://${import.meta.env.VITE_API_HOST}/api/zigbee`))
|
||||
|
||||
const store = useStore()
|
||||
|
||||
useEffect(() => {
|
||||
websocket.current.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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
if (websocket.current.readyState === WebSocket.OPEN) {
|
||||
websocket.current.close()
|
||||
}
|
||||
}
|
||||
}, [store])
|
||||
|
||||
function setBrightness(deviceName: ZigbeeDeviceName, brightness: number) {
|
||||
const request: JrpcRequest<"setDeviceState"> = {
|
||||
id: crypto.randomUUID(),
|
||||
jsonrpc: "2.0",
|
||||
method: "setDeviceState",
|
||||
params: {
|
||||
deviceName,
|
||||
state:
|
||||
brightness === 0
|
||||
? { state: "OFF", brightness: 0 }
|
||||
: { state: "ON", brightness: Math.round((brightness / 100) * 254) },
|
||||
},
|
||||
}
|
||||
websocket.current.send(JSON.stringify(request))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen bg-neutral-300 dark:bg-neutral-800 p-2">
|
||||
<div className="h-screen bg-neutral-300 dark:bg-neutral-800 p-2 select-none">
|
||||
<div className="w-full h-full grid grid-cols-4 grid-rows-5 gap-2 bg-neutral-300 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300">
|
||||
<DateTimeTile />
|
||||
<WeatherTile />
|
||||
|
||||
<TFLTile className="row-start-1 row-span-1" />
|
||||
|
||||
<SystemTile className="row-start-2 row-span-1" systemName="helian" displayName="Helian" />
|
||||
<SystemTile className="row-start-2 row-span-1" systemName="akira" displayName="Akira" />
|
||||
<TFLTile className="row-start-1 row-span-1" />
|
||||
<Tile className="row-start-3 col-span-2 row-span-3" />
|
||||
|
||||
<LightControlTile
|
||||
className="row-start-3 col-start-3 col-span-1"
|
||||
deviceName={ZIGBEE_DEVICE.livingRoomFloorLamp}
|
||||
onRequestBrightnessChange={(brightness) => {
|
||||
setBrightness(ZIGBEE_DEVICE.livingRoomFloorLamp, brightness)
|
||||
}}
|
||||
/>
|
||||
<LightControlTile
|
||||
className="row-start-3 col-start-4 col-span-1"
|
||||
deviceName={ZIGBEE_DEVICE.deskLamp}
|
||||
onRequestBrightnessChange={(brightness) => {
|
||||
setBrightness(ZIGBEE_DEVICE.deskLamp, brightness)
|
||||
}}
|
||||
/>
|
||||
|
||||
<Tile className="row-start-4 col-span-2 row-span-3" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -520,4 +592,184 @@ function SystemTile({
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { queryOptions } from "@tanstack/react-query"
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:8000"
|
||||
const API_BASE_URL = `http://${import.meta.env.VITE_API_HOST || "localhost:8000"}`
|
||||
|
||||
// System Info
|
||||
export interface BeszelSystemInfo {
|
||||
|
||||
2
apps/dashboard/src/env.d.ts
vendored
2
apps/dashboard/src/env.d.ts
vendored
@@ -1,7 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL: string;
|
||||
readonly VITE_API_HOST: string;
|
||||
readonly VITE_DEFAULT_LATITUDE: string;
|
||||
readonly VITE_DEFAULT_LONGITUDE: string;
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans",
|
||||
"Droid Sans", "Helvetica Neue", sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { queryOptions } from "@tanstack/react-query"
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:8000"
|
||||
const API_BASE_URL = `http://${import.meta.env.VITE_API_HOST || "localhost:8000"}`
|
||||
|
||||
// Disruption Summary
|
||||
export interface DisruptionSummary {
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
Wind,
|
||||
} from "lucide-react"
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || "http://localhost:3000"
|
||||
const API_BASE_URL = `http://${import.meta.env.VITE_API_HOST || "localhost:3000"}`
|
||||
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user