Mon Linky dans Warp 10 avec un joli dashboard

Depuis longtemps, je cherchais un moyen simple de récupérer mes données de conso Linky et de les afficher dans un dashboard.

Pour accéder aux données du Linky collectées par Enedis, il n’y a pas 36 solutions :

  • Aller sur votre espace perso et cliquer sur un bouton pour télécharger un CSV avec ses données (pas pratique)
  • Utiliser une lib qui scrappe ce site pour récupérer ces données au format CSV. Sauf que depuis des mois, ils ont changé la façon de se loguer et ont ajouté un captcha. Il n’y a plus aucune lib de fonctionnelle (dommage, ça marchait bien)
  • Avoir un numéro de Siret, contractualiser avec Enedis, recueillir son propre consentement (oui, je sais, c’est con) et suivre une procédure très lourde pour se connecter à leur SGE.
  • Avoir un numéro de Siret, contractualiser avec Enedis, bâtir une app déclarée et se connecter sur leur DataHub.
  • Utiliser la connexion TéléInfo directement sur le Linky, ce qui fera l’objet d’un prochain post.

Bref, pour les particuliers qui, comme moi, veulent geeker un peu avec leurs propres données, c’est pas possible. Cependant, autour du DataHub, il y a quelques initiatives de tiers permettant de s’y connecter (via le tiers) comme :

C’est sur cet axe, à défaut d’avoir accès à une vraie API en direct, que je suis parti.

Attention, pensez à vous autoriser à collecter vos données horaires dans votre espace client!

Pour commencer, il vous faut un Warp 10. Facile à installer en local ou sur un serveur (il y a même un Docker), vous pouvez même tester dans le cloud avec leur SandBox. Ici, on va le faire avec la SandBox (mais dans la vraie vie, j’utilise une instance hébergée sur un Pi, oui, oui, ça tourne bien sur un Pi).

Préparation de Warp 10

Direction https://sandbox.senx.io/ pour se créer son espace et ses tokens. C’est pas compliqué, un clic suffit :

SandBox Warp 10
La puissance à portée d’un clic

Bon, ça nous laisse soit 2 jours (délai de rétention des données), soit 14 jours (délai des tokens mais il faut ré-uploader les données) pour jouer avec. (Plus d’info ici)

Copiez les 3 tokens quelque part et gardez les précieusement.

Récupération des données

Pour se faire, je vais m’appuyer sur cette librairie NodeJS : bokub/linky

Rien de bien sorcier, si ce n’est qu’on va devoir récupérer des jetons d’OAuth via https://conso.vercel.app/, un fameux tiers qui se connecte au DataHub Enedis. Suivez ce qui est écrit sur ce site, rien de bien sorcier. Vous aurez à autoriser cette app à se connecter à votre espace perso. Notez également quelque part, les jetons générés et gardez les au chaud.

Ouvrez un terminal, on va créer un nouveau projet javascript monstrueux :

$ mkdir linky2warp10
$ cd linky2warp10
$ npm init
$ npm install @senx/warp10 dayjs linky node-cron

Créez un fichier conf.json dans lequel vous renseignerez les jetons Linky, votre n° de PDL (n° de compteur Linky, quoi, dispo dans votre espace client) et les tokens Warp 10:

{
  "accessToken": "xxxxxxxxxxxxxxxxxxx", 
  "refreshToken": "xxxxxxxxxxxxxxxxxxx",
  "usagePointId": "n° de PDL",
  "warp10": {
    "w10URL": "https://sandbox.senx.io/api/v0",
    "rt": "lK8M3_ixxxxxxxxxxxx3AsTc2ojYk",
    "wt": "hHo5qxxxxxxxxxc7PRUeHa",
    "dt": "ttIb7TxxxxxxxxxxxxxxxWir4YynF"
  }
}
  • w10URL.rt : Read Token
  • w10URL.wt : Write Token
  • w10URL.dt : Delete Token (on sait jamais, ça peut servir)

Créez un fichier index.js :

D’abord les imports

const linky = require('linky');
const fs = require('fs');
const dayjs = require('dayjs');
const Warp10 = require('@senx/warp10');
const cron = require('node-cron');

const conf = JSON.parse(fs.readFileSync('conf.json', 'utf-8'));
const w10 = new Warp10.Warp10(conf.warp10.w10URL)

Ensuite, il faut créer une session Linky avec la gestion du refresh token :

