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
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
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
|
||||
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']);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue