2 * Copyright (c) 2010-2023 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.eclipse.jdt.annotation.NonNullByDefault;
39 import org.eclipse.jdt.annotation.Nullable;
40 import org.openhab.binding.airvisualnode.internal.config.AirVisualNodeConfig;
41 import org.openhab.binding.airvisualnode.internal.dto.MeasurementsInterface;
42 import org.openhab.binding.airvisualnode.internal.dto.NodeDataInterface;
43 import org.openhab.binding.airvisualnode.internal.dto.airvisual.NodeData;
44 import org.openhab.binding.airvisualnode.internal.dto.airvisualpro.ProNodeData;
45 import org.openhab.core.library.types.DateTimeType;
46 import org.openhab.core.library.types.DecimalType;
47 import org.openhab.core.library.types.QuantityType;
48 import org.openhab.core.thing.Channel;
49 import org.openhab.core.thing.ChannelUID;
50 import org.openhab.core.thing.Thing;
51 import org.openhab.core.thing.ThingStatus;
52 import org.openhab.core.thing.ThingStatusDetail;
53 import org.openhab.core.thing.binding.BaseThingHandler;
54 import org.openhab.core.thing.binding.builder.ThingBuilder;
55 import org.openhab.core.types.Command;
56 import org.openhab.core.types.RefreshType;
57 import org.openhab.core.types.State;
58 import org.openhab.core.types.UnDefType;
59 import org.slf4j.Logger;
60 import org.slf4j.LoggerFactory;
62 import com.google.gson.FieldNamingPolicy;
63 import com.google.gson.Gson;
64 import com.google.gson.GsonBuilder;
66 import jcifs.smb.NtlmPasswordAuthentication;
67 import jcifs.smb.SmbFile;
68 import jcifs.smb.SmbFileInputStream;
71 * The {@link AirVisualNodeHandler} is responsible for handling commands, which are
72 * sent to one of the channels.
74 * @author Victor Antonovich - Initial contribution
77 public class AirVisualNodeHandler extends BaseThingHandler {
79 private final Logger logger = LoggerFactory.getLogger(AirVisualNodeHandler.class);
81 public static final String NODE_JSON_FILE = "latest_config_measurements.json";
82 private static final long DELAY_IN_MS = 500;
84 private final Gson gson;
85 private @Nullable ScheduledFuture<?> pollFuture;
86 private long refreshInterval;
87 private String nodeAddress = "";
88 private String nodeUsername = "";
89 private String nodePassword = "";
90 private String nodeShareName = "";
91 private @Nullable NodeDataInterface nodeData;
92 private boolean isProVersion = false;
94 public AirVisualNodeHandler(Thing thing) {
96 gson = new GsonBuilder().setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES).create();
100 public void initialize() {
101 logger.debug("Initializing AirVisual Node handler");
103 AirVisualNodeConfig config = getConfigAs(AirVisualNodeConfig.class);
105 if (config.address.isBlank()) {
106 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Node address must be set");
109 if (config.password.isBlank()) {
110 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Node password must be set");
114 this.nodeAddress = config.address;
115 this.nodeUsername = config.username;
116 this.nodePassword = config.password;
117 this.nodeShareName = config.share;
118 this.refreshInterval = config.refresh * 1000L;
121 var jsonData = gson.fromJson(getNodeJsonData(), Map.class);
122 this.isProVersion = jsonData.get("measurements") instanceof ArrayList;
123 } catch (IOException e) {
124 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Can't get node json");
128 if (!this.isProVersion) {
135 private void removeProChannels() {
136 List<Channel> channels = new ArrayList<>(getThing().getChannels());
137 channels.removeIf(channel -> isProChannel(channel.getLabel()));
138 replaceChannels(channels);
141 private boolean isProChannel(@Nullable String channelLabel) {
142 if (channelLabel == null || channelLabel.isBlank()) {
145 return "PM0.1".equals(channelLabel) || "PM10".equals(channelLabel);
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 ScheduledFuture<?> localFuture = pollFuture;
177 if (localFuture != null) {
178 localFuture.cancel(false);
182 private synchronized void schedulePoll() {
183 logger.debug("Scheduling poll for {}}ms out, then every {} ms", DELAY_IN_MS, refreshInterval);
184 pollFuture = scheduler.scheduleWithFixedDelay(this::poll, DELAY_IN_MS, refreshInterval, TimeUnit.MILLISECONDS);
187 private void poll() {
189 logger.debug("Polling for state");
191 updateStatus(ThingStatus.ONLINE);
192 } catch (IOException e) {
193 logger.debug("Could not connect to Node", e);
194 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
198 private void pollNode() throws IOException {
199 String jsonData = getNodeJsonData();
201 NodeDataInterface currentNodeData;
203 currentNodeData = gson.fromJson(jsonData, ProNodeData.class);
205 currentNodeData = gson.fromJson(jsonData, NodeData.class);
207 NodeDataInterface localNodeDate = nodeData;
208 if (localNodeDate == null
209 || currentNodeData.getStatus().getDatetime() > localNodeDate.getStatus().getDatetime()) {
210 nodeData = currentNodeData;
211 // Update all channels from the updated Node data
212 for (Channel channel : getThing().getChannels()) {
213 updateChannel(channel.getUID().getId(), false);
218 private String getNodeJsonData() throws IOException {
219 String url = "smb://" + nodeAddress + "/" + nodeShareName + "/" + NODE_JSON_FILE;
220 NtlmPasswordAuthentication auth = new NtlmPasswordAuthentication(null, nodeUsername, nodePassword);
221 try (SmbFileInputStream in = new SmbFileInputStream(new SmbFile(url, auth))) {
222 return new String(in.readAllBytes(), StandardCharsets.UTF_8);
226 private void updateChannel(String channelId, boolean force) {
227 NodeDataInterface localnodeData = nodeData;
228 if (localnodeData != null && (force || isLinked(channelId))) {
229 State state = getChannelState(channelId, localnodeData);
230 logger.debug("Update channel {} with state {}", channelId, state);
231 updateState(channelId, state);
235 private State getChannelState(String channelId, NodeDataInterface nodeData) {
236 State state = UnDefType.UNDEF;
238 // Handle system channel IDs separately, because 'switch/case' expressions must be constant expressions
239 if (CHANNEL_BATTERY_LEVEL.equals(channelId)) {
240 state = new DecimalType(BigDecimal.valueOf(nodeData.getStatus().getBattery()).longValue());
241 } else if (CHANNEL_WIFI_STRENGTH.equals(channelId)) {
242 state = new DecimalType(
243 BigDecimal.valueOf(Math.max(0, nodeData.getStatus().getWifiStrength() - 1)).longValue());
245 MeasurementsInterface measurements = nodeData.getMeasurements();
246 // Handle binding-specific channel IDs
249 state = new QuantityType<>(measurements.getCo2Ppm(), PARTS_PER_MILLION);
251 case CHANNEL_HUMIDITY:
252 state = new QuantityType<>(measurements.getHumidityRH(), PERCENT);
255 state = new QuantityType<>(measurements.getPm25AQIUS(), ONE);
259 state = new QuantityType<>(measurements.getPm25Ugm3(), MICRO(GRAM).divide(CUBIC_METRE));
263 state = new QuantityType<>(measurements.getPm10Ugm3(), MICRO(GRAM).divide(CUBIC_METRE));
267 state = new QuantityType<>(measurements.getPm01Ugm3(), MICRO(GRAM).divide(CUBIC_METRE));
269 case CHANNEL_TEMP_CELSIUS:
270 state = new QuantityType<>(measurements.getTemperatureC(), CELSIUS);
272 case CHANNEL_TIMESTAMP:
273 // It seem the Node timestamp is Unix timestamp converted from UTC time plus timezone offset.
274 // Not sure about DST though, but it's best guess at now
275 Instant instant = Instant.ofEpochMilli(nodeData.getStatus().getDatetime() * 1000L);
276 ZonedDateTime zonedDateTime = ZonedDateTime.ofInstant(instant, ZoneId.of("UTC"));
277 ZoneId zoneId = ZoneId.of(nodeData.getSettings().getTimezone());
278 ZoneRules zoneRules = zoneId.getRules();
279 zonedDateTime.minus(Duration.ofSeconds(zoneRules.getOffset(instant).getTotalSeconds()));
280 if (zoneRules.isDaylightSavings(instant)) {
281 zonedDateTime.minus(Duration.ofSeconds(zoneRules.getDaylightSavings(instant).getSeconds()));
283 state = new DateTimeType(zonedDateTime);
285 case CHANNEL_USED_MEMORY:
286 state = new DecimalType(BigDecimal.valueOf(nodeData.getStatus().getUsedMemory()).longValue());