Add MQTT listener, mock publisher, health check, API endpoints
This commit is contained in:
parent
a78a50ef0c
commit
048e507d04
6 changed files with 295 additions and 24 deletions
75
app/Console/Commands/HealthCheck.php
Normal file
75
app/Console/Commands/HealthCheck.php
Normal 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,20 +1,89 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Console\Commands;
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
use Illuminate\Console\Attributes\Description;
|
use App\Models\Device;
|
||||||
use Illuminate\Console\Attributes\Signature;
|
use App\Models\Reading;
|
||||||
|
use App\Models\Event;
|
||||||
use Illuminate\Console\Command;
|
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
|
class MqttListener extends Command
|
||||||
{
|
{
|
||||||
/**
|
protected $signature = 'mqtt:listen';
|
||||||
* Execute the console command.
|
protected $description = 'Subscribe to MQTT topics and persist readings/events';
|
||||||
*/
|
|
||||||
public function handle()
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
73
app/Console/Commands/MqttMock.php
Normal file
73
app/Console/Commands/MqttMock.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/Http/Controllers/Api/DeviceController.php
Normal file
51
app/Http/Controllers/Api/DeviceController.php
Normal 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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -65,7 +65,7 @@
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'timezone' => 'UTC',
|
'timezone' => env('APP_TIMEZONE', 'UTC'),
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,19 @@
|
||||||
<?php
|
<?php
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use App\Models\Device;
|
use App\Http\Controllers\Api\DeviceController;
|
||||||
Route::get('/', function () {
|
|
||||||
$device = Device::first();
|
Route::get('/', fn() => response()->json([
|
||||||
return response()->json([
|
'app' => 'Heizung',
|
||||||
'app'=>'Heizung',
|
'status' => 'running',
|
||||||
'status'=>'running',
|
'time' => now()->format('Y-m-d H:i:s'),
|
||||||
'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]));
|
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']);
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue