/*********************************************************************************************************************
 *  CmdLine : Ligne de Commande Serial + Telnet  —  v20
 *  Implémentation
 *
 *  Sources et références :
 *    • Terminal.cpp — rweather/arduino-projects (MIT license)
 *      Origine des algorithmes : readKey(), machine d'état IAC,
 *      timeout ESC 40ms, backspace "\b \b".
 *    • RFC 854  — Telnet Protocol Specification
 *    • RFC 857  — Telnet Echo Option  (IAC WILL ECHO)
 *    • RFC 858  — Telnet Suppress Go Ahead Option (IAC WILL SGA)
 *
 *  Organisation du fichier :
 *    1. Constantes Telnet (IAC, WILL, WONT, DO, DONT, options)
 *    2. initSt()              — initialisation d'un CanalState
 *    3. Constructeur + begin()
 *    4. telnetCmd()           — envoi d'une négociation IAC
 *    5. readTelnetKey()       — machine d'état Telnet complète
 *    6. update()              — boucle Serial
 *    7. updateTelnet()        — boucle Telnet
 *    8. handleKey()           — dispatch commun Serial + Telnet
 *    9. onEnter()             — validation de ligne
 *   10. prompt()              — affichage du prompt
 *   11. redrawLine()          — réaffichage de la ligne
 *   12. historyPush()         — enregistrement historique
 *   13. historyNav()          — navigation historique ↑ ↓
 *   14. cmdStrcmpLower()      — comparaison insensible à la casse (helper)
 *   15. cmdStrncmpLower()     — idem sur N caractères (helper)
 *   16. tabComplete()         — complétion TAB + cycle Re-TAB
 *   17. moveCursor()          — déplacement curseur ← →
 *   18. insertChar()          — insertion caractère
 *   19. delLeft()             — suppression caractère (Backspace)
 *   20. processCommand()      — parse + exécution commande
 *   21. print / println       — sortie simultanée Serial + Telnet
 *   22. print(val, base)      — sortie avec base numérique (DEC/HEX/OCT/BIN)
 *   23. write(buf, len)       — écriture binaire brute simultanée
 *********************************************************************************************************************/

#include "CmdLineTelnet_UNOR4.h"
#include <string.h>   // memset, memcpy, memmove, strcmp, strncpy, strlen

// ── Constantes de négociation Telnet (RFC 854 / 857 / 858) ──────────────────
// Ces valeurs sont les octets envoyés dans les séquences IAC.
#define IAC       0xFF  // Interpret As Command — préfixe de toute commande Telnet
#define WILL      0xFB  // "Je vais faire ceci"  (déclaration serveur→client)
#define WONT      0xFC  // "Je ne ferai pas ceci" (refus serveur→client)
#define DO        0xFD  // "Fais ceci"            (demande serveur→client)
#define DONT      0xFE  // "Ne fais pas ceci"     (interdiction serveur→client)
#define OPT_ECHO  0x01  // Option ECHO (RFC 857) — gestion de l'écho caractères
#define OPT_SGA   0x03  // Option Suppress Go Ahead (RFC 858) — mode full-duplex


// ════════════════════════════════════════════════════════════════════════════
//  initSt()  —  initialisation d'un CanalState
//
//  Remet tous les champs à leur valeur par défaut.
//  Appelé dans le constructeur pour _stS (Serial) et _stT (Telnet).
//
//  IN   st : référence sur l'état à initialiser (modifié en place)
//  OUT  —
// ════════════════════════════════════════════════════════════════════════════
static void initSt(CanalState &st) {
    memset(&st, 0, sizeof(st));   // Met tout à 0 / false / '\0'

    st.histPos     = -1;          // -1 = on est sur la ligne courante (pas en navigation)
    st.tabMatchIdx = -1;          // -1 = pas de cycle TAB en cours
    st.tstate      = TS_INIT;     // Machine d'état Telnet au repos
    st.willEchoSent = false;      // Négociation WILL ECHO pas encore envoyée
}


// ════════════════════════════════════════════════════════════════════════════
//  Constructeur
//
//  Initialise les deux états de canaux (Serial et Telnet).
//  IN   stream : référence au stream Serial de l'Arduino
// ════════════════════════════════════════════════════════════════════════════
CmdLine::CmdLine(Stream &s) : _stream(s) {
    initSt(_stS);   // État canal Serial
    initSt(_stT);   // État canal Telnet
}


// ════════════════════════════════════════════════════════════════════════════
//  begin()
//
//  Enregistre la table des commandes et affiche le premier prompt Serial.
//  À appeler dans setup() après Serial.begin() et Ethernet.begin().
//
//  IN   cmds : tableau de cmd_t déclaré globalement dans le sketch
//  IN   n    : nombre d'entrées — utiliser countof(commands)
//  OUT  —    : affiche "> " sur Serial
// ════════════════════════════════════════════════════════════════════════════
void CmdLine::begin(const cmd_t *cmds, size_t n) {
    _commands = cmds;
    _num      = n;
    prompt(_stS, _stream);   // Premier prompt Serial
}


// ════════════════════════════════════════════════════════════════════════════
//  telnetCmd()
//
//  Envoie une séquence de négociation Telnet de 3 octets : IAC cmd opt.
//  Exemple : IAC WILL ECHO = { 0xFF, 0xFB, 0x01 }
//
//  IN   client : client Telnet destinataire
//  IN   cmd    : commande Telnet (WILL/WONT/DO/DONT)
//  IN   opt    : option (OPT_ECHO=0x01, OPT_SGA=0x03, autre)
//  OUT  —      : écrit 3 octets sur le socket TCP
// ════════════════════════════════════════════════════════════════════════════
void CmdLine::telnetCmd(EthernetClient &client, uint8_t cmd, uint8_t opt) {
    uint8_t buf[3] = { IAC, cmd, opt };
    client.write(buf, 3);
}


