npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@coremarine/sbg-ecom

v1.0.0

Published

Parser for the SBG Systems sbgECom binary protocol (ELLIPSE, EKINOX, APOGEE), emitting the unified CoreMarine CMA format

Readme

SBG sbgECom parser

npm (scoped) publish npm

sbgECom Parser parses the binary protocol of SBG Systems inertial systems — ELLIPSE, EKINOX and APOGEE.

sbgECom is SBG's own binary protocol. Every frame starts with the sync bytes 0xFF 0x5A, then a message id, a message class, a little-endian length, the payload, a CRC-16 Kermit and the end flag 0x33 — §2.1.1 of the Inertial Sensor Interface Firmware Reference Manual (SBGFWM.2.3).

All 34 output logs of SBG_ECOM_CLASS_LOG_ECOM_0 are decoded — every log in §2.3.1 of the manual, each transcribed from its datasheet table with units, descriptions and decoded status words. See Logs.

⭐ One stream, two protocols. An SBG device does not wrap its NMEA output in an eCom frame: the sentences arrive as plain ASCII interleaved with the binary frames on the same wire. This parser reads both from one buffer with no configuration — see The mixed stream.

Nothing you feed the parser is dropped silently. A bad CRC, a truncated body, a log this build does not know, or bytes that are not sbgECom at all — each comes back as a sentence saying so, never as an empty array. See Failed, unmodelled and garbage frames.

The output is the unified CMA format shared by every CoreMarine device parser — see docs/CMA.md.

Install

npm i @coremarine/sbg-ecom

Ships ESM + CJS + types. Runs on node, deno, bun and the web: there are no node:* imports and no Buffer in the parse path — input is a Uint8Array or a string, and every raw is Base64. Requires Node >=22.

How to use it

import { SBGParser } from '@coremarine/sbg-ecom'

// every option is optional
const parser = new SBGParser()
// const parser = new SBGParser({ memory: true, bufferLimit: 4095, firmware: '2.3' })

// feed it whatever the wire gives you — bytes from a serial port, or a string
const sentences = parser.parseData(bytesFromSerialPort)

// or in two steps
parser.addData(bytesFromSerialPort)
const drained = parser.parseData()

addData appends to an internal buffer and decodes everything complete in it; parseData drains what has been decoded. A partial frame stays on the buffer until the rest of its bytes arrive, so feeding the stream one byte at a time gives byte-identical output to feeding it whole.

Options

| option | default | what it does | | --- | --- | --- | | memory | true | keep the buffer between calls, so a frame split across chunks still decodes. false replaces it each time. | | bufferLimit | 4095 | maximum bytes held pending. The largest frame the protocol allows is 6 + 4086 + 3, so the default fits one exactly. Exceeding it emits a garbage sentence and resets — a binary payload can contain 0xFF 0x5A, so without this a wrong device on the line would grow the buffer forever, silently. | | firmware | '2.3' | which knowledge base decodes the logs. An unsupported value is ignored rather than throwing. |

Output

Every sentence is a CMA. A real SBG_ECOM_LOG_EKF_EULER frame from tests/fixtures/stream-mixed.bin, abridged:

