Skip to content

Unidesa / Cirsa

Binary frames, a checksum that's a byte sum wearing a mask, and counters read by numeric id. Cirsa speaks the same thing, so --protocol cirsa gets the same simulator with a different name tag. Saves money on translators.

Frame

STX  SD  SO  NM  CMD  ND  DATA[ND]  CHK  EXT
02   ..  ..  ..  ..   ..  ........  ..   03
Field Size Meaning
STX 1 Always 0x02.
SD 1 Destination. Replies use 0xFF, because the readers scan for 02 FF.
SO 1 Source. Replies use 0x00.
NM 1 Message number. Replies echo the request's, so clients tracking it stay calm.
CMD 1 Command.
ND 1 Number of data bytes.
DATA ND Payload.
CHK 1 XOR55 checksum over STX through the end of DATA.
EXT 1 Always 0x03.

Total length is always 8 + ND.

XOR55

chk = (sum(frame_without_chk_and_ext) ^ 0x55) & 0xFF

That's the whole algorithm. Add the bytes, XOR with 0x55, keep the low byte. Don't overthink it. The people who designed it certainly didn't.

Commands

CMD Name Request data Reply data
0x0B Access anything 00
0x06 Logout anything 00
0x2E Custom selection (group, counter_id) pairs 00
0x16 Read counters group first last group first received (id value[4 LE])*
other Unknown anything 01, with the request's CMD echoed back

Custom selection (0x2E)

The payload is a list of (group, counter_id) pairs. The group byte is read and ignored, same as my emails. An odd trailing byte gets dropped. The counter ids become the new custom selection. An empty list keeps the old one.

Read counters (0x16)

The request has three bytes:

  • group: 0xFF reads the custom selection. Anything else (the readers use 0x00) reads the default list, whatever 0x2E picked.
  • first: an offset into that list, not a counter id. 0 is the start. The reader works out cnt = counter + first + 1 on its own.
  • last: the last offset to include. 0 means "to the end".

The reply carries group, first, the number of counters that follow, then 5 bytes per counter: the id, then the value as 4 bytes little endian, unsigned. Pages are capped at 32 counters. Nobody wants a 200-byte reply landing on a 9600 baud line.

Fewer than 3 request bytes gets error 01. The reader didn't say what it wanted, so it doesn't get it.

The default counter list, in order:

1, 2, 17, 3, 31, 32, 33, 34, 154, 4, 7, 8, 11, 12, 24, 35, 84, 97, 160, 161, 162, 163,
130, 131, 132, 133, 6, 80, 10, 81, 73, 114, 115, 116, 117, 122, 123, 124, 125

A real exchange

Generated by running the simulator with counter 1 at 123456 and counter 2 at 120000:

access         ->  02 00 00 01 0B 00 5B 03
               <-  02 FF 00 01 0B 01 00 5B 03

select [1, 2]  ->  02 00 00 02 2E 04 00 01 00 02 6C 03
               <-  02 FF 00 02 2E 01 00 67 03

read 0xFF 0 0  ->  02 00 00 03 16 03 FF 00 00 48 03
               <-  02 FF 00 03 16 0D FF 00 02 01 40 E2 01 00 02 C0 D4 01 00 B6 03

Read the last one right to left and it says: group FF, first 0, 2 counters, counter 01 is 0x0001E240 (123456), counter 02 is 0x0001D4C0 (120000). Little endian. Of course it's little endian.

Streams and bad frames

TCP doesn't care where your frames end. One read can hold half a frame, or three frames glued together. The CLI and the pytest fixture both buffer the stream and cut it into whole frames before the simulator sees them. Junk in front of an STX gets thrown away.

A frame with bad delimiters or a bad checksum gets logged and skipped. No reply at all. The client gets to sit in silence and think about what it did, which is what a real machine does with garbage.

The simulator's own handle() is stricter: it takes exactly one frame and raises ValueError on a bad one. Use that directly in unit tests when you want the error.

Building frames in Python

from morpheus_vlt.core.framing import UnidesaMessage, build_unidesa_reply, xor55_checksum

frame = build_unidesa_reply(sd=0x00, so=0x00, nm=0x01, cmd=0x16, data=bytes([0xFF, 0x00, 0x00]))
msg = UnidesaMessage.parse(frame)
msg.validate()  # raises ValueError if the delimiters or checksum are off

build_unidesa_reply builds any frame, request or reply. The name is a little optimistic.