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:
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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)
|
||||||
@@ -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)">▲</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)">◀</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)">▶</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)">▼</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()
|
||||||
Reference in New Issue
Block a user