Skip to Content
TCC Datamega TerminalFormat des trames

Format des trames

Chaque requête et chaque réponse est une seule trame de 8 positions :

Position12345678
NomSOHLENSEQCMDDATAAMBCRC16 (ASCII)ETX
Longueur (octets)11110–200141
Valeur01h20h–FFh20h–FFh20h–FFh20h–FFh05h30h–3Fh03h

Description des champs

ChampDescription
SOHStart of Header. Valeur fixe 01h.
LENOctet de longueur. Nombre d’octets de LEN (position 2) à AMB (position 6) inclus, plus un décalage fixe de 20h. Pour une trame de n octets de DATA : LEN = (1 + 1 + 1 + n + 1) + 0x20 = n + 0x24. Sans données (n = 0), LEN = 24h.
SEQNuméro de séquence. Requête : chaque nouvelle commande utilise une valeur différente dans la plage 20h–FFh. Réponse : le dispositif renvoie le SEQ de la requête.
CMDCode du type de commande (voir Référence des commandes), ex. C1h, C0h, 31h, 38h.
DATACharge utile de la commande : 0 à 200 octets de texte UTF-8 séparé par des virgules (avec échappement). Si une commande n’a pas de données, la longueur est 0.
AMBDélimiteur. Valeur fixe 05h. Marque la fin de DATA.
CRC16Somme de contrôle CRC16-Modbus de LEN…AMB, encodée en 4 octets ASCII (voir ci-dessous).
ETXEnd of Text. Valeur fixe 03h. Marque la fin de la trame.

La somme de contrôle CRC16 (le point à ne pas rater)

  • Algorithme : CRC16-Modbus (polynôme 0xA001, valeur initiale 0xFFFF, réfléchi). Plage 0000h–FFFFh.
  • Plage de calcul : les octets de LEN jusqu’à AMB (positions 2 à 6) : LEN, SEQ, CMD, DATA…, AMB. Cela n’inclut ni SOH ni ETX.
  • Transmission : le résultat 16 bits est envoyé en 4 octets ASCII, quartet de poids fort en premier. Chaque quartet hexadécimal (0F, valeur 0 à 15) est transmis comme un octet égal à quartet + 0x30.
⚠️

Ce n’est pas du texte hexadécimal standard. Les chiffres 09 correspondent à '0''9' (0x300x39), mais AF correspondent à 0x3A0x3F, soit les caractères :, ;, <, =, >, ?.

Exemple (issu de la spécification) : CRC 0x12AB → quartets 1, 2, A, B → octets 31h 32h 3Ah 3Bh → les caractères "12:;".

Échappement des caractères spéciaux dans DATA

Comme les champs de DATA sont séparés par des virgules et que la trame emploie des octets de contrôle, les caractères suivants doivent être remplacés par leur séquence d’échappement lorsqu’ils apparaissent dans une valeur texte de DATA (et inversés à la lecture d’une réponse) :

CaractèreSéquence d’échappement
\r (retour chariot)^xa;
\n (saut de ligne)^xd;
, (virgule)^x2c;
< (inférieur à)^lt;
> (supérieur à)^gt;
& (esperluette)^amp;

Les champs texte utilisent l’encodage UTF-8. L’échappement de la virgule est celui que vous rencontrerez le plus souvent : par exemple, une adresse 123 Main St, New York doit être envoyée comme 123 Main St^x2c; New York.

Les libellés de la spécification pour CR/LF apparaissent inversés — \r est listé avec ^xa; et \n avec ^xd;. Reproduisez exactement les séquences d’échappement du tableau ci-dessus plutôt que de les déduire de l’hexadécimal contenu dans les noms.

Une trame concrète

La commande de statut C1h ne porte aucune donnée. Sa trame de 10 octets sur le fil est 01 24 20 c1 05 38 3d 39 3a 03, qui se décompose ainsi :

