Create Stats Page

https://www.picourse.dev/next-js

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 to lib 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

picture 0

Cloudflare tunnel

https://kodelan.medium.com/hosting-your-websites-locally-with-cloudflare-tunnels-without-opening-a-port-in-your-router-9aec430c0c32

  • 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

https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/routing-to-tunnel/dns/#create-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 

picture 1

  • 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

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