Finalizing binding

Signed-off-by: Gaël L'hopital <gael@lhopital.org>
This commit is contained in:
Gaël L'hopital 2024-11-15 14:42:08 +01:00
parent 272fb89d33
commit dc1ac4d86e
10 changed files with 542 additions and 187 deletions

View File

@ -45,6 +45,8 @@ public class AirParifBindingConstants {
public static final String CHANNEL_COMMENT = "comment";
public static final String CHANNEL_MESSAGE = "message";
public static final String CHANNEL_TOMORROW = "tomorrow";
public static final String CHANNEL_TIMESTAMP = "timestamp";
public static final String CHANNEL_VALUE = "value";
public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(APIBRIDGE_THING_TYPE,
LOCATION_THING_TYPE);

View File

@ -12,6 +12,7 @@
*/
package org.openhab.binding.airparif.internal.api;
import java.time.Duration;
import java.time.LocalDate;
import java.time.ZoneId;
import java.time.ZonedDateTime;
@ -19,6 +20,7 @@ import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
@ -29,6 +31,9 @@ import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.airparif.internal.api.AirParifApi.Pollen;
import org.openhab.binding.airparif.internal.api.AirParifApi.Scope;
import org.openhab.core.library.types.StringType;
import org.openhab.core.types.State;
import org.openhab.core.types.UnDefType;
import com.google.gson.annotations.SerializedName;
@ -74,6 +79,10 @@ public class AirParifDto {
public String dayDescription() {
return bulletin.fr;
}
public boolean isToday() {
return previsionDate.equals(LocalDate.now());
}
}
public record DailyEpisode(//
@ -106,28 +115,51 @@ public class AirParifDto {
private static ZoneId DEFAULT_ZONE = ZoneId.of("Europe/Paris");
public List<Pollens> data = List.of();
private @Nullable Set<ZonedDateTime> validities;
private @Nullable ZonedDateTime beginValidity;
private @Nullable ZonedDateTime endValidity;
public Optional<Pollens> getData() {
return Optional.ofNullable(data.isEmpty() ? null : data.get(0));
}
private Set<ZonedDateTime> getValidities() {
Set<ZonedDateTime> result = new TreeSet<>();
getData().ifPresent(pollens -> {
Matcher matcher = PATTERN.matcher(pollens.periode);
while (matcher.find()) {
result.add(LocalDate.parse(matcher.group(), FORMATTER).atStartOfDay(DEFAULT_ZONE));
}
});
return result;
Set<ZonedDateTime> local;
if (validities != null) {
local = validities;
} else {
local = new TreeSet<>();
getData().ifPresent(pollens -> {
Matcher matcher = PATTERN.matcher(pollens.periode);
while (matcher.find()) {
local.add(LocalDate.parse(matcher.group(), FORMATTER).atStartOfDay(DEFAULT_ZONE));
}
});
validities = local;
}
return local;
}
public Optional<ZonedDateTime> getBeginValidity() {
return Optional.ofNullable(getValidities().iterator().next());
if (beginValidity == null) {
beginValidity = getValidities().iterator().next();
}
return Optional.ofNullable(beginValidity);
}
public Optional<ZonedDateTime> getEndValidity() {
return Optional.ofNullable(getValidities().stream().reduce((prev, next) -> next).orElse(null));
if (endValidity == null) {
endValidity = getValidities().stream().reduce((prev, next) -> next).orElse(null);
}
return Optional.ofNullable(endValidity);
}
public Duration getValidityDuration() {
return Objects.requireNonNull(getEndValidity().map(end -> {
Duration duration = Duration.between(ZonedDateTime.now().withZoneSameInstant(end.getZone()), end);
return duration.isNegative() ? Duration.ZERO : duration;
}).orElse(Duration.ZERO));
}
public Optional<String> getComment() {
@ -150,17 +182,25 @@ public class AirParifDto {
}
}
public record Result(//
public record Concentration(//
@SerializedName("polluant") Pollutant pollutant, //
ZonedDateTime date, //
@SerializedName("valeurs") double[] values, //
Message message) {
@Nullable Message message) {
public State getMessage() {
return message != null ? new StringType(message.fr()) : UnDefType.NULL;
}
public double getValue() {
return values[0];
}
}
public record Route(//
@SerializedName("dateRequise") ZonedDateTime requestedDate, //
double[][] longlats, //
@SerializedName("resultats") Result[] results, //
@SerializedName("resultats") List<Concentration> concentrations, //
@Nullable Message[] messages) {
}

View File

@ -21,12 +21,15 @@ import java.net.URI;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
@ -46,6 +49,7 @@ import org.eclipse.jetty.http.HttpStatus;
import org.eclipse.jetty.http.HttpStatus.Code;
import org.openhab.binding.airparif.internal.AirParifException;
import org.openhab.binding.airparif.internal.AirParifIconProvider;
import org.openhab.binding.airparif.internal.api.AirParifApi.Pollen;
import org.openhab.binding.airparif.internal.api.AirParifDto.Bulletin;
import org.openhab.binding.airparif.internal.api.AirParifDto.Episode;
import org.openhab.binding.airparif.internal.api.AirParifDto.ItineraireResponse;
@ -54,18 +58,23 @@ import org.openhab.binding.airparif.internal.api.AirParifDto.PollensResponse;
import org.openhab.binding.airparif.internal.api.AirParifDto.Route;
import org.openhab.binding.airparif.internal.api.AirParifDto.Version;
import org.openhab.binding.airparif.internal.api.ColorMap;
import org.openhab.binding.airparif.internal.api.PollenAlertLevel;
import org.openhab.binding.airparif.internal.api.Pollutant;
import org.openhab.binding.airparif.internal.config.BridgeConfiguration;
import org.openhab.binding.airparif.internal.deserialization.AirParifDeserializer;
import org.openhab.core.library.types.DateTimeType;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.types.StringType;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.ChannelGroupUID;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.thing.ThingUID;
import org.openhab.core.thing.binding.BaseBridgeHandler;
import org.openhab.core.types.Command;
import org.openhab.core.types.State;
import org.openhab.core.types.UnDefType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@ -78,7 +87,7 @@ import org.slf4j.LoggerFactory;
*
*/
@NonNullByDefault
public class AirParifBridgeHandler extends BaseBridgeHandler {
public class AirParifBridgeHandler extends BaseBridgeHandler implements HandlerUtils {
private static final int REQUEST_TIMEOUT_MS = (int) TimeUnit.SECONDS.toMillis(30);
private static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8;
@ -86,11 +95,10 @@ public class AirParifBridgeHandler extends BaseBridgeHandler {
private final AirParifDeserializer deserializer;
private final AirParifIconProvider iconProvider;
private final HttpClient httpClient;
private final Map<String, ScheduledFuture<?>> jobs = new HashMap<>();
private BridgeConfiguration config = new BridgeConfiguration();
private @Nullable ScheduledFuture<?> pollensJob;
private @Nullable ScheduledFuture<?> dailyJob;
private @Nullable PollensResponse pollens;
public AirParifBridgeHandler(Bridge bridge, HttpClient httpClient, AirParifDeserializer deserializer,
AirParifIconProvider iconProvider) {
@ -112,19 +120,10 @@ public class AirParifBridgeHandler extends BaseBridgeHandler {
scheduler.execute(this::initiateConnexion);
}
private @Nullable ScheduledFuture<?> cancelFuture(@Nullable ScheduledFuture<?> job) {
if (job != null && !job.isCancelled()) {
job.cancel(true);
}
return null;
}
@Override
public void dispose() {
logger.debug("Disposing the AirParif bridge handler.");
pollensJob = cancelFuture(pollensJob);
dailyJob = cancelFuture(dailyJob);
cleanJobs();
}
public synchronized String executeUri(URI uri, HttpMethod method, @Nullable String payload)
@ -154,10 +153,9 @@ public class AirParifBridgeHandler extends BaseBridgeHandler {
} else if (statusCode == Code.FORBIDDEN) {
throw new AirParifException("@text/offline.config-error-invalid-apikey");
}
String content = new String(response.getContent(), DEFAULT_CHARSET);
throw new AirParifException("Error '%s' requesting: %s", statusCode.getMessage(), uri.toString());
} catch (TimeoutException | ExecutionException e) {
throw new AirParifException(e, "Exception while calling %s", request.getURI());
throw new AirParifException(e, "Exception while calling %s: %s", request.getURI(), e.getMessage());
} catch (InterruptedException e) {
throw new AirParifException(e, "Execution interrupted: %s", e.getMessage());
}
@ -180,7 +178,7 @@ public class AirParifBridgeHandler extends BaseBridgeHandler {
private void initiateConnexion() {
Version version;
try { // This does validate communication with the server
try { // This is only intended to validate communication with the server
version = executeUri(VERSION_URI, Version.class);
} catch (AirParifException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
@ -195,9 +193,9 @@ public class AirParifBridgeHandler extends BaseBridgeHandler {
return;
}
getThing().setProperty("api-version", version.version());
getThing().setProperty("key-expiration", keyInfo.expiration().toString());
getThing().setProperty("scopes", keyInfo.scopes().stream().map(e -> e.name()).collect(Collectors.joining(",")));
thing.setProperty("api-version", version.version());
thing.setProperty("key-expiration", keyInfo.expiration().toString());
thing.setProperty("scopes", keyInfo.scopes().stream().map(e -> e.name()).collect(Collectors.joining(",")));
logger.info("The api key is valid until {}", keyInfo.expiration().toString());
updateStatus(ThingStatus.ONLINE);
@ -209,73 +207,92 @@ public class AirParifBridgeHandler extends BaseBridgeHandler {
logger.warn("Error reading ColorMap: {]", e.getMessage());
}
pollensJob = scheduler.schedule(this::updatePollens, 1, TimeUnit.SECONDS);
dailyJob = scheduler.schedule(this::updateDaily, 2, TimeUnit.SECONDS);
ThingUID thingUID = thing.getUID();
schedule("Pollens Update", () -> updatePollens(new ChannelGroupUID(thingUID, GROUP_POLLENS)),
Duration.ofSeconds(1));
schedule("Air Quality Bulletin", () -> updateDailyAQBulletin(new ChannelGroupUID(thingUID, GROUP_AQ_BULLETIN),
new ChannelGroupUID(thingUID, GROUP_AQ_BULLETIN_TOMORROW)), Duration.ofSeconds(2));
schedule("Episode", () -> updateEpisode(new ChannelGroupUID(thingUID, GROUP_DAILY)), Duration.ofSeconds(3));
}
private void updateDaily() {
private void updatePollens(ChannelGroupUID pollensGroupUID) {
PollensResponse localPollens;
try {
Bulletin bulletin = executeUri(PREV_BULLETIN_URI, Bulletin.class);
logger.debug("The bulletin is {}", bulletin.today().dayDescription());
Set.of(bulletin.today(), bulletin.tomorrow()).stream().forEach(aq -> {
String groupName = aq.previsionDate().equals(LocalDate.now()) ? GROUP_AQ_BULLETIN
: GROUP_AQ_BULLETIN_TOMORROW + "#";
updateState(groupName + CHANNEL_COMMENT,
!aq.available() ? UnDefType.UNDEF : new StringType(aq.bulletin().fr()));
aq.concentrations().forEach(measure -> {
String cName = groupName + measure.pollutant().name().toLowerCase();
updateState(cName + "-min", !aq.available() ? UnDefType.UNDEF
: new QuantityType<>(measure.min(), measure.pollutant().unit));
updateState(cName + "-max", !aq.available() ? UnDefType.UNDEF
: new QuantityType<>(measure.max(), measure.pollutant().unit));
});
});
Episode episode = executeUri(EPISODES_URI, Episode.class);
logger.debug("The episode is {}", episode);
// if (episode.active()) {
// updateState(GROUP_DAILY + "#" + CHANNEL_MESSAGE, new StringType(episode.message().fr()));
// updateState(GROUP_DAILY + "#" + CHANNEL_TOMORROW, new StringType(episode.message().fr()));
// }
ZonedDateTime tomorrowMorning = ZonedDateTime.now().plusDays(1).truncatedTo(ChronoUnit.DAYS).plusMinutes(1);
long delay = Duration.between(ZonedDateTime.now(), tomorrowMorning).getSeconds();
logger.debug("Rescheduling daily job tomorrow morning");
dailyJob = scheduler.schedule(this::updateDaily, delay, TimeUnit.SECONDS);
} catch (AirParifException e) {
logger.warn("Error update pollens data: {}", e.getMessage());
}
}
private void updatePollens() {
try {
PollensResponse pollens = executeUri(POLLENS_URI, PollensResponse.class);
pollens.getComment()
.ifPresent(comment -> updateState(GROUP_POLLENS + "#" + CHANNEL_COMMENT, new StringType(comment)));
pollens.getBeginValidity().ifPresent(
begin -> updateState(GROUP_POLLENS + "#" + CHANNEL_BEGIN_VALIDITY, new DateTimeType(begin)));
pollens.getEndValidity().ifPresent(end -> {
updateState(GROUP_POLLENS + "#" + CHANNEL_END_VALIDITY, new DateTimeType(end));
logger.info("Pollens bulletin valid until {}", end);
long delay = Duration.between(ZonedDateTime.now(), end).getSeconds();
if (delay < 0) {
// what if the bulletin was not updated and the delay is passed ?
delay = 3600;
logger.debug("Update time of the bulletin is in the past - will retry in one hour");
} else {
delay += 60;
}
pollensJob = scheduler.schedule(this::updatePollens, delay, TimeUnit.SECONDS);
});
getThing().getThings().stream().map(Thing::getHandler).filter(LocationHandler.class::isInstance)
.map(LocationHandler.class::cast).forEach(locHand -> locHand.setPollens(pollens));
localPollens = executeUri(POLLENS_URI, PollensResponse.class);
} catch (AirParifException e) {
logger.warn("Error updating pollens data: {}", e.getMessage());
return;
}
updateState(new ChannelUID(pollensGroupUID, CHANNEL_COMMENT), Objects.requireNonNull(
localPollens.getComment().map(comment -> (State) new StringType(comment)).orElse(UnDefType.NULL)));
updateState(new ChannelUID(pollensGroupUID, CHANNEL_BEGIN_VALIDITY), Objects.requireNonNull(
localPollens.getBeginValidity().map(begin -> (State) new DateTimeType(begin)).orElse(UnDefType.NULL)));
updateState(new ChannelUID(pollensGroupUID, CHANNEL_END_VALIDITY), Objects.requireNonNull(
localPollens.getEndValidity().map(end -> (State) new DateTimeType(end)).orElse(UnDefType.NULL)));
long delay = localPollens.getValidityDuration().getSeconds();
// if delay is null, update in 3600 seconds
delay += delay == 0 ? 3600 : 60;
schedule("Pollens Update", () -> updatePollens(pollensGroupUID), Duration.ofSeconds(delay));
// Send pollens information to childs
getThing().getThings().stream().map(Thing::getHandler).filter(LocationHandler.class::isInstance)
.map(LocationHandler.class::cast).forEach(locHand -> locHand.setPollens(localPollens));
pollens = localPollens;
}
private void updateDailyAQBulletin(ChannelGroupUID todayGroupUID, ChannelGroupUID tomorrowGroupUID) {
Bulletin bulletin;
try {
bulletin = executeUri(PREV_BULLETIN_URI, Bulletin.class);
} catch (AirParifException e) {
logger.warn("Error updating Air Quality Bulletin: {}", e.getMessage());
return;
}
Set.of(bulletin.today(), bulletin.tomorrow()).stream().forEach(aq -> {
ChannelGroupUID groupUID = aq.isToday() ? todayGroupUID : tomorrowGroupUID;
updateState(new ChannelUID(groupUID, CHANNEL_COMMENT),
!aq.available() ? UnDefType.UNDEF : new StringType(aq.bulletin().fr()));
aq.concentrations().forEach(measure -> {
Pollutant pollutant = measure.pollutant();
String cName = pollutant.name().toLowerCase() + "-";
updateState(new ChannelUID(groupUID, cName + "min"),
aq.available() ? new QuantityType<>(measure.min(), pollutant.unit) : UnDefType.UNDEF);
updateState(new ChannelUID(groupUID, cName + "max"),
aq.available() ? new QuantityType<>(measure.max(), pollutant.unit) : UnDefType.UNDEF);
});
});
ZonedDateTime tomorrowMorning = ZonedDateTime.now().plusDays(1).truncatedTo(ChronoUnit.DAYS).plusMinutes(1);
logger.debug("Rescheduling daily air quality bulletin job tomorrow morning");
schedule("Air Quality Bulletin", () -> updateDailyAQBulletin(todayGroupUID, tomorrowGroupUID),
Duration.between(ZonedDateTime.now(), tomorrowMorning));
}
private void updateEpisode(ChannelGroupUID dailyGroupUID) {
Episode episode;
try {
episode = executeUri(EPISODES_URI, Episode.class);
} catch (AirParifException e) {
logger.warn("Error updating Episode: {}", e.getMessage());
return;
}
logger.debug("The episode is {}", episode);
updateState(new ChannelUID(dailyGroupUID, CHANNEL_MESSAGE), new StringType(episode.message().fr()));
updateState(new ChannelUID(dailyGroupUID, CHANNEL_TOMORROW), new StringType(episode.message().fr()));
// Set.of(episode.today(), episode.tomorrow()).stream().forEach(aq -> {
// });
ZonedDateTime tomorrowMorning = ZonedDateTime.now().plusDays(1).truncatedTo(ChronoUnit.DAYS).plusMinutes(1);
schedule("Episode", () -> updateEpisode(dailyGroupUID), Duration.between(ZonedDateTime.now(), tomorrowMorning));
}
public @Nullable Route getConcentrations(String location) {
@ -294,4 +311,34 @@ public class AirParifBridgeHandler extends BaseBridgeHandler {
}
return null;
}
public Map<Pollen, PollenAlertLevel> requestPollens(String department) {
PollensResponse localPollens = pollens;
return localPollens != null ? localPollens.getDepartment(department) : Map.of();
}
@Override
public @Nullable Bridge getBridge() {
return super.getBridge();
}
@Override
public void updateStatus(ThingStatus status, ThingStatusDetail statusDetail, @Nullable String description) {
super.updateStatus(status, statusDetail, description);
}
@Override
public ScheduledExecutorService getScheduler() {
return scheduler;
}
@Override
public Logger getLogger() {
return logger;
}
@Override
public Map<String, ScheduledFuture<?>> getJobs() {
return jobs;
}
}

View File

@ -0,0 +1,89 @@
/**
* Copyright (c) 2010-2024 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.airparif.internal.handler;
import java.time.Duration;
import java.util.Map;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.thing.binding.BridgeHandler;
import org.slf4j.Logger;
/**
* The {@link HandlerUtils} defines and implements some common methods for Thing Handlers
*
* @author Gaël L'hopital - Initial contribution
*/
@NonNullByDefault
public interface HandlerUtils {
default @Nullable ScheduledFuture<?> cancelFuture(@Nullable ScheduledFuture<?> job) {
if (job != null && !job.isCancelled()) {
job.cancel(true);
}
return null;
}
@SuppressWarnings("unchecked")
default @Nullable <T extends BridgeHandler> T getBridgeHandler(Class<T> clazz) {
Bridge bridge = getBridge();
if (bridge != null && bridge.getStatus() == ThingStatus.ONLINE) {
BridgeHandler bridgeHandler = bridge.getHandler();
if (bridgeHandler != null) {
if (bridgeHandler.getClass() == clazz) {
return (T) bridgeHandler;
} else {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "@text/incorrect-bridge");
}
} else {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "@text/incorrect-bridge");
}
} else {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE, "");
}
return null;
}
default void schedule(String jobName, Runnable job, Duration duration) {
ScheduledFuture<?> result = getJobs().remove(jobName);
String operation = "Scheduling";
if (result != null) {
operation = "Rescheduled";
cancelFuture(result);
}
getLogger().info("{} {} in {}", operation, jobName, duration);
getJobs().put(jobName, getScheduler().schedule(job, duration.getSeconds(), TimeUnit.SECONDS));
}
default void cleanJobs() {
getJobs().values().forEach(job -> cancelFuture(job));
getJobs().clear();
}
void updateStatus(ThingStatus status, ThingStatusDetail statusDetail, @Nullable String description);
@Nullable
Bridge getBridge();
ScheduledExecutorService getScheduler();
Logger getLogger();
Map<String, ScheduledFuture<?>> getJobs();
}

View File

@ -12,9 +12,13 @@
*/
package org.openhab.binding.airparif.internal.handler;
import static org.openhab.binding.airparif.internal.AirParifBindingConstants.GROUP_POLLENS;
import static org.openhab.binding.airparif.internal.AirParifBindingConstants.*;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
@ -23,8 +27,11 @@ import org.openhab.binding.airparif.internal.api.AirParifDto.PollensResponse;
import org.openhab.binding.airparif.internal.api.AirParifDto.Route;
import org.openhab.binding.airparif.internal.api.PollenAlertLevel;
import org.openhab.binding.airparif.internal.config.LocationConfiguration;
import org.openhab.core.library.types.DateTimeType;
import org.openhab.core.library.types.DecimalType;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.ChannelGroupUID;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus;
@ -41,11 +48,13 @@ import org.slf4j.LoggerFactory;
* @author Gaël L'hopital - Initial contribution
*/
@NonNullByDefault
public class LocationHandler extends BaseThingHandler {
public class LocationHandler extends BaseThingHandler implements HandlerUtils {
private final Logger logger = LoggerFactory.getLogger(LocationHandler.class);
private final Map<String, ScheduledFuture<?>> jobs = new HashMap<>();
private @Nullable LocationConfiguration config;
private Map<Pollen, PollenAlertLevel> myPollens = Map.of();
public LocationHandler(Thing thing) {
super(thing);
@ -55,46 +64,85 @@ public class LocationHandler extends BaseThingHandler {
public void initialize() {
config = getConfigAs(LocationConfiguration.class);
updateStatus(ThingStatus.UNKNOWN);
schedule("Local Air Quality", this::getConcentrations, Duration.ofSeconds(2));
}
scheduler.execute(this::getConcentrations);
@Override
public void dispose() {
logger.debug("Disposing the AirParif bridge handler.");
cleanJobs();
}
public void setPollens(PollensResponse pollens) {
LocationConfiguration local = config;
if (local != null) {
Map<Pollen, PollenAlertLevel> alerts = pollens.getDepartment(local.department);
alerts.forEach((pollen, level) -> {
updateState(GROUP_POLLENS + "#" + pollen.name().toLowerCase(), new DecimalType(level.ordinal()));
});
updatePollenChannels(pollens.getDepartment(local.department));
updateStatus(ThingStatus.ONLINE);
}
}
private void updatePollenChannels(Map<Pollen, PollenAlertLevel> pollens) {
ChannelGroupUID pollensUID = new ChannelGroupUID(thing.getUID(), GROUP_POLLENS);
myPollens = pollens;
pollens.forEach((pollen, level) -> updateState(new ChannelUID(pollensUID, pollen.name().toLowerCase()),
new DecimalType(level.ordinal())));
}
private void getConcentrations() {
AirParifBridgeHandler apiHandler = getApiBridgeHandler();
AirParifBridgeHandler apiHandler = getBridgeHandler(AirParifBridgeHandler.class);
LocationConfiguration local = config;
long delay = 3600;
if (apiHandler != null && local != null) {
if (myPollens.isEmpty()) {
updatePollenChannels(apiHandler.requestPollens(local.department));
}
Route route = apiHandler.getConcentrations(local.location);
if (route != null) {
route.concentrations().forEach(concentration -> {
ChannelGroupUID groupUID = new ChannelGroupUID(thing.getUID(),
concentration.pollutant().name().toLowerCase());
updateState(new ChannelUID(groupUID, CHANNEL_TIMESTAMP), new DateTimeType(concentration.date()));
updateState(new ChannelUID(groupUID, CHANNEL_MESSAGE), concentration.getMessage());
updateState(new ChannelUID(groupUID, CHANNEL_VALUE),
new QuantityType<>(concentration.getValue(), concentration.pollutant().unit));
});
updateStatus(ThingStatus.ONLINE);
}
} else {
delay = 10;
}
schedule("Local Air Quality", this::getConcentrations, Duration.ofSeconds(delay));
}
@Override
public void handleCommand(ChannelUID channelUID, Command command) {
// TODO Auto-generated method stub
}
private @Nullable AirParifBridgeHandler getApiBridgeHandler() {
Bridge bridge = this.getBridge();
if (bridge != null && bridge.getStatus() == ThingStatus.ONLINE) {
if (bridge.getHandler() instanceof AirParifBridgeHandler airParifBridgeHandler) {
return airParifBridgeHandler;
} else {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "@text/incorrect-bridge");
}
} else {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
}
return null;
@Override
public @Nullable Bridge getBridge() {
return super.getBridge();
}
@Override
public void updateStatus(ThingStatus status, ThingStatusDetail statusDetail, @Nullable String description) {
super.updateStatus(status, statusDetail, description);
}
@Override
public ScheduledExecutorService getScheduler() {
return scheduler;
}
@Override
public Logger getLogger() {
return logger;
}
@Override
public Map<String, ScheduledFuture<?>> getJobs() {
return jobs;
}
}

View File

@ -7,8 +7,14 @@ addon.airparif.description = Air Quality data and forecasts provided by AirParif
thing-type.airparif.api.label = AirParif API Portal
thing-type.airparif.api.description = Bridge to the AirParif API Portal. In order to receive the data, you must register an account on https://www.airparif.fr/contact and receive your API token.
thing-type.airparif.api.group.aq-bulletin.label = Today's Air Quality Bulletin
thing-type.airparif.api.group.aq-bulletin-tomorrow.label = Tomorrow's Air Quality Bulletin
thing-type.airparif.location.label = Department Report
thing-type.airparif.location.description = AirParif air quality report for the given location
thing-type.airparif.location.group.no2.label = NO2 Concentration Information
thing-type.airparif.location.group.o3.label = Ozone Concentration Information
thing-type.airparif.location.group.pm10.label = PM10 Concentration Information
thing-type.airparif.location.group.pm25.label = PM2.5 Concentration Information
# thing types config
@ -28,6 +34,25 @@ thing-type.config.airparif.location.location.label = Location
# channel group types
channel-group-type.airparif.air-quality-bulletin.label = Air Quality Bulletin
channel-group-type.airparif.air-quality-bulletin.channel.comment.label = Message
channel-group-type.airparif.air-quality-bulletin.channel.comment.description = General message for the air quality bulletin
channel-group-type.airparif.air-quality-bulletin.channel.no2-max.label = NO2 Max
channel-group-type.airparif.air-quality-bulletin.channel.no2-max.description = Maximum level of NO2 concentation
channel-group-type.airparif.air-quality-bulletin.channel.no2-min.label = NO2 Min
channel-group-type.airparif.air-quality-bulletin.channel.no2-min.description = Minimum level of NO2 concentation
channel-group-type.airparif.air-quality-bulletin.channel.o3-max.label = O3 Max
channel-group-type.airparif.air-quality-bulletin.channel.o3-max.description = Maximum level of O3 concentation
channel-group-type.airparif.air-quality-bulletin.channel.o3-min.label = O3 Min
channel-group-type.airparif.air-quality-bulletin.channel.o3-min.description = Minimum level of O3 concentation
channel-group-type.airparif.air-quality-bulletin.channel.pm10-max.label = PM 10 Max
channel-group-type.airparif.air-quality-bulletin.channel.pm10-max.description = Maximum level of PM 10 concentation
channel-group-type.airparif.air-quality-bulletin.channel.pm10-min.label = PM 10 Min
channel-group-type.airparif.air-quality-bulletin.channel.pm10-min.description = Minimum level of PM 10 concentation
channel-group-type.airparif.air-quality-bulletin.channel.pm25-max.label = PM 2.5 Max
channel-group-type.airparif.air-quality-bulletin.channel.pm25-max.description = Maximum level of PM 2.5 concentation
channel-group-type.airparif.air-quality-bulletin.channel.pm25-min.label = PM 2.5 Min
channel-group-type.airparif.air-quality-bulletin.channel.pm25-min.description = Minimum level of PM 2.5 concentation
channel-group-type.airparif.bridge-pollens.label = Pollen information for the region
channel-group-type.airparif.bridge-pollens.channel.begin-validity.label = Begin Validity
channel-group-type.airparif.bridge-pollens.channel.begin-validity.description = Current bulletin validity start
@ -35,7 +60,33 @@ channel-group-type.airparif.bridge-pollens.channel.comment.label = Begin Validit
channel-group-type.airparif.bridge-pollens.channel.comment.description = Current bulletin validity start
channel-group-type.airparif.bridge-pollens.channel.end-validity.label = End Validity
channel-group-type.airparif.bridge-pollens.channel.end-validity.description = Current bulletin validity ending
channel-group-type.airparif.daily.label = Daily information for the region
channel-group-type.airparif.daily.channel.message.label = Message
channel-group-type.airparif.daily.channel.message.description = Current bulletin validity start
channel-group-type.airparif.daily.channel.tomorrow.label = Tomorrow
channel-group-type.airparif.daily.channel.tomorrow.description = Current bulletin validity start
channel-group-type.airparif.dept-pollens.label = Pollen information for the department
channel-group-type.airparif.pollutant-mpc.label = Pollutant Concentration Information
channel-group-type.airparif.pollutant-mpc.channel.message.label = Message
channel-group-type.airparif.pollutant-mpc.channel.message.description = Polllutant concentration alert message
channel-group-type.airparif.pollutant-mpc.channel.timestamp.label = Timestamp
channel-group-type.airparif.pollutant-mpc.channel.timestamp.description = Timestamp of the measure
channel-group-type.airparif.pollutant-mpc.channel.value.label = Concentration
channel-group-type.airparif.pollutant-mpc.channel.value.description = Concentration of the given pollutant
channel-group-type.airparif.pollutant-ndx.label = Global Pollutant Index
channel-group-type.airparif.pollutant-ndx.channel.message.label = Message
channel-group-type.airparif.pollutant-ndx.channel.message.description = Alert message associated to the value of the index
channel-group-type.airparif.pollutant-ndx.channel.timestamp.label = Timestamp
channel-group-type.airparif.pollutant-ndx.channel.timestamp.description = Timestamp of the evaluation
channel-group-type.airparif.pollutant-ndx.channel.value.label = Value
channel-group-type.airparif.pollutant-ndx.channel.value.description = Value of the global Index
channel-group-type.airparif.pollutant-ppb.label = Pollutant Concentration Information
channel-group-type.airparif.pollutant-ppb.channel.message.label = Message
channel-group-type.airparif.pollutant-ppb.channel.message.description = Polllutant concentration alert message
channel-group-type.airparif.pollutant-ppb.channel.timestamp.label = Timestamp
channel-group-type.airparif.pollutant-ppb.channel.timestamp.description = Timestamp of the measure
channel-group-type.airparif.pollutant-ppb.channel.value.label = Concentration
channel-group-type.airparif.pollutant-ppb.channel.value.description = Concentration of the given pollutant
# channel types
@ -85,6 +136,8 @@ channel-type.airparif.linden-level.state.option.0 = None
channel-type.airparif.linden-level.state.option.1 = Low
channel-type.airparif.linden-level.state.option.2 = Average
channel-type.airparif.linden-level.state.option.3 = High
channel-type.airparif.mpc-value.label = Measure
channel-type.airparif.ndx-value.label = Measure
channel-type.airparif.oak-level.label = Oak
channel-type.airparif.oak-level.state.option.0 = None
channel-type.airparif.oak-level.state.option.1 = Low
@ -110,6 +163,7 @@ channel-type.airparif.poplar-level.state.option.0 = None
channel-type.airparif.poplar-level.state.option.1 = Low
channel-type.airparif.poplar-level.state.option.2 = Average
channel-type.airparif.poplar-level.state.option.3 = High
channel-type.airparif.ppb-value.label = Measure
channel-type.airparif.ragweed-level.label = Ragweed
channel-type.airparif.ragweed-level.state.option.0 = None
channel-type.airparif.ragweed-level.state.option.1 = Low

View File

@ -13,10 +13,12 @@
<channel-groups>
<channel-group id="pollens" typeId="bridge-pollens"/>
<channel-group id="aq-bulletin" typeId="air-quality-bulletin">
<label>Today's Air Quality Bulletin</label></channel-group>
<channel-group id="aq-bulletin-tomorrow" typeId="air-quality-bulletin">
<label>Tomorrow's Air Quality Bulletin</label></channel-group>
<channel-group id="aq-bulletin" typeId="air-quality-bulletin">
<label>Today's Air Quality Bulletin</label>
</channel-group>
<channel-group id="aq-bulletin-tomorrow" typeId="air-quality-bulletin">
<label>Tomorrow's Air Quality Bulletin</label>
</channel-group>
<channel-group id="daily" typeId="daily"/>
</channel-groups>

View File

@ -22,61 +22,115 @@
</channels>
</channel-group-type>
<channel-group-type id="air-quality-bulletin">
<label>Air Quality Bulletin</label>
<channels>
<channel id="comment" typeId="comment">
<label>Message</label>
<description>General message for the air quality bulletin</description>
</channel>
<channel id="no2-min" typeId="ppb-value">
<label>NO2 Min</label>
<description>Minimum level of NO2 concentation</description>
</channel>
<channel id="no2-max" typeId="ppb-value">
<label>NO2 Max</label>
<description>Maximum level of NO2 concentation</description>
</channel>
<channel id="o3-min" typeId="ppb-value">
<label>O3 Min</label>
<description>Minimum level of O3 concentation</description>
</channel>
<channel id="o3-max" typeId="ppb-value">
<label>O3 Max</label>
<description>Maximum level of O3 concentation</description>
</channel>
<channel id="pm10-min" typeId="mpc-value">
<label>PM 10 Min</label>
<description>Minimum level of PM 10 concentation</description>
</channel>
<channel id="pm10-max" typeId="mpc-value">
<label>PM 10 Max</label>
<description>Maximum level of PM 10 concentation</description>
</channel>
<channel id="pm25-min" typeId="mpc-value">
<label>PM 2.5 Min</label>
<description>Minimum level of PM 2.5 concentation</description>
</channel>
<channel id="pm25-max" typeId="mpc-value">
<label>PM 2.5 Max</label>
<description>Maximum level of PM 2.5 concentation</description>
</channel>
</channels>
</channel-group-type>
<channel-group-type id="air-quality-bulletin">
<label>Air Quality Bulletin</label>
<channels>
<channel id="comment" typeId="comment">
<label>Message</label>
<description>General message for the air quality bulletin</description>
</channel>
<channel id="no2-min" typeId="ppb-value">
<label>NO2 Min</label>
<description>Minimum level of NO2 concentation</description>
</channel>
<channel id="no2-max" typeId="ppb-value">
<label>NO2 Max</label>
<description>Maximum level of NO2 concentation</description>
</channel>
<channel id="o3-min" typeId="ppb-value">
<label>O3 Min</label>
<description>Minimum level of O3 concentation</description>
</channel>
<channel id="o3-max" typeId="ppb-value">
<label>O3 Max</label>
<description>Maximum level of O3 concentation</description>
</channel>
<channel id="pm10-min" typeId="mpc-value">
<label>PM 10 Min</label>
<description>Minimum level of PM 10 concentation</description>
</channel>
<channel id="pm10-max" typeId="mpc-value">
<label>PM 10 Max</label>
<description>Maximum level of PM 10 concentation</description>
</channel>
<channel id="pm25-min" typeId="mpc-value">
<label>PM 2.5 Min</label>
<description>Minimum level of PM 2.5 concentation</description>
</channel>
<channel id="pm25-max" typeId="mpc-value">
<label>PM 2.5 Max</label>
<description>Maximum level of PM 2.5 concentation</description>
</channel>
</channels>
</channel-group-type>
<channel-group-type id="daily">
<label>Daily information for the region</label>
<channels>
<channel id="message" typeId="comment">
<label>Message</label>
<description>Current bulletin validity start</description>
</channel>
<channel id="tomorrow" typeId="comment">
<label>Tomorrow</label>
<description>Current bulletin validity start</description>
</channel>
</channels>
</channel-group-type>
<channel-group-type id="pollutant-mpc">
<label>Pollutant Concentration Information</label>
<channels>
<channel id="message" typeId="comment">
<label>Message</label>
<description>Polllutant concentration alert message</description>
</channel>
<channel id="timestamp" typeId="timestamp">
<label>Timestamp</label>
<description>Timestamp of the measure</description>
</channel>
<channel id="value" typeId="mpc-value">
<label>Concentration</label>
<description>Concentration of the given pollutant</description>
</channel>
</channels>
</channel-group-type>
<channel-group-type id="pollutant-ppb">
<label>Pollutant Concentration Information</label>
<channels>
<channel id="message" typeId="comment">
<label>Message</label>
<description>Polllutant concentration alert message</description>
</channel>
<channel id="timestamp" typeId="timestamp">
<label>Timestamp</label>
<description>Timestamp of the measure</description>
</channel>
<channel id="value" typeId="ppb-value">
<label>Concentration</label>
<description>Concentration of the given pollutant</description>
</channel>
</channels>
</channel-group-type>
<channel-group-type id="pollutant-ndx">
<label>Global Pollutant Index</label>
<channels>
<channel id="message" typeId="comment">
<label>Message</label>
<description>Alert message associated to the value of the index</description>
</channel>
<channel id="timestamp" typeId="timestamp">
<label>Timestamp</label>
<description>Timestamp of the evaluation</description>
</channel>
<channel id="value" typeId="ndx-value">
<label>Value</label>
<description>Value of the global Index</description>
</channel>
</channels>
</channel-group-type>
<channel-group-type id="daily">
<label>Daily information for the region</label>
<channels>
<channel id="message" typeId="comment">
<label>Message</label>
<description>Current bulletin validity start</description>
</channel>
<channel id="tomorrow" typeId="comment">
<label>Tomorrow</label>
<description>Current bulletin validity start</description>
</channel>
</channels>
</channel-group-type>
<channel-group-type id="dept-pollens">
<label>Pollen information for the department</label>

View File

@ -126,7 +126,7 @@
<channel-type id="urticaceae-level">
<item-type>Number</item-type>
<label>Urticacea</label>
<category>oh:airparif:urticacea</category>
<category>oh:airparif:urticaceae</category>
<state readOnly="true">
<options>
<option value="0">None</option>
@ -291,17 +291,23 @@
</state>
</channel-type>
<channel-type id="ppb-value">
<item-type unitHint="ppb">Number:Dimensionless</item-type>
<label>Measure</label>
<state readOnly="true" pattern="%.0f %unit%"/>
</channel-type>
<channel-type id="ppb-value">
<item-type unitHint="ppb">Number:Dimensionless</item-type>
<label>Measure</label>
<state readOnly="true" pattern="%.0f %unit%"/>
</channel-type>
<channel-type id="mpc-value">
<item-type unitHint="µg/m³">Number:Density</item-type>
<label>Measure</label>
<state readOnly="true" pattern="%.0f %unit%"/>
</channel-type>
<channel-type id="ndx-value">
<item-type>Number</item-type>
<label>Measure</label>
<state readOnly="true"/>
</channel-type>
<channel-type id="mpc-value">
<item-type unitHint="µg/m³">Number:Density</item-type>
<label>Measure</label>
<state readOnly="true" pattern="%.0f %unit%"/>
</channel-type>
</thing:thing-descriptions>

View File

@ -14,6 +14,19 @@
<channel-groups>
<channel-group id="pollens" typeId="dept-pollens"/>
<channel-group id="indice" typeId="pollutant-ndx"/>
<channel-group id="o3" typeId="pollutant-ppb">
<label>Ozone Concentration Information</label>
</channel-group>
<channel-group id="no2" typeId="pollutant-ppb">
<label>NO2 Concentration Information</label>
</channel-group>
<channel-group id="pm25" typeId="pollutant-mpc">
<label>PM2.5 Concentration Information</label>
</channel-group>
<channel-group id="pm10" typeId="pollutant-mpc">
<label>PM10 Concentration Information</label>
</channel-group>
</channel-groups>
<representation-property>department</representation-property>