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
* is estimated using the time interval between the current and the last dataframe, and using
* the system clock.
* If you want to have the time, as declared by the TIC dataframe, use {@link #getRawTICTime()}.
* <p>
* The TIC time may have shifted from the real time. In this case, {@link #isInternalClockBad()} will return true.
* <p>
* To have the time of the dataframe according to the system clock, use {@link #getFrameSystemTime()}.
* <p>
* <ul>
* <li>Historical mode: not provided.</li>
* <li>Standard mode: DATE field.</li>
* </ul>
* @return the millisecond epoch time of the last dataset according to the electric meter.
*/
public synchronized Long getTICTime() { return ticTime; }
/**
* The time of the dataframe, according to the electric meter.
* <p>
* The time is received from the meter with a precision of a second.
* If you want to have the time with millisecond precision, estimated using the system time, use {@link #getTICTime()}.
* <p>
* The TIC time may have shifted from the real time. In this case, {@link #isInternalClockBad()} will return true.
* <p>
* To have the time of the dataframe according to the system clock, use {@link #getFrameSystemTime()}.
* <p>
* <ul>
* <li>Historical mode: not provided.</li>
* <li>Standard mode: DATE field.</li>
* </ul>
* @return the millisecond epoch time of the last dataset according to the electric meter.
*/
public synchronized Long getRawTICTime() { return rawTICTime; }
private String TICVersion = null;
private void updateTICVersion(String v) { TICVersion = v; }
/**
* Specification version of the TIC protocol.
* <p>
* <ul>
* <li>Historical mode: not provided.</li>
* <li>Standard mode: VTIC field.</li>
* </ul>
* This current data dispatcher supports the protocol {@code 02}.
*
* @return the specification version of the TIC protocol, or {@code null} if the TIC is in historical mode.
*/
public synchronized String getTICVersion() { return TICVersion; }
private String serialNumber = null;
private void updateSerialNumber(String a) { serialNumber = a; }
/**
* Serial number of the electric meter, as written physically on the front cover (except the 2 last digits).
* <p>
* <ul>
* <li>Historical mode: ADCO field.</li>
* <li>Standard mode: ADCS field.</li>
* </ul>
* @return the serial number of the electric meter.
*/
public synchronized String getSerialNumber() { return serialNumber; }
public synchronized String getSerialNumberManufacturerCode() {
return serialNumber == null ? null : serialNumber.substring(0, 2);
}
public synchronized Integer getSerialNumberManufactureYear() {
return serialNumber == null ? null : (Integer.parseInt(serialNumber.substring(2, 4)) + 2000);
}
public synchronized String getSerialNumberMeterType() {
return serialNumber == null ? null : serialNumber.substring(4, 6);
}
public synchronized String getSerialNumberUniquePart() {
return serialNumber == null ? null : serialNumber.substring(6);
}
private String prm = null;
private void updatePRM(String a) { prm = a; }
/**
* Serial number of the electric meter, as written physically on the front cover (except the 2 last digits).
* <p>
* <ul>
* <li>Historical mode: not provided.</li>
* <li>Standard mode: PRM field.</li>
* </ul>
* @return the serial number of the electric meter.
*/
public synchronized String getPRM() { return prm; }
private String subscribedOption = null;
private void updateSubscribedOption(String k, String o) {
if (k.equals("OPTARIF") && o.equals("HC.."))
subscribedOption = "H PLEINE/CREUSE";
else if (k.equals("OPTARIF") && o.equals("EJP."))
subscribedOption = "EJP";
else
subscribedOption = o.trim();
}
/**
* The name or the id of the currently active subscription option.
* <p>
* Some example of price schedule option are constant price, peak/off-peak time prices, ...
* <p>
* <ul>
* <li>Historical mode: OPTARIF field, with standardized values: {@code BASE}, {@code HC} or {@code EJP}.</li>
* <li>Standard mode: NGTF field, the content being a non-standardized display name.</li>
* </ul>
* @return the price schedule option subscribed by the consumer.
*/
public synchronized String getSubscribedOption() { return subscribedOption; }
private Integer subscribedIntensity = null;
private void updateHistoricalSubscribedIntensity(String i) {
subscribedIntensity = Integer.parseInt(i);
Integer oldCutPower = cutPower;
referencePower = cutPower = subscribedIntensity * 200;
if (!Objects.equals(cutPower, oldCutPower))
MQTTSender.CUT_POWER.publish(cutPower);
}
/**
* The subscribed intensity.
* <p>
* <ul>
* <li>Historical mode: ISOUSC field.</li>
* <li>Standard mode: not provided directly, but we use {@code getReferencePower()/ 200} to set this value.</li>
* </ul>
* @return the subscribed intensity, in Ampere.
* @deprecated the consumption limit is provided in the contract in term of apparent power (kVA), not intensity (A).
* Also, the transmitted intensity (in both historical and standard TIC mode) is not accurate since
* it is based on the assumption that the voltage is always 200 V.
*/
@Deprecated
public synchronized Integer getSubscribedIntensity() { return subscribedIntensity; }
private Integer referencePower = null;
private void updateRefPower(String pRef) {
referencePower = Integer.parseInt(pRef) * 1000;
subscribedIntensity = referencePower / 200;
}
/**
* The subscribed maximum apparent power.
* <p>
* <ul>
* <li>Historical mode: not provided directly, but we use {@link #getSubscribedIntensity()} {@code * 200} to set this value.</li>
* <li>Standard mode: PREF field in kVA.</li>
* </ul>
* @return the reference (subscribed) apparent power, in Volt Ampere (VA).
*/
public synchronized Integer getReferencePower() { return referencePower; }
private Integer cutPower = null;
private void updateCutPower(String p) {
Integer oldCutPower = cutPower;
cutPower = Integer.parseInt(p) * 1000;
if (!Objects.equals(cutPower, oldCutPower))
MQTTSender.CUT_POWER.publish(cutPower);
}
/**
* The maximum apparent power allowed before cutting the current.
* <p>
* May be different than the reference power.
* <p>
* <ul>
* <li>Historical mode: not provided directly, but we use {@link #getSubscribedIntensity()} {@code * 200} to set this value.</li>
* <li>Standard mode: PCOUP field in kVA.</li>
* </ul>
* @return the maximum allowed apparent power, in Volt Ampere (VA).
*/
public synchronized Integer getCutPower() { return cutPower; }
private Integer apparentPower = null;
private void updateApparentPower(String i) {
Integer oldApparentPower = apparentPower;
apparentPower = Integer.parseInt(i);
if (!Objects.equals(apparentPower, oldApparentPower))
MQTTSender.APPARENT_POWER.publish(apparentPower);
}
/**
* The current apparent power.
* <p>
* <ul>
* <li>Historical mode: PAPP field, with a precision of 10 VA.</li>
* <li>Standard mode: SINSTS field, with a precision of 1 VA.</li>
* </ul>
* @return the current apparent power, in Volt Ampere (VA).
*/
public synchronized Integer getApparentPower() { return apparentPower; }
private Integer maxPowerToday = null;
private Long maxPowerTimeToday = null;
private void updateMaxPowerToday(long t, String i) {
maxPowerToday = Integer.parseInt(i);
maxPowerTimeToday = t;
}
/**
* The max apparent power of today.
* <p>
* <ul>
* <li>Historical mode: not provided.</li>
* <li>Standard mode: SMAXSN field.</li>
* </ul>
* @return the max apparent power of today, in Volt Ampere (VA).
*/
public synchronized Integer getMaxPowerToday() { return maxPowerToday; }
/**
* The time when the max apparent power of today was registered.
* <p>
* <ul>
* <li>Historical mode: not provided.</li>
* <li>Standard mode: SMAXSN datetime field.</li>
* </ul>
* @return the millisecond epoch time of when the max apparent power of today was registered.
*/
public synchronized Long getMaxPowerTimeToday() { return maxPowerTimeToday; }
private Integer maxPowerYesterday = null;
private Long maxPowerTimeYesterday = null;
private void updateMaxPowerYesterday(long t, String i) {
maxPowerYesterday = Integer.parseInt(i);
maxPowerTimeYesterday = t;
}
/**
* The max apparent power of yesterday.
* <p>
* <ul>
* <li>Historical mode: not provided.</li>
* <li>Standard mode: SMAXSN-1 field.</li>
* </ul>
* @return the max apparent power of yesterday, in Volt Ampere (VA).
*/
public synchronized Integer getMaxPowerYesterday() { return maxPowerYesterday; }
/**
* The time when the max apparent power of today was registered.
* <p>
* <ul>
* <li>Historical mode: not provided.</li>
* <li>Standard mode: SMAXSN-1 datetime field.</li>
* </ul>
* @return the millisecond epoch time of when the max apparent power of today was registered.
*/
public synchronized Long getMaxPowerTimeYesterday() { return maxPowerTimeYesterday; }
private int totalIndex = 0;
private final int[] indexes = new int[10];
private final String[] indexNames = new String[10];
private int currentIndex = 0;
private void updateIndexHist(String k, String v) {
int i = getIndexIdFromHistId(k);
indexes[i] = Integer.parseInt(v);
indexNames[i] = k;
int oldTotalIndex = totalIndex;
totalIndex = 0;
for (int idx : indexes)
totalIndex += idx;
if (totalIndex != oldTotalIndex)
MQTTSender.INDEX_TOTAL.publish(totalIndex);
}
private void updateCurrIndexHist(String i) {
currentIndex = getIndexIdFromHistId(i);
}
private static int getIndexIdFromHistId(String i) {
switch (i) {
case "HCHP": // index name
case "EJPHPM": // index name
case "BBRHPJB": // index name
case "HP..": // PTEC value
case "PM..": // PTEC value
case "HPJB": // PTEC value
return 1;
case "BBRHCJW": // index name
case "HCJW": // PTEC value
return 2;
case "BBRHPJW": // index name
case "HPJW": // PTEC value
return 3;
case "BBRHCJR": // index name
case "HCJR": // PTEC value
return 4;
case "BBRHPJR": // index name
case "HPJR": // PTEC value
return 5;
}
return 0; // BASE, HCHC, EJPHN, BBRHCJB ; TH.., HC.., HN.., HCJB
}
private void updateIndexStd(String k, String v) { indexes[Integer.parseInt(k)-1] = Integer.parseInt(v); }
private void updateTotalIndexStd(String v) {
int oldTotalIndex = totalIndex;
totalIndex = Integer.parseInt(v);
if (totalIndex != oldTotalIndex)
MQTTSender.INDEX_TOTAL.publish(totalIndex);
}
private void updateCurrentIndexId(String v) { currentIndex = Integer.parseInt(v) - 1; }
private void updateNameOfCurrentIndex(String v) { indexNames[currentIndex] = v; }
/**
* List of the 10 indexes managed by the electric meter.
* <p>
* <ul>
* <li>Historical mode: fields with various names. All the indexes are not transmitted, so others indexes are considered 0.</li>
* <li>Standard mode: EASFxx fields.</li>
* </ul>
* @return the full list of indexes, all measured in Watt hour (Wh)
*/
public synchronized int[] getIndexes() { return Arrays.copyOf(indexes, indexes.length); }
/**
* List of the names for each indexes.
* <p>
* <ul>
* <li>Historical mode: the name of an index is based on the name of the TIC field that provide the index.</li>
* <li>Standard mode: the name provided by the field LTARF is applied to the current running index specified by NTARF.</li>
* </ul>
* @return the full list of name for each indexes. Unknown names are null.
*/
public synchronized String[] getIndexNames() { return Arrays.copyOf(indexNames, indexNames.length); }
public synchronized int getIndex(int i) { return indexes[i]; }
public synchronized String getIndexName(int i) { return indexNames[i]; }
public synchronized int getIndexCount() { return indexes.length; }
public synchronized int getTotalIndex() { return totalIndex; }
public synchronized int getCurrentIndex() { return currentIndex; }
private final int[] distributorIndexes = new int[4];
private int currentDistributorIndex = 0;
private void updateDistributorIndex(String k, String v) { distributorIndexes[Integer.parseInt(k)-1] = Integer.parseInt(v); }
/**
* List of the 4 distributor indexes managed by the electric meter.
* <p>
* These indexes are not related to the pricing applied for the customer.
* <p>
* <ul>
* <li>Historical mode: not provided.</li>
* <li>Standard mode: EASDxx fields.</li>
* </ul>
* @return the full list of distributor indexes, all measured in Watt hour (Wh). In historical mode, all values are 0.
*/
public synchronized int[] getDistributorIndexes() { return Arrays.copyOf(distributorIndexes, distributorIndexes.length); }
public synchronized int getDistributorIndex(int i) { return distributorIndexes[i]; }
public synchronized int getDistributorIndexCount() { return distributorIndexes.length; }
public synchronized int getCurrentDistributorIndex() { return currentDistributorIndex; }
private Integer rmsCurrent = null;
private void updateRMSCurrent(String i) { rmsCurrent = Integer.parseInt(i); }
public synchronized Integer getRMSCurrent() { return rmsCurrent; }
private Integer rmsVoltage = null;
private void updateRMSVoltage(String i) { rmsVoltage = Integer.parseInt(i); }
public synchronized Integer getRMSVoltage() { return rmsVoltage; }
private Integer avgVoltage = null;
private Long avgVoltageTime = null;
private void updateAvgVoltage(long t, String i) {
avgVoltage = Integer.parseInt(i);
avgVoltageTime = t;
}
public synchronized Integer getAvgVoltage() { return avgVoltage; }
public synchronized Long getAvgVoltageLastUpdate() { return avgVoltageTime; }
private Integer providerCalendarDayToday = null;
private Integer providerCalendarDayTomorrow = null;
private void updateProviderCalDayToday(String d) { providerCalendarDayToday = Integer.parseInt(d); }
private void updateProviderCalDayTomorrow(String d) { providerCalendarDayTomorrow = Integer.parseInt(d); }
public synchronized Integer getProviderCalendarDayToday() { return providerCalendarDayToday; }
public synchronized Integer getProviderCalendarDayTomorrow() { return providerCalendarDayTomorrow; }
private String message1 = null, message2 = null;
private void updateMessage1(String m) { message1 = m.trim(); }
private void updateMessage2(String m) { message2 = m == null ? null : m.trim(); }
public synchronized String getMessage1() { return message1; }
public synchronized String getMessage2() { return message2; }
private boolean[] relaysClosed = null;
private void updateRelaysStatus(String r) {
int reg = Integer.parseInt(r);
relaysClosed = new boolean[8];
for (int i = 0; i < 8; i++) {
relaysClosed[i] = (reg & 1) == 1;
reg >>= 1;
}
}
/**
* The status of the relays.
* <p>
* The relay id is 0 based (first relay is 0)
* <p>
* <ul>
* <li>Historical mode: not provided.</li>
* <li>Standard mode: RELAIS field.</li>
* </ul>
* @param r the 0-based relay id (from 0 to 7). The relay 0 is the physical relay in the electric meter.
* @return true if the specified relay is closed (the current goes through), false if the TIC says otherwise, or null if TIC does not provide the information.
*/
public synchronized Boolean isRelayClosed(int r) {
if (relaysClosed == null || r < 0 || r >= relaysClosed.length)
return null;
return relaysClosed[r];
}
/**
* The status of the relays.
* <p>
* <ul>
* <li>Historical mode: not provided.</li>
* <li>Standard mode: RELAIS field.</li>
* </ul>
* @return the status of all the relays. The relay 0 is the physical one. For each relay, true means closed (the current goes through), and false means opened.
*/
public synchronized boolean[] getRelaysStatus() {
return relaysClosed;
}
private CutRelayStatus cutRelayStatus = null;
private Boolean providerCoverOpened = null;
private Boolean overVoltage = null;
private Boolean overPowerConsumption = null;
private Boolean producerMode = null;
private Boolean activeEnergyNegative = null;
private Boolean internalClockBad = null;
private EuridisOutputStatus euridisOutputStatus = null;
private CPLStatus cplStatus = null;
private Boolean cplSynchronized = null;
private void updateStatusRegister(String r) {
int reg = Integer.parseInt(r, 16);
int isOffPeakRelayOpened = reg & 0x1; reg >>= 1;
int cutRelayStatusRaw = reg & 0x7; reg >>= 3;
int isProviderCoverOpened = reg & 0x1; reg >>= 1;
/* unused bit */ reg >>= 1;
int isOverVoltage = reg & 0x1; reg >>= 1;
int isOverPowerConsumption = reg & 0x1; reg >>= 1;
int isProducerMode = reg & 0x1; reg >>= 1;
int isActiveEnergyNegative = reg & 0x1; reg >>= 1;
int currentActiveIndex = reg & 0xF; reg >>= 4;
int currentDistIndexRaw = reg & 0x3; reg >>= 2;
int isInternalClockBad = reg & 0x1; reg >>= 1;
int isTICModeStandard = reg & 0x1; reg >>= 1;
/* unused bit */ reg >>= 1;
int euridisOutputStatusRaw = reg & 0x3; reg >>= 2;
int cplStatusRaw = reg & 0x3; reg >>= 2;
int isCPLSynchronized = reg & 0x1; reg >>= 1;
int tempoCurrentDayColorRaw= reg & 0x3; reg >>= 2;
int tempoNextDayColorRaw = reg & 0x3; reg >>= 2;
//int mobilePointNotice = reg & 0x3; reg >>= 2;
//int currentMobilePoint = reg & 0x3; reg >>= 2;
if (isRelayClosed(0) != null && ((isOffPeakRelayOpened == 0) != isRelayClosed(0))) {
Log.warn("Inconsistent status of relay 0 between RELAIS data set and value in STGE data set."
+ " RELAIS says relay 0 is " + (isRelayClosed(0) ? "closed" : "opened") + " and"
+ " STGE s bit 0 says off peak relay is " + (isOffPeakRelayOpened == 0 ? "closed" : "opened") + ".");
}
cutRelayStatus = cutRelayStatusRaw < CutRelayStatus.values().length ? CutRelayStatus.values()[cutRelayStatusRaw] : null;
providerCoverOpened = isProviderCoverOpened == 1;
overVoltage = isOverVoltage == 1;
overPowerConsumption = isOverPowerConsumption == 1;
producerMode = isProducerMode == 1;
activeEnergyNegative = isActiveEnergyNegative == 1;
if (currentActiveIndex != currentIndex) {
Log.warn("Inconsistent current index id between NTARF data set value and current active index value in register in STGE data set");
}
currentDistributorIndex = currentDistIndexRaw;
internalClockBad = isInternalClockBad == 1;
if (isTICModeStandard != 1) {
Log.warn("Register in STGE data set declared TIC mode as historical but we are processing the data frame as standard mode. (STGE is only send in standard mode, btw)");
}
euridisOutputStatus = euridisOutputStatusRaw < EuridisOutputStatus.values().length ? EuridisOutputStatus.values()[euridisOutputStatusRaw] : null;
cplStatus = cplStatusRaw < CPLStatus.values().length ? CPLStatus.values()[cplStatusRaw] : null;
cplSynchronized = isCPLSynchronized == 1;
tempoCurrentDayColor = tempoCurrentDayColorRaw < TempoDayColor.values().length ? TempoDayColor.values()[tempoCurrentDayColorRaw] : null;
tempoNextDayColor = tempoNextDayColorRaw < TempoDayColor.values().length ? TempoDayColor.values()[tempoNextDayColorRaw] : null;
}
public enum CutRelayStatus {
CLOSED, OPENED_OVERPOWER, OPENED_OVERVOLTAGE, OPENED_REMOTE_CONTROL,
OPENED_OVERHEAT_WITH_OVERCURRENT, OPENED_OVERHEAT_NO_OVERCURRENT
}
public enum EuridisOutputStatus {
DISABLED, ENABLED_UNSECURED, UNDEFINED, ENABLED_SECURED
}
public enum CPLStatus { NEW_UNLOCK, NEW_LOCK, REGISTERED }
public synchronized CutRelayStatus getCutRelayStatus() { return cutRelayStatus; }
public synchronized Boolean isProviderCoverOpened() { return providerCoverOpened; }
public synchronized Boolean isOverVoltage() { return overVoltage; }
public synchronized Boolean isOverPowerConsumption() { return overPowerConsumption; }
public synchronized Boolean isProducerMode() { return producerMode; }
public synchronized Boolean isActiveEnergyNegative() { return activeEnergyNegative; }
public synchronized Boolean isInternalClockBad() { return internalClockBad; }
public synchronized EuridisOutputStatus getEuridisOutputStatus() { return euridisOutputStatus; }
public synchronized CPLStatus getCPLStatus() { return cplStatus; }
public synchronized Boolean isCPLSynchronized() { return cplSynchronized; }
private TempoDayColor tempoCurrentDayColor = null;
private TempoDayColor tempoNextDayColor = null;
private void updateDEMAIN(String o) {
if (o.equals("----"))
tempoNextDayColor = TempoDayColor.UNKNOWN;
else if (o.equals("BLEU"))
tempoNextDayColor = TempoDayColor.BLUE;
else if (o.equals("BLAN"))
tempoNextDayColor = TempoDayColor.WHITE;
else if (o.equals("ROUG"))
tempoNextDayColor = TempoDayColor.RED;
else
tempoNextDayColor = null;
}
public enum TempoDayColor { UNKNOWN, BLUE, WHITE, RED }
public synchronized TempoDayColor getTempoCurrentDayColor() { return tempoCurrentDayColor; }
public synchronized TempoDayColor getTempoNextDayColor() { return tempoNextDayColor; }
private List<DayScheduleTimeSlot> nextDayScheduling = null;
private void updateNextDayScheduling(String d) {
String[] rawSlots = d.split(" ");
nextDayScheduling = new ArrayList<>();
for (String rawSlot : rawSlots) {
if (rawSlot.length() != 8 || rawSlot.equals("NONUTILE"))
continue;
int hour = Integer.parseInt(rawSlot.substring(0, 2));
int minute = Integer.parseInt(rawSlot.substring(2, 4));
int reg = Integer.parseInt(rawSlot.substring(4), 16);
int indexChangeRaw = reg & 0xF; reg >>= 4;
boolean[] virtRelays = new boolean[7];
for (int i = 0; i < 7; i++) {
virtRelays[i] = (reg & 0x1) == 1;
reg >>= 1;
}
/* unused bits */ reg >>= 3;
int physRelayChange = reg & 0x3; reg >>= 2;
nextDayScheduling.add(new DayScheduleTimeSlot(
minute * 60_000L + hour * 3_600_000L,
PhysicalRelayChange.values()[physRelayChange],
indexChangeRaw >= 1 && indexChangeRaw <= 10 ? (indexChangeRaw - 1) : null,
virtRelays));
}
}
public static class DayScheduleTimeSlot {
public final long millisDayStart;
public final PhysicalRelayChange physicalRelayChange;
public final Integer changeToIndex;
public final boolean[] virtualRelaysStatus;
public DayScheduleTimeSlot(long start, PhysicalRelayChange physRelay, Integer indexChange, boolean[] virtRelays) {
millisDayStart = start;
physicalRelayChange = physRelay;
changeToIndex = indexChange;
virtualRelaysStatus = virtRelays;
}
public enum PhysicalRelayChange { NO_CHANGE, TEMPO_OR_NO_CHANGE, OPEN, CLOSE }
}
public synchronized List<DayScheduleTimeSlot> getNextDayScheduling() { return nextDayScheduling; }
private String MOTDETAT = null;
private void updateMOTDETAT(String o) { MOTDETAT = o; }
/**
* The value of MOTDETAT from the historical TIC data frame.
* <p>
* The interpretation of the data is not documented.
* <p>
* <ul>
* <li>Historical mode: MOTDETAT field.</li>
* <li>Standard mode: not provided.</li>
* </ul>
* @return the raw value of MOTDETAT, or null if absent.
* @deprecated this value is only supported on historical TIC mode, and its interpretation is not documented.
*/
@Deprecated
public synchronized String getMOTDETAT() { return MOTDETAT; }
private String HHPHC = null;
private void updateHHPHC(String o) { HHPHC = o; }
/**
* The value of HHPHC from the historical TIC data frame, indicating the time in the day for the .
* <p>
* The interpretation of the data is not documented.
* <p>
* <ul>
* <li>Historical mode: HHPHC field.</li>
* <li>Standard mode: not provided.</li>
* </ul>
* @return the raw value of HHPHC, or null if absent.
* @deprecated this value is only supported on historical TIC mode, and its interpretation is not documented.
*/
@Deprecated
public synchronized String getHHPHC() { return HHPHC; }
private boolean histMobilePointNotice = false;
/**
* The notice for the upcoming mobile point (EJP subscription).
* <p>
* <ul>
* <li>Historical mode: presence of PEJP field, during 30 minutes before the start of the mobile point.</li>
* <li>Standard mode: not provided.</li>
* </ul>
* @return true if the TIC is in historical mode and a mobile point will start in the next 30 minutes, false otherwise.
* @deprecated this value is only supported on historical TIC mode.
*/
@Deprecated
public synchronized boolean getHistMobilePointNotice() { return histMobilePointNotice; }
}

View File

@ -0,0 +1,296 @@
package fr.mbaloup.home.tic;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
import org.rapidoid.log.Log;
public class TICRawDecoder extends Thread {
static final char FRAME_START = 0x02;
static final char FRAME_END = 0x03;
static final char TRANSMISSION_END = 0x04;
static final char DATASET_START = 0x0A;
static final char DATASET_END = 0x0D;
static final char SEP_SP = 0x20;
static final char SEP_HT = 0x09;
static final String INPUT_PATH = System.getenv("TIC_TTY_INPUT");
static final int CORRUPTION_MAX_LEVEL = 10; // max number of corruption detected while reading raw input
static final long CORRUPTION_LEVEL_DECAY_INTERVAL = 5000; // interval in ms between each corruption level decrement
private InputStream input;
private int corruptedDataCount = 0;
private long lastCorruptedData = System.currentTimeMillis();
private final BlockingQueue<DataFrame> outQueue = new LinkedBlockingQueue<>(10000);
public TICRawDecoder() throws IOException {
super("TIC Input Thread");
configureInput();
start();
}
private void configureInput() throws IOException {
input = new FileInputStream(INPUT_PATH);
//input = new SimulatedTICInputStream();
}
private void signalCorruptedInput(String desc) {
corruptedDataCount++;
lastCorruptedData = System.currentTimeMillis();
Log.warn("Raw input corruption detected (" + corruptedDataCount + "): " + desc);
}
private boolean checkCorruptedInputStatus() throws IOException {
if (corruptedDataCount > 0) {
if (lastCorruptedData < System.currentTimeMillis() - 5000) {
corruptedDataCount--;
lastCorruptedData = System.currentTimeMillis();
}
}
if (corruptedDataCount > 10) {
Log.warn("Raw input corruption is too high, reopening the input stream... ");
try {
input.close();
try { Thread.sleep(2000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
configureInput();
}
finally {
corruptedDataCount = 0;
}
return true;
}
return false;
}
@Override
public void run() {
try {
Map<String, DataSet> frameData = null;
long frameTime = -1;
StringBuilder dsBuffer = new StringBuilder(32);
boolean processingDS = false;
Log.info("Decoder is now running.");
for(;;) {
if (checkCorruptedInputStatus()) {
frameData = null;
dsBuffer.setLength(0);
processingDS = false;
}
char c = (char) input.read();
// handle new frame
if (c == FRAME_START) {
frameData = new LinkedHashMap<>();
frameTime = System.currentTimeMillis();
continue;
}
// skip all data encountered before our first FRAME_START
if (frameData == null)
continue;
// handle frame end
if (c == FRAME_END || c == TRANSMISSION_END) {
if (!frameData.isEmpty()) {
outQueue.add(new DataFrame(frameTime, frameData));
}
else {
signalCorruptedInput("FRAME_END or TRANSMISSION_END encountered with not data frame registered.");
}
frameData = null;
continue;
}
// infogroup start
if (c == DATASET_START) {
if (processingDS) // should not happened
signalCorruptedInput("DATASET_START encountered while already decoding an info group.");
processingDS = true;
dsBuffer.setLength(0);
continue;
}
// infogroup end
if (c == DATASET_END) {
if (dsBuffer.length() >= 3) {
try {
String[] dsData = toDataSetStrings(dsBuffer.toString());
if (dsData != null) {
if (dsData.length == 2) {
frameData.put(dsData[0], new DataSet(null, dsData[1]));
}
else if (dsData.length == 3) {
frameData.put(dsData[0], new DataSet(DataConverter.fromDateToMillis(dsData[1]), dsData[2]));
}
else {
signalCorruptedInput("Data set content corrupted (invalid format).");
}
}
} catch (Exception e) {
signalCorruptedInput("Error while decoding data set: " + e.getMessage());
}
}
else {
signalCorruptedInput("DATASET_END encountered too soon.");
}
processingDS = false;
continue;
}
// skip all data outside infogroups
if (!processingDS) {
signalCorruptedInput("Expected DATASET_START, but received regular character.");
continue;
}
dsBuffer.append(c);
}
} catch (IOException e) {
Log.error("Error while reading raw TIC input", e);
System.exit(1);
}
}
int checksumMode = 0;
char prevSeparator = 0x00;
private String[] toDataSetStrings(String buff) {
char checksum = buff.charAt(buff.length() - 1);
char separator = buff.charAt(buff.length() - 2);
if (separator != SEP_SP && separator != SEP_HT) {
signalCorruptedInput("Invalid info group separator in buffer " + hexdump(buff));
return null;
}
// check separator
if (prevSeparator == 0x00)
prevSeparator = separator;
else if (separator != prevSeparator) {
signalCorruptedInput("Received separator is not the same as previously received. Buffer is " + hexdump(buff));
return null;
}
// check checksum
if (checksumMode == 0) {
char cs1 = checksum(buff.substring(0, buff.length() - 2));
char cs2 = checksum(buff.substring(0, buff.length() - 1));
if (checksum == cs1) {
if (checksum != cs2) {
checksumMode = 1;
}
// else {} // ignored, because in this case, both checksum are equals to the received checksum
}
else {
if (checksum == cs2) {
checksumMode = 2;
}
else {
signalCorruptedInput("Invalid checksum. Received " + hex(checksum) + " but expected either " + hex(cs1) + " (mode 1) or " + hex(cs2) + " (mode 2). Buffer is " + hexdump(buff));
return null;
}
}
}
else if (checksumMode == 1) {
char cs1 = checksum(buff.substring(0, buff.length() - 2));
if (checksum != cs1) {
signalCorruptedInput("Invalid checksum. Received " + hex(checksum) + " but expected " + hex(cs1) + " (mode 1). Buffer is " + hexdump(buff));
return null;
}
}
else if (checksumMode == 2) {
char cs2 = checksum(buff.substring(0, buff.length() - 1));
if (checksum != cs2) {
signalCorruptedInput("Invalid checksum. Received " + hex(checksum) + " but expected " + hex(cs2) + " (mode 2). Buffer is " + hexdump(buff));
return null;
}
}
// separate key, value and eventually the time data
return buff.substring(0, buff.length() - 2).split("" + separator, separator == SEP_SP ? 2 : 3);
}
public static char checksum(String s) {
int cs = 0;
for (char c : s.toCharArray()) {
cs += c;
}
return (char) ((cs & 0x3F) + 0x20);
}
private static String hex(char c) {
return String.format("0x%02x", (int)c);
}
private static String hexdump(String s) {
StringBuilder sb = new StringBuilder();
for (char c : s.toCharArray())
sb.append(String.format("%02x ", (int)c));
sb.append('|');
for (char c : s.toCharArray())
sb.append((c >= ' ' && c < 0x7f) ? c : '.');
sb.append('|');
return sb.toString();
}
public DataFrame getNextDataFrame() throws InterruptedException {
if (outQueue.size() > 3) {
Log.warn("TICDecoder queue is growing (size=" + outQueue.size() + ")");
}
return outQueue.take();
}
}

View File

@ -0,0 +1,58 @@
package fr.mbaloup.home.util;
import java.util.Calendar;
import java.util.GregorianCalendar;
public class TimeUtil {
public static boolean isDifferentDay(long c1, long c2) {
return isDifferentDay(getCalendarOfTime(c1), getCalendarOfTime(c2));
}
public static boolean isDifferentDay(Calendar c1, Calendar c2) {
return getDayOfMonth(c1) != getDayOfMonth(c2)
|| getMonth(c1) != getMonth(c2)
|| getYear(c1) != getYear(c2);
}
public static int getNbDayInMonth(Calendar cal) {
switch(getMonth(cal)) {
case Calendar.JANUARY:
case Calendar.MARCH:
case Calendar.MAY:
case Calendar.JULY:
case Calendar.AUGUST:
case Calendar.OCTOBER:
case Calendar.DECEMBER:
return 31;
case Calendar.FEBRUARY:
return ((GregorianCalendar) cal).isLeapYear(getYear(cal)) ? 29 : 28;
default:
return 30;
}
}
public static int getDayOfMonth(Calendar cal) {
return cal.get(Calendar.DAY_OF_MONTH);
}
public static int getMonth(Calendar cal) {
return cal.get(Calendar.MONTH);
}
public static int getYear(Calendar cal) {
return cal.get(Calendar.YEAR);
}
public static void setMidnight(Calendar cal) {
cal.set(Calendar.HOUR_OF_DAY, 0);
cal.set(Calendar.MINUTE, 0);
cal.set(Calendar.SECOND, 0);
cal.set(Calendar.MILLISECOND, 0);
}
public static Calendar getCalendarOfTime(long time) {
Calendar cal = new GregorianCalendar();
cal.setTimeInMillis(time);
return cal;
}
}

12
update.sh Normal file
View File

@ -0,0 +1,12 @@
#!/bin/sh
cd "${0%/*}" || exit
mkdir -p workdir
# needs the presence of a docker-compose.yml file (use the provided example)
docker compose down && \
git pull && \
mvn clean package && \
docker compose up -d