// ════════════════════════════════════════════════════════════════════════════
//  readTelnetKey()
//
//  Lit UN octet du client Telnet et le décode via la machine d'état.
//  La machine d'état est stockée dans _stT.tstate → persistante entre appels.
//
//  MACHINE D'ÉTAT (9 états) :
//  ┌──────────┬────────────────┬─────────────────────────────────────────┐
//  │ État     │ Entrée         │ Action / Transition                     │
//  ├──────────┼────────────────┼─────────────────────────────────────────┤
//  │ TS_INIT  │ 0xFF (IAC)     │ → TS_IAC                                │
//  │          │ 0x1B (ESC)     │ → TS_ESC, démarre timer                 │
//  │          │ 0x0D (CR)      │ KEY_RETURN + → TS_CR                    │
//  │          │ 0x0A (LF)      │ KEY_RETURN (LF seul)                    │
//  │          │ 0x09 (TAB)     │ KEY_TAB                                 │
//  │          │ 0x08/0x7F      │ KEY_BACKSPACE                           │
//  │          │ 0x20..0x7E     │ retourne le caractère (int)             │
//  │          │ autre          │ KEY_NONE (ignoré)                       │
//  ├──────────┼────────────────┼─────────────────────────────────────────┤
//  │ TS_CR    │ 0x0A (LF)      │ KEY_NONE (mange le LF de CRLF)         │
//  │          │ 0x00 (NUL)     │ KEY_NONE (mange le NUL de CR+NUL)      │
//  │          │ autre          │ → TS_INIT + traite normalement          │
//  ├──────────┼────────────────┼─────────────────────────────────────────┤
//  │ TS_ESC   │ '[' reçu       │ → TS_MATCH, reset timer                 │
//  │          │ timeout 40ms   │ → TS_INIT (ESC seul ignoré)            │
//  │          │ autre          │ → TS_INIT + traite normalement          │
//  ├──────────┼────────────────┼─────────────────────────────────────────┤
//  │ TS_MATCH │ 'A' (0x41)     │ KEY_UP    + → TS_INIT                  │
//  │          │ 'B' (0x42)     │ KEY_DOWN  + → TS_INIT                  │
//  │          │ 'C' (0x43)     │ KEY_RIGHT + → TS_INIT                  │
//  │          │ 'D' (0x44)     │ KEY_LEFT  + → TS_INIT                  │
//  │          │ timeout 200ms  │ → TS_INIT (séquence abandonnée)        │
//  │          │ autre lettre   │ KEY_NONE  + → TS_INIT                  │
//  ├──────────┼────────────────┼─────────────────────────────────────────┤
//  │ TS_IAC   │ WILL (0xFB)    │ → TS_WILL                              │
//  │          │ WONT (0xFC)    │ → TS_WONT                              │
//  │          │ DO   (0xFD)    │ → TS_DO                                │
//  │          │ DONT (0xFE)    │ → TS_DONT                              │
//  │          │ autre          │ → TS_INIT (NOP Telnet)                 │
//  ├──────────┼────────────────┼─────────────────────────────────────────┤
//  │ TS_WILL  │ n'importe quoi │ → TS_INIT                              │
//  │          │ (1er WILL vu)  │ + envoie IAC WILL ECHO + WILL SGA      │
//  │          │                │ + envoie IAC DONT <opt> si non accepté │
//  ├──────────┼────────────────┼─────────────────────────────────────────┤
//  │ TS_WONT  │ n'importe quoi │ → TS_INIT (refus client, ignoré)       │
//  │ TS_DONT  │ n'importe quoi │ → TS_INIT (refus client, ignoré)       │
//  ├──────────┼────────────────┼─────────────────────────────────────────┤
//  │ TS_DO    │ OPT_ECHO (0x01)│ → TS_INIT (confirme notre WILL ECHO)   │
//  │          │ OPT_SGA  (0x03)│ → TS_INIT + envoie WILL SGA            │
//  │          │ autre          │ → TS_INIT + envoie WONT <opt>          │
//  └──────────┴────────────────┴─────────────────────────────────────────┘
//
//  IN   client : client Telnet courant
//  OUT  int    : KEY_xxx, caractère ASCII 0x20..0x7E, ou KEY_NONE
// ════════════════════════════════════════════════════════════════════════════
int CmdLine::readTelnetKey(EthernetClient &client) {

    // ── Timeout état TS_ESC ───────────────────────────────────────────────────
    // Si on a reçu ESC mais rien d'autre dans les 40ms → ESC seul → abandon.
    // (ESC seul n'est pas une commande dans notre CLI, on l'ignore.)
    if (_stT.tstate == TS_ESC) {
        if (!client.available()) {
            if ((millis() - _stT.timer) >= ESC_TIMEOUT_MS) {
                _stT.tstate = TS_INIT;    // Timeout → on repart à zéro
            }
            return KEY_NONE;              // Rien à retourner
        }
        // Un octet est disponible → on tombe dans le switch ci-dessous
    }

    // ── Timeout état TS_MATCH (ESC[ reçu, attend A/B/C/D) ────────────────────
    if (_stT.tstate == TS_MATCH) {
        if (!client.available()) {
            if ((millis() - _stT.timer) >= SEQ_TIMEOUT_MS) {
                _stT.tstate = TS_INIT;    // Timeout → séquence incomplète abandonnée
            }
            return KEY_NONE;
        }
    }

    // ── Pas de données ────────────────────────────────────────────────────────
    if (!client.available()) return KEY_NONE;

    // ── Lecture d'un octet ────────────────────────────────────────────────────
    int ch = client.read();
    if (ch < 0) return KEY_NONE;
    unsigned char c = (unsigned char)ch;

    // ── Dispatch selon l'état courant ─────────────────────────────────────────
    switch (_stT.tstate) {

    // ── TS_CR : on vient d'émettre KEY_RETURN sur le CR ──────────────────────
    // RFC 854 : ENTER en Telnet = CR + LF  (CRLF)  ou  CR + NUL  (CR seul)
    // PuTTY ^M coché   : envoie 0x0D 0x0A
    // PuTTY ^M décoché : envoie 0x0D 0x00
    // MobaXterm        : envoie 0x0D 0x0A
    // On mange l'octet suivant dans les deux cas pour éviter un double ENTER.
    case TS_CR:
        _stT.tstate = TS_INIT;
        if (c == 0x0A || c == 0x00) {
            return KEY_NONE;    // LF ou NUL mangé — pas de double ENTER
        }
        // Autre octet → on sort du state CR et on traite normalement
        goto process_init;

    // ── TS_ESC : on a reçu ESC (0x1B), on attend '[' ─────────────────────────
    // Les flèches ANSI sont : ESC [ A/B/C/D
    case TS_ESC:
        if (c == '[') {
            // '[' reçu → on passe à TS_MATCH pour attendre la lettre finale
            _stT.tstate = TS_MATCH;
            _stT.timer  = millis();     // Reset le timer pour SEQ_TIMEOUT
            return KEY_NONE;
        }
        // Pas '[' → ce n'est pas une séquence de flèche connue → abandon
        _stT.tstate = TS_INIT;
        goto process_init;   // On traite c comme un caractère normal

    // ── TS_MATCH : on a reçu ESC[, on attend la lettre finale (A/B/C/D) ──────
    case TS_MATCH:
        _stT.tstate = TS_INIT;
        if (c == 0x41) return KEY_UP;     // A = flèche ↑
        if (c == 0x42) return KEY_DOWN;   // B = flèche ↓
        if (c == 0x43) return KEY_RIGHT;  // C = flèche →
        if (c == 0x44) return KEY_LEFT;   // D = flèche ←
        return KEY_NONE;   // Autre lettre finale → séquence non reconnue, ignorée

    // ── TS_IAC : on a reçu IAC (0xFF), on attend la commande Telnet ──────────
    case TS_IAC:
        switch (c) {
            case WILL: _stT.tstate = TS_WILL; break;   // Client va faire X
            case WONT: _stT.tstate = TS_WONT; break;   // Client ne fera pas X
            case DO:   _stT.tstate = TS_DO;   break;   // Client demande au serveur de faire X
            case DONT: _stT.tstate = TS_DONT; break;   // Client demande au serveur de ne pas faire X
            default:   _stT.tstate = TS_INIT; break;   // NOP ou autre commande Telnet → ignorée
        }
        return KEY_NONE;

    // ── TS_WILL : le client déclare WILL <option> ────────────────────────────
    // Le client indique qu'il va activer l'option c.
    // C'est l'occasion de répondre WILL ECHO (désactive l'écho local PuTTY).
    // On ne répond qu'une seule fois grâce au flag willEchoSent.
    case TS_WILL:
        _stT.tstate = TS_INIT;
        if (!_stT.willEchoSent) {
            _stT.willEchoSent = true;
            telnetCmd(client, WILL, OPT_ECHO);  // IAC WILL ECHO : c'est nous qui gérons l'écho
            telnetCmd(client, WILL, OPT_SGA);   // IAC WILL SGA  : mode full-duplex
        }
        // On accepte WindowSize (0x1F) et Remote Flow Control (0x21)
        // On refuse poliment toutes les autres options du client
        if (c != 0x1F && c != 0x21) {
            telnetCmd(client, DONT, c);   // IAC DONT <opt>
        }
        return KEY_NONE;

    // ── TS_WONT / TS_DONT : le client refuse une option ──────────────────────
    // Le client dit qu'il ne fera pas X ou nous demande de ne pas faire X.
    // On n'a rien à faire → retour à l'état normal.
    case TS_WONT:
    case TS_DONT:
        _stT.tstate = TS_INIT;
        return KEY_NONE;

    // ── TS_DO : le client demande au serveur de faire <option> ───────────────
    case TS_DO:
        _stT.tstate = TS_INIT;
        if (c == OPT_ECHO) {
            // DO ECHO : le client confirme qu'il veut qu'on gère l'écho
            // → c'est la réponse à notre WILL ECHO, rien à faire de plus
        } else if (c == OPT_SGA) {
            telnetCmd(client, WILL, OPT_SGA);   // On accepte SGA
        } else {
            telnetCmd(client, WONT, c);          // On refuse les autres options DO
        }
        return KEY_NONE;

    // ── TS_INIT : état normal ─────────────────────────────────────────────────
    case TS_INIT:
    default:
        break;   // Traitement ci-dessous (process_init)
    }

// ── Traitement des octets en état normal (TS_INIT) ────────────────────────
process_init:

    if (c == IAC) {
        // Début de séquence de négociation Telnet
        _stT.tstate = TS_IAC;
        return KEY_NONE;
    }
    if (c == 0x1B) {
        // Début de séquence ESC (potentiellement une flèche ANSI)
        _stT.tstate = TS_ESC;
        _stT.timer  = millis();   // Démarre le timeout ESC_TIMEOUT_MS
        return KEY_NONE;
    }
    if (c == 0x0D) {
        // CR = ENTER
        // On passe en TS_CR pour manger le prochain LF ou NUL (RFC 854)
        _stT.tstate = TS_CR;
        return KEY_RETURN;
    }
    if (c == 0x0A) {
        // LF seul (sans CR précédent) = ENTER aussi
        return KEY_RETURN;
    }
    if (c == 0x09) {
        return KEY_TAB;       // TAB = complétion
    }
    if (c == 0x08 || c == 0x7F) {
        return KEY_BACKSPACE;  // BS (0x08) ou DEL (0x7F) = suppression
    }
    if (c >= 0x20 && c <= 0x7E) {
        return (int)c;         // Caractère imprimable ASCII → retourné tel quel
    }

    // Tout autre octet de contrôle (0x00..0x1F non géré, 0x80..0xFE) → ignoré
    return KEY_NONE;
}


