add features

* feat: autojoin toggle
* feat: auto add devices
* feat: proxy feature
* feat: support puppeteer in docker
* feat: toggle to activate cloud proxy
* feat: relay device information
* feat: relay logs to cloud
* feat: migrate on start
* feat: calculate battery state, wifi signal
* feat: eye candy for configure view
* feat: update via api
This commit is contained in:
Benjamin Nussbaum 2025-02-26 09:33:54 +01:00
parent d4eb832186
commit 715e6a2562
53 changed files with 1459 additions and 460 deletions

View file

@ -0,0 +1,18 @@
<?php
namespace App\Console\Commands;
use App\Jobs\FetchProxyCloudResponses;
use Illuminate\Console\Command;
class FetchProxyCloudResponsesCommand extends Command
{
protected $signature = 'trmnl:cloud:proxy';
protected $description = 'Fetch Cloud Screens';
public function handle(): void
{
FetchProxyCloudResponses::dispatchSync();
}
}

View file

@ -2,11 +2,8 @@
namespace App\Console\Commands;
use App\Models\Device;
use App\Jobs\GenerateScreenJob;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Storage;
use Ramsey\Uuid\Uuid;
use Spatie\Browsershot\Browsershot;
class ScreenGeneratorCommand extends Command
{
@ -22,101 +19,28 @@ class ScreenGeneratorCommand extends Command
*
* @var string
*/
protected $description = '';
protected $description = 'Generate a screen for a terminal device';
/**
* Execute the console command.
*/
public function handle()
{
$deviceId = $this->argument('deviceId');
$view = $this->argument('view');
$uuid = Uuid::uuid4()->toString();
$pngPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.png');
$bmpPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.bmp');
// Generate PNG
try {
Browsershot::html(view($view)->render())
->windowSize(800, 480)
->save($pngPath);
} catch (\Exception $e) {
$this->error('Failed to generate PNG: '.$e->getMessage());
$markup = view($view)->render();
} catch (\Throwable $e) {
$this->error('Failed to render view: '.$e->getMessage());
return;
return 1;
}
try {
$this->convertToBmpImageMagick($pngPath, $bmpPath);
GenerateScreenJob::dispatchSync($deviceId, $markup);
} catch (\ImagickException $e) {
$this->error('Failed to convert image to BMP: '.$e->getMessage());
}
$this->info('Screen generation job finished.');
Device::find($deviceId)->update(['current_screen_image' => $uuid]);
$this->cleanupFolder();
}
/**
* @throws \ImagickException
*/
public function convertToBmpImageMagick(string $pngPath, string $bmpPath): void
{
$imagick = new \Imagick($pngPath);
$imagick->setImageType(\Imagick::IMGTYPE_GRAYSCALE);
$imagick->quantizeImage(2, \Imagick::COLORSPACE_GRAY, 0, true, false);
$imagick->setImageDepth(1);
$imagick->stripImage();
$imagick->setFormat('BMP3');
$imagick->writeImage($bmpPath);
$imagick->clear();
}
// TODO retuns 8-bit BMP
// public function convertToBmpGD(string $pngPath, string $bmpPath): void
// {
// // Load the PNG image
// $image = imagecreatefrompng($pngPath);
//
// // Create a new true color image with the same dimensions
// $bwImage = imagecreatetruecolor(imagesx($image), imagesy($image));
//
// // Convert to black and white
// imagefilter($image, IMG_FILTER_GRAYSCALE);
//
// // Copy the grayscale image to the new image
// imagecopy($bwImage, $image, 0, 0, 0, 0, imagesx($image), imagesy($image));
//
// // Create a 1-bit palette
// imagetruecolortopalette($bwImage, true, 2);
//
// // Save as BMP
//
// imagebmp($bwImage, $bmpPath, false);
//
// // Free up memory
// imagedestroy($image);
// imagedestroy($bwImage);
// }
public function cleanupFolder(): void
{
$activeImageUuids = Device::pluck('current_screen_image')->filter()->toArray();
$files = Storage::disk('public')->files('/images/generated/');
foreach ($files as $file) {
if (basename($file) === '.gitignore') {
continue;
}
// Get filename without path and extension
$fileUuid = pathinfo($file, PATHINFO_FILENAME);
// If the UUID is not in use by any device, move it to archive
if (! in_array($fileUuid, $activeImageUuids)) {
Storage::disk('public')->delete($file);
}
}
return 0;
}
}

