Compare commits

..

No commits in common. "main" and "0.28.0" have entirely different histories.
main ... 0.28.0

67 changed files with 1066 additions and 2423 deletions

View file

@ -6,6 +6,7 @@ on:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
@ -39,9 +40,7 @@ jobs:
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.REGISTRY }}/usetrmnl/byos_laravel
${{ env.REGISTRY }}/usetrmnl/larapaper
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}

View file

@ -1,10 +1,10 @@
########################
# Base Image
########################
FROM bnussbau/serversideup-php:8.4-fpm-nginx-alpine-imagick-chromium@sha256:ed705a4060d50143ddc538c1288afff217eaf76ad5791f7556a97943854cf745 AS base
FROM bnussbau/serversideup-php:8.4-fpm-nginx-alpine-imagick-chromium@sha256:52ac545fdb57b2ab7568b1c7fc0a98cb1a69a275d8884249778a80914272fa48 AS base
LABEL org.opencontainers.image.source=https://github.com/usetrmnl/larapaper
LABEL org.opencontainers.image.description="LaraPaper"
LABEL org.opencontainers.image.source=https://github.com/usetrmnl/byos_laravel
LABEL org.opencontainers.image.description="TRMNL BYOS Laravel"
LABEL org.opencontainers.image.licenses=MIT
ARG APP_VERSION

View file

