feat: initial project setup with camera control panel

3D printing project with Ender 3 V3 SE documentation, OctoPrint/Pi
infrastructure docs, and PTZ camera control webapp for two TENVIS
cameras proxied through Raspberry Pi to Frigate NVR.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mortdecai
2026-03-26 19:14:30 -04:00
commit 9daf6acd66
5 changed files with 560 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
# Credentials
octopi.sethpc.xzy_api_key.md
GITEA_API.md
.env
# Symlinked templates
SESSION.default.md
CREATE_PROJECT.md
PI3BPLUS.md
+93
View File
@@ -0,0 +1,93 @@
# 3D Printing Project
## Project Overview
3D printing setup and management for two Creality Ender 3 printers, with OctoPrint remote control and IP camera monitoring via a Raspberry Pi proxy.
## Hardware
### Printer
| Printer | Speed | Extruder | Auto-Level | Build Volume | Hot End |
|---|---|---|---|---|---|
| Creality Ender 3 V3 SE | 250 mm/s | Sprite Direct Drive | CR Touch | 220x220x250 mm | Standard |
### Filament Inventory
| Material | Color | Brand | Diameter |
|---|---|---|---|
| PLA | White | OVERTURE | 1.75 mm |
| PLA | Black | OVERTURE | 1.75 mm |
| PLA | Black | ELEGOO | 1.75 mm |
| PLA | Dark Blue | ELEGOO | 1.75 mm |
| PLA | Transparent | SUNLU | 1.75 mm |
| ABS | Black | Creality | 1.75 mm |
| PETG | Red | Creality | 1.75 mm |
| PETG | Black | Creality | 1.75 mm |
### Accessories
- Creality Hardened Steel MK8 Nozzles (5-pack)
## Remote Printing Infrastructure
### Raspberry Pi 3 B+ (`seth-pi`)
- **Hostname:** seth-pi
- **OS:** Debian 13 (Raspberry Pi OS Lite)
- **User:** seth (key-only SSH, no root login)
- **wlan0:** 192.168.0.102 (main network, DHCP) — default route
- **eth0:** 192.168.0.101/24 (isolated camera router subnet)
- **WiFi SSID:** WiFrei
### OctoPrint
- **URL:** http://octopi.sethpc.xyz (port 5000 on Pi)
- **API key:** see `octopi.sethpc.xzy_api_key.md`
### go2rtc (v1.9.14)
Restreams both cameras with H.264 hardware transcoding and MJPEG fallback.
- **API:** http://192.168.0.102:1984
- **RTSP:** rtsp://192.168.0.102:8554
- **Streams:**
- `cam1` / `cam1_mjpeg` — Camera 1 (192.168.0.100)
- `cam2` / `cam2_mjpeg` — Camera 2 (192.168.0.103)
### IP Cameras — TENVIS (on isolated eth0 subnet)
Both TENVIS cameras use credentials `admin:admin` and expose RTSP on port 554 (`/11` path), HTTP on 80, and ONVIF on 8080.
- **Internal model:** C9F0SeZ0N0P4L0 (Hi3510-based SoC)
- **Firmware:** V9.1.6.1.24-20170925
- **Hardware version:** V1.0.0.1
- **CGI interface:** `/cgi-bin/hi3510/param.cgi`
- **cam2 has an SD card inserted** (59GB)
| Camera | Isolated IP | Proxy Ports (on .102) |
|---|---|---|
| cam1 | 192.168.0.100 | HTTP:18080, ONVIF:18081, RTSP:18554 |
| cam2 | 192.168.0.103 | HTTP:28080, ONVIF:28081, RTSP:28554 |
### Proxy Architecture
The Pi bridges two networks:
- **eth0** (192.168.0.101) connects to an isolated router with the two IP cameras
- **wlan0** (192.168.0.102) connects to the main homelab network
- **socat** systemd services proxy each camera port from the isolated subnet to the main network
- Port scheme: `1xxxx` = cam1, `2xxxx` = cam2
Proxy services (all systemd, auto-restart):
- `ipcam-proxy-http.service` — 18080 -> 192.168.0.100:80
- `ipcam-proxy-onvif.service` — 18081 -> 192.168.0.100:8080
- `ipcam-proxy-rtsp.service` — 18554 -> 192.168.0.100:554
- `ipcam2-proxy-http.service` — 28080 -> 192.168.0.103:80
- `ipcam2-proxy-onvif.service` — 28081 -> 192.168.0.103:8080
- `ipcam2-proxy-rtsp.service` — 28554 -> 192.168.0.103:554
## Camera Control Panel
- **URL:** http://192.168.0.220:8090
- **Location:** `/opt/cam-control/server.py` on CT 241 (Frigate)
- **Service:** `cam-control.service` (systemd, enabled)
- **Features:** Live MJPEG feeds, PTZ D-pad (continuous/step), speed control, flip/mirror toggles
- **Source:** `./cam-control/server.py`
## Gitea
- **Repo:** https://git.sethpc.xyz/Seth/3d-printing
- **Remote:** `https://Seth:REDACTED_GITEA_TOKEN@git.sethpc.xyz/Seth/3d-printing.git`
## Conventions
- Camera credentials: admin/admin (default, isolated network only)
- Pi SSH: key-only as `seth`, no password auth
- Internal password for homelab services: REDACTED_PASSWORD
+70
View File
@@ -0,0 +1,70 @@
I have a Creality Ender 3 V3 SE 3D Printer, 250mm/s Faster FDM 3D Printers with CR Touch Auto Leveling, Sprite Direct Extruder Auto-Load Filament Dual Z-axis & Y-axis, Printing Size 8.66 * 8.66 * 9.84 inch https://www.amazon.com/dp/B0DD7F2BH9?ref_=ppx_hzsearch_conn_dt_b_fed_asin_title_6&th=1
OVERTURE PLA Filament 1.75mm PLA 3D Printer Filament, 1kg Cardboard Spool (2.2lbs), Dimensional Accuracy +/- 0.02mm, Fit Most FDM Printer (White 1-Pack)View order detailsOrdered on November 21, 2024
OVERTURE PLA Filament 1.75mm PLA 3D Printer Filament, 1kg Cardboard Spool (2.2lbs), Dimensional Accuracy +/- 0.02mm, Fit Most FDM Printer (White 1-Pack)
Buy it again
View your item
Creality ABS Filament 1.75mm, 3D Printer Filament, Excellent Resistance, Odorless Non-Toxic, Stability, Tough, 1kg(2.2lbs) Printing Filament for 3D Printer (Black)
View order detailsOrdered on August 9, 2024
Creality ABS Filament 1.75mm, 3D Printer Filament, Excellent Resistance, Odorless Non-Toxic, Stability, Tough, 1kg(2.2lbs) Printing Filament for 3D Printer (Black)
Buy it again
View your item
Official Creality PETG 3D Printer Filament 1.75mm 1KG (2.2lbs), High Precision, Strong Toughness, Odorless, Better Flow, Moistureproof 3D Printing CR PETG Filament, Red
View order detailsOrdered on August 9, 2024
Official Creality PETG 3D Printer Filament 1.75mm 1KG (2.2lbs), High Precision Strong Toughness, Odorless Better Flow Moistureproof 3D Printing Ender Filament(Red)
Buy it again
View your item
Official Creality PETG 3D Printer Filament 1.75mm 1KG (2.2lbs), High Precision, Strong Toughness, Odorless, Better Flow, Moistureproof 3D Printing CR PETG Filament, Black
View order detailsOrdered on August 9, 2024
Official Creality PETG 3D Printer Filament 1.75mm 1KG (2.2lbs), High Precision Strong Toughness, Odorless Better Flow Moistureproof 3D Printing Ender Filament(Black)
Buy it again
View your item
SUNLU 3D Printer Filament PLA Filament 1.75mm, Neatly Wound 3D Printing Filament 1.75mm, Dimensional Accuracy +/- 0.02 mm, Fit Most FDM 3D Printers, 1kg Spool (2.2lbs), Transparent, Clear PLA
View order detailsOrdered on August 9, 2024
SUNLU 3D Printer Filament 1.75mm, Neatly Wound 3D Printing Filament, +/- 0.02 mm Dimensional Accuracy, Fits Most FDM Printers, 1kg Spool, Transparent PLA
Buy it again
View your item
ELEGOO PLA Filament 1.75mm Black 1KG, 3D Printer Filament Dimensional Accuracy +/- 0.02mm, 1kg Cardboard Spool(2.2lbs) 3D Printing Filament Fits for Most FDM 3D Printers
View order detailsOrdered on August 8, 2024
ELEGOO PLA Filament 1.75mm Black 1KG, 3D Printer Filament Dimensional Accuracy +/- 0.02mm, 1kg Cardboard Spool(2.2lbs) 3D Printing Filament Fits for Most FDM 3D Printers
Buy it again
View your item
ELEGOO PLA Filament 1.75mm Dark Blue 1KG, 3D Printer Filament Dimensional Accuracy +/- 0.02mm, 1kg Cardboard Spool(2.2lbs) 3D Printing Filament Fits for Most FDM 3D Printers
View order detailsOrdered on August 8, 2024
ELEGOO PLA Filament 1.75mm Dark Blue 1KG, 3D Printer Filament Dimensional Accuracy +/- 0.02mm, 1kg Cardboard Spool(2.2lbs) 3D Printing Filament Fits for Most FDM 3D Printers
Buy it again
View your item
Creality 5 Packs Hardened Steel MK8 Nozzle for 3D Printers with High Temperature Resistance Upgraded Tungsten All Metal Nozzle Ends for Makerbot Ender 3 Ender-3 pro S1 CR-10 Series
View order detailsOrdered on August 5, 2024
Creality 5 Packs Hardened Steel MK8 Nozzle for 3D Printers with High Temperature Resistance Upgraded Tungsten All Metal Nozzle Ends for Makerbot Ender 3 Ender-3 pro S1 CR-10 Series
Buy it again
View your item
OVERTURE PLA Filament 1.75mm, Neatly Wound 3D Printer Filament 1kg Spool (2.2lbs), Dimensional Accuracy +/- 0.02 mm, Fit Most FDM 3D Printers (Black 1-Pack)
View order detailsOrdered on August 5, 2024
OVERTURE PLA Filament 1.75mm PLA 3D Printer Filament, 1kg Cardboard Spool (2.2lbs), Dimensional Accuracy +/- 0.02mm, Fit Most FDM Printer (Black 1-Pack)
Buy it again
View your item
Creality Ender 3 S1 3D Printer with Direct Drive Extruder CR Touch Auto Leveling High Precision Double Z-axis Screw Silent Board Printing Size 8.6X8.6X10.6in, Upgrade Ender 3 V2 for Beginners
View order detailsOrdered on August 5, 2024
Creality Ender 3 S1 Pro with 200mm/s Printing Speed, Sprite Direct Extruder CR Touch Auto Leveling 300℃ High-Temp Printing, Dual Z-axis Screw Printing Size 8.66 * 8.66 * 10.63''
Buy it again
View your item
+81
View File
@@ -0,0 +1,81 @@
# SESSION.md — 3D Printing Project
## Context Files
- `/root/bin/core_homelab.md` — cluster topology, SSH aliases
- `/root/bin/services_directory.md` — active service IPs and domains
- `/root/bin/SESSION.md` — global session memory
- `./CONTEXT.md` — printer hardware, filament inventory, and accessories
- `./GITEA_API.md` — Gitea push credentials and commit convention
## Project Summary
3D printing setup and management for a Creality Ender 3 homelab. Covers two printers (Ender 3 V3 SE and Ender 3 S1 Pro), filament inventory across PLA/PETG/ABS in multiple colors, and hardened steel MK8 nozzles. The CONTEXT.md file serves as an inventory of purchased hardware and consumables.
## Memory Discipline
- Update `SESSION.md` immediately when a durable fact, decision, or fix is discovered.
- Before every final reply, run a memory check and append any missing durable notes.
- End every reply with one line: `Session memory: updated` or `Session memory: no new durable facts.`
## Hardware
### Printer
| Printer | Speed | Extruder | Auto-Level | Build Volume |
|---|---|---|---|---|
| Creality Ender 3 V3 SE | 250 mm/s | Sprite Direct Drive | CR Touch | 220 x 220 x 250 mm |
### Filament Inventory
| Material | Color | Brand | Diameter |
|---|---|---|---|
| PLA | White | OVERTURE | 1.75 mm |
| PLA | Black | OVERTURE | 1.75 mm |
| PLA | Black | ELEGOO | 1.75 mm |
| PLA | Dark Blue | ELEGOO | 1.75 mm |
| PLA | Transparent | SUNLU | 1.75 mm |
| ABS | Black | Creality | 1.75 mm |
| PETG | Red | Creality | 1.75 mm |
| PETG | Black | Creality | 1.75 mm |
### Accessories
- Creality Hardened Steel MK8 Nozzles (5-pack) — for abrasive filaments
## Gitea
- Repo: https://git.sethpc.xyz/Seth/3d-printing
- Remote: `https://Seth:REDACTED_GITEA_TOKEN@git.sethpc.xyz/Seth/3d-printing.git`
- API key: see `./GITEA_API.md`
## Session Notes
### Initial setup (2026-03-17)
- Created SESSION.md from template in SESSION.default.md
- Organized CONTEXT.md inventory data into structured hardware/filament tables
### Camera/Pi discovery (2026-03-24)
- Pi 3B+ (`seth-pi`) found at 192.168.0.102 (wlan0) / 192.168.0.101 (eth0)
- eth0 connects to isolated router with 2 IP cameras (192.168.0.100, 192.168.0.103)
- 6 socat proxy services bridge camera HTTP/RTSP/ONVIF to main network
- go2rtc v1.9.14 restreams both cameras (H.264 hw + MJPEG)
- OctoPrint running on port 5000
- Camera creds: admin/admin (default, isolated subnet)
- Created CLAUDE.md with full infrastructure documentation
- [x] Document OctoPrint setup — done, documented in CLAUDE.md
- Camera make: TENVIS (both cameras)
### Camera control panel (2026-03-24)
- Built single-page PTZ control webapp (`cam-control/server.py`)
- Deployed to Frigate CT 241 at http://192.168.0.220:8090
- systemd service `cam-control.service` enabled and running
- Features: dual MJPEG feeds via go2rtc, PTZ D-pad, speed, flip/mirror
### OctoPrint timelapse fix (2026-03-24)
- Pi fstab pointed to pve197 for tank SMB — updated to node-173 post-migration
- Created octoprint Samba user on node-173 (creds: octoprint/octoprint)
- Timelapses dir at /tank/Timelapses now accessible, OctoPrint restarted
### Open threads
- [x] Identified camera: TENVIS, internal model C9F0SeZ0N0P4L0 (Hi3510 SoC), firmware V9.1.6.1.24-20170925
- [ ] Add slicer profiles and recommended print settings per filament type
- [ ] Set up Gitea repo and push project files
- [ ] Change default camera passwords (admin/admin)
+307
View File
@@ -0,0 +1,307 @@
#!/usr/bin/env python3
"""PTZ Camera Control Panel for two TENVIS cameras proxied via Raspberry Pi."""
import json
import urllib.request
import urllib.parse
from http.server import HTTPServer, BaseHTTPRequestHandler
from socketserver import ThreadingMixIn
CAMERAS = {
"cam1": {"host": "192.168.0.102", "http_port": 18080, "label": "Printer Cam 1"},
"cam2": {"host": "192.168.0.102", "http_port": 28080, "label": "Printer Cam 2"},
}
GO2RTC = "127.0.0.1:1984"
AUTH = ("admin", "admin")
PORT = 8090
HTML = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Camera Control</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #1a1a2e; color: #e0e0e0; font-family: -apple-system, system-ui, sans-serif; }
h1 { text-align: center; padding: 16px 0 8px; font-size: 1.4rem; color: #c0c0c0; }
.grid { display: flex; flex-wrap: wrap; justify-content: center; gap: 16px; padding: 8px 16px 16px; }
.cam-panel {
background: #16213e; border-radius: 12px; padding: 12px;
flex: 1 1 480px; max-width: 640px; min-width: 320px;
}
.cam-label { text-align: center; font-size: 1.1rem; font-weight: 600; margin-bottom: 8px; color: #a0c4ff; }
.feed { width: 100%; aspect-ratio: 16/9; background: #000; border-radius: 8px; object-fit: contain; }
.controls { display: flex; gap: 12px; margin-top: 10px; align-items: flex-start; justify-content: center; flex-wrap: wrap; }
.dpad {
display: grid; grid-template-columns: repeat(3, 48px); grid-template-rows: repeat(3, 48px);
gap: 4px;
}
.dpad button {
background: #0f3460; border: 1px solid #1a5276; border-radius: 8px; color: #e0e0e0;
font-size: 1.2rem; cursor: pointer; transition: background 0.15s;
display: flex; align-items: center; justify-content: center;
}
.dpad button:hover { background: #1a5276; }
.dpad button:active { background: #2980b9; }
.dpad .center { background: #c0392b; border-color: #e74c3c; font-size: 0.7rem; font-weight: 700; }
.dpad .center:hover { background: #e74c3c; }
.dpad .empty { visibility: hidden; }
.side-controls { display: flex; flex-direction: column; gap: 8px; }
.side-controls label { font-size: 0.8rem; color: #888; }
.side-controls select, .side-controls button {
background: #0f3460; border: 1px solid #1a5276; border-radius: 6px; color: #e0e0e0;
padding: 6px 10px; font-size: 0.85rem; cursor: pointer;
}
.side-controls select:hover, .side-controls button:hover { background: #1a5276; }
.toggle-row { display: flex; gap: 6px; }
.toggle-row button { flex: 1; font-size: 0.8rem; padding: 6px 8px; }
.toggle-row button.active { background: #2980b9; border-color: #3498db; }
.status { text-align: center; font-size: 0.75rem; color: #666; margin-top: 4px; min-height: 1.2em; }
</style>
</head>
<body>
<h1>Camera Control Panel</h1>
<div class="grid" id="grid"></div>
<script>
const CAMS = """ + json.dumps({k: v["label"] for k, v in CAMERAS.items()}) + """;
function buildPanel(camId, label) {
return `
<div class="cam-panel" id="panel-${camId}">
<div class="cam-label">${label}</div>
<img class="feed" id="feed-${camId}" alt="${label} feed">
<div class="controls">
<div class="dpad">
<div class="empty"></div>
<button onmousedown="ptz('${camId}','up',0)" onmouseup="ptz('${camId}','stop',0)" ontouchstart="ptz('${camId}','up',0)" ontouchend="ptz('${camId}','stop',0)">&#9650;</button>
<div class="empty"></div>
<button onmousedown="ptz('${camId}','left',0)" onmouseup="ptz('${camId}','stop',0)" ontouchstart="ptz('${camId}','left',0)" ontouchend="ptz('${camId}','stop',0)">&#9664;</button>
<button class="center" onclick="ptz('${camId}','home',1)">HOME</button>
<button onmousedown="ptz('${camId}','right',0)" onmouseup="ptz('${camId}','stop',0)" ontouchstart="ptz('${camId}','right',0)" ontouchend="ptz('${camId}','stop',0)">&#9654;</button>
<div class="empty"></div>
<button onmousedown="ptz('${camId}','down',0)" onmouseup="ptz('${camId}','stop',0)" ontouchstart="ptz('${camId}','down',0)" ontouchend="ptz('${camId}','stop',0)">&#9660;</button>
<div class="empty"></div>
</div>
<div class="side-controls">
<div>
<label>Step Mode</label>
<select id="step-${camId}">
<option value="0">Continuous (hold)</option>
<option value="1">Single step</option>
</select>
</div>
<div>
<label>Speed</label>
<select id="speed-${camId}" onchange="setSpeed('${camId}')">
<option value="1">1 (slow)</option>
<option value="2" selected>2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5 (fast)</option>
</select>
</div>
<div>
<label>Image</label>
<div class="toggle-row">
<button id="flip-${camId}" onclick="toggleAttr('${camId}','flip')">Flip</button>
<button id="mirror-${camId}" onclick="toggleAttr('${camId}','mirror')">Mirror</button>
</div>
</div>
</div>
</div>
<div class="status" id="status-${camId}"></div>
</div>`;
}
document.getElementById('grid').innerHTML = Object.entries(CAMS).map(([id, label]) => buildPanel(id, label)).join('');
async function ptz(cam, act, step) {
const stepMode = document.getElementById('step-' + cam).value;
const s = step !== undefined ? step : parseInt(stepMode);
const realStep = act === 'stop' ? 0 : (stepMode === '1' ? 1 : s);
try {
const r = await fetch(`/api/${cam}/ptz?act=${act}&step=${realStep}`);
const t = await r.text();
showStatus(cam, `${act} - ${t.trim()}`);
} catch(e) { showStatus(cam, 'Error: ' + e.message); }
}
async function setSpeed(cam) {
const spd = document.getElementById('speed-' + cam).value;
try {
const r = await fetch(`/api/${cam}/speed?pan=${spd}&tilt=${spd}`);
const t = await r.text();
showStatus(cam, `Speed set to ${spd}`);
} catch(e) { showStatus(cam, 'Error: ' + e.message); }
}
async function toggleAttr(cam, attr) {
const btn = document.getElementById(attr + '-' + cam);
const isActive = btn.classList.contains('active');
const val = isActive ? 'off' : 'on';
try {
const r = await fetch(`/api/${cam}/image?${attr}=${val}`);
const t = await r.text();
if (t.includes('ok')) {
btn.classList.toggle('active');
showStatus(cam, `${attr} ${val}`);
} else { showStatus(cam, t.trim()); }
} catch(e) { showStatus(cam, 'Error: ' + e.message); }
}
async function loadImageState(cam) {
try {
const r = await fetch(`/api/${cam}/imageattr`);
const t = await r.text();
const flip = t.match(/flip="(\w+)"/);
const mirror = t.match(/mirror="(\w+)"/);
if (flip && flip[1] === 'on') document.getElementById('flip-' + cam).classList.add('active');
if (mirror && mirror[1] === 'on') document.getElementById('mirror-' + cam).classList.add('active');
const speed = t.match(/panspeed="(\d+)"/);
if (speed) document.getElementById('speed-' + cam).value = speed[1];
} catch(e) {}
}
function showStatus(cam, msg) {
const el = document.getElementById('status-' + cam);
el.textContent = msg;
clearTimeout(el._t);
el._t = setTimeout(() => el.textContent = '', 3000);
}
Object.keys(CAMS).forEach(loadImageState);
// Snapshot polling for live feeds
function startFeed(camId) {
const img = document.getElementById('feed-' + camId);
let running = true;
async function poll() {
if (!running) return;
const next = new Image();
next.onload = () => { img.src = next.src; setTimeout(poll, 200); };
next.onerror = () => { setTimeout(poll, 1000); };
next.src = '/api/' + camId + '/frame?t=' + Date.now();
}
poll();
}
Object.keys(CAMS).forEach(startFeed);
</script>
</body>
</html>"""
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
path = self.path.split("?")[0]
query = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
if path == "/":
self._respond(200, "text/html", HTML)
return
# /api/<cam>/stream -> proxy go2rtc MJPEG
parts = path.strip("/").split("/")
if len(parts) >= 3 and parts[0] == "api" and parts[1] in CAMERAS:
cam_id = parts[1]
cam = CAMERAS[cam_id]
action = parts[2]
if action == "stream":
self._proxy_stream(cam_id)
return
elif action == "frame":
self._proxy_frame(cam_id)
return
elif action == "ptz":
act = query.get("act", ["stop"])[0]
step = query.get("step", ["0"])[0]
resp = self._cam_cgi(cam, f"cmd=ptzctrl&-step={step}&-act={act}")
self._respond(200, "text/plain", resp)
return
elif action == "speed":
pan = query.get("pan", ["2"])[0]
tilt = query.get("tilt", ["2"])[0]
resp = self._cam_cgi(cam, f"cmd=setptzspeed&-panspeed={pan}&-tiltspeed={tilt}")
self._respond(200, "text/plain", resp)
return
elif action == "image":
params = "&".join(f"-{k}={v[0]}" for k, v in query.items())
resp = self._cam_cgi(cam, f"cmd=setimageattr&{params}")
self._respond(200, "text/plain", resp)
return
elif action == "imageattr":
resp = self._cam_cgi(cam, "cmd=getimageattr")
resp2 = self._cam_cgi(cam, "cmd=getptzspeed")
self._respond(200, "text/plain", resp + "\n" + resp2)
return
self._respond(404, "text/plain", "Not found")
def _cam_cgi(self, cam, params):
url = f"http://{cam['host']}:{cam['http_port']}/cgi-bin/hi3510/param.cgi?{params}"
try:
req = urllib.request.Request(url)
cred = f"{AUTH[0]}:{AUTH[1]}"
import base64
req.add_header("Authorization", "Basic " + base64.b64encode(cred.encode()).decode())
with urllib.request.urlopen(req, timeout=5) as resp:
return resp.read().decode()
except Exception as e:
return f"Error: {e}"
def _proxy_frame(self, cam_id):
url = f"http://{GO2RTC}/api/frame.jpeg?src=printer_{cam_id}"
try:
req = urllib.request.Request(url)
with urllib.request.urlopen(req, timeout=5) as resp:
data = resp.read()
self.send_response(200)
self.send_header("Content-Type", "image/jpeg")
self.send_header("Content-Length", str(len(data)))
self.send_header("Cache-Control", "no-cache, no-store")
self.end_headers()
self.wfile.write(data)
except Exception as e:
self._respond(502, "text/plain", f"Frame error: {e}")
def _proxy_stream(self, cam_id):
url = f"http://{GO2RTC}/api/stream.mjpeg?src=printer_{cam_id}"
try:
req = urllib.request.Request(url)
resp = urllib.request.urlopen(req, timeout=10)
content_type = resp.headers.get("Content-Type", "multipart/x-mixed-replace")
self.send_response(200)
self.send_header("Content-Type", content_type)
self.send_header("Cache-Control", "no-cache")
self.end_headers()
while True:
chunk = resp.read(8192)
if not chunk:
break
self.wfile.write(chunk)
except (BrokenPipeError, ConnectionResetError):
pass
except Exception as e:
self._respond(502, "text/plain", f"Stream error: {e}")
def _respond(self, code, content_type, body):
data = body.encode() if isinstance(body, str) else body
self.send_response(code)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def log_message(self, fmt, *args):
pass # suppress request logs
class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):
daemon_threads = True
if __name__ == "__main__":
server = ThreadingHTTPServer(("0.0.0.0", PORT), Handler)
print(f"Camera Control Panel running on http://0.0.0.0:{PORT}")
server.serve_forever()