Guide de développement — ProJote

Ce document explique l'intégralité de l'architecture et du code du plugin ProJote. Il sert à la fois de documentation technique et de cours pratique pour comprendre comment développer un plugin Jeedom avec daemon Python.

1. Architecture générale d'un plugin Jeedom

Un plugin Jeedom suit toujours la même architecture en couches :

Navigateur (JS) ──AJAX──► ProJote.ajax.php ──► ProJote.class.php ──► BDD Jeedom
                                                       │
                                                 socket TCP
                                                       │
                                              ProJoted.py (daemon)
                                                       │
                                               API Pronote (internet)
                                                       │
                                           HTTP callback (POST)
                                                       │
                                              jeeProJote.php ──► ProJote.class.php

Le cœur du plugin est la classe PHP ProJote qui hérite de eqLogic (un "équipement" Jeedom). Le daemon Python tourne en arrière-plan, collecte les données Pronote, et les renvoie vers Jeedom via un callback HTTP.

2. Structure des fichiers du plugin

ProJote/
├── plugin_info/
│   ├── info.json           ← Métadonnées obligatoires du plugin
│   ├── packages.json       ← Dépendances système (apt, pip, npm)
│   └── ProJote.png         ← Icône du plugin (128×128 px)
├── core/
│   ├── class/
│   │   └── ProJote.class.php   ← Classe principale (eqLogic + cmd)
│   ├── ajax/
│   │   └── ProJote.ajax.php    ← Endpoints AJAX (actions JS → PHP)
│   └── php/
│       └── jeeProJote.php      ← Callback HTTP du daemon Python
├── resources/
│   └── ProJoted/
│       ├── ProJoted.py         ← Daemon Python principal
│       ├── LoginConnect.py     ← Gestion connexion login/password
│       ├── QRConnect.py        ← Gestion connexion QR code
│       └── jeedom/
│           └── jeedom.py       ← Module officiel Jeedom Python
├── desktop/
│   ├── php/
│   │   └── ProJote.php         ← Vue liste des équipements
│   ├── js/
│   │   └── ProJote.js          ← JavaScript frontend
│   └── modal/
│       └── cmd.config.*.html   ← Templates modaux de config
├── core/template/
│   └── dashboard/
│       └── cmd.info.string.note.html ← Template widget
└── docs/
    └── fr_FR/
        ├── index.html          ← Documentation utilisateur
        └── dev.html            ← Ce fichier

3. plugin_info/ — Métadonnées du plugin

info.json

Ce fichier est obligatoire. Jeedom le lit pour connaître le plugin.

{
    "id": "ProJote",            // Identifiant unique — doit correspondre au nom du dossier
    "name": "Plugin Pronote",   // Nom affiché dans le market
    "pluginVersion": "0.9d",    // Version du plugin (semver recommandé)
    "description": {
        "fr_FR": "Plugin destinée à récupérer les informations de Pronote",
        "en_US": "Plugin to retrieve information from Pronote school management platform"
    },
    "licence": "AGPL",          // Obligatoire AGPL pour les plugins Jeedom
    "author": "Aldarande",
    "require": "4.4",           // Version Jeedom minimale requise
    "category": "organization",
    "hasDependency": true,      // Le plugin a des dépendances à installer
    "hasOwnDeamon": true,       // Le plugin gère son propre daemon
    "maxDependancyInstallTime": 60,
    "compatibility": ["smart", "luna", "atlas", "rpi", "docker", "diy", "mobile"]
}
Règle id doit être identique au nom du dossier du plugin ET au nom de la classe PHP principale.

packages.json

Déclare les dépendances à installer automatiquement. Remplace les anciens scripts install_apt.sh.

{
    "apt": {
        "python3-dev": {},
        "python3-venv": {},
        "python3-requests": {},
        "python3-pyudev": {}
    },
    "post-install": {
        "script": "plugins/ProJote/resources/post-install.sh"
    }
}

Le script post-install.sh crée le venv Python et installe les packages pip spécifiques (dont pronotepy).

4. core/class/ProJote.class.php — La classe principale

4.1 Héritage et structure

class ProJote extends eqLogic {
    // Propriétés statiques spéciales lues par le Core Jeedom
    public static $_encryptConfigKey = array('Token_password');
    public static $_widgetPossibility = array(...);

    // Méthodes statiques du daemon (Jeedom les appelle automatiquement)
    public static function deamon_info() { ... }
    public static function deamon_start() { ... }
    public static function deamon_stop() { ... }
    public static function dependancy_info() { ... }
    public static function dependancy_install() { ... }

    // Méthodes du cycle de vie d'un équipement
    public function preSave() { ... }
    public function postSave() { ... }
    public function preRemove() { ... }

    // Méthodes de cron
    public static function cronHourly() { ... }

    // Méthodes métier spécifiques à ProJote
    public static function sendToDeamon($data) { ... }
    public function getListeDefaultCommandes() { ... }
}

class ProJoteCmd extends cmd {
    public function execute($_options = []) { ... }
}

4.2 $_encryptConfigKey — Chiffrement automatique

public static $_encryptConfigKey = array('Token_password');

Jeedom chiffre automatiquement en BDD les valeurs de configuration dont la clé est listée ici. Le déchiffrement est aussi automatique lors de la lecture. Utilisez-le pour les mots de passe, tokens, clés API.

4.3 $_widgetPossibility — Paramètres de widget

public static $_widgetPossibility = array(
    'custom'         => true,
    'custom::layout' => false,
    'parameters'     => array(
        'accent_color' => array(          // Couleur d'accentuation (sélecteur)
            'label'   => 'Couleur d\'accentuation',
            'type'    => 'color',
            'default' => '#94C904',
        ),
        'font_size' => array(             // Taille de police de base (select)
            'label'   => 'Taille de police',
            'type'    => 'select',
            'default' => '12px',
            'values'  => array('10px'=>'10', '12px'=>'12 (défaut)', '14px'=>'14', '16px'=>'16'),
        ),
        'default_tab' => array(           // Onglet ouvert à l'affichage initial
            'label'   => 'Onglet par défaut',
            'type'    => 'select',
            'default' => 'dv',
            'values'  => array('dv'=>'Devoirs', 'notes'=>'Notes', 'abs'=>'Absences',
                               'ret'=>'Retards', 'pun'=>'Punitions'),
        ),
        'edt_nav_mode' => array(          // Mode navigation EDT jours suivants
            'label'   => 'Navigation EDT jours suivants',
            'type'    => 'select',
            'default' => 'next_day',
            'values'  => array('next_day'=>'Jour suivant (J+1)', 'arrows'=>'Flèches (J+1 à J+4)'),
        ),
    ),
);

Expose des paramètres configurables depuis l'onglet Affichage de l'équipement (section Paramètres avancés). Ces valeurs sont accessibles dans toHtml() via $replace['#param_name#'] et injectées en tant que placeholders dans le template HTML (ex. #accent_color#, #font_size#, #default_tab#, #edt_nav_mode#).

4.4 deamon_info() — État du daemon

public static function deamon_info() {
    $return = array(
        'log'       => __CLASS__,
        'state'     => 'nok',
        'launchable' => 'ok',
    );

    // Vérifier si le PID est actif
    $pid_file = jeedom::getTmpFolder(__CLASS__) . '/deamon.pid';
    if (file_exists($pid_file)) {
        $pid = intval(file_get_contents($pid_file));
        if (posix_getsid($pid) !== false) {  // le processus est vivant
            $return['state'] = 'ok';
        }
    }

    // Vérifier si le daemon est lançable (configuration minimale)
    $url = config::byKey('Url', __CLASS__);
    if (empty($url)) {
        $return['launchable'] = 'nok';
        $return['launchable_message'] = 'URL Pronote non configurée';
    }
    return $return;
}
Clés importantes

4.5 deamon_start() — Démarrage du daemon

public static function deamon_start() {
    self::deamon_stop();  // S'assurer que l'instance précédente est arrêtée

    $path = realpath(dirname(__FILE__) . '/../../resources/ProJoted');
    $socketport = config::byKey('socketport', __CLASS__, '55369');
    $callback = network::getNetworkAccess('internal', 'http:127.0.0.1:port:comp');
    $loglevel = log::convertLogLevel(log::getLogLevel(__CLASS__));
    $apikey = jeedom::getApiKey(__CLASS__);
    $pid_file = jeedom::getTmpFolder(__CLASS__) . '/deamon.pid';
    $data_dir = dirname(dirname(__FILE__)) . '/data';

    // system::getCmdPython3() gère automatiquement venv Debian 12 vs système
    $cmd = system::getCmdPython3(__CLASS__) . " {$path}/ProJoted.py";
    $cmd .= ' --loglevel ' . $loglevel;
    $cmd .= ' --socketport ' . $socketport;
    $cmd .= ' --callback ' . $callback . '/plugins/ProJote/core/php/jeeProJote.php';
    $cmd .= ' --apikey ' . $apikey;
    $cmd .= ' --cycle 3';
    $cmd .= ' --pid ' . $pid_file;
    $cmd .= ' --datadir ' . escapeshellarg($data_dir);

    exec($cmd . ' >> ' . log::getPathToLog(__CLASS__) . ' 2>&1 &');

    // Attendre le démarrage (max 20s)
    $i = 0;
    while ($i < 20) {
        sleep(1);
        $deamon_info = self::deamon_info();
        if ($deamon_info['state'] === 'ok') break;
        $i++;
    }
}
Important Utilisez toujours system::getCmdPython3(__CLASS__) et non un chemin Python hardcodé. Cette méthode gère automatiquement le venv Python (Debian 12) ou le Python système selon la plateforme.

4.6 deamon_stop() — Arrêt du daemon

public static function deamon_stop() {
    $pid_file = jeedom::getTmpFolder(__CLASS__) . '/deamon.pid';
    if (file_exists($pid_file)) {
        $pid = intval(file_get_contents($pid_file));
        system::kill($pid);     // Kill propre par PID
        unlink($pid_file);      // Supprimer le fichier PID
    }
    system::kill('ProJoted.py');  // Kill par nom de processus (sécurité)
}
À éviter shell_exec(system::getCmdSudo() . 'rm -rf ' . $pid_file) — inutilement root, et dangereux. Utilisez simplement unlink().

4.7 preSave() — Avant sauvegarde d'un équipement

public function preSave() {
    // Générer un UUID si absent (identifiant unique de l'équipement)
    if (empty($this->getConfiguration('CmdId'))) {
        $this->setConfiguration('CmdId', jeedom::createUniqueId());
    }

    // Largeur par défaut sur le dashboard (360px depuis v0.9b)
    if (empty($this->getDisplay('width'))) {
        $this->setDisplay('width', '360px');
    }

    // IMPORTANT : protéger les tokens de ne pas être écrasés lors d'une sauvegarde
    // (la sauvegarde UI ne renvoie pas les champs chiffrés)
    $existingConfig = eqLogic::byId($this->getId());
    if ($existingConfig) {
        $existing_token = $existingConfig->getConfiguration('Token_password');
        $new_token = $this->getConfiguration('Token_password');
        if (empty($new_token) && !empty($existing_token)) {
            $this->setConfiguration('Token_password', $existing_token);
        }
    }
}

4.8 postSave() — Après sauvegarde

public function postSave() {
    // Créer/mettre à jour les commandes par défaut
    foreach ($this->getListeDefaultCommandes() as $cmdData) {
        $cmd = $this->getCmd(null, $cmdData['logicalId']);
        if (!is_object($cmd)) {
            $cmd = new ProJoteCmd();
            $cmd->setEqLogic_id($this->getId());
            $cmd->setLogicalId($cmdData['logicalId']);
        }
        $cmd->setName($cmdData['name']);
        $cmd->setType($cmdData['type']);
        $cmd->setSubType($cmdData['subType'] ?? 'string');
        $cmd->save();
    }
}

4.9 sendToDeamon() — Envoyer une commande au daemon

public static function sendToDeamon($data) {
    $socketport = config::byKey('socketport', __CLASS__, '55369');
    $socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
    if (@socket_connect($socket, '127.0.0.1', intval($socketport))) {
        socket_write($socket, json_encode($data) . "\n");
        socket_close($socket);
    }
}

Cette méthode envoie un JSON au daemon Python via socket TCP. C'est le canal PHP → Python.

5. core/ajax/ProJote.ajax.php — Le handler AJAX

<?php
try {
    require_once dirname(__FILE__) . '/../../../../core/php/core.inc.php';
    include_file('core', 'authentification', 'php');
    if (!isConnect('admin')) {
        throw new Exception('{{401 - Accès non autorisé}}');
    }

    // Whitelist obligatoire depuis Jeedom Core 4.1
    ajax::init(['Validate', 'ValidateQRCode', 'ChangeEnfant', 'GetConfig', 'GetWidgetData']);

    $action = init('action');

    if ($action == 'Validate') {
        $eqLogicId = init('eqLogicId');
        $url = init('url');
        // ... traitement ...
        ajax::success();
    }

    if ($action == 'GetWidgetData') {
        $eqLogicId = init('eqLogicId');
        $eqLogic = ProJote::byId($eqLogicId);
        $data = $eqLogic->getConfiguration('widget_json', '{}');
        ajax::success(json_decode($data, true));
    }

    throw new Exception('Action non reconnue : ' . $action);

} catch (Exception $e) {
    ajax::error(displayException($e), $e->getCode());
}
Whitelist obligatoire ajax::init(['action1', 'action2']) avec la liste blanche des actions est obligatoire depuis Core 4.1. Sans cela, toutes les actions passent — faille de sécurité.

Côté JavaScript, les appels se font via :

// Jeedom Core 4.4 — utiliser domUtils.ajax (Vanilla JS)
domUtils.ajax({
    type: 'POST',
    url: 'plugins/ProJote/core/ajax/ProJote.ajax.php',
    data: { action: 'GetWidgetData', eqLogicId: eqLogic.id },
    success: function(data) {
        // data contient le résultat de ajax::success($data)
    },
    error: function(error) { console.error(error); }
});

6. core/php/jeeProJote.php — Le callback daemon

Ce fichier est l'endpoint HTTP que le daemon Python appelle pour envoyer ses données à Jeedom.

<?php
require_once dirname(__FILE__) . '/../../../../core/php/core.inc.php';

// Vérifier l'API key — OBLIGATOIRE pour les callbacks daemon
jeedom::apiAccess(init('apikey'), 'ProJote');

$result = json_decode(file_get_contents('php://input'), true);
if (!is_array($result)) {
    die();
}

$eqLogicId = $result['CmdId'] ?? null;
$eqLogic = ProJote::byId($eqLogicId);
if (!is_object($eqLogic)) {
    die();
}

// Mettre à jour les commandes Jeedom avec les nouvelles valeurs
$eqLogic->checkAndUpdateCmd('connection_status', $result['connection_status'] ?? '');
$eqLogic->checkAndUpdateCmd('nb_devoirs', count($result['Devoirs'] ?? []));
// ... autres commandes ...

// Sauvegarder les données JSON complètes pour le widget
$eqLogic->setConfiguration('widget_json', json_encode($result));
$eqLogic->save(true);  // true = sauvegarde directe sans cycle complet

// Persister sur disque pour la reconnexion par token
saveDataToJsonFile($eqLogic, $result);
Flux des données Le daemon envoie toutes ses données en un seul POST JSON vers ce fichier. Le callback les dispatche vers les commandes Jeedom (checkAndUpdateCmd) et sauvegarde le JSON complet dans la config de l'équipement pour le widget.

checkAndUpdateCmd() — Mise à jour des valeurs de commandes

// Met à jour la valeur d'une commande ET déclenche les scénarios associés
$eqLogic->checkAndUpdateCmd('logicalId', $newValue);

Cette méthode est fondamentale : elle met à jour la valeur de la commande en BDD, met à jour le cache, et déclenche les scénarios Jeedom qui écoutent cette commande.

7. resources/ProJoted/ProJoted.py — Le daemon Python

7.1 Structure générale

# Arguments reçus au démarrage depuis PHP
parser = argparse.ArgumentParser()
parser.add_argument("--loglevel")
parser.add_argument("--callback")    # URL HTTP de retour vers Jeedom
parser.add_argument("--apikey")      # Clé API Jeedom pour le callback
parser.add_argument("--socketport")  # Port TCP d'écoute (commandes PHP → Python)
parser.add_argument("--pid")         # Chemin du fichier PID
parser.add_argument("--cycle")       # Fréquence de rafraîchissement
parser.add_argument("--datadir")     # Chemin du dossier data du plugin
args = parser.parse_args()

_data_dir = args.datadir or "/var/www/html/plugins/ProJote/data"

7.2 Module jeedom.py — Communication bidirectionnelle

from jeedom.jeedom import jeedom_utils, jeedom_com, jeedom_socket

# Initialiser le socket d'écoute (PHP → Python)
jeedom_socket = jeedom_socket(port=_socket_port, address='127.0.0.1')
jeedom_socket.open()

# Envoyer des données vers PHP (Python → PHP via HTTP)
jeedom_com = jeedom_com(apikey=_apikey, loglevel=_log_level, url=_callback)
jeedom_com.send_change_immediate({'CmdId': eqid, 'Notes': [...], ...})

7.3 Boucle principale — Architecture multi-thread

Depuis la version 0.9, chaque équipement est traité dans un thread Python dédié. La fonction read_socket() dépile les messages et délègue immédiatement à un thread par eqLogicId — le daemon ne se bloque plus sur un équipement lent.

import threading

# ── Verrous et registre des threads actifs ───────────────
_failed_attempts_lock = threading.Lock()
_active_eq_lock       = threading.Lock()
_active_eq            = {}   # {eqLogicId: threading.Thread}

def process_message(message):
    """Traitement complet d'un équipement — exécuté dans son propre thread."""
    eq_id = str(message.get("CmdId", ""))
    try:
        # ... connexion Pronote + collecte des données ...
        with _failed_attempts_lock:
            failed_attempts[eq_id] = {"count": 0, "timestamp": time.time()}
    except Exception as e:
        logging.error("Erreur traitement équipement %s : %s", eq_id, e)
    finally:
        # Libère toujours le slot, même en cas d'exception
        with _active_eq_lock:
            _active_eq.pop(eq_id, None)

def read_socket():
    """Dépile les messages entrants et spawne un thread par équipement."""
    global JEEDOM_SOCKET_MESSAGE
    try:
        if JEEDOM_SOCKET_MESSAGE.empty():
            return
        message = json.loads(JEEDOM_SOCKET_MESSAGE.get().decode("utf-8"))
        eq_id = str(message.get("CmdId", ""))

        with _active_eq_lock:
            existing = _active_eq.get(eq_id)
            if existing and existing.is_alive():
                # Requête ignorée : l'équipement est déjà en cours de traitement
                logging.warning("Équipement %s déjà en cours — requête ignorée.", eq_id)
                return
            t = threading.Thread(
                target=process_message, args=(message,),
                daemon=True, name=f"eq-{eq_id}"
            )
            _active_eq[eq_id] = t
            t.start()

    except Exception as e:
        logging.error("Erreur dans read_socket : %s", e)
Thread safety Tous les accès au dictionnaire failed_attempts sont protégés par _failed_attempts_lock. Le registre _active_eq est protégé par _active_eq_lock. Le finally dans process_message garantit la libération du slot même en cas d'exception.

7.4 Monkey patches pronotepy

ProJote applique deux corrections à la volée sur la librairie pronotepy :

# Patch 1 : Grade.__init__ — gère les champs optionnels absents
# Sans ce patch, une note sans noteMax/noteMin lève une KeyError
_original_grade_init = pronotepy.dataClasses.Grade.__init__

def _patched_grade_init(self, json_data):
    for field in ["noteMax", "noteMin", "coefficient", "commentaire",
                  "estBonus", "estFacultatif", "estRamenerSur20"]:
        if field not in json_data:
            json_data[field] = {"V": "" if field != "estBonus" else False}
    _original_grade_init(self, json_data)

pronotepy.dataClasses.Grade.__init__ = _patched_grade_init

# Patch 2 : html_parse — préserve les <br> comme espaces
# Sans ce patch : "Faire ex 1<br>Apprendre" → "Faire ex 1Apprendre"
@staticmethod
def _patched_html_parse(html_text):
    text = re.sub(r'<br\s*/?>', ' ', html_text, flags=re.IGNORECASE)
    text = re.sub(r'<[^>]+>', '', text)
    return html.unescape(text).strip()

pronotepy.dataClasses.Util.html_parse = _patched_html_parse

7.5 Connexion à Pronote

# Méthode 1 : Login / mot de passe
client = pronotepy.ParentClient(url, username=login, password=password, ent=ent_func)

# Méthode 2 : QR Code (token)
qr_data = {'jeton': token, 'login': login}
client = pronotepy.ParentClient.token_login(url, username=login,
                                             token=token, uuid=uuid, ent=ent_func)

# Méthode 3 : Reconnexion token persistant (depuis fichier JSON)
token_data = load_persistent_token(eqLogicId)
if token_data:
    client = pronotepy.ParentClient.token_login(...)

# Sélectionner l'enfant (client ParentClient)
client.set_child(child_name)

7.6 Collecte des données Pronote

Fonction PythonDonnées collectéesCommandes Jeedom
Emploidutemps()EDT aujourd'hui + 4 prochains jours scolaires (batch sur 14 j), compteur cours annulés depuis la rentréeedt_aujourdhui, edt_J1…edt_J4, edt_prochainjour (= J1, rétro-compat)
Notes()Notes de toutes les périodes + moyennesnb_notes, moy_generale, etc.
Devoirs()Devoirs du jour et du lendemain + fichiers jointsnb_devoirs, devoirs_aujourdhui
Absences()Liste des absencesnb_absences
Retards()Liste des retardsnb_retards
Punitions()Liste des punitions/retenuesnb_punitions
Notifications()Notifications/informations Pronotenb_notifications
Competences()Évaluations par compétencesnb_competences
Menus()Menus de la cantinemenus
download_photo()Photo de profil de l'élèvephoto_eleve

7.7 Notes() — Structure et calcul de la moyenne

La fonction Notes() itère sur toutes les périodes de client.periods et collecte :

La structure renvoyée au widget via widget_json.moyennes_periodes est :

moyennes_periodes = [
    {
        "periode":        "Semestre 1",
        "moyenne_eleve":  "16.5",   # str, virgule → point ; "" si non publiée
        "moyenne_classe": "14.2",
    },
    ...
]

Seules les périodes ayant au moins une valeur non vide (overall_average ou class_overall_average) sont incluses — Pronote ne publie la moyenne officielle qu'après clôture du bilan de période.

Affichage côté widget (JS)

Le widget JS utilise en priorité moyennes_periodes[dernière entrée].moyenne_eleve. Si cette valeur est vide (période en cours, bilan non clôturé), il bascule sur un calcul local pondéré :

// Calcul local (fallback)
tot = 0; coefs = 0;
notes.forEach(n => {
    r = parseFloat(n.note) / parseFloat(n.sur || '20');  // ratio /1
    c = parseFloat(n.coeff ?? '1');
    if (!isNaN(r) && !isNaN(c)) { tot += r * 20 * c; coefs += c; }
});
moyenne = coefs > 0 ? (tot / coefs).toFixed(1) : '–';

Cas particuliers gérés :

CasComportement
note = "Abs", "Disp"parseFloat() retourne NaN → note exclue du calcul
coeff = 0Contribue 0 au numérateur et 0 au dénominateur → sans effet sur la moyenne
coeff décimal (1.5, 0.5…)Géré nativement par parseFloat()
sur absentDéfaut 20
Le calcul local donne un résultat différent de la moyenne officielle Pronote. Pronote calcule une moyenne de moyennes par matière (chaque matière a sa propre moyenne pondérée, puis les moyennes matières sont pondérées par le coefficient matière). Le calcul local est une moyenne pondérée directe sur toutes les notes individuelles — l'écart peut atteindre quelques dixièmes.

7.8 Emploidutemps() — Collecte des 4 prochains jours (v0.9b)

Avant la v0.9b, le daemon effectuait N appels API séquentiels pour trouver le prochain jour scolaire (boucle delta++). Désormais un seul appel sur 14 jours est effectué, les cours sont groupés par date et les 4 premières dates non vides sont conservées.

# 1 appel API sur 14 jours au lieu de N appels
lessons_range = client.lessons(today + timedelta(days=1), today + timedelta(days=14))

# Grouper par date (lesson.start est un datetime)
days_by_date = {}
for lesson in lessons_range:
    d = lesson.start.date()
    days_by_date.setdefault(d, []).append(lesson)

# Prendre les 4 premières dates avec cours
next_school_dates = sorted(days_by_date.keys())[:4]

# Stocker dans data["edt_J1"] … data["edt_J4"]
# + data["edt_J1_date"], data["edt_J1_debut"], data["edt_J1_fin"], data["edt_J1_cancel"]

# Rétro-compatibilité obligatoire :
data["edt_prochainjour"]        = data["edt_J1"]       # = J1
data["edt_prochainjour_date"]   = data["edt_J1_date"]

# Tableau compact pour le widget (évite la duplication dans widget_json)
data["edt_next_days"] = [
    {"cours": data["edt_J1"], "date": "...", "debut": "...", "fin": "...", "cancel": 0},
    # … J2, J3, J4 si disponibles
]

Structure des commandes Jeedom générées :

LogicalIdDescriptionType
edt_J{n}Liste des cours du jour J+n (JSON)info/string
edt_J{n}_dateDate du jour J+n (jj/mm/aaaa)info/string
edt_J{n}_debutHeure de début (HHMM)info/string
edt_J{n}_finHeure de fin (HHMM)info/string
edt_J{n}_cancelNombre de cours annulésinfo/numeric

n ∈ {1, 2, 3, 4} — soit 20 nouvelles commandes. edt_prochainjour reste = J1.

widget_json Le callback jeeProJote.php stocke edt_next_days (tableau compact) dans widget_json. Le widget JS lit d.edt_next_days[edtDayIdx] pour afficher le jour courant dans la colonne droite.

7.8 Circuit breaker — Protection anti-boucle

failed_attempts = {}  # Compteur d'échecs par eqLogicId

def check_circuit_breaker(eqLogicId, max_attempts=5):
    """Empêche les reconnexions infinies en cas d'échec répété."""
    if eqLogicId not in failed_attempts:
        failed_attempts[eqLogicId] = {"count": 0, "last_attempt": 0}

    now = time.time()
    # Réinitialiser après 5 minutes
    if now - failed_attempts[eqLogicId]["last_attempt"] > 300:
        failed_attempts[eqLogicId]["count"] = 0

    if failed_attempts[eqLogicId]["count"] > max_attempts:
        logging.error(f"Circuit breaker bloqué pour {eqLogicId}")
        return False

    failed_attempts[eqLogicId]["count"] += 1
    failed_attempts[eqLogicId]["last_attempt"] = now
    return True

7.8 Renouvellement automatique des tokens backup

Quand le token principal est expiré, le daemon utilise le token de secours (backup token) pour reconnecter l'équipement. Il profite ensuite de cette session active pour renouveler les deux tokens et les sauvegarder — le cycle de vie est ainsi remis à zéro sans intervention de l'utilisateur.

# Après connexion réussie via le token backup :
logging.warning(
    "ProJote — Token principal expiré pour l'équipement %s. "
    "Token backup utilisé. Renouvellement automatique des tokens en cours.",
    eqLogicId,
)

new_backup_credentials = None
try:
    # Créer une seconde session avec un UUID modifié pour obtenir de nouveaux credentials
    renew_uuid = backup_uuid + "-bk" if not backup_uuid.endswith("-bk") else backup_uuid + "2"
    new_backup_client = pronotepy.ParentClient.token_login(
        pronote_url=backup_token["pronote_url"],
        username=backup_token["username"],
        password=backup_token["password"],   # = token dans les credentials pronotepy
        client_identifier=backup_token["client_identifier"],
        uuid=renew_uuid,
    )
    if new_backup_client and new_backup_client.logged_in:
        new_backup_credentials = new_backup_client.export_credentials()
        logging.info("Token backup renouvelé avec succès pour l'équipement %s.", eqLogicId)
    else:
        logging.warning("ProJote — Token backup non renouvelé pour l'équipement %s.", eqLogicId)
except Exception as e_bk:
    logging.warning("ProJote — Renouvellement backup échoué pour %s : %s", eqLogicId, e_bk)

# Sauvegarder les deux tokens (principal via `client`, backup via `new_backup_credentials`)
writedataPronotepy(client, _data_dir, eqLogicId, backup_token=new_backup_credentials)
logging.warning(
    "ProJote — Tokens renouvelés pour l'équipement %s. "
    "Token backup : %s.",
    eqLogicId,
    "OK" if new_backup_credentials else "NON RENOUVELÉ",
)
Logs WARNING obligatoires Chaque étape du renouvellement émet un message de niveau WARNING dans les logs ProJote : détection du token expiré, tentative de renouvellement, résultat (succès ou échec). Ces messages sont visibles sans activer le mode debug.

8. Widget et templates HTML

8.1 Template widget (ProJote.html)

Le fichier core/template/dashboard/ProJote.html définit le rendu visuel complet du tableau de bord. Toutes les règles CSS sont préfixées par .pjw-#id# pour éviter tout conflit avec d'autres widgets Jeedom. Le JavaScript est encapsulé dans une IIFE ((function(){})();).

Variables JS injectées par toHtml() :

Variable JSPlaceholder PHPDéfautRôle
D#initData#{}Données widget (JSON complet de widget_json)
VIS#visibilityMap#tout visibleCarte de visibilité des sections selon les commandes masquées
DEFAULT_TAB#default_tab#'dv'Identifiant de l'onglet actif au chargement
EDT_NAV_MODE#edt_nav_mode#'next_day''next_day' = J+1 fixe, 'arrows' = navigation J+1→J+4
LAST_LOGIN_CMD_ID#lastLoginCmdId#0ID de la commande LastLogin pour déclencher le refresh

Flux de données :

  1. Au chargement, D (#initData#) est injecté directement — render() est appelé immédiatement si non vide.
  2. Quand le daemon met à jour la commande LastLogin, jeedom.cmd.addUpdateFunction() déclenche un appel AJAX GetWidgetData qui rappelle render().
  3. L'objet JSON est distribué aux renderers : renderHeader, renderEDT, renderNotesBar, renderDevoirs, renderDernieresNotes, renderAbsences, renderRetards, renderPunitions.
<script>
(function () {
    'use strict';
    var ID = '#id#';  // Remplacé par Jeedom avec l'ID réel

    // Liaison Jeedom : appelé à chaque changement de valeur
    jeedom.cmd.addUpdateFunction('#id#', function(_opts) {
        var v = _opts.value;
        if (!v || v.trim() === '' || v === '{}') return;
        try {
            render(JSON.parse(v), _opts.collectDate || _opts.valueDate);
        } catch(e) {
            console.error('[ProJote] Erreur JSON :', e);
        }
    });

    // Premier rendu avec la valeur déjà en cache
    showSpinner();  // Spinner actif pendant le chargement initial
    jeedom.cmd.refreshValue([{
        cmd_id:      '#id#',
        value:       '#value#',
        collectDate: '#collectDate#',
    }]);
})();
</script>

8.2 Spinner de chargement

Pendant la collecte des données (chargement initial ou rafraîchissement manuel), le logo ProJote tourne dans la zone avatar grâce à une animation CSS scoped.

<!-- CSS : animation scoped par instance de widget -->
@keyframes pjwSpin#id# {
    from { transform: rotate(0deg); }
    to   { transform: rotate(360deg); }
}
.pjw-#id# .ph-av { cursor: pointer; }
.pjw-#id# .ph-av-loading img {
    animation: pjwSpin#id# 1.1s linear infinite;
    border-radius: 12px;
    opacity: .85;
}

