diff --git a/app/Console/Commands/FirmwareCheckCommand.php b/app/Console/Commands/FirmwareCheckCommand.php new file mode 100644 index 0000000..be36824 --- /dev/null +++ b/app/Console/Commands/FirmwareCheckCommand.php @@ -0,0 +1,37 @@ + 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.'); + } + } +} diff --git a/app/Console/Commands/FirmwareUpdateCommand.php b/app/Console/Commands/FirmwareUpdateCommand.php new file mode 100644 index 0000000..66f3640 --- /dev/null +++ b/app/Console/Commands/FirmwareUpdateCommand.php @@ -0,0 +1,75 @@ + '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."); + } + } +} diff --git a/app/Jobs/FirmwareDownloadJob.php b/app/Jobs/FirmwareDownloadJob.php new file mode 100644 index 0000000..39cf199 --- /dev/null +++ b/app/Jobs/FirmwareDownloadJob.php @@ -0,0 +1,47 @@ +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()); + } + } +} \ No newline at end of file diff --git a/app/Jobs/FirmwarePollJob.php b/app/Jobs/FirmwarePollJob.php new file mode 100644 index 0000000..d2d7a12 --- /dev/null +++ b/app/Jobs/FirmwarePollJob.php @@ -0,0 +1,55 @@ +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()); + } + } +} diff --git a/app/Models/Device.php b/app/Models/Device.php index f7df91e..38c3ac6 100644 --- a/app/Models/Device.php +++ b/app/Models/Device.php @@ -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'); + } } diff --git a/app/Models/Firmware.php b/app/Models/Firmware.php new file mode 100644 index 0000000..63db578 --- /dev/null +++ b/app/Models/Firmware.php @@ -0,0 +1,25 @@ + 'boolean', + ]; + } + + public static function getLatest(): ?self + { + return self::where('latest', true)->first(); + } +} diff --git a/database/factories/FirmwareFactory.php b/database/factories/FirmwareFactory.php new file mode 100644 index 0000000..f0b27ee --- /dev/null +++ b/database/factories/FirmwareFactory.php @@ -0,0 +1,24 @@ + $this->faker->word(), + 'url' => $this->faker->url(), + 'latest' => $this->faker->boolean(), + 'storage_location' => $this->faker->word(), + 'created_at' => Carbon::now(), + 'updated_at' => Carbon::now(), + ]; + } +} diff --git a/database/migrations/2025_05_28_232528_create_firmware_table.php b/database/migrations/2025_05_28_232528_create_firmware_table.php new file mode 100644 index 0000000..e238629 --- /dev/null +++ b/database/migrations/2025_05_28_232528_create_firmware_table.php @@ -0,0 +1,25 @@ +id(); + $table->string('version_tag'); + $table->string('url')->nullable(); + $table->boolean('latest')->default(false); + $table->string('storage_location')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('firmware'); + } +}; diff --git a/database/migrations/2025_05_29_010428_add_update_firmware_id_to_devices_table.php b/database/migrations/2025_05_29_010428_add_update_firmware_id_to_devices_table.php new file mode 100644 index 0000000..fc5b99b --- /dev/null +++ b/database/migrations/2025_05_29_010428_add_update_firmware_id_to_devices_table.php @@ -0,0 +1,23 @@ +foreignId('update_firmware_id')->nullable()->constrained('firmware')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('devices', function (Blueprint $table) { + $table->dropForeign(['update_firmware_id']); + $table->dropColumn('update_firmware_id'); + }); + } +}; diff --git a/resources/views/livewire/devices/configure.blade.php b/resources/views/livewire/devices/configure.blade.php index 84cbce5..aabac4a 100644 --- a/resources/views/livewire/devices/configure.blade.php +++ b/resources/views/livewire/devices/configure.blade.php @@ -1,5 +1,7 @@ user()->devices->contains($device), 403); @@ -44,6 +51,8 @@ new class extends Component { $this->rotate = $device->rotate; $this->image_format = $device->image_format; $this->playlists = $device->playlists()->with('items.plugin')->orderBy('created_at')->get(); + $this->firmwares = \App\Models\Firmware::orderBy('latest', 'desc')->orderBy('created_at', 'desc')->get(); + $this->selected_firmware_id = $this->firmwares->where('latest', true)->first()?->id; return view('livewire.devices.configure', [ 'image' => ($current_image_uuid) ? url($current_image_path) : null, @@ -216,6 +225,26 @@ new class extends Component { $this->active_until = optional($playlist->active_until)->format('H:i'); $this->refresh_time = $playlist->refresh_time; } + + public function updateFirmware() + { + abort_unless(auth()->user()->devices->contains($this->device), 403); + + $this->validate([ + 'selected_firmware_id' => 'required|exists:firmware,id', + ]); + + + if ($this->download_firmware) { + FirmwareDownloadJob::dispatchSync(Firmware::find($this->selected_firmware_id)); + } + + $this->device->update([ + 'update_firmware_id' => $this->selected_firmware_id, + ]); + + Flux::modal('update-firmware')->close(); + } } ?> @@ -266,9 +295,18 @@ new class extends Component { - - - + + + + + + Update Firmware + + + Delete Device + + + @@ -309,6 +347,39 @@ new class extends Component { + +
+
+ Update Firmware + Select a firmware version to update to +
+ +
+
+ + @foreach($firmwares as $firmware) + + {{ $firmware->version_tag }} {{ $firmware->latest ? '(Latest)' : '' }} + + @endforeach + +
+ +
+ + + Check if the Device has no internet connection. + +
+ +
+ + Update Firmware +
+
+
+
+
Delete {{$device->name}}? diff --git a/routes/console.php b/routes/console.php index e125176..8265a81 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,8 +1,11 @@ cron( config('services.trmnl.proxy_refresh_cron') ? config('services.trmnl.proxy_refresh_cron') : sprintf('*/%s * * * *', intval(config('services.trmnl.proxy_refresh_minutes', 15))) ); + +Schedule::job(FirmwarePollJob::class)->daily(); diff --git a/tests/Feature/Jobs/FirmwareDownloadJobTest.php b/tests/Feature/Jobs/FirmwareDownloadJobTest.php new file mode 100644 index 0000000..30d9e29 --- /dev/null +++ b/tests/Feature/Jobs/FirmwareDownloadJobTest.php @@ -0,0 +1,38 @@ +makeDirectory('/firmwares'); +}); + +test('it creates firmwares directory if it does not exist', function () { + $firmware = Firmware::factory()->create([ + 'url' => 'https://example.com/firmware.bin', + 'version_tag' => '1.0.0', + ]); + + (new FirmwareDownloadJob($firmware))->handle(); + + expect(Storage::disk('public')->exists('firmwares'))->toBeTrue(); +}); + +test('it downloads firmware and updates storage location', function () { + Http::fake([ + 'https://example.com/firmware.bin' => Http::response('fake firmware content', 200), + ]); + + $firmware = Firmware::factory()->create([ + 'url' => 'https://example.com/firmware.bin', + 'version_tag' => '1.0.0', + ]); + + (new FirmwareDownloadJob($firmware))->handle(); + + expect($firmware->fresh()->storage_location)->toBe('firmwares/FW1.0.0.bin'); +}); diff --git a/tests/Feature/Jobs/FirmwarePollJobTest.php b/tests/Feature/Jobs/FirmwarePollJobTest.php new file mode 100644 index 0000000..4b91180 --- /dev/null +++ b/tests/Feature/Jobs/FirmwarePollJobTest.php @@ -0,0 +1,116 @@ + Http::response([ + 'version' => '1.0.0', + 'url' => 'https://example.com/firmware.bin' + ], 200) + ]); + + (new FirmwarePollJob())->handle(); + + expect(Firmware::where('version_tag', '1.0.0')->exists())->toBeTrue() + ->and(Firmware::where('version_tag', '1.0.0')->first()) + ->url->toBe('https://example.com/firmware.bin') + ->latest->toBeTrue(); +}); + +test('it updates existing firmware record when polling', function () { + $existingFirmware = Firmware::factory()->create([ + 'version_tag' => '1.0.0', + 'url' => 'https://old-url.com/firmware.bin', + 'latest' => true + ]); + + Http::fake([ + 'usetrmnl.com/api/firmware/latest' => Http::response([ + 'version' => '1.0.0', + 'url' => 'https://new-url.com/firmware.bin' + ], 200) + ]); + + (new FirmwarePollJob())->handle(); + + expect($existingFirmware->fresh()) + ->url->toBe('https://new-url.com/firmware.bin') + ->latest->toBeTrue(); +}); + +test('it marks previous firmware as not latest when new version is found', function () { + $oldFirmware = Firmware::factory()->create([ + 'version_tag' => '1.0.0', + 'latest' => true + ]); + + Http::fake([ + 'usetrmnl.com/api/firmware/latest' => Http::response([ + 'version' => '1.1.0', + 'url' => 'https://example.com/firmware.bin' + ], 200) + ]); + + (new FirmwarePollJob())->handle(); + + expect($oldFirmware->fresh()->latest)->toBeFalse() + ->and(Firmware::where('version_tag', '1.1.0')->first()->latest)->toBeTrue(); +}); + +test('it handles connection exception gracefully', function () { + Http::fake([ + 'usetrmnl.com/api/firmware/latest' => function () { + throw new ConnectionException('Connection failed'); + } + ]); + + (new FirmwarePollJob())->handle(); + + // Verify no firmware records were created + expect(Firmware::count())->toBe(0); +}); + +test('it handles invalid response gracefully', function () { + Http::fake([ + 'usetrmnl.com/api/firmware/latest' => Http::response(null, 200) + ]); + + (new FirmwarePollJob())->handle(); + + // Verify no firmware records were created + expect(Firmware::count())->toBe(0); +}); + +test('it handles missing version in response gracefully', function () { + Http::fake([ + 'usetrmnl.com/api/firmware/latest' => Http::response([ + 'url' => 'https://example.com/firmware.bin' + ], 200) + ]); + + (new FirmwarePollJob())->handle(); + + // Verify no firmware records were created + expect(Firmware::count())->toBe(0); +}); + +test('it handles missing url in response gracefully', function () { + Http::fake([ + 'usetrmnl.com/api/firmware/latest' => Http::response([ + 'version' => '1.0.0' + ], 200) + ]); + + (new FirmwarePollJob())->handle(); + + // Verify no firmware records were created + expect(Firmware::count())->toBe(0); +});