{
  "raw": "/1oGACAAmGQpnj9gx7t2hfi7KhW4v68bIzu6ASM7galSPdQIAAARRTM=",
  "timestamp": 1679996289100,
  "id": "0:6",
  "protocol": { "name": "SBG ECOM", "version": "2.3" },
  "payload": [
    {
      "raw": "mGQpng==",
      "name": "TIME_STAMP",
      "type": "uint32",
      "value": 2653512856,
      "units": "us",
      "description": "Time since the sensor was powered up. NOT a clock — an uptime counter…"
    },
    {
      "raw": "P2DHuw==",
      "name": "ROLL",
      "type": "float32",
      "value": -0.006084471475332975,
      "units": "rad",
      "description": "Roll angle",
      "metadata": { "value": -0.34861453610431686, "units": "deg" }
    },
    // … PITCH, YAW, ROLL_ACC, PITCH_ACC, YAW_ACC …
    {
      "raw": "1AgAAA==",
      "name": "SOLUTION_STATUS",
      "type": "uint32",
      "value": 2260,
      "description": "Kalman filter status: the solution mode in bits 0-3, then which aiding data…",
      "metadata": {
        "label": "SBG_ECOM_SOL_MODE_NAV_POSITION",
        "attitudeValid": true,
        "velocityValid": true,
        "positionValid": true,
        "gps1PositionUsed": true
        // … one flag per documented bit
      }
    }
  ],
  "metadata": {
    "name": "SBG_ECOM_LOG_EKF_EULER",
    "class":   { "raw": "AA==", "name": "CLASS", "type": "uint8",  "value": 0,  "description": "SBG_ECOM_CLASS_LOG_ECOM_0" },
    "message": { "raw": "Bg==", "name": "MSG",   "type": "uint8",  "value": 6,  "description": "Message identifier within the class" },
    "length":  { "raw": "IAA=", "name": "LEN",   "type": "uint16", "value": 32, "units": "bytes" },
    "crc":     { "raw": "EUU=", "name": "CRC",   "type": "uint16", "value": 17681 },
    "timestamp": { "received": 1679996289100, "parsed": 1679996289100 }
  },
  "description": "Computed orientation as Euler angles, with a 1 sigma accuracy per axis…"
}

id is '<class>:<message>'

Identity in sbgECom is a pair: §2.1.1 gives MSG and CLASS as two independent header bytes, and there is no revision concept. MSG 6 means EKF_EULER in class 0x00 and something else entirely in class 0x02, so the class is part of the identity — '0:6', not '6'. The log's name is at metadata.name.

(This differs from @coremarine/septentrio-sbf, whose ids are a bare number, because SBF packs one block number and a revision into a single uint16.)

Metadata

Three levels, as docs/CMA.md defines them:

  • Sentence (metadata) — the header fields (class, message, length, CRC) as Field-shaped entries so the raw bytes sit next to the parsed value, plus name and the timestamp block.
  • Field (payload[i].metadata) — one field decoded into a richer form: a bitmask into named booleans, an enum code into its documented label, a radian angle into degrees, a scaled integer into its engineering value.
  • Payload (metadata.payload) — a value aggregated from two or more fields. GPS1_POS and EKF_NAV publish ellipsoidAltitude (altitude + undulation); UTC_TIME publishes the assembled utc epoch; the event logs publish every event's absolute time.

Angles are radians on the wire and degrees in metadata. sbgECom reports every angle in radians; marine consumers want degrees, so every angular field carries the conversion rather than leaving each consumer to rediscover the factor.

Scaled integers keep their raw value. IMU_SHORT reports delta velocities as counts at 1048576 LSB per m·s⁻², so value is the count the wire carried and metadata.value is the engineering figure. Both are useful; only one is what the device sent.

Timestamps

The first field of almost every log is TIME_STAMP, and it is not a clock — the manual calls it "time since sensor is powered up". You cannot say when a sample was taken from an uptime counter alone, so this parser never presents one as a time.

It becomes a time through SBG_ECOM_LOG_UTC_TIME, which publishes the counter and the matching UTC. §2.3.3.2 is explicit: "You thus have to use this frame if you would like to time stamp all data to an absolute UTC or GPS time reference." Once such a frame arrives with CLOCK_STATUS reporting SBG_ECOM_UTC_VALID, the parser learns the correspondence and every later log gets a real time:

parser.clock                            // { uptime: 10000000, utc: 1785587415500 } — or undefined
sentence.metadata.timestamp.sentence    // the log's own absolute time
sentence.timestamp                      // promoted to it: the device's clock beats the host's

Anything short of SBG_ECOM_UTC_VALID means the device is propagating a guess internally, and it is refused — a guessed clock is worse than an honest absence. Logs decoded before the first UTC_TIME of a session simply carry no sentence time.