// ════════════════════════════════════════════════════════════════════════════
//  update()  —  canal Serial
//
//  À appeler dans loop() à chaque cycle.
//  Lit tous les octets disponibles sur Serial et les traite.
//
//  Machine d'état ESC Serial — 3 états (_serialEscState) :
//    0  ──► octet normal   ──► traitement direct
//    0  ──► 0x1B (ESC)     ──► état 1
//    1  ──► '[' (0x5B)     ──► état 2
//    1  ──► autre          ──► état 0 (abandon)
//    2  ──► A (0x41)       ──► KEY_UP,    état 0
//    2  ──► B (0x42)       ──► KEY_DOWN,  état 0
//    2  ──► C (0x43)       ──► KEY_RIGHT, état 0
//    2  ──► D (0x44)       ──► KEY_LEFT,  état 0
//    2  ──► autre          ──► état 0 (ignoré)
//
//  Pas de timeout : Serial est synchrone, les 3 octets d'une flèche
//  arrivent dans la même lecture (contrairement à Telnet TCP).
//
//  OUT  — : appelle handleKey() pour chaque touche reconnue
// ════════════════════════════════════════════════════════════════════════════
void CmdLine::update() {
    while (_stream.available()) {
        unsigned char c = (unsigned char)_stream.read();
        int key = KEY_NONE;

        // ── Machine d'état ESC (état 1 : on attend '[') ──────────────────────
        if (_serialEscState == 1) {
            _serialEscState = (c == '[') ? 2 : 0;   // '[' → état 2, sinon abandon
            continue;
        }

        // ── Machine d'état ESC (état 2 : on attend A/B/C/D) ──────────────────
        if (_serialEscState == 2) {
            _serialEscState = 0;   // Retour à l'état normal dans tous les cas
            if      (c == 0x41) key = KEY_UP;
            else if (c == 0x42) key = KEY_DOWN;
            else if (c == 0x43) key = KEY_RIGHT;
            else if (c == 0x44) key = KEY_LEFT;
            if (key != KEY_NONE) handleKey(_stS, _stream, key);
            continue;
        }

        // ── Début séquence ESC ────────────────────────────────────────────────
        if (c == 0x1B) { _serialEscState = 1; continue; }

        // ── Caractères normaux ────────────────────────────────────────────────
        if      (c == 0x0D || c == 0x0A) key = KEY_RETURN;
        else if (c == 0x09)              key = KEY_TAB;
        else if (c == 0x08 || c == 0x7F) key = KEY_BACKSPACE;
        else if (c >= 0x20 && c <= 0x7E) key = (int)c;
        // Autres octets de contrôle → key reste KEY_NONE → ignorés

        if (key != KEY_NONE) handleKey(_stS, _stream, key);
    }
}


