mirror of
https://github.com/usetrmnl/byos_laravel.git
synced 2026-01-13 15:07:49 +00:00
This commit is contained in:
parent
a88e72b75e
commit
ba3bf31bb7
29 changed files with 2379 additions and 215 deletions
46
app/Console/Commands/FetchDeviceModelsCommand.php
Normal file
46
app/Console/Commands/FetchDeviceModelsCommand.php
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\FetchDeviceModelsJob;
|
||||
use Exception;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
final class FetchDeviceModelsCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'device-models:fetch';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Fetch device models from the TRMNL API and update the database';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$this->info('Dispatching FetchDeviceModelsJob...');
|
||||
|
||||
try {
|
||||
FetchDeviceModelsJob::dispatchSync();
|
||||
|
||||
$this->info('FetchDeviceModelsJob has been dispatched successfully.');
|
||||
|
||||
return self::SUCCESS;
|
||||
} catch (Exception $e) {
|
||||
$this->error('Failed to dispatch FetchDeviceModelsJob: '.$e->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,7 @@ enum ImageFormat: string
|
|||
case PNG_8BIT_GRAYSCALE = 'png_8bit_grayscale';
|
||||
case BMP3_1BIT_SRGB = 'bmp3_1bit_srgb';
|
||||
case PNG_8BIT_256C = 'png_8bit_256c';
|
||||
case PNG_2BIT_4C = 'png_2bit_4c';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
|
|
@ -16,6 +17,7 @@ enum ImageFormat: string
|
|||
self::PNG_8BIT_GRAYSCALE => 'PNG 8-bit Grayscale Gray 2c',
|
||||
self::BMP3_1BIT_SRGB => 'BMP3 1-bit sRGB 2c',
|
||||
self::PNG_8BIT_256C => 'PNG 8-bit Grayscale Gray 256c',
|
||||
self::PNG_2BIT_4C => 'PNG 2-bit Grayscale 4c',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
125
app/Jobs/FetchDeviceModelsJob.php
Normal file
125
app/Jobs/FetchDeviceModelsJob.php
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\DeviceModel;
|
||||
use Exception;
|
||||
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;
|
||||
|
||||
final class FetchDeviceModelsJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
private const API_URL = 'https://usetrmnl.com/api/models';
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
try {
|
||||
$response = Http::timeout(30)->get(self::API_URL);
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::error('Failed to fetch device models from API', [
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$data = $response->json('data', []);
|
||||
|
||||
if (! is_array($data)) {
|
||||
Log::error('Invalid response format from device models API', [
|
||||
'response' => $response->json(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->processDeviceModels($data);
|
||||
|
||||
Log::info('Successfully fetched and updated device models', [
|
||||
'count' => count($data),
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
Log::error('Exception occurred while fetching device models', [
|
||||
'message' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the device models data and update/create records.
|
||||
*/
|
||||
private function processDeviceModels(array $deviceModels): void
|
||||
{
|
||||
foreach ($deviceModels as $modelData) {
|
||||
try {
|
||||
$this->updateOrCreateDeviceModel($modelData);
|
||||
} catch (Exception $e) {
|
||||
Log::error('Failed to process device model', [
|
||||
'model_data' => $modelData,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update or create a device model record.
|
||||
*/
|
||||
private function updateOrCreateDeviceModel(array $modelData): void
|
||||
{
|
||||
$name = $modelData['name'] ?? null;
|
||||
|
||||
if (! $name) {
|
||||
Log::warning('Device model data missing name field', [
|
||||
'model_data' => $modelData,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$attributes = [
|
||||
'label' => $modelData['label'] ?? '',
|
||||
'description' => $modelData['description'] ?? '',
|
||||
'width' => $modelData['width'] ?? 0,
|
||||
'height' => $modelData['height'] ?? 0,
|
||||
'colors' => $modelData['colors'] ?? 0,
|
||||
'bit_depth' => $modelData['bit_depth'] ?? 0,
|
||||
'scale_factor' => $modelData['scale_factor'] ?? 1,
|
||||
'rotation' => $modelData['rotation'] ?? 0,
|
||||
'mime_type' => $modelData['mime_type'] ?? '',
|
||||
'offset_x' => $modelData['offset_x'] ?? 0,
|
||||
'offset_y' => $modelData['offset_y'] ?? 0,
|
||||
'published_at' => $modelData['published_at'] ?? null,
|
||||
'source' => 'api',
|
||||
];
|
||||
|
||||
DeviceModel::updateOrCreate(
|
||||
['name' => $name],
|
||||
$attributes
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -78,6 +78,7 @@ class FetchProxyCloudResponses implements ShouldQueue
|
|||
Log::info("Successfully updated proxy cloud response for device: {$device->mac_address}");
|
||||
|
||||
if ($device->last_log_request) {
|
||||
try {
|
||||
Http::withHeaders([
|
||||
'id' => $device->mac_address,
|
||||
'access-token' => $device->api_key,
|
||||
|
|
@ -91,9 +92,16 @@ class FetchProxyCloudResponses implements ShouldQueue
|
|||
'user-agent' => 'ESP32HTTPClient',
|
||||
])->post(config('services.trmnl.proxy_base_url').'/api/log', $device->last_log_request);
|
||||
|
||||
// Only clear the pending log request if the POST succeeded
|
||||
$device->update([
|
||||
'last_log_request' => null,
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
// Do not fail the entire proxy fetch if the log upload fails
|
||||
Log::error("Failed to upload device log for device: {$device->mac_address}", [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
|
|
|
|||
|
|
@ -182,7 +182,10 @@ class Device extends Model
|
|||
{
|
||||
return $this->belongsTo(Firmware::class, 'update_firmware_id');
|
||||
}
|
||||
|
||||
public function deviceModel(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(DeviceModel::class);
|
||||
}
|
||||
public function logs(): HasMany
|
||||
{
|
||||
return $this->hasMany(DeviceLog::class);
|
||||
|
|
|
|||
27
app/Models/DeviceModel.php
Normal file
27
app/Models/DeviceModel.php
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
final class DeviceModel extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected $casts = [
|
||||
'width' => 'integer',
|
||||
'height' => 'integer',
|
||||
'colors' => 'integer',
|
||||
'bit_depth' => 'integer',
|
||||
'scale_factor' => 'float',
|
||||
'rotation' => 'integer',
|
||||
'offset_x' => 'integer',
|
||||
'offset_y' => 'integer',
|
||||
'published_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ namespace App\Services;
|
|||
|
||||
use App\Enums\ImageFormat;
|
||||
use App\Models\Device;
|
||||
use App\Models\DeviceModel;
|
||||
use App\Models\Plugin;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
|
@ -20,11 +21,14 @@ class ImageGenerationService
|
|||
{
|
||||
public static function generateImage(string $markup, $deviceId): string
|
||||
{
|
||||
$device = Device::find($deviceId);
|
||||
$device = Device::with('deviceModel')->find($deviceId);
|
||||
$uuid = Uuid::uuid4()->toString();
|
||||
$pngPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.png');
|
||||
$bmpPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.bmp');
|
||||
|
||||
// Get image generation settings from DeviceModel if available, otherwise use device settings
|
||||
$imageSettings = self::getImageSettings($device);
|
||||
|
||||
// Generate PNG
|
||||
if (config('app.puppeteer_mode') === 'sidecar-aws') {
|
||||
try {
|
||||
|
|
@ -43,19 +47,219 @@ class ImageGenerationService
|
|||
} else {
|
||||
try {
|
||||
$browsershot = Browsershot::html($markup)
|
||||
->setOption('args', config('app.puppeteer_docker') ? ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu'] : [])
|
||||
->windowSize(800, 480);
|
||||
|
||||
->setOption('args', config('app.puppeteer_docker') ? ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu'] : []);
|
||||
if (config('app.puppeteer_wait_for_network_idle')) {
|
||||
$browsershot->waitUntilNetworkIdle();
|
||||
}
|
||||
if (config('app.puppeteer_window_size_strategy') == 'v2') {
|
||||
$browsershot->windowSize($imageSettings['width'], $imageSettings['height']);
|
||||
} else {
|
||||
$browsershot->windowSize(800, 480);
|
||||
}
|
||||
$browsershot->save($pngPath);
|
||||
} catch (Exception $e) {
|
||||
Log::error('Failed to generate PNG: '.$e->getMessage());
|
||||
throw new RuntimeException('Failed to generate PNG: '.$e->getMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
switch ($device->image_format) {
|
||||
|
||||
// Convert image based on DeviceModel settings or fallback to device settings
|
||||
self::convertImage($pngPath, $bmpPath, $imageSettings);
|
||||
|
||||
$device->update(['current_screen_image' => $uuid]);
|
||||
Log::info("Device $device->id: updated with new image: $uuid");
|
||||
|
||||
return $uuid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image generation settings from DeviceModel if available, otherwise use device settings
|
||||
*/
|
||||
private static function getImageSettings(Device $device): array
|
||||
{
|
||||
// If device has a DeviceModel, use its settings
|
||||
if ($device->deviceModel) {
|
||||
/** @var DeviceModel $model */
|
||||
$model = $device->deviceModel;
|
||||
|
||||
return [
|
||||
'width' => $model->width,
|
||||
'height' => $model->height,
|
||||
'colors' => $model->colors,
|
||||
'bit_depth' => $model->bit_depth,
|
||||
'scale_factor' => $model->scale_factor,
|
||||
'rotation' => $model->rotation,
|
||||
'mime_type' => $model->mime_type,
|
||||
'offset_x' => $model->offset_x,
|
||||
'offset_y' => $model->offset_y,
|
||||
'image_format' => self::determineImageFormatFromModel($model),
|
||||
'use_model_settings' => true,
|
||||
];
|
||||
}
|
||||
|
||||
// Fallback to device settings
|
||||
return [
|
||||
'width' => $device->width ?? 800,
|
||||
'height' => $device->height ?? 480,
|
||||
'colors' => 2,
|
||||
'bit_depth' => 1,
|
||||
'scale_factor' => 1.0,
|
||||
'rotation' => $device->rotate ?? 0,
|
||||
'mime_type' => 'image/png',
|
||||
'offset_x' => 0,
|
||||
'offset_y' => 0,
|
||||
'image_format' => $device->image_format,
|
||||
'use_model_settings' => false,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the appropriate ImageFormat based on DeviceModel settings
|
||||
*/
|
||||
private static function determineImageFormatFromModel(DeviceModel $model): string
|
||||
{
|
||||
// Map DeviceModel settings to ImageFormat
|
||||
if ($model->mime_type === 'image/bmp' && $model->bit_depth === 1) {
|
||||
return ImageFormat::BMP3_1BIT_SRGB->value;
|
||||
}
|
||||
if ($model->mime_type === 'image/png' && $model->bit_depth === 8 && $model->colors === 2) {
|
||||
return ImageFormat::PNG_8BIT_GRAYSCALE->value;
|
||||
}
|
||||
if ($model->mime_type === 'image/png' && $model->bit_depth === 8 && $model->colors === 256) {
|
||||
return ImageFormat::PNG_8BIT_256C->value;
|
||||
}
|
||||
if ($model->mime_type === 'image/png' && $model->bit_depth === 2 && $model->colors === 4) {
|
||||
return ImageFormat::PNG_2BIT_4C->value;
|
||||
}
|
||||
|
||||
// Default to AUTO for unknown combinations
|
||||
return ImageFormat::AUTO->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert image based on the provided settings
|
||||
*/
|
||||
private static function convertImage(string $pngPath, string $bmpPath, array $settings): void
|
||||
{
|
||||
$imageFormat = $settings['image_format'];
|
||||
$useModelSettings = $settings['use_model_settings'] ?? false;
|
||||
|
||||
if ($useModelSettings) {
|
||||
// Use DeviceModel-specific conversion
|
||||
self::convertUsingModelSettings($pngPath, $bmpPath, $settings);
|
||||
} else {
|
||||
// Use legacy device-specific conversion
|
||||
self::convertUsingLegacySettings($pngPath, $bmpPath, $imageFormat, $settings);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert image using DeviceModel settings
|
||||
*/
|
||||
private static function convertUsingModelSettings(string $pngPath, string $bmpPath, array $settings): void
|
||||
{
|
||||
try {
|
||||
$imagick = new Imagick($pngPath);
|
||||
|
||||
// Apply scale factor if needed
|
||||
if ($settings['scale_factor'] !== 1.0) {
|
||||
$newWidth = (int) ($settings['width'] * $settings['scale_factor']);
|
||||
$newHeight = (int) ($settings['height'] * $settings['scale_factor']);
|
||||
$imagick->resizeImage($newWidth, $newHeight, Imagick::FILTER_LANCZOS, 1, true);
|
||||
} else {
|
||||
// Resize to model dimensions if different from generated size
|
||||
if ($imagick->getImageWidth() !== $settings['width'] || $imagick->getImageHeight() !== $settings['height']) {
|
||||
$imagick->resizeImage($settings['width'], $settings['height'], Imagick::FILTER_LANCZOS, 1, true);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply rotation
|
||||
if ($settings['rotation'] !== 0) {
|
||||
$imagick->rotateImage(new ImagickPixel('black'), $settings['rotation']);
|
||||
}
|
||||
|
||||
// Apply offset if specified
|
||||
if ($settings['offset_x'] !== 0 || $settings['offset_y'] !== 0) {
|
||||
$imagick->rollImage($settings['offset_x'], $settings['offset_y']);
|
||||
}
|
||||
|
||||
// Handle special case for 4-color, 2-bit PNG
|
||||
if ($settings['colors'] === 4 && $settings['bit_depth'] === 2 && $settings['mime_type'] === 'image/png') {
|
||||
self::convertTo4Color2BitPng($imagick, $settings['width'], $settings['height']);
|
||||
} else {
|
||||
// Set image type and color depth based on model settings
|
||||
$imagick->setImageType(Imagick::IMGTYPE_GRAYSCALE);
|
||||
|
||||
if ($settings['bit_depth'] === 1) {
|
||||
$imagick->quantizeImage(2, Imagick::COLORSPACE_GRAY, 0, true, false);
|
||||
$imagick->setImageDepth(1);
|
||||
} else {
|
||||
$imagick->quantizeImage($settings['colors'], Imagick::COLORSPACE_GRAY, 0, true, false);
|
||||
$imagick->setImageDepth($settings['bit_depth']);
|
||||
}
|
||||
}
|
||||
|
||||
$imagick->stripImage();
|
||||
|
||||
// Save in the appropriate format
|
||||
if ($settings['mime_type'] === 'image/bmp') {
|
||||
$imagick->setFormat('BMP3');
|
||||
$imagick->writeImage($bmpPath);
|
||||
} else {
|
||||
$imagick->setFormat('png');
|
||||
$imagick->writeImage($pngPath);
|
||||
}
|
||||
|
||||
$imagick->clear();
|
||||
} catch (ImagickException $e) {
|
||||
throw new RuntimeException('Failed to convert image using model settings: '.$e->getMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert image to 4-color, 2-bit PNG using custom colormap and dithering
|
||||
*/
|
||||
private static function convertTo4Color2BitPng(Imagick $imagick, int $width, int $height): void
|
||||
{
|
||||
// Step 1: Create 4-color grayscale colormap in memory
|
||||
$colors = ['#000000', '#555555', '#aaaaaa', '#ffffff'];
|
||||
$colormap = new Imagick();
|
||||
|
||||
foreach ($colors as $color) {
|
||||
$swatch = new Imagick();
|
||||
$swatch->newImage(1, 1, new ImagickPixel($color));
|
||||
$swatch->setImageFormat('png');
|
||||
$colormap->addImage($swatch);
|
||||
}
|
||||
|
||||
$colormap = $colormap->appendImages(true); // horizontal
|
||||
$colormap->setType(Imagick::IMGTYPE_PALETTE);
|
||||
$colormap->setImageFormat('png');
|
||||
|
||||
// Step 2: Resize to target dimensions without keeping aspect ratio
|
||||
$imagick->resizeImage($width, $height, Imagick::FILTER_LANCZOS, 1, false);
|
||||
|
||||
// Step 3: Apply Floyd–Steinberg dithering
|
||||
$imagick->setOption('dither', 'FloydSteinberg');
|
||||
|
||||
// Step 4: Remap to our 4-color colormap
|
||||
// $imagick->remapImage($colormap, Imagick::DITHERMETHOD_FLOYDSTEINBERG);
|
||||
|
||||
// Step 5: Force 2-bit grayscale PNG
|
||||
$imagick->setImageFormat('png');
|
||||
$imagick->setImageDepth(2);
|
||||
$imagick->setType(Imagick::IMGTYPE_GRAYSCALE);
|
||||
|
||||
// Cleanup colormap
|
||||
$colormap->clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert image using legacy device settings
|
||||
*/
|
||||
private static function convertUsingLegacySettings(string $pngPath, string $bmpPath, string $imageFormat, array $settings): void
|
||||
{
|
||||
switch ($imageFormat) {
|
||||
case ImageFormat::BMP3_1BIT_SRGB->value:
|
||||
try {
|
||||
self::convertToBmpImageMagick($pngPath, $bmpPath);
|
||||
|
|
@ -66,35 +270,24 @@ class ImageGenerationService
|
|||
case ImageFormat::PNG_8BIT_GRAYSCALE->value:
|
||||
case ImageFormat::PNG_8BIT_256C->value:
|
||||
try {
|
||||
self::convertToPngImageMagick($pngPath, $device->width, $device->height, $device->rotate, quantize: $device->image_format === ImageFormat::PNG_8BIT_GRAYSCALE->value);
|
||||
self::convertToPngImageMagick($pngPath, $settings['width'], $settings['height'], $settings['rotation'], quantize: $imageFormat === ImageFormat::PNG_8BIT_GRAYSCALE->value);
|
||||
} catch (ImagickException $e) {
|
||||
throw new RuntimeException('Failed to convert image to PNG: '.$e->getMessage(), 0, $e);
|
||||
}
|
||||
break;
|
||||
case ImageFormat::AUTO->value:
|
||||
default:
|
||||
if (isset($device->last_firmware_version)
|
||||
&& version_compare($device->last_firmware_version, '1.5.2', '<')) {
|
||||
// For AUTO format, we need to check if this is a legacy device
|
||||
// This would require checking if the device has a firmware version
|
||||
// For now, we'll use the device's current logic
|
||||
try {
|
||||
self::convertToBmpImageMagick($pngPath, $bmpPath);
|
||||
} catch (ImagickException $e) {
|
||||
throw new RuntimeException('Failed to convert image to BMP: '.$e->getMessage(), 0, $e);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
self::convertToPngImageMagick($pngPath, $device->width, $device->height, $device->rotate);
|
||||
self::convertToPngImageMagick($pngPath, $settings['width'], $settings['height'], $settings['rotation']);
|
||||
} catch (ImagickException $e) {
|
||||
throw new RuntimeException('Failed to convert image to PNG: '.$e->getMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$device->update(['current_screen_image' => $uuid]);
|
||||
Log::info("Device $device->id: updated with new image: $uuid");
|
||||
|
||||
return $uuid;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws ImagickException
|
||||
*/
|
||||
|
|
@ -124,6 +317,7 @@ class ImageGenerationService
|
|||
}
|
||||
|
||||
$imagick->setImageType(Imagick::IMGTYPE_GRAYSCALE);
|
||||
$imagick->setOption('dither', 'FloydSteinberg');
|
||||
|
||||
if ($quantize) {
|
||||
$imagick->quantizeImage(2, Imagick::COLORSPACE_GRAY, 0, true, false);
|
||||
|
|
@ -159,16 +353,20 @@ class ImageGenerationService
|
|||
public static function resetIfNotCacheable(?Plugin $plugin): void
|
||||
{
|
||||
if ($plugin?->id) {
|
||||
if (
|
||||
Device::query()
|
||||
->where('width', '!=', 800)
|
||||
// Check if any devices have custom dimensions or use DeviceModels
|
||||
$hasCustomDimensions = Device::query()
|
||||
->where(function ($query) {
|
||||
$query->where('width', '!=', 800)
|
||||
->orWhere('height', '!=', 480)
|
||||
->orWhere('rotate', '!=', 0)
|
||||
->exists()
|
||||
) {
|
||||
->orWhere('rotate', '!=', 0);
|
||||
})
|
||||
->orWhereNotNull('device_model_id')
|
||||
->exists();
|
||||
|
||||
if ($hasCustomDimensions) {
|
||||
// TODO cache image per device
|
||||
$plugin->update(['current_image' => null]);
|
||||
Log::debug('Skip cache as devices with other dimensions exist');
|
||||
Log::debug('Skip cache as devices with custom dimensions or DeviceModels exist');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -131,6 +131,7 @@ return [
|
|||
'puppeteer_docker' => env('PUPPETEER_DOCKER', false),
|
||||
'puppeteer_mode' => env('PUPPETEER_MODE', 'local'),
|
||||
'puppeteer_wait_for_network_idle' => env('PUPPETEER_WAIT_FOR_NETWORK_IDLE', false),
|
||||
'puppeteer_window_size_strategy' => env('PUPPETEER_WINDOW_SIZE_STRATEGY', null),
|
||||
|
||||
'notifications' => [
|
||||
'battery_low' => [
|
||||
|
|
|
|||
38
database/factories/DeviceModelFactory.php
Normal file
38
database/factories/DeviceModelFactory.php
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\DeviceModel;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\DeviceModel>
|
||||
*/
|
||||
class DeviceModelFactory extends Factory
|
||||
{
|
||||
protected $model = DeviceModel::class;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->faker->unique()->slug(),
|
||||
'label' => $this->faker->words(2, true),
|
||||
'description' => $this->faker->sentence(),
|
||||
'width' => $this->faker->randomElement([800, 1024, 1280, 1920]),
|
||||
'height' => $this->faker->randomElement([480, 600, 720, 1080]),
|
||||
'colors' => $this->faker->randomElement([2, 16, 256, 65536]),
|
||||
'bit_depth' => $this->faker->randomElement([1, 4, 8, 16]),
|
||||
'scale_factor' => $this->faker->randomElement([1, 2, 4]),
|
||||
'rotation' => $this->faker->randomElement([0, 90, 180, 270]),
|
||||
'mime_type' => $this->faker->randomElement(['image/png', 'image/jpeg', 'image/gif']),
|
||||
'offset_x' => $this->faker->numberBetween(-100, 100),
|
||||
'offset_y' => $this->faker->numberBetween(-100, 100),
|
||||
'published_at' => $this->faker->optional()->dateTimeBetween('-1 year', 'now'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('device_models', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name')->unique();
|
||||
$table->string('label');
|
||||
$table->text('description');
|
||||
$table->unsignedInteger('width');
|
||||
$table->unsignedInteger('height');
|
||||
$table->unsignedInteger('colors');
|
||||
$table->unsignedInteger('bit_depth');
|
||||
$table->float('scale_factor');
|
||||
$table->integer('rotation');
|
||||
$table->string('mime_type');
|
||||
$table->integer('offset_x')->default(0);
|
||||
$table->integer('offset_y')->default(0);
|
||||
$table->timestamp('published_at')->nullable();
|
||||
$table->string('source')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('device_models');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('devices', function (Blueprint $table) {
|
||||
$table->foreignId('device_model_id')->nullable()->constrained('device_models')->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('devices', function (Blueprint $table) {
|
||||
$table->dropForeign(['device_model_id']);
|
||||
$table->dropColumn('device_model_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
285
database/migrations/2025_08_16_135740_seed_device_models.php
Normal file
285
database/migrations/2025_08_16_135740_seed_device_models.php
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
<?php
|
||||
|
||||
use App\Models\DeviceModel;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
$deviceModels = [
|
||||
[
|
||||
'name' => 'og_png',
|
||||
'label' => 'TRMNL OG (1-bit)',
|
||||
'description' => 'TRMNL OG (1-bit)',
|
||||
'width' => 800,
|
||||
'height' => 480,
|
||||
'colors' => 2,
|
||||
'bit_depth' => 1,
|
||||
'scale_factor' => 1,
|
||||
'rotation' => 0,
|
||||
'mime_type' => 'image/png',
|
||||
'offset_x' => 0,
|
||||
'offset_y' => 0,
|
||||
'published_at' => '2024-01-01 00:00:00',
|
||||
'source' => 'api',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
[
|
||||
'name' => 'og_plus',
|
||||
'label' => 'TRMNL OG (2-bit)',
|
||||
'description' => 'TRMNL OG (2-bit)',
|
||||
'width' => 800,
|
||||
'height' => 480,
|
||||
'colors' => 4,
|
||||
'bit_depth' => 2,
|
||||
'scale_factor' => 1,
|
||||
'rotation' => 0,
|
||||
'mime_type' => 'image/png',
|
||||
'offset_x' => 0,
|
||||
'offset_y' => 0,
|
||||
'published_at' => '2024-01-01 00:00:00',
|
||||
'source' => 'api',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
[
|
||||
'name' => 'amazon_kindle_2024',
|
||||
'label' => 'Amazon Kindle 2024',
|
||||
'description' => 'Amazon Kindle 2024',
|
||||
'width' => 1400,
|
||||
'height' => 840,
|
||||
'colors' => 256,
|
||||
'bit_depth' => 8,
|
||||
'scale_factor' => 2.414,
|
||||
'rotation' => 90,
|
||||
'mime_type' => 'image/png',
|
||||
'offset_x' => 75,
|
||||
'offset_y' => 25,
|
||||
'published_at' => '2024-01-01 00:00:00',
|
||||
'source' => 'api',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
[
|
||||
'name' => 'amazon_kindle_paperwhite_6th_gen',
|
||||
'label' => 'Amazon Kindle PW 6th Gen',
|
||||
'description' => 'Amazon Kindle PW 6th Gen',
|
||||
'width' => 1024,
|
||||
'height' => 768,
|
||||
'colors' => 256,
|
||||
'bit_depth' => 8,
|
||||
'scale_factor' => 1,
|
||||
'rotation' => 90,
|
||||
'mime_type' => 'image/png',
|
||||
'offset_x' => 0,
|
||||
'offset_y' => 0,
|
||||
'published_at' => '2024-01-01 00:00:00',
|
||||
'source' => 'api',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
[
|
||||
'name' => 'amazon_kindle_paperwhite_7th_gen',
|
||||
'label' => 'Amazon Kindle PW 7th Gen',
|
||||
'description' => 'Amazon Kindle PW 7th Gen',
|
||||
'width' => 1448,
|
||||
'height' => 1072,
|
||||
'colors' => 256,
|
||||
'bit_depth' => 8,
|
||||
'scale_factor' => 1,
|
||||
'rotation' => 90,
|
||||
'mime_type' => 'image/png',
|
||||
'offset_x' => 0,
|
||||
'offset_y' => 0,
|
||||
'published_at' => '2024-01-01 00:00:00',
|
||||
'source' => 'api',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
[
|
||||
'name' => 'inkplate_10',
|
||||
'label' => 'Inkplate 10',
|
||||
'description' => 'Inkplate 10',
|
||||
'width' => 1200,
|
||||
'height' => 820,
|
||||
'colors' => 8,
|
||||
'bit_depth' => 3,
|
||||
'scale_factor' => 1,
|
||||
'rotation' => 0,
|
||||
'mime_type' => 'image/png',
|
||||
'offset_x' => 0,
|
||||
'offset_y' => 0,
|
||||
'published_at' => '2024-01-01 00:00:00',
|
||||
'source' => 'api',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
[
|
||||
'name' => 'amazon_kindle_7',
|
||||
'label' => 'Amazon Kindle 7',
|
||||
'description' => 'Amazon Kindle 7',
|
||||
'width' => 800,
|
||||
'height' => 600,
|
||||
'colors' => 256,
|
||||
'bit_depth' => 8,
|
||||
'scale_factor' => 1,
|
||||
'rotation' => 90,
|
||||
'mime_type' => 'image/png',
|
||||
'offset_x' => 0,
|
||||
'offset_y' => 0,
|
||||
'published_at' => '2024-01-01 00:00:00',
|
||||
'source' => 'api',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
[
|
||||
'name' => 'inky_impression_7_3',
|
||||
'label' => 'Inky Impression 7.3',
|
||||
'description' => 'Inky Impression 7.3',
|
||||
'width' => 800,
|
||||
'height' => 480,
|
||||
'colors' => 2,
|
||||
'bit_depth' => 1,
|
||||
'scale_factor' => 1,
|
||||
'rotation' => 0,
|
||||
'mime_type' => 'image/png',
|
||||
'offset_x' => 0,
|
||||
'offset_y' => 0,
|
||||
'published_at' => '2024-01-01 00:00:00',
|
||||
'source' => 'api',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
[
|
||||
'name' => 'kobo_libra_2',
|
||||
'label' => 'Kobo Libra 2',
|
||||
'description' => 'Kobo Libra 2',
|
||||
'width' => 1680,
|
||||
'height' => 1264,
|
||||
'colors' => 256,
|
||||
'bit_depth' => 8,
|
||||
'scale_factor' => 1,
|
||||
'rotation' => 90,
|
||||
'mime_type' => 'image/png',
|
||||
'offset_x' => 0,
|
||||
'offset_y' => 0,
|
||||
'published_at' => '2024-01-01 00:00:00',
|
||||
'source' => 'api',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
[
|
||||
'name' => 'amazon_kindle_oasis_2',
|
||||
'label' => 'Amazon Kindle Oasis 2',
|
||||
'description' => 'Amazon Kindle Oasis 2',
|
||||
'width' => 1680,
|
||||
'height' => 1264,
|
||||
'colors' => 256,
|
||||
'bit_depth' => 8,
|
||||
'scale_factor' => 1,
|
||||
'rotation' => 90,
|
||||
'mime_type' => 'image/png',
|
||||
'offset_x' => 0,
|
||||
'offset_y' => 0,
|
||||
'published_at' => '2024-01-01 00:00:00',
|
||||
'source' => 'api',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
[
|
||||
'name' => 'kobo_aura_one',
|
||||
'label' => 'Kobo Aura One',
|
||||
'description' => 'Kobo Aura One',
|
||||
'width' => 1872,
|
||||
'height' => 1404,
|
||||
'colors' => 256,
|
||||
'bit_depth' => 8,
|
||||
'scale_factor' => 1,
|
||||
'rotation' => 90,
|
||||
'mime_type' => 'image/png',
|
||||
'offset_x' => 0,
|
||||
'offset_y' => 0,
|
||||
'published_at' => '2024-01-01 00:00:00',
|
||||
'source' => 'api',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
[
|
||||
'name' => 'kobo_aura_hd',
|
||||
'label' => 'Kobo Aura HD',
|
||||
'description' => 'Kobo Aura HD',
|
||||
'width' => 1440,
|
||||
'height' => 1080,
|
||||
'colors' => 16,
|
||||
'bit_depth' => 4,
|
||||
'scale_factor' => 1,
|
||||
'rotation' => 90,
|
||||
'mime_type' => 'image/png',
|
||||
'offset_x' => 0,
|
||||
'offset_y' => 0,
|
||||
'published_at' => '2024-01-01 00:00:00',
|
||||
'source' => 'api',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
[
|
||||
'name' => 'inky_impression_13_3',
|
||||
'label' => 'Inky Impression 13.3',
|
||||
'description' => 'Inky Impression 13.3',
|
||||
'width' => 1600,
|
||||
'height' => 1200,
|
||||
'colors' => 2,
|
||||
'bit_depth' => 1,
|
||||
'scale_factor' => 1,
|
||||
'rotation' => 0,
|
||||
'mime_type' => 'image/png',
|
||||
'offset_x' => 0,
|
||||
'offset_y' => 0,
|
||||
'published_at' => '2024-01-01 00:00:00',
|
||||
'source' => 'api',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
];
|
||||
|
||||
// Upsert by unique 'name' to avoid duplicates and keep data fresh
|
||||
DeviceModel::query()->upsert(
|
||||
$deviceModels,
|
||||
['name'],
|
||||
[
|
||||
'label', 'description', 'width', 'height', 'colors', 'bit_depth', 'scale_factor',
|
||||
'rotation', 'mime_type', 'offset_x', 'offset_y', 'published_at', 'source',
|
||||
'created_at', 'updated_at',
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
$names = [
|
||||
'og_png',
|
||||
'amazon_kindle_2024',
|
||||
'amazon_kindle_paperwhite_6th_gen',
|
||||
'amazon_kindle_paperwhite_7th_gen',
|
||||
'inkplate_10',
|
||||
'amazon_kindle_7',
|
||||
'inky_impression_7_3',
|
||||
'kobo_libra_2',
|
||||
'amazon_kindle_oasis_2',
|
||||
'og_plus',
|
||||
'kobo_aura_one',
|
||||
'kobo_aura_hd',
|
||||
'inky_impression_13_3',
|
||||
];
|
||||
|
||||
DeviceModel::query()->whereIn('name', $names)->delete();
|
||||
}
|
||||
};
|
||||
389
resources/views/livewire/device-models/index.blade.php
Normal file
389
resources/views/livewire/device-models/index.blade.php
Normal file
|
|
@ -0,0 +1,389 @@
|
|||
<?php
|
||||
|
||||
use App\Models\DeviceModel;
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
new class extends Component {
|
||||
|
||||
public $deviceModels;
|
||||
|
||||
public $name;
|
||||
public $label;
|
||||
public $description;
|
||||
public $width;
|
||||
public $height;
|
||||
public $colors;
|
||||
public $bit_depth;
|
||||
public $scale_factor = 1.0;
|
||||
public $rotation = 0;
|
||||
public $mime_type = 'image/png';
|
||||
public $offset_x = 0;
|
||||
public $offset_y = 0;
|
||||
public $published_at;
|
||||
|
||||
protected $rules = [
|
||||
'name' => 'required|string|max:255|unique:device_models,name',
|
||||
'label' => 'required|string|max:255',
|
||||
'description' => 'required|string',
|
||||
'width' => 'required|integer|min:1',
|
||||
'height' => 'required|integer|min:1',
|
||||
'colors' => 'required|integer|min:1',
|
||||
'bit_depth' => 'required|integer|min:1',
|
||||
'scale_factor' => 'required|numeric|min:0.1',
|
||||
'rotation' => 'required|integer',
|
||||
'mime_type' => 'required|string|max:255',
|
||||
'offset_x' => 'required|integer',
|
||||
'offset_y' => 'required|integer',
|
||||
'published_at' => 'nullable|date',
|
||||
];
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->deviceModels = DeviceModel::all();
|
||||
return view('livewire.device-models.index');
|
||||
}
|
||||
|
||||
public function createDeviceModel(): void
|
||||
{
|
||||
$this->validate();
|
||||
|
||||
DeviceModel::create([
|
||||
'name' => $this->name,
|
||||
'label' => $this->label,
|
||||
'description' => $this->description,
|
||||
'width' => $this->width,
|
||||
'height' => $this->height,
|
||||
'colors' => $this->colors,
|
||||
'bit_depth' => $this->bit_depth,
|
||||
'scale_factor' => $this->scale_factor,
|
||||
'rotation' => $this->rotation,
|
||||
'mime_type' => $this->mime_type,
|
||||
'offset_x' => $this->offset_x,
|
||||
'offset_y' => $this->offset_y,
|
||||
'published_at' => $this->published_at,
|
||||
]);
|
||||
|
||||
$this->reset(['name', 'label', 'description', 'width', 'height', 'colors', 'bit_depth', 'scale_factor', 'rotation', 'mime_type', 'offset_x', 'offset_y', 'published_at']);
|
||||
\Flux::modal('create-device-model')->close();
|
||||
|
||||
$this->deviceModels = DeviceModel::all();
|
||||
session()->flash('message', 'Device model created successfully.');
|
||||
}
|
||||
|
||||
public $editingDeviceModelId;
|
||||
|
||||
public function editDeviceModel(DeviceModel $deviceModel): void
|
||||
{
|
||||
$this->editingDeviceModelId = $deviceModel->id;
|
||||
$this->name = $deviceModel->name;
|
||||
$this->label = $deviceModel->label;
|
||||
$this->description = $deviceModel->description;
|
||||
$this->width = $deviceModel->width;
|
||||
$this->height = $deviceModel->height;
|
||||
$this->colors = $deviceModel->colors;
|
||||
$this->bit_depth = $deviceModel->bit_depth;
|
||||
$this->scale_factor = $deviceModel->scale_factor;
|
||||
$this->rotation = $deviceModel->rotation;
|
||||
$this->mime_type = $deviceModel->mime_type;
|
||||
$this->offset_x = $deviceModel->offset_x;
|
||||
$this->offset_y = $deviceModel->offset_y;
|
||||
$this->published_at = $deviceModel->published_at?->format('Y-m-d\TH:i');
|
||||
}
|
||||
|
||||
public function updateDeviceModel(): void
|
||||
{
|
||||
$deviceModel = DeviceModel::findOrFail($this->editingDeviceModelId);
|
||||
|
||||
$this->validate([
|
||||
'name' => 'required|string|max:255|unique:device_models,name,' . $deviceModel->id,
|
||||
'label' => 'required|string|max:255',
|
||||
'description' => 'required|string',
|
||||
'width' => 'required|integer|min:1',
|
||||
'height' => 'required|integer|min:1',
|
||||
'colors' => 'required|integer|min:1',
|
||||
'bit_depth' => 'required|integer|min:1',
|
||||
'scale_factor' => 'required|numeric|min:0.1',
|
||||
'rotation' => 'required|integer',
|
||||
'mime_type' => 'required|string|max:255',
|
||||
'offset_x' => 'required|integer',
|
||||
'offset_y' => 'required|integer',
|
||||
'published_at' => 'nullable|date',
|
||||
]);
|
||||
|
||||
$deviceModel->update([
|
||||
'name' => $this->name,
|
||||
'label' => $this->label,
|
||||
'description' => $this->description,
|
||||
'width' => $this->width,
|
||||
'height' => $this->height,
|
||||
'colors' => $this->colors,
|
||||
'bit_depth' => $this->bit_depth,
|
||||
'scale_factor' => $this->scale_factor,
|
||||
'rotation' => $this->rotation,
|
||||
'mime_type' => $this->mime_type,
|
||||
'offset_x' => $this->offset_x,
|
||||
'offset_y' => $this->offset_y,
|
||||
'published_at' => $this->published_at,
|
||||
]);
|
||||
|
||||
$this->reset(['name', 'label', 'description', 'width', 'height', 'colors', 'bit_depth', 'scale_factor', 'rotation', 'mime_type', 'offset_x', 'offset_y', 'published_at', 'editingDeviceModelId']);
|
||||
\Flux::modal('edit-device-model-' . $deviceModel->id)->close();
|
||||
|
||||
$this->deviceModels = DeviceModel::all();
|
||||
session()->flash('message', 'Device model updated successfully.');
|
||||
}
|
||||
|
||||
public function deleteDeviceModel(DeviceModel $deviceModel): void
|
||||
{
|
||||
$deviceModel->delete();
|
||||
|
||||
$this->deviceModels = DeviceModel::all();
|
||||
session()->flash('message', 'Device model deleted successfully.');
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
|
||||
<div>
|
||||
<div class="py-12">
|
||||
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-2xl font-semibold dark:text-gray-100">Device Models</h2>
|
||||
{{-- <flux:modal.trigger name="create-device-model">--}}
|
||||
{{-- <flux:button icon="plus" variant="primary">Add Device Model</flux:button>--}}
|
||||
{{-- </flux:modal.trigger>--}}
|
||||
</div>
|
||||
@if (session()->has('message'))
|
||||
<div class="mb-4">
|
||||
<flux:callout variant="success" icon="check-circle" heading=" {{ session('message') }}">
|
||||
<x-slot name="controls">
|
||||
<flux:button icon="x-mark" variant="ghost"
|
||||
x-on:click="$el.closest('[data-flux-callout]').remove()"/>
|
||||
</x-slot>
|
||||
</flux:callout>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<flux:modal name="create-device-model" class="md:w-96">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<flux:heading size="lg">Add Device Model</flux:heading>
|
||||
</div>
|
||||
|
||||
<form wire:submit="createDeviceModel">
|
||||
<div class="mb-4">
|
||||
<flux:input label="Name" wire:model="name" id="name" class="block mt-1 w-full" type="text"
|
||||
name="name" autofocus/>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<flux:input label="Label" wire:model="label" id="label" class="block mt-1 w-full"
|
||||
type="text"
|
||||
name="label"/>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<flux:input label="Description" wire:model="description" id="description"
|
||||
class="block mt-1 w-full" name="description"/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<flux:input label="Width" wire:model="width" id="width" class="block mt-1 w-full"
|
||||
type="number"
|
||||
name="width"/>
|
||||
<flux:input label="Height" wire:model="height" id="height" class="block mt-1 w-full"
|
||||
type="number"
|
||||
name="height"/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<flux:input label="Colors" wire:model="colors" id="colors" class="block mt-1 w-full"
|
||||
type="number"
|
||||
name="colors"/>
|
||||
<flux:input label="Bit Depth" wire:model="bit_depth" id="bit_depth"
|
||||
class="block mt-1 w-full" type="number"
|
||||
name="bit_depth"/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<flux:input label="Scale Factor" wire:model="scale_factor" id="scale_factor"
|
||||
class="block mt-1 w-full" type="number"
|
||||
name="scale_factor" step="0.1"/>
|
||||
<flux:input label="Rotation" wire:model="rotation" id="rotation" class="block mt-1 w-full"
|
||||
type="number"
|
||||
name="rotation"/>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<flux:input label="MIME Type" wire:model="mime_type" id="mime_type"
|
||||
class="block mt-1 w-full" type="text"
|
||||
name="mime_type"/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<flux:input label="Offset X" wire:model="offset_x" id="offset_x" class="block mt-1 w-full"
|
||||
type="number"
|
||||
name="offset_x"/>
|
||||
<flux:input label="Offset Y" wire:model="offset_y" id="offset_y" class="block mt-1 w-full"
|
||||
type="number"
|
||||
name="offset_y"/>
|
||||
</div>
|
||||
|
||||
<div class="flex">
|
||||
<flux:spacer/>
|
||||
<flux:button type="submit" variant="primary">Create Device Model</flux:button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</flux:modal>
|
||||
|
||||
@foreach ($deviceModels as $deviceModel)
|
||||
<flux:modal name="edit-device-model-{{ $deviceModel->id }}" class="md:w-96">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<flux:heading size="lg">Edit Device Model</flux:heading>
|
||||
</div>
|
||||
|
||||
<form wire:submit="updateDeviceModel">
|
||||
<div class="mb-4">
|
||||
<flux:input label="Name" wire:model="name" id="edit_name" class="block mt-1 w-full"
|
||||
type="text"
|
||||
name="edit_name"/>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<flux:input label="Label" wire:model="label" id="edit_label" class="block mt-1 w-full"
|
||||
type="text"
|
||||
name="edit_label"/>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<flux:input label="Description" wire:model="description" id="edit_description"
|
||||
class="block mt-1 w-full" name="edit_description"/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<flux:input label="Width" wire:model="width" id="edit_width" class="block mt-1 w-full"
|
||||
type="number"
|
||||
name="edit_width"/>
|
||||
<flux:input label="Height" wire:model="height" id="edit_height"
|
||||
class="block mt-1 w-full" type="number"
|
||||
name="edit_height"/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<flux:input label="Colors" wire:model="colors" id="edit_colors"
|
||||
class="block mt-1 w-full" type="number"
|
||||
name="edit_colors"/>
|
||||
<flux:input label="Bit Depth" wire:model="bit_depth" id="edit_bit_depth"
|
||||
class="block mt-1 w-full" type="number"
|
||||
name="edit_bit_depth"/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<flux:input label="Scale Factor" wire:model="scale_factor" id="edit_scale_factor"
|
||||
class="block mt-1 w-full" type="number"
|
||||
name="edit_scale_factor" step="0.1"/>
|
||||
<flux:input label="Rotation" wire:model="rotation" id="edit_rotation"
|
||||
class="block mt-1 w-full" type="number"
|
||||
name="edit_rotation"/>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<flux:select label="MIME Type" wire:model="mime_type" id="edit_mime_type" name="edit_mime_type">
|
||||
<flux:select.option>image/png</flux:select.option>
|
||||
<flux:select.option>image/bmp</flux:select.option>
|
||||
</flux:select>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<flux:input label="Offset X" wire:model="offset_x" id="edit_offset_x"
|
||||
class="block mt-1 w-full" type="number"
|
||||
name="edit_offset_x"/>
|
||||
<flux:input label="Offset Y" wire:model="offset_y" id="edit_offset_y"
|
||||
class="block mt-1 w-full" type="number"
|
||||
name="edit_offset_y"/>
|
||||
</div>
|
||||
|
||||
<div class="flex">
|
||||
<flux:spacer/>
|
||||
<flux:button type="submit" variant="primary">Update Device Model</flux:button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</flux:modal>
|
||||
@endforeach
|
||||
|
||||
<table
|
||||
class="min-w-full table-fixed text-zinc-800 divide-y divide-zinc-800/10 dark:divide-white/20 text-zinc-800"
|
||||
data-flux-table>
|
||||
<thead data-flux-columns>
|
||||
<tr>
|
||||
<th class="py-3 px-3 first:pl-0 last:pr-0 text-left text-sm font-medium text-zinc-800 dark:text-white"
|
||||
data-flux-column>
|
||||
<div class="whitespace-nowrap flex group-[]/right-align:justify-end">Description</div>
|
||||
</th>
|
||||
<th class="py-3 px-3 first:pl-0 last:pr-0 text-left text-sm font-medium text-zinc-800 dark:text-white"
|
||||
data-flux-column>
|
||||
<div class="whitespace-nowrap flex group-[]/right-align:justify-end">Width</div>
|
||||
</th>
|
||||
<th class="py-3 px-3 first:pl-0 last:pr-0 text-left text-sm font-medium text-zinc-800 dark:text-white"
|
||||
data-flux-column>
|
||||
<div class="whitespace-nowrap flex group-[]/right-align:justify-end">Height</div>
|
||||
</th>
|
||||
<th class="py-3 px-3 first:pl-0 last:pr-0 text-left text-sm font-medium text-zinc-800 dark:text-white"
|
||||
data-flux-column>
|
||||
<div class="whitespace-nowrap flex group-[]/right-align:justify-end">Bit Depth</div>
|
||||
</th>
|
||||
<th class="py-3 px-3 first:pl-0 last:pr-0 text-left text-sm font-medium text-zinc-800 dark:text-white"
|
||||
data-flux-column>
|
||||
<div class="whitespace-nowrap flex group-[]/right-align:justify-end">Actions</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody class="divide-y divide-zinc-800/10 dark:divide-white/20" data-flux-rows>
|
||||
@foreach ($deviceModels as $deviceModel)
|
||||
<tr data-flux-row>
|
||||
<td class="py-3 px-3 first:pl-0 last:pr-0 text-sm whitespace-nowrap text-zinc-500 dark:text-zinc-300"
|
||||
>
|
||||
<div>
|
||||
<div class="font-medium text-zinc-800 dark:text-white">{{ $deviceModel->label }}</div>
|
||||
<div class="text-xs text-zinc-500">{{ Str::limit($deviceModel->name, 50) }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-3 px-3 first:pl-0 last:pr-0 text-sm whitespace-nowrap text-zinc-500 dark:text-zinc-300"
|
||||
>
|
||||
{{ $deviceModel->width }}
|
||||
</td>
|
||||
<td class="py-3 px-3 first:pl-0 last:pr-0 text-sm whitespace-nowrap text-zinc-500 dark:text-zinc-300"
|
||||
>
|
||||
{{ $deviceModel->height }}
|
||||
</td>
|
||||
<td class="py-3 px-3 first:pl-0 last:pr-0 text-sm whitespace-nowrap text-zinc-500 dark:text-zinc-300"
|
||||
>
|
||||
{{ $deviceModel->bit_depth }}
|
||||
</td>
|
||||
<td class="py-3 px-3 first:pl-0 last:pr-0 text-sm whitespace-nowrap font-medium text-zinc-800 dark:text-white"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<flux:button.group>
|
||||
<flux:modal.trigger name="edit-device-model-{{ $deviceModel->id }}">
|
||||
<flux:button wire:click="editDeviceModel({{ $deviceModel->id }})" icon="pencil"
|
||||
iconVariant="outline">
|
||||
</flux:button>
|
||||
</flux:modal.trigger>
|
||||
<flux:button wire:click="deleteDeviceModel({{ $deviceModel->id }})" icon="trash"
|
||||
iconVariant="outline">
|
||||
</flux:button>
|
||||
</flux:button.group>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
<?php
|
||||
|
||||
use App\Jobs\FirmwareDownloadJob;
|
||||
use App\Models\DeviceModel;
|
||||
use App\Models\Firmware;
|
||||
use App\Models\Playlist;
|
||||
use App\Models\PlaylistItem;
|
||||
|
|
@ -19,6 +20,7 @@ new class extends Component {
|
|||
public $height;
|
||||
public $rotate;
|
||||
public $image_format;
|
||||
public $device_model_id;
|
||||
|
||||
// Sleep mode and special function
|
||||
public $sleep_mode_enabled = false;
|
||||
|
|
@ -34,6 +36,9 @@ new class extends Component {
|
|||
public $active_until;
|
||||
public $refresh_time = null;
|
||||
|
||||
// Device model properties
|
||||
public $deviceModels;
|
||||
|
||||
// Firmware properties
|
||||
public $firmwares;
|
||||
public $selected_firmware_id;
|
||||
|
|
@ -56,6 +61,12 @@ new class extends Component {
|
|||
$this->height = $device->height;
|
||||
$this->rotate = $device->rotate;
|
||||
$this->image_format = $device->image_format;
|
||||
$this->device_model_id = $device->device_model_id;
|
||||
$this->deviceModels = DeviceModel::orderBy('label')->get()->sortBy(function ($deviceModel) {
|
||||
// Put TRMNL models at the top, then sort alphabetically within each group
|
||||
$isTrmnl = str_starts_with($deviceModel->label, 'TRMNL');
|
||||
return $isTrmnl ? '0' . $deviceModel->label : '1' . $deviceModel->label;
|
||||
});
|
||||
$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;
|
||||
|
|
@ -77,6 +88,24 @@ new class extends Component {
|
|||
redirect()->route('devices');
|
||||
}
|
||||
|
||||
public function updatedDeviceModelId()
|
||||
{
|
||||
// Convert empty string to null for custom selection
|
||||
if (empty($this->device_model_id)) {
|
||||
$this->device_model_id = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->device_model_id) {
|
||||
$deviceModel = DeviceModel::find($this->device_model_id);
|
||||
if ($deviceModel) {
|
||||
$this->width = $deviceModel->width;
|
||||
$this->height = $deviceModel->height;
|
||||
$this->rotate = $deviceModel->rotation;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function updateDevice()
|
||||
{
|
||||
abort_unless(auth()->user()->devices->contains($this->device), 403);
|
||||
|
|
@ -90,12 +119,16 @@ new class extends Component {
|
|||
'height' => 'required|integer|min:1',
|
||||
'rotate' => 'required|integer|min:0|max:359',
|
||||
'image_format' => 'required|string',
|
||||
'device_model_id' => 'nullable|exists:device_models,id',
|
||||
'sleep_mode_enabled' => 'boolean',
|
||||
'sleep_mode_from' => 'nullable|date_format:H:i',
|
||||
'sleep_mode_to' => 'nullable|date_format:H:i',
|
||||
'special_function' => 'nullable|string',
|
||||
]);
|
||||
|
||||
// Convert empty string to null for custom selection
|
||||
$deviceModelId = empty($this->device_model_id) ? null : $this->device_model_id;
|
||||
|
||||
$this->device->update([
|
||||
'name' => $this->name,
|
||||
'friendly_id' => $this->friendly_id,
|
||||
|
|
@ -105,6 +138,7 @@ new class extends Component {
|
|||
'height' => $this->height,
|
||||
'rotate' => $this->rotate,
|
||||
'image_format' => $this->image_format,
|
||||
'device_model_id' => $deviceModelId,
|
||||
'sleep_mode_enabled' => $this->sleep_mode_enabled,
|
||||
'sleep_mode_from' => $this->sleep_mode_from,
|
||||
'sleep_mode_to' => $this->sleep_mode_to,
|
||||
|
|
@ -357,6 +391,20 @@ new class extends Component {
|
|||
|
||||
<flux:input label="Friendly ID" wire:model="friendly_id"/>
|
||||
<flux:input label="MAC Address" wire:model="mac_address"/>
|
||||
|
||||
<flux:input label="Default Refresh Interval (seconds)" wire:model="default_refresh_interval"
|
||||
type="number"/>
|
||||
|
||||
<flux:select label="Device Model" wire:model.live="device_model_id">
|
||||
<flux:select.option value="">Custom (Manual Dimensions)</flux:select.option>
|
||||
@foreach($deviceModels as $deviceModel)
|
||||
<flux:select.option value="{{ $deviceModel->id }}">
|
||||
{{ $deviceModel->label }} ({{ $deviceModel->width }}x{{ $deviceModel->height }})
|
||||
</flux:select.option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
|
||||
@if(empty($device_model_id))
|
||||
<flux:separator class="my-4" text="Advanced Device Settings" />
|
||||
<div class="flex gap-4">
|
||||
<flux:input label="Width (px)" wire:model="width" type="number" />
|
||||
|
|
@ -368,8 +416,7 @@ new class extends Component {
|
|||
<flux:select.option value="{{ $format->value }}">{{$format->label()}}</flux:select.option>
|
||||
@endforeach
|
||||
</flux:select>
|
||||
<flux:input label="Default Refresh Interval (seconds)" wire:model="default_refresh_interval"
|
||||
type="number"/>
|
||||
@endif
|
||||
|
||||
<flux:separator class="my-4" text="Special Functions" />
|
||||
<flux:select label="Special Function" wire:model="special_function">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Device;
|
||||
use App\Models\DeviceModel;
|
||||
use Livewire\Volt\Component;
|
||||
|
||||
new class extends Component {
|
||||
|
|
@ -22,6 +23,8 @@ new class extends Component {
|
|||
public $is_mirror = false;
|
||||
|
||||
public $mirror_device_id = null;
|
||||
public $device_model_id = null;
|
||||
public $deviceModels;
|
||||
|
||||
public ?int $pause_duration;
|
||||
|
||||
|
|
@ -29,15 +32,29 @@ new class extends Component {
|
|||
'mac_address' => 'required',
|
||||
'api_key' => 'required',
|
||||
'default_refresh_interval' => 'required|integer',
|
||||
'device_model_id' => 'nullable|exists:device_models,id',
|
||||
'mirror_device_id' => 'required_if:is_mirror,true',
|
||||
];
|
||||
|
||||
public function mount()
|
||||
{
|
||||
$this->devices = auth()->user()->devices;
|
||||
$this->deviceModels = DeviceModel::orderBy('label')->get()->sortBy(function ($deviceModel) {
|
||||
// Put TRMNL models at the top, then sort alphabetically within each group
|
||||
$isTrmnl = str_starts_with($deviceModel->label, 'TRMNL');
|
||||
return $isTrmnl ? '0' . $deviceModel->label : '1' . $deviceModel->label;
|
||||
});
|
||||
return view('livewire.devices.manage');
|
||||
}
|
||||
|
||||
public function updatedDeviceModelId(): void
|
||||
{
|
||||
// Convert empty string to null for custom selection
|
||||
if (empty($this->device_model_id)) {
|
||||
$this->device_model_id = null;
|
||||
}
|
||||
}
|
||||
|
||||
public function createDevice(): void
|
||||
{
|
||||
$this->validate();
|
||||
|
|
@ -49,6 +66,9 @@ new class extends Component {
|
|||
abort_if($mirrorDevice->mirror_device_id !== null, 403, 'Cannot mirror a device that is already a mirror device');
|
||||
}
|
||||
|
||||
// Convert empty string to null for custom selection
|
||||
$deviceModelId = empty($this->device_model_id) ? null : $this->device_model_id;
|
||||
|
||||
Device::create([
|
||||
'name' => $this->name,
|
||||
'mac_address' => $this->mac_address,
|
||||
|
|
@ -56,6 +76,7 @@ new class extends Component {
|
|||
'default_refresh_interval' => $this->default_refresh_interval,
|
||||
'friendly_id' => $this->friendly_id,
|
||||
'user_id' => auth()->id(),
|
||||
'device_model_id' => $deviceModelId,
|
||||
'mirror_device_id' => $this->is_mirror ? $this->mirror_device_id : null,
|
||||
]);
|
||||
|
||||
|
|
@ -154,6 +175,19 @@ new class extends Component {
|
|||
autofocus/>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<flux:select label="Device Model" wire:model.live="device_model_id">
|
||||
<flux:select.option value="">Custom (Manual Dimensions)</flux:select.option>
|
||||
@if ($deviceModels && $deviceModels->count() > 0)
|
||||
@foreach($deviceModels as $deviceModel)
|
||||
<flux:select.option value="{{ $deviceModel->id }}">
|
||||
{{ $deviceModel->label }} ({{ $deviceModel->width }}x{{ $deviceModel->height }})
|
||||
</flux:select.option>
|
||||
@endforeach
|
||||
@endif
|
||||
</flux:select>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<flux:checkbox wire:model.live="is_mirror" label="Mirrors Device"/>
|
||||
</div>
|
||||
|
|
|
|||
35
resources/views/vendor/trmnl/components/screen.blade.php
vendored
Normal file
35
resources/views/vendor/trmnl/components/screen.blade.php
vendored
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
@props([
|
||||
'noBleed' => false,
|
||||
'darkMode' => false,
|
||||
'deviceVariant' => 'og',
|
||||
'deviceOrientation' => null,
|
||||
'colorDepth' => '2bit',
|
||||
'scaleLevel' => null,
|
||||
])
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||
<link href="https://fonts.bunny.net/css?family=Inter:300,400,500" rel="stylesheet"/>
|
||||
@if (config('trmnl-blade.framework_css_url'))
|
||||
<link rel="stylesheet"
|
||||
href="{{ config('trmnl-blade.framework_css_url') }}">
|
||||
@else
|
||||
<link rel="stylesheet"
|
||||
href="https://usetrmnl.com/css/{{ config('trmnl-blade.framework_version', '1.2.0') }}/plugins.css">
|
||||
@endif
|
||||
@if (config('trmnl-blade.framework_js_url'))
|
||||
<script src="{{ config('trmnl-blade.framework_js_url') }}"></script>
|
||||
@else
|
||||
<script src="https://usetrmnl.com/js/{{ config('trmnl-blade.framework_version', '1.2.0') }}/plugins.js"></script>
|
||||
@endif
|
||||
<title>{{ $title ?? config('app.name') }}</title>
|
||||
</head>
|
||||
<body class="environment trmnl">
|
||||
<div class="screen {{$noBleed ? 'screen--no-bleed' : ''}} {{ $darkMode ? 'dark-mode' : '' }} {{$deviceVariant ? 'screen--' . $deviceVariant : ''}} {{ $deviceOrientation ? 'screen--' . $deviceOrientation : ''}} {{ $colorDepth ? 'screen--' . $colorDepth : ''}} {{ $scaleLevel ? 'screen--scale-' . $scaleLevel : ''}}">
|
||||
{{ $slot }}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -266,6 +266,22 @@ Route::get('/devices', function (Request $request) {
|
|||
]);
|
||||
})->middleware('auth:sanctum');
|
||||
|
||||
Route::get('/device-models', function (Request $request) {
|
||||
$deviceModels = App\Models\DeviceModel::get([
|
||||
'id',
|
||||
'name',
|
||||
'label',
|
||||
'description',
|
||||
'width',
|
||||
'height',
|
||||
'bit_depth',
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'data' => $deviceModels,
|
||||
]);
|
||||
})->middleware('auth:sanctum');
|
||||
|
||||
Route::post('/display/update', function (Request $request) {
|
||||
$request->validate([
|
||||
'device_id' => 'required|exists:devices,id',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<?php
|
||||
|
||||
use App\Jobs\CleanupDeviceLogsJob;
|
||||
use App\Jobs\FetchDeviceModelsJob;
|
||||
use App\Jobs\FetchProxyCloudResponses;
|
||||
use App\Jobs\FirmwarePollJob;
|
||||
use App\Jobs\NotifyDeviceBatteryLowJob;
|
||||
|
|
@ -13,4 +14,5 @@ Schedule::job(FetchProxyCloudResponses::class, [])->cron(
|
|||
|
||||
Schedule::job(FirmwarePollJob::class)->daily();
|
||||
Schedule::job(CleanupDeviceLogsJob::class)->daily();
|
||||
Schedule::job(FetchDeviceModelsJob::class)->weekly();
|
||||
Schedule::job(NotifyDeviceBatteryLowJob::class)->dailyAt('10:00');
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ Route::middleware(['auth'])->group(function () {
|
|||
Volt::route('/devices/{device}/configure', 'devices.configure')->name('devices.configure');
|
||||
Volt::route('/devices/{device}/logs', 'devices.logs')->name('devices.logs');
|
||||
|
||||
Volt::route('/device-models', 'device-models.index')->name('device-models.index');
|
||||
|
||||
Volt::route('plugins', 'plugins.index')->name('plugins.index');
|
||||
|
||||
Volt::route('plugins/recipe/{plugin}', 'plugins.recipe')->name('plugins.recipe');
|
||||
|
|
|
|||
35
tests/Feature/Api/DeviceModelsEndpointTest.php
Normal file
35
tests/Feature/Api/DeviceModelsEndpointTest.php
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
it('allows an authenticated user to fetch device models', function (): void {
|
||||
$user = User::factory()->create();
|
||||
|
||||
Sanctum::actingAs($user);
|
||||
|
||||
$response = $this->getJson('/api/device-models');
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonStructure([
|
||||
'data' => [
|
||||
'*' => [
|
||||
'id',
|
||||
'name',
|
||||
'label',
|
||||
'description',
|
||||
'width',
|
||||
'height',
|
||||
'bit_depth',
|
||||
],
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('blocks unauthenticated users from accessing device models', function (): void {
|
||||
$response = $this->getJson('/api/device-models');
|
||||
|
||||
$response->assertUnauthorized();
|
||||
});
|
||||
|
|
@ -1,137 +1,134 @@
|
|||
<?php
|
||||
|
||||
namespace Tests\Feature\Auth;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
use Laravel\Socialite\Two\User as SocialiteUser;
|
||||
use Mockery;
|
||||
use Tests\TestCase;
|
||||
|
||||
class OidcAuthenticationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
beforeEach(function (): void {
|
||||
// Enable OIDC for testing
|
||||
Config::set('services.oidc.enabled', true);
|
||||
Config::set('services.oidc.endpoint', 'https://example.com/oidc');
|
||||
Config::set('services.oidc.client_id', 'test-client-id');
|
||||
Config::set('services.oidc.client_secret', 'test-client-secret');
|
||||
}
|
||||
|
||||
public function test_oidc_redirect_works_when_enabled()
|
||||
{
|
||||
// Mock Socialite OIDC driver to avoid any external HTTP calls
|
||||
$provider = Mockery::mock();
|
||||
$provider->shouldReceive('redirect')->andReturn(redirect('/fake-oidc-redirect'));
|
||||
|
||||
// Default Socialite user returned by callback
|
||||
$socialiteUser = mockSocialiteUser();
|
||||
$provider->shouldReceive('user')->andReturn($socialiteUser);
|
||||
|
||||
Socialite::shouldReceive('driver')
|
||||
->with('oidc')
|
||||
->andReturn($provider);
|
||||
});
|
||||
|
||||
afterEach(function (): void {
|
||||
Mockery::close();
|
||||
});
|
||||
|
||||
it('oidc redirect works when enabled', function (): void {
|
||||
$response = $this->get(route('auth.oidc.redirect'));
|
||||
|
||||
// Since we're using a mock OIDC provider, this will likely fail
|
||||
// but we can check that the route exists and is accessible
|
||||
$this->assertNotEquals(404, $response->getStatusCode());
|
||||
}
|
||||
expect($response->getStatusCode())->not->toBe(404);
|
||||
});
|
||||
|
||||
public function test_oidc_redirect_fails_when_disabled()
|
||||
{
|
||||
it('oidc redirect fails when disabled', function (): void {
|
||||
Config::set('services.oidc.enabled', false);
|
||||
|
||||
$response = $this->get(route('auth.oidc.redirect'));
|
||||
|
||||
$response->assertRedirect(route('login'));
|
||||
$response->assertSessionHasErrors(['oidc' => 'OIDC authentication is not enabled.']);
|
||||
}
|
||||
});
|
||||
|
||||
public function test_oidc_callback_creates_new_user()
|
||||
{
|
||||
$mockUser = $this->mockSocialiteUser();
|
||||
it('oidc callback creates new user (placeholder)', function (): void {
|
||||
mockSocialiteUser();
|
||||
|
||||
$response = $this->get(route('auth.oidc.callback'));
|
||||
$this->get(route('auth.oidc.callback'));
|
||||
|
||||
// We expect to be redirected to dashboard after successful authentication
|
||||
// In a real test, this would be mocked properly
|
||||
$this->assertTrue(true); // Placeholder assertion
|
||||
}
|
||||
expect(true)->toBeTrue(); // Placeholder assertion
|
||||
});
|
||||
|
||||
public function test_oidc_callback_updates_existing_user_by_oidc_sub()
|
||||
{
|
||||
it('oidc callback updates existing user by oidc_sub (placeholder)', function (): void {
|
||||
// Create a user with OIDC sub
|
||||
$user = User::factory()->create([
|
||||
User::factory()->create([
|
||||
'oidc_sub' => 'test-sub-123',
|
||||
'name' => 'Old Name',
|
||||
'email' => 'old@example.com',
|
||||
]);
|
||||
|
||||
$mockUser = $this->mockSocialiteUser([
|
||||
mockSocialiteUser([
|
||||
'id' => 'test-sub-123',
|
||||
'name' => 'Updated Name',
|
||||
'email' => 'updated@example.com',
|
||||
]);
|
||||
|
||||
// This would need proper mocking of Socialite in a real test
|
||||
$this->assertTrue(true); // Placeholder assertion
|
||||
}
|
||||
expect(true)->toBeTrue(); // Placeholder assertion
|
||||
});
|
||||
|
||||
public function test_oidc_callback_links_existing_user_by_email()
|
||||
{
|
||||
it('oidc callback links existing user by email (placeholder)', function (): void {
|
||||
// Create a user without OIDC sub but with matching email
|
||||
$user = User::factory()->create([
|
||||
User::factory()->create([
|
||||
'oidc_sub' => null,
|
||||
'email' => 'test@example.com',
|
||||
]);
|
||||
|
||||
$mockUser = $this->mockSocialiteUser([
|
||||
mockSocialiteUser([
|
||||
'id' => 'test-sub-456',
|
||||
'email' => 'test@example.com',
|
||||
]);
|
||||
|
||||
// This would need proper mocking of Socialite in a real test
|
||||
$this->assertTrue(true); // Placeholder assertion
|
||||
}
|
||||
expect(true)->toBeTrue(); // Placeholder assertion
|
||||
});
|
||||
|
||||
public function test_oidc_callback_fails_when_disabled()
|
||||
{
|
||||
it('oidc callback fails when disabled', function (): void {
|
||||
Config::set('services.oidc.enabled', false);
|
||||
|
||||
$response = $this->get(route('auth.oidc.callback'));
|
||||
|
||||
$response->assertRedirect(route('login'));
|
||||
$response->assertSessionHasErrors(['oidc' => 'OIDC authentication is not enabled.']);
|
||||
}
|
||||
});
|
||||
|
||||
public function test_login_view_shows_oidc_button_when_enabled()
|
||||
{
|
||||
it('login view shows oidc button when enabled', function (): void {
|
||||
$response = $this->get(route('login'));
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertSee('Continue with OIDC');
|
||||
$response->assertSee('Or');
|
||||
}
|
||||
});
|
||||
|
||||
public function test_login_view_hides_oidc_button_when_disabled()
|
||||
{
|
||||
it('login view hides oidc button when disabled', function (): void {
|
||||
Config::set('services.oidc.enabled', false);
|
||||
|
||||
$response = $this->get(route('login'));
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertDontSee('Continue with OIDC');
|
||||
}
|
||||
});
|
||||
|
||||
public function test_user_model_has_oidc_sub_fillable()
|
||||
{
|
||||
it('user model has oidc_sub fillable', function (): void {
|
||||
$user = new User();
|
||||
|
||||
$this->assertContains('oidc_sub', $user->getFillable());
|
||||
}
|
||||
expect($user->getFillable())->toContain('oidc_sub');
|
||||
});
|
||||
|
||||
/**
|
||||
/**
|
||||
* Mock a Socialite user for testing.
|
||||
*
|
||||
* @param array<string, mixed> $userData
|
||||
*/
|
||||
protected function mockSocialiteUser(array $userData = [])
|
||||
{
|
||||
function mockSocialiteUser(array $userData = []): SocialiteUser
|
||||
{
|
||||
$defaultData = [
|
||||
'id' => 'test-sub-123',
|
||||
'name' => 'Test User',
|
||||
|
|
@ -141,6 +138,7 @@ class OidcAuthenticationTest extends TestCase
|
|||
|
||||
$userData = array_merge($defaultData, $userData);
|
||||
|
||||
/** @var SocialiteUser $socialiteUser */
|
||||
$socialiteUser = Mockery::mock(SocialiteUser::class);
|
||||
$socialiteUser->shouldReceive('getId')->andReturn($userData['id']);
|
||||
$socialiteUser->shouldReceive('getName')->andReturn($userData['name']);
|
||||
|
|
@ -148,11 +146,4 @@ class OidcAuthenticationTest extends TestCase
|
|||
$socialiteUser->shouldReceive('getAvatar')->andReturn($userData['avatar']);
|
||||
|
||||
return $socialiteUser;
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Mockery::close();
|
||||
parent::tearDown();
|
||||
}
|
||||
}
|
||||
89
tests/Feature/DeviceModelsTest.php
Normal file
89
tests/Feature/DeviceModelsTest.php
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\DeviceModel;
|
||||
use App\Models\User;
|
||||
|
||||
it('allows a user to view the device models page', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$deviceModels = DeviceModel::factory()->count(3)->create();
|
||||
|
||||
$response = $this->actingAs($user)->get('/device-models');
|
||||
|
||||
$response->assertSuccessful();
|
||||
$response->assertSee('Device Models');
|
||||
$response->assertSee('Add Device Model');
|
||||
|
||||
foreach ($deviceModels as $deviceModel) {
|
||||
$response->assertSee($deviceModel->label);
|
||||
$response->assertSee((string) $deviceModel->width);
|
||||
$response->assertSee((string) $deviceModel->height);
|
||||
$response->assertSee((string) $deviceModel->bit_depth);
|
||||
}
|
||||
});
|
||||
|
||||
it('allows creating a device model', function (): void {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$deviceModelData = [
|
||||
'name' => 'test-model',
|
||||
'label' => 'Test Model',
|
||||
'description' => 'A test device model',
|
||||
'width' => 800,
|
||||
'height' => 600,
|
||||
'colors' => 256,
|
||||
'bit_depth' => 8,
|
||||
'scale_factor' => 1.0,
|
||||
'rotation' => 0,
|
||||
'mime_type' => 'image/png',
|
||||
'offset_x' => 0,
|
||||
'offset_y' => 0,
|
||||
];
|
||||
|
||||
$deviceModel = DeviceModel::create($deviceModelData);
|
||||
|
||||
$this->assertDatabaseHas('device_models', $deviceModelData);
|
||||
expect($deviceModel->name)->toBe($deviceModelData['name']);
|
||||
});
|
||||
|
||||
it('allows updating a device model', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$deviceModel = DeviceModel::factory()->create();
|
||||
|
||||
$updatedData = [
|
||||
'name' => 'updated-model',
|
||||
'label' => 'Updated Model',
|
||||
'description' => 'An updated device model',
|
||||
'width' => 1024,
|
||||
'height' => 768,
|
||||
'colors' => 65536,
|
||||
'bit_depth' => 16,
|
||||
'scale_factor' => 1.5,
|
||||
'rotation' => 90,
|
||||
'mime_type' => 'image/jpeg',
|
||||
'offset_x' => 10,
|
||||
'offset_y' => 20,
|
||||
];
|
||||
|
||||
$deviceModel->update($updatedData);
|
||||
|
||||
$this->assertDatabaseHas('device_models', $updatedData);
|
||||
expect($deviceModel->fresh()->name)->toBe($updatedData['name']);
|
||||
});
|
||||
|
||||
it('allows deleting a device model', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$deviceModel = DeviceModel::factory()->create();
|
||||
|
||||
$deviceModelId = $deviceModel->id;
|
||||
$deviceModel->delete();
|
||||
|
||||
$this->assertDatabaseMissing('device_models', ['id' => $deviceModelId]);
|
||||
});
|
||||
|
||||
it('redirects unauthenticated users from the device models page', function (): void {
|
||||
$response = $this->get('/device-models');
|
||||
|
||||
$response->assertRedirect('/login');
|
||||
});
|
||||
20
tests/Feature/FetchDeviceModelsCommandTest.php
Normal file
20
tests/Feature/FetchDeviceModelsCommandTest.php
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\FetchDeviceModelsJob;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('command dispatches fetch device models job', function () {
|
||||
Queue::fake();
|
||||
|
||||
$this->artisan('device-models:fetch')
|
||||
->expectsOutput('Dispatching FetchDeviceModelsJob...')
|
||||
->expectsOutput('FetchDeviceModelsJob has been dispatched successfully.')
|
||||
->assertExitCode(0);
|
||||
|
||||
Queue::assertPushed(FetchDeviceModelsJob::class);
|
||||
});
|
||||
|
|
@ -10,6 +10,12 @@ uses(Illuminate\Foundation\Testing\RefreshDatabase::class);
|
|||
beforeEach(function () {
|
||||
Storage::fake('public');
|
||||
Storage::disk('public')->makeDirectory('/images/generated');
|
||||
Http::preventStrayRequests();
|
||||
Http::fake([
|
||||
'https://example.com/test-image.bmp*' => Http::response([], 200),
|
||||
'https://trmnl.app/api/log' => Http::response([], 200),
|
||||
'https://example.com/api/log' => Http::response([], 200),
|
||||
]);
|
||||
});
|
||||
|
||||
test('it fetches and processes proxy cloud responses for devices', function () {
|
||||
|
|
|
|||
425
tests/Feature/ImageGenerationServiceTest.php
Normal file
425
tests/Feature/ImageGenerationServiceTest.php
Normal file
|
|
@ -0,0 +1,425 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\ImageFormat;
|
||||
use App\Models\Device;
|
||||
use App\Models\DeviceModel;
|
||||
use App\Services\ImageGenerationService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
Storage::fake('public');
|
||||
Storage::disk('public')->makeDirectory('/images/generated');
|
||||
});
|
||||
|
||||
it('generates image for device without device model', function (): void {
|
||||
// Create a device without a DeviceModel (legacy behavior)
|
||||
$device = Device::factory()->create([
|
||||
'width' => 800,
|
||||
'height' => 480,
|
||||
'rotate' => 0,
|
||||
'image_format' => ImageFormat::PNG_8BIT_GRAYSCALE->value,
|
||||
]);
|
||||
|
||||
$markup = '<div style="background: white; color: black; padding: 20px;">Test Content</div>';
|
||||
$uuid = ImageGenerationService::generateImage($markup, $device->id);
|
||||
|
||||
// Assert the device was updated with a new image UUID
|
||||
$device->refresh();
|
||||
expect($device->current_screen_image)->toBe($uuid);
|
||||
|
||||
// Assert PNG file was created
|
||||
Storage::disk('public')->assertExists("/images/generated/{$uuid}.png");
|
||||
})->skipOnGitHubActions();
|
||||
|
||||
it('generates image for device with device model', function (): void {
|
||||
// Create a DeviceModel
|
||||
$deviceModel = DeviceModel::factory()->create([
|
||||
'width' => 1024,
|
||||
'height' => 768,
|
||||
'colors' => 256,
|
||||
'bit_depth' => 8,
|
||||
'scale_factor' => 1.0,
|
||||
'rotation' => 0,
|
||||
'mime_type' => 'image/png',
|
||||
'offset_x' => 0,
|
||||
'offset_y' => 0,
|
||||
]);
|
||||
|
||||
// Create a device with the DeviceModel
|
||||
$device = Device::factory()->create([
|
||||
'device_model_id' => $deviceModel->id,
|
||||
]);
|
||||
|
||||
$markup = '<div style="background: white; color: black; padding: 20px;">Test Content</div>';
|
||||
$uuid = ImageGenerationService::generateImage($markup, $device->id);
|
||||
|
||||
// Assert the device was updated with a new image UUID
|
||||
$device->refresh();
|
||||
expect($device->current_screen_image)->toBe($uuid);
|
||||
|
||||
// Assert PNG file was created
|
||||
Storage::disk('public')->assertExists("/images/generated/{$uuid}.png");
|
||||
})->skipOnGitHubActions();
|
||||
|
||||
it('generates 4-color 2-bit PNG with device model', function (): void {
|
||||
// Create a DeviceModel for 4-color, 2-bit PNG
|
||||
$deviceModel = DeviceModel::factory()->create([
|
||||
'width' => 800,
|
||||
'height' => 480,
|
||||
'colors' => 4,
|
||||
'bit_depth' => 2,
|
||||
'scale_factor' => 1.0,
|
||||
'rotation' => 0,
|
||||
'mime_type' => 'image/png',
|
||||
'offset_x' => 0,
|
||||
'offset_y' => 0,
|
||||
]);
|
||||
|
||||
// Create a device with the DeviceModel
|
||||
$device = Device::factory()->create([
|
||||
'device_model_id' => $deviceModel->id,
|
||||
]);
|
||||
|
||||
$markup = '<div style="background: white; color: black; padding: 20px;">Test Content</div>';
|
||||
$uuid = ImageGenerationService::generateImage($markup, $device->id);
|
||||
|
||||
// Assert the device was updated with a new image UUID
|
||||
$device->refresh();
|
||||
expect($device->current_screen_image)->toBe($uuid);
|
||||
|
||||
// Assert PNG file was created
|
||||
Storage::disk('public')->assertExists("/images/generated/{$uuid}.png");
|
||||
|
||||
// Verify the image file has content and isn't blank
|
||||
$imagePath = Storage::disk('public')->path("/images/generated/{$uuid}.png");
|
||||
$imageSize = filesize($imagePath);
|
||||
expect($imageSize)->toBeGreaterThan(200); // Should be at least 200 bytes for a 2-bit PNG
|
||||
|
||||
// Verify it's a valid PNG file
|
||||
$imageInfo = getimagesize($imagePath);
|
||||
expect($imageInfo[0])->toBe(800); // Width
|
||||
expect($imageInfo[1])->toBe(480); // Height
|
||||
expect($imageInfo[2])->toBe(IMAGETYPE_PNG); // PNG type
|
||||
|
||||
// Debug: Check if the image has any non-transparent pixels
|
||||
$image = imagecreatefrompng($imagePath);
|
||||
$width = imagesx($image);
|
||||
$height = imagesy($image);
|
||||
$hasContent = false;
|
||||
|
||||
// Check a few sample pixels to see if there's content
|
||||
for ($x = 0; $x < min(10, $width); $x += 2) {
|
||||
for ($y = 0; $y < min(10, $height); $y += 2) {
|
||||
$color = imagecolorat($image, $x, $y);
|
||||
if ($color !== 0) { // Not black/transparent
|
||||
$hasContent = true;
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
imagedestroy($image);
|
||||
expect($hasContent)->toBe(true, 'Image should contain visible content');
|
||||
})->skipOnGitHubActions();
|
||||
|
||||
it('generates BMP with device model', function (): void {
|
||||
// Create a DeviceModel for BMP format
|
||||
$deviceModel = DeviceModel::factory()->create([
|
||||
'width' => 800,
|
||||
'height' => 480,
|
||||
'colors' => 2,
|
||||
'bit_depth' => 1,
|
||||
'scale_factor' => 1.0,
|
||||
'rotation' => 0,
|
||||
'mime_type' => 'image/bmp',
|
||||
'offset_x' => 0,
|
||||
'offset_y' => 0,
|
||||
]);
|
||||
|
||||
// Create a device with the DeviceModel
|
||||
$device = Device::factory()->create([
|
||||
'device_model_id' => $deviceModel->id,
|
||||
]);
|
||||
|
||||
$markup = '<div style="background: white; color: black; padding: 20px;">Test Content</div>';
|
||||
$uuid = ImageGenerationService::generateImage($markup, $device->id);
|
||||
|
||||
// Assert the device was updated with a new image UUID
|
||||
$device->refresh();
|
||||
expect($device->current_screen_image)->toBe($uuid);
|
||||
|
||||
// Assert BMP file was created
|
||||
Storage::disk('public')->assertExists("/images/generated/{$uuid}.bmp");
|
||||
})->skipOnGitHubActions();
|
||||
|
||||
it('applies scale factor from device model', function (): void {
|
||||
// Create a DeviceModel with scale factor
|
||||
$deviceModel = DeviceModel::factory()->create([
|
||||
'width' => 800,
|
||||
'height' => 480,
|
||||
'colors' => 256,
|
||||
'bit_depth' => 8,
|
||||
'scale_factor' => 2.0, // Scale up by 2x
|
||||
'rotation' => 0,
|
||||
'mime_type' => 'image/png',
|
||||
'offset_x' => 0,
|
||||
'offset_y' => 0,
|
||||
]);
|
||||
|
||||
// Create a device with the DeviceModel
|
||||
$device = Device::factory()->create([
|
||||
'device_model_id' => $deviceModel->id,
|
||||
]);
|
||||
|
||||
$markup = '<div style="background: white; color: black; padding: 20px;">Test Content</div>';
|
||||
$uuid = ImageGenerationService::generateImage($markup, $device->id);
|
||||
|
||||
// Assert the device was updated with a new image UUID
|
||||
$device->refresh();
|
||||
expect($device->current_screen_image)->toBe($uuid);
|
||||
|
||||
// Assert PNG file was created
|
||||
Storage::disk('public')->assertExists("/images/generated/{$uuid}.png");
|
||||
})->skipOnGitHubActions();
|
||||
|
||||
it('applies rotation from device model', function (): void {
|
||||
// Create a DeviceModel with rotation
|
||||
$deviceModel = DeviceModel::factory()->create([
|
||||
'width' => 800,
|
||||
'height' => 480,
|
||||
'colors' => 256,
|
||||
'bit_depth' => 8,
|
||||
'scale_factor' => 1.0,
|
||||
'rotation' => 90, // Rotate 90 degrees
|
||||
'mime_type' => 'image/png',
|
||||
'offset_x' => 0,
|
||||
'offset_y' => 0,
|
||||
]);
|
||||
|
||||
// Create a device with the DeviceModel
|
||||
$device = Device::factory()->create([
|
||||
'device_model_id' => $deviceModel->id,
|
||||
]);
|
||||
|
||||
$markup = '<div style="background: white; color: black; padding: 20px;">Test Content</div>';
|
||||
$uuid = ImageGenerationService::generateImage($markup, $device->id);
|
||||
|
||||
// Assert the device was updated with a new image UUID
|
||||
$device->refresh();
|
||||
expect($device->current_screen_image)->toBe($uuid);
|
||||
|
||||
// Assert PNG file was created
|
||||
Storage::disk('public')->assertExists("/images/generated/{$uuid}.png");
|
||||
})->skipOnGitHubActions();
|
||||
|
||||
it('applies offset from device model', function (): void {
|
||||
// Create a DeviceModel with offset
|
||||
$deviceModel = DeviceModel::factory()->create([
|
||||
'width' => 800,
|
||||
'height' => 480,
|
||||
'colors' => 256,
|
||||
'bit_depth' => 8,
|
||||
'scale_factor' => 1.0,
|
||||
'rotation' => 0,
|
||||
'mime_type' => 'image/png',
|
||||
'offset_x' => 10, // Offset by 10 pixels
|
||||
'offset_y' => 20, // Offset by 20 pixels
|
||||
]);
|
||||
|
||||
// Create a device with the DeviceModel
|
||||
$device = Device::factory()->create([
|
||||
'device_model_id' => $deviceModel->id,
|
||||
]);
|
||||
|
||||
$markup = '<div style="background: white; color: black; padding: 20px;">Test Content</div>';
|
||||
$uuid = ImageGenerationService::generateImage($markup, $device->id);
|
||||
|
||||
// Assert the device was updated with a new image UUID
|
||||
$device->refresh();
|
||||
expect($device->current_screen_image)->toBe($uuid);
|
||||
|
||||
// Assert PNG file was created
|
||||
Storage::disk('public')->assertExists("/images/generated/{$uuid}.png");
|
||||
})->skipOnGitHubActions();
|
||||
|
||||
it('falls back to device settings when no device model', function (): void {
|
||||
// Create a device with custom settings but no DeviceModel
|
||||
$device = Device::factory()->create([
|
||||
'width' => 1024,
|
||||
'height' => 768,
|
||||
'rotate' => 180,
|
||||
'image_format' => ImageFormat::PNG_8BIT_256C->value,
|
||||
]);
|
||||
|
||||
$markup = '<div style="background: white; color: black; padding: 20px;">Test Content</div>';
|
||||
$uuid = ImageGenerationService::generateImage($markup, $device->id);
|
||||
|
||||
// Assert the device was updated with a new image UUID
|
||||
$device->refresh();
|
||||
expect($device->current_screen_image)->toBe($uuid);
|
||||
|
||||
// Assert PNG file was created
|
||||
Storage::disk('public')->assertExists("/images/generated/{$uuid}.png");
|
||||
})->skipOnGitHubActions();
|
||||
|
||||
it('handles auto image format for legacy devices', function (): void {
|
||||
// Create a device with AUTO format (legacy behavior)
|
||||
$device = Device::factory()->create([
|
||||
'width' => 800,
|
||||
'height' => 480,
|
||||
'rotate' => 0,
|
||||
'image_format' => ImageFormat::AUTO->value,
|
||||
'last_firmware_version' => '1.6.0', // Modern firmware
|
||||
]);
|
||||
|
||||
$markup = '<div style="background: white; color: black; padding: 20px;">Test Content</div>';
|
||||
$uuid = ImageGenerationService::generateImage($markup, $device->id);
|
||||
|
||||
// Assert the device was updated with a new image UUID
|
||||
$device->refresh();
|
||||
expect($device->current_screen_image)->toBe($uuid);
|
||||
|
||||
// Assert PNG file was created (modern firmware defaults to PNG)
|
||||
Storage::disk('public')->assertExists("/images/generated/{$uuid}.png");
|
||||
})->skipOnGitHubActions();
|
||||
|
||||
it('cleanupFolder removes unused images', function (): void {
|
||||
// Create active devices with images
|
||||
Device::factory()->create(['current_screen_image' => 'active-uuid-1']);
|
||||
Device::factory()->create(['current_screen_image' => 'active-uuid-2']);
|
||||
|
||||
// Create some test files
|
||||
Storage::disk('public')->put('/images/generated/active-uuid-1.png', 'test');
|
||||
Storage::disk('public')->put('/images/generated/active-uuid-2.png', 'test');
|
||||
Storage::disk('public')->put('/images/generated/inactive-uuid.png', 'test');
|
||||
Storage::disk('public')->put('/images/generated/another-inactive.png', 'test');
|
||||
|
||||
// Run cleanup
|
||||
ImageGenerationService::cleanupFolder();
|
||||
|
||||
// Assert active files are preserved
|
||||
Storage::disk('public')->assertExists('/images/generated/active-uuid-1.png');
|
||||
Storage::disk('public')->assertExists('/images/generated/active-uuid-2.png');
|
||||
|
||||
// Assert inactive files are removed
|
||||
Storage::disk('public')->assertMissing('/images/generated/inactive-uuid.png');
|
||||
Storage::disk('public')->assertMissing('/images/generated/another-inactive.png');
|
||||
})->skipOnGitHubActions();
|
||||
|
||||
it('cleanupFolder preserves .gitignore', function (): void {
|
||||
// Create gitignore file
|
||||
Storage::disk('public')->put('/images/generated/.gitignore', '*');
|
||||
|
||||
// Create some test files
|
||||
Storage::disk('public')->put('/images/generated/test.png', 'test');
|
||||
|
||||
// Run cleanup
|
||||
ImageGenerationService::cleanupFolder();
|
||||
|
||||
// Assert gitignore is preserved
|
||||
Storage::disk('public')->assertExists('/images/generated/.gitignore');
|
||||
})->skipOnGitHubActions();
|
||||
|
||||
it('resetIfNotCacheable resets when device models exist', function (): void {
|
||||
// Create a plugin
|
||||
$plugin = App\Models\Plugin::factory()->create(['current_image' => 'test-uuid']);
|
||||
|
||||
// Create a device with DeviceModel (should trigger cache reset)
|
||||
Device::factory()->create([
|
||||
'device_model_id' => DeviceModel::factory()->create()->id,
|
||||
]);
|
||||
|
||||
// Run reset check
|
||||
ImageGenerationService::resetIfNotCacheable($plugin);
|
||||
|
||||
// Assert plugin image was reset
|
||||
$plugin->refresh();
|
||||
expect($plugin->current_image)->toBeNull();
|
||||
})->skipOnGitHubActions();
|
||||
|
||||
it('resetIfNotCacheable resets when custom dimensions exist', function (): void {
|
||||
// Create a plugin
|
||||
$plugin = App\Models\Plugin::factory()->create(['current_image' => 'test-uuid']);
|
||||
|
||||
// Create a device with custom dimensions (should trigger cache reset)
|
||||
Device::factory()->create([
|
||||
'width' => 1024, // Different from default 800
|
||||
'height' => 768, // Different from default 480
|
||||
]);
|
||||
|
||||
// Run reset check
|
||||
ImageGenerationService::resetIfNotCacheable($plugin);
|
||||
|
||||
// Assert plugin image was reset
|
||||
$plugin->refresh();
|
||||
expect($plugin->current_image)->toBeNull();
|
||||
})->skipOnGitHubActions();
|
||||
|
||||
it('resetIfNotCacheable preserves image for standard devices', function (): void {
|
||||
// Create a plugin
|
||||
$plugin = App\Models\Plugin::factory()->create(['current_image' => 'test-uuid']);
|
||||
|
||||
// Create devices with standard dimensions (should not trigger cache reset)
|
||||
Device::factory()->count(3)->create([
|
||||
'width' => 800,
|
||||
'height' => 480,
|
||||
'rotate' => 0,
|
||||
]);
|
||||
|
||||
// Run reset check
|
||||
ImageGenerationService::resetIfNotCacheable($plugin);
|
||||
|
||||
// Assert plugin image was preserved
|
||||
$plugin->refresh();
|
||||
expect($plugin->current_image)->toBe('test-uuid');
|
||||
})->skipOnGitHubActions();
|
||||
|
||||
it('determines correct image format from device model', function (): void {
|
||||
// Test BMP format detection
|
||||
$bmpModel = DeviceModel::factory()->create([
|
||||
'mime_type' => 'image/bmp',
|
||||
'bit_depth' => 1,
|
||||
'colors' => 2,
|
||||
]);
|
||||
|
||||
$device = Device::factory()->create(['device_model_id' => $bmpModel->id]);
|
||||
$markup = '<div>Test</div>';
|
||||
$uuid = ImageGenerationService::generateImage($markup, $device->id);
|
||||
|
||||
$device->refresh();
|
||||
expect($device->current_screen_image)->toBe($uuid);
|
||||
Storage::disk('public')->assertExists("/images/generated/{$uuid}.bmp");
|
||||
|
||||
// Test PNG 8-bit grayscale format detection
|
||||
$pngGrayscaleModel = DeviceModel::factory()->create([
|
||||
'mime_type' => 'image/png',
|
||||
'bit_depth' => 8,
|
||||
'colors' => 2,
|
||||
]);
|
||||
|
||||
$device2 = Device::factory()->create(['device_model_id' => $pngGrayscaleModel->id]);
|
||||
$uuid2 = ImageGenerationService::generateImage($markup, $device2->id);
|
||||
|
||||
$device2->refresh();
|
||||
expect($device2->current_screen_image)->toBe($uuid2);
|
||||
Storage::disk('public')->assertExists("/images/generated/{$uuid2}.png");
|
||||
|
||||
// Test PNG 8-bit 256 color format detection
|
||||
$png256Model = DeviceModel::factory()->create([
|
||||
'mime_type' => 'image/png',
|
||||
'bit_depth' => 8,
|
||||
'colors' => 256,
|
||||
]);
|
||||
|
||||
$device3 = Device::factory()->create(['device_model_id' => $png256Model->id]);
|
||||
$uuid3 = ImageGenerationService::generateImage($markup, $device3->id);
|
||||
|
||||
$device3->refresh();
|
||||
expect($device3->current_screen_image)->toBe($uuid3);
|
||||
Storage::disk('public')->assertExists("/images/generated/{$uuid3}.png");
|
||||
})->skipOnGitHubActions();
|
||||
|
|
@ -16,6 +16,10 @@ test('it creates firmwares directory if it does not exist', function () {
|
|||
'version_tag' => '1.0.0',
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'https://example.com/firmware.bin' => Http::response('fake firmware content', 200),
|
||||
]);
|
||||
|
||||
(new FirmwareDownloadJob($firmware))->handle();
|
||||
|
||||
expect(Storage::disk('public')->exists('firmwares'))->toBeTrue();
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ beforeEach(function () {
|
|||
|
||||
test('it creates new firmware record when polling', function () {
|
||||
Http::fake([
|
||||
'usetrmnl.com/api/firmware/latest' => Http::response([
|
||||
'https://usetrmnl.com/api/firmware/latest' => Http::response([
|
||||
'version' => '1.0.0',
|
||||
'url' => 'https://example.com/firmware.bin',
|
||||
], 200),
|
||||
|
|
@ -33,7 +33,7 @@ test('it updates existing firmware record when polling', function () {
|
|||
]);
|
||||
|
||||
Http::fake([
|
||||
'usetrmnl.com/api/firmware/latest' => Http::response([
|
||||
'https://usetrmnl.com/api/firmware/latest' => Http::response([
|
||||
'version' => '1.0.0',
|
||||
'url' => 'https://new-url.com/firmware.bin',
|
||||
], 200),
|
||||
|
|
@ -53,7 +53,7 @@ test('it marks previous firmware as not latest when new version is found', funct
|
|||
]);
|
||||
|
||||
Http::fake([
|
||||
'usetrmnl.com/api/firmware/latest' => Http::response([
|
||||
'https://usetrmnl.com/api/firmware/latest' => Http::response([
|
||||
'version' => '1.1.0',
|
||||
'url' => 'https://example.com/firmware.bin',
|
||||
], 200),
|
||||
|
|
@ -67,7 +67,7 @@ test('it marks previous firmware as not latest when new version is found', funct
|
|||
|
||||
test('it handles connection exception gracefully', function () {
|
||||
Http::fake([
|
||||
'usetrmnl.com/api/firmware/latest' => function () {
|
||||
'https://usetrmnl.com/api/firmware/latest' => function () {
|
||||
throw new ConnectionException('Connection failed');
|
||||
},
|
||||
]);
|
||||
|
|
@ -80,7 +80,7 @@ test('it handles connection exception gracefully', function () {
|
|||
|
||||
test('it handles invalid response gracefully', function () {
|
||||
Http::fake([
|
||||
'usetrmnl.com/api/firmware/latest' => Http::response(null, 200),
|
||||
'https://usetrmnl.com/api/firmware/latest' => Http::response(null, 200),
|
||||
]);
|
||||
|
||||
(new FirmwarePollJob)->handle();
|
||||
|
|
@ -91,7 +91,7 @@ test('it handles invalid response gracefully', function () {
|
|||
|
||||
test('it handles missing version in response gracefully', function () {
|
||||
Http::fake([
|
||||
'usetrmnl.com/api/firmware/latest' => Http::response([
|
||||
'https://usetrmnl.com/api/firmware/latest' => Http::response([
|
||||
'url' => 'https://example.com/firmware.bin',
|
||||
], 200),
|
||||
]);
|
||||
|
|
@ -104,7 +104,7 @@ test('it handles missing version in response gracefully', function () {
|
|||
|
||||
test('it handles missing url in response gracefully', function () {
|
||||
Http::fake([
|
||||
'usetrmnl.com/api/firmware/latest' => Http::response([
|
||||
'https://usetrmnl.com/api/firmware/latest' => Http::response([
|
||||
'version' => '1.0.0',
|
||||
], 200),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -20,11 +20,15 @@ registerSpatiePestHelpers();
|
|||
arch()
|
||||
->preset()
|
||||
->laravel()
|
||||
->ignoring(App\Http\Controllers\Auth\OidcController::class);
|
||||
->ignoring([
|
||||
App\Http\Controllers\Auth\OidcController::class,
|
||||
App\Models\DeviceModel::class,
|
||||
]);
|
||||
|
||||
arch()
|
||||
->expect('App')
|
||||
->not->toUse(['die', 'dd', 'dump']);
|
||||
->not->toUse(['die', 'dd', 'dump', 'ray']);
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Expectations
|
||||
|
|
|
|||
262
tests/Unit/Services/ImageGenerationServiceTest.php
Normal file
262
tests/Unit/Services/ImageGenerationServiceTest.php
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\ImageFormat;
|
||||
use App\Models\Device;
|
||||
use App\Models\DeviceModel;
|
||||
use App\Services\ImageGenerationService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('get_image_settings returns device model settings when available', function (): void {
|
||||
// Create a DeviceModel
|
||||
$deviceModel = DeviceModel::factory()->create([
|
||||
'width' => 1024,
|
||||
'height' => 768,
|
||||
'colors' => 256,
|
||||
'bit_depth' => 8,
|
||||
'scale_factor' => 1.5,
|
||||
'rotation' => 90,
|
||||
'mime_type' => 'image/png',
|
||||
'offset_x' => 10,
|
||||
'offset_y' => 20,
|
||||
]);
|
||||
|
||||
// Create a device with the DeviceModel
|
||||
$device = Device::factory()->create([
|
||||
'device_model_id' => $deviceModel->id,
|
||||
]);
|
||||
|
||||
// Use reflection to access private method
|
||||
$reflection = new ReflectionClass(ImageGenerationService::class);
|
||||
$method = $reflection->getMethod('getImageSettings');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$settings = $method->invoke(null, $device);
|
||||
|
||||
// Assert DeviceModel settings are used
|
||||
expect($settings['width'])->toBe(1024);
|
||||
expect($settings['height'])->toBe(768);
|
||||
expect($settings['colors'])->toBe(256);
|
||||
expect($settings['bit_depth'])->toBe(8);
|
||||
expect($settings['scale_factor'])->toBe(1.5);
|
||||
expect($settings['rotation'])->toBe(90);
|
||||
expect($settings['mime_type'])->toBe('image/png');
|
||||
expect($settings['offset_x'])->toBe(10);
|
||||
expect($settings['offset_y'])->toBe(20);
|
||||
expect($settings['use_model_settings'])->toBe(true);
|
||||
})->skipOnGitHubActions();
|
||||
|
||||
it('get_image_settings falls back to device settings when no device model', function (): void {
|
||||
// Create a device without DeviceModel
|
||||
$device = Device::factory()->create([
|
||||
'width' => 800,
|
||||
'height' => 480,
|
||||
'rotate' => 180,
|
||||
'image_format' => ImageFormat::PNG_8BIT_GRAYSCALE->value,
|
||||
]);
|
||||
|
||||
// Use reflection to access private method
|
||||
$reflection = new ReflectionClass(ImageGenerationService::class);
|
||||
$method = $reflection->getMethod('getImageSettings');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$settings = $method->invoke(null, $device);
|
||||
|
||||
// Assert device settings are used
|
||||
expect($settings['width'])->toBe(800);
|
||||
expect($settings['height'])->toBe(480);
|
||||
expect($settings['rotation'])->toBe(180);
|
||||
expect($settings['image_format'])->toBe(ImageFormat::PNG_8BIT_GRAYSCALE->value);
|
||||
expect($settings['use_model_settings'])->toBe(false);
|
||||
})->skipOnGitHubActions();
|
||||
|
||||
it('get_image_settings uses defaults for missing device properties', function (): void {
|
||||
// Create a device without DeviceModel and missing properties
|
||||
$device = Device::factory()->create([
|
||||
'width' => null,
|
||||
'height' => null,
|
||||
'rotate' => null,
|
||||
// image_format has a default value of 'auto', so we can't set it to null
|
||||
]);
|
||||
|
||||
// Use reflection to access private method
|
||||
$reflection = new ReflectionClass(ImageGenerationService::class);
|
||||
$method = $reflection->getMethod('getImageSettings');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$settings = $method->invoke(null, $device);
|
||||
|
||||
// Assert default values are used
|
||||
expect($settings['width'])->toBe(800);
|
||||
expect($settings['height'])->toBe(480);
|
||||
expect($settings['rotation'])->toBe(0);
|
||||
expect($settings['colors'])->toBe(2);
|
||||
expect($settings['bit_depth'])->toBe(1);
|
||||
expect($settings['scale_factor'])->toBe(1.0);
|
||||
expect($settings['mime_type'])->toBe('image/png');
|
||||
expect($settings['offset_x'])->toBe(0);
|
||||
expect($settings['offset_y'])->toBe(0);
|
||||
// image_format will be null if the device doesn't have it set, which is the expected behavior
|
||||
expect($settings['image_format'])->toBeNull();
|
||||
})->skipOnGitHubActions();
|
||||
|
||||
it('determine_image_format_from_model returns correct formats', function (): void {
|
||||
// Use reflection to access private method
|
||||
$reflection = new ReflectionClass(ImageGenerationService::class);
|
||||
$method = $reflection->getMethod('determineImageFormatFromModel');
|
||||
$method->setAccessible(true);
|
||||
|
||||
// Test BMP format
|
||||
$bmpModel = DeviceModel::factory()->create([
|
||||
'mime_type' => 'image/bmp',
|
||||
'bit_depth' => 1,
|
||||
'colors' => 2,
|
||||
]);
|
||||
$format = $method->invoke(null, $bmpModel);
|
||||
expect($format)->toBe(ImageFormat::BMP3_1BIT_SRGB->value);
|
||||
|
||||
// Test PNG 8-bit grayscale format
|
||||
$pngGrayscaleModel = DeviceModel::factory()->create([
|
||||
'mime_type' => 'image/png',
|
||||
'bit_depth' => 8,
|
||||
'colors' => 2,
|
||||
]);
|
||||
$format = $method->invoke(null, $pngGrayscaleModel);
|
||||
expect($format)->toBe(ImageFormat::PNG_8BIT_GRAYSCALE->value);
|
||||
|
||||
// Test PNG 8-bit 256 color format
|
||||
$png256Model = DeviceModel::factory()->create([
|
||||
'mime_type' => 'image/png',
|
||||
'bit_depth' => 8,
|
||||
'colors' => 256,
|
||||
]);
|
||||
$format = $method->invoke(null, $png256Model);
|
||||
expect($format)->toBe(ImageFormat::PNG_8BIT_256C->value);
|
||||
|
||||
// Test PNG 2-bit 4 color format
|
||||
$png4ColorModel = DeviceModel::factory()->create([
|
||||
'mime_type' => 'image/png',
|
||||
'bit_depth' => 2,
|
||||
'colors' => 4,
|
||||
]);
|
||||
$format = $method->invoke(null, $png4ColorModel);
|
||||
expect($format)->toBe(ImageFormat::PNG_2BIT_4C->value);
|
||||
|
||||
// Test unknown format returns AUTO
|
||||
$unknownModel = DeviceModel::factory()->create([
|
||||
'mime_type' => 'image/jpeg',
|
||||
'bit_depth' => 16,
|
||||
'colors' => 65536,
|
||||
]);
|
||||
$format = $method->invoke(null, $unknownModel);
|
||||
expect($format)->toBe(ImageFormat::AUTO->value);
|
||||
})->skipOnGitHubActions();
|
||||
|
||||
it('cleanup_folder identifies active images correctly', function (): void {
|
||||
// Create devices with images
|
||||
$device1 = Device::factory()->create(['current_screen_image' => 'active-uuid-1']);
|
||||
$device2 = Device::factory()->create(['current_screen_image' => 'active-uuid-2']);
|
||||
$device3 = Device::factory()->create(['current_screen_image' => null]);
|
||||
|
||||
// Create a plugin with image
|
||||
$plugin = App\Models\Plugin::factory()->create(['current_image' => 'plugin-uuid']);
|
||||
|
||||
// For unit testing, we could test the logic that determines active UUIDs
|
||||
$activeDeviceImageUuids = Device::pluck('current_screen_image')->filter()->toArray();
|
||||
$activePluginImageUuids = App\Models\Plugin::pluck('current_image')->filter()->toArray();
|
||||
$activeImageUuids = array_merge($activeDeviceImageUuids, $activePluginImageUuids);
|
||||
|
||||
expect($activeImageUuids)->toContain('active-uuid-1');
|
||||
expect($activeImageUuids)->toContain('active-uuid-2');
|
||||
expect($activeImageUuids)->toContain('plugin-uuid');
|
||||
expect($activeImageUuids)->not->toContain(null);
|
||||
});
|
||||
|
||||
it('reset_if_not_cacheable detects device models', function (): void {
|
||||
// Create a plugin
|
||||
$plugin = App\Models\Plugin::factory()->create(['current_image' => 'test-uuid']);
|
||||
|
||||
// Create a device with DeviceModel
|
||||
Device::factory()->create([
|
||||
'device_model_id' => DeviceModel::factory()->create()->id,
|
||||
]);
|
||||
|
||||
// Test that the method detects DeviceModels and resets cache
|
||||
ImageGenerationService::resetIfNotCacheable($plugin);
|
||||
|
||||
$plugin->refresh();
|
||||
expect($plugin->current_image)->toBeNull();
|
||||
})->skipOnGitHubActions();
|
||||
|
||||
it('reset_if_not_cacheable detects custom dimensions', function (): void {
|
||||
// Create a plugin
|
||||
$plugin = App\Models\Plugin::factory()->create(['current_image' => 'test-uuid']);
|
||||
|
||||
// Create a device with custom dimensions
|
||||
Device::factory()->create([
|
||||
'width' => 1024, // Different from default 800
|
||||
'height' => 768, // Different from default 480
|
||||
]);
|
||||
|
||||
// Test that the method detects custom dimensions and resets cache
|
||||
ImageGenerationService::resetIfNotCacheable($plugin);
|
||||
|
||||
$plugin->refresh();
|
||||
expect($plugin->current_image)->toBeNull();
|
||||
})->skipOnGitHubActions();
|
||||
|
||||
it('reset_if_not_cacheable preserves cache for standard devices', function (): void {
|
||||
// Create a plugin
|
||||
$plugin = App\Models\Plugin::factory()->create(['current_image' => 'test-uuid']);
|
||||
|
||||
// Create devices with standard dimensions
|
||||
Device::factory()->count(3)->create([
|
||||
'width' => 800,
|
||||
'height' => 480,
|
||||
'rotate' => 0,
|
||||
]);
|
||||
|
||||
// Test that the method preserves cache for standard devices
|
||||
ImageGenerationService::resetIfNotCacheable($plugin);
|
||||
|
||||
$plugin->refresh();
|
||||
expect($plugin->current_image)->toBe('test-uuid');
|
||||
})->skipOnGitHubActions();
|
||||
|
||||
it('reset_if_not_cacheable handles null plugin', function (): void {
|
||||
// Test that the method handles null plugin gracefully
|
||||
expect(fn () => ImageGenerationService::resetIfNotCacheable(null))->not->toThrow(Exception::class);
|
||||
})->skipOnGitHubActions();
|
||||
|
||||
it('image_format enum includes new 2bit 4c format', function (): void {
|
||||
// Test that the new format is properly defined in the enum
|
||||
expect(ImageFormat::PNG_2BIT_4C->value)->toBe('png_2bit_4c');
|
||||
expect(ImageFormat::PNG_2BIT_4C->label())->toBe('PNG 2-bit Grayscale 4c');
|
||||
});
|
||||
|
||||
it('device model relationship works correctly', function (): void {
|
||||
// Create a DeviceModel
|
||||
$deviceModel = DeviceModel::factory()->create();
|
||||
|
||||
// Create a device with the DeviceModel
|
||||
$device = Device::factory()->create([
|
||||
'device_model_id' => $deviceModel->id,
|
||||
]);
|
||||
|
||||
// Test the relationship
|
||||
expect($device->deviceModel)->toBeInstanceOf(DeviceModel::class);
|
||||
expect($device->deviceModel->id)->toBe($deviceModel->id);
|
||||
});
|
||||
|
||||
it('device without device model returns null relationship', function (): void {
|
||||
// Create a device without DeviceModel
|
||||
$device = Device::factory()->create([
|
||||
'device_model_id' => null,
|
||||
]);
|
||||
|
||||
// Test the relationship returns null
|
||||
expect($device->deviceModel)->toBeNull();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue