Add MQTT listener, mock publisher, health check, API endpoints

This commit is contained in:
Jan (Plesk) 2026-09-30 07:08:08 +02:00
parent a78a50ef0c
commit 048e507d04
6 changed files with 295 additions and 24 deletions

View file

@ -0,0 +1,75 @@
<?php
namespace App\Console\Commands;
use App\Models\Device;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Mail;
class HealthCheck extends Command
{
protected $signature = 'heizung:healthcheck
{--stale=5 : Minutes without data = alarm}
{--notify= : Email address for alerts}
{--sms= : Phone number for mail2sms}';
protected $description = 'Check device liveness and alert on state changes';
public function handle(): int
{
$stale = (int) $this->option('stale');
$email = $this->option('notify') ?: env('HEALTHCHECK_EMAIL');
$sms = $this->option('sms') ?: env('HEALTHCHECK_SMS');
$th = now()->subMinutes($stale);
$problems = 0;
foreach (Device::all() as $d) {
$down = !$d->last_seen_at || $d->last_seen_at->lt($th);
$prev = Cache::get("hc:d:{$d->id}", 'ok');
$new = $down ? 'down' : 'ok';
$this->line("$d->name: $new");
if ($new !== $prev) {
$this->notify($d, $new, $email, $sms);
Cache::put("hc:d:{$d->id}", $new, now()->addDays(30));
}
$problems += $down ? 1 : 0;
}
return $problems > 0 ? 1 : 0;
}
private function notify(Device $d, string $state, ?string $email, ?string $sms): void
{
if ($state === 'down') {
$subj = "[ALARM] Heizung {$d->name} offline";
$body = "'{$d->name}' seit ".($d->last_seen_at?->diffForHumans() ?? 'immer').
" keine Daten.\nLast seen: ".($d->last_seen_at?->toDateTimeString() ?? 'nie');
} else {
$subj = "[OK] Heizung {$d->name} wieder online";
$body = "'{$d->name}' liefert wieder Daten.\nLast seen: ".$d->last_seen_at?->toDateTimeString();
}
if ($email) $this->sendMail($email, $subj, $body);
if ($sms) $this->sendSms($sms, $subj, $body);
$this->warn("notify: $subj");
}
private function sendMail(string $to, string $subj, string $body): void
{
try {
Mail::raw($body, function ($m) use ($to, $subj) {
$m->to($to)->subject($subj);
});
} catch (\Throwable $e) {
$this->error("Mail failed: ".$e->getMessage());
}
}
private function sendSms(string $number, string $subj, string $body): void
{
// mail2sms Gateway: subject=Nummer, body=Text
try {
Mail::raw(substr($subj.' '.$body, 0, 140), function ($m) use ($number) {
$m->to('mail2sms@dimedtec.net')->subject($number);
});
} catch (\Throwable $e) {
$this->error("SMS failed: ".$e->getMessage());
}
}
}

View file

@ -1,20 +1,89 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use App\Models\Device;
use App\Models\Reading;
use App\Models\Event;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use PhpMqtt\Client\ConnectionSettings;
use PhpMqtt\Client\MqttClient;
use Throwable;
#[Signature('app:mqtt-listener')]
#[Description('Command description')]
class MqttListener extends Command
{
/**
* Execute the console command.
*/
public function handle()
protected $signature = 'mqtt:listen';
protected $description = 'Subscribe to MQTT topics and persist readings/events';
public function handle(): int
{
//
$client = $this->makeClient();
$prefix = env('MQTT_TOPIC_PREFIX','ems-esp');
$topic = $prefix.'/+/+';
$client->subscribe($topic, fn($t,$m) => $this->onMessage($t,$m), 0);
$this->info("Subscribed: $topic");
try { $client->loop(true, true); }
catch (Throwable $e) { $this->error($e->getMessage()); return 1; }
finally { $client->disconnect(); }
return 0;
}
private function makeClient(): MqttClient
{
$host = env('MQTT_HOST');
$port = (int) env('MQTT_PORT',8883);
$clientId = env('MQTT_CLIENT_ID','heizung-laravel');
$client = new MqttClient($host, $port, $clientId);
$cs = (new ConnectionSettings)
->setUsername(env('MQTT_USERNAME'))
->setPassword(env('MQTT_PASSWORD'))
->setUseTls(true)
->setTlsVerifyPeer(true)
->setTlsVerifyPeerName(true)
->setTlsCertificateAuthorityPath(env('MQTT_CA_PATH','/etc/ssl/certs'))
->setKeepAliveInterval(60)
->setConnectTimeout(10);
$client->connect($cs, true);
return $client;
}
private function onMessage(string $topic, string $msg): void
{
$parts = explode('/', $topic);
if (count($parts) < 3) return;
[$prefix, $deviceSlug, $category] = $parts;
$device = Device::where('slug', $deviceSlug)->first();
if (!$device) {
$this->warn("Unknown device: $deviceSlug");
return;
}
$device->update(['last_seen_at' => now()]);
$payload = json_decode($msg, true);
if (!is_array($payload)) {
$this->warn("Invalid JSON on $topic");
return;
}
$this->persistReadings($device, $category, $payload);
}
private function persistReadings(Device $device, string $category, array $payload): void
{
$now = now();
$rows = [];
foreach ($payload as $key => $value) {
$row = [
'device_id' => $device->id,
'metric' => $category.'.'.$key,
'value' => null,
'value_text' => null,
'recorded_at' => $now,
];
if (is_numeric($value)) $row['value'] = (float) $value;
elseif (is_bool($value)) $row['value'] = $value ? 1 : 0;
elseif (is_string($value) || $value === null) $row['value_text'] = $value !== null ? substr((string)$value,0,50) : null;
else continue;
$rows[] = $row;
}
if (!empty($rows)) Reading::insert($rows);
}
}