// ════════════════════════════════════════════════════════════════════════════
//  updateTelnet()  —  canal Telnet
//
//  À appeler dans loop() à chaque cycle.
//  Appelle readTelnetKey() en boucle tant que des touches sont disponibles,
//  puis passe chaque touche décodée à handleKey().
//  Mémorise le pointeur client pour que print/println écrivent sur Telnet.
//
//  IN   client : EthernetClient courant (résultat de serverTelnet.available())
//  OUT  —      : appelle handleKey() pour chaque touche décodée
// ════════════════════════════════════════════════════════════════════════════
void CmdLine::updateTelnet(EthernetClient &client) {
    _c = &client;           // Mémorise le client pour print() / println()
    if (!client) return;    // Pas de client connecté → rien à faire

    while (true) {
        int key = readTelnetKey(client);    // Décode un octet (machine d'état)
        if (key == KEY_NONE) break;         // Rien de disponible → on arrête
        handleKey(_stT, client, key);       // Traite la touche décodée
    }
}


// ════════════════════════════════════════════════════════════════════════════
//  handleKey()  —  dispatch commun Serial + Telnet
//
//  Reçoit une touche décodée (KEY_xxx ou caractère ASCII) et exécute
//  l'action correspondante. Identique pour les deux canaux.
//
//  IN   st  : état du canal concerné (_stS ou _stT)
//  IN   out : stream de sortie (Serial ou EthernetClient)
//  IN   key : KEY_RETURN, KEY_BACKSPACE, KEY_TAB, KEY_UP/DOWN/RIGHT/LEFT,
//             ou caractère imprimable 0x20..0x7E
//  OUT  —   : modifie st et écrit sur out
// ════════════════════════════════════════════════════════════════════════════
void CmdLine::handleKey(CanalState &st, Print &out, int key) {
    switch (key) {

    case KEY_RETURN:
        out.print(F("\r\n"));       // Descend d'une ligne sur le terminal
        st.tabMatchIdx = -1;        // Toute touche non-TAB réinitialise le cycle TAB
        onEnter(st, out);           // Valide + exécute + nouveau prompt
        break;

    case KEY_BACKSPACE:
        st.tabMatchIdx = -1;
        delLeft(st, out);           // Supprime le caractère à gauche du curseur
        break;

    case KEY_TAB:
        tabComplete(st, out);       // Complète / liste / cycle
        redrawLine(st, out);        // Filet de sécurité : écrase "^I" si écho PuTTY encore actif
        break;

    case KEY_UP:
        st.tabMatchIdx = -1;
        historyNav(st, out, -1);    // ↑ : remonte dans l'historique
        break;

    case KEY_DOWN:
        st.tabMatchIdx = -1;
        historyNav(st, out, +1);    // ↓ : redescend dans l'historique
        break;

    case KEY_RIGHT:
        st.tabMatchIdx = -1;
        moveCursor(st, out, +1);    // → : déplace le curseur vers la droite
        break;

    case KEY_LEFT:
        st.tabMatchIdx = -1;
        moveCursor(st, out, -1);    // ← : déplace le curseur vers la gauche
        break;

    default:
        // Caractère imprimable ASCII
        if (key >= 0x20 && key <= 0x7E) {
            st.tabMatchIdx = -1;
            insertChar(st, out, (unsigned char)key);
        }
        // Toute autre valeur → ignorée silencieusement
        break;
    }
}


// ════════════════════════════════════════════════════════════════════════════
//  onEnter()
//
//  Appelé quand KEY_RETURN est reçu.
//  1. Termine la chaîne dans st.buf
//  2. Si le buffer n'est pas vide : enregistre dans l'historique + exécute
//  3. Affiche le prompt suivant
//
//  IN   st, out
//  OUT  — : appelle historyPush(), processCommand(), prompt()
// ════════════════════════════════════════════════════════════════════════════
void CmdLine::onEnter(CanalState &st, Print &out) {
    st.buf[st.len] = '\0';                          // Termine proprement la chaîne
    if (st.len > 0) {
        historyPush(st, st.buf);                    // Enregistre dans l'historique
        processCommand(st, out);                    // Parse et exécute
    }
    prompt(st, out);                                // Affiche "> "
}


// ════════════════════════════════════════════════════════════════════════════
//  prompt()
//
//  Remet le buffer et les curseurs à zéro, puis affiche "> " sur le canal.
//
//  IN   st, out
//  OUT  — : remet st.len=0, st.cur=0, st.histPos=-1, st.lineSaved=false
//           affiche "> " sur out
// ════════════════════════════════════════════════════════════════════════════
void CmdLine::prompt(CanalState &st, Print &out) {
    st.len       = 0;
    st.cur       = 0;
    st.histPos   = -1;       // Remet la navigation historique au point de départ
    st.lineSaved = false;    // Invalide la sauvegarde de ligne
    out.print(F("> "));
}


