Compare commits
28 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90fbfadfa4 | ||
|
|
7301cac8ca | ||
|
|
5ca028b885 | ||
|
|
7864c8b7ab | ||
|
|
433bda9639 | ||
|
|
3abc67ff67 | ||
|
|
9df538de16 | ||
|
|
ba541f62f1 | ||
|
|
26b5f3ceb1 | ||
|
|
c194ab5db1 | ||
|
|
d246ac2c59 | ||
|
|
d96fb297bc | ||
|
|
d17fa1eedb | ||
|
|
00d242dac1 | ||
|
|
b8b6caba12 | ||
|
|
5eb442d9d6 | ||
|
|
c7abc14e26 | ||
|
|
0797f17ebb | ||
|
|
64e2b6cdbd | ||
|
|
f23fbf3a3d | ||
|
|
83b9ca89ee | ||
|
|
84c340be4b | ||
|
|
1be02bb510 | ||
|
|
cc2cb070da | ||
|
|
d884ac0a58 | ||
|
|
89a2edfcbb | ||
|
|
d83a4095cb | ||
|
|
3419085325 |
5
.github/workflows/docker-build.yml
vendored
|
|
@ -6,7 +6,6 @@ on:
|
|||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
|
|
@ -40,7 +39,9 @@ jobs:
|
|||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
images: |
|
||||
${{ env.REGISTRY }}/usetrmnl/byos_laravel
|
||||
${{ env.REGISTRY }}/usetrmnl/larapaper
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
########################
|
||||
# Base Image
|
||||
########################
|
||||
FROM bnussbau/serversideup-php:8.4-fpm-nginx-alpine-imagick-chromium@sha256:52ac545fdb57b2ab7568b1c7fc0a98cb1a69a275d8884249778a80914272fa48 AS base
|
||||
FROM bnussbau/serversideup-php:8.4-fpm-nginx-alpine-imagick-chromium@sha256:ed705a4060d50143ddc538c1288afff217eaf76ad5791f7556a97943854cf745 AS base
|
||||
|
||||
LABEL org.opencontainers.image.source=https://github.com/usetrmnl/byos_laravel
|
||||
LABEL org.opencontainers.image.description="TRMNL BYOS Laravel"
|
||||
LABEL org.opencontainers.image.source=https://github.com/usetrmnl/larapaper
|
||||
LABEL org.opencontainers.image.description="LaraPaper"
|
||||
LABEL org.opencontainers.image.licenses=MIT
|
||||
|
||||
ARG APP_VERSION
|
||||
|
|
|
|||
31
README.md
|
|
@ -1,8 +1,8 @@
|
|||
## TRMNL BYOS (PHP/Laravel)
|
||||
## LaraPaper (PHP/Laravel)
|
||||
|
||||
[](https://github.com/usetrmnl/byos_laravel/actions/workflows/test.yml)
|
||||
[](https://github.com/usetrmnl/larapaper/actions/workflows/test.yml)
|
||||
|
||||
TRMNL BYOS Laravel is a self-hostable implementation of a TRMNL server, built with Laravel.
|
||||
LaraPaper is a self-hostable implementation of a TRMNL server (BYOS), 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, it’s the most popular community-driven BYOS.
|
||||
|
||||

|
||||
|
|
@ -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/byos_laravel/pull/27))
|
||||
* Kindle ([trmnl-kindle](https://github.com/usetrmnl/larapaper/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 byos_laravel container
|
||||
docker ps #find container id of larapaper 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 you’re 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).
|
||||
If you’re 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).
|
||||
It’s a quick way to get started without having to manually manage Docker setup.
|
||||
|
||||
#### PikaPods
|
||||
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)
|
||||
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)
|
||||
|
||||
#### Umbrel
|
||||
Umbrel is supported through a community store, [see](http://github.com/bnussbau/umbrel-store).
|
||||
|
|
@ -121,11 +121,16 @@ 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. | 0 |
|
||||
| `FORCE_HTTPS` | If your server handles SSL termination, enforce HTTPS. Alternative: `TRUSTED_PROXIES`. | 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 |
|
||||
| `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 device’s screen dimensions | `null` |
|
||||
|
||||
#### Login
|
||||
|
||||
|
|
@ -168,13 +173,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 Laravel BYOS, create a user and login
|
||||
3) In Laravel BYOS in the header bar, activate the toggle "Permit Auto-Join"
|
||||
2) Setup LaraPaper, create a user and login
|
||||
3) In LaraPaper 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 your Laravel BYOS
|
||||
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.
|
||||
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 Laravel BYOS plugin is scheduled, the device will show your cloud plugins.
|
||||
8) As long as no LaraPaper plugin is scheduled, the device will show your cloud plugins.
|
||||
|
||||
###### Troubleshooting
|
||||
|
||||
|
|
|
|||
|
|
@ -184,7 +184,7 @@ class GenerateDefaultImagesCommand extends Command
|
|||
};
|
||||
|
||||
// Determine device properties from DeviceModel
|
||||
$deviceVariant = $deviceModel->name ?? 'og';
|
||||
$deviceVariant = $deviceModel->css_name ?? $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,6 +196,7 @@ class GenerateDefaultImagesCommand extends Command
|
|||
'deviceVariant' => $deviceVariant,
|
||||
'colorDepth' => $colorDepth,
|
||||
'scaleLevel' => $scaleLevel,
|
||||
'cssVariables' => $deviceModel->css_variables,
|
||||
])->render();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -156,7 +156,7 @@ class CheckVersionUpdateJob
|
|||
|
||||
private function extractLatestVersion(array $response, bool $enablePrereleases): array
|
||||
{
|
||||
if (! $enablePrereleases || ! is_array($response) || ! isset($response[0])) {
|
||||
if (! $enablePrereleases || ! isset($response[0])) {
|
||||
return [
|
||||
Arr::get($response, 'tag_name'),
|
||||
$response,
|
||||
|
|
|
|||
|
|
@ -12,8 +12,10 @@ 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
|
||||
{
|
||||
|
|
@ -209,12 +211,41 @@ 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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -34,8 +34,13 @@ class GenerateScreenJob implements ShouldQueue
|
|||
Device::find($this->deviceId)->update(['current_screen_image' => $newImageUuid]);
|
||||
|
||||
if ($this->pluginId) {
|
||||
// cache current image
|
||||
Plugin::find($this->pluginId)->update(['current_image' => $newImageUuid]);
|
||||
$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);
|
||||
}
|
||||
|
||||
ImageGenerationService::cleanupFolder();
|
||||
|
|
|
|||
|
|
@ -4,11 +4,14 @@ 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
|
||||
|
|
@ -27,6 +30,7 @@ final class DeviceModel extends Model
|
|||
'offset_x' => 'integer',
|
||||
'offset_y' => 'integer',
|
||||
'published_at' => 'datetime',
|
||||
'css_variables' => 'array',
|
||||
];
|
||||
|
||||
public function getColorDepthAttribute(): ?string
|
||||
|
|
@ -71,8 +75,54 @@ 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -140,8 +140,9 @@ class PlaylistItem extends Model
|
|||
if (! $this->isMashup()) {
|
||||
return view('trmnl-layouts.single', [
|
||||
'colorDepth' => $device?->colorDepth(),
|
||||
'deviceVariant' => $device?->deviceVariant() ?? 'og',
|
||||
'deviceVariant' => $device?->deviceModel?->css_name ?? $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'),
|
||||
|
|
@ -162,8 +163,9 @@ class PlaylistItem extends Model
|
|||
|
||||
return view('trmnl-layouts.mashup', [
|
||||
'colorDepth' => $device?->colorDepth(),
|
||||
'deviceVariant' => $device?->deviceVariant() ?? 'og',
|
||||
'deviceVariant' => $device?->deviceModel?->css_name ?? $device?->deviceVariant() ?? 'og',
|
||||
'scaleLevel' => $device?->scaleLevel(),
|
||||
'cssVariables' => $device?->deviceModel?->css_variables,
|
||||
'mashupLayout' => $this->getMashupLayoutType(),
|
||||
'slot' => implode('', $pluginMarkups),
|
||||
])->render();
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ class Plugin extends Model
|
|||
'preferred_renderer' => 'string',
|
||||
'plugin_type' => 'string',
|
||||
'alias' => 'boolean',
|
||||
'current_image_metadata' => 'array',
|
||||
];
|
||||
|
||||
protected static function boot()
|
||||
|
|
@ -71,6 +72,7 @@ class Plugin extends Model
|
|||
'render_markup_shared',
|
||||
])) {
|
||||
$model->current_image = null;
|
||||
$model->current_image_metadata = null;
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -222,7 +224,7 @@ class Plugin extends Model
|
|||
if ($this->data_strategy !== 'polling' || ! $this->polling_url) {
|
||||
return;
|
||||
}
|
||||
$headers = ['User-Agent' => 'usetrmnl/byos_laravel', 'Accept' => 'application/json'];
|
||||
$headers = ['User-Agent' => 'usetrmnl/larapaper', 'Accept' => 'application/json'];
|
||||
|
||||
// resolve headers
|
||||
if ($this->polling_header) {
|
||||
|
|
@ -573,10 +575,45 @@ 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 : []),
|
||||
],
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -584,10 +621,11 @@ class Plugin extends Model
|
|||
if ($size === 'full') {
|
||||
return view('trmnl-layouts.single', [
|
||||
'colorDepth' => $device?->colorDepth(),
|
||||
'deviceVariant' => $device?->deviceVariant() ?? 'og',
|
||||
'deviceVariant' => $device?->deviceModel?->css_name ?? $device?->deviceVariant() ?? 'og',
|
||||
'noBleed' => $this->no_bleed,
|
||||
'darkMode' => $this->dark_mode,
|
||||
'scaleLevel' => $device?->scaleLevel(),
|
||||
'cssVariables' => $device?->deviceModel?->css_variables,
|
||||
'slot' => $renderedContent,
|
||||
])->render();
|
||||
}
|
||||
|
|
@ -595,9 +633,10 @@ class Plugin extends Model
|
|||
return view('trmnl-layouts.mashup', [
|
||||
'mashupLayout' => $this->getPreviewMashupLayoutForSize($size),
|
||||
'colorDepth' => $device?->colorDepth(),
|
||||
'deviceVariant' => $device?->deviceVariant() ?? 'og',
|
||||
'deviceVariant' => $device?->deviceModel?->css_name ?? $device?->deviceVariant() ?? 'og',
|
||||
'darkMode' => $this->dark_mode,
|
||||
'scaleLevel' => $device?->scaleLevel(),
|
||||
'cssVariables' => $device?->deviceModel?->css_variables,
|
||||
'slot' => $renderedContent,
|
||||
])->render();
|
||||
|
||||
|
|
@ -617,10 +656,11 @@ class Plugin extends Model
|
|||
if ($size === 'full') {
|
||||
return view('trmnl-layouts.single', [
|
||||
'colorDepth' => $device?->colorDepth(),
|
||||
'deviceVariant' => $device?->deviceVariant() ?? 'og',
|
||||
'deviceVariant' => $device?->deviceModel?->css_name ?? $device?->deviceVariant() ?? 'og',
|
||||
'noBleed' => $this->no_bleed,
|
||||
'darkMode' => $this->dark_mode,
|
||||
'scaleLevel' => $device?->scaleLevel(),
|
||||
'cssVariables' => $device?->deviceModel?->css_variables,
|
||||
'slot' => $renderedView,
|
||||
])->render();
|
||||
}
|
||||
|
|
@ -628,9 +668,10 @@ class Plugin extends Model
|
|||
return view('trmnl-layouts.mashup', [
|
||||
'mashupLayout' => $this->getPreviewMashupLayoutForSize($size),
|
||||
'colorDepth' => $device?->colorDepth(),
|
||||
'deviceVariant' => $device?->deviceVariant() ?? 'og',
|
||||
'deviceVariant' => $device?->deviceModel?->css_name ?? $device?->deviceVariant() ?? 'og',
|
||||
'darkMode' => $this->dark_mode,
|
||||
'scaleLevel' => $device?->scaleLevel(),
|
||||
'cssVariables' => $device?->deviceModel?->css_variables,
|
||||
'slot' => $renderedView,
|
||||
])->render();
|
||||
}
|
||||
|
|
@ -734,8 +775,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;
|
||||
|
|
|
|||
|
|
@ -331,36 +331,88 @@ class ImageGenerationService
|
|||
}
|
||||
}
|
||||
|
||||
public static function resetIfNotCacheable(?Plugin $plugin): void
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
if ($plugin?->id) {
|
||||
// Image webhook plugins have finalized images that shouldn't be reset
|
||||
if ($plugin->plugin_type === 'image_webhook') {
|
||||
return;
|
||||
}
|
||||
// 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();
|
||||
if (! $plugin?->id || $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");
|
||||
}
|
||||
|
||||
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');
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -514,7 +566,7 @@ class ImageGenerationService
|
|||
};
|
||||
|
||||
// Determine device properties from DeviceModel or device settings
|
||||
$deviceVariant = $device->deviceVariant();
|
||||
$deviceVariant = $device->deviceModel?->css_name ?? $device->deviceVariant();
|
||||
$deviceOrientation = $device->rotate > 0 ? 'portrait' : 'landscape';
|
||||
$colorDepth = $device->colorDepth() ?? '1bit';
|
||||
$scaleLevel = $device->scaleLevel();
|
||||
|
|
@ -528,6 +580,7 @@ class ImageGenerationService
|
|||
'deviceOrientation' => $deviceOrientation,
|
||||
'colorDepth' => $colorDepth,
|
||||
'scaleLevel' => $scaleLevel,
|
||||
'cssVariables' => $device->deviceModel?->css_variables ?? [],
|
||||
];
|
||||
|
||||
// Add plugin name for error screens
|
||||
|
|
|
|||
|
|
@ -25,7 +25,12 @@ class IcalResponseParser implements ResponseParser
|
|||
}
|
||||
|
||||
try {
|
||||
$this->parser->parseString($body);
|
||||
// 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);
|
||||
|
||||
$events = $this->parser->getEvents()->sorted()->getArrayCopy();
|
||||
$windowStart = now()->subDays(7);
|
||||
|
|
|
|||
|
|
@ -11,12 +11,12 @@
|
|||
],
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"php": "^8.4",
|
||||
"ext-imagick": "*",
|
||||
"ext-simplexml": "*",
|
||||
"ext-zip": "*",
|
||||
"bnussbau/laravel-trmnl-blade": "2.3.*",
|
||||
"bnussbau/trmnl-pipeline-php": "0.7.*",
|
||||
"bnussbau/laravel-trmnl-blade": "^2.3",
|
||||
"bnussbau/trmnl-pipeline-php": "^0.8",
|
||||
"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": "^3.2",
|
||||
"om/icalparser": "^4.0",
|
||||
"spatie/browsershot": "^5.0",
|
||||
"spatie/laravel-settings": "^3.6",
|
||||
"stevebauman/purify": "^6.3",
|
||||
|
|
|
|||
853
composer.lock
generated
|
|
@ -13,7 +13,7 @@ return [
|
|||
|
|
||||
*/
|
||||
|
||||
'name' => env('APP_NAME', 'Laravel'),
|
||||
'name' => env('APP_NAME', 'LaraPaper'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
@ -127,11 +127,13 @@ 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', null),
|
||||
'puppeteer_window_size_strategy' => env('PUPPETEER_WINDOW_SIZE_STRATEGY', 'v2'),
|
||||
|
||||
'notifications' => [
|
||||
'battery_low' => [
|
||||
|
|
@ -154,5 +156,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/byos_laravel'),
|
||||
'github_repo' => env('GITHUB_REPO', 'usetrmnl/larapaper'),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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('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']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
<?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,
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
<?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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
namespace Database\Seeders;
|
||||
|
||||
use App\Models\Device;
|
||||
use App\Models\Playlist;
|
||||
use App\Models\Plugin;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Seeder;
|
||||
|
|
@ -23,9 +24,19 @@ class DatabaseSeeder extends Seeder
|
|||
'password' => bcrypt('admin@example.com'),
|
||||
]);
|
||||
|
||||
Device::factory(1)->create([
|
||||
$device = Device::factory()->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();
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
services:
|
||||
app:
|
||||
image: ghcr.io/usetrmnl/byos_laravel:latest
|
||||
image: ghcr.io/usetrmnl/larapaper: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
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
#### Clone the repository
|
||||
|
||||
```bash
|
||||
git clone git@github.com:usetrmnl/byos_laravel.git
|
||||
git clone git@github.com:usetrmnl/larapaper.git
|
||||
```
|
||||
|
||||
#### Copy environment file
|
||||
|
|
|
|||
890
package-lock.json
generated
|
|
@ -25,7 +25,7 @@
|
|||
"codemirror": "^6.0.2",
|
||||
"concurrently": "^9.0.1",
|
||||
"laravel-vite-plugin": "^2.0",
|
||||
"puppeteer": "24.30.0",
|
||||
"puppeteer": "24.37.0",
|
||||
"tailwindcss": "^4.0.7",
|
||||
"vite": "^7.0.4"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,5 +2,9 @@
|
|||
<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">
|
||||
<span class="mb-0.5 truncate leading-none font-semibold">TRMNL BYOS Laravel</span>
|
||||
@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
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,10 @@
|
|||
])
|
||||
|
||||
<div class="flex w-full flex-col text-center">
|
||||
<flux:heading size="xl">{{ $title }}</flux:heading>
|
||||
@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:subheading>{{ $description }}</flux:subheading>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,12 +5,14 @@
|
|||
'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}}">
|
||||
scale-level="{{$scaleLevel}}"
|
||||
:css-variables="$cssVariables">
|
||||
<x-trmnl::view>
|
||||
<x-trmnl::layout>
|
||||
<x-trmnl::richtext gapSize="large" align="center">
|
||||
|
|
|
|||
|
|
@ -5,18 +5,20 @@
|
|||
'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}}">
|
||||
scale-level="{{$scaleLevel}}"
|
||||
:css-variables="$cssVariables">
|
||||
<x-trmnl::view>
|
||||
<x-trmnl::layout>
|
||||
<x-trmnl::richtext gapSize="large" align="center">
|
||||
<x-trmnl::title>Welcome to BYOS Laravel!</x-trmnl::title>
|
||||
<x-trmnl::title>Welcome to LaraPaper!</x-trmnl::title>
|
||||
<x-trmnl::content>Your device is connected.</x-trmnl::content>
|
||||
</x-trmnl::richtext>
|
||||
</x-trmnl::layout>
|
||||
<x-trmnl::title-bar title="byos_laravel"/>
|
||||
<x-trmnl::title-bar title="LaraPaper"/>
|
||||
</x-trmnl::view>
|
||||
</x-trmnl::screen>
|
||||
|
|
|
|||
|
|
@ -5,11 +5,13 @@
|
|||
'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}}">
|
||||
scale-level="{{$scaleLevel}}"
|
||||
:css-variables="$cssVariables">
|
||||
<x-trmnl::view>
|
||||
<x-trmnl::layout>
|
||||
<x-trmnl::richtext gapSize="large" align="center">
|
||||
|
|
@ -23,6 +25,6 @@
|
|||
<x-trmnl::title>Sleep Mode</x-trmnl::title>
|
||||
</x-trmnl::richtext>
|
||||
</x-trmnl::layout>
|
||||
<x-trmnl::title-bar title="byos_laravel"/>
|
||||
<x-trmnl::title-bar title="LaraPaper"/>
|
||||
</x-trmnl::view>
|
||||
</x-trmnl::screen>
|
||||
|
|
|
|||
|
|
@ -39,6 +39,11 @@ 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',
|
||||
|
|
@ -102,10 +107,12 @@ 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']);
|
||||
$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->mime_type = 'image/png';
|
||||
$this->scale_factor = 1.0;
|
||||
$this->rotation = 0;
|
||||
|
|
@ -131,6 +138,10 @@ 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) {
|
||||
|
|
@ -158,6 +169,8 @@ 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 {
|
||||
|
|
@ -176,12 +189,14 @@ 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', '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', 'css_name', 'css_variables', 'editingDeviceModelId', 'viewingDeviceModelId']);
|
||||
Flux::modal('device-model-modal')->close();
|
||||
|
||||
$this->deviceModels = DeviceModel::all();
|
||||
|
|
@ -217,9 +232,38 @@ 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();
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
|
|
@ -344,6 +388,40 @@ 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/>
|
||||
|
|
|
|||
|
|
@ -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/byos_laravel/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/larapaper/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/byos_laravel/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/larapaper/issues/new" target="_blank" class="underline">GitHub</a>. Include the recipe URL.</flux:text></li>
|
||||
</flux:callout>
|
||||
</div>
|
||||
<livewire:catalog.trmnl />
|
||||
|
|
|
|||
|
|
@ -72,8 +72,8 @@ new class extends Component
|
|||
<x-trmnl::view>
|
||||
<x-trmnl::layout>
|
||||
<x-trmnl::richtext gapSize="large" align="center">
|
||||
<x-trmnl::title>TRMNL BYOS Laravel</x-trmnl::title>
|
||||
<x-trmnl::content>“This screen was rendered by BYOS Laravel”</x-trmnl::content>
|
||||
<x-trmnl::title>LaraPaper</x-trmnl::title>
|
||||
<x-trmnl::content>“This screen was rendered by BYOS LaraPaper”</x-trmnl::content>
|
||||
<x-trmnl::label variant="underline">Benjamin Nussbaum</x-trmnl::label>
|
||||
</x-trmnl::richtext>
|
||||
</x-trmnl::layout>
|
||||
|
|
|
|||
|
|
@ -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 {{ $latestVersion }}.
|
||||
You are running the latest version.
|
||||
</flux:callout.text>
|
||||
</flux:callout>
|
||||
@endif
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
|
||||
<title>{{ $title ?? 'TRMNL BYOS Laravel' }}</title>
|
||||
<title>{{ $title ?? 'LaraPaper' }}</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" />
|
||||
|
|
|
|||
|
|
@ -6,18 +6,22 @@
|
|||
'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}}">
|
||||
scale-level="{{$scaleLevel}}"
|
||||
:css-variables="$cssVariables">
|
||||
<x-trmnl::mashup mashup-layout="{{ $mashupLayout }}">
|
||||
{!! $slot !!}
|
||||
</x-trmnl::mashup>
|
||||
</x-trmnl::screen>
|
||||
@else
|
||||
<x-trmnl::screen colorDepth="{{$colorDepth}}">
|
||||
<x-trmnl::screen colorDepth="{{$colorDepth}}" device-variant="{{$deviceVariant}}" device-orientation="{{$deviceOrientation}}"
|
||||
scale-level="{{$scaleLevel}}"
|
||||
:css-variables="$cssVariables">
|
||||
<x-trmnl::mashup mashup-layout="{{ $mashupLayout }}">
|
||||
{!! $slot !!}
|
||||
</x-trmnl::mashup>
|
||||
|
|
|
|||
|
|
@ -5,16 +5,21 @@
|
|||
'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}}">
|
||||
scale-level="{{$scaleLevel}}"
|
||||
:css-variables="$cssVariables">
|
||||
{!! $slot !!}
|
||||
</x-trmnl::screen>
|
||||
@else
|
||||
<x-trmnl::screen colorDepth="{{$colorDepth}}" no-bleed="{{$noBleed}}" dark-mode="{{$darkMode}}">
|
||||
<x-trmnl::screen colorDepth="{{$colorDepth}}" no-bleed="{{$noBleed}}" dark-mode="{{$darkMode}}"
|
||||
device-variant="{{$deviceVariant}}" device-orientation="{{$deviceOrientation}}"
|
||||
scale-level="{{$scaleLevel}}"
|
||||
:css-variables="$cssVariables">
|
||||
{!! $slot !!}
|
||||
</x-trmnl::screen>
|
||||
@endif
|
||||
|
|
|
|||
|
|
@ -1,19 +1,13 @@
|
|||
@props([
|
||||
'noBleed' => false,
|
||||
'darkMode' => false,
|
||||
'deviceVariant' => 'og',
|
||||
'deviceVariant' => 'ogv2',
|
||||
'deviceOrientation' => null,
|
||||
'colorDepth' => '1bit',
|
||||
'scaleLevel' => null,
|
||||
'cssVariables' => null,
|
||||
])
|
||||
|
||||
@php
|
||||
// HOTFIX Github Issue https://github.com/usetrmnl/byos_laravel/issues/190
|
||||
if ($colorDepth == '2bit'){
|
||||
$deviceVariant = 'ogv2';
|
||||
}
|
||||
@endphp
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
|
||||
<head>
|
||||
|
|
@ -33,9 +27,18 @@ if ($colorDepth == '2bit'){
|
|||
<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>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<x-layouts::auth.card>
|
||||
<div class="flex flex-col gap-6">
|
||||
<x-auth-header title="TRMNL BYOS Laravel" description="Server is up and running."/>
|
||||
<x-auth-header title="LaraPaper" 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,6 +32,11 @@
|
|||
@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>
|
||||
|
|
|
|||
|
|
@ -88,8 +88,8 @@ Route::get('/display', function (Request $request) {
|
|||
$refreshTimeOverride = $playlistItem->playlist()->first()->refresh_time;
|
||||
$plugin = $playlistItem->plugin;
|
||||
|
||||
// Reset cache if Devices with different dimensions exist
|
||||
ImageGenerationService::resetIfNotCacheable($plugin);
|
||||
ImageGenerationService::resetIfNotCacheable($plugin, $device);
|
||||
$plugin->refresh();
|
||||
|
||||
// Check and update stale data if needed
|
||||
if ($plugin->isDataStale() || $plugin->current_image === null) {
|
||||
|
|
@ -699,6 +699,9 @@ 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;
|
||||
|
||||
|
|
@ -744,9 +747,13 @@ Route::get('/display/{uuid}/alias', function (Request $request, string $uuid) {
|
|||
palette: $deviceModel->palette
|
||||
);
|
||||
|
||||
// Update plugin cache if using og_png
|
||||
// Update plugin cache if using og_png (recipes only get metadata for cache comparison)
|
||||
if ($deviceModelName === 'og_png') {
|
||||
$plugin->update(['current_image' => $imageUuid]);
|
||||
$update = ['current_image' => $imageUuid];
|
||||
if ($plugin->plugin_type === 'recipe') {
|
||||
$update['current_image_metadata'] = ImageGenerationService::buildImageMetadataFromDeviceModel($deviceModel);
|
||||
}
|
||||
$plugin->update($update);
|
||||
}
|
||||
|
||||
// Return the generated image
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
BIN
storage/app/public/images/default-screens/sleep_1648_824_8_0.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
|
After Width: | Height: | Size: 26 KiB |
BIN
storage/app/public/images/default-screens/sleep_800_480_1_0.png
Normal file
|
After Width: | Height: | Size: 686 B |
BIN
storage/app/public/images/default-screens/sleep_800_480_2_0.png
Normal file
|
After Width: | Height: | Size: 901 B |
BIN
storage/app/public/images/default-screens/sleep_960_540_4_0.png
Normal file
|
After Width: | Height: | Size: 2 KiB |
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
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;
|
||||
|
||||
|
|
@ -58,3 +60,26 @@ 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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ 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);
|
||||
|
|
@ -22,6 +23,10 @@ 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([
|
||||
|
|
@ -270,39 +275,15 @@ it('cleanupFolder preserves .gitignore', function (): void {
|
|||
Storage::disk('public')->assertExists('/images/generated/.gitignore');
|
||||
});
|
||||
|
||||
it('resetIfNotCacheable resets when device models exist', function (): void {
|
||||
// Create a plugin
|
||||
$plugin = App\Models\Plugin::factory()->create(['current_image' => 'test-uuid']);
|
||||
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']);
|
||||
|
||||
// Create a device with DeviceModel (should trigger cache reset)
|
||||
Device::factory()->create([
|
||||
'device_model_id' => DeviceModel::factory()->create()->id,
|
||||
]);
|
||||
|
||||
// Run reset check
|
||||
Device::factory()->create(['device_model_id' => DeviceModel::factory()->create()->id]);
|
||||
ImageGenerationService::resetIfNotCacheable($plugin);
|
||||
|
||||
// Assert plugin image was reset
|
||||
$plugin->refresh();
|
||||
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();
|
||||
expect($plugin->current_image)->toBe('test-uuid');
|
||||
});
|
||||
|
||||
it('resetIfNotCacheable preserves image for standard devices', function (): void {
|
||||
|
|
@ -325,27 +306,122 @@ 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
|
||||
// Create a plugin with cached image and metadata
|
||||
$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>',
|
||||
]);
|
||||
|
||||
// Create devices with standard dimensions (cacheable)
|
||||
Device::factory()->count(2)->create([
|
||||
'width' => 800,
|
||||
'height' => 480,
|
||||
'rotate' => 0,
|
||||
]);
|
||||
$plugin->update(['render_markup' => '<div>Updated markup</div>']);
|
||||
|
||||
// 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 {
|
||||
|
|
|
|||
46
tests/Feature/PixelLogoConfigTest.php
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<?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);
|
||||
});
|
||||
163
tests/Feature/Plugins/Ical/IcalParserTest.php
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
<?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();
|
||||
});
|
||||
100
tests/Feature/ScreenCssVariablesTest.php
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
<?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');
|
||||
});
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
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([
|
||||
|
|
@ -117,3 +118,27 @@ 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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -176,37 +176,15 @@ it('cleanup_folder identifies active images correctly', function (): void {
|
|||
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']);
|
||||
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]);
|
||||
|
||||
// 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();
|
||||
});
|
||||
|
||||
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();
|
||||
expect($plugin->current_image)->toBe('test-uuid');
|
||||
});
|
||||
|
||||
it('reset_if_not_cacheable preserves cache for standard devices', function (): void {
|
||||
|
|
@ -258,26 +236,21 @@ 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 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)
|
||||
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']);
|
||||
$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)->toBeNull();
|
||||
expect($plugin->current_image)->toBe('test-uuid');
|
||||
});
|
||||
|
||||
it('reset_if_not_cacheable handles null plugin', function (): void {
|
||||
|
|
|
|||