Skip to content

Commit 963bc74

Browse files
Merge pull request #48 from Mes-Open/develop
2 parents d1c7b51 + a55be19 commit 963bc74

133 files changed

Lines changed: 9298 additions & 1552 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222

2323
[![Discord](https://img.shields.io/badge/Discord-Join%20us-5865F2?logo=discord&logoColor=white)](https://discord.gg/fw3fG78pZj)
2424

25+
2526
</div>
2627

2728
---
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<?php
2+
3+
namespace App\Console\Commands;
4+
5+
use App\Models\MachineConnection;
6+
use App\Services\Machine\MachineSignalIngestor;
7+
use App\Services\Machine\Modbus\ModbusReader;
8+
use Illuminate\Console\Command;
9+
10+
/**
11+
* Long-running Modbus TCP poller. One process per machine_connection: connects,
12+
* then every poll_interval_ms reads all active tags and feeds each into the
13+
* MachineSignalIngestor. Reconnects with backoff on transport errors.
14+
*
15+
* php artisan modbus:poll --connection=3
16+
*/
17+
class ModbusPollCommand extends Command
18+
{
19+
protected $signature = 'modbus:poll {--connection= : machine_connection id} {--once : single poll cycle then exit (for testing)}';
20+
21+
protected $description = 'Poll a Modbus TCP device and ingest machine signals';
22+
23+
public function handle(MachineSignalIngestor $ingestor, \App\Services\Machine\RuntimeMonitor $runtime): int
24+
{
25+
$connection = MachineConnection::with(['modbusConnection', 'activeTags.workstation'])
26+
->find($this->option('connection'));
27+
28+
if (! $connection || ! $connection->modbusConnection) {
29+
$this->error('Modbus connection not found.');
30+
31+
return self::FAILURE;
32+
}
33+
34+
$modbus = $connection->modbusConnection;
35+
$tags = $connection->activeTags;
36+
$intervalUs = max(100, $modbus->poll_interval_ms) * 1000;
37+
$once = (bool) $this->option('once');
38+
39+
$this->info("Polling {$connection->name} ({$modbus->host}:{$modbus->port}), {$tags->count()} tags");
40+
41+
do {
42+
$reader = new ModbusReader($modbus);
43+
try {
44+
$reader->connect();
45+
$connection->markConnected();
46+
47+
do {
48+
$cycleStart = microtime(true);
49+
$runtime->heartbeat($connection->protocol, $connection->id);
50+
foreach ($tags as $tag) {
51+
try {
52+
$value = $reader->readTag($tag);
53+
$ingestor->ingest($tag, $value);
54+
$connection->increment('messages_received');
55+
} catch (\Throwable $e) {
56+
$this->warn("tag {$tag->name}: {$e->getMessage()}");
57+
}
58+
}
59+
60+
if ($once) {
61+
break 2;
62+
}
63+
64+
$elapsed = (int) ((microtime(true) - $cycleStart) * 1_000_000);
65+
usleep(max(0, $intervalUs - $elapsed));
66+
} while (true);
67+
} catch (\Throwable $e) {
68+
$connection->markError($e->getMessage());
69+
$this->error("connection error: {$e->getMessage()}");
70+
if ($once) {
71+
return self::FAILURE;
72+
}
73+
sleep(5); // backoff before reconnect
74+
} finally {
75+
$reader->close();
76+
}
77+
} while (! $once);
78+
79+
return self::SUCCESS;
80+
}
81+
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
<?php
2+
3+
namespace App\Console\Commands;
4+
5+
use Illuminate\Console\Command;
6+
7+
/**
8+
* Minimal Modbus TCP *server* that simulates a machine, so the real
9+
* modbus:poll daemon has something to read end-to-end without hardware.
10+
*
11+
* Register map (holding & input registers share the same backing store):
12+
* 0 → state (1=RUNNING, 2=IDLE, 3=FAULT)
13+
* 1 → good counter (increments each second while RUNNING)
14+
* 2 → reject counter
15+
* 3 → temperature (telemetry, °C ×10)
16+
*
17+
* The machine cycles RUNNING → IDLE → RUNNING → FAULT … on a timer.
18+
*
19+
* php artisan modbus:simulate --port=5020
20+
*/
21+
class ModbusSimulateCommand extends Command
22+
{
23+
protected $signature = 'modbus:simulate {--port=5020} {--host=0.0.0.0}';
24+
25+
protected $description = 'Run a simulated Modbus TCP machine (for testing the poller)';
26+
27+
/** @var array<int,int> holding/input registers */
28+
private array $reg = [0 => 1, 1 => 0, 2 => 0, 3 => 220];
29+
30+
public function handle(): int
31+
{
32+
$host = $this->option('host');
33+
$port = (int) $this->option('port');
34+
35+
$server = @stream_socket_server("tcp://{$host}:{$port}", $errno, $errstr);
36+
if (! $server) {
37+
$this->error("Cannot bind {$host}:{$port}: {$errstr}");
38+
39+
return self::FAILURE;
40+
}
41+
stream_set_blocking($server, false);
42+
$this->info("Modbus simulator listening on {$host}:{$port} (Ctrl+C to stop)");
43+
44+
$clients = [];
45+
$lastTick = microtime(true);
46+
// Scripted state timeline (state, seconds)
47+
$script = [[1, 15], [2, 5], [1, 20], [3, 8], [1, 15], [2, 4]];
48+
$phase = 0;
49+
$phaseElapsed = 0.0;
50+
51+
while (true) {
52+
$read = array_merge([$server], $clients);
53+
$write = $except = null;
54+
if (@stream_select($read, $write, $except, 1) === false) {
55+
break;
56+
}
57+
58+
foreach ($read as $sock) {
59+
if ($sock === $server) {
60+
$client = @stream_socket_accept($server, 0);
61+
if ($client) {
62+
stream_set_blocking($client, false);
63+
$clients[(int) $client] = $client;
64+
}
65+
66+
continue;
67+
}
68+
69+
$data = @fread($sock, 1024);
70+
if ($data === '' || $data === false) {
71+
fclose($sock);
72+
unset($clients[(int) $sock]);
73+
74+
continue;
75+
}
76+
$response = $this->handleRequest($data);
77+
if ($response !== null) {
78+
@fwrite($sock, $response);
79+
}
80+
}
81+
82+
// Advance simulation on ~1s ticks.
83+
$now = microtime(true);
84+
$dt = $now - $lastTick;
85+
if ($dt >= 1.0) {
86+
$lastTick = $now;
87+
$phaseElapsed += $dt;
88+
[$state, $duration] = $script[$phase];
89+
$this->reg[0] = $state;
90+
if ($state === 1) { // RUNNING
91+
$this->reg[1] += 2; // good parts
92+
if (random_int(1, 10) === 1) {
93+
$this->reg[2] += 1; // occasional reject
94+
}
95+
$this->reg[3] = 220 + random_int(-5, 15);
96+
}
97+
if ($phaseElapsed >= $duration) {
98+
$phaseElapsed = 0;
99+
$phase = ($phase + 1) % count($script);
100+
}
101+
$label = [1 => 'RUNNING', 2 => 'IDLE', 3 => 'FAULT'][$this->reg[0]];
102+
$this->line(sprintf('[sim] state=%s good=%d reject=%d temp=%.1f', $label, $this->reg[1], $this->reg[2], $this->reg[3] / 10));
103+
}
104+
}
105+
106+
return self::SUCCESS;
107+
}
108+
109+
/**
110+
* Parse a Modbus TCP frame and build a response. Supports FC 0x03/0x04
111+
* (read holding/input registers) and 0x01/0x02 (read coils/discretes).
112+
*/
113+
private function handleRequest(string $data): ?string
114+
{
115+
if (strlen($data) < 12) {
116+
return null;
117+
}
118+
$mbap = unpack('ntid/nprot/nlen/Cunit', substr($data, 0, 7));
119+
$fc = ord($data[7]);
120+
$body = substr($data, 8);
121+
$req = unpack('naddr/nqty', $body);
122+
$addr = $req['addr'];
123+
$qty = max(1, $req['qty']);
124+
125+
if (in_array($fc, [0x03, 0x04], true)) {
126+
$payload = '';
127+
for ($i = 0; $i < $qty; $i++) {
128+
$val = $this->reg[$addr + $i] ?? 0;
129+
$payload .= pack('n', $val & 0xFFFF);
130+
}
131+
$pdu = chr($fc).chr(strlen($payload)).$payload;
132+
} elseif (in_array($fc, [0x01, 0x02], true)) {
133+
$byteCount = intdiv($qty + 7, 8);
134+
$bits = 0;
135+
for ($i = 0; $i < $qty; $i++) {
136+
if (($this->reg[$addr + $i] ?? 0) > 0) {
137+
$bits |= (1 << $i);
138+
}
139+
}
140+
$pdu = chr($fc).chr($byteCount).pack('C', $bits & 0xFF);
141+
} else {
142+
// Illegal function exception
143+
$pdu = chr($fc | 0x80).chr(0x01);
144+
}
145+
146+
$len = strlen($pdu) + 1; // + unit id
147+
148+
return pack('nnn', $mbap['tid'], 0, $len).chr($mbap['unit']).$pdu;
149+
}
150+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
namespace App\Events\Machine;
4+
5+
use App\Models\Workstation;
6+
use App\Models\WorkstationState;
7+
use Illuminate\Foundation\Events\Dispatchable;
8+
use Illuminate\Queue\SerializesModels;
9+
10+
class WorkstationStateChanged
11+
{
12+
use Dispatchable;
13+
use SerializesModels;
14+
15+
public function __construct(
16+
public Workstation $workstation,
17+
public ?string $from,
18+
public string $to,
19+
public WorkstationState $state,
20+
) {}
21+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Api\V1;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Models\MachineConnection;
7+
use App\Models\MachineTag;
8+
use App\Services\Machine\MachineSignalIngestor;
9+
use App\Services\Machine\RuntimeMonitor;
10+
use Illuminate\Http\JsonResponse;
11+
use Illuminate\Http\Request;
12+
13+
/**
14+
* Bridge endpoint for external protocol gateways (OPC UA sidecar, custom REST
15+
* pushers). The gateway fetches its config (which nodes/tags to read), then
16+
* posts normalized readings back here; each reading flows through the same
17+
* MachineSignalIngestor as Modbus/MQTT. Posting also refreshes the runtime
18+
* heartbeat so the UI knows the gateway is alive.
19+
*/
20+
class MachineGatewayController extends Controller
21+
{
22+
public function __construct(
23+
private readonly MachineSignalIngestor $ingestor,
24+
private readonly RuntimeMonitor $runtime,
25+
) {}
26+
27+
/**
28+
* Config the gateway needs to connect and subscribe.
29+
*/
30+
public function config(MachineConnection $machineConnection): JsonResponse
31+
{
32+
$machineConnection->load(['opcuaConnection', 'activeTags']);
33+
$opc = $machineConnection->opcuaConnection;
34+
35+
return response()->json([
36+
'connection' => [
37+
'id' => $machineConnection->id,
38+
'name' => $machineConnection->name,
39+
'protocol' => $machineConnection->protocol,
40+
'is_active' => $machineConnection->is_active,
41+
],
42+
'opcua' => $opc ? [
43+
'endpoint_url' => $opc->endpoint_url,
44+
'security_policy' => $opc->security_policy,
45+
'security_mode' => $opc->security_mode,
46+
'auth_mode' => $opc->auth_mode,
47+
'username' => $opc->username,
48+
'publishing_interval_ms' => $opc->publishing_interval_ms,
49+
] : null,
50+
'tags' => $machineConnection->activeTags->map(fn (MachineTag $t) => [
51+
'id' => $t->id,
52+
'name' => $t->name,
53+
'node_id' => $t->address,
54+
'signal_type' => $t->signal_type,
55+
'data_type' => $t->data_type,
56+
])->values(),
57+
]);
58+
}
59+
60+
/**
61+
* Receive normalized readings from the gateway and ingest them.
62+
*
63+
* Body: { readings: [ { tag_id?, node_id?, value, ts? }, ... ] }
64+
*/
65+
public function ingest(Request $request, MachineConnection $machineConnection): JsonResponse
66+
{
67+
$data = $request->validate([
68+
'readings' => ['required', 'array', 'min:1'],
69+
'readings.*.tag_id' => ['nullable', 'integer'],
70+
'readings.*.node_id' => ['nullable', 'string'],
71+
'readings.*.value' => ['present'],
72+
'readings.*.ts' => ['nullable', 'date'],
73+
]);
74+
75+
// Heartbeat: a posting gateway is, by definition, alive.
76+
$this->runtime->heartbeat($machineConnection->protocol, $machineConnection->id);
77+
$machineConnection->markConnected();
78+
79+
$tags = $machineConnection->activeTags()->get()->keyBy('id');
80+
$byAddress = $machineConnection->activeTags()->get()->keyBy('address');
81+
82+
$accepted = 0;
83+
foreach ($data['readings'] as $r) {
84+
$tag = isset($r['tag_id']) ? $tags->get($r['tag_id']) : null;
85+
$tag ??= isset($r['node_id']) ? $byAddress->get($r['node_id']) : null;
86+
if (! $tag) {
87+
continue;
88+
}
89+
$at = isset($r['ts']) ? \Illuminate\Support\Carbon::parse($r['ts']) : null;
90+
$this->ingestor->ingest($tag, $r['value'], $at);
91+
$machineConnection->increment('messages_received');
92+
$accepted++;
93+
}
94+
95+
return response()->json(['accepted' => $accepted]);
96+
}
97+
98+
/**
99+
* Standalone heartbeat (gateway connected but no readings to push yet).
100+
*/
101+
public function heartbeat(MachineConnection $machineConnection): JsonResponse
102+
{
103+
$this->runtime->heartbeat($machineConnection->protocol, $machineConnection->id);
104+
105+
return response()->json(['ok' => true]);
106+
}
107+
}

backend/app/Http/Controllers/Api/V1/MaterialLotController.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,9 @@ public function forwardGenealogy(MaterialLot $materialLot): JsonResponse
112112
*/
113113
public function backwardGenealogy(MaterialLot $materialLot): JsonResponse
114114
{
115-
$sourceBatchId = data_get($materialLot->extra_data, 'source_batch_id');
115+
// Prefer the formal FK column; fall back to the legacy extra_data hint.
116+
$sourceBatchId = $materialLot->source_batch_id
117+
?? data_get($materialLot->extra_data, 'source_batch_id');
116118
$upstream = collect();
117119

118120
if ($sourceBatchId) {

0 commit comments

Comments
 (0)