Initial commit: Battery Monitor with 4G Power Management

This is a Next.js 16 web application for Raspberry Pi uConsole CM5 that:
- Monitors battery metrics in real-time from AXP20x power supply
- Visualizes battery data with charts (Recharts)
- Stores historical data in SQLite database
- Exports data to CSV
- Manages monitoring sessions

New Feature: 4G Power Manager
- Automatically detects 4G modem state changes
- Reduces CM5 CPU frequency when 4G is active to prevent hangs
- Uses udev rules + systemd service for reliable monitoring
- Solves battery power delivery limitation (18-20W max from AXP228)

Hardware: Raspberry Pi CM5 with AXP228 power management IC
Tech Stack: Next.js 16, React 19, TypeScript, Tailwind CSS 4, shadcn/ui
This commit is contained in:
2025-11-03 21:55:56 +08:00
commit 19ef7b88d3
39 changed files with 10617 additions and 0 deletions

50
.gitignore vendored Normal file
View File

@@ -0,0 +1,50 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# battery monitor specific
battery-data.db
battery-data.db-shm
battery-data.db-wal
/var/log/4g-power-manager.log
/tmp/4g-modem-state
/tmp/4g-modem-last-state
test-manual.js

160
CLAUDE.md Normal file
View File

@@ -0,0 +1,160 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Battery Monitor is a Next.js 16 web application designed to run on Raspberry Pi hardware. It monitors and visualizes battery metrics in real-time by reading from Linux sysfs power supply interfaces specific to AXP20x/AXP22x power management chips commonly found in embedded systems.
## Development Commands
```bash
# Install dependencies
npm install
# Run development server (http://localhost:3000)
npm run dev
# Build for production
npm build
# Run production server
npm start
# Run linter
npm run lint
```
## Hardware-Specific Context
This application is designed to run on Raspberry Pi or similar embedded Linux systems with AXP20x battery management hardware. The API reads from these sysfs paths:
- Battery data: `/sys/class/power_supply/axp20x-battery/*`
- AC adapter: `/sys/class/power_supply/axp22x-ac/online`
**Important**: The application will fail to read battery data on systems without these hardware interfaces. When testing on non-Pi hardware, the API will return 500 errors.
## Architecture
### Frontend (Client-Side)
- **Main Component**: `src/components/BatteryMonitor.tsx` is a client component that polls the battery API every 2 seconds when monitoring is active
- **State Management**: Uses React hooks to maintain current battery data and historical readings (last 100 data points)
- **Visualization**: Uses Recharts for real-time line and area charts showing battery percentage, power consumption, voltage, and current trends
- **Data Export**: CSV export functionality for historical battery data
### Backend (Server-Side)
- **API Route**: `src/app/api/battery/route.ts` provides a GET endpoint at `/api/battery`
- Add `?save=true` query parameter to save readings to database
- **Hardware Interface**: Reads directly from Linux sysfs using Node.js `fs.readFileSync()`
- **Data Transformation**: Converts raw values from microvolts (µV) and microamps (µA) to volts and amps
- **Database**: SQLite database (`battery-data.db`) stores historical readings
- `src/lib/db.ts` contains database utilities and schema
- `/api/battery/history` - Query historical data by time range
- `/api/battery/sessions` - Get monitoring session summaries
### UI Components
Located in `src/components/ui/`, these are shadcn/ui components customized for this project:
- Card, Button, Badge, Progress, Table components
- Chart components for Recharts integration
- All use the `cn()` utility from `src/lib/utils.ts` for Tailwind class merging
## Path Aliases
The project uses `@/*` to reference `src/*`:
```typescript
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
```
## Key Dependencies
- **Next.js 16**: App Router architecture with React 19
- **Recharts**: Charts and data visualization
- **shadcn/ui**: UI component library (Radix UI primitives)
- **Tailwind CSS 4**: Styling with PostCSS
- **date-fns**: Date formatting
- **lucide-react**: Icons
- **better-sqlite3**: SQLite database for persistent data storage
## TypeScript Configuration
- Strict mode enabled
- JSX mode: `react-jsx` (React 19 automatic runtime)
- Module resolution: `bundler`
- Path alias `@/*` maps to `./src/*`
## Data Flow
### Live Monitoring Mode
1. User clicks "Start Monitoring" button
2. `BatteryMonitor` component sets up 2-second polling interval
3. Each poll hits `/api/battery?save=true` endpoint (saves to database)
4. API reads raw battery values from sysfs
5. API converts and calculates derived values (voltage, current, power)
6. Data is saved to SQLite database
7. Frontend updates state, appends to historical data (max 100 points in memory)
8. Charts automatically re-render with new data
9. User can export accumulated data as CSV
### Historical Data Mode
1. User selects start/end date/time in "Load Historical Data" card
2. Frontend queries `/api/battery/history?start=<ISO>&end=<ISO>`
3. Database returns all readings within the time range
4. Charts display historical data
5. User can switch back to "Live View" to resume real-time monitoring
## Working with Battery Data
The `BatteryData` interface is shared between API and frontend:
```typescript
interface BatteryData {
timestamp: string; // ISO 8601 format
percentage: number; // 0-100
voltage: number; // volts (converted from µV)
current: number; // amps (converted from µA, positive=charging, negative=discharging)
power: number; // watts (voltage * current, positive=charging, negative=discharging)
status: string; // e.g., "Charging", "Discharging", "Full"
health: string; // e.g., "Good", "Unknown"
acConnected: boolean; // true if AC adapter is connected
}
```
### Database Schema
The SQLite database (`battery-data.db`) contains two tables:
**`monitoring_sessions` table** - Tracks monitoring sessions:
- `id`: Auto-incrementing primary key
- `name`: Custom session name (nullable, editable by user)
- `start_time`: ISO 8601 timestamp when monitoring started
- `end_time`: ISO 8601 timestamp when monitoring stopped
- `reading_count`: Number of readings in this session
- `created_at`: Database insertion timestamp
**`battery_readings` table** - Stores individual battery readings:
- `id`: Auto-incrementing primary key
- `session_id`: Foreign key to `monitoring_sessions` (nullable)
- `timestamp`: ISO 8601 timestamp from battery reading
- `percentage`, `voltage`, `current`, `power`, `status`, `health`: Battery metrics
- `ac_connected`: Boolean (stored as 0/1)
- `created_at`: Database insertion timestamp
Indexes on `timestamp`, `created_at`, and `session_id` for efficient queries.
### Session Management
- When "Start Monitoring" is clicked, a new session is created
- All readings during monitoring are linked to that session via `session_id`
- When "Stop Monitoring" is clicked, the session's `end_time` and `reading_count` are updated
- Users can select sessions from a dropdown or edit session names inline for easier identification
- Default session name: `Session [start timestamp]` (can be customized)
- Users can delete sessions with a confirmation dialog - this removes the session and all associated readings
## Error Handling
- If sysfs files cannot be read, API returns 500 error with `{ error: 'Unable to read battery data' }`
- Frontend displays error state in a red error card
- Missing or null values from sysfs are handled gracefully in `getBatteryData()`

