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.apache.commons.io.IOUtils;
36 import org.openhab.binding.airvisualnode.internal.config.AirVisualNodeConfig;
37 import org.openhab.binding.airvisualnode.internal.json.NodeData;
38 import org.openhab.core.library.types.DateTimeType;
39 import org.openhab.core.library.types.DecimalType;
40 import org.openhab.core.library.types.QuantityType;
41 import org.openhab.core.thing.Channel;
42 import org.openhab.core.thing.ChannelUID;
43 import org.openhab.core.thing.Thing;
44 import org.openhab.core.thing.ThingStatus;
45 import org.openhab.core.thing.ThingStatusDetail;
46 import org.openhab.core.thing.binding.BaseThingHandler;
47 import org.openhab.core.types.Command;
48 import org.openhab.core.types.RefreshType;
49 import org.openhab.core.types.State;
50 import org.openhab.core.types.UnDefType;
51 import org.slf4j.Logger;
52 import org.slf4j.LoggerFactory;
54 import com.google.gson.FieldNamingPolicy;
55 import com.google.gson.Gson;
56 import com.google.gson.GsonBuilder;
58 import jcifs.smb.NtlmPasswordAuthentication;
59 import jcifs.smb.SmbFile;
60 import jcifs.smb.SmbFileInputStream;
63 * The {@link AirVisualNodeHandler} is responsible for handling commands, which are
64 * sent to one of the channels.
66 * @author Victor Antonovich - Initial contribution
68 public class AirVisualNodeHandler extends BaseThingHandler {
70 private final Logger logger = LoggerFactory.getLogger(AirVisualNodeHandler.class);
72 public static final String NODE_JSON_FILE = "latest_config_measurements.json";
74 private final Gson gson;
76 private ScheduledFuture<?> pollFuture;
78 private long refreshInterval;
80 private String nodeAddress;
82 private String nodeUsername;
84 private String nodePassword;
86 private String nodeShareName;
88 private NodeData nodeData;
90 public AirVisualNodeHandler(Thing thing) {
92 gson = new GsonBuilder().setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES).create();
96 public void initialize() {
97 logger.debug("Initializing AirVisual Node handler");
99 AirVisualNodeConfig config = getConfigAs(AirVisualNodeConfig.class);
101 if (config.address == null) {
102 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Node address must be set");
105 this.nodeAddress = config.address;
107 this.nodeUsername = config.username;
109 if (config.password == null) {
110 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Node password must be set");
113 this.nodePassword = config.password;
115 this.nodeShareName = config.share;
117 this.refreshInterval = config.refresh * 1000L;
123 public void handleCommand(ChannelUID channelUID, Command command) {
124 if (command instanceof RefreshType) {
125 updateChannel(channelUID.getId(), true);
127 logger.debug("Can not handle command '{}'", command);
132 public void handleRemoval() {
133 super.handleRemoval();
138 public void dispose() {
143 private synchronized void stopPoll() {
144 if (pollFuture != null && !pollFuture.isCancelled()) {
145 pollFuture.cancel(false);
149 private synchronized void schedulePoll() {
150 logger.debug("Scheduling poll for 500ms out, then every {} ms", refreshInterval);
151 pollFuture = scheduler.scheduleWithFixedDelay(this::poll, 500, refreshInterval, TimeUnit.MILLISECONDS);
154 private void poll() {
156 logger.debug("Polling for state");
158 updateStatus(ThingStatus.ONLINE);
159 } catch (IOException e) {
160 logger.debug("Could not connect to Node", e);
161 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
165 private void pollNode() throws IOException {
166 String jsonData = getNodeJsonData();
167 NodeData currentNodeData = gson.fromJson(jsonData, NodeData.class);
168 if (nodeData == null || currentNodeData.getStatus().getDatetime() > nodeData.getStatus().getDatetime()) {
169 nodeData = currentNodeData;
170 // Update all channels from the updated Node data
171 for (Channel channel : getThing().getChannels()) {
172 updateChannel(channel.getUID().getId(), false);
177 private String getNodeJsonData() throws IOException {
178 String url = "smb://" + nodeAddress + "/" + nodeShareName + "/" + NODE_JSON_FILE;
179 NtlmPasswordAuthentication auth = new NtlmPasswordAuthentication(null, nodeUsername, nodePassword);
180 try (SmbFileInputStream in = new SmbFileInputStream(new SmbFile(url, auth))) {
181 return IOUtils.toString(in, StandardCharsets.UTF_8.name());
185 private void updateChannel(String channelId, boolean force) {
186 if (nodeData != null && (force || isLinked(channelId))) {
187 State state = getChannelState(channelId, nodeData);
188 logger.debug("Update channel {} with state {}", channelId, state);
189 updateState(channelId, state);
193 private State getChannelState(String channelId, NodeData nodeData) {
194 State state = UnDefType.UNDEF;
196 // Handle system channel IDs separately, because 'switch/case' expressions must be constant expressions
197 if (CHANNEL_BATTERY_LEVEL.equals(channelId)) {
198 state = new DecimalType(BigDecimal.valueOf(nodeData.getStatus().getBattery()).longValue());
199 } else if (CHANNEL_WIFI_STRENGTH.equals(channelId)) {
200 state = new DecimalType(
201 BigDecimal.valueOf(Math.max(0, nodeData.getStatus().getWifiStrength() - 1)).longValue());
203 // Handle binding-specific channel IDs
206 state = new QuantityType<>(nodeData.getMeasurements().getCo2Ppm(), PARTS_PER_MILLION);
208 case CHANNEL_HUMIDITY:
209 state = new QuantityType<>(nodeData.getMeasurements().getHumidityRH(), PERCENT);
212 state = new QuantityType<>(nodeData.getMeasurements().getPm25AQIUS(), ONE);
216 state = new QuantityType<>(nodeData.getMeasurements().getPm25Ugm3(),
217 MICRO(GRAM).divide(CUBIC_METRE));
219 case CHANNEL_TEMP_CELSIUS:
220 state = new QuantityType<>(nodeData.getMeasurements().getTemperatureC(), CELSIUS);
222 case CHANNEL_TIMESTAMP:
223 // It seem the Node timestamp is Unix timestamp converted from UTC time plus timezone offset.
224 // Not sure about DST though, but it's best guess at now
225 Instant instant = Instant.ofEpochMilli(nodeData.getStatus().getDatetime() * 1000L);
226 ZonedDateTime zonedDateTime = ZonedDateTime.ofInstant(instant, ZoneId.of("UTC"));
227 ZoneId zoneId = ZoneId.of(nodeData.getSettings().getTimezone());
228 ZoneRules zoneRules = zoneId.getRules();
229 zonedDateTime.minus(Duration.ofSeconds(zoneRules.getOffset(instant).getTotalSeconds()));
230 if (zoneRules.isDaylightSavings(instant)) {
231 zonedDateTime.minus(Duration.ofSeconds(zoneRules.getDaylightSavings(instant).getSeconds()));
233 state = new DateTimeType(zonedDateTime);
235 case CHANNEL_USED_MEMORY:
236 state = new DecimalType(BigDecimal.valueOf(nodeData.getStatus().getUsedMemory()).longValue());