Create Stats Page
https://www.picourse.dev/next-js
- Install Node.js
- Create Next.js project
npx create-next-app@latest my-app
- run app
cd my-app
npm run dev
- Use raspberry pi hostname to visit the app in local network
raspberrypi.local:3000
- Add shadcn to the next.js project
npx shadcn@latest init
- Add ‘Card’ and ‘Progress’ components
npx shadcn@latest add card progress
- Add a stats page
nano src/app/page.tsx
import { getSystemDetails } from "@/lib/system";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
export default async function Home() {
const systemInfo = await getSystemDetails();
return (
<main className="min-h-screen bg-background flex flex-col items-center justify-center p-6">
<h1 className="text-3xl font-bold mb-6 text-foreground">Raspberry Pi</h1>
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>System Information</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
{[
["Hostname", systemInfo.os.hostname()],
["Platform", systemInfo.os.platform()],
["Architecture", systemInfo.os.arch()],
["CPU Temperature", `${systemInfo.cpuTemp.toFixed(1)}°C`],
].map(([label, value]) => (
<div key={label} className="flex justify-between text-sm">
<span className="text-muted-foreground">{label}:</span>
<span className="text-foreground font-medium">{value}</span>
</div>
))}
</div>
<div className="space-y-2">
<h3 className="text-lg font-semibold text-foreground">CPU Usage</h3>
{systemInfo.cpuUsage.map((usage, index) => (
<div key={index} className="space-y-1">
<div className="flex justify-between text-sm text-muted-foreground">
<span>Core {index}</span>
<span>{usage}%</span>
</div>
<Progress value={parseFloat(usage)} className="h-2" />
</div>
))}
</div>
<div className="space-y-2">
<h3 className="text-lg font-semibold text-foreground">Memory Usage</h3>
<div className="flex justify-between text-sm text-muted-foreground">
<span>Used</span>
<span>{systemInfo.memoryUsage.used.toFixed(2)} / {systemInfo.memoryUsage.total.toFixed(2)} GB</span>
</div>
<Progress
value={(systemInfo.memoryUsage.used / systemInfo.memoryUsage.total) * 100}
className="h-2"
/>
</div>
</CardContent>
</Card>
</main>
);
}
- Add a
system.ts
file tolib
directory
nano src/lib/system.ts
import os from "os";
import { exec } from "child_process";
import { promisify } from "util";
const execAsync = promisify(exec);
function getCpuUsage() {
const cpus = os.cpus();
return cpus.map((cpu) => {
const total = Object.values(cpu.times).reduce((acc, tv) => acc + tv, 0);
const usage = 100 - (100 * cpu.times.idle) / total;
return usage.toFixed(1);
});
}
async function getCpuTemp() {
const { stdout } = await execAsync("vcgencmd measure_temp");
// in celsius! OBVIOUSLY!
return parseFloat(stdout.replace("temp=", "").replace("'C", ""));
}
function bytesToGB(bytes: number) {
return (bytes / (1024 * 1024 * 1024)).toFixed(2);
}
export async function getSystemDetails() {
// Get CPU usage
const cpuUsage = getCpuUsage();
// Get memory info
const totalMem = os.totalmem();
const freeMem = os.freemem();
const usedMem = totalMem - freeMem;
const cpuTemp = await getCpuTemp();
return {
os,
cpuTemp,
cpuUsage,
memoryUsage: {
total: parseFloat(bytesToGB(totalMem)),
used: parseFloat(bytesToGB(usedMem)),
free: parseFloat(bytesToGB(freeMem)),
},
};
}
- Navigate to the
/stats
page
Cloudflare tunnel
- Authenticate your cloudflared instance with your account
cloudflared tunnel login
- Create new tunnel
cloudflared tunnel create picourse-tunnel
- Configure the tunnel by editing
~/.cloudflared/config.yml
tunnel: pi-tunnel
credentials-file: <json_file>
ingress:
- hostname: raspi.ctio.tech
service: http://localhost:3000
- service: http_status:404
- Run tunnel
cloudflared tunnel run picourse-tunnel
- Add a DNS Record for the tunnel
Camera
- Create
capture_img_fastapi.py
file
import io
from picamera2 import Picamera2
from fastapi import FastAPI
from fastapi.responses import Response
app = FastAPI()
@app.get("/image")
def get_image():
picam2 = Picamera2()
capture_config = picam2.create_still_configuration(main={"size": (1920, 1080)})
picam2.configure(capture_config)
data = io.BytesIO()
picam2.start()
picam2.capture_file(data, format="jpeg")
picam2.stop()
picam2.close()
return Response(content=data.getvalue(), media_type="image/jpeg")
- Run the server
fastapi dev --host 0.0.0.0 capture_img_fastapi.py
- visit raspberrypi.local:8000/image in your browser
- raspberrypi.local = ip address of the pi
- Every time you refresh, you should see a new image from the camera
raspberrypi.local:8000/image
-
Add this feature to teh next.js app
-
Modify
next.config.js
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
async rewrites() {
return [
{
source: '/py/:path*',
destination: 'http://127.0.0.1:8000/:path*',
},
]
},
};
export default nextConfig;
- Add image tagg to
app/src/page.tsx
<img
src="/py/image"
alt="Raspberry Pi"
className="w-2/3"
/>
- This uses next.js rewrites to allow the next.js app to easily call the python server
- Any client-side request that starts with
py
will get forwarded to the python server
- Any client-side request that starts with
Stream Video over http using mjpeg
- Create
fastapi_video_stream.py
file
import io
from picamera2 import Picamera2
from picamera2.encoders import MJPEGEncoder, Quality
from picamera2.outputs import FileOutput
from fastapi import FastAPI
from starlette.background import BackgroundTask
from fastapi.responses import Response
from fastapi.responses import StreamingResponse
from threading import Condition
import logging
app = FastAPI()
@app.get("/image")
def get_image():
picam2 = Picamera2()
capture_config = picam2.create_still_configuration(main={"size": (1920, 1080)})
picam2.configure(capture_config)
data = io.BytesIO()
picam2.start()
picam2.capture_file(data, format="jpeg")
picam2.stop()
picam2.close()
return Response(content=data.getvalue(), media_type="image/jpeg")
class StreamingOutput(io.BufferedIOBase):
def __init__(self):
self.frame = None
self.condition = Condition()
def write(self, buf):
with self.condition:
self.frame = buf
self.condition.notify_all()
def read(self):
with self.condition:
self.condition.wait()
return self.frame
def generate_frames(output):
while True:
try:
frame = output.read()
yield (b"--frame\r\n" b"Content-Type: image/jpeg\r\n\r\n" + frame + b"\r\n")
except Exception as e:
logging.error(f"Error in generate_frames: {str(e)}")
break
print("done")
@app.get("/mjpeg")
async def mjpeg():
picam2 = Picamera2()
video_config = picam2.create_video_configuration(main={"size": (1920, 1080)})
picam2.configure(video_config)
output = StreamingOutput()
picam2.start_recording(MJPEGEncoder(), FileOutput(output), Quality.VERY_HIGH)
def stop():
print("Stopping recording")
picam2.stop_recording()
picam2.close()
return StreamingResponse(
generate_frames(output),
media_type="multipart/x-mixed-replace; boundary=frame",
background=BackgroundTask(stop),
)
- Run the server
fastapi dev --host 0.0.0.0 fastapi_video_stream.py
- visit raspberrypi.local:8000//mjpeg in your browser
- raspberrypi.local = ip address of the pi
- You should see a video stream now
- Note: /image still works
raspberrypi.local:8000/mjpeg