36
README.md Normal file
View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

22
components.json Normal file
View File

@@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

18
eslint.config.mjs Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

7
next.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

7586
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "battery-monitor",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3",
"better-sqlite3": "^12.4.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.552.0",
"next": "16.0.1",
"react": "19.2.0",
"react-day-picker": "^9.11.1",
"react-dom": "19.2.0",
"recharts": "^2.15.4",
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
"@playwright/test": "^1.56.1",
"@tailwindcss/postcss": "^4",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.0.1",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
}
}

7
postcss.config.mjs Normal file
View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
public/next.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

115
scripts/4g-power-manager.sh Executable file
View File

@@ -0,0 +1,115 @@
#!/bin/bash
#
# 4G Modem Power Manager for uConsole CM5
# Automatically adjusts CPU governor and frequency when 4G modem state changes
#
# This script is triggered by udev when the 4G modem state changes.
# It reduces CPU power consumption when 4G is active to prevent system hangs
# due to insufficient battery power delivery.
LOGFILE="/var/log/4g-power-manager.log"
MODEM_STATE_FILE="/tmp/4g-modem-state"
# Configuration
NORMAL_GOVERNOR="ondemand" # Governor when 4G is off
POWERSAVE_GOVERNOR="powersave" # Governor when 4G is active
MAX_FREQ_NORMAL="2400000" # Max freq (2.4GHz) when 4G is off
MAX_FREQ_POWERSAVE="1800000" # Max freq (1.8GHz) when 4G is active - reduced to save power
# Logging function
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOGFILE"
}
# Check if modem is active (connected/registered)
check_modem_active() {
# Method 1: Check if wwan0 interface exists and is up
if ip link show wwan0 2>/dev/null | grep -q "state UP"; then
return 0 # Modem is active
fi
# Method 2: Check ModemManager state
if command -v mmcli &> /dev/null; then
local modem_state=$(mmcli -m 0 --output-keyvalue 2>/dev/null | grep "modem.generic.state " | cut -d: -f2 | tr -d ' ')
# States: connected, registered, enabled mean modem is potentially active
if [[ "$modem_state" == "connected" ]] || [[ "$modem_state" == "registered" ]]; then
return 0 # Modem is active
fi
fi
# Method 3: Check if there's active data on wwan0
if [ -f /sys/class/net/wwan0/statistics/rx_bytes ]; then
local rx_bytes=$(cat /sys/class/net/wwan0/statistics/rx_bytes 2>/dev/null || echo 0)
local tx_bytes=$(cat /sys/class/net/wwan0/statistics/tx_bytes 2>/dev/null || echo 0)
local total=$((rx_bytes + tx_bytes))
# Read previous state
local prev_total=0
if [ -f "$MODEM_STATE_FILE" ]; then
prev_total=$(cat "$MODEM_STATE_FILE")
fi
# Save current state
echo "$total" > "$MODEM_STATE_FILE"
# If traffic increased significantly, modem is active
if [ $((total - prev_total)) -gt 1000 ]; then
return 0 # Modem is active (data transfer)
fi
fi
return 1 # Modem is inactive
}
# Apply CPU power settings
apply_cpu_settings() {
local governor=$1
local max_freq=$2
log "Applying CPU settings: governor=$governor, max_freq=${max_freq}Hz"
# Apply to all CPUs
for cpu in /sys/devices/system/cpu/cpu[0-9]*; do
if [ -f "$cpu/cpufreq/scaling_governor" ]; then
echo "$governor" > "$cpu/cpufreq/scaling_governor" 2>/dev/null
log " Set $cpu governor to $governor"
fi
if [ -f "$cpu/cpufreq/scaling_max_freq" ]; then
echo "$max_freq" > "$cpu/cpufreq/scaling_max_freq" 2>/dev/null
log " Set $cpu max frequency to ${max_freq}Hz"
fi
done
}
# Main logic
main() {
log "=== 4G Power Manager triggered ==="
# Check if cpufreq is available
if [ ! -f /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor ]; then
log "ERROR: CPU frequency scaling not available"
exit 1
fi
# Determine modem state
if check_modem_active; then
log "4G modem is ACTIVE - Applying power-saving mode"
apply_cpu_settings "$POWERSAVE_GOVERNOR" "$MAX_FREQ_POWERSAVE"
else
log "4G modem is INACTIVE - Applying normal mode"
apply_cpu_settings "$NORMAL_GOVERNOR" "$MAX_FREQ_NORMAL"
fi
# Display current power consumption from battery
if [ -f /sys/class/power_supply/axp20x-battery/power_now ]; then
local power_now=$(cat /sys/class/power_supply/axp20x-battery/power_now)
local power_watts=$(echo "scale=2; $power_now / 1000000" | bc 2>/dev/null || echo "N/A")
log "Current battery power draw: ${power_watts}W"
fi
log "=== 4G Power Manager completed ==="
}
# Run main function
main "$@"

View File

@@ -0,0 +1,58 @@
#!/bin/bash
#
# 4G Modem Power Monitor Daemon
# Continuously monitors 4G modem state and adjusts power settings
#
# This daemon runs in the background and checks modem state every 5 seconds.
# It's more reliable than udev for detecting modem state changes during
# network activity (connecting, disconnecting, data transfer).
SCRIPT_DIR="/home/pi/battery-monitor/scripts"
CHECK_INTERVAL=5 # Check every 5 seconds
LAST_STATE_FILE="/tmp/4g-modem-last-state"
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"
}
# Get current modem state
get_modem_state() {
# Check if wwan0 is up and has IP address
if ip addr show wwan0 2>/dev/null | grep -q "inet "; then
echo "active"
return
fi
# Check ModemManager state
if command -v mmcli &> /dev/null; then
local modem_state=$(mmcli -m 0 --output-keyvalue 2>/dev/null | grep "modem.generic.state " | cut -d: -f2 | tr -d ' ')
if [[ "$modem_state" == "connected" ]] || [[ "$modem_state" == "registered" ]]; then
echo "active"
return
fi
fi
echo "inactive"
}
log "4G Power Monitor Daemon started"
# Initialize last state
LAST_STATE="unknown"
if [ -f "$LAST_STATE_FILE" ]; then
LAST_STATE=$(cat "$LAST_STATE_FILE")
fi
while true; do
CURRENT_STATE=$(get_modem_state)
# Only trigger power manager if state changed
if [ "$CURRENT_STATE" != "$LAST_STATE" ]; then
log "Modem state changed: $LAST_STATE -> $CURRENT_STATE"
"$SCRIPT_DIR/4g-power-manager.sh"
echo "$CURRENT_STATE" > "$LAST_STATE_FILE"
LAST_STATE="$CURRENT_STATE"
fi
sleep "$CHECK_INTERVAL"
done

View File