// JavaScript
var _spinning = false;

function showSpinner() {
    _spinning = true;
    var av = el('pjwav' + ID);
    if (!av) return;
    av.classList.add('ph-av-loading');
    av.innerHTML = '<img src="/plugins/ProJote/plugin_info/ProJote_icon.png" alt="">';
}

function hideSpinner() {
    _spinning = false;
    var av = el('pjwav' + ID);
    if (!av) return;
    av.classList.remove('ph-av-loading');
}

// Clic sur l'avatar → rafraîchissement manuel
av.addEventListener('click', function() {
    if (_spinning) return;   // Ignore si déjà en cours
    showSpinner();
    jeedom.cmd.execute({id: ID});
});

// hideSpinner() est appelé au début de render() pour stopper l'animation
function render(d, ts) {
    hideSpinner();
    // ... rendu des données ...
}
Keyframes scoped Jeedom remplace #id# à l'affichage. Chaque instance du widget obtient donc un nom d'animation unique (pjwSpin42, pjwSpin43…) — plusieurs widgets ProJote sur le même dashboard ne se perturbent pas.

8.3 Navigation EDT — flèches J+1 à J+4

Quand le paramètre edt_nav_mode vaut 'arrows', la colonne droite de l'EDT affiche des boutons ‹ › permettant de naviguer entre J+1 et J+4. Les données viennent de d.edt_next_days (tableau de 1 à 4 objets).

