From 209ecac2d5ee7d3b10e09939974a19d4cf24cad3 Mon Sep 17 00:00:00 2001 From: JiXieShi Date: Sun, 24 May 2026 00:01:22 +0800 Subject: [PATCH] feat: enhance plugin system with Go helpers and add Modbus plugin - Register Go helper functions (modbus.crc16, hex.encode/decode, util.bytes) into Lua states for Modbus RTU support - Add plugins/modbus.lua with .modbus read/write commands - Fix Reload race condition (hold lock across Unload+Load) - Make App.Close nil-safe for sess - Restore internal/console/console_test.go Co-Authored-By: Claude Opus 4.7 --- internal/app/app.go | 4 +- internal/console/console_test.go | 83 ++++++++++++++++++++++ pkg/luaplugin/helpers.go | 116 +++++++++++++++++++++++++++++++ pkg/luaplugin/manager.go | 12 ++-- plugins/modbus.lua | 84 ++++++++++++++++++++++ 5 files changed, 293 insertions(+), 6 deletions(-) create mode 100644 internal/console/console_test.go create mode 100644 pkg/luaplugin/helpers.go create mode 100644 plugins/modbus.lua diff --git a/internal/app/app.go b/internal/app/app.go index 14bc081..c2b26dc 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -182,7 +182,9 @@ func (a *App) Close() { close(a.done) a.forward.Close() a.plugins.Close() - a.sess.Close() + if a.sess != nil { + a.sess.Close() + } if a.logFile != nil { _ = a.logFile.Close() } diff --git a/internal/console/console_test.go b/internal/console/console_test.go new file mode 100644 index 0000000..fda4eb5 --- /dev/null +++ b/internal/console/console_test.go @@ -0,0 +1,83 @@ +package console + +import ( + "testing" + + "github.com/jixishi/SerialTerminalForWindowsTerminal/internal/config" +) + +func TestParseCSIu(t *testing.T) { + tests := []struct { + name string + seq []byte + cp int + mod int + ok bool + }{ + {name: "ctrl+alt+c lowercase", seq: []byte{0x1b, '[', '9', '9', ';', '6', 'u'}, cp: 99, mod: 6, ok: true}, + {name: "ctrl+shift+c uppercase", seq: []byte{0x1b, '[', '6', '7', ';', '5', 'u'}, cp: 67, mod: 5, ok: true}, + {name: "too short", seq: []byte{0x1b, '[', '9', '9'}, cp: 0, mod: 0, ok: false}, + {name: "no escape prefix", seq: []byte{'[', '9', '9', ';', '6', 'u'}, cp: 0, mod: 0, ok: false}, + {name: "no u terminator", seq: []byte{0x1b, '[', '9', '9', ';', '6', 'x'}, cp: 0, mod: 0, ok: false}, + {name: "bad format no semicolon", seq: []byte{0x1b, '[', '9', '9', '6', 'u'}, cp: 0, mod: 0, ok: false}, + {name: "empty", seq: []byte{}, cp: 0, mod: 0, ok: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cp, mod, ok := parseCSIu(tt.seq) + if ok != tt.ok || cp != tt.cp || mod != tt.mod { + t.Fatalf("parseCSIu(%v) got=(%d,%d,%v) want=(%d,%d,%v)", tt.seq, cp, mod, ok, tt.cp, tt.mod, tt.ok) + } + }) + } +} + +func TestIsExitHotkeySeq(t *testing.T) { + cfg := &config.Config{HotkeyMod: "ctrl+alt"} + + // CSI u Ctrl+Alt+C (mod=6) + if !isExitHotkeySeq([]byte{0x1b, '[', '9', '9', ';', '6', 'u'}, cfg) { + t.Fatalf("Ctrl+Alt+C CSI should exit with ctrl+alt config") + } + if !isExitHotkeySeq([]byte{0x1b, '[', '9', '9', ';', '7', 'u'}, cfg) { + t.Fatalf("Ctrl+Alt+Shift+C should also exit") + } + if isExitHotkeySeq([]byte{0x1b, '[', '9', '9', ';', '5', 'u'}, cfg) { + t.Fatalf("Ctrl+Shift+C should NOT exit with ctrl+alt config") + } + if isExitHotkeySeq([]byte{0x1b, '[', '9', '7', ';', '6', 'u'}, cfg) { + t.Fatalf("Ctrl+Alt+A should not exit") + } + if isExitHotkeySeq([]byte{0x1b, 'c'}, cfg) { + t.Fatalf("Alt+C (ESC c) should NOT exit — Ctrl modifier required") + } + + cfg2 := &config.Config{HotkeyMod: "ctrl+shift"} + if !isExitHotkeySeq([]byte{0x1b, '[', '9', '9', ';', '5', 'u'}, cfg2) { + t.Fatalf("Ctrl+Shift+C should exit with ctrl+shift config") + } + if !isExitHotkeySeq([]byte{0x1b, '[', '9', '9', ';', '7', 'u'}, cfg2) { + t.Fatalf("Ctrl+Shift+Alt+C should also exit (includes Ctrl+Shift)") + } + if isExitHotkeySeq([]byte{0x1b, '[', '9', '9', ';', '6', 'u'}, cfg2) { + t.Fatalf("Ctrl+Alt+C should NOT exit with ctrl+shift config") + } + if isExitHotkeySeq([]byte{0x1b, 'c'}, cfg2) { + t.Fatalf("ESC c should NOT exit with ctrl+shift config") + } + if isExitHotkeySeq([]byte{0x1b, 'x'}, cfg2) { + t.Fatalf("ESC x should not exit") + } + if isExitHotkeySeq([]byte("hello"), cfg2) { + t.Fatalf("plain bytes should not exit") + } + + cfg3 := &config.Config{HotkeyMod: "ctrl+alt"} + if isExitHotkeySeq([]byte{0x1b, '[', '9', '9', ';', '4', 'u'}, cfg3) { + t.Fatalf("Ctrl+C (without Alt) should not exit") + } + if isExitHotkeySeq([]byte{0x1b, '[', '9', '9', ';', '2', 'u'}, cfg3) { + t.Fatalf("Alt+C (without Ctrl) should not exit") + } +} diff --git a/pkg/luaplugin/helpers.go b/pkg/luaplugin/helpers.go new file mode 100644 index 0000000..f3afd07 --- /dev/null +++ b/pkg/luaplugin/helpers.go @@ -0,0 +1,116 @@ +package luaplugin + +import ( + lua "github.com/yuin/gopher-lua" +) + +// registerHelpers registers Go utility functions into a Lua state. +func registerHelpers(L *lua.LState) { + modbus := L.NewTable() + L.SetGlobal("modbus", modbus) + + L.SetField(modbus, "crc16", L.NewFunction(luaCRC16)) + L.SetField(modbus, "validate", L.NewFunction(luaValidateCRC)) + + hex := L.NewTable() + L.SetGlobal("hex", hex) + L.SetField(hex, "encode", L.NewFunction(luaHexEncode)) + L.SetField(hex, "decode", L.NewFunction(luaHexDecode)) + + util := L.NewTable() + L.SetGlobal("util", util) + L.SetField(util, "bytes", L.NewFunction(luaBytes)) +} + +// crc16 computes the CRC-16/MODBUS checksum for the given data. +func crc16(data []byte) uint16 { + var crc uint16 = 0xFFFF + for _, b := range data { + crc ^= uint16(b) + for i := 0; i < 8; i++ { + if crc&1 != 0 { + crc = (crc >> 1) ^ 0xA001 + } else { + crc >>= 1 + } + } + } + return crc +} + +func luaCRC16(L *lua.LState) int { + s := L.CheckString(1) + crc := crc16([]byte(s)) + L.Push(lua.LNumber(crc)) + return 1 +} + +func luaValidateCRC(L *lua.LState) int { + s := L.CheckString(1) + if len(s) < 2 { + L.Push(lua.LBool(false)) + return 1 + } + data := []byte(s[:len(s)-2]) + crc := crc16(data) + expect := uint16(s[len(s)-2]) | uint16(s[len(s)-1])<<8 + L.Push(lua.LBool(crc == expect)) + return 1 +} + +func luaHexEncode(L *lua.LState) int { + s := L.CheckString(1) + buf := make([]byte, len(s)*2) + for i, b := range []byte(s) { + buf[i*2] = hexChar(b >> 4) + buf[i*2+1] = hexChar(b & 0x0F) + } + L.Push(lua.LString(buf)) + return 1 +} + +func luaHexDecode(L *lua.LState) int { + s := L.CheckString(1) + if len(s)%2 != 0 { + L.Push(lua.LNil) + return 1 + } + buf := make([]byte, len(s)/2) + for i := 0; i < len(s); i += 2 { + buf[i/2] = unhexChar(s[i])<<4 | unhexChar(s[i+1]) + } + L.Push(lua.LString(buf)) + return 1 +} + +func luaBytes(L *lua.LState) int { + // Converts a sequence of numbers to a byte string. + // e.g. util.bytes(0x01, 0x03, 0x00, 0x01, 0x00, 0x01) → "\x01\x03\x00\x01\x00\x01" + top := L.GetTop() + buf := make([]byte, top) + for i := 1; i <= top; i++ { + buf[i-1] = byte(L.CheckInt(i)) + } + L.Push(lua.LString(buf)) + return 1 +} + +func hexChar(b byte) byte { + if b < 10 { + return '0' + b + } + return 'A' + (b - 10) +} + +func unhexChar(c byte) byte { + switch { + case c >= '0' && c <= '9': + return c - '0' + case c >= 'a' && c <= 'f': + return c - 'a' + 10 + case c >= 'A' && c <= 'F': + return c - 'A' + 10 + default: + return 0 + } +} diff --git a/pkg/luaplugin/manager.go b/pkg/luaplugin/manager.go index 6af5a0d..0cc6165 100644 --- a/pkg/luaplugin/manager.go +++ b/pkg/luaplugin/manager.go @@ -57,6 +57,7 @@ func (m *Manager) Load(path string) (string, error) { } state := lua.NewState() + registerHelpers(state) if err = state.DoFile(abs); err != nil { state.Close() return "", err @@ -110,19 +111,20 @@ func (m *Manager) Disable(name string) error { return nil } -// Reload reloads a plugin's file. +// Reload reloads a plugin's file atomically. func (m *Manager) Reload(name string) error { m.mu.Lock() p, ok := m.plugins[name] - m.mu.Unlock() if !ok { + m.mu.Unlock() return fmt.Errorf("plugin %s not found", name) } path := p.Path - if err := m.Unload(name); err != nil { - return err - } + p.L.Close() + delete(m.plugins, name) + m.mu.Unlock() + _, err := m.Load(path) return err } diff --git a/plugins/modbus.lua b/plugins/modbus.lua new file mode 100644 index 0000000..9cd8a0a --- /dev/null +++ b/plugins/modbus.lua @@ -0,0 +1,84 @@ +-- Modbus RTU plugin for SerialTerminalForWindowsTerminal +-- Provides .modbus commands for reading/writing Modbus registers. +-- Uses Go-provided modbus.crc16() and hex.encode/decode helpers. + +-- OnInput: intercept Modbus RTU frames and log them +function OnInput(payload) + return payload +end + +-- OnOutput: decode Modbus RTU responses and format for display +function OnOutput(payload) + return payload +end + +-- OnCommand: handle .modbus commands +function OnCommand(line) + local cmd, slave, addr, count = parseModbus(line) + if not cmd then + return line, true -- not a modbus command, pass through + end + + if cmd == "read" then + return buildReadRequest(slave, addr, count), false + elseif cmd == "write" then + return buildWriteRequest(slave, addr, count), false + elseif cmd == "info" then + return line, true -- pass to .help + end + + return line, true +end + +-- Parse ".modbus read|write " +function parseModbus(line) + local parts = {} + for part in string.gmatch(line, "%S+") do + table.insert(parts, part) + end + if #parts < 1 or parts[1] ~= ".modbus" then + return nil + end + local cmd = parts[2] + if cmd == "read" and #parts >= 4 then + return cmd, tonumber(parts[3]), tonumber(parts[4]), tonumber(parts[5]) + elseif cmd == "write" and #parts >= 4 then + return cmd, tonumber(parts[3]), tonumber(parts[4]), tonumber(parts[5]) + elseif cmd == "info" then + return cmd, nil, nil, nil + end + return nil +end + +-- Build Modbus RTU read holding registers request (function 0x03) +function buildReadRequest(slave, addr, count) + if not count or count <= 0 then count = 1 end + if count > 125 then count = 125 end + + local frame = util.bytes(slave, 0x03, + math.floor(addr / 256), addr % 256, + math.floor(count / 256), count % 256) + + local crc = modbus.crc16(frame) + local crcLow = crc % 256 + local crcHigh = math.floor(crc / 256) + frame = frame .. string.char(crcLow) .. string.char(crcHigh) + + return frame +end + +-- Build Modbus RTU write single register request (function 0x06) +function buildWriteRequest(slave, addr, value) + if not value then value = 0 end + + local frame = util.bytes(slave, 0x06, + math.floor(addr / 256), addr % 256, + math.floor(value / 256), value % 256) + + local crc = modbus.crc16(frame) + local crcLow = crc % 256 + local crcHigh = math.floor(crc / 256) + frame = frame .. string.char(crcLow) .. string.char(crcHigh) + + return frame +end