@@ -0,0 +1,20 @@
[Unit]
Description=4G Modem Power Monitor
Documentation=https://github.com/clockworkpi/uConsole
After=network.target ModemManager.service
Wants=ModemManager.service
[Service]
Type=simple
ExecStart=/home/pi/battery-monitor/scripts/4g-power-monitor-daemon.sh
Restart=on-failure
RestartSec=10
User=root
# Logging
StandardOutput=journal
StandardError=journal
SyslogIdentifier=4g-power-monitor
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,23 @@
# udev rules for 4G modem power management on uConsole CM5
#
# This rule triggers the power management script when:
# 1. The 4G modem USB device is added/removed
# 2. The wwan0 network interface state changes
#
# The script automatically adjusts CPU governor and frequency to prevent
# system hangs due to insufficient battery power when 4G is active.
# Trigger on Qualcomm/SimTech 4G modem USB device changes
SUBSYSTEM=="usb", ATTR{idVendor}=="1e0e", ATTR{idProduct}=="9001", ACTION=="add", RUN+="/home/pi/battery-monitor/scripts/4g-power-manager.sh"
SUBSYSTEM=="usb", ATTR{idVendor}=="1e0e", ATTR{idProduct}=="9001", ACTION=="remove", RUN+="/home/pi/battery-monitor/scripts/4g-power-manager.sh"
# Trigger on wwan0 network interface changes (up/down)
SUBSYSTEM=="net", KERNEL=="wwan0", ACTION=="add", RUN+="/home/pi/battery-monitor/scripts/4g-power-manager.sh"
SUBSYSTEM=="net", KERNEL=="wwan0", ACTION=="remove", RUN+="/home/pi/battery-monitor/scripts/4g-power-manager.sh"
# Trigger on modem manager state changes via cdc-wdm0 (modem control device)
SUBSYSTEM=="usb", KERNEL=="cdc-wdm0", ACTION=="change", RUN+="/home/pi/battery-monitor/scripts/4g-power-manager.sh"
# Trigger on ttyUSB device changes (modem serial ports)
SUBSYSTEM=="tty", KERNEL=="ttyUSB[0-4]", ACTION=="add", RUN+="/home/pi/battery-monitor/scripts/4g-power-manager.sh"
SUBSYSTEM=="tty", KERNEL=="ttyUSB[0-4]", ACTION=="remove", RUN+="/home/pi/battery-monitor/scripts/4g-power-manager.sh"

View File

@@ -0,0 +1,341 @@
# 4G Power Manager for uConsole CM5
## Problem Statement
The uConsole CM5 with 4G module active can consume 22-25W peak power, but the AXP228 power management IC can only sustainably deliver ~18-20W from battery. This causes:
- System hangs when 4G is active on battery power
- Requires physical battery removal to restart
- Issue is more frequent with CM5 + 4G module combination
- Works fine on USB power (can supplement with external power)
## Solution
This automatic power management system detects when the 4G modem is active and **reduces CPU power consumption** to keep total system power within the AXP228's sustainable limits.
### How It Works
**When 4G is INACTIVE:**
- CPU Governor: `ondemand` (dynamic scaling)
- Max CPU Frequency: 2.4GHz (full performance)
- Power budget: ~12-15W system + margin
**When 4G is ACTIVE:**
- CPU Governor: `powersave` (low power)
- Max CPU Frequency: 1.8GHz (reduced by 25%)
- Power budget: 4G module (~5-10W) + CPU (~8-12W) = ~18W total
- **Stays within AXP228's 18-20W battery limit**
## Components
### 1. Main Script: `4g-power-manager.sh`
- Detects modem state using multiple methods
- Applies CPU governor and frequency changes
- Logs all actions to `/var/log/4g-power-manager.log`
### 2. udev Rules: `99-4g-power-manager.rules`
- Triggers on USB modem device add/remove
- Triggers on wwan0 interface changes
- Responds to modem control device changes
### 3. Systemd Service: `4g-power-monitor.service`
- Background daemon that monitors modem state every 5 seconds
- More reliable than udev for detecting state changes during network activity
- Auto-restarts on failure
### 4. Daemon Script: `4g-power-monitor-daemon.sh`
- Continuous monitoring loop
- Tracks state changes and triggers power manager
- Complements udev rules for comprehensive coverage
## Installation
### Quick Install (Recommended)
```bash
cd /home/pi/battery-monitor/scripts
sudo ./install-4g-power-manager.sh
```
This installs both udev rules and systemd service.
### Manual Installation
**Install udev rules only:**
```bash
sudo ./install-4g-power-manager.sh udev
```
**Install daemon service only:**
```bash
sudo ./install-4g-power-manager.sh daemon
```
## Usage
### Check Status
```bash
# View real-time logs
tail -f /var/log/4g-power-manager.log
# Check daemon service status
systemctl status 4g-power-monitor
# Check current CPU settings
cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor
cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_max_freq
# Check modem status
mmcli -m 0
ip addr show wwan0
```
### Manual Testing
```bash
# Test the power manager script manually
sudo /home/pi/battery-monitor/scripts/4g-power-manager.sh
# Check the log output
tail -20 /var/log/4g-power-manager.log
```
### Control the Service
```bash
# Stop the monitoring service
sudo systemctl stop 4g-power-monitor
# Start the monitoring service
sudo systemctl start 4g-power-monitor
# Disable (won't start on boot)
sudo systemctl disable 4g-power-monitor
# Enable (start on boot)
sudo systemctl enable 4g-power-monitor
# View service logs
journalctl -u 4g-power-monitor -f
```
## Configuration
Edit the configuration variables in `4g-power-manager.sh`:
```bash
# Governor settings
NORMAL_GOVERNOR="ondemand" # When 4G is off
POWERSAVE_GOVERNOR="powersave" # When 4G is active
# Frequency limits (in Hz)
MAX_FREQ_NORMAL="2400000" # 2.4GHz when 4G is off
MAX_FREQ_POWERSAVE="1800000" # 1.8GHz when 4G is active
```
### Tuning Options
**More aggressive power saving:**
```bash
MAX_FREQ_POWERSAVE="1500000" # 1.5GHz (saves more power)
POWERSAVE_GOVERNOR="conservative" # Even smoother scaling
```
**Less aggressive (if hangs still occur):**
```bash
MAX_FREQ_POWERSAVE="2000000" # 2.0GHz (slightly higher)
# Consider upgrading to high-drain 18650 batteries
```
**Different governor when 4G is off:**
```bash
NORMAL_GOVERNOR="schedutil" # More responsive than ondemand
# or
NORMAL_GOVERNOR="performance" # Always max speed (higher power)
```
After editing, reload the service:
```bash
sudo systemctl restart 4g-power-monitor
```
## Monitoring Power Consumption
Use the battery monitor app to track power consumption:
```bash
# Check current power draw
cat /sys/class/power_supply/axp20x-battery/power_now
# Convert to watts
echo "scale=2; $(cat /sys/class/power_supply/axp20x-battery/power_now) / 1000000" | bc
# Monitor voltage under load
watch -n 1 'cat /sys/class/power_supply/axp20x-battery/voltage_now'
```
**What to look for:**
- Power should stay below 18-20W on battery
- Voltage should stay above 3.3V under load
- If voltage drops below 3.3V, consider upgrading batteries
## Troubleshooting
### Hangs Still Occur
1. **Check if service is running:**
```bash
systemctl status 4g-power-monitor
```
2. **Verify CPU settings are applied:**
```bash
# When 4G is active, should show powersave and 1800000
cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor
cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_max_freq
```
3. **Lower CPU frequency further:**
- Edit `MAX_FREQ_POWERSAVE="1500000"` (1.5GHz)
- Restart service: `sudo systemctl restart 4g-power-monitor`
4. **Check battery health:**
- Voltage drops below 3.3V = weak batteries
- Upgrade to high-drain cells (Samsung 25R, Sony VTC6, LG HG2)
### Service Not Starting
```bash
# Check for errors
journalctl -u 4g-power-monitor -n 50
# Verify script is executable
ls -l /home/pi/battery-monitor/scripts/4g-power-monitor-daemon.sh
# Test script manually
sudo /home/pi/battery-monitor/scripts/4g-power-monitor-daemon.sh
```
### udev Rules Not Triggering
```bash
# Reload udev rules
sudo udevadm control --reload-rules
sudo udevadm trigger
# Monitor udev events
sudo udevadm monitor
# Check if rules file exists
ls -l /etc/udev/rules.d/99-4g-power-manager.rules
```
### Script Doesn't Detect Modem
```bash
# Check if modem is detected
lsusb | grep 1e0e:9001
mmcli -L
ip link show wwan0
# If modem USB ID is different, update udev rules
lsusb # Find your modem's vendor:product ID
# Edit 99-4g-power-manager.rules with correct IDs
```
## Uninstallation
```bash
# Stop and disable service
sudo systemctl stop 4g-power-monitor
sudo systemctl disable 4g-power-monitor
# Remove files
sudo rm /etc/systemd/system/4g-power-monitor.service
sudo rm /etc/udev/rules.d/99-4g-power-manager.rules
sudo rm /var/log/4g-power-manager.log
sudo rm /tmp/4g-modem-state /tmp/4g-modem-last-state
# Reload
sudo systemctl daemon-reload
sudo udevadm control --reload-rules
```
## Performance Impact
**With 4G active:**
- CPU performance reduced by ~20-30%
- Still usable for web browsing, terminal work, light coding
- Intensive tasks (compilation, video encoding) will be slower
- **Trade-off: Slower but stable vs Fast but crashes**
**Recommendations:**
- Disable 4G when doing CPU-intensive work
- Use USB power for maximum performance + 4G
- Upgrade batteries for better sustained power delivery
## Battery Recommendations
To improve power delivery and reduce need for CPU throttling:
**High-drain 18650 cells:**
- Samsung 25R (2500mAh, 20A CDR) - Best balance
- Samsung 30Q (3000mAh, 15A CDR) - More capacity
- Sony VTC6 (3000mAh, 15A CDR) - High quality
- LG HG2 (3000mAh, 20A CDR) - Popular choice
**Avoid:**
- Generic/unbranded cells
- "UltraFire" or similar fake brands
- Cells with <10A discharge rating
- Old/used laptop pulls (high internal resistance)
## Technical Details
### Power Budget Analysis
| Component | Idle | Normal | Peak |
|-----------|------|--------|------|
| CM5 CPU | 2W | 8W | 15W |
| 4G Module | 0.5W | 3W | 10W |
| Display | 1W | 2W | 3W |
| Other | 1W | 2W | 2W |
| **Total** | **4.5W** | **15W** | **30W** |
**AXP228 limits:**
- Battery discharge path: ~18-20W sustained
- USB + battery: ~25W sustained
- Peak spikes: Can handle briefly, then protection triggers
**With 4G active at 1.8GHz:**
- CM5: ~8-10W (reduced from 12-15W)
- 4G: ~5-10W (during transmission)
- Other: ~3W
- **Total: ~16-23W** (within limits most of the time)
### Detection Methods
The script uses multiple detection methods for reliability:
1. **Network interface check**: `wwan0` state and IP address
2. **ModemManager state**: Connected/registered status
3. **Network traffic**: RX/TX byte counters
4. **udev events**: USB device and interface changes
This multi-layered approach ensures the modem state is detected even if one method fails.
## Contributing
Found an issue or have a suggestion? Please report it at:
- ClockworkPi Forum: https://forum.clockworkpi.com/c/uconsole
- GitHub: https://github.com/clockworkpi/uConsole/issues
## License
GPL v3 (same as uConsole hardware designs)
## Credits
Created to solve the CM5 + 4G battery hang issue on uConsole.
Based on research into AXP228 power management and 18650 battery limitations.

