596 lines
16 KiB
Go
596 lines
16 KiB
Go
package mock
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"fmt"
|
|
"log/slog"
|
|
"math"
|
|
"sync"
|
|
"time"
|
|
|
|
goplc "bjhardman.cn/bjhardman/goplc"
|
|
)
|
|
|
|
// MockPLC implements goplc.Client with in-memory storage and auto-reply.
|
|
type MockPLC struct {
|
|
mu sync.RWMutex
|
|
boolStore map[string]bool // address string → bool value
|
|
byteArrStore map[string][]byte // address string → byte array value
|
|
connected bool
|
|
|
|
// auto-reply rules: trigger address string → autoReply config
|
|
autoReplyRules map[string]signalAutoReply
|
|
|
|
// MeasureNG controls whether inspection returns NG instead of OK (default=false)
|
|
MeasureNG bool
|
|
}
|
|
|
|
type signalAutoReply struct {
|
|
replyAddr string // reply address to set true after delay
|
|
delay time.Duration // delay before setting reply
|
|
deviceDoneAddr string // optional: device-done signal address
|
|
deviceDoneDelay time.Duration // delay before setting device-done
|
|
}
|
|
|
|
// NewMockPLC creates a MockPLC with default auto-reply rules.
|
|
func NewMockPLC() *MockPLC {
|
|
m := &MockPLC{
|
|
boolStore: make(map[string]bool),
|
|
byteArrStore: make(map[string][]byte),
|
|
connected: true,
|
|
autoReplyRules: make(map[string]signalAutoReply),
|
|
}
|
|
m.initAutoReplyRules()
|
|
// 清洗线就绪信号默认为 true(流水线始终就绪)
|
|
m.boolStore["M1146.1"] = true
|
|
// 废料台就绪信号默认为 true(Mock 环境下废料台始终就绪)
|
|
m.boolStore["M1150.1"] = true
|
|
return m
|
|
}
|
|
|
|
// initAutoReplyRules sets up auto-reply rules for all robot action signals.
|
|
// Signal addresses from signal.csv:
|
|
//
|
|
// Task signals (PC→PLC, trigger at M1xxx, reply at M1xxx+9):
|
|
// M1000 = FetchWorkpieceFromDock
|
|
// M1010 = PlaceWorkpieceToDock
|
|
// M1020 = FetchWorkpieceFromTempStation
|
|
// M1030 = PlaceWorkpieceToTempStation
|
|
// M1040 = PlaceWorkpieceToMachine
|
|
// M1050 = FetchWorkpieceFromMachine
|
|
// M1060 = ExchangeWorkpieceToMachine
|
|
// M1070 = PlaceWorkpieceToRollingLine
|
|
// M1080 = FetchWorkpieceFromRollingLine
|
|
// M1090 = PlaceWorkpieceToSamplingStation
|
|
// M1100 = FetchWorkpieceFromSamplingStation
|
|
// M1110 = FetchWorkpieceFromMarkingStation
|
|
// M1120 = MoveWorkpieceToMarkingStation
|
|
// M1130 = StartScanCode
|
|
// M1160 = PlaceWorkpieceToWasteStation
|
|
//
|
|
// Device done signals (PLC→PC, status signals):
|
|
// CNCDone1 = M1140.0, CNCDone2 = M1141.0, CNCDone3 = M1142.0, CNCDone4 = M1143.0
|
|
// WasherDone = M1144.0, DeburrDone = M1145.0, CleaningDone = M1146.0
|
|
// MeasureOK = M1148.0, MeasureNG = M1148.1
|
|
// SamplingOK = M1151.0, SamplingNG = M1151.1
|
|
// ScanCodeData = DB28.DBB2
|
|
func (m *MockPLC) initAutoReplyRules() {
|
|
actionDelay := 1000 * time.Millisecond
|
|
|
|
// Standard robot actions: trigger at M10xx, reply at M10xx+9
|
|
simpleActions := []struct {
|
|
trigger int
|
|
}{
|
|
{1000}, // FetchWorkpieceFromDock
|
|
{1010}, // PlaceWorkpieceToDock
|
|
{1020}, // FetchWorkpieceFromTempStation
|
|
{1030}, // PlaceWorkpieceToTempStation
|
|
{1050}, // FetchWorkpieceFromMachine
|
|
{1080}, // FetchWorkpieceFromRollingLine
|
|
{1100}, // FetchWorkpieceFromSamplingStation
|
|
{1110}, // FetchWorkpieceFromMarkingStation
|
|
{1120}, // MoveWorkpieceToMarkingStation
|
|
{1160}, // PlaceWorkpieceToWasteStation
|
|
}
|
|
for _, a := range simpleActions {
|
|
trigger := fmt.Sprintf("M%d", a.trigger)
|
|
reply := fmt.Sprintf("M%d", a.trigger+9)
|
|
m.autoReplyRules[trigger] = signalAutoReply{
|
|
replyAddr: reply,
|
|
delay: actionDelay,
|
|
}
|
|
}
|
|
|
|
// PlaceWorkpieceToMachine (M1040): reply + trigger device done based on machineId
|
|
m.autoReplyRules["M1040"] = signalAutoReply{
|
|
replyAddr: "M1049",
|
|
delay: actionDelay,
|
|
}
|
|
|
|
// ExchangeWorkpieceToMachine (M1060): reply + trigger CNC done based on machineId
|
|
m.autoReplyRules["M1060"] = signalAutoReply{
|
|
replyAddr: "M1069",
|
|
delay: actionDelay,
|
|
}
|
|
|
|
// PlaceWorkpieceToRollingLine (M1070): reply + trigger MeasureOK
|
|
m.autoReplyRules["M1070"] = signalAutoReply{
|
|
replyAddr: "M1079",
|
|
delay: actionDelay,
|
|
deviceDoneAddr: "M1148.0", // MeasureOK = M1148.0
|
|
deviceDoneDelay: 3 * time.Second,
|
|
}
|
|
|
|
// StartScanCode (M1130): reply + write scan code data
|
|
m.autoReplyRules["M1130"] = signalAutoReply{
|
|
replyAddr: "M1139",
|
|
delay: 1000 * time.Millisecond,
|
|
}
|
|
|
|
// PlaceWorkpieceToSamplingStation (M1090): reply + trigger SamplingOK
|
|
m.autoReplyRules["M1090"] = signalAutoReply{
|
|
replyAddr: "M1099",
|
|
delay: actionDelay,
|
|
deviceDoneAddr: "M1151.0", // SamplingOK (s7.Address.String() omits .0)
|
|
deviceDoneDelay: 2 * time.Second,
|
|
}
|
|
|
|
// WasherStartup (M1144.2): 启动清洗机,4s 后触发 WasherDone (M1144.0)
|
|
m.autoReplyRules["M1144.2"] = signalAutoReply{
|
|
replyAddr: "M1153.2",
|
|
delay: 100 * time.Millisecond,
|
|
deviceDoneAddr: "M1144.0", // WasherDone
|
|
deviceDoneDelay: 4 * time.Second,
|
|
}
|
|
}
|
|
|
|
// handleTriggerSignal processes a trigger signal write and schedules auto-reply.
|
|
func (m *MockPLC) handleTriggerSignal(addr string) {
|
|
rule, ok := m.autoReplyRules[addr]
|
|
if !ok {
|
|
slog.Warn("[MockPLC] handleTriggerSignal: no rule found", "addr", addr)
|
|
return
|
|
}
|
|
|
|
// 快照参数数据,防止并发写入覆盖(M1041/M1061 是共享参数区域)
|
|
var paramSnapshot []byte
|
|
if addr == "M1040" || addr == "M1060" {
|
|
paramAddr := "M1041"
|
|
if addr == "M1060" {
|
|
paramAddr = "M1061"
|
|
}
|
|
m.mu.RLock()
|
|
if data, exists := m.byteArrStore[paramAddr]; exists {
|
|
paramSnapshot = make([]byte, len(data))
|
|
copy(paramSnapshot, data)
|
|
}
|
|
m.mu.RUnlock()
|
|
}
|
|
|
|
slog.Info("[MockPLC] handleTriggerSignal: scheduling reply", "trigger", addr, "replyAddr", rule.replyAddr, "delay", rule.delay)
|
|
|
|
go func() {
|
|
time.Sleep(rule.delay)
|
|
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
// Set reply bit to true
|
|
m.boolStore[rule.replyAddr] = true
|
|
slog.Info("[MockPLC] reply set", "replyAddr", rule.replyAddr, "value", true)
|
|
|
|
// Auto-clear reply after 2 seconds so it doesn't stick around
|
|
go func(replyAddr string) {
|
|
time.Sleep(2 * time.Second)
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
delete(m.boolStore, replyAddr)
|
|
}(rule.replyAddr)
|
|
|
|
// Handle scan code: write mock scan code data
|
|
if addr == "M1130" {
|
|
m.writeScanCodeData()
|
|
}
|
|
|
|
// Handle PlaceWorkpieceToMachine (M1040): trigger device done based on machineId
|
|
if addr == "M1040" {
|
|
m.handlePlaceToMachineWithParams(paramSnapshot)
|
|
}
|
|
// Handle ExchangeWorkpieceToMachine (M1060): trigger CNC done based on machineId
|
|
if addr == "M1060" {
|
|
m.handleExchangeToMachineWithParams(paramSnapshot)
|
|
}
|
|
|
|
// Trigger device done signal if configured
|
|
if rule.deviceDoneAddr != "" {
|
|
doneAddr := rule.deviceDoneAddr
|
|
// M1070 (PlaceWorkpieceToRollingLine): respect MeasureNG flag
|
|
if addr == "M1070" && m.MeasureNG {
|
|
doneAddr = "M1148.1" // MeasureNG
|
|
}
|
|
doneDelay := rule.deviceDoneDelay
|
|
go func() {
|
|
time.Sleep(doneDelay)
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.boolStore[doneAddr] = true
|
|
// Auto-clear device done signal after 3 seconds
|
|
go func(da string) {
|
|
time.Sleep(3 * time.Second)
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
delete(m.boolStore, da)
|
|
}(doneAddr)
|
|
}()
|
|
}
|
|
}()
|
|
}
|
|
|
|
// handlePlaceToMachine reads the machineId from params written to M1041
|
|
// and triggers the corresponding device done signal.
|
|
func (m *MockPLC) handlePlaceToMachine() {
|
|
// Params are written via Write() to addr.Addr.Add(1,0).String() = M1041
|
|
// Format: byte0=workpieceType, byte1=machineId, byte2=location(slotNo)
|
|
data, ok := m.byteArrStore["M1041"]
|
|
if !ok || len(data) < 2 {
|
|
return
|
|
}
|
|
machineId := data[1]
|
|
slotNo := 1
|
|
if len(data) >= 3 {
|
|
slotNo = int(data[2])
|
|
}
|
|
m.triggerMachineDone(machineId, slotNo)
|
|
}
|
|
|
|
// handleExchangeToMachine reads the machineId from params written to M1061
|
|
func (m *MockPLC) handleExchangeToMachine() {
|
|
data, ok := m.byteArrStore["M1061"]
|
|
if !ok || len(data) < 2 {
|
|
return
|
|
}
|
|
machineId := data[1]
|
|
slotNo := 1
|
|
if len(data) >= 3 {
|
|
slotNo = int(data[2])
|
|
}
|
|
m.triggerMachineDone(machineId, slotNo)
|
|
}
|
|
|
|
// handlePlaceToMachineWithParams 使用快照参数触发设备完成信号(防止并发覆盖)
|
|
func (m *MockPLC) handlePlaceToMachineWithParams(data []byte) {
|
|
if len(data) < 2 {
|
|
return
|
|
}
|
|
machineId := data[1]
|
|
slotNo := 1
|
|
if len(data) >= 3 {
|
|
slotNo = int(data[2])
|
|
}
|
|
slog.Info("[MockPLC] handlePlaceToMachine", "machineId", machineId, "slotNo", slotNo)
|
|
m.triggerMachineDone(machineId, slotNo)
|
|
}
|
|
|
|
// handleExchangeToMachineWithParams 使用快照参数触发设备完成信号(防止并发覆盖)
|
|
func (m *MockPLC) handleExchangeToMachineWithParams(data []byte) {
|
|
if len(data) < 2 {
|
|
return
|
|
}
|
|
machineId := data[1]
|
|
slotNo := 1
|
|
if len(data) >= 3 {
|
|
slotNo = int(data[2])
|
|
}
|
|
slog.Info("[MockPLC] handleExchangeToMachine", "machineId", machineId, "slotNo", slotNo)
|
|
m.triggerMachineDone(machineId, slotNo)
|
|
}
|
|
|
|
// triggerMachineDone sets the device done signal for the given machineId after a delay.
|
|
func (m *MockPLC) triggerMachineDone(machineId byte, slotNo int) {
|
|
var doneAddr string
|
|
var delay time.Duration
|
|
|
|
switch machineId {
|
|
case 1:
|
|
doneAddr = "M1140.0" // CNCDone1 = M1140.0
|
|
delay = 10 * time.Second
|
|
case 2:
|
|
doneAddr = "M1141.0" // CNCDone2 = M1141.0
|
|
delay = 10 * time.Second
|
|
case 3:
|
|
doneAddr = "M1142.0" // CNCDone3 = M1142.0
|
|
delay = 10 * time.Second
|
|
case 4:
|
|
doneAddr = "M1143.0" // CNCDone4 = M1143.0
|
|
delay = 10 * time.Second
|
|
case 5:
|
|
// WASHER_HP: 不再在 PlaceWorkpieceToMachine 时触发 washerDone
|
|
// 改由 WasherStartup 信号触发(见 auto-reply 规则)
|
|
return
|
|
case 6:
|
|
doneAddr = "M1148.0" // MeasureOK (内窥镜检测)
|
|
delay = 3 * time.Second
|
|
case 7:
|
|
doneAddr = "M1145.0" // DeburrDone = M1145.0
|
|
delay = 2 * time.Second
|
|
case 8:
|
|
doneAddr = "M1146.0" // CleaningDone = M1146.0
|
|
delay = 3 * time.Second
|
|
case 12:
|
|
doneAddr = "M1151.0" // SamplingOK
|
|
delay = 3 * time.Second
|
|
default:
|
|
slog.Warn("[MockPLC] triggerMachineDone: unknown machineId", "machineId", machineId)
|
|
return
|
|
}
|
|
|
|
go func() {
|
|
time.Sleep(delay)
|
|
m.mu.Lock()
|
|
m.boolStore[doneAddr] = true
|
|
m.mu.Unlock()
|
|
|
|
// Auto-clear device done signal after 3 seconds
|
|
go func(da string) {
|
|
time.Sleep(3 * time.Second)
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
delete(m.boolStore, da)
|
|
}(doneAddr)
|
|
}()
|
|
}
|
|
|
|
// writeScanCodeData writes mock scan code data to ScanCodeData (DB28.DBB2).
|
|
// Caller must hold m.mu.Lock.
|
|
func (m *MockPLC) writeScanCodeData() {
|
|
code := time.Now().Format("M20060102150405")
|
|
|
|
data := make([]byte, 32)
|
|
copy(data, code)
|
|
|
|
m.byteArrStore["DB28.DBB2"] = data
|
|
}
|
|
|
|
// --- goplc.Client interface implementation ---
|
|
|
|
func (m *MockPLC) Connect() error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.connected = true
|
|
return nil
|
|
}
|
|
|
|
func (m *MockPLC) Close() error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
m.connected = false
|
|
return nil
|
|
}
|
|
|
|
func (m *MockPLC) IsConnected() bool {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
return m.connected
|
|
}
|
|
|
|
// Read reads size bytes from the given address.
|
|
func (m *MockPLC) Read(address string, size int) ([]byte, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
|
|
if data, ok := m.byteArrStore[address]; ok {
|
|
if len(data) >= size {
|
|
return data[:size], nil
|
|
}
|
|
result := make([]byte, size)
|
|
copy(result, data)
|
|
return result, nil
|
|
}
|
|
return make([]byte, size), nil
|
|
}
|
|
|
|
// ReadBool reads a boolean value from the given address.
|
|
func (m *MockPLC) ReadBool(address string) (bool, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
return m.boolStore[address], nil
|
|
}
|
|
|
|
// ReadBools reads multiple boolean values starting from the given address.
|
|
func (m *MockPLC) ReadBools(address string, size int) ([]bool, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
result := make([]bool, size)
|
|
for i := 0; i < size; i++ {
|
|
addr := fmt.Sprintf("%s.%d", address, i)
|
|
result[i] = m.boolStore[addr]
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// ReadByte reads a single byte from the given address.
|
|
func (m *MockPLC) ReadByte(address string) (byte, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
if data, ok := m.byteArrStore[address]; ok && len(data) > 0 {
|
|
return data[0], nil
|
|
}
|
|
return 0, nil
|
|
}
|
|
|
|
// ReadUint16 reads a 16-bit unsigned integer.
|
|
func (m *MockPLC) ReadUint16(address string) (uint16, error) {
|
|
b, err := m.Read(address, 2)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return binary.BigEndian.Uint16(b), nil
|
|
}
|
|
|
|
// ReadInt16 reads a 16-bit signed integer.
|
|
func (m *MockPLC) ReadInt16(address string) (int16, error) {
|
|
v, err := m.ReadUint16(address)
|
|
return int16(v), err
|
|
}
|
|
|
|
// ReadUint32 reads a 32-bit unsigned integer.
|
|
func (m *MockPLC) ReadUint32(address string) (uint32, error) {
|
|
b, err := m.Read(address, 4)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return binary.BigEndian.Uint32(b), nil
|
|
}
|
|
|
|
// ReadInt32 reads a 32-bit signed integer.
|
|
func (m *MockPLC) ReadInt32(address string) (int32, error) {
|
|
v, err := m.ReadUint32(address)
|
|
return int32(v), err
|
|
}
|
|
|
|
// ReadUint64 reads a 64-bit unsigned integer.
|
|
func (m *MockPLC) ReadUint64(address string) (uint64, error) {
|
|
b, err := m.Read(address, 8)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return binary.BigEndian.Uint64(b), nil
|
|
}
|
|
|
|
// ReadInt64 reads a 64-bit signed integer.
|
|
func (m *MockPLC) ReadInt64(address string) (int64, error) {
|
|
v, err := m.ReadUint64(address)
|
|
return int64(v), err
|
|
}
|
|
|
|
// ReadFloat32 reads a 32-bit float.
|
|
func (m *MockPLC) ReadFloat32(address string) (float32, error) {
|
|
b, err := m.Read(address, 4)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return math.Float32frombits(binary.BigEndian.Uint32(b)), nil
|
|
}
|
|
|
|
// ReadFloat64 reads a 64-bit float.
|
|
func (m *MockPLC) ReadFloat64(address string) (float64, error) {
|
|
b, err := m.Read(address, 8)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return math.Float64frombits(binary.BigEndian.Uint64(b)), nil
|
|
}
|
|
|
|
// Write writes byte data to the given address.
|
|
func (m *MockPLC) Write(address string, data []byte) error {
|
|
m.mu.Lock()
|
|
m.byteArrStore[address] = make([]byte, len(data))
|
|
copy(m.byteArrStore[address], data)
|
|
m.mu.Unlock()
|
|
return nil
|
|
}
|
|
|
|
// WriteBool writes a boolean value to the given address.
|
|
// If the value is true and the address matches an auto-reply rule, triggers the rule.
|
|
func (m *MockPLC) WriteBool(address string, value bool) error {
|
|
m.mu.Lock()
|
|
m.boolStore[address] = value
|
|
m.mu.Unlock()
|
|
|
|
if value {
|
|
m.mu.RLock()
|
|
_, isTrigger := m.autoReplyRules[address]
|
|
m.mu.RUnlock()
|
|
if isTrigger {
|
|
m.handleTriggerSignal(address)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// WriteBools writes multiple boolean values starting from the given address.
|
|
func (m *MockPLC) WriteBools(address string, values []bool) error {
|
|
for i, value := range values {
|
|
addr := fmt.Sprintf("%s.%d", address, i)
|
|
if err := m.WriteBool(addr, value); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// WriteByte writes a single byte to the given address.
|
|
// This is the key method: when a trigger signal is written (value=1),
|
|
// it schedules an auto-reply.
|
|
func (m *MockPLC) WriteByte(address string, value byte) error {
|
|
m.mu.Lock()
|
|
// Store as single-byte array
|
|
m.byteArrStore[address] = []byte{value}
|
|
m.mu.Unlock()
|
|
|
|
// Check if this is a trigger signal (value=1)
|
|
if value == 1 {
|
|
m.mu.RLock()
|
|
_, isTrigger := m.autoReplyRules[address]
|
|
m.mu.RUnlock()
|
|
slog.Info("[MockPLC] WriteByte trigger check", "address", address, "isTrigger", isTrigger)
|
|
if isTrigger {
|
|
m.handleTriggerSignal(address)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// WriteUint16 writes a 16-bit unsigned integer.
|
|
func (m *MockPLC) WriteUint16(address string, value uint16) error {
|
|
buf := make([]byte, 2)
|
|
binary.BigEndian.PutUint16(buf, value)
|
|
return m.Write(address, buf)
|
|
}
|
|
|
|
// WriteInt16 writes a 16-bit signed integer.
|
|
func (m *MockPLC) WriteInt16(address string, value int16) error {
|
|
return m.WriteUint16(address, uint16(value))
|
|
}
|
|
|
|
// WriteUint32 writes a 32-bit unsigned integer.
|
|
func (m *MockPLC) WriteUint32(address string, value uint32) error {
|
|
buf := make([]byte, 4)
|
|
binary.BigEndian.PutUint32(buf, value)
|
|
return m.Write(address, buf)
|
|
}
|
|
|
|
// WriteInt32 writes a 32-bit signed integer.
|
|
func (m *MockPLC) WriteInt32(address string, value int32) error {
|
|
return m.WriteUint32(address, uint32(value))
|
|
}
|
|
|
|
// WriteUint64 writes a 64-bit unsigned integer.
|
|
func (m *MockPLC) WriteUint64(address string, value uint64) error {
|
|
buf := make([]byte, 8)
|
|
binary.BigEndian.PutUint64(buf, value)
|
|
return m.Write(address, buf)
|
|
}
|
|
|
|
// WriteInt64 writes a 64-bit signed integer.
|
|
func (m *MockPLC) WriteInt64(address string, value int64) error {
|
|
return m.WriteUint64(address, uint64(value))
|
|
}
|
|
|
|
// WriteFloat32 writes a 32-bit float.
|
|
func (m *MockPLC) WriteFloat32(address string, value float32) error {
|
|
buf := make([]byte, 4)
|
|
binary.BigEndian.PutUint32(buf, math.Float32bits(value))
|
|
return m.Write(address, buf)
|
|
}
|
|
|
|
// WriteFloat64 writes a 64-bit float.
|
|
func (m *MockPLC) WriteFloat64(address string, value float64) error {
|
|
buf := make([]byte, 8)
|
|
binary.BigEndian.PutUint64(buf, math.Float64bits(value))
|
|
return m.Write(address, buf)
|
|
}
|
|
|
|
// Compile-time check that MockPLC implements goplc.Client.
|
|
var _ goplc.Client = (*MockPLC)(nil)
|