Compare commits

...
Author SHA1 Message Date
Jeppe Bundgaard 85eb0039d1 Add department hardware workspace backend 2026-04-23 12:45:07 +02:00
13 changed files with 1365 additions and 30 deletions
@@ -9,15 +9,19 @@ class department_gate_config
public string $type; public string $type;
public ?string $phone_number = null; public ?string $phone_number = null;
public ?int $call_duration_threshold = null; public ?int $call_duration_threshold = null;
public ?string $relay_id = null;
public ?int $pulse_seconds = null;
/** /**
* @param array $config * @param array $config
*/ */
public function __construct(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->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->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; $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; return $array;
} }
@@ -58,6 +70,19 @@ class department_gate_config
if ($this->call_duration_threshold === null) { if ($this->call_duration_threshold === null) {
throw new Exception('Call duration threshold is required for PHONE_CALL gate type'); 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);
} }
} }
@@ -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(); $apiBaseUrl = $this->getApiBaseUrl();
$parsed = parse_url($apiBaseUrl); if (trim($apiBaseUrl) === '') {
$host = $parsed['host'] ?? null;
if (!is_string($host) || trim($host) === '') {
return null; return null;
} }
$scheme = strtolower((string)($parsed['scheme'] ?? 'http')) === 'https' ? 'https' : 'http'; return rtrim($apiBaseUrl, '/') . '/edge-broker';
$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;
} }
private function buildBrokerPublicWebSocketUrl(string $path = ''): ?string private function buildBrokerPublicWebSocketUrl(string $path = ''): ?string
@@ -3034,9 +3021,11 @@ BASH;
} }
$port = isset($parsed['port']) ? ':' . (int)$parsed['port'] : ''; $port = isset($parsed['port']) ? ':' . (int)$parsed['port'] : '';
$basePath = rtrim((string)($parsed['path'] ?? ''), '/');
$normalizedPath = '/' . ltrim($path, '/'); $normalizedPath = '/' . ltrim($path, '/');
$fullPath = $basePath . ($normalizedPath === '/' ? '' : $normalizedPath);
return $scheme . '://' . $host . $port . ($normalizedPath === '/' ? '' : $normalizedPath); return $scheme . '://' . $host . $port . ($fullPath === '' ? '' : $fullPath);
} }
private function buildBrokerInternalUrl(): ?string private function buildBrokerInternalUrl(): ?string
@@ -3133,6 +3122,29 @@ BASH;
} }
$metadata['fallback_mode'] = $fallbackMode; $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'])) { if (isset($binding['last_resolution']) && is_array($binding['last_resolution'])) {
$metadata['last_resolution'] = (array)$binding['last_resolution']; $metadata['last_resolution'] = (array)$binding['last_resolution'];
@@ -4,6 +4,7 @@ namespace routes;
use classes\authentication; use classes\authentication;
use classes\edge_gateway_manager; use classes\edge_gateway_manager;
use classes\edge_gateway_department_workspace_service;
use classes\edge_gateway_operation_exception; use classes\edge_gateway_operation_exception;
use classes\edge_gateway_operation_service; use classes\edge_gateway_operation_service;
use classes\edge_gateway_registry_service; use classes\edge_gateway_registry_service;
@@ -23,6 +24,12 @@ class moduleEdgeGatewayRoute
$this->get('/modules/edge-gateways', fn() => $this->handleListGateways(), [ $this->get('/modules/edge-gateways', fn() => $this->handleListGateways(), [
'modules_shelly_config' => 'List edge gateway module fleet', '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(), [ $this->get('/modules/edge-gateways/{id}', fn() => $this->handleGatewayDetail(), [
'modules_shelly_config' => 'View edge gateway module detail', 'modules_shelly_config' => 'View edge gateway module detail',
]); ]);
@@ -102,6 +109,26 @@ class moduleEdgeGatewayRoute
$response->success($gateway); $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 private function handleGatewayTasksPage(): void
{ {
global /** @var response $response */ $response; global /** @var response $response */ $response;
@@ -395,4 +422,9 @@ class moduleEdgeGatewayRoute
{ {
return new edge_gateway_manager(); 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; namespace objects;
use classes\department_gate_config; use classes\department_gate_config;
use classes\edge_gateway_manager;
use classes\bird; use classes\bird;
use classes\db; use classes\db;
use classes\object_property; use classes\object_property;
@@ -262,6 +263,11 @@ class department_gates_o extends db
return new slack(); return new slack();
} }
protected function resolveEdgeGatewayManager(): edge_gateway_manager
{
return new edge_gateway_manager();
}
/** /**
* @return array<int,array<string,mixed>> * @return array<int,array<string,mixed>>
*/ */
@@ -367,9 +373,25 @@ class department_gates_o extends db
$this->requireSelected(); $this->requireSelected();
$config = (array)$this->config->value(); $config = (array)$this->config->value();
if (!$this->matchesPhoneCallGateConfig($config)) { if ($this->matchesPhoneCallGateConfig($config)) {
throw new Exception('Unsupported gate type: ' . (string)($config['type'] ?? '')); $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'])) { if (!isset($config['phone_number'])) {
throw new Exception('Phone number is required for PHONE_CALL gate type'); 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); 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);
}
}
} }
+141 -5
View File
@@ -4,6 +4,7 @@ namespace objects;
use classes\db; use classes\db;
use classes\object_property; use classes\object_property;
use Exception;
use traits\db_object_t; use traits\db_object_t;
class plate_scanners_o extends db class plate_scanners_o extends db
@@ -11,12 +12,16 @@ class plate_scanners_o extends db
use db_object_t; use db_object_t;
public object_property $department_id; public object_property $department_id;
public object_property $lane_id;
public object_property $name; public object_property $name;
public object_property $notes; public object_property $notes;
public object_property $api_key; public object_property $api_key;
private static bool $schemaInitialized = false;
public function structure(): void public function structure(): void
{ {
self::ensureSchema();
$this->setTable('plate_scanners'); $this->setTable('plate_scanners');
} }
@@ -41,22 +46,25 @@ class plate_scanners_o extends db
public function getObjectProperties(): void public function getObjectProperties(): void
{ {
$this->department_id = new object_property($this->table, $this->id, 'department_id', 'int'); $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->name = new object_property($this->table, $this->id, 'name', 'string');
$this->notes = new object_property($this->table, $this->id, 'notes', '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'); $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; global $db, $response;
try { try {
// Generate an API key // Generate an API key
$api_key = bin2hex(random_bytes(32)); $api_key = bin2hex(random_bytes(32));
$lane_id = $this->normalizeLaneId($department_id, $lane_id);
// Avoid SQL injection // Avoid SQL injection
$name = $db->escape_string($name); $name = $db->escape_string($name);
$notes = $db->escape_string($notes); $notes = $db->escape_string($notes);
$laneValue = $lane_id === null ? 'NULL' : (string)$lane_id;
// Create a new record in the database // 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); $db->query($sql);
// Get the id of the new record // Get the id of the new record
@@ -69,7 +77,14 @@ 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; global $db, $response;
$this->id = $id; $this->id = $id;
@@ -77,8 +92,17 @@ class plate_scanners_o extends db
// Avoid SQL injection // Avoid SQL injection
$name = $db->escape_string($name); $name = $db->escape_string($name);
$notes = $db->escape_string($notes); $notes = $db->escape_string($notes);
$setParts = [
"department_id = $department_id",
"name = '$name'",
"notes = '$notes'",
];
if ($laneIdProvided) {
$lane_id = $this->normalizeLaneId($department_id, $lane_id);
$setParts[] = 'lane_id = ' . ($lane_id === null ? 'NULL' : (string)$lane_id);
}
// Update the record in the database // Update the record in the database
$sql = "UPDATE $this->table SET department_id = $department_id, name = '$name', notes = '$notes' WHERE id = $id"; $sql = "UPDATE $this->table SET " . implode(', ', $setParts) . " WHERE id = $id";
$db->query($sql); $db->query($sql);
// Set the values of the object properties // Set the values of the object properties
@@ -102,4 +126,116 @@ class plate_scanners_o extends db
} }
return $this; 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;
}
}
@@ -69,6 +69,17 @@ class machineButtonPressRoute
return (int)$lane->id; 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()); $lanes = (new department_lanes_o())->getDepartmentLanes((int)$plateScanner->department_id->value());
if (count($lanes) === 1) { if (count($lanes) === 1) {
return (int)$lanes[0]->id; return (int)$lanes[0]->id;
@@ -34,7 +34,11 @@ class plateScannersRoute
'name', 'name',
'notes' 'notes'
]) ])
->listObjectsWithPaginationIfSet() ->listObjectsWithPaginationIfSet(
static function (array $scanner): array {
return (new plate_scanners_o())->select((int)$scanner['id'])->asArray();
}
)
); );
} else { } else {
// Log the incident // Log the incident
@@ -68,12 +72,19 @@ class plateScannersRoute
if (!isset($data['notes'])) { if (!isset($data['notes'])) {
$response->error('Notes is required', 400); $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 // 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 // Log the incident
(new logs_o())->add('numberplatescanners', 'global', 1, $user->id, 'ADD_NUMBER_PLATE_SCANNER', 'Successfully added a number plate scanner'); (new logs_o())->add('numberplatescanners', 'global', 1, $user->id, 'ADD_NUMBER_PLATE_SCANNER', 'Successfully added a number plate scanner');
// Return a success message // Return a success message
$response->success(['message' => 'Number plate scanner added']); $response->success([
'message' => 'Number plate scanner added',
'scanner' => $scanner->asArray(),
]);
} else { } else {
// Log the incident // Log the incident
(new logs_o())->add('numberplatescanners', 'global', 1, 0, 'ADD_NUMBER_PLATE_SCANNER', 'No user found, or invalid session'); (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'])) { if (!isset($data['notes'])) {
$response->error('Notes is required', 400); $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 // 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 // Log the incident
(new logs_o())->add('numberplatescanners', 'global', 1, $user->id, 'EDIT_NUMBER_PLATE_SCANNER', 'Successfully edited a number plate scanner'); (new logs_o())->add('numberplatescanners', 'global', 1, $user->id, 'EDIT_NUMBER_PLATE_SCANNER', 'Successfully edited a number plate scanner');
// Return a success message // Return a success message
$response->success(['message' => 'Number plate scanner edited']); $response->success([
'message' => 'Number plate scanner edited',
'scanner' => $scanner->asArray(),
]);
} else { } else {
// Log the incident // Log the incident
(new logs_o())->add('numberplatescanners', 'global', 1, 0, 'EDIT_NUMBER_PLATE_SCANNER', 'No user found, or invalid session'); (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 () { self::get('/department/numberplatescanners', function () {
// Require the user to be logged in // Require the user to be logged in
global $response; global $response;
@@ -152,12 +205,14 @@ class plateScannersRoute
'department_id' => (int)self::getParameter('id') 'department_id' => (int)self::getParameter('id')
], [ ], [
'id', 'id',
'lane_id',
'name', 'name',
'notes' 'notes'
]); ]);
// Parse the result // Parse the result
foreach ( $result as $key => $value ) { foreach ( $result as $key => $value ) {
$result[$key]['id'] = (int)$value['id']; $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 // Return the list of plate scanners
$response->success( $response->success(
@@ -176,4 +231,4 @@ class plateScannersRoute
] ]
); );
} }
} }
@@ -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,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'");
});
@@ -5,6 +5,8 @@ it('registers module-scoped edge gateway operator routes', function (): void {
expect($route)->not->toBeFalse(); expect($route)->not->toBeFalse();
expect($route)->toContain("'/modules/edge-gateways'"); 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/{id}'");
expect($route)->toContain("'/modules/edge-gateways/install-token'"); expect($route)->toContain("'/modules/edge-gateways/install-token'");
expect($route)->toContain("'/modules/edge-gateways/install-token/{id}/status'"); 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');
});