diff --git a/app/Jobs/NotifyDeviceBatteryLowJob.php b/app/Jobs/NotifyDeviceBatteryLowJob.php new file mode 100644 index 0000000..2508365 --- /dev/null +++ b/app/Jobs/NotifyDeviceBatteryLowJob.php @@ -0,0 +1,54 @@ +battery_percent; + + // If battery is above threshold, reset the notification flag + if ($batteryPercent > $batteryThreshold && $device->battery_notification_sent) { + $device->battery_notification_sent = false; + $device->save(); + + continue; + } + + // Skip if battery is not low or notification was already sent + if ($batteryPercent > $batteryThreshold || $device->battery_notification_sent) { + continue; + } + + /** @var User|null $user */ + $user = $device->user; + + if (! $user) { + continue; // Skip if no user is associated with the device + } + + // Send notification and mark as sent + $user->notify(new BatteryLow($device)); + $device->battery_notification_sent = true; + $device->save(); + } + } +} diff --git a/app/Models/Device.php b/app/Models/Device.php index 4e76909..2b8de5f 100644 --- a/app/Models/Device.php +++ b/app/Models/Device.php @@ -15,6 +15,7 @@ class Device extends Model protected $guarded = ['id']; protected $casts = [ + 'battery_notification_sent' => 'boolean', 'proxy_cloud' => 'boolean', 'last_log_request' => 'json', 'proxy_cloud_response' => 'json', @@ -179,4 +180,9 @@ class Device extends Model { return $this->hasMany(DeviceLog::class); } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } } diff --git a/app/Models/User.php b/app/Models/User.php index ffe8c97..1f524a7 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -72,4 +72,9 @@ class User extends Authenticatable // implements MustVerifyEmail { return $this->hasMany(Plugin::class); } + + public function routeNotificationForWebhook(): ?string + { + return config('services.webhook.notifications.url'); + } } diff --git a/app/Notifications/BatteryLow.php b/app/Notifications/BatteryLow.php new file mode 100644 index 0000000..c76c87f --- /dev/null +++ b/app/Notifications/BatteryLow.php @@ -0,0 +1,71 @@ +device = $device; + } + + /** + * Get the notification's delivery channels. + * + * @return array + */ + public function via(object $notifiable): array + { + return ['mail', WebhookChannel::class]; + } + + /** + * Get the mail representation of the notification. + */ + public function toMail(object $notifiable): MailMessage + { + return (new MailMessage)->markdown('mail.battery-low', ['device' => $this->device]); + } + + public function toWebhook(object $notifiable) + { + return WebhookMessage::create() + ->data([ + 'topic' => config('services.webhook.notifications.topic', 'battery.low'), + 'message' => "Battery below {$this->device->battery_percent}% on device: {$this->device->name}", + 'device_id' => $this->device->id, + 'device_name' => $this->device->name, + 'battery_percent' => $this->device->battery_percent, + + ]) + ->userAgent(config('app.name')) + ->header('X-TrmnlByos-Event', 'battery.low'); + } + + /** + * Get the array representation of the notification. + * + * @return array + */ + public function toArray(object $notifiable): array + { + return [ + 'device_name' => $this->device->name, + 'battery_percent' => $this->device->battery_percent, + ]; + } +} diff --git a/app/Notifications/Channels/WebhookChannel.php b/app/Notifications/Channels/WebhookChannel.php new file mode 100644 index 0000000..f115c44 --- /dev/null +++ b/app/Notifications/Channels/WebhookChannel.php @@ -0,0 +1,60 @@ +client = $client; + } + + /** + * Send the given notification. + * + * @param mixed $notifiable + * + * @throws Exception + * @throws GuzzleException + */ + public function send($notifiable, Notification $notification): ?Response + { + $url = $notifiable->routeNotificationFor('webhook', $notification); + + if (! $url) { + return null; + } + + if (! method_exists($notification, 'toWebhook')) { + throw new Exception('Notification does not implement toWebhook method.'); + } + + $webhookData = $notification->toWebhook($notifiable)->toArray(); + $response = $this->client->post($url, [ + 'query' => Arr::get($webhookData, 'query'), + 'body' => json_encode(Arr::get($webhookData, 'data')), + 'verify' => Arr::get($webhookData, 'verify'), + 'headers' => Arr::get($webhookData, 'headers'), + ]); + + if (! $response instanceof Response) { + throw new Exception('Webhook request did not return a valid GuzzleHttp\Psr7\Response.'); + } + + if ($response->getStatusCode() >= 300 || $response->getStatusCode() < 200) { + throw new Exception('Webhook request failed with status code: '.$response->getStatusCode()); + } + + return $response; + } +} diff --git a/app/Notifications/Messages/WebhookMessage.php b/app/Notifications/Messages/WebhookMessage.php new file mode 100644 index 0000000..6da9f55 --- /dev/null +++ b/app/Notifications/Messages/WebhookMessage.php @@ -0,0 +1,129 @@ +data = $data; + } + + /** + * Set the Webhook parameters to be URL encoded. + * + * @param mixed $query + * @return $this + */ + public function query($query) + { + $this->query = $query; + + return $this; + } + + /** + * Set the Webhook data to be JSON encoded. + * + * @param mixed $data + * @return $this + */ + public function data($data) + { + $this->data = $data; + + return $this; + } + + /** + * Add a Webhook request custom header. + * + * @param string $name + * @param string $value + * @return $this + */ + public function header($name, $value) + { + $this->headers[$name] = $value; + + return $this; + } + + /** + * Set the Webhook request UserAgent. + * + * @param string $userAgent + * @return $this + */ + public function userAgent($userAgent) + { + $this->headers['User-Agent'] = $userAgent; + + return $this; + } + + /** + * Indicate that the request should be verified. + * + * @return $this + */ + public function verify($value = true) + { + $this->verify = $value; + + return $this; + } + + /** + * @return array + */ + public function toArray() + { + return [ + 'query' => $this->query, + 'data' => $this->data, + 'headers' => $this->headers, + 'verify' => $this->verify, + ]; + } +} diff --git a/config/app.php b/config/app.php index 444d0ac..bd50723 100644 --- a/config/app.php +++ b/config/app.php @@ -131,6 +131,12 @@ return [ 'puppeteer_docker' => env('PUPPETEER_DOCKER', false), 'puppeteer_mode' => env('PUPPETEER_MODE', 'local'), + 'notifications' => [ + 'battery_low' => [ + 'warn_at_percent' => env('NOTIFICATION_BATTERYLOW_WARNATPERCENT', 20), + ], + ], + /* |-------------------------------------------------------------------------- | Application Version diff --git a/config/services.php b/config/services.php index c9e4891..14b9dd1 100644 --- a/config/services.php +++ b/config/services.php @@ -43,4 +43,11 @@ return [ 'image_url_timeout' => env('TRMNL_IMAGE_URL_TIMEOUT', 30), // 30 seconds; increase on low-powered devices ], + 'webhook' => [ + 'notifications' => [ + 'url' => env('WEBHOOK_NOTIFICATION_URL', null), + 'topic' => env('WEBHOOK_NOTIFICATION_TOPIC', 'null'), + ], + ], + ]; diff --git a/database/migrations/2025_06_18_105902_add_battery_notification_sent_to_devices_table.php b/database/migrations/2025_06_18_105902_add_battery_notification_sent_to_devices_table.php new file mode 100644 index 0000000..ffe23bb --- /dev/null +++ b/database/migrations/2025_06_18_105902_add_battery_notification_sent_to_devices_table.php @@ -0,0 +1,28 @@ +boolean('battery_notification_sent')->default(false); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('devices', function (Blueprint $table) { + $table->dropColumn('battery_notification_sent'); + }); + } +}; diff --git a/resources/views/mail/battery-low.blade.php b/resources/views/mail/battery-low.blade.php new file mode 100644 index 0000000..34b3b63 --- /dev/null +++ b/resources/views/mail/battery-low.blade.php @@ -0,0 +1,7 @@ + +# Battery Low + +The battery of {{ $device->name }} is running below {{ $device->battery_percent }}%. Please charge your device soon. + +{{ config('app.name') }} + diff --git a/routes/console.php b/routes/console.php index 9aecc63..7dce7de 100644 --- a/routes/console.php +++ b/routes/console.php @@ -3,6 +3,7 @@ use App\Jobs\CleanupDeviceLogsJob; use App\Jobs\FetchProxyCloudResponses; use App\Jobs\FirmwarePollJob; +use App\Jobs\NotifyDeviceBatteryLowJob; use Illuminate\Support\Facades\Schedule; Schedule::job(FetchProxyCloudResponses::class, [])->cron( @@ -12,3 +13,4 @@ Schedule::job(FetchProxyCloudResponses::class, [])->cron( Schedule::job(FirmwarePollJob::class)->daily(); Schedule::job(CleanupDeviceLogsJob::class)->daily(); +Schedule::job(NotifyDeviceBatteryLowJob::class)->dailyAt('10:00');