From 048e507d0490064631a1f6ddb9b2efad52f67e4a Mon Sep 17 00:00:00 2001 From: "Jan (Plesk)" Date: Wed, 30 Sep 2026 07:08:08 +0200 Subject: [PATCH] Add MQTT listener, mock publisher, health check, API endpoints --- app/Console/Commands/HealthCheck.php | 75 ++++++++++++++++ app/Console/Commands/MqttListener.php | 89 ++++++++++++++++--- app/Console/Commands/MqttMock.php | 73 +++++++++++++++ app/Http/Controllers/Api/DeviceController.php | 51 +++++++++++ config/app.php | 2 +- routes/web.php | 29 +++--- 6 files changed, 295 insertions(+), 24 deletions(-) create mode 100644 app/Console/Commands/HealthCheck.php create mode 100644 app/Console/Commands/MqttMock.php create mode 100644 app/Http/Controllers/Api/DeviceController.php diff --git a/app/Console/Commands/HealthCheck.php b/app/Console/Commands/HealthCheck.php new file mode 100644 index 0000000..2c0cc7d --- /dev/null +++ b/app/Console/Commands/HealthCheck.php @@ -0,0 +1,75 @@ +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()); + } + } +} diff --git a/app/Console/Commands/MqttListener.php b/app/Console/Commands/MqttListener.php index c02296c..a35fe42 100644 --- a/app/Console/Commands/MqttListener.php +++ b/app/Console/Commands/MqttListener.php @@ -1,20 +1,89 @@ 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); } } diff --git a/app/Console/Commands/MqttMock.php b/app/Console/Commands/MqttMock.php new file mode 100644 index 0000000..46a2fd4 --- /dev/null +++ b/app/Console/Commands/MqttMock.php @@ -0,0 +1,73 @@ +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; + } +} diff --git a/app/Http/Controllers/Api/DeviceController.php b/app/Http/Controllers/Api/DeviceController.php new file mode 100644 index 0000000..4314c36 --- /dev/null +++ b/app/Http/Controllers/Api/DeviceController.php @@ -0,0 +1,51 @@ +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]); + } +} diff --git a/config/app.php b/config/app.php index 423eed5..28be821 100644 --- a/config/app.php +++ b/config/app.php @@ -65,7 +65,7 @@ | */ - 'timezone' => 'UTC', + 'timezone' => env('APP_TIMEZONE', 'UTC'), /* |-------------------------------------------------------------------------- diff --git a/routes/web.php b/routes/web.php index 2cf458e..db13f8b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,16 +1,19 @@ 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'), - ]); -}); +use App\Http\Controllers\Api\DeviceController; + +Route::get('/', fn() => response()->json([ + 'app' => 'Heizung', + 'status' => 'running', + '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']); +});