1
0

Erster Commit

This commit is contained in:
Anna Christina Naß 2025-07-07 16:25:22 +02:00
parent 3b9e7bb194
commit ce5baaac35
23 changed files with 671 additions and 2 deletions

View File

@ -1,3 +1,95 @@
# plweb # PlusLifeWeb
Dieses kleine PHP-Projekt bietet die Möglichkeit, Tests, die mit einem
[Pluslife-Gerät](https://altruan.de/pages/pluslife-pcr) und dem
[Pluslife Analyzer](https://virus.sucks/) durchgeführt wurden, zu speichern
und später nochmals anzuschauen.
Außerdem können Benachrichtigungen via Webhook an [Home Assistant](https://www.home-assistant.io/)
geschickt werden, um diese weiter zu verarbeiten.
Hierfür wurden folgende Komponenten benutzt:
* [Pluslife Analyzer Displayer von sistason](https://github.com/sistason/pluslife_analyzer_displayer/tree/main):
Dieses Projekt war der Ausgangspunkt für PlusLifeWeb
* jQuery
* Bootstrap
* Twig
* Chart.js
* PostgreSQL als Datenbank
**Wichtig:**
Es wird dringend davon abgeraten, PlusLifeWeb öffentlich zugänglich zu machen, da keinerlei Authentifizierung
implementiert ist! Jede Person, die PlusLifeWeb aufrufen kann, kann die Testergebnisse anschauen und auch
neue per Webhook hinzufügen!
## Installation
PlusLifeWeb wurde unter Devuan 12 "Bookworm" entwickelt und getestet. Daher sollte es auch unter
Debian 12 funktionieren.
Grundsätzlich wird ein Webserver, z.B. Apache, benötigt.
Die Verbindung zum Webserver muß per HTTPS möglich sein, damit der Webhook von virus.sucks funktioniert.
Folgende PHP-Pakete werden benötigt:
* php-pgsql
* php-twig
* php-json
* php-curl
Zur Installation genügt es, die Dateien in einem Webserver-Verzeichnis abzulegen, in dem PHP funktioniert.
### Datenbank
In PostgreSQL wird eine Datenbanktabelle für die Tests benötigt.
Die Datenbank wird als Benutzer *postgres* angelegt (Als root: `su - postgres`).
```
# createdb plweb
```
Anschließend, weiterhin als Benutzer postgres, das Kommando `psql` aufrufen:
```
# psql plweb
```
Mit folgenden SQL-Kommandos wird dann die Tabelle sowie ein Benutzer angelegt:
```sql
CREATE TABLE tests (
id SERIAL PRIMARY KEY,
timestamp TIMESTAMP NOT NULL,
data JSONB
);
CREATE USER plweb WITH ENCRYPTED PASSWORD 'plweb';
GRANT ALL PRIVILEGES ON DATABASE PLWEB TO plweb;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA PUBLIC TO plweb;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA PUBLIC TO plweb;
```
Das Paßwort ist hier nur *plweb* - es sollte besser ein anderes benutzt werden.
### Konfiguration
In der Datei `config.php` können folgende Parameter eingestellt werden:
```php
$hawebhook = "http://homeassistant:8123/webhook/plweb";
$dbconnstring = "host=localhost dbname=plweb user=plweb password=plweb";
$test_url = "https://example.com/plweb/test.php?";
```
Die Variable `hawebhook` enthält die URL des Home Assistant-Webhooks, so wie dieser
von PlusLifeWeb aus (serverseitig) aufgerufen wird.
Die Datenbankkonfiguration ist in `dbconnstring` enthalten. Hier ggf. das Paßwort anpassen.
Die `test_url` zeigt zum Script test.php der PlusLifeWeb-Installation.
Diese URL wird, zusammen mit der ID des fertigen Tests, als Benachrichtigung an Home
Assistant übertragen.
PlusLifeWeb

7
config.php Normal file
View File

@ -0,0 +1,7 @@
<?php
$hawebhook = "http://homeassistant:8123/webhook/plweb"; // Webhook-URL von Home Assistant
$dbconnstring = "host=localhost dbname=plweb user=plweb password=plweb";
$test_url = "https://example.com/plweb/test.php?";
?>

6
css/bootstrap.min.css vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

37
doc/HomeAssistant.md Normal file
View File

@ -0,0 +1,37 @@
```
alias: Webhook Pluslife
mode: single
triggers:
- trigger: webhook
allowed_methods:
- POST
local_only: true
webhook_id: plweb
conditions: []
actions:
- if:
- condition: template
value_template: "{% if trigger.json.url is defined %}true{% endif %}"
then:
- action: notify.familie
metadata: {}
data:
message: |-
{{ trigger.json.message }}
Klicken, um den Test anzusehen.
data:
clickAction: "{{ trigger.json.url }}"
group: plweb
title: PlusLifeWeb
else:
- action: notify.familie
metadata: {}
data:
message: "{{ trigger.json.message }}"
title: PlusLifeWeb
data:
group: plweb
```

42
functions.php Normal file
View File

@ -0,0 +1,42 @@
<?php
/**
* An example CORS-compliant method. It will allow any GET, POST, or OPTIONS requests from any
* origin.
*
* In a production environment, you probably want to be more restrictive, but this gives you
* the general idea of what is involved. For the nitty-gritty low-down, read:
*
* - https://developer.mozilla.org/en/HTTP_access_control
* - https://fetch.spec.whatwg.org/#http-cors-protocol
*
*/
function cors() {
// Allow from any origin
header("Access-Control-Allow-Origin: *");
header('Access-Control-Max-Age: 86400'); // cache for 1 day
header("Access-Control-Allow-Methods: GET, POST, OPTIONS");
header("Access-Control-Request-Headers: content-type");
// Access-Control headers are received during OPTIONS requests
if ($_SERVER['REQUEST_METHOD'] == 'OPTIONS') {
// header("Access-Control-Allow-Methods: GET, POST, OPTIONS");
// header("Access-Control-Request-Headers: content-type");
exit(0);
}
}
function return_success() {
return json_encode(['status' => 'success', 'message' => 'OK']);
}
function return_error($code, $msg) {
http_response_code($code);
return json_encode(['status' => 'error', 'message' => $msg]);
}
function twig_error($twig, $code, $msg) {
http_response_code($code);
return $twig->render('error.html.twig', [ 'error' => $msg ]);
}
?>

27
index.php Normal file
View File

@ -0,0 +1,27 @@
<?php
require_once 'Twig/autoload.php';
require_once 'config.php';
require_once 'functions.php';
use Twig\Environment;
use Twig\Loader\FilesystemLoader;
$loader = new FilesystemLoader(__DIR__ . '/templates');
$twig = new Environment($loader);
// --------------
$dbconn = pg_connect($dbconnstring)
or die(twig_error($twig, 'Kann Verbindung zur Datenbank nicht herstellen.'));
$query = "SELECT id,timestamp FROM tests ORDER BY timestamp DESC";
$result = pg_query($dbconn, $query)
or die(twig_error($twig, 'Datenbankfehler: ' . pg_last_error()));
$tests = pg_fetch_all($result);
pg_close($dbconn);
echo $twig->render('index.html.twig', [ 'tests' => $tests ]);
?>

7
js/bootstrap.min.js vendored Normal file

File diff suppressed because one or more lines are too long

1
js/bootstrap.min.js.map Normal file

File diff suppressed because one or more lines are too long

14
js/chart.umd.js Normal file

File diff suppressed because one or more lines are too long

1
js/chart.umd.js.map Normal file

File diff suppressed because one or more lines are too long

2
js/jquery.min.js vendored Normal file

File diff suppressed because one or more lines are too long

115
js/pluslife.js Normal file
View File

@ -0,0 +1,115 @@
var chart;
var channel_colors = {
"0": {"color": "#a6cee3", "name": "Channel 1"},
"1": {"color": "#1f78b4", "name": "Channel 2"},
"2": {"color": "#b2df8a", "name": "Channel 3"},
"3": {"color": "#33a02c", "name": "Control Channel (4)"},
"4": {"color": "#fb9a99", "name": "Channel 5"},
"5": {"color": "#e31a1c", "name": "Channel 6"},
"6": {"color": "#fdbf6f", "name": "Channel 7"}
};
var channel_time_data = {
"0": new Map(),
"1": new Map(),
"2": new Map(),
"3": new Map(),
"4": new Map(),
"5": new Map(),
"6": new Map()
};
var labels = [];
var datasets = {};
for (const [channel, time_values] of Object.entries(channel_time_data)) {
datasets[channel] = {
label: channel_colors[channel].name,
borderColor: channel_colors[channel].color,
fill: false,
cubicInterpolationMode: 'monotone',
tension: 0.4,
channel: channel,
data: []
}
//TODO: gaps?
for (const [time, value] of time_values.entries()) {
if (! labels.includes(time)) labels.push(time);
datasets[channel].data.push(value);
}
}
var config = {
type: 'line',
data: {
labels: labels,
datasets: Object.values(datasets)
},
options: {
responsive: true,
maintainAspectRatio: false,
aspectRatio: 2,
interaction: {
intersect: false,
},
plugins: {
legend: {
labels: {
boxWidth: 10
}
}
},
scales: {
x: {
display: true,
title: {
display: true,
text: "Test Time [min:sec]"
}
},
y: {
display: true,
//suggestedMin: -10,
//suggestedMax: 200
}
},
elements: {
point: {
radius: 2,
}
}
},
};
function update_chart(obj){
for (const [channel, data] of Object.entries(obj)) {
for (var [time, value] of Object.entries(data)) {
time = Number(time); // js json keys are always strings...
channel_time_data[channel].set(time, value);
if (! chart.data.labels.includes(time)) {
chart.data.labels.push(time);
var progress_html = document.getElementById('status').getElementsByTagName('progress')[0];
time_remaining = 35*60 - time;
minutes_remaining = (time_remaining/60).toFixed(0);
seconds_remaining = (time_remaining%60);
human_readable_remaining = `${minutes_remaining}:${seconds_remaining} min`;
progress_html.innerHTML = human_readable_remaining;
progress_html.value = time;
document.getElementById('timeremaining').innerHTML = `${human_readable_remaining} remaining`;
}
chart.data.datasets.forEach((dataset) => {
if (dataset.channel == channel)
dataset.data.push(value);
});
}
}
if (obj) chart.update();
}
(function() {
chart = new Chart(document.getElementById('data'), config);
})();

98
js/plweb.js Normal file
View File

@ -0,0 +1,98 @@
function get_human_readable_time(time){
minutes = (time > 60) ? Math.floor(time/60) : 0;
seconds = (time%60).toFixed(0);
return `${minutes}:${seconds}`;
}
function result_text(result) {
const result_enum = {1: 'Negative', 2: 'Positive', 3: 'Invalid'};
if (Number.isInteger(result)) return result_enum[Number(result)];
else return result.toLowerCase().replace(/\b\w/g, s => s.toUpperCase());
}
function result_color(result) {
const result_color = {1: 'success', 2: 'danger', 3: 'dark'};
if (Number.isInteger(result)) return result_color[Number(result)];
else {
switch (result.toLowerCase()) {
case "negative":
return result_color[1];
break;
case "positive":
return result_color[2];
break;
default: // includes invalid
return result_color[3];
break;
}
}
}
function parse_and_show_pluslife_result(overall_result, channel_results){
$("#testresult").append("Pluslife says: " + result_text(overall_result));
channel_results_html = '';
for (const [channel, data] of Object.entries(channel_colors)) {
var result = result_text(channel_results[Number(channel)]);
var color = result_color(channel_results[Number(channel)]);
if (channel == "3"){
result = (result == "Positive") ? "Detected" : "Not Detected";
color = (result == "Detected") ? "primary" : color;
}
channel_results_html += `<span class="badge rounded-pill text-bg-${color} me-1">${channel}: ${result.slice(0,3)}</span>`;
}
$("#testresult_channels").append(channel_results_html);
}
function update_chart(timestamp, overall_result, result_channels, sampledata){
$("#testdate").append(new Date(timestamp).toUTCString());
if (overall_result || result_channels)
parse_and_show_pluslife_result(overall_result, result_channels);
document.getElementById('datacontainer').hidden = false;
chart.data.labels.length = 0;
chart.data.datasets.forEach((dataset) => {
dataset.data.length = 0;
});
var offset = -1;
var data_index = 0;
var filled_array = Array(1000).fill(-1);
sampledata.forEach((sample) => {
var human_readable_time = get_human_readable_time(Math.floor(sample.samplingTime/10));
if (! chart.data.labels.includes(human_readable_time)){
chart.data.labels.push(human_readable_time);
offset += 1;
}
data_index = offset*7 + sample.startingChannel;
filled_array[data_index] = sample.firstChannelResult/64;
chart.data.datasets.forEach((dataset) => {
if (dataset.channel == sample.startingChannel)
dataset.data.push(sample.firstChannelResult);
});
});
chart.update();
}
function show_error(text) {
console.log(text);
$("#error").append(text);
$("#error").show();
}
(function() {
if (data != null && typeof data == 'object') {
update_chart(Date.parse(json.test.data.temperatureSamples[0].time),
json.test.result.detectionResult,
json.test.result.channelResults,
json.test.data.samples);
} else {
show_error("Konnte Testdaten nicht laden");
}
})();

36
templates/base.html.twig Normal file
View File

@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" type="text/css" href="css/bootstrap.min.css">
<!-- <link rel="stylesheet" type="text/css" href="css/styles.css"> -->
<title>{% block title %}{% endblock %}</title>
</head>
<body>
<nav class="navbar navbar-dark sticky-top bg-primary shadow mb-4 justify-content-center">
<div class="container-fliud">
<a class="navbar-brand" href="index.php">PlusLifeWeb</a>
{% if block("header") is defined %}
<span class="navbar-text">{% block header %}{% endblock %}</span>
{% endif %}
</div>
</nav>
<main class="container">
<div>
{% block content %}{% endblock %}
</div>
</main>
<hr>
<p/>
<script src="js/jquery.min.js"></script>
<script src="js/bootstrap.min.js"></script>
{% block js %}{% endblock %}
</body>
</html>

13
templates/error.html.twig Normal file
View File

@ -0,0 +1,13 @@
{% extends 'base.html.twig' %}
{% block title %}PlusLifeWeb - Fehler{% endblock %}
{% block header %}Fehler{% endblock %}
{% block content %}
<div class="bg-light p-3 rounded mt-3">
<pre class="mb-0">
{{ error }}
</pre>
</div>
{% endblock %}

23
templates/index.html.twig Normal file
View File

@ -0,0 +1,23 @@
{% extends 'base.html.twig' %}
{% block title %}PlusLifeWeb{% endblock %}
{% block content %}
{% if tests is not empty %}
<div>Test auswählen:</div>
<table class="table">
<tbody>
{% for test in tests %}
<tr><td><a href="test.php?id={{ test.id }}">{{ test.timestamp }}</td></tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="bg-light p-3 rounded mt-3">
<pre class="mb-0">Keine Tests gefunden.</pre>
</div>
{% endif %}
{% endblock %}

40
templates/test.html.twig Normal file
View File

@ -0,0 +1,40 @@
{% extends 'base.html.twig' %}
{% block js %}
<script>
var json = {{ test|raw }};
</script>
<script src="js/chart.umd.js"></script>
<script src="js/pluslife.js"></script>
<script src="js/plweb.js"></script>
{% endblock %}
{% block title %}PlusLifeWeb Test Analyse{% endblock %}
{% block header %}Test Analyse{% endblock %}
{% block content %}
<div id="datacontainer" hidden="hidden">
<div class="row justify-content-center chart-container" style="position: relative; min-height: 400px; max-height:600px;">
<canvas id="data"></canvas>
</div>
<div class="row text-center">
<div class="col">
<div class="row">
<div class="col">
<strong>Datum: </strong><span id="testdate"></span><br />
<strong>Ergebnis: </strong><span id="testresult"></span><br />
</div>
</div>
<div class="row">
<div class="col justify-content-center" id="testresult_channels"></div>
</div>
</div>
</div>
</div>
<div class="row text-center">
<div class="col">
<span id="error" style="color: red;display: none;"></span>
</div>
</div>
{% endblock %}

33
test.php Normal file
View File

@ -0,0 +1,33 @@
<?php
require_once 'Twig/autoload.php';
require_once 'config.php';
require_once 'functions.php';
use Twig\Environment;
use Twig\Loader\FilesystemLoader;
$loader = new FilesystemLoader(__DIR__ . '/templates');
$twig = new Environment($loader);
// --------------
if (!isset($_GET['id']))
die(twig_error($twig, 400, "Keine ID angegeben."));
$id = (int)$_GET['id'];
$dbconn = pg_connect($dbconnstring)
or die(twig_error($twig, 500, 'Datenbankfehler: ' . pg_last_error()));
$query = "SELECT data FROM tests WHERE id=" . $id;
$result = pg_query($dbconn, $query)
or die(twig_error($twig, 'Datenbankfehler: ' . pg_last_error()));
$json = pg_fetch_result($result, 'data')
or die(twig_error($twig, 404, 'Test nicht gefunden.'));
pg_close($dbconn);
echo $twig->render('test.html.twig', [ 'test' => $json ]);
?>

BIN
vendor/bootstrap-5.3.7-dist.zip vendored Normal file

Binary file not shown.

BIN
vendor/chart.js-4.5.0.tgz vendored Normal file

Binary file not shown.

2
vendor/jquery-3.7.1.min.js vendored Normal file

File diff suppressed because one or more lines are too long

72
webhook.php Normal file
View File

@ -0,0 +1,72 @@
<?php
include 'config.php';
include 'functions.php';
cors();
// Read the raw POST data
$data = json_decode(file_get_contents('php://input'), true)
or die(return_error(400, 'Invalid data'));
switch ($data['event']) {
case "DEVICE_READY":
case "ALREADY_TESTING":
case "TEST_STARTED":
case "CONTINUE_TEST":
echo return_success();
send_notification($data['event']);
break;
case "NEW_DATA":
echo return_success();
break;
case "TEST_FINISHED":
test_finished();
break;
default:
die(return_error(400, 'Unknown event ' . $data['event']));
break;
}
function send_notification($msg, $url = null) {
global $hawebhook;
$post = [ 'message' => $msg ];
if ($url != null) $post['url'] = $url;
$ch = curl_init($hawebhook);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, array("Content-Type: application/json"));
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($post));
$rc = curl_exec($ch);
curl_close($ch);
}
function test_finished() {
global $dbconnstring;
global $data;
global $test_url;
$dbconn = pg_connect($dbconnstring)
or die(return_error(500, 'connect Datenbankfehler: ' . pg_last_error()));
$query = "INSERT INTO tests VALUES (DEFAULT, $1, $2) RETURNING id";
$values = [ date('Y-m-d H:i:s'), json_encode($data) ];
$rc = pg_query_params($dbconn, $query, $values);
if ($rc) {
$id = pg_fetch_result($rc, 0, 0);
echo return_success();
send_notification("Test fertig. ID: " . $id, $test_url + $id);
} else {
echo return_error(500, 'insert Datenbankfehler: ' . pg_last_error());
}
pg_close($dbconn);
}
?>