diff --git a/models.py b/models.py index 5f88067..bec54aa 100644 --- a/models.py +++ b/models.py @@ -549,41 +549,64 @@ class SettleBalanceData(BaseModel): # ============================================================================= -# Cassette configs — operator-driven ATM cassette inventory (#29). +# Cassette configs — operator-driven ATM cassette inventory (#29 v1.1). # ============================================================================= -# Schema is denomination-keyed per the locked design (#29 body + the -# 06:40Z coord-log audit): every ATM-side layer below the wire keys on -# denomination (state-store.ts:54, hal-service.ts:116/189). The -# satmachineadmin schema mirrors this so the operator UI can't author a -# duplicate-denomination payload that the ATM would silently collapse. +# Schema is position-keyed per the coordinated v1.1 redesign at coord-log +# 2026-05-30T18:30Z + 18:45Z. The earlier denomination-keyed shape (m007) +# was wrong: real machines have N cassettes of the same denomination for +# cash-out throughput, and operators need to swap cartridge denominations +# during refill ($20 bay becomes a $50 bay) without re-provisioning. # -# Position is operator-assignable display order (and used by the ATM as -# the HAL slot-index assignment), not the addressable unit. +# Wire shape: +# {"positions": {"": {"denomination": N, "count": M}}} # -# state_count / state_at / state_event_id are reserved nullable from day 1 -# for the v2 reverse-channel reconciliation consumer (bitspire-cassettes- -# state:). v1 populates them on bootstrap-event receipt -# but the UI doesn't render reconciliation. +# Editable surface per row: +# - denomination: yes (operator swaps cartridges during refill) +# - count: yes (refill / decrement) +# Read-only per row: +# - position: hardware bay number; the slot count is fixed by the +# dispenser model (e.g., Tejo has 4 positions). +# +# No "denomination must be unique within payload" constraint: multiple +# same-denom cassettes are operationally valid. The ATM HAL distributes +# a dispense request greedy across all positions matching the requested +# denomination (lamassu-next#56 v1.1 HAL refactor). +# +# state_* columns are reserved nullable for the v2 reverse-channel +# reconciliation consumer (bitspire-cassettes-state:). +# v1 populates them on bootstrap-event receipt but the UI doesn't render +# reconciliation. state_denomination (added in m008) lets v2 highlight +# operator-believed-vs-ATM-reported denomination drift per slot. class CassetteConfig(BaseModel): machine_id: str + position: int denomination: int count: int - position: int updated_at: datetime updated_by: Optional[str] + state_denomination: Optional[int] state_count: Optional[int] state_at: Optional[datetime] state_event_id: Optional[str] class UpsertCassetteConfigData(BaseModel): - """Operator edits a single cassette row's count or position from the - dashboard. Both fields optional; pass only those changed.""" + """Operator edits a single cassette row's denomination or count from + the dashboard. Both fields optional; pass only those changed. + Position is not edited — it's the row's identity (hardware bay).""" + denomination: Optional[int] = None count: Optional[int] = None - position: Optional[int] = None + + @validator("denomination") + def denomination_positive(cls, v): + if v is None: + return v + if v <= 0: + raise ValueError("denomination must be > 0") + return v @validator("count") def count_non_negative(cls, v): @@ -593,26 +616,18 @@ class UpsertCassetteConfigData(BaseModel): raise ValueError("count must be >= 0") return v - @validator("position") - def position_positive(cls, v): - if v is None: - return v - if v <= 0: - raise ValueError("position must be > 0") - return v - class CassettePayloadRow(BaseModel): - """One denomination's payload values in the wire-format - `{"denominations": {"": {"position", "count"}}}`.""" + """One position's payload values in the wire-format + `{"positions": {"": {"denomination", "count"}}}`.""" - position: int + denomination: int count: int - @validator("position") - def position_positive(cls, v): + @validator("denomination") + def denomination_positive(cls, v): if v <= 0: - raise ValueError("position must be > 0") + raise ValueError("denomination must be > 0") return v @validator("count") @@ -628,45 +643,45 @@ class PublishCassettesPayload(BaseModel): - operator → ATM (d-tag `bitspire-cassettes:`) - ATM → operator (d-tag `bitspire-cassettes-state:`) - Wire shape: `{"denominations": {"": {"position", "count"}}}`. + Wire shape: `{"positions": {"": {"denomination", "count"}}}`. JSON object keys are always strings; the validator coerces back to - int on parse. The denomination key set MUST match what the receiver - already has (no add / no remove from this payload). + int on parse. The position key set MUST match what the receiver + already has (slot count is hardware-fixed; no add/remove from this + payload). + + No denomination-unique constraint: multiple same-denom cassettes are + operationally valid (cash-out throughput on a popular denom). """ - denominations: dict[int, CassettePayloadRow] + positions: dict[int, CassettePayloadRow] - @validator("denominations", pre=True) + @validator("positions", pre=True) def coerce_string_keys_to_int(cls, v): if not isinstance(v, dict): - raise ValueError("denominations must be a dict") + raise ValueError("positions must be a dict") out = {} for k, val in v.items(): try: key_int = int(k) except (TypeError, ValueError) as exc: raise ValueError( - f"denomination key {k!r} is not an int" + f"position key {k!r} is not an int" ) from exc if key_int <= 0: - raise ValueError(f"denomination must be > 0 (got {key_int})") + raise ValueError(f"position must be > 0 (got {key_int})") out[key_int] = val return out - @validator("denominations") - def no_duplicate_positions(cls, v): - positions = [row.position for row in v.values()] - if len(set(positions)) != len(positions): - raise ValueError("duplicate position values in payload") - return v - def to_wire_dict(self) -> dict: """Serialise back to the wire format with string keys for JSON object compatibility. Used by the publisher to build the kind-30078 event content before NIP-44 v2 encryption.""" return { - "denominations": { - str(denom): {"position": row.position, "count": row.count} - for denom, row in self.denominations.items() + "positions": { + str(pos): { + "denomination": row.denomination, + "count": row.count, + } + for pos, row in self.positions.items() } }