// Variables module-level (IIFE)
var edtDayIdx     = 0;          // index courant dans edt_next_days (0 = J+1)
var edtCurrentData = null;      // données en cours (mises à jour à chaque render)

function renderEdtDay(d) {
    var days = (d && d.edt_next_days) || [];
    var day  = days[edtDayIdx] || null;
    var showNav = EDT_NAV_MODE === 'arrows';

    // Afficher/masquer les flèches
    prev.style.display = showNav ? '' : 'none';
    next.style.display = showNav ? '' : 'none';

    // Rendre le jour sélectionné
    lbl.textContent = day ? day.date : 'Prochain jour';
    ec.innerHTML    = day ? edtCoursesHtml(mergeAndSort(day.cours, retenues)) : '...';

    // Griser les flèches aux extrémités
    if (showNav) {
        prev.disabled = (edtDayIdx === 0);
        next.disabled = (edtDayIdx >= days.length - 1);
    }
}

function initEDT() {
    prev.addEventListener('click', function() {
        if (edtDayIdx > 0) { edtDayIdx--; renderEdtDay(edtCurrentData); }
    });
    next.addEventListener('click', function() {
        var max = (edtCurrentData.edt_next_days || []).length - 1;
        if (edtDayIdx < max) { edtDayIdx++; renderEdtDay(edtCurrentData); }
    });
}
Boutons HTML Les boutons / sont dans le DOM avec id="pjwnav-prev#id#" et id="pjwnav-next#id#". En mode next_day, ils sont cachés (display:none) mais restent dans le DOM — aucun changement de structure HTML entre les deux modes.

