Bonjour à tous,
Je vous propose un petit partage/tuto d’un dashboard de suivi des batteries construit en HTML, CSS et Javascript via Node-Red :
Le flow est surement pas parfait, car c’est mon premier « projet » sur NodeRed
Je n’ai pas utilisé le node template pour la page HTML car je n’arrivais pas à faire ce que je veux en mustache pour boucler sur la liste des batteries, je me suis donc rabattu vers un langage que je connais mieux, le JS.
Je suis donc ouvert à toutes critiques et idées d’améliorations
Rendu final PC:
Rendu Mobile:
Pré-réquis :
HA, Node-red, Optionnel Battery Note :
Permet d’avoir des infos supplémentaires sur les batteries de vos capteurs (type de battery, nombre, date de remplacement etc…)
Pour la partie HTML et CSS je me suis basé sur le projet de Bedimcode :
Voici le petit Flow créer pour générer le dashboard :
Le déclencheur du flow est un simple horodatage avec un run toutes les 60 minutes, mais vous pouvez bien entendu également lier le get all battery avec un state event ou autre déclencheur.
Le get all battery est aussi très simple puisqu’il récupère tous les capteurs avec une classe battery. C’est dans le node Normalize que je fais un tri et retire les doublons liés au fait que j’utilise battery note.
La partie un peu plus complexe (c’est un grand mot ^^ ) commence ici: Normalize Battery Level
Globalement, ce nœud me permet de filtrer plus finement mes capteurs et surtout de dédoublonner mes entitées battery du fait que j’utilise l’intégration battery note qui créer une entité supplémentaire qui s’appelle battery_plus. J’ai également deux intégration Xiaomi (Miot auto et gateway 3 pour mes capteurs Bluetooth )
Dans la première partie du code, je garde uniquement les entitées dont le nom inclus ‹ battery_plus › cela me permet directement de supprimer l’entité standard battery de home assistant.
Ensuite le « parseInt » permet de transformer le texte de valeur de la battery en format Integer (nombre entier) ainsi les valeurs de battery qui serait unknow par exemple, seront transformés en « NaN » not a number et pourront facilement être exclu par la suite.
Ensuite je modifie les friendly name de mes entités pour les raccourcir et retirer le terme Batterie+ de leur nom.
Pour terminer cette première partie, je fini sur un .sort qui me permet de mettre les entités dans l’ordre croissant de leur valeur de batterie (Les batteries les plus faibles en premier)
Ensuite la deuxième partie du code est une boucle qui me permet de stocker les valeurs de batteries en fonction de leur valeur dans 5 grandes catégories ( je ne m’en suis pas servie dans le reste de ma page, mais à la base je m’étais dit que ça pourrait être utile… j’ai donc laissé cette partie qui ne dérange pas pour la suite) [100-75] [75-50][50-25][25-10][10-0]
Maintenant que les données ont été triées et ordonnées, on va pouvoir générer un page HTML dynamique. Je vais simplement vous expliquer la partie script, le HTML n’a rien de particulier.
Donc la petite subtilité ici, c’est que les capteurs sont dans une map qui contient des listes.
J’ai donc une première boucle qui contient 5 listes à traiter et ensuite dans la seconde boucle, j’ai mes différentes batteries.
Je génère donc une nouvelle carte de batterie pour chacune avec les informations que je désire afficher et je la rajoute à ma page HTML d’origine.
Ensuite l’autre partie de script sera exécuté directement dans la page HTML et non dans node-Red.
Ce script sert à générer dynamiquement le CSS de vos différentes batteries pour ajuster le visuel a son niveau.
Pour terminer on sauvegarde simplement cette page HTML dans Home assistant :
Il faudra adapter le chemin en fonction de l’url que vous aller indiquer dans votre config HA.
Et, également de comment tourne votre serveur Red ( Docker ou plugin HA )
Config HA pour avoir le panel de suivi des batteries qui pointe vers l’emplacement de stockage de la page HTML:
Le flow pour jouer avec
[{"id":"b7e2f52708e15f8a","type":"comment","z":"e453ff62415924bf","name":"Batteries Dashboard","info":"","x":230,"y":920,"wires":[]},{"id":"72efcb545bfc0e84","type":"ha-get-entities","z":"e453ff62415924bf","name":"Get All Battery","server":"01bc5675ea304f7f","version":1,"rules":[{"property":"attributes.device_class","logic":"is","value":"battery","valueType":"str"}],"outputType":"array","outputEmptyResults":true,"outputLocationType":"msg","outputLocation":"payload","outputResultsCount":1,"x":430,"y":960,"wires":[["9c0e8a73c7402aef"]]},{"id":"28cf29e7b576a96e","type":"inject","z":"e453ff62415924bf","name":"","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"3600","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"","payloadType":"date","x":230,"y":960,"wires":[["72efcb545bfc0e84"]]},{"id":"06d378c662f594d3","type":"function","z":"e453ff62415924bf","name":"Webpage","func":"const moment = global.get('moment');\nconst parisTime = moment().tz(\"Europe/Paris\").locale('fr').format('LLLL');\nvar batteryLevelMap = msg.payload;\nvar allItems = [];\nvar htmlPage = `<!DOCTYPE html>\n <html lang=\"en\">\n <head>\n <meta charset=\"UTF-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n <meta http-equiv=\"refresh\" content=\"60\">\n\n <!--=============== REMIXICONS ===============-->\n <link href=\"https://cdn.jsdelivr.net/npm/remixicon@2.5.0/fonts/remixicon.css\" rel=\"stylesheet\">\n\n <!--=============== CSS ===============-->\n <style>\n /*=============== GOOGLE FONTS ===============*/\n @import url(\"https://fonts.googleapis.com/css2?family=Rubik:wght@400;600&display=swap\");\n\n /*=============== VARIABLES CSS ===============*/\n .grid {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));\n gap: 10px;\n }\n :root {\n /*========== Colors ==========*/\n /*Color mode HSL(hue, saturation, lightness)*/\n --gradient-color-red: linear-gradient(90deg, \n hsl(7, 89%, 46%) 15%,\n hsl(11, 93%, 68%) 100%);\n --gradient-color-orange: linear-gradient(90deg, \n hsl(22, 89%, 46%) 15%,\n hsl(54, 90%, 45%) 100%);\n --gradient-color-yellow: linear-gradient(90deg, \n hsl(54, 89%, 46%) 15%,\n hsl(92, 90%, 45%) 100%);\n --gradient-color-green: linear-gradient(90deg, \n hsl(92, 89%, 46%) 15%,\n hsl(92, 90%, 68%) 100%);\n --text-color: #fff;\n --body-color: hsl(0, 0%, 11%);\n --container-color: hsl(0, 0%, 9%);\n\n /*========== Font and typography ==========*/\n /*.5rem = 8px | 1rem = 16px ...*/\n --body-font: 'Rubik', sans-serif;\n\n --biggest-font-size: 2.5rem;\n --normal-font-size: .938rem;\n --smaller-font-size: .75rem;\n }\n\n /* Responsive typography */\n @media screen and (min-width: 968px) {\n :root {\n --biggest-font-size: 2.75rem;\n --normal-font-size: 1rem;\n --smaller-font-size: .813rem;\n }\n }\n\n /*=============== BASE ===============*/\n * {\n box-sizing: border-box;\n padding: 0;\n margin: 0;\n }\n\n body {\n font-family: var(--body-font);\n font-size: var(--normal-font-size);\n background-color: var(--body-color);\n color: var(--text-color);\n }\n\n /*=============== BATTERY ===============*/\n .battery {\n height: 100vh;\n display: grid;\n place-items: center;\n margin: 0 1.5rem;\n }\n\n .battery__card {\n position: relative;\n width: 100%;\n height: 240px;\n background-color: var(--container-color);\n padding: 1.5rem 2rem;\n border-radius: 1.5rem;\n display: grid;\n grid-template-columns: repeat(2, 1fr);\n align-items: center;\n }\n .battery__data {\n height: -webkit-fill-available;\n display: grid;\n }\n .battery__text {\n margin-bottom: 0.5rem;\n font-size: large;\n font-weight: bolder;\n width: max-content;\n }\n\n .battery__percentage {\n font-size: var(--biggest-font-size);\n margin: 0 0 auto 0;\n }\n\n .battery__status {\n position: absolute;\n bottom: 1.5rem;\n display: flex;\n align-items: center;\n column-gap: .5rem;\n font-size: var(--smaller-font-size);\n }\n\n .battery__status i {\n font-size: 1.25rem;\n }\n\n .battery__pill {\n position: relative;\n width: 75px;\n height: 180px;\n background-color: var(--container-color);\n box-shadow: inset 20px 0 48px hsl(0, 0%, 16%), \n inset -4px 12px 48px hsl(0, 0%, 56%);\n border-radius: 3rem;\n justify-self: flex-end;\n }\n\n .battery__level {\n position: absolute;\n inset: 2px;\n border-radius: 3rem;\n overflow: hidden;\n }\n\n .battery__liquid {\n position: absolute;\n bottom: 0;\n left: 0;\n right: 0;\n height: 36px;\n background: var(--gradient-color-red);\n box-shadow: inset -10px 0 12px hsla(0, 0%, 0%, .1), \n inset 12px 0 12px hsla(0, 0%, 0%, .15);\n transition: .3s;\n }\n\n .battery__liquid::after {\n content: '';\n position: absolute;\n height: 8px;\n background: var(--gradient-color-red);\n box-shadow: inset 0px -3px 6px hsla(0, 0%, 0%, .2);\n left: 0;\n right: 0;\n margin: 0 auto;\n top: -4px;\n border-radius: 50%;\n }\n\n /* Full battery icon color */\n .green-color {\n background: var(--gradient-color-green);\n }\n\n /* Battery charging animation */\n .animated-green {\n background: var(--gradient-color-green);\n animation: animated-charging-battery 1.2s infinite alternate;\n }\n\n /* Low battery animation */\n .animated-red {\n background: var(--gradient-color-red);\n animation: animated-low-battery 1.2s infinite alternate;\n }\n\n .animated-green,\n .animated-red,\n .green-color {\n -webkit-background-clip: text;\n color: transparent;\n }\n\n @keyframes animated-charging-battery {\n 0% {\n text-shadow: none;\n }\n 100% {\n text-shadow: 0 0 6px hsl(92, 90%, 68%);\n }\n }\n\n @keyframes animated-low-battery {\n 0% {\n text-shadow: none;\n }\n 100% {\n text-shadow: 0 0 8px hsl(7, 89%, 46%);\n }\n }\n\n /* Liquid battery with gradient color */\n .gradient-color-red,\n .gradient-color-red::after {\n background: var(--gradient-color-red);\n }\n\n .gradient-color-orange,\n .gradient-color-orange::after {\n background: var(--gradient-color-orange);\n }\n\n .gradient-color-yellow,\n .gradient-color-yellow::after {\n background: var(--gradient-color-yellow);\n }\n\n .gradient-color-green,\n .gradient-color-green::after {\n background: var(--gradient-color-green);\n }\n\n /*=============== BREAKPOINTS ===============*/\n /* For small devices */\n @media screen and (max-width: 320px) {\n .battery__card {\n zoom: .8;\n }\n }\n\n /* For medium devices \n @media screen and (min-width: 430px) {\n .battery__card {\n width: 312px;\n }\n }*/\n\n /* For large devices \n @media screen and (min-width: 1024px) {\n .battery__card {\n zoom: 1.5;\n }\n }*/\n .datetime-style {\n font-family: 'Arial', sans-serif;\n color: #333;\n background-color: #f9f9f9;\n padding: 8px 16px;\n border-radius: 5px;\n box-shadow: 0 2px 4px rgba(0,0,0,0.1);\n display: inline-block;\n margin: 10px;\n }\n </style>\n <title>Suivi des batteries</title>\n </head>\n <body>\n <div class=\"date\">\n <div>Date du Snapshot: </div>\n <div id=\"datetime-display\" class=\"datetime-style\">${parisTime}</div>\n <!--=============== BATTERY ===============-->\n <section class=\"battery grid\">`;\n batteryLevelMap.forEach((items, category) => {\n items.forEach(sensor => {\n allItems.push(sensor);\n var newDiv = `<div class=\"battery__card\">\n <div class=\"battery__data\">\n <p class=\"battery__text\">${sensor.attributes.friendly_name}</p>\n <h1 class=\"battery__percentage\">\n ${sensor.state}\n </h1>\n <p>Type: ${sensor.attributes.battery_type_and_quantity}</p>\n <p class=\"battery__status\">\n Low battery <i class=\"ri-plug-line\"></i>\n </p>\n </div>\n <div class=\"battery__pill\">\n <div class=\"battery__level\">\n <div class=\"battery__liquid\"></div>\n </div>\n </div>\n </div>`;\n htmlPage = htmlPage + newDiv;\n });\n });\n htmlPage = htmlPage + `</section>\n <!--=============== MAIN JS ===============-->\n <script>\n /*=============== BATTERY ===============*/\n/*=============== BATTERY ===============*/\ninitBattery()\n\nfunction initBattery(){\n const batteryLiquid = document.querySelectorAll('.battery__liquid'),\n batteryStatus = document.querySelectorAll('.battery__status'),\n batteryPercentage = document.querySelectorAll('.battery__percentage')\n var i = 0;\n batteryPercentage.forEach((batt) =>{\n /* 1. We update the number level of the battery */\n let level = Math.floor(batteryPercentage[i].innerHTML)\n console.log(level);\n batteryPercentage[i].innerHTML = level+ '%'\n\n /* 2. We update the background level of the battery */\n batteryLiquid[i].style.height = level+'%'\n\n /* 3. We validate full battery, low battery and if it is charging or not */\n if(level == 100){ /* We validate if the battery is full */\n batteryStatus[i].innerHTML = 'Full battery <i class=\"ri-battery-2-fill green-color\"></i>'\n batteryLiquid[i].style.height = '103%' /* To hide the ellipse */\n }\n else if(level <= 20 &! batt.charging){ /* We validate if the battery is low */\n batteryStatus[i].innerHTML = 'Low battery <i class=\"ri-plug-line animated-red\"></i>'\n }\n else if(batt.charging){ /* We validate if the battery is charging */\n batteryStatus[i].innerHTML = 'Charging... <i class=\"ri-flashlight-line animated-green\"></i>'\n }\n else{ /* If it's not loading, don't show anything. */\n batteryStatus[i].innerHTML = ''\n }\n \n /* 4. We change the colors of the battery and remove the other colors */\n if(level <=20){\n batteryLiquid[i].classList.add('gradient-color-red')\n batteryLiquid[i].classList.remove('gradient-color-orange','gradient-color-yellow','gradient-color-green')\n }\n else if(level <= 40){\n batteryLiquid[i].classList.add('gradient-color-orange')\n batteryLiquid[i].classList.remove('gradient-color-red','gradient-color-yellow','gradient-color-green')\n }\n else if(level <= 80){\n batteryLiquid[i].classList.add('gradient-color-yellow')\n batteryLiquid[i].classList.remove('gradient-color-red','gradient-color-orange','gradient-color-green')\n }\n else{\n batteryLiquid[i].classList.add('gradient-color-green')\n batteryLiquid[i].classList.remove('gradient-color-red','gradient-color-orange','gradient-color-yellow')\n }\n i++;\n })\n}\n </script>\n </body>\n</html>`;\n\nfunction getBatteryClass(category) {\n switch (category) {\n case '10': return 'very-low-battery';\n case '25': return 'low-battery';\n case '50': return 'medium-battery';\n case '75': return 'high-battery';\n case 'full': return 'very-high-battery';\n default: return '';\n }\n}\n\nmsg.payload = htmlPage\n\nreturn msg;","outputs":1,"timeout":"","noerr":0,"initialize":"","finalize":"","libs":[],"x":870,"y":960,"wires":[["abdddf65db1f13a7"]]},{"id":"abdddf65db1f13a7","type":"file","z":"e453ff62415924bf","name":"Save File","filename":"/HAnodeRed/batterycheck.html","filenameType":"str","appendNewline":false,"createDir":false,"overwriteFile":"true","encoding":"none","x":1050,"y":960,"wires":[[]]},{"id":"9c0e8a73c7402aef","type":"function","z":"e453ff62415924bf","name":"Normalize Battery Level","func":"// Initialization of arrays\nvar less10array = [];\nvar less25array = [];\nvar less50array = [];\nvar less75array = [];\nvar fullarray = [];\n\nvar ThisArray = msg.payload.filter(entity => entity.entity_id.includes('battery_plus')).map(entity => {\n entity.state = parseInt(entity.state);\n if (entity.attributes.friendly_name && entity.attributes.friendly_name.includes(\"Batterie+\")) {\n entity.attributes.friendly_name = entity.attributes.friendly_name.replace(\"Batterie+\", \"\");\n }\n if (entity.attributes.friendly_name && entity.attributes.friendly_name.includes(\"Battery+\")) {\n entity.attributes.friendly_name = entity.attributes.friendly_name.replace(\"Battery+\", \"\");\n }\n if (entity.attributes.friendly_name && entity.attributes.friendly_name.includes(\"Mi Temperature\")) {\n entity.attributes.friendly_name = entity.attributes.friendly_name.replace(\"Mi Temperature\", \"Temp.\");\n }\n if (entity.attributes.friendly_name && entity.attributes.friendly_name.includes(\"Détecteur de fumée\")) {\n entity.attributes.friendly_name = entity.attributes.friendly_name.replace(\"Détecteur de fumée\", \"Fumée\");\n }\n if (entity.attributes.friendly_name && entity.attributes.friendly_name.includes(\"Xiaomi Temperature and Humidity Monitor Clock\")) {\n entity.attributes.friendly_name = \"Temp. Salon\";\n }\n return entity;\n}).sort((a, b) => parseInt(a.state) - parseInt(b.state));\nThisArray.forEach(myFunction);\n\nfunction myFunction(item, index) {\n const batteryLevelAttr = item.attributes && item.attributes[\"Battery Level\"];\n const state = !isNaN(batteryLevelAttr) ? batteryLevelAttr : item.state;\n const level = parseInt(state);\n\n if (item.attributes && item.attributes.entity_class === \"MiotSensorSubEntity\" && item.entity_id !== \"sensor.xiaomi_temperature_and_humidity_monitor_clock_battery_plus\") {\n // Skip this item\n } else if (!isFinite(level)) {\n // Skip if level is not finite\n } else if (level < 10) {\n less10array.push(item);\n } else if (level < 25) {\n less25array.push(item);\n } else if (level < 50) {\n less50array.push(item);\n } else if (level < 75) {\n less75array.push(item);\n } else {\n fullarray.push(item);\n }\n}\n\n// Creating and populating the map after arrays are populated\nvar batteryLevelMap = new Map([\n ['10', less10array],\n ['25', less25array],\n ['50', less50array],\n ['75', less75array],\n ['full', fullarray],\n]);\n\n// Verifying the map contents\nconsole.log(batteryLevelMap);\nmsg.payload = batteryLevelMap;\nreturn msg;\n","outputs":1,"timeout":"","noerr":0,"initialize":"","finalize":"","libs":[],"x":660,"y":960,"wires":[["06d378c662f594d3"]]},{"id":"01bc5675ea304f7f","type":"server","name":"Home Assistant","version":5,"addon":false,"rejectUnauthorizedCerts":true,"ha_boolean":"y|yes|true|on|home|open","connectionDelay":true,"cacheJson":true,"heartbeat":false,"heartbeatInterval":"30","areaSelector":"friendlyName","deviceSelector":"friendlyName","entitySelector":"friendlyName","statusSeparator":": ","statusYear":"hidden","statusMonth":"short","statusDay":"numeric","statusHourCycle":"default","statusTimeFormat":"h:m","enableGlobalContextStore":false}]