Add department hardware workspace and plate scanner updates

This commit is contained in:
Jeppe Bundgaard
2026-04-23 13:31:57 +02:00
parent e09e23025d
commit c38a4379bd
30 changed files with 2561 additions and 60 deletions
+15 -2
View File
@@ -53,8 +53,21 @@ services:
container_name: edge-broker
environment:
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
ports:
- "4300:4300"
labels:
- "traefik.enable=true"
- "traefik.http.routers.edge-broker-api.rule=Host(`api.example.com`) && PathPrefix(`/edge-broker`)"
- "traefik.http.routers.edge-broker-api.entrypoints=websecure"
- "traefik.http.routers.edge-broker-api.tls=true"
- "traefik.http.routers.edge-broker-api.tls.certresolver=le"
- "traefik.http.routers.edge-broker-api.middlewares=edge-broker-strip"
- "traefik.http.routers.edge-broker-api.service=edge-broker"
- "traefik.http.routers.edge-broker-local.rule=Host(`localhost`) && PathPrefix(`/api/edge-broker`)"
- "traefik.http.routers.edge-broker-local.entrypoints=web"
- "traefik.http.routers.edge-broker-local.middlewares=edge-broker-strip-local"
- "traefik.http.routers.edge-broker-local.service=edge-broker"
- "traefik.http.middlewares.edge-broker-strip.stripPrefix.prefixes=/edge-broker"
- "traefik.http.middlewares.edge-broker-strip-local.stripPrefix.prefixes=/api/edge-broker"
- "traefik.http.services.edge-broker.loadbalancer.server.port=4300"
caddy:
image: caddy:2.7.6-alpine
+424
View File
@@ -0,0 +1,424 @@
services:
traefik:
image: traefik:2.11
container_name: traefik
ports:
- "80:80"
- "443:443"
- "4433:4433"
- "9100:9100"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./services/traefik/traefik.yml:/etc/traefik/traefik.yml:ro
- ./services/traefik/dynamic.yml:/etc/traefik/dynamic.yml:ro
- ./services/traefik/acme.json:/acme.json
- ./services/traefik/acme-io.json:/acme-io.json
labels:
- "traefik.enable=true"
- "traefik.http.routers.traefik.rule=Host(`traefik.truckwash.dk`)"
- "traefik.http.routers.traefik.entrypoints=websecure"
- "traefik.http.routers.traefik.tls=true"
- "traefik.http.routers.traefik.tls.certresolver=le"
- "traefik.http.routers.traefik.service=api@internal"
- "traefik.http.routers.traefik.middlewares=dashboard-allow-local@file,dashboard-auth@file"
- "traefik.http.routers.traefik-http.rule=Host(`traefik.truckwash.dk`)"
- "traefik.http.routers.traefik-http.entrypoints=web"
- "traefik.http.routers.traefik-http.middlewares=redirect-to-https@file"
- "traefik.http.routers.traefik-http.service=api@internal"
- "traefik.http.routers.traefik-local.rule=Host(`traefik.localhost`)"
- "traefik.http.routers.traefik-local.entrypoints=web"
- "traefik.http.routers.traefik-local.service=api@internal"
- "traefik.http.routers.traefik-local.middlewares=dashboard-allow-local@file,dashboard-auth@file"
redis:
image: redis:7
container_name: redis
volumes:
- nnks_redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
redis-staging:
image: redis:7
container_name: redis-staging
volumes:
- nnks_redis_staging:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
mysql-debug:
image: mysql:8.4
container_name: mysql-debug
environment:
MYSQL_ROOT_PASSWORD: ${CONFIG_DB_DEBUG_PASSWORD:-debug_root_password}
MYSQL_DATABASE: ${CONFIG_DB_DEBUG_DATABASE:-nnks_db_debug}
ports:
- "3307:3306"
volumes:
- db_debug_data:/var/lib/mysql
healthcheck:
test: ["CMD-SHELL", "MYSQL_PWD=$$MYSQL_ROOT_PASSWORD mysqladmin -u root ping --silent"]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
edge-broker:
build:
context: .
dockerfile: services/edge-broker/Dockerfile
container_name: edge-broker
environment:
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
labels:
- "traefik.enable=true"
- "traefik.http.routers.edge-broker-api.rule=Host(`api.truckwash.dk`) && PathPrefix(`/edge-broker`)"
- "traefik.http.routers.edge-broker-api.entrypoints=websecure"
- "traefik.http.routers.edge-broker-api.tls=true"
- "traefik.http.routers.edge-broker-api.tls.certresolver=le"
- "traefik.http.routers.edge-broker-api.middlewares=secure-headers@file,edge-broker-strip"
- "traefik.http.routers.edge-broker-api.priority=200"
- "traefik.http.routers.edge-broker-api.service=edge-broker"
- "traefik.http.routers.edge-broker-api-io.rule=Host(`api.truckwash.io`) && PathPrefix(`/edge-broker`)"
- "traefik.http.routers.edge-broker-api-io.entrypoints=websecure"
- "traefik.http.routers.edge-broker-api-io.tls=true"
- "traefik.http.routers.edge-broker-api-io.tls.certresolver=le_io"
- "traefik.http.routers.edge-broker-api-io.middlewares=secure-headers@file,edge-broker-strip"
- "traefik.http.routers.edge-broker-api-io.priority=200"
- "traefik.http.routers.edge-broker-api-io.service=edge-broker"
- "traefik.http.routers.edge-broker-api-staging.rule=Host(`api.truckwash.io`) && PathPrefix(`/edge-broker`)"
- "traefik.http.routers.edge-broker-api-staging.entrypoints=websecure-staging"
- "traefik.http.routers.edge-broker-api-staging.tls=true"
- "traefik.http.routers.edge-broker-api-staging.tls.certresolver=le_io"
- "traefik.http.routers.edge-broker-api-staging.middlewares=secure-headers@file,edge-broker-strip"
- "traefik.http.routers.edge-broker-api-staging.priority=200"
- "traefik.http.routers.edge-broker-api-staging.service=edge-broker"
- "traefik.http.routers.edge-broker-local.rule=Host(`localhost`) && PathPrefix(`/api/edge-broker`)"
- "traefik.http.routers.edge-broker-local.entrypoints=web"
- "traefik.http.routers.edge-broker-local.middlewares=secure-headers@file,edge-broker-strip-local"
- "traefik.http.routers.edge-broker-local.priority=200"
- "traefik.http.routers.edge-broker-local.service=edge-broker"
- "traefik.http.routers.edge-broker-local-secure.rule=Host(`localhost`) && PathPrefix(`/api/edge-broker`)"
- "traefik.http.routers.edge-broker-local-secure.entrypoints=websecure"
- "traefik.http.routers.edge-broker-local-secure.tls=true"
- "traefik.http.routers.edge-broker-local-secure.middlewares=secure-headers@file,edge-broker-strip-local"
- "traefik.http.routers.edge-broker-local-secure.priority=200"
- "traefik.http.routers.edge-broker-local-secure.service=edge-broker"
- "traefik.http.routers.edge-broker-local-staging.rule=Host(`localhost`) && PathPrefix(`/api/edge-broker`)"
- "traefik.http.routers.edge-broker-local-staging.entrypoints=websecure-staging"
- "traefik.http.routers.edge-broker-local-staging.tls=true"
- "traefik.http.routers.edge-broker-local-staging.middlewares=secure-headers@file,edge-broker-strip-local"
- "traefik.http.routers.edge-broker-local-staging.priority=200"
- "traefik.http.routers.edge-broker-local-staging.service=edge-broker"
- "traefik.http.middlewares.edge-broker-strip.stripPrefix.prefixes=/edge-broker"
- "traefik.http.middlewares.edge-broker-strip-local.stripPrefix.prefixes=/api/edge-broker"
- "traefik.http.services.edge-broker.loadbalancer.server.port=4300"
caddy:
image: caddy:2.7.6-alpine
container_name: caddy
depends_on:
- php1
- php2
- php3
- php4
- php5
volumes:
- ./services/nginx/app:/var/www/html
- ./services/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- ./services/caddy/logs:/var/log/caddy
labels:
- "traefik.enable=true"
- "traefik.http.routers.api.rule=Host(`api.truckwash.dk`)"
- "traefik.http.routers.api.entrypoints=websecure"
- "traefik.http.routers.api.tls=true"
- "traefik.http.routers.api.tls.domains[0].main=api.truckwash.dk"
- "traefik.http.routers.api.tls.certresolver=le"
- "traefik.http.routers.api.service=caddy"
- "traefik.http.routers.api.middlewares=secure-headers@file,api-ratelimit@file"
- "traefik.http.routers.api-io.rule=Host(`api.truckwash.io`)"
- "traefik.http.routers.api-io.entrypoints=websecure"
- "traefik.http.routers.api-io.tls=true"
- "traefik.http.routers.api-io.tls.domains[0].main=api.truckwash.io"
- "traefik.http.routers.api-io.tls.certresolver=le_io"
- "traefik.http.routers.api-io.service=caddy"
- "traefik.http.routers.api-io.middlewares=secure-headers@file,api-ratelimit@file"
- "traefik.http.routers.api-http.rule=Host(`api.truckwash.dk`) || Host(`api.truckwash.io`)"
- "traefik.http.routers.api-http.entrypoints=web"
- "traefik.http.routers.api-http.middlewares=redirect-to-https@file"
- "traefik.http.routers.api-http.service=caddy"
- "traefik.http.routers.local.rule=Host(`localhost`)"
- "traefik.http.routers.local.entrypoints=web"
- "traefik.http.routers.local.service=caddy"
- "traefik.http.routers.local.middlewares=secure-headers@file"
- "traefik.http.routers.local-secure.rule=Host(`localhost`)"
- "traefik.http.routers.local-secure.entrypoints=websecure"
- "traefik.http.routers.local-secure.tls=true"
- "traefik.http.routers.local-secure.service=caddy"
- "traefik.http.routers.local-secure.middlewares=secure-headers@file"
- "traefik.http.routers.local-api.rule=Host(`localhost`) && PathPrefix(`/api`)"
- "traefik.http.routers.local-api.entrypoints=web"
- "traefik.http.routers.local-api.middlewares=strip-api-prefix@file,secure-headers@file"
- "traefik.http.routers.local-api.service=caddy"
- "traefik.http.routers.local-api.priority=100"
- "traefik.http.routers.local-api-secure.rule=Host(`localhost`) && PathPrefix(`/api`)"
- "traefik.http.routers.local-api-secure.entrypoints=websecure"
- "traefik.http.routers.local-api-secure.tls=true"
- "traefik.http.routers.local-api-secure.middlewares=strip-api-prefix@file,secure-headers@file"
- "traefik.http.routers.local-api-secure.service=caddy"
- "traefik.http.routers.local-api-secure.priority=100"
- "traefik.http.services.caddy.loadbalancer.server.port=80"
caddy-staging:
image: caddy:2.7.6-alpine
container_name: caddy-staging
depends_on:
- php-staging
volumes:
- ./services/nginx/staging:/var/www/html
- ./services/caddy/Caddyfile-staging:/etc/caddy/Caddyfile:ro
- ./services/caddy/logs-staging:/var/log/caddy
labels:
- "traefik.enable=true"
- "traefik.http.routers.api-staging.rule=Host(`api.truckwash.io`)"
- "traefik.http.routers.api-staging.entrypoints=websecure-staging"
- "traefik.http.routers.api-staging.tls=true"
- "traefik.http.routers.api-staging.tls.domains[0].main=api.truckwash.io"
- "traefik.http.routers.api-staging.tls.certresolver=le_io"
- "traefik.http.routers.api-staging.service=caddy-staging"
- "traefik.http.routers.api-staging.middlewares=secure-headers@file,api-ratelimit@file"
- "traefik.http.routers.local-staging.rule=Host(`localhost`)"
- "traefik.http.routers.local-staging.entrypoints=websecure-staging"
- "traefik.http.routers.local-staging.service=caddy-staging"
- "traefik.http.routers.local-staging.middlewares=secure-headers@file"
- "traefik.http.routers.local-staging-secure.rule=Host(`localhost`)"
- "traefik.http.routers.local-staging-secure.entrypoints=websecure-staging"
- "traefik.http.routers.local-staging-secure.tls=true"
- "traefik.http.routers.local-staging-secure.service=caddy-staging"
- "traefik.http.routers.local-staging-secure.middlewares=secure-headers@file"
- "traefik.http.routers.local-staging-api.rule=Host(`localhost`) && PathPrefix(`/api`)"
- "traefik.http.routers.local-staging-api.entrypoints=websecure-staging"
- "traefik.http.routers.local-staging-api.middlewares=strip-api-prefix@file,secure-headers@file"
- "traefik.http.routers.local-staging-api.service=caddy-staging"
- "traefik.http.routers.local-staging-api.priority=100"
- "traefik.http.routers.local-staging-api-secure.rule=Host(`localhost`) && PathPrefix(`/api`)"
- "traefik.http.routers.local-staging-api-secure.entrypoints=websecure-staging"
- "traefik.http.routers.local-staging-api-secure.tls=true"
- "traefik.http.routers.local-staging-api-secure.middlewares=strip-api-prefix@file,secure-headers@file"
- "traefik.http.routers.local-staging-api-secure.service=caddy-staging"
- "traefik.http.routers.local-staging-api-secure.priority=100"
- "traefik.http.services.caddy-staging.loadbalancer.server.port=80"
php1:
build:
context: .
dockerfile: services/php/Dockerfile
container_name: php1
depends_on:
- redis
- edge-broker
command: ["php-fpm"]
env_file:
- .env
environment:
AUTO_COMPOSER_INSTALL: "true"
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
volumes:
- ./services/nginx/app:/var/www/html
- ./services/edge-agent/dist:/services/edge-agent/dist:ro
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
- ./services/php/logs:/var/log/php
php2:
build:
context: .
dockerfile: services/php/Dockerfile
container_name: php2
depends_on:
- redis
- edge-broker
command: ["php-fpm"]
env_file:
- .env
environment:
AUTO_COMPOSER_INSTALL: "false"
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
volumes:
- ./services/nginx/app:/var/www/html
- ./services/edge-agent/dist:/services/edge-agent/dist:ro
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
- ./services/php/logs:/var/log/php
php3:
build:
context: .
dockerfile: services/php/Dockerfile
container_name: php3
depends_on:
- redis
- edge-broker
command: ["php-fpm"]
env_file:
- .env
environment:
AUTO_COMPOSER_INSTALL: "false"
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
volumes:
- ./services/nginx/app:/var/www/html
- ./services/edge-agent/dist:/services/edge-agent/dist:ro
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
- ./services/php/logs:/var/log/php
php4:
build:
context: .
dockerfile: services/php/Dockerfile
container_name: php4
depends_on:
- redis
- edge-broker
command: ["php-fpm"]
env_file:
- .env
environment:
AUTO_COMPOSER_INSTALL: "false"
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
volumes:
- ./services/nginx/app:/var/www/html
- ./services/edge-agent/dist:/services/edge-agent/dist:ro
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
- ./services/php/logs:/var/log/php
php5:
build:
context: .
dockerfile: services/php/Dockerfile
container_name: php5
depends_on:
- redis
- edge-broker
command: ["php-fpm"]
env_file:
- .env
environment:
AUTO_COMPOSER_INSTALL: "false"
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
volumes:
- ./services/nginx/app:/var/www/html
- ./services/edge-agent/dist:/services/edge-agent/dist:ro
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
- ./services/php/logs:/var/log/php
php-staging:
build:
context: .
dockerfile: services/php/Dockerfile
container_name: php-staging
depends_on:
- redis-staging
- edge-broker
command: ["php-fpm"]
env_file:
- .env.staging
environment:
AUTO_COMPOSER_INSTALL: "false"
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
volumes:
- ./services/nginx/staging:/var/www/html
- ./services/edge-agent/dist:/services/edge-agent/dist:ro
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
- ./services/php/logs-staging:/var/log/php
php-cron:
build:
context: .
dockerfile: services/php/Dockerfile
container_name: php-cron
depends_on:
- redis
- edge-broker
command: ["sh", "-c", "while true; do php index.php run cron; sleep 60; done"]
env_file:
- .env
environment:
AUTO_COMPOSER_INSTALL: "false"
EDGE_BROKER_URL: ${EDGE_BROKER_URL:-http://edge-broker:4300}
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
volumes:
- ./services/nginx/app:/var/www/html
- ./services/edge-agent/dist:/services/edge-agent/dist:ro
- ./services/php/php.ini:/usr/local/etc/php/conf.d/zz-custom.ini:ro
- ./services/php/logs:/var/log/php
portainer:
image: portainer/portainer-ce:2.21.4
container_name: portainer
profiles:
- dev
ports:
- "9443:9443"
- "9000:9000"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- portainer_data:/data
jaeger:
image: jaegertracing/all-in-one:1.53
container_name: jaeger
profiles:
- dev
environment:
- COLLECTOR_ZIPKIN_HTTP_PORT=9411
ports:
- "16686:16686"
n8n:
image: n8nio/n8n:latest
container_name: n8n
restart: always
environment:
- N8N_HOST=n8n.truckwash.io
- N8N_PORT=5678
- N8N_PROTOCOL=https
- NODE_ENV=production
- WEBHOOK_URL=https://n8n.truckwash.io/
- GENERIC_TIMEZONE=${CONFIG_TIMEZONE:-Europe/Copenhagen}
volumes:
- n8n_data:/home/node/.n8n
labels:
- "traefik.enable=true"
- "traefik.http.routers.n8n.rule=Host(`n8n.truckwash.io`)"
- "traefik.http.routers.n8n.entrypoints=websecure"
- "traefik.http.routers.n8n.tls=true"
- "traefik.http.routers.n8n.tls.certresolver=le_io"
- "traefik.http.routers.n8n.service=n8n"
- "traefik.http.routers.n8n-http.rule=Host(`n8n.truckwash.io`)"
- "traefik.http.routers.n8n-http.entrypoints=web"
- "traefik.http.routers.n8n-http.middlewares=redirect-to-https@file"
- "traefik.http.routers.n8n-http.service=n8n"
- "traefik.http.services.n8n.loadbalancer.server.port=5678"
volumes:
db_data:
db_debug_data:
nnks_redis:
nnks_redis_staging:
es_data:
portainer_data:
fleet-server-data:
elastic-agent-data:
n8n_data:
+37 -2
View File
@@ -87,8 +87,43 @@ services:
container_name: edge-broker
environment:
EDGE_BROKER_SHARED_SECRET: ${EDGE_BROKER_SHARED_SECRET:-truckwash-edge-dev}
ports:
- "4300:4300"
labels:
- "traefik.enable=true"
- "traefik.http.routers.edge-broker-api.rule=Host(`api.truckwash.dk`) && PathPrefix(`/edge-broker`)"
- "traefik.http.routers.edge-broker-api.entrypoints=websecure"
- "traefik.http.routers.edge-broker-api.tls=true"
- "traefik.http.routers.edge-broker-api.tls.certresolver=le"
- "traefik.http.routers.edge-broker-api.middlewares=secure-headers@file,edge-broker-strip"
- "traefik.http.routers.edge-broker-api.service=edge-broker"
- "traefik.http.routers.edge-broker-api-io.rule=Host(`api.truckwash.io`) && PathPrefix(`/edge-broker`)"
- "traefik.http.routers.edge-broker-api-io.entrypoints=websecure"
- "traefik.http.routers.edge-broker-api-io.tls=true"
- "traefik.http.routers.edge-broker-api-io.tls.certresolver=le_io"
- "traefik.http.routers.edge-broker-api-io.middlewares=secure-headers@file,edge-broker-strip"
- "traefik.http.routers.edge-broker-api-io.service=edge-broker"
- "traefik.http.routers.edge-broker-api-staging.rule=Host(`api.truckwash.io`) && PathPrefix(`/edge-broker`)"
- "traefik.http.routers.edge-broker-api-staging.entrypoints=websecure-staging"
- "traefik.http.routers.edge-broker-api-staging.tls=true"
- "traefik.http.routers.edge-broker-api-staging.tls.certresolver=le_io"
- "traefik.http.routers.edge-broker-api-staging.middlewares=secure-headers@file,edge-broker-strip"
- "traefik.http.routers.edge-broker-api-staging.service=edge-broker"
- "traefik.http.routers.edge-broker-local.rule=Host(`localhost`) && PathPrefix(`/api/edge-broker`)"
- "traefik.http.routers.edge-broker-local.entrypoints=web"
- "traefik.http.routers.edge-broker-local.middlewares=secure-headers@file,edge-broker-strip-local"
- "traefik.http.routers.edge-broker-local.service=edge-broker"
- "traefik.http.routers.edge-broker-local-secure.rule=Host(`localhost`) && PathPrefix(`/api/edge-broker`)"
- "traefik.http.routers.edge-broker-local-secure.entrypoints=websecure"
- "traefik.http.routers.edge-broker-local-secure.tls=true"
- "traefik.http.routers.edge-broker-local-secure.middlewares=secure-headers@file,edge-broker-strip-local"
- "traefik.http.routers.edge-broker-local-secure.service=edge-broker"
- "traefik.http.routers.edge-broker-local-staging.rule=Host(`localhost`) && PathPrefix(`/api/edge-broker`)"
- "traefik.http.routers.edge-broker-local-staging.entrypoints=websecure-staging"
- "traefik.http.routers.edge-broker-local-staging.tls=true"
- "traefik.http.routers.edge-broker-local-staging.middlewares=secure-headers@file,edge-broker-strip-local"
- "traefik.http.routers.edge-broker-local-staging.service=edge-broker"
- "traefik.http.middlewares.edge-broker-strip.stripPrefix.prefixes=/edge-broker"
- "traefik.http.middlewares.edge-broker-strip-local.stripPrefix.prefixes=/api/edge-broker"
- "traefik.http.services.edge-broker.loadbalancer.server.port=4300"
caddy:
image: caddy:2.7.6-alpine
+25 -6
View File
@@ -15,6 +15,7 @@ function readRequiredSource(...pathSegments) {
const baseComposeSource = readRequiredSource("docker-compose.yml");
const exampleComposeSource = readRequiredSource("docker-compose.example.yml");
const standaloneProdComposeSource = readRequiredSource("docker-compose.prod.standalone.yml");
const traefikSource = [
readRequiredSource("services", "traefik", "traefik.yml"),
readRequiredSource("services", "traefik", "traefik.prod.yml"),
@@ -35,14 +36,32 @@ test("traefik does not expose a dedicated public edge broker port", () => {
assert.doesNotMatch(traefikSource, /edge-broker:\s*\n\s*address:\s*":4300"/);
});
test("base docker compose exposes the edge broker service on port 4300", () => {
assert.match(baseComposeSource, /\bedge-broker:\b/);
assert.match(baseComposeSource, /edge-broker:\s*\n[\s\S]*?\n\s+ports:\s*\n\s+- "4300:4300"/);
test("base docker compose routes edge broker traffic through traefik", () => {
const serviceBlock = readComposeServiceBlock(baseComposeSource, "edge-broker");
assert.doesNotMatch(serviceBlock, /\n\s+ports:\s*\n[\s\S]*?\n\s+- "4300:4300"/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.rule=Host\(`api\.truckwash\.dk`\) && PathPrefix\(`\/edge-broker`\)/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api-io\.rule=Host\(`api\.truckwash\.io`\) && PathPrefix\(`\/edge-broker`\)/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-local\.rule=Host\(`localhost`\) && PathPrefix\(`\/api\/edge-broker`\)/);
assert.match(serviceBlock, /traefik\.http\.middlewares\.edge-broker-strip\.stripPrefix\.prefixes=\/edge-broker/);
assert.match(serviceBlock, /traefik\.http\.middlewares\.edge-broker-strip-local\.stripPrefix\.prefixes=\/api\/edge-broker/);
assert.match(serviceBlock, /traefik\.http\.services\.edge-broker\.loadbalancer\.server\.port=4300/);
});
test("example docker compose exposes the edge broker service on port 4300", () => {
assert.match(exampleComposeSource, /\bedge-broker:\b/);
assert.match(exampleComposeSource, /edge-broker:\s*\n[\s\S]*?\n\s+ports:\s*\n\s+- "4300:4300"/);
test("example docker compose routes edge broker traffic through traefik", () => {
const serviceBlock = readComposeServiceBlock(exampleComposeSource, "edge-broker");
assert.doesNotMatch(serviceBlock, /\n\s+ports:\s*\n[\s\S]*?\n\s+- "4300:4300"/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.rule=Host\(`api\.example\.com`\) && PathPrefix\(`\/edge-broker`\)/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-local\.rule=Host\(`localhost`\) && PathPrefix\(`\/api\/edge-broker`\)/);
assert.match(serviceBlock, /traefik\.http\.services\.edge-broker\.loadbalancer\.server\.port=4300/);
});
test("standalone production compose routes edge broker traffic through traefik", () => {
const serviceBlock = readComposeServiceBlock(standaloneProdComposeSource, "edge-broker");
assert.doesNotMatch(serviceBlock, /\n\s+ports:\s*\n[\s\S]*?\n\s+- "4300:4300"/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api\.rule=Host\(`api\.truckwash\.dk`\) && PathPrefix\(`\/edge-broker`\)/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-api-io\.rule=Host\(`api\.truckwash\.io`\) && PathPrefix\(`\/edge-broker`\)/);
assert.match(serviceBlock, /traefik\.http\.routers\.edge-broker-local\.rule=Host\(`localhost`\) && PathPrefix\(`\/api\/edge-broker`\)/);
assert.match(serviceBlock, /traefik\.http\.services\.edge-broker\.loadbalancer\.server\.port=4300/);
});
test("php services receive broker websocket environment defaults", () => {
File diff suppressed because one or more lines are too long
@@ -10565,3 +10565,45 @@
[Tue Apr 21 12:54:05 2026] 127.0.0.1:47012 Closing
[Tue Apr 21 12:54:05 2026] 127.0.0.1:47026 Accepted
[Tue Apr 21 12:54:06 2026] 127.0.0.1:47026 Closing
[Thu Apr 23 11:09:21 2026] PHP 8.2.15 Development Server (http://127.0.0.1:37467) started
[Thu Apr 23 11:09:21 2026] 127.0.0.1:45138 Accepted
[Thu Apr 23 11:09:23 2026] 127.0.0.1:45138 Closing
[Thu Apr 23 11:09:23 2026] 127.0.0.1:45152 Accepted
[Thu Apr 23 11:09:27 2026] 127.0.0.1:45152 Closing
[Thu Apr 23 11:09:28 2026] 127.0.0.1:45158 Accepted
[Thu Apr 23 11:09:32 2026] 127.0.0.1:45158 Closing
[Thu Apr 23 11:09:38 2026] 127.0.0.1:40568 Accepted
[Thu Apr 23 11:09:42 2026] 127.0.0.1:40568 Closing
[Thu Apr 23 11:10:39 2026] PHP 8.2.15 Development Server (http://127.0.0.1:42453) started
[Thu Apr 23 11:10:39 2026] 127.0.0.1:57264 Accepted
[Thu Apr 23 11:10:41 2026] 127.0.0.1:57264 Closing
[Thu Apr 23 11:10:41 2026] 127.0.0.1:57272 Accepted
[Thu Apr 23 11:10:45 2026] 127.0.0.1:57272 Closing
[Thu Apr 23 11:10:45 2026] 127.0.0.1:57278 Accepted
[Thu Apr 23 11:10:49 2026] 127.0.0.1:57278 Closing
[Thu Apr 23 11:10:49 2026] 127.0.0.1:41688 Accepted
[Thu Apr 23 11:10:55 2026] 127.0.0.1:41688 Closing
[Thu Apr 23 11:11:02 2026] 127.0.0.1:43002 Accepted
[Thu Apr 23 11:11:05 2026] 127.0.0.1:43002 Closing
[Thu Apr 23 11:11:27 2026] PHP 8.2.15 Development Server (http://127.0.0.1:45343) started
[Thu Apr 23 11:11:27 2026] 127.0.0.1:47492 Accepted
[Thu Apr 23 11:11:29 2026] 127.0.0.1:47492 Closing
[Thu Apr 23 11:11:29 2026] 127.0.0.1:44236 Accepted
[Thu Apr 23 11:11:34 2026] 127.0.0.1:44236 Closing
[Thu Apr 23 11:11:34 2026] 127.0.0.1:44242 Accepted
[Thu Apr 23 11:11:38 2026] 127.0.0.1:44242 Closing
[Thu Apr 23 11:11:38 2026] 127.0.0.1:44250 Accepted
[Thu Apr 23 11:11:43 2026] 127.0.0.1:44250 Closing
[Thu Apr 23 11:11:49 2026] 127.0.0.1:52682 Accepted
[Thu Apr 23 11:11:53 2026] 127.0.0.1:52682 Closing
[Thu Apr 23 11:15:24 2026] PHP 8.2.15 Development Server (http://127.0.0.1:45041) started
[Thu Apr 23 11:15:24 2026] 127.0.0.1:54634 Accepted
[Thu Apr 23 11:15:25 2026] 127.0.0.1:54634 Closing
[Thu Apr 23 11:15:25 2026] 127.0.0.1:54642 Accepted
[Thu Apr 23 11:15:30 2026] 127.0.0.1:54642 Closing
[Thu Apr 23 11:15:30 2026] 127.0.0.1:40348 Accepted
[Thu Apr 23 11:15:34 2026] 127.0.0.1:40348 Closing
[Thu Apr 23 11:15:34 2026] 127.0.0.1:40350 Accepted
[Thu Apr 23 11:15:39 2026] 127.0.0.1:40350 Closing
[Thu Apr 23 11:15:45 2026] 127.0.0.1:56004 Accepted
[Thu Apr 23 11:15:49 2026] 127.0.0.1:56004 Closing
@@ -9,15 +9,19 @@ class department_gate_config
public string $type;
public ?string $phone_number = null;
public ?int $call_duration_threshold = null;
public ?string $relay_id = null;
public ?int $pulse_seconds = null;
/**
* @param array $config
*/
public function __construct(array $config = [])
{
$this->type = (string)($config['type'] ?? '');
$this->type = strtoupper(trim((string)($config['type'] ?? '')));
$this->phone_number = isset($config['phone_number']) ? (string)$config['phone_number'] : null;
$this->call_duration_threshold = isset($config['call_duration_threshold']) ? (int)$config['call_duration_threshold'] : null;
$this->relay_id = isset($config['relay_id']) ? trim((string)$config['relay_id']) : null;
$this->pulse_seconds = isset($config['pulse_seconds']) ? (int)$config['pulse_seconds'] : null;
}
/**
@@ -37,6 +41,14 @@ class department_gate_config
$array['call_duration_threshold'] = $this->call_duration_threshold;
}
if ($this->relay_id !== null) {
$array['relay_id'] = $this->relay_id;
}
if ($this->pulse_seconds !== null) {
$array['pulse_seconds'] = $this->pulse_seconds;
}
return $array;
}
@@ -58,6 +70,19 @@ class department_gate_config
if ($this->call_duration_threshold === null) {
throw new Exception('Call duration threshold is required for PHONE_CALL gate type');
}
return;
}
if ($this->type === 'RELAY') {
if ($this->relay_id === null || $this->relay_id === '') {
throw new Exception('relay_id is required for RELAY gate type');
}
if ($this->pulse_seconds !== null && $this->pulse_seconds < 0) {
throw new Exception('pulse_seconds must be a positive integer for RELAY gate type');
}
return;
}
throw new Exception('Unsupported gate config type: ' . $this->type);
}
}
+50 -3
View File
@@ -54,7 +54,7 @@ class shelly implements shelly_i
self::requireValidSecretKey();
// Send the request
$response = match ($method) {
//'GET' => self::sendGetRequest($endpoint, $data),
'GET' => self::sendGetRequest($endpoint, $data),
'POST' => self::sendPostRequest($endpoint, $data),
//'PUT' => self::sendPutRequest($endpoint, $data),
//'DELETE' => self::sendDeleteRequest($endpoint, $data),
@@ -186,9 +186,56 @@ class shelly implements shelly_i
return json_decode($response);
}
function appendAuthKeyToQuery(string $url): string
/**
* @inheritDoc
* @throws Exception
*/
function sendGetRequest(string $endpoint, array $data): array|object|null
{
return $url . '?auth_key=' . $this->config->secret_key->getVariableValue();
self::requireModuleEnabled();
self::requireValidSecretKey();
self::requireValidServerURL();
$this->waitForShellyRateLimitWindow();
$ch = curl_init();
curl_setopt(
$ch,
CURLOPT_URL,
self::appendAuthKeyToQuery($this->config->server_url->getVariableValue() . $endpoint, $data)
);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPGET, true);
$response = curl_exec($ch);
$status_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
if (curl_errno($ch)) {
self::exception(
[
'method' => 'GET',
'endpoint' => $endpoint,
'data' => $data,
'response' => $response,
],
$status_code
);
}
curl_close($ch);
if ($response === false) {
return null;
}
return json_decode($response);
}
function appendAuthKeyToQuery(string $url, array $query = []): string
{
$query['auth_key'] = $this->config->secret_key->getVariableValue();
$separator = str_contains($url, '?') ? '&' : '?';
return $url . $separator . http_build_query($query);
}
/**
@@ -0,0 +1,243 @@
<?php
namespace classes;
use Exception;
class shelly_relay_inventory
{
private ?shelly $client;
/** @var callable|null */
private $inventory_fetcher = null;
public function __construct(?shelly $client = null)
{
$this->client = $client;
}
public function setInventoryFetcher(callable $fetcher): self
{
$this->inventory_fetcher = $fetcher;
return $this;
}
/**
* @return array<int,array<string,mixed>>
* @throws Exception
*/
public function listRelayOptions(): array
{
$devices_status = $this->fetchOwnedDevicesStatus();
$options_by_id = [];
foreach ($devices_status as $device) {
$option = $this->buildRelayOption($this->normalizeToArray($device));
if ($option === null) {
continue;
}
$options_by_id[$option['id']] = $option;
}
$options = array_values($options_by_id);
usort($options, static function (array $left, array $right): int {
$online_compare = ((int)!($left['online'] ?? false)) <=> ((int)!($right['online'] ?? false));
if ($online_compare !== 0) {
return $online_compare;
}
$name_compare = strcasecmp((string)($left['name'] ?? ''), (string)($right['name'] ?? ''));
if ($name_compare !== 0) {
return $name_compare;
}
return strcmp((string)($left['id'] ?? ''), (string)($right['id'] ?? ''));
});
return $options;
}
/**
* @return array<string,mixed>
* @throws Exception
*/
private function fetchOwnedDevicesStatus(): array
{
$payload = is_callable($this->inventory_fetcher)
? ($this->inventory_fetcher)()
: $this->getClient()->sendGetRequest('/device/all_status', [
'show_info' => 'true',
'no_shared' => 'true',
]);
$normalized = $this->normalizeToArray($payload);
$is_ok = $normalized['isok'] ?? null;
if ($is_ok === false) {
throw new Exception('Shelly relay inventory request failed');
}
$devices_status = $normalized['data']['devices_status'] ?? null;
if (!is_array($devices_status)) {
throw new Exception('Shelly relay inventory response was missing devices_status');
}
return $devices_status;
}
private function getClient(): shelly
{
if ($this->client instanceof shelly) {
return $this->client;
}
$this->client = new shelly();
return $this->client;
}
/**
* @param array<string,mixed> $device
* @return array<string,mixed>|null
*/
private function buildRelayOption(array $device): ?array
{
if ($device === [] || !$this->isRelayCapableDevice($device)) {
return null;
}
$device_id = $this->extractFirstString([
$device['_dev_info']['id'] ?? null,
$device['id'] ?? null,
]);
if ($device_id === '') {
return null;
}
$device_name = $this->extractFirstString([
$device['name'] ?? null,
$device['_dev_info']['name'] ?? null,
$device['settings']['name'] ?? null,
$device['settings']['device']['name'] ?? null,
$device['status']['name'] ?? null,
$device['status']['sys']['device']['name'] ?? null,
]);
$device_code = $this->extractFirstString([
$device['_dev_info']['code'] ?? null,
$device['code'] ?? null,
]);
return [
'id' => $device_id,
'name' => $this->buildRelayLabel($device_id, $device_name, $device_code),
'device_id' => $device_id,
'device_name' => $device_name !== '' ? $device_name : null,
'code' => $device_code !== '' ? $device_code : null,
'online' => $this->extractOnlineState($device),
];
}
/**
* @param array<string,mixed> $device
*/
private function isRelayCapableDevice(array $device): bool
{
if ($this->payloadContainsRelayState($device)) {
return true;
}
if ($this->payloadContainsRelayState($device['status'] ?? null)) {
return true;
}
return $this->payloadContainsRelayState($device['settings'] ?? null);
}
private function payloadContainsRelayState(array|object|null $payload): bool
{
$normalized = $this->normalizeToArray($payload);
if ($normalized === []) {
return false;
}
foreach (['switch:0', 'switch_0', 'switch0'] as $switch_key) {
if (array_key_exists($switch_key, $normalized)) {
return true;
}
}
foreach (['relays', 'switches'] as $collection_key) {
if (isset($normalized[$collection_key]) && is_array($normalized[$collection_key]) && $normalized[$collection_key] !== []) {
return true;
}
}
return false;
}
/**
* @param array<string,mixed> $device
*/
private function extractOnlineState(array $device): bool
{
$online = $device['_dev_info']['online'] ?? $device['online'] ?? null;
if (is_bool($online)) {
return $online;
}
if (is_numeric($online)) {
return (int)$online === 1;
}
return false;
}
private function buildRelayLabel(string $device_id, string $device_name, string $device_code): string
{
$label = $device_id;
if ($device_name !== '' && strcasecmp($device_name, $device_id) !== 0) {
$label = $device_name . ' (' . $device_id . ')';
}
if ($device_code !== '') {
return $label . ' · ' . $device_code;
}
return $label;
}
/**
* @param array<int,mixed> $values
*/
private function extractFirstString(array $values): string
{
foreach ($values as $value) {
$normalized = trim((string)($value ?? ''));
if ($normalized !== '') {
return $normalized;
}
}
return '';
}
/**
* @return array<string,mixed>
*/
private function normalizeToArray(array|object|null $payload): array
{
if (is_array($payload)) {
return $payload;
}
if (is_object($payload)) {
$encoded = json_encode($payload, JSON_UNESCAPED_UNICODE);
if (!is_string($encoded)) {
return [];
}
$decoded = json_decode($encoded, true);
return is_array($decoded) ? $decoded : [];
}
return [];
}
}
@@ -37,6 +37,14 @@ interface shelly_i
*/
function requireValidServerUrl(): void;
/**
* Send a GET request to Shelly.
* @param string $endpoint The endpoint to send the request to
* @param array $data Query parameters to append to the request
* @return array|object|null The response from Shelly
*/
function sendGetRequest(string $endpoint, array $data): array|object|null;
/**
* Send a POST request to the shelly
* @param string $endpoint The endpoint to send the request to (e.g. "latest")
@@ -0,0 +1,865 @@
<?php
namespace classes;
use Exception;
use objects\department_gates_o;
use objects\department_lanes_o;
use objects\department_relays_o;
use objects\departments_o;
use objects\plate_scanners_o;
use objects\plate_scans_o;
class edge_gateway_department_workspace_service
{
public function __construct(private readonly ?edge_gateway_manager $manager = null)
{
edge_gateway_schema_bootstrap::ensureTables();
}
/**
* @return array<int,array<string,mixed>>
* @throws Exception
*/
public function listDepartmentSummaries(): array
{
$departments = (new departments_o())->list(true);
usort($departments, static function (array $left, array $right): int {
$leftPriority = (int)($left['order_priority'] ?? PHP_INT_MAX);
$rightPriority = (int)($right['order_priority'] ?? PHP_INT_MAX);
if ($leftPriority !== $rightPriority) {
return $leftPriority <=> $rightPriority;
}
return (int)($left['id'] ?? 0) <=> (int)($right['id'] ?? 0);
});
$summaries = [];
foreach ($departments as $departmentRow) {
$departmentId = (int)($departmentRow['id'] ?? 0);
if ($departmentId <= 0) {
continue;
}
$workspace = $this->buildDepartmentWorkspace($departmentId, false, $departmentRow);
$summaries[] = $workspace['summary'];
}
return $summaries;
}
/**
* @return array<string,mixed>
* @throws Exception
*/
public function getDepartmentWorkspace(int $departmentId): array
{
return $this->buildDepartmentWorkspace($departmentId, true);
}
/**
* @param array<string,mixed>|null $departmentRow
* @return array<string,mixed>
* @throws Exception
*/
private function buildDepartmentWorkspace(int $departmentId, bool $includeGateways, ?array $departmentRow = null): array
{
$department = (new departments_o())->select($departmentId);
if (!$department->exists()) {
throw new Exception('Department not found');
}
$departmentPayload = [
'id' => $departmentId,
'name' => (string)$department->name->value(),
'description' => (string)$department->description->value(),
'order_priority' => (int)$department->order_priority->value(),
];
$transportMode = $this->manager()->getDepartmentTransportMode($departmentId);
$gateways = $this->manager()->listGateways($departmentId, true);
$bindingsByRelayId = $this->indexBindingsByRelayId($gateways);
$relayCatalog = $this->indexRelayCatalog($departmentId);
$consumersByRelayId = [];
$lanes = $this->buildLanePayloads($departmentId, $bindingsByRelayId, $relayCatalog, $consumersByRelayId);
$gates = $this->buildGatePayloads($departmentId, $bindingsByRelayId, $relayCatalog, $consumersByRelayId);
$gateways = $this->applyBindingConsumerContexts($gateways, $consumersByRelayId);
$selfServe = $this->buildSelfServePayload($department, $lanes);
$scanners = $this->buildScannerPayloads($departmentId, $lanes);
$issues = $this->buildIssues($transportMode, $gateways, $lanes, $gates, $scanners, $selfServe);
$actions = $this->buildActions($departmentId, $gateways, $lanes, $gates, $scanners, $selfServe);
$summary = $this->buildSummary(
$departmentPayload,
$departmentRow,
$transportMode,
$gateways,
$lanes,
$gates,
$scanners,
$selfServe,
$issues
);
return [
'department' => $departmentPayload,
'summary' => $summary,
'gateways' => $includeGateways ? $gateways : [],
'lanes' => $lanes,
'self_serve' => $selfServe,
'gates' => $gates,
'scanners' => $scanners,
'issues' => $issues,
'actions' => $actions,
];
}
/**
* @param array<int,array<string,mixed>> $gateways
* @return array<string,array<int,array<string,mixed>>>
*/
private function indexBindingsByRelayId(array $gateways): array
{
$bindingsByRelayId = [];
foreach ($gateways as $gateway) {
$bindings = isset($gateway['bindings']) && is_array($gateway['bindings'])
? (array)$gateway['bindings']
: [];
foreach ($bindings as $binding) {
if (!is_array($binding)) {
continue;
}
$relayId = trim((string)($binding['relay_id'] ?? ''));
if ($relayId === '') {
continue;
}
$binding['gateway_label'] = (string)($gateway['label'] ?? ('Gateway ' . ($gateway['id'] ?? '')));
$binding['gateway_status'] = (string)($gateway['status'] ?? edge_gateway_manager::STATUS_OFFLINE);
$binding['is_primary_gateway'] = (bool)($gateway['is_primary'] ?? false);
$bindingsByRelayId[$relayId][] = $binding;
}
}
foreach ($bindingsByRelayId as $relayId => $bindings) {
usort($bindings, static function (array $left, array $right): int {
return ((int)($right['is_primary_gateway'] ?? 0) <=> (int)($left['is_primary_gateway'] ?? 0))
?: ((int)($left['gateway_id'] ?? 0) <=> (int)($right['gateway_id'] ?? 0));
});
$bindingsByRelayId[$relayId] = $bindings;
}
return $bindingsByRelayId;
}
/**
* @return array<string,array<string,mixed>>
* @throws Exception
*/
private function indexRelayCatalog(int $departmentId): array
{
$catalog = [];
foreach ((new department_relays_o())->getDepartmentRelays($departmentId) as $relay) {
if (!$relay->exists()) {
continue;
}
$relayId = trim((string)$relay->relay_id->value());
if ($relayId === '') {
continue;
}
$catalog[$relayId] = $relay->asArray();
}
return $catalog;
}
/**
* @param array<string,array<int,array<string,mixed>>> $bindingsByRelayId
* @param array<string,array<string,mixed>> $relayCatalog
* @param array<string,array<int,array<string,mixed>>> $consumersByRelayId
* @return array<int,array<string,mixed>>
* @throws Exception
*/
private function buildLanePayloads(
int $departmentId,
array $bindingsByRelayId,
array $relayCatalog,
array &$consumersByRelayId
): array {
$lanes = [];
$slotMap = [
'relay_in_id' => 'ENTRY',
'relay_out_id' => 'EXIT',
'relay_machine_id' => 'MACHINE',
'relay_machine_program_picker_id' => 'PROGRAM_PICKER',
'relay_machine_cleaner_id' => 'CLEANER',
];
foreach ((new department_lanes_o())->getDepartmentLanes($departmentId) as $lane) {
if (!$lane->exists()) {
continue;
}
$relaySlots = [];
$boundRelayCount = 0;
foreach ($slotMap as $property => $slotName) {
$relayId = trim((string)$lane->{$property}->value());
if ($relayId === '') {
continue;
}
$consumersByRelayId[$relayId][] = [
'type' => 'lane',
'id' => (int)$lane->id,
'slot' => $slotName,
'label' => (string)$lane->name->value(),
];
$coverage = $this->buildRelayCoverage($relayId, $bindingsByRelayId);
if ((bool)($coverage['covered'] ?? false)) {
$boundRelayCount += 1;
}
$relaySlots[] = [
'slot' => $slotName,
'relay_id' => $relayId,
'catalog' => $relayCatalog[$relayId] ?? null,
'coverage' => $coverage,
];
}
$requiredRelayCount = count($relaySlots);
$laneStatus = 'UNKNOWN';
try {
$laneStatus = (string)$lane->getLaneStatus()->name;
} catch (\Throwable) {
}
$lanes[] = [
'id' => (int)$lane->id,
'department' => (int)$lane->department->value(),
'name' => (string)$lane->name->value(),
'relay_in_id' => $lane->relay_in_id->value() === null ? null : (string)$lane->relay_in_id->value(),
'relay_out_id' => $lane->relay_out_id->value() === null ? null : (string)$lane->relay_out_id->value(),
'relay_machine_id' => $lane->relay_machine_id->value() === null ? null : (string)$lane->relay_machine_id->value(),
'relay_machine_program_picker_id' => $lane->relay_machine_program_picker_id->value() === null ? null : (string)$lane->relay_machine_program_picker_id->value(),
'relay_machine_cleaner_id' => $lane->relay_machine_cleaner_id->value() === null ? null : (string)$lane->relay_machine_cleaner_id->value(),
'dynamic_image_id' => $lane->dynamic_image_id->value() === null ? null : (int)$lane->dynamic_image_id->value(),
'machine_type_id' => $lane->machine_type_id->value() === null ? null : (int)$lane->machine_type_id->value(),
'status' => $laneStatus,
'self_serve_products' => $lane->getSelfServeLaneProducts(),
'relay_slots' => $relaySlots,
'binding_coverage' => [
'required' => $requiredRelayCount,
'bound' => $boundRelayCount,
'missing' => max(0, $requiredRelayCount - $boundRelayCount),
'state' => $requiredRelayCount === 0
? 'NOT_REQUIRED'
: ($boundRelayCount === $requiredRelayCount ? 'READY' : 'MISSING'),
],
'links' => [
'legacy' => '/superuser/department/lanes/' . (int)$lane->id,
'self_serve_studio' => '/admin/' . $departmentId . '/modules/self-serve/studio',
],
];
}
return $lanes;
}
/**
* @param array<string,array<int,array<string,mixed>>> $bindingsByRelayId
* @param array<string,array<string,mixed>> $relayCatalog
* @param array<string,array<int,array<string,mixed>>> $consumersByRelayId
* @return array<int,array<string,mixed>>
* @throws Exception
*/
private function buildGatePayloads(
int $departmentId,
array $bindingsByRelayId,
array $relayCatalog,
array &$consumersByRelayId
): array {
$gates = [];
foreach ((new department_gates_o())->getDepartmentGates($departmentId) as $gate) {
if (!$gate->exists()) {
continue;
}
$config = (array)$gate->config->value();
$gateType = strtoupper(trim((string)($config['type'] ?? 'UNKNOWN')));
$relayId = trim((string)($config['relay_id'] ?? ''));
if ($gateType === 'RELAY' && $relayId !== '') {
$consumersByRelayId[$relayId][] = [
'type' => 'gate',
'id' => (int)$gate->id,
'slot' => ((bool)$gate->is_entrance->value() ? 'ENTRANCE' : ((bool)$gate->is_exit->value() ? 'EXIT' : 'GENERAL')),
'label' => (string)$gate->name->value(),
];
}
$coverage = $gateType === 'RELAY' && $relayId !== ''
? $this->buildRelayCoverage($relayId, $bindingsByRelayId)
: null;
$gates[] = [
'id' => (int)$gate->id,
'department' => (int)$gate->department->value(),
'name' => (string)$gate->name->value(),
'is_entrance' => (bool)$gate->is_entrance->value(),
'is_exit' => (bool)$gate->is_exit->value(),
'config' => $config,
'transport_type' => $gateType,
'config_complete' => $this->isGateConfigComplete($config),
'relay' => $relayId !== '' ? ($relayCatalog[$relayId] ?? ['relay_id' => $relayId]) : null,
'coverage' => $coverage,
];
}
return $gates;
}
/**
* @param array<int,array<string,mixed>> $lanes
* @return array<string,mixed>
* @throws Exception
*/
private function buildSelfServePayload(departments_o $department, array $lanes): array
{
$enabled = false;
try {
$enabled = $department->getSelfServeEnabled();
} catch (\Throwable) {
}
$readyLanes = array_values(array_filter($lanes, static function (array $lane): bool {
return (string)($lane['binding_coverage']['state'] ?? 'UNKNOWN') === 'READY';
}));
$taskRows = (new \objects\department_selfserve_tasks_o())->getFieldsWhere([
'department' => (int)$department->id,
'deleted_at' => null,
], ['id', 'lane', 'product']);
$productIds = [];
foreach ($taskRows as $taskRow) {
if (isset($taskRow['product'])) {
$productIds[(int)$taskRow['product']] = true;
}
}
return [
'enabled' => $enabled,
'lane_count' => count($lanes),
'ready_lanes' => count($readyLanes),
'configured_task_count' => count($taskRows),
'configured_product_count' => count($productIds),
'readiness_state' => !$enabled
? 'DISABLED'
: (count($lanes) === 0 ? 'UNCONFIGURED' : (count($readyLanes) === count($lanes) ? 'READY' : 'PARTIAL')),
'links' => [
'studio' => '/admin/' . (int)$department->id . '/modules/self-serve/studio',
'legacy' => '/superuser/selfserve',
],
];
}
/**
* @param array<int,array<string,mixed>> $lanes
* @return array<int,array<string,mixed>>
* @throws Exception
*/
private function buildScannerPayloads(int $departmentId, array $lanes): array
{
$laneIndex = [];
foreach ($lanes as $lane) {
$laneIndex[(int)$lane['id']] = $lane;
}
$recentScansByScannerId = $this->groupRecentScansByScannerId($departmentId);
$scanners = [];
foreach ((new plate_scanners_o())->getDepartmentScanners($departmentId) as $scanner) {
if (!$scanner->exists()) {
continue;
}
$scannerPayload = $scanner->asArray();
$laneId = isset($scannerPayload['lane_id']) ? (int)($scannerPayload['lane_id'] ?? 0) : 0;
$assignedLane = $laneId > 0 ? ($laneIndex[$laneId] ?? null) : null;
$recentScans = $recentScansByScannerId[(int)$scanner->id] ?? [];
$recentScanAt = $recentScans !== [] ? ($recentScans[0]['created_at'] ?? null) : null;
$assignmentState = $laneId <= 0
? 'UNASSIGNED'
: ($assignedLane === null
? 'INVALID'
: (((int)($assignedLane['binding_coverage']['missing'] ?? 0) === 0) ? 'READY' : 'PARTIAL'));
$scanners[] = [
...$scannerPayload,
'assigned_lane' => $assignedLane,
'assignment_state' => $assignmentState,
'recent_scan_at' => $recentScanAt,
'recent_scans' => $recentScans,
'recent_scan_count' => count($recentScans),
];
}
return $scanners;
}
/**
* @return array<string,array<int,array<string,mixed>>>
*/
private function groupRecentScansByScannerId(int $departmentId): array
{
$rows = (new plate_scans_o())->getFieldsWhere([
'department_id' => $departmentId,
], ['id', 'plate_scanner_id', 'plate', 'bay_id', 'created_at']);
usort($rows, static function (array $left, array $right): int {
$rightTimestamp = strtotime((string)($right['created_at'] ?? '')) ?: 0;
$leftTimestamp = strtotime((string)($left['created_at'] ?? '')) ?: 0;
return $rightTimestamp <=> $leftTimestamp ?: ((int)($right['id'] ?? 0) <=> (int)($left['id'] ?? 0));
});
$grouped = [];
foreach ($rows as $row) {
$scannerId = (int)($row['plate_scanner_id'] ?? 0);
if ($scannerId <= 0) {
continue;
}
if (!isset($grouped[$scannerId])) {
$grouped[$scannerId] = [];
}
if (count($grouped[$scannerId]) >= 5) {
continue;
}
$grouped[$scannerId][] = [
'id' => (int)($row['id'] ?? 0),
'plate' => (string)($row['plate'] ?? ''),
'bay_id' => isset($row['bay_id']) ? (string)$row['bay_id'] : null,
'created_at' => isset($row['created_at']) ? (string)$row['created_at'] : null,
];
}
return $grouped;
}
/**
* @param array<int,array<string,mixed>> $gateways
* @param array<int,array<string,mixed>> $lanes
* @param array<int,array<string,mixed>> $gates
* @param array<int,array<string,mixed>> $scanners
* @return array<int,array<string,mixed>>
*/
private function buildIssues(
string $transportMode,
array $gateways,
array $lanes,
array $gates,
array $scanners,
array $selfServe
): array {
$issues = [];
if ($gateways === []) {
$issues[] = [
'severity' => 'danger',
'code' => 'NO_GATEWAY',
'message' => 'No edge gateway has been claimed for this department.',
];
}
$onlineGateways = array_values(array_filter($gateways, static function (array $gateway): bool {
return strtoupper((string)($gateway['status'] ?? '')) === edge_gateway_manager::STATUS_ONLINE;
}));
if ($transportMode === edge_gateway_manager::TRANSPORT_MODE_GATEWAY && $onlineGateways === []) {
$issues[] = [
'severity' => 'danger',
'code' => 'NO_ONLINE_GATEWAY',
'message' => 'Gateway transport mode is enabled, but no department gateway is currently online.',
];
}
foreach ($lanes as $lane) {
if ((int)($lane['binding_coverage']['missing'] ?? 0) > 0) {
$issues[] = [
'severity' => 'warning',
'code' => 'LANE_BINDING_GAP',
'message' => 'Lane ' . (string)$lane['name'] . ' is missing relay bindings.',
'target_type' => 'lane',
'target_id' => (int)$lane['id'],
];
}
}
foreach ($gates as $gate) {
if (!($gate['config_complete'] ?? false)) {
$issues[] = [
'severity' => 'warning',
'code' => 'GATE_CONFIG_INCOMPLETE',
'message' => 'Gate ' . (string)$gate['name'] . ' has incomplete transport configuration.',
'target_type' => 'gate',
'target_id' => (int)$gate['id'],
];
continue;
}
if (($gate['transport_type'] ?? '') === 'RELAY' && !($gate['coverage']['covered'] ?? false)) {
$issues[] = [
'severity' => 'warning',
'code' => 'GATE_BINDING_MISSING',
'message' => 'Gate ' . (string)$gate['name'] . ' is assigned to an unbound relay.',
'target_type' => 'gate',
'target_id' => (int)$gate['id'],
];
}
}
foreach ($scanners as $scanner) {
if (($scanner['assignment_state'] ?? 'UNASSIGNED') === 'UNASSIGNED') {
$issues[] = [
'severity' => 'warning',
'code' => 'SCANNER_UNASSIGNED',
'message' => 'Scanner ' . (string)$scanner['name'] . ' is not assigned to a default lane.',
'target_type' => 'scanner',
'target_id' => (int)$scanner['id'],
];
} elseif (($scanner['assignment_state'] ?? '') === 'PARTIAL') {
$issues[] = [
'severity' => 'info',
'code' => 'SCANNER_LANE_PARTIAL',
'message' => 'Scanner ' . (string)$scanner['name'] . ' is assigned to a lane with missing relay coverage.',
'target_type' => 'scanner',
'target_id' => (int)$scanner['id'],
];
}
}
if (($selfServe['enabled'] ?? false) && (int)($selfServe['ready_lanes'] ?? 0) < (int)($selfServe['lane_count'] ?? 0)) {
$issues[] = [
'severity' => 'warning',
'code' => 'SELFSERVE_PARTIAL_READY',
'message' => 'Self-serve is enabled, but one or more lanes are missing required relay coverage.',
];
}
return $issues;
}
/**
* @param array<int,array<string,mixed>> $gateways
* @param array<int,array<string,mixed>> $lanes
* @param array<int,array<string,mixed>> $gates
* @param array<int,array<string,mixed>> $scanners
* @return array<int,array<string,mixed>>
*/
private function buildActions(
int $departmentId,
array $gateways,
array $lanes,
array $gates,
array $scanners,
array $selfServe
): array {
$actions = [
[
'code' => 'OPEN_GATEWAY_TAB',
'label' => 'Open gateway controls',
'path' => '/superuser/departments/' . $departmentId . '/gateways?tab=gateways',
],
];
if ($gateways === []) {
$actions[] = [
'code' => 'INSTALL_GATEWAY',
'label' => 'Install first edge gateway',
'path' => '/superuser/configuration/edgegateway',
];
}
foreach ($lanes as $lane) {
if ((int)($lane['binding_coverage']['missing'] ?? 0) > 0) {
$actions[] = [
'code' => 'REVIEW_LANE_BINDINGS',
'label' => 'Resolve lane bindings',
'path' => '/superuser/departments/' . $departmentId . '/gateways?tab=lanes',
];
break;
}
}
foreach ($gates as $gate) {
if (($gate['transport_type'] ?? '') === 'RELAY' && !($gate['coverage']['covered'] ?? false)) {
$actions[] = [
'code' => 'REVIEW_GATE_BINDINGS',
'label' => 'Resolve gate relay bindings',
'path' => '/superuser/departments/' . $departmentId . '/gateways?tab=gates',
];
break;
}
}
foreach ($scanners as $scanner) {
if (($scanner['assignment_state'] ?? 'UNASSIGNED') === 'UNASSIGNED') {
$actions[] = [
'code' => 'ASSIGN_SCANNERS',
'label' => 'Assign scanners to lanes',
'path' => '/superuser/departments/' . $departmentId . '/gateways?tab=scanners',
];
break;
}
}
if (($selfServe['enabled'] ?? false) && (int)($selfServe['lane_count'] ?? 0) > 0) {
$actions[] = [
'code' => 'OPEN_SELFSERVE_STUDIO',
'label' => 'Open self-serve studio',
'path' => '/admin/' . $departmentId . '/modules/self-serve/studio',
];
}
return $actions;
}
/**
* @param array<string,mixed> $departmentPayload
* @param array<string,mixed>|null $departmentRow
* @param array<int,array<string,mixed>> $gateways
* @param array<int,array<string,mixed>> $lanes
* @param array<int,array<string,mixed>> $gates
* @param array<int,array<string,mixed>> $scanners
* @param array<int,array<string,mixed>> $issues
* @return array<string,mixed>
*/
private function buildSummary(
array $departmentPayload,
?array $departmentRow,
string $transportMode,
array $gateways,
array $lanes,
array $gates,
array $scanners,
array $selfServe,
array $issues
): array {
$onlineGatewayCount = count(array_filter($gateways, static function (array $gateway): bool {
return strtoupper((string)($gateway['status'] ?? '')) === edge_gateway_manager::STATUS_ONLINE;
}));
$primaryGateway = null;
foreach ($gateways as $gateway) {
if (!empty($gateway['is_primary'])) {
$primaryGateway = [
'id' => (int)$gateway['id'],
'label' => (string)($gateway['label'] ?? ('Gateway ' . $gateway['id'])),
'status' => (string)($gateway['status'] ?? edge_gateway_manager::STATUS_OFFLINE),
];
break;
}
}
if ($primaryGateway === null && $gateways !== []) {
$gateway = $gateways[0];
$primaryGateway = [
'id' => (int)$gateway['id'],
'label' => (string)($gateway['label'] ?? ('Gateway ' . $gateway['id'])),
'status' => (string)($gateway['status'] ?? edge_gateway_manager::STATUS_OFFLINE),
];
}
$requiredRelayIds = [];
$coveredRelayIds = [];
foreach ($lanes as $lane) {
foreach ((array)($lane['relay_slots'] ?? []) as $slot) {
$relayId = trim((string)($slot['relay_id'] ?? ''));
if ($relayId === '') {
continue;
}
$requiredRelayIds[$relayId] = true;
if (!empty($slot['coverage']['covered'])) {
$coveredRelayIds[$relayId] = true;
}
}
}
foreach ($gates as $gate) {
if (($gate['transport_type'] ?? '') !== 'RELAY') {
continue;
}
$relayId = trim((string)($gate['relay']['relay_id'] ?? $gate['config']['relay_id'] ?? ''));
if ($relayId === '') {
continue;
}
$requiredRelayIds[$relayId] = true;
if (!empty($gate['coverage']['covered'])) {
$coveredRelayIds[$relayId] = true;
}
}
$assignedScannerCount = count(array_filter($scanners, static function (array $scanner): bool {
return (int)($scanner['lane_id'] ?? 0) > 0;
}));
$recentScanAt = null;
foreach ($scanners as $scanner) {
$candidate = isset($scanner['recent_scan_at']) ? (string)$scanner['recent_scan_at'] : null;
if ($candidate === null || trim($candidate) === '') {
continue;
}
if ($recentScanAt === null || strtotime($candidate) > strtotime($recentScanAt)) {
$recentScanAt = $candidate;
}
}
$relayGateCount = count(array_filter($gates, static function (array $gate): bool {
return ($gate['transport_type'] ?? '') === 'RELAY';
}));
$phoneGateCount = count(array_filter($gates, static function (array $gate): bool {
return ($gate['transport_type'] ?? '') === 'PHONE_CALL';
}));
return [
'department_id' => (int)$departmentPayload['id'],
'department_name' => (string)$departmentPayload['name'],
'order_priority' => (int)($departmentRow['order_priority'] ?? $departmentPayload['order_priority'] ?? PHP_INT_MAX),
'transport_mode' => $transportMode,
'gateway_count' => count($gateways),
'online_gateway_count' => $onlineGatewayCount,
'primary_gateway' => $primaryGateway,
'lane_count' => count($lanes),
'self_serve_enabled' => (bool)($selfServe['enabled'] ?? false),
'self_serve_ready_lanes' => (int)($selfServe['ready_lanes'] ?? 0),
'required_relay_count' => count($requiredRelayIds),
'bound_relay_count' => count($coveredRelayIds),
'missing_binding_count' => max(0, count($requiredRelayIds) - count($coveredRelayIds)),
'gate_count' => count($gates),
'gate_transport_mix' => [
'relay' => $relayGateCount,
'phone_call' => $phoneGateCount,
],
'scanner_count' => count($scanners),
'assigned_scanner_count' => $assignedScannerCount,
'recent_scan_at' => $recentScanAt,
'issue_count' => count($issues),
'health' => $this->deriveHealthState($transportMode, $gateways, $issues),
];
}
/**
* @param array<int,array<string,mixed>> $gateways
* @param array<int,array<string,mixed>> $issues
*/
private function deriveHealthState(string $transportMode, array $gateways, array $issues): string
{
foreach ($issues as $issue) {
if (($issue['severity'] ?? '') === 'danger') {
return 'AT_RISK';
}
}
if ($transportMode === edge_gateway_manager::TRANSPORT_MODE_GATEWAY) {
foreach ($gateways as $gateway) {
if (strtoupper((string)($gateway['status'] ?? '')) === edge_gateway_manager::STATUS_ONLINE) {
return $issues === [] ? 'READY' : 'PARTIAL';
}
}
return 'AT_RISK';
}
return $issues === [] ? 'READY' : 'PARTIAL';
}
/**
* @param array<string,array<int,array<string,mixed>>> $bindingsByRelayId
* @return array<string,mixed>
*/
private function buildRelayCoverage(string $relayId, array $bindingsByRelayId): array
{
$bindings = $bindingsByRelayId[$relayId] ?? [];
$primaryBinding = $bindings[0] ?? null;
return [
'relay_id' => $relayId,
'covered' => $bindings !== [],
'status' => $bindings !== [] ? 'BOUND' : 'MISSING',
'binding_count' => count($bindings),
'primary_binding' => $primaryBinding,
'bindings' => $bindings,
];
}
/**
* @param array<int,array<string,mixed>> $gateways
* @param array<string,array<int,array<string,mixed>>> $consumersByRelayId
* @return array<int,array<string,mixed>>
*/
private function applyBindingConsumerContexts(array $gateways, array $consumersByRelayId): array
{
foreach ($gateways as $gatewayIndex => $gateway) {
$bindings = isset($gateway['bindings']) && is_array($gateway['bindings'])
? (array)$gateway['bindings']
: [];
foreach ($bindings as $bindingIndex => $binding) {
if (!is_array($binding)) {
continue;
}
$relayId = trim((string)($binding['relay_id'] ?? ''));
if ($relayId === '') {
continue;
}
$metadata = isset($binding['metadata']) && is_array($binding['metadata'])
? (array)$binding['metadata']
: [];
$consumerContexts = $consumersByRelayId[$relayId] ?? [];
$metadata['consumer_contexts'] = $consumerContexts;
$metadata['consumers'] = $consumerContexts;
$bindings[$bindingIndex]['metadata'] = $metadata;
$bindings[$bindingIndex]['consumer_contexts'] = $consumerContexts;
}
$gateways[$gatewayIndex]['bindings'] = $bindings;
}
return $gateways;
}
/**
* @param array<string,mixed> $config
*/
private function isGateConfigComplete(array $config): bool
{
$type = strtoupper(trim((string)($config['type'] ?? '')));
if ($type === 'PHONE_CALL') {
return trim((string)($config['phone_number'] ?? '')) !== ''
&& isset($config['call_duration_threshold']);
}
if ($type === 'RELAY') {
return trim((string)($config['relay_id'] ?? '')) !== '';
}
return false;
}
private function manager(): edge_gateway_manager
{
return $this->manager ?? new edge_gateway_manager();
}
}
@@ -2999,24 +2999,11 @@ BASH;
}
$apiBaseUrl = $this->getApiBaseUrl();
$parsed = parse_url($apiBaseUrl);
$host = $parsed['host'] ?? null;
if (!is_string($host) || trim($host) === '') {
if (trim($apiBaseUrl) === '') {
return null;
}
$scheme = strtolower((string)($parsed['scheme'] ?? 'http')) === 'https' ? 'https' : 'http';
$port = (int)(getenv('EDGE_PUBLIC_BROKER_PORT') ?: 4300);
if ($port <= 0) {
$port = 4300;
}
$hostWithPort = $host;
if (!(($scheme === 'https' && $port === 443) || ($scheme === 'http' && $port === 80))) {
$hostWithPort .= ':' . $port;
}
return $scheme . '://' . $hostWithPort;
return rtrim($apiBaseUrl, '/') . '/edge-broker';
}
private function buildBrokerPublicWebSocketUrl(string $path = ''): ?string
@@ -3034,9 +3021,11 @@ BASH;
}
$port = isset($parsed['port']) ? ':' . (int)$parsed['port'] : '';
$basePath = rtrim((string)($parsed['path'] ?? ''), '/');
$normalizedPath = '/' . ltrim($path, '/');
$fullPath = $basePath . ($normalizedPath === '/' ? '' : $normalizedPath);
return $scheme . '://' . $host . $port . ($normalizedPath === '/' ? '' : $normalizedPath);
return $scheme . '://' . $host . $port . ($fullPath === '' ? '' : $fullPath);
}
private function buildBrokerInternalUrl(): ?string
@@ -3133,6 +3122,29 @@ BASH;
}
$metadata['fallback_mode'] = $fallbackMode;
$consumerContexts = $binding['consumer_contexts'] ?? $binding['consumers'] ?? $metadata['consumer_contexts'] ?? $metadata['consumers'] ?? [];
if (!is_array($consumerContexts)) {
$consumerContexts = [];
}
$consumerContexts = array_values(array_filter(array_map(static function (mixed $consumer): ?array {
if (!is_array($consumer)) {
return null;
}
$consumerType = trim((string)($consumer['type'] ?? ''));
if ($consumerType === '') {
return null;
}
return [
'type' => $consumerType,
'id' => isset($consumer['id']) ? (int)$consumer['id'] : null,
'slot' => isset($consumer['slot']) ? (string)$consumer['slot'] : null,
'label' => isset($consumer['label']) ? (string)$consumer['label'] : null,
];
}, $consumerContexts)));
$metadata['consumer_contexts'] = $consumerContexts;
$metadata['consumers'] = $consumerContexts;
if (isset($binding['last_resolution']) && is_array($binding['last_resolution'])) {
$metadata['last_resolution'] = (array)$binding['last_resolution'];
@@ -4,6 +4,7 @@ namespace routes;
use classes\authentication;
use classes\edge_gateway_manager;
use classes\edge_gateway_department_workspace_service;
use classes\edge_gateway_operation_exception;
use classes\edge_gateway_operation_service;
use classes\edge_gateway_registry_service;
@@ -23,6 +24,12 @@ class moduleEdgeGatewayRoute
$this->get('/modules/edge-gateways', fn() => $this->handleListGateways(), [
'modules_shelly_config' => 'List edge gateway module fleet',
]);
$this->get('/modules/edge-gateways/workspace/departments', fn() => $this->handleDepartmentWorkspaceList(), [
'modules_shelly_config' => 'List department hardware workspaces',
]);
$this->get('/modules/edge-gateways/workspace/departments/{id}', fn() => $this->handleDepartmentWorkspaceDetail(), [
'modules_shelly_config' => 'View department hardware workspace detail',
]);
$this->get('/modules/edge-gateways/{id}', fn() => $this->handleGatewayDetail(), [
'modules_shelly_config' => 'View edge gateway module detail',
]);
@@ -102,6 +109,26 @@ class moduleEdgeGatewayRoute
$response->success($gateway);
}
private function handleDepartmentWorkspaceList(): void
{
global /** @var response $response */ $response;
$user = $this->requireModuleOperator();
$summaries = $this->workspaces()->listDepartmentSummaries();
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_WORKSPACE_LIST', 'Listed department hardware workspace summaries');
$response->success($summaries);
}
private function handleDepartmentWorkspaceDetail(): void
{
global /** @var response $response */ $response;
$user = $this->requireModuleOperator();
$departmentId = (int)$this->fromRoute('id');
self::requireParameterIntPositive($departmentId, 'id');
$this->requireDepartmentAccess((string)$departmentId);
(new logs_o())->add('modules_edgegateway', 'global', 1, $user->id, 'MODULES_EDGEGATEWAY_WORKSPACE_GET', 'Fetched department hardware workspace detail');
$response->success($this->workspaces()->getDepartmentWorkspace($departmentId));
}
private function handleGatewayTasksPage(): void
{
global /** @var response $response */ $response;
@@ -395,4 +422,9 @@ class moduleEdgeGatewayRoute
{
return new edge_gateway_manager();
}
private function workspaces(): edge_gateway_department_workspace_service
{
return new edge_gateway_department_workspace_service();
}
}
@@ -3,6 +3,7 @@
namespace objects;
use classes\department_gate_config;
use classes\edge_gateway_manager;
use classes\bird;
use classes\db;
use classes\object_property;
@@ -262,6 +263,11 @@ class department_gates_o extends db
return new slack();
}
protected function resolveEdgeGatewayManager(): edge_gateway_manager
{
return new edge_gateway_manager();
}
/**
* @return array<int,array<string,mixed>>
*/
@@ -367,9 +373,25 @@ class department_gates_o extends db
$this->requireSelected();
$config = (array)$this->config->value();
if (!$this->matchesPhoneCallGateConfig($config)) {
throw new Exception('Unsupported gate type: ' . (string)($config['type'] ?? ''));
if ($this->matchesPhoneCallGateConfig($config)) {
$this->openPhoneCallGate($config);
return;
}
if (strtoupper(trim((string)($config['type'] ?? ''))) === 'RELAY') {
$this->openRelayBackedGate($config);
return;
}
throw new Exception('Unsupported gate type: ' . (string)($config['type'] ?? ''));
}
/**
* @param array<string,mixed> $config
* @throws Exception
*/
protected function openPhoneCallGate(array $config): void
{
if (!isset($config['phone_number'])) {
throw new Exception('Phone number is required for PHONE_CALL gate type');
}
@@ -391,4 +413,30 @@ class department_gates_o extends db
throw new Exception('Failed to open gate relay via phone call', 0, $e);
}
}
/**
* @param array<string,mixed> $config
* @throws Exception
*/
protected function openRelayBackedGate(array $config): void
{
$relayId = trim((string)($config['relay_id'] ?? ''));
if ($relayId === '') {
throw new Exception('relay_id is required for RELAY gate type');
}
$departmentId = (int)$this->department->value();
if ($departmentId <= 0) {
throw new Exception('Gate department is invalid');
}
$pulseSeconds = isset($config['pulse_seconds']) ? max(0, (int)$config['pulse_seconds']) : 1;
$manager = $this->resolveEdgeGatewayManager();
$manager->dispatchRelaySwitch($departmentId, $relayId, true);
if ($pulseSeconds > 0) {
usleep($pulseSeconds * 1000000);
$manager->dispatchRelaySwitch($departmentId, $relayId, false);
}
}
}
+140 -14
View File
@@ -4,6 +4,7 @@ namespace objects;
use classes\db;
use classes\object_property;
use Exception;
use traits\db_object_t;
class plate_scanners_o extends db
@@ -11,12 +12,16 @@ class plate_scanners_o extends db
use db_object_t;
public object_property $department_id;
public object_property $lane_id;
public object_property $name;
public object_property $notes;
public object_property $api_key;
private static bool $schemaInitialized = false;
public function structure(): void
{
self::ensureSchema();
$this->setTable('plate_scanners');
}
@@ -41,22 +46,25 @@ class plate_scanners_o extends db
public function getObjectProperties(): void
{
$this->department_id = new object_property($this->table, $this->id, 'department_id', 'int');
$this->lane_id = new object_property($this->table, $this->id, 'lane_id', 'int');
$this->name = new object_property($this->table, $this->id, 'name', 'string');
$this->notes = new object_property($this->table, $this->id, 'notes', 'string');
$this->api_key = new object_property($this->table, $this->id, 'api_key', 'string');
}
public function add(int $department_id, string $name, string $notes): void
public function add(int $department_id, string $name, string $notes, ?int $lane_id = null): void
{
global $db, $response;
try {
// Generate an API key
$api_key = bin2hex(random_bytes(32));
$lane_id = $this->normalizeLaneId($department_id, $lane_id);
// Avoid SQL injection
$name = $db->escape_string($name);
$notes = $db->escape_string($notes);
$laneValue = $lane_id === null ? 'NULL' : (string)$lane_id;
// Create a new record in the database
$sql = "INSERT INTO $this->table (department_id, name, notes, api_key) VALUES ($department_id, '$name', '$notes', '$api_key')";
$sql = "INSERT INTO $this->table (department_id, lane_id, name, notes, api_key) VALUES ($department_id, $laneValue, '$name', '$notes', '$api_key')";
$db->query($sql);
// Get the id of the new record
@@ -69,20 +77,26 @@ class plate_scanners_o extends db
}
}
public function edit(int $id, int $department_id, string $name, string $notes): void
public function edit(
int $id,
int $department_id,
string $name,
string $notes,
?int $lane_id = null,
bool $laneIdProvided = false
): void
{
global $db, $response;
$this->id = $id;
global $response;
try {
// Avoid SQL injection
$name = $db->escape_string($name);
$notes = $db->escape_string($notes);
// Update the record in the database
$sql = "UPDATE $this->table SET department_id = $department_id, name = '$name', notes = '$notes' WHERE id = $id";
$db->query($sql);
// Set the values of the object properties
$this->getObjectProperties();
$this->select($id);
// Use object_property setters so cached field values are invalidated before we serialize the scanner.
$this->department_id->set($department_id);
$this->name->set($name);
$this->notes->set($notes);
if ($laneIdProvided) {
$lane_id = $this->normalizeLaneId($department_id, $lane_id);
$this->lane_id->set($lane_id);
}
} catch (\Exception $e) {
$response->error($e->getMessage());
}
@@ -102,4 +116,116 @@ class plate_scanners_o extends db
}
return $this;
}
/**
* @return array{id:int,department_id:int,lane_id:int|null,name:string,notes:string,api_key:string}
* @throws Exception
*/
public function asArray(): array
{
$this->requireSelected();
return [
'id' => (int)$this->id,
'department_id' => (int)$this->department_id->value(),
'lane_id' => $this->lane_id->value() === null ? null : (int)$this->lane_id->value(),
'name' => (string)$this->name->value(),
'notes' => (string)$this->notes->value(),
'api_key' => (string)$this->api_key->value(),
];
}
/**
* @return array<int,plate_scanners_o>
* @throws Exception
*/
public function getDepartmentScanners(int $departmentId): array
{
$scanners = [];
$rows = self::getFieldsWhere([
'department_id' => $departmentId,
], ['id']);
foreach ($rows as $row) {
$scanner = (new plate_scanners_o())->select((int)$row['id']);
if ($scanner->exists()) {
$scanners[] = $scanner;
}
}
return $scanners;
}
/**
* @throws Exception
*/
public function rotateApiKey(int $id): array
{
$scanner = $this->select($id);
if (!$scanner->exists()) {
throw new Exception('Number plate scanner not found');
}
$newApiKey = bin2hex(random_bytes(32));
$scanner->api_key->set($newApiKey);
return $scanner->asArray();
}
private function normalizeLaneId(int $departmentId, ?int $laneId): ?int
{
if ($laneId === null || $laneId <= 0) {
return null;
}
$lane = (new department_lanes_o())->select($laneId);
if (!$lane->exists()) {
throw new Exception('Department lane not found');
}
if ((int)$lane->department->value() !== $departmentId) {
throw new Exception('The lane does not belong to the number plate scanner department');
}
return (int)$lane->id;
}
private static function ensureSchema(): void
{
if (self::$schemaInitialized) {
return;
}
global $db;
if (!self::tableHasColumn('plate_scanners', 'lane_id')) {
$db->query("ALTER TABLE `plate_scanners` ADD COLUMN `lane_id` INT NULL AFTER `department_id`");
}
self::$schemaInitialized = true;
}
private static function tableHasColumn(string $table, string $column): bool
{
global $db;
$table = $db->escape_string($table);
$column = $db->escape_string($column);
$database = $db->escape_string($db->getDatabase());
$result = $db->query(
"SELECT COUNT(*) AS c
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = '$database'
AND TABLE_NAME = '$table'
AND COLUMN_NAME = '$column'"
);
if (!$result) {
return false;
}
$row = $result->fetch_assoc();
return ((int)($row['c'] ?? 0)) > 0;
}
}
@@ -4,6 +4,7 @@ namespace routes;
use classes\authentication;
use classes\response;
use classes\shelly_relay_inventory;
use dynamicimages\images\machine_1;
use objects\categories_o;
use objects\department_lanes_o;
@@ -81,6 +82,52 @@ class departmentLanesRoute
]
);
$this->get('/department/lanes/relay-options', function () {
global $response;
$this->requirePermission('list_department_lanes');
$user = (new authentication())->get_user();
if ($user) {
try {
$options = (new shelly_relay_inventory())->listRelayOptions();
(new logs_o())->add(
'department_lanes',
'global',
1,
$user->id,
'LIST_DEPARTMENT_LANE_RELAY_OPTIONS',
'User listed available Shelly relay options'
);
$response->success($options);
} catch (\Throwable $e) {
(new logs_o())->add(
'department_lanes',
'global',
0,
$user->id,
'LIST_DEPARTMENT_LANE_RELAY_OPTIONS',
'Failed to list Shelly relay options: ' . $e->getMessage()
);
$response->error('Failed to fetch Shelly relay options: ' . $e->getMessage(), 400);
}
} else {
(new logs_o())->add(
'department_lanes',
'global',
1,
0,
'LIST_DEPARTMENT_LANE_RELAY_OPTIONS',
'User tried to list Shelly relay options without being logged in'
);
$response->error('Invalid session', 400);
}
},
[
'list_department_lanes' => 'List available Shelly relay options for department lanes'
]
);
/**
* Generate dynamic image for a department lane (machine UI)
*
@@ -69,6 +69,17 @@ class machineButtonPressRoute
return (int)$lane->id;
}
if ($plateScanner->lane_id->value() !== null) {
$lane = (new department_lanes_o())->select((int)$plateScanner->lane_id->value());
if (!$lane->exists()) {
$response->error('The default lane configured for the plate scanner no longer exists', 404);
}
if ((int)$lane->department->value() !== (int)$plateScanner->department_id->value()) {
$response->error('The default lane does not belong to the plate scanner department', 403);
}
return (int)$lane->id;
}
$lanes = (new department_lanes_o())->getDepartmentLanes((int)$plateScanner->department_id->value());
if (count($lanes) === 1) {
return (int)$lanes[0]->id;
@@ -34,7 +34,11 @@ class plateScannersRoute
'name',
'notes'
])
->listObjectsWithPaginationIfSet()
->listObjectsWithPaginationIfSet(
static function (array $scanner): array {
return (new plate_scanners_o())->select((int)$scanner['id'])->asArray();
}
)
);
} else {
// Log the incident
@@ -68,12 +72,19 @@ class plateScannersRoute
if (!isset($data['notes'])) {
$response->error('Notes is required', 400);
}
$laneId = array_key_exists('lane_id', $data) && $data['lane_id'] !== null
? (int)$data['lane_id']
: null;
// Add the number plate scanner
(new plate_scanners_o())->add($data['department_id'], $data['name'], $data['notes']);
$scanner = new plate_scanners_o();
$scanner->add((int)$data['department_id'], (string)$data['name'], (string)$data['notes'], $laneId);
// Log the incident
(new logs_o())->add('numberplatescanners', 'global', 1, $user->id, 'ADD_NUMBER_PLATE_SCANNER', 'Successfully added a number plate scanner');
// Return a success message
$response->success(['message' => 'Number plate scanner added']);
$response->success([
'message' => 'Number plate scanner added',
'scanner' => $scanner->asArray(),
]);
} else {
// Log the incident
(new logs_o())->add('numberplatescanners', 'global', 1, 0, 'ADD_NUMBER_PLATE_SCANNER', 'No user found, or invalid session');
@@ -109,12 +120,25 @@ class plateScannersRoute
if (!isset($data['notes'])) {
$response->error('Notes is required', 400);
}
$laneIdProvided = array_key_exists('lane_id', $data);
$laneId = $laneIdProvided && $data['lane_id'] !== null ? (int)$data['lane_id'] : null;
// Edit the number plate scanner
(new plate_scanners_o())->edit($data['id'], $data['department_id'], $data['name'], $data['notes']);
$scanner = new plate_scanners_o();
$scanner->edit(
(int)$data['id'],
(int)$data['department_id'],
(string)$data['name'],
(string)$data['notes'],
$laneId,
$laneIdProvided
);
// Log the incident
(new logs_o())->add('numberplatescanners', 'global', 1, $user->id, 'EDIT_NUMBER_PLATE_SCANNER', 'Successfully edited a number plate scanner');
// Return a success message
$response->success(['message' => 'Number plate scanner edited']);
$response->success([
'message' => 'Number plate scanner edited',
'scanner' => $scanner->asArray(),
]);
} else {
// Log the incident
(new logs_o())->add('numberplatescanners', 'global', 1, 0, 'EDIT_NUMBER_PLATE_SCANNER', 'No user found, or invalid session');
@@ -127,6 +151,35 @@ class plateScannersRoute
]
);
$this->post('/numberplatescanners/{id}/rotate-key', function () {
global $response;
$this->requirePermission('edit_number_plate_scanner');
$user = (new authentication())->get_user();
if (!$user) {
(new logs_o())->add('numberplatescanners', 'global', 1, 0, 'ROTATE_NUMBER_PLATE_SCANNER_KEY', 'No user found, or invalid session');
$response->error('Invalid session', 400);
}
$scannerId = (int)$this->fromRoute('id');
self::requireParameterIntPositive($scannerId, 'id');
$scanner = (new plate_scanners_o())->select($scannerId);
if (!$scanner->exists()) {
$response->error('Number plate scanner not found', 404);
}
self::requireDepartmentAccess((int)$scanner->department_id->value());
$rotatedScanner = (new plate_scanners_o())->rotateApiKey($scannerId);
(new logs_o())->add('numberplatescanners', 'global', 1, $user->id, 'ROTATE_NUMBER_PLATE_SCANNER_KEY', 'Successfully rotated a number plate scanner API key');
$response->success([
'message' => 'Number plate scanner API key rotated',
'scanner' => $rotatedScanner,
'api_key' => (string)$rotatedScanner['api_key'],
]);
}, [
'edit_number_plate_scanner' => 'Rotate a number plate scanner API key',
]);
self::get('/department/numberplatescanners', function () {
// Require the user to be logged in
global $response;
@@ -152,12 +205,14 @@ class plateScannersRoute
'department_id' => (int)self::getParameter('id')
], [
'id',
'lane_id',
'name',
'notes'
]);
// Parse the result
foreach ( $result as $key => $value ) {
$result[$key]['id'] = (int)$value['id'];
$result[$key]['lane_id'] = $value['lane_id'] === null ? null : (int)$value['lane_id'];
}
// Return the list of plate scanners
$response->success(
@@ -0,0 +1,125 @@
<?php
declare(strict_types=1);
usesApiSuite();
it('returns the updated lane id after editing a scanner whose null lane was already cached', function (): void {
api_test_covers('PUT /numberplatescanners', 'happy');
$department = api_fixtures()->createDepartment([
'name' => 'Scanner Department ' . uniqid('', false),
]);
$session = api_fixtures()->createUserSession([
'add_department_lane',
'add_number_plate_scanner',
'edit_number_plate_scanner',
]);
$laneName = 'Lane ' . uniqid('', false);
$laneResponse = api_client()->post('/department/lanes', [
'department' => $department['id'],
'name' => $laneName,
], $session['headers']);
$laneResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess();
$lane = api_test_runtime()->queryOne(
"SELECT id FROM department_lanes WHERE department = " . (int)$department['id'] .
" AND name = '" . api_test_runtime()->db()->real_escape_string($laneName) . "'" .
' ORDER BY id DESC LIMIT 1'
);
expect($lane)->not->toBeNull();
$laneId = (int)($lane['id'] ?? 0);
expect($laneId)->toBeGreaterThan(0);
api_fixtures()->cleanupDeleteById('department_lanes', $laneId);
$scannerName = 'Scanner ' . uniqid('', false);
$createResponse = api_client()->post('/numberplatescanners', [
'department_id' => $department['id'],
'name' => $scannerName,
'notes' => 'Original notes',
], $session['headers']);
$createResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess()
->assertMessage('Number plate scanner added');
$createdScanner = api_test_runtime()->queryOne(
"SELECT id FROM plate_scanners WHERE department_id = " . (int)$department['id'] .
" AND name = '" . api_test_runtime()->db()->real_escape_string($scannerName) . "'" .
' ORDER BY id DESC LIMIT 1'
);
expect($createdScanner)->not->toBeNull();
$scannerId = (int)($createdScanner['id'] ?? 0);
expect($scannerId)->toBeGreaterThan(0);
api_fixtures()->cleanupDeleteById('plate_scanners', $scannerId);
$redis = api_test_runtime()->redis();
expect($redis)->not->toBeNull();
$cacheKeys = [
"obj_prop:plate_scanners:{$scannerId}:lane_id" => '___NULL___',
"obj_prop:plate_scanners:{$scannerId}:name" => $scannerName,
"obj_prop:plate_scanners:{$scannerId}:notes" => 'Original notes',
];
foreach ($cacheKeys as $cacheKey => $value) {
$redis->set($cacheKey, $value);
}
$updateResponse = api_client()->put('/numberplatescanners', [
'id' => $scannerId,
'department_id' => $department['id'],
'lane_id' => $laneId,
'name' => 'Updated scanner',
'notes' => 'Updated notes',
], $session['headers']);
$updateResponse
->assertStatus(200)
->assertEnvelope()
->assertSuccess()
->assertMessage('Number plate scanner edited');
$redis->del(array_keys($cacheKeys));
$updatedScanner = $updateResponse->data()['scanner'] ?? null;
expect($updatedScanner)->toBeArray();
expect($updatedScanner['id'] ?? null)->toBe($scannerId);
expect($updatedScanner['department_id'] ?? null)->toBe($department['id']);
expect($updatedScanner['lane_id'] ?? null)->toBe($laneId);
expect($updatedScanner['name'] ?? null)->toBe('Updated scanner');
expect($updatedScanner['notes'] ?? null)->toBe('Updated notes');
$row = api_fixtures()->fetchRowById('plate_scanners', $scannerId);
expect($row)->not->toBeNull();
expect((int)($row['lane_id'] ?? 0))->toBe($laneId);
expect($row['name'] ?? null)->toBe('Updated scanner');
expect($row['notes'] ?? null)->toBe('Updated notes');
});
it('rejects plate scanner edits when the permission is missing', function (): void {
api_test_covers('PUT /numberplatescanners', 'auth');
$session = api_fixtures()->createUserSession([]);
$response = api_client()->put('/numberplatescanners', [
'id' => 1,
'department_id' => 1,
'name' => 'No permission',
'notes' => 'Should fail',
], $session['headers']);
$response
->assertStatus(403)
->assertEnvelope()
->assertSuccess(false)
->assertMissingPermissions(['edit_number_plate_scanner']);
});
@@ -14,6 +14,7 @@ return [
'GET /orders',
'POST /orders',
'PUT /orders',
'PUT /numberplatescanners',
'DELETE /orders',
'POST /bird/voice/calls/webhook/inbound',
],
@@ -0,0 +1,49 @@
#!/bin/sh
set -eu
tmp_dir="$(mktemp -d)"
cleanup() {
rm -rf "$tmp_dir"
}
trap cleanup EXIT HUP INT TERM
cat > "$tmp_dir/composer.json" <<'JSON'
{
"name": "truckwash/composer-entrypoint-fixture",
"autoload": {
"classmap": [
"src/"
]
}
}
JSON
mkdir -p "$tmp_dir/src"
cat > "$tmp_dir/src/FixtureClass.php" <<'PHP'
<?php
class FixtureClass
{
}
PHP
COMPOSER_ALLOW_SUPERUSER=1 composer install \
--no-dev \
--prefer-dist \
--optimize-autoloader \
--no-interaction \
-d "$tmp_dir" >/dev/null 2>&1
printf '%s\n' '<?php' > "$tmp_dir/vendor/composer/autoload_real.php"
AUTO_COMPOSER_INSTALL=true \
APP_DIR="$tmp_dir" \
MODULE_DIR="$tmp_dir/no-module" \
LOG_FILE="$tmp_dir/composer-install.log" \
/usr/local/bin/docker-entrypoint.sh \
php -r "require \$argv[1]; echo class_exists('FixtureClass') ? 'autoload-ok' . PHP_EOL : 'autoload-missing' . PHP_EOL;" \
"$tmp_dir/vendor/autoload.php" >/dev/null
php -d display_errors=1 -r "require \$argv[1]; exit(class_exists('FixtureClass') ? 0 : 1);" "$tmp_dir/vendor/autoload.php"
echo "composer-entrypoint-autoload-recovery-ok"
@@ -0,0 +1,79 @@
<?php
app_require('classes/edge_gateway_manager.php');
app_require('classes/object_property.php');
app_require('objects/department_gates_o.php');
use classes\edge_gateway_manager;
use classes\object_property;
use objects\department_gates_o;
final class DepartmentGatesRelayManagerFake extends edge_gateway_manager
{
public array $switchCalls = [];
public function __construct()
{
}
public function dispatchRelaySwitch(int $departmentId, string $logicalRelayId, bool $on): array
{
$this->switchCalls[] = [
'department_id' => $departmentId,
'relay_id' => $logicalRelayId,
'on' => $on,
];
return [
'relay_id' => $logicalRelayId,
'online' => true,
'on' => $on,
];
}
}
final class DepartmentGatesRelayOpenHarness extends department_gates_o
{
public function __construct(
array $config,
int $departmentId,
private readonly DepartmentGatesRelayManagerFake $manager,
) {
$this->id = 1001;
$departmentProperty = new object_property('department_gates', -1, 'department', 'int');
$departmentProperty->set($departmentId);
$this->department = $departmentProperty;
$configProperty = new object_property('department_gates', -1, 'config', 'json');
$configProperty->set($config);
$this->config = $configProperty;
}
public function requireSelected(): void
{
}
protected function resolveEdgeGatewayManager(): edge_gateway_manager
{
return $this->manager;
}
}
it('dispatches relay-backed gates through the edge gateway relay manager', function (): void {
$manager = new DepartmentGatesRelayManagerFake();
$gate = new DepartmentGatesRelayOpenHarness([
'type' => 'RELAY',
'relay_id' => 'ENTRY-GATE-1',
'pulse_seconds' => 0,
], 17, $manager);
$gate->openGate();
expect($manager->switchCalls)->toBe([
[
'department_id' => 17,
'relay_id' => 'ENTRY-GATE-1',
'on' => true,
],
]);
});
@@ -0,0 +1,30 @@
<?php
app_require('classes/department_gate_config.php');
use classes\department_gate_config;
it('validates relay gate configs and keeps relay-specific fields in the payload', function (): void {
$config = new department_gate_config([
'type' => 'RELAY',
'relay_id' => 'ENTRY-GATE-1',
'pulse_seconds' => 0,
]);
$config->validate();
expect($config->toArray())->toMatchArray([
'type' => 'RELAY',
'relay_id' => 'ENTRY-GATE-1',
'pulse_seconds' => 0,
]);
});
it('rejects relay gate configs without a logical relay id', function (): void {
$config = new department_gate_config([
'type' => 'RELAY',
]);
expect(fn() => $config->validate())
->toThrow(Exception::class, 'relay_id is required for RELAY gate type');
});
@@ -0,0 +1,10 @@
<?php
it('registers a department lane relay options endpoint backed by Shelly inventory', function (): void {
$route = file_get_contents(app_path('routes/departmentLanesRoute.php'));
expect($route)->not->toBeFalse();
expect($route)->toContain("'/department/lanes/relay-options'");
expect($route)->toContain('new shelly_relay_inventory()');
expect($route)->toContain('LIST_DEPARTMENT_LANE_RELAY_OPTIONS');
});
@@ -0,0 +1,17 @@
<?php
it('defines the department hardware workspace service payload surface', function (): void {
$service = file_get_contents(app_path('modules/edgegateway/classes/edge_gateway_department_workspace_service.php'));
expect($service)->not->toBeFalse();
expect($service)->toContain('class edge_gateway_department_workspace_service');
expect($service)->toContain("'summary' => \$summary");
expect($service)->toContain("'gateways' => \$includeGateways ? \$gateways : []");
expect($service)->toContain("'lanes' => \$lanes");
expect($service)->toContain("'self_serve' => \$selfServe");
expect($service)->toContain("'gates' => \$gates");
expect($service)->toContain("'scanners' => \$scanners");
expect($service)->toContain("'issues' => \$issues");
expect($service)->toContain("'actions' => \$actions");
expect($service)->toContain("'consumer_contexts'");
});
@@ -30,6 +30,13 @@ function with_edge_gateway_server_state(array $server, callable $callback): void
}
}
function invoke_edge_gateway_private(object $instance, string $method, mixed ...$arguments): mixed
{
$reflection = new ReflectionMethod($instance, $method);
$reflection->setAccessible(true);
return $reflection->invokeArgs($instance, $arguments);
}
it('builds install script urls with the compose edge gateway artifacts and forwarded https scheme', function (): void {
with_edge_gateway_server_state([
'HTTP_HOST' => 'api.truckwash.io:4433',
@@ -64,7 +71,7 @@ it('builds install script urls with the compose edge gateway artifacts and forwa
expect($script)->toContain('"composeFileName":"docker-compose.gateway.yml"');
expect($script)->toContain('"stateDatabasePath":"/opt/truckwash-edge-agent/runtime/gateway-state.sqlite"');
expect($script)->toContain('"operationPollTimeoutSeconds":20');
expect($script)->toContain('"brokerUrl":"https://api.truckwash.io:4300"');
expect($script)->toContain('"brokerUrl":"https://api.truckwash.io:4433/edge-broker"');
expect($script)->not->toContain('"shellActionPollTimeoutSeconds"');
});
});
@@ -94,6 +101,20 @@ it('prefers EDGE_PUBLIC_API_URL when explicitly configured', function (): void {
expect($manager->getApiBaseUrl())->toBe('https://edge.example.test/api');
expect($manager->buildInstallScriptUrl('token-1'))->toBe('https://edge.example.test/api/edge-agent/install.sh?token=token-1');
expect($script)->toContain('"apiUrl":"https://edge.example.test/api"');
expect($script)->toContain('"brokerUrl":"https://edge.example.test:4300"');
expect($script)->toContain('"brokerUrl":"https://edge.example.test/api/edge-broker"');
});
});
it('builds websocket broker urls on the traefik broker path', function (): void {
with_edge_gateway_server_state([
'HTTP_HOST' => 'api.truckwash.io:4433',
'HTTP_X_FORWARDED_PROTO' => 'https',
], function (): void {
$manager = new EdgeGatewayManagerUrlHarness();
expect(invoke_edge_gateway_private($manager, 'buildBrokerPublicUrl'))
->toBe('https://api.truckwash.io:4433/edge-broker');
expect(invoke_edge_gateway_private($manager, 'buildBrokerPublicWebSocketUrl', '/ws/browser-shell'))
->toBe('wss://api.truckwash.io:4433/edge-broker/ws/browser-shell');
});
});
@@ -5,6 +5,8 @@ it('registers module-scoped edge gateway operator routes', function (): void {
expect($route)->not->toBeFalse();
expect($route)->toContain("'/modules/edge-gateways'");
expect($route)->toContain("'/modules/edge-gateways/workspace/departments'");
expect($route)->toContain("'/modules/edge-gateways/workspace/departments/{id}'");
expect($route)->toContain("'/modules/edge-gateways/{id}'");
expect($route)->toContain("'/modules/edge-gateways/install-token'");
expect($route)->toContain("'/modules/edge-gateways/install-token/{id}/status'");
@@ -0,0 +1,23 @@
<?php
it('adds lane-aware scanner management and a dedicated rotate-key action', function (): void {
$route = file_get_contents(app_path('routes/plateScannersRoute.php'));
$scannerObject = file_get_contents(app_path('objects/plate_scanners_o.php'));
expect($route)->not->toBeFalse();
expect($route)->toContain("'/numberplatescanners/{id}/rotate-key'");
expect($route)->toContain("'lane_id'");
expect($scannerObject)->not->toBeFalse();
expect($scannerObject)->toContain('public object_property $lane_id;');
expect($scannerObject)->toContain('public function rotateApiKey(int $id): array');
expect($scannerObject)->toContain('ADD COLUMN `lane_id` INT NULL AFTER `department_id`');
});
it('uses the scanner default lane before requiring an explicit lane_id in machine button webhooks', function (): void {
$route = file_get_contents(app_path('routes/machineButtonPressRoute.php'));
expect($route)->not->toBeFalse();
expect($route)->toContain('$plateScanner->lane_id->value()');
expect($route)->toContain('default lane configured for the plate scanner');
});
@@ -0,0 +1,81 @@
<?php
app_require('classes/shelly_relay_inventory.php');
use classes\shelly_relay_inventory;
it('normalizes owned Shelly devices into relay select options', function (): void {
$inventory = (new shelly_relay_inventory())->setInventoryFetcher(static function (): array {
return [
'isok' => true,
'data' => [
'devices_status' => [
'device-key-1' => [
'_dev_info' => [
'id' => 'shelly-plus-01',
'code' => 'SPSW-001PE16EU',
'online' => 1,
],
'name' => 'Entry Gate',
'status' => [
'switch:0' => [
'output' => false,
],
],
],
'device-key-2' => [
'_dev_info' => [
'id' => 'shelly-legacy-02',
'code' => 'SHSW-1',
'online' => 0,
],
'relays' => [
['ison' => false],
],
],
'device-key-3' => [
'_dev_info' => [
'id' => 'shelly-sensor-01',
'code' => 'SHHT-1',
'online' => 1,
],
'sensor' => [
'temperature' => 21.5,
],
],
],
],
];
});
expect($inventory->listRelayOptions())->toBe([
[
'id' => 'shelly-plus-01',
'name' => 'Entry Gate (shelly-plus-01) · SPSW-001PE16EU',
'device_id' => 'shelly-plus-01',
'device_name' => 'Entry Gate',
'code' => 'SPSW-001PE16EU',
'online' => true,
],
[
'id' => 'shelly-legacy-02',
'name' => 'shelly-legacy-02 · SHSW-1',
'device_id' => 'shelly-legacy-02',
'device_name' => null,
'code' => 'SHSW-1',
'online' => false,
],
]);
});
it('fails fast when Shelly inventory does not include owned devices status', function (): void {
$inventory = (new shelly_relay_inventory())->setInventoryFetcher(static function (): array {
return [
'isok' => true,
'data' => [],
];
});
expect(fn() => $inventory->listRelayOptions())
->toThrow(Exception::class, 'Shelly relay inventory response was missing devices_status');
});
+14 -3
View File
@@ -2,9 +2,9 @@
set -e
# Config
APP_DIR="/var/www/html"
MODULE_DIR="$APP_DIR/modules/washcertificates"
LOG_FILE="/var/log/php/composer-install.log"
APP_DIR="${APP_DIR:-/var/www/html}"
MODULE_DIR="${MODULE_DIR:-$APP_DIR/modules/washcertificates}"
LOG_FILE="${LOG_FILE:-/var/log/php/composer-install.log}"
# Gate auto-install (set to "true" only on one PHP container, e.g. php1)
AUTO_COMPOSER_INSTALL="${AUTO_COMPOSER_INSTALL:-true}"
@@ -14,17 +14,28 @@ log() { printf "[entrypoint] %s\n" "$*"; }
vendor_sanity_ok() {
dir="$1"
autoload_file="$dir/vendor/autoload.php"
autoload_real_file="$dir/vendor/composer/autoload_real.php"
aws_s3_api_file="$dir/vendor/aws/aws-sdk-php/src/data/s3/2006-03-01/api-2.json.php"
if [ ! -f "$autoload_file" ]; then
return 1
fi
if [ ! -f "$autoload_real_file" ]; then
log "Vendor sanity check failed: missing $autoload_real_file"
return 1
fi
if [ -f "$dir/composer.lock" ] && [ "$dir/composer.lock" -nt "$autoload_file" ]; then
log "composer.lock is newer than vendor/autoload.php in $dir"
return 1
fi
if ! php -d display_errors=1 -r 'require $argv[1];' "$autoload_file" >/dev/null 2>&1; then
log "Vendor sanity check failed for $autoload_file"
return 1
fi
if [ -f "$aws_s3_api_file" ] && ! php -l "$aws_s3_api_file" >/dev/null 2>&1; then
log "Vendor sanity check failed for $aws_s3_api_file"
return 1