View file

@ -0,0 +1,95 @@
<?php
namespace App\Jobs;
use App\Models\Device;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
class FetchProxyCloudResponses implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Execute the job.
*/
public function handle(): void
{
Device::where('proxy_cloud', true)->each(function ($device) {
try {
$response = Http::withHeaders([
'id' => $device->mac_address,
'access-token' => $device->api_key,
'width' => 800,
'height' => 480,
'rssi' => $device->last_rssi_level,
'battery_voltage' => $device->last_battery_voltage,
'refresh-rate' => $device->default_refresh_interval,
'fw-version' => $device->last_firmware_version,
'accept-encoding' => 'identity;q=1,chunked;q=0.1,*;q=0',
'user-agent' => 'ESP32HTTPClient',
])->get(config('services.trmnl.proxy_base_url').'/api/display');
$device->update([
'proxy_cloud_response' => $response->json(),
]);
$imageUrl = $response->json('image_url');
$filename = $response->json('filename');
\Log::info('Response data: '.$imageUrl);
if (isset($imageUrl)) {
try {
$imageContents = Http::get($imageUrl)->body();
if (! Storage::disk('public')->exists("images/generated/{$filename}.bmp")) {
Storage::disk('public')->put(
"images/generated/{$filename}.bmp",
$imageContents
);
}
$device->update([
'current_screen_image' => $filename,
]);
} catch (\Exception $e) {
Log::error("Failed to download and save image for device: {$device->mac_address}", [
'error' => $e->getMessage(),
]);
}
}
Log::info("Successfully updated proxy cloud response for device: {$device->mac_address}");
if ($device->last_log_request) {
Http::withHeaders([
'id' => $device->mac_address,
'access-token' => $device->api_key,
'width' => 800,
'height' => 480,
'rssi' => $device->last_rssi_level,
'battery_voltage' => $device->last_battery_voltage,
'refresh-rate' => $device->default_refresh_interval,
'fw-version' => $device->last_firmware_version,
'accept-encoding' => 'identity;q=1,chunked;q=0.1,*;q=0',
'user-agent' => 'ESP32HTTPClient',
])->post(config('services.trmnl.proxy_base_url').'/api/log', $device->last_log_request);
$device->update([
'last_log_request' => null,
]);
}
} catch (\Exception $e) {
Log::error("Failed to fetch proxy cloud response for device: {$device->mac_address}", [
'error' => $e->getMessage(),
]);
}
});
}
}

View file

@ -0,0 +1,89 @@
<?php
namespace App\Jobs;
use App\Models\Device;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Storage;
use Ramsey\Uuid\Uuid;
use Spatie\Browsershot\Browsershot;
class GenerateScreenJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Create a new job instance.
*/
public function __construct(
private readonly int $deviceId,
private readonly string $markup
) {}
/**
* Execute the job.
*/
public function handle(): void
{
$uuid = Uuid::uuid4()->toString();
$pngPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.png');
$bmpPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.bmp');
// Generate PNG
try {
Browsershot::html($this->markup)
->setOption('args', config('app.puppeteer_docker') ? ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu'] : [])
->windowSize(800, 480)
->save($pngPath);
} catch (\Exception $e) {
throw new \RuntimeException('Failed to generate PNG: '.$e->getMessage(), 0, $e);
}
try {
$this->convertToBmpImageMagick($pngPath, $bmpPath);
} catch (\ImagickException $e) {
throw new \RuntimeException('Failed to convert image to BMP: '.$e->getMessage(), 0, $e);
}
Device::find($this->deviceId)->update(['current_screen_image' => $uuid]);
\Log::info("Device $this->deviceId: updated with new image: $uuid");
$this->cleanupFolder();
}
/**
* @throws \ImagickException
*/
private function convertToBmpImageMagick(string $pngPath, string $bmpPath): void
{
$imagick = new \Imagick($pngPath);
$imagick->setImageType(\Imagick::IMGTYPE_GRAYSCALE);
$imagick->quantizeImage(2, \Imagick::COLORSPACE_GRAY, 0, true, false);
$imagick->setImageDepth(1);
$imagick->stripImage();
$imagick->setFormat('BMP3');
$imagick->writeImage($bmpPath);
$imagick->clear();
}
private function cleanupFolder(): void
{
$activeImageUuids = Device::pluck('current_screen_image')->filter()->toArray();
$files = Storage::disk('public')->files('/images/generated/');
foreach ($files as $file) {
if (basename($file) === '.gitignore') {
continue;
}
// Get filename without path and extension
$fileUuid = pathinfo($file, PATHINFO_FILENAME);
// If the UUID is not in use by any device, move it to archive
if (! in_array($fileUuid, $activeImageUuids)) {
Storage::disk('public')->delete($file);
}
}
}
}