metadata.timestamp.received and .parsed are always present and always the host's, so the host-side timings stay visible whatever the device says.

The mixed stream

§2.1.4 says of the NMEA and third-party message classes: "This class is only used for identification purpose and does not contain any sbgECom message." The class ids exist so you can configure which sentence goes out — the sentence itself is emitted as raw ASCII alongside the binary frames. §2.1.1 Note 4 warns the integrator directly: "It belongs to the user to decode the different formats if several protocols are used at the same time."

So there is no protocol setting. Both framings are always looked for, and a mixed batch needs no special handling because the output is CMA either way:

const sentences = parser.parseData(oneChunkOfTheWire)

sentences[0].id            // '0:9'   protocol.name 'SBG ECOM'
sentences[1].id            // 'GGA'   protocol.name 'NMEA'
sentences[2].id            // '0:13'  protocol.name 'SBG ECOM'

The NMEA half is @coremarine/nmea-parser, composed rather than reimplemented, so every standard sentence it knows works here — and its extension points are reachable for your own:

parser.nmea.addSentences(yourYaml)

Frames are resolved first, and only the bytes between them are examined for sentences. That ordering is what makes the two framings unambiguous: a $ inside an eCom payload is never scanned, and 0xFF cannot appear in ASCII NMEA text.

The proprietary sentences of §3.3

All of them are modelled, with the vendor as protocol.name — a vendor sentence carries the vendor, not NMEA:

| id | msg | protocol.name | fields | | --- | --- | --- | --- | | PSBGI | 01 | SBG NMEA | 8 | | PSBGB | 04 | SBG NMEA | 23 | | PRDID | 00 | TELEDYNE RDI | 3 | | PASHR | 02 and 12 | ASHTECH | 11 | | PHINF | 05 | IXBLUE | 1 | | PHTRO | 06 | IXBLUE | 4 | | PHLIN | 07 | IXBLUE | 3 | | PHOCT | 08 | IXBLUE | 19 | | INDYN | 09 | IXBLUE | 10 |

PHINF is the one worth a closer look: its single field is a 32-bit OCTANS status word, and the 28 named flags of §3.3.8 are decoded into that field's metadata.status, mirrored at metadata.payload.status. The raw hex stays the field value. SBG's own caveat applies — these are OCTANS definitions, and "some status couldn't be directly translated" to an SBG device.

Two things a consumer should know:

  • PSBGI and PSBGB carry one more field than the manual's tables list (8 and 23, not 7 and 22). SBG's own formatter emits a trailing comma before the checksum; the printed checksums only verify with it. The extra field is named reserved and is always empty.
  • Several Ixblue sentences use sign conventions that contradict SBG's own, and each affected field says so in its description: PHLIN sway and heave are reversed, PHOCT sway and heave are reversed and its pitch is positive bow down, and INDYN pitch is reversed. PHOCT's heading rate is degrees per minute where PSBGB's rates are per second.

§3.3.13 Trimble GGK needs nothing — it is $PTNL,GGK,..., which nmea-parser already models as PTNLGGK, field for field and with the same quality enum, so it parses here today.

⚠️ $PASHR carries two messages, and its heave sign is yours to know

PASHR (msg 02, §3.3.5) and WASSP (msg 12, §3.3.6) are the same wire id with the same 11 fields. The only difference is the heave sign: positive down for msg 02, positive up for msg 12.

Nothing in the sentence records which one you are receiving. The sbgECom message id never reaches the wire — the NMEA half of the stream is not wrapped in eCom frames — and the manual's null example is byte-identical under both sections, checksum included, so no resolver could separate them either.

So it is modelled once, and the heave field's description names both conventions rather than picking one:

sentences[0].payload[5].name         // 'heave'
sentences[0].payload[5].description  // '⚠️ HEAVE SIGN IS CONFIGURATION-DEPENDENT …'