8.4 Onglet par défaut

Le HTML hardcode class="tab-btn active" et class="tab-pane active" sur l'onglet Devoirs (dv). Si DEFAULT_TAB !== 'dv', initTabs() retire les classes active de tous les éléments et les réapplique sur l'onglet configuré.

function initTabs() {
    if (DEFAULT_TAB && DEFAULT_TAB !== 'dv') {
        // Retirer l'état actif du défaut HTML
        tabsEl.querySelectorAll('.tab-btn, .tab-pane').forEach(function(el) {
            el.classList.remove('active');
        });
        // Activer l'onglet configuré
        var btn  = tabsEl.querySelector('[data-tab="' + DEFAULT_TAB + ID + '"]');
        var pane = el(DEFAULT_TAB + ID);
        if (btn)  btn.classList.add('active');
        if (pane) pane.classList.add('active');
    }
    // Gestion des clics...
}

9. desktop/ — Interface utilisateur

9.0 Onglet Affichage — paramètres widget & photo

L'onglet Affichage de l'équipement présente une mise en page deux colonnes : à gauche les paramètres du widget, à droite une prévisualisation live.

Paramètres widget

Les valeurs sont stockées sous display.parameters_xxx (clé plate, préfixe parameters_) :

Champ HTMLClé displayUsage dans toHtml()
data-l2key="parameters_accent_color"display.parameters_accent_color$this->getDisplay('parameters_accent_color')
data-l2key="parameters_font_size"display.parameters_font_size$this->getDisplay('parameters_font_size')
data-l2key="parameters_default_tab"display.parameters_default_tab$this->getDisplay('parameters_default_tab')
data-l2key="parameters_edt_nav_mode"display.parameters_edt_nav_mode$this->getDisplay('parameters_edt_nav_mode')
Piège Jeedom : preToHtml() expose les paramètres via $replace['#key#'] uniquement si ils sont stockés sous display.parameters.key (sous-tableau). Nos champs utilisent display.parameters_key (clé plate) — il faut donc lire via $this->getDisplay('parameters_key'), pas via $replace['#parameters_key#'].