const session = new linky.Session({
  accessToken: conf.accessToken,
  refreshToken: conf.refreshToken,
  usagePointId: conf.usagePointId,
  onTokenRefresh: (accessToken, refreshToken) => {
    fs.writeFileSync('conf.json-bak-' + dayjs().valueOf(), JSON.stringify(conf), 'utf-8')
    conf.accessToken = accessToken;
    conf.refreshToken = refreshToken;
    // Cette fonction sera appelée si les tokens sont renouvelés
    // Les tokens précédents ne seront plus valides
    // Il faudra utiliser ces nouveaux tokens à la prochaine création de session
    fs.writeFileSync('conf.json', JSON.stringify(conf), 'utf-8')
  },
});

Ensuite on va se coder la fonction de récupération de l’historique :

// à partir d'une date dans le passé et de step en step
function updateHistory(startDate, step) { 
  const now = dayjs().subtract(1, 'day');
  return getBetween2Dates(startDate, now.format('YYYY-MM-DD'), step)
}

// entre 2 dates et de step en step
function getBetween2Dates(startDate, endDate, step = 1) {
  let start = dayjs(startDate, 'YYYY-MM-DD'); 
  let end = dayjs(endDate, 'YYYY-MM-DD');
  return new Promise(async (resolve, reject) => {
    let sum = 0; // le nombre de données que l'on va récupérer
    // Zou! on boucle de step en step pour aller du début à la fin de la période temporelle
    while (end.isAfter(start) && !!session) { 
      const next = end.subtract(step, 'day');
      try {
       // On récupère notre courbe de charge qui offre un point toutes les 30 minutes
        const data = await session.getLoadCurve(next.format('YYYY-MM-DD'), end.format('YYYY-MM-DD'));
        let inputFormat = [];
        data.data.forEach(d => {
          // On converti au format Warp 10
          const ts = dayjs(d.date, 'YYYY-MM-DD HH:mm:ss').valueOf() * 1000;
          if (d.value) {
            // Pensez à modifier subscribed avec votre puissance souscrite
            inputFormat.push(`${ts}// enedis.linky{unit=W,subscribed=9,pdl=${conf.usagePointId}} ${d.value}`);
          }
        });
        // on pousse notre buffer dans Warp 10
        await w10.update(conf.warp10.wt, inputFormat);
        sum +=  data.data.length;
      } catch (e) {
        console.error(start.format('YYYY-MM-DD'), e);
      }
      end = next;
    }
    resolve(sum);
  });
}

Parfait, maintenant on peut récupérer notre historique complet (ou presque) entre le premier Janvier 2015 et maintenant.

updateHistory('2015-01-01', 1).then(r => console.log(r))

Il arrive que certains jours, Enedis n’ait pas collecté de données, (il y aura des 404 et pas des Peugeots) et si vous tapez trop loin dans le passé, vous aurez un 500 (de mémoire) pour vous dire qu’Enedis n’a pas collecté de données avant que vous ayez activé votre collecte de votre conso horaire, donc soyez raisonnable quant au choix dans la date (arf).

Une fois fini, vous avez un historique quasi complet. Mais il faut le faire tourner tous les jours :

cron.schedule('0 1 * * *', () => { // 1 h du mat tous les jours
  const start = dayjs().subtract(5, 'day'); // les 5 derniers jours
  updateHistory(start.format('YYYY-MM-DD'), 1).then(r => console.log(r));
});

Y’a plus qu’à déployé ce script sur un Raspberry (ou ce que vous voulez), le lancer et il collectera quotidiennement vos données.

Afficher mes données

Pour ce faire, allez sur http://studio.senx.io/ et sélectionnez la Sandbox dans la liste des endpoints.

WarpStudio
WarpStudio

Codons notre premier scripte pour afficher 30 jours d’historique. (remplacez bien les textes par vos infos 😉 )

'votre token de lecture' 'token' STORE
'n° de PDL' 'pdl' STORE
[ $token 'enedis.linky' { 'pdl' $pdl } NOW 30 d ] FETCH

Cliquez sur l’onglet ‘Dataviz’ et admirez en bavant votre courbe de charge exprimée en W :

Courbe de charge
Ma conso à moi

Déjà, c’est cool, mais j’en veux plus, je veux un vrai dashboard qui claque!

On va utiliser Discovery (décrit ici). D’abord, le squelette. Créez un fichier index.html :

<html>
<head>
  <title>Linky dashboard</title>
</head>
<body>
  <discovery-dashboard url="https://sandbox.senx.io/api/v0/exec" cols="12" cell-height="160">
// Le WarpScript va ici
  </discovery-dashboard>
<!-- Les imports -->
  <script nomodule src="https://unpkg.com/@senx/discovery-widgets/dist/discovery/discovery.js"></script>
  <script type="module" src="https://unpkg.com/@senx/discovery-widgets/dist/discovery/discovery.esm.js"></script>
