espilon-source/tools/C3PO/templates/cameras.html
Eun0us 8b6c1cd53d ε - ChaCha20-Poly1305 AEAD + HKDF crypto upgrade + C3PO rewrite + docs
Crypto:
- Replace broken ChaCha20 (static nonce) with ChaCha20-Poly1305 AEAD
- HKDF-SHA256 key derivation from per-device factory NVS master keys
- Random 12-byte nonce per message (ESP32 hardware RNG)
- crypto_init/encrypt/decrypt API with mbedtls legacy (ESP-IDF v5.3.2)
- Custom partition table with factory NVS (fctry at 0x10000)

Firmware:
- crypto.c full rewrite, messages.c device_id prefix + AEAD encrypt
- crypto_init() at boot with esp_restart() on failure
- Fix command_t initializations across all modules (sub/help fields)
- Clean CMakeLists dependencies for ESP-IDF v5.3.2

C3PO (C2):
- Rename tools/c2 + tools/c3po -> tools/C3PO
- Per-device CryptoContext with HKDF key derivation
- KeyStore (keys.json) for master key management
- Transport parses device_id:base64(...) wire format

Tools:
- New tools/provisioning/provision.py for factory NVS key generation
- Updated flasher with mbedtls config for v5.3.2

Docs:
- Update all READMEs for new crypto, C3PO paths, provisioning
- Update roadmap, architecture diagrams, security sections
- Update CONTRIBUTING.md project structure
2026-02-10 21:28:45 +01:00

271 lines
7.7 KiB
HTML

{% extends "base.html" %}
{% block title %}Cameras - ESPILON{% endblock %}
{% block content %}
<div class="page-header">
<div class="page-title">Cameras <span>Live Feed</span></div>
<div class="status">
<div class="status-dot"></div>
<span id="camera-count">{{ image_files|length }}</span> camera(s)
</div>
</div>
{% if image_files %}
<div class="grid grid-cameras" id="grid">
{% for img in image_files %}
<div class="card" data-camera-id="{{ img.replace('.jpg', '') }}">
<div class="card-header">
<span class="name">{{ img.replace('.jpg', '').replace('_', ':') }}</span>
<div class="card-actions">
<button class="btn-record" data-camera="{{ img.replace('.jpg', '') }}" title="Start Recording">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="8"/>
</svg>
</button>
<span class="badge badge-live">LIVE</span>
</div>
</div>
<div class="card-body card-body-image">
<img src="/streams/{{ img }}?t=0"
data-src="/streams/{{ img }}"
data-default="/static/images/no-signal.png"
onerror="this.src=this.dataset.default">
</div>
<div class="record-indicator" style="display: none;">
<span class="record-dot"></span>
<span class="record-time">00:00</span>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-cameras">
<div class="no-signal-container">
<img src="/static/images/no-signal.png" alt="No Signal" class="no-signal-img">
<h2>No active cameras</h2>
<p>Waiting for ESP32-CAM devices to send frames on UDP port 5000</p>
</div>
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<style>
.card-actions {
display: flex;
align-items: center;
gap: 8px;
}
.btn-record {
width: 28px;
height: 28px;
border-radius: 50%;
border: none;
background: var(--bg-elevated);
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.btn-record:hover {
background: var(--status-error-bg);
color: var(--status-error);
}
.btn-record.recording {
background: var(--status-error);
color: white;
animation: pulse-record 1.5s infinite;
}
@keyframes pulse-record {
0%, 100% { opacity: 1; }
50% { opacity: 0.6; }
}
.record-indicator {
padding: 8px 16px;
background: var(--bg-elevated);
border-top: 1px solid var(--border-color);
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--status-error);
}
.record-dot {
width: 8px;
height: 8px;
background: var(--status-error);
border-radius: 50%;
animation: pulse-record 1s infinite;
}
.record-time {
font-family: 'JetBrains Mono', monospace;
font-weight: 600;
}
.empty-cameras {
display: flex;
justify-content: center;
align-items: center;
min-height: 60vh;
}
.no-signal-container {
text-align: center;
}
.no-signal-img {
max-width: 300px;
margin-bottom: 24px;
opacity: 0.8;
border-radius: 12px;
}
.no-signal-container h2 {
font-size: 20px;
color: var(--text-primary);
margin-bottom: 8px;
}
.no-signal-container p {
color: var(--text-muted);
font-size: 14px;
}
.card-body-image img {
min-height: 180px;
object-fit: contain;
background: var(--bg-tertiary);
}
</style>
<script>
// Recording state
const recordingState = {};
// Refresh camera images
function refresh() {
const t = Date.now();
document.querySelectorAll('.card-body-image img').forEach(img => {
// Only update if not showing default image
if (!img.src.includes('no-signal')) {
img.src = img.dataset.src + '?t=' + t;
}
});
}
// Check for new/removed cameras
async function checkCameras() {
try {
const res = await fetch('/api/cameras');
const data = await res.json();
const current = document.querySelectorAll('.card').length;
document.getElementById('camera-count').textContent = data.count || 0;
// Update recording states
if (data.cameras) {
data.cameras.forEach(cam => {
updateRecordingUI(cam.id, cam.recording);
});
}
if (data.count !== current) location.reload();
} catch (e) {}
}
// Update recording UI
function updateRecordingUI(cameraId, isRecording) {
const card = document.querySelector(`[data-camera-id="${cameraId}"]`);
if (!card) return;
const btn = card.querySelector('.btn-record');
const indicator = card.querySelector('.record-indicator');
if (isRecording) {
btn.classList.add('recording');
btn.title = 'Stop Recording';
indicator.style.display = 'flex';
// Start timer if not already
if (!recordingState[cameraId]) {
recordingState[cameraId] = { startTime: Date.now() };
}
} else {
btn.classList.remove('recording');
btn.title = 'Start Recording';
indicator.style.display = 'none';
delete recordingState[cameraId];
}
}
// Update recording timers
function updateTimers() {
for (const [cameraId, state] of Object.entries(recordingState)) {
const card = document.querySelector(`[data-camera-id="${cameraId}"]`);
if (!card) continue;
const timeEl = card.querySelector('.record-time');
if (timeEl) {
const elapsed = Math.floor((Date.now() - state.startTime) / 1000);
const mins = Math.floor(elapsed / 60).toString().padStart(2, '0');
const secs = (elapsed % 60).toString().padStart(2, '0');
timeEl.textContent = `${mins}:${secs}`;
}
}
}
// Toggle recording
async function toggleRecording(cameraId) {
const btn = document.querySelector(`[data-camera="${cameraId}"]`);
const isRecording = btn.classList.contains('recording');
try {
const endpoint = isRecording ? 'stop' : 'start';
const res = await fetch(`/api/recording/${endpoint}/${cameraId}`, {
method: 'POST'
});
const data = await res.json();
if (data.error) {
console.error('Recording error:', data.error);
return;
}
updateRecordingUI(cameraId, !isRecording);
if (!isRecording) {
recordingState[cameraId] = { startTime: Date.now() };
}
} catch (e) {
console.error('Recording toggle failed:', e);
}
}
// Event listeners
document.querySelectorAll('.btn-record').forEach(btn => {
btn.addEventListener('click', (e) => {
e.preventDefault();
const cameraId = btn.dataset.camera;
toggleRecording(cameraId);
});
});
// Intervals
setInterval(refresh, 100);
setInterval(checkCameras, 5000);
setInterval(updateTimers, 1000);
// Initial check
checkCameras();
</script>
{% endblock %}