Photo de profil

Trois emplacements possibles :

Le champ configuration.photo_source détermine laquelle utiliser (none, pronote, manual, auto). La résolution est effectuée dans deux endroits :

Upload / suppression (AJAX)

Deux actions dans ProJote.ajax.php :

ActionDescription
UploadManualPhotoValide le MIME (JPEG/PNG/WebP), max 5 Mo, déplace vers data/{id}/profile_picture_manual.jpg
DeleteManualPhotoSupprime data/{id}/profile_picture_manual.jpg (succès même si absent)

9.1 desktop/php/ProJote.php — Vue liste

Rendu côté serveur de la page d'administration du plugin. Jeedom l'inclut dans l'interface.

<?php
if (!isConnect('admin')) {
    throw new Exception('{{401 - Accès non autorisé}}');
}
$plugin = plugin::byId('ProJote');
if ($plugin === null) {
    throw new Exception('{{Plugin ProJote non trouvé}}');
}
$eqLogics = eqLogic::byType($plugin->getId());
sendVarToJS('eqType', $plugin->getId());
sendVarToJS('LogLevel', log::convertLogLevel(log::getLogLevel($plugin->getId())));
?>
<div class="row">
    <!-- Liste des équipements -->
    <div class="eqLogicThumbnailDisplay">
        <?php foreach ($eqLogics as $eqLogic) { ... } ?>
    </div>
</div>
sendVarToJS() Exporte une variable PHP vers JavaScript. Elle sera accessible dans ProJote.js via la variable globale correspondante.

9.2 desktop/js/ProJote.js — JavaScript frontend

Jeedom 4.4 utilise Vanilla JS (pas jQuery). Les APIs principales :

// Initialisation au chargement de la page
document.addEventListener('DOMContentLoaded', function() {
    jeedomUtils.initPage({
        eqType: eqType,
        onEqLogicSelect: function(eqLogic) {
            loadEqLogicConfig(eqLogic);
        }
    });
});

// Appel AJAX
domUtils.ajax({
    type: 'POST',
    url: 'plugins/ProJote/core/ajax/ProJote.ajax.php',
    data: { action: 'GetConfig', eqLogicId: id },
    success: function(data) { ... },
    error: function(error) { jeedomUtils.showAlert({message: error, level: 'danger'}); }
});

// Boîte de dialogue
jeeDialog.dialog({
    title: 'Scan QR Code',
    content: '<canvas id="qr-canvas"></canvas>',
    buttons: {
        Fermer: { click: function() { jeeDialog.closeAll(); } }
    }
});

10. Communication PHP ↔ Python

10.1 PHP → Python (commandes en temps réel)

Jeedom envoie des commandes au daemon via socket TCP :

// PHP — envoyer une commande
$socket = socket_create(AF_INET, SOCK_STREAM, SOL_TCP);
socket_connect($socket, '127.0.0.1', 55369);
socket_write($socket, json_encode(['action' => 'refresh', 'eqLogicId' => 42]) . "\n");
socket_close($socket);
# Python — recevoir une commande
message = jeedom_socket.get()
data = json.loads(message)
if data['action'] == 'refresh':
    collect_data(data['eqLogicId'])

10.2 Python → PHP (envoi de données)

Le daemon envoie ses données via HTTP POST vers le callback :

# Python — envoyer les données vers Jeedom
payload = {
    'CmdId': eqLogicId,
    'connection_status': 'connected',
    'Notes': [...],
    'Devoirs': [...],
    # ... toutes les données ...
}
jeedom_com.send_change_immediate(payload)
# Sous le capot, jeedom_com fait :
requests.post(
    callback_url,
    params={'apikey': apikey},
    json=payload
)

10.3 Schéma complet

Utilisateur clique "Rafraîchir"
    │
    ▼
ProJote.js ──POST──► ProJote.ajax.php (action=Validate)
                            │
                            ▼
                    ProJote.class.php::sendToDeamon()
                            │
                        Socket TCP
                            │
                            ▼
                    ProJoted.py (reçoit la commande)
                            │
                    pronotepy (API Pronote)
                            │
                    Collecte toutes les données
                            │
                        HTTP POST
                            │
                            ▼
                    jeeProJote.php
                            │
                            ▼
                    checkAndUpdateCmd() → commandes Jeedom
                    setConfiguration('widget_json') → widget
                    saveDataToJsonFile() → persistance disque