@ -1,8 +1,8 @@
## LaraPaper (PHP/Laravel)
## TRMNL BYOS (PHP/Laravel)
[![tests](https://github.com/usetrmnl/larapaper/actions/workflows/test.yml/badge.svg)](https://github.com/usetrmnl/larapaper/actions/workflows/test.yml)
[![tests](https://github.com/usetrmnl/byos_laravel/actions/workflows/test.yml/badge.svg)](https://github.com/usetrmnl/byos_laravel/actions/workflows/test.yml)
LaraPaper is a self-hostable implementation of a TRMNL server (BYOS), built with Laravel.
TRMNL BYOS Laravel is a self-hostable implementation of a TRMNL server, built with Laravel.
It allows you to manage TRMNL devices, generate screens using **native plugins** (Screens API, Markup), **recipes** (130+ from the [OSS community catalog](https://bnussbau.github.io/trmnl-recipe-catalog/), 700+ from the [TRMNL catalog](https://trmnl.com/recipes), or your own), or the **API**, and can also act as a **proxy** for the native cloud service (Core). With over 50k downloads and 200+ stars, its the most popular community-driven BYOS.
![Screenshot](README_byos-screenshot.png)
@ -26,7 +26,7 @@ It allows you to manage TRMNL devices, generate screens using **native plugins**
* Custom ESP32 with TRMNL firmware
* E-Reader Devices
* KOReader ([trmnl-koreader](https://github.com/usetrmnl/trmnl-koreader))
* Kindle ([trmnl-kindle](https://github.com/usetrmnl/larapaper/pull/27))
* Kindle ([trmnl-kindle](https://github.com/usetrmnl/byos_laravel/pull/27))
* Nook ([trmnl-nook](https://github.com/usetrmnl/trmnl-nook))
* Kobo ([trmnl-kobo](https://github.com/usetrmnl/trmnl-kobo))
* Android Devices with [trmnl-android](https://github.com/usetrmnl/trmnl-android)
@ -61,7 +61,7 @@ Docker Compose file located at: [docker/prod/docker-compose.yml](docker/prod/doc
##### Backup Database
```sh
docker ps #find container id of larapaper container
docker ps #find container id of byos_laravel container
docker cp {{CONTAINER_ID}}:/var/www/html/database/storage/database.sqlite database_backup.sqlite
```
@ -73,11 +73,11 @@ docker compose up -d
```
#### VPS
If youre using a VPS (e.g., Hetzner) and prefer an alternative to native Docker, you can install Dokploy and deploy LaraPaper using the integrated [Template](https://templates.dokploy.com/?q=trmnl+byos+laravel).
If youre using a VPS (e.g., Hetzner) and prefer an alternative to native Docker, you can install Dokploy and deploy BYOS Laravel using the integrated [Template](https://templates.dokploy.com/?q=trmnl+byos+laravel).
Its a quick way to get started without having to manually manage Docker setup.
#### PikaPods
You can vote for LaraPaper to be included as PikaPods Template here: [feedback.pikapods.com](https://feedback.pikapods.com/posts/842/add-app-trmnl-byos-laravel)
You can vote for TRMNL BYOS Laravel to be included as PikaPods Template here: [feedback.pikapods.com](https://feedback.pikapods.com/posts/842/add-app-trmnl-byos-laravel)
#### Umbrel
Umbrel is supported through a community store, [see](http://github.com/bnussbau/umbrel-store).
@ -121,16 +121,11 @@ php artisan db:seed --class=ExampleRecipesSeeder
| `TRMNL_PROXY_REFRESH_MINUTES` | How often should the server fetch new images from native service | 15 |
| `REGISTRATION_ENABLED` | Allow user registration via Webinterface | 1 |
| `SSL_MODE` | SSL Mode, if not using a Reverse Proxy ([docs](https://serversideup.net/open-source/docker-php/docs/customizing-the-image/configuring-ssl)) | `off` |
| `FORCE_HTTPS` | If your server handles SSL termination, enforce HTTPS. Alternative: `TRUSTED_PROXIES`. | 0 |
| `FORCE_HTTPS` | If your server handles SSL termination, enforce HTTPS. | 0 |
| `TRUSTED_PROXIES` | If your server handles SSL termination, allow mixed mode. e.g. `"172.0.0.0/8"` or `*` | null |
| `PHP_OPCACHE_ENABLE` | Enable PHP Opcache | 0 |
| `TRMNL_IMAGE_URL_TIMEOUT` | How long TRMNL waits for a response on the display endpoint. (sec) | 30 |
| `APP_TIMEZONE` | Default timezone, which will be used by the PHP date functions. UTC is recommended. | UTC |
##### Experimental Environment Variables
| Environment Variable | Description | Default |
|----------------------------------|--------------------------------------------------------------------------------|---------|
| `PUPPETEER_WINDOW_SIZE_STRATEGY` | Set to `v2` to size the browser window to match the devices screen dimensions | `null` |
| `APP_TIMEZONE` | Default timezone, which will be used by the PHP date functions | UTC |
#### Login
@ -173,13 +168,13 @@ See this YouTube guide: [https://www.youtube.com/watch?v=3xehPW-PCOM](https://ww
### ☁️ Activate fresh TRMNL Device with Cloud Proxy
1) Setup the TRMNL as in the official docs with the cloud service (connect one of the plugins to later verify it works)
2) Setup LaraPaper, create a user and login
3) In LaraPaper in the header bar, activate the toggle "Permit Auto-Join"
2) Setup Laravel BYOS, create a user and login
3) In Laravel BYOS in the header bar, activate the toggle "Permit Auto-Join"
4) Press and hold the button on the back of your TRMNL for 5 seconds to reactivate the captive portal (or reflash).
5) Go through the setup process again, in the screen where you provide the Wi-Fi credentials there is also option to set the Server URL. Use the local address of LaraPaper.
5) Go through the setup process again, in the screen where you provide the Wi-Fi credentials there is also option to set the Server URL. Use the local address of your Laravel BYOS
6) The device should automatically appear in the device list; you can deactivate the "Permit Auto-Join" toggle again.
7) In the devices list, activate the toggle "☁️ Proxy" for your device. (Make sure that the queue worker is active. In the docker image it should be running automatically.)
8) As long as no LaraPaper plugin is scheduled, the device will show your cloud plugins.
8) As long as no Laravel BYOS plugin is scheduled, the device will show your cloud plugins.
###### Troubleshooting

View file

@ -184,7 +184,7 @@ class GenerateDefaultImagesCommand extends Command
};
// Determine device properties from DeviceModel
$deviceVariant = $deviceModel->css_name ?? $deviceModel->name ?? 'og';
$deviceVariant = $deviceModel->name ?? 'og';
$colorDepth = $deviceModel->color_depth ?? '1bit'; // Use the accessor method
$scaleLevel = $deviceModel->scale_level; // Use the accessor method
$darkMode = $imageType === 'sleep'; // Sleep mode uses dark mode, setup uses light mode
@ -196,7 +196,6 @@ class GenerateDefaultImagesCommand extends Command
'deviceVariant' => $deviceVariant,
'colorDepth' => $colorDepth,
'scaleLevel' => $scaleLevel,
'cssVariables' => $deviceModel->css_variables,
])->render();
}
}

View file

@ -156,7 +156,7 @@ class CheckVersionUpdateJob
private function extractLatestVersion(array $response, bool $enablePrereleases): array
{
if (! $enablePrereleases || ! isset($response[0])) {
if (! $enablePrereleases || ! is_array($response) || ! isset($response[0])) {
return [
Arr::get($response, 'tag_name'),
$response,

View file

@ -12,10 +12,8 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
final class FetchDeviceModelsJob implements ShouldQueue
{
@ -211,41 +209,12 @@ final class FetchDeviceModelsJob implements ShouldQueue
$attributes['palette_id'] = $firstPaletteId;
}
$attributes['css_name'] = $this->parseCssNameFromApi($modelData['css'] ?? null);
$attributes['css_variables'] = $this->parseCssVariablesFromApi($modelData['css'] ?? null);
DeviceModel::updateOrCreate(
['name' => $name],
$attributes
);
}
/**
* Extract css_name from API css payload (strip "screen--" prefix from classes.device).
*/
private function parseCssNameFromApi(mixed $css): ?string
{
$deviceClass = is_array($css) ? Arr::get($css, 'classes.device') : null;
return (is_string($deviceClass) ? Str::after($deviceClass, 'screen--') : null) ?: null;
}
/**
* Extract css_variables from API css payload (convert [[key, value], ...] to associative array).
*/
private function parseCssVariablesFromApi(mixed $css): ?array
{
$pairs = is_array($css) ? Arr::get($css, 'variables', []) : [];
if (! is_array($pairs)) {
return null;
}
$validPairs = Arr::where($pairs, fn (mixed $pair): bool => is_array($pair) && isset($pair[0], $pair[1]));
$variables = Arr::pluck($validPairs, 1, 0);
return $variables !== [] ? $variables : null;
}
/**
* Get the first palette ID from model data.
*/

View file

@ -34,13 +34,8 @@ class GenerateScreenJob implements ShouldQueue
Device::find($this->deviceId)->update(['current_screen_image' => $newImageUuid]);
if ($this->pluginId) {
$plugin = Plugin::find($this->pluginId);
$update = ['current_image' => $newImageUuid];
if ($plugin->plugin_type === 'recipe') {
$device = Device::with(['deviceModel', 'deviceModel.palette'])->find($this->deviceId);
$update['current_image_metadata'] = ImageGenerationService::buildImageMetadataFromDevice($device);
}
$plugin->update($update);
// cache current image
Plugin::find($this->pluginId)->update(['current_image' => $newImageUuid]);
}
ImageGenerationService::cleanupFolder();

View file

@ -4,14 +4,11 @@ declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* @property-read array<string, string> $css_variables
* @property-read string|null $css_name
* @property-read DevicePalette|null $palette
*/
final class DeviceModel extends Model
@ -30,7 +27,6 @@ final class DeviceModel extends Model
'offset_x' => 'integer',
'offset_y' => 'integer',
'published_at' => 'datetime',
'css_variables' => 'array',
];
public function getColorDepthAttribute(): ?string
@ -75,54 +71,8 @@ final class DeviceModel extends Model
return null;
}
/**
* Returns css_name for v2 (per-device sizing); for v1 returns 'og' to preserve legacy single-variant behaviour.
*
* @return Attribute<string|null, string|null>
*/
protected function cssName(): Attribute
{
/** @var Attribute<string|null, string|null> */
return Attribute::get(
fn (mixed $value): ?string => config('app.puppeteer_window_size_strategy') === 'v2' ? ($value !== null ? (string) $value : null) : 'og'
);
}
public function palette(): BelongsTo
{
return $this->belongsTo(DevicePalette::class, 'palette_id');
}
/**
* Returns css_variables with --screen-w and --screen-h filled from width/height
* when puppeteer_window_size_strategy is v2 and they are not set.
*
* @return Attribute<array<string, string>, array<string, string>>
*/
protected function cssVariables(): Attribute
{
/** @var Attribute<array<string, string>, array<string, string>> */
return Attribute::get(
/** @return array<string, string> */
function (mixed $value, array $attributes): array {
$vars = is_array($value) ? $value : (is_string($value) ? (json_decode($value, true) ?? []) : []);
if (config('app.puppeteer_window_size_strategy') !== 'v2') {
return $vars;
}
$width = $attributes['width'] ?? null;
$height = $attributes['height'] ?? null;
if (empty($vars['--screen-w']) && $width !== null && $width !== '') {
$vars['--screen-w'] = is_numeric($width) ? (int) $width.'px' : (string) $width;
}
if (empty($vars['--screen-h']) && $height !== null && $height !== '') {
$vars['--screen-h'] = is_numeric($height) ? (int) $height.'px' : (string) $height;
}
/** @var array<string, string> $vars */
return $vars;
});
}
}

View file

@ -140,9 +140,8 @@ class PlaylistItem extends Model
if (! $this->isMashup()) {
return view('trmnl-layouts.single', [
'colorDepth' => $device?->colorDepth(),
'deviceVariant' => $device?->deviceModel?->css_name ?? $device?->deviceVariant() ?? 'og',
'deviceVariant' => $device?->deviceVariant() ?? 'og',
'scaleLevel' => $device?->scaleLevel(),
'cssVariables' => $device?->deviceModel?->css_variables,
'slot' => $this->plugin instanceof Plugin
? $this->plugin->render('full', false, $device)
: throw new Exception('Invalid plugin instance'),
@ -163,9 +162,8 @@ class PlaylistItem extends Model
return view('trmnl-layouts.mashup', [
'colorDepth' => $device?->colorDepth(),
'deviceVariant' => $device?->deviceModel?->css_name ?? $device?->deviceVariant() ?? 'og',
'deviceVariant' => $device?->deviceVariant() ?? 'og',
'scaleLevel' => $device?->scaleLevel(),
'cssVariables' => $device?->deviceModel?->css_variables,
'mashupLayout' => $this->getMashupLayoutType(),
'slot' => implode('', $pluginMarkups),
])->render();

View file

@ -49,7 +49,6 @@ class Plugin extends Model
'preferred_renderer' => 'string',
'plugin_type' => 'string',
'alias' => 'boolean',
'current_image_metadata' => 'array',
];
protected static function boot()
@ -72,7 +71,6 @@ class Plugin extends Model
'render_markup_shared',
])) {
$model->current_image = null;
$model->current_image_metadata = null;
}
});
@ -224,7 +222,7 @@ class Plugin extends Model
if ($this->data_strategy !== 'polling' || ! $this->polling_url) {
return;
}
$headers = ['User-Agent' => 'usetrmnl/larapaper', 'Accept' => 'application/json'];
$headers = ['User-Agent' => 'usetrmnl/byos_laravel', 'Accept' => 'application/json'];
// resolve headers
if ($this->polling_header) {
@ -575,45 +573,10 @@ class Plugin extends Model
$renderedContent = $template->render($liquidContext);
}
} else {
// Get timezone from user or fall back to app timezone
$timezone = $this->user->timezone ?? config('app.timezone');
// Calculate UTC offset in seconds
$utcOffset = (string) Carbon::now($timezone)->getOffset();
$renderedContent = Blade::render($markup, [
'size' => $size,
'data' => $this->data_payload,
'config' => $this->configuration ?? [],
'trmnl' => [
'system' => [
'timestamp_utc' => now()->utc()->timestamp,
],
'user' => [
'utc_offset' => $utcOffset,
'name' => $this->user->name ?? 'Unknown User',
'locale' => 'en',
'time_zone_iana' => $timezone,
],
'device' => [
'friendly_id' => $device?->friendly_id,
'percent_charged' => $device?->battery_percent,
'wifi_strength' => $device?->wifi_strength,
'height' => $device?->height,
'width' => $device?->width,
],
'plugin_settings' => [
'instance_name' => $this->name,
'strategy' => $this->data_strategy,
'dark_mode' => $this->dark_mode ? 'yes' : 'no',
'no_screen_padding' => $this->no_bleed ? 'yes' : 'no',
'polling_headers' => $this->polling_header,
'polling_url' => $this->polling_url,
'custom_fields_values' => [
...(is_array($this->configuration) ? $this->configuration : []),
],
],
],
]);
}
@ -621,11 +584,10 @@ class Plugin extends Model
if ($size === 'full') {
return view('trmnl-layouts.single', [
'colorDepth' => $device?->colorDepth(),
'deviceVariant' => $device?->deviceModel?->css_name ?? $device?->deviceVariant() ?? 'og',
'deviceVariant' => $device?->deviceVariant() ?? 'og',
'noBleed' => $this->no_bleed,
'darkMode' => $this->dark_mode,
'scaleLevel' => $device?->scaleLevel(),
'cssVariables' => $device?->deviceModel?->css_variables,
'slot' => $renderedContent,
])->render();
}
@ -633,10 +595,9 @@ class Plugin extends Model
return view('trmnl-layouts.mashup', [
'mashupLayout' => $this->getPreviewMashupLayoutForSize($size),
'colorDepth' => $device?->colorDepth(),
'deviceVariant' => $device?->deviceModel?->css_name ?? $device?->deviceVariant() ?? 'og',
'deviceVariant' => $device?->deviceVariant() ?? 'og',
'darkMode' => $this->dark_mode,
'scaleLevel' => $device?->scaleLevel(),
'cssVariables' => $device?->deviceModel?->css_variables,
'slot' => $renderedContent,
])->render();
@ -656,11 +617,10 @@ class Plugin extends Model
if ($size === 'full') {
return view('trmnl-layouts.single', [
'colorDepth' => $device?->colorDepth(),
'deviceVariant' => $device?->deviceModel?->css_name ?? $device?->deviceVariant() ?? 'og',
'deviceVariant' => $device?->deviceVariant() ?? 'og',
'noBleed' => $this->no_bleed,
'darkMode' => $this->dark_mode,
'scaleLevel' => $device?->scaleLevel(),
'cssVariables' => $device?->deviceModel?->css_variables,
'slot' => $renderedView,
])->render();
}
@ -668,10 +628,9 @@ class Plugin extends Model
return view('trmnl-layouts.mashup', [
'mashupLayout' => $this->getPreviewMashupLayoutForSize($size),
'colorDepth' => $device?->colorDepth(),
'deviceVariant' => $device?->deviceModel?->css_name ?? $device?->deviceVariant() ?? 'og',
'deviceVariant' => $device?->deviceVariant() ?? 'og',
'darkMode' => $this->dark_mode,
'scaleLevel' => $device?->scaleLevel(),
'cssVariables' => $device?->deviceModel?->css_variables,
'slot' => $renderedView,
])->render();
}
@ -775,8 +734,8 @@ class Plugin extends Model
}
}
// Append "_copy" to the name
$attributes['name'] = $this->name.'_copy';
// Append " (Copy)" to the name
$attributes['name'] = $this->name.' (Copy)';
// Set user_id - use provided userId or fall back to original plugin's user_id
$attributes['user_id'] = $userId ?? $this->user_id;

View file

@ -331,88 +331,36 @@ class ImageGenerationService
}
}
/**
* Ensure plugin image cache is valid for the current context. No-op for image_webhook.
* When deviceOrModel is provided (recipe only), clears cache if stored metadata does not match.
*/
public static function resetIfNotCacheable(?Plugin $plugin, Device|DeviceModel|null $deviceOrModel = null): void
public static function resetIfNotCacheable(?Plugin $plugin): void
{
if (! $plugin?->id || $plugin->plugin_type === 'image_webhook') {
if ($plugin?->id) {
// Image webhook plugins have finalized images that shouldn't be reset
if ($plugin->plugin_type === 'image_webhook') {
return;
}
if ($deviceOrModel === null || $plugin->plugin_type !== 'recipe') {
return;
}
if ($plugin->current_image === null) {
return;
}
if (self::imageMetadataMatches($plugin->current_image_metadata, $deviceOrModel)) {
return;
}
$plugin->update([
'current_image' => null,
'current_image_metadata' => null,
]);
Log::debug("Plugin {$plugin->id}: cleared image cache due to metadata mismatch");
}
// Check if any devices have custom dimensions or use non-standard DeviceModels
$hasCustomDimensions = Device::query()
->where(function ($query): void {
$query->where('width', '!=', 800)
->orWhere('height', '!=', 480)
->orWhere('rotate', '!=', 0);
})
->orWhereHas('deviceModel', function ($query): void {
// Only allow caching if all device models have standard dimensions (800x480, rotation=0)
$query->where(function ($subQuery): void {
$subQuery->where('width', '!=', 800)
->orWhere('height', '!=', 480)
->orWhere('rotation', '!=', 0);
});
})
->exists();
/**
* Build canonical image metadata from a Device for cache comparison.
*
* @return array{width: int, height: int, rotation: int, palette_id: int|null, mime_type: string}
*/
public static function buildImageMetadataFromDevice(Device $device): array
{
$device->loadMissing(['deviceModel', 'deviceModel.palette']);
$settings = self::getImageSettings($device);
$paletteId = $device->palette_id ?? $device->deviceModel?->palette_id;
return [
'width' => $settings['width'],
'height' => $settings['height'],
'rotation' => $settings['rotation'] ?? 0,
'palette_id' => $paletteId,
'mime_type' => $settings['mime_type'],
];
}
/**
* Build canonical image metadata from a DeviceModel for cache comparison.
*
* @return array{width: int, height: int, rotation: int, palette_id: int|null, mime_type: string}
*/
public static function buildImageMetadataFromDeviceModel(DeviceModel $model): array
{
return [
'width' => $model->width,
'height' => $model->height,
'rotation' => $model->rotation ?? 0,
'palette_id' => $model->palette_id,
'mime_type' => $model->mime_type,
];
}
/**
* Check if stored metadata matches the current device or device model.
* Returns false if stored is null or empty so cache is regenerated and metadata is stored.
*/
public static function imageMetadataMatches(?array $stored, Device|DeviceModel $deviceOrModel): bool
{
if ($stored === null || $stored === []) {
return false;
}
$current = $deviceOrModel instanceof Device
? self::buildImageMetadataFromDevice($deviceOrModel)
: self::buildImageMetadataFromDeviceModel($deviceOrModel);
foreach (['width', 'height', 'rotation', 'palette_id', 'mime_type'] as $key) {
if (($stored[$key] ?? null) !== ($current[$key] ?? null)) {
return false;
if ($hasCustomDimensions) {
// TODO cache image per device
$plugin->update(['current_image' => null]);
Log::debug('Skip cache as devices with custom dimensions or non-standard DeviceModels exist');
}
}
return true;
}
/**
@ -566,7 +514,7 @@ class ImageGenerationService
};
// Determine device properties from DeviceModel or device settings
$deviceVariant = $device->deviceModel?->css_name ?? $device->deviceVariant();
$deviceVariant = $device->deviceVariant();
$deviceOrientation = $device->rotate > 0 ? 'portrait' : 'landscape';
$colorDepth = $device->colorDepth() ?? '1bit';
$scaleLevel = $device->scaleLevel();
@ -580,7 +528,6 @@ class ImageGenerationService
'deviceOrientation' => $deviceOrientation,
'colorDepth' => $colorDepth,
'scaleLevel' => $scaleLevel,
'cssVariables' => $device->deviceModel?->css_variables ?? [],
];
// Add plugin name for error screens

View file

@ -25,12 +25,7 @@ class IcalResponseParser implements ResponseParser
}
try {
// Workaround for om/icalparser v4.0.0 bug where it fails if ORGANIZER or ATTENDEE has no parameters.
// When ORGANIZER or ATTENDEE has no parameters (no semicolon after the key),
// IcalParser::parseRow returns an empty string for $middle instead of an array,
// which causes a type error in a foreach loop in IcalParser::parseString.
$normalizedBody = preg_replace('/^(ORGANIZER|ATTENDEE):/m', '$1;CN=Unknown:', $body);
$this->parser->parseString($normalizedBody);
$this->parser->parseString($body);
$events = $this->parser->getEvents()->sorted()->getArrayCopy();
$windowStart = now()->subDays(7);

View file

@ -11,12 +11,12 @@
],
"license": "MIT",
"require": {
"php": "^8.4",
"php": "^8.2",
"ext-imagick": "*",
"ext-simplexml": "*",
"ext-zip": "*",
"bnussbau/laravel-trmnl-blade": "^2.3",
"bnussbau/trmnl-pipeline-php": "^0.8",
"bnussbau/laravel-trmnl-blade": "2.3.*",
"bnussbau/trmnl-pipeline-php": "0.7.*",
"keepsuit/laravel-liquid": "^0.5.2",
"laravel/fortify": "^1.30",
"laravel/framework": "^12.1",
@ -25,7 +25,7 @@
"laravel/tinker": "^2.10.1",
"livewire/flux": "^2.0",
"livewire/livewire": "^4.0",
"om/icalparser": "^4.0",
"om/icalparser": "^3.2",
"spatie/browsershot": "^5.0",
"spatie/laravel-settings": "^3.6",
"stevebauman/purify": "^6.3",

835
composer.lock generated

File diff suppressed because it is too large Load diff

View file

@ -13,7 +13,7 @@ return [
|
*/
'name' => env('APP_NAME', 'LaraPaper'),
'name' => env('APP_NAME', 'Laravel'),
/*
|--------------------------------------------------------------------------
@ -127,13 +127,11 @@ return [
'enabled' => env('REGISTRATION_ENABLED', true),
],
'pixel_logo_enabled' => env('PIXELLOGO_ENABLED', true),
'force_https' => env('FORCE_HTTPS', false),
'puppeteer_docker' => env('PUPPETEER_DOCKER', false),
'puppeteer_mode' => env('PUPPETEER_MODE', 'local'),
'puppeteer_wait_for_network_idle' => env('PUPPETEER_WAIT_FOR_NETWORK_IDLE', true),
'puppeteer_window_size_strategy' => env('PUPPETEER_WINDOW_SIZE_STRATEGY', 'v2'),
'puppeteer_window_size_strategy' => env('PUPPETEER_WINDOW_SIZE_STRATEGY', null),
'notifications' => [
'battery_low' => [
@ -156,5 +154,5 @@ return [
'catalog_url' => env('CATALOG_URL', 'https://raw.githubusercontent.com/bnussbau/trmnl-recipe-catalog/refs/heads/main/catalog.yaml'),
'github_repo' => env('GITHUB_REPO', 'usetrmnl/larapaper'),
'github_repo' => env('GITHUB_REPO', 'usetrmnl/byos_laravel'),
];

View file

@ -1,29 +0,0 @@
<?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('device_models', function (Blueprint $table) {
$table->string('css_name')->nullable()->after('kind');
$table->json('css_variables')->nullable()->after('css_name');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('device_models', function (Blueprint $table) {
$table->dropColumn(['css_name', 'css_variables']);
});
}
};

View file

@ -1,160 +0,0 @@
<?php
use App\Models\DeviceModel;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
/**
* CSS name and variables for device models created by seed_device_models (og_png until inky_impression_13_3).
*
* @var array<string, array{css_name: string, css_variables: array<string, string>}>
*/
private const SEEDED_CSS = [
'og_png' => [
'css_name' => 'og_png',
'css_variables' => [
'--screen-w' => '800px',
'--screen-h' => '480px',
'--ui-scale' => '1.0',
'--gap-scale' => '1.0',
],
],
'og_plus' => [
'css_name' => 'ogv2',
'css_variables' => [
'--screen-w' => '800px',
'--screen-h' => '480px',
'--ui-scale' => '1.0',
'--gap-scale' => '1.0',
],
],
'amazon_kindle_2024' => [
'css_name' => 'amazon_kindle_2024',
'css_variables' => [
'--screen-w' => '800px',
'--screen-h' => '480px',
'--ui-scale' => '0.8',
'--gap-scale' => '1.0',
],
],
'amazon_kindle_paperwhite_6th_gen' => [
'css_name' => 'amazon_kindle_paperwhite_6th_gen',
'css_variables' => [
'--screen-w' => '800px',
'--screen-h' => '600px',
'--ui-scale' => '1.0',
'--gap-scale' => '1.0',
],
],
'amazon_kindle_paperwhite_7th_gen' => [
'css_name' => 'amazon_kindle_paperwhite_7th_gen',
'css_variables' => [
'--screen-w' => '905px',
'--screen-h' => '670px',
'--ui-scale' => '1.0',
'--gap-scale' => '1.0',
],
],
'inkplate_10' => [
'css_name' => 'inkplate_10',
'css_variables' => [
'--screen-w' => '800px',
'--screen-h' => '547px',
'--ui-scale' => '1.0',
'--gap-scale' => '1.0',
],
],
'amazon_kindle_7' => [
'css_name' => 'amazon_kindle_7',
'css_variables' => [
'--screen-w' => '800px',
'--screen-h' => '600px',
'--ui-scale' => '1.0',
'--gap-scale' => '1.0',
],
],
'inky_impression_7_3' => [
'css_name' => 'inky_impression_7_3',
'css_variables' => [
'--screen-w' => '800px',
'--screen-h' => '480px',
'--ui-scale' => '1.0',
'--gap-scale' => '1.0',
],
],
'kobo_libra_2' => [
'css_name' => 'kobo_libra_2',
'css_variables' => [
'--screen-w' => '800px',
'--screen-h' => '602px',
'--ui-scale' => '1.0',
'--gap-scale' => '1.0',
],
],
'amazon_kindle_oasis_2' => [
'css_name' => 'amazon_kindle_oasis_2',
'css_variables' => [
'--screen-w' => '800px',
'--screen-h' => '602px',
'--ui-scale' => '1.0',
'--gap-scale' => '1.0',
],
],
'kobo_aura_one' => [
'css_name' => 'kobo_aura_one',
'css_variables' => [
'--screen-w' => '1040px',
'--screen-h' => '780px',
'--ui-scale' => '1.0',
'--gap-scale' => '1.0',
],
],
'kobo_aura_hd' => [
'css_name' => 'kobo_aura_hd',
'css_variables' => [
'--screen-w' => '800px',
'--screen-h' => '600px',
'--ui-scale' => '1.0',
'--gap-scale' => '1.0',
],
],
'inky_impression_13_3' => [
'css_name' => 'inky_impression_13_3',
'css_variables' => [
'--screen-w' => '800px',
'--screen-h' => '600px',
'--ui-scale' => '1.0',
'--gap-scale' => '1.0',
],
],
];
/**
* Run the migrations.
*/
public function up(): void
{
foreach (self::SEEDED_CSS as $name => $payload) {
DeviceModel::query()
->where('name', $name)
->update([
'css_name' => $payload['css_name'],
'css_variables' => $payload['css_variables'],
]);
}
}
/**
* Reverse the migrations.
*/
public function down(): void
{
DeviceModel::query()
->whereIn('name', array_keys(self::SEEDED_CSS))
->update([
'css_name' => null,
'css_variables' => null,
]);
}
};

View file

@ -1,28 +0,0 @@
<?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('plugins', function (Blueprint $table) {
$table->json('current_image_metadata')->nullable()->after('current_image');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('plugins', function (Blueprint $table) {
$table->dropColumn('current_image_metadata');
});
}
};

View file

@ -3,7 +3,6 @@
namespace Database\Seeders;
use App\Models\Device;
use App\Models\Playlist;
use App\Models\Plugin;
use App\Models\User;
use Illuminate\Database\Seeder;
@ -24,19 +23,9 @@ class DatabaseSeeder extends Seeder
'password' => bcrypt('admin@example.com'),
]);
$device = Device::factory()->create([
Device::factory(1)->create([
'mac_address' => '00:00:00:00:00:00',
'api_key' => 'test-api-key',
'proxy_cloud' => false,
]);
Playlist::factory()->create([
'device_id' => $device->id,
'name' => 'Default',
'is_active' => true,
'active_from' => null,
'active_until' => null,
'weekdays' => null
]);
// Device::factory(5)->create();

View file

@ -1,10 +1,9 @@
services:
app:
image: ghcr.io/usetrmnl/larapaper:latest
image: ghcr.io/usetrmnl/byos_laravel:latest
ports:
- "4567:8080"
environment:
# Generate the APP_KEY with `echo "base64:$(openssl rand -base64 32)"`
#- APP_KEY=
- PHP_OPCACHE_ENABLE=1
- TRMNL_PROXY_REFRESH_MINUTES=15

View file

@ -9,7 +9,7 @@
#### Clone the repository
```bash
git clone git@github.com:usetrmnl/larapaper.git
git clone git@github.com:usetrmnl/byos_laravel.git
```
#### Copy environment file

890
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -25,7 +25,7 @@
"codemirror": "^6.0.2",
"concurrently": "^9.0.1",
"laravel-vite-plugin": "^2.0",
"puppeteer": "24.37.0",
"puppeteer": "24.30.0",
"tailwindcss": "^4.0.7",
"vite": "^7.0.4"
},

View file

@ -22,7 +22,6 @@
refreshTimer: null,
renderedAt: 0,
ui: {},
wakeLock: null,
showStatus: function (message) {
trmnl.ui.img.style.display = "none";
@ -41,8 +40,6 @@
trmnl.ui.apiKeyInput.value = data.api_key || "";
trmnl.ui.baseURLInput.value = data.base_url || "";
trmnl.ui.displayModeSelect.value = data.display_mode || "";
trmnl.ui.fullscreenToggle.checked = !!data.fullscreen;
trmnl.ui.wakeLockToggle.checked = !!trmnl.wakeLock || !!data.wake_lock;
trmnl.ui.setup.style.display = "flex";
},
@ -53,8 +50,6 @@
var apiKey = trmnl.ui.apiKeyInput.value;
var baseURL = trmnl.ui.baseURLInput.value;
var displayMode = trmnl.ui.displayModeSelect.value;
var fullscreenEnabled = trmnl.ui.fullscreenToggle.checked;
var wakeLockEnabled = trmnl.ui.wakeLockToggle.checked;
if (!apiKey) {
return;
@ -63,26 +58,9 @@
trmnl.saveSettings({
api_key: apiKey,
base_url: baseURL,
display_mode: displayMode,
fullscreen: fullscreenEnabled,
wake_lock: wakeLockEnabled
display_mode: displayMode
});
if (wakeLockEnabled) {
trmnl.acquireWakeLock().then(function () {
trmnl.ui.wakeLockToggle.checked = !!trmnl.wakeLock;
}).catch(function (err) {
console.warn("Wake Lock request failed:", err);
trmnl.ui.wakeLockToggle.checked = false;
});
} else {
trmnl.releaseWakeLock().then(function () {
trmnl.ui.wakeLockToggle.checked = false;
}).catch(function (err) {
console.warn("Wake Lock release failed:", err);
});
}
trmnl.fetchDisplay();
},
@ -90,144 +68,6 @@
trmnl.ui.setup.style.display = "none";
},
isFullscreenSupported: function () {
return !!(
document.fullscreenEnabled ||
document.webkitFullscreenEnabled ||
document.msFullscreenEnabled
);
},
isFullscreenActive: function () {
return !!(
document.fullscreenElement ||
document.webkitFullscreenElement ||
document.msFullscreenElement
);
},
enterFullscreen: function () {
if (!trmnl.isFullscreenSupported()) return;
var el = document.documentElement;
var promise;
if (el.requestFullscreen) {
promise = el.requestFullscreen();
} else if (el.webkitRequestFullscreen) {
promise = el.webkitRequestFullscreen();
} else if (el.msRequestFullscreen) {
promise = el.msRequestFullscreen();
}
if (promise && promise.catch) {
promise.catch(function (err) {
console.warn("Enter fullscreen failed:", err);
});
}
},
exitFullscreen: function () {
if (!trmnl.isFullscreenSupported()) return;
if (!trmnl.isFullscreenActive()) return;
var promise;
if (document.exitFullscreen) {
promise = document.exitFullscreen();
} else if (document.webkitExitFullscreen) {
promise = document.webkitExitFullscreen();
} else if (document.msExitFullscreen) {
promise = document.msExitFullscreen();
}
if (promise && promise.catch) {
promise.catch(function (err) {
console.warn("Exit fullscreen failed:", err);
});
}
},
syncFullscreenToggle: function () {
var active = trmnl.isFullscreenActive();
trmnl.ui.fullscreenToggle.checked = active;
},
isWakeLockSupported: function () {
return (
window.isSecureContext &&
navigator.wakeLock &&
typeof navigator.wakeLock.request === "function"
);
},
acquireWakeLock: function () {
if (!trmnl.isWakeLockSupported()) {
return {
then: function () { return this; },
catch: function () { return this; }
};
}
if (trmnl.wakeLock) {
return Promise.resolve();
}
return navigator.wakeLock.request("screen")
.then(function (sentinel) {
trmnl.wakeLock = sentinel;
sentinel.addEventListener("release", function () {
trmnl.wakeLock = null;
trmnl.ui.wakeLockToggle.checked = false;
});
console.log("Wake Lock attivo");
})
.catch(function (err) {
console.warn("Wake Lock failed:", err);
trmnl.wakeLock = null;
trmnl.ui.wakeLockToggle.checked = false;
});
},
releaseWakeLock: function () {
if (!trmnl.wakeLock) {
return Promise.resolve();
}
return trmnl.wakeLock.release()
.then(function () {
trmnl.wakeLock = null;
console.log("Wake Lock rilasciato");
})
.catch(function (err) {
console.warn("Release failed:", err);
trmnl.wakeLock = null;
});
},
toggleWakeLock: function () {
if (!trmnl.isWakeLockSupported()) return;
if (trmnl.wakeLock) {
trmnl.releaseWakeLock().then(function () {
trmnl.ui.wakeLockToggle.checked = false;
});
} else {
trmnl.acquireWakeLock().then(function () {
trmnl.ui.wakeLockToggle.checked = !!trmnl.wakeLock;
});
}
},
fetchDisplay: function (opts) {
opts = opts || {};
clearTimeout(trmnl.refreshTimer);
@ -292,12 +132,8 @@
trmnl.showStatus("Error processing response: " + e.message);
}
} else {
var msg = xhr.statusText
if (xhr.status == 404) {
msg = "Maybe wrong API key";
}
trmnl.showStatus(
"Failed to fetch screen: " + xhr.status + " " + msg
"Failed to fetch screen: " + xhr.status + " " + xhr.statusText
);
}
};
@ -430,110 +266,15 @@
trmnl.ui.apiKeyInput = document.getElementById("api_key");
trmnl.ui.baseURLInput = document.getElementById("base_url");
trmnl.ui.displayModeSelect = document.getElementById("display_mode");
trmnl.ui.fullscreenToggle = document.getElementById("fullscreenToggle");
trmnl.ui.wakeLockToggle = document.getElementById("wakeLockToggle");
trmnl.ui.setup = document.getElementById("setup");
// Sync fullscreen state
document.addEventListener("fullscreenchange", trmnl.syncFullscreenToggle);
document.addEventListener("webkitfullscreenchange", trmnl.syncFullscreenToggle);
document.addEventListener("msfullscreenchange", trmnl.syncFullscreenToggle);
// Fullscreen toggle
if (!trmnl.isFullscreenSupported()) {
trmnl.ui.fullscreenToggle.disabled = true;
trmnl.ui.fullscreenToggle.parentElement.style.opacity = "0.5";
trmnl.ui.fullscreenToggle.parentElement.style.cursor = "not-allowed";
} else {
trmnl.ui.fullscreenToggle.addEventListener("change", function (e) {
e.stopPropagation();
if (e.target.checked) {
trmnl.enterFullscreen();
} else {
trmnl.exitFullscreen();
}
});
}
var wakeLockHint = document.getElementById("wakeLockHint");
// Wake Lock toggle
if (trmnl.isWakeLockSupported()) {
trmnl.ui.wakeLockToggle.disabled = false;
trmnl.ui.wakeLockToggle.parentElement.style.opacity = "1";
trmnl.ui.wakeLockToggle.parentElement.style.cursor = "pointer";
if (wakeLockHint) wakeLockHint.style.display = "none";
trmnl.ui.wakeLockToggle.addEventListener("change", function () {
trmnl.toggleWakeLock();
});
document.addEventListener("visibilitychange", function () {
if (
document.visibilityState === "visible" &&
trmnl.ui.wakeLockToggle.checked
) {
trmnl.acquireWakeLock();
}
});
} else {
// unsupported (HTTP or old browser)
trmnl.ui.wakeLockToggle.disabled = true;
trmnl.ui.wakeLockToggle.checked = false;
trmnl.ui.wakeLockToggle.parentElement.style.opacity = "0.5";
trmnl.ui.wakeLockToggle.parentElement.style.cursor = "not-allowed";
if (!window.isSecureContext && wakeLockHint) {
wakeLockHint.style.display = "block";
}
}
// get settings from localstorage
var settings = trmnl.getSettings();
// show setup form if missing apikey
if (!settings || !settings.api_key) {
trmnl.showSetupForm();
} else {
trmnl.fetchDisplay();
}
// Auto fullscreen at first click/touch if option enabled
if (settings.fullscreen && trmnl.isFullscreenSupported()) {
var activateFullscreenOnce = function () {
trmnl.enterFullscreen();
document.removeEventListener("click", activateFullscreenOnce);
document.removeEventListener("touchstart", activateFullscreenOnce);
};
document.addEventListener("click", activateFullscreenOnce, { once: true });
document.addEventListener("touchstart", activateFullscreenOnce, { once: true });
}
// Auto Wake Lock at first click/touch if option enabled
if (settings.wake_lock && trmnl.isWakeLockSupported()) {
var acquireWakeLockOnce = function () {
trmnl.acquireWakeLock().then(function () {
trmnl.ui.wakeLockToggle.checked = !!trmnl.wakeLock;
}).catch(function (err) {
console.warn("Wake Lock request failed:", err);
trmnl.ui.wakeLockToggle.checked = false;
});
document.removeEventListener("click", acquireWakeLockOnce);
document.removeEventListener("touchstart", acquireWakeLockOnce);
};
document.addEventListener("click", acquireWakeLockOnce, { once: true });
document.addEventListener("touchstart", acquireWakeLockOnce, { once: true });
}
trmnl.syncFullscreenToggle();
} //init end
};
document.addEventListener("DOMContentLoaded", function () {
@ -652,7 +393,8 @@
display: block;
}
label {
label,
summary {
font-size: 1.25em;
margin-bottom: 0.5em;
cursor: pointer;
@ -681,10 +423,6 @@
width: 100%;
}
.btn-secondary {
background-color: #777;
}
.btn-clear {
margin-top: 1em;
background-color: #777;
@ -709,127 +447,8 @@
background-color: #ffffff;
}
.setting-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1em;
flex-wrap: nowrap;
}
.setting-row label,
.setting-row .toggle-label {
font-size: 1.25em;
margin: 0;
cursor: default;
}
.setting-row select,
.setting-row .switch {
width: auto;
min-width: 52px;
height: 28px;
}
.switch {
position: relative;
display: inline-block;
width: 52px;
height: 28px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
inset: 0;
background-color: #ccc;
border-radius: 28px;
transition: background-color 0.2s ease;
}
.slider::before {
content: "";
position: absolute;
height: 22px;
width: 22px;
left: 3px;
top: 3px;
background-color: white;
border-radius: 50%;
transition: transform 0.2s ease;
}
.switch input:checked+.slider {
background-color: #f54900;
}
.switch input:checked+.slider::before {
transform: translateX(24px);
}
.switch input:disabled+.slider {
background-color: #ccc;
cursor: not-allowed;
}
.switch input:disabled+.slider::before {
background-color: #eee;
}
.form-select-small {
width: 6em;
font-size: 1em;
padding: 0.4em 0.5em;
border: 1px solid #ccc;
border-radius: 0.5em;
background-color: #ffffff;
}
.toggle-label {
font-size: 1.25em;
margin: 0;
cursor: default;
pointer-events: auto;
}
.setting-hint {
font-size: 0.75em;
color: #f41414;
margin-top: 0.2em;
margin-left: 0.5em;
}
/* Fallback for iOS 9 */
@media screen and (max-width: 1024px) and (-webkit-min-device-pixel-ratio: 1) {
.setting-row {
display: block;
overflow: hidden;
}
.setting-row label,
.setting-row .toggle-label {
float: left;
line-height: 28px;
margin-right: 0.5em;
}
.setting-row select,
.setting-row .switch {
float: right;
width: auto;
min-width: 52px;
height: 28px;
}
.setting-hint {
display: none !important;
}
#unsupported {
color: red;
}
</style>
</head>
@ -840,6 +459,18 @@
<img src="/mirror/assets/logo--brand.svg" alt="TRMNL Logo" />
<form onsubmit="return trmnl.saveSetup(event)">
<fieldset>
<label for="api_key">Device API Key</label>
<input name="api_key" id="api_key" type="text" placeholder="API Key" class="form-control" required />
</fieldset>
<fieldset>
<select id="display_mode" name="display_mode">
<option value="" selected="selected">Light Mode</option>
<option value="dark">Dark Mode</option>
<option value="night">Night Mode</option>
</select>
</fieldset>
<fieldset>
<label for="base_url">Custom Server URL</label>
@ -847,43 +478,11 @@
class="form-control" value="" />
</fieldset>
<fieldset>
<label for="api_key">Device API Key</label>
<input name="api_key" id="api_key" type="text" placeholder="API Key" class="form-control" required />
</fieldset>
<fieldset class="setting-row">
<label for="display_mode">Display Mode</label>
<select id="display_mode" name="display_mode" class="form-select-small">
<option value="" selected>Light</option>
<option value="dark">Dark</option>
<option value="night">Night</option>
</select>
</fieldset>
<fieldset class="setting-row">
<span class="toggle-label">Fullscreen</span>
<label class="switch">
<input type="checkbox" id="fullscreenToggle">
<span class="slider"></span>
</label>
</fieldset>
<fieldset class="setting-row">
<div>
<span class="toggle-label">Screen Wake Lock</span>
<div id="wakeLockHint" class="setting-hint" style="display:none;">
Require HTTPS
</div>
</div>
<label class="switch">
<input type="checkbox" id="wakeLockToggle">
<span class="slider"></span>
</label>
</fieldset>
<button class="btn">Save</button>
<button class="btn btn-clear" type="button" onclick="trmnl.clearSettings()">Clear settings</button>
<button class="btn btn-clear" type="button" onclick="trmnl.clearSettings()">
Clear settings
</button>
</form>
</div>
@ -897,11 +496,10 @@
<div id="error-message"></div>
<div style="display: flex; margin-top: 1em">
<button class="btn" onclick="trmnl.showSetupForm()">Setup</button>
<button class="btn btn-secondary" onclick="trmnl.fetchDisplay()">Retry</button>
<button class="btn" onclick="trmnl.fetchDisplay()">Retry</button>
</div>
</div>
</div>
</body>
</html>

View file

@ -2,9 +2,5 @@
<x-app-logo-icon class="size-5 fill-current text-white dark:text-black" />
</div>
<div class="ml-1 grid flex-1 text-left text-sm">
@if(config('app.pixel_logo_enabled'))
<svg xmlns="http://www.w3.org/2000/svg" class="h-7 pt-1.5 dark:fill-white" viewBox="0 0 1000 150"><path fill-rule="evenodd" d="M894.75 119.01V47.28h16.1V31.54h55.63v15.74h16.29v24.7h-23.43V56.43H918V119Zm-90.59 0v-15.55h-16.1V47.28h16.1V31.54h55.63v15.74h16.29v24.7h-23.42V56.43H811.3v6.22h32.39v25.07h-32.4v6.4h41.37V78.2h23.42v25.07h-16.29v15.74Zm-122.8 30.38V47.28h16.11V31.54h55.63v15.74h16.3v56.18h-16.38V119H704.6v30.4Zm23.25-55.26h41.36v-37.7H704.6ZM574.5 119v-15.55h-16.1V47.28h16.1V31.54h55.63v15.73h16.37v46.85h16.2v24.9h-23.42v-15.56h-9.15V119Zm7.13-24.89H623v-37.7h-41.36Zm-124.44 24.9V15.97h16.1V.25h55.64v15.73h16.29v56h-16.38v15.74h-48.4v31.3Zm23.24-56.37h41.36V25.32h-41.36ZM357.64 119v-15.55h-16.1V47.28h16.1V31.54h55.63v15.73h16.38v46.85h16.2v24.9h-23.43v-15.56h-9.15V119Zm7.14-24.89h41.36v-37.7h-41.36Zm-124.44 24.9V47.27h16.1V31.54h55.63v15.74h16.3v24.7h-23.43V56.43h-41.36V119Zm-106.87 0v-15.56h-16.1V47.28h16.1V31.54h55.63v15.73h16.37v46.85h16.2v24.9h-23.42v-15.56h-9.15V119Zm7.13-24.9h41.36v-37.7H140.6Zm-108.33 24.9v-15.56h-16.1V.25H39.4v93.88h41.36V78.39h23.43v25.07h-16.3V119Z"/></svg>
@else
<span class="mb-0.5 truncate leading-none font-semibold">LaraPaper</span>
@endif
<span class="mb-0.5 truncate leading-none font-semibold">TRMNL BYOS Laravel</span>
</div>

View file

@ -4,10 +4,6 @@
])
<div class="flex w-full flex-col text-center">
@if(config('app.pixel_logo_enabled'))
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 dark:fill-white" viewBox="0 0 1000 150"><path fill-rule="evenodd" d="M894.75 119.01V47.28h16.1V31.54h55.63v15.74h16.29v24.7h-23.43V56.43H918V119Zm-90.59 0v-15.55h-16.1V47.28h16.1V31.54h55.63v15.74h16.29v24.7h-23.42V56.43H811.3v6.22h32.39v25.07h-32.4v6.4h41.37V78.2h23.42v25.07h-16.29v15.74Zm-122.8 30.38V47.28h16.11V31.54h55.63v15.74h16.3v56.18h-16.38V119H704.6v30.4Zm23.25-55.26h41.36v-37.7H704.6ZM574.5 119v-15.55h-16.1V47.28h16.1V31.54h55.63v15.73h16.37v46.85h16.2v24.9h-23.42v-15.56h-9.15V119Zm7.13-24.89H623v-37.7h-41.36Zm-124.44 24.9V15.97h16.1V.25h55.64v15.73h16.29v56h-16.38v15.74h-48.4v31.3Zm23.24-56.37h41.36V25.32h-41.36ZM357.64 119v-15.55h-16.1V47.28h16.1V31.54h55.63v15.73h16.38v46.85h16.2v24.9h-23.43v-15.56h-9.15V119Zm7.14-24.89h41.36v-37.7h-41.36Zm-124.44 24.9V47.27h16.1V31.54h55.63v15.74h16.3v24.7h-23.43V56.43h-41.36V119Zm-106.87 0v-15.56h-16.1V47.28h16.1V31.54h55.63v15.73h16.37v46.85h16.2v24.9h-23.42v-15.56h-9.15V119Zm7.13-24.9h41.36v-37.7H140.6Zm-108.33 24.9v-15.56h-16.1V.25H39.4v93.88h41.36V78.39h23.43v25.07h-16.3V119Z"/></svg>
@else
<flux:heading size="xl">LaraPaper</flux:heading>
@endif
<flux:heading size="xl">{{ $title }}</flux:heading>
<flux:subheading>{{ $description }}</flux:subheading>
</div>

View file

@ -5,14 +5,12 @@
'deviceOrientation' => null,
'colorDepth' => '1bit',
'scaleLevel' => null,
'cssVariables' => null,
'pluginName' => 'Recipe',
])
<x-trmnl::screen colorDepth="{{$colorDepth}}" no-bleed="{{$noBleed}}" dark-mode="{{$darkMode}}"
device-variant="{{$deviceVariant}}" device-orientation="{{$deviceOrientation}}"
scale-level="{{$scaleLevel}}"
:css-variables="$cssVariables">
scale-level="{{$scaleLevel}}">
<x-trmnl::view>
<x-trmnl::layout>
<x-trmnl::richtext gapSize="large" align="center">

View file

@ -5,20 +5,18 @@
'deviceOrientation' => null,
'colorDepth' => '1bit',
'scaleLevel' => null,
'cssVariables' => null,
])
<x-trmnl::screen colorDepth="{{$colorDepth}}" no-bleed="{{$noBleed}}" dark-mode="{{$darkMode}}"
device-variant="{{$deviceVariant}}" device-orientation="{{$deviceOrientation}}"
scale-level="{{$scaleLevel}}"
:css-variables="$cssVariables">
scale-level="{{$scaleLevel}}">
<x-trmnl::view>
<x-trmnl::layout>
<x-trmnl::richtext gapSize="large" align="center">
<x-trmnl::title>Welcome to LaraPaper!</x-trmnl::title>
<x-trmnl::title>Welcome to BYOS Laravel!</x-trmnl::title>
<x-trmnl::content>Your device is connected.</x-trmnl::content>
</x-trmnl::richtext>
</x-trmnl::layout>
<x-trmnl::title-bar title="LaraPaper"/>
<x-trmnl::title-bar title="byos_laravel"/>
</x-trmnl::view>
</x-trmnl::screen>

View file

@ -5,13 +5,11 @@
'deviceOrientation' => null,
'colorDepth' => '1bit',
'scaleLevel' => null,
'cssVariables' => null,
])
<x-trmnl::screen colorDepth="{{$colorDepth}}" no-bleed="{{$noBleed}}" dark-mode="{{$darkMode}}"
device-variant="{{$deviceVariant}}" device-orientation="{{$deviceOrientation}}"
scale-level="{{$scaleLevel}}"
:css-variables="$cssVariables">
scale-level="{{$scaleLevel}}">
<x-trmnl::view>
<x-trmnl::layout>
<x-trmnl::richtext gapSize="large" align="center">
@ -25,6 +23,6 @@
<x-trmnl::title>Sleep Mode</x-trmnl::title>
</x-trmnl::richtext>
</x-trmnl::layout>
<x-trmnl::title-bar title="LaraPaper"/>
<x-trmnl::title-bar title="byos_laravel"/>
</x-trmnl::view>
</x-trmnl::screen>

View file

@ -39,11 +39,6 @@ new class extends Component
public $palette_id;
public $css_name;
/** @var array<int, array{key: string, value: string}> */
public array $css_variables = [];
protected $rules = [
'name' => 'required|string|max:255|unique:device_models,name',
'label' => 'required|string|max:255',
@ -107,12 +102,10 @@ new class extends Component
$this->offset_y = $deviceModel->offset_y;
$this->published_at = $deviceModel->published_at?->format('Y-m-d\TH:i');
$this->palette_id = $deviceModel->palette_id;
$this->css_name = $deviceModel->css_name;
$this->css_variables = collect($deviceModel->css_variables ?? [])->map(fn (string $value, string $key): array => ['key' => $key, 'value' => $value])->values()->all();
} else {
$this->editingDeviceModelId = null;
$this->viewingDeviceModelId = null;
$this->reset(['name', 'label', 'description', 'width', 'height', 'colors', 'bit_depth', 'scale_factor', 'rotation', 'mime_type', 'offset_x', 'offset_y', 'published_at', 'palette_id', 'css_name', 'css_variables']);
$this->reset(['name', 'label', 'description', 'width', 'height', 'colors', 'bit_depth', 'scale_factor', 'rotation', 'mime_type', 'offset_x', 'offset_y', 'published_at', 'palette_id']);
$this->mime_type = 'image/png';
$this->scale_factor = 1.0;
$this->rotation = 0;
@ -138,10 +131,6 @@ new class extends Component
'offset_y' => 'required|integer',
'published_at' => 'nullable|date',
'palette_id' => 'nullable|exists:device_palettes,id',
'css_name' => 'nullable|string|max:255',
'css_variables' => 'nullable|array',
'css_variables.*.key' => 'nullable|string|max:255',
'css_variables.*.value' => 'nullable|string|max:500',
];
if ($this->editingDeviceModelId) {
@ -169,8 +158,6 @@ new class extends Component
'offset_y' => $this->offset_y,
'published_at' => $this->published_at,
'palette_id' => $this->palette_id ?: null,
'css_name' => $this->css_name ?: null,
'css_variables' => $this->normalizeCssVariables(),
]);
$message = 'Device model updated successfully.';
} else {
@ -189,14 +176,12 @@ new class extends Component
'offset_y' => $this->offset_y,
'published_at' => $this->published_at,
'palette_id' => $this->palette_id ?: null,
'css_name' => $this->css_name ?: null,
'css_variables' => $this->normalizeCssVariables(),
'source' => 'manual',
]);
$message = 'Device model created successfully.';
}
$this->reset(['name', 'label', 'description', 'width', 'height', 'colors', 'bit_depth', 'scale_factor', 'rotation', 'mime_type', 'offset_x', 'offset_y', 'published_at', 'palette_id', 'css_name', 'css_variables', 'editingDeviceModelId', 'viewingDeviceModelId']);
$this->reset(['name', 'label', 'description', 'width', 'height', 'colors', 'bit_depth', 'scale_factor', 'rotation', 'mime_type', 'offset_x', 'offset_y', 'published_at', 'palette_id', 'editingDeviceModelId', 'viewingDeviceModelId']);
Flux::modal('device-model-modal')->close();
$this->deviceModels = DeviceModel::all();
@ -232,38 +217,9 @@ new class extends Component
$this->offset_y = $deviceModel->offset_y;
$this->published_at = $deviceModel->published_at?->format('Y-m-d\TH:i');
$this->palette_id = $deviceModel->palette_id;
$this->css_name = $deviceModel->css_name;
$this->css_variables = collect($deviceModel->css_variables ?? [])->map(fn (string $value, string $key): array => ['key' => $key, 'value' => $value])->values()->all();
$this->js('Flux.modal("device-model-modal").show()');
}
public function addCssVariable(): void
{
$this->css_variables = array_merge($this->css_variables, [['key' => '', 'value' => '']]);
}
public function removeCssVariable(int $index): void
{
$vars = $this->css_variables;
array_splice($vars, $index, 1);
$this->css_variables = array_values($vars);
}
/**
* @return array<string, string>|null
*/
private function normalizeCssVariables(): ?array
{
$pairs = collect($this->css_variables)
->filter(fn (array $p): bool => trim($p['key'] ?? '') !== '');
if ($pairs->isEmpty()) {
return null;
}
return $pairs->mapWithKeys(fn (array $p): array => [$p['key'] => $p['value'] ?? ''])->all();
}
}
?>
@ -388,40 +344,6 @@ new class extends Component
</flux:select>
</div>
<div class="mb-4">
<flux:input label="CSS Model Identifier" wire:model="css_name" id="css_name" class="block mt-1 w-full" type="text"
name="css_name" :disabled="(bool) $viewingDeviceModelId"/>
</div>
<div class="mb-4">
<flux:heading size="sm" class="mb-2">CSS Variables</flux:heading>
@if ($viewingDeviceModelId)
@if (count($css_variables) > 0)
<dl class="space-y-1.5 text-sm">
@foreach ($css_variables as $var)
<div class="flex gap-2">
<dt class="font-medium text-zinc-600 dark:text-zinc-400 min-w-[120px]">{{ $var['key'] }}</dt>
<dd class="text-zinc-800 dark:text-zinc-200">{{ $var['value'] }}</dd>
</div>
@endforeach
</dl>
@else
<p class="text-sm text-zinc-500 dark:text-zinc-400">No CSS variables</p>
@endif
@else
<div class="space-y-3">
@foreach ($css_variables as $index => $var)
<div class="flex gap-2 items-start" wire:key="css-var-{{ $index }}">
<flux:input wire:model="css_variables.{{ $index }}.key" placeholder="e.g. --screen-w" class="flex-1 min-w-0" type="text"/>
<flux:input wire:model="css_variables.{{ $index }}.value" placeholder="e.g. 800px" class="flex-1 min-w-0" type="text"/>
<flux:button type="button" wire:click="removeCssVariable({{ $index }})" icon="trash" variant="ghost" iconVariant="outline"/>
</div>
@endforeach
<flux:button type="button" wire:click="addCssVariable" variant="ghost" icon="plus" size="sm">Add variable</flux:button>
</div>
@endif
</div>
@if (!$viewingDeviceModelId)
<div class="flex">
<flux:spacer/>

View file

@ -264,7 +264,7 @@ new class extends Component
{{-- <li><flux:text><code>date: "%N"</code> is unsupported. Use <code>date: "u"</code> instead </flux:text></li>--}}
{{-- </ul>--}}
</ul>
<flux:text class="mt-1">Please report <a href="https://github.com/usetrmnl/larapaper/issues/new" target="_blank" class="underline">issues on GitHub</a>. Include your example zip file.</flux:text></li>
<flux:text class="mt-1">Please report <a href="https://github.com/usetrmnl/byos_laravel/issues/new" target="_blank" class="underline">issues on GitHub</a>. Include your example zip file.</flux:text></li>
</div>
<form wire:submit="importZip">
@ -315,7 +315,7 @@ new class extends Component
<li><flux:text>API responses in formats other than <span class="font-mono">JSON</span> are not yet fully supported.</flux:text></li>
<li><flux:text>There are limitations in payload size (Data Payload, Template).</flux:text></li>
</ul>
<flux:text class="mt-1">Please report issues, aside from the known limitations, on <a href="https://github.com/usetrmnl/larapaper/issues/new" target="_blank" class="underline">GitHub</a>. Include the recipe URL.</flux:text></li>
<flux:text class="mt-1">Please report issues, aside from the known limitations, on <a href="https://github.com/usetrmnl/byos_laravel/issues/new" target="_blank" class="underline">GitHub</a>. Include the recipe URL.</flux:text></li>
</flux:callout>
</div>
<livewire:catalog.trmnl />

View file

@ -72,8 +72,8 @@ new class extends Component
<x-trmnl::view>
<x-trmnl::layout>
<x-trmnl::richtext gapSize="large" align="center">
<x-trmnl::title>LaraPaper</x-trmnl::title>
<x-trmnl::content>“This screen was rendered by BYOS LaraPaper</x-trmnl::content>
<x-trmnl::title>TRMNL BYOS Laravel</x-trmnl::title>
<x-trmnl::content>“This screen was rendered by BYOS Laravel</x-trmnl::content>
<x-trmnl::label variant="underline">Benjamin Nussbaum</x-trmnl::label>
</x-trmnl::richtext>
</x-trmnl::layout>

View file

@ -248,7 +248,7 @@ new class extends Component
<flux:callout icon="check-circle" variant="success">
<flux:callout.heading>Up to Date</flux:callout.heading>
<flux:callout.text>
You are running the latest version.
You are running the latest version {{ $latestVersion }}.
</flux:callout.text>
</flux:callout>
@endif

View file

@ -1,7 +1,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ $title ?? 'LaraPaper' }}</title>
<title>{{ $title ?? 'TRMNL BYOS Laravel' }}</title>
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=inter:400,500,600&display=swap" rel="stylesheet" />

View file

@ -6,22 +6,18 @@
'deviceOrientation' => null,
'colorDepth' => '1bit',
'scaleLevel' => null,
'cssVariables' => null,
])
@if(config('app.puppeteer_window_size_strategy') === 'v2')
<x-trmnl::screen colorDepth="{{$colorDepth}}" no-bleed="{{$noBleed}}" dark-mode="{{$darkMode}}"
device-variant="{{$deviceVariant}}" device-orientation="{{$deviceOrientation}}"
scale-level="{{$scaleLevel}}"
:css-variables="$cssVariables">
scale-level="{{$scaleLevel}}">
<x-trmnl::mashup mashup-layout="{{ $mashupLayout }}">
{!! $slot !!}
</x-trmnl::mashup>
</x-trmnl::screen>
@else
<x-trmnl::screen colorDepth="{{$colorDepth}}" device-variant="{{$deviceVariant}}" device-orientation="{{$deviceOrientation}}"
scale-level="{{$scaleLevel}}"
:css-variables="$cssVariables">
<x-trmnl::screen colorDepth="{{$colorDepth}}">
<x-trmnl::mashup mashup-layout="{{ $mashupLayout }}">
{!! $slot !!}
</x-trmnl::mashup>

View file

@ -5,21 +5,16 @@
'deviceOrientation' => null,
'colorDepth' => '1bit',
'scaleLevel' => null,
'cssVariables' => null,
])
@if(config('app.puppeteer_window_size_strategy') === 'v2')
<x-trmnl::screen colorDepth="{{$colorDepth}}" no-bleed="{{$noBleed}}" dark-mode="{{$darkMode}}"
device-variant="{{$deviceVariant}}" device-orientation="{{$deviceOrientation}}"
scale-level="{{$scaleLevel}}"
:css-variables="$cssVariables">
scale-level="{{$scaleLevel}}">
{!! $slot !!}
</x-trmnl::screen>
@else
<x-trmnl::screen colorDepth="{{$colorDepth}}" no-bleed="{{$noBleed}}" dark-mode="{{$darkMode}}"
device-variant="{{$deviceVariant}}" device-orientation="{{$deviceOrientation}}"
scale-level="{{$scaleLevel}}"
:css-variables="$cssVariables">
<x-trmnl::screen colorDepth="{{$colorDepth}}" no-bleed="{{$noBleed}}" dark-mode="{{$darkMode}}">
{!! $slot !!}
</x-trmnl::screen>
@endif

View file

@ -1,11 +1,10 @@
@props([
'noBleed' => false,
'darkMode' => false,
'deviceVariant' => 'ogv2',
'deviceVariant' => 'og',
'deviceOrientation' => null,
'colorDepth' => '1bit',
'scaleLevel' => null,
'cssVariables' => null,
])
<!DOCTYPE html>
@ -27,18 +26,9 @@
<script src="{{ config('services.trmnl.base_url') }}/js/{{ config('trmnl-blade.framework_js_version') ?? config('trmnl-blade.framework_version', '2.1.0') }}/plugins.js"></script>
@endif
<title>{{ $title ?? config('app.name') }}</title>
@if(config('app.puppeteer_window_size_strategy') === 'v2' && !empty($cssVariables) && is_array($cssVariables))
<style>
:root {
@foreach($cssVariables as $name => $value)
{{ $name }}: {{ $value }};
@endforeach
}
</style>
@endif
</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 : '' }}">
<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>

View file

@ -1,6 +1,6 @@
<x-layouts::auth.card>
<div class="flex flex-col gap-6">
<x-auth-header title="LaraPaper" description="Server is up and running."/>
<x-auth-header title="TRMNL BYOS Laravel" description="Server is up and running."/>
</div>
<header class="w-full lg:max-w-4xl max-w-[335px] text-sm mt-6 not-has-[nav]:hidden">
@if (Route::has('login'))
@ -32,11 +32,6 @@
@endif
</header>
@auth
@if(config('app.version'))
<flux:text class="text-xs">Version: <a href="https://github.com/{{ config('app.github_repo') }}/releases/tag/{{ config('app.version') }}"
target="_blank">{{ config('app.version') }}</a>
</flux:text>
@endif
<livewire:update-check />
@endauth
</x-layouts::auth.card>

View file

@ -88,8 +88,8 @@ Route::get('/display', function (Request $request) {
$refreshTimeOverride = $playlistItem->playlist()->first()->refresh_time;
$plugin = $playlistItem->plugin;
ImageGenerationService::resetIfNotCacheable($plugin, $device);
$plugin->refresh();
// Reset cache if Devices with different dimensions exist
ImageGenerationService::resetIfNotCacheable($plugin);
// Check and update stale data if needed
if ($plugin->isDataStale() || $plugin->current_image === null) {
@ -699,9 +699,6 @@ Route::get('/display/{uuid}/alias', function (Request $request, string $uuid) {
], 404);
}
ImageGenerationService::resetIfNotCacheable($plugin, $deviceModel);
$plugin->refresh();
// Check if we can use cached image (only for og_png and if data is not stale)
$useCache = $deviceModelName === 'og_png' && ! $plugin->isDataStale() && $plugin->current_image !== null;
@ -747,13 +744,9 @@ Route::get('/display/{uuid}/alias', function (Request $request, string $uuid) {
palette: $deviceModel->palette
);
// Update plugin cache if using og_png (recipes only get metadata for cache comparison)
// Update plugin cache if using og_png
if ($deviceModelName === 'og_png') {
$update = ['current_image' => $imageUuid];
if ($plugin->plugin_type === 'recipe') {
$update['current_image_metadata'] = ImageGenerationService::buildImageMetadataFromDeviceModel($deviceModel);
}
$plugin->update($update);
$plugin->update(['current_image' => $imageUuid]);
}
// Return the generated image

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 686 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 901 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2 KiB

View file

@ -2,8 +2,6 @@
use App\Jobs\GenerateScreenJob;
use App\Models\Device;
use App\Models\DeviceModel;
use App\Models\Plugin;
use Bnussbau\TrmnlPipeline\TrmnlPipeline;
use Illuminate\Support\Facades\Storage;
@ -60,26 +58,3 @@ test('it preserves gitignore file during cleanup', function (): void {
Storage::disk('public')->assertExists('/images/generated/.gitignore');
});
test('it saves current_image_metadata for recipe plugins', function (): void {
$deviceModel = DeviceModel::factory()->create([
'width' => 800,
'height' => 480,
'rotation' => 0,
'mime_type' => 'image/png',
'palette_id' => null,
]);
$device = Device::factory()->create(['device_model_id' => $deviceModel->id]);
$plugin = Plugin::factory()->create(['plugin_type' => 'recipe']);
$job = new GenerateScreenJob($device->id, $plugin->id, '<div>Test</div>');
$job->handle();
$plugin->refresh();
expect($plugin->current_image)->not->toBeNull();
expect($plugin->current_image_metadata)->toBeArray();
expect($plugin->current_image_metadata)->toHaveKeys(['width', 'height', 'rotation', 'palette_id', 'mime_type']);
expect($plugin->current_image_metadata['width'])->toBe(800);
expect($plugin->current_image_metadata['height'])->toBe(480);
expect($plugin->current_image_metadata['mime_type'])->toBe('image/png');
});

View file

@ -8,7 +8,6 @@ use App\Models\DeviceModel;
use App\Services\ImageGenerationService;
use Bnussbau\TrmnlPipeline\TrmnlPipeline;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
uses(RefreshDatabase::class);
@ -23,10 +22,6 @@ afterEach(function (): void {
TrmnlPipeline::restore();
});
it('plugins table has current_image_metadata column', function (): void {
expect(Schema::hasColumn('plugins', 'current_image_metadata'))->toBeTrue();
});
it('generates image for device without device model', function (): void {
// Create a device without a DeviceModel (legacy behavior)
$device = Device::factory()->create([
@ -275,15 +270,39 @@ it('cleanupFolder preserves .gitignore', function (): void {
Storage::disk('public')->assertExists('/images/generated/.gitignore');
});
it('resetIfNotCacheable does not reset recipe cache based on other devices', function (): void {
// Cache validity is now determined at use-time via current_image_metadata
$plugin = App\Models\Plugin::factory()->create(['current_image' => 'test-uuid', 'plugin_type' => 'recipe']);
it('resetIfNotCacheable resets when device models exist', function (): void {
// Create a plugin
$plugin = App\Models\Plugin::factory()->create(['current_image' => 'test-uuid']);
Device::factory()->create(['device_model_id' => DeviceModel::factory()->create()->id]);
// 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)->toBe('test-uuid');
expect($plugin->current_image)->toBeNull();
});
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();
});
it('resetIfNotCacheable preserves image for standard devices', function (): void {
@ -306,122 +325,27 @@ it('resetIfNotCacheable preserves image for standard devices', function (): void
});
it('cache is reset when plugin markup changes', function (): void {
// Create a plugin with cached image and metadata
// Create a plugin with cached image
$plugin = App\Models\Plugin::factory()->create([
'current_image' => 'cached-uuid',
'current_image_metadata' => ['width' => 800, 'height' => 480, 'rotation' => 0, 'palette_id' => null, 'mime_type' => 'image/png'],
'render_markup' => '<div>Original markup</div>',
]);
$plugin->update(['render_markup' => '<div>Updated markup</div>']);
// Create devices with standard dimensions (cacheable)
Device::factory()->count(2)->create([
'width' => 800,
'height' => 480,
'rotate' => 0,
]);
// Update the plugin markup
$plugin->update([
'render_markup' => '<div>Updated markup</div>',
]);
// Assert cache was reset when markup changed
$plugin->refresh();
expect($plugin->current_image)->toBeNull();
expect($plugin->current_image_metadata)->toBeNull();
});
it('buildImageMetadataFromDevice returns canonical metadata shape', function (): void {
$deviceModel = DeviceModel::factory()->create([
'width' => 800,
'height' => 480,
'rotation' => 0,
'mime_type' => 'image/png',
'palette_id' => null,
]);
$device = Device::factory()->create(['device_model_id' => $deviceModel->id]);
$meta = ImageGenerationService::buildImageMetadataFromDevice($device);
expect($meta)->toHaveKeys(['width', 'height', 'rotation', 'palette_id', 'mime_type']);
expect($meta['width'])->toBe(800);
expect($meta['height'])->toBe(480);
expect($meta['rotation'])->toBe(0);
expect($meta['mime_type'])->toBe('image/png');
});
it('buildImageMetadataFromDeviceModel returns canonical metadata shape', function (): void {
$model = DeviceModel::factory()->create([
'width' => 1024,
'height' => 768,
'rotation' => 90,
'mime_type' => 'image/bmp',
'palette_id' => null,
]);
$meta = ImageGenerationService::buildImageMetadataFromDeviceModel($model);
expect($meta)->toHaveKeys(['width', 'height', 'rotation', 'palette_id', 'mime_type']);
expect($meta['width'])->toBe(1024);
expect($meta['height'])->toBe(768);
expect($meta['rotation'])->toBe(90);
expect($meta['mime_type'])->toBe('image/bmp');
});
it('imageMetadataMatches returns false when stored is null or empty', function (): void {
$device = Device::factory()->create(['width' => 800, 'height' => 480, 'rotate' => 0]);
expect(ImageGenerationService::imageMetadataMatches(null, $device))->toBeFalse();
expect(ImageGenerationService::imageMetadataMatches([], $device))->toBeFalse();
});
it('imageMetadataMatches returns true when metadata matches device', function (): void {
$deviceModel = DeviceModel::factory()->create([
'width' => 800,
'height' => 480,
'rotation' => 0,
'mime_type' => 'image/png',
'palette_id' => null,
]);
$device = Device::factory()->create(['device_model_id' => $deviceModel->id]);
$stored = ImageGenerationService::buildImageMetadataFromDevice($device);
expect(ImageGenerationService::imageMetadataMatches($stored, $device))->toBeTrue();
});
it('imageMetadataMatches returns false when metadata differs', function (): void {
$device = Device::factory()->create(['width' => 800, 'height' => 480, 'rotate' => 0]);
$stored = ['width' => 800, 'height' => 480, 'rotation' => 0, 'palette_id' => null, 'mime_type' => 'image/png'];
$device->update(['width' => 1024]);
$device->refresh();
expect(ImageGenerationService::imageMetadataMatches($stored, $device))->toBeFalse();
});
it('resetIfNotCacheable clears recipe cache when metadata does not match', function (): void {
$plugin = App\Models\Plugin::factory()->create([
'plugin_type' => 'recipe',
'current_image' => 'cached-uuid',
'current_image_metadata' => ['width' => 800, 'height' => 480, 'rotation' => 0, 'palette_id' => null, 'mime_type' => 'image/png'],
]);
$device = Device::factory()->create(['width' => 1024, 'height' => 768, 'rotate' => 0]);
ImageGenerationService::resetIfNotCacheable($plugin, $device);
$plugin->refresh();
expect($plugin->current_image)->toBeNull();
expect($plugin->current_image_metadata)->toBeNull();
});
it('resetIfNotCacheable preserves cache when metadata matches', function (): void {
$deviceModel = DeviceModel::factory()->create([
'width' => 800,
'height' => 480,
'rotation' => 0,
'mime_type' => 'image/png',
]);
$device = Device::factory()->create(['device_model_id' => $deviceModel->id]);
$meta = ImageGenerationService::buildImageMetadataFromDevice($device);
$plugin = App\Models\Plugin::factory()->create([
'plugin_type' => 'recipe',
'current_image' => 'cached-uuid',
'current_image_metadata' => $meta,
]);
ImageGenerationService::resetIfNotCacheable($plugin, $device);
$plugin->refresh();
expect($plugin->current_image)->toBe('cached-uuid');
});
it('determines correct image format from device model', function (): void {

View file

@ -1,46 +0,0 @@
<?php
use App\Models\User;
use Illuminate\Support\Facades\Config;
uses(Illuminate\Foundation\Testing\RefreshDatabase::class);
test('auth pages show pixel logo SVG when pixel_logo_enabled is true', function (): void {
Config::set('app.pixel_logo_enabled', true);
$response = $this->get('/login');
$response->assertStatus(200);
$response->assertSee('viewBox="0 0 1000 150"', false);
});
test('auth pages show heading instead of pixel logo when pixel_logo_enabled is false', function (): void {
Config::set('app.pixel_logo_enabled', false);
$response = $this->get('/login');
$response->assertStatus(200);
$response->assertDontSee('viewBox="0 0 1000 150"', false);
$response->assertSee('LaraPaper');
});
test('app logo shows text when pixel_logo_enabled is false', function (): void {
Config::set('app.pixel_logo_enabled', false);
$user = User::factory()->create();
$response = $this->actingAs($user)->get(route('dashboard'));
$response->assertStatus(200);
$response->assertSee('LaraPaper');
$response->assertDontSee('viewBox="0 0 1000 150"', false);
});
test('app logo shows pixel logo SVG when pixel_logo_enabled is true', function (): void {
Config::set('app.pixel_logo_enabled', true);
$user = User::factory()->create();
$response = $this->actingAs($user)->get(route('dashboard'));
$response->assertStatus(200);
$response->assertSee('viewBox="0 0 1000 150"', false);
});

View file

@ -1,163 +0,0 @@
<?php
declare(strict_types=1);
use App\Models\Plugin;
use Carbon\Carbon;
use Illuminate\Support\Facades\Http;
test('iCal plugin parses Google Calendar invitation event', function (): void {
// Set test time close to the event in the issue
Carbon::setTestNow(Carbon::parse('2026-03-10 12:00:00', 'Europe/Budapest'));
$icalContent = <<<'ICS'
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example Corp.//EN
BEGIN:VEVENT
DTSTART;TZID=Europe/Budapest:20260311T100000
DTEND;TZID=Europe/Budapest:20260311T110000
DTSTAMP:20260301T100000Z
ORGANIZER:mailto:organizer@example.com
UID:xxxxxxxxxxxxxxxxxxx@google.com
SEQUENCE:0
DESCRIPTION:-::~:~::~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~
·:~:~:~:~:~:~:~:~::~:~::-
Csatlakozás a Google Meet szolgáltatással: https://meet.google.com/xxx-xxxx-xxx
További információ a Meetről: https://support.google.com/a/users/answer/9282720
Kérjük, ne szerkeszd ezt a szakaszt.
-::~:~::~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~:~
·:~:~:~:~:~:~:~:~:~:~:~:~::~:~::-
LOCATION:Meet XY Street, ZIP; https://meet.google.com/xxx-xxxx-xxx
SUMMARY:Meeting
STATUS:CONFIRMED
ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;RSVP=TRUE;X-NUM-GUESTS=0;X-PM-TOKEN=REDACTED;PARTSTAT=ACCEPTED:mailto:participant1@example.com
ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;RSVP=TRUE;CN=participant2@example.com;X-NUM-GUESTS=0;X-PM-TOKEN=REDACTED;PARTSTAT=ACCEPTED:mailto:participant2@example.com
ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;RSVP=TRUE;CN=participant3@example.com;X-NUM-GUESTS=0;X-PM-TOKEN=REDACTED;PARTSTAT=NEEDS-ACTION:mailto:participant3@example.com
END:VEVENT
END:VCALENDAR
ICS;
Http::fake([
'example.com/calendar.ics' => Http::response($icalContent, 200, ['Content-Type' => 'text/calendar']),
]);
$plugin = Plugin::factory()->create([
'data_strategy' => 'polling',
'polling_url' => 'https://example.com/calendar.ics',
'polling_verb' => 'get',
]);
$plugin->updateDataPayload();
$plugin->refresh();
expect($plugin->data_payload)->not->toHaveKey('error');
expect($plugin->data_payload)->toHaveKey('ical');
expect($plugin->data_payload['ical'])->toHaveCount(1);
expect($plugin->data_payload['ical'][0]['SUMMARY'])->toBe('Meeting');
Carbon::setTestNow();
});
test('iCal plugin parses recurring events with multiple BYDAY correctly', function (): void {
// Set test now to Monday 2024-03-25
Carbon::setTestNow(Carbon::parse('2024-03-25 12:00:00', 'UTC'));
$icalContent = <<<'ICS'
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Example Corp.//EN
BEGIN:VEVENT
DESCRIPTION:XXX-REDACTED
RRULE:FREQ=WEEKLY;UNTIL=20250604T220000Z;INTERVAL=1;BYDAY=TU,TH;WKST=MO
UID:040000008200E00074C5B7101A82E00800000000E07AF34F937EDA01000000000000000
01000000061F3E918C753424E8154B36E55452933
SUMMARY:Recurring Meeting
DTSTART;VALUE=DATE:20240326
DTEND;VALUE=DATE:20240327
DTSTAMP:20240605T082436Z
CLASS:PUBLIC
STATUS:CONFIRMED
END:VEVENT
END:VCALENDAR
ICS;
Http::fake([
'example.com/recurring.ics' => Http::response($icalContent, 200, ['Content-Type' => 'text/calendar']),
]);
$plugin = Plugin::factory()->create([
'data_strategy' => 'polling',
'polling_url' => 'https://example.com/recurring.ics',
'polling_verb' => 'get',
]);
$plugin->updateDataPayload();
$plugin->refresh();
$ical = $plugin->data_payload['ical'];
// Week of March 25, 2024:
// Tue March 26: 2024-03-26 (DTSTART)
// Thu March 28: 2024-03-28 (Recurrence)
// The parser window is now-7 days to now+30 days.
// Window: 2024-03-18 to 2024-04-24.
$summaries = collect($ical)->pluck('SUMMARY');
expect($summaries)->toContain('Recurring Meeting');
$dates = collect($ical)->map(fn ($event) => Carbon::parse($event['DTSTART'])->format('Y-m-d'))->values();
// Check if Tuesday March 26 is present
expect($dates)->toContain('2024-03-26');
// Check if Thursday March 28 is present (THIS IS WHERE IT IS EXPECTED TO FAIL BASED ON THE ISSUE)
expect($dates)->toContain('2024-03-28');
Carbon::setTestNow();
});
test('iCal plugin parses recurring events with multiple BYDAY and specific DTSTART correctly', function (): void {
// Set test now to Monday 2024-03-25
Carbon::setTestNow(Carbon::parse('2024-03-25 12:00:00', 'UTC'));
$icalContent = <<<'ICS'
BEGIN:VCALENDAR
VERSION:2.0
X-WR-TIMEZONE:UTC
PRODID:-//Example Corp.//EN
BEGIN:VEVENT
RRULE:FREQ=WEEKLY;UNTIL=20250604T220000Z;INTERVAL=1;BYDAY=TU,TH;WKST=MO
UID:recurring-event-2
SUMMARY:Recurring Meeting 2
DTSTART:20240326T100000
DTEND:20240326T110000
DTSTAMP:20240605T082436Z
END:VEVENT
END:VCALENDAR
ICS;
Http::fake([
'example.com/recurring2.ics' => Http::response($icalContent, 200, ['Content-Type' => 'text/calendar']),
]);
$plugin = Plugin::factory()->create([
'data_strategy' => 'polling',
'polling_url' => 'https://example.com/recurring2.ics',
'polling_verb' => 'get',
]);
$plugin->updateDataPayload();
$plugin->refresh();
$ical = $plugin->data_payload['ical'];
$dates = collect($ical)->map(fn ($event) => Carbon::parse($event['DTSTART'])->format('Y-m-d'))->values();
expect($dates)->toContain('2024-03-26');
expect($dates)->toContain('2024-03-28');
Carbon::setTestNow();
});

View file

@ -1,100 +0,0 @@
<?php
use App\Models\Device;
use App\Models\DeviceModel;
use Illuminate\Support\Facades\Config;
beforeEach(function (): void {
Config::set('app.puppeteer_window_size_strategy', 'v2');
});
test('screen component outputs :root with --screen-w and --screen-h when cssVariables are passed', function (): void {
$html = view('trmnl-layouts.single', [
'slot' => '<div>test</div>',
'cssVariables' => [
'--screen-w' => '800px',
'--screen-h' => '480px',
],
])->render();
expect($html)->toContain(':root');
expect($html)->toContain('--screen-w: 800px');
expect($html)->toContain('--screen-h: 480px');
});
test('DeviceModel css_variables attribute merges --screen-w and --screen-h from dimensions when not set', function (): void {
$deviceModel = DeviceModel::factory()->create([
'width' => 800,
'height' => 480,
'css_variables' => null,
]);
$vars = $deviceModel->css_variables;
expect($vars)->toHaveKey('--screen-w', '800px');
expect($vars)->toHaveKey('--screen-h', '480px');
});
test('DeviceModel css_variables attribute does not override --screen-w and --screen-h when already set', function (): void {
$deviceModel = DeviceModel::factory()->create([
'width' => 800,
'height' => 480,
'css_variables' => [
'--screen-w' => '1200px',
'--screen-h' => '900px',
],
]);
$vars = $deviceModel->css_variables;
expect($vars['--screen-w'])->toBe('1200px');
expect($vars['--screen-h'])->toBe('900px');
});
test('DeviceModel css_variables attribute fills only missing --screen-w or --screen-h from dimensions', function (): void {
$deviceModel = DeviceModel::factory()->create([
'width' => 800,
'height' => 480,
'css_variables' => [
'--screen-w' => '640px',
],
]);
$vars = $deviceModel->css_variables;
expect($vars['--screen-w'])->toBe('640px');
expect($vars['--screen-h'])->toBe('480px');
});
test('DeviceModel css_variables attribute returns raw vars when strategy is not v2', function (): void {
Config::set('app.puppeteer_window_size_strategy', 'v1');
$deviceModel = DeviceModel::factory()->create([
'width' => 800,
'height' => 480,
'css_variables' => ['--custom' => 'value'],
]);
$vars = $deviceModel->css_variables;
expect($vars)->toBe(['--custom' => 'value']);
});
test('device model css_variables are available via device relationship for rendering', function (): void {
Config::set('app.puppeteer_window_size_strategy', 'v2');
$deviceModel = DeviceModel::factory()->create([
'width' => 800,
'height' => 480,
'css_variables' => null,
]);
$device = Device::factory()->create([
'device_model_id' => $deviceModel->id,
]);
$device->load('deviceModel');
$vars = $device->deviceModel?->css_variables ?? [];
expect($vars)->toHaveKey('--screen-w', '800px');
expect($vars)->toHaveKey('--screen-h', '480px');
});

View file

@ -3,7 +3,6 @@
declare(strict_types=1);
use App\Models\DeviceModel;
use Illuminate\Support\Facades\Config;
test('device model has required attributes', function (): void {
$deviceModel = DeviceModel::factory()->create([
@ -118,27 +117,3 @@ test('device model factory creates valid data', function (): void {
expect($deviceModel->offset_x)->toBeInt();
expect($deviceModel->offset_y)->toBeInt();
});
test('css_name returns og when puppeteer_window_size_strategy is v1', function (): void {
Config::set('app.puppeteer_window_size_strategy', 'v1');
$deviceModel = DeviceModel::factory()->create(['css_name' => 'my_device']);
expect($deviceModel->css_name)->toBe('og');
});
test('css_name returns db value when puppeteer_window_size_strategy is v2', function (): void {
Config::set('app.puppeteer_window_size_strategy', 'v2');
$deviceModel = DeviceModel::factory()->create(['css_name' => 'my_device']);
expect($deviceModel->css_name)->toBe('my_device');
});
test('css_name returns null when puppeteer_window_size_strategy is v2 and db value is null', function (): void {
Config::set('app.puppeteer_window_size_strategy', 'v2');
$deviceModel = DeviceModel::factory()->create(['css_name' => null]);
expect($deviceModel->css_name)->toBeNull();
});

View file

@ -824,7 +824,7 @@ test('plugin duplicate copies all attributes except id and uuid', function (): v
expect($duplicate->id)->not->toBe($original->id)
->and($duplicate->uuid)->not->toBe($original->uuid)
->and($duplicate->name)->toBe('Original Plugin_copy')
->and($duplicate->name)->toBe('Original Plugin (Copy)')
->and($duplicate->user_id)->toBe($original->user_id)
->and($duplicate->data_stale_minutes)->toBe($original->data_stale_minutes)
->and($duplicate->data_strategy)->toBe($original->data_strategy)
@ -859,7 +859,7 @@ test('plugin duplicate sets trmnlp_id to null to avoid unique constraint violati
expect($duplicate->trmnlp_id)->toBeNull()
->and($original->trmnlp_id)->toBe('test-trmnlp-id-123')
->and($duplicate->name)->toBe('Plugin with trmnlp_id_copy');
->and($duplicate->name)->toBe('Plugin with trmnlp_id (Copy)');
});
test('plugin duplicate copies render_markup_view file content to render_markup', function (): void {
@ -890,7 +890,7 @@ test('plugin duplicate copies render_markup_view file content to render_markup',
expect($duplicate->render_markup)->toBe($testContent)
->and($duplicate->markup_language)->toBe('blade')
->and($duplicate->render_markup_view)->toBeNull()
->and($duplicate->name)->toBe('View Plugin_copy');
->and($duplicate->name)->toBe('View Plugin (Copy)');
} finally {
// Clean up test file
if (file_exists($testViewPath)) {
@ -949,7 +949,7 @@ test('plugin duplicate handles missing view file gracefully', function (): void
$duplicate = $original->duplicate();
expect($duplicate->render_markup_view)->toBeNull()
->and($duplicate->name)->toBe('Missing View Plugin_copy');
->and($duplicate->name)->toBe('Missing View Plugin (Copy)');
});
test('plugin duplicate uses provided user_id', function (): void {

View file

@ -176,15 +176,37 @@ it('cleanup_folder identifies active images correctly', function (): void {
expect($activeImageUuids)->not->toContain(null);
});
it('reset_if_not_cacheable does not reset recipe cache when other devices exist', function (): void {
// Cache validity is now determined at use-time via metadata
$plugin = App\Models\Plugin::factory()->create(['current_image' => 'test-uuid', 'plugin_type' => 'recipe']);
Device::factory()->create(['device_model_id' => DeviceModel::factory()->create()->id]);
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)->toBe('test-uuid');
expect($plugin->current_image)->toBeNull();
});
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();
});
it('reset_if_not_cacheable preserves cache for standard devices', function (): void {
@ -236,21 +258,26 @@ it('reset_if_not_cacheable preserves cache for og_png and og_plus device models'
expect($plugin->current_image)->toBe('test-uuid');
});
it('reset_if_not_cacheable does not reset cache for non-standard device models', function (): void {
// Cache is now validated at use-time via metadata comparison
$plugin = App\Models\Plugin::factory()->create(['current_image' => 'test-uuid', 'plugin_type' => 'recipe']);
it('reset_if_not_cacheable resets cache for non-standard device models', function (): void {
// Create a plugin
$plugin = App\Models\Plugin::factory()->create(['current_image' => 'test-uuid']);
// Create a non-standard device model (e.g., kindle)
$kindleModel = DeviceModel::factory()->create([
'name' => 'test_amazon_kindle_2024',
'width' => 1400,
'height' => 840,
'rotation' => 90,
]);
// Create a device with the non-standard device model
Device::factory()->create(['device_model_id' => $kindleModel->id]);
// Test that the method resets cache for non-standard device models
ImageGenerationService::resetIfNotCacheable($plugin);
$plugin->refresh();
expect($plugin->current_image)->toBe('test-uuid');
expect($plugin->current_image)->toBeNull();
});
it('reset_if_not_cacheable handles null plugin', function (): void {