]> git.basschouten.com Git - openhab-addons.git/blob
7f52e6fa20a2e46fd6ce748c9b9677eda5a95850
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.airvisualnode.internal.handler;
14
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;
23
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;
34 import java.util.Map;
35 import java.util.concurrent.ScheduledFuture;
36 import java.util.concurrent.TimeUnit;
37
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;
61
62 import com.google.gson.FieldNamingPolicy;
63 import com.google.gson.Gson;
64 import com.google.gson.GsonBuilder;
65
66 import jcifs.smb.NtlmPasswordAuthentication;
67 import jcifs.smb.SmbFile;
68 import jcifs.smb.SmbFileInputStream;
69
70 /**
71  * The {@link AirVisualNodeHandler} is responsible for handling commands, which are
72  * sent to one of the channels.
73  *
74  * @author Victor Antonovich - Initial contribution
75  */
76 @NonNullByDefault
77 public class AirVisualNodeHandler extends BaseThingHandler {
78
79     private final Logger logger = LoggerFactory.getLogger(AirVisualNodeHandler.class);
80
81     public static final String NODE_JSON_FILE = "latest_config_measurements.json";
82     private static final long DELAY_IN_MS = 500;
83
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;
93
94     public AirVisualNodeHandler(Thing thing) {
95         super(thing);
96         gson = new GsonBuilder().setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES).create();
97     }
98
99     @Override
100     public void initialize() {
101         logger.debug("Initializing AirVisual Node handler");
102
103         AirVisualNodeConfig config = getConfigAs(AirVisualNodeConfig.class);
104
105         if (config.address.isBlank()) {
106             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Node address must be set");
107             return;
108         }
109         if (config.password.isBlank()) {
110             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Node password must be set");
111             return;
112         }
113
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;
119
120         try {
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");
125             return;
126         }
127
128         if (!this.isProVersion) {
129             removeProChannels();
130         }
131
132         schedulePoll();
133     }
134
135     private void removeProChannels() {
136         List<Channel> channels = new ArrayList<>(getThing().getChannels());
137         channels.removeIf(channel -> isProChannel(channel.getLabel()));
138         replaceChannels(channels);
139     }
140
141     private boolean isProChannel(@Nullable String channelLabel) {
142         if (channelLabel == null || channelLabel.isBlank()) {
143             return false;
144         }
145         return "PM0.1".equals(channelLabel) || "PM10".equals(channelLabel);
146     }
147
148     private void replaceChannels(List<Channel> channels) {
149         ThingBuilder thingBuilder = editThing();
150         thingBuilder.withChannels(channels);
151         updateThing(thingBuilder.build());
152     }
153
154     @Override
155     public void handleCommand(ChannelUID channelUID, Command command) {
156         if (command instanceof RefreshType) {
157             updateChannel(channelUID.getId(), true);
158         } else {
159             logger.debug("Can not handle command '{}'", command);
160         }
161     }
162
163     @Override
164     public void handleRemoval() {
165         super.handleRemoval();
166         stopPoll();
167     }
168
169     @Override
170     public void dispose() {
171         super.dispose();
172         stopPoll();
173     }
174
175     private synchronized void stopPoll() {
176         ScheduledFuture<?> localFuture = pollFuture;
177         if (localFuture != null) {
178             localFuture.cancel(false);
179         }
180     }
181
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);
185     }
186
187     private void poll() {
188         try {
189             logger.debug("Polling for state");
190             pollNode();
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());
195         }
196     }
197
198     private void pollNode() throws IOException {
199         String jsonData = getNodeJsonData();
200
201         NodeDataInterface currentNodeData;
202         if (isProVersion) {
203             currentNodeData = gson.fromJson(jsonData, ProNodeData.class);
204         } else {
205             currentNodeData = gson.fromJson(jsonData, NodeData.class);
206         }
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);
214             }
215         }
216     }
217
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);
223         }
224     }
225
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);
232         }
233     }
234
235     private State getChannelState(String channelId, NodeDataInterface nodeData) {
236         State state = UnDefType.UNDEF;
237
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());
244         } else {
245             MeasurementsInterface measurements = nodeData.getMeasurements();
246             // Handle binding-specific channel IDs
247             switch (channelId) {
248                 case CHANNEL_CO2:
249                     state = new QuantityType<>(measurements.getCo2Ppm(), PARTS_PER_MILLION);
250                     break;
251                 case CHANNEL_HUMIDITY:
252                     state = new QuantityType<>(measurements.getHumidityRH(), PERCENT);
253                     break;
254                 case CHANNEL_AQI_US:
255                     state = new QuantityType<>(measurements.getPm25AQIUS(), ONE);
256                     break;
257                 case CHANNEL_PM_25:
258                     // PM2.5 is in ug/m3
259                     state = new QuantityType<>(measurements.getPm25Ugm3(), MICRO(GRAM).divide(CUBIC_METRE));
260                     break;
261                 case CHANNEL_PM_10:
262                     // PM10 is in ug/m3
263                     state = new QuantityType<>(measurements.getPm10Ugm3(), MICRO(GRAM).divide(CUBIC_METRE));
264                     break;
265                 case CHANNEL_PM_01:
266                     // PM0.1 is in ug/m3
267                     state = new QuantityType<>(measurements.getPm01Ugm3(), MICRO(GRAM).divide(CUBIC_METRE));
268                     break;
269                 case CHANNEL_TEMP_CELSIUS:
270                     state = new QuantityType<>(measurements.getTemperatureC(), CELSIUS);
271                     break;
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()));
282                     }
283                     state = new DateTimeType(zonedDateTime);
284                     break;
285                 case CHANNEL_USED_MEMORY:
286                     state = new DecimalType(BigDecimal.valueOf(nodeData.getStatus().getUsedMemory()).longValue());
287                     break;
288             }
289         }
290
291         return state;
292     }
293 }