Versioning Protocol Adapters Across Firmware Revisions

The same instrument model rarely behaves the same across its production life: a firmware point release renames a status field, swaps a comma-separated reply for JSON, or adds a command your adapter never sends. When one bench holds three revisions of the same power analyzer, a single hardcoded adapter silently misparses two of them. This guide builds a version-keyed adapter registry that reads the firmware string at connect, resolves the correct adapter by semantic-version range, and normalizes every reply into one stable internal schema — a discipline that sits directly under Protocol Abstraction Layers and keeps orchestration code oblivious to which revision answered.

Scope: One Model, Many Firmware Revisions

The narrow problem here is intra-model drift, not inter-vendor drift. You already own a working adapter for a given oscilloscope or source-measure unit; the challenge is that units on the same rig report FW 2.1.4, FW 3.0.0, and FW 3.2.1-rc2, and each revision changed the wire contract in a small but breaking way. This is orthogonal to framing raw byte streams — that layer is covered in implementing protocol abstraction in Python for legacy instruments — and orthogonal to mapping across vendors, which is the job of SCPI Command-Set Standardization. Here the vendor, model, and transport are fixed; only the firmware moves.

Two assumptions hold throughout. First, the instrument answers *IDN? with a comma-delimited vendor,model,serial,firmware tuple, and the firmware token is the sole authority on revision — reached over an already-open VISA Resource Manager session or a serial link. Second, every adapter, regardless of the revision it targets, must emit the same canonical dataclass, so the acquisition layer above never branches on version. The runtime is Python 3.11+ and depends only on the standard library.

Version Resolution: Max Satisfying Adapter

Adapter selection is a lookup over half-open version ranges. Each adapter declares the lowest firmware it supports; the adapter that owns a given device is the one with the highest floor that still does not exceed the device’s firmware. Formally, given the set of registered adapters A, each with an inclusive lower bound min_a and an exclusive upper bound max_a, the resolved adapter for a device at firmware v is:

Choosing the maximum satisfying floor — rather than the first match or the closest — is what makes the registry safe to extend. When a new revision 3.2.0 behaves like 3.0.0 until proven otherwise, you register nothing and it keeps resolving to the >=3.0.0 adapter; the day it diverges, you register a >=3.2.0 adapter and only devices at or above that line pick it up. Comparison must be true semantic-version ordering, so 3.10.0 > 3.2.0 (numeric per-field), never lexical string ordering where "3.10.0" < "3.2.0". Pre-release tags order below their release (3.2.0-rc1 < 3.2.0), matching the SemVer precedence rule so a release candidate never resolves to an adapter meant for the final firmware.

Firmware-version adapter resolution to a canonical schema On the left, an *IDN? reply string enters a FirmwareVersion parser that extracts the semantic version 3.2.1. The registry holds three version-ranged adapters: one covering firmware from 2.0.0 up to 3.0.0, one from 3.0.0 up to 3.2.0, and one from 3.2.0 onward. The resolver selects the adapter with the highest lower bound that still satisfies the device version — here the 3.2.0-and-up adapter. Each adapter parses its revision-specific reply format and all three emit the same canonical Reading schema of value, unit, and status, so orchestration code above never branches on firmware. VERSION-KEYED ADAPTER RESOLUTION *IDN? reply ACME,PA5,91,FW 3.2.1 FirmwareVersion.parse v=3.2.1 AdapterRegistry.resolve Adapter A [2.0.0, 3.0.0) CSV reply Adapter B [3.0.0, 3.2.0) renamed field Adapter C ← selected [3.2.0, ∞) JSON reply max floor ≤ v Reading value · unit · status canonical schema

The registry parses the firmware token, resolves the adapter whose lower bound is the greatest that still satisfies the device version, and that adapter normalizes its revision-specific reply into one canonical Reading the orchestration layer consumes unchanged.

A Version-Keyed Adapter Registry in Python

The module below parses the firmware token into a comparable FirmwareVersion, registers adapters against half-open ranges, and resolves an *IDN? string to exactly one adapter. Each adapter subclass owns the reply format of its revision band and returns the shared Reading; the registry never inspects a payload, and orchestration never inspects a version.

from __future__ import annotations

import json
import re
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from functools import total_ordering
from typing import Optional

_FW_RE = re.compile(r"(\d+)\.(\d+)\.(\d+)(?:[-.]?([0-9A-Za-z.]+))?")


class FirmwareResolutionError(Exception):
    """No registered adapter satisfies the device firmware."""