// ════════════════════════════════════════════════════════════════════════════
//  redrawLine()
//
//  Réaffiche la ligne entière depuis le début de la ligne terminal.
//  Séquence envoyée :
//    \r          → retour colonne 0 (sans descendre d'une ligne)
//    "> "        → prompt
//    buf[0..len] → contenu du buffer
//    ESC[K       → efface jusqu'à la fin de la ligne (résidus éventuels)
//    ESC[D × N   → recule le curseur de N positions pour se placer en st.cur
//
//  Rôle Telnet :
//    Le \r ramène au début de ligne SANS descendre. Il écrase visuellement
//    ce que PuTTY a affiché localement (écho local) :
//      TAB    → affichait "^I"   avant la réponse Arduino
//      Flèche → affichait "^[[A" avant la réponse Arduino
//    Après redrawLine(), la ligne correcte écrase ces caractères parasites.
//
//  Rôle Serial :
//    Réaffiche proprement après une navigation historique ou une édition
//    au milieu de la ligne.
//
//  IN   st, out
//  OUT  — : écrit sur out
// ════════════════════════════════════════════════════════════════════════════
void CmdLine::redrawLine(CanalState &st, Print &out) {
    out.print('\r');                                       // Retour colonne 0
    out.print(F("> "));                                    // Prompt
    if (st.len > 0) {
        out.write((const uint8_t*)st.buf, st.len);        // Contenu du buffer
    }
    out.print(F("\033[K"));                                // Efface jusqu'à fin de ligne

    // Replace le curseur à la position st.cur
    // Si st.cur == st.len → curseur en fin, rien à faire
    // Si st.cur < st.len  → on recule de (len - cur) positions
    size_t back = st.len - st.cur;
    for (size_t i = 0; i < back; ++i) {
        out.print(F("\033[D"));    // ESC[D : curseur ← d'une position
    }
}


// ════════════════════════════════════════════════════════════════════════════
//  historyPush()
//
//  Ajoute une commande dans l'historique circulaire.
//  N'enregistre pas les lignes vides ni les doublons consécutifs.
//
//  Structure circulaire :
//    histHead pointe sur la prochaine case libre à écrire.
//    Après écriture, histHead avance mod CMDLINE_HISTORY_SIZE.
//    histCount compte les entrées valides (plafonne à CMDLINE_HISTORY_SIZE).
//
//  IN   st
//  IN   cmd : chaîne '\0'-terminée à enregistrer
//  OUT  — : modifie st.history, st.histHead, st.histCount
// ════════════════════════════════════════════════════════════════════════════
void CmdLine::historyPush(CanalState &st, const char *cmd) {
    if (!cmd[0]) return;    // Ligne vide → pas d'enregistrement

    // Vérifie le doublon avec la dernière entrée enregistrée
    if (st.histCount > 0) {
        int last = (st.histHead - 1 + CMDLINE_HISTORY_SIZE) % CMDLINE_HISTORY_SIZE;
        if (strcmp(st.history[last], cmd) == 0) return;    // Doublon → ignoré
    }

    // Enregistre dans la case histHead
    strncpy(st.history[st.histHead], cmd, CMDLINE_BUFFER_SIZE - 1);
    st.history[st.histHead][CMDLINE_BUFFER_SIZE - 1] = '\0';

    st.histHead = (st.histHead + 1) % CMDLINE_HISTORY_SIZE;        // Avance la tête
    if (st.histCount < CMDLINE_HISTORY_SIZE) ++st.histCount;        // Incrémente jusqu'au max
}


// ════════════════════════════════════════════════════════════════════════════
//  historyNav()
//
//  Navigue dans l'historique avec ↑ ou ↓.
//
//  Navigation ↑ (dir = -1, vers le passé) :
//    - Au premier ↑ : sauvegarde la ligne courante dans st.savedLine
//    - Incrémente st.histPos (0 = plus récent, histCount-1 = plus ancien)
//    - Si déjà sur la plus ancienne → ne fait rien
//
//  Navigation ↓ (dir = +1, vers le présent) :
//    - Décrémente st.histPos
//    - Si st.histPos revient à -1 → restaure st.savedLine (ligne originale)
//
//  Indexation dans le tableau circulaire :
//    L'entrée la plus récente est à (histHead - 1) mod CMDLINE_HISTORY_SIZE.
//    L'entrée N-ième (histPos = N) est à (histHead - 1 - N) mod CMDLINE_HISTORY_SIZE.
//
//  IN   st, out
//  IN   dir : -1 = ↑ (vers le passé)  /  +1 = ↓ (vers le présent)
//  OUT  — : modifie st.buf, st.len, st.cur, st.histPos, appelle redrawLine()
// ════════════════════════════════════════════════════════════════════════════
void CmdLine::historyNav(CanalState &st, Print &out, int dir) {
    if (!st.histCount) return;    // Historique vide → rien à faire

    if (dir == -1) {
        // ── ↑ : remonte vers le passé ────────────────────────────────────────
        if (st.histPos == -1) {
            // Premier ↑ : sauvegarde la ligne en cours de saisie
            strncpy(st.savedLine, st.buf, CMDLINE_BUFFER_SIZE);
            st.savedLine[CMDLINE_BUFFER_SIZE - 1] = '\0';
            st.lineSaved = true;
        }
        if (st.histPos < st.histCount - 1) ++st.histPos;
        else return;    // Déjà sur la plus ancienne → rien

    } else {
        // ── ↓ : redescend vers le présent ────────────────────────────────────
        if (st.histPos == -1) return;   // Déjà sur la ligne courante → rien
        --st.histPos;
    }

    if (st.histPos == -1) {
        // Retour sur la ligne originale → restaure la sauvegarde
        if (st.lineSaved) {
            strncpy(st.buf, st.savedLine, CMDLINE_BUFFER_SIZE);
            st.buf[CMDLINE_BUFFER_SIZE - 1] = '\0';
            st.len = strlen(st.buf);
        } else {
            st.buf[0] = '\0';
            st.len = 0;
        }
    } else {
        // Charge l'entrée d'historique correspondante
        // La plus récente est à (histHead - 1), la suivante à (histHead - 2)...
        int idx = (st.histHead - 1 - st.histPos + CMDLINE_HISTORY_SIZE) % CMDLINE_HISTORY_SIZE;
        strncpy(st.buf, st.history[idx], CMDLINE_BUFFER_SIZE);
        st.buf[CMDLINE_BUFFER_SIZE - 1] = '\0';
        st.len = strlen(st.buf);
    }

    st.cur = st.len;        // Curseur en fin de la ligne chargée
    redrawLine(st, out);    // Réaffiche proprement
}