View File

@@ -0,0 +1,78 @@
#!/bin/bash
#
# Installation script for 4G Power Manager
# Run with: sudo ./install-4g-power-manager.sh
set -e # Exit on error
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
INSTALL_MODE=${1:-"both"} # Options: udev, daemon, both
echo "=== 4G Power Manager Installation ==="
echo ""
# Check if running as root
if [ "$EUID" -ne 0 ]; then
echo "ERROR: Please run as root (use sudo)"
exit 1
fi
# Install bc for power calculations
if ! command -v bc &> /dev/null; then
echo "Installing bc for power calculations..."
apt-get update && apt-get install -y bc
fi
# Install udev rules
if [[ "$INSTALL_MODE" == "udev" ]] || [[ "$INSTALL_MODE" == "both" ]]; then
echo "Installing udev rules..."
cp "$SCRIPT_DIR/99-4g-power-manager.rules" /etc/udev/rules.d/
echo "✓ Copied udev rules to /etc/udev/rules.d/"
# Reload udev rules
udevadm control --reload-rules
udevadm trigger
echo "✓ Reloaded udev rules"
fi
# Install systemd service
if [[ "$INSTALL_MODE" == "daemon" ]] || [[ "$INSTALL_MODE" == "both" ]]; then
echo "Installing systemd service..."
cp "$SCRIPT_DIR/4g-power-monitor.service" /etc/systemd/system/
echo "✓ Copied service file to /etc/systemd/system/"
# Reload systemd
systemctl daemon-reload
echo "✓ Reloaded systemd"
# Enable and start service
systemctl enable 4g-power-monitor.service
systemctl start 4g-power-monitor.service
echo "✓ Enabled and started 4g-power-monitor service"
fi
# Create log file with proper permissions
touch /var/log/4g-power-manager.log
chmod 644 /var/log/4g-power-manager.log
echo "✓ Created log file at /var/log/4g-power-manager.log"
echo ""
echo "=== Installation Complete ==="
echo ""
echo "The 4G Power Manager is now active and will:"
echo " • Detect when 4G modem becomes active"
echo " • Automatically reduce CPU frequency to 1.8GHz"
echo " • Switch to powersave governor"
echo " • Restore normal settings when 4G is inactive"
echo ""
echo "Useful commands:"
echo " • View logs: tail -f /var/log/4g-power-manager.log"
echo " • Check service: systemctl status 4g-power-monitor"
echo " • Stop service: sudo systemctl stop 4g-power-monitor"
echo " • Disable service: sudo systemctl disable 4g-power-monitor"
echo " • Test manually: sudo $SCRIPT_DIR/4g-power-manager.sh"
echo ""
echo "Current CPU settings:"
cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor 2>/dev/null || echo " Governor: N/A"
cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_max_freq 2>/dev/null || echo " Max freq: N/A"
echo ""

