diff --git a/CODEOWNERS b/CODEOWNERS index 1cc5357555..be72a3d33d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -34,6 +34,7 @@ /bundles/org.openhab.binding.astro/ @gerrieg /bundles/org.openhab.binding.asuswrt/ @wildcs /bundles/org.openhab.binding.atlona/ @mlobstein +/bundles/org.openhab.binding.atmofrance/ @clinique /bundles/org.openhab.binding.autelis/ @digitaldan /bundles/org.openhab.binding.automower/ @maxpg /bundles/org.openhab.binding.avmfritz/ @cweitkamp diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml index 05d81faf20..2d0d2af68e 100644 --- a/bom/openhab-addons/pom.xml +++ b/bom/openhab-addons/pom.xml @@ -161,6 +161,11 @@ org.openhab.binding.atlona ${project.version} + + org.openhab.addons.bundles + org.openhab.binding.atmofrance + ${project.version} + org.openhab.addons.bundles org.openhab.binding.autelis diff --git a/bundles/org.openhab.binding.atmofrance/NOTICE b/bundles/org.openhab.binding.atmofrance/NOTICE new file mode 100644 index 0000000000..f58c2e6843 --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/NOTICE @@ -0,0 +1,27 @@ +This content is produced and maintained by the openHAB project. + +* Project home: https://www.openhab.org + +== Declared Project Licenses + +This program and the accompanying materials are made available under the terms +of the Eclipse Public License 2.0 which is available at +https://www.eclipse.org/legal/epl-2.0/. + +== Source Code + +https://github.com/openhab/openhab-addons + +== Credits +Icon set coming from the noon project (https://thenounproject.com/): +Hazel, Ash : Imogen Oh +Birch, Oak : monkik +Cypress, Alder : Levi +Poplar, Rumex : Laymik +Willow : PizzaOter +Hornbeam, Linden : Cannavale +Olive : BnB Studio +Chestnut : Muhammad Fadli Rusady +Plantain : Lars Meiertoberens +Grasses : Neneng Yuliani Lestari +Ragweed, Wormwood : bsd studio \ No newline at end of file diff --git a/bundles/org.openhab.binding.atmofrance/README.md b/bundles/org.openhab.binding.atmofrance/README.md new file mode 100644 index 0000000000..9003cc350b --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/README.md @@ -0,0 +1,96 @@ +# Atmo France Binding + +This binding uses the [Atmo France service](https://www.atmo-france.org/) for providing air quality and pollens information in France. +To use it, you first need to [register and get your credentials](https://admindata.atmo-france.org/inscription-api). +You'll need your username and password. + +## Supported Things + +- `api`: bridge used to connect to the Atmo France service. +- `city`: Presents the pollens and air quality information for a given location. + +You can add multiple `city`, e.g. for gathering pollen or air quality data for different locations. + +## Thing Configuration + +### `api` Thing Configuration + +| Name | Type | Description | Default | Required | Advanced | +|-----------------|---------|------------------------------------------------|---------|----------|----------| +| username | text | Username of your Atmo Data API portal account | N/A | yes | no | +| password | text | Password of your Atmo Data API portal account | N/A | yes | no | + +### `city` Thing Configuration + +| Name | Type | Description | Default | Required | Advanced | +|-----------------|---------|---------------------------|---------|----------|----------| +| codeInsee | text | Insee code of the city | N/A | yes | no | + +### `city` Thing Channels + +| Group | Channel | Type | Read/Write | Description | +|---------|----------------|----------------|------------|----------------------------------------------------------| +| aq | index | Number | R | Air Quality index | +| aq | no2-index | Number | R | NO₂ (Nitrogen dioxide) | +| aq | so2-index | Number | R | SO₂ (Sulfur dioxide) | +| aq | o3-index | Number | R | O₃ (Ozone) | +| aq | pm10-index | Number | R | PM 10 (Particulate Matter < 10 µm) | +| aq | pm25-index | Number | R | PM 2.5 (Particulate Matter < 2,5 µm) | +| aq | effective-date | DateTime | R | Effective Date | +| aq | release-date | DateTime | R | Release Date | +| pollens | index | Number | R | Pollen Index | +| pollens | alder-conc | Number:Density | R | Alder concentration | +| pollens | alder-level | Number | R | Alder Index | +| pollens | birch-conc | Number:Density | R | Birch concentration | +| pollens | birch-level | Number | R | Birch Index | +| pollens | grasses-conc | Number:Density | R | Grasses concentration | +| pollens | grasses-level | Number | R | Grasses Index | +| pollens | olive-conc | Number:Density | R | Olive concentration | +| pollens | olive-level | Number | R | Olive Index | +| pollens | ragweed-conc | Number:Density | R | Ragweed concentration | +| pollens | ragweed-level | Number | R | Ragweed Index | +| pollens | wormwood-conc | Number:Density | R | Wormwood concentration | +| pollens | wormwood-level | Number | R | Wormwood Index | +| pollens | effective-date | DateTime | R | Effective Date | +| pollens | release-date | DateTime | R | Release Date | + +Air Quality index values: + +| Value | Meaning | +|-------|-----------------| +| 0 | Good | +| 1 | Average | +| 2 | Degraded | +| 3 | Bad | +| 4 | Very Bad | +| 5 | Extremely Bad | +| 7 | Event | +| 9 | Absent | + +Pollen index values: + +| Value | Meaning | +|-------|-----------------| +| 0 | Very Low | +| 1 | Low | +| 2 | Moderate | +| 3 | High | +| 4 | Very High | +| 5 | Extremely High | +| 9 | Absent | + +## Provided Icon Set + +This binding has its own IconProvider and makes available the following list of icons + +| Icon Name | Dynamic | Illustration | +|------------------------|---------|------------------------------------------| +| oh:atmofrance:aq | Yes | ![Air Quality](doc/images/aq.svg) | +| oh:atmofrance:pollen | Yes | ![Pollen](doc/images/pollen.svg) | +| oh:atmofrance:alder | Yes | ![Alder](doc/images/alder.svg) | +| oh:atmofrance:birch | Yes | ![Birch](doc/images/birch.svg) | +| oh:atmofrance:olive | Yes | ![Olive](doc/images/olive.svg) | +| oh:atmofrance:grasses | Yes | ![Grasses](doc/images/grasses.svg) | +| oh:atmofrance:ragweed | Yes | ![Ragweed](doc/images/ragweed.svg) | +| oh:atmofrance:wormwood | Yes | ![Wormwood](doc/images/wormwood.svg) | + diff --git a/bundles/org.openhab.binding.atmofrance/doc/images/alder.svg b/bundles/org.openhab.binding.atmofrance/doc/images/alder.svg new file mode 100755 index 0000000000..30cc5e33a0 --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/doc/images/alder.svg @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.atmofrance/doc/images/aq.svg b/bundles/org.openhab.binding.atmofrance/doc/images/aq.svg new file mode 100755 index 0000000000..8fc376eb4c --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/doc/images/aq.svg @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.atmofrance/doc/images/birch.svg b/bundles/org.openhab.binding.atmofrance/doc/images/birch.svg new file mode 100755 index 0000000000..54604c9793 --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/doc/images/birch.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.atmofrance/doc/images/grasses.svg b/bundles/org.openhab.binding.atmofrance/doc/images/grasses.svg new file mode 100755 index 0000000000..74ddca07b0 --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/doc/images/grasses.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.atmofrance/doc/images/olive.svg b/bundles/org.openhab.binding.atmofrance/doc/images/olive.svg new file mode 100755 index 0000000000..820ef168ce --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/doc/images/olive.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.atmofrance/doc/images/pollen.svg b/bundles/org.openhab.binding.atmofrance/doc/images/pollen.svg new file mode 100644 index 0000000000..f03f3420d2 --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/doc/images/pollen.svg @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.atmofrance/doc/images/ragweed.svg b/bundles/org.openhab.binding.atmofrance/doc/images/ragweed.svg new file mode 100755 index 0000000000..9da22d6825 --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/doc/images/ragweed.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.atmofrance/doc/images/wormwood.svg b/bundles/org.openhab.binding.atmofrance/doc/images/wormwood.svg new file mode 100755 index 0000000000..c8c8c90f6d --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/doc/images/wormwood.svg @@ -0,0 +1,13 @@ + + + + + + + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.atmofrance/pom.xml b/bundles/org.openhab.binding.atmofrance/pom.xml new file mode 100644 index 0000000000..d89e05d593 --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/pom.xml @@ -0,0 +1,17 @@ + + + + 4.0.0 + + + org.openhab.addons.bundles + org.openhab.addons.reactor.bundles + 5.2.0-SNAPSHOT + + + org.openhab.binding.atmofrance + + openHAB Add-ons :: Bundles :: AtmoFrance Binding + + diff --git a/bundles/org.openhab.binding.atmofrance/src/main/feature/feature.xml b/bundles/org.openhab.binding.atmofrance/src/main/feature/feature.xml new file mode 100644 index 0000000000..1e230cd7d4 --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/feature/feature.xml @@ -0,0 +1,9 @@ + + + mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features + + + openhab-runtime-base + mvn:org.openhab.addons.bundles/org.openhab.binding.atmofrance/${project.version} + + diff --git a/bundles/org.openhab.binding.atmofrance/src/main/java/org/openhab/binding/atmofrance/internal/AtmoFranceBindingConstants.java b/bundles/org.openhab.binding.atmofrance/src/main/java/org/openhab/binding/atmofrance/internal/AtmoFranceBindingConstants.java new file mode 100644 index 0000000000..cd9ec560b4 --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/java/org/openhab/binding/atmofrance/internal/AtmoFranceBindingConstants.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2010-2026 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.atmofrance.internal; + +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.thing.ThingTypeUID; + +/** + * The {@link AtmoFranceBindingConstants} class defines common constants, which are + * used across the whole binding. + * + * @author Gaël L'hopital - Initial contribution + */ +@NonNullByDefault +public class AtmoFranceBindingConstants { + + public static final String BINDING_ID = "atmofrance"; + + // List of all Thing Type UIDs + public static final ThingTypeUID THING_TYPE_API = new ThingTypeUID(BINDING_ID, "api"); + public static final ThingTypeUID THING_TYPE_CITY = new ThingTypeUID(BINDING_ID, "city"); + + // List of all channel groups + public static final String GROUP_AQ = "aq"; + public static final String GROUP_POLLENS = "pollens"; + + // List of all channels + public static final String CHANNEL_INDEX = "index"; + public static final String CHANNEL_EFFECTIVE_DATE = "effective-date"; + public static final String CHANNEL_RELEASE_DATE = "release-date"; + + public static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_API, THING_TYPE_CITY); +} diff --git a/bundles/org.openhab.binding.atmofrance/src/main/java/org/openhab/binding/atmofrance/internal/AtmoFranceException.java b/bundles/org.openhab.binding.atmofrance/src/main/java/org/openhab/binding/atmofrance/internal/AtmoFranceException.java new file mode 100755 index 0000000000..d6c4cd942f --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/java/org/openhab/binding/atmofrance/internal/AtmoFranceException.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2010-2026 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.atmofrance.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.thing.ThingStatusDetail; + +/** + * An exception that occurred while communicating with Atmo Data API server or related processes. + * + * @author Gaël L'hopital - Initial contribution + */ +@NonNullByDefault +public class AtmoFranceException extends Exception { + private static final long serialVersionUID = 1L; + private @Nullable ThingStatusDetail statusDetail; + + public @Nullable ThingStatusDetail getStatusDetail() { + return statusDetail; + } + + public AtmoFranceException(String format, Object... args) { + super(format.formatted(args)); + } + + public AtmoFranceException(Exception e, String format, Object... args) { + super(format.formatted(args), e); + } + + public AtmoFranceException(ThingStatusDetail statusDetail, String message) { + super("@text/" + message); + this.statusDetail = statusDetail; + } +} diff --git a/bundles/org.openhab.binding.atmofrance/src/main/java/org/openhab/binding/atmofrance/internal/AtmoFranceHandlerFactory.java b/bundles/org.openhab.binding.atmofrance/src/main/java/org/openhab/binding/atmofrance/internal/AtmoFranceHandlerFactory.java new file mode 100644 index 0000000000..46399061c7 --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/java/org/openhab/binding/atmofrance/internal/AtmoFranceHandlerFactory.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2010-2026 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.atmofrance.internal; + +import static org.openhab.binding.atmofrance.internal.AtmoFranceBindingConstants.*; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.atmofrance.internal.deserialization.AtmoFranceDeserializer; +import org.openhab.binding.atmofrance.internal.handler.AtmoFranceApiHandler; +import org.openhab.binding.atmofrance.internal.handler.CityHandler; +import org.openhab.core.io.net.http.HttpClientFactory; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingTypeUID; +import org.openhab.core.thing.binding.BaseThingHandlerFactory; +import org.openhab.core.thing.binding.ThingHandler; +import org.openhab.core.thing.binding.ThingHandlerFactory; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; + +/** + * The {@link AtmoFranceHandlerFactory} is responsible for creating things and thing + * handlers. + * + * @author Gaël L'hopital - Initial contribution + */ +@NonNullByDefault +@Component(configurationPid = "binding.atmofrance", service = ThingHandlerFactory.class) +public class AtmoFranceHandlerFactory extends BaseThingHandlerFactory { + + private final AtmoFranceDeserializer deserializer; + private final HttpClientFactory httpClientFactory; + + @Activate + public AtmoFranceHandlerFactory(final @Reference HttpClientFactory httpClientFactory, + final @Reference AtmoFranceDeserializer deserializer) { + this.httpClientFactory = httpClientFactory; + this.deserializer = deserializer; + } + + @Override + public boolean supportsThingType(ThingTypeUID thingTypeUID) { + return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID); + } + + @Override + protected @Nullable ThingHandler createHandler(Thing thing) { + ThingTypeUID thingTypeUID = thing.getThingTypeUID(); + + if (THING_TYPE_API.equals(thingTypeUID)) { + return new AtmoFranceApiHandler((Bridge) thing, httpClientFactory.getCommonHttpClient(), deserializer); + } else if (THING_TYPE_CITY.equals(thingTypeUID)) { + return new CityHandler(thing); + } + return null; + } +} diff --git a/bundles/org.openhab.binding.atmofrance/src/main/java/org/openhab/binding/atmofrance/internal/AtmoFranceIconProvider.java b/bundles/org.openhab.binding.atmofrance/src/main/java/org/openhab/binding/atmofrance/internal/AtmoFranceIconProvider.java new file mode 100755 index 0000000000..33d517ef9d --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/java/org/openhab/binding/atmofrance/internal/AtmoFranceIconProvider.java @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2010-2026 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.atmofrance.internal; + +import static org.openhab.binding.atmofrance.internal.AtmoFranceBindingConstants.BINDING_ID; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Locale; +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.atmofrance.internal.api.dto.PollenIndex; +import org.openhab.binding.atmofrance.internal.api.dto.Taxon; +import org.openhab.core.i18n.TranslationProvider; +import org.openhab.core.ui.icon.IconProvider; +import org.openhab.core.ui.icon.IconSet; +import org.openhab.core.ui.icon.IconSet.Format; +import org.osgi.framework.Bundle; +import org.osgi.framework.BundleContext; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link AtmoFranceIconProvider} is the class providing binding related icons. + * + * @author Gaël L'hopital - Initial contribution + */ +@Component(service = { IconProvider.class, AtmoFranceIconProvider.class }) +@NonNullByDefault +public class AtmoFranceIconProvider implements IconProvider { + private static final String NEUTRAL_COLOR = "#3d3c3c"; + private static final String DEFAULT_LABEL = "Atmo France Icons"; + private static final String AQ_ICON = "aq"; + private static final String POLLEN_ICON = "pollen"; + private static final String DEFAULT_DESCRIPTION = "Icons illustrating air quality levels provided by Atmo France"; + private static final List TAXON_ICONS = Taxon.AS_SET.stream().map(Taxon::name).map(String::toLowerCase) + .toList(); + + private final Logger logger = LoggerFactory.getLogger(AtmoFranceIconProvider.class); + private final TranslationProvider i18nProvider; + private final Bundle bundle; + + @Activate + public AtmoFranceIconProvider(final BundleContext context, final @Reference TranslationProvider i18nProvider) { + this.i18nProvider = i18nProvider; + this.bundle = context.getBundle(); + } + + @Override + public Set getIconSets() { + return getIconSets(null); + } + + @Override + public Set getIconSets(@Nullable Locale locale) { + String label = getText("label", DEFAULT_LABEL, locale); + String description = getText("description", DEFAULT_DESCRIPTION, locale); + + return Set.of(new IconSet(BINDING_ID, label, description, Set.of(Format.SVG))); + } + + private String getText(String entry, String defaultValue, @Nullable Locale locale) { + String text = locale == null ? null : i18nProvider.getText(bundle, "iconset." + entry, defaultValue, locale); + return text == null ? defaultValue : text; + } + + @Override + public @Nullable Integer hasIcon(String category, String iconSetId, Format format) { + return Format.SVG.equals(format) && iconSetId.equals(BINDING_ID) + && (category.equals(AQ_ICON) || category.equals(POLLEN_ICON) || TAXON_ICONS.contains(category)) ? 0 + : null; + } + + @Override + public @Nullable InputStream getIcon(String category, String iconSetId, @Nullable String state, Format format) { + int ordinal = -1; + try { + ordinal = state != null ? Integer.valueOf(state) : -1; + } catch (NumberFormatException ignore) { + } + + String iconName = "icon/%s.svg".formatted(category); + if ((category.equals(AQ_ICON) && ordinal >= 0 && ordinal <= 9 && ordinal != 8) + || (category.equals(POLLEN_ICON) && ordinal >= 0 && ordinal <= 5)) { + iconName = iconName.replace(".svg", "-%d.svg".formatted(ordinal)); + } + + String result = ""; + URL iconResource = bundle.getEntry(iconName); + if (iconResource != null) { + try (InputStream stream = iconResource.openStream()) { + result = new String(stream.readAllBytes(), StandardCharsets.UTF_8); + if (TAXON_ICONS.contains(category)) { + String color = PollenIndex.stateColor(ordinal); + result = result.replaceAll(NEUTRAL_COLOR, color); + } + } catch (IOException e) { + logger.warn("Unable to load resource '{}': {}", iconResource.getPath(), e.getMessage()); + } + } + return result.isEmpty() ? null : new ByteArrayInputStream(result.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/bundles/org.openhab.binding.atmofrance/src/main/java/org/openhab/binding/atmofrance/internal/api/AtmoFranceApi.java b/bundles/org.openhab.binding.atmofrance/src/main/java/org/openhab/binding/atmofrance/internal/api/AtmoFranceApi.java new file mode 100644 index 0000000000..32ce64ee77 --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/java/org/openhab/binding/atmofrance/internal/api/AtmoFranceApi.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2010-2026 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.atmofrance.internal.api; + +import java.net.URI; +import java.time.Duration; +import java.time.LocalDate; + +import javax.ws.rs.core.UriBuilder; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * {@link AtmoFranceApi} class defines paths used to interact with server api + * + * @author Gaël L'hopital - Initial contribution + * + */ +@NonNullByDefault +public class AtmoFranceApi { + public static final Duration TOKEN_VALIDITY = Duration.ofHours(24).minusMinutes(30); + private static final UriBuilder ATMO_BUILDER = UriBuilder.fromPath("api").scheme("https") + .host("admindata.atmo-france.org"); + public static final URI LOGIN_URI = ATMO_BUILDER.clone().path("login").build(); + private static final UriBuilder API_V2 = ATMO_BUILDER.clone().path("v2").path("data").path("indices"); + private static final UriBuilder ATMO_URI = API_V2.clone().path("atmo"); + private static final UriBuilder POLLENS_URI = API_V2.clone().path("pollens"); + + public static URI getAtmoUri(LocalDate date, String insee) { + return ATMO_URI.clone().queryParam("date", date).queryParam("code_zone", insee).build(); + } + + public static URI getPollensUri(LocalDate date, String insee) { + return POLLENS_URI.clone().queryParam("date", date).queryParam("code_zone", insee).build(); + } +} diff --git a/bundles/org.openhab.binding.atmofrance/src/main/java/org/openhab/binding/atmofrance/internal/api/dto/AtmoFranceDto.java b/bundles/org.openhab.binding.atmofrance/src/main/java/org/openhab/binding/atmofrance/internal/api/dto/AtmoFranceDto.java new file mode 100644 index 0000000000..9b30cfbfa9 --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/java/org/openhab/binding/atmofrance/internal/api/dto/AtmoFranceDto.java @@ -0,0 +1,187 @@ +/* + * Copyright (c) 2010-2026 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.atmofrance.internal.api.dto; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.List; + +import javax.measure.Unit; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.library.dimension.Density; +import org.openhab.core.library.types.DecimalType; +import org.openhab.core.library.types.QuantityType; +import org.openhab.core.library.unit.ImperialUnits; +import org.openhab.core.library.unit.SIUnits; +import org.openhab.core.types.State; +import org.openhab.core.types.UnDefType; + +/** + * {@link AtmoFranceDto} class defines DTO used to interact with server api + * + * @author Gaël L'hopital - Initial contribution + * + */ +@NonNullByDefault +public class AtmoFranceDto { + public static final Unit GRAIN_PER_CUBICMETER = ImperialUnits.GRAIN.divide(SIUnits.CUBIC_METRE) + .asType(Density.class); + public static final ZoneId DEFAULT_TZ = ZoneId.of("Europe/Paris"); + + public static record LoginResponse(String token) { + } + + public static record ErrorResponse(int code, String message) { + } + + public static class DataResponse { + String type = ""; + String name = ""; + Crs crs = new Crs("", new Properties("")); + public List> features = List.of(); + + public @Nullable T getProperties() { + if (!features.isEmpty()) { + return features.getFirst().properties; + } + return null; + } + } + + public static class AtmoResponse extends DataResponse { + } + + public static class PollensResponse extends DataResponse { + + } + + public static record Crs(String type, Properties properties) { + } + + public static record Properties(String name) { + } + + public static record Feature (String type, T properties, Geometry geometry) { + } + + public static record Geometry(String type, List coordinates) { + } + + public static class BaseProperties { + public String aasqa = ""; + + public Instant dateMaj = Instant.EPOCH; + private LocalDate dateDif = LocalDate.EPOCH; + private LocalDate dateEch = LocalDate.EPOCH; + + public String codeZone = ""; + public String libZone = ""; + public String typeZone = ""; + + public String libQual = ""; + public String source = ""; + + public Instant getDiffusionDate() { + return dateDif.atStartOfDay(DEFAULT_TZ).toInstant(); + } + + public Instant getEffectiveDate() { + return dateEch.atStartOfDay(DEFAULT_TZ).toInstant(); + } + } + + public static class PollensProperties extends BaseProperties { + public boolean alerte; + + private int codeAmbr; + private int codeArm; + private int codeAul; + private int codeBoul; + private int codeGram; + private int codeOliv; + + private int codeQual; + + private double concAmbr; + private double concArm; + private double concAul; + private double concBoul; + private double concGram; + private double concOliv; + + public String pollenResp = ""; + + public State getGlobal() { + return new DecimalType(PollenIndex.valueOf(codeQual).value); + } + + public State getTaxonIndex(Taxon taxon) { + int result = switch (taxon) { + case ALDER -> codeAul; + case BIRCH -> codeBoul; + case OLIVE -> codeOliv; + case GRASSES -> codeGram; + case WORMWOOD -> codeArm; + case RAGWEED -> codeAmbr; + default -> 9; + }; + return new DecimalType(PollenIndex.valueOf(result).value); + } + + public State getTaxonConc(Taxon taxon) { + double result = switch (taxon) { + case ALDER -> concAul; + case BIRCH -> concBoul; + case OLIVE -> concOliv; + case GRASSES -> concGram; + case WORMWOOD -> concArm; + case RAGWEED -> concAmbr; + default -> -1; + }; + + return result != -1 ? new QuantityType<>(result, GRAIN_PER_CUBICMETER) : UnDefType.NULL; + } + } + + public static class IndexProperties extends BaseProperties { + private AtmoIndex codeNo2 = AtmoIndex.UNKNOWN; + private AtmoIndex codeO3 = AtmoIndex.UNKNOWN; + private AtmoIndex codePm10 = AtmoIndex.UNKNOWN; + private AtmoIndex codePm25 = AtmoIndex.UNKNOWN; + private AtmoIndex codeSo2 = AtmoIndex.UNKNOWN; + + public AtmoIndex codeQual = AtmoIndex.UNKNOWN; + public String coulQual = ""; + + public String epsgReg = ""; + public double xReg; + public double xWgs84; + public double yReg; + public double yWgs84; + + public State getPollutantIndex(Pollutant pollutant) { + AtmoIndex result = switch (pollutant) { + case NO2 -> codeNo2; + case O3 -> codeO3; + case PM10 -> codePm10; + case PM25 -> codePm25; + case SO2 -> codeSo2; + default -> AtmoIndex.UNKNOWN; + }; + return new DecimalType(result.value); + } + } +} diff --git a/bundles/org.openhab.binding.atmofrance/src/main/java/org/openhab/binding/atmofrance/internal/api/dto/AtmoIndex.java b/bundles/org.openhab.binding.atmofrance/src/main/java/org/openhab/binding/atmofrance/internal/api/dto/AtmoIndex.java new file mode 100644 index 0000000000..9ed33ff309 --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/java/org/openhab/binding/atmofrance/internal/api/dto/AtmoIndex.java @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2010-2026 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.atmofrance.internal.api.dto; + +import java.util.EnumSet; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +import com.google.gson.annotations.SerializedName; + +/** + * @author Gaël L'hopital - Initial contribution + */ +@NonNullByDefault +public enum AtmoIndex { + @SerializedName("0") + ABSENT(9, "#dddddd"), + @SerializedName("1") + GOOD(0, "#50f0e6"), + @SerializedName("2") + AVERAGE(1, "#50ccaa"), + @SerializedName("3") + DEGRADED(2, "#f0e641"), + @SerializedName("4") + BAD(3, "#ff5050"), + @SerializedName("5") + VERY_BAD(4, "#960032"), + @SerializedName("6") + EXTREMELY_BAD(5, "#872181"), + @SerializedName("7") + EVENT(7, "#888888"), + UNKNOWN(-1, "#b3b3b3"); + + public static final EnumSet AS_SET = EnumSet.allOf(AtmoIndex.class); + + public final int value; + public final String color; + + AtmoIndex(int value, String color) { + this.value = value; + this.color = color; + } + + public static AtmoIndex valueOf(int ordinal) { + for (AtmoIndex index : AS_SET) { + if (index.value == ordinal) { + return index; + } + } + return UNKNOWN; + } +} diff --git a/bundles/org.openhab.binding.atmofrance/src/main/java/org/openhab/binding/atmofrance/internal/api/dto/PollenIndex.java b/bundles/org.openhab.binding.atmofrance/src/main/java/org/openhab/binding/atmofrance/internal/api/dto/PollenIndex.java new file mode 100644 index 0000000000..686798bc59 --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/java/org/openhab/binding/atmofrance/internal/api/dto/PollenIndex.java @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2010-2026 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.atmofrance.internal.api.dto; + +import java.util.EnumSet; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * @author Gaël L'hopital - Initial contribution + */ +@NonNullByDefault +public enum PollenIndex { + ABSENT(9, 0, "#dddddd"), + VERY_LOW(0, 1, "#50f0e6"), + LOW(1, 2, "#50ccaa"), + MODERATE(2, 3, "#f0e641"), + HIGH(3, 4, "#ff5050"), + VERY_HIGH(4, 5, "#960032"), + EXTREMELY_HIGH(5, 6, "#872181"), + UNKNOWN(-1, -1, "#b3b3b3"); + + public static final EnumSet AS_SET = EnumSet.allOf(PollenIndex.class); + + public final int value; + private final int apiValue; + public final String color; + + PollenIndex(int value, int apiValue, String color) { + this.value = value; + this.apiValue = apiValue; + this.color = color; + } + + public static PollenIndex valueOf(int apiValue) { + for (PollenIndex index : AS_SET) { + if (index.apiValue == apiValue) { + return index; + } + } + return UNKNOWN; + } + + public static String stateColor(int stateValue) { + for (PollenIndex index : AS_SET) { + if (index.value == stateValue) { + return index.color; + } + } + return UNKNOWN.color; + } +} diff --git a/bundles/org.openhab.binding.atmofrance/src/main/java/org/openhab/binding/atmofrance/internal/api/dto/Pollutant.java b/bundles/org.openhab.binding.atmofrance/src/main/java/org/openhab/binding/atmofrance/internal/api/dto/Pollutant.java new file mode 100644 index 0000000000..bff6750987 --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/java/org/openhab/binding/atmofrance/internal/api/dto/Pollutant.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2010-2026 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.atmofrance.internal.api.dto; + +import java.util.EnumSet; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link Pollutant} enum lists all pollutants tracked by Atmo France + * + * @author Gaël L'hopital - Initial contribution + */ +@NonNullByDefault +public enum Pollutant { + PM25, + PM10, + NO2, + O3, + SO2; + + public static final EnumSet AS_SET = EnumSet.allOf(Pollutant.class); +} diff --git a/bundles/org.openhab.binding.atmofrance/src/main/java/org/openhab/binding/atmofrance/internal/api/dto/Taxon.java b/bundles/org.openhab.binding.atmofrance/src/main/java/org/openhab/binding/atmofrance/internal/api/dto/Taxon.java new file mode 100644 index 0000000000..ea7e78e0ae --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/java/org/openhab/binding/atmofrance/internal/api/dto/Taxon.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2010-2026 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.atmofrance.internal.api.dto; + +import java.util.EnumSet; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * @author Gaël L'hopital - Initial contribution + */ +@NonNullByDefault +public enum Taxon { + ALDER, // aulne + BIRCH, // bouleau + OLIVE, // olivier + GRASSES, // graminees + WORMWOOD, // armoises + RAGWEED; // ambroisies + + /* + * Not available in API as of today + * CYPRESS, // cypres + * HAZEL, // noisetier + * POPLAR, // peuplier + * WILLOW, // saule + * ASH, // frene + * HORNBEAM, // charme + * PLANE, // platane + * OAK, // chene + * LINDEN, // tilleul + * CHESTNUT, // chataignier + * RUMEX, // rumex + * PLANTAIN, // plantain + * URTICACEAE; // urticacees + */ + + public static final EnumSet AS_SET = EnumSet.allOf(Taxon.class); +} diff --git a/bundles/org.openhab.binding.atmofrance/src/main/java/org/openhab/binding/atmofrance/internal/configuration/AtmoFranceConfiguration.java b/bundles/org.openhab.binding.atmofrance/src/main/java/org/openhab/binding/atmofrance/internal/configuration/AtmoFranceConfiguration.java new file mode 100644 index 0000000000..4ca1c29f9c --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/java/org/openhab/binding/atmofrance/internal/configuration/AtmoFranceConfiguration.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2010-2026 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.atmofrance.internal.configuration; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link AtmoFranceConfiguration} class contains fields mapping bridge configuration parameters. + * + * @author Gaël L'hopital - Initial contribution + */ +@NonNullByDefault +public class AtmoFranceConfiguration { + public String username = ""; + public String password = ""; + + public ConfigurationLevel check() { + if (username.isBlank()) { + return ConfigurationLevel.EMPTY_USERNAME; + } else if (password.isBlank()) { + return ConfigurationLevel.EMPTY_PASSWORD; + } + return ConfigurationLevel.COMPLETED; + } +} diff --git a/bundles/org.openhab.binding.atmofrance/src/main/java/org/openhab/binding/atmofrance/internal/configuration/CityConfiguration.java b/bundles/org.openhab.binding.atmofrance/src/main/java/org/openhab/binding/atmofrance/internal/configuration/CityConfiguration.java new file mode 100755 index 0000000000..a6285a8980 --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/java/org/openhab/binding/atmofrance/internal/configuration/CityConfiguration.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2010-2026 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.atmofrance.internal.configuration; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link CityConfiguration} is the class used to match the + * thing configuration. + * + * @author Gaël L'hopital - Initial contribution + */ +@NonNullByDefault +public class CityConfiguration { + public String codeInsee = ""; + + public ConfigurationLevel check() { + if (codeInsee.isBlank()) { + return ConfigurationLevel.EMPTY_INSEE; + } + return ConfigurationLevel.COMPLETED; + } +} diff --git a/bundles/org.openhab.binding.atmofrance/src/main/java/org/openhab/binding/atmofrance/internal/configuration/ConfigurationLevel.java b/bundles/org.openhab.binding.atmofrance/src/main/java/org/openhab/binding/atmofrance/internal/configuration/ConfigurationLevel.java new file mode 100644 index 0000000000..404dc63022 --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/java/org/openhab/binding/atmofrance/internal/configuration/ConfigurationLevel.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2010-2026 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.atmofrance.internal.configuration; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link ConfigurationLevel} describes configuration levels of a given account thing + * + * @author Gaël L'hopital - Initial contribution + */ +@NonNullByDefault +public enum ConfigurationLevel { + EMPTY_USERNAME("@text/conf-error-no-username"), + EMPTY_PASSWORD("@text/conf-error-no-password"), + EMPTY_INSEE("@text/conf-error-no-insee"), + COMPLETED(""); + + public String message; + + ConfigurationLevel(String message) { + this.message = message; + } +} diff --git a/bundles/org.openhab.binding.atmofrance/src/main/java/org/openhab/binding/atmofrance/internal/deserialization/AtmoFranceDeserializer.java b/bundles/org.openhab.binding.atmofrance/src/main/java/org/openhab/binding/atmofrance/internal/deserialization/AtmoFranceDeserializer.java new file mode 100755 index 0000000000..d5eaa389c1 --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/java/org/openhab/binding/atmofrance/internal/deserialization/AtmoFranceDeserializer.java @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2010-2026 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.atmofrance.internal.deserialization; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.atmofrance.internal.AtmoFranceException; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; + +import com.google.gson.FieldNamingPolicy; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonSyntaxException; + +/** + * The {@link AtmoFranceDeserializer} is responsible to instantiate suitable Gson (de)serializer + * + * @author Gaël L'hopital - Initial contribution + */ +@NonNullByDefault +@Component(service = AtmoFranceDeserializer.class) +public class AtmoFranceDeserializer { + private final Gson gson; + + @Activate + public AtmoFranceDeserializer() { + gson = new GsonBuilder().setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) + .registerTypeAdapter(Instant.class, (JsonDeserializer) (json, type, context) -> { + String value = json.getAsString(); + if (!value.contains("T")) { + return LocalDate.parse(value).atStartOfDay(ZoneOffset.UTC).toInstant(); + } else if (value.endsWith("Z") || value.matches(".*T.*[+-]\\d{2}:?\\d{2}$")) { + return OffsetDateTime.parse(value).toInstant(); + } else { + return LocalDateTime.parse(value).toInstant(ZoneOffset.UTC); + } + }) + .registerTypeAdapter(LocalDate.class, + (JsonDeserializer) (json, type, context) -> LocalDate.parse(json.getAsString())) + .create(); + } + + public T deserialize(Class clazz, String json) throws AtmoFranceException { + try { + T result = gson.fromJson(json, clazz); + if (result == null) { + throw new AtmoFranceException("Deserialization of '%s' resulted in null value", json); + } + + return result; + } catch (JsonSyntaxException e) { + throw new AtmoFranceException(e, "Unexpected error deserializing '%s'", e.getMessage()); + } + } + + public String serialize(Object object) { + return gson.toJson(object); + } +} diff --git a/bundles/org.openhab.binding.atmofrance/src/main/java/org/openhab/binding/atmofrance/internal/handler/AtmoFranceApiHandler.java b/bundles/org.openhab.binding.atmofrance/src/main/java/org/openhab/binding/atmofrance/internal/handler/AtmoFranceApiHandler.java new file mode 100644 index 0000000000..2245110b73 --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/java/org/openhab/binding/atmofrance/internal/handler/AtmoFranceApiHandler.java @@ -0,0 +1,244 @@ +/* + * Copyright (c) 2010-2026 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.atmofrance.internal.handler; + +import static org.openhab.binding.atmofrance.internal.api.AtmoFranceApi.*; + +import java.net.URI; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.LocalDate; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +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; + +import javax.ws.rs.core.MediaType; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.client.HttpClient; +import org.eclipse.jetty.client.api.ContentResponse; +import org.eclipse.jetty.client.api.Request; +import org.eclipse.jetty.client.util.StringContentProvider; +import org.eclipse.jetty.http.HttpHeader; +import org.eclipse.jetty.http.HttpMethod; +import org.eclipse.jetty.http.HttpStatus; +import org.eclipse.jetty.http.HttpStatus.Code; +import org.openhab.binding.atmofrance.internal.AtmoFranceException; +import org.openhab.binding.atmofrance.internal.api.AtmoFranceApi; +import org.openhab.binding.atmofrance.internal.api.dto.AtmoFranceDto; +import org.openhab.binding.atmofrance.internal.api.dto.AtmoFranceDto.AtmoResponse; +import org.openhab.binding.atmofrance.internal.api.dto.AtmoFranceDto.BaseProperties; +import org.openhab.binding.atmofrance.internal.api.dto.AtmoFranceDto.DataResponse; +import org.openhab.binding.atmofrance.internal.api.dto.AtmoFranceDto.ErrorResponse; +import org.openhab.binding.atmofrance.internal.api.dto.AtmoFranceDto.IndexProperties; +import org.openhab.binding.atmofrance.internal.api.dto.AtmoFranceDto.LoginResponse; +import org.openhab.binding.atmofrance.internal.api.dto.AtmoFranceDto.PollensProperties; +import org.openhab.binding.atmofrance.internal.api.dto.AtmoFranceDto.PollensResponse; +import org.openhab.binding.atmofrance.internal.configuration.AtmoFranceConfiguration; +import org.openhab.binding.atmofrance.internal.configuration.ConfigurationLevel; +import org.openhab.binding.atmofrance.internal.deserialization.AtmoFranceDeserializer; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.ThingStatusDetail; +import org.openhab.core.thing.binding.BaseBridgeHandler; +import org.openhab.core.types.Command; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link AtmoFranceApiHandler} is responsible for handling commands, which are + * sent to one of the channels. + * + * @author Gaël L'hopital - Initial contribution + */ +@NonNullByDefault +public class AtmoFranceApiHandler 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; + + private final Logger logger = LoggerFactory.getLogger(AtmoFranceApiHandler.class); + private final Map> jobs = new ConcurrentHashMap<>(); + private final AtmoFranceDeserializer deserializer; + private final HttpClient httpClient; + + private @Nullable AtmoFranceConfiguration config; + private @Nullable String bearer; + + public AtmoFranceApiHandler(Bridge bridge, HttpClient httpClient, AtmoFranceDeserializer deserializer) { + super(bridge); + this.deserializer = deserializer; + this.httpClient = httpClient; + } + + @Override + public void initialize() { + logger.debug("Initializing Atmo France bridge handler."); + var localConfig = getConfigAs(AtmoFranceConfiguration.class); + ConfigurationLevel configLevel = localConfig.check(); + + if (configLevel != ConfigurationLevel.COMPLETED) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, configLevel.message); + return; + } + this.config = localConfig; + updateStatus(ThingStatus.UNKNOWN); + scheduler.execute(this::initiateConnection); + } + + @Override + public void dispose() { + cleanJobs(); + config = null; + bearer = null; + super.dispose(); + } + + private void initiateConnection() { + try { + LoginResponse login = executeUri(LOGIN_URI, LoginResponse.class, + deserializer.serialize(Objects.requireNonNull(config))); + bearer = login.token(); + updateStatus(ThingStatus.ONLINE); + schedule("token refresh", this::initiateConnection, TOKEN_VALIDITY); + } catch (AtmoFranceException e) { + if (e.getStatusDetail() instanceof ThingStatusDetail statusDetail) { + updateStatus(ThingStatus.OFFLINE, statusDetail, e.getMessage()); + } else { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage()); + schedule("reconnect", this::initiateConnection, Duration.ofHours(1)); + } + } + } + + private synchronized T executeUri(URI uri, Class clazz) throws AtmoFranceException { + String content = executeUri(uri, HttpMethod.GET, null); + return deserializer.deserialize(clazz, content); + } + + private synchronized T executeUri(URI uri, Class clazz, String payload) throws AtmoFranceException { + String content = executeUri(uri, HttpMethod.POST, payload); + return deserializer.deserialize(clazz, content); + } + + private synchronized String executeUri(URI uri, HttpMethod method, @Nullable String payload) + throws AtmoFranceException { + logger.debug("executeUrl: {} ", uri); + + Request request = httpClient.newRequest(uri).method(method).timeout(REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS) + .header(HttpHeader.ACCEPT, MediaType.APPLICATION_JSON); + + handleBearer(request, bearer); + handlePayload(request, payload); + + try { + ContentResponse response = request.send(); + + Code statusCode = HttpStatus.getCode(response.getStatus()); + String content = new String(response.getContent(), DEFAULT_CHARSET); + if (statusCode == Code.OK) { + logger.trace("executeUrl: {} returned {}", uri, content); + return content; + } else if (statusCode == Code.UNAUTHORIZED) { + ErrorResponse error = deserializer.deserialize(ErrorResponse.class, content); + throw new AtmoFranceException(ThingStatusDetail.CONFIGURATION_ERROR, + "com-error-unauthorized [\"%s\"]".formatted(error.message())); + } + throw new AtmoFranceException("Error %d requesting: %s", response.getStatus(), uri.toString()); + } catch (TimeoutException | ExecutionException e) { + throw new AtmoFranceException(e, "Exception while calling %s: %s", request.getURI(), e.getMessage()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AtmoFranceException(e, "Execution interrupted: %s", e.getMessage()); + } + } + + private void handlePayload(Request request, @Nullable String payload) { + if (payload == null) { + return; + } + + StringContentProvider contentProvider = new StringContentProvider(MediaType.APPLICATION_JSON, payload, + DEFAULT_CHARSET); + request.content(contentProvider, MediaType.APPLICATION_JSON); + } + + private void handleBearer(Request request, @Nullable String bearer) { + if (bearer == null) { + return; + } + request.header(HttpHeader.AUTHORIZATION, "Bearer %s".formatted(bearer)); + } + + @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> getJobs() { + return jobs; + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + logger.debug("This thing does not handle commands"); + } + + private

