Complete guide to deploying MicroDoser as a web service with REST API for remote control.
┌─────────────────┐ HTTP/REST ┌──────────────────┐
│ Client │ ◄────────────────────────► │ Raspberry Pi 5 │
│ (Any device) │ (WiFi/LAN) │ + MicroDoser │
│ │ │ + FastAPI │
└─────────────────┘ └──────────────────┘
│
▼
┌──────────────┐
│ Hardware │
│ - Balance │
│ - Loader │
│ - CNC │
└──────────────┘
- ✅ Remote control via HTTP REST API
- ✅ Auto-start on boot
- ✅ Persistent service (survives SSH disconnection)
- ✅ Real-time status updates
- ✅ Error handling and feedback
- ✅ Authentication support
- ✅ WebSocket for real-time monitoring (optional)
# SSH into Raspberry Pi
ssh pi@raspberrypi.local
# Install uv (fast Python package installer)
curl -LsSf https://astral.sh/uv/install.sh | sh
# Add to PATH
echo 'export PATH="$HOME/.cargo/bin:$PATH"' >> ~/.bashrc
source ~/.bashrc
# Verify installation
uv --version# Clone repository
cd ~
git clone https://github.com/AccelerationConsortium/dose_every_well.git
cd dose_every_well
# Create virtual environment with uv
uv venv
# Activate environment
source .venv/bin/activate
# Install package with uv (much faster than pip)
uv pip install -e .
# Install web service dependencies
uv pip install fastapi uvicorn python-multipart websockets# Test imports
python -c "from dose_every_well import MicroDoser; print('✓ MicroDoser installed')"
python -c "from fastapi import FastAPI; print('✓ FastAPI installed')"Create the API server file:
nano ~/dose_every_well/api_server.pyPaste the following content:
#!/usr/bin/env python3
"""
MicroDoser Web API Service
FastAPI-based REST API for remote control of MicroDoser system.
"""
import logging
from typing import Optional, Dict, Any
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, BackgroundTasks
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from dose_every_well import MicroDoser, CNCDosingSystem
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler('/home/pi/dose_every_well/api_server.log'),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
# Global state
doser: Optional[MicroDoser] = None
dosing_system: Optional[CNCDosingSystem] = None
system_status = {
"initialized": False,
"plate_loaded": False,
"busy": False,
"error": None
}
# Lifespan context manager for startup/shutdown
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
logger.info("Starting MicroDoser API service...")
try:
await initialize_system()
logger.info("MicroDoser API service ready")
except Exception as e:
logger.error(f"Failed to initialize: {e}")
yield
# Shutdown
logger.info("Shutting down MicroDoser API service...")
await shutdown_system()
logger.info("MicroDoser API service stopped")
# Create FastAPI app
app = FastAPI(
title="MicroDoser API",
description="REST API for MicroDoser precision dosing system",
version="1.0.0",
lifespan=lifespan
)
# Enable CORS for web clients
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Configure appropriately for production
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Request/Response Models
class DoseRequest(BaseModel):
well: str
target_mg: float
verify: bool = True
class DosePlateRequest(BaseModel):
well_targets: Dict[str, float]
verify: bool = True
class SystemConfig(BaseModel):
balance_port: str = "/dev/ttyUSB1"
plate_type: str = "shallow_plate"
cnc_port: Optional[str] = None
# Helper functions
async def initialize_system():
"""Initialize MicroDoser system."""
global doser, dosing_system, system_status
try:
# Initialize CNC dosing system (optional)
try:
dosing_system = CNCDosingSystem(cnc_port='/dev/ttyUSB0')
dosing_system.initialize()
logger.info("CNC dosing system initialized")
except Exception as e:
logger.warning(f"CNC not available: {e}")
dosing_system = None
# Initialize MicroDoser
doser = MicroDoser(
balance_port='/dev/ttyUSB1',
plate_type='shallow_plate',
dosing_system=dosing_system
)
system_status["initialized"] = True
system_status["error"] = None
logger.info("MicroDoser initialized successfully")
except Exception as e:
system_status["error"] = str(e)
logger.error(f"Initialization failed: {e}")
raise
async def shutdown_system():
"""Shutdown MicroDoser system."""
global doser, dosing_system
if doser:
try:
doser.shutdown()
except Exception as e:
logger.error(f"Error during shutdown: {e}")
doser = None
dosing_system = None
system_status["initialized"] = False
def check_system():
"""Check if system is initialized."""
if not system_status["initialized"] or doser is None:
raise HTTPException(status_code=503, detail="System not initialized")
if system_status["busy"]:
raise HTTPException(status_code=409, detail="System busy")
# API Endpoints
@app.get("/")
async def root():
"""Root endpoint - API info."""
return {
"name": "MicroDoser API",
"version": "1.0.0",
"status": "online"
}
@app.get("/api/status")
async def get_status():
"""Get system status."""
try:
if doser:
doser_status = doser.get_status()
system_status.update(doser_status)
return {
"status": "success",
"system_status": system_status
}
except Exception as e:
logger.error(f"Error getting status: {e}")
return {
"status": "error",
"message": str(e)
}
@app.post("/api/initialize")
async def initialize(config: SystemConfig):
"""Initialize or reinitialize system with config."""
try:
await shutdown_system()
await initialize_system()
return {"status": "success", "message": "System initialized"}
except Exception as e:
logger.error(f"Initialization error: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/plate/load")
async def load_plate():
"""Load plate onto balance."""
check_system()
try:
system_status["busy"] = True
doser.load_plate()
system_status["plate_loaded"] = True
system_status["busy"] = False
return {"status": "success", "message": "Plate loaded"}
except Exception as e:
system_status["busy"] = False
logger.error(f"Error loading plate: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/plate/unload")
async def unload_plate():
"""Unload plate from balance."""
check_system()
try:
system_status["busy"] = True
doser.unload_plate()
system_status["plate_loaded"] = False
system_status["busy"] = False
return {"status": "success", "message": "Plate unloaded"}
except Exception as e:
system_status["busy"] = False
logger.error(f"Error unloading plate: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.get("/api/balance/read")
async def read_balance():
"""Read current balance value."""
check_system()
try:
mass_g = doser.read_balance()
mass_mg = mass_g * 1000
return {
"status": "success",
"mass_g": mass_g,
"mass_mg": mass_mg
}
except Exception as e:
logger.error(f"Error reading balance: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/balance/tare")
async def tare_balance():
"""Tare the balance."""
check_system()
try:
doser.tare_balance()
return {"status": "success", "message": "Balance tared"}
except Exception as e:
logger.error(f"Error taring balance: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/dose/well")
async def dose_well(request: DoseRequest):
"""Dose material to a single well."""
check_system()
if not dosing_system:
raise HTTPException(status_code=503, detail="Dosing system not available")
try:
system_status["busy"] = True
result = doser.dose_to_well(
well=request.well,
target_mg=request.target_mg,
verify=request.verify
)
system_status["busy"] = False
return {
"status": "success",
"result": result
}
except Exception as e:
system_status["busy"] = False
logger.error(f"Error dosing well {request.well}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/dose/plate")
async def dose_plate(request: DosePlateRequest, background_tasks: BackgroundTasks):
"""Dose material to multiple wells."""
check_system()
if not dosing_system:
raise HTTPException(status_code=503, detail="Dosing system not available")
try:
system_status["busy"] = True
results = doser.dose_plate(
well_targets=request.well_targets,
verify=request.verify
)
system_status["busy"] = False
return {
"status": "success",
"results": results
}
except Exception as e:
system_status["busy"] = False
logger.error(f"Error dosing plate: {e}")
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/system/shutdown")
async def shutdown():
"""Shutdown the system gracefully."""
try:
await shutdown_system()
return {"status": "success", "message": "System shutdown"}
except Exception as e:
logger.error(f"Error during shutdown: {e}")
raise HTTPException(status_code=500, detail=str(e))
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)Make it executable:
chmod +x ~/dose_every_well/api_server.pyCreate service file:
sudo nano /etc/systemd/system/microdose-api.servicePaste the following:
[Unit]
Description=MicroDoser API Service
After=network.target
[Service]
Type=simple
User=pi
WorkingDirectory=/home/pi/dose_every_well
Environment="PATH=/home/pi/dose_every_well/.venv/bin:/usr/local/bin:/usr/bin:/bin"
ExecStart=/home/pi/dose_every_well/.venv/bin/uvicorn api_server:app --host 0.0.0.0 --port 8000
Restart=always
RestartSec=10
# Logging
StandardOutput=append:/home/pi/dose_every_well/service.log
StandardError=append:/home/pi/dose_every_well/service_error.log
[Install]
WantedBy=multi-user.target# Reload systemd
sudo systemctl daemon-reload
# Enable service (start on boot)
sudo systemctl enable microdose-api.service
# Start service now
sudo systemctl start microdose-api.service
# Check status
sudo systemctl status microdose-api.service
# View logs
sudo journalctl -u microdose-api.service -f# Test from Raspberry Pi
curl http://localhost:8000/
# Test from another device on network
curl http://raspberrypi.local:8000/api/statusGET /api/status
Get current system status.
Response:
{
"status": "success",
"system_status": {
"initialized": true,
"plate_loaded": false,
"busy": false,
"error": null
}
}POST /api/plate/load
Load plate onto balance.
Response:
{
"status": "success",
"message": "Plate loaded"
}POST /api/plate/unload
Unload plate from balance.
GET /api/balance/read
Read current balance value.
Response:
{
"status": "success",
"mass_g": 0.0052,
"mass_mg": 5.2
}POST /api/balance/tare
Tare the balance to zero.
POST /api/dose/well
Dose material to a single well.
Request:
{
"well": "A1",
"target_mg": 5.0,
"verify": true
}Response:
{
"status": "success",
"result": {
"well": "A1",
"target_mg": 5.0,
"actual_mg": 5.2,
"error_mg": 0.2
}
}POST /api/dose/plate
Dose material to multiple wells.
Request:
{
"well_targets": {
"A1": 5.0,
"A2": 3.0,
"B1": 7.0
},
"verify": true
}import requests
# Base URL
BASE_URL = "http://raspberrypi.local:8000"
# Check status
response = requests.get(f"{BASE_URL}/api/status")
print(response.json())
# Load plate
response = requests.post(f"{BASE_URL}/api/plate/load")
print(response.json())
# Dose single well
response = requests.post(
f"{BASE_URL}/api/dose/well",
json={"well": "A1", "target_mg": 5.0, "verify": True}
)
result = response.json()
print(f"Dosed {result['result']['actual_mg']:.2f} mg to A1")
# Unload plate
response = requests.post(f"{BASE_URL}/api/plate/unload")
print(response.json())const BASE_URL = 'http://raspberrypi.local:8000';
// Check status
fetch(`${BASE_URL}/api/status`)
.then(response => response.json())
.then(data => console.log(data));
// Dose well
fetch(`${BASE_URL}/api/dose/well`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
well: 'A1',
target_mg: 5.0,
verify: true
})
})
.then(response => response.json())
.then(data => console.log(data));# Get status
curl http://raspberrypi.local:8000/api/status
# Load plate
curl -X POST http://raspberrypi.local:8000/api/plate/load
# Read balance
curl http://raspberrypi.local:8000/api/balance/read
# Dose well
curl -X POST http://raspberrypi.local:8000/api/dose/well \
-H "Content-Type: application/json" \
-d '{"well": "A1", "target_mg": 5.0, "verify": true}'
# Dose multiple wells
curl -X POST http://raspberrypi.local:8000/api/dose/plate \
-H "Content-Type: application/json" \
-d '{"well_targets": {"A1": 5.0, "A2": 3.0}, "verify": true}'# Check service status
sudo systemctl status microdose-api.service
# View detailed logs
sudo journalctl -u microdose-api.service -n 50
# Check if port is in use
sudo netstat -tulpn | grep 8000
# Test manually
cd ~/dose_every_well
source .venv/bin/activate
python api_server.py# Check if service is running
sudo systemctl status microdose-api.service
# Check firewall
sudo ufw status
sudo ufw allow 8000/tcp
# Test locally first
curl http://localhost:8000/api/status
# Find Raspberry Pi IP
hostname -I# Check hardware connections
i2cdetect -y 1 # Should show 0x40
ls /dev/ttyUSB* # Should show balance and CNC
# Check permissions
groups # Should include dialout, gpio, i2c
# View application logs
tail -f ~/dose_every_well/api_server.log# Stop service
sudo systemctl stop microdose-api.service
# Restart service
sudo systemctl restart microdose-api.service
# Disable auto-start
sudo systemctl disable microdose-api.service
# View real-time logs
sudo journalctl -u microdose-api.service -fInstall dependencies:
uv pip install python-jose passlib python-multipartAdd to api_server.py:
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from fastapi import Depends, HTTPException, status
security = HTTPBearer()
async def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)):
# Implement token verification
if credentials.credentials != "your-secret-token":
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials"
)
return credentials.credentials
# Protect endpoints
@app.post("/api/dose/well")
async def dose_well(request: DoseRequest, token: str = Depends(verify_token)):
# ... endpoint code# Install certbot
sudo apt install certbot
# Generate certificate (requires domain name)
sudo certbot certonly --standalone -d yourdomain.com
# Update service to use HTTPS
# Modify uvicorn command in service file:
# --ssl-keyfile /etc/letsencrypt/live/yourdomain.com/privkey.pem
# --ssl-certfile /etc/letsencrypt/live/yourdomain.com/fullchain.pem- Add WebSocket support for real-time updates
- Implement job queue for long-running operations
- Add database logging of all operations
- Create web dashboard UI
- Implement user authentication and authorization
- Add API rate limiting
- Quick Start - Basic installation
- Python API - Python API reference
- Architecture - System design