class ResponseSchemaError(Exception):
    """A reply did not match the format its adapter expects."""


@total_ordering
@dataclass(frozen=True, slots=True)
class FirmwareVersion:
    """Semantic-version triple with SemVer pre-release precedence."""

    major: int
    minor: int
    patch: int
    pre: Optional[str] = None  # e.g. "rc2"; None outranks any pre-release

    @classmethod
    def parse(cls, token: str) -> "FirmwareVersion":
        """Extract a version from a raw firmware token such as 'FW 3.2.1-rc2'."""
        m = _FW_RE.search(token)
        if m is None:
            raise FirmwareResolutionError(f"no version in firmware token {token!r}")
        return cls(int(m[1]), int(m[2]), int(m[3]), m[4] or None)

    def _key(self) -> tuple[int, int, int, int, str]:
        # A release (pre is None) sorts ABOVE any pre-release of the same triple.
        return (self.major, self.minor, self.patch, 0 if self.pre else 1, self.pre or "")

    def __lt__(self, other: "FirmwareVersion") -> bool:
        return self._key() < other._key()


@dataclass(frozen=True, slots=True)
class Reading:
    """Canonical, revision-agnostic measurement the orchestration layer sees."""

    value: float
    unit: str
    status: str


class RevisionAdapter(ABC):
    """Owns the wire contract for one contiguous firmware band."""

    #: Inclusive lower bound; the resolver picks the greatest floor <= device fw.
    min_fw: FirmwareVersion
    #: Exclusive upper bound; None means open-ended (newest band).
    max_fw: Optional[FirmwareVersion] = None

    def covers(self, fw: FirmwareVersion) -> bool:
        return self.min_fw <= fw and (self.max_fw is None or fw < self.max_fw)

    @abstractmethod
    def normalize(self, raw: bytes) -> Reading:
        """Parse this revision's reply into the canonical Reading."""


class CsvAdapter(RevisionAdapter):
    """<3.0.0: 'value,unit,flag' where flag 0=OK, 1=OVERRANGE."""

    min_fw = FirmwareVersion(2, 0, 0)
    max_fw = FirmwareVersion(3, 0, 0)

    def normalize(self, raw: bytes) -> Reading:
        parts = raw.decode("ascii", "replace").strip().split(",")
        if len(parts) != 3:
            raise ResponseSchemaError(f"expected 3 CSV fields, got {raw!r}")
        status = {"0": "ok", "1": "overrange"}.get(parts[2], "unknown")
        return Reading(float(parts[0]), parts[1], status)


class RenamedFieldAdapter(RevisionAdapter):
    """[3.0.0, 3.2.0): CSV order kept but the flag column became a word."""

    min_fw = FirmwareVersion(3, 0, 0)
    max_fw = FirmwareVersion(3, 2, 0)

    def normalize(self, raw: bytes) -> Reading:
        parts = raw.decode("ascii", "replace").strip().split(",")
        if len(parts) != 3:
            raise ResponseSchemaError(f"expected 3 CSV fields, got {raw!r}")
        return Reading(float(parts[0]), parts[1], parts[2].lower())


class JsonAdapter(RevisionAdapter):
    """>=3.2.0: JSON object; 'v'/'u'/'st' keys, status already lower-case."""

    min_fw = FirmwareVersion(3, 2, 0)
    max_fw = None

    def normalize(self, raw: bytes) -> Reading:
        try:
            obj = json.loads(raw)
            return Reading(float(obj["v"]), str(obj["u"]), str(obj["st"]))
        except (json.JSONDecodeError, KeyError, TypeError) as exc:
            raise ResponseSchemaError(f"malformed JSON reply {raw!r}") from exc


@dataclass
class AdapterRegistry:
    """Resolves an *IDN? string to the highest-floor adapter that fits."""

    _adapters: list[RevisionAdapter] = field(default_factory=list)

    def register(self, adapter: RevisionAdapter) -> None:
        self._adapters.append(adapter)

    def resolve(self, idn: str) -> RevisionAdapter:
        """Return the adapter whose lower bound is the greatest satisfying fw."""
        fw = FirmwareVersion.parse(idn.split(",")[-1])
        candidates = [a for a in self._adapters if a.covers(fw)]
        if not candidates:
            raise FirmwareResolutionError(
                f"firmware {fw} outside every registered adapter range"
            )
        return max(candidates, key=lambda a: a.min_fw)

