feat: add OTA data parser and fix verbatimModuleSyntax compliance

- Add lib/ota-data/ module: parses esp_ota_select_entry_t structures,
  determines active OTA partition from sequence numbers and CRC validation
- Fix all lib files to use `import type` for type-only imports to comply
  with TypeScript verbatimModuleSyntax (partition-table, nvs, app-image)
- Export ota-data from lib/index.ts
This commit is contained in:
kerms 2026-02-25 16:31:05 +01:00
parent 577b845afc
commit d32674617d
Signed by: kerms
GPG Key ID: 5432C10DDCF8DAD5
16 changed files with 151 additions and 14 deletions

View File

@ -1,5 +1,5 @@
import { readU8, readU16, readU32, readNullTermString } from '../shared/binary-reader';
import {
import type {
AppImageInfo, ImageHeader, ExtendedHeader, SegmentHeader, AppDescription,
} from './types';
import {

View File

@ -2,3 +2,4 @@ export * from './shared';
export * as nvs from './nvs';
export * as partitionTable from './partition-table';
export * as appImage from './app-image';
export * as otaData from './ota-data';

View File

@ -1,4 +1,5 @@
import { NvsPartition, NvsEntry, NvsType, NvsVersion, PageState, EntryState } from './types';
import type { NvsPartition, NvsEntry } from './types';
import { NvsType, NvsVersion, PageState, EntryState } from './types';
import {
PAGE_SIZE, PAGE_HEADER_SIZE, BITMAP_OFFSET, BITMAP_SIZE,
FIRST_ENTRY_OFFSET, ENTRY_SIZE, ENTRIES_PER_PAGE, KEY_FIELD_SIZE,

View File

@ -1,4 +1,5 @@
import { NvsPartition, NvsType, NvsVersion, PageState } from './types';
import type { NvsPartition } from './types';
import { NvsType, NvsVersion, PageState } from './types';
import {
PAGE_SIZE, PAGE_HEADER_SIZE, BITMAP_OFFSET, BITMAP_SIZE,
FIRST_ENTRY_OFFSET, ENTRY_SIZE, ENTRIES_PER_PAGE,

View File

@ -1,4 +1,5 @@
import { NvsPartition, NvsEntry, NvsType, NvsVersion, NvsEncoding, ENCODING_TO_TYPE } from './types';
import type { NvsPartition, NvsEntry, NvsEncoding } from './types';
import { NvsType, NvsVersion, ENCODING_TO_TYPE } from './types';
import { generateEntryId } from './nvs-partition';
/**

View File

@ -1,4 +1,5 @@
import { NvsPartition, NvsType, TYPE_TO_ENCODING, isPrimitiveType } from './types';
import type { NvsPartition } from './types';
import { NvsType, TYPE_TO_ENCODING, isPrimitiveType } from './types';
/** Convert Uint8Array to hex string */
function bytesToHex(data: Uint8Array): string {

View File

@ -1,7 +1,5 @@
import {
NvsEntry, NvsPartition, NvsFlashStats, NvsType, NvsVersion,
isPrimitiveType,
} from './types';
import type { NvsEntry, NvsPartition, NvsFlashStats } from './types';
import { NvsType, NvsVersion, isPrimitiveType } from './types';
import { ENTRIES_PER_PAGE, ENTRY_SIZE, PAGE_SIZE, MAX_KEY_LENGTH, MAX_NAMESPACES, MAX_STRING_LENGTH, MAX_BLOB_SIZE_V1, MAX_BLOB_SIZE_V2 } from './constants';
/** Generate a random unique ID for client-side entry tracking */

View File

@ -0,0 +1,8 @@
/** Size of one esp_ota_select_entry_t structure in bytes */
export const OTA_SELECT_ENTRY_SIZE = 32;
/** Minimum data needed: two entries */
export const OTA_DATA_MIN_SIZE = 2 * OTA_SELECT_ENTRY_SIZE;
/** Default number of OTA app partitions (used to derive partition name from seq) */
export const DEFAULT_NUM_OTA_PARTITIONS = 2;

9
lib/ota-data/index.ts Normal file
View File

@ -0,0 +1,9 @@
// Types
export type { OtaSelectEntry, OtaData } from './types';
export { OtaImgState, OTA_STATE_NAMES } from './types';
// Constants
export { OTA_SELECT_ENTRY_SIZE, OTA_DATA_MIN_SIZE, DEFAULT_NUM_OTA_PARTITIONS } from './constants';
// Parser
export { parseOtaData } from './parser';

78
lib/ota-data/parser.ts Normal file
View File

@ -0,0 +1,78 @@
import { readU32 } from '../shared/binary-reader';
import { readNullTermString } from '../shared/binary-reader';
import { crc32 } from '../shared/crc32';
import { OTA_SELECT_ENTRY_SIZE, OTA_DATA_MIN_SIZE, DEFAULT_NUM_OTA_PARTITIONS } from './constants';
import { OtaImgState } from './types';
import type { OtaSelectEntry, OtaData } from './types';
/**
* Parse a single esp_ota_select_entry_t (32 bytes):
* uint32_t ota_seq [0..3]
* uint8_t seq_label[20] [4..23]
* uint32_t ota_state [24..27]
* uint32_t crc [28..31]
*
* CRC is computed over the first 28 bytes.
*/
function parseEntry(data: Uint8Array, offset: number): OtaSelectEntry {
const seq = readU32(data, offset);
const label = readNullTermString(data, offset + 4, 20);
const state = readU32(data, offset + 24) as OtaImgState;
const storedCrc = readU32(data, offset + 28);
const computedCrc = crc32(data.subarray(offset, offset + 28));
return {
seq,
label,
state,
crc: storedCrc,
crcValid: storedCrc === computedCrc,
};
}
/** Check if an entry represents a valid (non-empty) OTA selection */
function isEntryValid(entry: OtaSelectEntry): boolean {
// seq of 0 or 0xFFFFFFFF means empty/erased
return entry.crcValid && entry.seq !== 0 && entry.seq !== 0xFFFFFFFF;
}
/**
* Parse OTA data partition binary.
*
* @param data - Raw bytes of the otadata partition (typically 0x2000 bytes, only first 64 used)
* @param numOtaPartitions - Number of OTA app partitions (default 2, used for partition name derivation)
*/
export function parseOtaData(data: Uint8Array, numOtaPartitions = DEFAULT_NUM_OTA_PARTITIONS): OtaData {
if (data.length < OTA_DATA_MIN_SIZE) {
throw new Error(`OTA data too short: ${data.length} bytes, need at least ${OTA_DATA_MIN_SIZE}`);
}
const entry0 = parseEntry(data, 0);
const entry1 = parseEntry(data, OTA_SELECT_ENTRY_SIZE);
// Determine active entry: the one with the higher valid seq
let activeIndex: number | null = null;
const valid0 = isEntryValid(entry0);
const valid1 = isEntryValid(entry1);
if (valid0 && valid1) {
activeIndex = entry0.seq >= entry1.seq ? 0 : 1;
} else if (valid0) {
activeIndex = 0;
} else if (valid1) {
activeIndex = 1;
}
let activeOtaPartition: string | null = null;
if (activeIndex !== null) {
const activeSeq = activeIndex === 0 ? entry0.seq : entry1.seq;
const partitionIndex = (activeSeq - 1) % numOtaPartitions;
activeOtaPartition = `ota_${partitionIndex}`;
}
return {
entries: [entry0, entry1],
activeIndex,
activeOtaPartition,
};
}

36
lib/ota-data/types.ts Normal file
View File

@ -0,0 +1,36 @@
/** OTA image state enum matching ESP-IDF esp_ota_img_states_t */
export enum OtaImgState {
NEW = 0x0,
PENDING_VERIFY = 0x1,
VALID = 0x2,
INVALID = 0x3,
ABORTED = 0x4,
UNDEFINED = 0xFFFFFFFF,
}
export const OTA_STATE_NAMES: Record<number, string> = {
[OtaImgState.NEW]: 'new',
[OtaImgState.PENDING_VERIFY]: 'pending_verify',
[OtaImgState.VALID]: 'valid',
[OtaImgState.INVALID]: 'invalid',
[OtaImgState.ABORTED]: 'aborted',
[OtaImgState.UNDEFINED]: 'undefined',
};
/** A single OTA select entry (parsed from 32-byte structure) */
export interface OtaSelectEntry {
seq: number;
label: string;
state: OtaImgState;
crc: number;
crcValid: boolean;
}
/** Parsed OTA data partition */
export interface OtaData {
entries: [OtaSelectEntry, OtaSelectEntry];
/** Index of the active entry (0 or 1), null if neither is valid */
activeIndex: number | null;
/** Derived OTA partition name, e.g. "ota_0", "ota_1" */
activeOtaPartition: string | null;
}

View File

@ -1,4 +1,5 @@
import { PartitionEntry, PartitionFlags, PartitionTable, PartitionType, NAME_TO_TYPE, subtypeFromName } from './types';
import type { PartitionEntry, PartitionTable } from './types';
import { PartitionFlags, PartitionType, NAME_TO_TYPE, subtypeFromName } from './types';
const U32_MAX = 0xFFFF_FFFF;

View File

@ -1,4 +1,5 @@
import { PartitionTable, TYPE_NAMES, getSubtypeName } from './types';
import type { PartitionTable } from './types';
import { TYPE_NAMES, getSubtypeName } from './types';
/** Format a number as hex with 0x prefix */
function toHex(val: number): string {

View File

@ -1,5 +1,6 @@
import { readU16, readU32, readNullTermString } from '../shared/binary-reader';
import { PartitionEntry, PartitionTable, PartitionType } from './types';
import type { PartitionEntry, PartitionTable } from './types';
import { PartitionType } from './types';
import { ENTRY_SIZE, ENTRY_MAGIC, MD5_MAGIC, NAME_FIELD_SIZE, MAX_ENTRIES } from './constants';
import { md5 } from './md5';

View File

@ -1,5 +1,5 @@
import { writeU16, writeU32, writeNullTermString } from '../shared/binary-writer';
import { PartitionTable } from './types';
import type { PartitionTable } from './types';
import { ENTRY_SIZE, ENTRY_MAGIC, MD5_MAGIC, NAME_FIELD_SIZE, TABLE_MAX_SIZE } from './constants';
import { md5 } from './md5';

View File

@ -1,4 +1,4 @@
import { PartitionEntry, PartitionTable } from './types';
import type { PartitionEntry, PartitionTable } from './types';
export interface PartitionValidationError {
type: 'overlap' | 'alignment' | 'duplicate_name';