11. Sécurité

11.1 Authentification dans les fichiers PHP

// Pour les pages admin (desktop/php/, ajax/)
if (!isConnect('admin')) {
    throw new Exception('{{401 - Accès non autorisé}}');
}

// Pour les callbacks daemon (core/php/)
jeedom::apiAccess(init('apikey'), 'ProJote');
// Lance une exception si la clé est invalide → arrête le traitement

11.2 Whitelist AJAX

// Obligatoire en Core 4.1+ — liste explicite des actions autorisées
ajax::init(['Validate', 'ValidateQRCode', 'ChangeEnfant', 'GetConfig', 'GetWidgetData']);

11.3 Niveaux de log valides

// CORRECT
log::add(__CLASS__, 'debug',   $message);
log::add(__CLASS__, 'info',    $message);
log::add(__CLASS__, 'warning', $message);
log::add(__CLASS__, 'error',   $message);  // et non 'erreur' !

// INCORRECT — "erreur" n'existe pas dans Jeedom
log::add(__CLASS__, 'erreur',  $message);  // ← bug silencieux

11.4 Chiffrement des données sensibles

// Lister les clés à chiffrer automatiquement
public static $_encryptConfigKey = array('Token_password', 'password');

// En lecture, Jeedom déchiffre automatiquement
$token = $eqLogic->getConfiguration('Token_password');  // déjà déchiffré

11.5 Échappement shell

// Toujours échapper les valeurs passées en shell
$cmd .= ' --datadir ' . escapeshellarg($data_dir);

// Éviter les injections dans les requêtes DB
// → Utiliser les méthodes Jeedom (getConfiguration, setConfiguration)
// → Éviter les requêtes SQL directes

Logs et dépannage du daemon

Structure des logs en mode séquentiel

Depuis la version 0.9, le daemon utilise une file d'attente unique : un seul équipement est traité à la fois. Les logs sont donc parfaitement linéaires et faciles à lire.

# Exemple de séquence normale avec 2 équipements
[INFO]  Équipement 42 ajouté à la file (taille file : 1).
[INFO]  Équipement 43 ajouté à la file (taille file : 2).
[INFO]  === Début traitement équipement 42 (file restante : 1) ===
[INFO]  Reconnexion avec token persistant réussie !
[INFO]  Je récupère l'emploi du temps
[INFO]  Je récupère les notes
...
[INFO]  === Fin traitement équipement 42 ===
[INFO]  === Début traitement équipement 43 (file restante : 0) ===
...
[INFO]  === Fin traitement équipement 43 ===

Identifier un équipement bloqué

Le watchdog émet un WARNING si un équipement monopolise le worker plus de 120 secondes :

[WARNING] ProJote — Watchdog : équipement 42 en cours depuis 121s
          (timeout 120s). Le worker est peut-être bloqué (Pronote injoignable ?).
          Libération forcée du slot.

Causes possibles et solutions :

Symptôme dans les logsCause probableAction
Watchdog déclenché toutes les synchros Timeout réseau sur le serveur Pronote de l'établissement Vérifier la connectivité réseau depuis le serveur Jeedom vers Pronote
=== Début eq-X === sans === Fin === correspondant Crash silencieux dans process_message Passer en mode Debug et relancer
Équipement X déjà dans la file — requête ignorée Normal : double clic ou cron en avance Aucune action requise
Token principal expiré … Token backup utilisé Token principal Pronote périmé (normal après quelques semaines) Les deux tokens sont renouvelés automatiquement — surveiller le WARNING suivant
Token backup non renouvelé Les deux tokens sont expirés simultanément Re-valider la connexion via QR Code ou identifiants

Commandes utiles pour le dépannage

# Voir les logs en temps réel (remplacer par le bon chemin Jeedom)
tail -f /var/www/html/log/ProJote

# Filtrer les lignes d'un seul équipement (ex: id 42)
grep "équipement 42\|eq-42" /var/www/html/log/ProJote

# Voir uniquement les débuts/fins de traitement
grep "=== " /var/www/html/log/ProJote

# Voir les avertissements et erreurs uniquement
grep -E "\[WARNING\]|\[ERROR\]" /var/www/html/log/ProJote

# Vérifier que le daemon tourne
ps aux | grep ProJoted
Plage nocturne 22h–4h Les synchronisations automatiques (cron Jeedom) sont désactivées pendant cette fenêtre. Un rafraîchissement manuel (commande Rafraîchir ou clic sur le widget) reste exécuté immédiatement à toute heure — le daemon ne filtre pas les requêtes manuelles.

12. Checklist de mise en production

Vérifications obligatoires avant release
ÉlémentVérificationStatut ProJote 0.9
info.json licence=AGPL, require="4.4", description bilingue ✅ Corrigé
packages.json Pas de sections vides (pip3, npm, yarn...) ✅ Corrigé
ajax::init() Whitelist explicite des actions ✅ Corrigé
log::add() Niveau valide (debug/info/warning/error) ✅ Corrigé
system::getCmdPython3() Utilisé pour lancer le daemon (Debian 12) ✅ Corrigé
PID cleanup unlink() et non shell_exec(rm) ✅ Corrigé
Chemins hardcodés Python Utilise _data_dir passé via --datadir ✅ Corrigé
Code de test Python Aucune date hardcodée, aucun code de debug ✅ Corrigé
message::add() Signature : ($_type, $_message, $_action, $_logicalId) ✅ Corrigé
error_log() Aucun appel debug en production ✅ Corrigé
Tokens protégés Non écrasés lors des sauvegardes UI ✅ OK
Monkey patches Grade.__init__ et html_parse patchés ✅ OK
Circuit breaker Protection anti-boucle de reconnexion ✅ OK