2 * Copyright (c) 2010-2021 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.Units.ONE;
21 import static org.openhab.core.library.unit.Units.PARTS_PER_MILLION;
22 import static org.openhab.core.library.unit.Units.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.ArrayList;
33 import java.util.List;
35 import java.util.concurrent.ScheduledFuture;
36 import java.util.concurrent.TimeUnit;
38 import org.openhab.binding.airvisualnode.internal.config.AirVisualNodeConfig;
39 import org.openhab.binding.airvisualnode.internal.json.MeasurementsInterface;
40 import org.openhab.binding.airvisualnode.internal.json.NodeDataInterface;
41 import org.openhab.binding.airvisualnode.internal.json.airvisual.NodeData;
42 import org.openhab.binding.airvisualnode.internal.json.airvisualpro.ProNodeData;
43 import org.openhab.core.library.types.DateTimeType;
44 import org.openhab.core.library.types.DecimalType;
45 import org.openhab.core.library.types.QuantityType;
46 import org.openhab.core.thing.Channel;
47 import org.openhab.core.thing.ChannelUID;
48 import org.openhab.core.thing.Thing;
49 import org.openhab.core.thing.ThingStatus;
50 import org.openhab.core.thing.ThingStatusDetail;
51 import org.openhab.core.thing.binding.BaseThingHandler;
52 import org.openhab.core.thing.binding.builder.ThingBuilder;
53 import org.openhab.core.types.Command;
54 import org.openhab.core.types.RefreshType;
55 import org.openhab.core.types.State;
56 import org.openhab.core.types.UnDefType;
57 import org.slf4j.Logger;
58 import org.slf4j.LoggerFactory;
60 import com.google.gson.FieldNamingPolicy;
61 import com.google.gson.Gson;
62 import com.google.gson.GsonBuilder;
64 import jcifs.smb.NtlmPasswordAuthentication;
65 import jcifs.smb.SmbFile;
66 import jcifs.smb.SmbFileInputStream;
69 * The {@link AirVisualNodeHandler} is responsible for handling commands, which are
70 * sent to one of the channels.
72 * @author Victor Antonovich - Initial contribution
74 public class AirVisualNodeHandler extends BaseThingHandler {
76 private final Logger logger = LoggerFactory.getLogger(AirVisualNodeHandler.class);
78 public static final String NODE_JSON_FILE = "latest_config_measurements.json";
80 private final Gson gson;
82 private ScheduledFuture<?> pollFuture;
84 private long refreshInterval;
86 private String nodeAddress;
88 private String nodeUsername;
90 private String nodePassword;
92 private String nodeShareName;
94 private NodeDataInterface nodeData;
96 private boolean isProVersion;
98 public AirVisualNodeHandler(Thing thing) {
100 gson = new GsonBuilder().setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES).create();
104 public void initialize() {
105 logger.debug("Initializing AirVisual Node handler");
107 AirVisualNodeConfig config = getConfigAs(AirVisualNodeConfig.class);
109 if (config.address == null) {
110 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Node address must be set");
113 this.nodeAddress = config.address;
115 this.nodeUsername = config.username;
117 if (config.password == null) {
118 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Node password must be set");
121 this.nodePassword = config.password;
123 this.nodeShareName = config.share;
125 this.refreshInterval = config.refresh * 1000L;
128 var jsonData = gson.fromJson(getNodeJsonData(), Map.class);
129 this.isProVersion = jsonData.get("measurements") instanceof ArrayList;
130 } catch (IOException e) {
131 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Can't get node json");
135 if (!this.isProVersion) {
142 private void removeProChannels() {
143 List<Channel> channels = new ArrayList<>(getThing().getChannels());
144 channels.removeIf(channel -> channel.getLabel().equals("PM0.1") || channel.getLabel().equals("PM10"));
145 replaceChannels(channels);
148 private void replaceChannels(List<Channel> channels) {
149 ThingBuilder thingBuilder = editThing();
150 thingBuilder.withChannels(channels);
151 updateThing(thingBuilder.build());
155 public void handleCommand(ChannelUID channelUID, Command command) {
156 if (command instanceof RefreshType) {
157 updateChannel(channelUID.getId(), true);
159 logger.debug("Can not handle command '{}'", command);
164 public void handleRemoval() {
165 super.handleRemoval();
170 public void dispose() {
175 private synchronized void stopPoll() {
176 if (pollFuture != null && !pollFuture.isCancelled()) {
177 pollFuture.cancel(false);
181 private synchronized void schedulePoll() {
182 logger.debug("Scheduling poll for 500ms out, then every {} ms", refreshInterval);
183 pollFuture = scheduler.scheduleWithFixedDelay(this::poll, 500, refreshInterval, TimeUnit.MILLISECONDS);
186 private void poll() {
188 logger.debug("Polling for state");
190 updateStatus(ThingStatus.ONLINE);
191 } catch (IOException e) {
192 logger.debug("Could not connect to Node", e);
193 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
197 private void pollNode() throws IOException {
198 String jsonData = getNodeJsonData();
200 NodeDataInterface currentNodeData;
202 currentNodeData = gson.fromJson(jsonData, ProNodeData.class);
204 currentNodeData = gson.fromJson(jsonData, NodeData.class);
207 if (nodeData == null || currentNodeData.getStatus().getDatetime() > nodeData.getStatus().getDatetime()) {
208 nodeData = currentNodeData;
209 // Update all channels from the updated Node data
210 for (Channel channel : getThing().getChannels()) {
211 updateChannel(channel.getUID().getId(), false);
216 private String getNodeJsonData() throws IOException {
217 String url = "smb://" + nodeAddress + "/" + nodeShareName + "/" + NODE_JSON_FILE;
218 NtlmPasswordAuthentication auth = new NtlmPasswordAuthentication(null, nodeUsername, nodePassword);
219 try (SmbFileInputStream in = new SmbFileInputStream(new SmbFile(url, auth))) {
220 return new String(in.readAllBytes(), StandardCharsets.UTF_8);
224 private void updateChannel(String channelId, boolean force) {
225 if (nodeData != null && (force || isLinked(channelId))) {
226 State state = getChannelState(channelId, nodeData);
227 logger.debug("Update channel {} with state {}", channelId, state);
228 updateState(channelId, state);
232 private State getChannelState(String channelId, NodeDataInterface nodeData) {
233 State state = UnDefType.UNDEF;
235 // Handle system channel IDs separately, because 'switch/case' expressions must be constant expressions
236 if (CHANNEL_BATTERY_LEVEL.equals(channelId)) {
237 state = new DecimalType(BigDecimal.valueOf(nodeData.getStatus().getBattery()).longValue());
238 } else if (CHANNEL_WIFI_STRENGTH.equals(channelId)) {
239 state = new DecimalType(
240 BigDecimal.valueOf(Math.max(0, nodeData.getStatus().getWifiStrength() - 1)).longValue());
242 MeasurementsInterface measurements = nodeData.getMeasurements();
243 // Handle binding-specific channel IDs
246 state = new QuantityType<>(measurements.getCo2Ppm(), PARTS_PER_MILLION);
248 case CHANNEL_HUMIDITY:
249 state = new QuantityType<>(measurements.getHumidityRH(), PERCENT);
252 state = new QuantityType<>(measurements.getPm25AQIUS(), ONE);
256 state = new QuantityType<>(measurements.getPm25Ugm3(), MICRO(GRAM).divide(CUBIC_METRE));
260 state = new QuantityType<>(measurements.getPm10Ugm3(), MICRO(GRAM).divide(CUBIC_METRE));
264 state = new QuantityType<>(measurements.getPm01Ugm3(), MICRO(GRAM).divide(CUBIC_METRE));
266 case CHANNEL_TEMP_CELSIUS:
267 state = new QuantityType<>(measurements.getTemperatureC(), CELSIUS);
269 case CHANNEL_TIMESTAMP:
270 // It seem the Node timestamp is Unix timestamp converted from UTC time plus timezone offset.
271 // Not sure about DST though, but it's best guess at now
272 Instant instant = Instant.ofEpochMilli(nodeData.getStatus().getDatetime() * 1000L);
273 ZonedDateTime zonedDateTime = ZonedDateTime.ofInstant(instant, ZoneId.of("UTC"));
274 ZoneId zoneId = ZoneId.of(nodeData.getSettings().getTimezone());
275 ZoneRules zoneRules = zoneId.getRules();
276 zonedDateTime.minus(Duration.ofSeconds(zoneRules.getOffset(instant).getTotalSeconds()));
277 if (zoneRules.isDaylightSavings(instant)) {
278 zonedDateTime.minus(Duration.ofSeconds(zoneRules.getDaylightSavings(instant).getSeconds()));
280 state = new DateTimeType(zonedDateTime);
282 case CHANNEL_USED_MEMORY:
283 state = new DecimalType(BigDecimal.valueOf(nodeData.getStatus().getUsedMemory()).longValue());