If you need the sign, take it from your device configuration, not from this sentence. Every other field means the same thing in both messages.

Failed, unmodelled and garbage frames

Four tiers, and only two of them are errors:

| tier | when | what you get | | --- | --- | --- | | decoded | CRC and ETX good, log modelled | full payload | | identified | CRC and ETX good, (class, message) not modelled | real id, payload: [], bytes at metadata.body, metadata.name: 'unknown', no errors | | failed | CRC mismatch or wrong ETX | decoded as far as possible plus errors | | garbage | bytes that cannot start a frame | id: 'unknown', the bytes in raw, errors explaining why |

How you detect a problem: errors is present. That is the only signal, and it is the same one every parser in this monorepo uses.

Identified is deliberately not an error. §2.4 states that SBG "reserves the right to add at the end of logs new fields in future revision of the sbgECom protocol", so a newer device is a normal event, not a fault — an unmodelled log arrives with its bytes intact, and a log that has grown decodes every field this build knows and publishes the extra bytes at metadata.trailing.

Adjacent junk is coalesced into one garbage sentence, so a noisy line produces one report rather than a flood. A trailing 0xFF is never garbage: it may be the first half of a sync split across two reads, so it stays pending.

Large frames

A frame with bit 7 of CLASS set is a large frame (§2.1.2.1): its payload begins with a 5-byte page header, and LEN includes it.

Each page is emitted as its own CMA with the pagination in metadata, and pages are not reassembled:

{
  "id": "0:6",                      // the large-frame bit is masked off — pagination is framing, not identity
  "payload": [{ "name": "DATA", "type": "string", "value": "3q2+7w==" }],
  "metadata": { "large": { "transmissionId": 7, "pageIndex": 1, "pages": 3 } }
}

Two reasons, both deliberate. A page cuts at a fixed byte boundary, so it can split a field in half and page 1 starts mid-field — there is no field list to publish for a fragment. And an in-parser reassembly buffer would hold a transmission forever if one page were lost on the wire: a memory leak with no symptom. Reassembly belongs to a layer that can time it out and see the gap.

In practice you will not meet one: §2.1.2.1 notes that "ELLIPSE Generation 1, 2 and 3 don't use large frame", and there are none in the committed corpus. The path exists so the parser cannot mis-frame one.

Introspection

Both calls return a Result and never throw. There is no protocol selector: an eCom id contains a colon and an NMEA id does not, so they dispatch on the id itself.

parser.sentenceIds                       // ['0:1', '0:2', … , 'GGA', 'RMC', …]

parser.getSentenceDefinition('0:6')      // the field table, CMA-shaped
parser.getSentenceDefinition('GGA')      // answered by nmea-parser
parser.getLogDefinition('0:6')           // the same, with eCom's own `name` and `opaque`

parser.getFakeSentence('0:6')            // a real frame: real CRC, real LEN, parses straight back
parser.getFakeSentence('0:6', undefined, { fields: { ROLL: 0.5 }, timestamp: 1_000_000 })

A fake frame is deterministic — the same request returns the same bytes forever, so it can be committed into a spec or a Node-RED example flow. { random: true } opts out.

A fake round trip proves the framing, not the field table: it is built from the same table it is decoded with, so the two agree even when the table is wrong. What catches a wrong table is the datasheet and a real capture — which is why tests/fixtures/ exists.

Logs

All 34 logs of SBG_ECOM_CLASS_LOG_ECOM_0, by manual section:

