mirror of
https://github.com/jixishi/SerialTerminalForWindowsTerminal.git
synced 2026-06-15 16:42:46 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
+3
-1
@@ -182,7 +182,9 @@ func (a *App) Close() {
|
|||||||
close(a.done)
|
close(a.done)
|
||||||
a.forward.Close()
|
a.forward.Close()
|
||||||
a.plugins.Close()
|
a.plugins.Close()
|
||||||
a.sess.Close()
|
if a.sess != nil {
|
||||||
|
a.sess.Close()
|
||||||
|
}
|
||||||
if a.logFile != nil {
|
if a.logFile != nil {
|
||||||
_ = a.logFile.Close()
|
_ = a.logFile.Close()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,6 +57,7 @@ func (m *Manager) Load(path string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
state := lua.NewState()
|
state := lua.NewState()
|
||||||
|
registerHelpers(state)
|
||||||
if err = state.DoFile(abs); err != nil {
|
if err = state.DoFile(abs); err != nil {
|
||||||
state.Close()
|
state.Close()
|
||||||
return "", err
|
return "", err
|
||||||
@@ -110,19 +111,20 @@ func (m *Manager) Disable(name string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reload reloads a plugin's file.
|
// Reload reloads a plugin's file atomically.
|
||||||
func (m *Manager) Reload(name string) error {
|
func (m *Manager) Reload(name string) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
p, ok := m.plugins[name]
|
p, ok := m.plugins[name]
|
||||||
m.mu.Unlock()
|
|
||||||
if !ok {
|
if !ok {
|
||||||
|
m.mu.Unlock()
|
||||||
return fmt.Errorf("plugin %s not found", name)
|
return fmt.Errorf("plugin %s not found", name)
|
||||||
}
|
}
|
||||||
|
|
||||||
path := p.Path
|
path := p.Path
|
||||||
if err := m.Unload(name); err != nil {
|
p.L.Close()
|
||||||
return err
|
delete(m.plugins, name)
|
||||||
}
|
m.mu.Unlock()
|
||||||
|
|
||||||
_, err := m.Load(path)
|
_, err := m.Load(path)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 <slave> <addr> <count|value>"
|
||||||
|
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
|
||||||
Reference in New Issue
Block a user