Octet(s)ChampSignification
01SOHStart of Header
24LENLongueur — pas de données, donc 0x24
20SEQNuméro de séquence (incrémenté à chaque requête)
c1CMDC1h — Status
05AMBDélimiteur de données
38 3d 39 3aCRC16Encodage ASCII 4 octets du CRC de cette trame (les caractères "8=9:")
03ETXEnd of Text

Utilitaires de cadrage et d’analyse

Ces utilitaires, complets et testés, implémentent l’échappement, le CRC16-Modbus, l’encodage du CRC sur 4 octets, la construction d’une trame et la lecture d’une trame de réponse. Les trois implémentations produisent des trames identiques octet par octet. Copiez-les dans votre projet.

Python (datamega.py)

"""Datamega TCC serial framing helpers.""" SOH, AMB, ETX = 0x01, 0x05, 0x03 _ESCAPES = {"\r": "^xa;", "\n": "^xd;", ",": "^x2c;", "<": "^lt;", ">": "^gt;", "&": "^amp;"} def escape(text: str) -> str: for raw, alt in _ESCAPES.items(): text = text.replace(raw, alt) return text def unescape(text: str) -> str: for raw, alt in _ESCAPES.items(): text = text.replace(alt, raw) return text def crc16_modbus(data: bytes) -> int: crc = 0xFFFF for b in data: crc ^= b for _ in range(8): crc = (crc >> 1) ^ 0xA001 if (crc & 1) else (crc >> 1) return crc & 0xFFFF def crc_ascii(crc: int) -> bytes: # 4 hex nibbles, most-significant first, each nibble value + 0x30 return bytes(((crc >> shift) & 0xF) + 0x30 for shift in (12, 8, 4, 0)) def build_frame(seq: int, cmd: int, data: bytes = b"") -> bytes: length = (4 + len(data)) + 0x20 # LEN..AMB count + 0x20 offset body = bytes([length, seq, cmd]) + data + bytes([AMB]) # CRC range: LEN..AMB return bytes([SOH]) + body + crc_ascii(crc16_modbus(body)) + bytes([ETX]) def read_frame(port) -> tuple[int, str]: """Read one reply frame; return (cmd, RAW DATA string). Raises on timeout/CRC error. The DATA string keeps its field-separating commas and its escaped values. Split on ',' FIRST, then unescape each field (a value comma is `^x2c;`).""" b = port.read(1) while b and b[0] != SOH: # sync to SOH b = port.read(1) if not b: raise TimeoutError("No reply before serial read timeout") frame = bytearray([SOH]) while True: b = port.read(1) if not b: raise TimeoutError("Incomplete frame before read timeout") frame += b if b[0] == ETX: break # SOH LEN SEQ CMD [DATA...] AMB CRC(4) ETX cmd = frame[3] data = bytes(frame[4:-6]) want = crc_ascii(crc16_modbus(bytes(frame[1:-5]))) # CRC over LEN..AMB if bytes(frame[-5:-1]) != want: raise ValueError("CRC mismatch in reply frame") return cmd, data.decode("utf-8") # raw: split on "," then unescape each field _seq = 0x1F def _next_seq() -> int: global _seq _seq = 0x20 if _seq >= 0xFF else _seq + 1 # keep SEQ in 20h..FFh, unique per request return _seq def send_command(port, cmd: int, data: str = "") -> str: """Send one command and return the reply's RAW DATA string. `data` must already be the comma-joined field string with each *value* escaped (use `escape()` / the `field()` helper in the end-to-end example); the field-separating commas stay literal. The reply is returned raw: split it on "," then unescape each field — use `split_fields()`. """ port.reset_input_buffer() port.write(build_frame(_next_seq(), cmd, data.encode("utf-8"))) return read_frame(port)[1] def split_fields(reply: str) -> list[str]: """Split a reply into fields and unescape each value (split FIRST, then unescape).""" return [unescape(f) for f in reply.split(",")]

