import { isValidChecksum } from './binary'; import { NeedMoreBytesError, ParserError } from './errors'; class UnexpectedCharError extends Error {} type Obis = string; interface ObisRegister { obis: Obis; rawValue: string; } class ObisParserResult { registers: ObisRegister[]; constructor(registers: ObisRegister[]) { this.registers = registers; } getValueOfObis(anObisCode: Obis): string | null { const findResult = this.registers.find(register => register.obis === anObisCode); if (!findResult) { return null; } return findResult.rawValue; } getMeterNumber() { return this.getValueOfObis('C.1.0') ?? this.getValueOfObis('C.1.0.2'); } } /** * @class ObisParser * * Unified LL(1) grammar supporting both single-block and multi-block OBIS formats: * * START → TRASH BLOCK+ * BLOCK → HEADER BODY+ OPTIONAL_TERMINATOR * HEADER → 0x01 0x4F 0x42 0x02 * OPTIONAL_TERMINATOR → ε | 0x03 | 0x21 EOL* * BODY → EOL* OBIS 0x28 VALUE 0x29 * OBIS → ALPHANUM+ (0x2E ALPHANUM+)* * VALUE → PRINTABLE* * EOL → 0x0D | 0x0A * * Traditional single-block: 0x01 O B 0x02 entries 0x03 (treated as 1 block) * Multi-block format: Control chars + entries separated by ! terminators (N blocks) */ interface ObisParserOptions { multiBlock?: boolean; expectedBlocks?: number; checksumValidation?: ChecksumValidation; } export enum ChecksumValidation { NONE, CRC_INCLUDE_START, CRC_INCLUDE_END, CRC_EXCLUDE_BOTH, CRC_INCLUDE_BOTH, } export class ObisParser { byteArray: Uint8Array; position: number; registers: ObisRegister[]; multiBlockMode: boolean; expectedBlocks: number; startOfTextIndex: number; checksumValidation: ChecksumValidation; constructor() { this.byteArray = new Uint8Array(); this.position = 0; this.registers = []; this.multiBlockMode = false; this.expectedBlocks = 1; this.startOfTextIndex = 0; this.checksumValidation = ChecksumValidation.NONE; } error(debugString) { throw new UnexpectedCharError(debugString); } incomplete(debugString) { throw new NeedMoreBytesError(debugString); } peekByte(byte, lookahead = 0) { const position = this.position + lookahead; const nextByte = this.byteArray[position]; return position < this.byteArray.length && nextByte === byte; } matchByte(byte) { if (this.position === this.byteArray.length) { this.incomplete(`expected ${byte.toString(16)}`); } const nextByte = this.byteArray[this.position] as number; if (nextByte === byte) { this.position += 1; } else { this.error(`found ${nextByte.toString(16)} expected ${byte.toString(16)} index ${this.position}`); } } matchRegex(aRegex) { if (this.position === this.byteArray.length) { this.incomplete(`expected ${aRegex}`); } const nextByte = this.byteArray[this.position] as number; const nextByteAsString = String.fromCharCode(nextByte); if (!aRegex.test(nextByteAsString)) { this.error( `found ${nextByteAsString} (${nextByte.toString(16)}) expected ${aRegex} index ${this.position}`, ); } this.position += 1; return nextByteAsString; } peekRegex(aRegex, lookahead = 0) { const position = this.position + lookahead; if (position < 0 || position >= this.byteArray.length) { return false; } const nextByte = this.byteArray[position] as number; const nextByteAsString = String.fromCharCode(nextByte); return aRegex.test(nextByteAsString); } matchEnd() { return this.position === this.byteArray.length; } matchAnyByte() { if (this.position === this.byteArray.length) { this.incomplete('expected any byte'); } this.position += 1; } skipOne() { this.position = Math.min(this.position + 1, this.byteArray.length); } /** Multiblock (Holley) has simple header */ peekHeader() { return ( (this.multiBlockMode && this.peekByte(0x02)) || (this.peekByte(0x01, 0) && this.peekByte(0x4f, 1) && this.peekByte(0x42, 2) && this.peekByte(0x02, 3)) ); } trash() { while (!this.peekHeader() && !this.matchEnd()) { this.skipOne(); } } optionalEndOfLine() { while (this.peekByte(0x0d) || this.peekByte(0x0a)) { this.skipOne(); } } body() { const obis = this.obis(); this.matchByte(0x28); // '(' const rawValue = this.value(); this.matchByte(0x29); // ')' this.registers.push({ obis, rawValue, }); } oneOrMoreAlphaNumeric() { const string: string[] = []; while (this.peekRegex(/[A-Z0-9]/)) { string.push(this.matchRegex(/[A-Z0-9]/) as string); } return string.join(''); } value() { const string: string[] = []; while (!this.peekByte(0x29) && !this.matchEnd()) { string.push(this.matchRegex(/[A-Za-z0-9!"#$%&'*+,./:;<=>?@[\] ^_`{|}~-]/) as string); } return string.join(''); } obis() { let string = ''; const start = this.oneOrMoreAlphaNumeric(); string = start; while (this.peekByte(0x2e)) { this.matchByte(0x2e); const end = this.oneOrMoreAlphaNumeric(); string += `.${end}`; } return string; } /** * BODY+ → Parse one or more bodies * BODY → EOL* OBIS 0x28 VALUE 0x29 */ bodyPlus() { while (this.canParseBody()) { // Parse body - let any errors bubble up naturally (including NeedMoreBytesError) this.body(); } } matchChecksum() { const nextByte = this.byteArray[this.position] as number; if (!nextByte) { this.incomplete('expected checksum byte after block terminator'); } if (this.checksumValidation !== ChecksumValidation.NONE) { const dataFrame = this.byteArray.slice( this.startOfTextIndex, this.checksumValidation === ChecksumValidation.CRC_INCLUDE_END || this.checksumValidation === ChecksumValidation.CRC_INCLUDE_BOTH ? this.position : this.position - 1, ); if (!isValidChecksum(dataFrame, nextByte)) { this.error('invalid checksum byte'); } } this.matchAnyByte(); // checksum byte } /** * Skip separators between blocks: optional 0x04 and optional checksum byte * * This may be joined with optionalTerminator in the future */ consumeInterBlockSeparator() { if (this.peekByte(0x04) || this.peekByte(0x03)) { // Holley packets include ETX '!' followed by 0x04 and optional CHECKSUM BYTE before next block starts if (this.peekByte(0x04)) { this.matchByte(0x04); } if (this.peekByte(0x03)) { this.matchByte(0x03); } if ((!this.peekByte(0x02) && !this.matchEnd()) || this.peekByte(0x02, 1)) { this.matchChecksum(); } } } /** * Determine if another BODY production can be parsed */ canParseBody() { this.optionalEndOfLine(); if (this.position >= this.byteArray.length) { return false; } // Stop when we encounter block terminators: 0x03 (ETX) or 0x21 ('!') return !(this.peekByte(0x03) || this.peekByte(0x21)); } matchHeader() { this.startOfTextIndex = this.position; if (this.multiBlockMode) { this.matchByte(0x02); if ( this.checksumValidation === ChecksumValidation.CRC_INCLUDE_END || this.checksumValidation === ChecksumValidation.CRC_EXCLUDE_BOTH ) this.startOfTextIndex = this.position; } else { this.matchByte(0x01); this.matchByte(0x4f); // O this.matchByte(0x42); // B this.matchByte(0x02); if ( this.checksumValidation === ChecksumValidation.CRC_INCLUDE_END || this.checksumValidation === ChecksumValidation.CRC_EXCLUDE_BOTH ) this.startOfTextIndex = this.position; } } /** * OPTIONAL_TERMINATOR → ε | 0x03 | 0x21 EOL* * Match optional block terminator */ optionalTerminator() { let matched = false; this.optionalEndOfLine(); if (this.peekByte(0x03)) { this.matchByte(0x03); if ( ((!this.peekByte(0x02) && !this.matchEnd()) || this.peekByte(0x02, 1)) && ((!this.peekByte(0x01) && !this.matchEnd()) || this.peekByte(0x01, 1)) ) { this.matchChecksum(); } matched = true; } else if (this.peekByte(0x21)) { this.matchByte(0x21); this.optionalEndOfLine(); matched = true; } // else: ε (empty production - no terminator) return matched; } /** * BLOCK → HEADER BODY+ OPTIONAL_TERMINATOR * Parse a single block * * A block is considered complete if it ends with a terminator (0x03 or 0x21) */ block() { this.matchHeader(); this.bodyPlus(); return this.optionalTerminator(); } /** * START → TRASH BLOCK+ * Entry point for parsing */ start() { // TRASH: Skip any leading garbage until we find a valid start this.trash(); let lastBlockTerminated = false; let blocksParsed = 0; while (!this.matchEnd() && this.peekHeader() && blocksParsed < this.expectedBlocks) { lastBlockTerminated = this.block(); blocksParsed += 1; this.consumeInterBlockSeparator(); if (!this.multiBlockMode) { break; } } if (this.registers.length === 0) { this.incomplete('expected OBIS data'); } if (!lastBlockTerminated) { this.incomplete('expected terminator'); } if (blocksParsed < this.expectedBlocks) { this.incomplete(`expected at least ${this.expectedBlocks} blocks, found ${blocksParsed}`); } } parse(byteArray: Uint8Array, options?: ObisParserOptions): ObisParserResult { this.byteArray = byteArray; this.position = 0; this.registers = []; this.multiBlockMode = options?.multiBlock ?? false; this.expectedBlocks = options?.expectedBlocks ?? 1; this.startOfTextIndex = 0; this.checksumValidation = options?.checksumValidation ?? ChecksumValidation.NONE; try { this.start(); } catch (error) { if (error instanceof UnexpectedCharError) { throw new ParserError(error.message); } else { throw error; } } this.registers.reverse(); return new ObisParserResult(this.registers); } }