Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
f30ce24
feat: production quantity correction — configurable edit policy (none…
jakubprzepiora-cyber May 26, 2026
3e50cf2
chore: bump develop version to v0.12.0
jakubprzepiora-cyber May 26, 2026
1472f9d
fix(schedule): update planned_start_at/planned_end_at on drag & drop …
jakubprzepiora-cyber May 27, 2026
f6b3a7a
feat(schedule): stack overlapping WOs in lanes on hourly Gantt view i…
jakubprzepiora-cyber May 27, 2026
3e6cd59
docs: add YouTube demo video thumbnail to README
jakubprzepiora-cyber May 27, 2026
45fdf46
docs: remove YouTube video from README
jakubprzepiora-cyber May 27, 2026
6d1a793
feat: add Import button + example CSV download on Materials, Product …
jakubprzepiora-cyber May 27, 2026
2bed0f0
feat: add settings import (JSON upload) with security whitelist
jakubprzepiora-cyber May 27, 2026
0b3fe1b
fix(security): ownership check on production corrections, harden sett…
jakubprzepiora-cyber May 27, 2026
ad2b0a8
fix(security): restrict CORS defaults — empty origins (block all), GE…
jakubprzepiora-cyber May 27, 2026
970d350
feat: full config export/import — lines, products, templates, materia…
jakubprzepiora-cyber May 27, 2026
e8f6316
fix: config import uses upsert instead of truncate to preserve FK rel…
jakubprzepiora-cyber May 27, 2026
877df35
fix: config import — use savepoints for PostgreSQL error recovery, ad…
jakubprzepiora-cyber May 27, 2026
1e7c841
feat(alerts): show ALL open issues (not just blocking), add real-time…
jakubprzepiora-cyber May 27, 2026
c8d2368
fix: reported issue with packing
JanKolo04 May 27, 2026
3cfcf1d
feat(schedule): show maintenance events on planner (weekly, hourly vi…
jakubprzepiora-cyber May 28, 2026
77a7d58
feat(maintenance): add scheduled_end_at — maintenance events have sta…
jakubprzepiora-cyber May 28, 2026
891a349
fix(schedule): increase maintenance event block size on hourly Gantt …
jakubprzepiora-cyber May 28, 2026
c69ebc5
fix(schedule): pass maintenanceEvents to hourly partial (was missing …
jakubprzepiora-cyber May 28, 2026
275d520
fix(schedule): maintenance blocks top-aligned and more visible on hou…
jakubprzepiora-cyber May 28, 2026
5d17ece
fix(schedule): correct maintenance block position — diffInMinutes arg…
jakubprzepiora-cyber May 28, 2026
938596c
feat(schedule): show upcoming recurring maintenance on planner from s…
jakubprzepiora-cyber May 28, 2026
1600120
feat(schedule): generate recurring maintenance blocks for entire visi…
jakubprzepiora-cyber May 28, 2026
ed8370b
feat: maintenance reminder popup with sound for all users (supervisor…
jakubprzepiora-cyber May 28, 2026
54f9880
feat(operator): add real-time polling for workstation queue and produ…
jakubprzepiora-cyber May 28, 2026
6f05671
feat(auth): Two-Factor Authentication with TOTP and recovery codes (#41)
jakubprzepiora-cyber May 28, 2026
9d345a1
fix: security review
JanKolo04 May 28, 2026
f15172f
feat(production): workstation routing — restrict operators to their o…
jakubprzepiora-cyber May 29, 2026
125a5e1
feat(i18n): add Turkish (Türkçe) as a third UI language
jakubprzepiora-cyber May 29, 2026
e52bf98
Merge pull request #44 from Mes-Open/feature/turkish-locale
jakub-przepiora May 29, 2026
f73a6f8
feat(traceability): material genealogy — lot-link, console, serial units
jakubprzepiora-cyber May 29, 2026
f529df9
Merge feature/traceability: material genealogy (lot-link, console, se…
jakubprzepiora-cyber May 29, 2026
863ca79
feat(connectivity): Modbus TCP + protocol-agnostic machine signal pip…
jakubprzepiora-cyber May 29, 2026
e2c7d5b
Merge feature/machine-connectivity: Modbus TCP + machine signal pipeline
jakubprzepiora-cyber May 29, 2026
69c3658
feat(connectivity): OPC UA gateway + runtime health awareness
jakubprzepiora-cyber May 29, 2026
e8e6ea6
Merge feature/opcua-runtime: OPC UA gateway + runtime health awareness
jakubprzepiora-cyber May 29, 2026
ae2fbb2
docs: machine connectivity architecture + intentionally-deferred rati…
jakubprzepiora-cyber May 29, 2026
91f3977
Merge branch 'develop' into fix/22-print-labels
JanKolo04 May 29, 2026
d275524
fix(i18n): restore Polish packaging/label translations dropped in mer…
JanKolo04 May 29, 2026
f106097
Merge pull request #47 from Mes-Open/fix/22-print-labels
jakub-przepiora May 29, 2026
a55be19
Merge remote-tracking branch 'origin/main' into chore/merge-main-into…
jakubprzepiora-cyber May 30, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

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


</div>

---
Expand Down
81 changes: 81 additions & 0 deletions backend/app/Console/Commands/ModbusPollCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
<?php

namespace App\Console\Commands;

use App\Models\MachineConnection;
use App\Services\Machine\MachineSignalIngestor;
use App\Services\Machine\Modbus\ModbusReader;
use Illuminate\Console\Command;

/**
* Long-running Modbus TCP poller. One process per machine_connection: connects,
* then every poll_interval_ms reads all active tags and feeds each into the
* MachineSignalIngestor. Reconnects with backoff on transport errors.
*
* php artisan modbus:poll --connection=3
*/
class ModbusPollCommand extends Command
{
protected $signature = 'modbus:poll {--connection= : machine_connection id} {--once : single poll cycle then exit (for testing)}';

protected $description = 'Poll a Modbus TCP device and ingest machine signals';

public function handle(MachineSignalIngestor $ingestor, \App\Services\Machine\RuntimeMonitor $runtime): int
{
$connection = MachineConnection::with(['modbusConnection', 'activeTags.workstation'])
->find($this->option('connection'));

if (! $connection || ! $connection->modbusConnection) {
$this->error('Modbus connection not found.');

return self::FAILURE;
}

$modbus = $connection->modbusConnection;
$tags = $connection->activeTags;
$intervalUs = max(100, $modbus->poll_interval_ms) * 1000;
$once = (bool) $this->option('once');

$this->info("Polling {$connection->name} ({$modbus->host}:{$modbus->port}), {$tags->count()} tags");

do {
$reader = new ModbusReader($modbus);
try {
$reader->connect();
$connection->markConnected();

do {
$cycleStart = microtime(true);
$runtime->heartbeat($connection->protocol, $connection->id);
foreach ($tags as $tag) {
try {
$value = $reader->readTag($tag);
$ingestor->ingest($tag, $value);
$connection->increment('messages_received');
} catch (\Throwable $e) {
$this->warn("tag {$tag->name}: {$e->getMessage()}");
}
}

if ($once) {
break 2;
}

$elapsed = (int) ((microtime(true) - $cycleStart) * 1_000_000);
usleep(max(0, $intervalUs - $elapsed));
} while (true);
} catch (\Throwable $e) {
$connection->markError($e->getMessage());
$this->error("connection error: {$e->getMessage()}");
if ($once) {
return self::FAILURE;
}
sleep(5); // backoff before reconnect
} finally {
$reader->close();
}
} while (! $once);

return self::SUCCESS;
}
}
150 changes: 150 additions & 0 deletions backend/app/Console/Commands/ModbusSimulateCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;

/**
* Minimal Modbus TCP *server* that simulates a machine, so the real
* modbus:poll daemon has something to read end-to-end without hardware.
*
* Register map (holding & input registers share the same backing store):
* 0 → state (1=RUNNING, 2=IDLE, 3=FAULT)
* 1 → good counter (increments each second while RUNNING)
* 2 → reject counter
* 3 → temperature (telemetry, °C ×10)
*
* The machine cycles RUNNING → IDLE → RUNNING → FAULT … on a timer.
*
* php artisan modbus:simulate --port=5020
*/
class ModbusSimulateCommand extends Command
{
protected $signature = 'modbus:simulate {--port=5020} {--host=0.0.0.0}';

protected $description = 'Run a simulated Modbus TCP machine (for testing the poller)';

/** @var array<int,int> holding/input registers */
private array $reg = [0 => 1, 1 => 0, 2 => 0, 3 => 220];

public function handle(): int
{
$host = $this->option('host');
$port = (int) $this->option('port');

$server = @stream_socket_server("tcp://{$host}:{$port}", $errno, $errstr);
if (! $server) {
$this->error("Cannot bind {$host}:{$port}: {$errstr}");

return self::FAILURE;
}
stream_set_blocking($server, false);
$this->info("Modbus simulator listening on {$host}:{$port} (Ctrl+C to stop)");

$clients = [];
$lastTick = microtime(true);
// Scripted state timeline (state, seconds)
$script = [[1, 15], [2, 5], [1, 20], [3, 8], [1, 15], [2, 4]];
$phase = 0;
$phaseElapsed = 0.0;

while (true) {
$read = array_merge([$server], $clients);
$write = $except = null;
if (@stream_select($read, $write, $except, 1) === false) {
break;
}

foreach ($read as $sock) {
if ($sock === $server) {
$client = @stream_socket_accept($server, 0);
if ($client) {
stream_set_blocking($client, false);
$clients[(int) $client] = $client;
}

continue;
}

$data = @fread($sock, 1024);
if ($data === '' || $data === false) {
fclose($sock);
unset($clients[(int) $sock]);

continue;
}
$response = $this->handleRequest($data);
if ($response !== null) {
@fwrite($sock, $response);
}
}

// Advance simulation on ~1s ticks.
$now = microtime(true);
$dt = $now - $lastTick;
if ($dt >= 1.0) {
$lastTick = $now;
$phaseElapsed += $dt;
[$state, $duration] = $script[$phase];
$this->reg[0] = $state;
if ($state === 1) { // RUNNING
$this->reg[1] += 2; // good parts
if (random_int(1, 10) === 1) {
$this->reg[2] += 1; // occasional reject
}
$this->reg[3] = 220 + random_int(-5, 15);
}
if ($phaseElapsed >= $duration) {
$phaseElapsed = 0;
$phase = ($phase + 1) % count($script);
}
$label = [1 => 'RUNNING', 2 => 'IDLE', 3 => 'FAULT'][$this->reg[0]];
$this->line(sprintf('[sim] state=%s good=%d reject=%d temp=%.1f', $label, $this->reg[1], $this->reg[2], $this->reg[3] / 10));
}
}

return self::SUCCESS;
}

/**
* Parse a Modbus TCP frame and build a response. Supports FC 0x03/0x04
* (read holding/input registers) and 0x01/0x02 (read coils/discretes).
*/
private function handleRequest(string $data): ?string
{
if (strlen($data) < 12) {
return null;
}
$mbap = unpack('ntid/nprot/nlen/Cunit', substr($data, 0, 7));
$fc = ord($data[7]);
$body = substr($data, 8);
$req = unpack('naddr/nqty', $body);
$addr = $req['addr'];
$qty = max(1, $req['qty']);

if (in_array($fc, [0x03, 0x04], true)) {
$payload = '';
for ($i = 0; $i < $qty; $i++) {
$val = $this->reg[$addr + $i] ?? 0;
$payload .= pack('n', $val & 0xFFFF);
}
$pdu = chr($fc).chr(strlen($payload)).$payload;
} elseif (in_array($fc, [0x01, 0x02], true)) {
$byteCount = intdiv($qty + 7, 8);
$bits = 0;
for ($i = 0; $i < $qty; $i++) {
if (($this->reg[$addr + $i] ?? 0) > 0) {
$bits |= (1 << $i);
}
}
$pdu = chr($fc).chr($byteCount).pack('C', $bits & 0xFF);
} else {
// Illegal function exception
$pdu = chr($fc | 0x80).chr(0x01);
}

$len = strlen($pdu) + 1; // + unit id

return pack('nnn', $mbap['tid'], 0, $len).chr($mbap['unit']).$pdu;
}
}
21 changes: 21 additions & 0 deletions backend/app/Events/Machine/WorkstationStateChanged.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

namespace App\Events\Machine;

use App\Models\Workstation;
use App\Models\WorkstationState;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

class WorkstationStateChanged
{
use Dispatchable;
use SerializesModels;

public function __construct(
public Workstation $workstation,
public ?string $from,
public string $to,
public WorkstationState $state,
) {}
}
107 changes: 107 additions & 0 deletions backend/app/Http/Controllers/Api/V1/MachineGatewayController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
<?php

namespace App\Http\Controllers\Api\V1;

use App\Http\Controllers\Controller;
use App\Models\MachineConnection;
use App\Models\MachineTag;
use App\Services\Machine\MachineSignalIngestor;
use App\Services\Machine\RuntimeMonitor;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

/**
* Bridge endpoint for external protocol gateways (OPC UA sidecar, custom REST
* pushers). The gateway fetches its config (which nodes/tags to read), then
* posts normalized readings back here; each reading flows through the same
* MachineSignalIngestor as Modbus/MQTT. Posting also refreshes the runtime
* heartbeat so the UI knows the gateway is alive.
*/
class MachineGatewayController extends Controller
{
public function __construct(
private readonly MachineSignalIngestor $ingestor,
private readonly RuntimeMonitor $runtime,
) {}

/**
* Config the gateway needs to connect and subscribe.
*/
public function config(MachineConnection $machineConnection): JsonResponse
{
$machineConnection->load(['opcuaConnection', 'activeTags']);
$opc = $machineConnection->opcuaConnection;

return response()->json([
'connection' => [
'id' => $machineConnection->id,
'name' => $machineConnection->name,
'protocol' => $machineConnection->protocol,
'is_active' => $machineConnection->is_active,
],
'opcua' => $opc ? [
'endpoint_url' => $opc->endpoint_url,
'security_policy' => $opc->security_policy,
'security_mode' => $opc->security_mode,
'auth_mode' => $opc->auth_mode,
'username' => $opc->username,
'publishing_interval_ms' => $opc->publishing_interval_ms,
] : null,
'tags' => $machineConnection->activeTags->map(fn (MachineTag $t) => [
'id' => $t->id,
'name' => $t->name,
'node_id' => $t->address,
'signal_type' => $t->signal_type,
'data_type' => $t->data_type,
])->values(),
]);
}

/**
* Receive normalized readings from the gateway and ingest them.
*
* Body: { readings: [ { tag_id?, node_id?, value, ts? }, ... ] }
*/
public function ingest(Request $request, MachineConnection $machineConnection): JsonResponse
{
$data = $request->validate([
'readings' => ['required', 'array', 'min:1'],
'readings.*.tag_id' => ['nullable', 'integer'],
'readings.*.node_id' => ['nullable', 'string'],
'readings.*.value' => ['present'],
'readings.*.ts' => ['nullable', 'date'],
]);

// Heartbeat: a posting gateway is, by definition, alive.
$this->runtime->heartbeat($machineConnection->protocol, $machineConnection->id);
$machineConnection->markConnected();

$tags = $machineConnection->activeTags()->get()->keyBy('id');
$byAddress = $machineConnection->activeTags()->get()->keyBy('address');

$accepted = 0;
foreach ($data['readings'] as $r) {
$tag = isset($r['tag_id']) ? $tags->get($r['tag_id']) : null;
$tag ??= isset($r['node_id']) ? $byAddress->get($r['node_id']) : null;
if (! $tag) {
continue;
}
$at = isset($r['ts']) ? \Illuminate\Support\Carbon::parse($r['ts']) : null;
$this->ingestor->ingest($tag, $r['value'], $at);
$machineConnection->increment('messages_received');
$accepted++;
}

return response()->json(['accepted' => $accepted]);
}

/**
* Standalone heartbeat (gateway connected but no readings to push yet).
*/
public function heartbeat(MachineConnection $machineConnection): JsonResponse
{
$this->runtime->heartbeat($machineConnection->protocol, $machineConnection->id);

return response()->json(['ok' => true]);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,9 @@ public function forwardGenealogy(MaterialLot $materialLot): JsonResponse
*/
public function backwardGenealogy(MaterialLot $materialLot): JsonResponse
{
$sourceBatchId = data_get($materialLot->extra_data, 'source_batch_id');
// Prefer the formal FK column; fall back to the legacy extra_data hint.
$sourceBatchId = $materialLot->source_batch_id
?? data_get($materialLot->extra_data, 'source_batch_id');
$upstream = collect();

if ($sourceBatchId) {
Expand Down
Loading
Loading