Skip to content

Commit

Permalink
Add support for Actisense ELB file format (mainly BST-95 format, crea…
Browse files Browse the repository at this point in the history
…ted by W2K-1 device)
  • Loading branch information
aldas committed Jun 6, 2023
1 parent de2f4ab commit 516d9e1
Show file tree
Hide file tree
Showing 9 changed files with 332 additions and 14 deletions.
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ following features:
* N2K Ascii,
* N2K Binary,
* Raw ASCII
* ELB (log files from W2K-1 device)
* Can output read raw frames/messages as:
* JSON,
* HEX,
Expand Down Expand Up @@ -107,6 +108,14 @@ Actisense [W2K-1](https://actisense.com/products/w2k-1-nmea-2000-wifi-gateway/)
and print output in JSON format.


Read Actisense EBL log file as `BST-95` format (created by W2K-1 device) and output decoded messages as `json` format:
```bash
./n2k-reader -pgns=canboat/testdata/canboat.json \
-device="actisense/testdata/actisense_w2k1_bst95.ebl" \
-is-file=true \
-output-format=json \
-input-format=elb
```

## Library example

Expand Down
9 changes: 6 additions & 3 deletions actisense/binaryreader.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const (
STX = 0x02
// ETX end packet byte for Actisense parsed NMEA2000 packet
ETX = 0x03
// DLE marker byte before start/end packet byte. Is sent before STX or ETX byte is sent (DLE+STX or DLE+ETX)
// DLE marker byte before start/end packet byte. Is sent before STX or ETX byte is sent (DLE+STX or DLE+ETX). Is escaped by sending double DLE+DLE characters.
DLE = 0x10

// cmdNGTMessageReceived identifies that packet is received/incoming NMEA200 data message as NGT binary format.
Expand Down Expand Up @@ -70,6 +70,9 @@ type Config struct {
// OutputActisenseMessages instructs device to output Actisense own messages
OutputActisenseMessages bool

// LogFunc callback to output/print debug/log statements
LogFunc func(format string, a ...any)

// IsN2KWriter instructs device to write/send messages to NMEA200 bus as N2K binary format (used by Actisense W2K-1)
IsN2KWriter bool

Expand Down Expand Up @@ -164,8 +167,8 @@ func (d *BinaryFormatDevice) ReadRawMessage(ctx context.Context) (nmea.RawMessag
}
if currentByte == ETX { // end of message sequence
msg := message[0:messageByteIndex]
if d.config.DebugLogRawMessageBytes {
fmt.Printf("# DEBUG read raw actisense binary message: %x\n", msg)
if d.config.DebugLogRawMessageBytes && d.config.LogFunc != nil {
d.config.LogFunc("# DEBUG read raw actisense binary message: %x\n", msg)
}
switch message[0] {
case cmdNGTMessageReceived, cmdNGTMessageSend:
Expand Down
4 changes: 3 additions & 1 deletion actisense/binaryreader_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -432,15 +432,17 @@ func TestNGT1Device_Read(t *testing.T) {
wr := bufio.NewReadWriter(bufio.NewReader(r), nil)

device := NewBinaryDevice(wr)
i := 0
for {
packet, err := device.ReadRawMessage(context.Background())
if err == io.EOF {
break
}
i++

assert.NoError(t, err)
assert.Equal(t, packet, packet)
if err != nil {
if err != nil || i > 20 {
break
}
}
Expand Down
181 changes: 181 additions & 0 deletions actisense/eblreader.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
package actisense

import (
"context"
"errors"
"github.com/aldas/go-nmea-client"
"io"
"os"
"time"
)

// EBL log file format used by Actisense W2K-1. Probably called "CAN-Raw (BST-95) message format"
//
// Example data frame from one EBL file:
// 1b 01 07 95 0e 28 9a 00 01 f8 09 3d 0d b3 22 48 32 59 0d 1b 0a
//
// 1b 01 <-- start of data frame (ESC+SOH)
//
// 07 95 <-- "95" is maybe row type. Actisense EBL Reader v2.027 says "now has added support for the new CAN-Raw (BST-95) message format that is used for all data logging on Actisense W2K-1"
// 0e <-- lengths 14 bytes till end
// 28 9a <-- timestamp 39464 (hex 9A28) (little endian)
// 00 01 f8 09 <--- 0x09f80100 = src:0, dst:255, pgn:129025 (1f801), prio:2 (little endian)
// 3d 0d b3 22 48 32 59 0d <-- CAN payload (N2K endian rules), lat(32bit) 22b30d3d = 582159677, lon(32bit) 0d593248 = 223949384
// 1b 0a <-- end of data frame (ESC+LF)
const (
// SOH is start of data frame byte for Actisense BST-95 (EBL file created by Actisense W2K-1 device)
SOH = 0x01
// NL is end of data frame byte
NL = 0x0A
// ESC is marker byte before start/end data frame byte. Is sent before SOH or NL byte is sent (ESC+SOH or ESC+NL). Is escaped by sending double ESC+ESC characters.
ESC = 0x1b
)

// EBLFormatDevice is implementing Actisense EBL file format
type EBLFormatDevice struct {
device io.ReadWriter

sleepFunc func(timeout time.Duration)
timeNow func() time.Time

config Config
}

// NewEBLFormatDevice creates new instance of Actisense device using binary formats (NGT1 and N2K binary)
func NewEBLFormatDevice(reader io.ReadWriter) *EBLFormatDevice {
return NewEBLFormatDeviceWithConfig(reader, Config{ReceiveDataTimeout: 150 * time.Millisecond})
}

// NewEBLFormatDeviceWithConfig creates new instance of Actisense device using binary formats (NGT1 and N2K binary) with given config
func NewEBLFormatDeviceWithConfig(reader io.ReadWriter, config Config) *EBLFormatDevice {
if config.ReceiveDataTimeout > 0 {
config.ReceiveDataTimeout = 5 * time.Second
}
return &EBLFormatDevice{
device: reader,
sleepFunc: time.Sleep,
timeNow: time.Now,
config: config,
}
}

// ReadRawMessage reads raw data and parses it to nmea.RawMessage. This method block until full RawMessage is read or
// an error occurs (including context related errors).
func (d *EBLFormatDevice) ReadRawMessage(ctx context.Context) (nmea.RawMessage, error) {
// Actisense N2K binary message can be up to ISOTP size 1785
message := make([]byte, nmea.ISOTPDataMaxSize)
messageByteIndex := 0

buf := make([]byte, 1)
lastReadWithDataTime := d.timeNow()
var previousByteWasEscape bool
var currentByte byte

state := waitingStartOfMessage
for {
select {
case <-ctx.Done():
return nmea.RawMessage{}, ctx.Err()
default:
}

n, err := d.device.Read(buf)
// on read errors we do not return immediately as for:
// os.ErrDeadlineExceeded - we set new deadline on next iteration
// io.EOF - we check if already read + received is enough to form complete message
if err != nil && !(errors.Is(err, os.ErrDeadlineExceeded) || errors.Is(err, io.EOF)) {
return nmea.RawMessage{}, err
}

now := d.timeNow()
if n == 0 {
if errors.Is(err, io.EOF) && now.Sub(lastReadWithDataTime) > d.config.ReceiveDataTimeout {
return nmea.RawMessage{}, err
}
continue
}
lastReadWithDataTime = now
previousByteWasEscape = currentByte == ESC
currentByte = buf[0]

switch state {
case waitingStartOfMessage: // start of message is (ESC + SOH)
if previousByteWasEscape && currentByte == SOH {
state = readingMessageData
}
case readingMessageData:
if currentByte == ESC {
state = processingEscapeSequence
break
}
message[messageByteIndex] = currentByte
messageByteIndex++
case processingEscapeSequence:
if currentByte == ESC { // any ESC characters are double escaped (ESC ESC)
state = readingMessageData
message[messageByteIndex] = currentByte
messageByteIndex++
break
}
if currentByte == NL { // end of message sequence (ESC + NL)
if messageByteIndex-2 <= 2 {
return nmea.RawMessage{}, errors.New("message too short to be BST95 format")
}
msg := message[0:messageByteIndex]
if d.config.DebugLogRawMessageBytes && d.config.LogFunc != nil {
d.config.LogFunc("# DEBUG read raw actisense ELB message: %x\n", msg)
}
if msg[0] == 0x7 && msg[1] == cmdRAWActisenseMessageReceived { // 0x07+0x95 seems to identify BST-95 message
return fromActisenseBST95Message(msg[2:], now)
}
if d.config.LogFunc != nil {
d.config.LogFunc("# ERROR unknown message type read: %x\n", msg)
}
}
// when unknown ESC + ??? sequence - discard this current message and wait for next start sequence
state = waitingStartOfMessage
messageByteIndex = 0
}
}

}

func fromActisenseBST95Message(raw []byte, now time.Time) (nmea.RawMessage, error) {
const startOfData = 7 // length(1) + timestamp(2) + canid(4) = 7
if len(raw) < 8 { // startOfData + min length of data (1)
return nmea.RawMessage{}, errors.New("raw message actual length too short to be valid BST-95 message")
}
if int(raw[0]) != len(raw)-1 {
return nmea.RawMessage{}, errors.New("raw message length field does not match actual length")
}

canID := uint32(raw[3]) + uint32(raw[4])<<8 + uint32(raw[5])<<16 + uint32(raw[6])<<24

dataBytes := make([]byte, len(raw)-startOfData)
copy(dataBytes, raw[startOfData:])

return nmea.RawMessage{
Time: now,
Header: nmea.ParseCANID(canID),
// W2K-1 seems to use some kind of (offset) counter for timestamp. Probably some other message type in beginning
// of the EBL file has "start" time for that file to which this timestamp offset should be added to.
//Timestamp: uint16(raw[1]) + uint16(raw[2]) << 8,
Data: dataBytes,
}, nil
}

// Initialize initializes connection to device. Otherwise BinaryFormatDevice will not send data.
func (d *EBLFormatDevice) Initialize() error {
return nil
}

func (d *EBLFormatDevice) WriteRawMessage(ctx context.Context, msg nmea.RawMessage) error {
return nil
}

func (d *EBLFormatDevice) Close() error {
if c, ok := d.device.(io.Closer); ok {
return c.Close()
}
return errors.New("device does not implement Closer interface")
}
118 changes: 118 additions & 0 deletions actisense/eblreader_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package actisense

import (
"bufio"
"bytes"
"context"
"github.com/aldas/go-nmea-client"
test_test "github.com/aldas/go-nmea-client/test"
"github.com/stretchr/testify/assert"
"testing"
"time"
)

func TestEBLFormatDevice_Read(t *testing.T) {
now := test_test.UTCTime(1665488842) // Tue Oct 11 2022 11:47:22 GMT+0000

exampleData := test_test.LoadBytes(t, "actisense_w2k1_bst95.ebl")
r := bytes.NewReader(exampleData)
wr := bufio.NewReadWriter(bufio.NewReader(r), nil)

device := NewEBLFormatDevice(wr)
device.timeNow = func() time.Time {
return now
}
packet, err := device.ReadRawMessage(context.Background())
if err != nil {
assert.NoError(t, err)
return
}

firstPacket := nmea.RawMessage{
Time: now,
Header: nmea.CanBusHeader{
PGN: 129025,
Priority: 2,
Source: 0,
Destination: 255,
},
Data: nmea.RawData{0x3d, 0x0d, 0xb3, 0x22, 0x48, 0x32, 0x59, 0x0d},
}
assert.Equal(t, firstPacket, packet)

packet, err = device.ReadRawMessage(context.Background())
if err != nil {
assert.NoError(t, err)
return
}

secondPacket := nmea.RawMessage{
Time: now,
Header: nmea.CanBusHeader{
PGN: 130843,
Priority: 7,
Source: 0,
Destination: 255,
},
Data: nmea.RawData{0x40, 0x0a, 0x3f, 0x9f, 0x09, 0xff, 0x0c, 0x01},
}
assert.Equal(t, secondPacket, packet)
}

func TestFromActisenseBST95Message(t *testing.T) {
now := test_test.UTCTime(1665488842) // Tue Oct 11 2022 11:47:22 GMT+0000

var testCases = []struct {
name string
whenRaw []byte
expect nmea.RawMessage
expectError string
}{
{
name: "ok",
whenRaw: []byte{0x0e, 0x28, 0x9a, 0x00, 0x01, 0xf8, 0x09, 0x3d, 0x0d, 0xb3, 0x22, 0x48, 0x32, 0x59, 0x0d},
expect: nmea.RawMessage{
Time: now,
Header: nmea.CanBusHeader{
PGN: 129025,
Priority: 2,
Source: 0,
Destination: 255,
},
Data: nmea.RawData{0x3d, 0x0d, 0xb3, 0x22, 0x48, 0x32, 0x59, 0x0d},
},
},
{
name: "nok, too short, missing data",
whenRaw: []byte{0x0e, 0x28, 0x9a, 0x00, 0x01, 0xf8, 0x09},
expect: nmea.RawMessage{
Time: time.Time{},
Header: nmea.CanBusHeader{},
Data: nil,
},
expectError: "raw message actual length too short to be valid BST-95 message",
},
{
name: "nok, incorrect length value",
whenRaw: []byte{0x0e, 0x28, 0x9a, 0x00, 0x01, 0xf8, 0x09, 0x3d, 0x0d, 0xb3, 0x22, 0x48, 0x32, 0x59},
expect: nmea.RawMessage{
Time: time.Time{},
Header: nmea.CanBusHeader{},
Data: nil,
},
expectError: "raw message length field does not match actual length",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result, err := fromActisenseBST95Message(tc.whenRaw, now)

assert.Equal(t, tc.expect, result)
if tc.expectError != "" {
assert.EqualError(t, err, tc.expectError)
} else {
assert.NoError(t, err)
}
})
}
}
4 changes: 2 additions & 2 deletions actisense/n2kasciidevice.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,8 @@ func (d *N2kASCIIDevice) ReadRawMessage(ctx context.Context) (nmea.RawMessage, e
d.readIndex += messageEndIndex

message := d.readBuffer[0:d.readIndex]
if d.config.DebugLogRawMessageBytes {
fmt.Printf("# DEBUG Actisense N2K ASCII message: %x\n", message)
if d.config.DebugLogRawMessageBytes && d.config.LogFunc != nil {
d.config.LogFunc("# DEBUG Actisense N2K ASCII message: %x\n", message)
}
now := d.timeNow()
rawMessage, skip, err := parseN2KAscii(message, now)
Expand Down
4 changes: 2 additions & 2 deletions actisense/rawasciidevice.go
Original file line number Diff line number Diff line change
Expand Up @@ -172,8 +172,8 @@ func (d *RawASCIIDevice) ReadRawFrame(ctx context.Context) (nmea.RawFrame, error
d.readIndex += endIndex

frame := d.readBuffer[0:d.readIndex]
if d.config.DebugLogRawMessageBytes {
fmt.Printf("# DEBUG Read Actisense RAW ASCII frame: %v\n", utils.FormatSpaces(frame))
if d.config.DebugLogRawMessageBytes && d.config.LogFunc != nil {
d.config.LogFunc("# DEBUG Read Actisense RAW ASCII frame: %v\n", utils.FormatSpaces(frame))
}
now := d.timeNow()
rawFrame, skip, err := parseRawASCII(frame, now)
Expand Down
Binary file added actisense/testdata/actisense_w2k1_bst95.ebl
Binary file not shown.
Loading

0 comments on commit 516d9e1

Please sign in to comment.