From 2a9a2a9612b59ccfcfcdb0a3c268c9d5a0d863f1 Mon Sep 17 00:00:00 2001 From: Shrey Date: Fri, 10 Apr 2026 11:08:19 -0400 Subject: [PATCH 1/6] Fields From 871cffa5728ec9d2cafb581c8081d581653fbb3a Mon Sep 17 00:00:00 2001 From: Shrey Date: Sun, 26 Apr 2026 13:00:28 -0400 Subject: [PATCH 2/6] fields done --- areas/sw_libs/parsley-ts/src/fields.ts | 356 ++++++++++++++++++ areas/sw_libs/parsley-ts/tests/fields.test.ts | 352 +++++++++++++++++ 2 files changed, 708 insertions(+) create mode 100644 areas/sw_libs/parsley-ts/src/fields.ts create mode 100644 areas/sw_libs/parsley-ts/tests/fields.test.ts diff --git a/areas/sw_libs/parsley-ts/src/fields.ts b/areas/sw_libs/parsley-ts/src/fields.ts new file mode 100644 index 0000000..0267608 --- /dev/null +++ b/areas/sw_libs/parsley-ts/src/fields.ts @@ -0,0 +1,356 @@ +/* +Field transcoders. Each field knows how to convert between a JS value and a +bigint of `length` bits, LSB-aligned to match BitString. +*/ + +export abstract class Field { + /* + Abstract base class for all fields that can be transcoded. + + Note: data is assumed to be LSB-aligned to match the implementation of BitString. + */ + + name: string; + length: number; + unit: string; + variable_length: boolean; + + constructor(name: string, length: number, unit: string = "") { + this.name = name; + this.length = length; + this.unit = unit; + this.variable_length = false; + } + + abstract decode(data: bigint): unknown; + /* + Converts `self.length` bits of `data` to the field's corresponding python value. + This value could be an integer, string, etc. depending on the specific field type. + */ + + + abstract encode(value: unknown): readonly [bigint, number]; + /* + Converts value to `self.length` bits of data and returns a tuple of (encoded_value, self.length) + or raises a ValueError with an appropiate message if this is not possible. + + self.length is returned in order to properly parse leading zeros. For example, + the following cases are bit-level identical, but are not equilvalent for our purposes: + 2-bit: ______10 + 4-bit: ____0010 + 8-bit: 00000010 (<= they all look like this) + */ +} + +// Reverse the byte order of an unsigned integer that occupies `byteCount` bytes. +function byteSwap(value: bigint, byteCount: number): bigint { + let result = BigInt(0); + for (let i = 0; i < byteCount; i++) { + result = (result << 8n) | (value & 0xffn); + value >>= 8n; + } + return result; +} + +export class ASCII extends Field { + /* + Transcodes binary data and ASCII text. + + b'\x48\x65\x79' <=> 'Hey' + Encoded data is left-aligned with trailing null bytes. + */ + constructor(name: string, length: number) { + super(name, length); + this.variable_length = true; + } + + public decode(data: bigint): string { + const byteCount = this.length / 8; + const hex = data.toString(16).padStart(byteCount * 2, "0"); + return Buffer.from(hex, "hex").toString("ascii").replace(/\0/g, ""); + } + + public encode(value: string): [bigint, number] { + if (typeof value !== "string") { + throw new Error(`${value} is not a string`); + } + const bytes = Buffer.from(value, "ascii"); + + // Detect non-ASCII + if (!/^[\x00-\x7F]*$/.test(value)) { + throw new Error(`${value} contains non-ascii character(s)`); + } + if (this.length < 8 * bytes.length) { + throw new Error(`${value} is too large for ${Math.floor(this.length / 8)} character(s)`); + } + + const byteCount = this.length / 8; + const hex = bytes.toString("hex").padEnd(byteCount * 2, "0"); + return [hex.length === 0 ? BigInt(0) : BigInt(`0x${hex}`), this.length]; + } +} + +export class Enum extends Field { + /* + Transcodes binary data using a user-defined dictionary. + + This allows for customizable byte interpretations: + dictionary: {'GENERAL_CMD': 0x060, 'RESET_CMD': 0x160} + b'\x01\x60' <=> 'RESET_CMD' + */ + + map_key_val: Record; + map_val_key: Record; + + constructor(name: string, length: number, map_key_val: Record) { + super(name, length); + this.map_key_val = map_key_val; + this.map_val_key = Object.fromEntries( + Object.entries(map_key_val).map(([k, v]) => [v, k]), + ); + + const valueSize = Object.values(map_key_val).length; + const uniqueValueSize = new Set(Object.values(map_key_val)).size; + if (valueSize !== uniqueValueSize) { + throw new Error( + `Mapping "${this.name}" is not bijective: has ${valueSize} values but only ${uniqueValueSize} are unique`, + ); + } + + const max = BigInt(1) << BigInt(length); + for (const [k, v] of Object.entries(map_key_val)) { + if (v < 0) { + throw new Error(`Mapping value ${v} for key ${k} must be non-negative`); + } + if (BigInt(v) >= max) { + throw new Error( + `Mapping value ${v} for key ${k} is too large to fit in ${length} bits`, + ); + } + } + } + + public decode(data: bigint): string { + const key = Number(data); + const name = this.map_val_key[key]; + if (name === undefined) { + throw new Error(`Value "${data}" not found in map "${this.name}"`); + } + return name; + } + + public encode(value: string): [bigint, number] { + if (!Object.prototype.hasOwnProperty.call(this.map_key_val, value)) { + throw new Error(`Key "${value}" not found in map "${this.name}"`); + } + return [BigInt(this.map_key_val[value]!), this.length]; + } + + public get_keys(): string[] { + return Object.keys(this.map_key_val); + } +} + +export class Numeric extends Field { + /* + Transcodes binary data and numbers (ie. (un)signed and/or fixed point) + with an optional scaling factor during transcoding. + + For example: + b'\xFC' <=> -4 (two's complement) + */ + scale: number; + signed: boolean; + big_endian: boolean; + + constructor(name: string, length: number, scale: number = 1, signed: boolean = false, big_endian: boolean = true, unit: string = "") { + super(name, length, unit); + this.scale = scale; + this.signed = signed; + this.big_endian = big_endian; + } + + public decode(data: bigint): number { + const byteCount = Math.ceil(this.length / 8); + let raw = this.big_endian ? data : byteSwap(data, byteCount); + + if (this.signed) { + const signBit = BigInt(1) << BigInt(this.length - 1); + if (raw & signBit) { + raw -= BigInt(1) << BigInt(this.length); + } + } + return Number(raw) * this.scale; + } + + public encode(value: number): [bigint, number] { + if (typeof value !== "number" || Number.isNaN(value)) { + throw new Error(`Value "${value}" is not a valid number`); + } + + const intVal = Math.floor(value / this.scale); + const max = BigInt(1) << BigInt(this.signed ? this.length - 1 : this.length); + const min = this.signed ? -(BigInt(1) << BigInt(this.length - 1)) : BigInt(0); + const big = BigInt(intVal); + + if (big >= max) { + throw new Error( + `Value "${intVal}" (0x${intVal.toString(16)}) is too large for ${this.length} ${this.signed ? "signed" : "unsigned"} bits`, + ); + } + if (big < min) { + throw new Error( + this.signed + ? `Value "${intVal}" (0x${intVal.toString(16)}) is too small for ${this.length} signed bits` + : `Cannot encode negative value "${intVal}" in an unsigned field`, + ); + } + + // Two's complement for negatives in signed fields. + const unsigned = big < BigInt(0) ? big + (BigInt(1) << BigInt(this.length)) : big; + const byteCount = Math.ceil(this.length / 8); + const encoded = this.big_endian ? unsigned : byteSwap(unsigned, byteCount); + return [encoded, this.length]; + } +} + +export class Floating extends Field { + /* + IEEE 754 single-precision float. 32 bits. + + For example: + 9.8125 -> b'A\x1d\x00\x00' + + (Note, byte order may be reversed depending on endianess) + */ + big_endian: boolean; + + constructor(name: string, big_endian: boolean = true, unit: string = "") { + super(name, 32, unit); + this.big_endian = big_endian; + } + + public decode(data: bigint): number { + const buf = new ArrayBuffer(4); + const view = new DataView(buf); + view.setUint32(0, Number(data & 0xffffffffn), false); + return view.getFloat32(0, !this.big_endian); + } + + public encode(value: number): [bigint, number] { + if (typeof value !== "number" || Number.isNaN(value)) { + throw new Error(`Value "${value}" is not a valid float`); + } + const buf = new ArrayBuffer(4); + const view = new DataView(buf); + view.setFloat32(0, value, !this.big_endian); + return [BigInt(view.getUint32(0, false)), this.length]; + } +} + +export class Switch extends Enum { + /* + An Enum wrapper to map binary data -> list of Fields using a user-defined dictionary. + + For perspective, our CAN messages are defined as a Switch that maps: + binary data <=> string (message type) and then + string -> list of Fields (the specific fields that are defined in the message type) + */ + map_key_fields: Record; + + constructor( + name: string, + length: number, + map_key_val: Record, + map_key_fields: Record, + ) { + super(name, length, map_key_val); + this.map_key_fields = map_key_fields; + } + + public get_fields(key: string): Field[] { + const fields = this.map_key_fields[key]; + if (fields === undefined) { + throw new Error(`Key "${key}" not found in switch "${this.name}"`); + } + return fields; + } +} + +export class Bitfield extends Field { + /* + Transcodes binary data and bitfields using a user-defined dictionary. + + This is a bitfield, so the dictionary maps the bit position to the name of the field. + For example: + dictionary: {'E_NOMINAL': 0, 'E_5V_OVER_CURRENT': 1, 'E_5V_OVER_VOLTAGE': 2} + b'\x01\x60' <=> 'E_5V_OVER_CURRENT|E_5V_OVER_VOLTAGE' + */ + default: string; + map_name_offset: Record | null; + + constructor(name: string, length: number, default_value: string = "DEFAULT_STRING", map_name_offset: Record | null = null, unit: string = "") { + super(name, length, unit); + this.default = default_value; + this.map_name_offset = map_name_offset; + + // Every flag's bit position must fit in the field + if (map_name_offset !== null) { + for (const [flag, offset] of Object.entries(map_name_offset)) { + if (offset < 0 || offset >= length) { + throw new Error( + `Bitfield "${name}": flag "${flag}" offset ${offset} doesn't fit in ${length} bits`, + ); + } + } + } + } + + public decode(data: bigint): string { + if (this.map_name_offset === null) { + const hexWidth = Math.ceil(this.length / 4); + return "0x" + data.toString(16).padStart(hexWidth, "0"); + } + + const set: string[] = []; + for (const [flag, bit] of Object.entries(this.map_name_offset)) { + if (data & (BigInt(1) << BigInt(bit))) { + set.push(flag); + } + } + return set.length === 0 ? this.default : set.join("|"); + } + + public encode(value: string): [bigint, number] { + if (typeof value !== "string") { + throw new Error(`Value "${value}" is not a valid bitfield string`); + } + + const max = BigInt(1) << BigInt(this.length); + + if (this.map_name_offset === null) { + let parsed: bigint; + try { + parsed = BigInt(value); + } catch { + throw new Error(`Value "${value}" is not a valid bitfield string`); + } + if (parsed < BigInt(0) || parsed >= max) { + throw new Error(`Value "${value}" does not fit in ${this.length} bits`); + } + return [parsed, this.length]; + } + + let bits = BigInt(0); + if (value !== this.default) { + for (const name of value.split("|")) { + const offset = this.map_name_offset[name]; + if (offset === undefined) { + throw new Error(`Name "${name}" not found in bitfield "${this.name}"`); + } + bits |= BigInt(1) << BigInt(offset); + } + } + return [bits, this.length]; + } +} diff --git a/areas/sw_libs/parsley-ts/tests/fields.test.ts b/areas/sw_libs/parsley-ts/tests/fields.test.ts new file mode 100644 index 0000000..35c5e6d --- /dev/null +++ b/areas/sw_libs/parsley-ts/tests/fields.test.ts @@ -0,0 +1,352 @@ +import { describe, it, expect } from 'vitest' +import { ASCII, Enum, Numeric, Floating, Switch, Bitfield } from '../src/fields.js' +import { BoardTypeId, BoardErrorBitfieldOffset } from '../src/messageTypes.js' + +// Adapted from waterloo-rocketry/parsley tests/test_fields.py. +// The Python tests use `bytes`; we use the bigint LSB-aligned form that +// BitString.pop()/push() exchange with field encoders. Conversion: a byte +// sequence b"\xAA\xBB" of length 16 maps to the bigint 0xAABBn. + +describe('ASCII', () => { + it('encode and decode round-trip', () => { + const a = new ASCII('string', 32) + const [data, length] = a.encode('aBcD') + expect(data).toBe(0x61426344n) + expect(length).toBe(32) + expect(a.decode(0x4c4d414fn)).toBe('LMAO') + }) + + it('decodes spaces', () => { + const a = new ASCII('string', 32) + expect(a.decode(0x20205720n)).toBe(' W ') + }) + + it('decodes implicit leading zeros (front padding)', () => { + // Equivalent to Python's a.decode(b"\x57") with length 32 — the bigint + // is 0x57n and the decoder reconstitutes the full 4-byte field. + const a = new ASCII('string', 32) + expect(a.decode(0x57n)).toBe('W') + }) + + it('encode left-aligns with trailing nulls', () => { + const a = new ASCII('string', 32) + const [data] = a.encode('a') + expect(data).toBe(0x61000000n) + }) + + it('encode then decode is identity', () => { + const a = new ASCII('string', 32) + const [data] = a.encode('1234') + expect(a.decode(data)).toBe('1234') + }) + + it('encode empty string', () => { + const a = new ASCII('string', 32) + expect(a.encode('')[0]).toBe(0n) + }) + + it('throws on non-string input', () => { + const a = new ASCII('string', 16) + // @ts-expect-error wrong type on purpose + expect(() => a.encode(12)).toThrow() + // @ts-expect-error wrong type on purpose + expect(() => a.encode(Buffer.from('12'))).toThrow() + }) + + it('throws on non-ASCII characters', () => { + const a = new ASCII('string', 16) + expect(() => a.encode('😎')).toThrow() + }) + + it('throws when string overflows the field', () => { + const a = new ASCII('string', 16) + expect(() => a.encode('xdd')).toThrow() + }) +}) + +describe('Enum', () => { + it('encodes and decodes against a real map', () => { + const e = new Enum('enum', 8, BoardTypeId) + const [data, length] = e.encode('INJECTOR') + expect(data).toBe(0x01n) + expect(length).toBe(8) + expect(e.decode(0x0an)).toBe('PAYLOAD') + }) + + it('round-trips', () => { + const e = new Enum('enum', 8, { a: 1, b: 10, c: 100 }) + expect(e.decode(e.encode('a')[0])).toBe('a') + }) + + it('rejects non-bijective maps', () => { + expect(() => new Enum('enum', 8, { a: 1, b: 0, c: 1 })).toThrow() + }) + + it('rejects negative values', () => { + expect(() => new Enum('enum', 8, { a: -1, b: 0, c: 1 })).toThrow() + }) + + it('rejects values that overflow the field', () => { + expect(() => new Enum('enum', 8, { max: 0x3f3f3f3f })).toThrow() + }) + + it('encode throws on unknown key', () => { + const e = new Enum('enum', 8, { a: 1, b: 2, c: 3 }) + expect(() => e.encode('d')).toThrow() + }) + + it('decode throws on unknown value', () => { + const e = new Enum('enum', 8, { a: 1, b: 2, c: 3 }) + expect(() => e.decode(0xffn)).toThrow() + }) + + it('get_keys returns all keys', () => { + const e = new Enum('enum', 8, { a: 1, b: 2 }) + expect(new Set(e.get_keys())).toEqual(new Set(['a', 'b'])) + }) +}) + +describe('Numeric', () => { + it('basic encode/decode', () => { + const n = new Numeric('num', 8) + const [data, length] = n.encode(250) + expect(data).toBe(0xfan) + expect(length).toBe(8) + expect(n.decode(0x21n)).toBe(33) + }) + + it('honors integer scale', () => { + const n = new Numeric('time', 8, 2) + expect(n.encode(12)[0]).toBe(0x06n) + }) + + it('honors fractional scale', () => { + const n = new Numeric('time', 8, 1 / 2) + expect(n.encode(12)[0]).toBe(0x18n) + }) + + it('round-trips', () => { + const n = new Numeric('num', 8) + expect(n.decode(n.encode(255)[0])).toBe(255) + }) + + it('scale imprecision survives a round trip', () => { + const n = new Numeric('time', 24, 1 / 1000) + const [data] = n.encode(54.321) + expect(n.decode(data)).toBeCloseTo(54.321, 3) + }) + + it('throws on non-number input', () => { + const n = new Numeric('num', 8) + // @ts-expect-error wrong type on purpose + expect(() => n.encode('12')).toThrow() + // @ts-expect-error wrong type on purpose + expect(() => n.encode(Buffer.from('12'))).toThrow() + }) + + it('enforces unsigned bounds', () => { + const n = new Numeric('num', 8) + expect(() => n.encode(0)).not.toThrow() + expect(() => n.encode(255)).not.toThrow() + expect(() => n.encode(-1)).toThrow() + expect(() => n.encode(256)).toThrow() + }) + + it('enforces signed bounds', () => { + const n = new Numeric('num', 8, 1, true) + expect(() => n.encode(-128)).not.toThrow() + expect(() => n.encode(0)).not.toThrow() + expect(() => n.encode(127)).not.toThrow() + expect(() => n.encode(-129)).toThrow() + expect(() => n.encode(128)).toThrow() + }) + + it('enforces scaled bounds', () => { + const u = new Numeric('num', 8, 1 / 4) + expect(() => u.encode(0)).not.toThrow() + expect(() => u.encode(63)).not.toThrow() + expect(() => u.encode(-1)).toThrow() + expect(() => u.encode(64)).toThrow() + + const s = new Numeric('num', 8, 1 / 4, true) + expect(() => s.encode(-32)).not.toThrow() + expect(() => s.encode(0)).not.toThrow() + expect(() => s.encode(31)).not.toThrow() + expect(() => s.encode(-33)).toThrow() + expect(() => s.encode(32)).toThrow() + }) + + it('handles negative scale', () => { + const n = new Numeric('num', 8, -1 / 2) + expect(() => n.encode(-5)).not.toThrow() + expect(() => n.encode(5)).toThrow() + }) + + it('signed two\'s complement decode', () => { + const n = new Numeric('num', 8, 1, true) + expect(n.decode(0xfcn)).toBe(-4) + expect(n.decode(0x7fn)).toBe(127) + expect(n.decode(0x80n)).toBe(-128) + }) + + it('little-endian byte-swaps', () => { + const n = new Numeric('num', 16, 1, false, false) + // value 0x1234 → bytes [0x34, 0x12] → bigint 0x3412n + expect(n.encode(0x1234)[0]).toBe(0x3412n) + expect(n.decode(0x3412n)).toBe(0x1234) + }) +}) + +describe('Floating', () => { + it('round-trips dyadic rationals', () => { + const f = new Floating('Num') + for (const v of [2.0, 0.5, 2.5, 27.015625, 1.3125, 69.0, 420.0]) { + expect(f.decode(f.encode(v)[0])).toBe(v) + } + }) + + it('little-endian round-trip', () => { + const f = new Floating('Num', false) + expect(f.decode(f.encode(2.0)[0])).toBe(2.0) + expect(f.decode(f.encode(0.5)[0])).toBe(0.5) + }) + + it('encodes 9.8125 to the documented bit pattern', () => { + // Python comment in fields.py: 9.8125 → b'\x41\x1d\x00\x00' + const f = new Floating('Num') + expect(f.encode(9.8125)[0]).toBe(0x411d0000n) + }) + + it('throws on non-number input', () => { + const f = new Floating('Num') + // @ts-expect-error wrong type on purpose + expect(() => f.encode('not a number')).toThrow() + }) +}) + +describe('Switch', () => { + it('decodes, encodes, and dispatches fields', () => { + const inner = new Numeric('x', 8) + const sw = new Switch( + 'status', + 8, + { a: 0x01, b: 0x02, c: 0x03 }, + { a: [inner], b: [], c: [] }, + ) + const [data, length] = sw.encode('a') + expect(data).toBe(0x01n) + expect(length).toBe(8) + const decoded = sw.decode(data) + expect(decoded).toBe('a') + expect(sw.get_fields(decoded)).toEqual([inner]) + }) + + it('get_keys returns all keys', () => { + const sw = new Switch('status', 8, { a: 1, b: 2 }, { a: [], b: [] }) + expect(new Set(sw.get_keys())).toEqual(new Set(['a', 'b'])) + }) + + it('get_fields throws on unknown key', () => { + const sw = new Switch('status', 8, { a: 1 }, { a: [] }) + expect(() => sw.get_fields('nope')).toThrow() + }) +}) + +describe('Bitfield', () => { + const make = () => + new Bitfield('general_board_status', 16, 'E_NOMINAL', BoardErrorBitfieldOffset) + + const singleBitCases: [bigint, string][] = [ + [0x0000n, 'E_NOMINAL'], + [0x0001n, 'E_5V_OVER_CURRENT'], + [0x0002n, 'E_5V_OVER_VOLTAGE'], + [0x0004n, 'E_5V_UNDER_VOLTAGE'], + [0x0008n, 'E_12V_OVER_CURRENT'], + [0x0010n, 'E_12V_OVER_VOLTAGE'], + [0x0020n, 'E_12V_UNDER_VOLTAGE'], + [0x0040n, 'E_BATT_OVER_CURRENT'], + [0x0080n, 'E_BATT_OVER_VOLTAGE'], + [0x0100n, 'E_BATT_UNDER_VOLTAGE'], + [0x0200n, 'E_MOTOR_OVER_CURRENT'], + [0x0400n, 'E_IO_ERROR'], + [0x0800n, 'E_FS_ERROR'], + [0x1000n, 'E_WATCHDOG_TIMEOUT'], + [0x2000n, 'E_12V_EFUSE_FAULT'], + [0x4000n, 'E_5V_EFUSE_FAULT'], + [0x8000n, 'E_PT_OUT_OF_RANGE'], + ] + it.each(singleBitCases)('decode 0x%s → %s', (data, expected) => { + expect(make().decode(data)).toBe(expected) + }) + + it('decodes combined flags', () => { + const bf = make() + expect(bf.decode(0x0003n)).toBe('E_5V_OVER_CURRENT|E_5V_OVER_VOLTAGE') + expect(bf.decode(0x0005n)).toBe('E_5V_OVER_CURRENT|E_5V_UNDER_VOLTAGE') + }) + + it('decodes a custom (raw) bitfield as a zero-padded hex string', () => { + const bf = new Bitfield('raw', 16) + expect(bf.decode(0x0006n)).toBe('0x0006') + expect(bf.decode(0x0000n)).toBe('0x0000') + // odd bit-widths round up to the next nibble + const bf12 = new Bitfield('raw', 12) + expect(bf12.decode(0x00fn)).toBe('0x00f') + }) + + const encodeCases: [string, bigint][] = [ + ['E_NOMINAL', 0x0000n], + ['E_5V_OVER_CURRENT', 0x0001n], + ['E_5V_OVER_VOLTAGE', 0x0002n], + ['E_5V_OVER_CURRENT|E_5V_OVER_VOLTAGE', 0x0003n], + ['E_5V_OVER_CURRENT|E_5V_UNDER_VOLTAGE', 0x0005n], + ['E_WATCHDOG_TIMEOUT', 0x1000n], + ] + it.each(encodeCases)('encode %s', (value, expected) => { + const [data, length] = make().encode(value) + expect(data).toBe(expected) + expect(length).toBe(16) + }) + + it('encode/decode round-trip', () => { + const bf = make() + for (const v of [ + 'E_NOMINAL', + 'E_5V_OVER_CURRENT', + 'E_5V_OVER_CURRENT|E_5V_OVER_VOLTAGE', + ]) { + expect(bf.decode(bf.encode(v)[0])).toBe(v) + } + }) + + it('throws on unknown flag', () => { + expect(() => make().encode('E_NOT_A_REAL_FLAG')).toThrow() + }) + + it('throws on non-string value', () => { + // @ts-expect-error wrong type on purpose + expect(() => make().encode(42)).toThrow() + }) + + it('encodes a custom (raw) bitfield', () => { + const bf = new Bitfield('raw', 16) + const [data, length] = bf.encode('0b110') + expect(data).toBe(0x0006n) + expect(length).toBe(16) + }) + + it('custom bitfield rejects unparseable strings', () => { + const bf = new Bitfield('raw', 16) + expect(() => bf.encode('not_a_number')).toThrow() + }) + + it('custom bitfield rejects overflow', () => { + const bf = new Bitfield('raw', 8) + expect(() => bf.encode('0x1FF')).toThrow() + }) + + it('constructor rejects offsets beyond field length', () => { + expect(() => new Bitfield('raw', 4, 'NONE', { HIGH: 4 })).toThrow() + expect(() => new Bitfield('raw', 4, 'NONE', { LOW: -1 })).toThrow() + }) +}) From f68b9eb8d75a8188c7ba3f71f7833a006a1b63a0 Mon Sep 17 00:00:00 2001 From: Shrey Date: Sun, 26 Apr 2026 13:57:01 -0400 Subject: [PATCH 3/6] coderabbit nits --- areas/sw_libs/parsley-ts/src/fields.ts | 4 ++ areas/sw_libs/parsley-ts/tests/fields.test.ts | 61 +++++++++++-------- 2 files changed, 40 insertions(+), 25 deletions(-) diff --git a/areas/sw_libs/parsley-ts/src/fields.ts b/areas/sw_libs/parsley-ts/src/fields.ts index 0267608..8ef52ad 100644 --- a/areas/sw_libs/parsley-ts/src/fields.ts +++ b/areas/sw_libs/parsley-ts/src/fields.ts @@ -61,6 +61,10 @@ export class ASCII extends Field { */ constructor(name: string, length: number) { super(name, length); + // ASCII characters are 8 bits each; sub-byte fields don't make sense. + if (length % 8 !== 0) { + throw new Error(`ASCII "${name}": length must be a multiple of 8, got ${length}`); + } this.variable_length = true; } diff --git a/areas/sw_libs/parsley-ts/tests/fields.test.ts b/areas/sw_libs/parsley-ts/tests/fields.test.ts index 35c5e6d..8f8bfc2 100644 --- a/areas/sw_libs/parsley-ts/tests/fields.test.ts +++ b/areas/sw_libs/parsley-ts/tests/fields.test.ts @@ -3,9 +3,6 @@ import { ASCII, Enum, Numeric, Floating, Switch, Bitfield } from '../src/fields. import { BoardTypeId, BoardErrorBitfieldOffset } from '../src/messageTypes.js' // Adapted from waterloo-rocketry/parsley tests/test_fields.py. -// The Python tests use `bytes`; we use the bigint LSB-aligned form that -// BitString.pop()/push() exchange with field encoders. Conversion: a byte -// sequence b"\xAA\xBB" of length 16 maps to the bigint 0xAABBn. describe('ASCII', () => { it('encode and decode round-trip', () => { @@ -22,8 +19,6 @@ describe('ASCII', () => { }) it('decodes implicit leading zeros (front padding)', () => { - // Equivalent to Python's a.decode(b"\x57") with length 32 — the bigint - // is 0x57n and the decoder reconstitutes the full 4-byte field. const a = new ASCII('string', 32) expect(a.decode(0x57n)).toBe('W') }) @@ -55,13 +50,19 @@ describe('ASCII', () => { it('throws on non-ASCII characters', () => { const a = new ASCII('string', 16) - expect(() => a.encode('😎')).toThrow() + expect(() => a.encode('💀')).toThrow() + expect(() => a.encode('🥀')).toThrow() }) it('throws when string overflows the field', () => { const a = new ASCII('string', 16) expect(() => a.encode('xdd')).toThrow() }) + + it('rejects non-byte-aligned lengths at construction', () => { + expect(() => new ASCII('string', 12)).toThrow() + expect(() => new ASCII('string', 7)).toThrow() + }) }) describe('Enum', () => { @@ -182,6 +183,15 @@ describe('Numeric', () => { expect(() => n.encode(5)).toThrow() }) + it('uses floor when encoding negative scaled values', () => { + // -13 / 2 = -6.5. Python's int(value // scale) = -7 (floor). + // Math.trunc would give -6, which would be wrong. + const n = new Numeric('time', 16, 2, true) + const [data] = n.encode(-13) + // -7 in two's complement, 16 bits = 0xFFF9 + expect(data).toBe(0xfff9n) + }) + it('signed two\'s complement decode', () => { const n = new Numeric('num', 8, 1, true) expect(n.decode(0xfcn)).toBe(-4) @@ -256,26 +266,27 @@ describe('Bitfield', () => { const make = () => new Bitfield('general_board_status', 16, 'E_NOMINAL', BoardErrorBitfieldOffset) - const singleBitCases: [bigint, string][] = [ - [0x0000n, 'E_NOMINAL'], - [0x0001n, 'E_5V_OVER_CURRENT'], - [0x0002n, 'E_5V_OVER_VOLTAGE'], - [0x0004n, 'E_5V_UNDER_VOLTAGE'], - [0x0008n, 'E_12V_OVER_CURRENT'], - [0x0010n, 'E_12V_OVER_VOLTAGE'], - [0x0020n, 'E_12V_UNDER_VOLTAGE'], - [0x0040n, 'E_BATT_OVER_CURRENT'], - [0x0080n, 'E_BATT_OVER_VOLTAGE'], - [0x0100n, 'E_BATT_UNDER_VOLTAGE'], - [0x0200n, 'E_MOTOR_OVER_CURRENT'], - [0x0400n, 'E_IO_ERROR'], - [0x0800n, 'E_FS_ERROR'], - [0x1000n, 'E_WATCHDOG_TIMEOUT'], - [0x2000n, 'E_12V_EFUSE_FAULT'], - [0x4000n, 'E_5V_EFUSE_FAULT'], - [0x8000n, 'E_PT_OUT_OF_RANGE'], + // [bigint value, hex display, expected flag] + const singleBitCases: [bigint, string, string][] = [ + [0x0000n, '0x0000', 'E_NOMINAL'], + [0x0001n, '0x0001', 'E_5V_OVER_CURRENT'], + [0x0002n, '0x0002', 'E_5V_OVER_VOLTAGE'], + [0x0004n, '0x0004', 'E_5V_UNDER_VOLTAGE'], + [0x0008n, '0x0008', 'E_12V_OVER_CURRENT'], + [0x0010n, '0x0010', 'E_12V_OVER_VOLTAGE'], + [0x0020n, '0x0020', 'E_12V_UNDER_VOLTAGE'], + [0x0040n, '0x0040', 'E_BATT_OVER_CURRENT'], + [0x0080n, '0x0080', 'E_BATT_OVER_VOLTAGE'], + [0x0100n, '0x0100', 'E_BATT_UNDER_VOLTAGE'], + [0x0200n, '0x0200', 'E_MOTOR_OVER_CURRENT'], + [0x0400n, '0x0400', 'E_IO_ERROR'], + [0x0800n, '0x0800', 'E_FS_ERROR'], + [0x1000n, '0x1000', 'E_WATCHDOG_TIMEOUT'], + [0x2000n, '0x2000', 'E_12V_EFUSE_FAULT'], + [0x4000n, '0x4000', 'E_5V_EFUSE_FAULT'], + [0x8000n, '0x8000', 'E_PT_OUT_OF_RANGE'], ] - it.each(singleBitCases)('decode 0x%s → %s', (data, expected) => { + it.each(singleBitCases)('decode %s → %s', (data, _hex, expected) => { expect(make().decode(data)).toBe(expected) }) From 502e3fff05e62aa6f4f63132f864dbfba0354849 Mon Sep 17 00:00:00 2001 From: Shrey Date: Sun, 7 Jun 2026 11:55:52 -0400 Subject: [PATCH 4/6] Migrate fields tests to RocketCAN 2026.4 snake_case enums messageTypes was renamed CamelCase->snake_case upstream and the board error bitfield grew past 16 bits, so widen the bitfield test to 32 bits to match the spec. --- areas/sw_libs/parsley-ts/tests/fields.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/areas/sw_libs/parsley-ts/tests/fields.test.ts b/areas/sw_libs/parsley-ts/tests/fields.test.ts index 8f8bfc2..ec0f601 100644 --- a/areas/sw_libs/parsley-ts/tests/fields.test.ts +++ b/areas/sw_libs/parsley-ts/tests/fields.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest' import { ASCII, Enum, Numeric, Floating, Switch, Bitfield } from '../src/fields.js' -import { BoardTypeId, BoardErrorBitfieldOffset } from '../src/messageTypes.js' +import { board_type_id, board_error_bitfield_offset } from '../src/messageTypes.js' // Adapted from waterloo-rocketry/parsley tests/test_fields.py. @@ -67,7 +67,7 @@ describe('ASCII', () => { describe('Enum', () => { it('encodes and decodes against a real map', () => { - const e = new Enum('enum', 8, BoardTypeId) + const e = new Enum('enum', 8, board_type_id) const [data, length] = e.encode('INJECTOR') expect(data).toBe(0x01n) expect(length).toBe(8) @@ -264,7 +264,7 @@ describe('Switch', () => { describe('Bitfield', () => { const make = () => - new Bitfield('general_board_status', 16, 'E_NOMINAL', BoardErrorBitfieldOffset) + new Bitfield('general_board_status', 32, 'E_NOMINAL', board_error_bitfield_offset) // [bigint value, hex display, expected flag] const singleBitCases: [bigint, string, string][] = [ @@ -316,7 +316,7 @@ describe('Bitfield', () => { it.each(encodeCases)('encode %s', (value, expected) => { const [data, length] = make().encode(value) expect(data).toBe(expected) - expect(length).toBe(16) + expect(length).toBe(32) }) it('encode/decode round-trip', () => { From a94b107eb68c37d70251122eafd86336f11d1778 Mon Sep 17 00:00:00 2001 From: Shrey Date: Thu, 11 Jun 2026 08:38:28 -0400 Subject: [PATCH 5/6] Update import path for messageTypes -> message_types rename --- areas/sw_libs/parsley-ts/tests/fields.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/areas/sw_libs/parsley-ts/tests/fields.test.ts b/areas/sw_libs/parsley-ts/tests/fields.test.ts index ec0f601..f24b72c 100644 --- a/areas/sw_libs/parsley-ts/tests/fields.test.ts +++ b/areas/sw_libs/parsley-ts/tests/fields.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest' import { ASCII, Enum, Numeric, Floating, Switch, Bitfield } from '../src/fields.js' -import { board_type_id, board_error_bitfield_offset } from '../src/messageTypes.js' +import { board_type_id, board_error_bitfield_offset } from '../src/message_types.js' // Adapted from waterloo-rocketry/parsley tests/test_fields.py. From 47949bf9af5edc555cb1d51d02ae12ea915ff5c5 Mon Sep 17 00:00:00 2001 From: Shrey Date: Sun, 4 Oct 2026 19:43:41 -0400 Subject: [PATCH 6/6] fix lint --- areas/sw_libs/parsley-ts/src/fields.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/areas/sw_libs/parsley-ts/src/fields.ts b/areas/sw_libs/parsley-ts/src/fields.ts index 8ef52ad..40afed3 100644 --- a/areas/sw_libs/parsley-ts/src/fields.ts +++ b/areas/sw_libs/parsley-ts/src/fields.ts @@ -81,7 +81,7 @@ export class ASCII extends Field { const bytes = Buffer.from(value, "ascii"); // Detect non-ASCII - if (!/^[\x00-\x7F]*$/.test(value)) { + if ([...value].some((c) => c.charCodeAt(0) > 0x7f)) { throw new Error(`${value} contains non-ascii character(s)`); } if (this.length < 8 * bytes.length) {