camera - (real) remote control, brandschutz - configurable course, dashboard - vbb licensed API (a real vbb license, how cool)

This commit is contained in:
2026-09-08 18:21:51 +02:00
parent 3f98cf2663
commit ce90820094
24 changed files with 3044 additions and 2065 deletions
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
require_once $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/general_files/initialise.php';
require_once __DIR__ . '/util/camera_remote_helpers.php';
header('Content-Type: application/json; charset=utf-8');
function camera_remote_response(int $status, array $payload): never
{
http_response_code($status);
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
camera_remote_response(405, ['ok' => false, 'error' => 'Methode nicht erlaubt.']);
}
if (!camera_remote_is_authorized(is_array($groups ?? null) ? $groups : [])) {
camera_remote_response(403, ['ok' => false, 'error' => 'Keine Berechtigung für die Kamera-Fernbedienung.']);
}
try {
camera_remote_verify_csrf($_POST['csrf_token'] ?? null);
$action = is_string($_POST['action'] ?? null) ? $_POST['action'] : '';
camera_remote_run($action);
camera_remote_response(200, ['ok' => true]);
} catch (InvalidArgumentException $exception) {
camera_remote_response(400, ['ok' => false, 'error' => $exception->getMessage()]);
} catch (RuntimeException $exception) {
camera_remote_response(503, ['ok' => false, 'error' => $exception->getMessage()]);
} catch (Throwable $exception) {
error_log('Camera Remote: ' . $exception->getMessage());
camera_remote_response(500, ['ok' => false, 'error' => 'Der Kamerabefehl konnte nicht ausgeführt werden.']);
}
@@ -0,0 +1,104 @@
.camera-remote-layout {
max-width: 1100px;
}
.camera-remote-main {
min-height: 0;
}
.camera-remote-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-xl);
margin-bottom: var(--space-lg);
}
.camera-remote-heading h1 {
margin-bottom: var(--space-sm);
}
.camera-remote-heading p {
margin: 0;
color: var(--gray-600);
}
.camera-power-control {
min-width: 180px;
}
.camera-remote .camera-power-control .button {
margin: 0;
}
.camera-remote .mainpage_notice {
margin-bottom: var(--space-lg);
}
.camera-sections {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-md);
}
.camera-remote-panel {
min-height: 0;
padding: var(--space-lg);
}
.camera-remote-panel h2 {
margin-bottom: var(--space-md);
font-size: var(--font-size-lg);
}
.camera-presets {
display: grid;
gap: var(--space-sm);
}
.camera-remote .camera-presets .button_fullwidth {
margin: 0;
}
.camera-remote .camera-presets .camera-preset-active {
background: var(--theme-hover);
box-shadow: inset 5px 0 var(--nak-blue), var(--shadow-md);
}
.camera-remote .button_fullwidth:disabled {
opacity: 0.45;
cursor: not-allowed;
}
@media (max-width: 768px) {
#base.camera-remote {
padding: 0;
}
.camera-remote-heading {
align-items: flex-start;
}
.camera-sections {
grid-template-columns: 1fr;
}
}
@media (max-width: 480px) {
.camera-remote-layout {
padding: var(--space-md);
}
.camera-remote-heading {
flex-direction: column;
gap: var(--space-md);
}
.camera-power-control {
width: 100%;
}
.camera-remote-panel {
padding: var(--space-md);
}
}
@@ -0,0 +1,80 @@
(function () {
'use strict';
var configElement = document.getElementById('cameraRemoteConfig');
if (!configElement) return;
var config = JSON.parse(configElement.textContent);
var presets = Array.from(document.querySelectorAll('.camera-presets [data-camera-action]'));
var power = document.querySelector('[data-camera-action="power"]');
var status = document.getElementById('cameraRemoteStatus');
var busy = false;
presets.forEach(function (preset) { preset.setAttribute('aria-pressed', 'false'); });
function showError(message) {
status.textContent = message;
status.hidden = false;
}
function clearError() {
status.textContent = '';
status.hidden = true;
}
function setBusy(active) {
busy = active;
presets.forEach(function (preset) { preset.disabled = active || !config.configured; });
power.disabled = active || !config.configured;
}
function selectPreset(selected) {
presets.forEach(function (preset) {
var active = preset === selected;
preset.classList.toggle('camera-preset-active', active);
preset.setAttribute('aria-pressed', String(active));
});
}
async function request(action) {
var form = new FormData();
form.append('csrf_token', config.csrfToken);
form.append('action', action);
var response = await fetch('/intranet_repo/projects/camera_remote/api.php', {
method: 'POST',
body: form,
headers: {'Accept': 'application/json'},
});
var result;
try {
result = await response.json();
} catch (parseError) {
throw new Error('Der Server hat unerwartet geantwortet. Bitte laden Sie die Seite neu.');
}
if (!response.ok || !result.ok) throw new Error(result.error || 'Der Kamerabefehl ist fehlgeschlagen.');
}
async function runAction(control) {
if (busy || !config.configured) return;
clearError();
if (control === power) selectPreset(null);
setBusy(true);
try {
await request(control.dataset.cameraAction);
if (control !== power) {
selectPreset(control);
}
} catch (error) {
showError(error.message || 'Der Kamerabefehl konnte nicht ausgeführt werden.');
} finally {
setBusy(false);
}
}
presets.forEach(function (preset) {
preset.addEventListener('click', function () { runAction(preset); });
});
power.addEventListener('click', function () { runAction(power); });
}());
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
require_once $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/general_files/initialise.php';
require_once __DIR__ . '/util/camera_remote_helpers.php';
if (!camera_remote_is_authorized(is_array($groups ?? null) ? $groups : [])) {
http_response_code(403);
exit('Keine Berechtigung für die Kamera-Fernbedienung.');
}
$configured = camera_remote_is_configured();
$clientConfig = [
'csrfToken' => camera_remote_csrf_token(),
'configured' => $configured,
];
?>
<!DOCTYPE html>
<html lang="de" dir="ltr">
<head>
<?php require_once $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/projects/_central/php/html_head.php'; ?>
<link rel="stylesheet" href="/intranet_repo/projects/camera_remote/camera_remote.css?v=<?= (int) filemtime(__DIR__ . '/camera_remote.css') ?>">
<script id="cameraRemoteConfig" type="application/json"><?= json_encode($clientConfig, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_THROW_ON_ERROR) ?></script>
<script src="/intranet_repo/projects/camera_remote/camera_remote.js?v=<?= (int) filemtime(__DIR__ . '/camera_remote.js') ?>" defer></script>
</head>
<body class="theme-admin">
<?php require_once $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/projects/_central/php/header.php'; ?>
<?php include $g_phpSnippetsPath . 'overlay_message.php'; ?>
<div class="header_title ht_admin text_white" id="header_title">
<span class="header_title_text">Kamera-Fernbedienung</span>
</div>
<main class="content camera-remote" id="base">
<div class="content_flexcontainer camera-remote-layout">
<section class="content_panel camera-remote-main" aria-labelledby="cameraRemoteTitle">
<div class="camera-remote-heading">
<div>
<h1 id="cameraRemoteTitle">Kamera steuern</h1>
<p>Wählen Sie eine Kameraposition oder schalten Sie die Kamera ein beziehungsweise aus.</p>
</div>
<div class="camera-power-control">
<input class="button" type="button" data-camera-action="power" value="Kamera ein/aus"<?= $configured ? '' : ' disabled' ?>>
</div>
</div>
<?php $statusText = $configured ? '' : 'Home Assistant ist noch nicht konfiguriert. Bitte HA_BASE_URL und HA_TOKEN in der Umgebung hinterlegen.'; ?>
<div class="mainpage_notice mainpage_notice--error" id="cameraRemoteStatus" role="alert"<?= $configured ? ' hidden' : '' ?>>
<?= htmlspecialchars($statusText, ENT_QUOTES, 'UTF-8') ?>
</div>
<div class="camera-sections" aria-label="Kamerapositionen">
<section class="content_panel camera-remote-panel" aria-labelledby="cameraGeneralTitle">
<h2 id="cameraGeneralTitle">Allgemein</h2>
<div class="camera-presets">
<input class="button_fullwidth" type="button" data-camera-action="total" value="Totale"<?= $configured ? '' : ' disabled' ?>>
<input class="button_fullwidth" type="button" data-camera-action="lectern" value="Pult"<?= $configured ? '' : ' disabled' ?>>
</div>
</section>
<section class="content_panel camera-remote-panel" aria-labelledby="cameraAltarTitle">
<h2 id="cameraAltarTitle">Altar</h2>
<div class="camera-presets">
<input class="button_fullwidth" type="button" data-camera-action="altar" value="Altar"<?= $configured ? '' : ' disabled' ?>>
<input class="button_fullwidth" type="button" data-camera-action="chancel" value="Altarraum"<?= $configured ? '' : ' disabled' ?>>
<input class="button_fullwidth" type="button" data-camera-action="before_altar" value="Vor dem Altar"<?= $configured ? '' : ' disabled' ?>>
<input class="button_fullwidth" type="button" data-camera-action="flowers" value="Blumenschmuck"<?= $configured ? '' : ' disabled' ?>>
</div>
</section>
<section class="content_panel camera-remote-panel" aria-labelledby="cameraMusicTitle">
<h2 id="cameraMusicTitle">Musik</h2>
<div class="camera-presets">
<input class="button_fullwidth" type="button" data-camera-action="choir" value="Chor"<?= $configured ? '' : ' disabled' ?>>
<input class="button_fullwidth" type="button" data-camera-action="piano" value="Klavier"<?= $configured ? '' : ' disabled' ?>>
<input class="button_fullwidth" type="button" data-camera-action="organ" value="Orgel"<?= $configured ? '' : ' disabled' ?>>
<input class="button_fullwidth" type="button" data-camera-action="orchestra" value="Orchester/Kinderchor"<?= $configured ? '' : ' disabled' ?>>
</div>
</section>
</div>
</section>
</div>
</main>
<?php require_once $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/projects/_central/php/footer.php'; ?>
</body>
</html>
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
const CAMERA_REMOTE_ACTIONS = [
'power' => ['entity_id' => 'script.kamera_on_off', 'calls' => 1],
'total' => ['entity_id' => 'script.kamera_totale', 'calls' => 2],
'lectern' => ['entity_id' => 'script.kamera_pult', 'calls' => 2],
'altar' => ['entity_id' => 'script.kamera_altar', 'calls' => 2],
'chancel' => ['entity_id' => 'script.kamera_altarraum', 'calls' => 2],
'before_altar' => ['entity_id' => 'script.kamera_vor_dem_altar', 'calls' => 2],
'flowers' => ['entity_id' => 'script.kamera_blumenschmuck', 'calls' => 2],
'choir' => ['entity_id' => 'script.kamera_chor', 'calls' => 2],
'piano' => ['entity_id' => 'script.kamera_klavier', 'calls' => 2],
'organ' => ['entity_id' => 'script.kamera_orgel', 'calls' => 2],
'orchestra' => ['entity_id' => 'script.kamera_orchester_kinderchor', 'calls' => 2],
];
function camera_remote_is_authorized(array $groups): bool
{
return !empty($groups['Technik']) || !empty($groups['Admin']);
}
function camera_remote_csrf_token(): string
{
return $_SESSION['camera_remote_csrf_token'] ??= bin2hex(random_bytes(32));
}
function camera_remote_verify_csrf(mixed $token): void
{
$sessionToken = $_SESSION['camera_remote_csrf_token'] ?? '';
if (!is_string($token) || !is_string($sessionToken) || $sessionToken === '' || !hash_equals($sessionToken, $token)) {
throw new InvalidArgumentException('Ungültige Anfrage. Bitte laden Sie die Seite neu.');
}
}
/** @return array{base_url: string, token: string} */
function camera_remote_config(): array
{
$baseUrl = rtrim(trim((string) getenv('HA_BASE_URL')), '/');
$token = trim((string) getenv('HA_TOKEN'));
$scheme = strtolower((string) parse_url($baseUrl, PHP_URL_SCHEME));
$host = parse_url($baseUrl, PHP_URL_HOST);
if ($baseUrl === '' || $token === '') {
throw new RuntimeException('Home Assistant ist noch nicht konfiguriert.');
}
if (!in_array($scheme, ['http', 'https'], true) || !is_string($host) || $host === '') {
throw new RuntimeException('Die Home-Assistant-Adresse ist ungültig.');
}
return ['base_url' => $baseUrl, 'token' => $token];
}
function camera_remote_is_configured(): bool
{
try {
camera_remote_config();
return true;
} catch (RuntimeException) {
return false;
}
}
function camera_remote_request(string $path, ?array $payload = null): void
{
$config = camera_remote_config();
$handle = curl_init($config['base_url'] . $path);
if ($handle === false) {
throw new RuntimeException('Die Verbindung zu Home Assistant konnte nicht vorbereitet werden.');
}
$options = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $config['token'],
'Content-Type: application/json',
'Accept: application/json',
],
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_TIMEOUT => 8,
CURLOPT_FOLLOWLOCATION => false,
];
if ($payload !== null) {
$options[CURLOPT_POST] = true;
$options[CURLOPT_POSTFIELDS] = json_encode($payload, JSON_THROW_ON_ERROR);
}
curl_setopt_array($handle, $options);
try {
$response = curl_exec($handle);
$status = (int) curl_getinfo($handle, CURLINFO_HTTP_CODE);
$error = curl_error($handle);
} finally {
curl_close($handle);
}
if ($response === false || $status < 200 || $status >= 300) {
error_log('Camera Remote: Home Assistant antwortete mit ' . ($error !== '' ? $error : 'HTTP ' . $status));
throw new RuntimeException('Home Assistant konnte den Kamerabefehl nicht ausführen.');
}
try {
json_decode($response, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $exception) {
throw new RuntimeException('Home Assistant hat unerwartet geantwortet.', 0, $exception);
}
}
function camera_remote_run(string $action): void
{
$command = CAMERA_REMOTE_ACTIONS[$action] ?? null;
if (!is_array($command)) {
throw new InvalidArgumentException('Unbekannter Kamerabefehl.');
}
for ($call = 0; $call < $command['calls']; $call++) {
camera_remote_request('/api/services/script/turn_on', ['entity_id' => $command['entity_id']]);
}
}