mirror of
https://github.com/usetrmnl/byos_laravel.git
synced 2026-01-13 15:07:49 +00:00
Compare commits
362 commits
0.1.0-beta
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3032c09778 | ||
|
|
f1903bcbe8 | ||
|
|
621c108e78 | ||
|
|
131d99a2e3 | ||
|
|
7d1e74183d | ||
|
|
3f98a70ad9 | ||
|
|
0d6079db8b | ||
|
|
a86315c5c7 | ||
|
|
887c4d130b | ||
|
|
74e9e1eba3 | ||
|
|
53d4a8399f | ||
|
|
043f683db7 | ||
|
|
36e1ad8441 | ||
|
|
a06a0879ff | ||
|
|
ddce3947c6 | ||
|
|
4bc42cc1d2 | ||
|
|
94d5fca879 | ||
|
|
dc676327c2 | ||
|
|
e3bb9ad4e2 | ||
|
|
e176f2828e | ||
|
|
164a990dfe | ||
|
|
6d02415b7d | ||
|
|
3def60ae3e | ||
|
|
809965e81c | ||
|
|
b855ccffcb | ||
|
|
32dd4c3d08 | ||
|
|
a3f792944c | ||
|
|
3e670d37c0 | ||
|
|
46e792bc6d | ||
|
|
9019561bb3 | ||
|
|
838b4fd33b | ||
|
|
4451361f15 | ||
|
|
265972ac24 | ||
|
|
7f97114f6e | ||
|
|
3250bb0402 | ||
|
|
50853728bc | ||
|
|
3cdc267809 | ||
|
|
1298814521 | ||
|
|
a5cb38421e | ||
|
|
e6d66af298 | ||
|
|
d4b5cf99d5 | ||
|
|
d81c1b99f1 | ||
|
|
0b2b5bf25f | ||
|
|
f1a9103f0d | ||
|
|
d49a2d4f6c | ||
|
|
be2bb637c9 | ||
|
|
f3538048d4 | ||
|
|
a7963947f8 | ||
|
|
b1467204f8 | ||
|
|
fb9469d9cd | ||
|
|
b6faa2f232 | ||
|
|
60f2a38169 | ||
|
|
838db288e7 | ||
|
|
8776c668b4 | ||
|
|
1096118e03 | ||
|
|
b10bbca774 | ||
|
|
0322ec899e | ||
|
|
7c8e55588a | ||
|
|
dac8064938 | ||
|
|
fd41e77e7d | ||
|
|
568bd69fea | ||
|
|
61b9ff56e0 | ||
|
|
73f0fd26c2 | ||
|
|
7014250ac5 | ||
|
|
c157dcf3b6 | ||
|
|
742fd86c77 | ||
|
|
7489d85592 | ||
|
|
22a24383b2 | ||
|
|
468e8a130d | ||
|
|
346f04a7af | ||
|
|
31a73ccc6e | ||
|
|
042654993a | ||
|
|
6c438ff4d4 | ||
|
|
b7ce0b6152 | ||
|
|
cdf477e2ed | ||
|
|
e63953dc13 | ||
|
|
a8f3232ccc | ||
|
|
41baff51a6 | ||
|
|
f0f6b28107 | ||
|
|
e53c584eed | ||
|
|
1ccaa8382b | ||
|
|
36f783ac60 | ||
|
|
dd4237360c | ||
|
|
ef9cb81edb | ||
|
|
10b53c3772 | ||
|
|
52dfe92054 | ||
|
|
882cbff7fe | ||
|
|
80e2e8058a | ||
|
|
38e1b6f2a6 | ||
|
|
315fbac261 | ||
|
|
5abc452770 | ||
|
|
4de32e9d47 | ||
|
|
aa46dff00b | ||
|
|
311236a70d | ||
|
|
5e0d0ad73f | ||
|
|
f6897fdfc7 | ||
|
|
04ae695a14 | ||
|
|
a7e76f3c07 | ||
|
|
627d9ad09b | ||
|
|
583d8b2440 | ||
|
|
b18d561361 | ||
|
|
4c65c015b9 | ||
|
|
58e1fc32a4 | ||
|
|
74a65d6daf | ||
|
|
8aea83703c | ||
|
|
161200df44 | ||
|
|
23a7a217db | ||
|
|
c8f6dd3bec | ||
|
|
c1786dfb6d | ||
|
|
91e222f7a6 | ||
|
|
203584107f | ||
|
|
56548a96cb | ||
|
|
e812f56c11 | ||
|
|
50318b8b9d | ||
|
|
93dacb0baf | ||
|
|
4af4bfe14a | ||
|
|
96e0223f2f | ||
|
|
6f7efd9e36 | ||
|
|
3e5ba47a12 | ||
|
|
6ae3e023d4 | ||
|
|
e443539357 | ||
|
|
b4b6286172 | ||
|
|
c67a182cf2 | ||
|
|
a1a57014b6 | ||
|
|
42b515e322 | ||
|
|
4f251bf37e | ||
|
|
d8f47eb9c2 | ||
|
|
39ac9f0ad2 | ||
|
|
8958e65ec2 | ||
|
|
2d76afee6f | ||
|
|
00fc526371 | ||
|
|
b3b251bae2 | ||
|
|
0c5041a8ca | ||
|
|
e9037ef5d7 | ||
|
|
ee9f21a83d | ||
|
|
19a8bb18cc | ||
|
|
b7bcaf6feb | ||
|
|
85e887f8a5 | ||
|
|
8791a5154e | ||
|
|
29d1838690 | ||
|
|
97e6beaee4 | ||
|
|
cc4aa0560c | ||
|
|
93406b83a5 | ||
|
|
88e10101b8 | ||
|
|
e65473f932 | ||
|
|
12c82e02d7 | ||
|
|
f20977a822 | ||
|
|
425dbf6b3f | ||
|
|
495bbe7b7e | ||
|
|
ec704d8d83 | ||
|
|
38e77eaeb6 | ||
|
|
770b511290 | ||
|
|
4bb5723767 | ||
|
|
40ceba267a | ||
|
|
aa8d3d1428 | ||
|
|
d999b5157f | ||
|
|
d3690c9e10 | ||
|
|
6d7968a7b0 | ||
|
|
7434911275 | ||
|
|
e12f9bb91c | ||
|
|
bcbb1be1da | ||
|
|
f777e850b1 | ||
|
|
14d0fbfa7e | ||
|
|
9d1f62c6dd | ||
|
|
f38ac778f1 | ||
|
|
2eee024b36 | ||
|
|
a129c71d79 | ||
|
|
58fad59301 | ||
|
|
0ffc0acc3f | ||
|
|
fb506fa846 | ||
|
|
4c66761baa | ||
|
|
25f36eaf54 | ||
|
|
f4f8ab5181 | ||
|
|
6cd00943a1 | ||
|
|
e50cbc14ec | ||
|
|
989ad2e985 | ||
|
|
f1d5c196e8 | ||
|
|
414ca47cbf | ||
|
|
a927c0fb97 | ||
|
|
2318c8d2ae | ||
|
|
2427436b31 | ||
|
|
51af95da2c | ||
|
|
2ed3fd5ca9 | ||
|
|
4e3b47e4eb | ||
|
|
888b61a575 | ||
|
|
fdf8031d08 | ||
|
|
ba3bf31bb7 | ||
|
|
a88e72b75e | ||
|
|
0503be65c2 | ||
|
|
93dc4a1492 | ||
|
|
e49de8da5f | ||
|
|
032c82e4aa | ||
|
|
65b9162ef3 | ||
|
|
d6dd1c5f31 | ||
|
|
caaf5f8755 | ||
|
|
6bc74b2c5c | ||
|
|
b4639b3ffb | ||
|
|
7288fd7c6b | ||
|
|
55b188a7e8 | ||
|
|
9f23a7a48e | ||
|
|
393fa9598c | ||
|
|
eacb891cba | ||
|
|
4b88726c96 | ||
|
|
895d126ab7 | ||
|
|
227f0e51c2 | ||
|
|
a182b7143a | ||
|
|
0eab9ca2b2 | ||
|
|
f25d40ba46 | ||
|
|
92035ca52a | ||
|
|
a44479a48a | ||
|
|
c20e1a9a58 | ||
|
|
7e355c2d92 | ||
|
|
4fb5f54e18 | ||
|
|
4b748b102b | ||
|
|
f767d39c8f | ||
|
|
56a29e8aed | ||
|
|
2056e2a2c2 | ||
|
|
1e30ddb7b6 | ||
|
|
11ced8e03c | ||
|
|
45e40a5661 | ||
|
|
4b32f3e8b2 | ||
|
|
ba9def7d4b | ||
|
|
f5d5cb4aef | ||
|
|
a72e39e0ec | ||
|
|
72a407dd6f | ||
|
|
b438457d32 | ||
|
|
e326ded933 | ||
|
|
3673654df6 | ||
|
|
94c156d3c1 | ||
|
|
3568f60b9b | ||
|
|
779c0c3b7d | ||
|
|
02cc91a21b | ||
|
|
0c5a69e8c1 | ||
|
|
7590a9d64a | ||
|
|
dcf3bf7c67 | ||
|
|
33e1f99fad | ||
|
|
1122764333 | ||
|
|
c1a4acc40a | ||
|
|
e535496a1e | ||
|
|
16b2e8436e | ||
|
|
56c00c2489 | ||
|
|
ea6eef0db3 | ||
|
|
af934ffdc2 | ||
|
|
42c25fc403 | ||
|
|
4bc31e5c60 | ||
|
|
7cd4263a1d | ||
|
|
6ea32c4149 | ||
|
|
612e002f4e | ||
|
|
ccba0f23f1 | ||
|
|
56638b26e8 | ||
|
|
ed9d03d0b8 | ||
|
|
a7a2d9d73e | ||
|
|
c023eb591b | ||
|
|
c94fbb8589 | ||
|
|
4102d33336 | ||
|
|
78f1f74594 | ||
|
|
e0124ccb15 | ||
|
|
3141b3cc41 | ||
|
|
0ec6c27a53 | ||
|
|
f0b7180edd | ||
|
|
c045dc1e85 | ||
|
|
04d089c445 | ||
|
|
c3f451f6b6 | ||
|
|
d6533c4447 | ||
|
|
ae66d15051 | ||
|
|
23077ea445 | ||
|
|
87b9b57c3d | ||
|
|
93aac51182 | ||
|
|
eed132dad6 | ||
|
|
f187cabcd9 | ||
|
|
82ec221a87 | ||
|
|
a13d24a82d | ||
|
|
fcd95a43e0 | ||
|
|
0590fe9a4d | ||
|
|
c25432bc34 | ||
|
|
8bd5bcd76e | ||
|
|
022bbc12fb | ||
|
|
725843dec0 | ||
|
|
c069db219a | ||
|
|
fbb237f7d8 | ||
|
|
1d8371c628 | ||
|
|
a0a1082217 | ||
|
|
b18c3f6b01 | ||
|
|
9cb808dee6 | ||
|
|
d572c738cc | ||
|
|
d75d099490 | ||
|
|
27ea7d1496 | ||
|
|
ad5ff5d2c9 | ||
|
|
cc63c8cce2 | ||
|
|
580a5833a8 | ||
|
|
faaccdc6fc | ||
|
|
acb9a65215 | ||
|
|
d9ba831c20 | ||
|
|
81f721099c | ||
|
|
fd18cf5246 | ||
|
|
8d7a53b888 | ||
|
|
b4dfb1673f | ||
|
|
a80e24c8bd | ||
|
|
8481656cc6 | ||
|
|
fb7e5037e1 | ||
|
|
4de1403105 | ||
|
|
56210405ff | ||
|
|
929e7fc4c0 | ||
|
|
e7e1b10a04 | ||
|
|
b9369c224e | ||
|
|
aa0a60ac69 | ||
|
|
a0586116e0 | ||
|
|
6594545f12 | ||
|
|
c75eec059d | ||
|
|
7775ffae1e | ||
|
|
684a2901c4 | ||
|
|
e27c50ed14 | ||
|
|
159ef61e8e | ||
|
|
7a60bba99d | ||
|
|
f530875210 | ||
|
|
302730c81e | ||
|
|
b4dea89fad | ||
|
|
f1fafe7ef0 | ||
|
|
4336910fd8 | ||
|
|
7b9e0991d2 | ||
|
|
e2fa5c1340 | ||
|
|
c499f9ddb4 | ||
|
|
2a6ff6554b | ||
|
|
74845c3004 | ||
|
|
c1c31fcfb8 | ||
|
|
71eaeac949 | ||
|
|
b8a0aa47bf | ||
|
|
e13f9708fd | ||
|
|
c3a5c9110c | ||
|
|
6e5e4cd633 | ||
|
|
5367fc8a64 | ||
|
|
eec8ea7e68 | ||
|
|
58ff12485b | ||
|
|
fd5f1bc48c | ||
|
|
807d520323 | ||
|
|
b59e44a9aa | ||
|
|
aed7addfb5 | ||
|
|
9166b20d42 | ||
|
|
eee79fd11f | ||
|
|
0c0a3026ce | ||
|
|
8eb0b2ccd7 | ||
|
|
941305dff6 | ||
|
|
dfd77b392a | ||
|
|
282fdac583 | ||
|
|
a45684d940 | ||
|
|
6b40a2b836 | ||
|
|
c8b21acb36 | ||
|
|
cd50e27288 | ||
|
|
f9b88ce446 | ||
|
|
9a9b5080d6 | ||
|
|
88f90ff9ae | ||
|
|
4485e20725 | ||
|
|
a6eb2f6101 | ||
|
|
8a5a813540 | ||
|
|
4c8cdf992c | ||
|
|
3906747b1a | ||
|
|
f74d36e40d | ||
|
|
2c3747e061 | ||
|
|
8e47d102c0 | ||
|
|
b0610a62b0 | ||
|
|
09b0d8bcf8 | ||
|
|
f03fff5784 |
317 changed files with 32425 additions and 3350 deletions
|
|
@ -1,5 +1,5 @@
|
|||
# From official php image.
|
||||
FROM php:8.3-cli-alpine
|
||||
FROM php:8.4-cli-alpine
|
||||
# Create a user group and account under id 1000.
|
||||
RUN addgroup -g 1000 -S user && adduser -u 1000 -D user -G user
|
||||
# Install quality-of-life packages.
|
||||
|
|
@ -9,21 +9,22 @@ RUN apk add --no-cache composer
|
|||
# Add Chromium and Image Magick for puppeteer.
|
||||
RUN apk add --no-cache \
|
||||
imagemagick-dev \
|
||||
chromium
|
||||
chromium \
|
||||
libzip-dev
|
||||
|
||||
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
||||
ENV PUPPETEER_DOCKER=1
|
||||
|
||||
RUN mkdir -p /usr/src/php/ext/imagick
|
||||
RUN chmod 777 /usr/src/php/ext/imagick
|
||||
RUN curl -fsSL https://github.com/Imagick/imagick/archive/refs/tags/3.7.0.tar.gz | tar xvz -C "/usr/src/php/ext/imagick" --strip 1
|
||||
RUN curl -fsSL https://github.com/Imagick/imagick/archive/refs/tags/3.8.0.tar.gz | tar xvz -C "/usr/src/php/ext/imagick" --strip 1
|
||||
|
||||
# Install PHP extensions
|
||||
RUN docker-php-ext-install imagick
|
||||
RUN docker-php-ext-install imagick zip
|
||||
|
||||
# Composer uses its php binary, but we want it to use the container's one
|
||||
RUN rm -f /usr/bin/php83
|
||||
RUN ln -s /usr/local/bin/php /usr/bin/php83
|
||||
RUN rm -f /usr/bin/php84
|
||||
RUN ln -s /usr/local/bin/php /usr/bin/php84
|
||||
# Install postgres pdo driver.
|
||||
# RUN apk add --no-cache postgresql-dev && docker-php-ext-install pdo_pgsql
|
||||
# Install redis driver.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
# From official php image.
|
||||
FROM php:8.3-fpm-alpine
|
||||
FROM php:8.4-fpm-alpine
|
||||
RUN addgroup -g 1000 -S user && adduser -u 1000 -D user -G user
|
||||
# Install postgres pdo driver.
|
||||
# RUN apk add --no-cache postgresql-dev && docker-php-ext-install pdo_pgsql
|
||||
|
|
@ -14,17 +14,18 @@ RUN apk add --no-cache \
|
|||
nodejs \
|
||||
npm \
|
||||
imagemagick-dev \
|
||||
chromium
|
||||
chromium \
|
||||
libzip-dev
|
||||
|
||||
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
||||
ENV PUPPETEER_DOCKER=1
|
||||
|
||||
RUN mkdir -p /usr/src/php/ext/imagick
|
||||
RUN chmod 777 /usr/src/php/ext/imagick
|
||||
RUN curl -fsSL https://github.com/Imagick/imagick/archive/refs/tags/3.7.0.tar.gz | tar xvz -C "/usr/src/php/ext/imagick" --strip 1
|
||||
RUN curl -fsSL https://github.com/Imagick/imagick/archive/refs/tags/3.8.0.tar.gz | tar xvz -C "/usr/src/php/ext/imagick" --strip 1
|
||||
|
||||
# Install PHP extensions
|
||||
RUN docker-php-ext-install imagick
|
||||
RUN docker-php-ext-install imagick zip
|
||||
|
||||
RUN rm -f /usr/bin/php83
|
||||
RUN ln -s /usr/local/bin/php /usr/bin/php83
|
||||
RUN rm -f /usr/bin/php84
|
||||
RUN ln -s /usr/local/bin/php /usr/bin/php84
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
/.devcontainer
|
||||
/.github
|
||||
/.phpunit.cache
|
||||
/database/*.sqlite
|
||||
/bootstrap/cache/*
|
||||
/node_modules
|
||||
/public/build
|
||||
/public/hot
|
||||
|
|
@ -6,6 +10,7 @@
|
|||
/storage/*.key
|
||||
/storage/pail
|
||||
/vendor
|
||||
.editorconfig
|
||||
.env
|
||||
.env.backup
|
||||
.env.production
|
||||
|
|
@ -20,5 +25,3 @@ yarn-error.log
|
|||
/.idea
|
||||
/.vscode
|
||||
/.zed
|
||||
/bootstrap/cache/*
|
||||
/database/database.sqlite
|
||||
|
|
|
|||
|
|
@ -68,3 +68,10 @@ VITE_APP_NAME="${APP_NAME}"
|
|||
TRMNL_PROXY_BASE_URL=https://trmnl.app
|
||||
TRMNL_PROXY_REFRESH_MINUTES=15
|
||||
REGISTRATION_ENABLED=1
|
||||
|
||||
PUPPETEER_MODE=
|
||||
SIDECAR_ACCESS_KEY_ID=
|
||||
SIDECAR_SECRET_ACCESS_KEY=
|
||||
SIDECAR_REGION=
|
||||
SIDECAR_ARTIFACT_BUCKET_NAME=
|
||||
SIDECAR_EXECUTION_ROLE=
|
||||
|
|
|
|||
10
.github/workflows/docker-build.yml
vendored
10
.github/workflows/docker-build.yml
vendored
|
|
@ -19,6 +19,10 @@ jobs:
|
|||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Extract version from tag
|
||||
id: get_version
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
|
|
@ -38,8 +42,7 @@ jobs:
|
|||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=tag
|
||||
latest
|
||||
type=semver,pattern={{version}}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
|
|
@ -51,3 +54,6 @@ jobs:
|
|||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
target: production
|
||||
build-args: |
|
||||
APP_VERSION=${{ env.VERSION }}
|
||||
|
|
|
|||
10
.github/workflows/test.yml
vendored
10
.github/workflows/test.yml
vendored
|
|
@ -12,7 +12,7 @@ on:
|
|||
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ubuntu-24.04
|
||||
environment: Testing
|
||||
|
||||
steps:
|
||||
|
|
@ -23,7 +23,6 @@ jobs:
|
|||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: 8.4
|
||||
tools: composer:v2
|
||||
coverage: xdebug
|
||||
|
||||
- name: Setup Node
|
||||
|
|
@ -32,9 +31,6 @@ jobs:
|
|||
node-version: '22'
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install Node Dependencies
|
||||
run: npm i
|
||||
|
||||
- name: Install Dependencies
|
||||
run: composer install --no-interaction --prefer-dist --optimize-autoloader
|
||||
|
||||
|
|
@ -45,7 +41,9 @@ jobs:
|
|||
run: php artisan key:generate
|
||||
|
||||
- name: Build Assets
|
||||
run: npm run build
|
||||
run: |
|
||||
npm ci --no-audit
|
||||
npm run build
|
||||
|
||||
- name: Run Tests
|
||||
run: ./vendor/bin/pest --ci --coverage
|
||||
|
|
|
|||
12
.gitignore
vendored
12
.gitignore
vendored
|
|
@ -22,3 +22,15 @@ yarn-error.log
|
|||
/.vscode
|
||||
/.zed
|
||||
/database/seeders/PersonalDeviceSeeder.php
|
||||
/.junie/mcp/mcp.json
|
||||
/.cursor/mcp.json
|
||||
/.cursor/rules/laravel-boost.mdc
|
||||
/.github/copilot-instructions.md
|
||||
/.junie/guidelines.md
|
||||
/CLAUDE.md
|
||||
/.mcp.json
|
||||
/.ai
|
||||
.DS_Store
|
||||
/boost.json
|
||||
/.gemini
|
||||
/GEMINI.md
|
||||
|
|
|
|||
17
CONTRIBUTING.md
Normal file
17
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
### CONTRIBUTING
|
||||
Contributions are welcome! If you’d like to improve the project, follow these steps:
|
||||
|
||||
1. Open an Issue
|
||||
- Before submitting a pull request, create an issue to discuss your idea.
|
||||
- Clearly describe the feature or bug fix you want to work on.
|
||||
2. Fork the Repository & Create a Branch
|
||||
3. Make Your Changes & Add Tests
|
||||
- Ensure your code follows best practices.
|
||||
- Add Pest tests to cover your changes.
|
||||
4. Run Tests
|
||||
- `php artisan test`
|
||||
5. Submit a Pull Request (PR)
|
||||
- Push your branch and create a PR.
|
||||
- Provide a clear description of your changes.
|
||||
|
||||
Thank you for contributing!
|
||||
76
Dockerfile
76
Dockerfile
|
|
@ -1,37 +1,57 @@
|
|||
FROM bnussbau/php:8.3-fpm-opcache-imagick-puppeteer-alpine3.20
|
||||
########################
|
||||
# Base Image
|
||||
########################
|
||||
FROM bnussbau/serversideup-php:8.4-fpm-nginx-alpine-imagick-chromium@sha256:52ac545fdb57b2ab7568b1c7fc0a98cb1a69a275d8884249778a80914272fa48 AS base
|
||||
|
||||
# Install composer
|
||||
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
||||
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
|
||||
|
||||
# Set working directory
|
||||
ARG APP_VERSION
|
||||
ENV APP_VERSION=${APP_VERSION}
|
||||
|
||||
ENV AUTORUN_ENABLED="true"
|
||||
|
||||
# Mark trmnl-liquid-cli as installed
|
||||
ENV TRMNL_LIQUID_ENABLED=1
|
||||
|
||||
# Switch to the root user so we can do root things
|
||||
USER root
|
||||
|
||||
COPY --chown=www-data:www-data --from=bnussbau/trmnl-liquid-cli:0.1.0 /usr/local/bin/trmnl-liquid-cli /usr/local/bin/
|
||||
|
||||
# Set the working directory
|
||||
WORKDIR /var/www/html
|
||||
|
||||
# Copy configuration files
|
||||
COPY docker/nginx.conf /etc/nginx/http.d/default.conf
|
||||
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
||||
COPY docker/php.ini /usr/local/etc/php/conf.d/custom.ini
|
||||
# Copy the application files
|
||||
COPY --chown=www-data:www-data . /var/www/html
|
||||
COPY --chown=www-data:www-data .env.example .env
|
||||
|
||||
# Create required directories
|
||||
RUN mkdir -p /var/log/supervisor \
|
||||
&& mkdir -p storage/logs \
|
||||
&& mkdir -p storage/framework/{cache,sessions,views} \
|
||||
&& chmod -R 775 storage \
|
||||
&& mkdir -p bootstrap/cache \
|
||||
&& chmod -R 775 bootstrap/cache \
|
||||
&& mkdir -p database \
|
||||
&& touch database/database.sqlite \
|
||||
&& chmod -R 777 database
|
||||
# Install the composer dependencies
|
||||
RUN composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader
|
||||
|
||||
# Copy application files
|
||||
COPY --chown=www-data:www-data . .
|
||||
COPY --chown=www-data:www-data ./.env.example ./.env
|
||||
########################
|
||||
# Assets Image
|
||||
########################
|
||||
FROM node:22-alpine AS assets
|
||||
|
||||
# Install application dependencies
|
||||
RUN composer install --no-interaction --prefer-dist --optimize-autoloader
|
||||
RUN npm ci && npm run build
|
||||
# Copy the application
|
||||
COPY --from=base /var/www/html /app
|
||||
|
||||
# Expose port 80
|
||||
EXPOSE 80
|
||||
# Set the working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Start supervisor
|
||||
CMD ["/usr/bin/supervisord", "-n", "-c", "/etc/supervisor/conf.d/supervisord.conf"]
|
||||
# Install the node dependencies and build the assets
|
||||
RUN npm ci --no-audit \
|
||||
&& npm run build
|
||||
|
||||
########################
|
||||
# Production Image
|
||||
########################
|
||||
FROM base AS production
|
||||
|
||||
# Copy the assets from the assets image
|
||||
COPY --chown=www-data:www-data --from=assets /app/public/build /var/www/html/public/build
|
||||
COPY --chown=www-data:www-data --from=assets /app/node_modules /var/www/html/node_modules
|
||||
# Drop back to the www-data user
|
||||
USER www-data
|
||||
|
|
|
|||
298
README.md
298
README.md
|
|
@ -2,105 +2,136 @@
|
|||
|
||||
[](https://github.com/usetrmnl/byos_laravel/actions/workflows/test.yml)
|
||||
|
||||
Laravel Trmnl Server is a self-hostable implementation of a TRMNL server, built with Laravel.
|
||||
It enables you to manage TRMNL devices, generate screens dynamically, and can act as a proxy for the TRMNL API (native plugin system).
|
||||
Inspired by [usetrmnl/byos_sinatra](https://github.com/usetrmnl/byos_sinatra).
|
||||
|
||||
If you are looking for a Laravel package designed to streamline the development of both public and private TRMNL plugins, check out [bnussbau/trmnl-laravel](https://github.com/bnussbau/laravel-trmnl).
|
||||
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** (120+ from the [OSS community catalog](https://bnussbau.github.io/trmnl-recipe-catalog/), 600+ from the [TRMNL catalog](https://usetrmnl.com/recipes), or your own), or the **API**, and can also act as a **proxy** for the native cloud service (Core). With over 40k downloads and 160+ stars, it’s the most popular community-driven BYOS.
|
||||
|
||||

|
||||

|
||||
|
||||
* 👉 [more Screenshots](screenshots/SCREENSHOTS.md)
|
||||
|
||||
### Key Features
|
||||
|
||||
* 📡 Device Information – Display battery status, WiFi strength, firmware version, and more.
|
||||
* 🔍 Auto-Join – Automatically detects and adds devices from your local network.
|
||||
* 🖥️ Screen Generation – Supports Markup, API, or update via Code.
|
||||
* 🔄 TRMNL API Proxy – Can act as a proxy for the TRMNL Display API (requires TRMNL Developer Edition).
|
||||
* This enables a hybrid setup – for example, you can update your custom Train Monitor every 5 minutes in the morning, while displaying native TRMNL plugins throughout the day.
|
||||
* 📡 Device Information – Display battery status, WiFi strength, firmware version, and more.
|
||||
* 🔍 Auto-Join – Automatically detects and adds devices from your local network.
|
||||
* 🖥️ Screen Generation – Supports Plugins (including Mashups), Recipes, API, Markup, or updates via Code.
|
||||
* Support for TRMNL [Design Framework](https://usetrmnl.com/framework)
|
||||
* Compatible open-source recipes are available in the [community catalog](https://bnussbau.github.io/trmnl-recipe-catalog/)
|
||||
* Import from the [TRMNL community recipe catalog](https://usetrmnl.com/recipes)
|
||||
* Supported Devices
|
||||
* TRMNL OG (1-bit & 2-bit)
|
||||
* SeeedStudio TRMNL 7,5" (OG) DIY Kit
|
||||
* Seeed Studio (XIAO 7.5" ePaper Panel)
|
||||
* reTerminal E1001 Monochrome ePaper Display
|
||||
* 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))
|
||||
* 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)
|
||||
* Raspberry Pi (HDMI output) [trmnl-display](https://github.com/usetrmnl/trmnl-display)
|
||||
* 🔄 TRMNL API Proxy – Can act as a proxy for the native cloud service (requires TRMNL Developer Edition).
|
||||
* This enables a hybrid setup – for example, you can update your custom Train Monitor every 5 minutes in the morning, while displaying native TRMNL plugins throughout the day.
|
||||
* 🌙 Dark Mode – Switch between light and dark mode.
|
||||
* 🐳 Deployment – Dockerized setup for easier hosting (Dockerfile, docker-compose).
|
||||
* 💾 Flexible Database configuration – uses SQLite by default, also compatible with MySQL or PostgreSQL
|
||||
* 🛠️ Devcontainer support for easier development.
|
||||
|
||||
### 🎯 Target Audience
|
||||
|
||||
This project is for developers who are looking for a self-hosted server for devices running the TRMNL firmware.
|
||||
It serves as a starter kit, giving you the flexibility to build and extend it however you like.
|
||||

|
||||
|
||||
### Support ❤️
|
||||
This repo is maintained voluntarily by [@bnussbau](https://github.com/bnussbau).
|
||||
|
||||
Support the development of this package by purchasing a TRMNL device through our referral link: https://usetrmnl.com/?ref=laravel-trmnl. At checkout, use the code `laravel-trmnl` to receive a $15 discount on your purchase.
|
||||
Support the development of this package by purchasing a TRMNL device through the referral link: https://usetrmnl.com/?ref=laravel-trmnl. At checkout, use the code `laravel-trmnl` to receive a $15 discount on your purchase.
|
||||
|
||||
### Requirements
|
||||
or
|
||||
|
||||
[](https://www.buymeacoffee.com/bnussbau)
|
||||
|
||||
[GitHub Sponsors](https://github.com/sponsors/bnussbau/)
|
||||
|
||||
### Hosting
|
||||
|
||||
Run everywhere, where Docker is supported: Raspberry Pi, VPS, NAS, Container Cloud Service (Cloud Run, ...).
|
||||
For production use, generate a new APP_KEY (`php artisan key:generate --show`) and set the environment variable `APP_KEY=`. For personal use, you can disable registration (see section Environment Variables).
|
||||
|
||||
#### Docker Compose
|
||||
Docker Compose file located at: [docker/prod/docker-compose.yml](docker/prod/docker-compose.yml).
|
||||
|
||||
##### Backup Database
|
||||
```sh
|
||||
docker ps #find container id of byos_laravel container
|
||||
docker cp {{CONTAINER_ID}}:/var/www/html/database/storage/database.sqlite database_backup.sqlite
|
||||
```
|
||||
|
||||
##### Updating via Docker Compose
|
||||
```sh
|
||||
docker compose pull
|
||||
docker compose down
|
||||
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).
|
||||
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)
|
||||
|
||||
#### Umbrel
|
||||
Umbrel is supported through a community store, [see](http://github.com/bnussbau/umbrel-store).
|
||||
|
||||
#### Other Hosting Options
|
||||
Laravel Forge, or bare metal PHP server with Nginx or Apache is also supported.
|
||||
|
||||
#### Requirements
|
||||
|
||||
* PHP >= 8.2
|
||||
* ext-imagick
|
||||
* puppeteer [see Browsershot docs](https://spatie.be/docs/browsershot/v4/requirements)
|
||||
|
||||
### Installation
|
||||
### Local Development
|
||||
|
||||
#### Clone the repository
|
||||
see [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md)
|
||||
|
||||
|
||||
### Demo Plugins
|
||||
|
||||
Run the ExampleRecipesSeeder to seed the database with example plugins:
|
||||
|
||||
```bash
|
||||
git clone git@github.com:usetrmnl/byos_laravel.git
|
||||
php artisan db:seed --class=ExampleRecipesSeeder
|
||||
```
|
||||
|
||||
#### Copy environment file
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
php artisan key:generate
|
||||
```
|
||||
|
||||
#### Install dependencies
|
||||
|
||||
```bash
|
||||
composer install
|
||||
npm i
|
||||
```
|
||||
|
||||
#### Run migrations
|
||||
|
||||
```bash
|
||||
php artisan migrate --seed
|
||||
```
|
||||
|
||||
#### Run the server
|
||||
|
||||
To make your server accessible in the network, you can run the following command:
|
||||
|
||||
```bash
|
||||
php artisan serve --host=0.0.0.0 --port 4567
|
||||
```
|
||||
|
||||
### Docker
|
||||
Use the provided Dockerfile, or docker-compose file to run the server in a container.
|
||||
You can persist the database file by mounting a volume to `/var/www/html/database/database.sqlite`.
|
||||
|
||||
```Dockerfile
|
||||
# docker-compose.yaml
|
||||
volumes:
|
||||
- ./database/database.sqlite:/var/www/html/database/database.sqlite
|
||||
```
|
||||
* Zen Quotes
|
||||
* This Day in History
|
||||
* Weather
|
||||
* Train Departure Monitor
|
||||
* Home Assistant
|
||||
* Sunrise/Sunset
|
||||
|
||||
### Usage
|
||||
|
||||
#### Environment Variables
|
||||
|
||||
| environment | description | default |
|
||||
|-------------------------------|------------------------------------------------------------------|-------------------|
|
||||
| `TRMNL_PROXY_BASE_URL` | Base URL of the native TRMNL service | https://trmnl.app |
|
||||
| `TRMNL_PROXY_REFRESH_MINUTES` | How often should the server fetch new images from native service | 15 |
|
||||
| `REGISTRATION_ENABLED` | Allow user registration via Webinterface | 1 |
|
||||
| `FORCE_HTTPS` | If your server handles SSL termination, enforce HTTPS. | 0 |
|
||||
| Environment Variable | Description | Default |
|
||||
|-------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------|-------------------|
|
||||
| `TRMNL_PROXY_BASE_URL` | Base URL of the native TRMNL service | https://trmnl.app |
|
||||
| `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 |
|
||||
| `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 |
|
||||
|
||||
#### Login
|
||||
|
||||
If your environment is local, you can access the server at `http://localhost:4567` and login with user / password
|
||||
`admin@example.com` / `admin@example.com`, otherwise register. With environment variable `REGISTRATION_ENABLED` you can control, if registration is allowed.
|
||||
|
||||
#### ➕ Add Your TRMNL Device
|
||||
### ➕ Add Your TRMNL Device
|
||||
|
||||
##### Auto-Join (Local Network)
|
||||
|
||||
|
|
@ -112,11 +143,12 @@ If your environment is local, you can access the server at `http://localhost:456
|
|||
##### Manually
|
||||
|
||||
1. Open the Devices page:
|
||||
👉 http://localhost:4567/devices
|
||||
👉 http://localhost:4567/devices
|
||||
2. Click “Add New Device”.
|
||||
3. Retrieve your TRMNL MAC Address and API Key:
|
||||
- You can grab the TRMNL Mac Address and API Key from the TRMNL Dashboard
|
||||
- Alternatively, debug incoming requests to /api/setup to determine them
|
||||
- You can grab the TRMNL Mac Address and API Key from the TRMNL Dashboard
|
||||
- Alternatively, debug incoming requests to /api/setup to determine them
|
||||
|
||||
|
||||
### ⚙️ Configure Server for Device
|
||||
|
||||
|
|
@ -132,11 +164,37 @@ If your device firmware is older than 1.4.6, you need to flash a new firmware ve
|
|||
|
||||
See this YouTube guide: [https://www.youtube.com/watch?v=3xehPW-PCOM](https://www.youtube.com/watch?v=3xehPW-PCOM)
|
||||
|
||||
### ☁️ 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"
|
||||
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
|
||||
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.
|
||||
|
||||
###### Troubleshooting
|
||||
|
||||
Make sure that your device has a Developer license, you should be able to verify by calling the `https://trmnl.app/api/display` endpoint.
|
||||
|
||||
* [https://docs.usetrmnl.com/go/private-api/introduction](https://docs.usetrmnl.com/go/private-api/introduction)
|
||||
* [https://docs.usetrmnl.com/go/private-api/fetch-screen-content](https://docs.usetrmnl.com/go/private-api/fetch-screen-content)
|
||||
|
||||
### 🖥️ Generate Screens
|
||||
|
||||
#### Markup via Web Interface
|
||||
|
||||
1. Navigate to Plugins > Markup in the Web Interface.
|
||||
2. Enter your markup manually or select from the available templates.
|
||||
3. Save and apply the changes.
|
||||
|
||||
* Available Blade Components are listed here: [laravel-trmnl-blade | Blade Components](https://github.com/bnussbau/laravel-trmnl-blade/tree/main/resources/views/components)
|
||||
|
||||
#### 🎨 Blade View
|
||||
* Edit `resources/views/trmnl.blade.php`
|
||||
* Available Blade Components are listed here: [laravel-trmnl | Blade Components](https://github.com/bnussbau/laravel-trmnl/tree/main/resources/views/components)
|
||||
* Available Blade Components are listed here: [laravel-trmnl-blade | Blade Components](https://github.com/bnussbau/laravel-trmnl-blade/tree/main/resources/views/components)
|
||||
* To generate the screen, run
|
||||
|
||||
```bash
|
||||
|
|
@ -160,109 +218,15 @@ You can dynamically update screens by sending a POST request.
|
|||
}
|
||||
```
|
||||
|
||||
Token can be retrieved under Plugins > API in the Web Interface.
|
||||
### Releated Work
|
||||
* [bnussbau/laravel-trmnl-blade](https://github.com/bnussbau/laravel-trmnl-blade) – Blade Components on top of the TRMNL Design System
|
||||
* [bnussbau/trmnl-pipeline-php](https://github.com/bnussbau/trmnl-pipeline-php) – Browser Rendering and Image Conversion Pipeline with support for TRMNL Models API
|
||||
* [bnussbau/trmnl-recipe-catalog](https://github.com/bnussbau/trmnl-recipe-catalog) – A community-driven catalog of public repositories containing trmnlp-compatible recipes.
|
||||
|
||||
#### Markup via Web Interface
|
||||
|
||||
1. Navigate to Plugins > Markup in the Web Interface.
|
||||
2. Enter your markup manually or select from the available templates.
|
||||
3. Save and apply the changes.
|
||||
|
||||
* Available Blade Components are listed here: [laravel-trmnl | Blade Components](https://github.com/bnussbau/laravel-trmnl/tree/main/resources/views/components)
|
||||
|
||||
#### 🛠️ Generate Screens Programmatically
|
||||
|
||||
You can fetch external data, process it, and generate screens dynamically.
|
||||
* Fetch data from an external source.
|
||||
* Either render it in a Blade view or directly insert markup.
|
||||
* Use Laravel’s scheduler to automate updates.
|
||||
|
||||
#### 📌 Example: Fetch Train Monitor Data
|
||||
|
||||
This example retrieves data from [trmnl-train-monitor](https://github.com/bnussbau/trmnl-train-monitor) and updates the screen periodically.
|
||||
|
||||
##### Step 1: Create a new Artisan Command
|
||||
|
||||
```bash
|
||||
php artisan make:command PluginTrainMonitorFetch
|
||||
```
|
||||
|
||||
##### Step 2: Edit PluginTrainMonitorFetch.php
|
||||
|
||||
```php
|
||||
class PluginTrainMonitorFetch extends Command
|
||||
{
|
||||
protected $signature = 'plugin:train:fetch';
|
||||
|
||||
protected $description = 'Fetches train monitor data and updates the screen';
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$markup = Http::get('https://oebb.trmnl.yourserver.at/markup')->json('markup');
|
||||
GenerateScreenJob::dispatchSync(1, $markup);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
##### Step 3: Schedule the Command in console.php
|
||||
|
||||
```php
|
||||
Schedule::command('plugin:train:fetch')
|
||||
->everyFiveMinutes()
|
||||
->timezone('Europe/Vienna')
|
||||
->between('5:00', '18:00');
|
||||
```
|
||||
|
||||
This will automatically update the screen every 5 minutes between 5:00 AM and 6:00 PM local time.
|
||||
|
||||
### 🏗️ Roadmap
|
||||
|
||||
Here are some features and improvements that are open for contribution:
|
||||
|
||||
##### 🔌 Plugin System
|
||||
|
||||
- Enable configurable plugins via the Web Interface.
|
||||
- Ensure compatibility with the trmnl-laravel package.
|
||||
- Implement auto-discovery for plugins.
|
||||
|
||||
##### ⏳ Scheduling
|
||||
|
||||
- Move task scheduling from console.php to a Web Interface.
|
||||
- Allow users to configure custom schedule intervals.
|
||||
|
||||
##### 🖥️ “Native” Plugins
|
||||
- Add built-in plugins such as (as an example):
|
||||
- ☁️ Weather
|
||||
- 💬 Quotes
|
||||
- 🏡 Home Assistant integration
|
||||
- Provide Web UI controls to enable/disable plugins.
|
||||
|
||||
##### 📦 Visual Studio Code Devcontainer
|
||||
* ~~Add a .devcontainer to this repo for easier development with Docker.~~ ✅
|
||||
|
||||
##### Improve Code Coverage
|
||||
|
||||
- Expand Pest tests to cover more functionality.
|
||||
- Increase code coverage (currently at 86.9%).
|
||||
|
||||
### 🤝 Contribution
|
||||
Contributions are welcome! If you’d like to improve the project, follow these steps:
|
||||
|
||||
1. Open an Issue
|
||||
- Before submitting a pull request, create an issue to discuss your idea.
|
||||
- Clearly describe the feature or bug fix you want to work on.
|
||||
2. Fork the Repository & Create a Branch
|
||||
3. Make Your Changes & Add Tests
|
||||
- Ensure your code follows best practices.
|
||||
- Add Pest tests to cover your changes.
|
||||
4. Run Tests
|
||||
- `php artisan test`
|
||||
5. Submit a Pull Request (PR)
|
||||
- Push your branch and create a PR.
|
||||
- Provide a clear description of your changes.
|
||||
|
||||
Thank you for contributing!
|
||||
Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for details.
|
||||
|
||||
### License
|
||||
MIT
|
||||
[MIT](LICENSE.md)
|
||||
|
||||
|
|
|
|||
BIN
README_byos-devices.jpeg
Normal file
BIN
README_byos-devices.jpeg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
BIN
README_byos-screenshot-dark.png
Normal file
BIN
README_byos-screenshot-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 207 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 210 KiB After Width: | Height: | Size: 205 KiB |
20
app/Console/Commands/ExampleRecipesSeederCommand.php
Normal file
20
app/Console/Commands/ExampleRecipesSeederCommand.php
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Database\Seeders\ExampleRecipesSeeder;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Contracts\Console\PromptsForMissingInput;
|
||||
|
||||
class ExampleRecipesSeederCommand extends Command implements PromptsForMissingInput
|
||||
{
|
||||
protected $signature = 'recipes:seed {user_id}';
|
||||
|
||||
protected $description = 'Seed example recipes';
|
||||
|
||||
public function handle(ExampleRecipesSeeder $seeder): void
|
||||
{
|
||||
$user_id = $this->argument('user_id');
|
||||
$seeder->run($user_id);
|
||||
}
|
||||
}
|
||||
46
app/Console/Commands/FetchDeviceModelsCommand.php
Normal file
46
app/Console/Commands/FetchDeviceModelsCommand.php
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\FetchDeviceModelsJob;
|
||||
use Exception;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
final class FetchDeviceModelsCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'device-models:fetch';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Fetch device models from the TRMNL API and update the database';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$this->info('Dispatching FetchDeviceModelsJob...');
|
||||
|
||||
try {
|
||||
FetchDeviceModelsJob::dispatchSync();
|
||||
|
||||
$this->info('FetchDeviceModelsJob has been dispatched successfully.');
|
||||
|
||||
return self::SUCCESS;
|
||||
} catch (Exception $e) {
|
||||
$this->error('Failed to dispatch FetchDeviceModelsJob: '.$e->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
38
app/Console/Commands/FirmwareCheckCommand.php
Normal file
38
app/Console/Commands/FirmwareCheckCommand.php
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\FirmwarePollJob;
|
||||
use App\Models\Firmware;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
use function Laravel\Prompts\spin;
|
||||
use function Laravel\Prompts\table;
|
||||
|
||||
class FirmwareCheckCommand extends Command
|
||||
{
|
||||
protected $signature = 'trmnl:firmware:check {--download : Download the latest firmware if available}';
|
||||
|
||||
protected $description = 'Checks for the latest firmware and downloads it if flag --download is passed.';
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
spin(
|
||||
callback: fn () => FirmwarePollJob::dispatchSync(download: $this->option('download')),
|
||||
message: 'Checking for latest firmware...'
|
||||
);
|
||||
|
||||
$latestFirmware = Firmware::getLatest();
|
||||
if ($latestFirmware instanceof Firmware) {
|
||||
table(
|
||||
rows: [
|
||||
['Latest Version', $latestFirmware->version_tag],
|
||||
['Download URL', $latestFirmware->url],
|
||||
['Storage Location', $latestFirmware->storage_location],
|
||||
]
|
||||
);
|
||||
} else {
|
||||
$this->error('No firmware found.');
|
||||
}
|
||||
}
|
||||
}
|
||||
70
app/Console/Commands/FirmwareUpdateCommand.php
Normal file
70
app/Console/Commands/FirmwareUpdateCommand.php
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Device;
|
||||
use App\Models\Firmware;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
use function Laravel\Prompts\multiselect;
|
||||
use function Laravel\Prompts\select;
|
||||
|
||||
class FirmwareUpdateCommand extends Command
|
||||
{
|
||||
protected $signature = 'trmnl:firmware:update';
|
||||
|
||||
protected $description = 'Command description';
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
|
||||
$checkFirmware = select(
|
||||
label: 'Check for new firmware?',
|
||||
options: [
|
||||
'check' => 'Check. Devices will download binary from the original source.',
|
||||
'download' => 'Check & Download. Devices will download binary from BYOS.',
|
||||
'no' => 'Do not check.',
|
||||
],
|
||||
);
|
||||
|
||||
if ($checkFirmware !== 'no') {
|
||||
$this->call('trmnl:firmware:check', [
|
||||
'--download' => $checkFirmware === 'download',
|
||||
]);
|
||||
}
|
||||
|
||||
$firmwareVersion = select(
|
||||
label: 'Update to which version?',
|
||||
options: Firmware::pluck('version_tag', 'id')
|
||||
);
|
||||
|
||||
$devices = multiselect(
|
||||
label: 'Which devices should be updated?',
|
||||
options: [
|
||||
'all' => 'ALL Devices',
|
||||
...Device::all()->mapWithKeys(fn ($device): array =>
|
||||
// without _ returns index
|
||||
["_$device->id" => "$device->name (Current version: $device->last_firmware_version)"])->toArray(),
|
||||
],
|
||||
scroll: 10
|
||||
);
|
||||
|
||||
if ($devices === []) {
|
||||
$this->error('No devices selected. Aborting.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (in_array('all', $devices)) {
|
||||
$devices = Device::pluck('id')->toArray();
|
||||
} else {
|
||||
$devices = array_map(fn ($selected): int => (int) str_replace('_', '', $selected), $devices);
|
||||
}
|
||||
|
||||
foreach ($devices as $deviceId) {
|
||||
Device::find($deviceId)->update(['update_firmware_id' => $firmwareVersion]);
|
||||
|
||||
$this->info("Device with id [$deviceId] will update firmware on next request.");
|
||||
}
|
||||
}
|
||||
}
|
||||
201
app/Console/Commands/GenerateDefaultImagesCommand.php
Normal file
201
app/Console/Commands/GenerateDefaultImagesCommand.php
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\DeviceModel;
|
||||
use Bnussbau\TrmnlPipeline\Stages\BrowserStage;
|
||||
use Bnussbau\TrmnlPipeline\Stages\ImageStage;
|
||||
use Bnussbau\TrmnlPipeline\TrmnlPipeline;
|
||||
use Exception;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use Wnx\SidecarBrowsershot\BrowsershotLambda;
|
||||
|
||||
class GenerateDefaultImagesCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'images:generate-defaults {--force : Force regeneration of existing images}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Generate default images (setup-logo and sleep) for all device models from Blade templates';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$this->info('Starting generation of default images for all device models...');
|
||||
|
||||
$deviceModels = DeviceModel::all();
|
||||
|
||||
if ($deviceModels->isEmpty()) {
|
||||
$this->warn('No device models found in the database.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Found {$deviceModels->count()} device models to process.");
|
||||
|
||||
// Create the target directory
|
||||
$targetDir = 'images/default-screens';
|
||||
if (! Storage::disk('public')->exists($targetDir)) {
|
||||
Storage::disk('public')->makeDirectory($targetDir);
|
||||
$this->info("Created directory: {$targetDir}");
|
||||
}
|
||||
|
||||
$successCount = 0;
|
||||
$skipCount = 0;
|
||||
$errorCount = 0;
|
||||
|
||||
foreach ($deviceModels as $deviceModel) {
|
||||
$this->info("Processing device model: {$deviceModel->label} (ID: {$deviceModel->id})");
|
||||
|
||||
try {
|
||||
// Process setup-logo
|
||||
$setupResult = $this->transformImage('setup-logo', $deviceModel, $targetDir);
|
||||
if ($setupResult) {
|
||||
++$successCount;
|
||||
} else {
|
||||
++$skipCount;
|
||||
}
|
||||
|
||||
// Process sleep
|
||||
$sleepResult = $this->transformImage('sleep', $deviceModel, $targetDir);
|
||||
if ($sleepResult) {
|
||||
++$successCount;
|
||||
} else {
|
||||
++$skipCount;
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
$this->error("Error processing device model {$deviceModel->label}: ".$e->getMessage());
|
||||
++$errorCount;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("\nGeneration completed!");
|
||||
$this->info("Successfully processed: {$successCount} images");
|
||||
$this->info("Skipped (already exist): {$skipCount} images");
|
||||
$this->info("Errors: {$errorCount} images");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a single image for a device model using Blade templates
|
||||
*/
|
||||
private function transformImage(string $imageType, DeviceModel $deviceModel, string $targetDir): bool
|
||||
{
|
||||
// Generate filename: {width}_{height}_{bit_depth}_{rotation}.{extension}
|
||||
$extension = $deviceModel->mime_type === 'image/bmp' ? 'bmp' : 'png';
|
||||
$filename = "{$deviceModel->width}_{$deviceModel->height}_{$deviceModel->bit_depth}_{$deviceModel->rotation}.{$extension}";
|
||||
$targetPath = "{$targetDir}/{$imageType}_{$filename}";
|
||||
|
||||
// Check if target already exists and force is not set
|
||||
if (Storage::disk('public')->exists($targetPath) && ! $this->option('force')) {
|
||||
$this->line(" Skipping {$imageType} - already exists: {$filename}");
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Create custom Browsershot instance if using AWS Lambda
|
||||
$browsershotInstance = null;
|
||||
if (config('app.puppeteer_mode') === 'sidecar-aws') {
|
||||
$browsershotInstance = new BrowsershotLambda();
|
||||
}
|
||||
|
||||
// Generate HTML from Blade template
|
||||
$html = $this->generateHtmlFromTemplate($imageType, $deviceModel);
|
||||
// dump($html);
|
||||
|
||||
$browserStage = new BrowserStage($browsershotInstance);
|
||||
$browserStage->html($html);
|
||||
|
||||
// Set timezone from app config (no user context in this command)
|
||||
$browserStage->timezone(config('app.timezone'));
|
||||
|
||||
$browserStage
|
||||
->width($deviceModel->width)
|
||||
->height($deviceModel->height);
|
||||
|
||||
$browserStage->setBrowsershotOption('waitUntil', 'networkidle0');
|
||||
|
||||
if (config('app.puppeteer_docker')) {
|
||||
$browserStage->setBrowsershotOption('args', ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu']);
|
||||
}
|
||||
|
||||
$outputPath = Storage::disk('public')->path($targetPath);
|
||||
|
||||
$imageStage = new ImageStage();
|
||||
$imageStage->format($extension)
|
||||
->width($deviceModel->width)
|
||||
->height($deviceModel->height)
|
||||
->colors($deviceModel->colors)
|
||||
->bitDepth($deviceModel->bit_depth)
|
||||
->rotation($deviceModel->rotation)
|
||||
// ->offsetX($deviceModel->offset_x)
|
||||
// ->offsetY($deviceModel->offset_y)
|
||||
->outputPath($outputPath);
|
||||
|
||||
(new TrmnlPipeline())->pipe($browserStage)
|
||||
->pipe($imageStage)
|
||||
->process();
|
||||
|
||||
if (! file_exists($outputPath)) {
|
||||
throw new RuntimeException('Image file was not created: '.$outputPath);
|
||||
}
|
||||
|
||||
if (filesize($outputPath) === 0) {
|
||||
throw new RuntimeException('Image file is empty: '.$outputPath);
|
||||
}
|
||||
|
||||
$this->line(" ✓ Generated {$imageType}: {$filename}");
|
||||
|
||||
return true;
|
||||
|
||||
} catch (Exception $e) {
|
||||
$this->error(" ✗ Failed to generate {$imageType} for {$deviceModel->label}: ".$e->getMessage());
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate HTML from Blade template for the given image type and device model
|
||||
*/
|
||||
private function generateHtmlFromTemplate(string $imageType, DeviceModel $deviceModel): string
|
||||
{
|
||||
// Map image type to template name
|
||||
$templateName = match ($imageType) {
|
||||
'setup-logo' => 'default-screens.setup',
|
||||
'sleep' => 'default-screens.sleep',
|
||||
default => throw new InvalidArgumentException("Invalid image type: {$imageType}")
|
||||
};
|
||||
|
||||
// Determine device properties from DeviceModel
|
||||
$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
|
||||
|
||||
// Render the Blade template
|
||||
return view($templateName, [
|
||||
'noBleed' => false,
|
||||
'darkMode' => $darkMode,
|
||||
'deviceVariant' => $deviceVariant,
|
||||
'colorDepth' => $colorDepth,
|
||||
'scaleLevel' => $scaleLevel,
|
||||
])->render();
|
||||
}
|
||||
}
|
||||
175
app/Console/Commands/MashupCreateCommand.php
Normal file
175
app/Console/Commands/MashupCreateCommand.php
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Device;
|
||||
use App\Models\Playlist;
|
||||
use App\Models\PlaylistItem;
|
||||
use App\Models\Plugin;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class MashupCreateCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'mashup:create';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Create a new mashup and add it to a playlist';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
// Select device
|
||||
$device = $this->selectDevice();
|
||||
if (! $device instanceof Device) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Select playlist
|
||||
$playlist = $this->selectPlaylist($device);
|
||||
if (! $playlist instanceof Playlist) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Select mashup layout
|
||||
$layout = $this->selectLayout();
|
||||
if (! $layout) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Get mashup name
|
||||
$name = $this->getMashupName();
|
||||
if (! $name) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Select plugins
|
||||
$plugins = $this->selectPlugins($layout);
|
||||
if ($plugins->isEmpty()) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
$maxOrder = $playlist->items()->max('order') ?? 0;
|
||||
|
||||
// Create playlist item with mashup
|
||||
PlaylistItem::createMashup(
|
||||
playlist: $playlist,
|
||||
layout: $layout,
|
||||
pluginIds: $plugins->pluck('id')->toArray(),
|
||||
name: $name,
|
||||
order: $maxOrder + 1
|
||||
);
|
||||
|
||||
$this->info('Mashup created successfully!');
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
protected function selectDevice(): ?Device
|
||||
{
|
||||
$devices = Device::all();
|
||||
if ($devices->isEmpty()) {
|
||||
$this->error('No devices found. Please create a device first.');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$deviceId = $this->choice(
|
||||
'Select a device',
|
||||
$devices->mapWithKeys(fn ($device): array => [$device->id => $device->name])->toArray()
|
||||
);
|
||||
|
||||
return $devices->firstWhere('id', $deviceId);
|
||||
}
|
||||
|
||||
protected function selectPlaylist(Device $device): ?Playlist
|
||||
{
|
||||
/** @var Collection|Playlist[] $playlists */
|
||||
$playlists = $device->playlists;
|
||||
if ($playlists->isEmpty()) {
|
||||
$this->error('No playlists found for this device. Please create a playlist first.');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$playlistId = $this->choice(
|
||||
'Select a playlist',
|
||||
$playlists->mapWithKeys(fn (Playlist $playlist): array => [$playlist->id => $playlist->name])->toArray()
|
||||
);
|
||||
|
||||
return $playlists->firstWhere('id', $playlistId);
|
||||
}
|
||||
|
||||
protected function selectLayout(): ?string
|
||||
{
|
||||
return $this->choice(
|
||||
'Select a layout',
|
||||
PlaylistItem::getAvailableLayouts()
|
||||
);
|
||||
}
|
||||
|
||||
protected function getMashupName(): ?string
|
||||
{
|
||||
$name = $this->ask('Enter a name for this mashup', 'Mashup');
|
||||
|
||||
if (mb_strlen((string) $name) < 2) {
|
||||
$this->error('The name must be at least 2 characters.');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (mb_strlen((string) $name) > 50) {
|
||||
$this->error('The name must not exceed 50 characters.');
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return $name;
|
||||
}
|
||||
|
||||
protected function selectPlugins(string $layout): Collection
|
||||
{
|
||||
$requiredCount = PlaylistItem::getRequiredPluginCountForLayout($layout);
|
||||
|
||||
$plugins = Plugin::all();
|
||||
if ($plugins->isEmpty()) {
|
||||
$this->error('No plugins found. Please create some plugins first.');
|
||||
|
||||
return collect();
|
||||
}
|
||||
|
||||
$selectedPlugins = collect();
|
||||
$availablePlugins = $plugins->mapWithKeys(fn ($plugin): array => [$plugin->id => $plugin->name])->toArray();
|
||||
|
||||
for ($i = 0; $i < $requiredCount; ++$i) {
|
||||
$position = match ($i) {
|
||||
0 => 'first',
|
||||
1 => 'second',
|
||||
2 => 'third',
|
||||
3 => 'fourth',
|
||||
default => ($i + 1).'th'
|
||||
};
|
||||
|
||||
$pluginId = $this->choice(
|
||||
"Select the $position plugin",
|
||||
$availablePlugins
|
||||
);
|
||||
|
||||
$selectedPlugins->push($plugins->firstWhere('id', $pluginId));
|
||||
unset($availablePlugins[$pluginId]);
|
||||
}
|
||||
|
||||
return $selectedPlugins;
|
||||
}
|
||||
}
|
||||
104
app/Console/Commands/OidcTestCommand.php
Normal file
104
app/Console/Commands/OidcTestCommand.php
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Exception;
|
||||
use Illuminate\Console\Command;
|
||||
use InvalidArgumentException;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
|
||||
class OidcTestCommand extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'oidc:test';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Test OIDC configuration and driver registration';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$this->info('Testing OIDC Configuration...');
|
||||
$this->newLine();
|
||||
|
||||
// Check if OIDC is enabled
|
||||
$enabled = config('services.oidc.enabled');
|
||||
$this->line('OIDC Enabled: '.($enabled ? '✅ Yes' : '❌ No'));
|
||||
|
||||
// Check configuration values
|
||||
$endpoint = config('services.oidc.endpoint');
|
||||
$clientId = config('services.oidc.client_id');
|
||||
$clientSecret = config('services.oidc.client_secret');
|
||||
$redirect = config('services.oidc.redirect');
|
||||
if (! $redirect) {
|
||||
$redirect = config('app.url', 'http://localhost').'/auth/oidc/callback';
|
||||
}
|
||||
$scopes = config('services.oidc.scopes', []);
|
||||
$defaultScopes = ['openid', 'profile', 'email'];
|
||||
$effectiveScopes = empty($scopes) ? $defaultScopes : $scopes;
|
||||
|
||||
$this->line('OIDC Endpoint: '.($endpoint ? "✅ {$endpoint}" : '❌ Not set'));
|
||||
$this->line('Client ID: '.($clientId ? "✅ {$clientId}" : '❌ Not set'));
|
||||
$this->line('Client Secret: '.($clientSecret ? '✅ Set' : '❌ Not set'));
|
||||
$this->line('Redirect URL: '.($redirect ? "✅ {$redirect}" : '❌ Not set'));
|
||||
$this->line('Scopes: ✅ '.implode(', ', $effectiveScopes));
|
||||
|
||||
$this->newLine();
|
||||
|
||||
// Test driver registration
|
||||
try {
|
||||
// Only test driver if we have basic configuration
|
||||
if ($endpoint && $clientId && $clientSecret) {
|
||||
$driver = Socialite::driver('oidc');
|
||||
$this->line('OIDC Driver: ✅ Successfully registered and accessible');
|
||||
|
||||
if ($enabled) {
|
||||
$this->info('✅ OIDC is fully configured and ready to use!');
|
||||
$this->line('You can test the login flow at: /auth/oidc/redirect');
|
||||
} else {
|
||||
$this->warn('⚠️ OIDC driver is working but OIDC_ENABLED is false.');
|
||||
}
|
||||
} else {
|
||||
$this->line('OIDC Driver: ✅ Registered (configuration test skipped due to missing values)');
|
||||
$this->warn('⚠️ OIDC driver is registered but missing required configuration.');
|
||||
$this->line('Please set the following environment variables:');
|
||||
if (! $enabled) {
|
||||
$this->line(' - OIDC_ENABLED=true');
|
||||
}
|
||||
if (! $endpoint) {
|
||||
$this->line(' - OIDC_ENDPOINT=https://your-oidc-provider.com (base URL)');
|
||||
$this->line(' OR');
|
||||
$this->line(' - OIDC_ENDPOINT=https://your-oidc-provider.com/.well-known/openid-configuration (full URL)');
|
||||
}
|
||||
if (! $clientId) {
|
||||
$this->line(' - OIDC_CLIENT_ID=your-client-id');
|
||||
}
|
||||
if (! $clientSecret) {
|
||||
$this->line(' - OIDC_CLIENT_SECRET=your-client-secret');
|
||||
}
|
||||
}
|
||||
} catch (InvalidArgumentException $e) {
|
||||
if (str_contains($e->getMessage(), 'Driver [oidc] not supported')) {
|
||||
$this->error('❌ OIDC Driver registration failed: Driver not supported');
|
||||
} else {
|
||||
$this->error('❌ OIDC Driver error: '.$e->getMessage());
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
$this->warn('⚠️ OIDC Driver registered but configuration error: '.$e->getMessage());
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
|
||||
return Command::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ namespace App\Console\Commands;
|
|||
|
||||
use App\Jobs\GenerateScreenJob;
|
||||
use Illuminate\Console\Command;
|
||||
use Throwable;
|
||||
|
||||
class ScreenGeneratorCommand extends Command
|
||||
{
|
||||
|
|
@ -24,20 +25,19 @@ class ScreenGeneratorCommand extends Command
|
|||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
public function handle(): int
|
||||
{
|
||||
$deviceId = $this->argument('deviceId');
|
||||
$view = $this->argument('view');
|
||||
|
||||
try {
|
||||
$markup = view($view)->render();
|
||||
} catch (\Throwable $e) {
|
||||
} catch (Throwable $e) {
|
||||
$this->error('Failed to render view: '.$e->getMessage());
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
GenerateScreenJob::dispatchSync($deviceId, $markup);
|
||||
GenerateScreenJob::dispatchSync($deviceId, null, $markup);
|
||||
|
||||
$this->info('Screen generation job finished.');
|
||||
|
||||
|
|
|
|||
23
app/Enums/ImageFormat.php
Normal file
23
app/Enums/ImageFormat.php
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum ImageFormat: string
|
||||
{
|
||||
case AUTO = 'auto';
|
||||
case PNG_8BIT_GRAYSCALE = 'png_8bit_grayscale';
|
||||
case BMP3_1BIT_SRGB = 'bmp3_1bit_srgb';
|
||||
case PNG_8BIT_256C = 'png_8bit_256c';
|
||||
case PNG_2BIT_4C = 'png_2bit_4c';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::AUTO => 'Auto',
|
||||
self::PNG_8BIT_GRAYSCALE => 'PNG 8-bit Grayscale Gray 2c',
|
||||
self::BMP3_1BIT_SRGB => 'BMP3 1-bit sRGB 2c',
|
||||
self::PNG_8BIT_256C => 'PNG 8-bit Grayscale Gray 256c',
|
||||
self::PNG_2BIT_4C => 'PNG 2-bit Grayscale 4c',
|
||||
};
|
||||
}
|
||||
}
|
||||
123
app/Http/Controllers/Auth/OidcController.php
Normal file
123
app/Http/Controllers/Auth/OidcController.php
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Auth;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\User;
|
||||
use Exception;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
|
||||
class OidcController extends Controller
|
||||
{
|
||||
/**
|
||||
* Redirect the user to the OIDC provider authentication page.
|
||||
*/
|
||||
public function redirect()
|
||||
{
|
||||
if (! config('services.oidc.enabled')) {
|
||||
return redirect()->route('login')->withErrors(['oidc' => 'OIDC authentication is not enabled.']);
|
||||
}
|
||||
|
||||
// Check if all required OIDC configuration is present
|
||||
$requiredConfig = ['endpoint', 'client_id', 'client_secret'];
|
||||
foreach ($requiredConfig as $key) {
|
||||
if (! config("services.oidc.{$key}")) {
|
||||
Log::error("OIDC configuration missing: {$key}");
|
||||
|
||||
return redirect()->route('login')->withErrors(['oidc' => 'OIDC is not properly configured.']);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return Socialite::driver('oidc')->redirect();
|
||||
} catch (Exception $e) {
|
||||
Log::error('OIDC redirect error: '.$e->getMessage());
|
||||
|
||||
return redirect()->route('login')->withErrors(['oidc' => 'Failed to initiate OIDC authentication.']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Obtain the user information from the OIDC provider.
|
||||
*/
|
||||
public function callback(Request $request)
|
||||
{
|
||||
if (! config('services.oidc.enabled')) {
|
||||
return redirect()->route('login')->withErrors(['oidc' => 'OIDC authentication is not enabled.']);
|
||||
}
|
||||
|
||||
// Check if all required OIDC configuration is present
|
||||
$requiredConfig = ['endpoint', 'client_id', 'client_secret'];
|
||||
foreach ($requiredConfig as $key) {
|
||||
if (! config("services.oidc.{$key}")) {
|
||||
Log::error("OIDC configuration missing: {$key}");
|
||||
|
||||
return redirect()->route('login')->withErrors(['oidc' => 'OIDC is not properly configured.']);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$oidcUser = Socialite::driver('oidc')->user();
|
||||
|
||||
// Find or create the user
|
||||
$user = $this->findOrCreateUser($oidcUser);
|
||||
|
||||
// Log the user in
|
||||
Auth::login($user, true);
|
||||
|
||||
return redirect()->intended(route('dashboard', absolute: false));
|
||||
|
||||
} catch (Exception $e) {
|
||||
Log::error('OIDC callback error: '.$e->getMessage());
|
||||
|
||||
return redirect()->route('login')->withErrors(['oidc' => 'Failed to authenticate with OIDC provider.']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find or create a user based on OIDC information.
|
||||
*/
|
||||
protected function findOrCreateUser($oidcUser)
|
||||
{
|
||||
// First, try to find user by OIDC subject ID
|
||||
$user = User::where('oidc_sub', $oidcUser->getId())->first();
|
||||
|
||||
if ($user) {
|
||||
// Update user information from OIDC
|
||||
$user->update([
|
||||
'name' => $oidcUser->getName() ?: $user->name,
|
||||
'email' => $oidcUser->getEmail() ?: $user->email,
|
||||
]);
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
// If not found by OIDC sub, try to find by email
|
||||
if ($oidcUser->getEmail()) {
|
||||
$user = User::where('email', $oidcUser->getEmail())->first();
|
||||
|
||||
if ($user) {
|
||||
// Link the existing user with OIDC
|
||||
$user->update([
|
||||
'oidc_sub' => $oidcUser->getId(),
|
||||
'name' => $oidcUser->getName() ?: $user->name,
|
||||
]);
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
|
||||
// Create new user
|
||||
return User::create([
|
||||
'oidc_sub' => $oidcUser->getId(),
|
||||
'name' => $oidcUser->getName() ?: 'OIDC User',
|
||||
'email' => $oidcUser->getEmail() ?: $oidcUser->getId().'@oidc.local',
|
||||
'password' => bcrypt(Str::random(32)), // Random password since we're using OIDC
|
||||
'email_verified_at' => now(), // OIDC users are considered verified
|
||||
]);
|
||||
}
|
||||
}
|
||||
30
app/Jobs/CleanupDeviceLogsJob.php
Normal file
30
app/Jobs/CleanupDeviceLogsJob.php
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Device;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class CleanupDeviceLogsJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
Device::each(function ($device): void {
|
||||
$keepIds = $device->logs()->latest('device_timestamp')->take(50)->pluck('id');
|
||||
|
||||
// Delete all other logs for this device
|
||||
$device->logs()
|
||||
->whereNotIn('id', $keepIds)
|
||||
->delete();
|
||||
});
|
||||
}
|
||||
}
|
||||
247
app/Jobs/FetchDeviceModelsJob.php
Normal file
247
app/Jobs/FetchDeviceModelsJob.php
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\DeviceModel;
|
||||
use App\Models\DevicePalette;
|
||||
use Exception;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
final class FetchDeviceModelsJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
private const API_URL = 'https://usetrmnl.com/api/models';
|
||||
|
||||
private const PALETTES_API_URL = 'http://usetrmnl.com/api/palettes';
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
try {
|
||||
$this->processPalettes();
|
||||
|
||||
$response = Http::timeout(30)->get(self::API_URL);
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::error('Failed to fetch device models from API', [
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$data = $response->json('data', []);
|
||||
|
||||
if (! is_array($data)) {
|
||||
Log::error('Invalid response format from device models API', [
|
||||
'response' => $response->json(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->processDeviceModels($data);
|
||||
|
||||
Log::info('Successfully fetched and updated device models', [
|
||||
'count' => count($data),
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
Log::error('Exception occurred while fetching device models', [
|
||||
'message' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process palettes from API and update/create records.
|
||||
*/
|
||||
private function processPalettes(): void
|
||||
{
|
||||
try {
|
||||
$response = Http::timeout(30)->get(self::PALETTES_API_URL);
|
||||
|
||||
if (! $response->successful()) {
|
||||
Log::error('Failed to fetch palettes from API', [
|
||||
'status' => $response->status(),
|
||||
'body' => $response->body(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$data = $response->json('data', []);
|
||||
|
||||
if (! is_array($data)) {
|
||||
Log::error('Invalid response format from palettes API', [
|
||||
'response' => $response->json(),
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($data as $paletteData) {
|
||||
try {
|
||||
$this->updateOrCreatePalette($paletteData);
|
||||
} catch (Exception $e) {
|
||||
Log::error('Failed to process palette', [
|
||||
'palette_data' => $paletteData,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Log::info('Successfully fetched and updated palettes', [
|
||||
'count' => count($data),
|
||||
]);
|
||||
|
||||
} catch (Exception $e) {
|
||||
Log::error('Exception occurred while fetching palettes', [
|
||||
'message' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update or create a palette record.
|
||||
*/
|
||||
private function updateOrCreatePalette(array $paletteData): void
|
||||
{
|
||||
$name = $paletteData['id'] ?? null;
|
||||
|
||||
if (! $name) {
|
||||
Log::warning('Palette data missing id field', [
|
||||
'palette_data' => $paletteData,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$attributes = [
|
||||
'name' => $name,
|
||||
'description' => $paletteData['name'] ?? '',
|
||||
'grays' => $paletteData['grays'] ?? 2,
|
||||
'colors' => $paletteData['colors'] ?? null,
|
||||
'framework_class' => $paletteData['framework_class'] ?? '',
|
||||
'source' => 'api',
|
||||
];
|
||||
|
||||
DevicePalette::updateOrCreate(
|
||||
['name' => $name],
|
||||
$attributes
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the device models data and update/create records.
|
||||
*/
|
||||
private function processDeviceModels(array $deviceModels): void
|
||||
{
|
||||
foreach ($deviceModels as $modelData) {
|
||||
try {
|
||||
$this->updateOrCreateDeviceModel($modelData);
|
||||
} catch (Exception $e) {
|
||||
Log::error('Failed to process device model', [
|
||||
'model_data' => $modelData,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update or create a device model record.
|
||||
*/
|
||||
private function updateOrCreateDeviceModel(array $modelData): void
|
||||
{
|
||||
$name = $modelData['name'] ?? null;
|
||||
|
||||
if (! $name) {
|
||||
Log::warning('Device model data missing name field', [
|
||||
'model_data' => $modelData,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$attributes = [
|
||||
'label' => $modelData['label'] ?? '',
|
||||
'description' => $modelData['description'] ?? '',
|
||||
'width' => $modelData['width'] ?? 0,
|
||||
'height' => $modelData['height'] ?? 0,
|
||||
'colors' => $modelData['colors'] ?? 0,
|
||||
'bit_depth' => $modelData['bit_depth'] ?? 0,
|
||||
'scale_factor' => $modelData['scale_factor'] ?? 1,
|
||||
'rotation' => $modelData['rotation'] ?? 0,
|
||||
'mime_type' => $modelData['mime_type'] ?? '',
|
||||
'offset_x' => $modelData['offset_x'] ?? 0,
|
||||
'offset_y' => $modelData['offset_y'] ?? 0,
|
||||
'published_at' => $modelData['published_at'] ?? null,
|
||||
'kind' => $modelData['kind'] ?? null,
|
||||
'source' => 'api',
|
||||
];
|
||||
|
||||
// Set palette_id to the first palette from the model's palettes array
|
||||
$firstPaletteId = $this->getFirstPaletteId($modelData);
|
||||
if ($firstPaletteId) {
|
||||
$attributes['palette_id'] = $firstPaletteId;
|
||||
}
|
||||
|
||||
DeviceModel::updateOrCreate(
|
||||
['name' => $name],
|
||||
$attributes
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the first palette ID from model data.
|
||||
*/
|
||||
private function getFirstPaletteId(array $modelData): ?int
|
||||
{
|
||||
$paletteName = null;
|
||||
|
||||
// Check for palette_ids array
|
||||
if (isset($modelData['palette_ids']) && is_array($modelData['palette_ids']) && $modelData['palette_ids'] !== []) {
|
||||
$paletteName = $modelData['palette_ids'][0];
|
||||
}
|
||||
|
||||
// Check for palettes array (array of objects with id)
|
||||
if (! $paletteName && isset($modelData['palettes']) && is_array($modelData['palettes']) && $modelData['palettes'] !== []) {
|
||||
$firstPalette = $modelData['palettes'][0];
|
||||
if (is_array($firstPalette) && isset($firstPalette['id'])) {
|
||||
$paletteName = $firstPalette['id'];
|
||||
}
|
||||
}
|
||||
|
||||
if (! $paletteName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Look up palette by name to get the integer ID
|
||||
$palette = DevicePalette::where('name', $paletteName)->first();
|
||||
|
||||
return $palette?->id;
|
||||
}
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Device;
|
||||
use Exception;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
|
|
@ -11,6 +12,7 @@ use Illuminate\Queue\SerializesModels;
|
|||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class FetchProxyCloudResponses implements ShouldQueue
|
||||
{
|
||||
|
|
@ -21,53 +23,10 @@ class FetchProxyCloudResponses implements ShouldQueue
|
|||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
Device::where('proxy_cloud', true)->each(function ($device) {
|
||||
try {
|
||||
$response = Http::withHeaders([
|
||||
'id' => $device->mac_address,
|
||||
'access-token' => $device->api_key,
|
||||
'width' => 800,
|
||||
'height' => 480,
|
||||
'rssi' => $device->last_rssi_level,
|
||||
'battery_voltage' => $device->last_battery_voltage,
|
||||
'refresh-rate' => $device->default_refresh_interval,
|
||||
'fw-version' => $device->last_firmware_version,
|
||||
'accept-encoding' => 'identity;q=1,chunked;q=0.1,*;q=0',
|
||||
'user-agent' => 'ESP32HTTPClient',
|
||||
])->get(config('services.trmnl.proxy_base_url').'/api/display');
|
||||
|
||||
$device->update([
|
||||
'proxy_cloud_response' => $response->json(),
|
||||
]);
|
||||
|
||||
$imageUrl = $response->json('image_url');
|
||||
$filename = $response->json('filename');
|
||||
|
||||
\Log::info('Response data: '.$imageUrl);
|
||||
if (isset($imageUrl)) {
|
||||
try {
|
||||
$imageContents = Http::get($imageUrl)->body();
|
||||
if (! Storage::disk('public')->exists("images/generated/{$filename}.bmp")) {
|
||||
Storage::disk('public')->put(
|
||||
"images/generated/{$filename}.bmp",
|
||||
$imageContents
|
||||
);
|
||||
}
|
||||
|
||||
$device->update([
|
||||
'current_screen_image' => $filename,
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Failed to download and save image for device: {$device->mac_address}", [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Log::info("Successfully updated proxy cloud response for device: {$device->mac_address}");
|
||||
|
||||
if ($device->last_log_request) {
|
||||
Http::withHeaders([
|
||||
Device::where('proxy_cloud', true)->each(function ($device): void {
|
||||
if (! $device->getNextPlaylistItem()) {
|
||||
try {
|
||||
$response = Http::withHeaders([
|
||||
'id' => $device->mac_address,
|
||||
'access-token' => $device->api_key,
|
||||
'width' => 800,
|
||||
|
|
@ -78,17 +37,80 @@ class FetchProxyCloudResponses implements ShouldQueue
|
|||
'fw-version' => $device->last_firmware_version,
|
||||
'accept-encoding' => 'identity;q=1,chunked;q=0.1,*;q=0',
|
||||
'user-agent' => 'ESP32HTTPClient',
|
||||
])->post(config('services.trmnl.proxy_base_url').'/api/log', $device->last_log_request);
|
||||
])->get(config('services.trmnl.proxy_base_url').'/api/display');
|
||||
|
||||
$device->update([
|
||||
'last_log_request' => null,
|
||||
'proxy_cloud_response' => $response->json(),
|
||||
]);
|
||||
|
||||
$imageUrl = $response->json('image_url');
|
||||
$filename = $response->json('filename');
|
||||
|
||||
parse_str(parse_url($imageUrl)['query'] ?? '', $queryParams);
|
||||
$imageType = urldecode($queryParams['response-content-type'] ?? 'image/bmp');
|
||||
$imageExtension = $imageType === 'image/png' ? 'png' : 'bmp';
|
||||
|
||||
if (Str::contains($imageUrl, '.png')) {
|
||||
$imageExtension = 'png';
|
||||
}
|
||||
|
||||
\Log::info("Response data: $imageUrl. Image Extension: $imageExtension");
|
||||
if (isset($imageUrl)) {
|
||||
try {
|
||||
$imageContents = Http::get($imageUrl)->body();
|
||||
if (! Storage::disk('public')->exists("images/generated/{$filename}.{$imageExtension}")) {
|
||||
Storage::disk('public')->put(
|
||||
"images/generated/{$filename}.{$imageExtension}",
|
||||
$imageContents
|
||||
);
|
||||
}
|
||||
|
||||
$device->update([
|
||||
'current_screen_image' => $filename,
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
Log::error("Failed to download and save image for device: {$device->mac_address}", [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Log::info("Successfully updated proxy cloud response for device: {$device->mac_address}");
|
||||
|
||||
if ($device->last_log_request) {
|
||||
try {
|
||||
Http::withHeaders([
|
||||
'id' => $device->mac_address,
|
||||
'access-token' => $device->api_key,
|
||||
'width' => 800,
|
||||
'height' => 480,
|
||||
'rssi' => $device->last_rssi_level,
|
||||
'battery_voltage' => $device->last_battery_voltage,
|
||||
'refresh-rate' => $device->default_refresh_interval,
|
||||
'fw-version' => $device->last_firmware_version,
|
||||
'accept-encoding' => 'identity;q=1,chunked;q=0.1,*;q=0',
|
||||
'user-agent' => 'ESP32HTTPClient',
|
||||
])->post(config('services.trmnl.proxy_base_url').'/api/log', $device->last_log_request);
|
||||
|
||||
// Only clear the pending log request if the POST succeeded
|
||||
$device->update([
|
||||
'last_log_request' => null,
|
||||
]);
|
||||
} catch (Exception $e) {
|
||||
// Do not fail the entire proxy fetch if the log upload fails
|
||||
Log::error("Failed to upload device log for device: {$device->mac_address}", [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
Log::error("Failed to fetch proxy cloud response for device: {$device->mac_address}", [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
} catch (\Exception $e) {
|
||||
Log::error("Failed to fetch proxy cloud response for device: {$device->mac_address}", [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
} else {
|
||||
Log::info("Skipping device: {$device->mac_address} as it has a pending playlist item.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
52
app/Jobs/FirmwareDownloadJob.php
Normal file
52
app/Jobs/FirmwareDownloadJob.php
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Firmware;
|
||||
use Exception;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
class FirmwareDownloadJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(private Firmware $firmware) {}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
if (! Storage::disk('public')->exists('firmwares')) {
|
||||
Storage::disk('public')->makeDirectory('firmwares');
|
||||
}
|
||||
|
||||
try {
|
||||
$filename = "FW{$this->firmware->version_tag}.bin";
|
||||
$response = Http::get($this->firmware->url);
|
||||
|
||||
if (! $response->successful()) {
|
||||
throw new Exception('HTTP request failed with status: '.$response->status());
|
||||
}
|
||||
|
||||
// Save the response content to file
|
||||
Storage::disk('public')->put("firmwares/$filename", $response->body());
|
||||
|
||||
// Only update storage location if download was successful
|
||||
$this->firmware->update([
|
||||
'storage_location' => "firmwares/$filename",
|
||||
]);
|
||||
} catch (ConnectionException $e) {
|
||||
Log::error('Firmware download failed: '.$e->getMessage());
|
||||
// Don't update storage_location on failure
|
||||
} catch (Exception $e) {
|
||||
Log::error('An unexpected error occurred: '.$e->getMessage());
|
||||
// Don't update storage_location on failure
|
||||
}
|
||||
}
|
||||
}
|
||||
53
app/Jobs/FirmwarePollJob.php
Normal file
53
app/Jobs/FirmwarePollJob.php
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Firmware;
|
||||
use Exception;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Log;
|
||||
|
||||
class FirmwarePollJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(private bool $download = false) {}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
try {
|
||||
$response = Http::get('https://usetrmnl.com/api/firmware/latest')->json();
|
||||
|
||||
if (! is_array($response) || ! isset($response['version']) || ! isset($response['url'])) {
|
||||
Log::error('Invalid firmware response format received');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$latestFirmware = Firmware::updateOrCreate(
|
||||
['version_tag' => $response['version']],
|
||||
[
|
||||
'url' => $response['url'],
|
||||
'latest' => true,
|
||||
]
|
||||
);
|
||||
|
||||
Firmware::where('id', '!=', $latestFirmware->id)->update(['latest' => false]);
|
||||
|
||||
if ($this->download && $latestFirmware->url && $latestFirmware->storage_location === null) {
|
||||
FirmwareDownloadJob::dispatchSync($latestFirmware);
|
||||
}
|
||||
|
||||
} catch (ConnectionException $e) {
|
||||
Log::error('Firmware download failed: '.$e->getMessage());
|
||||
} catch (Exception $e) {
|
||||
Log::error('Unexpected error in firmware polling: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,14 +3,13 @@
|
|||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Device;
|
||||
use App\Models\Plugin;
|
||||
use App\Services\ImageGenerationService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use Spatie\Browsershot\Browsershot;
|
||||
|
||||
class GenerateScreenJob implements ShouldQueue
|
||||
{
|
||||
|
|
@ -21,6 +20,7 @@ class GenerateScreenJob implements ShouldQueue
|
|||
*/
|
||||
public function __construct(
|
||||
private readonly int $deviceId,
|
||||
private readonly ?int $pluginId,
|
||||
private readonly string $markup
|
||||
) {}
|
||||
|
||||
|
|
@ -29,61 +29,15 @@ class GenerateScreenJob implements ShouldQueue
|
|||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
$uuid = Uuid::uuid4()->toString();
|
||||
$pngPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.png');
|
||||
$bmpPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.bmp');
|
||||
$newImageUuid = ImageGenerationService::generateImage($this->markup, $this->deviceId);
|
||||
|
||||
// Generate PNG
|
||||
try {
|
||||
Browsershot::html($this->markup)
|
||||
->setOption('args', config('app.puppeteer_docker') ? ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu'] : [])
|
||||
->windowSize(800, 480)
|
||||
->save($pngPath);
|
||||
} catch (\Exception $e) {
|
||||
throw new \RuntimeException('Failed to generate PNG: '.$e->getMessage(), 0, $e);
|
||||
Device::find($this->deviceId)->update(['current_screen_image' => $newImageUuid]);
|
||||
|
||||
if ($this->pluginId) {
|
||||
// cache current image
|
||||
Plugin::find($this->pluginId)->update(['current_image' => $newImageUuid]);
|
||||
}
|
||||
|
||||
try {
|
||||
$this->convertToBmpImageMagick($pngPath, $bmpPath);
|
||||
} catch (\ImagickException $e) {
|
||||
throw new \RuntimeException('Failed to convert image to BMP: '.$e->getMessage(), 0, $e);
|
||||
}
|
||||
Device::find($this->deviceId)->update(['current_screen_image' => $uuid]);
|
||||
\Log::info("Device $this->deviceId: updated with new image: $uuid");
|
||||
|
||||
$this->cleanupFolder();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \ImagickException
|
||||
*/
|
||||
private function convertToBmpImageMagick(string $pngPath, string $bmpPath): void
|
||||
{
|
||||
$imagick = new \Imagick($pngPath);
|
||||
$imagick->setImageType(\Imagick::IMGTYPE_GRAYSCALE);
|
||||
$imagick->quantizeImage(2, \Imagick::COLORSPACE_GRAY, 0, true, false);
|
||||
$imagick->setImageDepth(1);
|
||||
$imagick->stripImage();
|
||||
$imagick->setFormat('BMP3');
|
||||
$imagick->writeImage($bmpPath);
|
||||
$imagick->clear();
|
||||
}
|
||||
|
||||
private function cleanupFolder(): void
|
||||
{
|
||||
$activeImageUuids = Device::pluck('current_screen_image')->filter()->toArray();
|
||||
|
||||
$files = Storage::disk('public')->files('/images/generated/');
|
||||
foreach ($files as $file) {
|
||||
if (basename($file) === '.gitignore') {
|
||||
continue;
|
||||
}
|
||||
// Get filename without path and extension
|
||||
$fileUuid = pathinfo($file, PATHINFO_FILENAME);
|
||||
// If the UUID is not in use by any device, move it to archive
|
||||
if (! in_array($fileUuid, $activeImageUuids)) {
|
||||
Storage::disk('public')->delete($file);
|
||||
}
|
||||
}
|
||||
ImageGenerationService::cleanupFolder();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
54
app/Jobs/NotifyDeviceBatteryLowJob.php
Normal file
54
app/Jobs/NotifyDeviceBatteryLowJob.php
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\Device;
|
||||
use App\Models\User;
|
||||
use App\Notifications\BatteryLow;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class NotifyDeviceBatteryLowJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$devices = Device::all();
|
||||
$batteryThreshold = config('app.notifications.battery_low.warn_at_percent');
|
||||
|
||||
foreach ($devices as $device) {
|
||||
$batteryPercent = $device->battery_percent;
|
||||
|
||||
// If battery is above threshold, reset the notification flag
|
||||
if ($batteryPercent > $batteryThreshold && $device->battery_notification_sent) {
|
||||
$device->battery_notification_sent = false;
|
||||
$device->save();
|
||||
|
||||
continue;
|
||||
}
|
||||
// Skip if battery is not low or notification was already sent
|
||||
if ($batteryPercent > $batteryThreshold) {
|
||||
continue;
|
||||
}
|
||||
if ($device->battery_notification_sent) {
|
||||
continue;
|
||||
}
|
||||
|
||||
/** @var User|null $user */
|
||||
$user = $device->user;
|
||||
|
||||
if (! $user) {
|
||||
continue; // Skip if no user is associated with the device
|
||||
}
|
||||
|
||||
// Send notification and mark as sent
|
||||
$user->notify(new BatteryLow($device));
|
||||
$device->battery_notification_sent = true;
|
||||
$device->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
62
app/Liquid/FileSystems/InlineTemplatesFileSystem.php
Normal file
62
app/Liquid/FileSystems/InlineTemplatesFileSystem.php
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Liquid\FileSystems;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use Keepsuit\Liquid\Contracts\LiquidFileSystem;
|
||||
|
||||
/**
|
||||
* A file system that allows registering inline templates defined with the template tag
|
||||
*/
|
||||
class InlineTemplatesFileSystem implements LiquidFileSystem
|
||||
{
|
||||
/**
|
||||
* @var array<string, string>
|
||||
*/
|
||||
protected array $templates = [];
|
||||
|
||||
/**
|
||||
* Register a template with the given name and content
|
||||
*/
|
||||
public function register(string $name, string $content): void
|
||||
{
|
||||
$this->templates[$name] = $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a template exists
|
||||
*/
|
||||
public function hasTemplate(string $templateName): bool
|
||||
{
|
||||
return isset($this->templates[$templateName]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered template names
|
||||
*
|
||||
* @return array<string>
|
||||
*/
|
||||
public function getTemplateNames(): array
|
||||
{
|
||||
return array_keys($this->templates);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all registered templates
|
||||
*/
|
||||
public function clear(): void
|
||||
{
|
||||
$this->templates = [];
|
||||
}
|
||||
|
||||
public function readTemplateFile(string $templateName): string
|
||||
{
|
||||
if (! isset($this->templates[$templateName])) {
|
||||
throw new InvalidArgumentException("Template '{$templateName}' not found in inline templates");
|
||||
}
|
||||
|
||||
return $this->templates[$templateName];
|
||||
}
|
||||
}
|
||||
136
app/Liquid/Filters/Data.php
Normal file
136
app/Liquid/Filters/Data.php
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
<?php
|
||||
|
||||
namespace App\Liquid\Filters;
|
||||
|
||||
use App\Liquid\Utils\ExpressionUtils;
|
||||
use Keepsuit\Liquid\Filters\FiltersProvider;
|
||||
|
||||
/**
|
||||
* Data filters for Liquid templates
|
||||
*/
|
||||
class Data extends FiltersProvider
|
||||
{
|
||||
/**
|
||||
* Convert a variable to JSON
|
||||
*
|
||||
* @param mixed $value The variable to convert
|
||||
* @return string JSON representation of the variable
|
||||
*/
|
||||
public function json(mixed $value): string
|
||||
{
|
||||
return json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an object in a collection by a specific key-value pair
|
||||
*
|
||||
* @param array $collection The collection to search in
|
||||
* @param string $key The key to search for
|
||||
* @param mixed $value The value to match
|
||||
* @param mixed $fallback Optional fallback value if no match is found
|
||||
* @return mixed The matching object or fallback value
|
||||
*/
|
||||
public function find_by(array $collection, string $key, mixed $value, mixed $fallback = null): mixed
|
||||
{
|
||||
foreach ($collection as $item) {
|
||||
if (is_array($item) && isset($item[$key]) && $item[$key] === $value) {
|
||||
return $item;
|
||||
}
|
||||
}
|
||||
|
||||
return $fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Group a collection by a specific key
|
||||
*
|
||||
* @param array $collection The collection to group
|
||||
* @param string $key The key to group by
|
||||
* @return array The grouped collection
|
||||
*/
|
||||
public function group_by(array $collection, string $key): array
|
||||
{
|
||||
$grouped = [];
|
||||
|
||||
foreach ($collection as $item) {
|
||||
if (is_array($item) && array_key_exists($key, $item)) {
|
||||
$groupKey = $item[$key];
|
||||
if (! isset($grouped[$groupKey])) {
|
||||
$grouped[$groupKey] = [];
|
||||
}
|
||||
$grouped[$groupKey][] = $item;
|
||||
}
|
||||
}
|
||||
|
||||
return $grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a random element from an array
|
||||
*
|
||||
* @param array $array The array to sample from
|
||||
* @return mixed A random element from the array
|
||||
*/
|
||||
public function sample(array $array): mixed
|
||||
{
|
||||
if ($array === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $array[array_rand($array)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a JSON string into a PHP value
|
||||
*
|
||||
* @param string $json The JSON string to parse
|
||||
* @return mixed The parsed JSON value
|
||||
*/
|
||||
public function parse_json(string $json): mixed
|
||||
{
|
||||
return json_decode($json, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter a collection using an expression
|
||||
*
|
||||
* @param mixed $input The collection to filter
|
||||
* @param string $variable The variable name to use in the expression
|
||||
* @param string $expression The expression to evaluate
|
||||
* @return array The filtered collection
|
||||
*/
|
||||
public function where_exp(mixed $input, string $variable, string $expression): array
|
||||
{
|
||||
// Return input as-is if it's not an array or doesn't have values method
|
||||
if (! is_array($input)) {
|
||||
return is_string($input) ? [$input] : [];
|
||||
}
|
||||
|
||||
// Convert hash to array of values if needed
|
||||
if (ExpressionUtils::isAssociativeArray($input)) {
|
||||
$input = array_values($input);
|
||||
}
|
||||
|
||||
$condition = ExpressionUtils::parseCondition($expression);
|
||||
$result = [];
|
||||
|
||||
foreach ($input as $object) {
|
||||
if (ExpressionUtils::evaluateCondition($condition, $variable, $object)) {
|
||||
$result[] = $object;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert array of strings to integers
|
||||
*
|
||||
* @param array $input Array of string numbers
|
||||
* @return array Array of integers
|
||||
*/
|
||||
public function map_to_i(array $input): array
|
||||
{
|
||||
return array_map(intval(...), $input);
|
||||
}
|
||||
}
|
||||
55
app/Liquid/Filters/Date.php
Normal file
55
app/Liquid/Filters/Date.php
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
|
||||
namespace App\Liquid\Filters;
|
||||
|
||||
use App\Liquid\Utils\ExpressionUtils;
|
||||
use Carbon\Carbon;
|
||||
use Keepsuit\Liquid\Filters\FiltersProvider;
|
||||
|
||||
/**
|
||||
* Data filters for Liquid templates
|
||||
*/
|
||||
class Date extends FiltersProvider
|
||||
{
|
||||
/**
|
||||
* Calculate a date that is a specified number of days in the past
|
||||
*
|
||||
* @param int|string $num The number of days to subtract
|
||||
* @return string The date in Y-m-d format
|
||||
*/
|
||||
public function days_ago(int|string $num): string
|
||||
{
|
||||
$days = (int) $num;
|
||||
|
||||
return Carbon::now()->subDays($days)->toDateString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date string with ordinal day (1st, 2nd, 3rd, etc.)
|
||||
*
|
||||
* @param string $dateStr The date string to parse
|
||||
* @param string $strftimeExp The strftime format string with <<ordinal_day>> placeholder
|
||||
* @return string The formatted date with ordinal day
|
||||
*/
|
||||
public function ordinalize(string $dateStr, string $strftimeExp): string
|
||||
{
|
||||
$date = Carbon::parse($dateStr);
|
||||
$ordinalDay = $date->ordinal('day');
|
||||
|
||||
// Convert strftime format to PHP date format
|
||||
$phpFormat = ExpressionUtils::strftimeToPhpFormat($strftimeExp);
|
||||
|
||||
// Split the format string by the ordinal day placeholder
|
||||
$parts = explode('<<ordinal_day>>', $phpFormat);
|
||||
|
||||
if (count($parts) === 2) {
|
||||
$before = $date->format($parts[0]);
|
||||
$after = $date->format($parts[1]);
|
||||
|
||||
return $before.$ordinalDay.$after;
|
||||
}
|
||||
|
||||
// Fallback: if no placeholder found, just format normally
|
||||
return $date->format($phpFormat);
|
||||
}
|
||||
}
|
||||
52
app/Liquid/Filters/Localization.php
Normal file
52
app/Liquid/Filters/Localization.php
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
<?php
|
||||
|
||||
namespace App\Liquid\Filters;
|
||||
|
||||
use DateTimeInterface;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Keepsuit\Liquid\Filters\FiltersProvider;
|
||||
|
||||
/**
|
||||
* Localization filters for Liquid templates
|
||||
*
|
||||
* Uses Laravel's translator for word translations. Translation files are located in the
|
||||
* lang/{locale}/custom_plugins.php files.
|
||||
*/
|
||||
class Localization extends FiltersProvider
|
||||
{
|
||||
/**
|
||||
* Localize a date with strftime syntax
|
||||
*
|
||||
* @param mixed $date The date to localize (string or DateTime)
|
||||
* @param string $format The strftime format string
|
||||
* @param string|null $locale The locale to use for localization
|
||||
* @return string The localized date string
|
||||
*/
|
||||
public function l_date(mixed $date, string $format = 'Y-m-d', ?string $locale = null): string
|
||||
{
|
||||
$carbon = $date instanceof DateTimeInterface ? Carbon::instance($date) : Carbon::parse($date);
|
||||
if ($locale) {
|
||||
$carbon->locale($locale);
|
||||
}
|
||||
|
||||
return $carbon->translatedFormat($format);
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a common word to another language
|
||||
*
|
||||
* @param string $word The word to translate
|
||||
* @param string $locale The locale to translate to
|
||||
* @return string The translated word
|
||||
*/
|
||||
public function l_word(string $word, string $locale): string
|
||||
{
|
||||
$translation = trans('custom_plugins.'.mb_strtolower($word), locale: $locale);
|
||||
|
||||
if ($translation === 'custom_plugins.'.mb_strtolower($word)) {
|
||||
return $word;
|
||||
}
|
||||
|
||||
return $translation;
|
||||
}
|
||||
}
|
||||
50
app/Liquid/Filters/Numbers.php
Normal file
50
app/Liquid/Filters/Numbers.php
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
namespace App\Liquid\Filters;
|
||||
|
||||
use Illuminate\Support\Number;
|
||||
use Keepsuit\Liquid\Filters\FiltersProvider;
|
||||
|
||||
class Numbers extends FiltersProvider
|
||||
{
|
||||
/**
|
||||
* Format a number with delimiters (default: comma)
|
||||
*
|
||||
* @param mixed $value The number to format
|
||||
* @param string $delimiter The delimiter to use (default: comma)
|
||||
* @param string $separator The separator for decimal part (default: period)
|
||||
*/
|
||||
public function number_with_delimiter(mixed $value, string $delimiter = ',', string $separator = '.'): string
|
||||
{
|
||||
// 2 decimal places for floats, 0 for integers
|
||||
$decimal = is_float($value + 0) ? 2 : 0;
|
||||
|
||||
return number_format($value, decimals: $decimal, decimal_separator: $separator, thousands_separator: $delimiter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a number as currency
|
||||
*
|
||||
* @param mixed $value The number to format
|
||||
* @param string $currency Currency symbol or locale code
|
||||
* @param string $delimiter The delimiter to use (default: comma)
|
||||
* @param string $separator The separator for decimal part (default: period)
|
||||
*/
|
||||
public function number_to_currency(mixed $value, string $currency = 'USD', string $delimiter = ',', string $separator = '.'): string
|
||||
{
|
||||
if ($currency === '$') {
|
||||
$currency = 'USD';
|
||||
} elseif ($currency === '€') {
|
||||
$currency = 'EUR';
|
||||
} elseif ($currency === '£') {
|
||||
$currency = 'GBP';
|
||||
}
|
||||
|
||||
$locale = $delimiter === '.' && $separator === ',' ? 'de' : 'en';
|
||||
|
||||
// 2 decimal places for floats, 0 for integers
|
||||
$decimal = is_float($value + 0) ? 2 : 0;
|
||||
|
||||
return Number::currency($value, in: $currency, locale: $locale, precision: $decimal);
|
||||
}
|
||||
}
|
||||
20
app/Liquid/Filters/StandardFilters.php
Normal file
20
app/Liquid/Filters/StandardFilters.php
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<?php
|
||||
|
||||
namespace App\Liquid\Filters;
|
||||
|
||||
class StandardFilters extends \Keepsuit\Liquid\Filters\StandardFilters
|
||||
{
|
||||
/**
|
||||
* Converts any URL-unsafe characters in a string to the
|
||||
* [percent-encoded](https://developer.mozilla.org/en-US/docs/Glossary/percent-encoding) equivalent.
|
||||
*/
|
||||
public function urlEncode(string|int|float|array|null $input): string
|
||||
{
|
||||
|
||||
if (is_array($input)) {
|
||||
$input = json_encode($input);
|
||||
}
|
||||
|
||||
return parent::urlEncode($input);
|
||||
}
|
||||
}
|
||||
61
app/Liquid/Filters/StringMarkup.php
Normal file
61
app/Liquid/Filters/StringMarkup.php
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
|
||||
namespace App\Liquid\Filters;
|
||||
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Str;
|
||||
use Keepsuit\Liquid\Filters\FiltersProvider;
|
||||
use League\CommonMark\CommonMarkConverter;
|
||||
use League\CommonMark\Exception\CommonMarkException;
|
||||
|
||||
/**
|
||||
* String, Markup, and HTML filters for Liquid templates
|
||||
*/
|
||||
class StringMarkup extends FiltersProvider
|
||||
{
|
||||
/**
|
||||
* Pluralize a word based on count
|
||||
*
|
||||
* @param string $word The word to pluralize
|
||||
* @param int $count The count to determine pluralization
|
||||
* @return string The pluralized word with count
|
||||
*/
|
||||
public function pluralize(string $word, int $count = 2): string
|
||||
{
|
||||
if ($count === 1) {
|
||||
return "{$count} {$word}";
|
||||
}
|
||||
|
||||
return "{$count} ".Str::plural($word, $count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert markdown to HTML
|
||||
*
|
||||
* @param string $markdown The markdown text to convert
|
||||
* @return string The HTML representation of the markdown
|
||||
*/
|
||||
public function markdown_to_html(string $markdown): ?string
|
||||
{
|
||||
$converter = new CommonMarkConverter();
|
||||
|
||||
try {
|
||||
return $converter->convert($markdown);
|
||||
} catch (CommonMarkException $e) {
|
||||
Log::error('Markdown conversion error: '.$e->getMessage());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip HTML tags from a string
|
||||
*
|
||||
* @param string $html The HTML string to strip
|
||||
* @return string The string without HTML tags
|
||||
*/
|
||||
public function strip_html(string $html): string
|
||||
{
|
||||
return strip_tags($html);
|
||||
}
|
||||
}
|
||||
43
app/Liquid/Filters/Uniqueness.php
Normal file
43
app/Liquid/Filters/Uniqueness.php
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
namespace App\Liquid\Filters;
|
||||
|
||||
use Keepsuit\Liquid\Concerns\ContextAware;
|
||||
use Keepsuit\Liquid\Filters\FiltersProvider;
|
||||
|
||||
/**
|
||||
* Uniqueness filters for Liquid templates
|
||||
*/
|
||||
class Uniqueness extends FiltersProvider
|
||||
{
|
||||
use ContextAware;
|
||||
|
||||
/**
|
||||
* Append a random string to ensure uniqueness within a template
|
||||
*
|
||||
* @param string $prefix The prefix to append the random string to
|
||||
* @return string The prefix with a random string appended
|
||||
*/
|
||||
public function append_random(string $prefix): string
|
||||
{
|
||||
return $prefix.$this->generateRandomString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random string
|
||||
*
|
||||
* @param int $length The length of the random string
|
||||
* @return string A random string
|
||||
*/
|
||||
private function generateRandomString(int $length = 4): string
|
||||
{
|
||||
$characters = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
$randomString = '';
|
||||
|
||||
for ($i = 0; $i < $length; ++$i) {
|
||||
$randomString .= $characters[random_int(0, mb_strlen($characters) - 1)];
|
||||
}
|
||||
|
||||
return $randomString;
|
||||
}
|
||||
}
|
||||
100
app/Liquid/Tags/TemplateTag.php
Normal file
100
app/Liquid/Tags/TemplateTag.php
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Liquid\Tags;
|
||||
|
||||
use App\Liquid\FileSystems\InlineTemplatesFileSystem;
|
||||
use Keepsuit\Liquid\Exceptions\SyntaxException;
|
||||
use Keepsuit\Liquid\Nodes\BodyNode;
|
||||
use Keepsuit\Liquid\Nodes\Raw;
|
||||
use Keepsuit\Liquid\Nodes\VariableLookup;
|
||||
use Keepsuit\Liquid\Parse\TagParseContext;
|
||||
use Keepsuit\Liquid\Render\RenderContext;
|
||||
use Keepsuit\Liquid\TagBlock;
|
||||
|
||||
/**
|
||||
* The {% template [name] %} tag block is used to define custom templates within the context of the current Liquid template.
|
||||
* These templates are registered with the InlineTemplatesFileSystem and can be rendered using the render tag.
|
||||
*/
|
||||
class TemplateTag extends TagBlock
|
||||
{
|
||||
protected string $templateName;
|
||||
|
||||
protected Raw $body;
|
||||
|
||||
public static function tagName(): string
|
||||
{
|
||||
return 'template';
|
||||
}
|
||||
|
||||
public static function hasRawBody(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function parse(TagParseContext $context): static
|
||||
{
|
||||
// Get the template name from the tag parameters
|
||||
$templateNameExpression = $context->params->expression();
|
||||
|
||||
$this->templateName = match (true) {
|
||||
is_string($templateNameExpression) => mb_trim($templateNameExpression),
|
||||
is_numeric($templateNameExpression) => (string) $templateNameExpression,
|
||||
$templateNameExpression instanceof VariableLookup => (string) $templateNameExpression,
|
||||
default => throw new SyntaxException('Template name must be a string, number, or variable'),
|
||||
};
|
||||
|
||||
// Validate template name (letters, numbers, underscores, and slashes only)
|
||||
if (! preg_match('/^[a-zA-Z0-9_\/]+$/', $this->templateName)) {
|
||||
throw new SyntaxException("Invalid template name '{$this->templateName}' - template names must contain only letters, numbers, underscores, and slashes");
|
||||
}
|
||||
|
||||
$context->params->assertEnd();
|
||||
|
||||
assert($context->body instanceof BodyNode);
|
||||
|
||||
$body = $context->body->children()[0] ?? null;
|
||||
$this->body = match (true) {
|
||||
$body instanceof Raw => $body,
|
||||
default => throw new SyntaxException('template tag must have a single raw body'),
|
||||
};
|
||||
|
||||
// Register the template with the file system during parsing
|
||||
$fileSystem = $context->getParseContext()->environment->fileSystem;
|
||||
if ($fileSystem instanceof InlineTemplatesFileSystem) {
|
||||
// Store the raw content for later rendering
|
||||
$fileSystem->register($this->templateName, $this->body->value);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function render(RenderContext $context): string
|
||||
{
|
||||
// Get the file system from the environment
|
||||
$fileSystem = $context->environment->fileSystem;
|
||||
|
||||
if (! $fileSystem instanceof InlineTemplatesFileSystem) {
|
||||
// If no inline file system is available, just return empty string
|
||||
// This allows the template to be used in contexts where inline templates aren't supported
|
||||
return '';
|
||||
}
|
||||
|
||||
// Register the template with the file system
|
||||
$fileSystem->register($this->templateName, $this->body->render($context));
|
||||
|
||||
// Return empty string as template tags don't output anything
|
||||
return '';
|
||||
}
|
||||
|
||||
public function getTemplateName(): string
|
||||
{
|
||||
return $this->templateName;
|
||||
}
|
||||
|
||||
public function getBody(): Raw
|
||||
{
|
||||
return $this->body;
|
||||
}
|
||||
}
|
||||
210
app/Liquid/Utils/ExpressionUtils.php
Normal file
210
app/Liquid/Utils/ExpressionUtils.php
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
<?php
|
||||
|
||||
namespace App\Liquid\Utils;
|
||||
|
||||
/**
|
||||
* Utility class for parsing and evaluating expressions in Liquid filters
|
||||
*/
|
||||
class ExpressionUtils
|
||||
{
|
||||
/**
|
||||
* Check if an array is associative
|
||||
*/
|
||||
public static function isAssociativeArray(array $array): bool
|
||||
{
|
||||
if ($array === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return array_keys($array) !== range(0, count($array) - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a condition expression into a structured format
|
||||
*/
|
||||
public static function parseCondition(string $expression): array
|
||||
{
|
||||
$expression = mb_trim($expression);
|
||||
|
||||
// Handle logical operators (and, or)
|
||||
if (str_contains($expression, ' and ')) {
|
||||
$parts = explode(' and ', $expression, 2);
|
||||
|
||||
return [
|
||||
'type' => 'and',
|
||||
'left' => self::parseCondition(mb_trim($parts[0])),
|
||||
'right' => self::parseCondition(mb_trim($parts[1])),
|
||||
];
|
||||
}
|
||||
|
||||
if (str_contains($expression, ' or ')) {
|
||||
$parts = explode(' or ', $expression, 2);
|
||||
|
||||
return [
|
||||
'type' => 'or',
|
||||
'left' => self::parseCondition(mb_trim($parts[0])),
|
||||
'right' => self::parseCondition(mb_trim($parts[1])),
|
||||
];
|
||||
}
|
||||
|
||||
// Handle comparison operators
|
||||
$operators = ['>=', '<=', '!=', '==', '>', '<', '='];
|
||||
|
||||
foreach ($operators as $operator) {
|
||||
if (str_contains($expression, $operator)) {
|
||||
$parts = explode($operator, $expression, 2);
|
||||
|
||||
return [
|
||||
'type' => 'comparison',
|
||||
'left' => mb_trim($parts[0]),
|
||||
'operator' => $operator === '=' ? '==' : $operator,
|
||||
'right' => mb_trim($parts[1]),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// If no operator found, treat as a simple expression
|
||||
return [
|
||||
'type' => 'simple',
|
||||
'expression' => $expression,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate a condition against an object
|
||||
*/
|
||||
public static function evaluateCondition(array $condition, string $variable, mixed $object): bool
|
||||
{
|
||||
switch ($condition['type']) {
|
||||
case 'and':
|
||||
return self::evaluateCondition($condition['left'], $variable, $object) &&
|
||||
self::evaluateCondition($condition['right'], $variable, $object);
|
||||
|
||||
case 'or':
|
||||
if (self::evaluateCondition($condition['left'], $variable, $object)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return self::evaluateCondition($condition['right'], $variable, $object);
|
||||
|
||||
case 'comparison':
|
||||
$leftValue = self::resolveValue($condition['left'], $variable, $object);
|
||||
$rightValue = self::resolveValue($condition['right'], $variable, $object);
|
||||
|
||||
return match ($condition['operator']) {
|
||||
'==' => $leftValue === $rightValue,
|
||||
'!=' => $leftValue !== $rightValue,
|
||||
'>' => $leftValue > $rightValue,
|
||||
'<' => $leftValue < $rightValue,
|
||||
'>=' => $leftValue >= $rightValue,
|
||||
'<=' => $leftValue <= $rightValue,
|
||||
default => false,
|
||||
};
|
||||
|
||||
case 'simple':
|
||||
$value = self::resolveValue($condition['expression'], $variable, $object);
|
||||
|
||||
return (bool) $value;
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a value from an expression, variable, or literal
|
||||
*/
|
||||
public static function resolveValue(string $expression, string $variable, mixed $object): mixed
|
||||
{
|
||||
$expression = mb_trim($expression);
|
||||
|
||||
// If it's the variable name, return the object
|
||||
if ($expression === $variable) {
|
||||
return $object;
|
||||
}
|
||||
|
||||
// If it's a property access (e.g., "n.age"), resolve it
|
||||
if (str_starts_with($expression, $variable.'.')) {
|
||||
$property = mb_substr($expression, mb_strlen($variable) + 1);
|
||||
if (is_array($object) && array_key_exists($property, $object)) {
|
||||
return $object[$property];
|
||||
}
|
||||
if (is_object($object) && property_exists($object, $property)) {
|
||||
return $object->$property;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try to parse as a number
|
||||
if (is_numeric($expression)) {
|
||||
return str_contains($expression, '.') ? (float) $expression : (int) $expression;
|
||||
}
|
||||
|
||||
// Try to parse as boolean
|
||||
if (in_array(mb_strtolower($expression), ['true', 'false'])) {
|
||||
return mb_strtolower($expression) === 'true';
|
||||
}
|
||||
|
||||
// Try to parse as null
|
||||
if (mb_strtolower($expression) === 'null') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Return as string (remove quotes if present)
|
||||
if ((str_starts_with($expression, '"') && str_ends_with($expression, '"')) ||
|
||||
(str_starts_with($expression, "'") && str_ends_with($expression, "'"))) {
|
||||
return mb_substr($expression, 1, -1);
|
||||
}
|
||||
|
||||
return $expression;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert strftime format string to PHP date format string
|
||||
*
|
||||
* @param string $strftimeFormat The strftime format string
|
||||
* @return string The PHP date format string
|
||||
*/
|
||||
public static function strftimeToPhpFormat(string $strftimeFormat): string
|
||||
{
|
||||
$conversions = [
|
||||
// Special Ruby format cases
|
||||
'%N' => 'u', // Microseconds (Ruby) -> microseconds (PHP)
|
||||
'%u' => 'u', // Microseconds (Ruby) -> microseconds (PHP)
|
||||
'%-m' => 'n', // Month without leading zero (Ruby) -> month without leading zero (PHP)
|
||||
'%-d' => 'j', // Day without leading zero (Ruby) -> day without leading zero (PHP)
|
||||
'%-H' => 'G', // Hour without leading zero (Ruby) -> hour without leading zero (PHP)
|
||||
'%-I' => 'g', // Hour 12h without leading zero (Ruby) -> hour 12h without leading zero (PHP)
|
||||
'%-M' => 'i', // Minute without leading zero (Ruby) -> minute without leading zero (PHP)
|
||||
'%-S' => 's', // Second without leading zero (Ruby) -> second without leading zero (PHP)
|
||||
'%z' => 'O', // Timezone offset (Ruby) -> timezone offset (PHP)
|
||||
'%Z' => 'T', // Timezone name (Ruby) -> timezone name (PHP)
|
||||
|
||||
// Standard strftime conversions
|
||||
'%A' => 'l', // Full weekday name
|
||||
'%a' => 'D', // Abbreviated weekday name
|
||||
'%B' => 'F', // Full month name
|
||||
'%b' => 'M', // Abbreviated month name
|
||||
'%Y' => 'Y', // Full year (4 digits)
|
||||
'%y' => 'y', // Year without century (2 digits)
|
||||
'%m' => 'm', // Month as decimal number (01-12)
|
||||
'%d' => 'd', // Day of month as decimal number (01-31)
|
||||
'%H' => 'H', // Hour in 24-hour format (00-23)
|
||||
'%I' => 'h', // Hour in 12-hour format (01-12)
|
||||
'%M' => 'i', // Minute as decimal number (00-59)
|
||||
'%S' => 's', // Second as decimal number (00-59)
|
||||
'%p' => 'A', // AM/PM
|
||||
'%P' => 'a', // am/pm
|
||||
'%j' => 'z', // Day of year as decimal number (001-366)
|
||||
'%w' => 'w', // Weekday as decimal number (0-6, Sunday is 0)
|
||||
'%U' => 'W', // Week number of year (00-53, Sunday is first day)
|
||||
'%W' => 'W', // Week number of year (00-53, Monday is first day)
|
||||
'%c' => 'D M j H:i:s Y', // Date and time representation
|
||||
'%x' => 'm/d/Y', // Date representation
|
||||
'%X' => 'H:i:s', // Time representation
|
||||
];
|
||||
|
||||
return str_replace(array_keys($conversions), array_values($conversions), $strftimeFormat);
|
||||
}
|
||||
}
|
||||
|
|
@ -10,14 +10,14 @@ class DeviceAutoJoin extends Component
|
|||
|
||||
public bool $isFirstUser = false;
|
||||
|
||||
public function mount()
|
||||
public function mount(): void
|
||||
{
|
||||
$this->deviceAutojoin = auth()->user()->assign_new_devices;
|
||||
$this->isFirstUser = auth()->user()->id === 1;
|
||||
|
||||
}
|
||||
|
||||
public function updating($name, $value)
|
||||
public function updating($name, $value): void
|
||||
{
|
||||
$this->validate([
|
||||
'deviceAutojoin' => 'boolean',
|
||||
|
|
@ -30,7 +30,7 @@ class DeviceAutoJoin extends Component
|
|||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
public function render(): \Illuminate\Contracts\View\View|\Illuminate\Contracts\View\Factory
|
||||
{
|
||||
return view('livewire.actions.device-auto-join');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ class Logout
|
|||
/**
|
||||
* Log the current user out of the application.
|
||||
*/
|
||||
public function __invoke()
|
||||
public function __invoke(): \Illuminate\Routing\Redirector|\Illuminate\Http\RedirectResponse
|
||||
{
|
||||
Auth::guard('web')->logout();
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use Livewire\Component;
|
|||
|
||||
class DeviceDashboard extends Component
|
||||
{
|
||||
public function render()
|
||||
public function render(): \Illuminate\Contracts\View\View|\Illuminate\Contracts\View\Factory
|
||||
{
|
||||
return view('livewire.device-dashboard', ['devices' => auth()->user()->devices()->paginate(10)]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,22 +2,49 @@
|
|||
|
||||
namespace App\Models;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use DateTimeInterface;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
/**
|
||||
* @property-read DeviceModel|null $deviceModel
|
||||
* @property-read DevicePalette|null $palette
|
||||
*/
|
||||
class Device extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
/**
|
||||
* Set the MAC address attribute, normalizing to uppercase.
|
||||
*/
|
||||
public function setMacAddressAttribute(?string $value): void
|
||||
{
|
||||
$this->attributes['mac_address'] = $value ? mb_strtoupper($value) : null;
|
||||
}
|
||||
|
||||
protected $casts = [
|
||||
'battery_notification_sent' => 'boolean',
|
||||
'proxy_cloud' => 'boolean',
|
||||
'last_log_request' => 'json',
|
||||
'proxy_cloud_response' => 'json',
|
||||
'width' => 'integer',
|
||||
'height' => 'integer',
|
||||
'rotate' => 'integer',
|
||||
'last_refreshed_at' => 'datetime',
|
||||
'sleep_mode_enabled' => 'boolean',
|
||||
'sleep_mode_from' => 'datetime:H:i',
|
||||
'sleep_mode_to' => 'datetime:H:i',
|
||||
'special_function' => 'string',
|
||||
'pause_until' => 'datetime',
|
||||
];
|
||||
|
||||
public function getBatteryPercentAttribute()
|
||||
public function getBatteryPercentAttribute(): int|float
|
||||
{
|
||||
$volts = $this->last_battery_voltage;
|
||||
|
||||
|
|
@ -28,7 +55,8 @@ class Device extends Model
|
|||
// Ensure the voltage is within range
|
||||
if ($volts <= $min_volt) {
|
||||
return 0;
|
||||
} elseif ($volts >= $max_volt) {
|
||||
}
|
||||
if ($volts >= $max_volt) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
|
|
@ -38,17 +66,87 @@ class Device extends Model
|
|||
return round($percent);
|
||||
}
|
||||
|
||||
public function getWifiStrenghAttribute()
|
||||
/**
|
||||
* Calculate battery voltage from percentage
|
||||
*
|
||||
* @param int $percent Battery percentage (0-100)
|
||||
* @return float Calculated voltage
|
||||
*/
|
||||
public function calculateVoltageFromPercent(int $percent): float
|
||||
{
|
||||
// Define min and max voltage for Li-ion battery (3.0V empty, 4.2V full)
|
||||
$min_volt = 3.0;
|
||||
$max_volt = 4.2;
|
||||
|
||||
// Ensure the percentage is within range
|
||||
if ($percent <= 0) {
|
||||
return $min_volt;
|
||||
}
|
||||
if ($percent >= 100) {
|
||||
return $max_volt;
|
||||
}
|
||||
|
||||
// Calculate voltage
|
||||
$voltage = $min_volt + (($percent / 100) * ($max_volt - $min_volt));
|
||||
|
||||
return round($voltage, 2);
|
||||
}
|
||||
|
||||
public function getWifiStrengthAttribute(): int
|
||||
{
|
||||
$rssi = $this->last_rssi_level;
|
||||
if ($rssi >= 0) {
|
||||
return 0; // No signal (0 bars)
|
||||
} elseif ($rssi <= -80) {
|
||||
}
|
||||
if ($rssi <= -80) {
|
||||
return 1; // Weak signal (1 bar)
|
||||
} elseif ($rssi <= -60) {
|
||||
}
|
||||
if ($rssi <= -60) {
|
||||
return 2; // Moderate signal (2 bars)
|
||||
} else {
|
||||
return 3; // Strong signal (3 bars)
|
||||
}
|
||||
|
||||
return 3; // Strong signal (3 bars)
|
||||
|
||||
}
|
||||
|
||||
public function getUpdateFirmwareAttribute(): bool
|
||||
{
|
||||
if ($this->update_firmware_id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->proxy_cloud_response && $this->proxy_cloud_response['update_firmware'];
|
||||
}
|
||||
|
||||
public function getFirmwareUrlAttribute(): ?string
|
||||
{
|
||||
if ($this->update_firmware_id) {
|
||||
$firmware = Firmware::find($this->update_firmware_id);
|
||||
if ($firmware) {
|
||||
if ($firmware->storage_location) {
|
||||
return Storage::disk('public')->url($firmware->storage_location);
|
||||
}
|
||||
|
||||
return $firmware->url;
|
||||
}
|
||||
}
|
||||
|
||||
if ($this->proxy_cloud_response && $this->proxy_cloud_response['firmware_url']) {
|
||||
return $this->proxy_cloud_response['firmware_url'];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function resetUpdateFirmwareFlag(): void
|
||||
{
|
||||
if ($this->proxy_cloud_response) {
|
||||
$this->proxy_cloud_response = array_merge($this->proxy_cloud_response, ['update_firmware' => false]);
|
||||
$this->save();
|
||||
}
|
||||
if ($this->update_firmware_id) {
|
||||
$this->update_firmware_id = null;
|
||||
$this->save();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -60,6 +158,7 @@ class Device extends Model
|
|||
public function getNextPlaylistItem(): ?PlaylistItem
|
||||
{
|
||||
// Get all active playlists
|
||||
/** @var \Illuminate\Support\Collection|Playlist[] $playlists */
|
||||
$playlists = $this->playlists()
|
||||
->where('is_active', true)
|
||||
->get();
|
||||
|
|
@ -76,4 +175,112 @@ class Device extends Model
|
|||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function playlist(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Playlist::class);
|
||||
}
|
||||
|
||||
public function mirrorDevice(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(self::class, 'mirror_device_id');
|
||||
}
|
||||
|
||||
public function updateFirmware(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Firmware::class, 'update_firmware_id');
|
||||
}
|
||||
|
||||
public function deviceModel(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(DeviceModel::class);
|
||||
}
|
||||
|
||||
public function palette(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(DevicePalette::class, 'palette_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the color depth string (e.g., "4bit") for the associated device model.
|
||||
*/
|
||||
public function colorDepth(): ?string
|
||||
{
|
||||
return $this->deviceModel?->color_depth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the scale level (e.g., large/xlarge/xxlarge) for the associated device model.
|
||||
*/
|
||||
public function scaleLevel(): ?string
|
||||
{
|
||||
return $this->deviceModel?->scale_level;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the device variant name, defaulting to 'og' if not available.
|
||||
*/
|
||||
public function deviceVariant(): string
|
||||
{
|
||||
return $this->deviceModel->name ?? 'og';
|
||||
}
|
||||
|
||||
public function logs(): HasMany
|
||||
{
|
||||
return $this->hasMany(DeviceLog::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function isSleepModeActive(?DateTimeInterface $now = null): bool
|
||||
{
|
||||
if (! $this->sleep_mode_enabled || ! $this->sleep_mode_from || ! $this->sleep_mode_to) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$now = $now instanceof DateTimeInterface ? Carbon::instance($now) : now();
|
||||
|
||||
// Handle overnight ranges (e.g. 22:00 to 06:00)
|
||||
return $this->sleep_mode_from < $this->sleep_mode_to
|
||||
? $now->between($this->sleep_mode_from, $this->sleep_mode_to)
|
||||
: ($now->gte($this->sleep_mode_from) || $now->lte($this->sleep_mode_to));
|
||||
}
|
||||
|
||||
public function getSleepModeEndsInSeconds(?DateTimeInterface $now = null): ?int
|
||||
{
|
||||
if (! $this->sleep_mode_enabled || ! $this->sleep_mode_from || ! $this->sleep_mode_to) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$now = $now instanceof DateTimeInterface ? Carbon::instance($now) : now();
|
||||
$from = $this->sleep_mode_from;
|
||||
$to = $this->sleep_mode_to;
|
||||
|
||||
// Handle overnight ranges (e.g. 22:00 to 06:00)
|
||||
if ($from < $to) {
|
||||
// Normal range, same day
|
||||
return $now->between($from, $to) ? (int) $now->diffInSeconds($to, false) : null;
|
||||
}
|
||||
// Overnight range
|
||||
if ($now->gte($from)) {
|
||||
// After 'from', before midnight
|
||||
return (int) $now->diffInSeconds($to->copy()->addDay(), false);
|
||||
}
|
||||
if ($now->lt($to)) {
|
||||
// After midnight, before 'to'
|
||||
return (int) $now->diffInSeconds($to, false);
|
||||
}
|
||||
|
||||
// Not in sleep window
|
||||
return null;
|
||||
|
||||
}
|
||||
|
||||
public function isPauseActive(): bool
|
||||
{
|
||||
return $this->pause_until && $this->pause_until->isFuture();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
27
app/Models/DeviceLog.php
Normal file
27
app/Models/DeviceLog.php
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class DeviceLog extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
public function device(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Device::class);
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'log_entry' => 'array',
|
||||
'device_timestamp' => 'datetime',
|
||||
];
|
||||
}
|
||||
}
|
||||
78
app/Models/DeviceModel.php
Normal file
78
app/Models/DeviceModel.php
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* @property-read DevicePalette|null $palette
|
||||
*/
|
||||
final class DeviceModel extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected $casts = [
|
||||
'width' => 'integer',
|
||||
'height' => 'integer',
|
||||
'colors' => 'integer',
|
||||
'bit_depth' => 'integer',
|
||||
'scale_factor' => 'float',
|
||||
'rotation' => 'integer',
|
||||
'offset_x' => 'integer',
|
||||
'offset_y' => 'integer',
|
||||
'published_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function getColorDepthAttribute(): ?string
|
||||
{
|
||||
if (! $this->bit_depth) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->bit_depth === 3) {
|
||||
return '2bit';
|
||||
}
|
||||
|
||||
// if higher than 4 return 4bit
|
||||
if ($this->bit_depth > 4) {
|
||||
return '4bit';
|
||||
}
|
||||
|
||||
return $this->bit_depth.'bit';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the scale level based on the device width.
|
||||
*/
|
||||
public function getScaleLevelAttribute(): ?string
|
||||
{
|
||||
if (! $this->width) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->width > 800 && $this->width <= 1000) {
|
||||
return 'large';
|
||||
}
|
||||
|
||||
if ($this->width > 1000 && $this->width <= 1400) {
|
||||
return 'xlarge';
|
||||
}
|
||||
|
||||
if ($this->width > 1400) {
|
||||
return 'xxlarge';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function palette(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(DevicePalette::class, 'palette_id');
|
||||
}
|
||||
}
|
||||
23
app/Models/DevicePalette.php
Normal file
23
app/Models/DevicePalette.php
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* @property array|null $colors
|
||||
*/
|
||||
final class DevicePalette extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'grays' => 'integer',
|
||||
'colors' => 'array',
|
||||
];
|
||||
}
|
||||
25
app/Models/Firmware.php
Normal file
25
app/Models/Firmware.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Firmware extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'latest' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
public static function getLatest(): ?self
|
||||
{
|
||||
return self::where('latest', true)->first();
|
||||
}
|
||||
}
|
||||
|
|
@ -18,6 +18,7 @@ class Playlist extends Model
|
|||
'weekdays' => 'array',
|
||||
'active_from' => 'datetime:H:i',
|
||||
'active_until' => 'datetime:H:i',
|
||||
'refresh_time' => 'integer',
|
||||
];
|
||||
|
||||
public function device(): BelongsTo
|
||||
|
|
@ -36,17 +37,36 @@ class Playlist extends Model
|
|||
return false;
|
||||
}
|
||||
|
||||
// Check weekday
|
||||
if ($this->weekdays !== null) {
|
||||
if (! in_array(now()->dayOfWeek, $this->weekdays)) {
|
||||
return false;
|
||||
}
|
||||
// Get user's timezone or fall back to app timezone
|
||||
$timezone = $this->device->user->timezone ?? config('app.timezone');
|
||||
$now = now($timezone);
|
||||
|
||||
// Check weekday (using timezone-aware time)
|
||||
if ($this->weekdays !== null && ! in_array($now->dayOfWeek, $this->weekdays)) {
|
||||
return false;
|
||||
}
|
||||
// Check time range
|
||||
|
||||
if ($this->active_from !== null && $this->active_until !== null) {
|
||||
if (! now()->between($this->active_from, $this->active_until)) {
|
||||
return false;
|
||||
// Create timezone-aware datetime objects for active_from and active_until
|
||||
$activeFrom = $now->copy()
|
||||
->setTimeFrom($this->active_from)
|
||||
->timezone($timezone);
|
||||
|
||||
$activeUntil = $now->copy()
|
||||
->setTimeFrom($this->active_until)
|
||||
->timezone($timezone);
|
||||
|
||||
// Handle time ranges that span across midnight
|
||||
if ($activeFrom > $activeUntil) {
|
||||
// Time range spans midnight (e.g., 09:01 to 03:58)
|
||||
if ($now >= $activeFrom || $now <= $activeUntil) {
|
||||
return true;
|
||||
}
|
||||
} elseif ($now >= $activeFrom && $now <= $activeUntil) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
|
|
@ -59,6 +79,7 @@ class Playlist extends Model
|
|||
}
|
||||
|
||||
// Get active playlist items ordered by display order
|
||||
/** @var \Illuminate\Support\Collection|PlaylistItem[] $playlistItems */
|
||||
$playlistItems = $this->items()
|
||||
->where('is_active', true)
|
||||
->orderBy('order')
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Models;
|
||||
|
||||
use Exception;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
|
@ -15,6 +16,7 @@ class PlaylistItem extends Model
|
|||
protected $casts = [
|
||||
'is_active' => 'boolean',
|
||||
'last_displayed_at' => 'datetime',
|
||||
'mashup' => 'json',
|
||||
];
|
||||
|
||||
public function playlist(): BelongsTo
|
||||
|
|
@ -26,4 +28,191 @@ class PlaylistItem extends Model
|
|||
{
|
||||
return $this->belongsTo(Plugin::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this playlist item is a mashup
|
||||
*/
|
||||
public function isMashup(): bool
|
||||
{
|
||||
return ! is_null($this->mashup);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mashup name if this is a mashup
|
||||
*/
|
||||
public function getMashupName(): ?string
|
||||
{
|
||||
return $this->mashup['mashup_name'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mashup layout type if this is a mashup
|
||||
*/
|
||||
public function getMashupLayoutType(): ?string
|
||||
{
|
||||
return $this->mashup['mashup_layout'] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all plugin IDs for this mashup
|
||||
*/
|
||||
public function getMashupPluginIds(): array
|
||||
{
|
||||
return $this->mashup['plugin_ids'] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of plugins required for the current layout
|
||||
*/
|
||||
public function getRequiredPluginCount(): int
|
||||
{
|
||||
if (! $this->isMashup()) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return match ($this->getMashupLayoutType()) {
|
||||
'1Lx1R', '1Tx1B' => 2, // Left-Right or Top-Bottom split
|
||||
'1Lx2R', '2Lx1R', '2Tx1B', '1Tx2B' => 3, // Two on one side, one on other
|
||||
'2x2' => 4, // Quadrant
|
||||
default => 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the layout type (horizontal, vertical, or grid)
|
||||
*/
|
||||
public function getLayoutType(): string
|
||||
{
|
||||
if (! $this->isMashup()) {
|
||||
return 'single';
|
||||
}
|
||||
|
||||
return match ($this->getMashupLayoutType()) {
|
||||
'1Lx1R', '1Lx2R', '2Lx1R' => 'vertical',
|
||||
'1Tx1B', '2Tx1B', '1Tx2B' => 'horizontal',
|
||||
'2x2' => 'grid',
|
||||
default => 'single',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the layout size for a plugin based on its position
|
||||
*/
|
||||
public function getLayoutSize(int $position = 0): string
|
||||
{
|
||||
if (! $this->isMashup()) {
|
||||
return 'full';
|
||||
}
|
||||
|
||||
return match ($this->getMashupLayoutType()) {
|
||||
'1Lx1R' => 'half_vertical', // Both sides are single plugins
|
||||
'1Tx1B' => 'half_horizontal', // Both sides are single plugins
|
||||
'2Lx1R' => match ($position) {
|
||||
0, 1 => 'quadrant', // Left side has 2 plugins
|
||||
2 => 'half_vertical', // Right side has 1 plugin
|
||||
default => 'full'
|
||||
},
|
||||
'1Lx2R' => match ($position) {
|
||||
0 => 'half_vertical', // Left side has 1 plugin
|
||||
1, 2 => 'quadrant', // Right side has 2 plugins
|
||||
default => 'full'
|
||||
},
|
||||
'2Tx1B' => match ($position) {
|
||||
0, 1 => 'quadrant', // Top side has 2 plugins
|
||||
2 => 'half_horizontal', // Bottom side has 1 plugin
|
||||
default => 'full'
|
||||
},
|
||||
'1Tx2B' => match ($position) {
|
||||
0 => 'half_horizontal', // Top side has 1 plugin
|
||||
1, 2 => 'quadrant', // Bottom side has 2 plugins
|
||||
default => 'full'
|
||||
},
|
||||
'2x2' => 'quadrant', // All positions are quadrants
|
||||
default => 'full'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Render all plugins with appropriate layout
|
||||
*/
|
||||
public function render(?Device $device = null): string
|
||||
{
|
||||
if (! $this->isMashup()) {
|
||||
return view('trmnl-layouts.single', [
|
||||
'colorDepth' => $device?->colorDepth(),
|
||||
'deviceVariant' => $device?->deviceVariant() ?? 'og',
|
||||
'scaleLevel' => $device?->scaleLevel(),
|
||||
'slot' => $this->plugin instanceof Plugin
|
||||
? $this->plugin->render('full', false)
|
||||
: throw new Exception('Invalid plugin instance'),
|
||||
])->render();
|
||||
}
|
||||
|
||||
$pluginMarkups = [];
|
||||
$pluginIds = $this->getMashupPluginIds();
|
||||
$plugins = Plugin::whereIn('id', $pluginIds)->get();
|
||||
|
||||
// Sort the collection to match plugin_ids order
|
||||
$plugins = $plugins->sortBy(fn ($plugin): int|string|false => array_search($plugin->id, $pluginIds))->values();
|
||||
|
||||
foreach ($plugins as $index => $plugin) {
|
||||
$size = $this->getLayoutSize($index);
|
||||
$pluginMarkups[] = $plugin->render($size, false);
|
||||
}
|
||||
|
||||
return view('trmnl-layouts.mashup', [
|
||||
'colorDepth' => $device?->colorDepth(),
|
||||
'deviceVariant' => $device?->deviceVariant() ?? 'og',
|
||||
'scaleLevel' => $device?->scaleLevel(),
|
||||
'mashupLayout' => $this->getMashupLayoutType(),
|
||||
'slot' => implode('', $pluginMarkups),
|
||||
])->render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Available mashup layouts with their descriptions
|
||||
*/
|
||||
public static function getAvailableLayouts(): array
|
||||
{
|
||||
return [
|
||||
'1Lx1R' => '1 Left - 1 Right (2 plugins)',
|
||||
'1Lx2R' => '1 Left - 2 Right (3 plugins)',
|
||||
'2Lx1R' => '2 Left - 1 Right (3 plugins)',
|
||||
'1Tx1B' => '1 Top - 1 Bottom (2 plugins)',
|
||||
'2Tx1B' => '2 Top - 1 Bottom (3 plugins)',
|
||||
'1Tx2B' => '1 Top - 2 Bottom (3 plugins)',
|
||||
'2x2' => 'Quadrant (4 plugins)',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the required number of plugins for a given layout
|
||||
*/
|
||||
public static function getRequiredPluginCountForLayout(string $layout): int
|
||||
{
|
||||
return match ($layout) {
|
||||
'1Lx1R', '1Tx1B' => 2,
|
||||
'1Lx2R', '2Lx1R', '2Tx1B', '1Tx2B' => 3,
|
||||
'2x2' => 4,
|
||||
default => 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new mashup with the given layout and plugins
|
||||
*/
|
||||
public static function createMashup(Playlist $playlist, string $layout, array $pluginIds, string $name, $order): self
|
||||
{
|
||||
return static::create([
|
||||
'playlist_id' => $playlist->id,
|
||||
'plugin_id' => $pluginIds[0], // First plugin is the main plugin
|
||||
'mashup' => [
|
||||
'mashup_layout' => $layout,
|
||||
'mashup_name' => $name,
|
||||
'plugin_ids' => $pluginIds,
|
||||
],
|
||||
'is_active' => true,
|
||||
'order' => $order,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,32 @@
|
|||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Liquid\FileSystems\InlineTemplatesFileSystem;
|
||||
use App\Liquid\Filters\Data;
|
||||
use App\Liquid\Filters\Date;
|
||||
use App\Liquid\Filters\Localization;
|
||||
use App\Liquid\Filters\Numbers;
|
||||
use App\Liquid\Filters\StandardFilters;
|
||||
use App\Liquid\Filters\StringMarkup;
|
||||
use App\Liquid\Filters\Uniqueness;
|
||||
use App\Liquid\Tags\TemplateTag;
|
||||
use App\Services\Plugin\Parsers\ResponseParserRegistry;
|
||||
use App\Services\PluginImportService;
|
||||
use Carbon\Carbon;
|
||||
use Exception;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\App;
|
||||
use Illuminate\Support\Facades\Blade;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Process;
|
||||
use Illuminate\Support\Str;
|
||||
use InvalidArgumentException;
|
||||
use Keepsuit\LaravelLiquid\LaravelLiquidExtension;
|
||||
use Keepsuit\Liquid\Exceptions\LiquidException;
|
||||
use Keepsuit\Liquid\Extensions\StandardExtension;
|
||||
|
||||
class Plugin extends Model
|
||||
{
|
||||
|
|
@ -17,21 +39,112 @@ class Plugin extends Model
|
|||
'data_payload' => 'json',
|
||||
'data_payload_updated_at' => 'datetime',
|
||||
'is_native' => 'boolean',
|
||||
'markup_language' => 'string',
|
||||
'configuration' => 'json',
|
||||
'configuration_template' => 'json',
|
||||
'no_bleed' => 'boolean',
|
||||
'dark_mode' => 'boolean',
|
||||
'preferred_renderer' => 'string',
|
||||
'plugin_type' => 'string',
|
||||
'alias' => 'boolean',
|
||||
];
|
||||
|
||||
protected static function boot()
|
||||
{
|
||||
parent::boot();
|
||||
|
||||
static::creating(function ($model) {
|
||||
static::creating(function ($model): void {
|
||||
if (empty($model->uuid)) {
|
||||
$model->uuid = Str::uuid();
|
||||
}
|
||||
});
|
||||
|
||||
static::updating(function ($model): void {
|
||||
// Reset image cache when markup changes
|
||||
if ($model->isDirty('render_markup')) {
|
||||
$model->current_image = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Sanitize configuration template on save
|
||||
static::saving(function ($model): void {
|
||||
$model->sanitizeTemplate();
|
||||
});
|
||||
}
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
// sanitize configuration template descriptions and help texts (since they allow HTML rendering)
|
||||
protected function sanitizeTemplate(): void
|
||||
{
|
||||
$template = $this->configuration_template;
|
||||
|
||||
if (isset($template['custom_fields']) && is_array($template['custom_fields'])) {
|
||||
foreach ($template['custom_fields'] as &$field) {
|
||||
if (isset($field['description'])) {
|
||||
$field['description'] = \Stevebauman\Purify\Facades\Purify::clean($field['description']);
|
||||
}
|
||||
if (isset($field['help_text'])) {
|
||||
$field['help_text'] = \Stevebauman\Purify\Facades\Purify::clean($field['help_text']);
|
||||
}
|
||||
}
|
||||
|
||||
$this->configuration_template = $template;
|
||||
}
|
||||
}
|
||||
|
||||
public function hasMissingRequiredConfigurationFields(): bool
|
||||
{
|
||||
if (! isset($this->configuration_template['custom_fields']) || empty($this->configuration_template['custom_fields'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($this->configuration_template['custom_fields'] as $field) {
|
||||
// Skip fields as they are informational only
|
||||
if ($field['field_type'] === 'author_bio') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($field['field_type'] === 'copyable') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($field['field_type'] === 'copyable_webhook_url') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$fieldKey = $field['keyname'] ?? $field['key'] ?? $field['name'];
|
||||
|
||||
// Check if field is required (not marked as optional)
|
||||
$isRequired = ! isset($field['optional']) || $field['optional'] !== true;
|
||||
|
||||
if ($isRequired) {
|
||||
$currentValue = $this->configuration[$fieldKey] ?? null;
|
||||
|
||||
// If the field has a default value and no current value is set, it's not missing
|
||||
if ((in_array($currentValue, [null, '', []], true)) && ! isset($field['default'])) {
|
||||
return true; // Found a required field that is not set and has no default
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false; // All required fields are set
|
||||
}
|
||||
|
||||
public function isDataStale(): bool
|
||||
{
|
||||
// Image webhook plugins don't use data staleness - images are pushed directly
|
||||
if ($this->plugin_type === 'image_webhook') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($this->data_strategy === 'webhook') {
|
||||
// Treat as stale if any webhook event has occurred in the past hour
|
||||
return $this->data_payload_updated_at && $this->data_payload_updated_at->gt(now()->subHour());
|
||||
}
|
||||
if (! $this->data_payload_updated_at || ! $this->data_stale_minutes) {
|
||||
return true;
|
||||
}
|
||||
|
|
@ -41,12 +154,489 @@ class Plugin extends Model
|
|||
|
||||
public function updateDataPayload(): void
|
||||
{
|
||||
if ($this->data_strategy === 'polling' && $this->polling_url) {
|
||||
$response = Http::get($this->polling_url)->json();
|
||||
$this->update([
|
||||
'data_payload' => $response,
|
||||
'data_payload_updated_at' => now(),
|
||||
]);
|
||||
if ($this->data_strategy !== 'polling' || ! $this->polling_url) {
|
||||
return;
|
||||
}
|
||||
$headers = ['User-Agent' => 'usetrmnl/byos_laravel', 'Accept' => 'application/json'];
|
||||
|
||||
// resolve headers
|
||||
if ($this->polling_header) {
|
||||
$resolvedHeader = $this->resolveLiquidVariables($this->polling_header);
|
||||
$headerLines = explode("\n", mb_trim($resolvedHeader));
|
||||
foreach ($headerLines as $line) {
|
||||
$parts = explode(':', $line, 2);
|
||||
if (count($parts) === 2) {
|
||||
$headers[mb_trim($parts[0])] = mb_trim($parts[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// resolve and clean URLs
|
||||
$resolvedPollingUrls = $this->resolveLiquidVariables($this->polling_url);
|
||||
$urls = array_values(array_filter( // array_values ensures 0, 1, 2...
|
||||
array_map('trim', explode("\n", $resolvedPollingUrls)),
|
||||
fn ($url): bool => filled($url)
|
||||
));
|
||||
|
||||
$combinedResponse = [];
|
||||
|
||||
// Loop through all URLs (Handles 1 or many)
|
||||
foreach ($urls as $index => $url) {
|
||||
$httpRequest = Http::withHeaders($headers);
|
||||
|
||||
if ($this->polling_verb === 'post' && $this->polling_body) {
|
||||
$resolvedBody = $this->resolveLiquidVariables($this->polling_body);
|
||||
$httpRequest = $httpRequest->withBody($resolvedBody);
|
||||
}
|
||||
|
||||
try {
|
||||
$httpResponse = ($this->polling_verb === 'post')
|
||||
? $httpRequest->post($url)
|
||||
: $httpRequest->get($url);
|
||||
|
||||
$response = $this->parseResponse($httpResponse);
|
||||
|
||||
// Nest if it's a sequential array
|
||||
if (array_keys($response) === range(0, count($response) - 1)) {
|
||||
$combinedResponse["IDX_{$index}"] = ['data' => $response];
|
||||
} else {
|
||||
$combinedResponse["IDX_{$index}"] = $response;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
Log::warning("Failed to fetch data from URL {$url}: ".$e->getMessage());
|
||||
$combinedResponse["IDX_{$index}"] = ['error' => 'Failed to fetch data'];
|
||||
}
|
||||
}
|
||||
|
||||
// unwrap IDX_0 if only one URL
|
||||
$finalPayload = (count($urls) === 1) ? reset($combinedResponse) : $combinedResponse;
|
||||
|
||||
$this->update([
|
||||
'data_payload' => $finalPayload,
|
||||
'data_payload_updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function parseResponse(Response $httpResponse): array
|
||||
{
|
||||
$parsers = app(ResponseParserRegistry::class)->getParsers();
|
||||
|
||||
foreach ($parsers as $parser) {
|
||||
$parserName = class_basename($parser);
|
||||
|
||||
try {
|
||||
$result = $parser->parse($httpResponse);
|
||||
|
||||
if ($result !== null) {
|
||||
return $result;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
Log::warning("Failed to parse {$parserName} response: ".$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return ['error' => 'Failed to parse response'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply Liquid template replacements (converts 'with' syntax to comma syntax)
|
||||
*/
|
||||
private function applyLiquidReplacements(string $template): string
|
||||
{
|
||||
|
||||
$replacements = [];
|
||||
|
||||
// Apply basic replacements
|
||||
$template = str_replace(array_keys($replacements), array_values($replacements), $template);
|
||||
|
||||
// Convert Ruby/strftime date formats to PHP date formats
|
||||
$template = $this->convertDateFormats($template);
|
||||
|
||||
// Convert {% render "template" with %} syntax to {% render "template", %} syntax
|
||||
$template = preg_replace(
|
||||
'/{%\s*render\s+([^}]+?)\s+with\s+/i',
|
||||
'{% render $1, ',
|
||||
$template
|
||||
);
|
||||
|
||||
// Convert for loops with filters to use temporary variables
|
||||
// This handles: {% for item in collection | filter: "key", "value" %}
|
||||
// Converts to: {% assign temp_filtered = collection | filter: "key", "value" %}{% for item in temp_filtered %}
|
||||
$template = preg_replace_callback(
|
||||
'/{%\s*for\s+(\w+)\s+in\s+([^|%}]+)\s*\|\s*([^%}]+)%}/',
|
||||
function (array $matches): string {
|
||||
$variableName = mb_trim($matches[1]);
|
||||
$collection = mb_trim($matches[2]);
|
||||
$filter = mb_trim($matches[3]);
|
||||
$tempVarName = '_temp_'.uniqid();
|
||||
|
||||
return "{% assign {$tempVarName} = {$collection} | {$filter} %}{% for {$variableName} in {$tempVarName} %}";
|
||||
},
|
||||
(string) $template
|
||||
);
|
||||
|
||||
return $template;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Ruby/strftime date formats to PHP date formats in Liquid templates
|
||||
*/
|
||||
private function convertDateFormats(string $template): string
|
||||
{
|
||||
// Handle date filter formats: date: "format" or date: 'format'
|
||||
$template = preg_replace_callback(
|
||||
'/date:\s*(["\'])([^"\']+)\1/',
|
||||
function (array $matches): string {
|
||||
$quote = $matches[1];
|
||||
$format = $matches[2];
|
||||
$convertedFormat = \App\Liquid\Utils\ExpressionUtils::strftimeToPhpFormat($format);
|
||||
|
||||
return 'date: '.$quote.$convertedFormat.$quote;
|
||||
},
|
||||
$template
|
||||
);
|
||||
|
||||
// Handle l_date filter formats: l_date: "format" or l_date: 'format'
|
||||
$template = preg_replace_callback(
|
||||
'/l_date:\s*(["\'])([^"\']+)\1/',
|
||||
function (array $matches): string {
|
||||
$quote = $matches[1];
|
||||
$format = $matches[2];
|
||||
$convertedFormat = \App\Liquid\Utils\ExpressionUtils::strftimeToPhpFormat($format);
|
||||
|
||||
return 'l_date: '.$quote.$convertedFormat.$quote;
|
||||
},
|
||||
(string) $template
|
||||
);
|
||||
|
||||
return $template;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a template contains a Liquid for loop pattern
|
||||
*
|
||||
* @param string $template The template string to check
|
||||
* @return bool True if the template contains a for loop pattern
|
||||
*/
|
||||
private function containsLiquidForLoop(string $template): bool
|
||||
{
|
||||
return preg_match('/{%-?\s*for\s+/i', $template) === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve Liquid variables in a template string using the Liquid template engine
|
||||
*
|
||||
* Uses the external trmnl-liquid renderer when:
|
||||
* - preferred_renderer is 'trmnl-liquid'
|
||||
* - External renderer is enabled in config
|
||||
* - Template contains a Liquid for loop pattern
|
||||
*
|
||||
* Otherwise uses the internal PHP-based Liquid renderer.
|
||||
*
|
||||
* @param string $template The template string containing Liquid variables
|
||||
* @return string The resolved template with variables replaced with their values
|
||||
*
|
||||
* @throws LiquidException
|
||||
* @throws Exception
|
||||
*/
|
||||
public function resolveLiquidVariables(string $template): string
|
||||
{
|
||||
// Get configuration variables - make them available at root level
|
||||
$variables = $this->configuration ?? [];
|
||||
|
||||
// Check if external renderer should be used
|
||||
$useExternalRenderer = $this->preferred_renderer === 'trmnl-liquid'
|
||||
&& config('services.trmnl.liquid_enabled')
|
||||
&& $this->containsLiquidForLoop($template);
|
||||
|
||||
if ($useExternalRenderer) {
|
||||
// Use external Ruby liquid renderer
|
||||
return $this->renderWithExternalLiquidRenderer($template, $variables);
|
||||
}
|
||||
|
||||
// Use the Liquid template engine to resolve variables
|
||||
$environment = App::make('liquid.environment');
|
||||
$environment->filterRegistry->register(StandardFilters::class);
|
||||
$liquidTemplate = $environment->parseString($template);
|
||||
$context = $environment->newRenderContext(data: $variables);
|
||||
|
||||
return $liquidTemplate->render($context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render template using external Ruby liquid renderer
|
||||
*
|
||||
* @param string $template The liquid template string
|
||||
* @param array $context The render context data
|
||||
* @return string The rendered HTML
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
private function renderWithExternalLiquidRenderer(string $template, array $context): string
|
||||
{
|
||||
$liquidPath = config('services.trmnl.liquid_path');
|
||||
|
||||
if (empty($liquidPath)) {
|
||||
throw new Exception('External liquid renderer path is not configured');
|
||||
}
|
||||
|
||||
// HTML encode the template
|
||||
$encodedTemplate = htmlspecialchars($template, ENT_QUOTES, 'UTF-8');
|
||||
|
||||
// Encode context as JSON
|
||||
$jsonContext = json_encode($context, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
if ($jsonContext === false) {
|
||||
throw new Exception('Failed to encode render context as JSON: '.json_last_error_msg());
|
||||
}
|
||||
|
||||
// Validate argument sizes
|
||||
app(PluginImportService::class)->validateExternalRendererArguments($encodedTemplate, $jsonContext, $liquidPath);
|
||||
|
||||
// Execute the external renderer
|
||||
$process = Process::run([
|
||||
$liquidPath,
|
||||
'--template',
|
||||
$encodedTemplate,
|
||||
'--context',
|
||||
$jsonContext,
|
||||
]);
|
||||
|
||||
if (! $process->successful()) {
|
||||
$errorOutput = $process->errorOutput() ?: $process->output();
|
||||
throw new Exception('External liquid renderer failed: '.$errorOutput);
|
||||
}
|
||||
|
||||
return $process->output();
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the plugin's markup
|
||||
*
|
||||
* @throws LiquidException
|
||||
*/
|
||||
public function render(string $size = 'full', bool $standalone = true, ?Device $device = null): string
|
||||
{
|
||||
if ($this->plugin_type !== 'recipe') {
|
||||
throw new InvalidArgumentException('Render method is only applicable for recipe plugins.');
|
||||
}
|
||||
|
||||
if ($this->render_markup) {
|
||||
$renderedContent = '';
|
||||
|
||||
if ($this->markup_language === 'liquid') {
|
||||
// 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();
|
||||
|
||||
// Build render context
|
||||
$context = [
|
||||
'size' => $size,
|
||||
'data' => $this->data_payload,
|
||||
'config' => $this->configuration ?? [],
|
||||
...(is_array($this->data_payload) ? $this->data_payload : []),
|
||||
'trmnl' => [
|
||||
'system' => [
|
||||
'timestamp_utc' => now()->utc()->timestamp,
|
||||
],
|
||||
'user' => [
|
||||
'utc_offset' => $utcOffset,
|
||||
'name' => $this->user->name ?? 'Unknown User',
|
||||
'locale' => 'en',
|
||||
'time_zone_iana' => $timezone,
|
||||
],
|
||||
'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 : []),
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
// Check if external renderer should be used
|
||||
if ($this->preferred_renderer === 'trmnl-liquid' && config('services.trmnl.liquid_enabled')) {
|
||||
// Use external Ruby renderer - pass raw template without preprocessing
|
||||
$renderedContent = $this->renderWithExternalLiquidRenderer($this->render_markup, $context);
|
||||
} else {
|
||||
// Use PHP keepsuit/liquid renderer
|
||||
// Create a custom environment with inline templates support
|
||||
$inlineFileSystem = new InlineTemplatesFileSystem();
|
||||
$environment = new \Keepsuit\Liquid\Environment(
|
||||
fileSystem: $inlineFileSystem,
|
||||
extensions: [new StandardExtension(), new LaravelLiquidExtension()]
|
||||
);
|
||||
|
||||
// Register all custom filters
|
||||
$environment->filterRegistry->register(Data::class);
|
||||
$environment->filterRegistry->register(Date::class);
|
||||
$environment->filterRegistry->register(Localization::class);
|
||||
$environment->filterRegistry->register(Numbers::class);
|
||||
$environment->filterRegistry->register(StringMarkup::class);
|
||||
$environment->filterRegistry->register(Uniqueness::class);
|
||||
|
||||
// Register the template tag for inline templates
|
||||
$environment->tagRegistry->register(TemplateTag::class);
|
||||
|
||||
// Apply Liquid replacements (including 'with' syntax conversion)
|
||||
$processedMarkup = $this->applyLiquidReplacements($this->render_markup);
|
||||
|
||||
$template = $environment->parseString($processedMarkup);
|
||||
$liquidContext = $environment->newRenderContext(data: $context);
|
||||
$renderedContent = $template->render($liquidContext);
|
||||
}
|
||||
} else {
|
||||
$renderedContent = Blade::render($this->render_markup, [
|
||||
'size' => $size,
|
||||
'data' => $this->data_payload,
|
||||
'config' => $this->configuration ?? [],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($standalone) {
|
||||
if ($size === 'full') {
|
||||
return view('trmnl-layouts.single', [
|
||||
'colorDepth' => $device?->colorDepth(),
|
||||
'deviceVariant' => $device?->deviceVariant() ?? 'og',
|
||||
'noBleed' => $this->no_bleed,
|
||||
'darkMode' => $this->dark_mode,
|
||||
'scaleLevel' => $device?->scaleLevel(),
|
||||
'slot' => $renderedContent,
|
||||
])->render();
|
||||
}
|
||||
|
||||
return view('trmnl-layouts.mashup', [
|
||||
'mashupLayout' => $this->getPreviewMashupLayoutForSize($size),
|
||||
'colorDepth' => $device?->colorDepth(),
|
||||
'deviceVariant' => $device?->deviceVariant() ?? 'og',
|
||||
'darkMode' => $this->dark_mode,
|
||||
'scaleLevel' => $device?->scaleLevel(),
|
||||
'slot' => $renderedContent,
|
||||
])->render();
|
||||
|
||||
}
|
||||
|
||||
return $renderedContent;
|
||||
}
|
||||
|
||||
if ($this->render_markup_view) {
|
||||
if ($standalone) {
|
||||
$renderedView = view($this->render_markup_view, [
|
||||
'size' => $size,
|
||||
'data' => $this->data_payload,
|
||||
'config' => $this->configuration ?? [],
|
||||
])->render();
|
||||
|
||||
if ($size === 'full') {
|
||||
return view('trmnl-layouts.single', [
|
||||
'colorDepth' => $device?->colorDepth(),
|
||||
'deviceVariant' => $device?->deviceVariant() ?? 'og',
|
||||
'noBleed' => $this->no_bleed,
|
||||
'darkMode' => $this->dark_mode,
|
||||
'scaleLevel' => $device?->scaleLevel(),
|
||||
'slot' => $renderedView,
|
||||
])->render();
|
||||
}
|
||||
|
||||
return view('trmnl-layouts.mashup', [
|
||||
'mashupLayout' => $this->getPreviewMashupLayoutForSize($size),
|
||||
'colorDepth' => $device?->colorDepth(),
|
||||
'deviceVariant' => $device?->deviceVariant() ?? 'og',
|
||||
'darkMode' => $this->dark_mode,
|
||||
'scaleLevel' => $device?->scaleLevel(),
|
||||
'slot' => $renderedView,
|
||||
])->render();
|
||||
}
|
||||
|
||||
return view($this->render_markup_view, [
|
||||
'size' => $size,
|
||||
'data' => $this->data_payload,
|
||||
'config' => $this->configuration ?? [],
|
||||
])->render();
|
||||
|
||||
}
|
||||
|
||||
return '<p>No render markup yet defined for this plugin.</p>';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a configuration value by key
|
||||
*/
|
||||
public function getConfiguration(string $key, $default = null)
|
||||
{
|
||||
return $this->configuration[$key] ?? $default;
|
||||
}
|
||||
|
||||
public function getPreviewMashupLayoutForSize(string $size): string
|
||||
{
|
||||
return match ($size) {
|
||||
'half_vertical' => '1Lx1R',
|
||||
'quadrant' => '2x2',
|
||||
default => '1Tx1B',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate the plugin, copying all attributes and handling render_markup_view
|
||||
*
|
||||
* @param int|null $userId Optional user ID for the duplicate. If not provided, uses the original plugin's user_id.
|
||||
* @return Plugin The newly created duplicate plugin
|
||||
*/
|
||||
public function duplicate(?int $userId = null): self
|
||||
{
|
||||
// Get all attributes except id and uuid
|
||||
// Use toArray() to get cast values (respects JSON casts)
|
||||
$attributes = $this->toArray();
|
||||
unset($attributes['id'], $attributes['uuid']);
|
||||
|
||||
// Handle render_markup_view - copy file content to render_markup
|
||||
if ($this->render_markup_view) {
|
||||
try {
|
||||
$basePath = resource_path('views/'.str_replace('.', '/', $this->render_markup_view));
|
||||
$paths = [
|
||||
$basePath.'.blade.php',
|
||||
$basePath.'.liquid',
|
||||
];
|
||||
|
||||
$fileContent = null;
|
||||
$markupLanguage = null;
|
||||
foreach ($paths as $path) {
|
||||
if (file_exists($path)) {
|
||||
$fileContent = file_get_contents($path);
|
||||
// Determine markup language based on file extension
|
||||
$markupLanguage = str_ends_with($path, '.liquid') ? 'liquid' : 'blade';
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($fileContent !== null) {
|
||||
$attributes['render_markup'] = $fileContent;
|
||||
$attributes['markup_language'] = $markupLanguage;
|
||||
$attributes['render_markup_view'] = null;
|
||||
} else {
|
||||
// File doesn't exist, remove the view reference
|
||||
$attributes['render_markup_view'] = null;
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
// If file reading fails, remove the view reference
|
||||
$attributes['render_markup_view'] = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
// Create and return the new plugin
|
||||
return self::create($attributes);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,9 @@ class User extends Authenticatable // implements MustVerifyEmail
|
|||
'email',
|
||||
'password',
|
||||
'assign_new_devices',
|
||||
'assign_new_device_id',
|
||||
'oidc_sub',
|
||||
'timezone',
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
@ -71,4 +74,9 @@ class User extends Authenticatable // implements MustVerifyEmail
|
|||
{
|
||||
return $this->hasMany(Plugin::class);
|
||||
}
|
||||
|
||||
public function routeNotificationForWebhook(): ?string
|
||||
{
|
||||
return config('services.webhook.notifications.url');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
66
app/Notifications/BatteryLow.php
Normal file
66
app/Notifications/BatteryLow.php
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\Device;
|
||||
use App\Notifications\Channels\WebhookChannel;
|
||||
use App\Notifications\Messages\WebhookMessage;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class BatteryLow extends Notification
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
/**
|
||||
* Create a new notification instance.
|
||||
*/
|
||||
public function __construct(private Device $device) {}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['mail', WebhookChannel::class];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mail representation of the notification.
|
||||
*/
|
||||
public function toMail(object $notifiable): MailMessage
|
||||
{
|
||||
return (new MailMessage)->markdown('mail.battery-low', ['device' => $this->device]);
|
||||
}
|
||||
|
||||
public function toWebhook(object $notifiable): WebhookMessage
|
||||
{
|
||||
return WebhookMessage::create()
|
||||
->data([
|
||||
'topic' => config('services.webhook.notifications.topic', 'battery.low'),
|
||||
'message' => "Battery below {$this->device->battery_percent}% on device: {$this->device->name}",
|
||||
'device_id' => $this->device->id,
|
||||
'device_name' => $this->device->name,
|
||||
'battery_percent' => $this->device->battery_percent,
|
||||
|
||||
])
|
||||
->userAgent(config('app.name'))
|
||||
->header('X-TrmnlByos-Event', 'battery.low');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the array representation of the notification.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
return [
|
||||
'device_name' => $this->device->name,
|
||||
'battery_percent' => $this->device->battery_percent,
|
||||
];
|
||||
}
|
||||
}
|
||||
54
app/Notifications/Channels/WebhookChannel.php
Normal file
54
app/Notifications/Channels/WebhookChannel.php
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<?php
|
||||
|
||||
namespace App\Notifications\Channels;
|
||||
|
||||
use Exception;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class WebhookChannel extends Notification
|
||||
{
|
||||
public function __construct(protected Client $client) {}
|
||||
|
||||
/**
|
||||
* Send the given notification.
|
||||
*
|
||||
* @param mixed $notifiable
|
||||
*
|
||||
* @throws Exception
|
||||
* @throws GuzzleException
|
||||
*/
|
||||
public function send($notifiable, Notification $notification): ?Response
|
||||
{
|
||||
$url = $notifiable->routeNotificationFor('webhook', $notification);
|
||||
|
||||
if (! $url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! method_exists($notification, 'toWebhook')) {
|
||||
throw new Exception('Notification does not implement toWebhook method.');
|
||||
}
|
||||
|
||||
$webhookData = $notification->toWebhook($notifiable)->toArray();
|
||||
$response = $this->client->post($url, [
|
||||
'query' => Arr::get($webhookData, 'query'),
|
||||
'body' => json_encode(Arr::get($webhookData, 'data')),
|
||||
'verify' => Arr::get($webhookData, 'verify'),
|
||||
'headers' => Arr::get($webhookData, 'headers'),
|
||||
]);
|
||||
|
||||
if (! $response instanceof Response) {
|
||||
throw new Exception('Webhook request did not return a valid GuzzleHttp\Psr7\Response.');
|
||||
}
|
||||
|
||||
if ($response->getStatusCode() >= 300 || $response->getStatusCode() < 200) {
|
||||
throw new Exception('Webhook request failed with status code: '.$response->getStatusCode());
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
122
app/Notifications/Messages/WebhookMessage.php
Normal file
122
app/Notifications/Messages/WebhookMessage.php
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
<?php
|
||||
|
||||
namespace App\Notifications\Messages;
|
||||
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
final class WebhookMessage extends Notification
|
||||
{
|
||||
/**
|
||||
* The GET parameters of the request.
|
||||
*
|
||||
* @var array|string|null
|
||||
*/
|
||||
private $query;
|
||||
|
||||
/**
|
||||
* The headers to send with the request.
|
||||
*
|
||||
* @var array|null
|
||||
*/
|
||||
private $headers;
|
||||
|
||||
/**
|
||||
* The Guzzle verify option.
|
||||
*
|
||||
* @var bool|string
|
||||
*/
|
||||
private $verify = false;
|
||||
|
||||
/**
|
||||
* @param mixed $data
|
||||
*/
|
||||
public static function create($data = ''): self
|
||||
{
|
||||
return new self($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $data
|
||||
*/
|
||||
public function __construct(
|
||||
/**
|
||||
* The POST data of the Webhook request.
|
||||
*/
|
||||
private $data = ''
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Set the Webhook parameters to be URL encoded.
|
||||
*
|
||||
* @param mixed $query
|
||||
* @return $this
|
||||
*/
|
||||
public function query($query): self
|
||||
{
|
||||
$this->query = $query;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the Webhook data to be JSON encoded.
|
||||
*
|
||||
* @param mixed $data
|
||||
* @return $this
|
||||
*/
|
||||
public function data($data): self
|
||||
{
|
||||
$this->data = $data;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a Webhook request custom header.
|
||||
*
|
||||
* @param string $name
|
||||
* @param string $value
|
||||
* @return $this
|
||||
*/
|
||||
public function header($name, $value): self
|
||||
{
|
||||
$this->headers[$name] = $value;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the Webhook request UserAgent.
|
||||
*
|
||||
* @param string $userAgent
|
||||
* @return $this
|
||||
*/
|
||||
public function userAgent($userAgent): self
|
||||
{
|
||||
$this->headers['User-Agent'] = $userAgent;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate that the request should be verified.
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function verify($value = true): self
|
||||
{
|
||||
$this->verify = $value;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
return [
|
||||
'query' => $this->query,
|
||||
'data' => $this->data,
|
||||
'headers' => $this->headers,
|
||||
'verify' => $this->verify,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,11 @@
|
|||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Services\OidcProvider;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
|
|
@ -20,7 +24,33 @@ class AppServiceProvider extends ServiceProvider
|
|||
public function boot(): void
|
||||
{
|
||||
if (app()->isProduction() && config('app.force_https')) {
|
||||
\URL::forceScheme('https');
|
||||
URL::forceScheme('https');
|
||||
}
|
||||
|
||||
Request::macro('hasValidSignature', function ($absolute = true, array $ignoreQuery = []) {
|
||||
$https = clone $this;
|
||||
$https->server->set('HTTPS', 'on');
|
||||
|
||||
$http = clone $this;
|
||||
$http->server->set('HTTPS', 'off');
|
||||
if (URL::hasValidSignature($https, $absolute, $ignoreQuery)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return URL::hasValidSignature($http, $absolute, $ignoreQuery);
|
||||
});
|
||||
|
||||
// Register OIDC provider with Socialite
|
||||
Socialite::extend('oidc', function (\Illuminate\Contracts\Foundation\Application $app): OidcProvider {
|
||||
$config = $app->make('config')->get('services.oidc', []);
|
||||
|
||||
return new OidcProvider(
|
||||
$app->make(Request::class),
|
||||
$config['client_id'] ?? null,
|
||||
$config['client_secret'] ?? null,
|
||||
$config['redirect'] ?? null,
|
||||
$config['scopes'] ?? ['openid', 'profile', 'email']
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
541
app/Services/ImageGenerationService.php
Normal file
541
app/Services/ImageGenerationService.php
Normal file
|
|
@ -0,0 +1,541 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\ImageFormat;
|
||||
use App\Models\Device;
|
||||
use App\Models\DeviceModel;
|
||||
use App\Models\Plugin;
|
||||
use Bnussbau\TrmnlPipeline\Stages\BrowserStage;
|
||||
use Bnussbau\TrmnlPipeline\Stages\ImageStage;
|
||||
use Bnussbau\TrmnlPipeline\TrmnlPipeline;
|
||||
use Exception;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use InvalidArgumentException;
|
||||
use Ramsey\Uuid\Uuid;
|
||||
use RuntimeException;
|
||||
use Wnx\SidecarBrowsershot\BrowsershotLambda;
|
||||
|
||||
use function config;
|
||||
use function file_exists;
|
||||
use function filesize;
|
||||
|
||||
class ImageGenerationService
|
||||
{
|
||||
public static function generateImage(string $markup, $deviceId): string
|
||||
{
|
||||
$device = Device::with(['deviceModel', 'palette', 'deviceModel.palette', 'user'])->find($deviceId);
|
||||
$uuid = self::generateImageFromModel(
|
||||
markup: $markup,
|
||||
deviceModel: $device->deviceModel,
|
||||
user: $device->user,
|
||||
palette: $device->palette ?? $device->deviceModel?->palette,
|
||||
device: $device
|
||||
);
|
||||
|
||||
$device->update(['current_screen_image' => $uuid]);
|
||||
Log::info("Device $device->id: updated with new image: $uuid");
|
||||
|
||||
return $uuid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an image from markup using a DeviceModel
|
||||
*
|
||||
* @param string $markup The HTML markup to render
|
||||
* @param DeviceModel|null $deviceModel The device model to use for image generation
|
||||
* @param \App\Models\User|null $user Optional user for timezone settings
|
||||
* @param \App\Models\DevicePalette|null $palette Optional palette, falls back to device model's palette
|
||||
* @param Device|null $device Optional device for legacy devices without DeviceModel
|
||||
* @return string The UUID of the generated image
|
||||
*/
|
||||
public static function generateImageFromModel(
|
||||
string $markup,
|
||||
?DeviceModel $deviceModel = null,
|
||||
?\App\Models\User $user = null,
|
||||
?\App\Models\DevicePalette $palette = null,
|
||||
?Device $device = null
|
||||
): string {
|
||||
$uuid = Uuid::uuid4()->toString();
|
||||
|
||||
try {
|
||||
// Get image generation settings from DeviceModel or Device (for legacy devices)
|
||||
$imageSettings = $deviceModel
|
||||
? self::getImageSettingsFromModel($deviceModel)
|
||||
: ($device ? self::getImageSettings($device) : self::getImageSettingsFromModel(null));
|
||||
|
||||
$fileExtension = $imageSettings['mime_type'] === 'image/bmp' ? 'bmp' : 'png';
|
||||
$outputPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.'.$fileExtension);
|
||||
|
||||
// Create custom Browsershot instance if using AWS Lambda
|
||||
$browsershotInstance = null;
|
||||
if (config('app.puppeteer_mode') === 'sidecar-aws') {
|
||||
$browsershotInstance = new BrowsershotLambda();
|
||||
}
|
||||
|
||||
$browserStage = new BrowserStage($browsershotInstance);
|
||||
$browserStage->html($markup);
|
||||
|
||||
// Set timezone from user or fall back to app timezone
|
||||
$timezone = $user?->timezone ?? config('app.timezone');
|
||||
$browserStage->timezone($timezone);
|
||||
|
||||
if (config('app.puppeteer_window_size_strategy') === 'v2') {
|
||||
$browserStage
|
||||
->width($imageSettings['width'])
|
||||
->height($imageSettings['height']);
|
||||
} else {
|
||||
// default behaviour for Framework v1
|
||||
$browserStage->useDefaultDimensions();
|
||||
}
|
||||
|
||||
if (config('app.puppeteer_wait_for_network_idle')) {
|
||||
$browserStage->setBrowsershotOption('waitUntil', 'networkidle0');
|
||||
}
|
||||
|
||||
if (config('app.puppeteer_docker')) {
|
||||
$browserStage->setBrowsershotOption('args', ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu']);
|
||||
}
|
||||
|
||||
// Get palette from parameter or fallback to device model's default palette
|
||||
$colorPalette = null;
|
||||
if ($palette && $palette->colors) {
|
||||
$colorPalette = $palette->colors;
|
||||
} elseif ($deviceModel?->palette && $deviceModel->palette->colors) {
|
||||
$colorPalette = $deviceModel->palette->colors;
|
||||
}
|
||||
|
||||
$imageStage = new ImageStage();
|
||||
$imageStage->format($fileExtension)
|
||||
->width($imageSettings['width'])
|
||||
->height($imageSettings['height'])
|
||||
->colors($imageSettings['colors'])
|
||||
->bitDepth($imageSettings['bit_depth'])
|
||||
->rotation($imageSettings['rotation'])
|
||||
->offsetX($imageSettings['offset_x'])
|
||||
->offsetY($imageSettings['offset_y'])
|
||||
->outputPath($outputPath);
|
||||
|
||||
// Apply color palette if available
|
||||
if ($colorPalette) {
|
||||
$imageStage->colormap($colorPalette);
|
||||
}
|
||||
|
||||
// Apply dithering if requested by markup
|
||||
$shouldDither = self::markupContainsDitherImage($markup);
|
||||
if ($shouldDither) {
|
||||
$imageStage->dither();
|
||||
}
|
||||
|
||||
(new TrmnlPipeline())->pipe($browserStage)
|
||||
->pipe($imageStage)
|
||||
->process();
|
||||
|
||||
if (! file_exists($outputPath)) {
|
||||
throw new RuntimeException('Image file was not created: '.$outputPath);
|
||||
}
|
||||
|
||||
if (filesize($outputPath) === 0) {
|
||||
throw new RuntimeException('Image file is empty: '.$outputPath);
|
||||
}
|
||||
|
||||
Log::info("Generated image: $uuid");
|
||||
|
||||
return $uuid;
|
||||
|
||||
} catch (Exception $e) {
|
||||
Log::error('Failed to generate image: '.$e->getMessage());
|
||||
throw new RuntimeException('Failed to generate image: '.$e->getMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image generation settings from DeviceModel if available, otherwise use device settings
|
||||
*/
|
||||
private static function getImageSettings(Device $device): array
|
||||
{
|
||||
// If device has a DeviceModel, use its settings
|
||||
if ($device->deviceModel) {
|
||||
return self::getImageSettingsFromModel($device->deviceModel);
|
||||
}
|
||||
|
||||
// Fallback to device settings
|
||||
$imageFormat = $device->image_format ?? ImageFormat::AUTO->value;
|
||||
$mimeType = self::getMimeTypeFromImageFormat($imageFormat);
|
||||
$colors = self::getColorsFromImageFormat($imageFormat);
|
||||
$bitDepth = self::getBitDepthFromImageFormat($imageFormat);
|
||||
|
||||
return [
|
||||
'width' => $device->width ?? 800,
|
||||
'height' => $device->height ?? 480,
|
||||
'colors' => $colors,
|
||||
'bit_depth' => $bitDepth,
|
||||
'scale_factor' => 1.0,
|
||||
'rotation' => $device->rotate ?? 0,
|
||||
'mime_type' => $mimeType,
|
||||
'offset_x' => 0,
|
||||
'offset_y' => 0,
|
||||
'image_format' => $imageFormat,
|
||||
'use_model_settings' => false,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image generation settings from a DeviceModel
|
||||
*/
|
||||
private static function getImageSettingsFromModel(?DeviceModel $deviceModel): array
|
||||
{
|
||||
if ($deviceModel) {
|
||||
return [
|
||||
'width' => $deviceModel->width,
|
||||
'height' => $deviceModel->height,
|
||||
'colors' => $deviceModel->colors,
|
||||
'bit_depth' => $deviceModel->bit_depth,
|
||||
'scale_factor' => $deviceModel->scale_factor,
|
||||
'rotation' => $deviceModel->rotation,
|
||||
'mime_type' => $deviceModel->mime_type,
|
||||
'offset_x' => $deviceModel->offset_x,
|
||||
'offset_y' => $deviceModel->offset_y,
|
||||
'image_format' => self::determineImageFormatFromModel($deviceModel),
|
||||
'use_model_settings' => true,
|
||||
];
|
||||
}
|
||||
|
||||
// Default settings if no device model provided
|
||||
return [
|
||||
'width' => 800,
|
||||
'height' => 480,
|
||||
'colors' => 2,
|
||||
'bit_depth' => 1,
|
||||
'scale_factor' => 1.0,
|
||||
'rotation' => 0,
|
||||
'mime_type' => 'image/png',
|
||||
'offset_x' => 0,
|
||||
'offset_y' => 0,
|
||||
'image_format' => ImageFormat::AUTO->value,
|
||||
'use_model_settings' => false,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the appropriate ImageFormat based on DeviceModel settings
|
||||
*/
|
||||
private static function determineImageFormatFromModel(DeviceModel $model): string
|
||||
{
|
||||
// Map DeviceModel settings to ImageFormat
|
||||
if ($model->mime_type === 'image/bmp' && $model->bit_depth === 1) {
|
||||
return ImageFormat::BMP3_1BIT_SRGB->value;
|
||||
}
|
||||
if ($model->mime_type === 'image/png' && $model->bit_depth === 8 && $model->colors === 2) {
|
||||
return ImageFormat::PNG_8BIT_GRAYSCALE->value;
|
||||
}
|
||||
if ($model->mime_type === 'image/png' && $model->bit_depth === 8 && $model->colors === 256) {
|
||||
return ImageFormat::PNG_8BIT_256C->value;
|
||||
}
|
||||
if ($model->mime_type === 'image/png' && $model->bit_depth === 2 && $model->colors === 4) {
|
||||
return ImageFormat::PNG_2BIT_4C->value;
|
||||
}
|
||||
|
||||
// Default to AUTO for unknown combinations
|
||||
return ImageFormat::AUTO->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MIME type from ImageFormat
|
||||
*/
|
||||
private static function getMimeTypeFromImageFormat(string $imageFormat): string
|
||||
{
|
||||
return match ($imageFormat) {
|
||||
ImageFormat::BMP3_1BIT_SRGB->value => 'image/bmp',
|
||||
ImageFormat::PNG_8BIT_GRAYSCALE->value,
|
||||
ImageFormat::PNG_8BIT_256C->value,
|
||||
ImageFormat::PNG_2BIT_4C->value => 'image/png',
|
||||
ImageFormat::AUTO->value => 'image/png', // Default for AUTO
|
||||
default => 'image/png',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get colors from ImageFormat
|
||||
*/
|
||||
private static function getColorsFromImageFormat(string $imageFormat): int
|
||||
{
|
||||
return match ($imageFormat) {
|
||||
ImageFormat::BMP3_1BIT_SRGB->value,
|
||||
ImageFormat::PNG_8BIT_GRAYSCALE->value => 2,
|
||||
ImageFormat::PNG_8BIT_256C->value => 256,
|
||||
ImageFormat::PNG_2BIT_4C->value => 4,
|
||||
ImageFormat::AUTO->value => 2, // Default for AUTO
|
||||
default => 2,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get bit depth from ImageFormat
|
||||
*/
|
||||
private static function getBitDepthFromImageFormat(string $imageFormat): int
|
||||
{
|
||||
return match ($imageFormat) {
|
||||
ImageFormat::BMP3_1BIT_SRGB->value,
|
||||
ImageFormat::PNG_8BIT_GRAYSCALE->value => 1,
|
||||
ImageFormat::PNG_8BIT_256C->value => 8,
|
||||
ImageFormat::PNG_2BIT_4C->value => 2,
|
||||
ImageFormat::AUTO->value => 1, // Default for AUTO
|
||||
default => 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether the provided HTML markup contains an <img> tag with class "image-dither".
|
||||
*/
|
||||
private static function markupContainsDitherImage(string $markup): bool
|
||||
{
|
||||
if (mb_trim($markup) === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Find <img ... class="..."> (or with single quotes) and inspect class tokens
|
||||
$imgWithClassPattern = '/<img\b[^>]*\bclass\s*=\s*(["\'])(.*?)\1[^>]*>/i';
|
||||
if (! preg_match_all($imgWithClassPattern, $markup, $matches)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($matches[2] as $classValue) {
|
||||
// Look for class token 'image-dither' or 'image--dither'
|
||||
if (preg_match('/(?:^|\s)image--?dither(?:\s|$)/', $classValue)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static function cleanupFolder(): void
|
||||
{
|
||||
$activeDeviceImageUuids = Device::pluck('current_screen_image')->filter()->toArray();
|
||||
$activePluginImageUuids = Plugin::pluck('current_image')->filter()->toArray();
|
||||
$activeImageUuids = array_merge($activeDeviceImageUuids, $activePluginImageUuids);
|
||||
|
||||
$files = Storage::disk('public')->files('/images/generated/');
|
||||
foreach ($files as $file) {
|
||||
if (basename($file) === '.gitignore') {
|
||||
continue;
|
||||
}
|
||||
// Get filename without path and extension
|
||||
$fileUuid = pathinfo($file, PATHINFO_FILENAME);
|
||||
// If the UUID is not in use by any device, move it to archive
|
||||
if (! in_array($fileUuid, $activeImageUuids)) {
|
||||
Storage::disk('public')->delete($file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static function resetIfNotCacheable(?Plugin $plugin): 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 ($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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get device-specific default image path for setup or sleep mode
|
||||
*/
|
||||
public static function getDeviceSpecificDefaultImage(Device $device, string $imageType): ?string
|
||||
{
|
||||
// Validate image type
|
||||
if (! in_array($imageType, ['setup-logo', 'sleep', 'error'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If device has a DeviceModel, try to find device-specific image
|
||||
if ($device->deviceModel) {
|
||||
$model = $device->deviceModel;
|
||||
$extension = $model->mime_type === 'image/bmp' ? 'bmp' : 'png';
|
||||
$filename = "{$model->width}_{$model->height}_{$model->bit_depth}_{$model->rotation}.{$extension}";
|
||||
$deviceSpecificPath = "images/default-screens/{$imageType}_{$filename}";
|
||||
|
||||
if (Storage::disk('public')->exists($deviceSpecificPath)) {
|
||||
return $deviceSpecificPath;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to original hardcoded images
|
||||
$fallbackPath = "images/{$imageType}.bmp";
|
||||
if (Storage::disk('public')->exists($fallbackPath)) {
|
||||
return $fallbackPath;
|
||||
}
|
||||
|
||||
// Try PNG fallback
|
||||
$fallbackPathPng = "images/{$imageType}.png";
|
||||
if (Storage::disk('public')->exists($fallbackPathPng)) {
|
||||
return $fallbackPathPng;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a default screen image from Blade template
|
||||
*/
|
||||
public static function generateDefaultScreenImage(Device $device, string $imageType, ?string $pluginName = null): string
|
||||
{
|
||||
// Validate image type
|
||||
if (! in_array($imageType, ['setup-logo', 'sleep', 'error'])) {
|
||||
throw new InvalidArgumentException("Invalid image type: {$imageType}");
|
||||
}
|
||||
|
||||
$uuid = Uuid::uuid4()->toString();
|
||||
|
||||
try {
|
||||
// Load device with relationships
|
||||
$device->load(['palette', 'deviceModel.palette', 'user']);
|
||||
|
||||
// Get image generation settings from DeviceModel if available, otherwise use device settings
|
||||
$imageSettings = self::getImageSettings($device);
|
||||
|
||||
$fileExtension = $imageSettings['mime_type'] === 'image/bmp' ? 'bmp' : 'png';
|
||||
$outputPath = Storage::disk('public')->path('/images/generated/'.$uuid.'.'.$fileExtension);
|
||||
|
||||
// Generate HTML from Blade template
|
||||
$html = self::generateDefaultScreenHtml($device, $imageType, $pluginName);
|
||||
|
||||
// Create custom Browsershot instance if using AWS Lambda
|
||||
$browsershotInstance = null;
|
||||
if (config('app.puppeteer_mode') === 'sidecar-aws') {
|
||||
$browsershotInstance = new BrowsershotLambda();
|
||||
}
|
||||
|
||||
$browserStage = new BrowserStage($browsershotInstance);
|
||||
$browserStage->html($html);
|
||||
|
||||
// Set timezone from user or fall back to app timezone
|
||||
$timezone = $device->user->timezone ?? config('app.timezone');
|
||||
$browserStage->timezone($timezone);
|
||||
|
||||
if (config('app.puppeteer_window_size_strategy') === 'v2') {
|
||||
$browserStage
|
||||
->width($imageSettings['width'])
|
||||
->height($imageSettings['height']);
|
||||
} else {
|
||||
$browserStage->useDefaultDimensions();
|
||||
}
|
||||
|
||||
if (config('app.puppeteer_wait_for_network_idle')) {
|
||||
$browserStage->setBrowsershotOption('waitUntil', 'networkidle0');
|
||||
}
|
||||
|
||||
if (config('app.puppeteer_docker')) {
|
||||
$browserStage->setBrowsershotOption('args', ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu']);
|
||||
}
|
||||
|
||||
// Get palette from device or fallback to device model's default palette
|
||||
$palette = $device->palette ?? $device->deviceModel?->palette;
|
||||
$colorPalette = null;
|
||||
|
||||
if ($palette && $palette->colors) {
|
||||
$colorPalette = $palette->colors;
|
||||
}
|
||||
|
||||
$imageStage = new ImageStage();
|
||||
$imageStage->format($fileExtension)
|
||||
->width($imageSettings['width'])
|
||||
->height($imageSettings['height'])
|
||||
->colors($imageSettings['colors'])
|
||||
->bitDepth($imageSettings['bit_depth'])
|
||||
->rotation($imageSettings['rotation'])
|
||||
->offsetX($imageSettings['offset_x'])
|
||||
->offsetY($imageSettings['offset_y'])
|
||||
->outputPath($outputPath);
|
||||
|
||||
// Apply color palette if available
|
||||
if ($colorPalette) {
|
||||
$imageStage->colormap($colorPalette);
|
||||
}
|
||||
|
||||
(new TrmnlPipeline())->pipe($browserStage)
|
||||
->pipe($imageStage)
|
||||
->process();
|
||||
|
||||
if (! file_exists($outputPath)) {
|
||||
throw new RuntimeException('Image file was not created: '.$outputPath);
|
||||
}
|
||||
|
||||
if (filesize($outputPath) === 0) {
|
||||
throw new RuntimeException('Image file is empty: '.$outputPath);
|
||||
}
|
||||
|
||||
Log::info("Device $device->id: generated default screen image: $uuid for type: $imageType");
|
||||
|
||||
return $uuid;
|
||||
|
||||
} catch (Exception $e) {
|
||||
Log::error('Failed to generate default screen image: '.$e->getMessage());
|
||||
throw new RuntimeException('Failed to generate default screen image: '.$e->getMessage(), 0, $e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate HTML from Blade template for default screens
|
||||
*/
|
||||
private static function generateDefaultScreenHtml(Device $device, string $imageType, ?string $pluginName = null): string
|
||||
{
|
||||
// Map image type to template name
|
||||
$templateName = match ($imageType) {
|
||||
'setup-logo' => 'default-screens.setup',
|
||||
'sleep' => 'default-screens.sleep',
|
||||
'error' => 'default-screens.error',
|
||||
default => throw new InvalidArgumentException("Invalid image type: {$imageType}")
|
||||
};
|
||||
|
||||
// Determine device properties from DeviceModel or device settings
|
||||
$deviceVariant = $device->deviceVariant();
|
||||
$deviceOrientation = $device->rotate > 0 ? 'portrait' : 'landscape';
|
||||
$colorDepth = $device->colorDepth() ?? '1bit';
|
||||
$scaleLevel = $device->scaleLevel();
|
||||
$darkMode = $imageType === 'sleep'; // Sleep mode uses dark mode, setup uses light mode
|
||||
|
||||
// Build view data
|
||||
$viewData = [
|
||||
'noBleed' => false,
|
||||
'darkMode' => $darkMode,
|
||||
'deviceVariant' => $deviceVariant,
|
||||
'deviceOrientation' => $deviceOrientation,
|
||||
'colorDepth' => $colorDepth,
|
||||
'scaleLevel' => $scaleLevel,
|
||||
];
|
||||
|
||||
// Add plugin name for error screens
|
||||
if ($imageType === 'error' && $pluginName !== null) {
|
||||
$viewData['pluginName'] = $pluginName;
|
||||
}
|
||||
|
||||
// Render the Blade template
|
||||
return view($templateName, $viewData)->render();
|
||||
}
|
||||
}
|
||||
158
app/Services/OidcProvider.php
Normal file
158
app/Services/OidcProvider.php
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Exception;
|
||||
use GuzzleHttp\Client;
|
||||
use Laravel\Socialite\Two\AbstractProvider;
|
||||
use Laravel\Socialite\Two\ProviderInterface;
|
||||
use Laravel\Socialite\Two\User;
|
||||
|
||||
class OidcProvider extends AbstractProvider implements ProviderInterface
|
||||
{
|
||||
/**
|
||||
* The scopes being requested.
|
||||
*/
|
||||
protected $scopes = [];
|
||||
|
||||
/**
|
||||
* The separating character for the requested scopes.
|
||||
*/
|
||||
protected $scopeSeparator = ' ';
|
||||
|
||||
/**
|
||||
* The OIDC configuration.
|
||||
*/
|
||||
protected $oidcConfig;
|
||||
|
||||
/**
|
||||
* The base URL for OIDC endpoints.
|
||||
*/
|
||||
protected $baseUrl;
|
||||
|
||||
/**
|
||||
* Create a new provider instance.
|
||||
*/
|
||||
public function __construct(\Illuminate\Http\Request $request, $clientId, $clientSecret, $redirectUrl, $scopes = [], $guzzle = [])
|
||||
{
|
||||
parent::__construct($request, $clientId, $clientSecret, $redirectUrl, $guzzle);
|
||||
|
||||
$endpoint = config('services.oidc.endpoint');
|
||||
if (! $endpoint) {
|
||||
throw new Exception('OIDC endpoint is not configured. Please set OIDC_ENDPOINT environment variable.');
|
||||
}
|
||||
|
||||
// Handle both full well-known URL and base URL
|
||||
if (str_ends_with((string) $endpoint, '/.well-known/openid-configuration')) {
|
||||
$this->baseUrl = str_replace('/.well-known/openid-configuration', '', $endpoint);
|
||||
} else {
|
||||
$this->baseUrl = mb_rtrim($endpoint, '/');
|
||||
}
|
||||
|
||||
$this->scopes = $scopes ?: ['openid', 'profile', 'email'];
|
||||
$this->loadOidcConfiguration();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load OIDC configuration from the well-known endpoint.
|
||||
*/
|
||||
protected function loadOidcConfiguration()
|
||||
{
|
||||
try {
|
||||
$url = $this->baseUrl.'/.well-known/openid-configuration';
|
||||
$client = app(Client::class);
|
||||
$response = $client->get($url);
|
||||
$this->oidcConfig = json_decode($response->getBody()->getContents(), true);
|
||||
|
||||
if (! $this->oidcConfig) {
|
||||
throw new Exception('OIDC configuration is empty or invalid JSON');
|
||||
}
|
||||
|
||||
if (! isset($this->oidcConfig['authorization_endpoint'])) {
|
||||
throw new Exception('authorization_endpoint not found in OIDC configuration');
|
||||
}
|
||||
|
||||
} catch (Exception $e) {
|
||||
throw new Exception('Failed to load OIDC configuration: '.$e->getMessage(), $e->getCode(), $e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the authentication URL for the provider.
|
||||
*/
|
||||
protected function getAuthUrl($state)
|
||||
{
|
||||
if (! $this->oidcConfig || ! isset($this->oidcConfig['authorization_endpoint'])) {
|
||||
throw new Exception('OIDC configuration not loaded or authorization_endpoint not found.');
|
||||
}
|
||||
|
||||
return $this->buildAuthUrlFromBase($this->oidcConfig['authorization_endpoint'], $state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the token URL for the provider.
|
||||
*/
|
||||
protected function getTokenUrl()
|
||||
{
|
||||
if (! $this->oidcConfig || ! isset($this->oidcConfig['token_endpoint'])) {
|
||||
throw new Exception('OIDC configuration not loaded or token_endpoint not found.');
|
||||
}
|
||||
|
||||
return $this->oidcConfig['token_endpoint'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the raw user for the given access token.
|
||||
*/
|
||||
protected function getUserByToken($token)
|
||||
{
|
||||
if (! $this->oidcConfig || ! isset($this->oidcConfig['userinfo_endpoint'])) {
|
||||
throw new Exception('OIDC configuration not loaded or userinfo_endpoint not found.');
|
||||
}
|
||||
|
||||
$response = $this->getHttpClient()->get($this->oidcConfig['userinfo_endpoint'], [
|
||||
'headers' => [
|
||||
'Authorization' => 'Bearer '.$token,
|
||||
],
|
||||
]);
|
||||
|
||||
return json_decode($response->getBody(), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the raw user array to a Socialite User instance.
|
||||
*/
|
||||
public function mapUserToObject(array $user)
|
||||
{
|
||||
return (new User)->setRaw($user)->map([
|
||||
'id' => $user['sub'],
|
||||
'nickname' => $user['preferred_username'] ?? null,
|
||||
'name' => $user['name'] ?? null,
|
||||
'email' => $user['email'] ?? null,
|
||||
'avatar' => $user['picture'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the access token response for the given code.
|
||||
*/
|
||||
public function getAccessTokenResponse($code)
|
||||
{
|
||||
$response = $this->getHttpClient()->post($this->getTokenUrl(), [
|
||||
'headers' => ['Accept' => 'application/json'],
|
||||
'form_params' => $this->getTokenFields($code),
|
||||
]);
|
||||
|
||||
return json_decode($response->getBody(), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the POST fields for the token request.
|
||||
*/
|
||||
protected function getTokenFields($code)
|
||||
{
|
||||
return array_merge(parent::getTokenFields($code), [
|
||||
'grant_type' => 'authorization_code',
|
||||
]);
|
||||
}
|
||||
}
|
||||
111
app/Services/Plugin/Parsers/IcalResponseParser.php
Normal file
111
app/Services/Plugin/Parsers/IcalResponseParser.php
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Plugin\Parsers;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use DateTimeInterface;
|
||||
use Exception;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use om\IcalParser;
|
||||
|
||||
class IcalResponseParser implements ResponseParser
|
||||
{
|
||||
public function __construct(
|
||||
private readonly IcalParser $parser = new IcalParser(),
|
||||
) {}
|
||||
|
||||
public function parse(Response $response): ?array
|
||||
{
|
||||
$contentType = $response->header('Content-Type');
|
||||
$body = $response->body();
|
||||
|
||||
if (! $this->isIcalResponse($contentType, $body)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->parser->parseString($body);
|
||||
|
||||
$events = $this->parser->getEvents()->sorted()->getArrayCopy();
|
||||
$windowStart = now()->subDays(7);
|
||||
$windowEnd = now()->addDays(30);
|
||||
|
||||
$filteredEvents = array_values(array_filter($events, function (array $event) use ($windowStart, $windowEnd): bool {
|
||||
$startDate = $this->asCarbon($event['DTSTART'] ?? null);
|
||||
|
||||
if (! $startDate instanceof Carbon) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $startDate->between($windowStart, $windowEnd, true);
|
||||
}));
|
||||
|
||||
$normalizedEvents = array_map($this->normalizeIcalEvent(...), $filteredEvents);
|
||||
|
||||
return ['ical' => $normalizedEvents];
|
||||
} catch (Exception $exception) {
|
||||
Log::warning('Failed to parse iCal response: '.$exception->getMessage());
|
||||
|
||||
return ['error' => 'Failed to parse iCal response'];
|
||||
}
|
||||
}
|
||||
|
||||
private function isIcalResponse(?string $contentType, string $body): bool
|
||||
{
|
||||
$normalizedContentType = $contentType ? mb_strtolower($contentType) : '';
|
||||
|
||||
if ($normalizedContentType && str_contains($normalizedContentType, 'text/calendar')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return str_contains($body, 'BEGIN:VCALENDAR');
|
||||
}
|
||||
|
||||
private function asCarbon(DateTimeInterface|string|null $value): ?Carbon
|
||||
{
|
||||
if ($value instanceof Carbon) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
if ($value instanceof DateTimeInterface) {
|
||||
return Carbon::instance($value);
|
||||
}
|
||||
|
||||
if (is_string($value) && $value !== '') {
|
||||
try {
|
||||
return Carbon::parse($value);
|
||||
} catch (Exception $exception) {
|
||||
Log::warning('Failed to parse date value: '.$exception->getMessage());
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function normalizeIcalEvent(array $event): array
|
||||
{
|
||||
$normalized = [];
|
||||
|
||||
foreach ($event as $key => $value) {
|
||||
$normalized[$key] = $this->normalizeIcalValue($value);
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
private function normalizeIcalValue(mixed $value): mixed
|
||||
{
|
||||
if ($value instanceof DateTimeInterface) {
|
||||
return Carbon::instance($value)->toAtomString();
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
return array_map($this->normalizeIcalValue(...), $value);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
26
app/Services/Plugin/Parsers/JsonOrTextResponseParser.php
Normal file
26
app/Services/Plugin/Parsers/JsonOrTextResponseParser.php
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Plugin\Parsers;
|
||||
|
||||
use Exception;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class JsonOrTextResponseParser implements ResponseParser
|
||||
{
|
||||
public function parse(Response $response): array
|
||||
{
|
||||
try {
|
||||
$json = $response->json();
|
||||
if ($json !== null) {
|
||||
return $json;
|
||||
}
|
||||
|
||||
return ['data' => $response->body()];
|
||||
} catch (Exception $e) {
|
||||
Log::warning('Failed to parse JSON response: '.$e->getMessage());
|
||||
|
||||
return ['error' => 'Failed to parse JSON response'];
|
||||
}
|
||||
}
|
||||
}
|
||||
15
app/Services/Plugin/Parsers/ResponseParser.php
Normal file
15
app/Services/Plugin/Parsers/ResponseParser.php
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Plugin\Parsers;
|
||||
|
||||
use Illuminate\Http\Client\Response;
|
||||
|
||||
interface ResponseParser
|
||||
{
|
||||
/**
|
||||
* Attempt to parse the given response.
|
||||
*
|
||||
* Return null when the parser is not applicable so other parsers can run.
|
||||
*/
|
||||
public function parse(Response $response): ?array;
|
||||
}
|
||||
31
app/Services/Plugin/Parsers/ResponseParserRegistry.php
Normal file
31
app/Services/Plugin/Parsers/ResponseParserRegistry.php
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Plugin\Parsers;
|
||||
|
||||
class ResponseParserRegistry
|
||||
{
|
||||
/**
|
||||
* @var array<int, ResponseParser>
|
||||
*/
|
||||
private readonly array $parsers;
|
||||
|
||||
/**
|
||||
* @param array<int, ResponseParser> $parsers
|
||||
*/
|
||||
public function __construct(array $parsers = [])
|
||||
{
|
||||
$this->parsers = $parsers ?: [
|
||||
new XmlResponseParser(),
|
||||
new IcalResponseParser(),
|
||||
new JsonOrTextResponseParser(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, ResponseParser>
|
||||
*/
|
||||
public function getParsers(): array
|
||||
{
|
||||
return $this->parsers;
|
||||
}
|
||||
}
|
||||
46
app/Services/Plugin/Parsers/XmlResponseParser.php
Normal file
46
app/Services/Plugin/Parsers/XmlResponseParser.php
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Plugin\Parsers;
|
||||
|
||||
use Exception;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use SimpleXMLElement;
|
||||
|
||||
class XmlResponseParser implements ResponseParser
|
||||
{
|
||||
public function parse(Response $response): ?array
|
||||
{
|
||||
$contentType = $response->header('Content-Type');
|
||||
|
||||
if (! $contentType || ! str_contains(mb_strtolower($contentType), 'xml')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$xml = simplexml_load_string($response->body());
|
||||
if ($xml === false) {
|
||||
throw new Exception('Invalid XML content');
|
||||
}
|
||||
|
||||
return ['rss' => $this->xmlToArray($xml)];
|
||||
} catch (Exception $exception) {
|
||||
Log::warning('Failed to parse XML response: '.$exception->getMessage());
|
||||
|
||||
return ['error' => 'Failed to parse XML response'];
|
||||
}
|
||||
}
|
||||
|
||||
private function xmlToArray(SimpleXMLElement $xml): array
|
||||
{
|
||||
$array = (array) $xml;
|
||||
|
||||
foreach ($array as $key => $value) {
|
||||
if ($value instanceof SimpleXMLElement) {
|
||||
$array[$key] = $this->xmlToArray($value);
|
||||
}
|
||||
}
|
||||
|
||||
return $array;
|
||||
}
|
||||
}
|
||||
172
app/Services/PluginExportService.php
Normal file
172
app/Services/PluginExportService.php
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Plugin;
|
||||
use App\Models\User;
|
||||
use Exception;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use RecursiveDirectoryIterator;
|
||||
use RecursiveIteratorIterator;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
use ZipArchive;
|
||||
|
||||
/**
|
||||
* PluginExportService
|
||||
*
|
||||
* Exports plugins to ZIP files in the same format that can be imported by PluginImportService.
|
||||
*
|
||||
* The exported ZIP file contains:
|
||||
* - settings.yml: Plugin configuration including custom fields, polling settings, etc.
|
||||
* - full.liquid or full.blade.php: The main template file
|
||||
* - shared.liquid: Optional shared template (for liquid templates)
|
||||
*
|
||||
* This format is compatible with the PluginImportService and can be used to:
|
||||
* - Backup plugins
|
||||
* - Share plugins between users
|
||||
* - Migrate plugins between environments
|
||||
* - Create plugin templates
|
||||
*/
|
||||
class PluginExportService
|
||||
{
|
||||
/**
|
||||
* Export a plugin to a ZIP file in the same format that can be imported
|
||||
*
|
||||
* @param Plugin $plugin The plugin to export
|
||||
* @param User $user The user exporting the plugin
|
||||
* @return BinaryFileResponse The ZIP file response
|
||||
*
|
||||
* @throws Exception If the ZIP file cannot be created
|
||||
*/
|
||||
public function exportToZip(Plugin $plugin, User $user): BinaryFileResponse
|
||||
{
|
||||
// Create a temporary directory
|
||||
$tempDirName = 'temp/'.uniqid('plugin_export_', true);
|
||||
Storage::makeDirectory($tempDirName);
|
||||
$tempDir = Storage::path($tempDirName);
|
||||
// Generate settings.yml content
|
||||
$settings = $this->generateSettingsYaml($plugin);
|
||||
$settingsYaml = Yaml::dump($settings, 10, 2, Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK);
|
||||
File::put($tempDir.'/settings.yml', $settingsYaml);
|
||||
// Generate full template content
|
||||
$fullTemplate = $this->generateFullTemplate($plugin);
|
||||
$extension = $plugin->markup_language === 'liquid' ? 'liquid' : 'blade.php';
|
||||
File::put($tempDir.'/full.'.$extension, $fullTemplate);
|
||||
// Generate shared.liquid if needed (for liquid templates)
|
||||
if ($plugin->markup_language === 'liquid') {
|
||||
$sharedTemplate = $this->generateSharedTemplate();
|
||||
/** @phpstan-ignore-next-line */
|
||||
if ($sharedTemplate) {
|
||||
File::put($tempDir.'/shared.liquid', $sharedTemplate);
|
||||
}
|
||||
}
|
||||
// Create ZIP file
|
||||
$zipPath = $tempDir.'/plugin_'.$plugin->trmnlp_id.'.zip';
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($zipPath, ZipArchive::CREATE) !== true) {
|
||||
throw new Exception('Could not create ZIP file.');
|
||||
}
|
||||
// Add files directly to ZIP root
|
||||
$this->addDirectoryToZip($zip, $tempDir, '');
|
||||
$zip->close();
|
||||
|
||||
// Return the ZIP file as a download response
|
||||
return response()->download($zipPath, 'plugin_'.$plugin->trmnlp_id.'.zip');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the settings.yml content for the plugin
|
||||
*/
|
||||
private function generateSettingsYaml(Plugin $plugin): array
|
||||
{
|
||||
$settings = [];
|
||||
|
||||
// Add fields in the specific order requested
|
||||
$settings['name'] = $plugin->name;
|
||||
$settings['no_screen_padding'] = 'no'; // Default value
|
||||
$settings['dark_mode'] = 'no'; // Default value
|
||||
$settings['strategy'] = $plugin->data_strategy;
|
||||
|
||||
// Add static data if available
|
||||
if ($plugin->data_payload) {
|
||||
$settings['static_data'] = json_encode($plugin->data_payload, JSON_PRETTY_PRINT);
|
||||
}
|
||||
|
||||
// Add polling configuration if applicable
|
||||
if ($plugin->data_strategy === 'polling') {
|
||||
if ($plugin->polling_verb) {
|
||||
$settings['polling_verb'] = $plugin->polling_verb;
|
||||
}
|
||||
if ($plugin->polling_url) {
|
||||
$settings['polling_url'] = $plugin->polling_url;
|
||||
}
|
||||
if ($plugin->polling_header) {
|
||||
// Convert header format from "key: value" to "key=value"
|
||||
$settings['polling_headers'] = str_replace(':', '=', $plugin->polling_header);
|
||||
}
|
||||
if ($plugin->polling_body) {
|
||||
$settings['polling_body'] = $plugin->polling_body;
|
||||
}
|
||||
}
|
||||
|
||||
$settings['refresh_interval'] = $plugin->data_stale_minutes;
|
||||
$settings['id'] = $plugin->trmnlp_id;
|
||||
|
||||
// Add custom fields from configuration template
|
||||
if (isset($plugin->configuration_template['custom_fields'])) {
|
||||
$settings['custom_fields'] = $plugin->configuration_template['custom_fields'];
|
||||
}
|
||||
|
||||
return $settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the full template content
|
||||
*/
|
||||
private function generateFullTemplate(Plugin $plugin): string
|
||||
{
|
||||
$markup = $plugin->render_markup;
|
||||
|
||||
// Remove the wrapper div if it exists (it will be added during import)
|
||||
$markup = preg_replace('/^<div class="view view--\{\{ size \}\}">\s*/', '', $markup);
|
||||
$markup = preg_replace('/\s*<\/div>\s*$/', '', $markup);
|
||||
|
||||
return mb_trim($markup);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the shared template content (for liquid templates)
|
||||
*/
|
||||
private function generateSharedTemplate(): null
|
||||
{
|
||||
// For now, we don't have a way to store shared templates separately
|
||||
// TODO - add support for shared templates
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a directory and its contents to a ZIP file
|
||||
*/
|
||||
private function addDirectoryToZip(ZipArchive $zip, string $dirPath, string $zipPath): void
|
||||
{
|
||||
$files = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($dirPath),
|
||||
RecursiveIteratorIterator::LEAVES_ONLY
|
||||
);
|
||||
|
||||
foreach ($files as $file) {
|
||||
if (! $file->isDir()) {
|
||||
$filePath = $file->getRealPath();
|
||||
$fileName = basename((string) $filePath);
|
||||
|
||||
// For root directory, just use the filename
|
||||
$relativePath = $zipPath === '' ? $fileName : $zipPath.'/'.mb_substr((string) $filePath, mb_strlen($dirPath) + 1);
|
||||
|
||||
$zip->addFile($filePath, $relativePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
598
app/Services/PluginImportService.php
Normal file
598
app/Services/PluginImportService.php
Normal file
|
|
@ -0,0 +1,598 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Plugin;
|
||||
use App\Models\User;
|
||||
use Exception;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use RecursiveDirectoryIterator;
|
||||
use RecursiveIteratorIterator;
|
||||
use Symfony\Component\Uid\Uuid;
|
||||
use Symfony\Component\Yaml\Yaml;
|
||||
use ZipArchive;
|
||||
|
||||
class PluginImportService
|
||||
{
|
||||
/**
|
||||
* Validate YAML settings
|
||||
*
|
||||
* @param array $settings The parsed YAML settings
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
private function validateYAML(array $settings): void
|
||||
{
|
||||
if (! isset($settings['custom_fields']) || ! is_array($settings['custom_fields'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($settings['custom_fields'] as $field) {
|
||||
if (isset($field['field_type']) && $field['field_type'] === 'multi_string') {
|
||||
|
||||
if (isset($field['default']) && str_contains($field['default'], ',')) {
|
||||
throw new Exception("Validation Error: The default value for multistring fields like `{$field['keyname']}` cannot contain commas.");
|
||||
}
|
||||
|
||||
if (isset($field['placeholder']) && str_contains($field['placeholder'], ',')) {
|
||||
throw new Exception("Validation Error: The placeholder value for multistring fields like `{$field['keyname']}` cannot contain commas.");
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a plugin from a ZIP file
|
||||
*
|
||||
* @param UploadedFile $zipFile The uploaded ZIP file
|
||||
* @param User $user The user importing the plugin
|
||||
* @param string|null $zipEntryPath Optional path to specific plugin in monorepo
|
||||
* @return Plugin The created plugin instance
|
||||
*
|
||||
* @throws Exception If the ZIP file is invalid or required files are missing
|
||||
*/
|
||||
public function importFromZip(UploadedFile $zipFile, User $user, ?string $zipEntryPath = null): Plugin
|
||||
{
|
||||
// Create a temporary directory using Laravel's temporary directory helper
|
||||
$tempDirName = 'temp/'.uniqid('plugin_import_', true);
|
||||
Storage::makeDirectory($tempDirName);
|
||||
$tempDir = Storage::path($tempDirName);
|
||||
|
||||
try {
|
||||
// Get the real path of the temporary file
|
||||
$zipFullPath = $zipFile->getRealPath();
|
||||
|
||||
// Extract the ZIP file using ZipArchive
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($zipFullPath) !== true) {
|
||||
throw new Exception('Could not open the ZIP file.');
|
||||
}
|
||||
|
||||
$zip->extractTo($tempDir);
|
||||
$zip->close();
|
||||
|
||||
// Find the required files (settings.yml and full.liquid/full.blade.php/shared.liquid/shared.blade.php)
|
||||
$filePaths = $this->findRequiredFiles($tempDir, $zipEntryPath);
|
||||
|
||||
// Validate that we found the required files
|
||||
if (! $filePaths['settingsYamlPath']) {
|
||||
throw new Exception('Invalid ZIP structure. Required file settings.yml is missing.');
|
||||
}
|
||||
|
||||
// Validate that we have at least one template file
|
||||
if (! $filePaths['fullLiquidPath'] && ! $filePaths['sharedLiquidPath'] && ! $filePaths['sharedBladePath']) {
|
||||
throw new Exception('Invalid ZIP structure. At least one of the following files is required: full.liquid, full.blade.php, shared.liquid, or shared.blade.php.');
|
||||
}
|
||||
|
||||
// Parse settings.yml
|
||||
$settingsYaml = File::get($filePaths['settingsYamlPath']);
|
||||
$settings = Yaml::parse($settingsYaml);
|
||||
$this->validateYAML($settings);
|
||||
|
||||
// Determine which template file to use and read its content
|
||||
$templatePath = null;
|
||||
$markupLanguage = 'blade';
|
||||
|
||||
if ($filePaths['fullLiquidPath']) {
|
||||
$templatePath = $filePaths['fullLiquidPath'];
|
||||
$fullLiquid = File::get($templatePath);
|
||||
|
||||
// Prepend shared.liquid or shared.blade.php content if available
|
||||
if ($filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) {
|
||||
$sharedLiquid = File::get($filePaths['sharedLiquidPath']);
|
||||
$fullLiquid = $sharedLiquid."\n".$fullLiquid;
|
||||
} elseif ($filePaths['sharedBladePath'] && File::exists($filePaths['sharedBladePath'])) {
|
||||
$sharedBlade = File::get($filePaths['sharedBladePath']);
|
||||
$fullLiquid = $sharedBlade."\n".$fullLiquid;
|
||||
}
|
||||
|
||||
// Check if the file ends with .liquid to set markup language
|
||||
if (pathinfo((string) $templatePath, PATHINFO_EXTENSION) === 'liquid') {
|
||||
$markupLanguage = 'liquid';
|
||||
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>';
|
||||
}
|
||||
} elseif ($filePaths['sharedLiquidPath']) {
|
||||
$templatePath = $filePaths['sharedLiquidPath'];
|
||||
$fullLiquid = File::get($templatePath);
|
||||
$markupLanguage = 'liquid';
|
||||
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>';
|
||||
} elseif ($filePaths['sharedBladePath']) {
|
||||
$templatePath = $filePaths['sharedBladePath'];
|
||||
$fullLiquid = File::get($templatePath);
|
||||
$markupLanguage = 'blade';
|
||||
}
|
||||
|
||||
// Ensure custom_fields is properly formatted
|
||||
if (! isset($settings['custom_fields']) || ! is_array($settings['custom_fields'])) {
|
||||
$settings['custom_fields'] = [];
|
||||
}
|
||||
|
||||
// Normalize options in custom_fields (convert non-named values to named values)
|
||||
$settings['custom_fields'] = $this->normalizeCustomFieldsOptions($settings['custom_fields']);
|
||||
|
||||
// Create configuration template with the custom fields
|
||||
$configurationTemplate = [
|
||||
'custom_fields' => $settings['custom_fields'],
|
||||
];
|
||||
|
||||
$plugin_updated = isset($settings['id'])
|
||||
&& Plugin::where('user_id', $user->id)->where('trmnlp_id', $settings['id'])->exists();
|
||||
// Create a new plugin
|
||||
$plugin = Plugin::updateOrCreate(
|
||||
[
|
||||
'user_id' => $user->id, 'trmnlp_id' => $settings['id'] ?? Uuid::v7(),
|
||||
],
|
||||
[
|
||||
'user_id' => $user->id,
|
||||
'name' => $settings['name'] ?? 'Imported Plugin',
|
||||
'trmnlp_id' => $settings['id'] ?? Uuid::v7(),
|
||||
'data_stale_minutes' => $settings['refresh_interval'] ?? 15,
|
||||
'data_strategy' => $settings['strategy'] ?? 'static',
|
||||
'polling_url' => $settings['polling_url'] ?? null,
|
||||
'polling_verb' => $settings['polling_verb'] ?? 'get',
|
||||
'polling_header' => isset($settings['polling_headers'])
|
||||
? str_replace('=', ':', $settings['polling_headers'])
|
||||
: null,
|
||||
'polling_body' => $settings['polling_body'] ?? null,
|
||||
'markup_language' => $markupLanguage,
|
||||
'render_markup' => $fullLiquid,
|
||||
'configuration_template' => $configurationTemplate,
|
||||
'data_payload' => json_decode($settings['static_data'] ?? '{}', true),
|
||||
]);
|
||||
|
||||
if (! $plugin_updated) {
|
||||
// Extract default values from custom_fields and populate configuration
|
||||
$configuration = [];
|
||||
foreach ($settings['custom_fields'] as $field) {
|
||||
if (isset($field['keyname']) && isset($field['default'])) {
|
||||
$configuration[$field['keyname']] = $field['default'];
|
||||
}
|
||||
}
|
||||
// set only if plugin is new
|
||||
$plugin->update([
|
||||
'configuration' => $configuration,
|
||||
]);
|
||||
}
|
||||
$plugin['trmnlp_yaml'] = $settingsYaml;
|
||||
|
||||
return $plugin;
|
||||
|
||||
} finally {
|
||||
// Clean up temporary directory
|
||||
Storage::deleteDirectory($tempDirName);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a plugin from a ZIP URL
|
||||
*
|
||||
* @param string $zipUrl The URL to the ZIP file
|
||||
* @param User $user The user importing the plugin
|
||||
* @param string|null $zipEntryPath Optional path to specific plugin in monorepo
|
||||
* @param string|null $preferredRenderer Optional preferred renderer (e.g., 'trmnl-liquid')
|
||||
* @param string|null $iconUrl Optional icon URL to set on the plugin
|
||||
* @param bool $allowDuplicate If true, generate a new UUID for trmnlp_id if a plugin with the same trmnlp_id already exists
|
||||
* @return Plugin The created plugin instance
|
||||
*
|
||||
* @throws Exception If the ZIP file is invalid or required files are missing
|
||||
*/
|
||||
public function importFromUrl(string $zipUrl, User $user, ?string $zipEntryPath = null, $preferredRenderer = null, ?string $iconUrl = null, bool $allowDuplicate = false): Plugin
|
||||
{
|
||||
// Download the ZIP file
|
||||
$response = Http::timeout(60)->get($zipUrl);
|
||||
|
||||
if (! $response->successful()) {
|
||||
throw new Exception('Could not download the ZIP file from the provided URL.');
|
||||
}
|
||||
|
||||
// Create a temporary file
|
||||
$tempDirName = 'temp/'.uniqid('plugin_import_', true);
|
||||
Storage::makeDirectory($tempDirName);
|
||||
$tempDir = Storage::path($tempDirName);
|
||||
$zipPath = $tempDir.'/plugin.zip';
|
||||
|
||||
// Save the downloaded content to a temporary file
|
||||
File::put($zipPath, $response->body());
|
||||
|
||||
try {
|
||||
// Extract the ZIP file using ZipArchive
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($zipPath) !== true) {
|
||||
throw new Exception('Could not open the downloaded ZIP file.');
|
||||
}
|
||||
|
||||
$zip->extractTo($tempDir);
|
||||
$zip->close();
|
||||
|
||||
// Find the required files (settings.yml and full.liquid/full.blade.php/shared.liquid/shared.blade.php)
|
||||
$filePaths = $this->findRequiredFiles($tempDir, $zipEntryPath);
|
||||
|
||||
// Validate that we found the required files
|
||||
if (! $filePaths['settingsYamlPath']) {
|
||||
throw new Exception('Invalid ZIP structure. Required file settings.yml is missing.');
|
||||
}
|
||||
|
||||
// Validate that we have at least one template file
|
||||
if (! $filePaths['fullLiquidPath'] && ! $filePaths['sharedLiquidPath'] && ! $filePaths['sharedBladePath']) {
|
||||
throw new Exception('Invalid ZIP structure. At least one of the following files is required: full.liquid, full.blade.php, shared.liquid, or shared.blade.php.');
|
||||
}
|
||||
|
||||
// Parse settings.yml
|
||||
$settingsYaml = File::get($filePaths['settingsYamlPath']);
|
||||
$settings = Yaml::parse($settingsYaml);
|
||||
$this->validateYAML($settings);
|
||||
|
||||
// Determine which template file to use and read its content
|
||||
$templatePath = null;
|
||||
$markupLanguage = 'blade';
|
||||
|
||||
if ($filePaths['fullLiquidPath']) {
|
||||
$templatePath = $filePaths['fullLiquidPath'];
|
||||
$fullLiquid = File::get($templatePath);
|
||||
|
||||
// Prepend shared.liquid or shared.blade.php content if available
|
||||
if ($filePaths['sharedLiquidPath'] && File::exists($filePaths['sharedLiquidPath'])) {
|
||||
$sharedLiquid = File::get($filePaths['sharedLiquidPath']);
|
||||
$fullLiquid = $sharedLiquid."\n".$fullLiquid;
|
||||
} elseif ($filePaths['sharedBladePath'] && File::exists($filePaths['sharedBladePath'])) {
|
||||
$sharedBlade = File::get($filePaths['sharedBladePath']);
|
||||
$fullLiquid = $sharedBlade."\n".$fullLiquid;
|
||||
}
|
||||
|
||||
// Check if the file ends with .liquid to set markup language
|
||||
if (pathinfo((string) $templatePath, PATHINFO_EXTENSION) === 'liquid') {
|
||||
$markupLanguage = 'liquid';
|
||||
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>';
|
||||
}
|
||||
} elseif ($filePaths['sharedLiquidPath']) {
|
||||
$templatePath = $filePaths['sharedLiquidPath'];
|
||||
$fullLiquid = File::get($templatePath);
|
||||
$markupLanguage = 'liquid';
|
||||
$fullLiquid = '<div class="view view--{{ size }}">'."\n".$fullLiquid."\n".'</div>';
|
||||
} elseif ($filePaths['sharedBladePath']) {
|
||||
$templatePath = $filePaths['sharedBladePath'];
|
||||
$fullLiquid = File::get($templatePath);
|
||||
$markupLanguage = 'blade';
|
||||
}
|
||||
|
||||
// Ensure custom_fields is properly formatted
|
||||
if (! isset($settings['custom_fields']) || ! is_array($settings['custom_fields'])) {
|
||||
$settings['custom_fields'] = [];
|
||||
}
|
||||
|
||||
// Normalize options in custom_fields (convert non-named values to named values)
|
||||
$settings['custom_fields'] = $this->normalizeCustomFieldsOptions($settings['custom_fields']);
|
||||
|
||||
// Create configuration template with the custom fields
|
||||
$configurationTemplate = [
|
||||
'custom_fields' => $settings['custom_fields'],
|
||||
];
|
||||
|
||||
// Determine the trmnlp_id to use
|
||||
$trmnlpId = $settings['id'] ?? Uuid::v7();
|
||||
|
||||
// If allowDuplicate is true and a plugin with this trmnlp_id already exists, generate a new UUID
|
||||
if ($allowDuplicate && isset($settings['id']) && Plugin::where('user_id', $user->id)->where('trmnlp_id', $settings['id'])->exists()) {
|
||||
$trmnlpId = Uuid::v7();
|
||||
}
|
||||
|
||||
$plugin_updated = ! $allowDuplicate && isset($settings['id'])
|
||||
&& Plugin::where('user_id', $user->id)->where('trmnlp_id', $settings['id'])->exists();
|
||||
|
||||
// Create a new plugin
|
||||
$plugin = Plugin::updateOrCreate(
|
||||
[
|
||||
'user_id' => $user->id, 'trmnlp_id' => $trmnlpId,
|
||||
],
|
||||
[
|
||||
'user_id' => $user->id,
|
||||
'name' => $settings['name'] ?? 'Imported Plugin',
|
||||
'trmnlp_id' => $trmnlpId,
|
||||
'data_stale_minutes' => $settings['refresh_interval'] ?? 15,
|
||||
'data_strategy' => $settings['strategy'] ?? 'static',
|
||||
'polling_url' => $settings['polling_url'] ?? null,
|
||||
'polling_verb' => $settings['polling_verb'] ?? 'get',
|
||||
'polling_header' => isset($settings['polling_headers'])
|
||||
? str_replace('=', ':', $settings['polling_headers'])
|
||||
: null,
|
||||
'polling_body' => $settings['polling_body'] ?? null,
|
||||
'markup_language' => $markupLanguage,
|
||||
'render_markup' => $fullLiquid,
|
||||
'configuration_template' => $configurationTemplate,
|
||||
'data_payload' => json_decode($settings['static_data'] ?? '{}', true),
|
||||
'preferred_renderer' => $preferredRenderer,
|
||||
'icon_url' => $iconUrl,
|
||||
]);
|
||||
|
||||
if (! $plugin_updated) {
|
||||
// Extract default values from custom_fields and populate configuration
|
||||
$configuration = [];
|
||||
foreach ($settings['custom_fields'] as $field) {
|
||||
if (isset($field['keyname']) && isset($field['default'])) {
|
||||
$configuration[$field['keyname']] = $field['default'];
|
||||
}
|
||||
}
|
||||
// set only if plugin is new
|
||||
$plugin->update([
|
||||
'configuration' => $configuration,
|
||||
]);
|
||||
}
|
||||
$plugin['trmnlp_yaml'] = $settingsYaml;
|
||||
|
||||
return $plugin;
|
||||
|
||||
} finally {
|
||||
// Clean up temporary directory
|
||||
Storage::deleteDirectory($tempDirName);
|
||||
}
|
||||
}
|
||||
|
||||
private function findRequiredFiles(string $tempDir, ?string $zipEntryPath = null): array
|
||||
{
|
||||
$settingsYamlPath = null;
|
||||
$fullLiquidPath = null;
|
||||
$sharedLiquidPath = null;
|
||||
$sharedBladePath = null;
|
||||
|
||||
// If zipEntryPath is specified, look for files in that specific directory first
|
||||
if ($zipEntryPath) {
|
||||
$targetDir = $tempDir.'/'.$zipEntryPath;
|
||||
if (File::exists($targetDir)) {
|
||||
// Check if files are directly in the target directory
|
||||
if (File::exists($targetDir.'/settings.yml')) {
|
||||
$settingsYamlPath = $targetDir.'/settings.yml';
|
||||
|
||||
if (File::exists($targetDir.'/full.liquid')) {
|
||||
$fullLiquidPath = $targetDir.'/full.liquid';
|
||||
} elseif (File::exists($targetDir.'/full.blade.php')) {
|
||||
$fullLiquidPath = $targetDir.'/full.blade.php';
|
||||
}
|
||||
|
||||
if (File::exists($targetDir.'/shared.liquid')) {
|
||||
$sharedLiquidPath = $targetDir.'/shared.liquid';
|
||||
} elseif (File::exists($targetDir.'/shared.blade.php')) {
|
||||
$sharedBladePath = $targetDir.'/shared.blade.php';
|
||||
}
|
||||
}
|
||||
|
||||
// Check if files are in src subdirectory of target directory
|
||||
if (! $settingsYamlPath && File::exists($targetDir.'/src/settings.yml')) {
|
||||
$settingsYamlPath = $targetDir.'/src/settings.yml';
|
||||
|
||||
if (File::exists($targetDir.'/src/full.liquid')) {
|
||||
$fullLiquidPath = $targetDir.'/src/full.liquid';
|
||||
} elseif (File::exists($targetDir.'/src/full.blade.php')) {
|
||||
$fullLiquidPath = $targetDir.'/src/full.blade.php';
|
||||
}
|
||||
|
||||
if (File::exists($targetDir.'/src/shared.liquid')) {
|
||||
$sharedLiquidPath = $targetDir.'/src/shared.liquid';
|
||||
} elseif (File::exists($targetDir.'/src/shared.blade.php')) {
|
||||
$sharedBladePath = $targetDir.'/src/shared.blade.php';
|
||||
}
|
||||
}
|
||||
|
||||
// If we found the required files in the target directory, return them
|
||||
if ($settingsYamlPath && ($fullLiquidPath || $sharedLiquidPath || $sharedBladePath)) {
|
||||
return [
|
||||
'settingsYamlPath' => $settingsYamlPath,
|
||||
'fullLiquidPath' => $fullLiquidPath,
|
||||
'sharedLiquidPath' => $sharedLiquidPath,
|
||||
'sharedBladePath' => $sharedBladePath,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// First, check if files are directly in the src folder
|
||||
if (File::exists($tempDir.'/src/settings.yml')) {
|
||||
$settingsYamlPath = $tempDir.'/src/settings.yml';
|
||||
|
||||
// Check for full.liquid or full.blade.php
|
||||
if (File::exists($tempDir.'/src/full.liquid')) {
|
||||
$fullLiquidPath = $tempDir.'/src/full.liquid';
|
||||
} elseif (File::exists($tempDir.'/src/full.blade.php')) {
|
||||
$fullLiquidPath = $tempDir.'/src/full.blade.php';
|
||||
}
|
||||
|
||||
// Check for shared.liquid or shared.blade.php in the same directory
|
||||
if (File::exists($tempDir.'/src/shared.liquid')) {
|
||||
$sharedLiquidPath = $tempDir.'/src/shared.liquid';
|
||||
} elseif (File::exists($tempDir.'/src/shared.blade.php')) {
|
||||
$sharedBladePath = $tempDir.'/src/shared.blade.php';
|
||||
}
|
||||
} else {
|
||||
// Search for the files in the extracted directory structure
|
||||
$directories = new RecursiveDirectoryIterator($tempDir, RecursiveDirectoryIterator::SKIP_DOTS);
|
||||
$files = new RecursiveIteratorIterator($directories);
|
||||
|
||||
foreach ($files as $file) {
|
||||
$filename = $file->getFilename();
|
||||
$filepath = $file->getPathname();
|
||||
|
||||
if ($filename === 'settings.yml') {
|
||||
$settingsYamlPath = $filepath;
|
||||
} elseif ($filename === 'full.liquid' || $filename === 'full.blade.php') {
|
||||
$fullLiquidPath = $filepath;
|
||||
} elseif ($filename === 'shared.liquid') {
|
||||
$sharedLiquidPath = $filepath;
|
||||
} elseif ($filename === 'shared.blade.php') {
|
||||
$sharedBladePath = $filepath;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if shared.liquid or shared.blade.php exists in the same directory as full.liquid
|
||||
if ($settingsYamlPath && $fullLiquidPath && ! $sharedLiquidPath && ! $sharedBladePath) {
|
||||
$fullLiquidDir = dirname((string) $fullLiquidPath);
|
||||
if (File::exists($fullLiquidDir.'/shared.liquid')) {
|
||||
$sharedLiquidPath = $fullLiquidDir.'/shared.liquid';
|
||||
} elseif (File::exists($fullLiquidDir.'/shared.blade.php')) {
|
||||
$sharedBladePath = $fullLiquidDir.'/shared.blade.php';
|
||||
}
|
||||
}
|
||||
|
||||
// If we found the files but they're not in the src folder,
|
||||
// check if they're in the root of the ZIP or in a subfolder
|
||||
if ($settingsYamlPath && ($fullLiquidPath || $sharedLiquidPath || $sharedBladePath)) {
|
||||
// If the files are in the root of the ZIP, create a src folder and move them there
|
||||
$srcDir = dirname((string) $settingsYamlPath);
|
||||
|
||||
// If the parent directory is not named 'src', create a src directory
|
||||
if (basename($srcDir) !== 'src') {
|
||||
$newSrcDir = $tempDir.'/src';
|
||||
File::makeDirectory($newSrcDir, 0755, true);
|
||||
|
||||
// Copy the files to the src directory
|
||||
File::copy($settingsYamlPath, $newSrcDir.'/settings.yml');
|
||||
|
||||
// Copy full.liquid or full.blade.php if it exists
|
||||
if ($fullLiquidPath) {
|
||||
$extension = pathinfo((string) $fullLiquidPath, PATHINFO_EXTENSION);
|
||||
File::copy($fullLiquidPath, $newSrcDir.'/full.'.$extension);
|
||||
$fullLiquidPath = $newSrcDir.'/full.'.$extension;
|
||||
}
|
||||
|
||||
// Copy shared.liquid or shared.blade.php if it exists
|
||||
if ($sharedLiquidPath) {
|
||||
File::copy($sharedLiquidPath, $newSrcDir.'/shared.liquid');
|
||||
$sharedLiquidPath = $newSrcDir.'/shared.liquid';
|
||||
} elseif ($sharedBladePath) {
|
||||
File::copy($sharedBladePath, $newSrcDir.'/shared.blade.php');
|
||||
$sharedBladePath = $newSrcDir.'/shared.blade.php';
|
||||
}
|
||||
|
||||
// Update the paths
|
||||
$settingsYamlPath = $newSrcDir.'/settings.yml';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'settingsYamlPath' => $settingsYamlPath,
|
||||
'fullLiquidPath' => $fullLiquidPath,
|
||||
'sharedLiquidPath' => $sharedLiquidPath,
|
||||
'sharedBladePath' => $sharedBladePath,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize options in custom_fields by converting non-named values to named values
|
||||
* This ensures that options like ["true", "false"] become [["true" => "true"], ["false" => "false"]]
|
||||
*
|
||||
* @param array $customFields The custom_fields array from settings
|
||||
* @return array The normalized custom_fields array
|
||||
*/
|
||||
private function normalizeCustomFieldsOptions(array $customFields): array
|
||||
{
|
||||
foreach ($customFields as &$field) {
|
||||
// Only process select fields with options
|
||||
if (isset($field['field_type']) && $field['field_type'] === 'select' && isset($field['options']) && is_array($field['options'])) {
|
||||
$normalizedOptions = [];
|
||||
foreach ($field['options'] as $option) {
|
||||
// If option is already a named value (array with key-value pair), keep it as is
|
||||
if (is_array($option)) {
|
||||
$normalizedOptions[] = $option;
|
||||
} else {
|
||||
// Convert non-named value to named value
|
||||
// Convert boolean to string, use lowercase for label
|
||||
$value = is_bool($option) ? ($option ? 'true' : 'false') : (string) $option;
|
||||
$normalizedOptions[] = [$value => $value];
|
||||
}
|
||||
}
|
||||
$field['options'] = $normalizedOptions;
|
||||
|
||||
// Normalize default value to match normalized option values
|
||||
if (isset($field['default'])) {
|
||||
$default = $field['default'];
|
||||
// If default is boolean, convert to string to match normalized options
|
||||
if (is_bool($default)) {
|
||||
$field['default'] = $default ? 'true' : 'false';
|
||||
} else {
|
||||
// Convert to string to ensure consistency
|
||||
$field['default'] = (string) $default;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $customFields;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that template and context are within command-line argument limits
|
||||
*
|
||||
* @param string $template The liquid template string
|
||||
* @param string $jsonContext The JSON-encoded context
|
||||
* @param string $liquidPath The path to the liquid renderer executable
|
||||
*
|
||||
* @throws Exception If the template or context exceeds argument limits
|
||||
*/
|
||||
public function validateExternalRendererArguments(string $template, string $jsonContext, string $liquidPath): void
|
||||
{
|
||||
// MAX_ARG_STRLEN on Linux is typically 131072 (128KB) for individual arguments
|
||||
// ARG_MAX is the total size of all arguments (typically 2MB on modern systems)
|
||||
$maxIndividualArgLength = 131072; // 128KB - MAX_ARG_STRLEN limit
|
||||
$maxTotalArgLength = $this->getMaxArgumentLength();
|
||||
|
||||
// Check individual argument sizes (template and context are the largest)
|
||||
if (mb_strlen($template) > $maxIndividualArgLength || mb_strlen($jsonContext) > $maxIndividualArgLength) {
|
||||
throw new Exception('Context too large for external liquid renderer. Reduce the size of the Payload or Template.');
|
||||
}
|
||||
|
||||
// Calculate total size of all arguments (path + flags + template + context)
|
||||
// Add overhead for path, flags, and separators (conservative estimate: ~200 bytes)
|
||||
$totalArgSize = mb_strlen($liquidPath) + mb_strlen('--template') + mb_strlen($template)
|
||||
+ mb_strlen('--context') + mb_strlen($jsonContext) + 200;
|
||||
|
||||
if ($totalArgSize > $maxTotalArgLength) {
|
||||
throw new Exception('Context too large for external liquid renderer. Reduce the size of the Payload or Template.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the maximum argument length for command-line arguments
|
||||
*
|
||||
* @return int Maximum argument length in bytes
|
||||
*/
|
||||
private function getMaxArgumentLength(): int
|
||||
{
|
||||
// Try to get ARG_MAX from system using getconf
|
||||
$argMax = null;
|
||||
if (function_exists('shell_exec')) {
|
||||
$result = @shell_exec('getconf ARG_MAX 2>/dev/null');
|
||||
if ($result !== null && is_numeric(mb_trim($result))) {
|
||||
$argMax = (int) mb_trim($result);
|
||||
}
|
||||
}
|
||||
|
||||
// Use conservative fallback if ARG_MAX cannot be determined
|
||||
// ARG_MAX on macOS is typically 262144 (256KB), on Linux it's usually 2097152 (2MB)
|
||||
// We use 200KB as a conservative limit that works on both systems
|
||||
// Note: ARG_MAX includes environment variables, so we leave headroom
|
||||
return $argMax !== null ? min($argMax, 204800) : 204800;
|
||||
}
|
||||
}
|
||||
|
|
@ -4,34 +4,45 @@
|
|||
"type": "project",
|
||||
"description": "TRMNL Server Implementation (BYOS) for Laravel",
|
||||
"keywords": [
|
||||
"laravel",
|
||||
"framework",
|
||||
"trmnl"
|
||||
"trmnl",
|
||||
"trmnl-server",
|
||||
"trmnl-byos",
|
||||
"laravel"
|
||||
],
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"ext-imagick": "*",
|
||||
"bnussbau/laravel-trmnl": "^0.1.4",
|
||||
"intervention/image": "^3.11",
|
||||
"ext-simplexml": "*",
|
||||
"ext-zip": "*",
|
||||
"bnussbau/laravel-trmnl-blade": "2.1.*",
|
||||
"bnussbau/trmnl-pipeline-php": "^0.6.0",
|
||||
"keepsuit/laravel-liquid": "^0.5.2",
|
||||
"laravel/framework": "^12.1",
|
||||
"laravel/sanctum": "^4.0",
|
||||
"laravel/socialite": "^5.23",
|
||||
"laravel/tinker": "^2.10.1",
|
||||
"livewire/flux": "^2.0",
|
||||
"livewire/volt": "^1.7",
|
||||
"om/icalparser": "^3.2",
|
||||
"spatie/browsershot": "^5.0",
|
||||
"spatie/pest-expectations": "^1.3"
|
||||
"stevebauman/purify": "^6.3",
|
||||
"symfony/yaml": "^7.3",
|
||||
"wnx/sidecar-browsershot": "^2.6"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
"larastan/larastan": "^3.0",
|
||||
"laravel/boost": "^1.0",
|
||||
"laravel/pail": "^1.2.2",
|
||||
"laravel/pint": "^1.18",
|
||||
"laravel/sail": "^1.41",
|
||||
"mockery/mockery": "^1.6",
|
||||
"nunomaduro/collision": "^8.6",
|
||||
"pestphp/pest": "^3.7",
|
||||
"pestphp/pest-plugin-drift": "^3.0",
|
||||
"pestphp/pest-plugin-laravel": "^3.1"
|
||||
"pestphp/pest": "^4.0",
|
||||
"pestphp/pest-plugin-drift": "^4.0",
|
||||
"pestphp/pest-plugin-laravel": "^4.0",
|
||||
"rector/rector": "^2.1"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
|
|
@ -64,7 +75,13 @@
|
|||
"dev": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite"
|
||||
]
|
||||
],
|
||||
"test": "vendor/bin/pest",
|
||||
"test-coverage": "vendor/bin/pest --coverage",
|
||||
"format": "vendor/bin/pint",
|
||||
"analyse": "vendor/bin/phpstan analyse",
|
||||
"analyze": "vendor/bin/phpstan analyse",
|
||||
"rector": "vendor/bin/rector process"
|
||||
},
|
||||
"extra": {
|
||||
"laravel": {
|
||||
|
|
|
|||
5037
composer.lock
generated
5037
composer.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -129,5 +129,28 @@ return [
|
|||
|
||||
'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),
|
||||
|
||||
'notifications' => [
|
||||
'battery_low' => [
|
||||
'warn_at_percent' => env('NOTIFICATION_BATTERYLOW_WARNATPERCENT', 20),
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Version
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value is the version of your application, which will be used when
|
||||
| displaying the version number in the UI. This is set during the Docker
|
||||
| build process from the release tag.
|
||||
|
|
||||
*/
|
||||
|
||||
'version' => env('APP_VERSION', null),
|
||||
|
||||
'catalog_url' => env('CATALOG_URL', 'https://raw.githubusercontent.com/bnussbau/trmnl-recipe-catalog/refs/heads/main/catalog.yaml'),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -40,6 +40,28 @@ return [
|
|||
'proxy_refresh_minutes' => env('TRMNL_PROXY_REFRESH_MINUTES', 15),
|
||||
'proxy_refresh_cron' => env('TRMNL_PROXY_REFRESH_CRON'),
|
||||
'override_orig_icon' => env('TRMNL_OVERRIDE_ORIG_ICON', false),
|
||||
'image_url_timeout' => env('TRMNL_IMAGE_URL_TIMEOUT', 30), // 30 seconds; increase on low-powered devices
|
||||
'liquid_enabled' => env('TRMNL_LIQUID_ENABLED', false),
|
||||
'liquid_path' => env('TRMNL_LIQUID_PATH', '/usr/local/bin/trmnl-liquid-cli'),
|
||||
],
|
||||
|
||||
'webhook' => [
|
||||
'notifications' => [
|
||||
'url' => env('WEBHOOK_NOTIFICATION_URL', null),
|
||||
'topic' => env('WEBHOOK_NOTIFICATION_TOPIC', 'null'),
|
||||
],
|
||||
],
|
||||
|
||||
'oidc' => [
|
||||
'enabled' => env('OIDC_ENABLED', false),
|
||||
// OIDC_ENDPOINT can be either:
|
||||
// - Base URL: https://your-provider.com (will append /.well-known/openid-configuration)
|
||||
// - Full well-known URL: https://your-provider.com/.well-known/openid-configuration
|
||||
'endpoint' => env('OIDC_ENDPOINT'),
|
||||
'client_id' => env('OIDC_CLIENT_ID'),
|
||||
'client_secret' => env('OIDC_CLIENT_SECRET'),
|
||||
'redirect' => env('APP_URL', 'http://localhost:8000').'/auth/oidc/callback',
|
||||
'scopes' => explode(',', env('OIDC_SCOPES', 'openid,profile,email')),
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
56
config/sidecar-browsershot.php
Normal file
56
config/sidecar-browsershot.php
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
/**
|
||||
* Define the allocated memory available to SidecarBrowsershot in megabytes. (Defaults to 2GB)
|
||||
* We suggest to allocate at least 513 MB of memory to push Chrome/Puppeteer out of "low-spec" mode.
|
||||
*
|
||||
* @see https://hammerstone.dev/sidecar/docs/main/functions/customization#memory
|
||||
* @see https://github.blog/2021-06-22-framework-building-open-graph-images/
|
||||
*/
|
||||
'memory' => env('SIDECAR_BROWSERSHOT_MEMORY', 2048),
|
||||
|
||||
/**
|
||||
* The default ephemeral storage available to SidecarBrowsershot, in megabytes. (Defaults to 512MB)
|
||||
*
|
||||
* @see https://hammerstone.dev/sidecar/docs/main/functions/customization#storage
|
||||
*/
|
||||
'storage' => env('SIDECAR_BROWSERSHOT_STORAGE', 512),
|
||||
|
||||
/**
|
||||
* The default timeout to use for SidecarBrowsershot, in seconds. (Defaults to 300)
|
||||
*
|
||||
* @see https://hammerstone.dev/sidecar/docs/main/functions/customization#timeout
|
||||
*/
|
||||
'timeout' => env('SIDECAR_BROWSERSHOT_TIMEOUT', 300),
|
||||
|
||||
/**
|
||||
* Define the number of warming instances to boot.
|
||||
*
|
||||
* @see https://hammerstone.dev/sidecar/docs/main/functions/warming
|
||||
*/
|
||||
'warming' => env('SIDECAR_BROWSERSHOT_WARMING_INSTANCES', 0),
|
||||
|
||||
/**
|
||||
* AWS Layers to use by the Lambda function.
|
||||
* Defaults to "shelfio/chrome-aws-lambda-layer" and "sidecar-browsershot-layer" in your respective AWS region.
|
||||
*
|
||||
* If you customize this, you must include both "sidecar-browsershot-layer" and "shelfio/chrome-aws-lambda-layer"
|
||||
* in your list, as the config overrides the default values.
|
||||
* (See BrowsershotFunction@layers for more details)
|
||||
*
|
||||
* @see https://github.com/shelfio/chrome-aws-lambda-layer
|
||||
* @see https://github.com/stefanzweifel/sidecar-browsershot-layer
|
||||
*/
|
||||
'layers' => [
|
||||
// "arn:aws:lambda:us-east-1:821527532446:layer:sidecar-browsershot-layer:2",
|
||||
// "arn:aws:lambda:us-east-1:764866452798:layer:chrome-aws-lambda:42",
|
||||
],
|
||||
|
||||
/**
|
||||
* Path to local directory containing fonts to be installed in the Lambda function.
|
||||
* During deployment, BorwsershotLambda will scan this directory for
|
||||
* any files and will bundle them into the Lambda function.
|
||||
*/
|
||||
'fonts' => resource_path('sidecar-browsershot/fonts'),
|
||||
];
|
||||
10
config/sidecar.php
Normal file
10
config/sidecar.php
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
/*
|
||||
* All of your function classes that you'd like to deploy go here.
|
||||
*/
|
||||
'functions' => [
|
||||
Wnx\SidecarBrowsershot\Functions\BrowsershotFunction::class,
|
||||
],
|
||||
];
|
||||
6
config/trustedproxy.php
Normal file
6
config/trustedproxy.php
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
// Comma‑separated list from .env, e.g. "10.0.0.0/8,172.16.0.0/12" or '*'
|
||||
'proxies' => ($proxies = env('TRUSTED_PROXIES', '')) === '*' ? '*' : array_filter(explode(',', $proxies)),
|
||||
];
|
||||
24
database/factories/DeviceLogFactory.php
Normal file
24
database/factories/DeviceLogFactory.php
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Device;
|
||||
use App\Models\DeviceLog;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class DeviceLogFactory extends Factory
|
||||
{
|
||||
protected $model = DeviceLog::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'log_entry' => ['creation_timestamp' => fake()->dateTimeBetween('-1 month', 'now')->getTimestamp(), 'device_status_stamp' => ['wifi_rssi_level' => -65, 'wifi_status' => 'connected', 'refresh_rate' => 900, 'time_since_last_sleep_start' => 901, 'current_fw_version' => '1.5.5', 'special_function' => 'none', 'battery_voltage' => 4.052, 'wakeup_reason' => 'timer', 'free_heap_size' => 215128, 'max_alloc_size' => 192500], 'log_id' => 17, 'log_message' => 'Error fetching API display: 7, detail: HTTP Client failed with error: connection refused(-1)', 'log_codeline' => 586, 'log_sourcefile' => "src\/bl.cpp", 'additional_info' => ['filename_current' => 'UUID.png', 'filename_new' => null, 'retry_attempt' => 5]],
|
||||
'device_timestamp' => fake()->dateTimeBetween('-1 month', 'now'),
|
||||
'created_at' => Carbon::now(),
|
||||
'updated_at' => Carbon::now(),
|
||||
'device_id' => Device::first(),
|
||||
];
|
||||
}
|
||||
}
|
||||
38
database/factories/DeviceModelFactory.php
Normal file
38
database/factories/DeviceModelFactory.php
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\DeviceModel;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\DeviceModel>
|
||||
*/
|
||||
class DeviceModelFactory extends Factory
|
||||
{
|
||||
protected $model = DeviceModel::class;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->faker->unique()->slug(),
|
||||
'label' => $this->faker->words(2, true),
|
||||
'description' => $this->faker->sentence(),
|
||||
'width' => $this->faker->randomElement([800, 1024, 1280, 1920]),
|
||||
'height' => $this->faker->randomElement([480, 600, 720, 1080]),
|
||||
'colors' => $this->faker->randomElement([2, 16, 256, 65536]),
|
||||
'bit_depth' => $this->faker->randomElement([1, 4, 8, 16]),
|
||||
'scale_factor' => $this->faker->randomElement([1, 2, 4]),
|
||||
'rotation' => $this->faker->randomElement([0, 90, 180, 270]),
|
||||
'mime_type' => $this->faker->randomElement(['image/png', 'image/jpeg', 'image/gif']),
|
||||
'offset_x' => $this->faker->numberBetween(-100, 100),
|
||||
'offset_y' => $this->faker->numberBetween(-100, 100),
|
||||
'published_at' => $this->faker->optional()->dateTimeBetween('-1 year', 'now'),
|
||||
];
|
||||
}
|
||||
}
|
||||
38
database/factories/DevicePaletteFactory.php
Normal file
38
database/factories/DevicePaletteFactory.php
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\DevicePalette;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\DevicePalette>
|
||||
*/
|
||||
class DevicePaletteFactory extends Factory
|
||||
{
|
||||
protected $model = DevicePalette::class;
|
||||
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'id' => 'test-'.$this->faker->unique()->slug(),
|
||||
'name' => $this->faker->words(3, true),
|
||||
'grays' => $this->faker->randomElement([2, 4, 16, 256]),
|
||||
'colors' => $this->faker->optional()->passthrough([
|
||||
'#FF0000',
|
||||
'#00FF00',
|
||||
'#0000FF',
|
||||
'#FFFF00',
|
||||
'#000000',
|
||||
'#FFFFFF',
|
||||
]),
|
||||
'framework_class' => null,
|
||||
'source' => 'api',
|
||||
];
|
||||
}
|
||||
}
|
||||
24
database/factories/FirmwareFactory.php
Normal file
24
database/factories/FirmwareFactory.php
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Firmware;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
class FirmwareFactory extends Factory
|
||||
{
|
||||
protected $model = Firmware::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'version_tag' => $this->faker->word(),
|
||||
'url' => $this->faker->url(),
|
||||
'latest' => $this->faker->boolean(),
|
||||
'storage_location' => $this->faker->word(),
|
||||
'created_at' => Carbon::now(),
|
||||
'updated_at' => Carbon::now(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ class PlaylistItemFactory extends Factory
|
|||
return [
|
||||
'playlist_id' => Playlist::factory(),
|
||||
'plugin_id' => Plugin::factory(),
|
||||
'mashup' => null,
|
||||
'order' => $this->faker->numberBetween(0, 100),
|
||||
'is_active' => $this->faker->boolean(80), // 80% chance of being active
|
||||
'last_displayed_at' => null,
|
||||
|
|
|
|||
|
|
@ -22,14 +22,31 @@ class PluginFactory extends Factory
|
|||
'polling_url' => $this->faker->url(),
|
||||
'polling_verb' => $this->faker->randomElement(['get', 'post']),
|
||||
'polling_header' => null,
|
||||
'polling_body' => null,
|
||||
'render_markup' => null,
|
||||
'render_markup_view' => null,
|
||||
'detail_view_route' => null,
|
||||
'icon_url' => null,
|
||||
'flux_icon_name' => null,
|
||||
'author_name' => $this->faker->name(),
|
||||
'plugin_type' => 'recipe',
|
||||
'created_at' => Carbon::now(),
|
||||
'updated_at' => Carbon::now(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicate that the plugin is an image webhook plugin.
|
||||
*/
|
||||
public function imageWebhook(): static
|
||||
{
|
||||
return $this->state(fn (array $attributes): array => [
|
||||
'plugin_type' => 'image_webhook',
|
||||
'data_strategy' => 'static',
|
||||
'data_stale_minutes' => 60,
|
||||
'polling_url' => null,
|
||||
'polling_verb' => 'get',
|
||||
'name' => $this->faker->randomElement(['Camera Feed', 'Security Camera', 'Webcam', 'Image Stream']),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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('playlists', function (Blueprint $table) {
|
||||
$table->integer('refresh_time')->nullable()->after('active_until');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('playlists', function (Blueprint $table) {
|
||||
$table->dropColumn('refresh_time');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// Correct the typo in render_markup_view for all plugin UUIDs
|
||||
$pluginUuids = [
|
||||
'9e46c6cf-358c-4bfe-8998-436b3a207fec', // ÖBB Departures
|
||||
'3b046eda-34e9-4232-b935-c33b989a284b', // Weather
|
||||
'21464b16-5f5a-4099-a967-f5c915e3da54', // Zen Quotes
|
||||
'8d472959-400f-46ee-afb2-4a9f1cfd521f', // This Day in History
|
||||
'4349fdad-a273-450b-aa00-3d32f2de788d', // Home Assistant
|
||||
];
|
||||
|
||||
foreach ($pluginUuids as $uuid) {
|
||||
DB::table('plugins')
|
||||
->where('uuid', $uuid)
|
||||
->update([
|
||||
'render_markup_view' => DB::raw("REPLACE(render_markup_view, 'receipts.', 'recipes.')"),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// Revert the typo correction if needed
|
||||
$pluginUuids = [
|
||||
'9e46c6cf-358c-4bfe-8998-436b3a207fec', // ÖBB Departures
|
||||
'3b046eda-34e9-4232-b935-c33b989a284b', // Weather
|
||||
'21464b16-5f5a-4099-a967-f5c915e3da54', // Zen Quotes
|
||||
'8d472959-400f-46ee-afb2-4a9f1cfd521f', // This Day in History
|
||||
'4349fdad-a273-450b-aa00-3d32f2de788d', // Home Assistant
|
||||
];
|
||||
|
||||
foreach ($pluginUuids as $uuid) {
|
||||
DB::table('plugins')
|
||||
->where('uuid', $uuid)
|
||||
->update([
|
||||
'render_markup_view' => DB::raw("REPLACE(render_markup_view, 'recipes.', 'receipts.')"),
|
||||
]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('devices', function (Blueprint $table) {
|
||||
$table->integer('width')->nullable()->default(800)->after('api_key');
|
||||
$table->integer('height')->nullable()->default(480)->after('width');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('devices', function (Blueprint $table) {
|
||||
$table->dropColumn('width');
|
||||
$table->dropColumn('height');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('devices', function (Blueprint $table) {
|
||||
$table->foreignId('mirror_device_id')->nullable()->constrained('devices')->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('devices', function (Blueprint $table) {
|
||||
$table->dropForeign(['mirror_device_id']);
|
||||
$table->dropColumn('mirror_device_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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('users', function (Blueprint $table) {
|
||||
$table->foreignId('assign_new_device_id')->nullable()->constrained('devices')->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropForeign(['assign_new_device_id']);
|
||||
$table->dropColumn('assign_new_device_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
28
database/migrations/2025_05_10_182724_add_plugin_cache.php
Normal file
28
database/migrations/2025_05_10_182724_add_plugin_cache.php
Normal file
|
|
@ -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->string('current_image')->nullable()->after('data_payload_updated_at');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('plugins', function (Blueprint $table) {
|
||||
$table->dropColumn('current_image');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('devices', function (Blueprint $table) {
|
||||
$table->integer('rotate')->nullable()->default(0)->after('width');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('devices', function (Blueprint $table) {
|
||||
$table->dropColumn('rotate');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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('devices', function (Blueprint $table) {
|
||||
$table->string('image_format')->default('auto')->after('rotate');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('devices', function (Blueprint $table) {
|
||||
$table->dropColumn('image_format');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('firmware', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('version_tag');
|
||||
$table->string('url')->nullable();
|
||||
$table->boolean('latest')->default(false);
|
||||
$table->string('storage_location')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('firmware');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('devices', function (Blueprint $table) {
|
||||
$table->foreignId('update_firmware_id')->nullable()->constrained('firmware')->nullOnDelete();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('devices', function (Blueprint $table) {
|
||||
$table->dropForeign(['update_firmware_id']);
|
||||
$table->dropColumn('update_firmware_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
use App\Models\Device;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('device_logs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignIdFor(Device::class)->constrained('devices')->cascadeOnDelete();
|
||||
$table->timestamp('device_timestamp');
|
||||
$table->json('log_entry');
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('device_logs');
|
||||
}
|
||||
};
|
||||
|
|
@ -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('devices', function (Blueprint $table) {
|
||||
$table->timestamp('last_refreshed_at')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('devices', function (Blueprint $table) {
|
||||
$table->dropColumn('last_refreshed_at');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('playlist_items', function (Blueprint $table) {
|
||||
$table->json('mashup')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('playlist_items', function (Blueprint $table) {
|
||||
$table->dropColumn('mashup');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
<?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('configuration_template')->nullable();
|
||||
$table->json('configuration')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('plugins', function (Blueprint $table) {
|
||||
$table->dropColumn('configuration_template');
|
||||
$table->dropColumn('configuration');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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('devices', function (Blueprint $table) {
|
||||
$table->boolean('battery_notification_sent')->default(false);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('devices', function (Blueprint $table) {
|
||||
$table->dropColumn('battery_notification_sent');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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('plugins', function (Blueprint $table) {
|
||||
$table->string('polling_url', 1024)->nullable()->change();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('plugins', function (Blueprint $table) {
|
||||
// old default string length value in Illuminate
|
||||
$table->string('polling_url', 255)->nullable()->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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->text('polling_body')->nullable()->after('polling_header');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('plugins', function (Blueprint $table) {
|
||||
$table->dropColumn('polling_body');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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->string('markup_language')->nullable()->after('render_markup');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('plugins', function (Blueprint $table) {
|
||||
$table->dropColumn('markup_language');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('devices', function (Blueprint $table) {
|
||||
$table->boolean('sleep_mode_enabled')->default(false);
|
||||
$table->time('sleep_mode_from')->nullable();
|
||||
$table->time('sleep_mode_to')->nullable();
|
||||
$table->string('special_function')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('devices', function (Blueprint $table) {
|
||||
$table->dropColumn(['sleep_mode_enabled', 'sleep_mode_from', 'sleep_mode_to', 'special_function']);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('devices', function (Blueprint $table) {
|
||||
$table->dateTime('pause_until')->nullable()->after('last_refreshed_at');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('devices', function (Blueprint $table) {
|
||||
$table->dropColumn('pause_until');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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('users', function (Blueprint $table) {
|
||||
$table->string('oidc_sub')->nullable()->unique()->after('email');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropUnique(['oidc_sub']);
|
||||
$table->dropColumn('oidc_sub');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('device_models', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name')->unique();
|
||||
$table->string('label');
|
||||
$table->text('description');
|
||||
$table->unsignedInteger('width');
|
||||
$table->unsignedInteger('height');
|
||||
$table->unsignedInteger('colors');
|
||||
$table->unsignedInteger('bit_depth');
|
||||
$table->float('scale_factor');
|
||||
$table->integer('rotation');
|
||||
$table->string('mime_type');
|
||||
$table->integer('offset_x')->default(0);
|
||||
$table->integer('offset_y')->default(0);
|
||||
$table->timestamp('published_at')->nullable();
|
||||
$table->string('source')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('device_models');
|
||||
}
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue