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
271 lines
7.7 KiB
HTML
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 %}
|