feat(addons): SMTP notification add-on with per-event toggles
Issue 4: an SMTP add-on for e-mail notifications. - SmtpSetup modal: server (host/port/encryption/user/pass), from/to, and individual toggles for the events you want mailed — device offline, low battery, automation messages. "Send test e-mail" button. Config stored encrypted on the addon row; a runtime mailer is built from it (no .env edits). - NotificationService: notify(event, …) sends only if SMTP is set up AND that event is enabled; send() for the test. - Triggers: automation "notify" actions e-mail (when enabled); notifications:sweep (every 5 min) e-mails on NEW device-offline / low-battery with per-condition dedup + battery hysteresis, so it's one mail per event. 6 tests (gating, per-event toggle, config persist + password-keep, sweep dedup). Suite 82 green, 12/12 tabs clean; card + modal browser-verified. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>feat/phase-1-bootstrap
parent
60030c3f61
commit
675d04e104
|
|
@ -0,0 +1,75 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Device;
|
||||
use App\Models\Entity;
|
||||
use App\Services\NotificationService;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
/**
|
||||
* Sweeps for alert conditions (device offline, low battery) and e-mails the SMTP add-on when a NEW
|
||||
* one appears. A per-condition cache flag dedups, so you get one mail when it happens — not one
|
||||
* every sweep — and the flag clears once the condition resolves (with hysteresis for batteries).
|
||||
*/
|
||||
class NotificationsSweepCommand extends Command
|
||||
{
|
||||
protected $signature = 'notifications:sweep';
|
||||
|
||||
protected $description = 'E-mail on new alert conditions (device offline, low battery).';
|
||||
|
||||
public function handle(NotificationService $notifier): int
|
||||
{
|
||||
// Nothing to do unless SMTP is configured.
|
||||
if ($notifier->config() === null) {
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->sweepOffline($notifier);
|
||||
$this->sweepBatteries($notifier);
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function sweepOffline(NotificationService $notifier): void
|
||||
{
|
||||
foreach (Device::where('status', 'active')->where('demo', false)->get() as $device) {
|
||||
$key = "notified:offline:{$device->id}";
|
||||
|
||||
if (! $device->isOnline()) {
|
||||
if (! Cache::has($key) && $notifier->notify('device_offline',
|
||||
__('notifications.device_offline_subject', ['name' => $device->name]),
|
||||
__('notifications.device_offline_body', ['name' => $device->name]))) {
|
||||
Cache::put($key, true, now()->addDay());
|
||||
}
|
||||
} else {
|
||||
Cache::forget($key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function sweepBatteries(NotificationService $notifier): void
|
||||
{
|
||||
$batteries = Entity::where('type', 'battery')->with('device', 'state')->get();
|
||||
|
||||
foreach ($batteries as $entity) {
|
||||
if ($entity->device === null || $entity->device->demo) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$pct = (int) data_get($entity->state, 'state.percent', 100);
|
||||
$key = "notified:battery:{$entity->id}";
|
||||
|
||||
if ($pct > 0 && $pct < 20) {
|
||||
if (! Cache::has($key) && $notifier->notify('low_battery',
|
||||
__('notifications.low_battery_subject', ['name' => $entity->device->name]),
|
||||
__('notifications.low_battery_body', ['name' => $entity->device->name, 'percent' => $pct]))) {
|
||||
Cache::put($key, true, now()->addDay());
|
||||
}
|
||||
} elseif ($pct >= 25) {
|
||||
Cache::forget($key); // hysteresis so it doesn't flap around the threshold
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
<?php
|
||||
|
||||
namespace App\Livewire\Modals;
|
||||
|
||||
use App\Models\Addon;
|
||||
use App\Services\AddonService;
|
||||
use App\Services\NotificationService;
|
||||
use LivewireUI\Modal\ModalComponent;
|
||||
|
||||
class SmtpSetup extends ModalComponent
|
||||
{
|
||||
public string $host = '';
|
||||
|
||||
public string $port = '587';
|
||||
|
||||
public string $encryption = 'tls';
|
||||
|
||||
public string $username = '';
|
||||
|
||||
public string $password = '';
|
||||
|
||||
public string $fromEmail = '';
|
||||
|
||||
public string $fromName = 'HomeOS';
|
||||
|
||||
public string $toEmail = '';
|
||||
|
||||
public bool $evDeviceOffline = true;
|
||||
|
||||
public bool $evLowBattery = true;
|
||||
|
||||
public bool $evAutomation = true;
|
||||
|
||||
public bool $hasStoredPassword = false;
|
||||
|
||||
public ?string $flash = null;
|
||||
|
||||
public bool $flashOk = true;
|
||||
|
||||
public static function modalMaxWidth(): string
|
||||
{
|
||||
return '3xl';
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$cfg = Addon::where('key', 'smtp')->first()?->config ?? [];
|
||||
|
||||
$this->host = $cfg['host'] ?? '';
|
||||
$this->port = (string) ($cfg['port'] ?? '587');
|
||||
$this->encryption = $cfg['encryption'] ?? 'tls';
|
||||
$this->username = $cfg['username'] ?? '';
|
||||
$this->fromEmail = $cfg['from_email'] ?? '';
|
||||
$this->fromName = $cfg['from_name'] ?? 'HomeOS';
|
||||
$this->toEmail = $cfg['to_email'] ?? '';
|
||||
$this->hasStoredPassword = filled($cfg['password'] ?? null);
|
||||
$this->evDeviceOffline = (bool) data_get($cfg, 'events.device_offline', true);
|
||||
$this->evLowBattery = (bool) data_get($cfg, 'events.low_battery', true);
|
||||
$this->evAutomation = (bool) data_get($cfg, 'events.automation', true);
|
||||
}
|
||||
|
||||
protected function rules(): array
|
||||
{
|
||||
return [
|
||||
'host' => 'required|string|max:190',
|
||||
'port' => 'required|integer|min:1|max:65535',
|
||||
'encryption' => 'required|in:,tls,ssl',
|
||||
'username' => 'nullable|string|max:190',
|
||||
'password' => $this->hasStoredPassword ? 'nullable|string|max:190' : 'nullable|string|max:190',
|
||||
'fromEmail' => 'required|email|max:190',
|
||||
'fromName' => 'nullable|string|max:100',
|
||||
'toEmail' => 'required|email|max:190',
|
||||
];
|
||||
}
|
||||
|
||||
private function persist(): void
|
||||
{
|
||||
$service = app(AddonService::class);
|
||||
$addon = $service->install('smtp');
|
||||
|
||||
$existing = $addon->config ?? [];
|
||||
$config = [
|
||||
'host' => $this->host,
|
||||
'port' => (int) $this->port,
|
||||
'encryption' => $this->encryption,
|
||||
'username' => $this->username,
|
||||
'password' => $this->password !== '' ? $this->password : ($existing['password'] ?? ''),
|
||||
'from_email' => $this->fromEmail,
|
||||
'from_name' => $this->fromName,
|
||||
'to_email' => $this->toEmail,
|
||||
'events' => [
|
||||
'device_offline' => $this->evDeviceOffline,
|
||||
'low_battery' => $this->evLowBattery,
|
||||
'automation' => $this->evAutomation,
|
||||
],
|
||||
];
|
||||
|
||||
$addon->forceFill(['config' => $config, 'status' => 'connected', 'connected_at' => now()])->save();
|
||||
}
|
||||
|
||||
public function sendTest(): void
|
||||
{
|
||||
$this->validate();
|
||||
$this->persist();
|
||||
|
||||
try {
|
||||
$ok = app(NotificationService::class)->send(__('addons.smtp.test_subject'), __('addons.smtp.test_body'));
|
||||
} catch (\Throwable $e) {
|
||||
report($e);
|
||||
$ok = false;
|
||||
}
|
||||
|
||||
$this->flash = $ok ? __('addons.smtp.test_ok') : __('addons.smtp.test_fail');
|
||||
$this->flashOk = $ok;
|
||||
}
|
||||
|
||||
public function save()
|
||||
{
|
||||
$this->validate();
|
||||
$this->persist();
|
||||
|
||||
$this->closeModal();
|
||||
|
||||
return redirect()->route('addons.index');
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.modals.smtp-setup');
|
||||
}
|
||||
}
|
||||
|
|
@ -31,6 +31,17 @@ class AddonRegistry
|
|||
'docs' => 'https://github.com/tsightler/ring-mqtt',
|
||||
'setup_modal' => 'modals.ring-setup',
|
||||
],
|
||||
'smtp' => [
|
||||
'key' => 'smtp',
|
||||
'name' => 'E-Mail (SMTP)',
|
||||
'vendor' => 'SMTP',
|
||||
'icon' => 'alert',
|
||||
'cloud' => false,
|
||||
'summary_key' => 'addons.smtp.summary',
|
||||
'body_key' => 'addons.smtp.body',
|
||||
'docs' => 'https://homeos.local',
|
||||
'setup_modal' => 'modals.smtp-setup',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -131,7 +131,13 @@ class AutomationEngine
|
|||
|
||||
$this->commands->setOn($entity, (bool) ($action['on'] ?? true), 'automation');
|
||||
} elseif ($type === 'notify') {
|
||||
Log::info('[automation'.($automation->dry_run ? ' dry-run' : '')."] {$automation->name}: ".($action['message'] ?? ''));
|
||||
$message = $action['message'] ?? '';
|
||||
Log::info('[automation'.($automation->dry_run ? ' dry-run' : '')."] {$automation->name}: ".$message);
|
||||
|
||||
// Also e-mail it if the SMTP add-on is set up and automation notifications are enabled.
|
||||
if (! $automation->dry_run) {
|
||||
app(\App\Services\NotificationService::class)->notify('automation', $automation->name, $message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Addon;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
|
||||
/**
|
||||
* Sends e-mail notifications via the user's SMTP add-on. The SMTP settings live (encrypted) on the
|
||||
* addon row; we configure a runtime mailer from them so no .env editing is needed. Each event type
|
||||
* is individually toggleable, so the user controls exactly what they get mailed about.
|
||||
*/
|
||||
class NotificationService
|
||||
{
|
||||
/** @return array<string,mixed>|null */
|
||||
public function config(): ?array
|
||||
{
|
||||
return Addon::where('key', 'smtp')->where('installed', true)->first()?->config;
|
||||
}
|
||||
|
||||
public function eventEnabled(string $event): bool
|
||||
{
|
||||
return (bool) data_get($this->config(), "events.{$event}", false);
|
||||
}
|
||||
|
||||
/** Send only if the SMTP add-on is set up AND this event type is enabled. */
|
||||
public function notify(string $event, string $subject, string $body): bool
|
||||
{
|
||||
if (! $this->eventEnabled($event)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->send($subject, $body);
|
||||
}
|
||||
|
||||
/** Send unconditionally (used by the "send test" button). Returns success. */
|
||||
public function send(string $subject, string $body): bool
|
||||
{
|
||||
$cfg = $this->config();
|
||||
if ($cfg === null || blank($cfg['to_email'] ?? null) || blank($cfg['host'] ?? null)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->configureMailer($cfg);
|
||||
|
||||
Mail::mailer('homeos_smtp')->raw($body, function ($message) use ($cfg) {
|
||||
$message->to($cfg['to_email'])
|
||||
->subject('[HomeOS] '.$subject)
|
||||
->from($cfg['from_email'] ?: 'homeos@localhost', $cfg['from_name'] ?: 'HomeOS');
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @param array<string,mixed> $cfg */
|
||||
private function configureMailer(array $cfg): void
|
||||
{
|
||||
config(['mail.mailers.homeos_smtp' => [
|
||||
'transport' => 'smtp',
|
||||
'host' => $cfg['host'],
|
||||
'port' => (int) ($cfg['port'] ?? 587),
|
||||
'encryption' => ($cfg['encryption'] ?? '') ?: null,
|
||||
'username' => ($cfg['username'] ?? '') ?: null,
|
||||
'password' => ($cfg['password'] ?? '') ?: null,
|
||||
'timeout' => 10,
|
||||
]]);
|
||||
}
|
||||
}
|
||||
|
|
@ -33,4 +33,31 @@ return [
|
|||
'status_hint_disconnected' => 'Noch keine Verbindung von der Bridge empfangen. Starte die Bridge und melde dich an.',
|
||||
'connected_since' => 'Verbunden seit',
|
||||
],
|
||||
|
||||
'smtp' => [
|
||||
'summary' => 'E-Mail-Benachrichtigungen bei Ereignissen (per SMTP).',
|
||||
'body' => 'Sende dir E-Mails, wenn ein Gerät offline geht, ein Akku leer wird oder eine Automation etwas meldet — einzeln einstellbar.',
|
||||
'setup_title'=> 'E-Mail-Benachrichtigungen',
|
||||
'server' => 'SMTP-Server',
|
||||
'host' => 'Host',
|
||||
'port' => 'Port',
|
||||
'encryption'=> 'Verschlüsselung',
|
||||
'enc_none' => 'Keine',
|
||||
'username' => 'Benutzername',
|
||||
'password' => 'Passwort',
|
||||
'password_stored' => 'Passwort gespeichert. Leer lassen, um es zu behalten.',
|
||||
'from_email'=> 'Absender-E-Mail',
|
||||
'from_name' => 'Absender-Name',
|
||||
'to_email' => 'Empfänger-E-Mail',
|
||||
'events_title' => 'Benachrichtigen bei',
|
||||
'ev_device_offline' => 'Gerät offline',
|
||||
'ev_low_battery' => 'Akku niedrig',
|
||||
'ev_automation' => 'Automation-Meldungen',
|
||||
'test' => 'Test-E-Mail senden',
|
||||
'test_subject' => 'Testbenachrichtigung',
|
||||
'test_body' => 'Dies ist eine Test-E-Mail von HomeOS. Wenn du das liest, funktioniert SMTP.',
|
||||
'test_ok' => 'Test-E-Mail gesendet.',
|
||||
'test_fail' => 'Senden fehlgeschlagen — Einstellungen prüfen.',
|
||||
'save' => 'Speichern',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'device_offline_subject' => 'Gerät offline: :name',
|
||||
'device_offline_body' => 'Das Gerät „:name" ist nicht mehr erreichbar.',
|
||||
'low_battery_subject' => 'Akku niedrig: :name',
|
||||
'low_battery_body' => 'Der Akku von „:name" ist bei :percent %.',
|
||||
];
|
||||
|
|
@ -33,4 +33,31 @@ return [
|
|||
'status_hint_disconnected' => 'No connection received from the bridge yet. Start the bridge and sign in.',
|
||||
'connected_since' => 'Connected since',
|
||||
],
|
||||
|
||||
'smtp' => [
|
||||
'summary' => 'E-mail notifications for events (via SMTP).',
|
||||
'body' => 'Get e-mails when a device goes offline, a battery runs low or an automation reports something — toggle each individually.',
|
||||
'setup_title'=> 'E-mail notifications',
|
||||
'server' => 'SMTP server',
|
||||
'host' => 'Host',
|
||||
'port' => 'Port',
|
||||
'encryption'=> 'Encryption',
|
||||
'enc_none' => 'None',
|
||||
'username' => 'Username',
|
||||
'password' => 'Password',
|
||||
'password_stored' => 'Password stored. Leave blank to keep it.',
|
||||
'from_email'=> 'From e-mail',
|
||||
'from_name' => 'From name',
|
||||
'to_email' => 'Recipient e-mail',
|
||||
'events_title' => 'Notify on',
|
||||
'ev_device_offline' => 'Device offline',
|
||||
'ev_low_battery' => 'Low battery',
|
||||
'ev_automation' => 'Automation messages',
|
||||
'test' => 'Send test e-mail',
|
||||
'test_subject' => 'Test notification',
|
||||
'test_body' => 'This is a test e-mail from HomeOS. If you can read this, SMTP works.',
|
||||
'test_ok' => 'Test e-mail sent.',
|
||||
'test_fail' => 'Sending failed — check the settings.',
|
||||
'save' => 'Save',
|
||||
],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'device_offline_subject' => 'Device offline: :name',
|
||||
'device_offline_body' => 'The device “:name” is no longer reachable.',
|
||||
'low_battery_subject' => 'Low battery: :name',
|
||||
'low_battery_body' => 'The battery of “:name” is at :percent%.',
|
||||
];
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
<form wire:submit="save">
|
||||
<x-modal :title="__('addons.smtp.setup_title')" icon="alert">
|
||||
<div class="p-5 flex flex-col gap-5">
|
||||
@if ($flash)
|
||||
<p @class([
|
||||
'rounded-lg border px-3 py-2 text-[12.5px]',
|
||||
'border-online/30 bg-online/10 text-online' => $flashOk,
|
||||
'border-offline/30 bg-offline/10 text-offline' => ! $flashOk,
|
||||
])>{{ $flash }}</p>
|
||||
@endif
|
||||
|
||||
{{-- Server --}}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div class="flex flex-col gap-1.5 sm:col-span-2">
|
||||
<label class="text-[12px] font-semibold text-ink-2">{{ __('addons.smtp.host') }}</label>
|
||||
<input wire:model="host" type="text" placeholder="smtp.example.com"
|
||||
class="w-full rounded-lg bg-raised border border-line px-3 py-2.5 text-sm font-mono text-ink outline-none focus:border-accent focus:ring-1 focus:ring-accent">
|
||||
@error('host') <p class="text-[12px] text-offline">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="text-[12px] font-semibold text-ink-2">{{ __('addons.smtp.port') }}</label>
|
||||
<input wire:model="port" type="number" min="1" max="65535"
|
||||
class="w-full rounded-lg bg-raised border border-line px-3 py-2.5 text-sm font-mono text-ink outline-none focus:border-accent focus:ring-1 focus:ring-accent">
|
||||
@error('port') <p class="text-[12px] text-offline">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="text-[12px] font-semibold text-ink-2">{{ __('addons.smtp.encryption') }}</label>
|
||||
<select wire:model="encryption" class="w-full rounded-lg bg-raised border border-line px-3 py-2.5 text-sm text-ink outline-none focus:border-accent">
|
||||
<option value="tls">TLS</option>
|
||||
<option value="ssl">SSL</option>
|
||||
<option value="">{{ __('addons.smtp.enc_none') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="text-[12px] font-semibold text-ink-2">{{ __('addons.smtp.username') }}</label>
|
||||
<input wire:model="username" type="text" autocomplete="off"
|
||||
class="w-full rounded-lg bg-raised border border-line px-3 py-2.5 text-sm text-ink outline-none focus:border-accent focus:ring-1 focus:ring-accent">
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="text-[12px] font-semibold text-ink-2">{{ __('addons.smtp.password') }}</label>
|
||||
<input wire:model="password" type="password" autocomplete="new-password"
|
||||
class="w-full rounded-lg bg-raised border border-line px-3 py-2.5 text-sm text-ink outline-none focus:border-accent focus:ring-1 focus:ring-accent">
|
||||
@if ($hasStoredPassword) <p class="text-[11px] text-ink-3">{{ __('addons.smtp.password_stored') }}</p> @endif
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="text-[12px] font-semibold text-ink-2">{{ __('addons.smtp.from_email') }}</label>
|
||||
<input wire:model="fromEmail" type="email"
|
||||
class="w-full rounded-lg bg-raised border border-line px-3 py-2.5 text-sm text-ink outline-none focus:border-accent focus:ring-1 focus:ring-accent">
|
||||
@error('fromEmail') <p class="text-[12px] text-offline">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<label class="text-[12px] font-semibold text-ink-2">{{ __('addons.smtp.to_email') }}</label>
|
||||
<input wire:model="toEmail" type="email"
|
||||
class="w-full rounded-lg bg-raised border border-line px-3 py-2.5 text-sm text-ink outline-none focus:border-accent focus:ring-1 focus:ring-accent">
|
||||
@error('toEmail') <p class="text-[12px] text-offline">{{ $message }}</p> @enderror
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{-- Events --}}
|
||||
<fieldset class="flex flex-col gap-2.5 rounded-xl border border-line-soft p-3.5">
|
||||
<legend class="px-1.5 text-[11px] font-bold uppercase tracking-wide text-ink-3">{{ __('addons.smtp.events_title') }}</legend>
|
||||
<label class="flex items-center gap-2.5 cursor-pointer text-[13px] text-ink"><input type="checkbox" wire:model="evDeviceOffline" class="w-4 h-4 rounded border-line text-accent focus:ring-accent"> {{ __('addons.smtp.ev_device_offline') }}</label>
|
||||
<label class="flex items-center gap-2.5 cursor-pointer text-[13px] text-ink"><input type="checkbox" wire:model="evLowBattery" class="w-4 h-4 rounded border-line text-accent focus:ring-accent"> {{ __('addons.smtp.ev_low_battery') }}</label>
|
||||
<label class="flex items-center gap-2.5 cursor-pointer text-[13px] text-ink"><input type="checkbox" wire:model="evAutomation" class="w-4 h-4 rounded border-line text-accent focus:ring-accent"> {{ __('addons.smtp.ev_automation') }}</label>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<x-slot:footer>
|
||||
<button type="button" wire:click="sendTest" wire:loading.attr="disabled" wire:target="sendTest"
|
||||
class="mr-auto rounded-lg border border-line px-3.5 py-2 text-[13px] font-semibold text-ink-2 hover:text-ink hover:bg-raised transition-colors">{{ __('addons.smtp.test') }}</button>
|
||||
<button type="button" wire:click="closeModal" class="rounded-lg border border-line px-3.5 py-2 text-[13px] font-semibold text-ink-2 hover:text-ink hover:bg-raised transition-colors">{{ __('common.cancel') }}</button>
|
||||
<button type="submit" class="rounded-lg bg-accent px-3.5 py-2 text-[13px] font-bold text-base hover:brightness-110 transition-[filter]">{{ __('addons.smtp.save') }}</button>
|
||||
</x-slot:footer>
|
||||
</x-modal>
|
||||
</form>
|
||||
|
|
@ -19,3 +19,6 @@ Schedule::command('automations:tick')->everyMinute()->withoutOverlapping();
|
|||
|
||||
// Poll local (HTTP) Shelly devices for near-live status (MQTT devices push instead).
|
||||
Schedule::command('shelly:poll')->everyTenSeconds()->withoutOverlapping();
|
||||
|
||||
// E-mail alerts (device offline, low battery) — only acts if the SMTP add-on is set up.
|
||||
Schedule::command('notifications:sweep')->everyFiveMinutes()->withoutOverlapping();
|
||||
|
|
|
|||
|
|
@ -16,11 +16,10 @@ class AddonTest extends TestCase
|
|||
|
||||
public function test_registry_exposes_ring_with_state_defaults(): void
|
||||
{
|
||||
$addons = AddonRegistry::withState();
|
||||
$addons = collect(AddonRegistry::withState());
|
||||
|
||||
$this->assertCount(1, $addons);
|
||||
$ring = $addons[0];
|
||||
$this->assertSame('ring', $ring['key']);
|
||||
$ring = $addons->firstWhere('key', 'ring');
|
||||
$this->assertNotNull($ring);
|
||||
$this->assertTrue($ring['cloud']);
|
||||
$this->assertFalse($ring['installed']);
|
||||
$this->assertSame('disconnected', $ring['status']);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,96 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Livewire\Modals\SmtpSetup;
|
||||
use App\Models\Addon;
|
||||
use App\Models\Device;
|
||||
use App\Models\User;
|
||||
use App\Services\NotificationService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Livewire\Livewire;
|
||||
use Tests\TestCase;
|
||||
|
||||
class NotificationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function configureSmtp(array $events = ['device_offline' => true, 'low_battery' => true, 'automation' => true]): void
|
||||
{
|
||||
Addon::create([
|
||||
'key' => 'smtp',
|
||||
'installed' => true,
|
||||
'status' => 'connected',
|
||||
'config' => [
|
||||
'host' => 'smtp.example.com', 'port' => 587, 'encryption' => 'tls',
|
||||
'username' => 'u', 'password' => 'p', 'from_email' => 'homeos@example.com',
|
||||
'from_name' => 'HomeOS', 'to_email' => 'me@example.com', 'events' => $events,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_notify_does_nothing_without_the_smtp_addon(): void
|
||||
{
|
||||
Mail::fake();
|
||||
$this->assertFalse(app(NotificationService::class)->notify('device_offline', 's', 'b'));
|
||||
}
|
||||
|
||||
public function test_notify_respects_the_per_event_toggle(): void
|
||||
{
|
||||
Mail::fake();
|
||||
$this->configureSmtp(['device_offline' => false, 'low_battery' => true, 'automation' => true]);
|
||||
|
||||
$this->assertFalse(app(NotificationService::class)->notify('device_offline', 's', 'b'));
|
||||
$this->assertTrue(app(NotificationService::class)->notify('low_battery', 's', 'b'));
|
||||
}
|
||||
|
||||
public function test_smtp_setup_persists_config_and_keeps_password(): void
|
||||
{
|
||||
$this->actingAs(User::factory()->create());
|
||||
|
||||
Livewire::test(SmtpSetup::class)
|
||||
->set('host', 'smtp.gmail.com')->set('port', '587')->set('encryption', 'tls')
|
||||
->set('username', 'me')->set('password', 'secret')
|
||||
->set('fromEmail', 'from@x.com')->set('toEmail', 'to@x.com')
|
||||
->set('evLowBattery', false)
|
||||
->call('save')
|
||||
->assertRedirect(route('addons.index'));
|
||||
|
||||
$cfg = Addon::where('key', 'smtp')->first()->config;
|
||||
$this->assertSame('smtp.gmail.com', $cfg['host']);
|
||||
$this->assertSame('secret', $cfg['password']);
|
||||
$this->assertFalse($cfg['events']['low_battery']);
|
||||
|
||||
// Re-saving with a blank password keeps the stored one.
|
||||
Livewire::test(SmtpSetup::class)
|
||||
->set('host', 'smtp.gmail.com')->set('port', '465')->set('encryption', 'ssl')
|
||||
->set('fromEmail', 'from@x.com')->set('toEmail', 'to@x.com')
|
||||
->set('password', '')
|
||||
->call('save');
|
||||
|
||||
$this->assertSame('secret', Addon::where('key', 'smtp')->first()->config['password']);
|
||||
}
|
||||
|
||||
public function test_sweep_emails_once_per_offline_device(): void
|
||||
{
|
||||
Mail::fake();
|
||||
$this->configureSmtp();
|
||||
|
||||
// Active, not demo, stale last_seen → offline.
|
||||
$device = Device::create(['name' => 'Lampe', 'vendor' => 'Shelly', 'protocol' => 'http', 'status' => 'active', 'demo' => false, 'last_seen_at' => now()->subHour(), 'config' => ['ip' => '1.2.3.4']]);
|
||||
|
||||
$this->artisan('notifications:sweep')->assertSuccessful();
|
||||
$this->assertTrue(Cache::has("notified:offline:{$device->id}"));
|
||||
|
||||
// Second sweep — still flagged, no re-notify (flag unchanged).
|
||||
$this->artisan('notifications:sweep')->assertSuccessful();
|
||||
$this->assertTrue(Cache::has("notified:offline:{$device->id}"));
|
||||
|
||||
// Back online → flag cleared so a future outage notifies again.
|
||||
$device->update(['last_seen_at' => now()]);
|
||||
$this->artisan('notifications:sweep')->assertSuccessful();
|
||||
$this->assertFalse(Cache::has("notified:offline:{$device->id}"));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue