這份文件把這套 CODESYS PLC + Go 橋接器 + Svelte HMI 堆疊在多台機器上累積的
踩雷與模式集中起來。main 是機種無關的乾淨範本:只有基礎建設與這些經驗,
沒有任何機種業務邏輯(飛剪、橫切機等各自活在自己的分支)。要長出一台新機器,
從這裡分支,照 §9 加東西。
更長的部署 / WSL 流程筆記見 .claude/skills/deploy-strategy、wsl-deploy-strategy。
作者私有的機器規格與內部部署狀態(特定 IP / 硬體)未隨公開範本附帶。
CODESYS RT task plc_bridge (Go) Svelte HMI (Chromium kiosk)
│ │ │
│ writes PlcData (seqlock) │ /ws WebSocket push (50–100ms) │
▼ │ ◄───────────────────────────────┤ commands (CmdMsg)
/dev/shm/plc_data ──────────────►│ Modbus TCP 127.0.0.1:5020→SCADA │
/dev/shm/plc_cmd ◄──────────────┤ HTTP/HTTPS :8443 → embedded UI │
│ role-password auth + TLS │
- PLC ↔ Go 只靠兩塊 shm(
plc_data唯讀、plc_cmd命令)。其餘(Modbus、JSON、 WebSocket、auth)全在 Go,PLC 完全不知情。加新外部介面永不動 PLC。 - 前端
npm run build產出frontend/dist/,被frontend/embed.go用//go:embed吞進 Go binary。HMI 不獨立部署,跟著 binary 走。
PlcData / PlcCommand 的 byte layout 必須 Go 與 IEC 兩邊逐位元組一致:
| Go 端 | IEC 端 |
|---|---|
| backend/internal/shm/layout.go | codesys_export/Device/Application/DUT/ShmBridge/*.st |
鐵律:
- 改 layout 一定兩邊同步 bump
Version。版本對不上,Go reader 直接拒絕掛載 segment(不會悄悄讀垃圾)。新欄位附加在尾端、舊 offset 不動。 - seqlock 防撕裂讀:writer 寫入期間把
Header.Seq設成奇數,reader 看到奇數 或前後 Seq 不一致就重讀。見 writer.go / reader.go 與seqlock_test.go。 vet.go用編譯期 assert 釘住 struct 大小——欄位順序/型別一漂移就編不過。- mmap 是 Linux-only,但整包要能在 Windows dev box 編譯:OS 相關碼分到
mapping_linux.go(//go:build linux,真 mmap)與mapping_other.go(//go:build !linux,stub 回 error)。這個 stub 就是讓go build ./...在 Windows / macOS 上過的關鍵——少了它,每個平台都編不動。
backend/cmd/plc_bridge import 了 frontend(embed 包),而 //go:embed dist
要求 build 當下 frontend/dist/ 必須存在。所以:
永遠先
npm run build(產生 dist),再go build。
make build 已照此順序。CI 也踩過這個雷:原本把 backend / frontend 拆成兩個
平行 job,backend job 沒先 build 前端,go vet ./backend/... 一碰 plc_bridge 就因
pattern dist: no matching files found 而紅。本範本的 CI(.github/workflows/ci.yml)
改成單一 job:先 node build 前端 → 再 go vet/go test/linux 交叉編譯。
backend/internal/auth — 三級權限,刻意極簡(無使用者帳號, 每級一個 bcrypt hash + 記憶體內 session token):
- operator 操作員 → tuner 調機 → vendor 廠商(後者涵蓋前者)。
- hash 從環境變數來:
PLC_BRIDGE_PASSWORD_HASH(vendor)、_TUNER_HASH、_OPERATOR_HASH。用plc_bridge -gen-hash(從 stdin 讀密碼、印 bcrypt hash)產生, 寫進 service env file——明文永不進 args / shell history / git。 - 沒設任何 hash = 套用範本內建預設密碼
111111(vendor):auth 仍啟用,唯讀 儀表板開放、操作機台需登入;登入頁會直接顯示這組預設密碼並附變更步驟,頂部列 也會掛一條提醒。/api/auth/status以usingDefault/defaultPassword標示。 正式部署務必用plc_bridge -gen-hash設PLC_BRIDGE_PASSWORD_HASH覆蓋(預設常數 在 backend/cmd/plc_bridge/main.go 的defaultVendorPassword)。 - operator 級是 opt-in:沒設 operator hash 時,operator 級路由維持開放,舊的 兩級部署行為不變。
- 寫入門禁走 WebSocket:控制指令(機台/軸/生產)經
/ws,由wsserver.Server的AuthorizeWrite(=authn.LoggedIn)把關——未登入連線仍收得到即時資料推播, 但送出的指令一律回unauthorizedack。前端 App.svelte 對應地讓儀表板常開、只有ControlPanel需登入。 - session cookie
HttpOnly+SameSite=Strict;只在 TLS 下標Secure。 - TLS 自動啟用:cwd 有
cert.pem/key.pem就走 HTTPS(systemd unit 與plc_bridge二進位都這樣偵測)。make cert產自簽憑證,make deploy-certs安裝。 - 路由門禁是單一 chokepoint:
auth.Wrap(mux, requiredRole)。範本的requiredRole回RoleNone(無受保護路由),加機種 API 時在那裡 gate 寫入(範例見 main.go 註解)。 前端藏分頁只是化妝,後端 Wrap 才是牆。
- 同源檢查:用
websocket.Upgrader{}(不覆寫CheckOrigin),gorilla 預設要求 Origin host == 請求 Host,擋掉跨站 WebSocket 劫持(別站網頁無法用操作員的環境 session 開我們的命令通道)。千萬別寫CheckOrigin: return true。 - 單一寫者:gorilla 禁止並發寫同一 conn。資料推送與命令 ack 全部走一個
goroutine(透過
ackschannel),ack 用 non-blocking send(塞爆就丟,不卡讀迴圈)。 - dev proxy 副作用:
vite.config.ts經 dev server proxy 到後端時,Origin 是localhost:5173、Host 是後端 → 必被同源檢查拒。所以 proxy 把/ws的 Origin header 拔掉,走「非瀏覽器客戶端」路徑放行。正式版前端由後端同源服務,不經此路。
frontend/src/lib/i18n — 不上 svelte-i18n:固定字串集的 kiosk HMI,零依賴、無 async 載入閃爍(FOUC)。
- 資料結構:
messages[locale][namespace][key],一個畫面一個 namespace 檔 (互不踩線、可並行編輯)。加 namespace 只動兩處:新建檔 + 在messages/index.ts的NS表加一行。 t('nav.logout', { role })走 dot-path 查詢 +{name}佔位替換。- 反應性陷阱:
t()直接讀$state的locale,所以在 template /$derived裡 呼叫即具反應性,切語言全畫面即時更新。但把t()拿去算 array/object 必須包在$derived(如 App.svelte 的roleLabel),否則只算一次、切語言不重譯。 - 範本只留
common/nav/login/demo四個機種無關 namespace。
更長版見 .claude/skills/deploy-strategy、wsl-deploy-strategy。重點雷:
- 兩條路:
make deploy(scp + ssh 到工業電腦)與make wsl-deploy(本機 WSL fallback,免 scp,repo 已在/mnt/c可見)。目標用.env(PLC_HOST/PLC_USER/WSL_DISTRO)覆寫,別 hardcode。 - shm 權限雷:CODESYS 在 WSL 以 root 跑,建出
/dev/shm/plc_{data,cmd}是root:root 0750,bridge 的 service user 讀不到 → 主控顯示「PLC 未連線」。systemd unit 用ExecStartPre=+...chmod o+r/o+rw在 bridge 開檔前放寬(每次啟動都重做, 因 CODESYS 每次 runtime 重啟都重建 segment)。make wsl-bootstrap一次建好 user / unit / sudoers allowlist。 - layout 改版後:
make shm-reset(或wsl-shm-reset)清掉/dev/shm/plc_*再 重啟,否則舊 segment 版號對不上掛不起來。
- Go:
go test ./backend/...——shm seqlock 契約、auth、wsserver 的applyCmd三條路徑(已知型別 ok / 未知型別錯 / nil-sink 不 panic)。make test/make vet原生在 dev box 跑(shm mmap 躲在//go:build linux後,host 也能編)。 - 前端 e2e scaffold:frontend/playwright.config.ts +
frontend/tests/smoke.spec.ts(mock/api、/ws,驗證 i18n 切換)。本地用npm run test:e2e(先npx playwright install chromium)。範本刻意不把 e2e 掛進 CI——等長出真畫面、有值得守的東西再加回去。
- 從
main分支(例如git switch -c my_machine)。 - shm:在
layout.go與DUT/ShmBridge/*.st兩邊加機種欄位,同步 bumpVersion,補vet.gosize assert 與 layout/seqlock 測試。 - 後端:在
backend/internal/加機種套件與 REST/WS 路由;在 plc_bridge 的requiredRole對寫入路由設權限級。 - 前端:在
messages/加 namespace、加畫面元件、在 App.svelte 接上。 - PLC:IEC 程式碼用 cds-text-sync 在
CODESYS
.project與codesys_export/*.st間雙向同步(Project_export.py出、Project_import.py回),就能用 VS Code 編、用 Git 版控(安裝與工作流見 README)。codesys_export/已備有通用 scaffold:StateMachine(抽象狀態機基底)、MC_BasicControl(PLCopen 動作包裝)、Functions/、GlobalVars。 - 機種專屬的廠商參考資料(xlsx / spec / demo)放本地
references/(已 gitignore), 別進範本。