</body>
</html>

Et maintenant le code WarpScript :

Pour les tuiles (à ajouter dans tiles), on va récupérer souvent un an, un mois et 24 heures d’historique, la dernière conso et la dernière date de synchro. Plutôt que de faire un accès à la BDD à chaque tuile, on va mutualiser cette récupération et on va présenter l’info différemment par tuile.

'n° de PDL' 'pdl' STORE
'votre read token' 'token' STORE
// on récupère 1 an qu'on met dans '1y'
[ $token 'enedis.linky' { 'pdl' $pdl } NOW 365 d ] FETCH '1y' STORE 
// on coupe pour ne garder que 30 jours, stoké dans '30d'
$1y NOW 30 d TIMECLIP '30d' STORE 
// on récupère la dernière date connue
$30d 0 GET LASTTICK 'lasttick' STORE 
// la dernière valeur connue
$30d $lasttick ATTICK 0 GET REVERSE 0 GET 'lastvalue' STORE
// et les dernières 24 heures connues que l'on place dans '24h'
$30d $lasttick 24 h TIMECLIP '24h' STORE 

Ensuite la coquille du dashboard :

{
  'title' 'Linky'
  'description' 'Personal electric consumption'
  'options' { 'scheme' 'CHARTANA' }
  'tiles' [
// ... les tuiles ici
  ]
}

Enfin, les tuiles.

La moyenne annuelle

{
   'title' '1 year average'
   'x' 2 'y' 0 'w' 2 'h' 1
   'type' 'display' 'unit' 'kW'
   'data' [ $1y bucketizer.mean 0 0 1 ] BUCKETIZE 0 GET 'gts' STORE // calcul de la moyenne
                // récupération de la valeur et conversion/arrondi en kW
                $gts VALUES 0 GET 1000.0 / 1000.0 * ROUND 1000.0 / 
}

La dernière conso connue

{
  'title' 'Last known consumption'
  'x' 4 'y' 0 'w' 2 'h' 1
  'type' 'display' 'unit' 'kW'
  'data' $lastvalue 1000.0 / 1000.0 * ROUND 1000.0 / // conversion/arrondi en kW
 }

La date de dernière synchro

{
  'title' 'Last sync date'
  'x' 0 'y' 0 'w' 2 'h' 1
  'type' 'display'
  'data'  { 'data' $lasttick 'globalParams' { 'timeMode' 'duration' } }
 }

Les dernière 24 heures connues

{
  'title' 'Last 24h of data '
  'x' 6 'y' 0 'w' 4 'h' 1
  'type' 'bar'
   // On ne garde que le max horaire
   'data' [ $24h bucketizer.max 0 1 h 0 ] BUCKETIZE 
 }

Le Max sur les derniers 30 jours

{
  'title' 'Max 1 month'
  'x' 10 'y' 2 'w' 2 'h' 1
  'type' 'display' 'unit' 'kW'
  'data' [ $30d bucketizer.max 0 0 1 ] BUCKETIZE 0 GET 'gts' STORE // recherche du max
     // récupération de la valeur et conversion/arrondi en kW
    $gts VALUES 0 GET 1000.0 / 1000.0 * ROUND 1000.0 / 'data' STORE 
    // récupération de la puissance souscrite (en kW)
    $gts LABELS 'subscribed' GET TODOUBLE 'subscribed' STORE 
      {
        'data' $data
        'globalParams' {
          // Si votre conso est > à la puissance souscrite alors la tuile aura un fond rouge. 
          // elle sera verte sinon
          'bgColor' <% $data $subscribed <= %> <% '#32cb0099' %> <% '#ff616f99' %> IFTE
          'fontColor' '#ffffff'
        }
    }
}

La max sur un an

{
  'title' 'Max in 1 year'
  'x' 10 'y' 1 'w' 2 'h' 1
  'type' 'display' 'unit' 'kW'
  'data' [ $1y bucketizer.max 0 0 1 ] BUCKETIZE 0 GET 'gts' STORE
    // récupération de la valeur et conversion/arrondi en kW
    $gts VALUES 0 GET 1000.0 / 1000.0 * ROUND 1000.0 / 'data' STORE
    $gts LABELS 'subscribed' GET 'subscribed' STORE
    { 'data' $data 'globalParams' {
      'bgColor' <% $data $subscribed TODOUBLE <= %> <% '#32cb0099' %> <% '#ff616f99' %> IFTE
      'fontColor' '#ffffff'
      }
   }
}

Le max sur 24 heures