View File

@@ -0,0 +1,44 @@
import { NextRequest, NextResponse } from 'next/server';
import { getReadingsByTimeRange, getRecentReadings } from '@/lib/db';
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const startTime = searchParams.get('start');
const endTime = searchParams.get('end');
const limit = searchParams.get('limit');
try {
let readings;
if (startTime && endTime) {
// Get readings within time range
readings = getReadingsByTimeRange(startTime, endTime);
} else if (limit) {
// Get recent N readings
readings = getRecentReadings(parseInt(limit));
} else {
// Default: get last 100 readings
readings = getRecentReadings(100);
}
// Transform to match frontend interface (ac_connected -> acConnected)
const transformedReadings = readings.map(reading => ({
timestamp: reading.timestamp,
percentage: reading.percentage,
voltage: reading.voltage,
current: reading.current,
power: reading.power,
status: reading.status,
health: reading.health,
acConnected: reading.ac_connected,
}));
return NextResponse.json(transformedReadings);
} catch (error) {
console.error('Error fetching battery history:', error);
return NextResponse.json(
{ error: 'Failed to fetch battery history' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,93 @@
import { NextRequest, NextResponse } from 'next/server';
import { insertReading } from '@/lib/db';
interface BatteryData {
timestamp: string;
percentage: number;
voltage: number; // in volts
current: number; // in amps
power: number; // in watts
status: string;
health: string;
acConnected: boolean;
}
function readBatteryFile(path: string): string | null {
try {
const fs = require('fs');
return fs.readFileSync(path, 'utf8').trim();
} catch (error) {
console.error(`Error reading ${path}:`, error);
return null;
}
}
function getBatteryData(): BatteryData | null {
const basePath = '/sys/class/power_supply/axp20x-battery';
const acPath = '/sys/class/power_supply/axp22x-ac';
// Read raw values
const capacityRaw = readBatteryFile(`${basePath}/capacity`);
const voltageRaw = readBatteryFile(`${basePath}/voltage_now`);
const currentRaw = readBatteryFile(`${basePath}/current_now`);
const status = readBatteryFile(`${basePath}/status`) || 'Unknown';
const health = readBatteryFile(`${basePath}/health`) || 'Unknown';
const acOnline = readBatteryFile(`${acPath}/online`);
if (!capacityRaw || !voltageRaw || !currentRaw) {
return null;
}
// Convert and calculate
const percentage = parseInt(capacityRaw);
const voltage = parseInt(voltageRaw) / 1000000; // Convert from µV to V
const current = parseInt(currentRaw) / 1000000; // Convert from µA to A (positive=charging, negative=discharging)
const power = voltage * current; // Calculate power in watts (positive=charging, negative=discharging)
const acConnected = acOnline === '1';
return {
timestamp: new Date().toISOString(),
percentage,
voltage,
current,
power,
status,
health,
acConnected,
};
}
export async function GET(request: NextRequest) {
const batteryData = getBatteryData();
if (!batteryData) {
return NextResponse.json(
{ error: 'Unable to read battery data' },
{ status: 500 }
);
}
// Check if we should save to database (via query param)
const searchParams = request.nextUrl.searchParams;
const saveToDb = searchParams.get('save') === 'true';
const sessionId = searchParams.get('sessionId');
if (saveToDb) {
try {
insertReading({
timestamp: batteryData.timestamp,
percentage: batteryData.percentage,
voltage: batteryData.voltage,
current: batteryData.current,
power: batteryData.power,
status: batteryData.status,
health: batteryData.health,
ac_connected: batteryData.acConnected,
}, sessionId ? parseInt(sessionId) : undefined);
} catch (error) {
console.error('Error saving to database:', error);
}
}
return NextResponse.json(batteryData);
}

View File

@@ -0,0 +1,83 @@
import { NextRequest, NextResponse } from 'next/server';
import { updateSessionName, updateSession, getReadingsBySession, deleteSession } from '@/lib/db';
// Update session name or end time
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const sessionId = parseInt(id);
const body = await request.json();
const { name, end_time, reading_count } = body;
if (name) {
updateSessionName(sessionId, name);
}
if (end_time !== undefined && reading_count !== undefined) {
updateSession(sessionId, end_time, reading_count);
}
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error updating session:', error);
return NextResponse.json(
{ error: 'Failed to update session' },
{ status: 500 }
);
}
}
// Get readings by session ID
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const sessionId = parseInt(id);
const readings = getReadingsBySession(sessionId);
// Transform to match frontend interface
const transformedReadings = readings.map(reading => ({
timestamp: reading.timestamp,
percentage: reading.percentage,
voltage: reading.voltage,
current: reading.current,
power: reading.power,
status: reading.status,
health: reading.health,
acConnected: reading.ac_connected,
}));
return NextResponse.json(transformedReadings);
} catch (error) {
console.error('Error fetching session readings:', error);
return NextResponse.json(
{ error: 'Failed to fetch session readings' },
{ status: 500 }
);
}
}
// Delete session and its associated readings
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const sessionId = parseInt(id);
deleteSession(sessionId);
return NextResponse.json({ success: true });
} catch (error) {
console.error('Error deleting session:', error);
return NextResponse.json(
{ error: 'Failed to delete session' },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from 'next/server';
import { getMonitoringSessions, createSession } from '@/lib/db';
export async function GET(request: NextRequest) {
try {
const sessions = getMonitoringSessions();
return NextResponse.json(sessions);
} catch (error) {
console.error('Error fetching monitoring sessions:', error);
return NextResponse.json(
{ error: 'Failed to fetch monitoring sessions' },
{ status: 500 }
);
}
}
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { start_time, end_time, name } = body;
if (!start_time || !end_time) {
return NextResponse.json(
{ error: 'start_time and end_time are required' },
{ status: 400 }
);
}
const sessionId = createSession(start_time, end_time, name);
return NextResponse.json({ id: sessionId, start_time, end_time, name });
} catch (error) {
console.error('Error creating monitoring session:', error);
return NextResponse.json(
{ error: 'Failed to create monitoring session' },
{ status: 500 }
);
}
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

122
src/app/globals.css Normal file
View File

@@ -0,0 +1,122 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

34
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,34 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "uConsole Battery Monitor",
description: "Real-time battery monitoring for uConsole",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html>
);
}

9
src/app/page.tsx Normal file
View File

@@ -0,0 +1,9 @@
import BatteryMonitor from '@/components/BatteryMonitor';
export default function Home() {
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<BatteryMonitor />
</div>
);
}

View File

@@ -0,0 +1,624 @@
'use client';
import { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { Button } from '@/components/ui/button';
import { Select } from '@/components/ui/select';
import { Battery, BatteryCharging, Zap, Download, Play, Pause, Calendar, Database, Edit2, Check, X, Trash2 } from 'lucide-react';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, AreaChart, Area } from 'recharts';
import { format } from 'date-fns';
interface BatteryData {
timestamp: string;
percentage: number;
voltage: number;
current: number;
power: number;
status: string;
health: string;
acConnected: boolean;
}
interface MonitoringSession {
id: number;
name: string | null;
start_time: string;
end_time: string;
reading_count: number;
created_at: string;
}
export default function BatteryMonitor() {
const [currentData, setCurrentData] = useState<BatteryData | null>(null);
const [historicalData, setHistoricalData] = useState<BatteryData[]>([]);
const [isMonitoring, setIsMonitoring] = useState(false);
const [error, setError] = useState<string | null>(null);
const [startDate, setStartDate] = useState<string>('');
const [endDate, setEndDate] = useState<string>('');
const [showHistoricalView, setShowHistoricalView] = useState(false);
const [sessions, setSessions] = useState<MonitoringSession[]>([]);
const [selectedSessionId, setSelectedSessionId] = useState<string>('');
const [currentSessionId, setCurrentSessionId] = useState<number | null>(null);
const [editingSessionId, setEditingSessionId] = useState<number | null>(null);
const [editingName, setEditingName] = useState<string>('');
const [showCustomRange, setShowCustomRange] = useState(false);
const fetchSessions = async () => {
try {
const response = await fetch('/api/battery/sessions');
if (response.ok) {
const data = await response.json();
setSessions(data);
}
} catch (err) {
console.error('Failed to fetch sessions:', err);
}
};
const loadSessionData = async (sessionId: number) => {
try {
const response = await fetch(`/api/battery/sessions/${sessionId}`);
if (!response.ok) {
throw new Error('Failed to fetch session data');
}
const data = await response.json();
setHistoricalData(data);
setShowHistoricalView(true);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
}
};
const updateSessionName = async (sessionId: number, name: string) => {
try {
const response = await fetch(`/api/battery/sessions/${sessionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
});
if (response.ok) {
fetchSessions(); // Refresh sessions list
}
} catch (err) {
console.error('Failed to update session name:', err);
}
};
const startMonitoringSession = async () => {
try {
const startTime = new Date().toISOString();
const response = await fetch('/api/battery/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ start_time: startTime, end_time: startTime }),
});
if (response.ok) {
const data = await response.json();
setCurrentSessionId(data.id);
}
} catch (err) {
console.error('Failed to create session:', err);
}
};
const stopMonitoringSession = async () => {
if (currentSessionId) {
try {
const endTime = new Date().toISOString();
await fetch(`/api/battery/sessions/${currentSessionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ end_time: endTime, reading_count: historicalData.length }),
});
setCurrentSessionId(null);
fetchSessions(); // Refresh sessions list
} catch (err) {
console.error('Failed to update session:', err);
}
}
};
const fetchBatteryData = async (saveToDb: boolean = false) => {
try {
let url = saveToDb ? '/api/battery?save=true' : '/api/battery';
if (saveToDb && currentSessionId) {
url += `&sessionId=${currentSessionId}`;
}
const response = await fetch(url);
if (!response.ok) {
throw new Error('Failed to fetch battery data');
}
const data = await response.json();
setCurrentData(data);
setHistoricalData(prev => {
const newData = [...prev, data];
// Keep only last 100 data points in memory
return newData.slice(-100);
});
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
}
};
const loadHistoricalData = async () => {
try {
let url = '/api/battery/history';
if (startDate && endDate) {
url += `?start=${encodeURIComponent(startDate)}&end=${encodeURIComponent(endDate)}`;
}
const response = await fetch(url);
if (!response.ok) {
throw new Error('Failed to fetch historical data');
}
const data = await response.json();
setHistoricalData(data);
setShowHistoricalView(true);
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
}
};
useEffect(() => {
// Initial fetch
fetchBatteryData();
fetchSessions();
// Set up interval for monitoring
let interval: NodeJS.Timeout | null = null;
if (isMonitoring) {
// When monitoring, save data to database
interval = setInterval(() => fetchBatteryData(true), 2000); // Update every 2 seconds
}
return () => {
if (interval) clearInterval(interval);
};
}, [isMonitoring]);
const handleStartMonitoring = async () => {
await startMonitoringSession();
setIsMonitoring(true);
};
const handleStopMonitoring = async () => {
await stopMonitoringSession();
setIsMonitoring(false);
};
const handleSessionSelect = (sessionId: string) => {
setSelectedSessionId(sessionId);
if (sessionId) {
loadSessionData(parseInt(sessionId));
}
};
const handleEditSessionName = (session: MonitoringSession) => {
setEditingSessionId(session.id);
setEditingName(session.name || `Session ${new Date(session.start_time).toLocaleString()}`);
};
const handleSaveSessionName = async () => {
if (editingSessionId && editingName) {
await updateSessionName(editingSessionId, editingName);
setEditingSessionId(null);
setEditingName('');
}
};
const handleCancelEdit = () => {
setEditingSessionId(null);
setEditingName('');
};
const handleDeleteSession = async (session: MonitoringSession) => {
const sessionName = session.name || `Session ${new Date(session.start_time).toLocaleString()}`;
const confirmed = window.confirm(
`Are you sure you want to delete "${sessionName}"?\n\nThis will permanently delete ${session.reading_count} readings associated with this session.`
);
if (confirmed) {
try {
const response = await fetch(`/api/battery/sessions/${session.id}`, {
method: 'DELETE',
});
if (response.ok) {
// Refresh sessions list
fetchSessions();
// If this was the currently displayed session, clear the view
if (selectedSessionId === String(session.id)) {
setSelectedSessionId('');
setShowHistoricalView(false);
setHistoricalData([]);
fetchBatteryData();
}
} else {
alert('Failed to delete session. Please try again.');
}
} catch (err) {
console.error('Failed to delete session:', err);
alert('An error occurred while deleting the session.');
}
}
};
const exportData = () => {
const csvContent = [
'Timestamp,Percentage,Voltage (V),Current (A),Power (W),Status,Health,AC Connected',
...historicalData.map(data =>
`${data.timestamp},${data.percentage},${data.voltage},${data.current},${data.power},${data.status},${data.health},${data.acConnected}`
)
].join('\n');
const blob = new Blob([csvContent], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `battery-report-${new Date().toISOString().split('T')[0]}.csv`;
a.click();
window.URL.revokeObjectURL(url);
};
const getBatteryIcon = () => {
if (!currentData) return <Battery className="h-8 w-8" />;
return currentData.acConnected ?
<BatteryCharging className="h-8 w-8 text-green-500" /> :
<Battery className="h-8 w-8" />;
};
const getStatusColor = (status: string) => {
switch (status.toLowerCase()) {
case 'charging': return 'bg-green-500';
case 'discharging': return 'bg-red-500';
case 'full': return 'bg-blue-500';
default: return 'bg-gray-500';
}
};
if (error) {
return (
<Card className="w-full max-w-4xl mx-auto">
<CardHeader>
<CardTitle className="text-red-600">Error</CardTitle>
</CardHeader>
<CardContent>
<p className="text-red-600">{error}</p>
</CardContent>
</Card>
);
}
return (
<div className="w-full max-w-6xl mx-auto p-6 space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold">Battery Monitor</h1>
<div className="flex space-x-2">
<Button
onClick={isMonitoring ? handleStopMonitoring : handleStartMonitoring}
variant={isMonitoring ? "destructive" : "default"}
>
{isMonitoring ? <Pause className="h-4 w-4 mr-2" /> : <Play className="h-4 w-4 mr-2" />}
{isMonitoring ? 'Stop Monitoring' : 'Start Monitoring'}
</Button>
<Button onClick={exportData} disabled={historicalData.length === 0}>
<Download className="h-4 w-4 mr-2" />
Export CSV
</Button>
</div>
</div>
{/* Historical Data Query Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Database className="h-5 w-5" />
<span>Load Historical Data</span>
</CardTitle>
<CardDescription>
Select a monitoring session or specify a custom time range
</CardDescription>
</CardHeader>
<CardContent>
{/* Session Selection */}
<div className="mb-6">
<label className="text-sm font-medium mb-2 block">Select Monitoring Session</label>
<Select
value={selectedSessionId}
onChange={(e) => handleSessionSelect(e.target.value)}
className="w-full"
>
<option value="">-- Select a session --</option>
{sessions.map((session) => (
<option key={session.id} value={session.id}>
{session.name || `Session ${new Date(session.start_time).toLocaleString()}`}
{' '}({session.reading_count} readings)
</option>
))}
</Select>
{/* Session List with Edit */}
{sessions.length > 0 && (
<div className="mt-4 space-y-2 max-h-60 overflow-y-auto">
{sessions.slice(0, 10).map((session) => (
<div key={session.id} className="flex items-center justify-between p-2 border rounded-md hover:bg-gray-50">
{editingSessionId === session.id ? (
<div className="flex-1 flex items-center gap-2">
<input
type="text"
value={editingName}
onChange={(e) => setEditingName(e.target.value)}
className="flex-1 px-2 py-1 border rounded text-sm"
autoFocus
/>
<Button size="sm" onClick={handleSaveSessionName} variant="ghost">
<Check className="h-4 w-4 text-green-600" />
</Button>
<Button size="sm" onClick={handleCancelEdit} variant="ghost">
<X className="h-4 w-4 text-red-600" />
</Button>
</div>
) : (
<>
<div className="flex-1">
<div className="text-sm font-medium">
{session.name || `Session ${new Date(session.start_time).toLocaleString()}`}
</div>
<div className="text-xs text-gray-500">
{new Date(session.start_time).toLocaleString()} {new Date(session.end_time).toLocaleString()}
{' '} {session.reading_count} readings
</div>
</div>
<div className="flex gap-1">
<Button size="sm" onClick={() => handleEditSessionName(session)} variant="ghost" title="Edit session name">
<Edit2 className="h-4 w-4" />
</Button>
<Button size="sm" onClick={() => handleDeleteSession(session)} variant="ghost" title="Delete session">
<Trash2 className="h-4 w-4 text-red-600 hover:text-red-800" />
</Button>
</div>
</>
)}
</div>
))}
</div>
)}
</div>
<div className="border-t pt-4">
<button
onClick={() => setShowCustomRange(!showCustomRange)}
className="text-sm font-medium text-blue-600 hover:text-blue-800 flex items-center gap-2"
>
{showCustomRange ? '▼' : '▶'} Or specify custom time range
</button>
{showCustomRange && (
<div className="flex flex-col md:flex-row gap-4 mt-3">
<div className="flex-1">
<label className="text-sm font-medium mb-2 block">Start Date/Time</label>
<input
type="datetime-local"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-full px-3 py-2 border rounded-md text-sm"
/>
</div>
<div className="flex-1">
<label className="text-sm font-medium mb-2 block">End Date/Time</label>
<input
type="datetime-local"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="w-full px-3 py-2 border rounded-md text-sm"
/>
</div>
<div className="flex items-end gap-2">
<Button
onClick={loadHistoricalData}
disabled={!startDate || !endDate}
>
<Calendar className="h-4 w-4 mr-2" />
Load Data
</Button>
</div>
</div>
)}
{showHistoricalView && (
<div className="mt-3">
<Button
onClick={() => {
setShowHistoricalView(false);
setHistoricalData([]);
setSelectedSessionId('');
fetchBatteryData();
}}
variant="outline"
size="sm"
>
Live View
</Button>
</div>
)}
</div>
{showHistoricalView && historicalData.length > 0 && (
<div className="mt-4">
<Badge variant="secondary">
Showing {historicalData.length} data points from {new Date(historicalData[0].timestamp).toLocaleString()} to {new Date(historicalData[historicalData.length - 1].timestamp).toLocaleString()}
</Badge>
</div>
)}
</CardContent>
</Card>
{currentData && (
<>
{/* Current Status Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
{getBatteryIcon()}
<span>Current Status</span>
</CardTitle>
<CardDescription>
Last updated: {new Date(currentData.timestamp).toLocaleString()}
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm font-medium">Battery Level</span>
<Badge variant="outline">{currentData.percentage}%</Badge>
</div>
<Progress value={currentData.percentage} className="h-2" />
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">Voltage:</span>
<span className="text-sm font-mono">{currentData.voltage.toFixed(2)}V</span>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">Current:</span>
<span className="text-sm font-mono">{currentData.current >= 0 ? '+' : ''}{currentData.current.toFixed(2)}A</span>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">Power:</span>
<span className="text-sm font-mono">{currentData.power >= 0 ? '+' : ''}{currentData.power.toFixed(2)}W</span>
</div>
</div>
</div>
<div className="mt-4 flex space-x-2">
<Badge className={getStatusColor(currentData.status)}>
{currentData.status}
</Badge>
<Badge variant="secondary">Health: {currentData.health}</Badge>
<Badge variant={currentData.acConnected ? "default" : "outline"}>
{currentData.acConnected ? "AC Connected" : "On Battery"}
</Badge>
</div>
</CardContent>
</Card>
{/* Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle>Battery Percentage</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={200}>
<LineChart data={historicalData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="timestamp"
tickFormatter={(value) => new Date(value).toLocaleTimeString()}
/>
<YAxis domain={[0, 100]} />
<Tooltip
labelFormatter={(value) => new Date(value).toLocaleString()}
formatter={(value: any) => [`${value}%`, 'Battery']}
/>
<Line
type="monotone"
dataKey="percentage"
stroke="#8884d8"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Power Consumption</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={historicalData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="timestamp"
tickFormatter={(value) => new Date(value).toLocaleTimeString()}
/>
<YAxis />
<Tooltip
labelFormatter={(value) => new Date(value).toLocaleString()}
formatter={(value: any) => [`${value.toFixed(2)}W`, 'Power']}
/>
<Area
type="monotone"
dataKey="power"
stroke="#82ca9d"
fill="#82ca9d"
fillOpacity={0.3}
/>
</AreaChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Voltage & Current Trends</CardTitle>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={historicalData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="timestamp"
tickFormatter={(value) => new Date(value).toLocaleTimeString()}
/>
<YAxis yAxisId="voltage" orientation="left" domain={[3.0, 4.5]} />
<YAxis yAxisId="current" orientation="right" />
<Tooltip
labelFormatter={(value) => new Date(value).toLocaleString()}
/>
<Legend />
<Line
yAxisId="voltage"
type="monotone"
dataKey="voltage"
stroke="#ff7300"
strokeWidth={2}
dot={false}
name="Voltage (V)"
/>
<Line
yAxisId="current"
type="monotone"
dataKey="current"
stroke="#387908"
strokeWidth={2}
dot={false}
name="Current (A)"
/>
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,60 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

357
src/components/ui/chart.tsx Normal file
View File

@@ -0,0 +1,357 @@
"use client"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}) {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
className={cn(
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}) {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}

View File

@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
export { Progress }

View File

@@ -0,0 +1,26 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface SelectProps
extends React.SelectHTMLAttributes<HTMLSelectElement> {}
const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
({ className, children, ...props }, ref) => {
return (
<select
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
>
{children}
</select>
)
}
)
Select.displayName = "Select"
export { Select }

116
src/components/ui/table.tsx Normal file
View File

@@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

236
src/lib/db.ts Normal file
View File

@@ -0,0 +1,236 @@
import Database from 'better-sqlite3';
import { join } from 'path';
// Initialize database
const dbPath = join(process.cwd(), 'battery-data.db');
const db = new Database(dbPath);
// Create tables if they don't exist
db.exec(`
CREATE TABLE IF NOT EXISTS battery_readings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER,
timestamp TEXT NOT NULL,
percentage INTEGER NOT NULL,
voltage REAL NOT NULL,
current REAL NOT NULL,
power REAL NOT NULL,
status TEXT NOT NULL,
health TEXT NOT NULL,
ac_connected INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (session_id) REFERENCES monitoring_sessions(id)
);
CREATE TABLE IF NOT EXISTS monitoring_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
start_time TEXT NOT NULL,
end_time TEXT NOT NULL,
reading_count INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_timestamp ON battery_readings(timestamp);
CREATE INDEX IF NOT EXISTS idx_created_at ON battery_readings(created_at);
CREATE INDEX IF NOT EXISTS idx_session_id ON battery_readings(session_id);
CREATE INDEX IF NOT EXISTS idx_session_start ON monitoring_sessions(start_time);
`);
export interface BatteryReading {
id?: number;
timestamp: string;
percentage: number;
voltage: number;
current: number;
power: number;
status: string;
health: string;
ac_connected: boolean;
created_at?: string;
}
// Insert a new battery reading
export function insertReading(reading: Omit<BatteryReading, 'id' | 'created_at'>, sessionId?: number) {
const stmt = db.prepare(`
INSERT INTO battery_readings (session_id, timestamp, percentage, voltage, current, power, status, health, ac_connected)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
sessionId || null,
reading.timestamp,
reading.percentage,
reading.voltage,
reading.current,
reading.power,
reading.status,
reading.health,
reading.ac_connected ? 1 : 0
);
return result.lastInsertRowid;
}
// Get readings within a time range
export function getReadingsByTimeRange(startTime: string, endTime: string): BatteryReading[] {
const stmt = db.prepare(`
SELECT
id,
timestamp,
percentage,
voltage,
current,
power,
status,
health,
ac_connected,
created_at
FROM battery_readings
WHERE timestamp BETWEEN ? AND ?
ORDER BY timestamp ASC
`);
const rows = stmt.all(startTime, endTime) as any[];
return rows.map(row => ({
...row,
ac_connected: Boolean(row.ac_connected)
}));
}
// Get recent readings (last N entries)
export function getRecentReadings(limit: number = 100): BatteryReading[] {
const stmt = db.prepare(`
SELECT
id,
timestamp,
percentage,
voltage,
current,
power,
status,
health,
ac_connected,
created_at
FROM battery_readings
ORDER BY timestamp DESC
LIMIT ?
`);
const rows = stmt.all(limit) as any[];
return rows.map(row => ({
...row,
ac_connected: Boolean(row.ac_connected)
})).reverse(); // Reverse to get chronological order
}
// Get all monitoring sessions with actual reading counts
export function getMonitoringSessions() {
const stmt = db.prepare(`
SELECT
s.id,
s.name,
s.start_time,
s.end_time,
COUNT(r.id) as reading_count,
s.created_at
FROM monitoring_sessions s
LEFT JOIN battery_readings r ON s.id = r.session_id
GROUP BY s.id
ORDER BY s.start_time DESC
`);
return stmt.all();
}
// Create a new monitoring session
export function createSession(startTime: string, endTime: string, name?: string) {
const stmt = db.prepare(`
INSERT INTO monitoring_sessions (name, start_time, end_time, reading_count)
VALUES (?, ?, ?, 0)
`);
const result = stmt.run(name, startTime, endTime);
return result.lastInsertRowid;
}
// Update session name
export function updateSessionName(sessionId: number, name: string) {
const stmt = db.prepare(`
UPDATE monitoring_sessions
SET name = ?
WHERE id = ?
`);
return stmt.run(name, sessionId);
}
// Update session end time and reading count
export function updateSession(sessionId: number, endTime: string, readingCount: number) {
const stmt = db.prepare(`
UPDATE monitoring_sessions
SET end_time = ?, reading_count = ?
WHERE id = ?
`);
return stmt.run(endTime, readingCount, sessionId);
}
// Get readings by session ID
export function getReadingsBySession(sessionId: number): BatteryReading[] {
const stmt = db.prepare(`
SELECT
id,
timestamp,
percentage,
voltage,
current,
power,
status,
health,
ac_connected,
created_at
FROM battery_readings
WHERE session_id = ?
ORDER BY timestamp ASC
`);
const rows = stmt.all(sessionId) as any[];
return rows.map(row => ({
...row,
ac_connected: Boolean(row.ac_connected)
}));
}
// Delete old readings (older than specified days)
export function deleteOldReadings(daysToKeep: number = 30) {
const stmt = db.prepare(`
DELETE FROM battery_readings
WHERE created_at < datetime('now', '-' || ? || ' days')
`);
return stmt.run(daysToKeep);
}
// Delete a session and all its associated readings
export function deleteSession(sessionId: number) {
// Delete readings associated with this session
const deleteReadingsStmt = db.prepare(`
DELETE FROM battery_readings
WHERE session_id = ?
`);
deleteReadingsStmt.run(sessionId);
// Delete the session itself
const deleteSessionStmt = db.prepare(`
DELETE FROM monitoring_sessions
WHERE id = ?
`);
return deleteSessionStmt.run(sessionId);
}
export default db;

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

34
tsconfig.json Normal file
View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}