// ════════════════════════════════════════════════════════════════════════════
//  cmdStrcmpLower()  —  comparaison insensible à la casse (helper interne)
//
//  Compare deux chaînes caractère par caractère en convertissant à la volée
//  chaque lettre en minuscule avant la comparaison.
//  Aucune des deux chaînes n'est modifiée.
//
//  Pourquoi cette fonction plutôt que strcasecmp() ?
//    strcasecmp() dépend de <strings.h> et n'est pas disponible sur toutes
//    les toolchains Arduino. Cette implémentation portable n'a aucune
//    dépendance externe et se comporte de façon identique pour l'ASCII.
//
//  Usage : permet de comparer l'entrée utilisateur (déjà en minuscules si
//    CMDLINE_CASE_INSENSITIVE=1) avec les noms de la table _commands[]
//    qui peuvent contenir des majuscules : SaveConf, LEDon, serveurWEB...
//    La table n'est PAS modifiée → les noms originaux sont préservés
//    pour l'affichage (complétion TAB, liste des commandes).
//
//  IN   a : première chaîne  (ex : "saveconf"  — entrée utilisateur)
//  IN   b : deuxième chaîne  (ex : "SaveConf"  — nom dans _commands[])
//  OUT  int : 0 si égales (insensible à la casse), ≠0 sinon
// ════════════════════════════════════════════════════════════════════════════
static int cmdStrcmpLower(const char *a, const char *b) {
    while (*a && *b) {
        char ca = (*a >= 'A' && *a <= 'Z') ? (*a + 32) : *a;
        char cb = (*b >= 'A' && *b <= 'Z') ? (*b + 32) : *b;
        if (ca != cb) return (int)ca - (int)cb;
        ++a; ++b;
    }
    return (int)*a - (int)*b;
}


// ════════════════════════════════════════════════════════════════════════════
//  cmdStrncmpLower()  —  comparaison insensible à la casse sur N caractères
//
//  Même principe que cmdStrcmpLower() mais limitée aux n premiers caractères.
//  Utilisée par tabComplete() pour comparer le préfixe saisi avec le début
//  des noms de commandes, sans modifier la table ni le buffer utilisateur.
//
//  IN   a : première chaîne  (ex : "save"     — préfixe utilisateur)
//  IN   b : deuxième chaîne  (ex : "SaveConf" — nom dans _commands[])
//  IN   n : nombre de caractères à comparer
//  OUT  int : 0 si les n premiers caractères sont égaux (insensible à la casse)
// ════════════════════════════════════════════════════════════════════════════
static int cmdStrncmpLower(const char *a, const char *b, size_t n) {
    for (size_t i = 0; i < n; ++i) {
        char ca = (a[i] >= 'A' && a[i] <= 'Z') ? (a[i] + 32) : a[i];
        char cb = (b[i] >= 'A' && b[i] <= 'Z') ? (b[i] + 32) : b[i];
        if (ca != cb) return (int)ca - (int)cb;
        if (ca == '\0') return 0;
    }
    return 0;
}


// ════════════════════════════════════════════════════════════════════════════
//  tabComplete()
//
//  Complétion TAB sur le préfixe st.buf[0..cur-1].
//
//  1er TAB (tabMatchIdx == -1) :
//    • Compte les commandes dont le nom commence par le préfixe courant.
//    • 0 match  → BEL (bip terminal, 0x07)
//    • 1 match  → complétion immédiate + espace + redrawLine
//    • N matchs → affiche la liste sur une nouvelle ligne
//                 charge la 1ère correspondance dans le buffer
//                 active le cycle Re-TAB (tabMatchIdx = 1)
//
//  Re-TAB (tabMatchIdx ≥ 0) :
//    • Charge la Nème correspondance dans le buffer (cycle)
//    • tabPrefixLen conservé → comparaison toujours sur le préfixe d'origine
//
//  Insensibilité à la casse (si CMDLINE_CASE_INSENSITIVE == 1) :
//    La comparaison utilise cmdStrncmpLower() → le préfixe "save" trouve
//    "SaveConf", "saveAll", "SAVECONF"... sans modifier la table ni le buffer.
//    Les noms affichés dans la liste TAB sont ceux de la table (casse originale).
//
//  IN   st, out
//  OUT  — : modifie st.buf, st.len, st.cur, st.tabMatchIdx, appelle redrawLine()
// ════════════════════════════════════════════════════════════════════════════
void CmdLine::tabComplete(CanalState &st, Print &out) {
    if (!_commands || !_num) return;

    if (st.tabMatchIdx == -1) {
        // ── Premier TAB ──────────────────────────────────────────────────────
        st.tabPrefixLen = st.cur;    // Mémorise la longueur du préfixe d'origine

        // Compte les correspondances en ignorant la casse (cmdStrncmpLower)
        // → "save" trouve "SaveConf", "saveAll", "SAVECONF"...
        int cnt = 0, first = -1;
        for (size_t i = 0; i < _num; ++i) {
            if (cmdStrncmpLower(st.buf, _commands[i].command, st.tabPrefixLen) == 0) {
                ++cnt;
                if (first < 0) first = (int)i;
            }
        }

        if (!cnt) {
            out.write((uint8_t)0x07);    // BEL : aucune correspondance
            return;
        }

        if (cnt == 1) {
            // Correspondance unique : complétion avec le nom original de la table
            // (casse préservée : "save" → "SaveConf")
            strncpy(st.buf, _commands[first].command, CMDLINE_BUFFER_SIZE - 1);
            st.buf[CMDLINE_BUFFER_SIZE - 1] = '\0';
            st.len = strlen(st.buf);
            st.cur = st.len;
            if (st.len < CMDLINE_BUFFER_SIZE - 1) {
                st.buf[st.len++] = ' ';    // Ajoute un espace après la commande
                st.cur = st.len;
            }
            redrawLine(st, out);
            return;    // tabMatchIdx reste -1 → prochain TAB repart à zéro
        }

        // N correspondances : affiche la liste (noms originaux) puis charge la 1ère
        out.print(F("\r\n"));
        for (size_t i = 0; i < _num; ++i) {
            if (cmdStrncmpLower(st.buf, _commands[i].command, st.tabPrefixLen) == 0) {
                out.print(F("  "));
                out.println(_commands[i].command);   // Affiche le nom original (SaveConf...)
            }
        }
        // Charge la 1ère correspondance (nom original) dans le buffer
        strncpy(st.buf, _commands[first].command, CMDLINE_BUFFER_SIZE - 1);
        st.buf[CMDLINE_BUFFER_SIZE - 1] = '\0';
        st.len = strlen(st.buf);
        st.cur = st.len;
        redrawLine(st, out);

        // Active le cycle Re-TAB en partant de la 2ème correspondance
        st.tabMatchCount = cnt;
        st.tabMatchIdx   = 1;    // La 1ère (index 0) vient d'être chargée
        return;
    }

    // ── Re-TAB : cycle sur les correspondances ────────────────────────────────
    // On utilise tabPrefixLen (mémorisé au 1er TAB) et cmdStrncmpLower().
    // buf contient déjà le nom complet de la dernière commande complétée
    // (ex: "SaveConf") → on compare ses N premiers caractères avec chaque
    // entrée de la table pour retrouver les correspondances du préfixe original.
    int target = st.tabMatchIdx % st.tabMatchCount;
    int found  = 0;

    for (size_t i = 0; i < _num; ++i) {
        if (cmdStrncmpLower(st.buf, _commands[i].command, st.tabPrefixLen) == 0) {
            if (found == target) {
                strncpy(st.buf, _commands[i].command, CMDLINE_BUFFER_SIZE - 1);
                st.buf[CMDLINE_BUFFER_SIZE - 1] = '\0';
                st.len = strlen(st.buf);
                st.cur = st.len;
                redrawLine(st, out);
                break;
            }
            ++found;
        }
    }
    ++st.tabMatchIdx;    // Avance le cycle pour le prochain TAB
}


