first commit

This commit is contained in:
Marc Baloup 2022-12-07 14:22:06 +01:00
commit 2a16f8e5f1
Signed by: marcbal
GPG Key ID: BBC0FE3ABC30B893
25 changed files with 4890 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
/target
/dependency-reduced-pom.xml
/docker-compose.yml
/workdir

3
.idea/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml

17
.idea/compiler.xml Normal file
View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<annotationProcessing>
<profile name="Maven default annotation processors profile" enabled="true">
<sourceOutputDir name="target/generated-sources/annotations" />
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
<outputRelativeToContentRoot value="true" />
<module name="tic" />
</profile>
</annotationProcessing>
<bytecodeTargetLevel>
<module name="tic-lib" target="1.8" />
<module name="tic-main" target="1.8" />
</bytecodeTargetLevel>
</component>
</project>

9
.idea/encodings.xml Normal file
View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding">
<file url="file://$PROJECT_DIR$/lib/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/lib/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/src/main/resources" charset="UTF-8" />
</component>
</project>

File diff suppressed because it is too large Load Diff

20
.idea/jarRepositories.xml Normal file
View File

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RemoteRepositoriesConfiguration">
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Central Repository" />
<option name="url" value="https://repo.maven.apache.org/maven2" />
</remote-repository>
<remote-repository>
<option name="id" value="central" />
<option name="name" value="Maven Central repository" />
<option name="url" value="https://repo1.maven.org/maven2" />
</remote-repository>
<remote-repository>
<option name="id" value="jboss.community" />
<option name="name" value="JBoss Community repository" />
<option name="url" value="https://repository.jboss.org/nexus/content/repositories/public/" />
</remote-repository>
</component>
</project>

17
.idea/misc.xml Normal file
View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="MavenProjectsManager">
<option name="originalFiles">
<list>
<option value="$PROJECT_DIR$/pom.xml" />
</list>
</option>
<option name="ignoredFiles">
<set>
<option value="$PROJECT_DIR$/lib/pom.xml" />
</set>
</option>
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="17" project-jdk-type="JavaSDK" />
</project>

6
.idea/vcs.xml Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

16
Dockerfile Normal file
View File

