Format des trames
Chaque requête et chaque réponse est une seule trame de 8 positions :
| Position | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|
| Nom | SOH | LEN | SEQ | CMD | DATA | AMB | CRC16 (ASCII) | ETX |
| Longueur (octets) | 1 | 1 | 1 | 1 | 0–200 | 1 | 4 | 1 |
| Valeur | 01h | 20h–FFh | 20h–FFh | 20h–FFh | 20h–FFh | 05h | 30h–3Fh | 03h |
Description des champs
| Champ | Description |
|---|---|
SOH | Start of Header. Valeur fixe 01h. |
LEN | Octet 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. |
SEQ | Numé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. |
CMD | Code du type de commande (voir Référence des commandes), ex. C1h, C0h, 31h, 38h. |
DATA | Charge 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. |
AMB | Délimiteur. Valeur fixe 05h. Marque la fin de DATA. |
CRC16 | Somme de contrôle CRC16-Modbus de LEN…AMB, encodée en 4 octets ASCII (voir ci-dessous). |
ETX | End 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ôme0xA001, valeur initiale0xFFFF, réfléchi). Plage0000h–FFFFh. - Plage de calcul : les octets de
LENjusqu’àAMB(positions 2 à 6) :LEN, SEQ, CMD, DATA…, AMB. Cela n’inclut niSOHniETX. - Transmission : le résultat 16 bits est envoyé en 4 octets ASCII, quartet de poids fort en premier. Chaque quartet hexadécimal (
0–F, valeur 0 à 15) est transmis comme un octet égal àquartet + 0x30.
Ce n’est pas du texte hexadécimal standard. Les chiffres 0–9 correspondent à '0'–'9' (0x30–0x39), mais A–F correspondent à 0x3A–0x3F, 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ère | Sé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) | Champ | Signification |
|---|---|---|
01 | SOH | Start of Header |
24 | LEN | Longueur — pas de données, donc 0x24 |
20 | SEQ | Numéro de séquence (incrémenté à chaque requête) |
c1 | CMD | C1h — Status |
05 | AMB | Délimiteur de données |
38 3d 39 3a | CRC16 | Encodage ASCII 4 octets du CRC de cette trame (les caractères "8=9:") |
03 | ETX | End 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ées — dé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