2 * Copyright (c) 2010-2024 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.keba.internal.handler;
15 import static org.openhab.binding.keba.internal.KebaBindingConstants.*;
17 import java.io.IOException;
18 import java.math.BigDecimal;
19 import java.net.InetSocketAddress;
20 import java.net.Socket;
21 import java.net.SocketAddress;
22 import java.nio.ByteBuffer;
24 import java.util.Map.Entry;
25 import java.util.Objects;
26 import java.util.concurrent.ScheduledFuture;
27 import java.util.concurrent.TimeUnit;
29 import javax.measure.quantity.Dimensionless;
30 import javax.measure.quantity.ElectricCurrent;
31 import javax.measure.quantity.ElectricPotential;
32 import javax.measure.quantity.Energy;
33 import javax.measure.quantity.Power;
34 import javax.measure.quantity.Time;
36 import org.openhab.binding.keba.internal.KebaBindingConstants.KebaSeries;
37 import org.openhab.binding.keba.internal.KebaBindingConstants.KebaType;
38 import org.openhab.core.cache.ExpiringCacheMap;
39 import org.openhab.core.config.core.Configuration;
40 import org.openhab.core.library.types.DecimalType;
41 import org.openhab.core.library.types.IncreaseDecreaseType;
42 import org.openhab.core.library.types.OnOffType;
43 import org.openhab.core.library.types.QuantityType;
44 import org.openhab.core.library.types.StringType;
45 import org.openhab.core.library.unit.Units;
46 import org.openhab.core.thing.ChannelUID;
47 import org.openhab.core.thing.Thing;
48 import org.openhab.core.thing.ThingStatus;
49 import org.openhab.core.thing.ThingStatusDetail;
50 import org.openhab.core.thing.binding.BaseThingHandler;
51 import org.openhab.core.types.Command;
52 import org.openhab.core.types.RefreshType;
53 import org.openhab.core.types.State;
54 import org.openhab.core.util.StringUtils;
55 import org.slf4j.Logger;
56 import org.slf4j.LoggerFactory;
58 import com.google.gson.JsonElement;
59 import com.google.gson.JsonObject;
60 import com.google.gson.JsonParseException;
61 import com.google.gson.JsonParser;
64 * The {@link KeContactHandler} is responsible for handling commands, which
65 * are sent to one of the channels.
67 * @author Karel Goderis - Initial contribution
69 public class KeContactHandler extends BaseThingHandler {
71 public static final String IP_ADDRESS = "ipAddress";
72 public static final String POLLING_REFRESH_INTERVAL = "refreshInterval";
73 public static final int POLLING_REFRESH_INTERVAL_DEFAULT = 15;
74 public static final int REPORT_INTERVAL = 3000;
75 public static final int BUFFER_SIZE = 1024;
76 public static final int REMOTE_PORT_NUMBER = 7090;
77 private static final String CACHE_REPORT_1 = "REPORT_1";
78 private static final String CACHE_REPORT_2 = "REPORT_2";
79 private static final String CACHE_REPORT_3 = "REPORT_3";
80 private static final String CACHE_REPORT_100 = "REPORT_100";
81 public static final int SOCKET_TIME_OUT_MS = 3000;
82 public static final int SOCKET_CHECK_PORT_NUMBER = 80;
84 private final Logger logger = LoggerFactory.getLogger(KeContactHandler.class);
86 private final KeContactTransceiver transceiver;
88 private ScheduledFuture<?> pollingJob;
89 private ExpiringCacheMap<String, ByteBuffer> cache;
91 private int maxPresetCurrent = 0;
92 private int maxSystemCurrent = 63000;
93 private KebaType type;
94 private KebaSeries series;
95 private int lastState = -1; // trigger a report100 at startup
96 private boolean isReport100needed = true;
98 public KeContactHandler(Thing thing, KeContactTransceiver transceiver) {
100 this.transceiver = transceiver;
104 public void initialize() {
106 if (isKebaReachable()) {
107 transceiver.registerHandler(this);
109 int refreshInterval = getRefreshInterval();
110 cache = new ExpiringCacheMap<>(Math.max(refreshInterval - 5, 0) * 1000);
112 cache.put(CACHE_REPORT_1, () -> transceiver.send("report 1", getHandler()));
113 cache.put(CACHE_REPORT_2, () -> transceiver.send("report 2", getHandler()));
114 cache.put(CACHE_REPORT_3, () -> transceiver.send("report 3", getHandler()));
115 cache.put(CACHE_REPORT_100, () -> transceiver.send("report 100", getHandler()));
117 if (pollingJob == null || pollingJob.isCancelled()) {
118 pollingJob = scheduler.scheduleWithFixedDelay(this::pollingRunnable, 0, refreshInterval,
122 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
123 "IP address or port number not set");
125 } catch (IOException e) {
126 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
127 "Exception during initialization of binding: " + e.toString());
131 private boolean isKebaReachable() throws IOException {
132 boolean isReachable = false;
133 SocketAddress sockAddr = new InetSocketAddress(getIPAddress(), SOCKET_CHECK_PORT_NUMBER);
134 Socket socket = new Socket();
136 socket.connect(sockAddr, SOCKET_TIME_OUT_MS);
141 logger.debug("isKebaReachable() returns {}", isReachable);
146 public void dispose() {
147 if (pollingJob != null && !pollingJob.isCancelled()) {
148 pollingJob.cancel(true);
152 transceiver.unRegisterHandler(this);
155 public String getIPAddress() {
156 return getConfig().get(IP_ADDRESS) != null ? (String) getConfig().get(IP_ADDRESS) : "";
159 public int getRefreshInterval() {
160 return getConfig().get(POLLING_REFRESH_INTERVAL) != null
161 ? ((BigDecimal) getConfig().get(POLLING_REFRESH_INTERVAL)).intValue()
162 : POLLING_REFRESH_INTERVAL_DEFAULT;
165 private KeContactHandler getHandler() {
170 public void updateStatus(ThingStatus status, ThingStatusDetail statusDetail, String description) {
171 super.updateStatus(status, statusDetail, description);
175 protected Configuration getConfig() {
176 return super.getConfig();
179 private void pollingRunnable() {
181 logger.debug("Running pollingRunnable to connect Keba wallbox");
182 long stamp = System.currentTimeMillis();
183 if (!isKebaReachable()) {
184 logger.debug("isKebaReachable() timed out after '{}' milliseconds", System.currentTimeMillis() - stamp);
185 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
186 "A timeout occurred while polling the charging station");
188 ByteBuffer response = cache.get(CACHE_REPORT_1);
189 if (response == null) {
190 logger.debug("Missing response from Keba station for 'report 1'");
195 Thread.sleep(REPORT_INTERVAL);
197 response = cache.get(CACHE_REPORT_2);
198 if (response == null) {
199 logger.debug("Missing response from Keba station for 'report 2'");
204 Thread.sleep(REPORT_INTERVAL);
206 response = cache.get(CACHE_REPORT_3);
207 if (response == null) {
208 logger.debug("Missing response from Keba station for 'report 3'");
213 if (isReport100needed) {
214 Thread.sleep(REPORT_INTERVAL);
216 response = cache.get(CACHE_REPORT_100);
217 if (response == null) {
218 logger.debug("Missing response from Keba station for 'report 100'");
222 isReport100needed = false;
225 } catch (IOException e) {
226 logger.debug("An error occurred while polling the KEBA KeContact '{}': {}", getThing().getUID(),
228 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
229 "An error occurred while polling the charging station");
230 } catch (InterruptedException e) {
231 logger.debug("Polling job has been interrupted for handler of thing '{}'.", getThing().getUID());
235 protected void onData(ByteBuffer byteBuffer) {
236 if (getThing().getStatus() != ThingStatus.ONLINE) {
237 updateStatus(ThingStatus.ONLINE);
240 String response = new String(byteBuffer.array(), 0, byteBuffer.limit());
241 response = Objects.requireNonNull(StringUtils.chomp(response));
243 if (response.contains("TCH-OK")) {
244 // ignore confirmation messages which are not JSON
249 JsonObject readObject = JsonParser.parseString(response).getAsJsonObject();
251 for (Entry<String, JsonElement> entry : readObject.entrySet()) {
252 switch (entry.getKey()) {
254 Map<String, String> properties = editProperties();
255 String product = entry.getValue().getAsString().trim();
256 properties.put(CHANNEL_MODEL, product);
257 updateProperties(properties);
258 if (product.contains("P20")) {
260 } else if (product.contains("P30")) {
263 series = KebaSeries.getSeries(product.substring(13, 14).charAt(0));
267 Map<String, String> properties = editProperties();
268 properties.put(CHANNEL_SERIAL, entry.getValue().getAsString());
269 updateProperties(properties);
273 Map<String, String> properties = editProperties();
274 properties.put(CHANNEL_FIRMWARE, entry.getValue().getAsString());
275 updateProperties(properties);
279 int state = entry.getValue().getAsInt();
282 updateState(CHANNEL_WALLBOX, OnOffType.OFF);
283 updateState(CHANNEL_VEHICLE, OnOffType.OFF);
284 updateState(CHANNEL_PLUG_LOCKED, OnOffType.OFF);
288 updateState(CHANNEL_WALLBOX, OnOffType.ON);
289 updateState(CHANNEL_VEHICLE, OnOffType.OFF);
290 updateState(CHANNEL_PLUG_LOCKED, OnOffType.OFF);
294 updateState(CHANNEL_WALLBOX, OnOffType.ON);
295 updateState(CHANNEL_VEHICLE, OnOffType.OFF);
296 updateState(CHANNEL_PLUG_LOCKED, OnOffType.ON);
300 updateState(CHANNEL_WALLBOX, OnOffType.ON);
301 updateState(CHANNEL_VEHICLE, OnOffType.ON);
302 updateState(CHANNEL_PLUG_LOCKED, OnOffType.OFF);
306 updateState(CHANNEL_WALLBOX, OnOffType.ON);
307 updateState(CHANNEL_VEHICLE, OnOffType.ON);
308 updateState(CHANNEL_PLUG_LOCKED, OnOffType.ON);
315 int state = entry.getValue().getAsInt();
316 State newState = new DecimalType(state);
317 updateState(CHANNEL_STATE, newState);
318 if (lastState != state) {
319 // the state is different from the last one, so we will trigger a report100
320 isReport100needed = true;
326 updateState(CHANNEL_ENABLED_SYSTEM, OnOffType.from(entry.getValue().getAsInt() == 1));
329 case "Enable user": {
330 updateState(CHANNEL_ENABLED_USER, OnOffType.from(entry.getValue().getAsInt() == 1));
334 int state = entry.getValue().getAsInt();
335 maxSystemCurrent = state;
336 State newState = new QuantityType<ElectricCurrent>(state / 1000.0, Units.AMPERE);
337 updateState(CHANNEL_MAX_SYSTEM_CURRENT, newState);
338 if (maxSystemCurrent != 0) {
339 if (maxSystemCurrent < maxPresetCurrent) {
340 transceiver.send("curr " + maxSystemCurrent, this);
341 updateState(CHANNEL_MAX_PRESET_CURRENT,
342 new QuantityType<ElectricCurrent>(maxSystemCurrent / 1000.0, Units.AMPERE));
343 updateState(CHANNEL_MAX_PRESET_CURRENT_RANGE, new QuantityType<Dimensionless>(
344 (maxSystemCurrent - 6000) * 100 / (maxSystemCurrent - 6000), Units.PERCENT));
347 logger.debug("maxSystemCurrent is 0. Ignoring.");
352 int state = entry.getValue().getAsInt();
353 maxPresetCurrent = state;
354 State newState = new QuantityType<ElectricCurrent>(state / 1000.0, Units.AMPERE);
355 updateState(CHANNEL_MAX_PRESET_CURRENT, newState);
356 if (maxSystemCurrent != 0) {
357 updateState(CHANNEL_MAX_PRESET_CURRENT_RANGE, new QuantityType<Dimensionless>(
358 Math.min(100, (state - 6000) * 100 / (maxSystemCurrent - 6000)), Units.PERCENT));
363 int state = entry.getValue().getAsInt();
364 State newState = new QuantityType<ElectricCurrent>(state / 1000.0, Units.AMPERE);
365 updateState(CHANNEL_FAILSAFE_CURRENT, newState);
369 int state = entry.getValue().getAsInt();
370 maxPresetCurrent = state;
371 State newState = new QuantityType<ElectricCurrent>(state / 1000.0, Units.AMPERE);
372 updateState(CHANNEL_PILOT_CURRENT, newState);
376 int state = entry.getValue().getAsInt();
377 State newState = new QuantityType<Dimensionless>(state / 10.0, Units.PERCENT);
378 updateState(CHANNEL_PILOT_PWM, newState);
382 int state = entry.getValue().getAsInt();
385 updateState(CHANNEL_OUTPUT, OnOffType.ON);
389 updateState(CHANNEL_OUTPUT, OnOffType.OFF);
396 int state = entry.getValue().getAsInt();
399 updateState(CHANNEL_INPUT, OnOffType.ON);
403 updateState(CHANNEL_INPUT, OnOffType.OFF);
410 long state = entry.getValue().getAsLong();
411 State newState = new QuantityType<Time>(state, Units.SECOND);
412 updateState(CHANNEL_UPTIME, newState);
416 int state = entry.getValue().getAsInt();
417 State newState = new QuantityType<ElectricPotential>(state, Units.VOLT);
418 updateState(CHANNEL_U1, newState);
422 int state = entry.getValue().getAsInt();
423 State newState = new QuantityType<ElectricPotential>(state, Units.VOLT);
424 updateState(CHANNEL_U2, newState);
428 int state = entry.getValue().getAsInt();
429 State newState = new QuantityType<ElectricPotential>(state, Units.VOLT);
430 updateState(CHANNEL_U3, newState);
434 int state = entry.getValue().getAsInt();
435 State newState = new QuantityType<ElectricCurrent>(state / 1000.0, Units.AMPERE);
436 updateState(CHANNEL_I1, newState);
440 int state = entry.getValue().getAsInt();
441 State newState = new QuantityType<ElectricCurrent>(state / 1000.0, Units.AMPERE);
442 updateState(CHANNEL_I2, newState);
446 int state = entry.getValue().getAsInt();
447 State newState = new QuantityType<ElectricCurrent>(state / 1000.0, Units.AMPERE);
448 updateState(CHANNEL_I3, newState);
452 long state = entry.getValue().getAsLong();
453 State newState = new QuantityType<Power>(state / 1000.0, Units.WATT);
454 updateState(CHANNEL_POWER, newState);
458 int state = entry.getValue().getAsInt();
459 State newState = new QuantityType<Dimensionless>(state / 10.0, Units.PERCENT);
460 updateState(CHANNEL_POWER_FACTOR, newState);
464 long state = entry.getValue().getAsLong();
465 State newState = new QuantityType<Energy>(state / 10.0, Units.WATT_HOUR);
466 updateState(CHANNEL_SESSION_CONSUMPTION, newState);
470 long state = entry.getValue().getAsLong();
471 State newState = new QuantityType<Energy>(state / 10.0, Units.WATT_HOUR);
472 updateState(CHANNEL_TOTAL_CONSUMPTION, newState);
476 int state = entry.getValue().getAsInt();
477 State newState = new DecimalType(state);
478 updateState(CHANNEL_AUTHON, newState);
482 int state = entry.getValue().getAsInt();
483 State newState = new DecimalType(state);
484 updateState(CHANNEL_AUTHREQ, newState);
488 String state = entry.getValue().getAsString().trim();
489 State newState = new StringType(state);
490 updateState(CHANNEL_SESSION_RFID_TAG, newState);
494 String state = entry.getValue().getAsString().trim();
495 State newState = new StringType(state);
496 updateState(CHANNEL_SESSION_RFID_CLASS, newState);
500 int state = entry.getValue().getAsInt();
501 State newState = new DecimalType(state);
502 updateState(CHANNEL_SESSION_SESSION_ID, newState);
506 int state = entry.getValue().getAsInt();
507 State newState = new QuantityType<Energy>(state / 10.0, Units.WATT_HOUR);
508 updateState(CHANNEL_SETENERGY, newState);
513 } catch (JsonParseException e) {
514 logger.debug("Invalid JSON data will be ignored: '{}'", response);
519 public void handleCommand(ChannelUID channelUID, Command command) {
520 if ((command instanceof RefreshType)) {
521 // let's assume we do frequent enough polling and ignore the REFRESH request here
522 // in order to prevent too many channel state updates
524 switch (channelUID.getId()) {
525 case CHANNEL_MAX_PRESET_CURRENT: {
526 if (command instanceof QuantityType<?> quantityCommand) {
527 QuantityType<?> value = Objects.requireNonNull(quantityCommand.toUnit("mA"));
529 transceiver.send("curr " + Math.min(Math.max(6000, value.intValue()), maxSystemCurrent), this);
533 case CHANNEL_MAX_PRESET_CURRENT_RANGE: {
534 if (command instanceof OnOffType || command instanceof IncreaseDecreaseType
535 || command instanceof QuantityType<?>) {
536 long newValue = 6000;
537 if (command == IncreaseDecreaseType.INCREASE) {
538 newValue = Math.min(Math.max(6000, maxPresetCurrent + 1), maxSystemCurrent);
539 } else if (command == IncreaseDecreaseType.DECREASE) {
540 newValue = Math.min(Math.max(6000, maxPresetCurrent - 1), maxSystemCurrent);
541 } else if (command == OnOffType.ON) {
542 newValue = maxSystemCurrent;
543 } else if (command == OnOffType.OFF) {
545 } else if (command instanceof QuantityType<?> quantityCommand) {
546 QuantityType<?> value = Objects.requireNonNull(quantityCommand.toUnit("%"));
547 newValue = Math.round(6000 + (maxSystemCurrent - 6000) * value.doubleValue() / 100.0);
551 transceiver.send("curr " + newValue, this);
555 case CHANNEL_ENABLED_USER: {
556 if (command instanceof OnOffType) {
557 if (command == OnOffType.ON) {
558 transceiver.send("ena 1", this);
559 } else if (command == OnOffType.OFF) {
560 transceiver.send("ena 0", this);
567 case CHANNEL_OUTPUT: {
568 if (command instanceof OnOffType) {
569 if (command == OnOffType.ON) {
570 transceiver.send("output 1", this);
571 } else if (command == OnOffType.OFF) {
572 transceiver.send("output 0", this);
579 case CHANNEL_DISPLAY: {
580 if (command instanceof StringType) {
581 if (type == KebaType.P30 && (series == KebaSeries.C || series == KebaSeries.X)) {
582 String cmd = command.toString();
583 int maxLength = (cmd.length() < 23) ? cmd.length() : 23;
584 transceiver.send("display 0 0 0 0 " + cmd.substring(0, maxLength), this);
586 logger.warn("'Display' is not supported on a KEBA KeContact {}:{}", type, series);
591 case CHANNEL_SETENERGY: {
592 if (command instanceof QuantityType<?> quantityCommand) {
593 QuantityType<?> value = Objects.requireNonNull(quantityCommand.toUnit(Units.WATT_HOUR));
595 "setenergy " + Math.min(Math.max(0, Math.round(value.doubleValue() * 10.0)), 999999999),
600 case CHANNEL_AUTHENTICATE: {
601 if (command instanceof StringType) {
602 String cmd = command.toString();
603 // cmd must contain ID + CLASS (works only if the RFID TAG is in the whitelist of the Keba
605 transceiver.send("start " + cmd, this);