Add department hardware workspace and plate scanner updates
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
+865
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
+17
@@ -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');
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user