Bonjour à tous,
Dans le cadre d’un projet personnel — la réalisation d’un abri motorisé modulaire pour astrographe — j’ai longuement exploré les différentes solutions permettant de piloter un moteur pas à pas puissant (tel le NEMA 23) avec une IPX800 V5. Après plusieurs essais et comparaisons, mon choix s’est finalement porté sur une approche simple et robuste : l’utilisation d’un driver compatible Modbus, en l’occurrence le DM556PR de chez StepperOnline, qui s’est révélé parfaitement adapté à ce type d’automatisation.
Petit plus : dans ce tutoriel je propose un Liveview avec script pour communiquer avec le Driver. Ce Liveview peut très facilement être adapté à d’autres besoins en modifiant très simplement le contenu des widgets (tableaux) sans avoir à modifier le script.
Sommaire :
Le DM556PR
Le DM556PR est un driver destiné aux moteurs pas à pas bipolaires et pilotable via Modbus RTU (RS‑485). Il offre un contrôle précis et fiable d’un moteur NEMA 23, sans nécessiter la génération d’un signal PULSE comme avec un driver classique. L’unité gère elle‑même la séquence d’impulsions, ce qui simplifie considérablement la mise en œuvre.
Caractéristiques principales
- Alimentation : 24 à 50 VDC
- Courant moteur : jusqu’à 4 A
- Vitesse : 0 à 3000 RPM
- Compatible NEMA 23 / 24 / 34
- Protections intégrées : surchauffe, surtension, surintensité
Entrées / Sorties
- Entrées : 7 entrées opto-isolées dont la fonction est programmable par Modbus. Enable, Reset, Stop (selon configuration)
- Sorties : 3 sorties opto-couplées dont la fonction est programmable (signal d’alarme du driver, status, ..)
Configuration Modbus
Pour une mise en route rapide, les paramètres de communication du protocole Modbus se font par dip-switch (adresse, bauds). Pour des besoins spécifiques, il faudra configurer les paramètres du protocole RS485 via Modbus (bit de parité, stop, …)
Générer un mouvement nécessite le paramétrage des différents modes au préalable, puis l’envoi d’une commande.
le registre 0x0027 est un registre de commande qui déclenche des actions, mais ces actions utilisent des paramètres stockés dans d’autres registres .
Voici un tableau récapitulatif des bits de 0x0027 et leurs dépendances paramétriques :
| Bit | Commande | Registres de paramètres nécessaires | Description |
|---|---|---|---|
| Bit0 | Démarrage mode position | 0x0020 : Vitesse de départ0x0021 : Temps d’accélération0x0022 : Temps de décélération0x0023 : Vitesse de positionnement0x0024–0x0025 : Nombre total de pulses (32 bits)0x0027 Bit2 : Mode relatif/absolu |
Mouvement en trapézoidal avec position cible |
| Bit1 | Démarrage mode vitesse | 0x001D : Vitesse JOG0x001E : Temps d’accélération JOG0x001F : Temps de décélération JOG |
Mouvement à vitesse constante après accélération |
| Bit2 | Mode position (relatif/absolu) | Utilisé avec Bit0 | 0 = position relative, 1 = position absolue |
| Bit3 | Commutation de mode | Contexte du mouvement en cours | 0 = ignorer nouvelle commande, 1 = interrompre et exécuter |
| Bit4 | Retour à l’origine | 0x0031 : Mode retour origine (1–35)0x0032 : Vitesse de recherche0x0033 : Vitesse de retour0x0034 : Temps accél/décél0x0035–0x0036 : Offset origine (32 bits)+ Config des entrées : origine, limites |
Déclenche la séquence de homing selon le mode choisi |
| Bit8 | Arrêt normal | 0x0022 : Temps de décélération (si en mode position)0x001F : Temps décélération JOG (si en mode vitesse) |
Décélération jusqu’à l’arrêt |
| Bit9 | Arrêt d’urgence | Aucun paramètre | Arrêt immédiat sans décélération |
Test via le logiciel du fabricant
Stepperonline fournit un logiciel de débogage compatible Windows. Il est alors possible de connecter le driver à un PC, via un adaptateur RS485/USB
Une fois l’adaptateur connecté au PC, il suffit de configurer la connexion ; les paramètres par défaut fonctionnent généralement sans modification.
Le logiciel permet de configurer et tester le driver. Même si les traductions du chinois vers l’anglais restent perfectibles, cet outil m’a été d’une aide précieuse pour mieux comprendre l’interdépendance de certains paramètres.
Mise en œuvre sur la V5
Pour plus d’informations, vous pouvez vous familiariser avec le protocole Modbus :
Modbus et IPX800 V5 — GCE Electronics
Choix du fonctionnement
Avant de commencer la programmation du driver, il est indispensable de définir précisément les besoins du système. Le driver propose en effet un grand nombre de paramètres associés à différents modes de fonctionnement — mode position (absolu ou relatif), retour à l’origine, mode vitesse, mode JOG, mode multiple, … — chacun permettant de configurer plusieurs paramètres.
Pour ce projet, j’ai choisi d’utiliser le mode Vitesse, qui est le plus simple à mettre en œuvre et à configurer. Dans ce mode, seuls trois paramètres sont nécessaires pour définir le mouvement : la vitesse, l’accélération et la décélération. Une vitesse positive entraîne le moteur dans le sens par défaut, tandis qu’une vitesse négative inverse la rotation. Le mouvement s’interrompt automatiquement lorsqu’un contact de fin de course est activé ou lorsqu’une commande d’arrêt est envoyée.
Ce choix présente également l’avantage de simplifier l’utilisation du registre de commande 0x0027, en éliminant toutes les commandes inutiles liées aux autres modes.
configuration Modbus
les paramètres par défaut du Driver sont compatibles avec l’IPX800.
Les objets Modbus
Pour chaque série de registres à traiter sur IPX, nous devons créer un objet Modbus Read et un objet Modbus Write si l’écriture est autorisée.
Dans la documentation officielle du Driver, toutes les adresses de registres sont données en hexadécimal (exemple : 0x0027). Lors de la création des objets Écriture et Lecture sur IPX, il faut convertir cette adresse hexa en décimal (exemple 0x0027 vaut 39 en décimal)
Il est à noter que par défaut le DM556PR est configuré pour présenter les valeurs 32 bits sur 2 registres 16 bits, le registre de gauche étant le registre de poids fort (Most Significant Word) et celui de droite le registre bas (Low Significant Word). il est donc possible d’utiliser une analogique 32 bits directement sans conversion binaire sur IPX.
Modbus Write
Registre 0x040 : Configuration du niveau logique sur les 7 entrées.
Nous avons besoin de 7 bits ( avec le bit 1 le plus à droite).
Nous complétons la trame avec un octet de poids fort à gauche (que nous n’utiliserons pas).
Registres 0x041 à 0x047 : Affectation des entrées X0 à X6
nous utilisons 7 registres consécutifs.
Registre 0x04B : Niveau logique sur les sorties Y0 à Y2
nous utilisons seulement 3 bits, nous complétons la trame à gauche pour obtenir une longueur totale de 16 bits.
Registre 0x004c à 0x004E : Assignation des sorties
Nous utilisons 3 registres
Registre 0x0011 : Nombre de Pulses/tour
nous utilisons 1 registre
Registres 0x001D à 0x001F : Paramètres du mode vitesse
Nous utilisons 3 registres consécutifs
Registre 0x0027 : Envoi d’une commande de mouvement
au lieu d’envoyer une valeur de commande décomposée en bits, j’envoie une valeur analogique dont la valeur est calculée en fonction des bits activés. Cela est possible car je n’utilise que des commandes simples ne nécessitant pas l’activation de plusieurs bits simultanément.
Registre 0x002D : Envoi d’une commande auxilliaire
Nous utilisons un registre
Modbus Read
Lecture de la position sur 32 bits (2 registres) et de la vitesse actuelle sur 16 bits (1 registre)
Vous remarquez que certains objets Read n’ont pas d’équivalent en Modbus Write, les données sont en lecture seule (exemple 0x0102).
Les registres 0x0027 et 0x002D correspondent à des commandes. Ils sont en écriture seule et n’ont donc pas d’objet lecture.
La vitesse de rotation (0x001D) et la position (0x000A-0x000B) peuvent être négatives lors d’une rotation en sens inverse. Le driver est configuré par défaut pour utiliser le complément à deux pour la conversion. Nous avons donc renseigné la conversion complément à deux dans les objets Lecture et Écriture pour ces registres.
Déclenchement de la lecture
Nous devons lire au moins une fois la valeur de chaque registre. Pour limiter au maximum les collisions entre les trames lues automatiquement par l’IPX800 et celles envoyées manuellement lors de l’écriture de paramètres ou de commandes, je ne réalise une lecture régulière que sur les registres d’état et de position (0x0007 et 0x000A–0x000B). Les autres registres ne sont lus qu’au chargement du LiveView. Le bouton Recharger met simplement à jour l’affichage avec les valeurs déjà présentes dans les variables de l’IPX, sans envoyer de nouvelle requête au driver.
Il est essentiel de ne jamais envoyer plusieurs trames Modbus simultanément. Pour garantir une mise à jour continue des informations du driver, j’ai associé les objets Read (registres 0x007 et 0x000A-0x000B) à un objet Clignotant. Celui‑ci permet de rafraîchir en permanence la position moteur (0x000A–0x000B) ainsi que le registre d’état (0x0007). Un objet est déclenché sur le front montant (lien de type LINK) et l’autre sur le front descendant (lien de type NOT), assurant ainsi une alternance régulière des lectures.
Le clignotant fonctionne en continu, avec des temporisations TA = 500ms et TB = 500ms.
Les objets Read associés aux autres registres sont déclenchés par un scénario. Lors du chargement du LiveView de configuration, le script active l’IO initIO, qui met en marche un clignotant. Ce dernier incrémente un objet Compteur toutes les 200 ms (Ta=100ms, TB=100ms)
Le moteur de scénario envoie alors une trame Modbus à chaque changement de valeur du compteur. Cette approche garantit l’émission d’une trame à un rythme régulier, sans perte ni risque de collision. Avec une trame envoyée toutes les 200 ms, la cadence reste largement suffisante à 115200 bauds pour permettre au driver de traiter correctement chaque requête et de renvoyer sa réponse à l’IPX.
Les règles chargées de l’envoi des trames Modbus :
La première règle active le clignotant afin d’incrémenter le compteur.
Les règles suivantes envoient une trame spécifique pour chaque valeur de compteur.
…
La dernière règle désactive le clignotant et remet le compteur à 0.
Les objets Modbus Write ne sont pas appelés automatiquement.
Liveview de configuration du Driver
J’ai conçu ce LiveView pour organiser les registres par thème au sein de tableaux, chaque tableau étant intégré dans un widget HTML dédié.
Créer un LiveView comportant une multitude de tableaux avec des valeurs en lecture et écriture m’a semblé fastidieux et risqué en termes de mémoire allouée par l’IPX, qui aurait probablement été insuffisante.
J’ai donc opté pour une approche minimaliste : des tableaux construits en HTML léger, enrichis dynamiquement par un script qui automatise :
- La mise en forme (boutons, cases à cocher, champs de saisie, listes déroulantes),
- L’association des variables de l’IPX via leurs identifiants (ID).
Chaque tableau est lié à un objet Modbus Write via un identifiant unique, et chaque valeur est associée soit à une IO (bit) soit à une Ana16 (registre) grâce à son ID. Le script se charge ensuite de lire et écrire les variables dans l’IPX, puis d’établir automatiquement les appels nécessaires vers les objets de l’IPX800 V5 pour communiquer avec le DM556PR .
La mise en forme de chaque tableau repose sur des attributs de données qu’il est alors nécessaire de renseigner dans chaque widget.
Remarques
Ce LiveView ne dispose pas de fonction de rafraîchissement automatique : il faut cliquer sur Recharger pour actualiser les données.
Je n’ai pas prévu de gérer l’ensemble des registres du Driver. Pour des raisons d’optimisation de la mémoire du LiveView, j’ai choisi de laisser certains registres de configuration à leur valeur par défaut, notamment tous les paramètres liés à la fonction de retour à l’origine (Homing ).
Si vous souhaitez manipuler l’intégralité des registres, je vous conseille de les répartir sur deux LiveViews distincts (par exemple : l’un pour la configuration, l’autre pour les commandes et retours d’état).
jeu d’attributs de données personnalisés :
Certains attributs peuvent être définis au niveau global (container). Dans ce cas, les lignes du tableau de valeur pour lesquelles l’attribut n’est pas définit seront valorisées par défaut. Si au contraire une valeur spécifique est définie au niveau de la ligne, elle viendra remplacer la valeur globale.
-
Attributs spéciaux:
data-idau niveau global contient l’id de l’IO correspondant à l’action menée lors du clic sur le bouton d’envoi du tableau. Généralement, c’est donc l’ID de l’IO correspondant au bouton Send de l’objet Modbus Écriture. Si le tableau contient des registres ne pouvant être écrits par le même objet Modbus Écriture, il faut alors renseigner l’ID d’une IO qui déclenchera des actions combinées par le moteur de scénario.data-optionsL’attributdata-optionsest conçu pour être polymorphe : selon que sa valeur soit un nombre ou une liste séparée par des virgules, le script adapte automatiquement le type de contrôle affiché (champ numérique, liste déroulante). Cette approche permet une grande flexibilité dans la configuration des tableaux tout en conservant une structure HTML minimaliste.- Si l’attribut contient une seule valeur numérique, un champ de saisie numérique est généré.
- S’il contient une liste de valeurs séparées par des virgules, la partie numérique située avant le texte (séparateur : ) est envoyée dans l’analogique associée.
- Si l’attribut n’est pas défini, un champ de saisie numérique est créé par défaut.
data-RWest un attribut facultatif qui indique si les données du tableau sont modifiables.- Si
data-RWn’est pas défini ou si sa valeur estRW, les données sont modifiables et le bouton d’envoi Modbus est généré. - Si
data-RWest égal àRO, les données ne sont pas modifiables et le bouton d’envoi Modbus n’est pas créé.
-
Utilisation des attributs de données par type de tableau
Tableau Bits :
- Container :
data-type="bit"(obligatoire)data-title(obligatoire) titre du tableaudata-id(obligatoire) représente l’id de l’IO Send de l’objet Modbus Écriture rattachédata-RW(facultatif) valeurs en Lecture seule si data-RW=‹ RO ›
- Lignes :
data-bit(obligatoire) contient un libellé libredata-fonction(obligatoire) Libellé du rôle de chaque bitdata-id(optionnel - spécifique) id de l’IO en lien avec chaque bit
Tableau Registres :
- Container :
data-type="registre"(obligatoire) Type de donnéesdata-title(obligatoire) Titre du tableaudata-id(obligatoire) représente l’id de l’IO menant l’action sur envoidata-RW(facultatif) valeurs en Lecture seule si data-RW=‹ RO ›data-fonction(optionnel - global) libellé qui sera appliqué à chaque valeur pour laquelle data-fonction n’est pas défini.data-options(optionnel - global)
- Lignes :
data-registre(obligatoire) : libellé libredata-id(obligatoire) id de l’analogique rattachée au registredata-fonction(facultatif) libellé du registredata-options(facultatif) valeurs(s) acceptée(s) par le registre
Exemple de xml simplifié pour lire et écrire des valeurs de bits dans des IO
- Container :
<!-- Tableau type Bits -->
<div class="container" data-type="bit" data-title="Configuration des Niveaux sur Sorties (Registre 0x004B) (0:normal, 1:inversé)" data-id="65769" >
<h3></h3>
<table>
<thead></thead>
<tbody>
<tr data-bit="Bit 1"
data-fonction="Niveau logique sur Y0 "
data-id="65766">
</tr>
<tr data-bit="Bit 2"
data-fonction="Niveau logique sur Y1"
data-id="65767">
</tr>
<tr data-bit="Bit 3"
data-fonction="Niveau logique sur Y2"
data-id="65768"></tr>
</tbody>
</table>
</div>
Exemple de xml simplifié pour lire et écrire des valeurs analogiques prédéfinies sur les registres
<!-- Tableau type registre -->
<div class="container" data-type="registre" data-title="Assignation des Sorties (0x004C–0x004E)" data-cols="3" data-id="65770" data-options=
"0: no function,
1: signal défaut moteur,
2: Driver status (0=inactif 1=en mouvement),
3: Signal de retour complet à l'origine ,
4: Signal d'arrivée,
5: Signal de freinage"
>
<h3></h3>
<table>
<thead></thead>
<tbody>
<tr data-registre="0x004C"
data-fonction="OUTPUT Y0"
data-id="262271">
</tr>
<tr data-registre="0x004D"
data-fonction="OUTPUT Y1"
data-id="262272">
</tr>
<tr data-registre="0x004E"
data-fonction="OUTPUT Y2"
data-id="262273"></tr>
</tbody>
</table>
</div>
Exemple de xml simplifié pour lire et écrire des valeurs analogiques non contraintes sur les registres
<!-- Tableau type registre -->
<div class="container" data-type="registre" data-title="Paramètres du mode Vitesse" data-id="65608" >
<h3></h3>
<table>
<thead></thead>
<tbody>
<tr data-registre="0x001D"
data-fonction="Vitesse du mouvement (0-3000 RPM)"
data-id="262216">
</tr>
<tr data-registre="0x001E"
data-fonction="Durée d'accélération (0-2000 ms)"
data-id="262217">
</tr>
<tr data-registre="0x001F"
data-fonction="Durée de décélération (0-2000 ms)"
data-id="262218"></tr>
</tbody>
</table>
</div>
Exemple de tableau en lecture seule
<div class="container" data-type="bit" data-title="(0x0007)Informations Status Driver" data-RW="RO" data-id="65585" >
<h3></h3>
<table>
<thead></thead>
<tbody>
<tr data-bit="bit 1"
data-fonction="Arrivé à destination"
data-id="65634">
</tr>
<tr data-bit="bit 2"
data-fonction="retour origine"
data-id="65635">
</tr>
<tr data-bit="bit 3"
data-fonction="Mouvement en cours"
data-id="65636"></tr>
<tr data-bit="bit 4"
data-fonction="Alarme moteur"
data-id="65637"></tr>
<tr data-bit="bit 5"
data-fonction="Moteur relâché"
data-id="65638"></tr>
<tr data-bit="bit 6"
data-fonction="Dépassement butée logicielle avant"
data-id="65639"></tr>
<tr data-bit="bit 7"
data-fonction="Dépassement butée logicielle arrière"
data-id="65640"></tr>
</tbody>
</table>
</div>
Le script de mise en forme automatique :
Le code non minifié ci-dessous ne sert qu’à la compréhension du tutoriel et pour tests avec un ou deux tableaux seulement.
Pour un Liveview plus complexe, il est impératif de prendre ce code sous sa forme minifiée. Il est intégré dans le Liveview téléchargeable un peu plus bas.
Dans ce script, il convient d’adapter la clé API (ligne 5).
initIO (ligne 8) représente l’ID de l’IO prise en compte par le moteur de scénario pour lire la valeur des registres sur le DM556PR. Adaptez au besoin.
Script de mise en forme (non minifié)
<script>
// ---------- Configuration IPX ----------
var API_CONFIG = {
server: "",
api: "L7rmxfgulQ95Sy9",
uriAna: "/api/core/ana/",
uriIO: "/api/core/io/",
initIO:65530
};
// ---------- Helpers ----------
function createSelect(options) {
const select = document.createElement('select');
options.forEach(opt => {
const option = document.createElement('option');
const text = opt.trim();
const value = text.includes(':') ? text.split(':')[0].trim() : text;
option.value = value;
option.textContent = text;
select.appendChild(option);
});
return select;
}
function createHidden(name, value) {
const input = document.createElement('input');
input.type = 'hidden';
input.name = name;
input.value = value || "";
return input;
}
function appendSendButton(tbody, label, callback, isReadOnly) {
const emptyRow = document.createElement('tr');
emptyRow.classList.add('empty-row');
const tdBtn = document.createElement('td');
tdBtn.colSpan = 3;
// Si mode RO, on affiche un message au lieu du bouton
if (isReadOnly) {
const roMsg = document.createElement('span');
roMsg.textContent = "Mode lecture seule";
roMsg.className = "ro-message";
roMsg.style.cssText = "color:#666; font-style:italic;";
tdBtn.appendChild(roMsg);
} else {
const btn = document.createElement('button');
btn.textContent = label;
btn.className = "send-btn";
btn.addEventListener('click', callback);
tdBtn.appendChild(btn);
}
emptyRow.appendChild(tdBtn);
tbody.appendChild(emptyRow);
}
function buildValueCell(optionsRaw, container, isReadOnly) {
const td = document.createElement('td');
if (!optionsRaw || optionsRaw.trim() === "") {
const input = document.createElement('input');
input.type = "number";
if (isReadOnly) {
input.disabled = true;
input.style.backgroundColor = "#f5f5f5";
} else {
input.addEventListener('input', () => markModified(container));
}
td.appendChild(input);
return td;
}
// Nettoyage des options (suppression des espaces et retours à ligne)
const cleanedOptions = optionsRaw.replace(/\s+/g, ' ').trim();
const options = cleanedOptions.split(',').map(opt => opt.trim()).filter(opt => opt.length > 0);
if (options.length === 1) {
const option = options[0];
const optionLower = option.toLowerCase();
if (optionLower === "no") {
return td; // Cellule vide
}
const input = document.createElement('input');
input.type = "number";
if (isReadOnly) {
input.disabled = true;
input.style.backgroundColor = "#f5f5f5";
}
const numValue = parseFloat(option);
if (!isNaN(numValue)) {
input.value = numValue;
} else {
input.placeholder = option;
}
if (!isReadOnly) {
input.addEventListener('input', () => markModified(container));
}
td.appendChild(input);
} else {
const select = createSelect(options);
if (isReadOnly) {
select.disabled = true;
select.style.backgroundColor = "#f5f5f5";
} else {
select.addEventListener('change', () => markModified(container));
}
td.appendChild(select);
}
return td;
}
// ---------- Status ----------
function updateStatus(container, icon) {
const status = container.querySelector('.status');
if (status) status.textContent = icon;
}
function markModified(container) {
updateStatus(container, "✏️");
}
function markOK(container) {
updateStatus(container, "✅");
}
// ---------- Build Table ----------
function buildTable(container) {
const { type, title, rw } = container.dataset;
const isBit = type === "bit";
const isReadOnly = rw === "RO";
const headLabels = isBit ? ['bit', 'fonction', 'valeur'] : ['registre', 'fonction', 'valeur'];
// Mise à jour du titre
const h3 = container.querySelector('h3');
if (h3) {
// Ajout de l'indicateur RO si en lecture seule
const titleText = title || "";
const roIndicator = isReadOnly ? " (Lecture seule)" : "";
h3.textContent = titleText + roIndicator;
const statusSpan = document.createElement('span');
statusSpan.className = "status";
statusSpan.textContent = "✅";
statusSpan.style.cssText = "float:right; font-weight:600;";
h3.appendChild(statusSpan);
}
// Reconstruction de l'en-tête
const thead = container.querySelector('thead');
if (thead) {
thead.innerHTML = '';
const headRow = document.createElement('tr');
headLabels.forEach(label => {
const th = document.createElement('th');
th.textContent = label;
headRow.appendChild(th);
});
thead.appendChild(headRow);
}
// Construction du corps
const tbody = container.querySelector('tbody');
if (!tbody) return;
const globalOptions = container.dataset.options ? container.dataset.options.trim() : null;
tbody.querySelectorAll('tr').forEach(tr => {
const id = tr.dataset.id ? tr.dataset.id.trim() : "";
if (isBit) {
const bit = tr.dataset.bit || "";
const fonction = tr.dataset.fonction ? tr.dataset.fonction.trim() : "";
if (!id) {
console.warn(`Ligne bit ${bit} sans data-id !`);
}
const tdBit = document.createElement('td');
tdBit.textContent = bit;
const tdFunc = document.createElement('td');
tdFunc.textContent = fonction;
const tdVal = document.createElement('td');
const cb = document.createElement('input');
cb.type = "checkbox";
if (isReadOnly) {
cb.disabled = true;
} else {
cb.addEventListener('change', () => markModified(container));
}
tdVal.appendChild(cb);
tr.innerHTML = '';
tr.appendChild(tdBit);
tr.appendChild(tdFunc);
tr.appendChild(tdVal);
// Ajout du champ caché pour l'ID
if (id) {
tr.appendChild(createHidden(`id_${bit}`, id));
}
} else {
const registre = tr.dataset.registre || "";
const fonction = tr.dataset.fonction ? tr.dataset.fonction.trim() : "";
if (!id) {
console.warn(`Ligne registre ${registre} sans data-id !`);
}
if (!fonction) {
console.warn(`Ligne registre ${registre} sans data-fonction !`);
}
const rowOptions = tr.dataset.options ? tr.dataset.options.trim() : globalOptions;
const tdReg = document.createElement('td');
tdReg.textContent = registre;
const tdFunc = document.createElement('td');
tdFunc.textContent = fonction;
const tdVal = buildValueCell(rowOptions, container, isReadOnly);
tr.innerHTML = '';
tr.appendChild(tdReg);
tr.appendChild(tdFunc);
tr.appendChild(tdVal);
// Champ caché pour le numéro de fonction (partie avant :)
const hiddenFuncNum = createHidden(`funcnum_${registre}`, "");
hiddenFuncNum.value = (fonction.split(':')[0] || "").trim();
tr.appendChild(hiddenFuncNum);
// Ajout du champ caché pour l'ID
if (id) {
tr.appendChild(createHidden(`id_${registre}`, id));
}
}
});
// Bouton d'envoi (désactivé en mode RO)
const buttonLabel = isBit ? "Envoyer Bits vers IPX" : "Envoyer Registres vers IPX";
const callback = isBit ? () => sendToIPX(container, "bit") : () => sendToIPX(container, "registre");
appendSendButton(tbody, buttonLabel, callback, isReadOnly);
}
// ---------- Fonctions API IPX ----------
function getApiUrl(id, type) {
const uri = type === "bit" ? API_CONFIG.uriIO : API_CONFIG.uriAna;
return `${API_CONFIG.server}${uri}${id}?ApiKey=${API_CONFIG.api}`;
}
async function readIPXValue(id, type) {
try {
const response = await fetch(getApiUrl(id, type), { method: "GET" });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (error) {
console.error(`Erreur lecture IPX ${id}:`, error);
throw error;
}
}
async function writeIPXValue(id, type, value) {
try {
const body = type === "bit"
? JSON.stringify({ on: value })
: JSON.stringify({ value: isNaN(parseFloat(value)) ? value : parseFloat(value) });
const response = await fetch(getApiUrl(id, type), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return await response.json();
} catch (error) {
console.error(`Erreur écriture IPX ${id}:`, error);
throw error;
}
}
async function activateContainerIO(containerId) {
if (!containerId) return;
try {
const response = await fetch(getApiUrl(containerId, "bit"), {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ on: true })
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
console.log(`✓ IO ${containerId} activée`);
return true;
} catch (error) {
console.error(`✗ Erreur activation IO ${containerId}:`, error);
return false;
}
}
// ---------- Envoi vers IPX ----------
async function sendToIPX(container, type) {
// Vérification du mode RO
if (container.dataset.rw === "RO") {
console.warn("Tentative d'écriture en mode lecture seule - bloquée");
alert("Impossible d'envoyer en mode lecture seule");
return;
}
console.log(`=== Envoi ${type === "bit" ? "Bits" : "Registres"} vers IPX ===`);
const containerId = container.dataset.id;
console.log("ID conteneur pour activation IO:", containerId || "Non défini");
const rows = container.querySelectorAll('tbody tr:not(.empty-row)');
const errors = [];
for (const tr of rows) {
const hiddenId = tr.querySelector('input[name^="id_"]');
if (!hiddenId) {
const ref = tr.querySelector('td:nth-child(1)')?.textContent.trim() || "";
console.warn(`${type === "bit" ? "Bit" : "Registre"} ${ref} sans ID - ignoré`);
errors.push(`${ref}: pas d'ID`);
continue;
}
const id = hiddenId.value;
let value = "";
if (type === "bit") {
value = tr.querySelector('input[type="checkbox"]')?.checked || false;
} else {
const valCell = tr.querySelector('td:nth-child(3)');
if (valCell) {
const select = valCell.querySelector('select');
const input = valCell.querySelector('input[type="number"]');
if (select) value = select.value;
else if (input) value = input.value;
else value = valCell.textContent.trim();
}
if (value === "" && !tr.querySelector('td:nth-child(3)')?.textContent?.includes("no")) {
console.warn(`Registre ${id} avec valeur vide - ignoré`);
errors.push(`${id}: valeur vide`);
continue;
}
}
try {
console.log(`Écriture ${type} -> ID:${id}, Valeur:${value}`);
await writeIPXValue(id, type, value);
console.log(`✓ ${type === "bit" ? "Bit" : "Registre"} ${id} écrit`);
} catch (error) {
console.error(`✗ Erreur ${type} ${id}:`, error);
errors.push(`${id}: ${error.message}`);
}
}
// Activation IO
if (containerId) {
try {
await activateContainerIO(containerId);
} catch (error) {
errors.push(`Activation IO: ${error.message}`);
}
}
// Mise à jour statut
if (errors.length === 0) {
console.log(`✓ Tous les ${type === "bit" ? "bits" : "registres"} envoyés`);
markOK(container);
} else {
console.error(`✗ Erreurs:`, errors);
alert("Erreurs lors de l'envoi:\n" + errors.join("\n"));
}
}
// ---------- Pré-chargement depuis IPX ----------
async function preloadValues() {
console.log("=== Pré-chargement IPX ===");
const errors = [];
for (const container of document.querySelectorAll('.container')) {
const type = container.dataset.type;
for (const tr of container.querySelectorAll('tbody tr[data-id]')) {
const id = tr.dataset.id;
if (!id) continue;
try {
const data = await readIPXValue(id, type);
if (type === "bit") {
const checkbox = tr.querySelector('input[type="checkbox"]');
if (checkbox) checkbox.checked = data.on || false;
} else {
const valCell = tr.querySelector('td:nth-child(3)');
if (!valCell) continue;
const select = valCell.querySelector('select');
const input = valCell.querySelector('input[type="number"]');
const valueStr = data.value?.toString() || "";
if (select) {
const optionValues = Array.from(select.options).map(o => o.value);
if (optionValues.includes(valueStr)) {
select.value = valueStr;
} else {
console.warn(`Valeur ${valueStr} hors options pour ${id}`);
errors.push(`${id}: valeur ${valueStr} hors options`);
}
} else if (input) {
input.value = valueStr;
} else if (valCell.textContent.trim() === "") {
valCell.textContent = valueStr;
}
}
} catch (error) {
console.error(`Erreur pré-chargement ${id}:`, error);
errors.push(`${id}: ${error.message}`);
}
}
if (errors.length === 0) markOK(container);
}
if (errors.length > 0) {
console.error("Erreurs pré-chargement:", errors);
alert("Erreurs pré-chargement:\n" + errors.join("\n"));
}
}
// ---------- Init ----------
function init() {aio(initIO)}
function relaod(){
document.querySelectorAll('.container').forEach(buildTable);
preloadValues();
}
// Initialiser les tables après un court délai
setTimeout(init, 600);
setTimeout(reload,1200);
</script>
<!-- Bouton Reload -->
<button class="send-btn" style="padding:20px;font-size:2em;background-color:#f20" onclick="preloadValues()">Recharger</button>
CSS
<style>
:root{--font-size:0.9em;--row-height:0.9em}
body{font-family:Inter,sans-serif;color:#e6edf3;font-size:var(--font-size)}
.send-btn{background:#1f6feb;color:#fff;border:none;border-radius:6px;padding:6px 12px;font-size:var(--font-size);cursor:pointer;transition:background .3s}
.send-btn:hover{background:#1158c7}
.container{flex:1 1 45%;min-width:300px;background:#17282E;border-radius:16px;padding:16px;box-sizing:border-box;overflow:visible}
h3{margin:0 0 12px;font-size:calc(var(--font-size) + 1px);color:#1f6feb}
table{width:100%;border-collapse:collapse;background:#0c1015;border:1px solid #1E343A;border-radius:10px;font-size:var(--font-size)}
th,td{padding:1px 6px;border-bottom:1px solid #1c2431;text-align:left;line-height:var(--row-height);font-size:var(--font-size)}
th{background:#131821;font-weight:700}
tr:hover td{background:rgba(31,111,235,.08)}
input[type="number"] {width:90%;padding:1px 6px;background:#0d1117;color:#e6edf3;border:1px solid #2a3240;border-radius:6px;font-size:var(--font-size)}
select{width:100%;padding:1px 6px;background:#0d1117;color:#e6edf3;border:1px solid #2a3240;border-radius:6px;font-size:var(--font-size)}
select{background-color:#999;color:#000;border-radius:4px;padding:1px}
.empty-row td{height:var(--row-height);background:transparent;border:none}
.img-responsive{max-width:100%;height:auto;display:block}
input:disabled,select:disabled{background-color:#ff9800!important;border:2px solid #cc7a00!important;border-radius:3px;color:#333;cursor:not-allowed;opacity:.9}input[type="checkbox"]:disabled{appearance:none;-webkit-appearance:none;width:18px;height:18px;position:relative}input[type="checkbox"]:disabled:checked::before{content:"";position:absolute;left:5px;top:1px;width:5px;height:10px;border:solid #000;border-width:0 2px 2px 0;transform:rotate(45deg)}
</style>
Voici le Liveview complet en version téléchargeable, comprenant la version minifiée du script :
DM556PR.zip (7,2 Ko)
Dans les widgets HTML du Liveview, adaptez les attributs data-id à votre configuration.
Mise en pratique
Cette étude du DM556PR s’inscrit dans la conception d’un abri motorisé, compact et démontable, destiné à protéger un télescope et le matériel d’astrophotographie lors de longues sessions d’imagerie, parfois réparties sur plusieurs nuits. Elle répond également aux besoins de l’observation en EAA (visuel assisté), activité pour laquelle je n’ai plus envie, avec l’âge, de passer des heures dehors durant les longues nuits d’hiver. L’objectif n’est pas de reproduire toutes les exigences d’un observatoire entièrement télé-opéré, mais d’en approcher les fonctionnalités essentielles dans un format plus simple et transportable.
Ce qui est prévu :
L’ouverture et la fermeture du toit seront confiées à un moteur pas‑à‑pas Nema 23 bipolaire (1.8°, 3A, 2.8Nm), piloté en Modbus via un driver DM556PR. Cette combinaison offrira la précision, la fiabilité et la facilité d’intégration nécessaires pour s’insérer proprement dans un système automatisé et sécurisé
La sécurité du matériel est un point clé : l’abri intègrera une alarme par détection de vibrations et pourra se refermer automatiquement en cas de pluie ou de vent fort. L’ensemble sera supervisé par une IPX V5, qui gèrera la communication Modbus et assurera un pilotage réactif. Le télescope est contrôlé à distance via un ASIAIR, qui interrompt automatiquement la prise de vue dès que le toit se ferme et que les étoiles disparaissent du champ. Il n’est donc pas nécessaire d’ajouter un driver ASCOM Dome : le toit sera suffisamment haut pour se refermer même si la monture n’est pas en position Home ou en plein retournement au méridien (pas besoin non plus d’une interface EQMOD pour synchroniser le toit et la monture)
L’ajout d’une caméra dans l’abri permettra de surveiller à distance le fonctionnement en toute sécurité, notamment pendant les phases critiques de retournement au méridien (câble accroché, obstacle, …)
L’abri modulaire : plans provisoires
Structure
L’abri sera réalisé en tôles d’acier de 0,63 mm montées sur une structure en tubes d’acier rectangulaires, offrant une bonne rigidité pour un poids contenu. Les panneaux latéraux seront démontables, et les tubes équipés d’écrous à sertir permettront un assemblage simple par plaques et équerres. Le toit s’ouvrira en deux moitiés symétriques, chacune montée sur des glissières industrielles de 22" à extraction totale pour charge lourde (210kg), alors que chaque demi-toit pèsera moins de 10 kg. Cela garantira une bonne rigidité de l’ensemble mobile et une bonne protection contre l’arrachement en cas de vandalisme.
Un ancrage au sol assurera la stabilité de l’ensemble face au vent ou aux tentatives de manipulation extérieure. Ces piquets d’ancrage ne seront accessibles que depuis l’intérieur de l’abri lorsque ce dernier sera en place. Lorsque le toit sera fermé et verrouillé, les ancrages seront sécurisés.
La fermeture du toit sera sécurisée par deux verrous électromécaniques, évitant de maintenir le moteur sous tension. Une platine relais sera connectée sur les sorties à collecteur ouvert. Une simple impulsion suffira à déverrouiller les demi-toits avant leur ouverture
Je réfléchis à monter le télescope sur son trépied ou sur une colonne ancrée au sol. Pour cette dernière option, je pense utiliser une vis de fondation de 1250 à 1500 mm, sur la tête de laquelle je fixerai un tube carré 100x100 m en acier de 3mm. Les éventuelles vibrations seront supprimées par des patins entre la tête de la vis et la platine soudée au tube.
Le guidage
Échelle non respectée
Échelle non respectée
Le moteur Nema 23, équipé d’un arbre double, entraînera simultanément deux axes, chacun composé par deux vis trapézoïdales TR8×8 (long:600mm, pas:2 mm, avance:8 mm) inversées (une vis à pas standard à droite et une vis avec pas à gauche), assurant ainsi un mouvement symétrique et parfaitement synchronisé des deux demi-toits. La vitesse de rotation, fixée à 300 RPM, permettra d’obtenir une fermeture complète en moins de 15 secondes, afin de mettre rapidement le matériel à l’abri en cas de pluie soudaine. Les rampes d’accélération et de décélération seront soigneusement paramétrées pour éliminer les à‑coups au démarrage comme à l’arrêt, garantissant un fonctionnement fluide et préservant la mécanique.
Les schémas
Le liveview de supervision en cours de construction
Widget pour dashboard (en cours)

Bonne journée
Si certaines personnes sont intéressées, je posterai ultérieurement des photos de la réalisation de l’abri après réalisation complète car j’ai déjà en tête des modifications au niveau des fonctionnalités. Je vais très certainement ajouter un récepteur RF avec une télécommande à rolling code pour ouvrir et fermer l’abri sans avoir à ouvrir l’ihm de l’ipx.
A bientôt






