// ════════════════════════════════════════════════════════════════════════════
//  moveCursor()
//
//  Déplace le curseur logique d'une position et envoie le code ANSI
//  correspondant au terminal pour bouger le curseur visuel.
//  Ne fait rien si le curseur est déjà aux bornes (pas de bip).
//
//  IN   st, out
//  IN   dir : -1 = ← gauche  /  +1 = → droite
//  OUT  — : modifie st.cur, envoie ESC[D ou ESC[C sur out
// ════════════════════════════════════════════════════════════════════════════
void CmdLine::moveCursor(CanalState &st, Print &out, int dir) {
    if (dir == -1 && st.cur > 0) {
        --st.cur;
        out.print(F("\033[D"));    // ESC[D : curseur ← d'une position
    } else if (dir == +1 && st.cur < st.len) {
        ++st.cur;
        out.print(F("\033[C"));    // ESC[C : curseur → d'une position
    }
    // Aux bornes → ignoré silencieusement
}


// ════════════════════════════════════════════════════════════════════════════
//  insertChar()
//
//  Insère le caractère c à la position st.cur dans st.buf.
//  Décale les caractères suivants vers la droite avec memmove.
//
//  Écho conditionné par _echoEnabled :
//    true  : écho normal — le caractère est renvoyé vers le terminal.
//            Nécessaire pour PuTTY Telnet (mode "remote echo").
//    false : pas d'écho brut du caractère — le terminal affiche lui-même
//            ce qu'il tape (MobaXterm, terminal avec "local echo ON").
//            Dans ce cas, si on est au milieu de la ligne, redrawLine()
//            est appelé pour que les caractères décalés s'affichent correctement.
//
//  Cas 1 — insertion en fin de ligne :
//    Écho : out.write(c) si _echoEnabled.
//
//  Cas 2 — insertion au milieu de la ligne :
//    Écho : out.write(c) + réécriture des décalés + ESC[K + repositionnement.
//    Sans écho : redrawLine() pour réafficher proprement les caractères décalés.
//
//  IN   st, out
//  IN   c : caractère imprimable 0x20..0x7E (garanti par handleKey)
//  OUT  — : modifie st.buf, st.len, st.cur, écrit sur out selon _echoEnabled
// ════════════════════════════════════════════════════════════════════════════
void CmdLine::insertChar(CanalState &st, Print &out, unsigned char c) {
    if (st.len >= CMDLINE_BUFFER_SIZE - 1) return;    // Buffer plein → ignoré

    // Décale les caractères à droite du curseur d'une position
    memmove(&st.buf[st.cur + 1], &st.buf[st.cur], st.len - st.cur);
    st.buf[st.cur] = (char)c;
    ++st.len;

    if (_echoEnabled) {
        if (st.cur == st.len - 1) {
            // ── Écho activé, fin de ligne : écho simple ───────────────────────
            out.write(c);
        } else {
            // ── Écho activé, milieu de ligne : réaffiche depuis le curseur ─────
            out.write(c);
            out.write((const uint8_t*)&st.buf[st.cur + 1], st.len - st.cur - 1);
            out.print(F("\033[K"));    // Efface le résidu en fin de ligne
            size_t back = st.len - st.cur - 1;
            for (size_t i = 0; i < back; ++i) out.print(F("\033[D"));
        }
    } else {
        // ── Écho désactivé : le terminal affiche lui-même le caractère ────────
        // Si on est au milieu de la ligne, les caractères décalés doivent
        // quand même être réaffichés correctement → redrawLine().
        if (st.cur < st.len - 1) {
            redrawLine(st, out);
        }
        // En fin de ligne : le terminal a déjà affiché le caractère, rien à faire.
    }

    ++st.cur;    // Avance le curseur logique
}


// ════════════════════════════════════════════════════════════════════════════
//  delLeft()
//
//  Supprime le caractère à gauche du curseur (Backspace).
//  Décale les caractères suivants vers la gauche avec memmove.
//
//  Méthode d'affichage "\b \b" (issue de Terminal.cpp) :
//    1. BS (0x08)   : recule le curseur terminal d'une position
//    2. Réécriture  : réécrit les caractères qui ont glissé vers la gauche
//    3. ESPACE      : efface le dernier caractère résiduel
//    4. BS × N      : recule le curseur à la bonne position finale
//
//  Cette méthode est universelle (PuTTY + MobaXterm + Serial).
//  Elle évite les séquences ESC[D qui peuvent se comporter différemment
//  selon la configuration du terminal.
//
//  IN   st, out
//  OUT  — : modifie st.buf, st.len, st.cur, écrit sur out
// ════════════════════════════════════════════════════════════════════════════
void CmdLine::delLeft(CanalState &st, Print &out) {
    if (!st.cur) return;    // Curseur au début → rien à supprimer

    // Décale les caractères à droite d'une position vers la gauche
    memmove(&st.buf[st.cur - 1], &st.buf[st.cur], st.len - st.cur);
    --st.cur;
    --st.len;
    st.buf[st.len] = '\0';

    out.write((uint8_t)0x08);                          // BS : recule le curseur terminal
    if (st.len > st.cur) {                             // Réécrit les caractères décalés
        out.write((const uint8_t*)&st.buf[st.cur], st.len - st.cur);
    }
    out.write((uint8_t)0x20);                          // Espace : efface le dernier résidu
    size_t back = st.len - st.cur + 1;                 // Nombre de positions à reculer
    for (size_t i = 0; i < back; ++i) out.write((uint8_t)0x08);   // BS × N
}