View file

@ -0,0 +1,37 @@
<?php
namespace App\Livewire\Actions;
use Livewire\Component;
class DeviceAutoJoin extends Component
{
public bool $deviceAutojoin = false;
public bool $isFirstUser = false;
public function mount()
{
$this->deviceAutojoin = auth()->user()->assign_new_devices;
$this->isFirstUser = auth()->user()->id === 1;
}
public function updating($name, $value)
{
$this->validate([
'deviceAutojoin' => 'boolean',
]);
if ($name === 'deviceAutojoin') {
auth()->user()->update([
'assign_new_devices' => $value,
]);
}
}
public function render()
{
return view('livewire.actions.device-auto-join');
}
}

View file

@ -1,55 +0,0 @@
<?php
namespace App\Livewire;
use App\Models\Device;
use Livewire\Component;
use Livewire\WithPagination;
class DeviceManager extends Component
{
use WithPagination;
public $showDeviceForm = false;
public $name;
public $mac_address;
public $api_key;
public $default_refresh_interval = 900;
public $friendly_id;
protected $rules = [
'mac_address' => 'required',
'api_key' => 'required',
'default_refresh_interval' => 'required|integer',
];
public function render()
{
return view('livewire.device-manager', [
'devices' => auth()->user()->devices()->paginate(10),
]);
}
public function createDevice(): void
{
$this->validate();
Device::factory([
'name' => $this->name,
'mac_address' => $this->mac_address,
'api_key' => $this->api_key,
'default_refresh_interval' => $this->default_refresh_interval,
'friendly_id' => $this->friendly_id,
'user_id' => auth()->id(),
])->create();
$this->reset();
\Flux::modal('create-device')->close();
session()->flash('message', 'Device created successfully.');
}
}

View file

@ -10,4 +10,44 @@ class Device extends Model
use HasFactory;
protected $guarded = ['id'];
protected $casts = [
'proxy_cloud' => 'boolean',
'last_log_request' => 'json',
];
public function getBatteryPercentAttribute()
{
$volts = $this->last_battery_voltage;
// Define min and max voltage for Li-ion battery (3.0V empty, 4.2V full)
$min_volt = 3.0;
$max_volt = 4.2;
// Ensure the voltage is within range
if ($volts <= $min_volt) {
return 0;
} elseif ($volts >= $max_volt) {
return 100;
}
// Calculate percentage
$percent = (($volts - $min_volt) / ($max_volt - $min_volt)) * 100;
return round($percent);
}
public function getWifiStrenghAttribute()
{
$rssi = $this->last_rssi_level;
if ($rssi >= 0) {
return 0; // No signal (0 bars)
} elseif ($rssi <= -80) {
return 1; // Weak signal (1 bar)
} elseif ($rssi <= -60) {
return 2; // Moderate signal (2 bars)
} else {
return 3; // Strong signal (3 bars)
}
}
}

View file

@ -24,6 +24,7 @@ class User extends Authenticatable // implements MustVerifyEmail
'name',
'email',
'password',
'assign_new_devices',
];
/**
@ -46,6 +47,7 @@ class User extends Authenticatable // implements MustVerifyEmail
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'assign_new_devices' => 'boolean',
];
}

View file

@ -19,6 +19,8 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
//
if (app()->isProduction() && config('app.force_https')) {
\URL::forceScheme('https');
}
}
}