Display TpLink Tapo power usage values
This commit is contained in:
parent
e6b5fed399
commit
a0487c0b59
5 changed files with 187 additions and 0 deletions
18
app/api/power/route.ts
Normal file
18
app/api/power/route.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const token = req.cookies.get("token")?.value;
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch("http://localhost:3001/power", {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json({ error: "Upstream error" }, { status: res.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
}
|
||||||
101
app/components/PowerCard.tsx
Normal file
101
app/components/PowerCard.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
import { type TapoDevice } from "../lib/getPower";
|
||||||
|
|
||||||
|
interface PowerCardProps {
|
||||||
|
device: TapoDevice | null;
|
||||||
|
label: string;
|
||||||
|
delay?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function powerColor(watts: number): string {
|
||||||
|
if (watts > 400) return "#ef4444";
|
||||||
|
if (watts > 200) return "#f59e0b";
|
||||||
|
return "#3b82f6";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PowerCard({ device, label, delay = 0 }: PowerCardProps) {
|
||||||
|
const pct = device ? Math.min(100, (device.current_power_w / 500) * 100) : 0;
|
||||||
|
const runtimeHours = device ? Math.floor(device.today_runtime_min / 60) : 0;
|
||||||
|
const runtimeMins = device ? device.today_runtime_min % 60 : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="bg-white border border-gray-200 rounded-2xl p-5 flex flex-col shadow-sm hover:shadow-md hover:-translate-y-0.5 transition-all duration-200 animate-fade-up"
|
||||||
|
style={{ animationDelay: `${delay}ms` }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-[0.68rem] font-medium tracking-widest uppercase text-gray-400">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
{device ? (
|
||||||
|
<span
|
||||||
|
className={`flex items-center gap-1.5 text-[0.62rem] font-medium uppercase tracking-widest ${
|
||||||
|
device.on ? "text-emerald-500" : "text-gray-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`w-1.5 h-1.5 rounded-full ${
|
||||||
|
device.on ? "bg-emerald-400" : "bg-gray-300"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{device.on ? "On" : "Off"}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{device ? (
|
||||||
|
<>
|
||||||
|
<div className="flex items-baseline gap-1.5 mt-0.5">
|
||||||
|
<span className="text-3xl font-medium tracking-tight text-gray-900 leading-none">
|
||||||
|
{device.current_power_w.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
<span className="text-base text-gray-400 font-medium">W</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[0.7rem] text-gray-400 mt-1 truncate">
|
||||||
|
{device.alias} · {device.model}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="h-[3px] bg-gray-100 rounded-full mt-4 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all duration-700"
|
||||||
|
style={{
|
||||||
|
width: `${pct}%`,
|
||||||
|
background: powerColor(device.current_power_w),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-2 mt-4 pt-4 border-t border-gray-100">
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-sm font-medium text-gray-900">
|
||||||
|
{(device.today_energy_wh / 1000).toFixed(3)}
|
||||||
|
<span className="text-gray-400 text-xs ml-0.5">kWh</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-[0.62rem] uppercase tracking-widest text-gray-400">
|
||||||
|
Today
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-sm font-medium text-gray-900">
|
||||||
|
{(device.month_energy_wh / 1000).toFixed(2)}
|
||||||
|
<span className="text-gray-400 text-xs ml-0.5">kWh</span>
|
||||||
|
</span>
|
||||||
|
<span className="text-[0.62rem] uppercase tracking-widest text-gray-400">
|
||||||
|
Month
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-sm font-medium text-gray-900">
|
||||||
|
{runtimeHours}h {runtimeMins}m
|
||||||
|
</span>
|
||||||
|
<span className="text-[0.62rem] uppercase tracking-widest text-gray-400">
|
||||||
|
Runtime
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="skeleton h-24 mt-2" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
app/components/PowerGrid.tsx
Normal file
18
app/components/PowerGrid.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { type PowerData } from "../lib/getPower";
|
||||||
|
import PowerCard from "./PowerCard";
|
||||||
|
|
||||||
|
interface PowerGridProps {
|
||||||
|
power: PowerData | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PowerGrid({ power }: PowerGridProps) {
|
||||||
|
const server = power?.devices.find((d) => d.name === "server") ?? null;
|
||||||
|
const desktop = power?.devices.find((d) => d.name === "desktop") ?? null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3.5 mb-11">
|
||||||
|
<PowerCard device={server} label="Server" delay={0} />
|
||||||
|
<PowerCard device={desktop} label="Desktop" delay={60} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
app/lib/getPower.ts
Normal file
24
app/lib/getPower.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
export interface TapoDevice {
|
||||||
|
name: string;
|
||||||
|
ip: string;
|
||||||
|
alias: string;
|
||||||
|
model: string;
|
||||||
|
on: boolean;
|
||||||
|
current_power_w: number;
|
||||||
|
today_energy_wh: number;
|
||||||
|
month_energy_wh: number;
|
||||||
|
today_runtime_min: number;
|
||||||
|
month_runtime_min: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PowerData {
|
||||||
|
timestamp: string;
|
||||||
|
devices: TapoDevice[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPower(): Promise<PowerData> {
|
||||||
|
const res = await fetch("/api/power");
|
||||||
|
if (res.status === 401) throw new Error("UNAUTHORIZED");
|
||||||
|
if (!res.ok) throw new Error(`Failed to fetch power: ${res.status}`);
|
||||||
|
return res.json() as Promise<PowerData>;
|
||||||
|
}
|
||||||
26
app/page.tsx
26
app/page.tsx
|
|
@ -9,15 +9,18 @@ import StatsGrid from "./components/StatsGrid";
|
||||||
import ServicesCard from "./components/ServicesCard";
|
import ServicesCard from "./components/ServicesCard";
|
||||||
import UptimeCard from "./components/UptimeCard";
|
import UptimeCard from "./components/UptimeCard";
|
||||||
import NetworkCard from "./components/NetworkCard";
|
import NetworkCard from "./components/NetworkCard";
|
||||||
|
import PowerGrid from "./components/PowerGrid";
|
||||||
import LinksGrid from "./components/LinksGrid";
|
import LinksGrid from "./components/LinksGrid";
|
||||||
import { useCheckAuth } from "@/hooks/useCheckAuth";
|
import { useCheckAuth } from "@/hooks/useCheckAuth";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { getPower, type PowerData } from "./lib/getPower";
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
useCheckAuth();
|
useCheckAuth();
|
||||||
|
|
||||||
const [stats, setStats] = useState<Stats | null>(null);
|
const [stats, setStats] = useState<Stats | null>(null);
|
||||||
|
const [power, setPower] = useState<PowerData | null>(null);
|
||||||
const [netSpeed, setNetSpeed] = useState<
|
const [netSpeed, setNetSpeed] = useState<
|
||||||
Record<string, { rx: number; tx: number }>
|
Record<string, { rx: number; tx: number }>
|
||||||
>({});
|
>({});
|
||||||
|
|
@ -76,6 +79,21 @@ export default function Home() {
|
||||||
|
|
||||||
// Clean up on unmount so we don't have multiple intervals running
|
// Clean up on unmount so we don't have multiple intervals running
|
||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchPower = async () => {
|
||||||
|
try {
|
||||||
|
setPower(await getPower());
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof Error && e.message === "UNAUTHORIZED") return;
|
||||||
|
console.error("Power fetch failed:", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchPower();
|
||||||
|
const id = setInterval(fetchPower, 10000);
|
||||||
|
return () => clearInterval(id);
|
||||||
}, []); // Empty array means this setup only happens ONCE.
|
}, []); // Empty array means this setup only happens ONCE.
|
||||||
|
|
||||||
// Derived values for the UI
|
// Derived values for the UI
|
||||||
|
|
@ -115,6 +133,14 @@ export default function Home() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-baseline justify-between mb-5">
|
||||||
|
<h2 className="text-lg font-medium tracking-tight text-gray-900">
|
||||||
|
Power Consumption
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PowerGrid power={power} />
|
||||||
|
|
||||||
<LinksGrid />
|
<LinksGrid />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue