-
-
- Hi, {currentUser?.full_name || currentUser?.email} 👋
-
-
- Welcome back, nice to see you again!!!
-
+
+
+ {health.level === "good"
+ ? "Good"
+ : health.level === "fair"
+ ? "Fair"
+ : "Poor"}{" "}
+ ({health.score}%)
+
+ )
+}
+
+function projectCoord(
+ lat: number,
+ lon: number,
+ useGcj02: boolean,
+): [number, number] {
+ if (!useGcj02) return [lat, lon]
+ return wgs84ToGcj02(lat, lon)
+}
+
+/* ---------- map sub-components ---------- */
+
+function FitBoundsToDevices({
+ coordinates,
+}: {
+ coordinates: Array<[number, number]>
+}) {
+ const map = useMap()
+ const hasFitted = useRef(false)
+
+ useEffect(() => {
+ if (coordinates.length === 0 || hasFitted.current) return
+ hasFitted.current = true
+ if (coordinates.length === 1) {
+ map.setView(coordinates[0], 14, { animate: true })
+ return
+ }
+ map.fitBounds(coordinates, { padding: [40, 40] })
+ }, [coordinates, map])
+
+ return null
+}
+
+/** Badges-only overlay — lives inside MapContainer, no dropdown needed */
+function MapStatusBadges({
+ isLive,
+ status,
+ deviceCount,
+ disconnectedDuration,
+}: {
+ isLive: boolean
+ status: ConnectionState
+ deviceCount: number
+ disconnectedDuration: number
+}) {
+ return (
+
+
+
+ {isLive && (
+
+
+
+
+ )}
+ {!isLive && }
+ {isLive
+ ? "Live"
+ : status === "retrying"
+ ? `Reconnecting${disconnectedDuration > 0 ? ` (${disconnectedDuration}s)` : ""}`
+ : "Connecting"}
+
+
+ Devices: {deviceCount}
+
+
+
+ )
+}
+
+/** FitBounds helper — must live inside MapContainer to call useMap() */
+function FitBoundsControl({
+ coordinates,
+}: {
+ coordinates: Array<[number, number]>
+}) {
+ const map = useMap()
+ const fitAll = useCallback(() => {
+ if (!coordinates.length) return
+ if (coordinates.length === 1) {
+ map.setView(coordinates[0], 14, { animate: true })
+ } else {
+ map.fitBounds(coordinates, { padding: [40, 40] })
+ }
+ }, [coordinates, map])
+
+ const focusDevice = useCallback(
+ (coordinate: [number, number]) => {
+ const maxZoom = map.getMaxZoom()
+ const targetZoom = Number.isFinite(maxZoom) ? maxZoom : 18
+ map.setView(coordinate, targetZoom, { animate: true })
+ },
+ [map],
+ )
+
+ useEffect(() => {
+ const ref = window as unknown as MapControlBridge
+ ref.__mapFitAll = fitAll
+ ref.__mapFocusDevice = focusDevice
+ return () => {
+ if (ref.__mapFitAll === fitAll) {
+ delete ref.__mapFitAll
+ }
+ if (ref.__mapFocusDevice === focusDevice) {
+ delete ref.__mapFocusDevice
+ }
+ }
+ }, [fitAll, focusDevice])
+
+ return null
+}
+
+/* ---------- WebSocket hook ---------- */
+
+function useTelemetryWS() {
+ const [snapshot, setSnapshot] = useState
(null)
+ const [status, setStatus] = useState("connecting")
+ const [disconnectedAt, setDisconnectedAt] = useState(null)
+ const retryCount = useRef(0)
+ const lastMessageAt = useRef(0)
+
+ useEffect(() => {
+ const token = localStorage.getItem("access_token")
+ if (!token) {
+ setStatus("retrying")
+ return
+ }
+
+ let reconnectTimer: number | undefined
+ let staleTimer: number | undefined
+ let socket: WebSocket | undefined
+ let shouldReconnect = true
+
+ const STALE_TIMEOUT_MS = 10_000
+
+ const resetStaleTimer = () => {
+ if (staleTimer) window.clearTimeout(staleTimer)
+ staleTimer = window.setTimeout(() => {
+ socket?.close()
+ }, STALE_TIMEOUT_MS)
+ }
+
+ const connect = () => {
+ setStatus((prev) => (prev === "live" ? prev : "connecting"))
+ socket = new WebSocket(buildTelemetryWsUrl(token))
+
+ socket.onopen = () => {
+ setStatus("live")
+ setDisconnectedAt(null)
+ retryCount.current = 0
+ lastMessageAt.current = Date.now()
+ resetStaleTimer()
+ }
+
+ socket.onmessage = (event) => {
+ try {
+ const payload = JSON.parse(event.data) as TelemetrySnapshot
+ setSnapshot(payload)
+ setStatus("live")
+ setDisconnectedAt(null)
+ lastMessageAt.current = Date.now()
+ resetStaleTimer()
+ } catch {
+ // ignore malformed messages
+ }
+ }
+
+ socket.onclose = () => {
+ if (staleTimer) window.clearTimeout(staleTimer)
+ if (!shouldReconnect) return
+ setStatus("retrying")
+ setDisconnectedAt((prev) => prev ?? Date.now())
+ const backoffMs = Math.min(2000 * 2 ** retryCount.current, 30000)
+ retryCount.current++
+ reconnectTimer = window.setTimeout(connect, backoffMs)
+ }
+
+ socket.onerror = () => {
+ socket?.close()
+ }
+ }
+
+ connect()
+
+ return () => {
+ shouldReconnect = false
+ if (reconnectTimer) window.clearTimeout(reconnectTimer)
+ if (staleTimer) window.clearTimeout(staleTimer)
+ if (socket) socket.close()
+ }
+ }, [])
+
+ return { snapshot, status, disconnectedAt }
+}
+
+/* ---------- Dashboard ---------- */
+
+function Dashboard() {
+ const { snapshot, status, disconnectedAt } = useTelemetryWS()
+ const [baseMap, setBaseMap] = useState("amap")
+ const [activeDeviceId, setActiveDeviceId] = useState("")
+ const [isPanelExpanded, setIsPanelExpanded] = useState(true)
+ const activeDeviceIdRef = useRef(activeDeviceId)
+ activeDeviceIdRef.current = activeDeviceId
+
+ const isGcj02 = BASE_MAPS[baseMap].gcj02
+
+ const devicesWithCoords = useMemo(
+ () =>
+ (snapshot?.devices ?? []).filter(
+ (device) =>
+ device.lat !== null &&
+ device.lon !== null &&
+ device.lat >= -90 &&
+ device.lat <= 90 &&
+ device.lon >= -180 &&
+ device.lon <= 180,
+ ),
+ [snapshot],
+ )
+
+ useEffect(() => {
+ if (!devicesWithCoords.length) {
+ if (activeDeviceIdRef.current) setActiveDeviceId("")
+ return
+ }
+ const exists = devicesWithCoords.some(
+ (d) => d.device_id === activeDeviceIdRef.current,
+ )
+ if (!exists && !activeDeviceIdRef.current) {
+ setActiveDeviceId(devicesWithCoords[0].device_id)
+ }
+ }, [devicesWithCoords])
+
+ const activeDevice = useMemo(
+ () =>
+ devicesWithCoords.find((d) => d.device_id === activeDeviceId) ??
+ devicesWithCoords[0] ??
+ null,
+ [devicesWithCoords, activeDeviceId],
+ )
+
+ const mapPoints = useMemo(
+ () =>
+ devicesWithCoords.map((d) =>
+ projectCoord(d.lat as number, d.lon as number, isGcj02),
+ ),
+ [devicesWithCoords, isGcj02],
+ )
+
+ const handleDeviceClick = useCallback(
+ (deviceId: string, coordinate: [number, number]) => {
+ setActiveDeviceId(deviceId)
+ const mapBridge = window as unknown as MapControlBridge
+ mapBridge.__mapFocusDevice?.(coordinate)
+ },
+ [],
+ )
+
+ const isLive = status === "live"
+
+ // Disconnected time ticker
+ const [, forceUpdate] = useState(0)
+ useEffect(() => {
+ if (!disconnectedAt) return
+ const interval = window.setInterval(() => forceUpdate((n) => n + 1), 1000)
+ return () => window.clearInterval(interval)
+ }, [disconnectedAt])
+
+ const disconnectedDuration = disconnectedAt
+ ? Math.floor((Date.now() - disconnectedAt) / 1000)
+ : 0
+
+ return (
+
+ {/* Map area — fills available space */}
+
+ {/* Controls live OUTSIDE MapContainer to avoid Leaflet z-index stacking issues */}
+
+
+
+
+
+
+ {(
+ Object.entries(BASE_MAPS) as Array<
+ [BaseMapType, (typeof BASE_MAPS)[BaseMapType]]
+ >
+ ).map(([key, provider]) => (
+ setBaseMap(key)}>
+ {provider.label} {baseMap === key && "✓"}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+ {devicesWithCoords.map((device) => {
+ const isOffline = checkIsOffline(device.last_event_time)
+ const isSelected = activeDeviceId === device.device_id
+ const projectedCoordinate = projectCoord(
+ device.lat as number,
+ device.lon as number,
+ isGcj02,
+ )
+ const [projLat, projLon] = projectedCoordinate
+ const color = deviceMarkerColor(device.fix_status, isOffline)
+ const icon = buildDeviceIcon(color, isSelected, isOffline)
+
+ return (
+
+ handleDeviceClick(device.device_id, projectedCoordinate),
+ }}
+ key={device.device_id}
+ >
+
+
+
+ {device.device_id}
+ {isOffline && (
+
+ (Offline)
+
+ )}
+
+
+ Lat:
+ {formatCoord(device.lat)}
+
+ Lon:
+ {formatCoord(device.lon)}
+
+ Alt:
+
+ {device.alt_m !== null
+ ? `${device.alt_m.toFixed(1)} m`
+ : "-"}
+
+
+ Fix:
+ {getFixStatusLabel(device.fix_status)}
+
+ Sats:
+ {device.satellites_used ?? "-"}
+
+
+ Last seen:{" "}
+ {device.last_event_time
+ ? new Date(device.last_event_time).toLocaleString()
+ : "Unknown"}
+
+
+
+
+ )
+ })}
+
+
+
+ {/* Selected Device Panel Overlay */}
+
+
+ setIsPanelExpanded(!isPanelExpanded)}
+ >
+
+ Selected Device
+ {activeDevice?.signal_health &&
+ !isPanelExpanded &&
+ signalHealthBadge(activeDevice.signal_health)}
+
+
+ {activeDevice?.signal_health &&
+ isPanelExpanded &&
+ signalHealthBadge(activeDevice.signal_health)}
+
+
+
+ {isPanelExpanded && (
+
+
+
Device ID
+
+ {activeDevice?.device_id ?? "-"}
+
+
+
+
Latitude
+
+ {formatCoord(activeDevice?.lat ?? null)}
+
+
+
+
Longitude
+
+ {formatCoord(activeDevice?.lon ?? null)}
+
+
+
+
+
+ Altitude
+
+
+ {activeDevice?.alt_m !== null &&
+ activeDevice?.alt_m !== undefined
+ ? `${activeDevice.alt_m.toFixed(1)} m`
+ : "-"}
+
+
+
+
Fix Status
+
+ {getFixStatusLabel(activeDevice?.fix_status ?? null)}
+
+
+
+
+
+ Satellites
+
+
+ {activeDevice?.satellites_used ?? "-"}
+
+
+
+
+
+ SNR
+
+
+ {activeDevice?.signal_health
+ ? `${activeDevice.signal_health.score}%`
+ : "-"}
+
+
+
+
+
+ Last Seen
+
+
+ {timeAgo(activeDevice?.last_event_time ?? null)}
+
+
+
+ )}
+
+
)
diff --git a/frontend/src/routes/_layout/locations.tsx b/frontend/src/routes/_layout/locations.tsx
index b0283f6..3331693 100644
--- a/frontend/src/routes/_layout/locations.tsx
+++ b/frontend/src/routes/_layout/locations.tsx
@@ -36,8 +36,12 @@ function LocationsTableContent() {
- You don't have any locations yet
- Add a new location to get started
+
+ You don't have any locations yet
+
+
+ Add a new location to get started
+
)
}
@@ -59,7 +63,9 @@ function Locations() {
Locations
-
Create and manage your locations
+
+ Create and manage your locations
+
diff --git a/frontend/src/routes/_layout/mqtt-raw.tsx b/frontend/src/routes/_layout/mqtt-raw.tsx
new file mode 100644
index 0000000..4530dbe
--- /dev/null
+++ b/frontend/src/routes/_layout/mqtt-raw.tsx
@@ -0,0 +1,411 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
+import { createFileRoute } from "@tanstack/react-router"
+import type { ColumnDef } from "@tanstack/react-table"
+import { Search } from "lucide-react"
+import { useMemo, useState } from "react"
+
+import { type MqttRawDaySummary, MqttRawService } from "@/client"
+import { DataTable } from "@/components/Common/DataTable"
+import PendingSkeleton from "@/components/Pending/PendingSkeleton"
+import { Button } from "@/components/ui/button"
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "@/components/ui/card"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Input } from "@/components/ui/input"
+import { LoadingButton } from "@/components/ui/loading-button"
+import { Slider } from "@/components/ui/slider"
+import useCustomToast from "@/hooks/useCustomToast"
+
+const MINUTE_STEPS_PER_DAY = 24 * 60
+
+export const Route = createFileRoute("/_layout/mqtt-raw")({
+ component: MqttRawPage,
+ head: () => ({
+ meta: [
+ {
+ title: "MQTT Raw Data - FastAPI Template",
+ },
+ ],
+ }),
+})
+
+function toUtc8Date(value: Date): Date {
+ const utcMillis = value.getTime() + value.getTimezoneOffset() * 60 * 1000
+ return new Date(utcMillis + 8 * 60 * 60 * 1000)
+}
+
+function todayIsoDateUtc8(): string {
+ const now = new Date()
+ return toUtc8Date(now).toISOString().slice(0, 10)
+}
+
+function formatDateTime(value: string | null | undefined): string {
+ if (!value) {
+ return "-"
+ }
+ const utc8 = toUtc8Date(new Date(value))
+ return `${utc8.toISOString().slice(0, 19).replace("T", " ")} (UTC+8)`
+}
+
+function formatMinuteLabel(minute: number): string {
+ const clamped = Math.max(0, Math.min(minute, MINUTE_STEPS_PER_DAY))
+ const hours = Math.floor(clamped / 60)
+ const minutes = clamped % 60
+ return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}`
+}
+
+function triggerFileDownload(blob: Blob, filename: string): void {
+ const url = URL.createObjectURL(blob)
+ const link = document.createElement("a")
+ link.href = url
+ link.download = filename
+ document.body.appendChild(link)
+ link.click()
+ document.body.removeChild(link)
+ URL.revokeObjectURL(url)
+}
+
+function toDownloadBlob(value: unknown): Blob {
+ if (value instanceof Blob) {
+ return value
+ }
+ if (value instanceof ArrayBuffer) {
+ return new Blob([value], { type: "application/octet-stream" })
+ }
+ if (typeof value === "string") {
+ return new Blob([value], { type: "application/octet-stream" })
+ }
+ return new Blob([JSON.stringify(value)], { type: "application/octet-stream" })
+}
+
+function MqttRawPage() {
+ const queryClient = useQueryClient()
+ const { showSuccessToast, showErrorToast } = useCustomToast()
+
+ const [selectedDay, setSelectedDay] = useState(todayIsoDateUtc8())
+ const [rangeStartMinute, setRangeStartMinute] = useState(0)
+ const [rangeEndMinute, setRangeEndMinute] = useState(MINUTE_STEPS_PER_DAY)
+ const [deleteDayOpen, setDeleteDayOpen] = useState(false)
+ const [deleteAllOpen, setDeleteAllOpen] = useState(false)
+ const [deleteAllConfirmText, setDeleteAllConfirmText] = useState("")
+
+ const {
+ data: daysResponse,
+ isPending,
+ isError,
+ error,
+ } = useQuery({
+ queryFn: () => MqttRawService.listDays({ limit: 200, skip: 0 }),
+ queryKey: ["mqtt-raw-days"],
+ })
+
+ const days = daysResponse?.data ?? []
+ const isFullDayRange =
+ rangeStartMinute === 0 && rangeEndMinute === MINUTE_STEPS_PER_DAY
+ const startSeconds = rangeStartMinute * 60
+ const endSeconds = rangeEndMinute * 60
+
+ const downloadMutation = useMutation({
+ mutationFn: async () => {
+ const data = await MqttRawService.download({
+ day: selectedDay,
+ startSeconds: isFullDayRange ? undefined : startSeconds,
+ endSeconds: isFullDayRange ? undefined : endSeconds,
+ })
+
+ const filename = isFullDayRange
+ ? `mqtt_raw_${selectedDay}.txt`
+ : `mqtt_raw_${selectedDay}_${startSeconds}_${endSeconds}.txt`
+ return {
+ blob: toDownloadBlob(data),
+ filename,
+ }
+ },
+ onSuccess: ({ blob, filename }) => {
+ triggerFileDownload(blob, filename)
+ showSuccessToast("Raw MQTT data downloaded")
+ },
+ onError: (downloadError) => {
+ const message =
+ downloadError instanceof Error
+ ? downloadError.message
+ : "Download failed"
+ showErrorToast(message)
+ },
+ })
+
+ const deleteDayMutation = useMutation({
+ mutationFn: () => MqttRawService.deleteDay({ day: selectedDay }),
+ onSuccess: (result) => {
+ showSuccessToast(result.message)
+ setDeleteDayOpen(false)
+ queryClient.invalidateQueries({ queryKey: ["mqtt-raw-days"] })
+ },
+ onError: (deleteError) => {
+ const message =
+ deleteError instanceof Error ? deleteError.message : "Delete failed"
+ showErrorToast(message)
+ },
+ })
+
+ const deleteAllMutation = useMutation({
+ mutationFn: () => MqttRawService.deleteAll({ confirm: true }),
+ onSuccess: (result) => {
+ showSuccessToast(result.message)
+ setDeleteAllConfirmText("")
+ setDeleteAllOpen(false)
+ queryClient.invalidateQueries({ queryKey: ["mqtt-raw-days"] })
+ },
+ onError: (deleteError) => {
+ const message =
+ deleteError instanceof Error ? deleteError.message : "Delete failed"
+ showErrorToast(message)
+ },
+ })
+
+ const columns = useMemo
[]>(
+ () => [
+ {
+ accessorKey: "day",
+ header: "Day (UTC+8)",
+ cell: ({ row }) => (
+
+ ),
+ },
+ {
+ accessorKey: "message_count",
+ header: "Messages",
+ cell: ({ row }) => (
+ {row.original.message_count}
+ ),
+ },
+ {
+ accessorKey: "first_received_at",
+ header: "First Message",
+ cell: ({ row }) => formatDateTime(row.original.first_received_at),
+ },
+ {
+ accessorKey: "last_received_at",
+ header: "Last Message",
+ cell: ({ row }) => formatDateTime(row.original.last_received_at),
+ },
+ ],
+ [],
+ )
+
+ return (
+
+
+
MQTT Raw Data
+
+ Download saved payload bytes by UTC+8 day/time range and clean old
+ data.
+
+
+
+
+
+ Download
+
+ Pick a UTC+8 day, then download the full day or a custom time range.
+
+
+
+
+
+
Date (UTC+8)
+
setSelectedDay(event.target.value)}
+ type="date"
+ value={selectedDay}
+ />
+
+
+
+ downloadMutation.mutate()}
+ type="button"
+ >
+ Download
+
+
+
+
+
+
+
+ Start: {formatMinuteLabel(rangeStartMinute)}
+
+
+ End: {formatMinuteLabel(rangeEndMinute)}
+
+
+
{
+ if (values.length !== 2) {
+ return
+ }
+ setRangeStartMinute(values[0])
+ setRangeEndMinute(values[1])
+ }}
+ step={1}
+ value={[rangeStartMinute, rangeEndMinute]}
+ />
+
+ {isFullDayRange
+ ? "Current selection: full day (00:00-24:00, UTC+8)."
+ : `Current selection: ${formatMinuteLabel(rangeStartMinute)}-${formatMinuteLabel(rangeEndMinute)}.`}
+
+
+
+
+
+
+
+ Delete
+
+ Delete data for one UTC+8 day or delete all saved MQTT raw data.
+
+
+
+
+
+
+
+
+
+
Saved Days (UTC+8)
+
+ Click a day to load it into the controls above.
+
+ {isPending ? (
+
+ ) : isError ? (
+
+ {error instanceof Error
+ ? error.message
+ : "Failed to load data days."}
+
+ ) : days.length === 0 ? (
+
+
+
+
+
No raw data saved yet
+
+ MQTT payloads will appear here after ingestion.
+
+
+ ) : (
+
+ )}
+
+
+
+
+
+
+ )
+}
diff --git a/uv.lock b/uv.lock
index 48635f8..a706a37 100644
--- a/uv.lock
+++ b/uv.lock
@@ -69,6 +69,7 @@ dependencies = [
{ name = "fastapi", extra = ["standard"] },
{ name = "httpx" },
{ name = "jinja2" },
+ { name = "paho-mqtt" },
{ name = "psycopg", extra = ["binary"] },
{ name = "pwdlib", extra = ["argon2", "bcrypt"] },
{ name = "pydantic" },
@@ -97,6 +98,7 @@ requires-dist = [
{ name = "fastapi", extras = ["standard"], specifier = ">=0.114.2,<1.0.0" },
{ name = "httpx", specifier = ">=0.25.1,<1.0.0" },
{ name = "jinja2", specifier = ">=3.1.4,<4.0.0" },
+ { name = "paho-mqtt", specifier = ">=2.1.0,<3.0.0" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.1.13,<4.0.0" },
{ name = "pwdlib", extras = ["argon2", "bcrypt"], specifier = ">=0.3.0" },
{ name = "pydantic", specifier = ">2.0" },
@@ -811,7 +813,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/32/6a/33d1702184d94106d3cdd7bfb788e19723206fce152e303473ca3b946c7b/greenlet-3.3.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:6f8496d434d5cb2dce025773ba5597f71f5410ae499d5dd9533e0653258cdb3d", size = 273658, upload-time = "2025-12-04T14:23:37.494Z" },
{ url = "https://files.pythonhosted.org/packages/d6/b7/2b5805bbf1907c26e434f4e448cd8b696a0b71725204fa21a211ff0c04a7/greenlet-3.3.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b96dc7eef78fd404e022e165ec55327f935b9b52ff355b067eb4a0267fc1cffb", size = 574810, upload-time = "2025-12-04T14:50:04.154Z" },
{ url = "https://files.pythonhosted.org/packages/94/38/343242ec12eddf3d8458c73f555c084359883d4ddc674240d9e61ec51fd6/greenlet-3.3.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73631cd5cccbcfe63e3f9492aaa664d278fda0ce5c3d43aeda8e77317e38efbd", size = 586248, upload-time = "2025-12-04T14:57:39.35Z" },
- { url = "https://files.pythonhosted.org/packages/f0/d0/0ae86792fb212e4384041e0ef8e7bc66f59a54912ce407d26a966ed2914d/greenlet-3.3.0-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b299a0cb979f5d7197442dccc3aee67fce53500cd88951b7e6c35575701c980b", size = 597403, upload-time = "2025-12-04T15:07:10.831Z" },
{ url = "https://files.pythonhosted.org/packages/b6/a8/15d0aa26c0036a15d2659175af00954aaaa5d0d66ba538345bd88013b4d7/greenlet-3.3.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7dee147740789a4632cace364816046e43310b59ff8fb79833ab043aefa72fd5", size = 586910, upload-time = "2025-12-04T14:25:59.705Z" },
{ url = "https://files.pythonhosted.org/packages/e1/9b/68d5e3b7ccaba3907e5532cf8b9bf16f9ef5056a008f195a367db0ff32db/greenlet-3.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:39b28e339fc3c348427560494e28d8a6f3561c8d2bcf7d706e1c624ed8d822b9", size = 1547206, upload-time = "2025-12-04T15:04:21.027Z" },
{ url = "https://files.pythonhosted.org/packages/66/bd/e3086ccedc61e49f91e2cfb5ffad9d8d62e5dc85e512a6200f096875b60c/greenlet-3.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b3c374782c2935cc63b2a27ba8708471de4ad1abaa862ffdb1ef45a643ddbb7d", size = 1613359, upload-time = "2025-12-04T14:27:26.548Z" },
@@ -819,7 +820,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1f/cb/48e964c452ca2b92175a9b2dca037a553036cb053ba69e284650ce755f13/greenlet-3.3.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:e29f3018580e8412d6aaf5641bb7745d38c85228dacf51a73bd4e26ddf2a6a8e", size = 274908, upload-time = "2025-12-04T14:23:26.435Z" },
{ url = "https://files.pythonhosted.org/packages/28/da/38d7bff4d0277b594ec557f479d65272a893f1f2a716cad91efeb8680953/greenlet-3.3.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a687205fb22794e838f947e2194c0566d3812966b41c78709554aa883183fb62", size = 577113, upload-time = "2025-12-04T14:50:05.493Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f2/89c5eb0faddc3ff014f1c04467d67dee0d1d334ab81fadbf3744847f8a8a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4243050a88ba61842186cb9e63c7dfa677ec146160b0efd73b855a3d9c7fcf32", size = 590338, upload-time = "2025-12-04T14:57:41.136Z" },
- { url = "https://files.pythonhosted.org/packages/80/d7/db0a5085035d05134f8c089643da2b44cc9b80647c39e93129c5ef170d8f/greenlet-3.3.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:670d0f94cd302d81796e37299bcd04b95d62403883b24225c6b5271466612f45", size = 601098, upload-time = "2025-12-04T15:07:11.898Z" },
{ url = "https://files.pythonhosted.org/packages/dc/a6/e959a127b630a58e23529972dbc868c107f9d583b5a9f878fb858c46bc1a/greenlet-3.3.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cb3a8ec3db4a3b0eb8a3c25436c2d49e3505821802074969db017b87bc6a948", size = 590206, upload-time = "2025-12-04T14:26:01.254Z" },
{ url = "https://files.pythonhosted.org/packages/48/60/29035719feb91798693023608447283b266b12efc576ed013dd9442364bb/greenlet-3.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2de5a0b09eab81fc6a382791b995b1ccf2b172a9fec934747a7a23d2ff291794", size = 1550668, upload-time = "2025-12-04T15:04:22.439Z" },
{ url = "https://files.pythonhosted.org/packages/0a/5f/783a23754b691bfa86bd72c3033aa107490deac9b2ef190837b860996c9f/greenlet-3.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4449a736606bd30f27f8e1ff4678ee193bc47f6ca810d705981cfffd6ce0d8c5", size = 1615483, upload-time = "2025-12-04T14:27:28.083Z" },
@@ -827,7 +827,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/0a/a3871375c7b9727edaeeea994bfff7c63ff7804c9829c19309ba2e058807/greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb", size = 276379, upload-time = "2025-12-04T14:23:30.498Z" },
{ url = "https://files.pythonhosted.org/packages/43/ab/7ebfe34dce8b87be0d11dae91acbf76f7b8246bf9d6b319c741f99fa59c6/greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3", size = 597294, upload-time = "2025-12-04T14:50:06.847Z" },
{ url = "https://files.pythonhosted.org/packages/a4/39/f1c8da50024feecd0793dbd5e08f526809b8ab5609224a2da40aad3a7641/greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655", size = 607742, upload-time = "2025-12-04T14:57:42.349Z" },
- { url = "https://files.pythonhosted.org/packages/77/cb/43692bcd5f7a0da6ec0ec6d58ee7cddb606d055ce94a62ac9b1aa481e969/greenlet-3.3.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c024b1e5696626890038e34f76140ed1daf858e37496d33f2af57f06189e70d7", size = 622297, upload-time = "2025-12-04T15:07:13.552Z" },
{ url = "https://files.pythonhosted.org/packages/75/b0/6bde0b1011a60782108c01de5913c588cf51a839174538d266de15e4bf4d/greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b", size = 609885, upload-time = "2025-12-04T14:26:02.368Z" },
{ url = "https://files.pythonhosted.org/packages/49/0e/49b46ac39f931f59f987b7cd9f34bfec8ef81d2a1e6e00682f55be5de9f4/greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53", size = 1567424, upload-time = "2025-12-04T15:04:23.757Z" },
{ url = "https://files.pythonhosted.org/packages/05/f5/49a9ac2dff7f10091935def9165c90236d8f175afb27cbed38fb1d61ab6b/greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614", size = 1636017, upload-time = "2025-12-04T14:27:29.688Z" },
@@ -835,7 +834,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140, upload-time = "2025-12-04T14:23:01.282Z" },
{ url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219, upload-time = "2025-12-04T14:50:08.309Z" },
{ url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211, upload-time = "2025-12-04T14:57:43.968Z" },
- { url = "https://files.pythonhosted.org/packages/79/07/c47a82d881319ec18a4510bb30463ed6891f2ad2c1901ed5ec23d3de351f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492", size = 624311, upload-time = "2025-12-04T15:07:14.697Z" },
{ url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833, upload-time = "2025-12-04T14:26:03.669Z" },
{ url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256, upload-time = "2025-12-04T15:04:25.276Z" },
{ url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483, upload-time = "2025-12-04T14:27:30.804Z" },
@@ -843,7 +841,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/7c/f0a6d0ede2c7bf092d00bc83ad5bafb7e6ec9b4aab2fbdfa6f134dc73327/greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f", size = 275671, upload-time = "2025-12-04T14:23:05.267Z" },
{ url = "https://files.pythonhosted.org/packages/44/06/dac639ae1a50f5969d82d2e3dd9767d30d6dbdbab0e1a54010c8fe90263c/greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365", size = 646360, upload-time = "2025-12-04T14:50:10.026Z" },
{ url = "https://files.pythonhosted.org/packages/e0/94/0fb76fe6c5369fba9bf98529ada6f4c3a1adf19e406a47332245ef0eb357/greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3", size = 658160, upload-time = "2025-12-04T14:57:45.41Z" },
- { url = "https://files.pythonhosted.org/packages/93/79/d2c70cae6e823fac36c3bbc9077962105052b7ef81db2f01ec3b9bf17e2b/greenlet-3.3.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dcd2bdbd444ff340e8d6bdf54d2f206ccddbb3ccfdcd3c25bf4afaa7b8f0cf45", size = 671388, upload-time = "2025-12-04T15:07:15.789Z" },
{ url = "https://files.pythonhosted.org/packages/b8/14/bab308fc2c1b5228c3224ec2bf928ce2e4d21d8046c161e44a2012b5203e/greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955", size = 660166, upload-time = "2025-12-04T14:26:05.099Z" },
{ url = "https://files.pythonhosted.org/packages/4b/d2/91465d39164eaa0085177f61983d80ffe746c5a1860f009811d498e7259c/greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55", size = 1615193, upload-time = "2025-12-04T15:04:27.041Z" },
{ url = "https://files.pythonhosted.org/packages/42/1b/83d110a37044b92423084d52d5d5a3b3a73cafb51b547e6d7366ff62eff1/greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc", size = 1683653, upload-time = "2025-12-04T14:27:32.366Z" },
@@ -851,7 +848,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/66/bd6317bc5932accf351fc19f177ffba53712a202f9df10587da8df257c7e/greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931", size = 282638, upload-time = "2025-12-04T14:25:20.941Z" },
{ url = "https://files.pythonhosted.org/packages/30/cf/cc81cb030b40e738d6e69502ccbd0dd1bced0588e958f9e757945de24404/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388", size = 651145, upload-time = "2025-12-04T14:50:11.039Z" },
{ url = "https://files.pythonhosted.org/packages/9c/ea/1020037b5ecfe95ca7df8d8549959baceb8186031da83d5ecceff8b08cd2/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3", size = 654236, upload-time = "2025-12-04T14:57:47.007Z" },
- { url = "https://files.pythonhosted.org/packages/69/cc/1e4bae2e45ca2fa55299f4e85854606a78ecc37fead20d69322f96000504/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2662433acbca297c9153a4023fe2161c8dcfdcc91f10433171cf7e7d94ba2221", size = 662506, upload-time = "2025-12-04T15:07:16.906Z" },
{ url = "https://files.pythonhosted.org/packages/57/b9/f8025d71a6085c441a7eaff0fd928bbb275a6633773667023d19179fe815/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b", size = 653783, upload-time = "2025-12-04T14:26:06.225Z" },
{ url = "https://files.pythonhosted.org/packages/f6/c7/876a8c7a7485d5d6b5c6821201d542ef28be645aa024cfe1145b35c120c1/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd", size = 1614857, upload-time = "2025-12-04T15:04:28.484Z" },
{ url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034, upload-time = "2025-12-04T14:27:33.531Z" },
@@ -1355,6 +1351,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
]
+[[package]]
+name = "paho-mqtt"
+version = "2.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/39/15/0a6214e76d4d32e7f663b109cf71fb22561c2be0f701d67f93950cd40542/paho_mqtt-2.1.0.tar.gz", hash = "sha256:12d6e7511d4137555a3f6ea167ae846af2c7357b10bc6fa4f7c3968fc1723834", size = 148848, upload-time = "2024-04-29T19:52:55.591Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c4/cb/00451c3cf31790287768bb12c6bec834f5d292eaf3022afc88e14b8afc94/paho_mqtt-2.1.0-py3-none-any.whl", hash = "sha256:6db9ba9b34ed5bc6b6e3812718c7e06e2fd7444540df2455d2c51bd58808feee", size = 67219, upload-time = "2024-04-29T19:52:48.345Z" },
+]
+
[[package]]
name = "pathspec"
version = "1.0.3"