html: add control.html and change POST /option interface

This commit is contained in:
Kamil Trzcinski
2023-05-30 11:26:09 +02:00
parent 67832e526c
commit 7cb28d810d
4 changed files with 855 additions and 45 deletions

View File

@ -5,6 +5,8 @@
- libcamera: expose all options with human readable settings
- v4l2: expose all options with human readable settings
- libcamera: do not expose some options that should not be made configurable
- http: add `/control` to provide simple JS interface to live edit camera settings
- http: change `/option` to accept `device=`, `key=`, and `value=`
## Variants

View File

@ -1,19 +1,17 @@
#include "util/http/http.h"
#include "util/opts/opts.h"
#include "util/opts/log.h"
#include "util/opts/fourcc.h"
#include "output/webrtc/webrtc.h"
#include "device/camera/camera.h"
#include "output/output.h"
#include "output/rtsp/rtsp.h"
extern unsigned char html_index_html[];
extern unsigned int html_index_html_len;
extern unsigned char html_webrtc_html[];
extern unsigned int html_webrtc_html_len;
extern unsigned char html_control_html[];
extern unsigned int html_control_html_len;
extern camera_t *camera;
void camera_status_json(http_worker_t *worker, FILE *stream);
extern void camera_status_json(http_worker_t *worker, FILE *stream);
static void http_once(FILE *stream, void (*fn)(FILE *stream, const char *data), void *headersp)
{
@ -25,12 +23,16 @@ static void http_once(FILE *stream, void (*fn)(FILE *stream, const char *data),
}
}
void *camera_http_set_option(http_worker_t *worker, FILE *stream, const char *key, const char *value, void *headersp)
static void camera_post_option(http_worker_t *worker, FILE *stream)
{
if (!camera) {
http_once(stream, http_500, headersp);
fprintf(stream, "No camera attached.\r\n");
return NULL;
char *device_name = http_get_param(worker, "device");
char *key = http_get_param(worker, "key");
char *value = http_get_param(worker, "value");
if (!key || !value) {
http_400(stream, "");
fprintf(stream, "No key or value passed.\r\n");
goto cleanup;
}
bool found = false;
@ -41,46 +43,33 @@ void *camera_http_set_option(http_worker_t *worker, FILE *stream, const char *ke
continue;
}
if (device_name && strcmp(dev->name, device_name)) {
continue;
}
int ret = device_set_option_string(dev, key, value);
if (ret > 0) {
http_once(stream, http_200, headersp);
http_once(stream, http_200, &found);
fprintf(stream, "%s: The '%s' was set to '%s'.\r\n", dev->name, key, value);
} else if (ret < 0) {
http_once(stream, http_500, headersp);
http_once(stream, http_500, &found);
fprintf(stream, "%s: Cannot set '%s' to '%s'.\r\n", dev->name, key, value);
}
found = true;
}
if (found)
return NULL;
http_once(stream, http_404, headersp);
fprintf(stream, "The '%s' was set not found.\r\n", key);
return NULL;
if (!found) {
http_once(stream, http_404, &found);
fprintf(stream, "The option was not found for device='%s', key='%s', value='%s'.\r\n",
device_name, key, value);
}
void camera_http_option(http_worker_t *worker, FILE *stream)
{
bool headers = false;
http_enum_params(worker, stream, camera_http_set_option, &headers);
if (headers) {
fprintf(stream, "---\r\n");
} else {
http_404(stream, "");
fprintf(stream, "No options passed.\r\n");
cleanup:
free(device_name);
free(key);
free(value);
}
fprintf(stream, "\r\nSet: /option?name=value\r\n\r\n");
if (camera) {
for (int i = 0; i < MAX_DEVICES; i++) {
device_dump_options(camera->devices[i], stream);
}
}
}
void http_cors_options(http_worker_t *worker, FILE *stream)
static void http_cors_options(http_worker_t *worker, FILE *stream)
{
fprintf(stream, "HTTP/1.1 204 No Data\r\n");
fprintf(stream, "Access-Control-Allow-Origin: *\r\n");
@ -102,7 +91,9 @@ http_method_t http_methods[] = {
{ "GET", "/video.mp4", http_mp4_video },
{ "GET", "/webrtc", http_content, "text/html", html_webrtc_html, 0, &html_webrtc_html_len },
{ "POST", "/webrtc", http_webrtc_offer },
{ "GET", "/option", camera_http_option },
{ "GET", "/control", http_content, "text/html", html_control_html, 0, &html_control_html_len },
{ "GET", "/option", camera_post_option },
{ "POST", "/option", camera_post_option },
{ "GET", "/status", camera_status_json },
{ "GET", "/", http_content, "text/html", html_index_html, 0, &html_index_html_len },
{ "OPTIONS", "*/", http_cors_options },

819
html/control.html Normal file
View File

@ -0,0 +1,819 @@
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Camera Streamer Web Control</title>
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<style>
body {
font-family: Arial,Helvetica,sans-serif;
background: #181818;
color: #EFEFEF;
font-size: 16px
}
a {
color: #EFEFEF;
text-decoration: underline
}
h2 {
font-size: 18px
}
section.main {
display: flex
}
#menu,section.main {
flex-direction: column
}
#menu {
display: none;
flex-wrap: nowrap;
color: #EFEFEF;
width: 380px;
background: #363636;
padding: 8px;
border-radius: 4px;
margin-top: -6px;
margin-right: 10px;
}
/* #content {
display: flex;
flex-wrap: wrap;
align-items: stretch
}
*/
figure {
padding: 0px;
margin: 0;
margin-block-start: 0;
margin-block-end: 0;
margin-inline-start: 0;
margin-inline-end: 0
}
figure img {
display: block;
max-width: 100%;
width: auto;
height: auto;
border-radius: 4px;
}
figure video {
display: block;
max-width: 100%;
width: auto;
height: auto;
border-radius: 4px;
}
section#buttons {
display: flex;
flex-wrap: nowrap;
justify-content: space-between
}
#logo {
margin-bottom: 10px;
}
#nav-toggle {
cursor: pointer;
display: block;
margin: 3px;
line-height: 28px;
}
#nav-toggle-cb {
outline: 0;
opacity: 0;
width: 0;
height: 0
}
#nav-toggle-cb:checked+#menu {
display: flex
}
#quality {
transform: rotateY(180deg);
}
.input-group {
display: flex;
flex-wrap: nowrap;
line-height: 22px;
margin: 5px 0
}
.input-group>label {
display: inline-block;
padding-right: 10px;
min-width: 47%
}
.input-group input,.input-group select {
flex-grow: 1
}
.input-group>a {
word-break: break-all
}
.range-max,.range-min {
display: inline-block;
padding: 0 5px
}
button {
display: block;
margin: 3px;
padding: 0 8px;
border: 0;
line-height: 28px;
cursor: pointer;
color: #fff;
background: #ff3034;
border-radius: 5px;
font-size: 16px;
outline: 0
}
button:hover {
background: #ff494d
}
button:active {
background: #f21c21
}
button.disabled {
cursor: default;
background: #a0a0a0
}
input[type=range] {
-webkit-appearance: none;
width: 0;
height: 22px;
background: #363636;
cursor: pointer;
margin: 0
}
input[type=range]:focus {
outline: 0
}
input[type=range]::-webkit-slider-runnable-track {
width: 100%;
height: 2px;
cursor: pointer;
background: #EFEFEF;
border-radius: 0;
border: 0 solid #EFEFEF
}
input[type=range]::-webkit-slider-thumb {
border: 1px solid rgba(0,0,30,0);
height: 22px;
width: 22px;
border-radius: 50px;
background: #ff3034;
cursor: pointer;
-webkit-appearance: none;
margin-top: -11.5px
}
input[type=range]:focus::-webkit-slider-runnable-track {
background: #EFEFEF
}
input[type=range]::-moz-range-track {
width: 100%;
height: 2px;
cursor: pointer;
background: #EFEFEF;
border-radius: 0;
border: 0 solid #EFEFEF
}
input[type=range]::-moz-range-thumb {
border: 1px solid rgba(0,0,30,0);
height: 22px;
width: 22px;
border-radius: 50px;
background: #ff3034;
cursor: pointer
}
input[type=range]::-ms-track {
width: 100%;
height: 2px;
cursor: pointer;
background: 0 0;
border-color: transparent;
color: transparent
}
input[type=range]::-ms-fill-lower {
background: #EFEFEF;
border: 0 solid #EFEFEF;
border-radius: 0
}
input[type=range]::-ms-fill-upper {
background: #EFEFEF;
border: 0 solid #EFEFEF;
border-radius: 0
}
input[type=range]::-ms-thumb {
border: 1px solid rgba(0,0,30,0);
height: 22px;
width: 22px;
border-radius: 50px;
background: #ff3034;
cursor: pointer;
height: 2px
}
input[type=range]:focus::-ms-fill-lower {
background: #EFEFEF
}
input[type=range]:focus::-ms-fill-upper {
background: #363636
}
input[type=text] {
border: 1px solid #363636;
font-size: 14px;
height: 20px;
margin: 1px;
outline: 0;
border-radius: 5px
}
.switch {
display: block;
position: relative;
line-height: 22px;
font-size: 16px;
height: 22px
}
.switch input {
outline: 0;
opacity: 0;
width: 0;
height: 0
}
.slider {
width: 50px;
height: 22px;
border-radius: 22px;
cursor: pointer;
background-color: grey
}
.slider,.slider:before {
display: inline-block;
transition: .4s
}
.slider:before {
position: relative;
content: "";
border-radius: 50%;
height: 16px;
width: 16px;
left: 4px;
top: 3px;
background-color: #fff
}
input:checked+.slider {
background-color: #ff3034
}
input:checked+.slider:before {
-webkit-transform: translateX(26px);
transform: translateX(26px)
}
select {
border: 1px solid #363636;
font-size: 14px;
height: 22px;
outline: 0;
border-radius: 5px
}
.image-container {
position: fixed;
min-width: 160px;
margin-right: 16px;
transform-origin: top left;
}
.close {
position: absolute;
z-index: 99;
background: #ff3034;
width: 16px;
height: 16px;
border-radius: 100px;
color: #fff;
text-align: center;
line-height: 18px;
cursor: pointer
}
.close-rot-none {
left: 5px;
top: 5px;
}
.close-rot-left {
right: 5px;
top: 5px;
}
.close-rot-right {
left: 5px;
bottom: 5px;
}
.hidden {
display: none
}
.inline-button {
line-height: 20px;
margin: 2px;
padding: 1px 4px 2px 4px;
}
.loader {
border: 0.5em solid #f3f3f3; /* Light grey */
border-top: 0.5em solid #000000; /* white */
border-radius: 50%;
width: 1em;
height: 1em;
-webkit-animation: spin 2s linear infinite; /* Safari */
animation: spin 2s linear infinite;
}
@-webkit-keyframes spin { /* Safari */
0% { -webkit-transform: rotate(0deg); }
100% { -webkit-transform: rotate(360deg); }
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@media (min-width: 800px) and (orientation:landscape) {
#content {
display:flex;
flex-wrap: nowrap;
align-items: stretch
}
}
</style>
</head>
<body>
<section class="main">
<div id="logo">
<label for="nav-toggle-cb" id="nav-toggle" style="float:left;">&#9776;&nbsp;&nbsp;Settings&nbsp;&nbsp;&nbsp;&nbsp;</label>
<button id="get-still" style="float:left;" disabled>Get Still</button>
<button id="mjpeg-stream" style="float:left;" disabled>Start MJPEG</button>
<button id="webrtc-stream" style="float:left;" disabled>Start WebRTC</button>
<div id="wait-settings" style="float:left;" class="loader" title="Waiting for camera settings to load"></div>
</div>
<div id="content">
<div class="hidden" id="sidebar">
<input type="checkbox" id="nav-toggle-cb" checked="checked">
<nav id="menu">
<!-- <h1>Preferences</h1>
<div class="input-group" id="preferences-group">
<button id="reboot" title="Reboot the camera module">Reboot</button>
<button id="save_prefs" title="Save Preferences on camera module">Save</button>
<button id="clear_prefs" title="Erase saved Preferences on camera module">Erase</button>
</div> -->
<h1>Experimental</h1>
<ul>
<li>Changes are not persisted.</li>
<li>Use <i>--camera-options</i> to persist CAMERA settings.</li>
<li>Use <i>--camera-snapshot.options</i> to persist SNAPSHOT settings.</li>
<li>Use <i>--camera-stream.options</i> to persist STREAM settings.</li>
<li>Use <i>--camera-video.options</i> to persist VIDEO settings.</li>
<li>Restart the <b>camera-streamer</b> service to reset.</li>
<li>Some options might crash the process.</li>
</ul>
<h1 id="anchor">Links</h1>
<div class="input-group hidden" id="snapshot-group">
<label for="snapshot_url">Snapshot</label>
<a id="snapshot_url" class="default-action">Unknown</a>
</div>
<div class="input-group hidden" id="stream-group">
<label for="stream_url">Stream</label>
<a id="stream_url" class="default-action">Unknown</a>
</div>
<div class="input-group hidden" id="video-group">
<label for="video_url">Video</label>
<a id="video_url" class="default-action">Unknown</a>
</div>
<div class="input-group hidden" id="webrtc-group">
<label for="webrtc_url">WebRTC</label>
<a id="webrtc_url" class="default-action">Unknown</a>
</div>
<div class="input-group hidden" id="rtsp-group">
<label for="rtsp_url">RTSP</label>
<a id="rtsp_url" class="default-action">Unknown</a>
</div>
<h1>Release</h1>
<div class="input-group">
<label for="git_version">Version</label>
<div id="git_version" class="default-action">Unknown</div>
</div>
<div class="input-group">
<label for="git_revision">Stream</label>
<div id="git_revision" class="default-action">Unknown</div>
</div>
</nav>
</div>
<figure>
<div id="stream-container" class="image-container hidden">
<div class="close close-rot-none" click="stopStream()">×</div>
<img id="stream-view" src="">
</div>
<div id="video-container" class="image-container hidden">
<div class="close close-rot-none" click="stopStream()">×</div>
<video id="video-view" controls autoplay muted playsinline src="">
</div>
</figure>
</div>
</section>
</body>
<script>
document.addEventListener('DOMContentLoaded', function (event) {
var streamURL = 'Undefined';
var snapshotURL = 'Undefined';
var webrtcURL = 'Undefined';
const header = document.getElementById('logo')
const settings = document.getElementById('sidebar')
const waitSettings = document.getElementById('wait-settings')
const hide = el => {
el.classList.add('hidden')
}
const show = el => {
el.classList.remove('hidden')
}
const show2 = (el, status) => {
if (status)
show(el);
else
hide(el);
}
const enable = el => {
el.classList.remove('disabled')
el.disabled = false
}
const disable = el => {
el.classList.add('disabled')
el.disabled = true
}
const enable2 = (el, status) => {
if (status)
enable(el);
else
disable(el);
}
document
.querySelectorAll('.close')
.forEach(el => {
el.onclick = () => {
hide(el.parentNode)
}
})
const configureEndpoints =(state) => {
snapshotURL = state.endpoints.snapshot.uri;
streamURL = state.endpoints.stream.uri;
webrtcURL = state.endpoints.webrtc.uri;
enable2(document.getElementById('get-still'), state.endpoints.snapshot.enabled);
enable2(document.getElementById('mjpeg-stream'), state.endpoints.stream.enabled);
enable2(document.getElementById('webrtc-stream'), state.endpoints.webrtc.enabled);
document.getElementById('git_version').textContent = state.version;
document.getElementById('git_revision').textContent = state.revision;
for (let type of ["snapshot", "stream", "video", "webrtc", "rtsp"]) {
if (!state.endpoints[type].enabled)
continue;
let groupView = document.getElementById(`${type}-group`);
let urlView = document.getElementById(`${type}_url`);
show(groupView);
urlView.href = urlView.innerHTML = state.endpoints[type].uri;
}
};
const sendOptionValue = (device, key, value) => {
device = encodeURIComponent(device);
key = encodeURIComponent(key);
value = encodeURIComponent(value);
return fetch(`option?device=${device}&key=${key}&value=${value}`, {
method: 'POST'
}).then(function (response) {
return response
});
};
const insertControl = (type) => {
const node = document.createElement(type);
const preferences = document.getElementById("anchor");
preferences.parentNode.insertBefore(node, preferences);
return node;
}
const createDeviceOption = (device, key, option) => {
const id_key = `${device}_${key}`;
const groupEl = insertControl("div");
groupEl.className = "input-group";
const labelEl = document.createElement("label");
labelEl.setAttribute("for", id_key);
labelEl.textContent = option.name;
groupEl.appendChild(labelEl);
switch (option.type) {
case "bool":
const divEl = document.createElement("div");
divEl.className = "switch";
groupEl.appendChild(divEl);
const inputEl = document.createElement("input");
inputEl.id = id_key;
inputEl.type = "checkbox";
inputEl.className = "default-action";
divEl.appendChild(inputEl);
const labelSliderEl = document.createElement("label");
labelSliderEl.setAttribute("for", id_key);
labelSliderEl.className = "slider";
divEl.appendChild(labelSliderEl);
if (option.value)
inputEl.checked = option.value == '1';
inputEl.onclick = () => {
sendOptionValue(device.name, key, inputEl.checked ? 1 : 0);
}
break;
case "integer":
case "integer64":
case "float":
if (option.menu) {
const selectEl = document.createElement("select");
selectEl.className = "default-action";
selectEl.id = id_key;
selectEl.value = option.value;
groupEl.appendChild(selectEl);
if (!option.value) {
const optionEl = document.createElement("option");
optionEl.text = "?";
selectEl.add(optionEl);
}
for (var value in option.menu) {
const optionEl = document.createElement("option");
optionEl.value = value;
optionEl.text = option.menu[value];
selectEl.add(optionEl);
if (optionEl.text == option.value)
selectEl.value = value;
}
selectEl.onchange = () => {
sendOptionValue(device.name, key, selectEl.value);
}
} else if (option.description && (!option.elems || option.elems == 1) && (range = option.description.match("^\\[(.*)\\.\\.(.*)\\]$"))) {
const minValue = Number(range[1]);
const maxValue = Number(range[2]);
let stepValue = (maxValue - minValue) / 20;
if (option.type != "float") {
stepValue = Math.round(stepValue);
if (stepValue < 1)
stepValue = 1;
}
const inputRangeEl = document.createElement("input");
inputRangeEl.id = id_key;
inputRangeEl.type = "range";
inputRangeEl.className = "default-action";
inputRangeEl.min = minValue;
inputRangeEl.max = maxValue;
inputRangeEl.step = stepValue;
inputRangeEl.style = "width: 100px";
if (option.value)
inputRangeEl.value = Number(option.value);
else
inputRangeEl.value = "?";
groupEl.appendChild(inputRangeEl);
const inputNumberEl = document.createElement("input");
inputNumberEl.id = id_key;
inputNumberEl.type = "number";
//inputNumberEl.className = "default-action";
inputNumberEl.className = "range-max";
inputNumberEl.style = "width: 20px";
inputNumberEl.min = minValue;
inputNumberEl.max = maxValue;
inputNumberEl.step = stepValue;
inputNumberEl.size = "4";
if (option.value)
inputNumberEl.value = Number(option.value);
else
inputNumberEl.value = "?";
groupEl.appendChild(inputNumberEl);
inputRangeEl.onchange = () => {
sendOptionValue(device.name, key, inputRangeEl.value);
inputNumberEl.value = inputRangeEl.value;
}
inputNumberEl.onchange = () => {
sendOptionValue(device.name, key, inputNumberEl.value);
inputRangeEl.value = inputNumberEl.value;
}
} else {
const divEl = document.createElement("div");
divEl.className = "text";
groupEl.appendChild(divEl);
const inputEl = document.createElement("input");
inputEl.id = id_key;
inputEl.type = "text";
inputEl.className = "default-action";
if (option.value)
inputEl.value = option.value;
else
inputEl.value = "?";
divEl.appendChild(inputEl);
if (option.description) {
const divDescriptionEl = document.createElement("div");
divDescriptionEl.className = "range-max";
divDescriptionEl.textContent = option.description;
divEl.appendChild(divDescriptionEl);
}
inputEl.onchange = () => {
sendOptionValue(device.name, key, inputEl.value);
}
}
break;
}
}
const createControls = (state) => {
for (var device of state.devices) {
const heading = insertControl("h1");
heading.textContent = device.name;
for (var key in device.options) {
createDeviceOption(device, key, device.options[key]);
}
}
};
var rtcPeerConfig = {
sdpSemantics: 'unified-plan'
};
rtcPeerConnection = new RTCPeerConnection(rtcPeerConfig);
// read initial values
fetch(`status`)
.then(function (response) {
return response.json()
})
.then(function (state) {
hide(waitSettings);
show(settings);
configureEndpoints(state);
createControls(state);
})
const streamContainer = document.getElementById("stream-container");
const videoContainer = document.getElementById("video-container");
const stopStream = () => {
window.stop();
rtcPeerConnection.close();
hide(streamContainer);
hide(videoContainer);
}
document.getElementById('get-still').onclick = () => {
stopStream();
const view = document.getElementById("stream-view");
view.src = `${snapshotURL}?_cb=${Date.now()}`;
view.scrollIntoView(false);
show(streamContainer);
}
document.getElementById('mjpeg-stream').onclick = () => {
stopStream();
const view = document.getElementById("stream-view");
view.src = `${streamURL}?_cb=${Date.now()}`;
view.scrollIntoView(false);
show(streamContainer);
}
document.getElementById('webrtc-stream').onclick = () => {
stopStream();
show(videoContainer);
fetch(webrtcURL, {
body: JSON.stringify({type: 'request'}),
headers: {'Content-Type': 'application/json'},
method: 'POST'
}).then(function(response) {
return response.json();
}).then(function(answer) {
rtcPeerConnection = new RTCPeerConnection(rtcPeerConfig);
rtcPeerConnection.addTransceiver('video', { direction: 'recvonly' });
//pc.addTransceiver('audio', {direction: 'recvonly'});
rtcPeerConnection.addEventListener('track', function(evt) {
console.log("track event " + evt.track.kind);
if (evt.track.kind == 'video') {
const view = document.getElementById("video-view");
view.srcObject = evt.streams[0];
view.scrollIntoView(false);
}
});
rtcPeerConnection.remote_pc_id = answer.id;
return rtcPeerConnection.setRemoteDescription(answer);
}).then(function() {
return rtcPeerConnection.createAnswer();
}).then(function(answer) {
return rtcPeerConnection.setLocalDescription(answer);
}).then(function() {
// wait for ICE gathering to complete
return new Promise(function(resolve) {
if (rtcPeerConnection.iceGatheringState === 'complete') {
resolve();
} else {
function checkState() {
if (rtcPeerConnection.iceGatheringState === 'complete') {
rtcPeerConnection.removeEventListener('icegatheringstatechange', checkState);
resolve();
}
}
rtcPeerConnection.addEventListener('icegatheringstatechange', checkState);
}
});
}).then(function(answer) {
var offer = rtcPeerConnection.localDescription;
return fetch(webrtcURL, {
body: JSON.stringify({
type: offer.type,
id: rtcPeerConnection.remote_pc_id,
sdp: offer.sdp,
}),
headers: { 'Content-Type': 'application/json' },
method: 'POST'
})
}).then(function(response) {
return response.json();
}).catch(function(e) {
alert(e);
});
}
})
</script>
</html>

View File

@ -56,14 +56,12 @@
</li>
<br>
<li>
<a href="option"><b>/option</b></a><br>
<a href="control"><b>/control</b></a><br>
<br>
<ul>
<li>See all configurable options cameras.</li>
<li>See all configurable camera options.</li>
<br>
<li><a href="option?key=value">/option?key=value</a> set <i>key</i> to <i>value</i>.</li>
<br>
<li><a href="option?AfTrigger=1">/option?AfTrigger=1</a> trigger auto focus for ArduCams.</li>
<li>/option?device=CAMERA&key=AfMode&value=auto</a> to set <i>AfMode</i> on <i>CAMERA</i>.</li>
</ul>
</li>
<br>