| § | logs | | --- | --- | | 2.3.3 general information and time | STATUS (1) · UTC_TIME (2) | | 2.3.4 inertial sensor data | IMU_DATA (3) · IMU_SHORT (44) | | 2.3.5 EKF output | EKF_EULER (6) · EKF_QUAT (7) · EKF_NAV (8) · SHIP_MOTION (9) · SHIP_MOTION_HP (32) | | 2.3.6 aiding sensors | MAG (4) · MAG_CALIB (5) · GPS1_VEL (13) · GPS1_POS (14) · GPS1_HDT (15) · GPS1_RAW (31) · GPS2_VEL (16) · GPS2_POS (17) · GPS2_HDT (18) · GPS2_RAW (38) · ODO_VEL (19) · AIR_DATA (36) · DVL_BOTTOM_TRACK (29) · DVL_WATER_TRACK (30) · DEPTH (47) · USBL (37) | | 2.3.7 miscellaneous | EVENT_AEVENT_E (24–28) · EVENT_OUT_A (45) · EVENT_OUT_B (46) · DIAG (48) · RTCM_RAW (49) |

The other message classes — CMD (0x10), LOG_ECOM_1 (high-rate, 0x01), the two NMEA identifier classes and THIRD_PARTY — are recognised but not modelled. A frame from one arrives as identified, with its real id and its bytes, rather than as garbage.

Which logs are verified against hardware

Thirteen of the 34 are decoded from real device output in the committed corpus: STATUS, UTC_TIME, IMU_DATA, MAG, EKF_EULER, EKF_QUAT, EKF_NAV, SHIP_MOTION, GPS1_VEL, GPS1_POS, GPS1_HDT, AIR_DATA, IMU_SHORT. Their values are additionally checked against physics — a stationary IMU measures 1 g, a quaternion is normalised, the position lands where the capture was taken.

The other 21 are datasheet-only. Their tables were transcribed by hand from the manual and round-trip through getFakeSentence, but no capture of them exists, so treat them as unverified against hardware. DIAG (48) deserves particular suspicion: the manual's field table for it is self-contradictory — it prints three mutually impossible offsets — and the layout here is reconstructed from its stated total size.

Two things the manual gets wrong

Recorded because both cost real time, and because a reader hitting them should know they were checked rather than missed:

  1. GPS1_POS/GPS2_POS offsets. The offset column prints 54 for BASE_STATION_ID and 56 for DIFF_AGE, which puts the end of the log at 58 — while the same table says "Total size 57". The fields are packed, at 53 and 55, and that is measured, not assumed: every GPS1_POS frame in the corpus has LEN 57.
  2. SBG_ECOM_LOG_DEPTH's DEPTH unit is printed as m/s; its own description calls it a depth measurement. It is metres.

Upgrading from 0.0.x

0.0.1 was a pre-release with no semver guarantee, and everything about this package changed in 1.0.0. There is no incremental migration — treat it as a new library:

| 0.0.x | 1.0.0 | | --- | --- | | a bespoke frame object | CMA — see Output | | getFrames() | parseData(data?): CMA[] | | 25 hand-written log decoders | all 34 logs of SBG_ECOM_CLASS_LOG_ECOM_0, as field tables | | logs identified by message number alone | id is '<class>:<message>', e.g. '0:6' | | NMEA on the same wire was not handled | plain NMEA is parsed from the same buffer, no switch to set | | — | sentenceIds, getSentenceDefinition, getFakeSentence |

Two behaviours worth knowing even if you never used 0.0.x:

  • Uptime is never presented as a clock. Every log carries microseconds since power-up. SBG_ECOM_LOG_UTC_TIME teaches the parser the uptime↔UTC correspondence, and only then is a log dated from it.
  • Nothing is dropped silently. A frame from an unmodelled class is identified rather than called garbage, and a bad CRC is decoded as far as possible and reported in errors.

Six real bugs in the 0.0.x decoders were fixed on the way, so a value that looked right before may legitimately differ now. They are catalogued in docs/STATUS.md.

Development

pnpm run sbg-ecom:test       # vitest, watch
pnpm run sbg-ecom:build      # format + tsup (ESM + CJS + types)
pnpm run sbg-ecom:lint       # eslint

The tests are worth reading before changing a field table: tests/fixtures/README.md states exactly what each capture must parse to, and tests/logs.test.ts checks the decoded numbers against physics.