// ════════════════════════════════════════════════════════════════════════════
//  processCommand()
//
//  Parse et exécute la commande contenue dans st.buf.
//
//  Étapes :
//    1. [si CMDLINE_CASE_INSENSITIVE == 1]
//       Convertit la partie commande saisie (avant le 1er espace) en minuscules.
//       L'argument (après l'espace) reste intact.
//       Exemple : "PING 8.8.8.8"    → commande saisie = "ping"   arg = "8.8.8.8"
//       Exemple : "SAVEconf"        → commande saisie = "saveconf"
//
//    2. Sépare la commande de l'argument au premier espace.
//
//    3. Recherche dans _commands[] avec cmdStrcmpLower() :
//       Compare la commande saisie (minuscules) avec chaque nom de la table
//       converti à la volée en minuscules → fonctionne même si la table
//       contient des majuscules : SaveConf, LEDon, serveurWEB...
//       La table _commands[] n'est JAMAIS modifiée.
//       Correspondance → appelle _commands[i].action(arg).
//       Aucune correspondance → affiche "Invalid command".
//
//  Exemple complet :
//    Table contient  : { "SaveConf", cmdSaveConf }
//    Utilisateur tape: "SAVECONF"  ou  "saveconf"  ou  "Saveconf"
//    Étape 1         : saisie → "saveconf"
//    Étape 3         : cmdStrcmpLower("saveconf", "SaveConf") → 0 ✓
//    Résultat        : cmdSaveConf() est appelée ✓
//
//  IN   st, out
//  OUT  — : appelle l'action correspondante ou affiche "Invalid command"
// ════════════════════════════════════════════════════════════════════════════
void CmdLine::processCommand(CanalState &st, Print &out) {

#if CMDLINE_CASE_INSENSITIVE
    // Convertit uniquement la partie commande saisie en minuscules.
    // La boucle s'arrête au premier espace → l'argument n'est PAS touché.
    // On utilise +32 au lieu de tolower() : portable, sans dépendance externe.
    for (char *q = st.buf; *q && *q != ' '; ++q) {
        if (*q >= 'A' && *q <= 'Z') *q += 32;
    }
#endif

    // Sépare commande et argument
    char *p = st.buf;
    while (*p && *p != ' ') ++p;    // Avance jusqu'à l'espace séparateur
    if (*p == ' ')  *p++ = '\0';    // Coupe : st.buf = commande seule
    while (*p == ' ') ++p;          // Saute les espaces multiples éventuels

    const char *arg = p;            // arg pointe sur l'argument (peut être "")

    if (_commands && _num) {
        for (size_t i = 0; i < _num; ++i) {
            // cmdStrcmpLower() compare sans modifier la table _commands[].
            // Permet de reconnaître "saveconf" même si la table dit "SaveConf".
            if (cmdStrcmpLower(st.buf, _commands[i].command) == 0) {
                _commands[i].action(arg);    // Appelle la fonction du sketch
                return;
            }
        }
        println(F("Invalid command"));       // Envoie sur Serial + Telnet
    }
}


// ════════════════════════════════════════════════════════════════════════════
//  print() / println() — sortie simultanée Serial + Telnet
//
//  Ces surcharges couvrent les types non capturés par le template du .h :
//    println() sans argument  — saut de ligne seul
//    print/println(F("..."))  — chaîne PROGMEM (__FlashStringHelper)
//
//  Le template générique dans le .h couvre : int, float, long, String, char[]...
// ════════════════════════════════════════════════════════════════════════════
void CmdLine::println() {
    Serial.println();
    if (_c && _c->connected()) _c->println();
}

void CmdLine::print(const __FlashStringHelper *t) {
    Serial.print(t);
    if (_c && _c->connected()) _c->print(t);
}

void CmdLine::println(const __FlashStringHelper *t) {
    Serial.println(t);
    if (_c && _c->connected()) _c->println(t);
}


// ════════════════════════════════════════════════════════════════════════════
//  print(val, base) / println(val, base)  —  sortie avec base numérique
//
//  Permet d'utiliser cmdLine.print(valeur, DEC) / cmdLine.print(val, HEX)
//  comme on le ferait avec Serial.print(valeur, DEC).
//
//  Deux surcharges couvrent long et unsigned long.
//  Les autres types entiers (int, byte, uint8_t...) se convertissent
//  implicitement vers l'un de ces deux types.
//
//  IN   n    : valeur entière à afficher
//  IN   base : DEC (10), HEX (16), OCT (8), BIN (2)
//  OUT  —    : affiche sur Serial + Telnet (si connecté)
// ════════════════════════════════════════════════════════════════════════════
void CmdLine::print(long n, int base) {
    Serial.print(n, base);
    if (_c && _c->connected()) _c->print(n, base);
}

void CmdLine::println(long n, int base) {
    Serial.println(n, base);
    if (_c && _c->connected()) _c->println(n, base);
}

void CmdLine::print(unsigned long n, int base) {
    Serial.print(n, base);
    if (_c && _c->connected()) _c->print(n, base);
}

void CmdLine::println(unsigned long n, int base) {
    Serial.println(n, base);
    if (_c && _c->connected()) _c->println(n, base);
}


// ════════════════════════════════════════════════════════════════════════════
//  write(buf, len)  —  écriture binaire brute simultanée Serial + Telnet
//
//  Permet d'utiliser : cmdLine.write((uint8_t*)buf, idx);
//  Envoie les octets bruts sur Serial ET sur le client Telnet (si connecté).
//  Utile pour envoyer des séquences ANSI, des buffers binaires, ou tout
//  contenu préparé dans un tableau d'octets.
//
//  IN   buf : pointeur sur le tableau d'octets à envoyer
//  IN   len : nombre d'octets à envoyer
//  OUT  size_t : nombre d'octets écrits sur Serial (valeur de référence)
// ════════════════════════════════════════════════════════════════════════════
size_t CmdLine::write(const uint8_t *buf, size_t len) {
    size_t n = Serial.write(buf, len);
    if (_c && _c->connected()) _c->write(buf, len);
    return n;
}
