feat(#17): add commands and jobs to poll, download and update firmware

feat(#17): add commands and jobs to poll, download and update firmware

feat(#17): update firmware modal

feat(#17): add tests
This commit is contained in:
Benjamin Nussbaum 2025-05-29 00:49:56 +02:00
parent 93aac51182
commit 87b9b57c3d
13 changed files with 567 additions and 3 deletions

View file

@ -0,0 +1,37 @@
<?php
namespace App\Console\Commands;
use App\Jobs\FirmwarePollJob;
use App\Models\Firmware;
use Illuminate\Console\Command;
use function Laravel\Prompts\spin;
use function Laravel\Prompts\table;
class FirmwareCheckCommand extends Command
{
protected $signature = 'trmnl:firmware:check {--download : Download the latest firmware if available}';
protected $description = 'Checks for the latest firmware and downloads it if flag --download is passed.';
public function handle(): void
{
spin(
callback: fn () => FirmwarePollJob::dispatchSync(download: $this->option('download')),
message: 'Checking for latest firmware...'
);
$latestFirmware = Firmware::getLatest();
if ($latestFirmware) {
table(
rows: [
['Latest Version', $latestFirmware->version_tag],
['Download URL', $latestFirmware->url],
['Storage Location', $latestFirmware->storage_location],
]
);
} else {
$this->error('No firmware found.');
}
}
}

View file

@ -0,0 +1,75 @@
<?php
namespace App\Console\Commands;
use App\Models\Device;
use App\Models\Firmware;
use Illuminate\Console\Command;
use function Laravel\Prompts\multiselect;
use function Laravel\Prompts\select;
class FirmwareUpdateCommand extends Command
{
protected $signature = 'trmnl:firmware:update';
protected $description = 'Command description';
public function handle(): void
{
$checkFirmware = select(
label: 'Check for new firmware?',
options: [
'check' => 'Check. Devices will download binary from the original source.',
'download' => 'Check & Download. Devices will download binary from BYOS.',
'no' => 'Do not check.',
],
);
if ($checkFirmware !== 'no') {
$this->call('trmnl:firmware:check', [
'--download' => $checkFirmware === 'download',
]);
}
$firmwareVersion = select(
label: 'Update to which version?',
options: Firmware::pluck('version_tag', 'id')
);
$devices = multiselect(
label: 'Which devices should be updated?',
options: [
'all' => 'ALL Devices',
...Device::all()->mapWithKeys(function ($device) {
// without _ returns index
return ["_$device->id" => "$device->name (Current version: $device->last_firmware_version)"];
})->toArray()
],
scroll: 10
);
if (empty($devices)) {
$this->error('No devices selected. Aborting.');
return;
}
if (in_array('all', $devices)) {
$devices = Device::pluck('id')->toArray();
} else {
$devices = array_map(function($selected) {
return (int) str_replace('_', '', $selected);
}, $devices);
}
foreach ($devices as $deviceId) {
Device::find($deviceId)->update(['update_firmware_id' => $firmwareVersion]);
$this->info("Device with id [$deviceId] will update firmware on next request.");
}
}
}

View file

@ -0,0 +1,47 @@
<?php
namespace App\Jobs;
use App\Models\Firmware;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
class FirmwareDownloadJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
private Firmware $firmware;
public function __construct(Firmware $firmware)
{
$this->firmware = $firmware;
}
public function handle(): void
{
if (! Storage::disk('public')->exists('firmwares')) {
Storage::disk('public')->makeDirectory('firmwares');
}
try {
$filename = "FW{$this->firmware->version_tag}.bin";
Http::sink(storage_path("app/public/firmwares/$filename"))
->get($this->firmware->url);
$this->firmware->update([
'storage_location' => "firmwares/$filename",
]);
} catch (ConnectionException $e) {
Log::error('Firmware download failed: '.$e->getMessage());
} catch (\Exception $e) {
Log::error('An unexpected error occurred: '.$e->getMessage());
}
}
}

View file

@ -0,0 +1,55 @@
<?php
namespace App\Jobs;
use App\Models\Firmware;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
class FirmwarePollJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
private bool $download;
public function __construct(bool $download = false)
{
$this->download = $download;
}
public function handle(): void
{
try {
$response = Http::get('https://usetrmnl.com/api/firmware/latest')->json();
if (!is_array($response) || !isset($response['version']) || !isset($response['url'])) {
\Log::error('Invalid firmware response format received');
return;
}
$latestFirmware = Firmware::updateOrCreate(
['version_tag' => $response['version']],
[
'url' => $response['url'],
'latest' => true,
]
);
Firmware::where('id', '!=', $latestFirmware->id)->update(['latest' => false]);
if ($this->download && $latestFirmware->url && $latestFirmware->storage_location === null) {
FirmwareDownloadJob::dispatchSync($latestFirmware);
}
} catch (ConnectionException $e) {
\Log::error('Firmware download failed: '.$e->getMessage());
} catch (\Exception $e) {
\Log::error('Unexpected error in firmware polling: '.$e->getMessage());
}
}
}

View file

@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Facades\Storage;
class Device extends Model
{
@ -63,6 +64,10 @@ class Device extends Model
return true;
}
if ($this->update_firmware_id) {
return true;
}
return false;
}
@ -72,6 +77,17 @@ class Device extends Model
return $this->proxy_cloud_response['firmware_url'];
}
if ($this->update_firmware_id) {
$firmware = Firmware::find($this->update_firmware_id);
if ($firmware) {
if ($firmware->storage_location) {
return Storage::disk('public')->url($firmware->storage_location);
}
return $firmware->url;
}
}
return null;
}
@ -81,6 +97,10 @@ class Device extends Model
$this->proxy_cloud_response = array_merge($this->proxy_cloud_response, ['update_firmware' => false]);
$this->save();
}
if ($this->update_firmware_id) {
$this->update_firmware_id = null;
$this->save();
}
}
public function playlists(): HasMany
@ -117,4 +137,9 @@ class Device extends Model
{
return $this->belongsTo(Device::class, 'mirror_device_id');
}
public function updateFirmware(): BelongsTo
{
return $this->belongsTo(Firmware::class, 'update_firmware_id');
}
}

25
app/Models/Firmware.php Normal file
View file

@ -0,0 +1,25 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Firmware extends Model
{
use HasFactory;
protected $guarded = ['id'];
protected function casts(): array
{
return [
'latest' => 'boolean',
];
}
public static function getLatest(): ?self
{
return self::where('latest', true)->first();
}
}