Passez data à send_command sous forme de chaîne de champs déjà jointe par des virgules, en appliquant escape() à chaque valeur au fur et à mesure (ainsi une vraie virgule dans une valeur devient ^x2c; tandis que les virgules de séparation des champs restent littérales). À la lecture, l’inverse : une réponse revient avec ses champs séparés par des virgules et ses valeurs encore échappéesdécoupez d’abord sur ,, puis déséchappez chaque champ (split_fields / Fields / fields). Ne déséchappez jamais la réponse entière avant de la découper (sinon une valeur contenant ^x2c; casserait le découpage — cas de la relecture d’avoir C4h/41h). Voir Transaction de facturation.

C# (TccProtocol.cs)

using System; using System.Collections.Generic; using System.IO.Ports; // NuGet: System.IO.Ports using System.Text; public static class TccProtocol { const byte SOH = 0x01, AMB = 0x05, ETX = 0x03; static readonly (string raw, string alt)[] Escapes = { ("\r", "^xa;"), ("\n", "^xd;"), (",", "^x2c;"), ("<", "^lt;"), (">", "^gt;"), ("&", "^amp;"), }; public static string Escape(string s) { foreach (var (r, a) in Escapes) s = s.Replace(r, a); return s; } public static string Unescape(string s) { foreach (var (r, a) in Escapes) s = s.Replace(a, r); return s; } public static ushort Crc16Modbus(byte[] data) { ushort crc = 0xFFFF; foreach (byte b in data) { crc ^= b; for (int i = 0; i < 8; i++) crc = (ushort)(((crc & 1) != 0) ? ((crc >> 1) ^ 0xA001) : (crc >> 1)); } return crc; } public static byte[] CrcAscii(int crc) => new[] { (byte)(((crc >> 12) & 0xF) + 0x30), (byte)(((crc >> 8) & 0xF) + 0x30), (byte)(((crc >> 4) & 0xF) + 0x30), (byte)(( crc & 0xF) + 0x30), }; public static byte[] BuildFrame(byte seq, byte cmd, byte[] data) { int len = (4 + data.Length) + 0x20; // LEN..AMB count + 0x20 var body = new List<byte> { (byte)len, seq, cmd }; body.AddRange(data); body.Add(AMB); // CRC range: LEN..AMB var frame = new List<byte> { SOH }; frame.AddRange(body); frame.AddRange(CrcAscii(Crc16Modbus(body.ToArray()))); frame.Add(ETX); return frame.ToArray(); } public static (byte cmd, string data) ReadFrame(SerialPort port) { int b; do { b = port.ReadByte(); } while (b != SOH); // sync to SOH (ReadByte throws TimeoutException) var frame = new List<byte> { SOH }; do { b = port.ReadByte(); frame.Add((byte)b); } while (b != ETX); var f = frame.ToArray(); var range = new byte[f.Length - 6]; // LEN..AMB Array.Copy(f, 1, range, 0, range.Length); var want = CrcAscii(Crc16Modbus(range)); for (int i = 0; i < 4; i++) if (f[f.Length - 5 + i] != want[i]) throw new Exception("CRC mismatch in reply frame"); var data = new byte[f.Length - 10]; // strip SOH LEN SEQ CMD ... AMB CRC(4) ETX Array.Copy(f, 4, data, 0, data.Length); return (f[3], Encoding.UTF8.GetString(data)); // raw: split then Unescape each field } static byte _seq = 0x1F; static byte NextSeq() => _seq = (byte)(_seq >= 0xFF ? 0x20 : _seq + 1); public static string SendCommand(SerialPort port, byte cmd, string data = "") { port.DiscardInBuffer(); var frame = BuildFrame(NextSeq(), cmd, Encoding.UTF8.GetBytes(data)); port.Write(frame, 0, frame.Length); return ReadFrame(port).data; // raw DATA; use Fields() to split + unescape } // Split a reply into fields, unescaping each value (split FIRST, then unescape). public static string[] Fields(string reply) { var parts = reply.Split(','); for (int i = 0; i < parts.Length; i++) parts[i] = Unescape(parts[i]); return parts; } }

Java (TccProtocol.java)

