camera - (real) remote control, brandschutz - configurable course, dashboard - vbb licensed API (a real vbb license, how cool)
This commit is contained in:
@@ -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']]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user