plc-checkweigher 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +71 -0
  2. package/bin/cli.js +64 -0
  3. package/package.json +31 -0
  4. package/setup.sh +490 -0
package/README.md ADDED
@@ -0,0 +1,71 @@
1
+ # PLC Check-Weigher System
2
+
3
+ > **Hardware:** Raspberry Pi 4B · Mitsubishi PLC (Type3E) · Check-weigher line
4
+ > **Author:** Bibin VR
5
+
6
+ Real-time check-weigher data logger and report system for a Mitsubishi PLC production line. Monitors each item, logs weight and status, generates PDF batch reports, and instantly pushes them to a network PC via SMB.
7
+
8
+ ## Features
9
+
10
+ - Live PLC polling at 50 ms — captures every item (accept/reject/weight)
11
+ - PDF batch report generated automatically at end of each production run
12
+ - Instant PDF push to Windows/Mac shared folder via SMB (no software needed on the receiving PC)
13
+ - Live operations dashboard — weight gauge, batch stats, item feed in real time
14
+ - PDF report viewer with live auto-refresh (new reports appear without page reload)
15
+ - Systemd service — starts at boot, reconnects on PLC disconnect
16
+
17
+ ## Quick Start
18
+
19
+ ```bash
20
+ # Install dependencies
21
+ python3 -m venv /home/pi/plc_env
22
+ source /home/pi/plc_env/bin/activate
23
+ pip install pymcprotocol flask reportlab
24
+ sudo apt install samba-client
25
+
26
+ # Start watcher (or install as systemd service — see procedure.md)
27
+ cd /home/pi/plc_checkweigher
28
+ python3 plc_watcher.py
29
+
30
+ # Start web interface
31
+ python3 web/app.py
32
+ ```
33
+
34
+ Open `http://<pi-ip>:8080` for the report viewer, `/live` for the dashboard.
35
+
36
+ ## PDF Push Setup
37
+
38
+ See **[procedure.md](procedure.md)** for full setup instructions including:
39
+ - Windows local user creation (avoids Microsoft account credential issues)
40
+ - macOS File Sharing configuration
41
+ - Email delivery via Gmail
42
+ - HTTP push using `pdf_receiver.py`
43
+
44
+ ## Project Layout
45
+
46
+ ```
47
+ plc_checkweigher/
48
+ ├── plc_watcher.py # systemd entry — waits for PLC START
49
+ ├── plc_reader.py # per-item data collection + PDF trigger
50
+ ├── plc_report.py # PDF generation (ReportLab)
51
+ ├── pdf_push.py # instant PDF delivery to network PC
52
+ ├── pdf_receiver.py # optional HTTP receiver for target PC
53
+ ├── plc_watcher.service # systemd unit
54
+ ├── procedure.md # full setup & operating procedure
55
+ └── web/
56
+ ├── app.py # Flask server (port 8080)
57
+ └── templates/
58
+ ├── index.html # report list with live SSE refresh
59
+ └── live.html # live operations dashboard
60
+ ```
61
+
62
+ ## Configuration
63
+
64
+ Edit the top of each file:
65
+
66
+ | File | Key settings |
67
+ |---|---|
68
+ | `plc_reader.py` | `PLC_IP`, `PLC_PORT` |
69
+ | `plc_watcher.py` | `PLC_IP`, `PLC_PORT` |
70
+ | `pdf_push.py` | `SMB_HOST`, `SMB_SHARE`, `SMB_USERNAME`, `SMB_PASSWORD` |
71
+ | `web/app.py` | `REPORTS_DIR`, `PORT` |
package/bin/cli.js ADDED
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const { spawnSync } = require('child_process');
5
+ const { platform, arch } = require('os');
6
+ const path = require('path');
7
+ const fs = require('fs');
8
+
9
+ // ── Colour helpers ────────────────────────────────────────────────────────────
10
+ const B = '\x1b[1;34m'; // bold blue
11
+ const G = '\x1b[0;32m'; // green
12
+ const R = '\x1b[1;31m'; // red
13
+ const Y = '\x1b[1;33m'; // yellow
14
+ const NC = '\x1b[0m';
15
+
16
+ function die(msg) {
17
+ console.error(`\n${R}Error:${NC} ${msg}`);
18
+ process.exit(1);
19
+ }
20
+
21
+ // ── Platform guards ───────────────────────────────────────────────────────────
22
+ if (platform() !== 'linux') {
23
+ die('This installer only runs on Raspberry Pi (Linux). Got: ' + platform());
24
+ }
25
+ if (arch() !== 'arm64') {
26
+ die('Requires 64-bit ARM (arm64). Got: ' + arch() +
27
+ '\nMake sure you are running 64-bit Raspberry Pi OS.');
28
+ }
29
+
30
+ // ── Banner ────────────────────────────────────────────────────────────────────
31
+ console.log(`
32
+ ${B}╔════════════════════════════════════════════╗
33
+ ║ PLC Check-Weigher — Full Stack Installer ║
34
+ ╚════════════════════════════════════════════╝${NC}
35
+ `);
36
+
37
+ // ── Locate setup.sh (bundled inside this npm package) ────────────────────────
38
+ const setupScript = path.resolve(__dirname, '..', 'setup.sh');
39
+ if (!fs.existsSync(setupScript)) {
40
+ die('setup.sh not found inside the package — try: npm install -g plc-checkweigher@latest');
41
+ }
42
+
43
+ // ── Inform the user ───────────────────────────────────────────────────────────
44
+ console.log(`${Y}This will:${NC}`);
45
+ console.log(' 1. Install the PREEMPT_RT real-time kernel (reboots once)');
46
+ console.log(' 2. Install all Python dependencies');
47
+ console.log(' 3. Clone / update the plc-checkweigher repo');
48
+ console.log(' 4. Configure WiFi, SMB file sharing');
49
+ console.log(' 5. Install systemd services with RT scheduling priority');
50
+ console.log('');
51
+ console.log(`${Y}Sudo password required to make system-level changes.${NC}`);
52
+ console.log('');
53
+
54
+ // ── Run setup.sh via sudo ─────────────────────────────────────────────────────
55
+ // stdio:'inherit' keeps the terminal fully interactive:
56
+ // • sudo password prompt works
57
+ // • WiFi password prompt works
58
+ // • All colour output passes through
59
+ const result = spawnSync('sudo', ['bash', setupScript], {
60
+ stdio: 'inherit',
61
+ env: process.env,
62
+ });
63
+
64
+ process.exit(result.status ?? 0);
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "plc-checkweigher",
3
+ "version": "1.0.0",
4
+ "description": "One-command installer for the PLC Check-Weigher system on Raspberry Pi (PREEMPT_RT kernel, Python stack, WiFi, SMB, systemd RT services)",
5
+ "bin": {
6
+ "plc-checkweigher": "bin/cli.js"
7
+ },
8
+ "files": [
9
+ "bin/",
10
+ "setup.sh"
11
+ ],
12
+ "keywords": [
13
+ "plc",
14
+ "checkweigher",
15
+ "raspberry-pi",
16
+ "installer",
17
+ "preempt-rt",
18
+ "real-time",
19
+ "mitsubishi",
20
+ "iot"
21
+ ],
22
+ "author": "Bibin-VR",
23
+ "license": "MIT",
24
+ "engines": {
25
+ "node": ">=16"
26
+ },
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "https://github.com/Bibin-VR/plc-checkweigher.git"
30
+ }
31
+ }
package/setup.sh ADDED
@@ -0,0 +1,490 @@
1
+ #!/usr/bin/env bash
2
+ # =============================================================================
3
+ # PLC Check-Weigher — Full Stack Bootstrap Installer
4
+ # =============================================================================
5
+ #
6
+ # One-liner (run this on any fresh Raspberry Pi):
7
+ #
8
+ # sudo bash -c "$(curl -sSL https://raw.githubusercontent.com/Bibin-VR/plc-checkweigher/main/setup.sh)"
9
+ #
10
+ # What it does (automatically, in order):
11
+ # 1. Validates hardware / OS / arch
12
+ # 2. Installs the PREEMPT_RT kernel (reboots once if needed, then resumes)
13
+ # 3. Installs all system & Python dependencies
14
+ # 4. Clones / updates this repo
15
+ # 5. Configures WiFi (sai @samarth) with highest autoconnect priority
16
+ # 6. Enables network-online guarantee before services start
17
+ # 7. Creates /home/pi/reports directory
18
+ # 8. Installs both systemd services with SCHED_FIFO real-time scheduling
19
+ # 9. Starts & enables everything
20
+ # 10. Verifies the full stack
21
+ #
22
+ # Override defaults via env vars before the command:
23
+ # WIFI_PASS="secret" PI_USER="pi" sudo bash -c "$(curl -sSL ...)"
24
+ # =============================================================================
25
+
26
+ set -euo pipefail
27
+ IFS=$'\n\t'
28
+
29
+ # ── Configurable defaults ─────────────────────────────────────────────────────
30
+ PI_USER="${PI_USER:-pi}"
31
+ WIFI_SSID="${WIFI_SSID:-sai @samarth}"
32
+ WIFI_PASS="${WIFI_PASS:-}" # prompted if empty and not connected
33
+ SMB_HOST="${SMB_HOST:-192.168.0.140}"
34
+ SMB_SHARE="${SMB_SHARE:-Reports}"
35
+ SMB_USER="${SMB_USER:-plcreport}"
36
+ SMB_PASS="${SMB_PASS:-plcreport}"
37
+ REPO_URL="https://github.com/Bibin-VR/plc-checkweigher.git"
38
+ REPO_BRANCH="main"
39
+
40
+ # ── Derived paths ─────────────────────────────────────────────────────────────
41
+ HOME_DIR="/home/${PI_USER}"
42
+ INSTALL_DIR="${HOME_DIR}/plc_checkweigher"
43
+ VENV_DIR="${HOME_DIR}/plc_env"
44
+ REPORTS_DIR="${HOME_DIR}/reports"
45
+ BOOT_FW="/boot/firmware"
46
+ STATE_DIR="/var/lib/plc-setup"
47
+ SELF_COPY="${STATE_DIR}/setup.sh"
48
+ CONT_SVC="plc-setup-continue.service"
49
+
50
+ # ── RT kernel package ─────────────────────────────────────────────────────────
51
+ RT_PKG="linux-image-6.12.86+deb13-rt-arm64"
52
+ RT_HDR="linux-headers-6.12.86+deb13-rt-arm64"
53
+
54
+ # ── Python packages (pinned) ──────────────────────────────────────────────────
55
+ PY_PKGS=(
56
+ "Flask==3.1.3"
57
+ "pymcprotocol==0.3.0"
58
+ "reportlab==4.5.1"
59
+ "pillow==12.2.0"
60
+ "pyserial==3.5"
61
+ "pymodbus==2.5.3"
62
+ "websockets==16.0"
63
+ "scapy==2.7.0"
64
+ )
65
+
66
+ # ── Terminal colours ──────────────────────────────────────────────────────────
67
+ R='\033[1;31m'; G='\033[0;32m'; B='\033[1;34m'; Y='\033[1;33m'; NC='\033[0m'
68
+ banner() { echo -e "\n${B}══════════════════════════════════════════════${NC}"; \
69
+ echo -e "${B} $*${NC}"; \
70
+ echo -e "${B}══════════════════════════════════════════════${NC}"; }
71
+ step() { echo -e "\n${Y}[${1}]${NC} ${2}"; }
72
+ ok() { echo -e " ${G}✓${NC} ${1}"; }
73
+ warn() { echo -e " ${Y}!${NC} ${1}"; }
74
+ die() { echo -e "\n${R}FATAL:${NC} ${1}" >&2; exit 1; }
75
+
76
+ # ── Guard: must be root ───────────────────────────────────────────────────────
77
+ [[ "${EUID}" -eq 0 ]] || die "Run with sudo: sudo bash -c \"\$(curl -sSL ...)\" "
78
+
79
+ # ── Guard: must be aarch64 ────────────────────────────────────────────────────
80
+ ARCH="$(uname -m)"
81
+ [[ "${ARCH}" == "aarch64" ]] || die "This installer targets 64-bit Raspberry Pi (aarch64). Got: ${ARCH}"
82
+
83
+ # ── Guard: home dir must exist ────────────────────────────────────────────────
84
+ [[ -d "${HOME_DIR}" ]] || die "User home ${HOME_DIR} not found. Set PI_USER= before running."
85
+
86
+ mkdir -p "${STATE_DIR}"
87
+
88
+ # =============================================================================
89
+ # PHASE 1 — Real-Time Kernel (skipped when already running PREEMPT_RT)
90
+ # =============================================================================
91
+ is_rt_kernel() { grep -q "PREEMPT_RT" /proc/version 2>/dev/null; }
92
+
93
+ install_rt_kernel() {
94
+ banner "Phase 1 — Installing PREEMPT_RT Kernel"
95
+
96
+ step "1a" "Backing up current kernel image ..."
97
+ if [[ ! -f "${BOOT_FW}/kernel8-stock.img" ]]; then
98
+ cp "${BOOT_FW}/kernel8.img" "${BOOT_FW}/kernel8-stock.img"
99
+ ok "Backup → ${BOOT_FW}/kernel8-stock.img"
100
+ else
101
+ ok "Backup already exists — skipping"
102
+ fi
103
+
104
+ CHKSUM_BEFORE="$(md5sum "${BOOT_FW}/kernel8.img" | cut -d' ' -f1)"
105
+
106
+ step "1b" "Installing ${RT_PKG} ..."
107
+ DEBIAN_FRONTEND=noninteractive apt-get install -y "${RT_PKG}" "${RT_HDR}"
108
+ ok "RT kernel package installed"
109
+
110
+ # Detect whether post-install hook overwrote kernel8.img
111
+ CHKSUM_AFTER="$(md5sum "${BOOT_FW}/kernel8.img" | cut -d' ' -f1)"
112
+ if [[ "${CHKSUM_BEFORE}" != "${CHKSUM_AFTER}" ]]; then
113
+ # Hook replaced kernel8.img → move to kernel8-rt.img, restore stock
114
+ cp "${BOOT_FW}/kernel8.img" "${BOOT_FW}/kernel8-rt.img"
115
+ cp "${BOOT_FW}/kernel8-stock.img" "${BOOT_FW}/kernel8.img"
116
+ ok "RT kernel → kernel8-rt.img | stock kernel restored as default"
117
+ else
118
+ # Hook did NOT copy — do it manually
119
+ RT_VMLINUZ="$(ls /boot/vmlinuz-*rt-arm64 2>/dev/null | sort -V | tail -1)"
120
+ [[ -n "${RT_VMLINUZ}" ]] || die "Cannot find RT vmlinuz in /boot/"
121
+ if file "${RT_VMLINUZ}" | grep -q gzip; then
122
+ zcat "${RT_VMLINUZ}" > "${BOOT_FW}/kernel8-rt.img"
123
+ else
124
+ cp "${RT_VMLINUZ}" "${BOOT_FW}/kernel8-rt.img"
125
+ fi
126
+ ok "RT kernel manually copied → ${BOOT_FW}/kernel8-rt.img"
127
+ fi
128
+
129
+ # Copy RT initramfs if one was generated
130
+ RT_INITRD="$(ls /boot/initrd.img-*rt-arm64 2>/dev/null | sort -V | tail -1 || true)"
131
+ if [[ -n "${RT_INITRD}" ]]; then
132
+ cp "${RT_INITRD}" "${BOOT_FW}/initramfs8-rt"
133
+ ok "RT initramfs → ${BOOT_FW}/initramfs8-rt"
134
+ fi
135
+
136
+ step "1c" "Activating RT kernel in ${BOOT_FW}/config.txt ..."
137
+ # Remove any previous RT block we wrote
138
+ sed -i '/### PLC-RT-BLOCK-START ###/,/### PLC-RT-BLOCK-END ###/d' \
139
+ "${BOOT_FW}/config.txt"
140
+
141
+ cat >> "${BOOT_FW}/config.txt" << 'CFGEOF'
142
+
143
+ ### PLC-RT-BLOCK-START ###
144
+ # PREEMPT_RT kernel — installed by plc-checkweigher setup.sh
145
+ kernel=kernel8-rt.img
146
+ initramfs initramfs8-rt followkernel
147
+ ### PLC-RT-BLOCK-END ###
148
+ CFGEOF
149
+ ok "config.txt updated — system will boot RT kernel after reboot"
150
+
151
+ step "1d" "Saving installer for post-reboot continuation ..."
152
+ # Save this very script so the continuation service can re-run it
153
+ if [[ -f "${SELF_COPY}" && "${BASH_SOURCE[0]}" != "${SELF_COPY}" ]]; then
154
+ : # already saved by a previous run
155
+ else
156
+ # If running via bash -c "$(curl ...)", BASH_SOURCE[0] is empty
157
+ # In that case, re-download from GitHub so we have a saved copy
158
+ if [[ -f "${BASH_SOURCE[0]:-}" ]]; then
159
+ cp "${BASH_SOURCE[0]}" "${SELF_COPY}"
160
+ else
161
+ curl -sSL \
162
+ "https://raw.githubusercontent.com/Bibin-VR/plc-checkweigher/${REPO_BRANCH}/setup.sh" \
163
+ -o "${SELF_COPY}"
164
+ fi
165
+ fi
166
+ chmod +x "${SELF_COPY}"
167
+
168
+ # Persist any env overrides so they survive across reboot
169
+ cat > "${STATE_DIR}/env" << ENVEOF
170
+ PI_USER="${PI_USER}"
171
+ WIFI_SSID="${WIFI_SSID}"
172
+ WIFI_PASS="${WIFI_PASS}"
173
+ SMB_HOST="${SMB_HOST}"
174
+ SMB_SHARE="${SMB_SHARE}"
175
+ SMB_USER="${SMB_USER}"
176
+ SMB_PASS="${SMB_PASS}"
177
+ ENVEOF
178
+ chmod 600 "${STATE_DIR}/env"
179
+
180
+ step "1e" "Creating post-reboot continuation service ..."
181
+ cat > "/etc/systemd/system/${CONT_SVC}" << SVCEOF
182
+ [Unit]
183
+ Description=PLC Check-Weigher Setup Continuation (post-RT-kernel reboot)
184
+ After=network.target
185
+ ConditionPathExists=${STATE_DIR}/env
186
+
187
+ [Service]
188
+ Type=oneshot
189
+ EnvironmentFile=${STATE_DIR}/env
190
+ ExecStart=/usr/bin/bash ${SELF_COPY}
191
+ ExecStartPost=/bin/rm -f ${STATE_DIR}/env
192
+ StandardOutput=journal+console
193
+ StandardError=journal+console
194
+ RemainAfterExit=yes
195
+
196
+ [Install]
197
+ WantedBy=multi-user.target
198
+ SVCEOF
199
+
200
+ systemctl daemon-reload
201
+ systemctl enable "${CONT_SVC}"
202
+ ok "Continuation service enabled — will auto-run phase 2 after reboot"
203
+
204
+ echo ""
205
+ echo -e "${G}╔═══════════════════════════════════════════════════╗${NC}"
206
+ echo -e "${G}║ RT kernel ready. Rebooting in 5 seconds ... ║${NC}"
207
+ echo -e "${G}║ Phase 2 will complete automatically on boot. ║${NC}"
208
+ echo -e "${G}║ Watch progress: journalctl -u ${CONT_SVC} -f ║${NC}"
209
+ echo -e "${G}╚═══════════════════════════════════════════════════╝${NC}"
210
+ sleep 5
211
+ reboot
212
+ }
213
+
214
+ # =============================================================================
215
+ # PHASE 2 — Full Application Setup (runs on RT kernel)
216
+ # =============================================================================
217
+ setup_full() {
218
+ banner "Phase 2 — Full Application Setup (PREEMPT_RT kernel confirmed)"
219
+
220
+ # ── 2.1 Update package list & install system deps ────────────────────────
221
+ step "2.1" "Installing system packages ..."
222
+ DEBIAN_FRONTEND=noninteractive apt-get update -q
223
+ DEBIAN_FRONTEND=noninteractive apt-get install -y \
224
+ git \
225
+ python3-venv \
226
+ python3-pip \
227
+ python3-dev \
228
+ samba-client \
229
+ cifs-utils \
230
+ network-manager \
231
+ curl \
232
+ build-essential
233
+ ok "System packages installed"
234
+
235
+ # ── 2.2 Clone or update repo ─────────────────────────────────────────────
236
+ step "2.2" "Setting up repository at ${INSTALL_DIR} ..."
237
+ if [[ -d "${INSTALL_DIR}/.git" ]]; then
238
+ sudo -u "${PI_USER}" git -C "${INSTALL_DIR}" pull --ff-only origin "${REPO_BRANCH}" \
239
+ && ok "Repo updated" || warn "git pull failed — using existing files"
240
+ else
241
+ sudo -u "${PI_USER}" git clone --branch "${REPO_BRANCH}" \
242
+ "${REPO_URL}" "${INSTALL_DIR}"
243
+ ok "Repo cloned → ${INSTALL_DIR}"
244
+ fi
245
+
246
+ # ── 2.3 Python virtual environment ───────────────────────────────────────
247
+ step "2.3" "Setting up Python venv at ${VENV_DIR} ..."
248
+ if [[ ! -d "${VENV_DIR}" ]]; then
249
+ sudo -u "${PI_USER}" python3 -m venv "${VENV_DIR}"
250
+ ok "venv created"
251
+ else
252
+ ok "venv already exists"
253
+ fi
254
+
255
+ # Upgrade pip silently, then install pinned packages
256
+ sudo -u "${PI_USER}" "${VENV_DIR}/bin/pip" install --quiet --upgrade pip
257
+ sudo -u "${PI_USER}" "${VENV_DIR}/bin/pip" install --quiet "${PY_PKGS[@]}"
258
+ ok "Python packages installed: ${PY_PKGS[*]}"
259
+
260
+ # ── 2.4 Runtime directories ──────────────────────────────────────────────
261
+ step "2.4" "Creating runtime directories ..."
262
+ mkdir -p "${REPORTS_DIR}"
263
+ chown "${PI_USER}:${PI_USER}" "${REPORTS_DIR}"
264
+ ok "${REPORTS_DIR}"
265
+
266
+ # ── 2.5 WiFi — ensure saved profile with autoconnect ────────────────────
267
+ step "2.5" "Configuring WiFi (SSID: ${WIFI_SSID}) ..."
268
+ # Find any existing profile with this SSID
269
+ EXISTING_PROFILE="$(nmcli -t -f NAME,TYPE con show | \
270
+ awk -F: '$2=="802-11-wireless"{print $1}' | head -1 || true)"
271
+
272
+ if nmcli device status | awk '$2=="wifi" && $3=="connected"' | grep -q .; then
273
+ ok "WiFi already connected — skipping profile creation"
274
+ EXISTING_PROFILE="$(nmcli -t -f NAME,DEVICE con show --active | \
275
+ grep ":wlan0" | cut -d: -f1 || true)"
276
+ elif [[ -n "${EXISTING_PROFILE}" ]]; then
277
+ ok "WiFi profile '${EXISTING_PROFILE}' already saved in NetworkManager"
278
+ else
279
+ # Need password — use WIFI_PASS env var or prompt
280
+ if [[ -z "${WIFI_PASS}" ]]; then
281
+ if [[ -t 0 ]]; then
282
+ read -r -s -p " Enter WiFi password for '${WIFI_SSID}': " WIFI_PASS < /dev/tty
283
+ echo ""
284
+ else
285
+ warn "WiFi password not provided. Set WIFI_PASS= env var or configure manually."
286
+ warn "Skipping WiFi profile creation."
287
+ WIFI_PASS=""
288
+ fi
289
+ fi
290
+ if [[ -n "${WIFI_PASS}" ]]; then
291
+ nmcli connection add \
292
+ type wifi \
293
+ ifname wlan0 \
294
+ con-name "${WIFI_SSID}" \
295
+ ssid "${WIFI_SSID}" \
296
+ wifi-sec.key-mgmt wpa-psk \
297
+ wifi-sec.psk "${WIFI_PASS}" \
298
+ connection.autoconnect yes
299
+ ok "WiFi profile '${WIFI_SSID}' created"
300
+ EXISTING_PROFILE="${WIFI_SSID}"
301
+ fi
302
+ fi
303
+
304
+ # Set high autoconnect priority on whichever profile is active
305
+ if [[ -n "${EXISTING_PROFILE}" ]]; then
306
+ nmcli connection modify "${EXISTING_PROFILE}" \
307
+ connection.autoconnect yes \
308
+ connection.autoconnect-priority 200 2>/dev/null || true
309
+ ok "Autoconnect priority set to 200 for '${EXISTING_PROFILE}'"
310
+ fi
311
+
312
+ # ── 2.6 NetworkManager-wait-online (guarantees IP before services start) ─
313
+ step "2.6" "Enabling NetworkManager-wait-online ..."
314
+ mkdir -p /etc/systemd/system/NetworkManager-wait-online.service.d/
315
+ cat > /etc/systemd/system/NetworkManager-wait-online.service.d/timeout.conf << 'EOF'
316
+ [Service]
317
+ ExecStart=
318
+ ExecStart=/usr/lib/NetworkManager/nm-online -s -q --timeout=60
319
+ EOF
320
+ systemctl enable NetworkManager-wait-online.service 2>/dev/null || true
321
+ ok "NetworkManager-wait-online enabled (60 s timeout)"
322
+
323
+ # ── 2.7 systemd service: plc_watcher (RT) ───────────────────────────────
324
+ step "2.7" "Installing plc_watcher.service (SCHED_FIFO:50, IOClass=realtime) ..."
325
+ cat > /etc/systemd/system/plc_watcher.service << EOF
326
+ [Unit]
327
+ Description=PLC Check-Weigher Start Watcher
328
+ After=network-online.target time-sync.target
329
+ Wants=network-online.target
330
+
331
+ [Service]
332
+ Type=simple
333
+ User=${PI_USER}
334
+ WorkingDirectory=${INSTALL_DIR}
335
+ Environment=PYTHONUNBUFFERED=1
336
+ ExecStart=${VENV_DIR}/bin/python3 -u ${INSTALL_DIR}/plc_watcher.py
337
+ Restart=always
338
+ RestartSec=5
339
+ StandardOutput=journal
340
+ StandardError=journal
341
+
342
+ # ── Real-time scheduling ──────────────────────────────
343
+ # SCHED_FIFO priority 50 preempts all SCHED_OTHER tasks.
344
+ # CPUSchedulingResetOnFork=no (default) means plc_reader.py
345
+ # subprocess inherits this RT policy via Popen automatically.
346
+ CPUSchedulingPolicy=fifo
347
+ CPUSchedulingPriority=50
348
+ IOSchedulingClass=realtime
349
+ IOSchedulingPriority=0
350
+ # Pin to core 3 — isolated from general OS scheduling noise
351
+ CPUAffinity=3
352
+ Nice=-15
353
+
354
+ [Install]
355
+ WantedBy=multi-user.target
356
+ EOF
357
+
358
+ # Mirror the service file back into the repo so git tracks it
359
+ cp /etc/systemd/system/plc_watcher.service \
360
+ "${INSTALL_DIR}/plc_watcher.service"
361
+ chown "${PI_USER}:${PI_USER}" "${INSTALL_DIR}/plc_watcher.service"
362
+ ok "plc_watcher.service installed"
363
+
364
+ # ── 2.8 systemd service: plc_web ─────────────────────────────────────────
365
+ step "2.8" "Installing plc_web.service ..."
366
+ cat > /etc/systemd/system/plc_web.service << EOF
367
+ [Unit]
368
+ Description=PLC Check-Weigher Report Viewer
369
+ After=network-online.target plc_watcher.service
370
+ Wants=network-online.target
371
+ BindsTo=plc_watcher.service
372
+
373
+ [Service]
374
+ Type=simple
375
+ User=${PI_USER}
376
+ WorkingDirectory=${INSTALL_DIR}/web
377
+ Environment=PYTHONUNBUFFERED=1
378
+ ExecStart=${VENV_DIR}/bin/python3 -u ${INSTALL_DIR}/web/app.py
379
+ Restart=always
380
+ RestartSec=5
381
+ StandardOutput=journal
382
+ StandardError=journal
383
+ Nice=-10
384
+
385
+ [Install]
386
+ WantedBy=multi-user.target
387
+ EOF
388
+ ok "plc_web.service installed"
389
+
390
+ # ── 2.9 Enable + start services ──────────────────────────────────────────
391
+ step "2.9" "Enabling and starting services ..."
392
+ systemctl daemon-reload
393
+ systemctl enable plc_watcher.service plc_web.service
394
+ systemctl restart plc_watcher.service || true
395
+ sleep 2
396
+ systemctl restart plc_web.service || true
397
+ ok "Services enabled and started"
398
+
399
+ # ── 2.10 Disable continuation service (we're done) ──────────────────────
400
+ if systemctl is-enabled "${CONT_SVC}" &>/dev/null 2>&1; then
401
+ systemctl disable "${CONT_SVC}" 2>/dev/null || true
402
+ rm -f "/etc/systemd/system/${CONT_SVC}"
403
+ systemctl daemon-reload
404
+ ok "Continuation service cleaned up"
405
+ fi
406
+
407
+ # ── 2.11 SMB connectivity check ─────────────────────────────────────────
408
+ step "2.11" "Verifying SMB share at //${SMB_HOST}/${SMB_SHARE} ..."
409
+ if ping -c 2 -W 2 "${SMB_HOST}" &>/dev/null; then
410
+ ok "Host ${SMB_HOST} reachable"
411
+ if smbclient "//${SMB_HOST}/${SMB_SHARE}" \
412
+ -U "${SMB_USER}%${SMB_PASS}" -c "ls" &>/dev/null 2>&1; then
413
+ ok "SMB auth success — PDF push is ready"
414
+ else
415
+ warn "SMB host reachable but auth failed — ensure the share is set up on the host"
416
+ fi
417
+ else
418
+ warn "SMB host ${SMB_HOST} not reachable — will retry at runtime"
419
+ fi
420
+
421
+ # ── Final verification report ─────────────────────────────────────────────
422
+ banner "Setup Complete — Verification"
423
+
424
+ echo ""
425
+ echo " Kernel:"
426
+ uname -r | sed 's/^/ /'
427
+ grep -o 'PREEMPT_RT' /proc/version 2>/dev/null \
428
+ && echo -e " ${G}✓ PREEMPT_RT confirmed${NC}" \
429
+ || echo -e " ${R}✗ PREEMPT_RT NOT detected${NC}"
430
+
431
+ echo ""
432
+ echo " Services:"
433
+ for svc in plc_watcher plc_web; do
434
+ STATE="$(systemctl is-active ${svc}.service 2>/dev/null || echo 'inactive')"
435
+ if [[ "${STATE}" == "active" ]]; then
436
+ echo -e " ${G}✓${NC} ${svc} (${STATE})"
437
+ else
438
+ echo -e " ${R}✗${NC} ${svc} (${STATE})"
439
+ fi
440
+ done
441
+
442
+ echo ""
443
+ echo " RT scheduling (plc_watcher):"
444
+ PID="$(systemctl show -p MainPID --value plc_watcher.service 2>/dev/null || echo '')"
445
+ if [[ -n "${PID}" && "${PID}" != "0" ]]; then
446
+ chrt -p "${PID}" 2>/dev/null | sed 's/^/ /' || true
447
+ ionice -p "${PID}" 2>/dev/null | sed 's/^/ /' || true
448
+ taskset -cp "${PID}" 2>/dev/null | sed 's/^/ /' || true
449
+ else
450
+ echo " (PID not yet available)"
451
+ fi
452
+
453
+ echo ""
454
+ echo " WiFi:"
455
+ nmcli -t -f NAME,DEVICE,STATE con show --active | grep wifi | sed 's/^/ /' \
456
+ || echo " (no active WiFi)"
457
+
458
+ echo ""
459
+ echo " Reports directory: ${REPORTS_DIR}"
460
+ echo " Web dashboard: http://$(hostname -I | awk '{print $1}'):8080"
461
+ echo ""
462
+ echo " Useful commands:"
463
+ echo " journalctl -u plc_watcher -f # live watcher log"
464
+ echo " journalctl -u plc_web -f # live web log"
465
+ echo " sudo chrt -p \$(systemctl show -p MainPID --value plc_watcher)"
466
+ echo ""
467
+ }
468
+
469
+ # =============================================================================
470
+ # Entry point — decide which phase to run
471
+ # =============================================================================
472
+ main() {
473
+ banner "PLC Check-Weigher Installer"
474
+ echo " Repo : ${REPO_URL}"
475
+ echo " User : ${PI_USER}"
476
+ echo " Kernel: $(uname -r)"
477
+ echo ""
478
+
479
+ if is_rt_kernel; then
480
+ ok "PREEMPT_RT kernel already running — proceeding directly to phase 2"
481
+ setup_full
482
+ else
483
+ warn "Standard kernel detected ($(uname -r))"
484
+ warn "Installing RT kernel — system will reboot once, then auto-complete setup."
485
+ install_rt_kernel
486
+ # install_rt_kernel reboots — execution never reaches here
487
+ fi
488
+ }
489
+
490
+ main "$@"