View file

@ -0,0 +1,73 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use PhpMqtt\Client\ConnectionSettings;
use PhpMqtt\Client\MqttClient;
class MqttMock extends Command
{
protected $signature = 'mqtt:mock
{--device=eltern : Device slug}
{--interval=10 : Seconds between publishes}
{--duration=0 : Stop after N seconds (0 = forever)}';
protected $description = 'Publish mock heating data to MQTT';
public function handle(): int
{
$c = $this->makeClient();
$dev = $this->option('device');
$iv = (int) $this->option('interval');
$dur = (int) $this->option('duration');
$end = $dur > 0 ? time()+$dur : null;
$this->info("Mock $dev every {$iv}s");
while (true) {
if ($end && time() >= $end) break;
[$b,$t] = $this->generate();
$c->publish("ems-esp/$dev/boiler", json_encode($b), 0);
$c->publish("ems-esp/$dev/thermostat", json_encode($t), 0);
$this->line(now()->format('H:i:s')." flow={$b['curflowtemp']} burn={$b['burngas']} ist={$t['hc1_currtemp']}");
sleep($iv);
}
$c->disconnect();
return 0;
}
private function generate(): array
{
$h = (float)now()->format('G') + (int)now()->format('i')/60.0;
$heat = ($h >= 6 && $h < 22);
$burn = $heat && (rand(0,100) < 60) ? 1 : 0;
$out = 5 + 8*sin(($h-3)/24*2*M_PI) + rand(-10,10)/10;
$set = $heat ? 21.0 : 18.0;
$ft = $burn ? 55 + (15-$out)*0.8 : 38;
return [[
'curflowtemp'=>round($ft + rand(-15,15)/10,1),
'selflowtemp'=>round($ft,1),
'burngas'=>$burn,
'wwseltemp'=>55,
'wwcurtemp'=>round(55-rand(0,30)/10,1),
'outdoortemp'=>round($out,1),
],[
'hc1_seltemp'=>$set,
'hc1_currtemp'=>round($set+rand(-6,6)/10,1),
'hc1_mode'=>$heat?'auto':'night',
]];
}
private function makeClient(): MqttClient
{
$c = new MqttClient(env('MQTT_HOST'), (int)env('MQTT_PORT',8883), 'heizung-mock');
$cs = (new ConnectionSettings)
->setUsername(env('MQTT_MOCK_USERNAME'))
->setPassword(env('MQTT_MOCK_PASSWORD'))
->setUseTls(true)
->setTlsVerifyPeer(true)
->setTlsVerifyPeerName(true)
->setTlsCertificateAuthorityPath(env('MQTT_CA_PATH','/etc/ssl/certs'))
->setKeepAliveInterval(60)
->setConnectTimeout(10);
$c->connect($cs, true);
return $c;
}
}

View file

@ -0,0 +1,51 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Device;
use App\Models\Reading;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class DeviceController extends Controller
{
public function index(): JsonResponse
{
return response()->json(Device::select('id','slug','name','type','last_seen_at')->get());
}
public function show(string $slug): JsonResponse
{
$d = Device::where('slug',$slug)->firstOrFail();
return response()->json($d);
}
public function latest(string $slug): JsonResponse
{
$d = Device::where('slug',$slug)->firstOrFail();
$sql = "SELECT metric, value, value_text, recorded_at FROM (SELECT metric, value, value_text, recorded_at, ROW_NUMBER() OVER (PARTITION BY metric ORDER BY recorded_at DESC) rn FROM readings WHERE device_id=?) t WHERE rn=1";
$rows = DB::select($sql, [$d->id]);
$m = [];
foreach ($rows as $r) {
$m[$r->metric] = [
'value' => $r->value !== null ? (float)$r->value : null,
'text' => $r->value_text,
'at' => $r->recorded_at,
];
}
return response()->json(['device' => $d->only(['slug','name','last_seen_at']), 'metrics' => $m]);
}
public function history(string $slug, Request $r): JsonResponse
{
$d = Device::where('slug',$slug)->firstOrFail();
$metric = $r->query('metric');
$hours = min(168, max(1, (int)$r->query('hours', 24)));
$since = now()->subHours($hours);
$q = Reading::where('device_id',$d->id)->where('recorded_at','>=',$since);
if ($metric) $q->where('metric',$metric);
$rows = $q->orderBy('recorded_at')->limit(5000)->get(['metric','value','recorded_at']);
return response()->json(['from'=>$since,'count'=>$rows->count(),'data'=>$rows]);
}
}

View file

@ -65,7 +65,7 @@
|
*/
'timezone' => 'UTC',
'timezone' => env('APP_TIMEZONE', 'UTC'),
/*
|--------------------------------------------------------------------------

View file

@ -1,16 +1,19 @@
<?php
use Illuminate\Support\Facades\Route;
use App\Models\Device;
Route::get('/', function () {
$device = Device::first();
return response()->json([
use App\Http\Controllers\Api\DeviceController;
Route::get('/', fn() => response()->json([
'app' => 'Heizung',
'status' => 'running',
'db'=>'ok',
'device_loaded'=>$device?->name,
'laravel'=>app()->version(),
'php'=>PHP_VERSION,
'time' => now()->format('Y-m-d H:i:s'),
]);
});
]));
Route::get('/health', fn() => response()->json(['ok'=>true]));
// API
Route::prefix('api')->group(function () {
Route::get('/devices', [DeviceController::class, 'index']);
Route::get('/devices/{slug}', [DeviceController::class, 'show']);
Route::get('/devices/{slug}/latest', [DeviceController::class, 'latest']);
Route::get('/devices/{slug}/history', [DeviceController::class, 'history']);
});