The load-bearing choices are three. FirmwareVersion compares by a numeric tuple, so 3.10.0 correctly outranks 3.2.0 and a release outranks its own release candidates; parsing is a search, not a match, so a FW prefix or a trailing build tag never derails extraction. Registration is order-independent because resolve() takes the max over the covering set — you can append adapters in any order and still get the tightest fit. And covers() uses a half-open interval, so adjacent bands like [3.0.0, 3.2.0) and [3.2.0, ∞) never both claim 3.2.0.

Validating Resolution and Normalization

Prove the registry against a table of *IDN? strings before trusting it on a rig: assert that each firmware resolves to the intended adapter and that every adapter collapses its revision-specific reply into the identical canonical Reading.

def _registry() -> AdapterRegistry:
    reg = AdapterRegistry()
    for a in (CsvAdapter(), RenamedFieldAdapter(), JsonAdapter()):
        reg.register(a)
    return reg


def test_resolution_and_normalization() -> None:
    reg = _registry()
    cases = [
        ("ACME,PA5,11,FW 2.1.4", CsvAdapter, b"5.001,V,0"),
        ("ACME,PA5,22,FW 3.0.0", RenamedFieldAdapter, b"5.001,V,OK"),
        ("ACME,PA5,33,FW 3.10.2", JsonAdapter, b'{"v":5.001,"u":"V","st":"ok"}'),
        # A release candidate resolves BELOW the 3.2.0 final it precedes.
        ("ACME,PA5,44,FW 3.2.0-rc1", RenamedFieldAdapter, b"5.001,V,OK"),
    ]
    for idn, expected_cls, raw in cases:
        adapter = reg.resolve(idn)
        assert isinstance(adapter, expected_cls), (idn, type(adapter))
        assert adapter.normalize(raw) == Reading(5.001, "V", "ok")

On live hardware, three signals confirm health. Log the resolved adapter class alongside the parsed FirmwareVersion at connect — a fleet-wide audit of that log line is the fastest way to spot a unit whose firmware fell outside every band. Tag each Reading with its source firmware through your Metadata Injection Workflows so a downstream analysis can prove which revision produced a given sample. Finally, alarm on any ResponseSchemaError: a resolved adapter that then fails to parse means the wire format drifted inside a band you thought was stable, which no version gate can catch on its own.

Failure Modes Unique to Firmware-Versioned Adapters

Unknown newer firmware falling back wrongly. A revision 4.0.0 ships with a reworked reply, but your newest adapter is the open-ended [3.2.0, ∞) JsonAdapter, so it silently claims the device and misparses every reading. An unbounded top band is a standing hazard: it assumes forward compatibility you have not verified. Cap the newest adapter at the next known-unsafe major (max_fw = FirmwareVersion(4, 0, 0)) so an unrecognized firmware raises FirmwareResolutionError at connect instead of returning corrupt data mid-run. Fail loud at the boundary, never fall back optimistically.

A point release changing a field silently. Firmware 3.2.1 keeps the JSON shape but changes the st vocabulary from "ok" to "nominal", and because it still resolves to the >=3.2.0 adapter, the status string passes through unmapped. Version gates cannot see sub-band drift; only a normalization assertion can. Validate the enumerated status set inside normalize() and raise ResponseSchemaError on an unrecognized token, then split a new [3.2.1, ∞) adapter — this is the exact case where a capability flag probed at connect beats a hard version gate, since the flag reflects observed behavior rather than an assumed range.

Version-string format drift between vendor builds. The same model reports FW 3.2.1 from a factory build but 3.2.1.8842 or v3.2.1_beta from a field-service reflash. A parser anchored with match or a rigid split(".") throws on the longer form and the connect aborts. The search-based _FW_RE above tolerates a FW/v prefix and an extra build segment, but confirm the regex against every firmware string your fleet actually emits — dump the raw *IDN? token to the connect log so an unparseable format surfaces as a FirmwareResolutionError you can diagnose from hex, not a stack trace deep in acquisition.

Adapter selected but response schema still diverges. Resolution succeeding is not the same as normalization succeeding: a revision can land inside a registered band yet still return a reply the band’s adapter cannot parse — a partial firmware flash, or a mode the adapter never exercised. Keep resolution and normalization as separate error domains (FirmwareResolutionError vs ResponseSchemaError) so monitoring distinguishes “we have no adapter for this firmware” from “the adapter we chose does not fit the bytes.” The second is the signal to split a band; conflating them hides which correction the fleet needs. Classifying these two distinctly is what lets recovery routing treat them differently, the same discipline SCPI Command-Set Standardization applies when mapping vendor verbs to a canonical API.

← Back to Protocol Abstraction Layers

References