commit 2a16f8e5f1ae1339bb6227f7e7db21745c2c51c0 Author: Marc Baloup Date: Wed Dec 7 14:22:06 2022 +0100 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ef6544a --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/target +/dependency-reduced-pom.xml +/docker-compose.yml +/workdir \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..26d3352 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,3 @@ +# Default ignored files +/shelf/ +/workspace.xml diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..446aa17 --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/encodings.xml b/.idea/encodings.xml new file mode 100644 index 0000000..3272d0d --- /dev/null +++ b/.idea/encodings.xml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..a3e605b --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,2550 @@ + + + + \ No newline at end of file diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml new file mode 100644 index 0000000..712ab9d --- /dev/null +++ b/.idea/jarRepositories.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..1625cfc --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,17 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..adffbc8 --- /dev/null +++ b/Dockerfile @@ -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 \ No newline at end of file diff --git a/docker-compose-example.yml b/docker-compose-example.yml new file mode 100644 index 0000000..9af2685 --- /dev/null +++ b/docker-compose-example.yml @@ -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" diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..d75f33c --- /dev/null +++ b/pom.xml @@ -0,0 +1,80 @@ + + 4.0.0 + + fr.mbaloup + tic + dev + tic + + + UTF-8 + 8 + 8 + + + + + org.eclipse.paho + org.eclipse.paho.client.mqttv3 + 1.2.5 + + + org.rapidoid + rapidoid-essentials + 5.5.5 + + + + + + ${project.name} + + + org.apache.maven.plugins + maven-jar-plugin + 2.4 + + + + fr.mbaloup.home.Main + ${maven.build.timestamp} + + + + + + org.apache.maven.plugins + maven-shade-plugin + 2.4.3 + + + + package + + shade + + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + + + + + + + + + + \ No newline at end of file diff --git a/res/static/index.css b/res/static/index.css new file mode 100644 index 0000000..7fa3d0b --- /dev/null +++ b/res/static/index.css @@ -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%; +} diff --git a/res/static/index.html b/res/static/index.html new file mode 100644 index 0000000..342962d --- /dev/null +++ b/res/static/index.html @@ -0,0 +1,157 @@ + + + + + Domotique + + + + + + + + + diff --git a/res/static/index.js b/res/static/index.js new file mode 100644 index 0000000..681d7ea --- /dev/null +++ b/res/static/index.js @@ -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); diff --git a/run.sh b/run.sh new file mode 100644 index 0000000..9efc6b3 --- /dev/null +++ b/run.sh @@ -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 diff --git a/src/main/java/fr/mbaloup/home/Main.java b/src/main/java/fr/mbaloup/home/Main.java new file mode 100644 index 0000000..3e3d6bb --- /dev/null +++ b/src/main/java/fr/mbaloup/home/Main.java @@ -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); + } + + + + + + +} diff --git a/src/main/java/fr/mbaloup/home/mqtt/MQTTSender.java b/src/main/java/fr/mbaloup/home/mqtt/MQTTSender.java new file mode 100644 index 0000000..2099a95 --- /dev/null +++ b/src/main/java/fr/mbaloup/home/mqtt/MQTTSender.java @@ -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 INDEX_TOTAL = new Topic<>("tic/index/_total_", 1, true, "tic_index_total", "TIC Index Total", "Wh", "energy", "total_increasing"); + public static final Topic APPARENT_POWER = new Topic<>("tic/power/app", 0, true, "tic_power_app", "TIC Apparent power", "VA", "energy", "measurement"); + public static final Topic 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 { + 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); + } + } + +} diff --git a/src/main/java/fr/mbaloup/home/tic/DataConverter.java b/src/main/java/fr/mbaloup/home/tic/DataConverter.java new file mode 100644 index 0000000..c693ead --- /dev/null +++ b/src/main/java/fr/mbaloup/home/tic/DataConverter.java @@ -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(); + } + + +} diff --git a/src/main/java/fr/mbaloup/home/tic/DataFrame.java b/src/main/java/fr/mbaloup/home/tic/DataFrame.java new file mode 100644 index 0000000..09162e2 --- /dev/null +++ b/src/main/java/fr/mbaloup/home/tic/DataFrame.java @@ -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 data; + public DataFrame(long t, Map 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); + } +} diff --git a/src/main/java/fr/mbaloup/home/tic/DataSet.java b/src/main/java/fr/mbaloup/home/tic/DataSet.java new file mode 100644 index 0000000..c34ee20 --- /dev/null +++ b/src/main/java/fr/mbaloup/home/tic/DataSet.java @@ -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; + } +} diff --git a/src/main/java/fr/mbaloup/home/tic/SimulatedTICInputStream.java b/src/main/java/fr/mbaloup/home/tic/SimulatedTICInputStream.java new file mode 100644 index 0000000..ee824ea --- /dev/null +++ b/src/main/java/fr/mbaloup/home/tic/SimulatedTICInputStream.java @@ -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(); + } + +} diff --git a/src/main/java/fr/mbaloup/home/tic/TICDataDispatcher.java b/src/main/java/fr/mbaloup/home/tic/TICDataDispatcher.java new file mode 100644 index 0000000..df10524 --- /dev/null +++ b/src/main/java/fr/mbaloup/home/tic/TICDataDispatcher.java @@ -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 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. + *

+ * 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. + *

+ * 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. + *

+ * 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()}. + *

