2 * Copyright (c) 2010-2020 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.airvisualnode.internal.handler;
15 import static org.openhab.binding.airvisualnode.internal.AirVisualNodeBindingConstants.*;
16 import static org.openhab.core.library.unit.MetricPrefix.MICRO;
17 import static org.openhab.core.library.unit.SIUnits.CELSIUS;
18 import static org.openhab.core.library.unit.SIUnits.CUBIC_METRE;
19 import static org.openhab.core.library.unit.SIUnits.GRAM;
20 import static org.openhab.core.library.unit.SmartHomeUnits.ONE;
21 import static org.openhab.core.library.unit.SmartHomeUnits.PARTS_PER_MILLION;
22 import static org.openhab.core.library.unit.SmartHomeUnits.PERCENT;
24 import java.io.IOException;
25 import java.math.BigDecimal;
26 import java.nio.charset.StandardCharsets;
27 import java.time.Duration;
28 import java.time.Instant;
29 import java.time.ZoneId;
30 import java.time.ZonedDateTime;
31 import java.time.zone.ZoneRules;
32 import java.util.concurrent.ScheduledFuture;
33 import java.util.concurrent.TimeUnit;
35 import org.openhab.binding.airvisualnode.internal.config.AirVisualNodeConfig;
36 import org.openhab.binding.airvisualnode.internal.json.NodeData;
37 import org.openhab.core.library.types.DateTimeType;
38 import org.openhab.core.library.types.DecimalType;
39 import org.openhab.core.library.types.QuantityType;
40 import org.openhab.core.thing.Channel;
41 import org.openhab.core.thing.ChannelUID;
42 import org.openhab.core.thing.Thing;
43 import org.openhab.core.thing.ThingStatus;
44 import org.openhab.core.thing.ThingStatusDetail;
45 import org.openhab.core.thing.binding.BaseThingHandler;
46 import org.openhab.core.types.Command;
47 import org.openhab.core.types.RefreshType;
48 import org.openhab.core.types.State;
49 import org.openhab.core.types.UnDefType;
50 import org.slf4j.Logger;
51 import org.slf4j.LoggerFactory;
53 import com.google.gson.FieldNamingPolicy;
54 import com.google.gson.Gson;
55 import com.google.gson.GsonBuilder;
57 import jcifs.smb.NtlmPasswordAuthentication;
58 import jcifs.smb.SmbFile;
59 import jcifs.smb.SmbFileInputStream;
62 * The {@link AirVisualNodeHandler} is responsible for handling commands, which are
63 * sent to one of the channels.
65 * @author Victor Antonovich - Initial contribution
67 public class AirVisualNodeHandler extends BaseThingHandler {
69 private final Logger logger = LoggerFactory.getLogger(AirVisualNodeHandler.class);
71 public static final String NODE_JSON_FILE = "latest_config_measurements.json";
73 private final Gson gson;
75 private ScheduledFuture<?> pollFuture;
77 private long refreshInterval;
79 private String nodeAddress;
81 private String nodeUsername;
83 private String nodePassword;
85 private String nodeShareName;
87 private NodeData nodeData;
89 public AirVisualNodeHandler(Thing thing) {
91 gson = new GsonBuilder().setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES).create();
95 public void initialize() {
96 logger.debug("Initializing AirVisual Node handler");
98 AirVisualNodeConfig config = getConfigAs(AirVisualNodeConfig.class);
100 if (config.address == null) {
101 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Node address must be set");
104 this.nodeAddress = config.address;
106 this.nodeUsername = config.username;
108 if (config.password == null) {
109 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Node password must be set");
112 this.nodePassword = config.password;
114 this.nodeShareName = config.share;
116 this.refreshInterval = config.refresh * 1000L;
122 public void handleCommand(ChannelUID channelUID, Command command) {
123 if (command instanceof RefreshType) {
124 updateChannel(channelUID.getId(), true);
126 logger.debug("Can not handle command '{}'", command);
131 public void handleRemoval() {
132 super.handleRemoval();
137 public void dispose() {
142 private synchronized void stopPoll() {
143 if (pollFuture != null && !pollFuture.isCancelled()) {
144 pollFuture.cancel(false);
148 private synchronized void schedulePoll() {
149 logger.debug("Scheduling poll for 500ms out, then every {} ms", refreshInterval);
150 pollFuture = scheduler.scheduleWithFixedDelay(this::poll, 500, refreshInterval, TimeUnit.MILLISECONDS);
153 private void poll() {
155 logger.debug("Polling for state");
157 updateStatus(ThingStatus.ONLINE);
158 } catch (IOException e) {
159 logger.debug("Could not connect to Node", e);
160 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
164 private void pollNode() throws IOException {
165 String jsonData = getNodeJsonData();
166 NodeData currentNodeData = gson.fromJson(jsonData, NodeData.class);
167 if (nodeData == null || currentNodeData.getStatus().getDatetime() > nodeData.getStatus().getDatetime()) {
168 nodeData = currentNodeData;
169 // Update all channels from the updated Node data
170 for (Channel channel : getThing().getChannels()) {
171 updateChannel(channel.getUID().getId(), false);
176 private String getNodeJsonData() throws IOException {
177 String url = "smb://" + nodeAddress + "/" + nodeShareName + "/" + NODE_JSON_FILE;
178 NtlmPasswordAuthentication auth = new NtlmPasswordAuthentication(null, nodeUsername, nodePassword);
179 try (SmbFileInputStream in = new SmbFileInputStream(new SmbFile(url, auth))) {
180 return new String(in.readAllBytes(), StandardCharsets.UTF_8);
184 private void updateChannel(String channelId, boolean force) {
185 if (nodeData != null && (force || isLinked(channelId))) {
186 State state = getChannelState(channelId, nodeData);
187 logger.debug("Update channel {} with state {}", channelId, state);
188 updateState(channelId, state);
192 private State getChannelState(String channelId, NodeData nodeData) {
193 State state = UnDefType.UNDEF;
195 // Handle system channel IDs separately, because 'switch/case' expressions must be constant expressions
196 if (CHANNEL_BATTERY_LEVEL.equals(channelId)) {
197 state = new DecimalType(BigDecimal.valueOf(nodeData.getStatus().getBattery()).longValue());
198 } else if (CHANNEL_WIFI_STRENGTH.equals(channelId)) {
199 state = new DecimalType(
200 BigDecimal.valueOf(Math.max(0, nodeData.getStatus().getWifiStrength() - 1)).longValue());
202 // Handle binding-specific channel IDs
205 state = new QuantityType<>(nodeData.getMeasurements().getCo2Ppm(), PARTS_PER_MILLION);
207 case CHANNEL_HUMIDITY:
208 state = new QuantityType<>(nodeData.getMeasurements().getHumidityRH(), PERCENT);
211 state = new QuantityType<>(nodeData.getMeasurements().getPm25AQIUS(), ONE);
215 state = new QuantityType<>(nodeData.getMeasurements().getPm25Ugm3(),
216 MICRO(GRAM).divide(CUBIC_METRE));
218 case CHANNEL_TEMP_CELSIUS:
219 state = new QuantityType<>(nodeData.getMeasurements().getTemperatureC(), CELSIUS);
221 case CHANNEL_TIMESTAMP:
222 // It seem the Node timestamp is Unix timestamp converted from UTC time plus timezone offset.
223 // Not sure about DST though, but it's best guess at now
224 Instant instant = Instant.ofEpochMilli(nodeData.getStatus().getDatetime() * 1000L);
225 ZonedDateTime zonedDateTime = ZonedDateTime.ofInstant(instant, ZoneId.of("UTC"));
226 ZoneId zoneId = ZoneId.of(nodeData.getSettings().getTimezone());
227 ZoneRules zoneRules = zoneId.getRules();
228 zonedDateTime.minus(Duration.ofSeconds(zoneRules.getOffset(instant).getTotalSeconds()));
229 if (zoneRules.isDaylightSavings(instant)) {
230 zonedDateTime.minus(Duration.ofSeconds(zoneRules.getDaylightSavings(instant).getSeconds()));
232 state = new DateTimeType(zonedDateTime);
234 case CHANNEL_USED_MEMORY:
235 state = new DecimalType(BigDecimal.valueOf(nodeData.getStatus().getUsedMemory()).longValue());