@ -0,0 +1,16 @@
FROM arm32v6/openjdk:8u212-jdk-alpine
ADD run.sh target/tic.jar /data/bin/
RUN chmod u+x /data/bin/*
ENV TIC_MQTT_SERVER_IP="127.0.0.1"
ENV TIC_MQTT_HASS_DISCOVERY_PREFIX="homeassistant"
ENV TIC_MQTT_USERNAME="name"
ENV TIC_MQTT_PASSWORD="pass"
ENV TIC_TTY_INPUT="/dev/ttyAMA0"
WORKDIR /data/workdir
ENTRYPOINT /data/bin/run.sh -i

View File

@ -0,0 +1,16 @@
version: "3"
services:
tic:
build: .
restart: always
environment:
TIC_MQTT_SERVER_IP: "mqtt_ip"
TIC_MQTT_HASS_DISCOVERY_PREFIX: "homeassistant"
TIC_MQTT_USERNAME: "username"
TIC_MQTT_PASSWORD: "password"
TIC_TTY_INPUT: "/dev/ttyAMA0"
devices:
- "/dev/ttyAMA0:/dev/ttyAMA0"
volumes:
- "./workdir:/data/workdir"

80
pom.xml Normal file
View File

@ -0,0 +1,80 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>fr.mbaloup</groupId>
<artifactId>tic</artifactId>
<version>dev</version>
<name>tic</name>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<groupId>org.eclipse.paho</groupId>
<artifactId>org.eclipse.paho.client.mqttv3</artifactId>
<version>1.2.5</version>
</dependency>
<dependency>
<groupId>org.rapidoid</groupId>
<artifactId>rapidoid-essentials</artifactId>
<version>5.5.5</version>
</dependency>
<!-- <dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>1.7.21</version>
<optional>true</optional>
</dependency> -->
</dependencies>
<build>
<finalName>${project.name}</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>2.4</version>
<configuration>
<archive>
<manifestEntries>
<Main-Class>fr.mbaloup.home.Main</Main-Class>
<Specification-Version>${maven.build.timestamp}</Specification-Version>
</manifestEntries>
</archive>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>2.4.3</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
</excludes>
</filter>
</filters>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

135
res/static/index.css Normal file
View File

@ -0,0 +1,135 @@
* { box-sizing: border-box; }
html, input { font-family: Arial, Helvetica, sans-serif; }
html, body { margin: 0; padding: 0;}
:root {
--color-theme: rgb(3.921%, 39.21%, 68.23%);
--min-size: calc(min(calc(100vw / 0.6), 100vh) / 3);
}
body {
padding: 16px;
}
.widget {
border: solid 1px var(--color-theme);
border-radius: 2px;
margin: 16px;
}
.widget > header {
background-color: var(--color-theme);
color: white;
padding: 10px;
}
.widget > main {
padding: 10px;
}
@media screen and (min-width: 800px) {
body {
display: flex;
flex-flow: row nowrap;
justify-content: center;
}
#left-widget-container {
width: 400px;
}
#right-widget-container {
flex-grow: 1;
}
#login-form .widget {
margin: 16px auto;
width: 400px;
}
}
@media screen and (max-width: 799.99px) {
.widget {
margin: 16px auto;
min-width: 300px;
max-width: 400px;
}
}
.raw-value {
font-family: monospace;
font-weight: bold;
font-size: 1.3em;
color: var(--color-theme);
}
.big-value {
text-align: right;
position: relative;
}
.big-value .name {
position: absolute;
top: 0.2em; left: 0;
}
.big-value .int-value {
font-size: 3.5em;
font-family: monospace;
font-weight: bold;
color: var(--color-theme);
}
.big-value .dec-value {
font-size: 1.6em;
font-family: monospace;
font-weight: bold;
color: var(--color-theme);
}
.big-value .unit {
position: absolute;
top: 0.2em; right: 0;
}
.big-value .meta {
position: absolute;
bottom: 1em; left: 0;
font-size: 0.8em;
}
.live-data-index:not(.index-current) {
opacity: 33%;
}
form input[type="text"],
form input[type="password"],
form input[type="email"],
form input[type="number"],
form input[type="date"],
form input[type="time"],
form textarea,
form select {
display: block;
margin: 5px auto;
border: solid 1px var(--color-theme);
border-radius: 2px;
padding: 5px;
font-size: 1.1em;
width: 100%;
}
form input[type="submit"] {
display: block;
margin: 5px auto;
border: solid 1px var(--color-theme);
border-radius: 2px;
padding: 5px;
font-size: 1.1em;
width: 100%;
}

157
res/static/index.html Normal file
View File

@ -0,0 +1,157 @@
<!DOCTYPE html>
<html lang="fr" dir="ltr">
<head>
<meta charset="utf-8">
<title>Domotique</title>
<link rel="stylesheet" type="text/css" href="index.css" media="screen"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body>
<div id="logged-in-content" style="display: none;">
<div id="left-widget-container">
<div class="widget">
<header>Puissances</header>
<main>
<div class="big-value">
<span class="name">Courante</span>
<span class="int-value" id="live-data-papp"></span><span style="visibility: hidden;">.<span class="dec-value">&nbsp;&nbsp;</span></span>
<span class="unit">VA</span>
</div>
<div class="big-value">
<span class="name">Coupure</span>
<span class="int-value" id="live-data-pcoup"></span><span style="visibility: hidden;">.<span class="dec-value">&nbsp;&nbsp;</span></span>
<span class="unit">VA</span>
</div>
<div class="big-value">
<span class="name">Référence (souscrite)</span>
<span class="int-value" id="live-data-pref"></span><span style="visibility: hidden;">.<span class="dec-value">&nbsp;&nbsp;</span></span>
<span class="unit">VA</span>
</div>
<div class="big-value">
<span class="name">Max aujourdhui</span>
<span class="meta" id="live-data-pj-time">--:--</span>
<span class="int-value" id="live-data-pj"></span><span style="visibility: hidden;">.<span class="dec-value">&nbsp;&nbsp;</span></span>
<span class="unit">VA</span>
</div>
<div class="big-value">
<span class="name">Max hier</span>
<span class="meta" id="live-data-pj-1-time">--:--</span>
<span class="int-value" id="live-data-pj-1"></span><span style="visibility: hidden;">.<span class="dec-value">&nbsp;&nbsp;</span></span>
<span class="unit">VA</span>
</div>
</main>
</div>
<div class="widget">
<header>Voltage</header>
<main>
<div class="big-value">
<span class="name">U<sub>eff</sub></span>
<span class="int-value" id="live-data-urms"></span><span style="visibility: hidden;">.<span class="dec-value">&nbsp;</span></span>
<span class="unit">V</span>
</div>
</main>
</div>
<div class="widget">
<header>Indexes cumulés</header>
<main id="live-data-indexes">
<div id="live-data-indexes-template" class="big-value live-data-index" style="display: none;">
<span class="name"></span>
<span class="int-value"></span>.<span class="dec-value">&nbsp;&nbsp;&nbsp;</span>
<span class="unit">kWh</span>
</div>
</main>
</div>
<div class="widget">
<header>Indexes jour</header>
<main id="live-data-indexes-day">
<div id="live-data-indexes-day-template" class="big-value" style="display: none;">
<span class="name"></span>
<span class="meta"></span>
<span class="int-value"></span>.<span class="dec-value"></span>
<span class="unit">kWh</span>
</div>
</main>
</div>
<div class="widget">
<header>Couts depuis minuit</header>
<main id="live-data-price-day">
<div id="live-data-price-day-template" class="big-value" style="display: none;">
<span class="name"></span>
<span class="meta"><span class="live-data-price-per-kwh"></span> € / kWh</span>
<span class="int-value"></span>.<span class="dec-value"></span>
<span class="unit"></span>
</div>
<div class="big-value">
<span class="name">Total</span>
<span class="int-value" id="live-data-price-total-int"></span>.<span class="dec-value" id="live-data-price-total-dec"></span>
<span class="unit"></span>
</div>
<div class="big-value">
<span class="name">Abonnement</span>
<span class="meta"><span id="live-data-price-sub-per-month"></span> € / mois</span>
<span class="int-value" id="live-data-price-sub-int"></span>.<span class="dec-value" id="live-data-price-sub-dec"></span>
<span class="unit"></span>
</div>
</main>
</div>
<div class="widget">
<header>Infos compteur</header>
<main>
Num série : <span class="raw-value" id="live-data-serial"></span><br>
PRM/<span title="Référence Point De Livraison">PDL</span> : <span class="raw-value" id="live-data-prm"></span><br>
Tarif : <span class="raw-value" id="live-data-OPTARIF"></span><br>
<span class="raw-value" id="live-data-status"></span>
</main>
</div>
<div class="widget">
<header>Air</header>
<main id="live-data-sensors">
<div id="live-data-sensors-template" class="big-value" style="display: none;">
<span class="name"></span>
<span class="meta"><span class="live-data-sensor-hum"></span> % Hum.</span>
<span class="int-value"></span>.<span class="dec-value"></span>
<span class="unit">°C</span>
</div>
</main>
</div>
<div class="widget">
<header>Connexion</header>
<main>
<form method="get" action="/_logout" target="login_frame">
<input type="submit" value="Se déconnecter"/>
</form>
</main>
</div>
</div>
<!-- <div id="right-widget-container">
<div class="widget">
<header>Widget 1</header>
<main>Du contenu</main>
</div>
<div class="widget">
<header>Widget 2</header>
<main>Encore<br>du<br>contenu</main>
</div>
<div class="widget">
<header>Widget 3</header>
<main>Encore<br>du contenu</main>
</div>
</div> -->
</div>
<div id="login-form" style="display: none;">
<div class="widget">
<header>Connexion</header>
<main>
<form method="post" action="/_login" onsubmit="setHTML('live-login-status', 'Connexion...');" target="login_frame">
<input type="text" name="username" placeholder="Pseudo"/>
<input type="password" name="password" placeholder="Mot de passe"/>
<input type="submit" value="Connexion"/>
<span class="raw-value" id="live-login-status"></span>
</form>
<iframe id="login_frame" name="login_frame" style="display: none;"></iframe>
</main>
</div>
</div>
</body>
</html>
<script src="index.js"></script>

233
res/static/index.js Normal file
View File

@ -0,0 +1,233 @@
let subscriptionMonth = 10.60;
let indexPrices = [0.1423, 0, 0, 0, 0, 0, 0, 0, 0, 0];
let currentData = {};
function setHTML(id, html) {
document.getElementById(id).innerHTML = html;
}
function leftPadStr(value, pad, count) {
value = value + '';
while (value.length < count) {
value = pad + value;
}
return value;
}
function onLiveDataReceived(data) {
if (data.serialNumber != currentData.serialNumber) {
setHTML("live-data-serial", data.serialNumber);
}
if (data.prm != currentData.prm) {
setHTML("live-data-prm", data.prm);
}
if (data.subscribedOption != currentData.subscribedOption) {
setHTML("live-data-OPTARIF", data.subscribedOption);
}
if (data.refPower != currentData.refPower) {
setHTML("live-data-pref", data.refPower);
}
if (data.cutPower != currentData.cutPower) {
setHTML("live-data-pcoup", data.cutPower);
}
if (data.maxPowerToday != currentData.maxPowerToday) {
setHTML("live-data-pj", data.maxPowerToday);
var d = new Date(data.maxPowerTimeToday);
setHTML("live-data-pj-time", d.toLocaleTimeString());
}
if (data.maxPowerYesterday != currentData.maxPowerYesterday) {
setHTML("live-data-pj-1", data.maxPowerYesterday);
var d = new Date(data.maxPowerTimeYesterday);
setHTML("live-data-pj-1-time", d.toLocaleTimeString());
}
if (data.appPower != currentData.appPower) {
setHTML("live-data-papp", data.appPower);
}
if (data.rmsVoltage != currentData.rmsVoltage) {
setHTML("live-data-urms", data.rmsVoltage);
}
if (data.indexes != currentData.indexes) {
var parent = document.getElementById('live-data-indexes');
var template = document.getElementById('live-data-indexes-template');
for (var i = 0; i < data.indexes.length; i++) {
var value = data.indexes[i];
var name = data.indexNames[i] || ('Index ' + (i + 1));
if (value <= 0)
continue;
var el = document.getElementById('live-data-index-' + i);
if (el == null) {
el = template.cloneNode(true);
el.id = 'live-data-index-' + i;
el.style.display = "";
if (data.currIndex == i)
el.classList.add('index-current');
el = parent.appendChild(el);
var nameEl = el.querySelector('.name');
nameEl.innerHTML = name;
}
var kWhEl = el.querySelector('.int-value');
var whEl = el.querySelector('.dec-value');
kWhEl.innerHTML = Math.floor(value / 1000);
whEl.innerHTML = leftPadStr(value % 1000, '0', 3);
}
}
if (data.currIndex != currentData.currIndex) {
var oldEl = document.getElementById('live-data-index-' + currentData.currIndex);
if (oldEl != null) {
oldEl.classList.remove('index-current');
}
var newEl = document.getElementById('live-data-index-' + data.currIndex);
if (newEl != null) {
newEl.classList.add('index-current');
}
}
var dayPriceSum = 0.0;
if (data.indexesMidnight != currentData.indexesMidnight) {
var parentIndex = document.getElementById('live-data-indexes-day');
var templateIndex = document.getElementById('live-data-indexes-day-template');
var parentPrice = document.getElementById('live-data-price-day');
var templatePrice = document.getElementById('live-data-price-day-template');
for (var i = 0; i < data.indexesMidnight.length; i++) {
var value = data.indexes[i] - data.indexesMidnight[i];
var name = data.indexNames[i] || ('Index ' + (i + 1));
if (value <= 0)
continue;
var el = document.getElementById('live-data-index-day-' + i);
if (el == null) {
el = templateIndex.cloneNode(true);
el.id = 'live-data-index-day-' + i;
el.style.display = "";
el = parentIndex.appendChild(el);
var nameEl = el.querySelector('.name');
nameEl.innerHTML = name;
}
var kWhEl = el.querySelector('.int-value');
var whEl = el.querySelector('.dec-value');
kWhEl.innerHTML = Math.floor(value / 1000);
whEl.innerHTML = leftPadStr(value % 1000, '0', 3);
el = document.getElementById('live-data-price-day-' + i);
if (el == null) {
el = templatePrice.cloneNode(true);
el.id = 'live-data-price-day-' + i;
el.style.display = "";
el = parentPrice.appendChild(el);
var nameEl = el.querySelector('.name');
nameEl.innerHTML = name;
}
var price = value * (indexPrices[i] / 1000);
dayPriceSum += price;
var euroEl = el.querySelector('.int-value');
var centsEl = el.querySelector('.dec-value');
var perkWh = el.querySelector('.live-data-price-per-kwh');
euroEl.innerHTML = Math.floor(price);
centsEl.innerHTML = leftPadStr(Math.floor(price * 10000) % 10000, '0', 4);
perkWh.innerHTML = indexPrices[i];
}
}
var dayPrice = subscriptionMonth / data.nbDayThisMonth;
var currDayPrice = dayPrice * ((data.date - data.getDayStartTime) / 86400000);
dayPriceSum += currDayPrice;
setHTML('live-data-price-sub-int', Math.floor(currDayPrice));
setHTML('live-data-price-sub-dec', leftPadStr(Math.floor(currDayPrice * 10000) % 10000, '0', 4));
setHTML('live-data-price-total-int', Math.floor(dayPriceSum));
setHTML('live-data-price-total-dec', leftPadStr(Math.floor(dayPriceSum * 10000) % 10000, '0', 4));
setHTML('live-data-price-sub-per-month', subscriptionMonth);
if (data.sensorsData != currentData.sensorsData) {
var parent = document.getElementById('live-data-sensors');
var template = document.getElementById('live-data-sensors-template');
for (var name in data.sensorsData) {
var temp = data.sensorsData[name].temp;
var hum = data.sensorsData[name].hum;
var el = document.getElementById('live-data-sensors-' + name);
if (el == null) {
el = template.cloneNode(true);
el.id = 'live-data-sensors-' + name;
el.style.display = "";
el = parent.appendChild(el);
var nameEl = el.querySelector('.name');
nameEl.innerHTML = name;
}
var intEl = el.querySelector('.int-value');
var decEl = el.querySelector('.dec-value');
var humEl = el.querySelector('.live-data-sensor-hum');
intEl.innerHTML = Math.floor(temp);
decEl.innerHTML = leftPadStr(Math.floor(temp * 10) % 10, '0', 1);
humEl.innerHTML = Math.floor(hum);
}
}
currentData = data;
}
function loginSuccess() {
document.getElementById("logged-in-content").style.display = "";
document.getElementById("login-form").style.display = "none";
setHTML("live-login-status", "");
}
function loginFail(message) {
document.getElementById("logged-in-content").style.display = "none";
document.getElementById("login-form").style.display = "";
setHTML("live-login-status", message);
}
function updateLiveData(afterLogin = false) {
var xhr = new XMLHttpRequest();
xhr.onload = function() {
if (xhr.readyState == 4) {
if (xhr.status == 403) {
loginFail(afterLogin ? "Mauvais identifiants" : "");
return;
}
loginSuccess();
if (xhr.status == 200) {
setHTML("live-data-status", "");
var jsonObj = JSON.parse(xhr.responseText);
onLiveDataReceived(jsonObj.data);
var delay = jsonObj.avgUpdateInterval - (jsonObj.now - jsonObj.lastUpdate) + 100;
if (delay < 200) // dont spam if data source is too late than usual
delay = 200;
setTimeout(function() { updateLiveData(false); }, delay);
}
else {
setHTML("live-data-status", "Erreur de connexion (backend offline)");
setTimeout(function() { updateLiveData(false); }, 5000);
}
}
}
xhr.ontimeout = function() {
setHTML("live-data-status", "Erreur de connexion (timeout)");
setTimeout(function() { updateLiveData(false); }, 5000);
}
xhr.onerror = function() {
setHTML("live-data-status", "Erreur de connexion");
setTimeout(function() { updateLiveData(false); }, 5000);
}
xhr.timeout = 5000;
xhr.open("GET", "/rest/currentData", true);
xhr.send();
}
document.getElementById("login_frame").onload = function() {
updateLiveData(true);
}
updateLiveData(false);

11
run.sh Normal file
View File

@ -0,0 +1,11 @@
#!/bin/sh
# The run.sh file must be the entry point of the docker container. It is not meant to be run by a user it a terminal.
# Please use update.sh instead
ls -la $TIC_TTY_INPUT
echo "Initializing serial port"
stty -F $TIC_TTY_INPUT 9600 raw cs7 parenb
echo "Starting TIC server"
java -cp res -jar /data/bin/tic.jar

View File

@ -0,0 +1,27 @@
package fr.mbaloup.home;
import java.io.IOException;
import org.rapidoid.log.Log;
import fr.mbaloup.home.tic.TICDataDispatcher;
import fr.mbaloup.home.tic.TICRawDecoder;
public class Main {
public static TICDataDispatcher tic;
public static void main(String[] args) throws IOException, InterruptedException {
Log.info("Initializing TIC raw decoder...");
TICRawDecoder decoder = new TICRawDecoder();
Log.info("Initializing TIC data dispatcher...");
tic = new TICDataDispatcher(decoder);
}
}

View File

@ -0,0 +1,118 @@
package fr.mbaloup.home.mqtt;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
import java.util.UUID;
import org.eclipse.paho.client.mqttv3.IMqttClient;
import org.eclipse.paho.client.mqttv3.MqttClient;
import org.eclipse.paho.client.mqttv3.MqttConnectOptions;
import org.eclipse.paho.client.mqttv3.MqttException;
import org.rapidoid.log.Log;
public class MQTTSender {
private static final String SERVER_URI = "tcp://" + System.getenv("TIC_MQTT_SERVER_IP");
private static final String HASS_DISCOVERY_PREFIX = System.getenv("TIC_MQTT_HASS_DISCOVERY_PREFIX");
private static final String USER = System.getenv("TIC_MQTT_USERNAME");
private static final String PASSWORD = System.getenv("TIC_MQTT_PASSWORD");
public static final Topic<Integer> INDEX_TOTAL = new Topic<>("tic/index/_total_", 1, true, "tic_index_total", "TIC Index Total", "Wh", "energy", "total_increasing");
public static final Topic<Integer> APPARENT_POWER = new Topic<>("tic/power/app", 0, true, "tic_power_app", "TIC Apparent power", "VA", "energy", "measurement");
public static final Topic<Integer> CUT_POWER = new Topic<>("tic/power/cut", 0, true, "tic_power_cut", "TIC Cut power", "VA", "energy", null);
private static final String clientId = UUID.randomUUID().toString();
private static IMqttClient publisher = null;
private static void connect() {
try {
publisher = new MqttClient(SERVER_URI, clientId);
MqttConnectOptions options = new MqttConnectOptions();
options.setAutomaticReconnect(true);
options.setCleanSession(true);
options.setConnectionTimeout(60);
options.setUserName(USER);
options.setPassword(PASSWORD.toCharArray());
publisher.connect(options);
} catch (MqttException e) {
Log.error("Cannot connect to MQTT broker.", e);
}
}
private static synchronized void publish(String topic, String value, int qos, boolean retained) {
if (publisher == null)
connect();
try {
publisher.publish(topic, value.getBytes(StandardCharsets.UTF_8), qos, retained);
} catch (MqttException e) {
Log.error("Cannot publish MQTT message.", e);
}
}
public static class Topic<T> {
private final String topic;
private final int qos;
private final boolean retained;
private final String hassSensorId;
private final String hassName;
private final String hassUnit;
private final String hassDeviceClass;
private final String hassStateClass;
private boolean hassConfigured = false;
protected Topic(String t, int q, boolean r, String id, String name, String unit, String dClass, String sClass) {
topic = t;
qos = q;
retained = r;
hassSensorId = id;
hassName = name;
hassUnit = unit;
hassDeviceClass = dClass;
hassStateClass = sClass;
}
public synchronized void publish(T value) {
if (!hassConfigured)
configure();
MQTTSender.publish(topic, valueToString(value), qos, retained);
}
private void configure() {
String hassJson = "{"
+ "\"name\":\"" + hassName + "\","
+ "\"unique_id\":\"" + hassSensorId + "\","
+ "\"state_topic\":\"" + topic + "\"";
if (hassUnit != null)
hassJson += ",\"unit_of_measurement\":\"" + hassUnit + "\"";
if (hassDeviceClass != null)
hassJson += ",\"device_class\":\"" + hassDeviceClass + "\"";
if (hassStateClass != null)
hassJson += ",\"state_class\":\"" + hassStateClass + "\"";
hassJson += "}";
MQTTSender.publish(HASS_DISCOVERY_PREFIX + "/sensor/" + hassSensorId + "/config", hassJson, 1, true);
hassConfigured = true;
}
protected String valueToString(T value) {
return Objects.toString(value);
}
}
}

View File

@ -0,0 +1,31 @@
package fr.mbaloup.home.tic;
import java.time.Instant;
import java.time.format.DateTimeFormatter;
import java.time.temporal.TemporalAccessor;
import java.util.Objects;
public class DataConverter {
// see https://www.enedis.fr/sites/default/files/Enedis-NOI-CPT_54E.pdf §6.2.1.1
public static long fromDateToMillis(String date) {
Objects.requireNonNull(date, "date cannot be null");
if (date.length() != 13)
throw new IllegalArgumentException("date must have exactly 13 characters");
int i = 0;
char season = date.charAt(i++);
String offsetStr = Character.toLowerCase(season) == 'e' ? "+02:00" : "+01:00";
String yearStr = date.substring(i, i += 2);
String monthStr = date.substring(i, i += 2);
String dayStr = date.substring(i, i += 2);
String hourStr = date.substring(i, i += 2);
String minStr = date.substring(i, i += 2);
String secStr = date.substring(i, i + 2);
TemporalAccessor ta = DateTimeFormatter.ISO_OFFSET_DATE_TIME.parse("20"+yearStr+"-"+monthStr+"-"+dayStr+"T"+hourStr+":"+minStr+":"+secStr+offsetStr);
return Instant.from(ta).toEpochMilli();
}
}

View File

@ -0,0 +1,25 @@
package fr.mbaloup.home.tic;
import java.util.Collections;
import java.util.Map;
public class DataFrame {
public final long time;
public final Map<String, DataSet> data;
public DataFrame(long t, Map<String, DataSet> ig) {
time = t;
data = Collections.unmodifiableMap(ig);
}
@Override
public String toString() {
return "{time=" + time + ",infoGroups=" + data + "}";
}
public void ifKeyPresent(String k, DataSetExecutor run) {
if (data.containsKey(k))
run.consume(k, data.get(k), time);
}
public interface DataSetExecutor {
void consume(String k, DataSet ds, long dfTime);
}
}

View File

@ -0,0 +1,9 @@
package fr.mbaloup.home.tic;
public class DataSet {
public final Long time;
public final String data;
public DataSet(Long t, String d) {
time = t; data = d;
}
}

View File

@ -0,0 +1,109 @@
package fr.mbaloup.home.tic;
import java.io.IOException;
import java.io.PipedInputStream;
import java.io.PipedOutputStream;
import java.util.Calendar;
import java.util.GregorianCalendar;
import java.util.Random;
public class SimulatedTICInputStream extends PipedInputStream {
PipedOutputStream out = new PipedOutputStream();
Thread th;
public SimulatedTICInputStream() throws IOException {
super(2048);
connect(out);
th = new Thread(this::run, "Simulated TIC Thread");
th.setDaemon(true);
th.start();
}
private boolean closed = false;
private int indexHC = 0;
private int indexHP = 0;
private long lastTime = System.currentTimeMillis();
private long wattSecondsCumul = 0; // 3600 Ws = 1 Wh
private final Random rand = new Random();
private int count = 0;
private void run() {
while (!closed) {
Calendar cal = new GregorianCalendar();
boolean hc = (cal.get(Calendar.HOUR_OF_DAY) < 8);
int power = cal.get(Calendar.MINUTE) % 2 == 0 ? 2500 : 200;
float r = rand.nextFloat();
power += r < 0.2 ? 10 : r < 0.4 ? -10 : 0;
long newT = cal.getTimeInMillis();
wattSecondsCumul += power * (newT - lastTime) / 1000;
if (wattSecondsCumul > 3600) {
int indexIncr = (int) (wattSecondsCumul / 3600);
wattSecondsCumul -= indexIncr * 3600L;
if (hc)
indexHC += indexIncr;
else
indexHP += indexIncr;
}
lastTime = newT;
write(TICRawDecoder.FRAME_START);
writeInfoGroup("ADCO", "DUMMY");
writeInfoGroup("OPTARIF", "HC..");
writeInfoGroup("ISOUSC", "30");
writeInfoGroup("HCHC", String.format("%08d", indexHC));
writeInfoGroup("HCHP", String.format("%08d", indexHP));
writeInfoGroup("PTEC", hc ? "HC.." : "HP..");
writeInfoGroup("IINST", String.format("%03d", Math.round(power / (float) 200)));
writeInfoGroup("IMAX", "090");
writeInfoGroup("PAPP", String.format("%05d", power));
writeInfoGroup("HHPHC", "A");
writeInfoGroup("MOTDETAT", String.format("%06d", (count++) % 1000000));
write(TICRawDecoder.FRAME_END);
}
}
private void writeInfoGroup(String key, String value) {
try {
Thread.sleep(8);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
write(TICRawDecoder.DATASET_START);
write(key);
write(TICRawDecoder.SEP_SP);
write(value);
write(TICRawDecoder.SEP_SP);
write(TICRawDecoder.checksum(key + TICRawDecoder.SEP_SP + value + TICRawDecoder.SEP_SP));
write(TICRawDecoder.DATASET_END);
}
private void write(String s) {
for (char c : s.toCharArray())
write(c);
}
private synchronized void write(char c) {
if (closed)
return;
try {
Thread.sleep(2);
out.write(c);
} catch (InterruptedException | IOException e) {
throw new RuntimeException(e);
}
}
@Override
public synchronized void close() throws IOException {
closed = true;
super.close();
}
}

View File

@ -0,0 +1,931 @@
package fr.mbaloup.home.tic;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedList;
import java.util.List;
import java.util.Objects;
import org.rapidoid.log.Log;
import fr.mbaloup.home.mqtt.MQTTSender;
import fr.mbaloup.home.tic.DataFrame.DataSetExecutor;
import fr.mbaloup.home.tic.TICDataDispatcher.DayScheduleTimeSlot.PhysicalRelayChange;
public class TICDataDispatcher extends Thread {
private final TICRawDecoder decoder;
public TICDataDispatcher(TICRawDecoder ticRaw) {
super("TIC Dispatcher Thread");
decoder = ticRaw;
start();
}
@Override
public void run() {
for (;;) {
try {
onDataUpdate(decoder.getNextDataFrame());
} catch (InterruptedException e) {
return;
} catch (Exception e) {
Log.error("Error while handling a data frame", e);
}
}
}
private synchronized void onDataUpdate(DataFrame df) {
if (df.data.containsKey("ADCO"))
updateHistorical(df);
else
updateStandard(df);
lastUpdates.addLast(System.currentTimeMillis());
while (lastUpdates.size() > 32)
lastUpdates.removeFirst();
if (lastUpdates.size() >= 2)
avgUpdateInterval = (lastUpdates.getLast() - lastUpdates.getFirst()) / (lastUpdates.size() - 1);
}
private void updateHistorical(DataFrame df) {
systTime = df.time;
df.ifKeyPresent("ADCO", (k, ds, t) -> {
updateSerialNumber(ds.data);
updateTICVersion(null);
});
df.ifKeyPresent("OPTARIF", (k, ds, t) -> updateSubscribedOption(k, ds.data));
df.ifKeyPresent("ISOUSC", (k, ds, t) -> updateHistoricalSubscribedIntensity(ds.data));
DataSetExecutor indexExecutor = (k, ds, t) -> updateIndexHist(k, ds.data);
df.ifKeyPresent("BASE", indexExecutor);
df.ifKeyPresent("HCHC", indexExecutor);
df.ifKeyPresent("HCHP", indexExecutor);
df.ifKeyPresent("EJPHN", indexExecutor);
df.ifKeyPresent("EJPHPM", indexExecutor);
df.ifKeyPresent("BBRHCJB", indexExecutor);
df.ifKeyPresent("BBRHPJB", indexExecutor);
df.ifKeyPresent("BBRHCJW", indexExecutor);
df.ifKeyPresent("BBRHPJW", indexExecutor);
df.ifKeyPresent("BBRHCJR", indexExecutor);
df.ifKeyPresent("BBRHPJR", indexExecutor);
histMobilePointNotice = df.data.containsKey("PEJP");
df.ifKeyPresent("PTEC", (k, ds, t) -> updateCurrIndexHist(ds.data));
df.ifKeyPresent("DEMAIN", (k, ds, t) -> updateDEMAIN(ds.data));
df.ifKeyPresent("IINST", (k, ds, t) -> updateRMSCurrent(ds.data));
overPowerConsumption = df.data.containsKey("ADPS");
df.ifKeyPresent("IMAX", (k, ds, t) -> {
/* unclear what represent this data, except "Intensité maximale appelée".
* On Linky electric meter with historical TIC mode,
* the received value is always 90 A.
*/
});
df.ifKeyPresent("PAPP", (k, ds, t) -> updateApparentPower(ds.data));
df.ifKeyPresent("HHPHC", (k, ds, t) -> updateHHPHC(ds.data));
df.ifKeyPresent("MOTDETAT", (k, ds, t) -> updateMOTDETAT(ds.data));
}
private void updateStandard(DataFrame df) {
df.ifKeyPresent("DATE", (k, ds, t) -> updateTICTimeStd(t, ds.time));
df.ifKeyPresent("ADSC", (k, ds, t) -> updateSerialNumber(ds.data));
df.ifKeyPresent("VTIC", (k, ds, t) -> updateTICVersion(ds.data));
df.ifKeyPresent("NGTF", (k, ds, t) -> updateSubscribedOption(k, ds.data));
df.ifKeyPresent("NTARF", (k, ds, t) -> {
// NTARF must be updated before LTARF
updateCurrentIndexId(ds.data);
df.ifKeyPresent("LTARF", (k2, ds2, t2) -> updateNameOfCurrentIndex(ds2.data));
});
df.ifKeyPresent("EAST", (k, ds, t) -> updateTotalIndexStd(ds.data));
DataSetExecutor indexExecutor = (k, ds, t) -> updateIndexStd(k.substring(4), ds.data);
df.ifKeyPresent("EASF01", indexExecutor);
df.ifKeyPresent("EASF02", indexExecutor);
df.ifKeyPresent("EASF03", indexExecutor);
df.ifKeyPresent("EASF04", indexExecutor);
df.ifKeyPresent("EASF05", indexExecutor);
df.ifKeyPresent("EASF06", indexExecutor);
df.ifKeyPresent("EASF07", indexExecutor);
df.ifKeyPresent("EASF08", indexExecutor);
df.ifKeyPresent("EASF09", indexExecutor);
df.ifKeyPresent("EASF10", indexExecutor);
DataSetExecutor distIndexExecutor = (k, ds, t) -> updateDistributorIndex(k.substring(4), ds.data);
df.ifKeyPresent("EASD01", distIndexExecutor);
df.ifKeyPresent("EASD02", distIndexExecutor);
df.ifKeyPresent("EASD03", distIndexExecutor);
df.ifKeyPresent("EASD04", distIndexExecutor);
df.ifKeyPresent("IRMS1", (k, ds, t) -> updateRMSCurrent(ds.data));
df.ifKeyPresent("URMS1", (k, ds, t) -> updateRMSVoltage(ds.data));
df.ifKeyPresent("PREF", (k, ds, t) -> updateRefPower(ds.data));
df.ifKeyPresent("PCOUP", (k, ds, t) -> updateCutPower(ds.data));
df.ifKeyPresent("SINSTS", (k, ds, t) -> updateApparentPower(ds.data));
df.ifKeyPresent("SMAXSN", (k, ds, t) -> updateMaxPowerToday(ds.time, ds.data));
df.ifKeyPresent("SMAXSN-1", (k, ds, t) -> updateMaxPowerYesterday(ds.time, ds.data));
df.ifKeyPresent("UMOY1", (k, ds, t) -> updateAvgVoltage(ds.time, ds.data));
df.ifKeyPresent("PRM", (k, ds, t) -> updatePRM(ds.data));
df.ifKeyPresent("NJOURF", (k, ds, t) -> updateProviderCalDayToday(ds.data));
df.ifKeyPresent("NJOURF+1", (k, ds, t) -> updateProviderCalDayTomorrow(ds.data));
df.ifKeyPresent("MSG1", (k, ds, t) -> updateMessage1(ds.data));
updateMessage2(df.data.containsKey("MSG2") ? df.data.get("MSG2").data : null);
df.ifKeyPresent("STGE", (k, ds, t) -> updateStatusRegister(ds.data)); // 4 last bits ignored for the moment
df.ifKeyPresent("RELAIS", (k, ds, t) -> updateRelaysStatus(ds.data));
df.ifKeyPresent("PJOURF+1", (k, ds, t) -> updateNextDayScheduling(ds.data));
}
private final LinkedList<Long> lastUpdates = new LinkedList<>();
private long avgUpdateInterval = -1;
/**
* The average time interval between each consecutive data frame received from the TIC.
* The average is measured using the 32 last data framesm
* @return A time interval, in ms.
*/
public synchronized long getAverageUpdateInterval() {
return avgUpdateInterval;
}
/**
* The time of when the last dataframe was fully received by the system, according to the system clock.
* <p>
* To have the time of the dataframe according to the electric meter, use {@link #getTICTime()}.
* To hae the time of when the first byte of the dataframe was received, use {@link #getFrameSystemTime()}.
* @return the millisecond epoch time of when the last dataframe was fully received by the system.
*/
public synchronized long getLastUpdateTime() {
return lastUpdates.isEmpty() ? -1 : lastUpdates.getLast();
}
private Long systTime = null;
private Long ticTime = null;
private Long rawTICTime = null;
private void updateTICTimeStd(long newSystTime, long newTICTime) {
rawTICTime = newTICTime;
/* Two consecutive data frame may have the same DATE value if the interval between 2 dataframe is below 1 second.
* In this case, this is necessary to determine a time closer to the real time (in the counter) to avoid bad
* interpretation of the data in subsequent processing (storage, display, ...).
*/
long ticTimeMin = newTICTime;
long ticTimeMax = newTICTime+999;
if (systTime == null) { // no previous data
if (newSystTime > ticTimeMin && newSystTime <= ticTimeMax) {
newTICTime = newSystTime;
}
else if (newSystTime > ticTimeMax) {
newTICTime = ticTimeMax;
}
}
else {
long interval = newSystTime - systTime;
long estimatedNewPreciseTICTime = ticTime + interval;
if (estimatedNewPreciseTICTime > ticTimeMin && estimatedNewPreciseTICTime <= ticTimeMax) {
newTICTime = estimatedNewPreciseTICTime;
}
else if (estimatedNewPreciseTICTime > ticTimeMax) {
newTICTime = ticTimeMax;
}
}
systTime = newSystTime;
ticTime = newTICTime;
}
/**
* The time of when the first byte of the last dataframe was received, according to the system clock.
* <p>
* To have the time of the dataframe according to the electric meter, use {@link #getTICTime()}.
* @return the millisecond epoch time of when the first byte of the last dataframe was received.
*/
public synchronized Long getFrameSystemTime() { return systTime; }
/**
* The time of the dataframe, according to the electric meter.
* <p>
* The time is received from the meter with a precision of a second. The value with millisecond precision
<