Initial go port of epics
This commit is contained in:
@@ -0,0 +1,441 @@
|
||||
package proto
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// epicsEpochOffset is the number of Unix seconds between the Unix epoch
|
||||
// (1970-01-01 00:00:00 UTC) and the EPICS epoch (1990-01-01 00:00:00 UTC).
|
||||
const epicsEpochOffset = 631152000
|
||||
|
||||
// maxStringSize is the fixed size of a CA string value (MAX_STRING_SIZE).
|
||||
const maxStringSize = 40
|
||||
|
||||
// maxEnumStates is the maximum number of enum strings (MAX_ENUM_STATES).
|
||||
const maxEnumStates = 16
|
||||
|
||||
// maxEnumStringSize is the fixed size of each enum string (MAX_ENUM_STRING_SIZE).
|
||||
const maxEnumStringSize = 26
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Alarm types //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
// AlarmSeverity mirrors epicsAlarmSeverity.
|
||||
type AlarmSeverity int
|
||||
|
||||
const (
|
||||
SeverityNone AlarmSeverity = 0
|
||||
SeverityMinor AlarmSeverity = 1
|
||||
SeverityMajor AlarmSeverity = 2
|
||||
SeverityInvalid AlarmSeverity = 3
|
||||
)
|
||||
|
||||
func (s AlarmSeverity) String() string {
|
||||
switch s {
|
||||
case SeverityNone:
|
||||
return "NO_ALARM"
|
||||
case SeverityMinor:
|
||||
return "MINOR"
|
||||
case SeverityMajor:
|
||||
return "MAJOR"
|
||||
case SeverityInvalid:
|
||||
return "INVALID"
|
||||
default:
|
||||
return "UNKNOWN"
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// TimeValue — decoded DBR_TIME_* payload //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
// TimeValue is the decoded form of any DBR_TIME_* payload.
|
||||
// Exactly one of the value fields is populated based on the DBR type.
|
||||
type TimeValue struct {
|
||||
Timestamp time.Time
|
||||
Status int16
|
||||
Severity AlarmSeverity
|
||||
// Value fields — only one is set.
|
||||
Double float64
|
||||
Float float32
|
||||
Long int32
|
||||
Short int16
|
||||
Enum uint16
|
||||
Char uint8
|
||||
Str string
|
||||
// Waveform values (when element count > 1).
|
||||
Doubles []float64
|
||||
}
|
||||
|
||||
// decodeTimestamp converts EPICS secPastEpoch+nsec to a Go time.Time.
|
||||
func decodeTimestamp(secPastEpoch, nsec uint32) time.Time {
|
||||
return time.Unix(int64(secPastEpoch)+epicsEpochOffset, int64(nsec)).UTC()
|
||||
}
|
||||
|
||||
// caString extracts a null-terminated string from a fixed-size byte slice.
|
||||
func caString(b []byte) string {
|
||||
idx := strings.IndexByte(string(b), 0)
|
||||
if idx < 0 {
|
||||
return string(b)
|
||||
}
|
||||
return string(b[:idx])
|
||||
}
|
||||
|
||||
// DBR_TIME_* wire layouts (all big-endian):
|
||||
//
|
||||
// Common header (12 bytes):
|
||||
// [0:2] int16 status
|
||||
// [2:4] int16 severity
|
||||
// [4:8] uint32 secPastEpoch
|
||||
// [8:12] uint32 nsec
|
||||
//
|
||||
// DBR_TIME_DOUBLE (type 25): 4-byte RISC pad at [12:16], float64 at [16:24]. Total=24.
|
||||
// DBR_TIME_FLOAT (type 20): float32 at [12:16]. Total=16.
|
||||
// DBR_TIME_LONG (type 22): int32 at [12:16]. Total=16.
|
||||
// DBR_TIME_SHORT (type 19): int16 at [12:14], 2-byte pad [14:16]. Total=16.
|
||||
// DBR_TIME_ENUM (type 23): uint16 at [12:14], 2-byte pad [14:16]. Total=16.
|
||||
// DBR_TIME_CHAR (type 24): uint8 at [12:13], 3-byte pad [13:16]. Total=16.
|
||||
// DBR_TIME_STRING (type 21): [40]byte at [12:52]. Total=52.
|
||||
|
||||
// DecodeTimeValue decodes a DBR_TIME_* payload.
|
||||
// dbrType is one of the DBRTime* constants; payload is the full message payload
|
||||
// (may include multiple elements for waveforms; count is the element count).
|
||||
func DecodeTimeValue(dbrType uint16, count uint32, payload []byte) (TimeValue, bool) {
|
||||
if len(payload) < 12 {
|
||||
return TimeValue{}, false
|
||||
}
|
||||
|
||||
status := int16(binary.BigEndian.Uint16(payload[0:]))
|
||||
severity := AlarmSeverity(binary.BigEndian.Uint16(payload[2:]))
|
||||
sec := binary.BigEndian.Uint32(payload[4:])
|
||||
nsec := binary.BigEndian.Uint32(payload[8:])
|
||||
|
||||
tv := TimeValue{
|
||||
Timestamp: decodeTimestamp(sec, nsec),
|
||||
Status: status,
|
||||
Severity: severity,
|
||||
}
|
||||
|
||||
switch dbrType {
|
||||
case DBRTimeDouble:
|
||||
// 4-byte RISC pad at [12:16], value at [16:24]
|
||||
if count > 1 {
|
||||
if len(payload) < 16+int(count)*8 {
|
||||
return TimeValue{}, false
|
||||
}
|
||||
vals := make([]float64, count)
|
||||
for i := range vals {
|
||||
bits := binary.BigEndian.Uint64(payload[16+i*8:])
|
||||
vals[i] = math.Float64frombits(bits)
|
||||
}
|
||||
tv.Doubles = vals
|
||||
tv.Double = vals[0]
|
||||
} else {
|
||||
if len(payload) < 24 {
|
||||
return TimeValue{}, false
|
||||
}
|
||||
bits := binary.BigEndian.Uint64(payload[16:])
|
||||
tv.Double = math.Float64frombits(bits)
|
||||
}
|
||||
|
||||
case DBRTimeFloat:
|
||||
if len(payload) < 16 {
|
||||
return TimeValue{}, false
|
||||
}
|
||||
bits := binary.BigEndian.Uint32(payload[12:])
|
||||
tv.Float = math.Float32frombits(bits)
|
||||
tv.Double = float64(tv.Float)
|
||||
|
||||
case DBRTimeLong:
|
||||
if len(payload) < 16 {
|
||||
return TimeValue{}, false
|
||||
}
|
||||
tv.Long = int32(binary.BigEndian.Uint32(payload[12:]))
|
||||
tv.Double = float64(tv.Long)
|
||||
|
||||
case DBRTimeShort:
|
||||
if len(payload) < 16 {
|
||||
return TimeValue{}, false
|
||||
}
|
||||
tv.Short = int16(binary.BigEndian.Uint16(payload[12:]))
|
||||
tv.Double = float64(tv.Short)
|
||||
|
||||
case DBRTimeEnum:
|
||||
if len(payload) < 16 {
|
||||
return TimeValue{}, false
|
||||
}
|
||||
tv.Enum = binary.BigEndian.Uint16(payload[12:])
|
||||
tv.Double = float64(tv.Enum)
|
||||
|
||||
case DBRTimeChar:
|
||||
if len(payload) < 16 {
|
||||
return TimeValue{}, false
|
||||
}
|
||||
tv.Char = payload[12]
|
||||
tv.Double = float64(tv.Char)
|
||||
|
||||
case DBRTimeString:
|
||||
if len(payload) < 12+maxStringSize {
|
||||
return TimeValue{}, false
|
||||
}
|
||||
tv.Str = caString(payload[12 : 12+maxStringSize])
|
||||
tv.Double = 0
|
||||
|
||||
default:
|
||||
return TimeValue{}, false
|
||||
}
|
||||
|
||||
return tv, true
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// CtrlDouble — decoded DBR_CTRL_DOUBLE payload (type 34) //
|
||||
// -------------------------------------------------------------------------- //
|
||||
//
|
||||
// Wire layout (88 bytes total, all big-endian):
|
||||
//
|
||||
// [0:2] int16 status
|
||||
// [2:4] int16 severity
|
||||
// [4:6] int16 precision
|
||||
// [6:8] uint16 RISC_pad0
|
||||
// [8:16] [8]byte units
|
||||
// [16:24] float64 upper_disp_limit
|
||||
// [24:32] float64 lower_disp_limit
|
||||
// [32:40] float64 upper_alarm_limit
|
||||
// [40:48] float64 upper_warning_limit
|
||||
// [48:56] float64 lower_warning_limit
|
||||
// [56:64] float64 lower_alarm_limit
|
||||
// [64:72] float64 upper_ctrl_limit
|
||||
// [72:80] float64 lower_ctrl_limit
|
||||
// [80:88] float64 value
|
||||
|
||||
// CtrlDouble is the decoded form of a DBR_CTRL_DOUBLE payload.
|
||||
type CtrlDouble struct {
|
||||
Status int16
|
||||
Severity AlarmSeverity
|
||||
Precision int16
|
||||
Units string
|
||||
UpperDispLimit float64
|
||||
LowerDispLimit float64
|
||||
UpperAlarmLimit float64
|
||||
UpperWarnLimit float64
|
||||
LowerWarnLimit float64
|
||||
LowerAlarmLimit float64
|
||||
UpperCtrlLimit float64
|
||||
LowerCtrlLimit float64
|
||||
Value float64
|
||||
}
|
||||
|
||||
// DecodeCtrlDouble decodes a DBR_CTRL_DOUBLE payload (minimum 88 bytes).
|
||||
func DecodeCtrlDouble(p []byte) (CtrlDouble, bool) {
|
||||
if len(p) < 88 {
|
||||
return CtrlDouble{}, false
|
||||
}
|
||||
f64 := func(off int) float64 {
|
||||
return math.Float64frombits(binary.BigEndian.Uint64(p[off:]))
|
||||
}
|
||||
return CtrlDouble{
|
||||
Status: int16(binary.BigEndian.Uint16(p[0:])),
|
||||
Severity: AlarmSeverity(binary.BigEndian.Uint16(p[2:])),
|
||||
Precision: int16(binary.BigEndian.Uint16(p[4:])),
|
||||
Units: caString(p[8:16]),
|
||||
UpperDispLimit: f64(16),
|
||||
LowerDispLimit: f64(24),
|
||||
UpperAlarmLimit: f64(32),
|
||||
UpperWarnLimit: f64(40),
|
||||
LowerWarnLimit: f64(48),
|
||||
LowerAlarmLimit: f64(56),
|
||||
UpperCtrlLimit: f64(64),
|
||||
LowerCtrlLimit: f64(72),
|
||||
Value: f64(80),
|
||||
}, true
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// CtrlLong — decoded DBR_CTRL_LONG payload (type 33) //
|
||||
// -------------------------------------------------------------------------- //
|
||||
//
|
||||
// Wire layout (48 bytes total):
|
||||
//
|
||||
// [0:2] int16 status
|
||||
// [2:4] int16 severity
|
||||
// [4:12] [8]byte units
|
||||
// [12:16] int32 upper_disp_limit
|
||||
// [16:20] int32 lower_disp_limit
|
||||
// [20:24] int32 upper_alarm_limit
|
||||
// [24:28] int32 upper_warning_limit
|
||||
// [28:32] int32 lower_warning_limit
|
||||
// [32:36] int32 lower_alarm_limit
|
||||
// [36:40] int32 upper_ctrl_limit
|
||||
// [40:44] int32 lower_ctrl_limit
|
||||
// [44:48] int32 value
|
||||
|
||||
// CtrlLong is the decoded form of a DBR_CTRL_LONG payload.
|
||||
type CtrlLong struct {
|
||||
Status int16
|
||||
Severity AlarmSeverity
|
||||
Units string
|
||||
UpperDispLimit int32
|
||||
LowerDispLimit int32
|
||||
UpperAlarmLimit int32
|
||||
UpperWarnLimit int32
|
||||
LowerWarnLimit int32
|
||||
LowerAlarmLimit int32
|
||||
UpperCtrlLimit int32
|
||||
LowerCtrlLimit int32
|
||||
Value int32
|
||||
}
|
||||
|
||||
// DecodeCtrlLong decodes a DBR_CTRL_LONG payload (minimum 48 bytes).
|
||||
func DecodeCtrlLong(p []byte) (CtrlLong, bool) {
|
||||
if len(p) < 48 {
|
||||
return CtrlLong{}, false
|
||||
}
|
||||
i32 := func(off int) int32 {
|
||||
return int32(binary.BigEndian.Uint32(p[off:]))
|
||||
}
|
||||
return CtrlLong{
|
||||
Status: int16(binary.BigEndian.Uint16(p[0:])),
|
||||
Severity: AlarmSeverity(binary.BigEndian.Uint16(p[2:])),
|
||||
Units: caString(p[4:12]),
|
||||
UpperDispLimit: i32(12),
|
||||
LowerDispLimit: i32(16),
|
||||
UpperAlarmLimit: i32(20),
|
||||
UpperWarnLimit: i32(24),
|
||||
LowerWarnLimit: i32(28),
|
||||
LowerAlarmLimit: i32(32),
|
||||
UpperCtrlLimit: i32(36),
|
||||
LowerCtrlLimit: i32(40),
|
||||
Value: i32(44),
|
||||
}, true
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// CtrlEnum — decoded DBR_CTRL_ENUM payload (type 31) //
|
||||
// -------------------------------------------------------------------------- //
|
||||
//
|
||||
// Wire layout (424 bytes total):
|
||||
//
|
||||
// [0:2] int16 status
|
||||
// [2:4] int16 severity
|
||||
// [4:6] int16 no_str (number of valid enum strings, max 16)
|
||||
// [6:422] [16][26]byte strs (enum string table)
|
||||
// [422:424] uint16 value
|
||||
|
||||
// CtrlEnum is the decoded form of a DBR_CTRL_ENUM payload.
|
||||
type CtrlEnum struct {
|
||||
Status int16
|
||||
Severity AlarmSeverity
|
||||
Strings []string // len == no_str
|
||||
Value uint16
|
||||
}
|
||||
|
||||
const ctrlEnumSize = 2 + 2 + 2 + maxEnumStates*maxEnumStringSize + 2 // 424
|
||||
|
||||
// DecodeCtrlEnum decodes a DBR_CTRL_ENUM payload (minimum 424 bytes).
|
||||
func DecodeCtrlEnum(p []byte) (CtrlEnum, bool) {
|
||||
if len(p) < ctrlEnumSize {
|
||||
return CtrlEnum{}, false
|
||||
}
|
||||
noStr := int(binary.BigEndian.Uint16(p[4:]))
|
||||
noStr = min(noStr, maxEnumStates)
|
||||
strs := make([]string, noStr)
|
||||
for i := range strs {
|
||||
off := 6 + i*maxEnumStringSize
|
||||
strs[i] = caString(p[off : off+maxEnumStringSize])
|
||||
}
|
||||
return CtrlEnum{
|
||||
Status: int16(binary.BigEndian.Uint16(p[0:])),
|
||||
Severity: AlarmSeverity(binary.BigEndian.Uint16(p[2:])),
|
||||
Strings: strs,
|
||||
Value: binary.BigEndian.Uint16(p[422:]),
|
||||
}, true
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// CtrlString — decoded DBR_CTRL_STRING payload (type 28) //
|
||||
// -------------------------------------------------------------------------- //
|
||||
//
|
||||
// Wire layout (44 bytes):
|
||||
//
|
||||
// [0:2] int16 status
|
||||
// [2:4] int16 severity
|
||||
// [4:44] [40]byte value
|
||||
|
||||
// CtrlString is the decoded form of a DBR_CTRL_STRING payload.
|
||||
type CtrlString struct {
|
||||
Status int16
|
||||
Severity AlarmSeverity
|
||||
Value string
|
||||
}
|
||||
|
||||
// DecodeCtrlString decodes a DBR_CTRL_STRING payload (minimum 44 bytes).
|
||||
func DecodeCtrlString(p []byte) (CtrlString, bool) {
|
||||
if len(p) < 4+maxStringSize {
|
||||
return CtrlString{}, false
|
||||
}
|
||||
return CtrlString{
|
||||
Status: int16(binary.BigEndian.Uint16(p[0:])),
|
||||
Severity: AlarmSeverity(binary.BigEndian.Uint16(p[2:])),
|
||||
Value: caString(p[4 : 4+maxStringSize]),
|
||||
}, true
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Put payload encoders //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
// EncodeDouble encodes a float64 value as a big-endian DBR_DOUBLE payload
|
||||
// padded to 8 bytes.
|
||||
func EncodeDouble(v float64) []byte {
|
||||
b := make([]byte, 8)
|
||||
binary.BigEndian.PutUint64(b, math.Float64bits(v))
|
||||
return b
|
||||
}
|
||||
|
||||
// EncodeLong encodes an int32 value as a big-endian DBR_LONG payload
|
||||
// padded to 8 bytes.
|
||||
func EncodeLong(v int32) []byte {
|
||||
b := make([]byte, 8) // 4 bytes value + 4 bytes pad
|
||||
binary.BigEndian.PutUint32(b, uint32(v))
|
||||
return b
|
||||
}
|
||||
|
||||
// EncodeShort encodes an int16 value as a big-endian DBR_SHORT payload
|
||||
// padded to 8 bytes.
|
||||
func EncodeShort(v int16) []byte {
|
||||
b := make([]byte, 8)
|
||||
binary.BigEndian.PutUint16(b, uint16(v))
|
||||
return b
|
||||
}
|
||||
|
||||
// EncodeString encodes a string as a null-terminated, 8-byte padded
|
||||
// DBR_STRING payload (capped at maxStringSize characters).
|
||||
func EncodeString(v string) []byte {
|
||||
if len(v) >= maxStringSize {
|
||||
v = v[:maxStringSize-1]
|
||||
}
|
||||
b := make([]byte, maxStringSize)
|
||||
copy(b, v)
|
||||
return PadBytes(b)
|
||||
}
|
||||
|
||||
// EncodeEventMask builds the 16-byte payload for a CA_PROTO_EVENT_ADD request.
|
||||
// mask is typically DBEDefault (DBEValue | DBEAlarm).
|
||||
//
|
||||
// Wire layout (16 bytes, all big-endian):
|
||||
//
|
||||
// [0:4] float32 m_lval (not used, zero)
|
||||
// [4:8] float32 p_delta (delta trigger, zero = disabled)
|
||||
// [8:12] float32 p_final (final trigger, zero = disabled)
|
||||
// [12:14] int16 p_count (element count, zero = use PV's count)
|
||||
// [14:16] int16 m_mask (DBE_VALUE | DBE_ALARM | ...)
|
||||
func EncodeEventMask(mask uint16) []byte {
|
||||
b := make([]byte, 16)
|
||||
binary.BigEndian.PutUint16(b[14:], mask) // m_mask is at offset 14, not 12
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package proto
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
)
|
||||
|
||||
// Header is the decoded form of a 16-byte CA message header.
|
||||
// All integer fields are stored in host byte order after decoding.
|
||||
type Header struct {
|
||||
Command uint16
|
||||
PayloadSize uint32 // actual payload size (resolved from extended form if needed)
|
||||
DataType uint16
|
||||
DataCount uint32 // actual element count (resolved from extended form if needed)
|
||||
Parameter1 uint32
|
||||
Parameter2 uint32
|
||||
}
|
||||
|
||||
// HeaderSize is the wire size of a standard CA header.
|
||||
const HeaderSize = 16
|
||||
|
||||
// Encode writes the header to buf (must be at least HeaderSize bytes).
|
||||
// If PayloadSize > 0xFFFE or DataCount > 0xFFFF the extended encoding is used
|
||||
// and the caller must prepend an extra 8 bytes; use EncodeExtended instead.
|
||||
func (h Header) Encode(buf []byte) {
|
||||
binary.BigEndian.PutUint16(buf[0:], h.Command)
|
||||
binary.BigEndian.PutUint16(buf[2:], uint16(h.PayloadSize))
|
||||
binary.BigEndian.PutUint16(buf[4:], h.DataType)
|
||||
binary.BigEndian.PutUint16(buf[6:], uint16(h.DataCount))
|
||||
binary.BigEndian.PutUint32(buf[8:], h.Parameter1)
|
||||
binary.BigEndian.PutUint32(buf[12:], h.Parameter2)
|
||||
}
|
||||
|
||||
// Bytes returns the 16-byte wire encoding of h.
|
||||
func (h Header) Bytes() []byte {
|
||||
buf := make([]byte, HeaderSize)
|
||||
h.Encode(buf)
|
||||
return buf
|
||||
}
|
||||
|
||||
// DecodeHeader reads exactly one CA header from r, handling the extended
|
||||
// encoding (payload_size == 0xFFFF) transparently.
|
||||
// Returns the decoded Header and the number of bytes read (16 or 24).
|
||||
func DecodeHeader(r io.Reader) (Header, int, error) {
|
||||
var raw [HeaderSize]byte
|
||||
if _, err := io.ReadFull(r, raw[:]); err != nil {
|
||||
return Header{}, 0, fmt.Errorf("ca: read header: %w", err)
|
||||
}
|
||||
|
||||
h := Header{
|
||||
Command: binary.BigEndian.Uint16(raw[0:]),
|
||||
DataType: binary.BigEndian.Uint16(raw[4:]),
|
||||
Parameter1: binary.BigEndian.Uint32(raw[8:]),
|
||||
Parameter2: binary.BigEndian.Uint32(raw[12:]),
|
||||
}
|
||||
|
||||
rawPayload := binary.BigEndian.Uint16(raw[2:])
|
||||
rawCount := binary.BigEndian.Uint16(raw[6:])
|
||||
|
||||
// Extended message: payload_size == 0xFFFF means real sizes are in the
|
||||
// next 8 bytes (parameter1 = real payload size, parameter2 = real count).
|
||||
if rawPayload == 0xFFFF && rawCount == 0x0000 {
|
||||
var ext [8]byte
|
||||
if _, err := io.ReadFull(r, ext[:]); err != nil {
|
||||
return Header{}, 16, fmt.Errorf("ca: read extended header: %w", err)
|
||||
}
|
||||
h.PayloadSize = binary.BigEndian.Uint32(ext[0:])
|
||||
h.DataCount = binary.BigEndian.Uint32(ext[4:])
|
||||
// Note: parameter1/2 in the base header are overwritten by the extended values.
|
||||
// In practice, the original parameter1/2 are still valid — the extended header
|
||||
// only carries sizes, not the original parameters. We already decoded parameter1/2
|
||||
// above from the base header.
|
||||
return h, 24, nil
|
||||
}
|
||||
|
||||
h.PayloadSize = uint32(rawPayload)
|
||||
h.DataCount = uint32(rawCount)
|
||||
return h, 16, nil
|
||||
}
|
||||
|
||||
// PadTo8 returns n rounded up to the nearest multiple of 8.
|
||||
// CA requires all message payloads to be padded to 8-byte boundaries.
|
||||
func PadTo8(n int) int {
|
||||
return (n + 7) &^ 7
|
||||
}
|
||||
|
||||
// PadBytes appends zero bytes to b until len(b) is a multiple of 8.
|
||||
func PadBytes(b []byte) []byte {
|
||||
need := PadTo8(len(b)) - len(b)
|
||||
return append(b, make([]byte, need)...)
|
||||
}
|
||||
|
||||
// BuildMessage assembles a complete CA message (header + payload).
|
||||
// payload may be nil for zero-length messages.
|
||||
func BuildMessage(h Header, payload []byte) []byte {
|
||||
h.PayloadSize = uint32(len(payload))
|
||||
msg := make([]byte, HeaderSize+len(payload))
|
||||
h.Encode(msg)
|
||||
copy(msg[HeaderSize:], payload)
|
||||
return msg
|
||||
}
|
||||
|
||||
// BuildStringPayload encodes a string as a null-terminated, 8-byte padded payload.
|
||||
func BuildStringPayload(s string) []byte {
|
||||
b := append([]byte(s), 0) // null terminator
|
||||
return PadBytes(b)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
// Package proto contains the low-level Channel Access wire-protocol constants,
|
||||
// header codec, and DBR type decode functions. All types are big-endian as
|
||||
// required by the CA specification.
|
||||
package proto
|
||||
|
||||
// CA command opcodes (uint16, first field of every message header).
|
||||
const (
|
||||
CmdVersion = 0 // version negotiation (first message on every TCP connection)
|
||||
CmdEventAdd = 1 // subscribe to value updates / server event push
|
||||
CmdEventCancel = 2 // cancel subscription
|
||||
CmdWrite = 4 // put value (fire-and-forget, no server reply)
|
||||
CmdSearch = 6 // UDP: locate IOC hosting a PV
|
||||
CmdNotFound = 14 // UDP: IOC does not host the requested PV
|
||||
CmdReadNotify = 15 // get value with callback (async GET)
|
||||
CmdCreateChan = 18 // create channel on TCP circuit
|
||||
CmdWriteNotify = 19 // put value with acknowledgement
|
||||
CmdClientName = 20 // announce client username (sent after VERSION)
|
||||
CmdHostName = 21 // announce client hostname (sent after VERSION)
|
||||
CmdAccessRights = 22 // server → client: access rights bitmask
|
||||
CmdEcho = 23 // heartbeat ping/pong
|
||||
CmdCreateFail = 26 // server → client: CREATE_CHAN failed
|
||||
CmdServerDisc = 27 // server → client: server shutting down
|
||||
)
|
||||
|
||||
// CA minor protocol revision announced during the VERSION handshake.
|
||||
// Corresponds to CA 4.13, supported by EPICS 3.14+ and all EPICS 7 servers.
|
||||
const MinorVersion = 13
|
||||
|
||||
// Default CA port (TCP and UDP).
|
||||
const DefaultPort = 5064
|
||||
|
||||
// Search reply data_type values.
|
||||
const (
|
||||
SearchReply = 10 // DO_REPLY: ask server to respond
|
||||
SearchNoReply = 5 // DONT_REPLY: suppress response (used by repeater)
|
||||
)
|
||||
|
||||
// AccessRights bitmask values (parameter2 of CmdAccessRights message).
|
||||
const (
|
||||
AccessRead = 1 << 0
|
||||
AccessWrite = 1 << 1
|
||||
)
|
||||
|
||||
// DBE event mask bits used in the 16-byte EVENT_ADD payload.
|
||||
const (
|
||||
DBEValue = 0x01 // trigger on any value change
|
||||
DBEAlarm = 0x04 // trigger on alarm state change
|
||||
DBEDefault = DBEValue | DBEAlarm
|
||||
)
|
||||
|
||||
// ---- DBF field types (native IOC field type, reported in CREATE_CHAN reply) ----
|
||||
|
||||
const (
|
||||
DBFString = 0
|
||||
DBFShort = 1 // also DBFInt
|
||||
DBFFloat = 2
|
||||
DBFEnum = 3
|
||||
DBFChar = 4
|
||||
DBFLong = 5
|
||||
DBFDouble = 6
|
||||
)
|
||||
|
||||
// ---- DBR request types ----
|
||||
|
||||
// Plain value types (used in WRITE).
|
||||
const (
|
||||
DBRString = 0
|
||||
DBRShort = 1
|
||||
DBRFloat = 2
|
||||
DBREnum = 3
|
||||
DBRChar = 4
|
||||
DBRLong = 5
|
||||
DBRDouble = 6
|
||||
)
|
||||
|
||||
// DBR_TIME_* types (value + timestamp + alarm status; used in EVENT_ADD).
|
||||
const (
|
||||
DBRTimeString = 21
|
||||
DBRTimeShort = 19
|
||||
DBRTimeFloat = 20
|
||||
DBRTimeEnum = 23
|
||||
DBRTimeChar = 24
|
||||
DBRTimeLong = 22
|
||||
DBRTimeDouble = 25
|
||||
)
|
||||
|
||||
// DBR_CTRL_* types (full control info: units, limits, enum strings; used in READ_NOTIFY).
|
||||
const (
|
||||
DBRCtrlString = 28
|
||||
DBRCtrlShort = 29
|
||||
DBRCtrlFloat = 30
|
||||
DBRCtrlEnum = 31
|
||||
DBRCtrlChar = 32
|
||||
DBRCtrlLong = 33
|
||||
DBRCtrlDouble = 34
|
||||
)
|
||||
|
||||
// NativeTimeType maps a DBF field type to the corresponding DBR_TIME_* type
|
||||
// that should be used for monitor subscriptions.
|
||||
func NativeTimeType(dbfType int, elementCount int) uint16 {
|
||||
switch dbfType {
|
||||
case DBFDouble:
|
||||
return DBRTimeDouble
|
||||
case DBFFloat:
|
||||
return DBRTimeFloat
|
||||
case DBFLong:
|
||||
return DBRTimeLong
|
||||
case DBFShort:
|
||||
return DBRTimeShort
|
||||
case DBFEnum:
|
||||
return DBRTimeEnum
|
||||
case DBFString:
|
||||
return DBRTimeString
|
||||
case DBFChar:
|
||||
if elementCount > 1 {
|
||||
return DBRTimeString // waveform of chars treated as string
|
||||
}
|
||||
return DBRTimeChar
|
||||
default:
|
||||
return DBRTimeDouble
|
||||
}
|
||||
}
|
||||
|
||||
// NativeCtrlType maps a DBF field type to the corresponding DBR_CTRL_* type
|
||||
// that should be used for metadata gets.
|
||||
func NativeCtrlType(dbfType int) uint16 {
|
||||
switch dbfType {
|
||||
case DBFDouble, DBFFloat:
|
||||
return DBRCtrlDouble
|
||||
case DBFLong, DBFShort, DBFChar:
|
||||
return DBRCtrlLong
|
||||
case DBFEnum:
|
||||
return DBRCtrlEnum
|
||||
case DBFString:
|
||||
return DBRCtrlString
|
||||
default:
|
||||
return DBRCtrlDouble
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
package proto_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/goca/proto"
|
||||
)
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Header round-trip //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestHeaderRoundTrip(t *testing.T) {
|
||||
h := proto.Header{
|
||||
Command: proto.CmdCreateChan,
|
||||
PayloadSize: 16,
|
||||
DataType: proto.DBFDouble,
|
||||
DataCount: 1,
|
||||
Parameter1: 0xDEAD,
|
||||
Parameter2: 0xBEEF,
|
||||
}
|
||||
wire := h.Bytes()
|
||||
if len(wire) != proto.HeaderSize {
|
||||
t.Fatalf("Bytes() len = %d, want %d", len(wire), proto.HeaderSize)
|
||||
}
|
||||
|
||||
got, n, err := proto.DecodeHeader(bytes.NewReader(wire))
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeHeader: %v", err)
|
||||
}
|
||||
if n != proto.HeaderSize {
|
||||
t.Errorf("bytes consumed = %d, want %d", n, proto.HeaderSize)
|
||||
}
|
||||
if got.Command != h.Command {
|
||||
t.Errorf("Command = %d, want %d", got.Command, h.Command)
|
||||
}
|
||||
if got.DataType != h.DataType {
|
||||
t.Errorf("DataType = %d, want %d", got.DataType, h.DataType)
|
||||
}
|
||||
if got.Parameter1 != h.Parameter1 {
|
||||
t.Errorf("Parameter1 = %d, want %d", got.Parameter1, h.Parameter1)
|
||||
}
|
||||
if got.Parameter2 != h.Parameter2 {
|
||||
t.Errorf("Parameter2 = %d, want %d", got.Parameter2, h.Parameter2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildMessage(t *testing.T) {
|
||||
payload := []byte("hello\x00\x00\x00") // 8 bytes (padded)
|
||||
h := proto.Header{Command: proto.CmdHostName, PayloadSize: 0}
|
||||
msg := proto.BuildMessage(h, payload)
|
||||
if len(msg) != proto.HeaderSize+len(payload) {
|
||||
t.Fatalf("len(msg) = %d, want %d", len(msg), proto.HeaderSize+len(payload))
|
||||
}
|
||||
// PayloadSize in wire should equal len(payload).
|
||||
wireSize := binary.BigEndian.Uint16(msg[2:4])
|
||||
if int(wireSize) != len(payload) {
|
||||
t.Errorf("wire PayloadSize = %d, want %d", wireSize, len(payload))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPadTo8(t *testing.T) {
|
||||
cases := [][2]int{{0, 0}, {1, 8}, {7, 8}, {8, 8}, {9, 16}, {16, 16}, {17, 24}}
|
||||
for _, c := range cases {
|
||||
if got := proto.PadTo8(c[0]); got != c[1] {
|
||||
t.Errorf("PadTo8(%d) = %d, want %d", c[0], got, c[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildStringPayload(t *testing.T) {
|
||||
p := proto.BuildStringPayload("TEST")
|
||||
if len(p)%8 != 0 {
|
||||
t.Errorf("payload length %d not padded to 8", len(p))
|
||||
}
|
||||
if p[4] != 0 {
|
||||
t.Errorf("expected null terminator at index 4, got %d", p[4])
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// DBR_TIME_* decoding //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
// buildTimeHeader builds the 12-byte common DBR_TIME header.
|
||||
func buildTimeHeader(status, severity int16, sec, nsec uint32) []byte {
|
||||
b := make([]byte, 12)
|
||||
binary.BigEndian.PutUint16(b[0:], uint16(status))
|
||||
binary.BigEndian.PutUint16(b[2:], uint16(severity))
|
||||
binary.BigEndian.PutUint32(b[4:], sec)
|
||||
binary.BigEndian.PutUint32(b[8:], nsec)
|
||||
return b
|
||||
}
|
||||
|
||||
func TestDecodeTimeDouble(t *testing.T) {
|
||||
const sec = 1_000_000
|
||||
const nsec = 500_000_000
|
||||
const want = 3.14159
|
||||
|
||||
hdr := buildTimeHeader(0, 0, sec, nsec)
|
||||
pad := make([]byte, 4) // RISC pad
|
||||
val := make([]byte, 8)
|
||||
binary.BigEndian.PutUint64(val, math.Float64bits(want))
|
||||
payload := append(append(hdr, pad...), val...)
|
||||
|
||||
tv, ok := proto.DecodeTimeValue(proto.DBRTimeDouble, 1, payload)
|
||||
if !ok {
|
||||
t.Fatal("DecodeTimeValue returned false")
|
||||
}
|
||||
if math.Abs(tv.Double-want) > 1e-10 {
|
||||
t.Errorf("Double = %g, want %g", tv.Double, want)
|
||||
}
|
||||
// EPICS epoch offset: 1990-01-01 00:00:00 UTC = Unix 631152000.
|
||||
wantUnix := int64(sec) + 631152000
|
||||
if tv.Timestamp.Unix() != wantUnix {
|
||||
t.Errorf("Timestamp.Unix() = %d, want %d", tv.Timestamp.Unix(), wantUnix)
|
||||
}
|
||||
if tv.Timestamp.Nanosecond() != int(nsec) {
|
||||
t.Errorf("Timestamp.Nanosecond() = %d, want %d", tv.Timestamp.Nanosecond(), nsec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeTimeLong(t *testing.T) {
|
||||
hdr := buildTimeHeader(0, 1, 0, 0)
|
||||
val := make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(val, 0xFFFFFFD6) // -42 as two's complement
|
||||
payload := append(hdr, val...)
|
||||
|
||||
tv, ok := proto.DecodeTimeValue(proto.DBRTimeLong, 1, payload)
|
||||
if !ok {
|
||||
t.Fatal("DecodeTimeValue returned false")
|
||||
}
|
||||
if tv.Long != -42 {
|
||||
t.Errorf("Long = %d, want -42", tv.Long)
|
||||
}
|
||||
if tv.Severity != proto.SeverityMinor {
|
||||
t.Errorf("Severity = %v, want Minor", tv.Severity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeTimeString(t *testing.T) {
|
||||
hdr := buildTimeHeader(0, 0, 0, 0)
|
||||
str := make([]byte, 40)
|
||||
copy(str, "hello")
|
||||
payload := append(hdr, str...)
|
||||
|
||||
tv, ok := proto.DecodeTimeValue(proto.DBRTimeString, 1, payload)
|
||||
if !ok {
|
||||
t.Fatal("DecodeTimeValue returned false")
|
||||
}
|
||||
if tv.Str != "hello" {
|
||||
t.Errorf("Str = %q, want %q", tv.Str, "hello")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeTimeEnum(t *testing.T) {
|
||||
hdr := buildTimeHeader(0, 0, 0, 0)
|
||||
val := []byte{0x00, 0x03, 0x00, 0x00} // enum=3 + 2-byte pad
|
||||
payload := append(hdr, val...)
|
||||
|
||||
tv, ok := proto.DecodeTimeValue(proto.DBRTimeEnum, 1, payload)
|
||||
if !ok {
|
||||
t.Fatal("DecodeTimeValue returned false")
|
||||
}
|
||||
if tv.Enum != 3 {
|
||||
t.Errorf("Enum = %d, want 3", tv.Enum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeTimeWaveform(t *testing.T) {
|
||||
hdr := buildTimeHeader(0, 0, 0, 0)
|
||||
pad := make([]byte, 4)
|
||||
vals := []float64{1.0, 2.5, -0.5}
|
||||
valbytes := make([]byte, len(vals)*8)
|
||||
for i, v := range vals {
|
||||
binary.BigEndian.PutUint64(valbytes[i*8:], math.Float64bits(v))
|
||||
}
|
||||
payload := append(append(hdr, pad...), valbytes...)
|
||||
|
||||
tv, ok := proto.DecodeTimeValue(proto.DBRTimeDouble, 3, payload)
|
||||
if !ok {
|
||||
t.Fatal("DecodeTimeValue returned false")
|
||||
}
|
||||
if len(tv.Doubles) != 3 {
|
||||
t.Fatalf("len(Doubles) = %d, want 3", len(tv.Doubles))
|
||||
}
|
||||
for i, want := range vals {
|
||||
if math.Abs(tv.Doubles[i]-want) > 1e-12 {
|
||||
t.Errorf("Doubles[%d] = %g, want %g", i, tv.Doubles[i], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeTimeTooShort(t *testing.T) {
|
||||
_, ok := proto.DecodeTimeValue(proto.DBRTimeDouble, 1, []byte{0, 1, 2})
|
||||
if ok {
|
||||
t.Error("expected false for short payload")
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// DBR_CTRL_DOUBLE decoding //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestDecodeCtrlDouble(t *testing.T) {
|
||||
p := make([]byte, 88)
|
||||
// status=0, severity=0, precision=3, pad=0, units="mA\0..."
|
||||
binary.BigEndian.PutUint16(p[4:], 3) // precision
|
||||
copy(p[8:], "mA")
|
||||
f64 := func(off int, v float64) {
|
||||
binary.BigEndian.PutUint64(p[off:], math.Float64bits(v))
|
||||
}
|
||||
f64(16, 100.0) // upper_disp
|
||||
f64(24, 0.0) // lower_disp
|
||||
f64(32, 90.0) // upper_alarm
|
||||
f64(40, 80.0) // upper_warn
|
||||
f64(48, 20.0) // lower_warn
|
||||
f64(56, 10.0) // lower_alarm
|
||||
f64(64, 95.0) // upper_ctrl
|
||||
f64(72, 5.0) // lower_ctrl
|
||||
f64(80, 55.5) // value
|
||||
|
||||
cd, ok := proto.DecodeCtrlDouble(p)
|
||||
if !ok {
|
||||
t.Fatal("DecodeCtrlDouble returned false")
|
||||
}
|
||||
if cd.Units != "mA" {
|
||||
t.Errorf("Units = %q, want %q", cd.Units, "mA")
|
||||
}
|
||||
if cd.Precision != 3 {
|
||||
t.Errorf("Precision = %d, want 3", cd.Precision)
|
||||
}
|
||||
if math.Abs(cd.Value-55.5) > 1e-10 {
|
||||
t.Errorf("Value = %g, want 55.5", cd.Value)
|
||||
}
|
||||
if math.Abs(cd.UpperDispLimit-100.0) > 1e-10 {
|
||||
t.Errorf("UpperDispLimit = %g, want 100.0", cd.UpperDispLimit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeCtrlEnum(t *testing.T) {
|
||||
p := make([]byte, 424)
|
||||
binary.BigEndian.PutUint16(p[4:], 3) // no_str = 3
|
||||
strs := []string{"OFF", "ON", "FAULT"}
|
||||
for i, s := range strs {
|
||||
copy(p[6+i*26:], s)
|
||||
}
|
||||
binary.BigEndian.PutUint16(p[422:], 1) // value = 1
|
||||
|
||||
ce, ok := proto.DecodeCtrlEnum(p)
|
||||
if !ok {
|
||||
t.Fatal("DecodeCtrlEnum returned false")
|
||||
}
|
||||
if len(ce.Strings) != 3 {
|
||||
t.Fatalf("len(Strings) = %d, want 3", len(ce.Strings))
|
||||
}
|
||||
if ce.Strings[2] != "FAULT" {
|
||||
t.Errorf("Strings[2] = %q, want FAULT", ce.Strings[2])
|
||||
}
|
||||
if ce.Value != 1 {
|
||||
t.Errorf("Value = %d, want 1", ce.Value)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Put payload encoders //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestEncodeDouble(t *testing.T) {
|
||||
b := proto.EncodeDouble(3.14)
|
||||
if len(b) != 8 {
|
||||
t.Fatalf("len = %d, want 8", len(b))
|
||||
}
|
||||
got := math.Float64frombits(binary.BigEndian.Uint64(b))
|
||||
if math.Abs(got-3.14) > 1e-15 {
|
||||
t.Errorf("decoded = %g, want 3.14", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeLong(t *testing.T) {
|
||||
b := proto.EncodeLong(-1)
|
||||
if len(b) != 8 {
|
||||
t.Fatalf("len = %d, want 8", len(b))
|
||||
}
|
||||
got := int32(binary.BigEndian.Uint32(b))
|
||||
if got != -1 {
|
||||
t.Errorf("decoded = %d, want -1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeString(t *testing.T) {
|
||||
b := proto.EncodeString("hello")
|
||||
if len(b)%8 != 0 {
|
||||
t.Errorf("len %d not padded to 8", len(b))
|
||||
}
|
||||
if string(b[:5]) != "hello" {
|
||||
t.Errorf("content = %q, want %q", b[:5], "hello")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeEventMask(t *testing.T) {
|
||||
b := proto.EncodeEventMask(proto.DBEDefault)
|
||||
if len(b) != 16 {
|
||||
t.Fatalf("len = %d, want 16", len(b))
|
||||
}
|
||||
// m_mask is at offset 14 (after m_lval[0:4], p_delta[4:8], p_final[8:12], p_count[12:14]).
|
||||
mask := binary.BigEndian.Uint16(b[14:])
|
||||
if mask != proto.DBEDefault {
|
||||
t.Errorf("mask = 0x%02X, want 0x%02X", mask, proto.DBEDefault)
|
||||
}
|
||||
// p_count at [12:14] must be zero.
|
||||
if pc := binary.BigEndian.Uint16(b[12:]); pc != 0 {
|
||||
t.Errorf("p_count = %d, want 0", pc)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// NativeTimeType / NativeCtrlType //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestNativeTimeType(t *testing.T) {
|
||||
cases := []struct {
|
||||
dbf int
|
||||
n int
|
||||
want uint16
|
||||
}{
|
||||
{proto.DBFDouble, 1, proto.DBRTimeDouble},
|
||||
{proto.DBFFloat, 1, proto.DBRTimeFloat},
|
||||
{proto.DBFLong, 1, proto.DBRTimeLong},
|
||||
{proto.DBFShort, 1, proto.DBRTimeShort},
|
||||
{proto.DBFEnum, 1, proto.DBRTimeEnum},
|
||||
{proto.DBFString, 1, proto.DBRTimeString},
|
||||
{proto.DBFChar, 1, proto.DBRTimeChar},
|
||||
{proto.DBFChar, 10, proto.DBRTimeString}, // char waveform → string
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := proto.NativeTimeType(c.dbf, c.n)
|
||||
if got != c.want {
|
||||
t.Errorf("NativeTimeType(%d,%d) = %d, want %d", c.dbf, c.n, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// AlarmSeverity.String //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestAlarmSeverityString(t *testing.T) {
|
||||
cases := []struct {
|
||||
s proto.AlarmSeverity
|
||||
want string
|
||||
}{
|
||||
{proto.SeverityNone, "NO_ALARM"},
|
||||
{proto.SeverityMinor, "MINOR"},
|
||||
{proto.SeverityMajor, "MAJOR"},
|
||||
{proto.SeverityInvalid, "INVALID"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := c.s.String(); got != c.want {
|
||||
t.Errorf("Severity(%d).String() = %q, want %q", c.s, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Timestamp sanity check //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestEPICSEpoch(t *testing.T) {
|
||||
// secPastEpoch=0 should decode to 1990-01-01 00:00:00 UTC.
|
||||
hdr := buildTimeHeader(0, 0, 0, 0)
|
||||
val := make([]byte, 8)
|
||||
payload := append(append(hdr, make([]byte, 4)...), val...)
|
||||
|
||||
tv, ok := proto.DecodeTimeValue(proto.DBRTimeDouble, 1, payload)
|
||||
if !ok {
|
||||
t.Fatal("DecodeTimeValue returned false")
|
||||
}
|
||||
want := time.Date(1990, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
if !tv.Timestamp.Equal(want) {
|
||||
t.Errorf("timestamp = %v, want %v", tv.Timestamp, want)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user