{
  'title' 'Max in 24 h'
  'x' 10 'y' 0 'w' 2 'h' 1
  'type' 'display' 'unit' 'kW'
  'data' [ $24h bucketizer.max 0 0 1 ] BUCKETIZE 0 GET 'gts' STORE
   // récupération de la valeur et conversion/arrondi en kW
  $gts VALUES 0 GET 1000.0 / 1000.0 * ROUND 1000.0 / 'data' STORE
  $gts LABELS 'subscribed' GET 'subscribed' STORE
  { 'data' $data 'globalParams' {
    'bgColor' <% $data $subscribed TODOUBLE <= %> <% '#32cb0099' %> <% '#ff616f99' %> IFTE
    'fontColor' '#ffffff'
    }
  }
}

La consommation sur les 30 derniers jours

{
  'title' 'Last month consumption'
  'x' 0 'y' 1 'w' 5 'h' 2
  'type' 'line'
  // on calcule le max par tranche de 3 heures
  'data' [ $30d bucketizer.max NOW 3 h 0 ] BUCKETIZE 0 GET 'conso' STORE
  // on récupère la puissance souscrite 
  $conso LABELS 'subscribed' GET TOLONG 1000 * 'subscribed' STORE
  // On récupère les bornes temporelles min et max
  $conso TICKLIST REVERSE 0 GET 'first' STORE
  $conso LASTTICK 'last' STORE
  // on se crée une courbe pour afficher une ligne correspondant à la puissance souscrite
  NEWGTS 'subscribed' RENAME 'psGTS' STORE
  // min
  $psGTS $first NaN NaN NaN $subscribed ADDVALUE DROP
  // max
  $psGTS $last NaN NaN NaN $subscribed ADDVALUE DROP
  // On met en forme avec des couleurs
  { 'data' [ $conso $psGTS  ] 'params' [ { 'type' 'area' } { 'datasetColor' '#ef5350' } ] }
}

La consommation annuelle

Pareil que précédemment.

{
  'title' 'Last year consumption'
  'x' 5 'y' 1 'w' 5 'h' 2
  'type' 'line'
  'data' [ $1y bucketizer.max NOW 1 d 0 ] BUCKETIZE 0 GET 'conso' STORE
    $conso
    $conso LABELS 'subscribed' GET TOLONG 1000 * 'subscribed' STORE
    $conso TICKLIST REVERSE 0 GET 'first' STORE
    $conso LASTTICK 'last' STORE
    NEWGTS 'subscribed' RENAME 'psGTS' STORE
    $psGTS $first NaN NaN NaN $subscribed ADDVALUE DROP
    $psGTS $last NaN NaN NaN $subscribed ADDVALUE
    2 ->LIST  'data' STORE
    { 'data' [ $conso $psGTS  ] 'params' [ { 'type' 'area' } { 'datasetColor' '#ef5350' } ] }
}

Le résultat

Et donc, en ajoutant un peu de CSS à notre page Web :

@import url('https://fonts.googleapis.com/css2?family=Kanit:wght@200&display=swap');

* {
    box-sizing: border-box;
}

:root {
    --wc-split-gutter-color: #404040;
    --warp-view-pagination-bg-color: #343a40 !important;
    --warp-view-pagination-border-color: #6c757d;
    --warp-view-datagrid-odd-bg-color: rgba(255, 255, 255, .05);
    --warp-view-datagrid-odd-color: #FFFFFF;
    --warp-view-datagrid-even-bg-color: #212529;
    --warp-view-datagrid-even-color: #FFFFFF;
    --warp-view-font-color: #FFFFFF;
    --warp-view-chart-label-color: #FFFFFF;
    --gts-stack-font-color: #FFFFFF;
    --warp-view-resize-handle-color: #111111;
    --warp-view-chart-legend-bg: #000;
    --gts-labelvalue-font-color: #ccc;
    --gts-separator-font-color: #FFFFFF;
    --gts-labelname-font-color: rgb(105, 223, 184);
    --gts-classname-font-color: rgb(126, 189, 245);
    --warp-view-chart-legend-color: #FFFFFF;
    --wc-tab-header-color: #FFFFFF;
    --wc-tab-header-selected-color: #404040;
    --warp-view-tile-background: #40404066;
}

body {
    font-family: 'Kanit', sans-serif;
    font-size: 12px;
    line-height: 1.52;
    color: #1b1b1b;
    background: #FAFBFF linear-gradient(40deg, #3BBC7D, #1D434C) fixed;
    padding: 1rem;
    height: calc(100vh - 2rem);
}


discovery-dashboard {
    color: transparent;
}

On obtient :

le résultat
et hop
Partager c'est la vie

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *