Trike MOSKITOS à recharge solaire : suivi de la course du soleil par des panneaux, schéma et liste du matériel.
le code Arduino (disponible en fin de billet) a été écrit avec l’aide de Claude (Anthropic) et des collègues. J'ai choisi le matériel et effectué les différents câblages puis testé et réglé sur mon matériel.
Présentation
Deux panneaux solaires, l'un sur le trike MoskitOS et l'autre sur sa remorque, sont inclinés chacun par un vérin électrique 12 V (course 150 mm, 20 s pour la course complète, environ 3 A). Un Arduino maître suit le soleil et commande le vérin du trike. Un Arduino esclave, sur la remorque, copie la position du maître par radio.
- Suivi du soleil : deux capteurs de lumière (LDR) comparent la lumière à gauche et à droite et le vérin s'oriente en conséquence.
- Modes : AUTO (suivi), MANUEL, ROUTE ou À PLAT (panneaux horizontaux pour rouler) et NUIT (panneaux remis à plat quand la lumière disparaît).
- Commande : un petit écran tactile (ESP32) ou une page web ouverte sur un smartphone Android, tous deux en Bluetooth avec le maître. Un STOP arrête les vérins immédiatement.
- Position : elle est calculée à partir du temps de marche des vérins, sans capteur de position. À la mise sous tension, chaque vérin descend en butée basse (22 s) pour se recaler. Un recalage identique a lieu chaque nuit.
Liste du matériel
Partie | Élément | Quantité | Remarque |
|---|---|---|---|
Maître | Arduino Uno (ATmega328P) | 1 | Un Nano convient aussi, mais le Uno s'est montré plus fiable ici |
Maître | Driver moteur BTS7960 | 1 | Pour le vérin du trike |
Maître | Vérin linéaire 12 V, course 150 mm, fins de course intégrées | 1 | Environ 3 A |
Maître | Capteurs de lumière LDR | 2 | Gauche et droite, sur A2 et A3 |
Maître | Joystick analogique avec bouton | 1 | Commande manuelle locale |
Maître | Écran LCD 16x2 avec module I2C (adresse 0x27) | 1 | État du système |
Maître | Module Bluetooth HM-10 (DSD TECH) | 1 | Avec diviseur de tension sur sa broche RX |
Maître | Module radio nRF24L01+PA+LNA avec antenne | 1 | Avec adaptateur 5 V vers 3,3 V |
Esclave | Arduino Uno ou Nano | 1 | Remorque |
Esclave | Driver moteur BTS7960 | 1 | Pour le vérin de la remorque |
Esclave | Vérin linéaire 12 V, course 150 mm | 1 | Identique au maître |
Esclave | Module radio nRF24L01+PA+LNA avec antenne | 1 | Avec adaptateur 5 V vers 3,3 V |
Commande | Écran tactile ESP32 (type CYD) | 1 | Optionnel, Bluetooth avec le HM-10 |
Commande | Smartphone Android avec Chrome | 1 | Optionnel, page web |
Alimentation | Convertisseur 48 V vers 12 V | 1 | Alimente les deux Arduino et les drivers |
Compléments | Condensateurs 100 µF / 25 V | 4 | Voir la section Condensateurs |
Compléments | Condensateurs 100 µF / 16 V | 2 | Un par adaptateur radio |
Compléments | Borniers pour les + 5 V et les masses, fils | selon besoin | Fils courts pour la radio |
Branchements du maître (Arduino Uno)
Élément | Broche du module | Broche de l'Arduino |
|---|---|---|
Driver BTS7960 | RPWM | D5 |
Driver BTS7960 | LPWM | D6 |
Driver BTS7960 | R_EN et L_EN | 5 V |
Driver BTS7960 | VCC | 5 V |
Driver BTS7960 | GND (commande) | GND |
LDR gauche | point milieu du diviseur | A2 |
LDR droite | point milieu du diviseur | A3 |
Joystick | axe X | A0 |
Joystick | axe Y | A1 |
Joystick | bouton | D2 |
HM-10 | TX+ | D7 |
HM-10 | RX | D8 |
LCD I2C | SDA | A4 |
LCD I2C | SCL | A5 |
LCD I2C | VCC et GND | 5 V et GND |
Le moteur du vérin se branche sur les sorties M+ et M− du driver. Son alimentation puissance (12 V) arrive sur B+ et B− du driver.
Branchements de l'esclave et de la radio
L'esclave ne comporte que le driver du vérin et le module radio. La radio se branche de la même façon sur le maître et sur l'esclave.
Élément | Broche du module | Broche de l'Arduino |
|---|---|---|
Driver BTS7960 | RPWM | D5 |
Driver BTS7960 | LPWM | D6 |
Driver BTS7960 | R_EN, L_EN et VCC | 5 V |
Driver BTS7960 | GND (commande) | GND |
Adaptateur radio | VCC (entrée) | 5 V (jamais 3V3, jamais VIN) |
Adaptateur radio | GND | GND |
Radio nRF24 | CE | D9 |
Radio nRF24 | CSN | D10 |
Radio nRF24 | SCK | D13 |
Radio nRF24 | MOSI | D11 |
Radio nRF24 | MISO | D12 |
Radio nRF24 | IRQ | non branché |
Le nRF24 ne supporte que 3,3 V : il est alimenté par l'adaptateur 5 V vers 3,3 V, qui est lui-même relié au 5 V de l'Arduino. Branché directement sur 5 V, le module est détruit. Un module PA+LNA consomme jusqu'à 115 mA à l'émission : n'utilisez pas la broche 3V3 de l'Arduino, trop faible.
L'antenne doit toujours être vissée avant la mise sous tension et placée bien droite, à l'écart des câbles moteur (au moins 10 cm). Une antenne mal placée a provoqué des pertes de paquets lors des essais.
Alimentation et masses
Un convertisseur 48 V vers 12 V alimente tout le système. Il fournit le 12 V de chaque Arduino (prise jack ou broche VIN) et de chaque driver (bornes B+ et B−). Le 5 V des modules est produit par le régulateur de chaque Arduino.
- Masse commune : le − du convertisseur 12 V, la borne B− de chaque driver, un GND de chaque Arduino et le GND des modules sont reliés au même point. Une masse flottante provoque du bruit sur le bus de la radio.
- Bornier + 5 V et bornier masse : regroupez les fils + 5 V sur un bornier et les masses sur un autre, au lieu de fils tordus ou soudés en chaîne.
- Fils courts pour la radio, éloignés d'au moins 10 cm des câbles du moteur.
- Le fil du moteur du vérin passe par la sortie du driver (M+ et M−). Il n'est pas relié à la masse commune.
Pour tester un Arduino seul sur le PC, débranchez son 12 V : ne raccordez pas l'USB et le 12 V en même temps.
Condensateurs
Les condensateurs absorbent les pointes de courant et stabilisent l'alimentation. Ils se branchent toujours en parallèle, entre le + et le − de ce qu'ils protègent, au plus près des broches. Sur un condensateur électrolytique, la patte marquée par la bande grise (−) va sur la masse : un condensateur inversé peut chauffer ou éclater.
Emplacement | Valeur | Entre | Pourquoi |
|---|---|---|---|
Entrée 12 V de chaque Arduino | 100 µF / 25 V | + 12 V et masse | Lisse le bruit du convertisseur 48/12 V et du moteur |
Entrée de puissance de chaque driver BTS7960 | 100 µF / 25 V | B+ et B− | Absorbe les pointes au démarrage du vérin |
Adaptateur radio 5 V vers 3,3 V, côté entrée | 100 µF / 16 V | VCC (5 V) et GND de l'adaptateur | Empêche la radio de décrocher à l'émission |
Sur l'adaptateur, la sortie 3,3 V est dans le support du module : le condensateur se soude donc sur les broches d'entrée (VCC et GND), sous l'adaptateur, avec des pattes de 1 cm environ. Une tension nominale plus élevée que celle indiquée convient toujours.
Programmes et bibliothèques
Appareil | Programme | Rôle |
|---|---|---|
Maître (Arduino Uno) |
| Suivi du soleil, vérin du trike, radio, Bluetooth, LCD |
Esclave (Arduino) |
| Suit la position du maître, recalage de nuit |
Écran tactile (ESP32) |
| Boutons et affichage en Bluetooth |
Smartphone Android |
| Mêmes commandes, ouverte dans Chrome |
Bibliothèques à installer dans l'IDE Arduino (menu Croquis, Inclure une bibliothèque, Gérer les bibliothèques) : RF24 (auteur TMRh20) pour la radio, LiquidCrystal I2C pour le LCD. SPI, Wire et SoftwareSerial sont déjà fournies avec l'IDE. Le driver BTS7960 se commande directement avec analogWrite, sans bibliothèque.
Pour l'écran ESP32, choisir la carte ESP32 Dev Module, le paquet esp32 d'Espressif et le schéma de partition Huge APP.
Réglages principaux, dans le programme du maître :
LDR_INVERSE = truesi les capteurs affichent une valeur basse en pleine lumière.POSITION_HORIZONTALE = 9500: position « à plat » du trike, en millisecondes de course (environ 135 ms par millimètre).RECALAGE_NUIT = true: recalage automatique en butée basse après une nuit.
Dans l'esclave, POSITION_HORIZONTALE_ESCLAVE = 10000 règle la position à plat de la remorque.
Mise en route, tests et dépannage
- Mettez sous tension le maître et l'esclave en même temps. Chaque vérin descend en butée basse pendant environ 22 s (le LCD du maître affiche CALIBRATION).
- Sur le LCD du maître, la fin de ligne indique R+ quand la liaison avec l'esclave est bonne et R− quand elle est perdue. Sur l'écran ou le smartphone, la ligne « Remorque » montre un pourcentage, ou « perdue ».
- Passez en MANUEL, appuyez sur MONTER quelques secondes, puis sur STOP : les deux vérins doivent suivre.
En cas de problème radio, testez les deux modules avec deux petits programmes d'essai (radio_A_emetteur.ino et radio_B_recepteur.ino) et lisez le moniteur série à 115200 bauds :
Affichage | Signification | Action |
|---|---|---|
| Le module n'est pas reconnu : les « OK » qui suivent sont faux | Vérifier les 6 fils de la radio, l'alimentation de l'adaptateur et les soudures |
| Le module est bon, mais la liaison ne passe pas | Vérifier l'autre module, les antennes (bien vissées, droites) et l'adresse commune |
« OK » avec un numéro qui suit celui de l'émetteur | Liaison saine | Remettre les programmes du trike |
Des « PAS DE REPONSE » intermittents | Liaison instable | Écarter les modules d'au moins 3 m, vérifier les antennes et les condensateurs |
Si un vérin ne bouge pas, vérifiez le driver : il doit recevoir 12 V sur B+ et B−, 5 V sur VCC, R_EN et L_EN. Un BTS7960 qui ne produit aucun mouvement alors que ces tensions sont présentes est probablement défectueux : échangez-le avec celui de l'autre voie pour le vérifier.
Conseils, limites et précautions
- Pas encore éprouvé sur la route : le système a été testé à l'arrêt. Les premiers essais en roulant diront comment il tient les vibrations et les parasites du moteur.
- Position calculée au temps : elle dérive peu à peu. Le recalage à chaque mise sous tension et chaque nuit la remet en phase. Les vérins ont leurs propres fins de course, qui coupent leur alimentation en butée.
- Un seul appareil Bluetooth à la fois : le module HM-10 n'accepte qu'une connexion. Éteignez l'écran tactile avant d'utiliser la page du smartphone, et réciproquement.
- Android uniquement pour la page du smartphone : Chrome gère le Bluetooth des pages web, mais pas Safari ni Chrome sur iPhone.
- Driver : le BTS7960 convient aux vérins de 3 A. Un module de 3 A comme le LMD18200T serait à sa limite et obligerait à modifier les programmes. Gardez un BTS7960 de rechange.
- Polarité : ne jamais inverser le + et le − de l'alimentation des drivers ni des condensateurs.
- STOP : après un STOP, la remorque termine parfois son rattrapage de quelques millimètres jusqu'à la position du trike avant de s'arrêter.
- Adaptation : les temps de course (20 s, 22 s de calibration) et les positions « à plat » doivent être mesurés et réglés pour vos propres vérins et vos propres panneaux.
code arduino "maitre":
// ===== MAITRE : suivi solaire + radio nRF24L01 + Bluetooth HM-10 =====
// Ajout : commande Bluetooth 'X' = arret immediat (bouton STOP de l'ecran tactile)
#include <Wire.h>
#include <SPI.h>
#include <RF24.h>
#include <SoftwareSerial.h>
#include <LiquidCrystal_I2C.h>
LiquidCrystal_I2C lcd(0x27, 16, 2);
RF24 radio(9, 10); // CE, CSN
SoftwareSerial ble(7, 8); // RX (depuis HM-10 TX), TX (vers HM-10 RX via diviseur)
const byte ADRESSE[6] = "SOL01";
// ---------- Broches (inchangees) ----------
const int RPWM = 5;
const int LPWM = 6;
const int LDR_GAUCHE = A2;
const int LDR_DROITE = A3;
const int PIN_JOY_X = A0;
const int PIN_JOY_Y = A1;
const int PIN_JOY_SW = 2;
// ---------- Reglages (inchanges) ----------
const int ZONE_MORTE = 50;
const int SEUIL_NUIT = 150;
const int VITESSE_AUTO = 200;
const int VITESSE_MANU = 255;
const int SEUIL_MIN = 200;
const int SEUIL_MAX = 800;
// Sens du capteur de lumiere. false : valeur haute = beaucoup de lumiere.
// true : module inverse (valeur haute dans le noir) -> on retourne la mesure.
const bool LDR_INVERSE = true;
int lireLDR(int pin) {
int v = analogRead(pin);
return LDR_INVERSE ? 1023 - v : v;
}
const long TEMPS_AJUSTEMENT = 3000;
const unsigned long TEMPS_VEILLE = 6000; // temps de veille de 10mn (600000)
const long TEMPS_COURSE_TOTALE = 14000;
const unsigned long TEMPS_CALIBRATION = 18000;
// ---------- Position a plat et luminosite ----------
// Position "panneau a plat" en ms depuis le zero (a regler, voir notice)
const long POSITION_HORIZONTALE = 7000;
const int SEUIL_JOUR = SEUIL_NUIT + 50; // reprise du suivi au-dessus de ce seuil
const unsigned long PERIODE_LUMIERE = 30000; // test de luminosite toutes les 30 s
const unsigned long CONFIRMATION_NUIT = 30000; // sombre pendant 5 min (300000) avant mise a plat, pour test 30000
// Recalage de nuit : avant la mise a plat de nuit, le verin descend jusqu'a sa butee basse
// (il coupe lui-meme son alimentation) et le compteur repart de zero. false = desactive.
const bool RECALAGE_NUIT = true;
// ---------- Messages radio ----------
struct Paquet { uint8_t mode; int32_t cible; }; // mode : 0 manuel, 1 auto, 2 a plat, 3 recalage bas
struct Retour { int32_t pos; uint8_t etat; }; // etat : 1 arret, 2 en mouvement
Paquet paquet;
Retour retour = {0, 0};
bool liaisonOk = false;
bool radioOk = false; // module nRF24 detecte au demarrage
// ---------- Etat ----------
long positionVirtuelle = 0;
bool modeAutomatique = true;
unsigned long dernierAjustementChrono = 0;
unsigned long dernierTourManuel = 0;
bool horizontal = false; // panneaux commandes a plat
bool enNuit = false; // a plat a cause du manque de lumiere
unsigned long debutSombre = 0, dernierTestLumiere = 0;
bool modeRoute = false; // a plat et verrouille (pendant les trajets)
char cmdBle = 0;
int bleSens = 0;
unsigned long bleImpulsionFin = 0;
unsigned long tRadio = 0, tStatut = 0;
bool recalage = false; // recalage sur la butee basse en cours (l'esclave descend aussi)
bool arretDemande = false; // commande 'X' recue : arret immediat, puis passage en manuel
bool pret = false; // vrai apres la calibration (X ignore pendant la mise a zero)
// Envoi radio avec delai maximal. radio.write() attend sans fin si le module decroche
// (alimentation instable) : tout le maitre, moteur compris, resterait fige.
bool envoiRadio() {
retour.etat = 0; // 0 = aucune reponse valide de l'esclave
radio.startFastWrite(&paquet, sizeof(paquet), false);
if (!radio.txStandBy(60)) { // attend 60 ms maximum la confirmation de l'esclave
radio.flush_tx(); // echec ou module fige : on vide l'envoi en attente
return false;
}
// La liaison n'est declaree bonne que si l'esclave a renvoye sa position (accuse avec donnees)
if (radio.available()) radio.read(&retour, sizeof(retour));
return (retour.etat == 1 || retour.etat == 2);
}
// ---------- Service radio + Bluetooth (appele partout) ----------
void service() {
while (ble.available()) {
char c = toupper(ble.read());
if (c == 'X') {
// ARRET immediat : coupe le moteur tout de suite, meme au milieu d'un mouvement
if (pret) { arretMoteur(); bleImpulsionFin = 0; arretDemande = true; }
}
else if (c == 'A' || c == 'M' || c == 'U' || c == 'D' || c == 'S' || c == 'H' || c == 'R' || c == '?') cmdBle = c;
}
unsigned long now = millis();
// Remorque presente : envoi toutes les 300 ms. Absente : simple appel toutes les 2 s,
// le maitre fonctionne normalement sans elle et reprend des qu'elle repond.
if (radioOk && now - tRadio >= (liaisonOk ? 300UL : 2000UL)) {
tRadio = now;
paquet.mode = recalage ? 3 : (horizontal ? 2 : (modeAutomatique ? 1 : 0));
paquet.cible = positionVirtuelle;
liaisonOk = envoiRadio();
}
if (now - tStatut >= 1000) {
tStatut = now;
ble.print(modeRoute ? "ROUTE" : enNuit ? "NUIT" : horizontal ? "HORIZ" : modeAutomatique ? "AUTO" : "MANUEL");
ble.print(" P1:"); ble.print(positionVirtuelle);
ble.print(" P2:"); ble.print(liaisonOk ? (long)retour.pos : -1L);
ble.print(" E:"); ble.print(abs(lireLDR(LDR_GAUCHE) - lireLDR(LDR_DROITE)));
ble.print(" R:"); ble.println(liaisonOk ? "OK" : "PERDUE");
}
}
// Remplace delay() : la radio et le Bluetooth restent actifs
void attendre(unsigned long ms) {
unsigned long t = millis();
while (millis() - t < ms) {
service();
delay(5);
}
}
void arretMoteur() {
analogWrite(RPWM, 0);
analogWrite(LPWM, 0);
}
void passerManuel() {
modeAutomatique = false;
enNuit = false;
arretMoteur();
lcd.clear();
lcd.print("MANUEL ACTIF");
attendre(1000);
lcd.clear();
dernierTourManuel = millis();
}
void passerAuto() {
modeAutomatique = true;
horizontal = false;
enNuit = false;
modeRoute = false;
debutSombre = 0;
arretMoteur();
lcd.clear();
lcd.print("RETOUR EN AUTO");
attendre(1000);
alignementInitialContinu();
dernierAjustementChrono = millis();
}
// Mode ROUTE : panneaux a plat, suivi coupe, joystick verrouille
void passerRoute() {
modeAutomatique = false;
enNuit = false;
modeRoute = true;
arretMoteur();
lcd.clear();
lcd.print("MODE: ROUTE");
mettreHorizontal();
dernierTourManuel = millis();
}
void traiterCommandeBle() {
char c = cmdBle;
cmdBle = 0;
switch (c) {
case 'A': if (!modeAutomatique) passerAuto(); break;
case 'M': if (modeAutomatique) passerManuel(); break;
case 'S': bleImpulsionFin = 0; break;
case 'U': if (!modeAutomatique && !modeRoute) { bleSens = 1; bleImpulsionFin = millis() + 1000; } break;
case 'D': if (!modeAutomatique && !modeRoute) { bleSens = -1; bleImpulsionFin = millis() + 1000; } break;
case 'R': passerRoute(); break;
case 'H': if (modeAutomatique) passerManuel(); mettreHorizontal(); break;
case '?': tStatut = 0; break;
}
}
// Recalage de nuit sur la butee basse : le verin coupe lui-meme son alimentation en fin de course
// (comme a l'allumage). Le compteur repart de zero, donc la mise a plat qui suit est exacte et
// le suivi du lendemain part d'une position juste. L'esclave descend en meme temps (mode 3).
// Interruptible : joystick, commande 'M' ou arret 'X'.
void rehomer() {
if (!RECALAGE_NUIT) return;
recalage = true;
lcd.setCursor(0, 1);
lcd.print("RECALAGE BAS ");
analogWrite(RPWM, 0);
analogWrite(LPWM, VITESSE_MANU);
unsigned long t0 = millis(), t = t0;
bool termine = false;
while (!termine) {
if (digitalRead(PIN_JOY_SW) == LOW || cmdBle == 'M' || arretDemande) break;
service();
delay(5);
unsigned long now = millis();
positionVirtuelle -= (long)(now - t); // le compteur descend pendant le recalage
t = now;
if (positionVirtuelle < 0) positionVirtuelle = 0;
termine = (now - t0 >= TEMPS_CALIBRATION);
}
arretMoteur();
recalage = false;
if (termine) positionVirtuelle = 0; // butee basse atteinte : compteur recale
}
// Suite d'un arret 'X' : moteur coupe, la position a plat n'est plus garantie,
// et on se retrouve en manuel (le suivi auto et le mode route ne repartent pas seuls).
void traiterArret() {
arretMoteur();
bleImpulsionFin = 0;
horizontal = false; // l'esclave suit alors la position reelle du maitre et s'arrete avec lui
if (modeAutomatique) {
passerManuel();
} else {
modeRoute = false; // un arret deverrouille le joystick
lcd.clear();
lcd.print("ARRET - MANUEL");
attendre(1000);
lcd.clear();
dernierTourManuel = millis();
}
}
void setup() {
pinMode(RPWM, OUTPUT);
pinMode(LPWM, OUTPUT);
pinMode(PIN_JOY_SW, INPUT_PULLUP);
arretMoteur();
delay(20);
bool demarrageRoute = (digitalRead(PIN_JOY_SW) == LOW); // bouton maintenu a l'allumage
ble.begin(9600);
// Si le module ne repond pas, on continue SANS radio (sinon radio.write peut bloquer le Nano)
radio.begin();
radioOk = radio.isChipConnected(); // tolerant : begin() peut echouer
(alimentation limite, module clone) alors que la liaison fonctionne
if (radioOk) {
radio.setPALevel(RF24_PA_LOW);
radio.setDataRate(RF24_250KBPS);
radio.enableAckPayload();
radio.setRetries(5, 10);
radio.openWritingPipe(ADRESSE);
radio.stopListening();
}
lcd.init();
lcd.backlight();
lcd.clear();
lcd.print("MOSKITOS SOLAIRE");
lcd.setCursor(0, 1);
lcd.print(radioOk ? "CALIBRATION... " : "CALIB.(RADIO KO)");
// Mise a zero : l'esclave fait la meme chose en parallele
positionVirtuelle = 0;
analogWrite(RPWM, 0);
analogWrite(LPWM, VITESSE_MANU); // pleine vitesse : course complete = 20 s, 22 s suffisent
attendre(TEMPS_CALIBRATION);
arretMoteur();
positionVirtuelle = 0;
pret = true;
if (demarrageRoute) {
while (digitalRead(PIN_JOY_SW) == LOW) { delay(10); }
passerRoute();
return;
}
lcd.setCursor(0, 1);
lcd.print("RECHERCHE SOLEIL");
attendre(1000);
alignementInitialContinu();
dernierAjustementChrono = millis();
}
void loop() {
service();
if (arretDemande) { arretDemande = false; traiterArret(); }
if (cmdBle) traiterCommandeBle();
// Bouton joystick : bascule auto / manuel (on attend le relachement AVANT d'agir)
if (digitalRead(PIN_JOY_SW) == LOW) {
unsigned long t0 = millis();
while (digitalRead(PIN_JOY_SW) == LOW) { delay(10); }
unsigned long appui = millis() - t0;
if (appui >= 2000) { // appui long : route <-> auto
if (modeRoute) passerAuto(); else passerRoute();
} else if (appui >= 30 && !modeRoute) { // appui court : auto <-> manuel
if (modeAutomatique) passerManuel(); else passerAuto();
}
}
if (modeAutomatique) {
if (millis() - dernierTestLumiere >= PERIODE_LUMIERE) {
dernierTestLumiere = millis();
gererLuminosite();
}
if (!enNuit && millis() - dernierAjustementChrono >= TEMPS_VEILLE) {
verifierEtAjusterPosition();
dernierAjustementChrono = millis();
}
char buf[17];
lcd.setCursor(0, 0);
lcd.print(enNuit ? "MODE: NUIT " : "MODE: AUTO ");
lcd.setCursor(0, 1);
// Ligne 2 : valeurs des deux cellules (G, D) et etat de la radio.
// Nuit = G et D tous les deux sous SEUIL_NUIT.
snprintf(buf, sizeof(buf), "G%-4dD%-4d R%c ",
lireLDR(LDR_GAUCHE), lireLDR(LDR_DROITE),
liaisonOk ? '+' : '-');
lcd.print(buf);
}
else {
unsigned long now = millis();
long dt = now - dernierTourManuel;
dernierTourManuel = now;
if (dt > 250) dt = 250;
int lectureJoyX = analogRead(PIN_JOY_X);
int lectureJoyY = analogRead(PIN_JOY_Y);
int sens = 0; // +1 montee, -1 descente
if (lectureJoyY < SEUIL_MIN || lectureJoyX < SEUIL_MIN) sens = 1;
else if (lectureJoyY > SEUIL_MAX || lectureJoyX > SEUIL_MAX) sens = -1;
else if (now < bleImpulsionFin) sens = bleSens;
if (modeRoute) sens = 0; // joystick verrouille
if (sens != 0) horizontal = false;
lcd.setCursor(0, 0);
lcd.print(modeRoute ? "MODE: ROUTE " : "MODE: MANUEL ");
lcd.setCursor(0, 1);
if (sens > 0) {
lcd.print("SYNCHRO: MONTEE^");
analogWrite(LPWM, 0);
analogWrite(RPWM, VITESSE_MANU);
positionVirtuelle += dt;
}
else if (sens < 0) {
lcd.print("SYNCHRO: DESC. v");
analogWrite(RPWM, 0);
analogWrite(LPWM, VITESSE_MANU);
positionVirtuelle -= dt;
}
else {
if (modeRoute) {
lcd.print("ROUTE: A PLAT ");
} else {
char b2[17];
snprintf(b2, sizeof(b2), "Repos Pos:%-5ld ", (long)positionVirtuelle);
lcd.print(b2);
}
arretMoteur();
}
positionVirtuelle = constrain(positionVirtuelle, 0, TEMPS_COURSE_TOTALE);
}
delay(50);
}
// Amene le panneau a une position (ms depuis le zero). Interruptible : bouton ou "M".
void allerPosition(long cible) {
cible = constrain(cible, 0L, TEMPS_COURSE_TOTALE);
lcd.setCursor(0, 1);
lcd.print("MISE HORIZONTALE");
unsigned long t = millis();
while (positionVirtuelle != cible) {
if (digitalRead(PIN_JOY_SW) == LOW || cmdBle == 'M' || arretDemande) break;
long ecart = cible - positionVirtuelle;
// Vitesse MANUELLE : la position a plat se releve en manuel, il faut la meme vitesse
if (ecart > 0) {
analogWrite(LPWM, 0);
analogWrite(RPWM, VITESSE_MANU);
} else {
analogWrite(RPWM, 0);
analogWrite(LPWM, VITESSE_MANU);
}
service();
delay(5);
// On compte le temps REEL ecoule (radio et Bluetooth comprises), pas un pas theorique
unsigned long now = millis();
long dt = now - t;
t = now;
if (ecart > 0) {
positionVirtuelle += dt;
if (positionVirtuelle >= cible) positionVirtuelle = cible;
} else {
positionVirtuelle -= dt;
if (positionVirtuelle <= cible) positionVirtuelle = cible;
}
}
arretMoteur();
}
// Les deux panneaux a plat : le maitre ici, l'esclave via le paquet radio (mode 2)
void mettreHorizontal() {
horizontal = true;
allerPosition(POSITION_HORIZONTALE);
}
// Nuit : sombre pendant 5 min => a plat. Retour du jour => nouvelle recherche du soleil.
void gererLuminosite() {
int g = lireLDR(LDR_GAUCHE);
int d = lireLDR(LDR_DROITE);
bool sombre = (g < SEUIL_NUIT && d < SEUIL_NUIT);
bool jour = (g > SEUIL_JOUR || d > SEUIL_JOUR);
if (!enNuit) {
if (sombre) {
if (debutSombre == 0) {
debutSombre = millis();
} else if (millis() - debutSombre >= CONFIRMATION_NUIT) {
enNuit = true;
rehomer();
mettreHorizontal();
}
} else {
debutSombre = 0;
}
} else if (jour) {
enNuit = false;
horizontal = false;
debutSombre = 0;
lcd.clear();
lcd.print("JOUR: RECHERCHE");
alignementInitialContinu();
dernierAjustementChrono = millis();
}
}
void alignementInitialContinu() {
int valGauche = lireLDR(LDR_GAUCHE);
int valDroite = lireLDR(LDR_DROITE);
int ecart = abs(valGauche - valDroite);
// Diagnostic : valeurs des LDR et raison du (non-)mouvement, 2,5 s a l'ecran
char dbg[17];
snprintf(dbg, sizeof(dbg), "G:%-4d D:%-4d ", valGauche, valDroite);
lcd.setCursor(0, 0);
if (valGauche < SEUIL_NUIT && valDroite < SEUIL_NUIT) lcd.print("TROP SOMBRE ");
else if (ecart <= ZONE_MORTE) lcd.print("DEJA ALIGNE ");
else lcd.print("RECHERCHE... ");
lcd.setCursor(0, 1);
lcd.print(dbg);
attendre(2500);
if (valGauche < SEUIL_NUIT && valDroite < SEUIL_NUIT) return;
while (ecart > ZONE_MORTE) {
if (arretDemande) break; // arret 'X' : traite par loop()
// Bouton joystick : passage en manuel (attendre le relachement)
if (digitalRead(PIN_JOY_SW) == LOW) {
while (digitalRead(PIN_JOY_SW) == LOW) { delay(10); }
modeAutomatique = false;
lcd.clear();
lcd.print("MANUEL ACTIF");
break;
}
// Commande Bluetooth : passage en manuel
if (cmdBle == 'M') {
cmdBle = 0;
modeAutomatique = false;
lcd.clear();
lcd.print("MANUEL ACTIF");
break;
}
bool sensGauche = (valGauche > valDroite);
if ((sensGauche && positionVirtuelle >=
TEMPS_COURSE_TOTALE) || (!sensGauche && positionVirtuelle <=
0)) {
lcd.setCursor(0, 0);
lcd.print("EN BUTEE ");
attendre(1500);
break;
}
if (sensGauche) {
analogWrite(LPWM, 0);
analogWrite(RPWM, VITESSE_AUTO);
positionVirtuelle += 100;
} else {
analogWrite(RPWM, 0);
analogWrite(LPWM, VITESSE_AUTO);
positionVirtuelle -= 100;
}
attendre(100);
arretMoteur();
attendre(50);
valGauche = lireLDR(LDR_GAUCHE);
valDroite = lireLDR(LDR_DROITE);
ecart = abs(valGauche - valDroite);
lcd.setCursor(0, 1);
lcd.print("Calage Eca: "); lcd.print(ecart); lcd.print(" ");
}
arretMoteur();
}
void verifierEtAjusterPosition() {
int valGauche = lireLDR(LDR_GAUCHE);
int valDroite = lireLDR(LDR_DROITE);
int ecart = abs(valGauche - valDroite);
if (valGauche < SEUIL_NUIT && valDroite < SEUIL_NUIT) return;
if (ecart > ZONE_MORTE) {
bool sensGauche = (valGauche > valDroite);
// Duree bornee : on ne depasse jamais la course disponible
long duree = sensGauche
? min(TEMPS_AJUSTEMENT, TEMPS_COURSE_TOTALE - positionVirtuelle)
: min(TEMPS_AJUSTEMENT, positionVirtuelle);
if (duree > 0) {
if (sensGauche) {
analogWrite(LPWM, 0);
analogWrite(RPWM, VITESSE_AUTO);
positionVirtuelle += duree;
} else {
analogWrite(RPWM, 0);
analogWrite(LPWM, VITESSE_AUTO);
positionVirtuelle -= duree;
}
unsigned long t0 = millis();
while (millis() - t0 < (unsigned long)duree && !arretDemande) { service(); delay(5); }
long fait = (long)(millis() - t0);
if (fait < duree) { // interrompu par 'X' : on corrige la position avec la duree reellement faite
positionVirtuelle += sensGauche ? (fait - duree) : (duree - fait);
}
arretMoteur();
}
}
}
code arduino "esclave"
// ===== ESCLAVE : second panneau, suit la position du maitre par radio =====
// Version de controle : affiche l'etat de la radio sur le moniteur serie (USB, 115200 bauds).
// Mettre DEBUG_SERIE a false une fois la liaison verifiee.
const bool DEBUG_SERIE = false;
#include <SPI.h>
#include <RF24.h>
RF24 radio(9, 10); // CE, CSN
const byte ADRESSE[6] = "SOL01"; // identique au maitre
// ---------- Broches ----------
const int RPWM = 5;
const int LPWM = 6;
// ---------- Reglages ----------
// true si le second panneau est monte "en miroir" (le verin monte quand le premier descend)
const bool INVERSER_SENS = false;
const int VITESSE_AUTO = 200;
const int VITESSE_MANU = 255;
const long TEMPS_COURSE_TOTALE = 20000;
// Position "panneau a plat" du second panneau, en ms depuis son zero (a regler)
const long POSITION_HORIZONTALE_ESCLAVE = 10000;
const unsigned long TEMPS_CALIBRATION = 22000;
const unsigned long TIMEOUT_LIAISON = 20000; // arret si plus de nouvelles du maitre
const long TOL_DEMARRAGE = 150; // ecart (ms) pour demarrer
const long TOL_ARRET = 40; // ecart (ms) pour s'arreter
// ---------- Messages radio (identiques au maitre) ----------
struct Paquet { uint8_t mode; int32_t cible; };
struct Retour { int32_t pos; uint8_t etat; };
Paquet paquet;
Retour retour = {0, 1};
long positionVirtuelle = 0;
long cible = 0;
uint8_t mode = 1;
bool enMouvement = false;
bool recuUneFois = false;
unsigned long dernierRx = 0;
unsigned long dernierTour = 0;
unsigned long nbRecus = 0, dernierAffiche = 0;
bool radioOk = false; // module nRF24 reellement detecte au demarrage
unsigned long debutRecalage = 0;
bool enRecalage = false;
// sens : +1 monter, -1 descendre, 0 arret
void moteur(int sens, int vitesse) {
int pinAvance = INVERSER_SENS ? LPWM : RPWM;
int pinRecul = INVERSER_SENS ? RPWM : LPWM;
if (sens > 0) {
analogWrite(pinRecul, 0);
analogWrite(pinAvance, vitesse);
} else if (sens < 0) {
analogWrite(pinAvance, 0);
analogWrite(pinRecul, vitesse);
} else {
analogWrite(pinAvance, 0);
analogWrite(pinRecul, 0);
}
}
void majAck() {
retour.pos = positionVirtuelle;
retour.etat = enMouvement ? 2 : 1;
radio.writeAckPayload(1, &retour, sizeof(retour));
}
void setup() {
pinMode(RPWM, OUTPUT);
pinMode(LPWM, OUTPUT);
moteur(0, 0);
if (DEBUG_SERIE) Serial.begin(115200);
bool debutOk = radio.begin();
radioOk = radio.isChipConnected(); // tolerant : begin() peut echouer sur une alimentation limite ou un clone
if (DEBUG_SERIE) {
Serial.println(F("ESCLAVE demarre"));
Serial.print(F("radio.begin = "));
Serial.println(debutOk);
Serial.print(F("puce nRF24 detectee = "));
Serial.println(radio.isChipConnected());
Serial.println(F("Calibration 22 s (la radio n'ecoute pas encore)..."));
}
radio.setPALevel(RF24_PA_LOW);
radio.setDataRate(RF24_250KBPS);
radio.enableAckPayload();
radio.openReadingPipe(1, ADRESSE);
// Mise a zero (en meme temps que le maitre). Pendant ce temps la radio
// n'ecoute pas : le maitre affichera "R-" jusqu'a la fin.
moteur(-1, VITESSE_MANU); // pleine vitesse : course complete = 20 s, 22 s suffisent
delay(TEMPS_CALIBRATION);
moteur(0, 0);
positionVirtuelle = 0;
radio.startListening();
if (DEBUG_SERIE) Serial.println(F("Calibration terminee : j'ecoute la radio."));
majAck();
dernierTour = millis();
}
void loop() {
unsigned long now = millis();
long dt = now - dernierTour;
dernierTour = now;
if (dt > 100) dt = 100;
// Module non detecte : on n'ecoute rien (sinon le bruit du bus SPI serait pris pour des ordres)
if (radioOk && radio.available()) {
radio.read(&paquet, sizeof(paquet));
if (paquet.mode > 3) { delay(20); return; } // paquet invalide (bruit) : ignore
mode = paquet.mode; // 0 manuel, 1 auto, 2 a plat, 3 recalage bas
if (mode == 2) cible = POSITION_HORIZONTALE_ESCLAVE;
else cible = constrain((long)paquet.cible, 0L, TEMPS_COURSE_TOTALE);
dernierRx = now;
recuUneFois = true;
nbRecus++;
majAck();
}
if (DEBUG_SERIE && now - dernierAffiche >= 1000) {
dernierAffiche = now;
Serial.print(F("radio OK = "));
Serial.print(radioOk);
Serial.print(F(" paquets recus = "));
Serial.print(nbRecus);
Serial.print(F(" mode = "));
Serial.print(mode);
Serial.print(F(" cible = "));
Serial.print(cible);
Serial.print(F(" pos = "));
Serial.println(positionVirtuelle);
}
// Securite : pas de nouvelles du maitre => on s'arrete
if (!recuUneFois || now - dernierRx > TIMEOUT_LIAISON) {
moteur(0, 0);
enMouvement = false;
delay(20);
return;
}
// Mode 3 : recalage de nuit. On descend jusqu'a la butee basse (le verin coupe son alimentation),
// tant que le maitre l'indique, 22 s au maximum, puis la position repart de zero.
if (mode == 3) {
if (!enRecalage) { enRecalage = true; debutRecalage = now; }
if (now - debutRecalage < TEMPS_CALIBRATION) {
moteur(-1, VITESSE_MANU);
positionVirtuelle -= dt;
if (positionVirtuelle < 0) positionVirtuelle = 0;
enMouvement = true;
} else {
moteur(0, 0);
positionVirtuelle = 0;
enMouvement = false;
}
delay(20);
return;
}
enRecalage = false;
long ecart = cible - positionVirtuelle;
if (!enMouvement && abs(ecart) > TOL_DEMARRAGE) enMouvement = true;
if (enMouvement && abs(ecart) <= TOL_ARRET) enMouvement = false;
if (enMouvement) {
int sens = (ecart > 0) ? 1 : -1;
moteur(sens, (mode == 0 || mode == 2) ? VITESSE_MANU : VITESSE_AUTO);
positionVirtuelle += sens * dt;
positionVirtuelle = constrain(positionVirtuelle, 0L, TEMPS_COURSE_TOTALE);
} else {
moteur(0, 0);
}
delay(20);
}
Code du test des antennes, celle du maitre "émetteur":
// ===== ARDUINO A : EMETTEUR =====
// Envoie un numero chaque seconde et affiche la reponse de l'Arduino B.
// Moniteur serie : 115200 bauds.
// Branchement nRF24L01 (Uno/Nano) : GND, VCC (voir note), CE=D9, CSN=D10, SCK=D13, MOSI=D11, MISO=D12
#include <SPI.h>
#include <RF24.h>
RF24 radio(9, 10); // CE, CSN
const byte ADRESSE[6] = "TEST1";
struct Message { uint32_t numero; };
struct Reponse { uint32_t recu; };
Message msg = {0};
Reponse rep;
unsigned long reussis = 0, rates = 0;
void setup() {
Serial.begin(115200);
bool b = radio.begin();
Serial.print(F("begin = ")); Serial.println(b);
Serial.print(F("puce detectee = ")); Serial.println(radio.isChipConnected());
radio.setPALevel(RF24_PA_LOW);
radio.setDataRate(RF24_250KBPS);
radio.enableAckPayload();
radio.setRetries(5, 10);
radio.openWritingPipe(ADRESSE);
radio.stopListening();
}
void loop() {
msg.numero++;
bool ok = radio.write(&msg, sizeof(msg));
if (ok) {
reussis++;
Serial.print(F("Envoi ")); Serial.print(msg.numero); Serial.print(F(" : OK"));
if (radio.available()) {
radio.read(&rep, sizeof(rep));
Serial.print(F(" | B a bien recu le numero ")); Serial.print(rep.recu);
}
} else {
rates++;
Serial.print(F("Envoi ")); Serial.print(msg.numero); Serial.print(F(" : PAS DE REPONSE"));
}
Serial.print(F(" (reussis ")); Serial.print(reussis);
Serial.print(F(", rates ")); Serial.print(rates); Serial.println(F(")"));
delay(1000);
}
Celui de l'esclave "récepteur":
// ===== ARDUINO B : RECEPTEUR =====
// Ecoute l'Arduino A, affiche ce qu'il recoit et renvoie un accuse de reception.
// Moniteur serie : 115200 bauds.
// Branchement nRF24L01 : GND, VCC (voir note), CE=D9, CSN=D10, SCK=D13, MOSI=D11, MISO=D12
#include <SPI.h>
#include <RF24.h>
RF24 radio(9, 10); // CE, CSN
const byte ADRESSE[6] = "TEST1"; // identique a A
struct Message { uint32_t numero; };
struct Reponse { uint32_t recu; };
Message msg;
Reponse rep = {0};
unsigned long total = 0;
void setup() {
Serial.begin(115200);
bool b = radio.begin();
Serial.print(F("begin = ")); Serial.println(b);
Serial.print(F("puce detectee = ")); Serial.println(radio.isChipConnected());
radio.setPALevel(RF24_PA_LOW);
radio.setDataRate(RF24_250KBPS);
radio.enableAckPayload();
radio.openReadingPipe(1, ADRESSE);
radio.writeAckPayload(1, &rep, sizeof(rep));
radio.startListening();
Serial.println(F("B ecoute..."));
}
void loop() {
if (radio.available()) {
radio.read(&msg, sizeof(msg));
total++;
rep.recu = msg.numero;
radio.writeAckPayload(1, &rep, sizeof(rep)); // sera renvoye avec l'accuse du prochain message
Serial.print(F("Recu de A : numero ")); Serial.print(msg.numero);
Serial.print(F(" (total recus ")); Serial.print(total); Serial.println(F(")"));
}
}
Code html pour la connexion en Bluetooth (mode BLE):
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>Trike solaire</title>
<style>
:root {
--fond:#0f1720; --carte:#1a2531; --texte:#e8eef4; --doux:#8fa1b3;
--vert:#2ea043; --bleu:#2f81f7; --orange:#d9822b; --rouge:#da3633; --gris:#3a4756;
}
* { box-sizing:border-box; -webkit-tap-highlight-color:transparent; }
body { margin:0; background:var(--fond); color:var(--texte); font-family:system-ui,sans-serif;
padding:16px; max-width:480px; margin-inline:auto; user-select:none; -webkit-user-select:none; }
h1 { font-size:18px; margin:0 0 12px; font-weight:600; }
.carte { background:var(--carte); border-radius:14px; padding:14px; margin-bottom:12px; }
#connexion { width:100%; padding:14px; font-size:16px; border:0; border-radius:12px;
background:var(--bleu); color:#fff; font-weight:600; }
#etat { margin-top:8px; font-size:14px; color:var(--doux); min-height:20px; }
.mode { text-align:center; font-size:26px; font-weight:700; padding:10px; border-radius:10px;
background:var(--gris); letter-spacing:1px; }
.mode.AUTO { background:var(--vert); } .mode.MANUEL { background:var(--orange); }
.mode.ROUTE, .mode.HORIZ { background:var(--bleu); } .mode.NUIT { background:#4b3f8a; }
.rangee { display:flex; justify-content:space-between; font-size:14px; color:var(--doux); margin:10px 0 4px; }
.rangee b { color:var(--texte); }
.barre { position:relative; height:16px; background:var(--gris); border-radius:8px; overflow:hidden; }
.barre i { position:absolute; left:0; top:0; bottom:0; width:0; background:var(--bleu); transition:width .3s; }
.barre u { position:absolute; top:0; bottom:0; width:3px; background:#fff; opacity:.8; }
.perdue { color:var(--rouge) !important; }
.grille { display:grid; grid-template-columns:1fr 1fr; gap:10px; }
button.b { padding:18px 8px; font-size:16px; font-weight:700; border:0; border-radius:12px;
color:#fff; background:var(--gris); touch-action:none; }
button.b:active { filter:brightness(1.3); }
.b.auto { background:var(--vert); } .b.manuel { background:var(--orange); }
.b.bleu { background:var(--bleu); }
.b.monter, .b.descendre { padding:30px 8px; font-size:20px; background:var(--gris); }
.b.stop { grid-column:1 / -1; background:var(--rouge); padding:24px 8px; font-size:22px; }
button:disabled { opacity:.4; }
small { color:var(--doux); display:block; margin-top:8px; font-size:12px; line-height:1.4; }
</style>
</head>
<body>
<h1>☀️ Trike solaire</h1>
<div class="carte">
<button id="connexion">Se connecter au trike</button>
<div id="etat">Non connecté.</div>
</div>
<div class="carte">
<div id="mode" class="mode">—</div>
<div class="rangee"><span>Trike (inclinaison)</span><b id="t-val">—</b></div>
<div class="barre"><i id="t-bar"></i><u id="t-plat"></u></div>
<div class="rangee"><span>Remorque</span><b id="r-val">—</b></div>
<div class="barre"><i id="r-bar"></i><u id="r-plat"></u></div>
<div class="rangee"><span>Écart lumière</span><b id="ecart">—</b></div>
</div>
<div class="carte">
<div class="grille">
<button class="b auto" data-cmd="A">AUTO</button>
<button class="b manuel" data-cmd="M">MANUEL</button>
<button class="b bleu" data-cmd="R">ROUTE</button>
<button class="b bleu" data-cmd="H">À PLAT</button>
<button class="b monter" id="monter">▲ MONTER</button>
<button class="b descendre" id="descendre">▼ DESCENDRE</button>
<button class="b stop" data-cmd="X">STOP</button>
</div>
<small>MONTER / DESCENDRE : maintenez le doigt appuyé. Le mouvement s'arrête quand vous lâchez.</small>
</div>
<script>
// ---------- Réglages ----------
const COURSE_TOTALE = 20000; // ms pour la course complète (150 mm)
const POS_PLAT = 9500; // position "à plat" (doit correspondre à POSITION_HORIZONTALE du maître)
const SERVICE = 0xFFE0, CARACT = 0xFFE1; // HM-10
let appareil = null, car = null, tampon = '', mode = '', repeter = null;
const $ = id => document.getElementById(id);
const boutons = () => document.querySelectorAll('button.b');
function etat(t) { $('etat').textContent = t; }
function activer(oui) { boutons().forEach(b => b.disabled = !oui); }
activer(false);
$('t-plat').style.left = (POS_PLAT / COURSE_TOTALE * 100) + '%';
$('r-plat').style.left = (POS_PLAT / COURSE_TOTALE * 100) + '%';
// ---------- Envoi d'une commande (un seul caractère) ----------
async function envoyer(c) {
if (!car) return;
try { await car.writeValueWithoutResponse(new TextEncoder().encode(c)); }
catch (e) { try { await car.writeValue(new TextEncoder().encode(c)); } catch (e2) { etat('Envoi impossible : ' + e2.message); } }
}
// ---------- Réception : une ligne par seconde ----------
// Exemple : "AUTO P1:9500 P2:9300 E:12 R:OK"
function lire(ligne) {
const m = ligne.match(/^(\S+)\s+P1:(-?\d+)\s+P2:(-?\d+)\s+E:(-?\d+)\s+R:(\w+)/);
if (!m) return;
mode = m[1];
const p1 = +m[2], p2 = +m[3], ecart = m[4], radio = m[5];
const pct = p => Math.max(0, Math.min(100, Math.round(p / COURSE_TOTALE * 100)));
const el = $('mode'); el.textContent = mode; el.className = 'mode ' + mode;
$('t-val').textContent = pct(p1) + ' % (' + Math.round(p1 / COURSE_TOTALE * 150) + ' mm)';
$('t-bar').style.width = pct(p1) + '%';
const perdue = radio !== 'OK' || p2 < 0;
$('r-val').textContent = perdue ? 'perdue' : pct(p2) + ' % (' + Math.round(p2 / COURSE_TOTALE * 150) + ' mm)';
$('r-val').classList.toggle('perdue', perdue);
$('r-bar').style.width = perdue ? '0%' : pct(p2) + '%';
$('ecart').textContent = ecart;
}
function reception(ev) {
tampon += new TextDecoder().decode(ev.target.value);
let i;
while ((i = tampon.indexOf('\n')) >= 0) {
lire(tampon.slice(0, i).trim());
tampon = tampon.slice(i + 1);
}
if (tampon.length > 200) tampon = '';
}
// ---------- Connexion Bluetooth ----------
async function connecter() {
if (!navigator.bluetooth) {
etat("Bluetooth indisponible ici. Ouvrez cette page avec Chrome sur Android.");
return;
}
try {
etat('Recherche du module…');
appareil = await navigator.bluetooth.requestDevice({
filters: [{ services: [SERVICE] }, { namePrefix: 'HMSoft' }],
optionalServices: [SERVICE]
});
appareil.addEventListener('gattserverdisconnected', deconnecte);
etat('Connexion…');
const serveur = await appareil.gatt.connect();
const service = await serveur.getPrimaryService(SERVICE);
car = await service.getCharacteristic(CARACT);
await car.startNotifications();
car.addEventListener('characteristicvaluechanged', reception);
activer(true);
$('connexion').textContent = 'Connecté ✓';
etat('Connecté à ' + (appareil.name || 'HM-10') + '.');
envoyer('?');
} catch (e) {
etat('Échec : ' + e.message);
activer(false);
}
}
function deconnecte() {
car = null; activer(false); arreterRepetition();
$('connexion').textContent = 'Se reconnecter';
etat('Déconnecté.');
}
$('connexion').addEventListener('click', () => {
if (appareil && appareil.gatt.connected) appareil.gatt.disconnect(); else connecter();
});
// ---------- Boutons simples ----------
document.querySelectorAll('button[data-cmd]').forEach(b =>
b.addEventListener('click', () => envoyer(b.dataset.cmd)));
// ---------- MONTER / DESCENDRE : maintenir appuyé ----------
function arreterRepetition() { if (repeter) { clearInterval(repeter); repeter = null; } }
async function demarrerMouvement(cmd) {
if (mode === 'AUTO' || mode === 'ROUTE' || mode === 'HORIZ' || mode === 'NUIT') {
await envoyer('M'); // passe en manuel d'abord
await new Promise(r => setTimeout(r, 150));
}
envoyer(cmd);
arreterRepetition();
repeter = setInterval(() => envoyer(cmd), 400); // répète tant que le doigt reste appuyé
}
function finMouvement() {
if (!repeter) return;
arreterRepetition();
envoyer('S'); // relâché : arrêt du mouvement
}
function maintien(id, cmd) {
const b = $(id);
b.addEventListener('pointerdown', e => { e.preventDefault(); b.setPointerCapture(e.pointerId); demarrerMouvement(cmd); });
['pointerup', 'pointercancel', 'lostpointercapture'].forEach(t => b.addEventListener(t, finMouvement));
b.addEventListener('contextmenu', e => e.preventDefault());
}
maintien('monter', 'U');
maintien('descendre', 'D');
// STOP général : aussi arrêter toute répétition
document.querySelector('.stop').addEventListener('click', arreterRepetition);
</script>
</body>
</html>