+ * The TIC time may have shifted from the real time. In this case, {@link #isInternalClockBad()} will return true. + *

+ * To have the time of the dataframe according to the system clock, use {@link #getFrameSystemTime()}. + *

+ *

    + *
  • Historical mode: not provided.
  • + *
  • Standard mode: DATE field.
  • + *
+ * @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. + *

+ * 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()}. + *

+ * The TIC time may have shifted from the real time. In this case, {@link #isInternalClockBad()} will return true. + *

+ * To have the time of the dataframe according to the system clock, use {@link #getFrameSystemTime()}. + *

+ *

    + *
  • Historical mode: not provided.
  • + *
  • Standard mode: DATE field.
  • + *
+ * @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. + *

+ *

    + *
  • Historical mode: not provided.
  • + *
  • Standard mode: VTIC field.
  • + *
+ * 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). + *

+ *

    + *
  • Historical mode: ADCO field.
  • + *
  • Standard mode: ADCS field.
  • + *
+ * @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). + *

+ *

    + *
  • Historical mode: not provided.
  • + *
  • Standard mode: PRM field.
  • + *
+ * @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. + *

+ * Some example of price schedule option are constant price, peak/off-peak time prices, ... + *

+ *

    + *
  • Historical mode: OPTARIF field, with standardized values: {@code BASE}, {@code HC} or {@code EJP}.
  • + *
  • Standard mode: NGTF field, the content being a non-standardized display name.
  • + *
+ * @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. + *

+ *

    + *
  • Historical mode: ISOUSC field.
  • + *
  • Standard mode: not provided directly, but we use {@code getReferencePower() / 200} to set this value.
  • + *
+ * @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. + *

+ *

    + *
  • Historical mode: not provided directly, but we use {@link #getSubscribedIntensity()} {@code * 200} to set this value.
  • + *
  • Standard mode: PREF field in kVA.
  • + *
+ * @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. + *

+ * May be different than the reference power. + *

+ *

    + *
  • Historical mode: not provided directly, but we use {@link #getSubscribedIntensity()} {@code * 200} to set this value.
  • + *
  • Standard mode: PCOUP field in kVA.
  • + *
+ * @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. + *

+ *

    + *
  • Historical mode: PAPP field, with a precision of 10 VA.
  • + *
  • Standard mode: SINSTS field, with a precision of 1 VA.
  • + *
+ * @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. + *

+ *

    + *
  • Historical mode: not provided.
  • + *
  • Standard mode: SMAXSN field.
  • + *
+ * @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. + *

+ *

    + *
  • Historical mode: not provided.
  • + *
  • Standard mode: SMAXSN datetime field.
  • + *
+ * @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. + *

+ *

    + *
  • Historical mode: not provided.
  • + *
  • Standard mode: SMAXSN-1 field.
  • + *
+ * @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. + *

+ *

    + *
  • Historical mode: not provided.
  • + *
  • Standard mode: SMAXSN-1 datetime field.
  • + *
+ * @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. + *

+ *

    + *
  • Historical mode: fields with various names. All the indexes are not transmitted, so others indexes are considered 0.
  • + *
  • Standard mode: EASFxx fields.
  • + *
+ * @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. + *

+ *

    + *
  • Historical mode: the name of an index is based on the name of the TIC field that provide the index.
  • + *
  • Standard mode: the name provided by the field LTARF is applied to the current running index specified by NTARF.
  • + *
+ * @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. + *

+ * These indexes are not related to the pricing applied for the customer. + *

+ *

    + *
  • Historical mode: not provided.
  • + *
  • Standard mode: EASDxx fields.
  • + *
+ * @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. + *

+ * The relay id is 0 based (first relay is 0) + *

+ *

    + *
  • Historical mode: not provided.
  • + *
  • Standard mode: RELAIS field.
  • + *
+ * @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. + *

+ *

    + *
  • Historical mode: not provided.
  • + *
  • Standard mode: RELAIS field.
  • + *
+ * @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 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 getNextDayScheduling() { return nextDayScheduling; } + + + + + + + + private String MOTDETAT = null; + private void updateMOTDETAT(String o) { MOTDETAT = o; } + /** + * The value of MOTDETAT from the historical TIC data frame. + *

+ * The interpretation of the data is not documented. + *

+ *

    + *
  • Historical mode: MOTDETAT field.
  • + *
  • Standard mode: not provided.
  • + *
+ * @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 . + *

+ * The interpretation of the data is not documented. + *

+ *

    + *
  • Historical mode: HHPHC field.
  • + *
  • Standard mode: not provided.
  • + *
+ * @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). + *

+ *

    + *
  • Historical mode: presence of PEJP field, during 30 minutes before the start of the mobile point.
  • + *
  • Standard mode: not provided.
  • + *
+ * @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; } + + + + + + + + +} diff --git a/src/main/java/fr/mbaloup/home/tic/TICRawDecoder.java b/src/main/java/fr/mbaloup/home/tic/TICRawDecoder.java new file mode 100644 index 0000000..3cc5e89 --- /dev/null +++ b/src/main/java/fr/mbaloup/home/tic/TICRawDecoder.java @@ -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 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 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(); + } +} diff --git a/src/main/java/fr/mbaloup/home/util/TimeUtil.java b/src/main/java/fr/mbaloup/home/util/TimeUtil.java new file mode 100644 index 0000000..ed3587b --- /dev/null +++ b/src/main/java/fr/mbaloup/home/util/TimeUtil.java @@ -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; + } +} diff --git a/update.sh b/update.sh new file mode 100644 index 0000000..e445c60 --- /dev/null +++ b/update.sh @@ -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 \ No newline at end of file