import com.fazecast.jSerialComm.SerialPort; // Maven: com.fazecast:jSerialComm import java.io.ByteArrayOutputStream; import java.nio.charset.StandardCharsets; import java.util.Arrays; public final class TccProtocol { static final int SOH = 0x01, AMB = 0x05, ETX = 0x03; static final String[][] ESCAPES = { {"\r", "^xa;"}, {"\n", "^xd;"}, {",", "^x2c;"}, {"<", "^lt;"}, {">", "^gt;"}, {"&", "^amp;"}, }; public static String escape(String s) { for (String[] e : ESCAPES) s = s.replace(e[0], e[1]); return s; } public static String unescape(String s) { for (String[] e : ESCAPES) s = s.replace(e[1], e[0]); return s; } public static int crc16Modbus(byte[] data) { int crc = 0xFFFF; for (byte bb : data) { crc ^= (bb & 0xFF); for (int i = 0; i < 8; i++) crc = ((crc & 1) != 0) ? ((crc >> 1) ^ 0xA001) : (crc >> 1); } return crc & 0xFFFF; } public static byte[] crcAscii(int crc) { return new byte[] { (byte) (((crc >> 12) & 0xF) + 0x30), (byte) (((crc >> 8) & 0xF) + 0x30), (byte) (((crc >> 4) & 0xF) + 0x30), (byte) (( crc & 0xF) + 0x30), }; } public static byte[] buildFrame(int seq, int cmd, byte[] data) { int len = (4 + data.length) + 0x20; // LEN..AMB count + 0x20 ByteArrayOutputStream body = new ByteArrayOutputStream(); body.write(len); body.write(seq); body.write(cmd); body.write(data, 0, data.length); body.write(AMB); // CRC range: LEN..AMB byte[] b = body.toByteArray(); ByteArrayOutputStream frame = new ByteArrayOutputStream(); frame.write(SOH); frame.write(b, 0, b.length); byte[] crc = crcAscii(crc16Modbus(b)); frame.write(crc, 0, crc.length); frame.write(ETX); return frame.toByteArray(); } public static String readFrame(SerialPort port) { byte[] one = new byte[1]; do { if (port.readBytes(one, 1) < 1) throw new RuntimeException("Read timeout"); } while ((one[0] & 0xFF) != SOH); // sync to SOH ByteArrayOutputStream frame = new ByteArrayOutputStream(); frame.write(SOH); do { if (port.readBytes(one, 1) < 1) throw new RuntimeException("Read timeout"); frame.write(one[0]); } while ((one[0] & 0xFF) != ETX); byte[] f = frame.toByteArray(); byte[] range = Arrays.copyOfRange(f, 1, f.length - 5); // LEN..AMB byte[] want = crcAscii(crc16Modbus(range)); for (int i = 0; i < 4; i++) if (f[f.length - 5 + i] != want[i]) throw new RuntimeException("CRC mismatch in reply frame"); byte[] data = Arrays.copyOfRange(f, 4, f.length - 6); // between CMD and AMB return new String(data, StandardCharsets.UTF_8); // raw: split then unescape each field } static int seq = 0x1F; static int nextSeq() { seq = (seq >= 0xFF) ? 0x20 : seq + 1; return seq; } public static String sendCommand(SerialPort port, int cmd, String data) { port.flushIOBuffers(); // drop any stale bytes before the exchange byte[] frame = buildFrame(nextSeq(), cmd, data.getBytes(StandardCharsets.UTF_8)); port.writeBytes(frame, frame.length); return readFrame(port); // raw DATA; use fields() to split + unescape } // Convenience overload for commands with no data (e.g. C1h, C4h, 41h, 43h, 38h, C9h). public static String sendCommand(SerialPort port, int cmd) { return sendCommand(port, cmd, ""); } // Split a reply into fields, unescaping each value (split FIRST, then unescape). public static String[] fields(String reply) { String[] parts = reply.split(",", -1); for (int i = 0; i < parts.length; i++) parts[i] = unescape(parts[i]); return parts; } }

Étape suivante → Transaction de facturation