> @Nullable P getFeatureProperties(URI uri, + Class clazz) { + try { + T response = executeUri(uri, clazz); + return response.getProperties(); + } catch (AtmoFranceException e) { + ThingStatusDetail detail = e.getStatusDetail(); + if (detail == null) { + detail = ThingStatusDetail.COMMUNICATION_ERROR; + } + updateStatus(ThingStatus.OFFLINE, detail, e.getMessage()); + } + return null; + } + + public @Nullable IndexProperties getAtmoIndex(String codeInsee) { + URI atmoUri = AtmoFranceApi.getAtmoUri(LocalDate.now(AtmoFranceDto.DEFAULT_TZ), codeInsee); + return getFeatureProperties(atmoUri, AtmoResponse.class); + } + + public @Nullable PollensProperties getPollens(String codeInsee) { + URI pollensUri = AtmoFranceApi.getPollensUri(LocalDate.now(AtmoFranceDto.DEFAULT_TZ), codeInsee); + return getFeatureProperties(pollensUri, PollensResponse.class); + } +} diff --git a/bundles/org.openhab.binding.atmofrance/src/main/java/org/openhab/binding/atmofrance/internal/handler/CityHandler.java b/bundles/org.openhab.binding.atmofrance/src/main/java/org/openhab/binding/atmofrance/internal/handler/CityHandler.java new file mode 100755 index 0000000000..b699d2cd14 --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/java/org/openhab/binding/atmofrance/internal/handler/CityHandler.java @@ -0,0 +1,200 @@ +/* + * Copyright (c) 2010-2026 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.atmofrance.internal.handler; + +import static org.openhab.binding.atmofrance.internal.AtmoFranceBindingConstants.*; + +import java.time.Duration; +import java.time.Instant; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.atmofrance.internal.api.dto.AtmoFranceDto.BaseProperties; +import org.openhab.binding.atmofrance.internal.api.dto.AtmoFranceDto.IndexProperties; +import org.openhab.binding.atmofrance.internal.api.dto.AtmoFranceDto.PollensProperties; +import org.openhab.binding.atmofrance.internal.api.dto.Pollutant; +import org.openhab.binding.atmofrance.internal.api.dto.Taxon; +import org.openhab.binding.atmofrance.internal.configuration.CityConfiguration; +import org.openhab.binding.atmofrance.internal.configuration.ConfigurationLevel; +import org.openhab.core.library.types.DateTimeType; +import org.openhab.core.library.types.DecimalType; +import org.openhab.core.thing.Bridge; +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.binding.BaseThingHandler; +import org.openhab.core.types.Command; +import org.openhab.core.types.State; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link CityHandler} is responsible for handling commands, which are + * sent to one of the channels. + * + * @author Gaël L'hopital - Initial contribution + */ +@NonNullByDefault +public class CityHandler extends BaseThingHandler implements HandlerUtils { + private static final String AQ_JOB = "%s Air Quality"; + private static final String POLLENS_JOB = "%s Pollens"; + + private final Logger logger = LoggerFactory.getLogger(CityHandler.class); + private final Map> jobs = new ConcurrentHashMap<>(); + + private @Nullable CityConfiguration config; + + public CityHandler(Thing thing) { + super(thing); + } + + @Override + public void initialize() { + var localConfig = getConfigAs(CityConfiguration.class); + ConfigurationLevel configLevel = localConfig.check(); + + if (configLevel != ConfigurationLevel.COMPLETED) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, configLevel.message); + return; + } + + config = localConfig; + + updateStatus(ThingStatus.UNKNOWN); + schedule(AQ_JOB.formatted(thing.getUID()), this::getAtmoIndex, Duration.ofSeconds(2)); + schedule(POLLENS_JOB.formatted(thing.getUID()), this::getPollens, Duration.ofSeconds(3)); + } + + @Override + public void dispose() { + logger.debug("Disposing the Atmo France city handler"); + cleanJobs(); + } + + private void getAtmoIndex() { + AtmoFranceApiHandler apiHandler = getBridgeHandler(AtmoFranceApiHandler.class); + CityConfiguration local = config; + long delay = 3600; + if (apiHandler != null && local != null) { + IndexProperties properties = apiHandler.getAtmoIndex(local.codeInsee); + if (properties != null) { + updateState(GROUP_AQ, CHANNEL_INDEX, properties.codeQual.value); + Pollutant.AS_SET.forEach(pollutant -> { + String channelName = pollutant.name().toLowerCase(Locale.ROOT) + "-index"; + updateState(GROUP_AQ, channelName, properties.getPollutantIndex(pollutant)); + }); + updateCommon(properties, GROUP_AQ); + updateStatus(ThingStatus.ONLINE); + } else { + if (getBridgeHandler(AtmoFranceApiHandler.class) != null) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, "@text/no-data"); + } else { + // the call to getBridgeHandler will already have put me on OFFLINE/BRIDGE_OFFLINE + return; + } + } + } else { + delay = 10; + } + schedule(AQ_JOB.formatted(thing.getUID()), this::getAtmoIndex, Duration.ofSeconds(delay)); + } + + private void updateCommon(BaseProperties properties, String group) { + updateState(group, CHANNEL_EFFECTIVE_DATE, properties.getEffectiveDate()); + updateState(group, CHANNEL_RELEASE_DATE, properties.getDiffusionDate()); + + updateProperty("%s-source".formatted(group), properties.source); + } + + private void getPollens() { + @Nullable + AtmoFranceApiHandler apiHandler = getBridgeHandler(AtmoFranceApiHandler.class); + @Nullable + CityConfiguration local = config; + long delay = 3600; + if (apiHandler != null && local != null) { + PollensProperties properties = apiHandler.getPollens(local.codeInsee); + if (properties != null) { + updateState(GROUP_POLLENS, CHANNEL_INDEX, properties.getGlobal()); + Taxon.AS_SET.forEach(taxon -> { + String channelName = taxon.name().toLowerCase(Locale.ROOT) + "-"; + updateState(GROUP_POLLENS, channelName + "level", properties.getTaxonIndex(taxon)); + updateState(GROUP_POLLENS, channelName + "conc", properties.getTaxonConc(taxon)); + }); + updateCommon(properties, GROUP_POLLENS); + updateStatus(ThingStatus.ONLINE); + } else { + if (getBridgeHandler(AtmoFranceApiHandler.class) != null) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, "@text/no-pollens"); + } else { + // the call to getBridgeHandler will already have put me on OFFLINE/BRIDGE_OFFLINE + return; + } + } + } else { + delay = 10; + } + schedule(POLLENS_JOB.formatted(thing.getUID()), this::getPollens, Duration.ofSeconds(delay)); + } + + private void updateState(String group, String channel, int value) { + updateState(group, channel, new DecimalType(value)); + } + + private void updateState(String group, String channel, Instant date) { + updateState(group, channel, new DateTimeType(date)); + } + + private void updateState(String group, String channel, State state) { + ChannelUID channelUID = new ChannelUID(getThing().getUID(), group, channel); + if (isLinked(channelUID)) { + updateState(channelUID, state); + } + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + logger.debug("This thing does not handle commands"); + } + + @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> getJobs() { + return jobs; + } +} diff --git a/bundles/org.openhab.binding.atmofrance/src/main/java/org/openhab/binding/atmofrance/internal/handler/HandlerUtils.java b/bundles/org.openhab.binding.atmofrance/src/main/java/org/openhab/binding/atmofrance/internal/handler/HandlerUtils.java new file mode 100644 index 0000000000..3d659360a5 --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/java/org/openhab/binding/atmofrance/internal/handler/HandlerUtils.java @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2010-2026 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.atmofrance.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 { + /** + * Cancels the given scheduled future if it exists and is not already cancelled. + * + * @param job the scheduled future to cancel + */ + default void cancelFuture(ScheduledFuture job) { + if (!job.isCancelled()) { + job.cancel(true); + } + } + + /** + * Retrieves the bridge handler of the specified class type if the bridge is online and correctly configured. + * + * @param the type of the bridge handler + * @param clazz the class of the bridge handler to retrieve + * @return the bridge handler instance, or null if not available or incorrect type + */ + default @Nullable T getBridgeHandler(Class clazz) { + Bridge bridge = getBridge(); + if (bridge != null && bridge.getStatus() == ThingStatus.ONLINE) { + if (bridge.getHandler() instanceof BridgeHandler bridgeHandler) { + if (bridgeHandler.getClass() == clazz) { + return clazz.cast(bridgeHandler); + } + } + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "@text/incorrect-bridge"); + } else { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE, ""); + } + return null; + } + + /** + * Schedules a job to run after the specified duration, cancelling any existing job with the same name. + * + * @param jobName the name of the job for tracking + * @param job the runnable job to execute + * @param duration the duration to wait before executing the job + */ + default void schedule(String jobName, Runnable job, Duration duration) { + ScheduledFuture result = getJobs().remove(jobName); + + getLogger().debug("{} {} in {}", result != null ? "Rescheduled" : "Scheduling", jobName, duration); + if (result != null) { + cancelFuture(result); + } + + getJobs().put(jobName, getScheduler().schedule(job, duration.getSeconds(), TimeUnit.SECONDS)); + } + + /** + * Cancels all scheduled jobs and clears the jobs map. + */ + default void cleanJobs() { + getJobs().values().forEach(this::cancelFuture); + getJobs().clear(); + } + + void updateStatus(ThingStatus status, ThingStatusDetail statusDetail, @Nullable String description); + + @Nullable + Bridge getBridge(); + + ScheduledExecutorService getScheduler(); + + Logger getLogger(); + + Map> getJobs(); +} diff --git a/bundles/org.openhab.binding.atmofrance/src/main/resources/OH-INF/addon/addon.xml b/bundles/org.openhab.binding.atmofrance/src/main/resources/OH-INF/addon/addon.xml new file mode 100644 index 0000000000..897eceab40 --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/resources/OH-INF/addon/addon.xml @@ -0,0 +1,12 @@ + + + + binding + AtmoFrance Binding + Air Quality Monitoring data provided by AtmoFrance. + cloud + fr + + diff --git a/bundles/org.openhab.binding.atmofrance/src/main/resources/OH-INF/i18n/atmofrance.properties b/bundles/org.openhab.binding.atmofrance/src/main/resources/OH-INF/i18n/atmofrance.properties new file mode 100644 index 0000000000..44f123a3b0 --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/resources/OH-INF/i18n/atmofrance.properties @@ -0,0 +1,160 @@ +# add-on + +addon.atmofrance.name = AtmoFrance Binding +addon.atmofrance.description = Air Quality Monitoring data provided by AtmoFrance. + +# thing types + +thing-type.atmofrance.api.label = Atmo Data API Portal +thing-type.atmofrance.api.description = Bridge to the Atmo Data API Portal. In order to receive the data, you must register an account at https://admindata.atmo-france.org/inscription-api and receive your account credentials. +thing-type.atmofrance.city.label = City Report +thing-type.atmofrance.city.description = Atmo France air quality report for the given city + +# thing types config + +thing-type.config.atmofrance.api.password.label = Password +thing-type.config.atmofrance.api.password.description = Password from Atmo Data API Account +thing-type.config.atmofrance.api.username.label = Username +thing-type.config.atmofrance.api.username.description = Username from Atmo Data API Account +thing-type.config.atmofrance.city.codeInsee.label = Insee Code + +# channel group types + +channel-group-type.atmofrance.aq.label = Air Quality Information +channel-group-type.atmofrance.aq.channel.release-date.label = Release date +channel-group-type.atmofrance.aq.channel.effective-date.label = Effective date +channel-group-type.atmofrance.aq.channel.no2-index.label = NO₂ +channel-group-type.atmofrance.aq.channel.no2-index.description = Nitrogen dioxide +channel-group-type.atmofrance.aq.channel.o3-index.label = O₃ +channel-group-type.atmofrance.aq.channel.o3-index.description = Ozone +channel-group-type.atmofrance.aq.channel.pm10-index.label = PM 10 +channel-group-type.atmofrance.aq.channel.pm10-index.description = Particulate matter < 10 µm +channel-group-type.atmofrance.aq.channel.pm25-index.label = PM 2.5 +channel-group-type.atmofrance.aq.channel.pm25-index.description = Particulate matter < 2,5 µm +channel-group-type.atmofrance.aq.channel.so2-index.label = SO₂ +channel-group-type.atmofrance.aq.channel.so2-index.description = Sulfur dioxide +channel-group-type.atmofrance.pollens.label = Pollen Information +channel-group-type.atmofrance.pollens.channel.alder-conc.label = Alder concentration +channel-group-type.atmofrance.pollens.channel.alder-level.label = Alder index +channel-group-type.atmofrance.pollens.channel.birch-conc.label = Birch concentration +channel-group-type.atmofrance.pollens.channel.birch-level.label = Birch index +channel-group-type.atmofrance.pollens.channel.release-date.label = Publication date +channel-group-type.atmofrance.pollens.channel.effective-date.label = Effective date +channel-group-type.atmofrance.pollens.channel.grasses-conc.label = Grasses concentration +channel-group-type.atmofrance.pollens.channel.grasses-level.label = Grasses index +channel-group-type.atmofrance.pollens.channel.olive-conc.label = Olive concentration +channel-group-type.atmofrance.pollens.channel.olive-level.label = Olive index +channel-group-type.atmofrance.pollens.channel.ragweed-conc.label = Ragweed concentration +channel-group-type.atmofrance.pollens.channel.ragweed-level.label = Ragweed index +channel-group-type.atmofrance.pollens.channel.wormwood-conc.label = Wormwood concentration +channel-group-type.atmofrance.pollens.channel.wormwood-level.label = Wormwood index + +# channel types + +channel-type.atmofrance.alder-level.label = Alder +channel-type.atmofrance.alder-level.state.option.0 = Very Low +channel-type.atmofrance.alder-level.state.option.1 = Low +channel-type.atmofrance.alder-level.state.option.2 = Moderate +channel-type.atmofrance.alder-level.state.option.3 = High +channel-type.atmofrance.alder-level.state.option.4 = Very High +channel-type.atmofrance.alder-level.state.option.5 = Extremely High +channel-type.atmofrance.alder-level.state.option.9 = Absent +channel-type.atmofrance.birch-level.label = Birch Level +channel-type.atmofrance.birch-level.state.option.0 = Very Low +channel-type.atmofrance.birch-level.state.option.1 = Low +channel-type.atmofrance.birch-level.state.option.2 = Moderate +channel-type.atmofrance.birch-level.state.option.3 = High +channel-type.atmofrance.birch-level.state.option.4 = Very High +channel-type.atmofrance.birch-level.state.option.5 = Extremely High +channel-type.atmofrance.birch-level.state.option.9 = Absent +channel-type.atmofrance.concentration.label = Measure +channel-type.atmofrance.grasses-level.label = Grasses +channel-type.atmofrance.grasses-level.state.option.0 = Very Low +channel-type.atmofrance.grasses-level.state.option.1 = Low +channel-type.atmofrance.grasses-level.state.option.2 = Moderate +channel-type.atmofrance.grasses-level.state.option.3 = High +channel-type.atmofrance.grasses-level.state.option.4 = Very High +channel-type.atmofrance.grasses-level.state.option.5 = Extremely High +channel-type.atmofrance.grasses-level.state.option.9 = Absent +channel-type.atmofrance.index-adv.label = Air Quality +channel-type.atmofrance.index-adv.state.option.0 = Good +channel-type.atmofrance.index-adv.state.option.1 = Average +channel-type.atmofrance.index-adv.state.option.2 = Degraded +channel-type.atmofrance.index-adv.state.option.3 = Bad +channel-type.atmofrance.index-adv.state.option.4 = Very Bad +channel-type.atmofrance.index-adv.state.option.5 = Extremely Bad +channel-type.atmofrance.index-adv.state.option.7 = Event +channel-type.atmofrance.index-adv.state.option.9 = Absent +channel-type.atmofrance.index.label = Air Quality +channel-type.atmofrance.index.state.option.0 = Good +channel-type.atmofrance.index.state.option.1 = Average +channel-type.atmofrance.index.state.option.2 = Degraded +channel-type.atmofrance.index.state.option.3 = Bad +channel-type.atmofrance.index.state.option.4 = Very Bad +channel-type.atmofrance.index.state.option.5 = Extremely Bad +channel-type.atmofrance.index.state.option.7 = Event +channel-type.atmofrance.index.state.option.9 = Absent +channel-type.atmofrance.localdate.label = Timestamp +channel-type.atmofrance.localdate.state.pattern = %1$td/%1$tm +channel-type.atmofrance.olive-level.label = Olive +channel-type.atmofrance.olive-level.state.option.0 = Very Low +channel-type.atmofrance.olive-level.state.option.1 = Low +channel-type.atmofrance.olive-level.state.option.2 = Moderate +channel-type.atmofrance.olive-level.state.option.3 = High +channel-type.atmofrance.olive-level.state.option.4 = Very High +channel-type.atmofrance.olive-level.state.option.5 = Extremely High +channel-type.atmofrance.olive-level.state.option.9 = Absent +channel-type.atmofrance.pollen-index.label = Pollen Index +channel-type.atmofrance.pollen-index.state.option.0 = Very Low +channel-type.atmofrance.pollen-index.state.option.1 = Low +channel-type.atmofrance.pollen-index.state.option.2 = Moderate +channel-type.atmofrance.pollen-index.state.option.3 = High +channel-type.atmofrance.pollen-index.state.option.4 = Very High +channel-type.atmofrance.pollen-index.state.option.5 = Extremely High +channel-type.atmofrance.pollen-index.state.option.9 = Absent +channel-type.atmofrance.ragweed-level.label = Ragweed +channel-type.atmofrance.ragweed-level.state.option.0 = Very Low +channel-type.atmofrance.ragweed-level.state.option.1 = Low +channel-type.atmofrance.ragweed-level.state.option.2 = Moderate +channel-type.atmofrance.ragweed-level.state.option.3 = High +channel-type.atmofrance.ragweed-level.state.option.4 = Very High +channel-type.atmofrance.ragweed-level.state.option.5 = Extremely High +channel-type.atmofrance.ragweed-level.state.option.9 = Absent +channel-type.atmofrance.wormwood-level.label = Wormwood +channel-type.atmofrance.wormwood-level.state.option.0 = Very Low +channel-type.atmofrance.wormwood-level.state.option.1 = Low +channel-type.atmofrance.wormwood-level.state.option.2 = Moderate +channel-type.atmofrance.wormwood-level.state.option.3 = High +channel-type.atmofrance.wormwood-level.state.option.4 = Very High +channel-type.atmofrance.wormwood-level.state.option.5 = Extremely High +channel-type.atmofrance.wormwood-level.state.option.9 = Absent + +# channel group types + +channel-group-type.atmofrance.pollens.channel.alder-conc.description = Alder concentration +channel-group-type.atmofrance.pollens.channel.alder-level.description = Alder index +channel-group-type.atmofrance.pollens.channel.birch-conc.description = Birch concentration +channel-group-type.atmofrance.pollens.channel.birch-level.description = Birch index +channel-group-type.atmofrance.pollens.channel.grasses-conc.description = Grasses concentration +channel-group-type.atmofrance.pollens.channel.grasses-level.description = Grasses index +channel-group-type.atmofrance.pollens.channel.olive-conc.description = Olive concentration +channel-group-type.atmofrance.pollens.channel.olive-level.description = Olive index +channel-group-type.atmofrance.pollens.channel.ragweed-conc.description = Ragweed concentration +channel-group-type.atmofrance.pollens.channel.ragweed-level.description = Ragweed index +channel-group-type.atmofrance.pollens.channel.wormwood-conc.description = Wormwood concentration +channel-group-type.atmofrance.pollens.channel.wormwood-level.description = Wormwood index + +# iconprovider + +iconset.label = AtmoFrance Icons +iconset.description = Icons illustrating air quality measures provided by Atmo France + +# error messages + +conf-error-no-username = A username must be provided +conf-error-no-password = A password must be provided +conf-error-no-insee = The INSEE code of the city must be provided +com-error-unauthorized = Communication error: {0} +incorrect-bridge = Wrong bridge type +no-data = No Atmo data provided +no-pollens = No Pollens data provided diff --git a/bundles/org.openhab.binding.atmofrance/src/main/resources/OH-INF/thing/api.xml b/bundles/org.openhab.binding.atmofrance/src/main/resources/OH-INF/thing/api.xml new file mode 100755 index 0000000000..55dd1381c0 --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/resources/OH-INF/thing/api.xml @@ -0,0 +1,27 @@ + + + + + + + Bridge to the Atmo Data API Portal. In order to receive the data, you must register an account at + https://admindata.atmo-france.org/inscription-api and receive your account credentials. + + WebService + + + + + Username from Atmo Data API Account + + + + Password from Atmo Data API Account + password + + + + diff --git a/bundles/org.openhab.binding.atmofrance/src/main/resources/OH-INF/thing/channel-groups.xml b/bundles/org.openhab.binding.atmofrance/src/main/resources/OH-INF/thing/channel-groups.xml new file mode 100644 index 0000000000..4e0d535d6d --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/resources/OH-INF/thing/channel-groups.xml @@ -0,0 +1,88 @@ + + + + + + + + + + Nitrogen dioxide + + + + Sulfur dioxide + + + + Ozone + + + + Particulate matter < 10 µm + + + + Particulate matter < 2,5 µm + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/bundles/org.openhab.binding.atmofrance/src/main/resources/OH-INF/thing/channels.xml b/bundles/org.openhab.binding.atmofrance/src/main/resources/OH-INF/thing/channels.xml new file mode 100755 index 0000000000..da5d0bf011 --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/resources/OH-INF/thing/channels.xml @@ -0,0 +1,220 @@ + + + + + DateTime + + time + + Status + Timestamp + + + + + + Number:Density + + + Measurement + AirQuality + + + + + + Number + + oh:atmofrance:aq + + Measurement + AirQuality + + + + + + + + + + + + + + + + + Number + + oh:atmofrance:pollen + + Measurement + AirQuality + + + + + + + + + + + + + + + + Number + + oh:atmofrance:aq + + Measurement + AirQuality + + + + + + + + + + + + + + + + + Number + + oh:atmofrance:alder + + Measurement + Level + + + + + + + + + + + + + + + + Number + + oh:atmofrance:birch + + Measurement + Level + + + + + + + + + + + + + + + + Number + + oh:atmofrance:olive + + Measurement + Level + + + + + + + + + + + + + + + + Number + + oh:atmofrance:grasses + + Measurement + Level + + + + + + + + + + + + + + + + + Number + + oh:atmofrance:wormwood + + Measurement + Level + + + + + + + + + + + + + + + + Number + + oh:atmofrance:ragweed + + Measurement + Level + + + + + + + + + + + + + + + diff --git a/bundles/org.openhab.binding.atmofrance/src/main/resources/OH-INF/thing/city.xml b/bundles/org.openhab.binding.atmofrance/src/main/resources/OH-INF/thing/city.xml new file mode 100755 index 0000000000..3752ce68c5 --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/resources/OH-INF/thing/city.xml @@ -0,0 +1,27 @@ + + + + + + + + + + Atmo France air quality report for the given city + WebService + + + + + + + + + + + + + diff --git a/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/alder.svg b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/alder.svg new file mode 100755 index 0000000000..30cc5e33a0 --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/alder.svg @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/aq-0.svg b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/aq-0.svg new file mode 100755 index 0000000000..f64c4e1486 --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/aq-0.svg @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/aq-1.svg b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/aq-1.svg new file mode 100755 index 0000000000..2ec54bd4a7 --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/aq-1.svg @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/aq-2.svg b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/aq-2.svg new file mode 100755 index 0000000000..9c275cd0e3 --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/aq-2.svg @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/aq-3.svg b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/aq-3.svg new file mode 100755 index 0000000000..cea0c40423 --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/aq-3.svg @@ -0,0 +1,9 @@ + + + + + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/aq-4.svg b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/aq-4.svg new file mode 100755 index 0000000000..33541b393d --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/aq-4.svg @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/aq-5.svg b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/aq-5.svg new file mode 100755 index 0000000000..653f93afe5 --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/aq-5.svg @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/aq-7.svg b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/aq-7.svg new file mode 100644 index 0000000000..08588dda33 --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/aq-7.svg @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/aq-9.svg b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/aq-9.svg new file mode 100644 index 0000000000..df6cc178c8 --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/aq-9.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/aq.svg b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/aq.svg new file mode 100755 index 0000000000..8fc376eb4c --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/aq.svg @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/ash.svg b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/ash.svg new file mode 100755 index 0000000000..618f3c0b11 --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/ash.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/birch.svg b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/birch.svg new file mode 100755 index 0000000000..54604c9793 --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/birch.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/chestnut.svg b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/chestnut.svg new file mode 100755 index 0000000000..57f5188ffc --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/chestnut.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/cypress.svg b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/cypress.svg new file mode 100755 index 0000000000..5311089b19 --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/cypress.svg @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/grasses.svg b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/grasses.svg new file mode 100755 index 0000000000..74ddca07b0 --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/grasses.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/hazel.svg b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/hazel.svg new file mode 100755 index 0000000000..acfba4a13e --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/hazel.svg @@ -0,0 +1,17 @@ + + + + + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/hornbeam.svg b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/hornbeam.svg new file mode 100755 index 0000000000..501877ee77 --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/hornbeam.svg @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/linden.svg b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/linden.svg new file mode 100755 index 0000000000..7dc5db9eab --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/linden.svg @@ -0,0 +1,8 @@ + + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/oak.svg b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/oak.svg new file mode 100755 index 0000000000..a7ee6cfd42 --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/oak.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/olive.svg b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/olive.svg new file mode 100755 index 0000000000..820ef168ce --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/olive.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/plane.svg b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/plane.svg new file mode 100755 index 0000000000..ef1bddb7e9 --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/plane.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/plantain.svg b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/plantain.svg new file mode 100755 index 0000000000..8907a5f429 --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/plantain.svg @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/pollen-0.svg b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/pollen-0.svg new file mode 100644 index 0000000000..2ccad4ff20 --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/pollen-0.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/pollen-1.svg b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/pollen-1.svg new file mode 100644 index 0000000000..17c4bd8248 --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/pollen-1.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/pollen-2.svg b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/pollen-2.svg new file mode 100644 index 0000000000..ecde6941f8 --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/pollen-2.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/pollen-3.svg b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/pollen-3.svg new file mode 100644 index 0000000000..38ac475918 --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/pollen-3.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/pollen-4.svg b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/pollen-4.svg new file mode 100644 index 0000000000..693058a527 --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/pollen-4.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/pollen-5.svg b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/pollen-5.svg new file mode 100644 index 0000000000..7168a49cfc --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/pollen-5.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/pollen.svg b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/pollen.svg new file mode 100644 index 0000000000..f03f3420d2 --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/pollen.svg @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/poplar.svg b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/poplar.svg new file mode 100755 index 0000000000..d582efa92d --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/poplar.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/ragweed.svg b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/ragweed.svg new file mode 100755 index 0000000000..9da22d6825 --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/ragweed.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/rumex.svg b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/rumex.svg new file mode 100755 index 0000000000..73194f0e80 --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/rumex.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/urticaceae.svg b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/urticaceae.svg new file mode 100755 index 0000000000..c1b8b69052 --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/urticaceae.svg @@ -0,0 +1,14 @@ + + + + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/willow.svg b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/willow.svg new file mode 100755 index 0000000000..52e6631136 --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/willow.svg @@ -0,0 +1,5 @@ + + + \ No newline at end of file diff --git a/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/wormwood.svg b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/wormwood.svg new file mode 100755 index 0000000000..c8c8c90f6d --- /dev/null +++ b/bundles/org.openhab.binding.atmofrance/src/main/resources/icon/wormwood.svg @@ -0,0 +1,13 @@ + + + + + + + + + \ No newline at end of file diff --git a/bundles/pom.xml b/bundles/pom.xml index 0aa434dafc..d6a4208208 100644 --- a/bundles/pom.xml +++ b/bundles/pom.xml @@ -69,6 +69,7 @@ org.openhab.binding.astro org.openhab.binding.asuswrt org.openhab.binding.atlona + org.openhab.binding.atmofrance org.openhab.binding.autelis org.openhab.binding.automower org.openhab.binding.avmfritz