Initial commit

Signed-off-by: Gaël L'hopital <gael@lhopital.org>
This commit is contained in:
Gaël L'hopital 2024-10-18 13:41:10 +02:00
parent 9544767641
commit 1265419834
39 changed files with 2630 additions and 0 deletions

View File

@ -15,6 +15,7 @@
/bundles/org.openhab.binding.adorne/ @theiding
/bundles/org.openhab.binding.ahawastecollection/ @soenkekueper
/bundles/org.openhab.binding.airgradient/ @austvik
/bundles/org.openhab.binding.airparif/ @clinique
/bundles/org.openhab.binding.airq/ @aurelio1 @fwolter
/bundles/org.openhab.binding.airquality/ @openhab/add-ons-maintainers
/bundles/org.openhab.binding.airvisualnode/ @3cky

View File

@ -66,6 +66,11 @@
<artifactId>org.openhab.binding.airgradient</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.airparif</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.airq</artifactId>

View File

@ -0,0 +1,13 @@
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

View File

@ -0,0 +1,94 @@
# AirParif Binding
_Give some details about what this binding is meant for - a protocol, system, specific device._
_If possible, provide some resources like pictures (only PNG is supported currently), a video, etc. to give an impression of what can be done with this binding._
_You can place such resources into a `doc` folder next to this README.md._
_Put each sentence in a separate line to improve readability of diffs._
## Supported Things
_Please describe the different supported things / devices including their ThingTypeUID within this section._
_Which different types are supported, which models were tested etc.?_
_Note that it is planned to generate some part of this based on the XML files within ```src/main/resources/OH-INF/thing``` of your binding._
- `bridge`: Short description of the Bridge, if any
- `sample`: Short description of the Thing with the ThingTypeUID `sample`
## Discovery
_Describe the available auto-discovery features here._
_Mention for what it works and what needs to be kept in mind when using it._
## Binding Configuration
_If your binding requires or supports general configuration settings, please create a folder ```cfg``` and place the configuration file ```<bindingId>.cfg``` inside it._
_In this section, you should link to this file and provide some information about the options._
_The file could e.g. look like:_
```
# Configuration for the AirParif Binding
#
# Default secret key for the pairing of the AirParif Thing.
# It has to be between 10-40 (alphanumeric) characters.
# This may be changed by the user for security reasons.
secret=openHABSecret
```
_Note that it is planned to generate some part of this based on the information that is available within ```src/main/resources/OH-INF/binding``` of your binding._
_If your binding does not offer any generic configurations, you can remove this section completely._
## Thing Configuration
_Describe what is needed to manually configure a thing, either through the UI or via a thing-file._
_This should be mainly about its mandatory and optional configuration parameters._
_Note that it is planned to generate some part of this based on the XML files within ```src/main/resources/OH-INF/thing``` of your binding._
### `sample` Thing Configuration
| Name | Type | Description | Default | Required | Advanced |
|-----------------|---------|---------------------------------------|---------|----------|----------|
| hostname | text | Hostname or IP address of the device | N/A | yes | no |
| password | text | Password to access the device | N/A | yes | no |
| refreshInterval | integer | Interval the device is polled in sec. | 600 | no | yes |
## Channels
_Here you should provide information about available channel types, what their meaning is and how they can be used._
_Note that it is planned to generate some part of this based on the XML files within ```src/main/resources/OH-INF/thing``` of your binding._
| Channel | Type | Read/Write | Description |
|---------|--------|------------|-----------------------------|
| control | Switch | RW | This is the control channel |
## Full Example
_Provide a full usage example based on textual configuration files._
_*.things, *.items examples are mandatory as textual configuration is well used by many users._
_*.sitemap examples are optional._
### Thing Configuration
```java
Example thing configuration goes here.
```
### Item Configuration
```java
Example item configuration goes here.
```
### Sitemap Configuration
```perl
Optional Sitemap configuration goes here.
Remove this section, if not needed.
```
## Any custom content here!
_Feel free to add additional sections for whatever you think should also be mentioned about your binding!_

View File

@ -0,0 +1,29 @@
Miguel Narvaez Miguel.Narvaez@airparif.fr via improvmx-mails.com
30 sept. 2024 10:06 (il y a 11 jours)
À gael@lhopital.org, api
Bonjour,
Veuillez trouver ci-dessous la clé API vous permettant daccéder à nos API Indices de la qualité de l'air, Épisodes, Cartographie, Pollens :
5a923300-e93d-2a0f-4321-96f115f19100
Cette clé sera active à partir de demain.
Nous ouvrons ces API dans une démarche damélioration continue, nhésitez pas à revenir vers nous pour des retours dexpérience et des propositions daméliorations.
La documentation des APIs se trouve à ladresse suivante :
https://api.airparif.fr/docs
Celle concernant les services Web de cartographie se trouve ici :
https://www.airparif.fr/doc_api_carto/
Les informations qui vous concernent sont destinées uniquement à Airparif. Votre adresse e-mail sera utilisée uniquement pour vous informer à propos des API. Vous disposez d'un droit d'accès, de modification, de rectification et de suppression de ces données (art. 34 de la loi "Informatique et Libertés").
Pour toute question concernant vos données personnelles ou nos API, nous vous invitons à nous contacter via notre formulaire en ligne :
https://www.airparif.asso.fr/contact
Bonne journée !
Airparif

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.addons.reactor.bundles</artifactId>
<version>4.3.0-SNAPSHOT</version>
</parent>
<artifactId>org.openhab.binding.airparif</artifactId>
<name>openHAB Add-ons :: Bundles :: AirParif Binding</name>
</project>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<features name="org.openhab.binding.airparif-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
<repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
<feature name="openhab-binding-airparif" description="AirParif Binding" version="${project.version}">
<feature>openhab-runtime-base</feature>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.airparif/${project.version}</bundle>
</feature>
</features>

View File

@ -0,0 +1,41 @@
/**
* 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;
import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.thing.ThingTypeUID;
/**
* The {@link AirParifBindingConstants} class defines common constants, which are used across the whole binding.
*
* @author Gaël L'hopital - Initial contribution
*/
@NonNullByDefault
public class AirParifBindingConstants {
public static final String BINDING_ID = "airparif";
public static final String LOCAL = "local";
// List of Bridge Type UIDs
public static final ThingTypeUID APIBRIDGE_THING_TYPE = new ThingTypeUID(BINDING_ID, "api");
// List of Things Type UIDs
public static final ThingTypeUID LOCATION_THING_TYPE = new ThingTypeUID(BINDING_ID, "location");
// List of all Channel ids
public static final String CHANNEL_1 = "channel1";
public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(APIBRIDGE_THING_TYPE,
LOCATION_THING_TYPE);
}

View File

@ -0,0 +1,33 @@
/**
* 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;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* An exception that occurred while communicating with Air Parif API server or related processes.
*
* @author Gaël L'hopital - Initial contribution
*/
@NonNullByDefault
public class AirParifException extends Exception {
private static final long serialVersionUID = 4234683995736417341L;
public AirParifException(String format, Object... args) {
super(format.formatted(args));
}
public AirParifException(Exception e, String format, Object... args) {
super(format.formatted(args), e);
}
}

View File

@ -0,0 +1,65 @@
/**
* 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;
import static org.openhab.binding.airparif.internal.AirParifBindingConstants.*;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
import org.openhab.binding.airparif.internal.deserialization.AirParifDeserializer;
import org.openhab.binding.airparif.internal.handler.AirParifBridgeHandler;
import org.openhab.binding.airparif.internal.handler.LocationHandler;
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 AirParifHandlerFactory} is responsible for creating things and thing handlers.
*
* @author Gaël L'hopital - Initial contribution
*/
@NonNullByDefault
@Component(configurationPid = "binding.airparif", service = ThingHandlerFactory.class)
public class AirParifHandlerFactory extends BaseThingHandlerFactory {
private final AirParifDeserializer deserializer;
private final HttpClient httpClient;
@Activate
public AirParifHandlerFactory(final @Reference HttpClientFactory httpClientFactory,
final @Reference AirParifDeserializer deserializer) {
this.httpClient = httpClientFactory.getCommonHttpClient();
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();
return APIBRIDGE_THING_TYPE.equals(thingTypeUID)
? new AirParifBridgeHandler((Bridge) thing, httpClient, deserializer)
: LOCATION_THING_TYPE.equals(thingTypeUID) ? new LocationHandler(thing) : null;
}
}

View File

@ -0,0 +1,99 @@
/**
* 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;
import static org.openhab.binding.airparif.internal.AirParifBindingConstants.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.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 AirParifIconProvider} is the class providing binding related icons.
*
* @author Gaël L'hopital - Initial contribution
*/
@Component(service = { IconProvider.class, AirParifIconProvider.class })
@NonNullByDefault
public class AirParifIconProvider implements IconProvider {
private static final String DEFAULT_LABEL = "Air Parif Icons";
private static final String DEFAULT_DESCRIPTION = "Icons illustrating air quality levels provided by AirParif";
private static final List<String> ICONS = List.of("average", "bad", "degrated", "extremely-bad", "good", "pollen");
private final Logger logger = LoggerFactory.getLogger(AirParifIconProvider.class);
private final TranslationProvider i18nProvider;
private final Bundle bundle;
@Activate
public AirParifIconProvider(final BundleContext context, final @Reference TranslationProvider i18nProvider) {
this.i18nProvider = i18nProvider;
this.bundle = context.getBundle();
}
@Override
public Set<IconSet> getIconSets() {
return getIconSets(null);
}
@Override
public Set<IconSet> getIconSets(@Nullable Locale locale) {
String label = getText("label", DEFAULT_LABEL, locale);
String description = getText("decription", 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) && ICONS.contains(category) ? 0 : null;
}
@Override
public @Nullable InputStream getIcon(String category, String iconSetId, @Nullable String state, Format format) {
URL iconResource = bundle.getEntry("icon/%s.svg".formatted(category));
String result;
try (InputStream stream = iconResource.openStream()) {
result = new String(stream.readAllBytes(), StandardCharsets.UTF_8);
} catch (IOException e) {
logger.warn("Unable to load ressource '{}': {}", iconResource.getPath(), e.getMessage());
result = "";
}
return result.isEmpty() ? null : new ByteArrayInputStream(result.getBytes());
}
}

View File

@ -0,0 +1,126 @@
/**
* 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.api;
import java.net.URI;
import java.util.EnumSet;
import javax.ws.rs.core.UriBuilder;
import org.eclipse.jdt.annotation.NonNullByDefault;
import com.google.gson.annotations.SerializedName;
/**
* {@link AirParifApi} class defines paths used to interact with server api
*
* @author Gaël L'hopital - Initial contribution
*
*/
@NonNullByDefault
public class AirParifApi {
private static final UriBuilder AIRPARIF_BUILDER = UriBuilder.fromPath("/").scheme("https").host("api.airparif.fr");
public static final URI VERSION_URI = AIRPARIF_BUILDER.clone().path("version").build();
public static final URI KEY_INFO_URI = AIRPARIF_BUILDER.clone().path("key-info").build();
public static final URI HORAIR_URI = AIRPARIF_BUILDER.clone().path("horair").path("itineraire").build();
public static final URI EPISODES_URI = AIRPARIF_BUILDER.clone().path("episodes").path("en-cours-et-prevus").build();
private static final UriBuilder INDICES_BUILDER = AIRPARIF_BUILDER.clone().path("indices").path("prevision");
public static final URI PREV_COLORS_URI = INDICES_BUILDER.clone().path("couleurs").build();
public static final URI PREV_BULLETIN_URI = INDICES_BUILDER.clone().path("bulletin").build();
private static final UriBuilder POLLENS_BUILDER = AIRPARIF_BUILDER.clone().path("pollens");
public static final URI POLLENS_URI = POLLENS_BUILDER.clone().path("bulletin").build();
// Poor interest, only returns highest risk level for the dept.
// public static final UriBuilder POLLENS_DEPT_BUILDER = POLLENS_BUILDER.clone().path("departement");
public enum Scope {
@SerializedName("Cartes et résultats Hor'Air")
MAPS,
@SerializedName("Pollens")
POLLENS,
@SerializedName("Épisodes")
EVENTS,
@SerializedName("Indices")
INDEXES,
UNKNOWN;
}
public enum Appreciation {
GOOD("Bon"),
AVERAGE("Moyen"),
DEGRATED("Dégradé"),
BAD("Mauvais"),
REALLY_BAD("Très Mauvais"),
EXTREMELY_BAD("Extrêmement Mauvais"),
UNKNOWN("");
public final String apiName;
Appreciation(String apiName) {
this.apiName = apiName;
}
public static final EnumSet<Appreciation> AS_SET = EnumSet.allOf(Appreciation.class);
}
public enum Pollen {
@SerializedName("cypres")
CYPRESS("cypres"),
@SerializedName("noisetier")
HAZEL("noisetier"),
@SerializedName("aulne")
ALDER("aulne"),
@SerializedName("peuplier")
POPLAR("peuplier"),
@SerializedName("saule")
WILLOW("saule"),
@SerializedName("frene")
ASH("frene"),
@SerializedName("charme")
HORNBEAM("charme"),
@SerializedName("bouleau")
BIRCH("bouleau"),
@SerializedName("platane")
PLANE("platane"),
@SerializedName("chene")
OAK("chene"),
@SerializedName("olivier")
OLIVE("olivier"),
@SerializedName("tilleul")
LINDEN("tilleul"),
@SerializedName("chataignier")
CHESTNUT("chataignier"),
@SerializedName("rumex")
RUMEX("rumex"),
@SerializedName("graminees")
GRASSES("graminees"),
@SerializedName("plantain")
PLANTAIN("plantain"),
@SerializedName("urticacees")
URTICACEAE("urticacees"),
@SerializedName("armoises")
WORMWOOD("armoises"),
@SerializedName("ambroisies")
RAGWEED("ambroisies");
public final String apiName;
Pollen(String apiName) {
this.apiName = apiName;
}
public static final EnumSet<Pollen> AS_SET = EnumSet.allOf(Pollen.class);
}
}

View File

@ -0,0 +1,127 @@
/**
* 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.api;
import java.time.LocalDate;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
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 com.google.gson.annotations.SerializedName;
/**
* {@link AirParifDto} class defines DTO used to interact with server api
*
* @author Gaël L'hopital - Initial contribution
*
*/
@NonNullByDefault
public class AirParifDto {
public record Version(//
String version) {
}
public record KeyInfo(//
ZonedDateTime expiration, //
@SerializedName("droits") Set<Scope> scopes) {
}
private record Message(//
String fr, //
@Nullable String en) {
}
public record PollutantConcentration(//
Pollutant pollutant, //
int min, //
int max) {
}
public record PollutantEpisode(//
@SerializedName("nom") Pollutant pollutant, //
@SerializedName("niveau") String level) {
}
public record DailyBulletin(//
@SerializedName("date") LocalDate previsionDate, //
@SerializedName("date_previ") LocalDate productionDate, //
@SerializedName("disponible") boolean available, //
Message bulletin, //
Set<PollutantConcentration> concentrations) {
public String dayDescription() {
return bulletin.fr;
}
}
public record DailyEpisode(//
@SerializedName("actif") boolean active, //
@SerializedName("polluants") Set<PollutantEpisode> pollutants) {
}
public record Bulletin( //
@SerializedName("jour") DailyBulletin today, //
@SerializedName("demain") DailyBulletin tomorrow) {
}
public record Episode( //
@SerializedName("actif") boolean active, Message message, @SerializedName("jour") DailyEpisode today, //
@SerializedName("demain") DailyEpisode tomorrow) {
}
public record Pollens(//
Pollen[] taxons, //
Map<String, PollenAlertLevel[]> valeurs, //
String commentaire, //
String periode) {
private static DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("dd.MM.yy");
private static Pattern PATTERN = Pattern.compile("\\d{2}.\\d{2}.\\d{2}");
private static @Nullable LocalDate getValidity(String periode, boolean begin) {
Matcher matcher = PATTERN.matcher(periode);
if (matcher.find()) {
String extractedDate = matcher.group();
if (begin) {
return LocalDate.parse(extractedDate, FORMATTER);
}
if (matcher.find()) {
extractedDate = matcher.group();
return LocalDate.parse(extractedDate, FORMATTER);
}
}
return null;
}
public @Nullable LocalDate beginValidity() {
return getValidity(periode, true);
}
public @Nullable LocalDate endValidity() {
return getValidity(periode, false);
}
}
public record PollensResponse(ArrayList<Pollens> data) {
}
}

View File

@ -0,0 +1,36 @@
/**
* 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.api;
import java.util.HashMap;
import java.util.Objects;
import org.openhab.binding.airparif.internal.api.AirParifApi.Appreciation;
/**
* Class association between air quality appreciation and its color
*
* @author Gaël L'hopital - Initial contribution
*/
public class ColorMap extends HashMap<Appreciation, String> {
private static final long serialVersionUID = -605462873565278453L;
private static Appreciation fromApiName(String searched) {
return Objects.requireNonNull(
Appreciation.AS_SET.stream().filter(mt -> searched.equals(mt.apiName)).findFirst().orElse(Appreciation.UNKNOWN));
}
public String put(String key, String value) {
return super.put(fromApiName(key), value);
}
}

View File

@ -0,0 +1,43 @@
/**
* 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.api;
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 PollenAlertLevel {
@SerializedName("0")
NONE(0),
@SerializedName("1")
LOW(1),
@SerializedName("2")
AVERAGE(2),
@SerializedName("3")
HIGH(3),
UNKNOWN(-1);
public static final EnumSet<PollenAlertLevel> AS_SET = EnumSet.allOf(PollenAlertLevel.class);
public final int riskLevel;
PollenAlertLevel(int riskLevel) {
this.riskLevel = riskLevel;
}
}

View File

@ -0,0 +1,41 @@
/**
* 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.api;
import java.util.EnumSet;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* The {@link Pollutant} enum lists all pollutants tracked by AirParif
*
* @author Gaël L'hopital - Initial contribution
*/
@NonNullByDefault
public enum Pollutant {
PM25,
PM10,
NO2,
O3,
UNKNOWN;
public static final EnumSet<Pollutant> AS_SET = EnumSet.allOf(Pollutant.class);
public static Pollutant safeValueOf(String searched) {
try {
return Pollutant.valueOf(searched);
} catch (IllegalArgumentException e) {
return Pollutant.UNKNOWN;
}
}
}

View File

@ -0,0 +1,25 @@
/**
* 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.config;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* The {@link BridgeConfiguration} is the class used to match the bridge configuration.
*
* @author Gaël L"hopital - Initial contribution
*/
@NonNullByDefault
public class BridgeConfiguration {
public String apikey = "";
}

View File

@ -0,0 +1,29 @@
/**
* 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.config;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* The {@link LocationConfiguration} is the class used to match the
* thing configuration.
*
* @author Gaël L"hopital - Initial contribution
*/
@NonNullByDefault
public class LocationConfiguration {
public static final String LOCATION = "location";
public int refresh = 10;
public String location = "";
}

View File

@ -0,0 +1,77 @@
/**
* 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.db;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.library.types.PointType;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.FieldNamingPolicy;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonIOException;
import com.google.gson.JsonSyntaxException;
/**
* The {@link DepartmentDbService} makes available a list of known French Metropolitan departments.
*
* @author Gaël L'hopital - Initial Contribution
*/
@Component(service = DepartmentDbService.class)
@NonNullByDefault
public class DepartmentDbService {
private final Logger logger = LoggerFactory.getLogger(DepartmentDbService.class);
private final List<Department> departments = new ArrayList<>();
public record Department(String id, String name, double northestLat, double southestLat, double eastestLon,
double westestLon) {
}
@Activate
public DepartmentDbService() {
try (InputStream is = Thread.currentThread().getContextClassLoader()
.getResourceAsStream("/db/departments.json");
Reader reader = new InputStreamReader(is, StandardCharsets.UTF_8)) {
Gson gson = new GsonBuilder().setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES).create();
departments.addAll(Arrays.asList(gson.fromJson(reader, Department[].class)));
logger.debug("Successfully loaded {} departments", departments.size());
} catch (IOException | JsonSyntaxException | JsonIOException e) {
logger.warn("Unable to load departments list: {}", e.getMessage());
}
}
public List<Department> getBounding(PointType location) {
double latitude = location.getLatitude().doubleValue();
double longitude = location.getLongitude().doubleValue();
return departments.stream().filter(dep -> dep.northestLat >= latitude && dep.southestLat <= latitude
&& dep.westestLon <= longitude && dep.eastestLon >= longitude).toList();
}
public @Nullable Department getDept(String deptId) {
return departments.stream().filter(dep -> dep.id.equalsIgnoreCase(deptId)).findFirst().orElse(null);
}
}

View File

@ -0,0 +1,75 @@
/**
* 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.deserialization;
import java.time.LocalDate;
import java.time.ZonedDateTime;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.airparif.internal.AirParifException;
import org.openhab.binding.airparif.internal.api.AirParifDto.PollutantConcentration;
import org.openhab.binding.airparif.internal.api.ColorMap;
import org.openhab.binding.airparif.internal.api.PollenAlertLevel;
import org.openhab.core.i18n.TimeZoneProvider;
import org.osgi.service.component.annotations.Activate;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
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 AirParifDeserializer} is responsible to instantiate suitable Gson (de)serializer
*
* @author Gaël L'hopital - Initial contribution
*/
@NonNullByDefault
@Component(service = AirParifDeserializer.class)
public class AirParifDeserializer {
private final Gson gson;
@Activate
public AirParifDeserializer(final @Reference TimeZoneProvider timeZoneProvider) {
gson = new GsonBuilder().setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
.registerTypeAdapter(PollenAlertLevel.class, new PollenAlertLevelDeserializer())
.registerTypeAdapterFactory(new StrictEnumTypeAdapterFactory())
.registerTypeAdapter(ColorMap.class, new ColorMapDeserializer())
.registerTypeAdapter(PollutantConcentration.class, new PollutantConcentrationDeserializer())
.registerTypeAdapter(LocalDate.class,
(JsonDeserializer<LocalDate>) (json, type, context) -> LocalDate
.parse(json.getAsJsonPrimitive().getAsString()))
.registerTypeAdapter(ZonedDateTime.class,
(JsonDeserializer<ZonedDateTime>) (json, type, context) -> ZonedDateTime
.parse(json.getAsJsonPrimitive().getAsString() + "Z")
.withZoneSameInstant(timeZoneProvider.getTimeZone()))
.create();
}
public <T> T deserialize(Class<T> clazz, String json) throws AirParifException {
try {
@Nullable
T result = gson.fromJson(json, clazz);
if (result != null) {
return result;
}
throw new AirParifException("Deserialization of '%s' resulted in null value", json);
} catch (JsonSyntaxException e) {
throw new AirParifException(e, "Unexpected error deserializing '%s'", json);
}
}
}

View File

@ -0,0 +1,42 @@
/**
* 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.deserialization;
import java.lang.reflect.Type;
import java.util.Map;
import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.airparif.internal.api.ColorMap;
import com.google.gson.JsonDeserializationContext;
import com.google.gson.JsonDeserializer;
import com.google.gson.JsonElement;
/**
* Specialized deserializer for ColorMap class
*
* @author Gaël L'hopital - Initial contribution
*/
@NonNullByDefault
class ColorMapDeserializer implements JsonDeserializer<ColorMap> {
@Override
public @Nullable ColorMap deserialize(JsonElement json, Type clazz, JsonDeserializationContext context) {
ColorMap result = new ColorMap();
Set<Map.Entry<String, JsonElement>> entrySet = json.getAsJsonObject().entrySet();
entrySet.forEach(entry -> result.put(entry.getKey(), entry.getValue().getAsString()));
return result;
}
}

View File

@ -0,0 +1,47 @@
/**
* 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.deserialization;
import java.lang.reflect.Type;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.airparif.internal.api.PollenAlertLevel;
import com.google.gson.JsonDeserializationContext;
import com.google.gson.JsonDeserializer;
import com.google.gson.JsonElement;
import com.google.gson.JsonSyntaxException;
/**
* Specialized deserializer for ColorMap class
*
* @author Gaël L'hopital - Initial contribution
*/
@NonNullByDefault
class PollenAlertLevelDeserializer implements JsonDeserializer<PollenAlertLevel> {
@Override
public @Nullable PollenAlertLevel deserialize(JsonElement json, Type clazz, JsonDeserializationContext context) {
int level;
try {
level = json.getAsInt();
} catch (JsonSyntaxException ignore) {
return PollenAlertLevel.UNKNOWN;
}
return PollenAlertLevel.AS_SET.stream().filter(s -> s.riskLevel == level).findFirst()
.orElse(PollenAlertLevel.UNKNOWN);
}
}

View File

@ -0,0 +1,52 @@
/**
* 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.deserialization;
import java.lang.reflect.Type;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.airparif.internal.api.AirParifDto.PollutantConcentration;
import org.openhab.binding.airparif.internal.api.Pollutant;
import com.google.gson.JsonArray;
import com.google.gson.JsonDeserializationContext;
import com.google.gson.JsonDeserializer;
import com.google.gson.JsonElement;
import com.google.gson.JsonSyntaxException;
/**
* Specialized deserializer for ColorMap class
*
* @author Gaël L'hopital - Initial contribution
*/
@NonNullByDefault
class PollutantConcentrationDeserializer implements JsonDeserializer<PollutantConcentration> {
@Override
public @Nullable PollutantConcentration deserialize(JsonElement json, Type clazz,
JsonDeserializationContext context) {
PollutantConcentration result = null;
JsonArray array = json.getAsJsonArray();
if (array.size() == 3) {
Pollutant pollutant = Pollutant.safeValueOf(array.get(0).getAsString());
try {
result = new PollutantConcentration(pollutant, array.get(1).getAsInt(), array.get(2).getAsInt());
} catch (JsonSyntaxException ignore) {
// result will remain null
}
}
return result;
}
}

View File

@ -0,0 +1,64 @@
/**
* 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.deserialization;
import java.io.IOException;
import java.io.StringReader;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import com.google.gson.Gson;
import com.google.gson.TypeAdapter;
import com.google.gson.TypeAdapterFactory;
import com.google.gson.reflect.TypeToken;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonWriter;
/**
* Enforces a fallback to UNKNOWN when deserializing enum types, marked as @NonNull whereas they were valued
* to null if the appropriate value is absent.
*
* @author Gaël L'hopital - Initial contribution
*/
@NonNullByDefault
class StrictEnumTypeAdapterFactory implements TypeAdapterFactory {
@Override
public @Nullable <T> TypeAdapter<T> create(@NonNullByDefault({}) Gson gson,
@NonNullByDefault({}) TypeToken<T> type) {
return type.getRawType().isEnum() ? newStrictEnumAdapter(gson.getDelegateAdapter(this, type)) : null;
}
private <T> TypeAdapter<T> newStrictEnumAdapter(@NonNullByDefault({}) TypeAdapter<T> delegateAdapter) {
return new TypeAdapter<>() {
@Override
public void write(JsonWriter out, @Nullable T value) throws IOException {
delegateAdapter.write(out, value);
}
@Override
public @Nullable T read(JsonReader in) throws IOException {
JsonReader delegateReader = new JsonReader(
new StringReader('"' + in.nextString().replace(",", "") + '"'));
@Nullable
T value = delegateAdapter.read(delegateReader);
delegateReader.close();
if (value == null) {
value = delegateAdapter.read(new JsonReader(new StringReader("\"UNKNOWN\"")));
}
return value;
}
};
}
}

View File

@ -0,0 +1,80 @@
/**
* 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.discovery;
import static org.openhab.binding.airparif.internal.AirParifBindingConstants.*;
import static org.openhab.binding.airparif.internal.config.LocationConfiguration.LOCATION;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.airparif.internal.handler.AirParifBridgeHandler;
import org.openhab.core.config.discovery.AbstractThingHandlerDiscoveryService;
import org.openhab.core.config.discovery.DiscoveryResultBuilder;
import org.openhab.core.i18n.LocaleProvider;
import org.openhab.core.i18n.LocationProvider;
import org.openhab.core.i18n.TranslationProvider;
import org.openhab.core.library.types.PointType;
import org.openhab.core.thing.ThingUID;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.osgi.service.component.annotations.ServiceScope;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link AirParifDiscoveryService} creates things based on the configured location.
*
* @author Gaël L'hopital - Initial Contribution
*/
@Component(scope = ServiceScope.PROTOTYPE, service = AirParifDiscoveryService.class)
@NonNullByDefault
public class AirParifDiscoveryService extends AbstractThingHandlerDiscoveryService<AirParifBridgeHandler> {
private static final int DISCOVER_TIMEOUT_SECONDS = 2;
private final Logger logger = LoggerFactory.getLogger(AirParifDiscoveryService.class);
private @NonNullByDefault({}) LocationProvider locationProvider;
public AirParifDiscoveryService() {
super(AirParifBridgeHandler.class, SUPPORTED_THING_TYPES_UIDS, DISCOVER_TIMEOUT_SECONDS);
}
@Reference(unbind = "-")
public void bindTranslationProvider(TranslationProvider translationProvider) {
this.i18nProvider = translationProvider;
}
@Reference(unbind = "-")
public void bindLocaleProvider(LocaleProvider localeProvider) {
this.localeProvider = localeProvider;
}
@Reference(unbind = "-")
public void bindLocationProvider(LocationProvider locationProvider) {
this.locationProvider = locationProvider;
}
@Override
protected void startScan() {
logger.debug("Starting AirParif discovery scan");
if (locationProvider.getLocation() instanceof PointType location) {
ThingUID bridgeUID = thingHandler.getThing().getUID();
thingDiscovered(DiscoveryResultBuilder.create(new ThingUID(LOCATION_THING_TYPE, bridgeUID, LOCAL))
.withLabel("@text/discovery.airparif.location.local.label") //
.withProperty(LOCATION, location.toString()) //
.withRepresentationProperty(LOCATION) //
.withBridge(bridgeUID).build());
} else {
logger.debug("LocationProvider.getLocation() is not set, no discovery results can be provided");
}
}
}

View File

@ -0,0 +1,176 @@
/**
* 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 static org.openhab.binding.airparif.internal.api.AirParifApi.*;
import java.net.URI;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.time.LocalDate;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.stream.Collectors;
import javax.ws.rs.core.MediaType;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jetty.client.HttpClient;
import org.eclipse.jetty.client.api.ContentResponse;
import org.eclipse.jetty.client.api.Request;
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.airparif.internal.AirParifException;
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.KeyInfo;
import org.openhab.binding.airparif.internal.api.AirParifDto.Pollens;
import org.openhab.binding.airparif.internal.api.AirParifDto.PollensResponse;
import org.openhab.binding.airparif.internal.api.AirParifDto.Version;
import org.openhab.binding.airparif.internal.api.ColorMap;
import org.openhab.binding.airparif.internal.config.BridgeConfiguration;
import org.openhab.binding.airparif.internal.deserialization.AirParifDeserializer;
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;
/**
* {@link AirParifBridgeHandler} is the handler for OpenUV API and connects it
* to the webservice.
*
* @author Gaël L'hopital - Initial contribution
*
*/
@NonNullByDefault
public class AirParifBridgeHandler extends BaseBridgeHandler {
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(AirParifBridgeHandler.class);
private final AirParifDeserializer deserializer;
private final HttpClient httpClient;
private BridgeConfiguration config = new BridgeConfiguration();
public AirParifBridgeHandler(Bridge bridge, HttpClient httpClient, AirParifDeserializer deserializer) {
super(bridge);
this.deserializer = deserializer;
this.httpClient = httpClient;
}
@Override
public void initialize() {
logger.debug("Initializing AirParif bridge handler.");
config = getConfigAs(BridgeConfiguration.class);
if (config.apikey.isEmpty()) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"@text/offline.config-error-unknown-apikey");
return;
}
initiateConnexion();
}
public synchronized String executeUri(URI uri) throws AirParifException {
logger.debug("executeUrl: {} ", uri);
Request request = httpClient.newRequest(uri).method(HttpMethod.GET)
.timeout(REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.header(HttpHeader.ACCEPT, MediaType.APPLICATION_JSON).header("X-Api-Key", config.apikey);
try {
ContentResponse response = request.send();
Code statusCode = HttpStatus.getCode(response.getStatus());
if (statusCode == Code.OK) {
String content = new String(response.getContent(), DEFAULT_CHARSET);
logger.trace("executeUrl: {} returned {}", uri, content);
return content;
} else if (statusCode == Code.FORBIDDEN) {
throw new AirParifException("@text/offline.config-error-invalid-apikey");
}
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());
} catch (InterruptedException e) {
throw new AirParifException(e, "Execution interrupted: %s", e.getMessage());
}
}
public synchronized <T> T executeUri(URI uri, Class<T> clazz) throws AirParifException {
String content = executeUri(uri);
return deserializer.deserialize(clazz, content);
}
@Override
public void handleCommand(ChannelUID channelUID, Command command) {
logger.debug("The AirParif bridge does not handles commands");
}
private void initiateConnexion() {
Version version;
KeyInfo keyInfo;
try { // This does validate communication with the server
version = executeUri(VERSION_URI, Version.class);
} catch (AirParifException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
return;
}
try { // This validates the api key value
keyInfo = executeUri(KEY_INFO_URI, KeyInfo.class);
} catch (AirParifException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
return;
}
getThing().setProperty("api-version", version.version());
getThing().setProperty("key-expiration", keyInfo.expiration().toString());
logger.info("The api key is valid until {}", keyInfo.expiration().toString());
getThing().setProperty("scopes", keyInfo.scopes().stream().map(e -> e.name()).collect(Collectors.joining(",")));
updateStatus(ThingStatus.ONLINE);
try {
ColorMap map = executeUri(PREV_COLORS_URI, ColorMap.class);
logger.info("The color map is {}", map.toString());
Bulletin bulletin = executeUri(PREV_BULLETIN_URI, Bulletin.class);
logger.info("The bulletin is {}", bulletin.today().dayDescription());
Episode episode = executeUri(EPISODES_URI, Episode.class);
logger.info("The bulletin is {}", episode);
Pollens pollens = executeUri(POLLENS_URI, PollensResponse.class).data().get(0);
logger.info("The pollens are {}", pollens);
LocalDate begin = pollens.beginValidity();
LocalDate end = pollens.endValidity();
String response = executeUri(POLLENS_DEPT_BUILDER.path("78").build());
logger.info("The pollens 78 {}", response);
} catch (AirParifException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
return;
}
}
}

View File

@ -0,0 +1,78 @@
/**
* 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 static org.openhab.binding.airparif.internal.AirParifBindingConstants.CHANNEL_1;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.airparif.internal.config.LocationConfiguration;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.binding.BaseThingHandler;
import org.openhab.core.types.Command;
import org.openhab.core.types.RefreshType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link LocationHandler} is responsible for handling commands, which are
* sent to one of the channels.
*
* @author Gaël L'hopital - Initial contribution
*/
@NonNullByDefault
public class LocationHandler extends BaseThingHandler {
private final Logger logger = LoggerFactory.getLogger(LocationHandler.class);
private @Nullable LocationConfiguration config;
public LocationHandler(Thing thing) {
super(thing);
}
@Override
public void handleCommand(ChannelUID channelUID, Command command) {
if (CHANNEL_1.equals(channelUID.getId())) {
if (command instanceof RefreshType) {
// TODO: handle data refresh
}
// TODO: handle command
// Note: if communication with thing fails for some reason,
// indicate that by setting the status with detail information:
// updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
// "Could not control device at IP address x.x.x.x");
}
}
@Override
public void initialize() {
config = getConfigAs(LocationConfiguration.class);
updateStatus(ThingStatus.UNKNOWN);
// Example for background initialization:
scheduler.execute(() -> {
boolean thingReachable = true; // <background task with long running initialization here>
// when done do:
if (thingReachable) {
updateStatus(ThingStatus.ONLINE);
} else {
updateStatus(ThingStatus.OFFLINE);
}
});
}
}

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<addon:addon id="airparif" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:addon="https://openhab.org/schemas/addon/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/addon/v1.0.0 https://openhab.org/schemas/addon-1.0.0.xsd">
<type>binding</type>
<name>AirParif Binding</name>
<description>Air Quality data and forecasts provided by AirParif.</description>
<connection>cloud</connection>
<countries>fr</countries>
</addon:addon>

View File

@ -0,0 +1,14 @@
# discovery result
discovery.airparif.location.local.label = Air Quality Report
# iconprovider
iconset.label = AirParif Icons
iconset.description = Icons illustrating air quality measures provided by AirParif
# thing status descriptions
offline.config-error-unknown-apikey = Parameter 'apikey' must be configured
offline.config-error-invalid-apikey = Parameter 'apikey' is invalid

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="airparif"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
<bridge-type id="api">
<label>AirParif API Portal</label>
<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.
</description>
<config-description>
<parameter name="apikey" type="text" required="true">
<label>API Key</label>
<description>Token used to access the service</description>
<context>password</context>
</parameter>
</config-description>
</bridge-type>
</thing:thing-descriptions>

View File

@ -0,0 +1,220 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="airparif"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
<channel-type id="alert-level">
<item-type>Number</item-type>
<label>@text/alertLevelChannelLabel</label>
<description>@text/alertLevelChannelDescription</description>
<category>error</category>
<tags>
<tag>Alarm</tag>
</tags>
<state readOnly="true">
<options>
<option value="0">Good</option>
<option value="1">Average</option>
<option value="2">Degrated</option>
<option value="3">Bad</option>
<option value="4">Extremely Bad</option>
</options>
</state>
</channel-type>
<channel-type id="timestamp" advanced="true">
<item-type>DateTime</item-type>
<label>@text/timestampChannelLabel</label>
<description>@text/timestampChannelDescription</description>
<category>time</category>
<state readOnly="true"/>
</channel-type>
<channel-type id="dominent">
<item-type>String</item-type>
<label>@text/dominentChannelLabel</label>
<state readOnly="true">
<options>
<option value="pm25">@text/pollutantPm25</option>
<option value="pm10">@text/pollutantPm10</option>
<option value="o3">@text/pollutantO3</option>
<option value="no2">@text/pollutantNO2</option>
<option value="co">@text/pollutantCO</option>
<option value="so2">@text/pollutantSO2</option>
</options>
</state>
</channel-type>
<channel-type id="rain-intensity">
<item-type>Number</item-type>
<label>Intensity</label>
<description>Rain intensity level</description>
<category>oh:meteofrance:intensity</category>
<state readOnly="true">
<options>
<option value="0">Dry Weather</option>
<option value="1">Light Rain</option>
<option value="2">Moderate Rain</option>
<option value="3">Heavy Rain</option>
</options>
</state>
</channel-type>
<channel-type id="vent">
<item-type>Number</item-type>
<label>Wind</label>
<description>Wind event alert level</description>
<category>oh:meteofrance:vent</category>
<state readOnly="true">
<options>
<option value="0">No special vigilance</option>
<option value="1">Be attentive</option>
<option value="2">Be very vigilant</option>
<option value="3">Absolute vigilance</option>
</options>
</state>
</channel-type>
<channel-type id="orage">
<item-type>Number</item-type>
<label>Storm</label>
<description>Storm alert level</description>
<category>oh:meteofrance:orage</category>
<state readOnly="true">
<options>
<option value="0">No special vigilance</option>
<option value="1">Be attentive</option>
<option value="2">Be very vigilant</option>
<option value="3">Absolute vigilance</option>
</options>
</state>
</channel-type>
<channel-type id="inondation">
<item-type>Number</item-type>
<label>Flood</label>
<description>Flood alert level</description>
<category>oh:meteofrance:inondation</category>
<state readOnly="true">
<options>
<option value="0">No special vigilance</option>
<option value="1">Be attentive</option>
<option value="2">Be very vigilant</option>
<option value="3">Absolute vigilance</option>
</options>
</state>
</channel-type>
<channel-type id="neige">
<item-type>Number</item-type>
<label>Snow</label>
<description>Snow event alert level</description>
<category>oh:meteofrance:neige</category>
<state readOnly="true">
<options>
<option value="0">No special vigilance</option>
<option value="1">Be attentive</option>
<option value="2">Be very vigilant</option>
<option value="3">Absolute vigilance</option>
</options>
</state>
</channel-type>
<channel-type id="canicule">
<item-type>Number</item-type>
<label>Heat Wave</label>
<description>High temperature alert level</description>
<category>oh:meteofrance:canicule</category>
<state readOnly="true">
<options>
<option value="0">No special vigilance</option>
<option value="1">Be attentive</option>
<option value="2">Be very vigilant</option>
<option value="3">Absolute vigilance</option>
</options>
</state>
</channel-type>
<channel-type id="grand-froid">
<item-type>Number</item-type>
<label>Extreme Cold</label>
<description>Negative temperature alert level</description>
<category>oh:meteofrance:grand-froid</category>
<state readOnly="true">
<options>
<option value="0">No special vigilance</option>
<option value="1">Be attentive</option>
<option value="2">Be very vigilant</option>
<option value="3">Absolute vigilance</option>
</options>
</state>
</channel-type>
<channel-type id="avalanches">
<item-type>Number</item-type>
<label>Avalanches</label>
<description>Avalanche alert level</description>
<category>oh:meteofrance:avalanches</category>
<state readOnly="true">
<options>
<option value="0">No special vigilance</option>
<option value="1">Be attentive</option>
<option value="2">Be very vigilant</option>
<option value="3">Absolute vigilance</option>
</options>
</state>
</channel-type>
<channel-type id="vague-submersion">
<item-type>Number</item-type>
<label>Wave Submersion</label>
<description>Submersion wave alert level</description>
<category>oh:meteofrance:vague-submersion</category>
<state readOnly="true">
<options>
<option value="0">No special vigilance</option>
<option value="1">Be attentive</option>
<option value="2">Be very vigilant</option>
<option value="3">Absolute vigilance</option>
</options>
</state>
</channel-type>
<channel-type id="pluie-inondation">
<item-type>Number</item-type>
<label>Rain Flood</label>
<description>Flood caused by rainfall alert level</description>
<category>oh:meteofrance:pluie-inondation</category>
<state readOnly="true">
<options>
<option value="0">No special vigilance</option>
<option value="1">Be attentive</option>
<option value="2">Be very vigilant</option>
<option value="3">Absolute vigilance</option>
</options>
</state>
</channel-type>
<channel-type id="comment">
<item-type>String</item-type>
<label>Comment</label>
<category>text</category>
<state readOnly="true" pattern="%s"/>
</channel-type>
<channel-type id="timestamp" advanced="true">
<item-type>DateTime</item-type>
<label>Observation Timestamp</label>
<category>time</category>
<state readOnly="true"/>
</channel-type>
<channel-type id="condition-icon">
<item-type>Image</item-type>
<label>Icon</label>
<description>Pictogram associated with the alert level.</description>
<state readOnly="true"/>
</channel-type>
</thing:thing-descriptions>

View File

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="airparif"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
<thing-type id="location">
<supported-bridge-type-refs>
<bridge-type-ref id="api"/>
</supported-bridge-type-refs>
<label>Air Quality Report</label>
<description>AirParif air quality report for the given location</description>
<channels>
<channel id="channel1" typeId="sample-channel"/>
</channels>
<config-description>
<parameter name="location" type="text" required="false"
pattern="^[-+]?([1-8]?\d(\.\d+)?|90(\.0+)?)[,]\s*[-+]?(180(\.0+)?|((1[0-7]\d)|([1-9]?\d))(\.\d+)?)$">
<context>location</context>
<label>Location</label>
</parameter>
</config-description>
</thing-type>
</thing:thing-descriptions>

View File

@ -0,0 +1,770 @@
[
{
"id": "01",
"name": "Ain",
"northest_lat": 46.517199374492,
"southest_lat": 45.611235124481,
"eastest_lon": 6.1697363568789,
"westest_lon": 4.729097034294
},
{
"id": "02",
"name": "Aisne",
"northest_lat": 50.069271974234,
"southest_lat": 48.837795469902,
"eastest_lon": 4.2540638814402,
"westest_lon": 2.9624508927558
},
{
"id": "03",
"name": "Allier",
"northest_lat": 46.803872076205,
"southest_lat": 45.930727869968,
"eastest_lon": 4.0055701432229,
"westest_lon": 2.2804029533754
},
{
"id": "04",
"name": "Alpes-de-Haute-Provence",
"northest_lat": 44.659501345682,
"southest_lat": 43.668282105491,
"eastest_lon": 6.9668199032047,
"westest_lon": 5.4980104773442
},
{
"id": "05",
"name": "Hautes-Alpes",
"northest_lat": 45.12684420383,
"southest_lat": 44.186478874057,
"eastest_lon": 7.0771048243018,
"westest_lon": 5.4185330627929
},
{
"id": "06",
"name": "Alpes-Maritimes",
"northest_lat": 44.361051257141,
"southest_lat": 43.480065494401,
"eastest_lon": 7.7169378581589,
"westest_lon": 6.6363906079569
},
{
"id": "07",
"name": "Ardèche",
"northest_lat": 45.365674921417,
"southest_lat": 44.264783733394,
"eastest_lon": 4.8865943991285,
"westest_lon": 3.8615128126047
},
{
"id": "08",
"name": "Ardennes",
"northest_lat": 50.168317417174,
"southest_lat": 49.228510768771,
"eastest_lon": 5.3935393812988,
"westest_lon": 4.0252899216328
},
{
"id": "09",
"name": "Ariège",
"northest_lat": 43.315601482419,
"southest_lat": 42.572397819345,
"eastest_lon": 2.1758766074869,
"westest_lon": 0.82612266137771
},
{
"id": "10",
"name": "Aube",
"northest_lat": 48.716672523486,
"southest_lat": 47.924112631788,
"eastest_lon": 4.863174195777,
"westest_lon": 3.3883584814447
},
{
"id": "11",
"name": "Aude",
"northest_lat": 43.459927734352,
"southest_lat": 42.64890098195,
"eastest_lon": 3.2405623482295,
"westest_lon": 1.6884233932357
},
{
"id": "12",
"name": "Aveyron",
"northest_lat": 44.941219492647,
"southest_lat": 43.692056102371,
"eastest_lon": 3.4507554815828,
"westest_lon": 1.8396044963184
},
{
"id": "13",
"name": "Bouches-du-Rhône",
"northest_lat": 43.92406219253,
"southest_lat": 43.162545513212,
"eastest_lon": 5.8132476569219,
"westest_lon": 4.2302808850321
},
{
"id": "14",
"name": "Calvados",
"northest_lat": 49.429859840723,
"southest_lat": 48.752223582274,
"eastest_lon": 0.44627431224134,
"westest_lon": -1.1595014604014
},
{
"id": "15",
"name": "Cantal",
"northest_lat": 45.480702895801,
"southest_lat": 44.61552895784,
"eastest_lon": 3.3699091459722,
"westest_lon": 2.0629079799591
},
{
"id": "16",
"name": "Charente",
"northest_lat": 46.139591492141,
"southest_lat": 45.191628193392,
"eastest_lon": 0.9456207917489,
"westest_lon": -0.46177341555077
},
{
"id": "17",
"name": "Charente-Maritime",
"northest_lat": 46.371051399922,
"southest_lat": 45.088810634907,
"eastest_lon": 0.005823224821197,
"westest_lon": -1.5614800452621
},
{
"id": "18",
"name": "Cher",
"northest_lat": 47.628965936616,
"southest_lat": 46.420403547753,
"eastest_lon": 3.0793324170792,
"westest_lon": 1.7745852665449
},
{
"id": "19",
"name": "Corrèze",
"northest_lat": 45.763895555756,
"southest_lat": 44.923721627249,
"eastest_lon": 2.5283596411119,
"westest_lon": 1.2271245972559
},
{
"id": "21",
"name": "Côte-d'Or",
"northest_lat": 48.030241950581,
"southest_lat": 46.900857518168,
"eastest_lon": 5.5185372800929,
"westest_lon": 4.0660574486622
},
{
"id": "22",
"name": "Côtes-d'Armor",
"northest_lat": 48.867411825697,
"southest_lat": 48.035478415172,
"eastest_lon": -1.9089921410274,
"westest_lon": -3.663669588163
},
{
"id": "23",
"name": "Creuse",
"northest_lat": 46.45481310551,
"southest_lat": 45.664008285608,
"eastest_lon": 2.6107853057918,
"westest_lon": 1.3748978470741
},
{
"id": "24",
"name": "Dordogne",
"northest_lat": 45.714569962764,
"southest_lat": 44.57129825478,
"eastest_lon": 1.4482602497483,
"westest_lon": -0.041998525054412
},
{
"id": "25",
"name": "Doubs",
"northest_lat": 47.579897594928,
"southest_lat": 46.553996454277,
"eastest_lon": 7.0622006908671,
"westest_lon": 5.6987272452696
},
{
"id": "26",
"name": "Drôme",
"northest_lat": 45.344042230781,
"southest_lat": 44.115716677493,
"eastest_lon": 5.8294720463131,
"westest_lon": 4.6477668446587
},
{
"id": "27",
"name": "Eure",
"northest_lat": 49.485111873749,
"southest_lat": 48.666521479531,
"eastest_lon": 1.8026740663848,
"westest_lon": 0.29722451460974
},
{
"id": "28",
"name": "Eure-et-Loir",
"northest_lat": 48.941051842112,
"southest_lat": 47.953852019595,
"eastest_lon": 1.9940901445311,
"westest_lon": 0.76023175104941
},
{
"id": "29",
"name": "Finistère",
"northest_lat": 48.75230874743,
"southest_lat": 47.762638930067,
"eastest_lon": -3.3880788564101,
"westest_lon": -5.138001239929
},
{
"id": "30",
"name": "Gard",
"northest_lat": 44.459798467391,
"southest_lat": 43.460183661653,
"eastest_lon": 4.8455501032842,
"westest_lon": 3.2628340569911
},
{
"id": "31",
"name": "Haute-Garonne",
"northest_lat": 43.920240096152,
"southest_lat": 42.68989270234,
"eastest_lon": 2.0478554672695,
"westest_lon": 0.44199364903152
},
{
"id": "32",
"name": "Gers",
"northest_lat": 44.078224550392,
"southest_lat": 43.310884111508,
"eastest_lon": 1.2013345895525,
"westest_lon": -0.28211623210758
},
{
"id": "33",
"name": "Gironde",
"northest_lat": 45.574691325999,
"southest_lat": 44.193811119459,
"eastest_lon": 0.31506020240148,
"westest_lon": -1.2617334302552
},
{
"id": "34",
"name": "Hérault",
"northest_lat": 43.969527164685,
"southest_lat": 43.212804132866,
"eastest_lon": 4.1944474773799,
"westest_lon": 2.5399656073586
},
{
"id": "35",
"name": "Ille-et-Vilaine",
"northest_lat": 48.704854504824,
"southest_lat": 47.631356309182,
"eastest_lon": -1.0168893967587,
"westest_lon": -2.289084836122
},
{
"id": "36",
"name": "Indre",
"northest_lat": 47.276819032313,
"southest_lat": 46.347214822447,
"eastest_lon": 2.2043920861378,
"westest_lon": 0.86746898682573
},
{
"id": "37",
"name": "Indre-et-Loire",
"northest_lat": 47.709346222795,
"southest_lat": 46.737086924375,
"eastest_lon": 1.3653663291974,
"westest_lon": 0.053277684947378
},
{
"id": "38",
"name": "Isère",
"northest_lat": 45.883269928025,
"southest_lat": 44.696067584965,
"eastest_lon": 6.3588423781754,
"westest_lon": 4.7441167394752
},
{
"id": "39",
"name": "Jura",
"northest_lat": 47.305475313171,
"southest_lat": 46.260731935709,
"eastest_lon": 6.2033299339615,
"westest_lon": 5.2548827302617
},
{
"id": "40",
"name": "Landes",
"northest_lat": 44.532195517275,
"southest_lat": 43.487949116697,
"eastest_lon": 0.13672631290526,
"westest_lon": -1.524870110434
},
{
"id": "41",
"name": "Loir-et-Cher",
"northest_lat": 48.132548568904,
"southest_lat": 47.18622172903,
"eastest_lon": 2.2478931361182,
"westest_lon": 0.58052041667909
},
{
"id": "42",
"name": "Loire",
"northest_lat": 46.275936357353,
"southest_lat": 45.232177394436,
"eastest_lon": 4.7604638818845,
"westest_lon": 3.6906909501902
},
{
"id": "43",
"name": "Haute-Loire",
"northest_lat": 45.427582294546,
"southest_lat": 44.743866105932,
"eastest_lon": 4.489606977621,
"westest_lon": 3.0822533822787
},
{
"id": "44",
"name": "Loire-Atlantique",
"northest_lat": 47.833557723029,
"southest_lat": 46.860078088448,
"eastest_lon": -0.94643916329696,
"westest_lon": -2.5589448655806
},
{
"id": "45",
"name": "Loiret",
"northest_lat": 48.344598562828,
"southest_lat": 47.482968445146,
"eastest_lon": 3.1284487900515,
"westest_lon": 1.5129691249084
},
{
"id": "46",
"name": "Lot",
"northest_lat": 45.046275852749,
"southest_lat": 44.204018679795,
"eastest_lon": 2.2108934010391,
"westest_lon": 0.98177646477517
},
{
"id": "47",
"name": "Lot-et-Garonne",
"northest_lat": 44.764390535693,
"southest_lat": 43.973860568873,
"eastest_lon": 1.0779367166615,
"westest_lon": -0.14068987994571
},
{
"id": "48",
"name": "Lozère",
"northest_lat": 44.971408091786,
"southest_lat": 44.113818175271,
"eastest_lon": 3.9981617468281,
"westest_lon": 2.981675726654
},
{
"id": "49",
"name": "Maine-et-Loire",
"northest_lat": 47.809992506553,
"southest_lat": 46.969397597368,
"eastest_lon": 0.23453049018557,
"westest_lon": -1.3541992398083
},
{
"id": "50",
"name": "Manche",
"northest_lat": 49.725557927402,
"southest_lat": 48.458282754255,
"eastest_lon": -0.73732101904671,
"westest_lon": -1.9472733176655
},
{
"id": "51",
"name": "Marne",
"northest_lat": 49.406179032892,
"southest_lat": 48.516108006569,
"eastest_lon": 5.0379027924329,
"westest_lon": 3.398657955437
},
{
"id": "52",
"name": "Haute-Marne",
"northest_lat": 48.688711618117,
"southest_lat": 47.576950536437,
"eastest_lon": 5.8908642780035,
"westest_lon": 4.6268310932286
},
{
"id": "53",
"name": "Mayenne",
"northest_lat": 48.567994064435,
"southest_lat": 47.733379704738,
"eastest_lon": -0.049909790963035,
"westest_lon": -1.238247803597
},
{
"id": "54",
"name": "Meurthe-et-Moselle",
"northest_lat": 49.562644003065,
"southest_lat": 48.349889737943,
"eastest_lon": 7.1231636635608,
"westest_lon": 5.429907860027
},
{
"id": "55",
"name": "Meuse",
"northest_lat": 49.617086785829,
"southest_lat": 48.410687855212,
"eastest_lon": 5.8541770017029,
"westest_lon": 4.8885820531146
},
{
"id": "56",
"name": "Morbihan",
"northest_lat": 48.210884763611,
"southest_lat": 47.283069445657,
"eastest_lon": -2.0357552590146,
"westest_lon": -3.7321436369252
},
{
"id": "57",
"name": "Moselle",
"northest_lat": 49.510019040716,
"southest_lat": 48.52694525177,
"eastest_lon": 7.6352815933424,
"westest_lon": 5.8934039932125
},
{
"id": "58",
"name": "Nièvre",
"northest_lat": 47.587958865747,
"southest_lat": 46.651760006926,
"eastest_lon": 4.2306617272065,
"westest_lon": 2.8451871650071
},
{
"id": "59",
"name": "Nord",
"northest_lat": 51.08854370897,
"southest_lat": 49.969186662527,
"eastest_lon": 4.2279959931456,
"westest_lon": 2.0677049871716
},
{
"id": "60",
"name": "Oise",
"northest_lat": 49.758309270134,
"southest_lat": 49.060452516659,
"eastest_lon": 3.162641421643,
"westest_lon": 1.6895744511517
},
{
"id": "61",
"name": "Orne",
"northest_lat": 48.972557592954,
"southest_lat": 48.181599665308,
"eastest_lon": 0.9762713097259,
"westest_lon": -0.86036021134895
},
{
"id": "62",
"name": "Pas-de-Calais",
"northest_lat": 51.006501514321,
"southest_lat": 50.020975633738,
"eastest_lon": 3.1883563131291,
"westest_lon": 1.5577948179294
},
{
"id": "63",
"name": "Puy-de-Dôme",
"northest_lat": 46.255486133208,
"southest_lat": 45.287121578381,
"eastest_lon": 3.9844000097893,
"westest_lon": 2.388014020679
},
{
"id": "64",
"name": "Pyrénées-Atlantiques",
"northest_lat": 43.596401195938,
"southest_lat": 42.777515930774,
"eastest_lon": 0.02629551293813,
"westest_lon": -1.7908870919282
},
{
"id": "65",
"name": "Hautes-Pyrénées",
"northest_lat": 43.609311711216,
"southest_lat": 42.674921018438,
"eastest_lon": 0.64553925526757,
"westest_lon": -0.3270823405503
},
{
"id": "66",
"name": "Pyrénées-Orientales",
"northest_lat": 42.918339638726,
"southest_lat": 42.33364688988,
"eastest_lon": 3.1747892794105,
"westest_lon": 1.7256472450279
},
{
"id": "67",
"name": "Bas-Rhin",
"northest_lat": 49.077884925649,
"southest_lat": 48.120371112534,
"eastest_lon": 8.2303986615424,
"westest_lon": 6.9403717864006
},
{
"id": "68",
"name": "Haut-Rhin",
"northest_lat": 48.310471263573,
"southest_lat": 47.422198455938,
"eastest_lon": 7.6220859200517,
"westest_lon": 6.8428287756472
},
{
"id": "69",
"name": "Rhône",
"northest_lat": 46.303994122044,
"southest_lat": 45.45503324486,
"eastest_lon": 5.1592030475156,
"westest_lon": 4.243469905983
},
{
"id": "70",
"name": "Haute-Saône",
"northest_lat": 48.023714993889,
"southest_lat": 47.253139353829,
"eastest_lon": 6.8235333222471,
"westest_lon": 5.3727580571009
},
{
"id": "71",
"name": "Saône-et-Loire",
"northest_lat": 47.155410810959,
"southest_lat": 46.156946471815,
"eastest_lon": 5.4622983197648,
"westest_lon": 3.6225898833129
},
{
"id": "72",
"name": "Sarthe",
"northest_lat": 48.482954540393,
"southest_lat": 47.569104805534,
"eastest_lon": 0.91379809767445,
"westest_lon": -0.44786007819229
},
{
"id": "73",
"name": "Savoie",
"northest_lat": 45.93845768164,
"southest_lat": 45.051837207667,
"eastest_lon": 7.1842712160815,
"westest_lon": 5.6230208703548
},
{
"id": "74",
"name": "Haute-Savoie",
"northest_lat": 46.408081546332,
"southest_lat": 45.682198985672,
"eastest_lon": 7.0438913499404,
"westest_lon": 5.8074048290847
},
{
"id": "75",
"name": "Paris",
"northest_lat": 48.902007785215,
"southest_lat": 48.816314210034,
"eastest_lon": 2.4675819883673,
"westest_lon": 2.2242191058804
},
{
"id": "76",
"name": "Seine-Maritime",
"northest_lat": 50.070851596042,
"southest_lat": 49.252262168305,
"eastest_lon": 1.79022549105,
"westest_lon": 0.065609431053556
},
{
"id": "77",
"name": "Seine-et-Marne",
"northest_lat": 49.11755332218,
"southest_lat": 48.122204261229,
"eastest_lon": 3.555613758785,
"westest_lon": 2.3931765378081
},
{
"id": "78",
"name": "Yvelines",
"northest_lat": 49.08303659502,
"southest_lat": 48.440146719021,
"eastest_lon": 2.2265538842831,
"westest_lon": 1.4472851104304
},
{
"id": "79",
"name": "Deux-Sèvres",
"northest_lat": 47.108333434925,
"southest_lat": 45.969661749473,
"eastest_lon": 0.22035828616308,
"westest_lon": -0.89196408624284
},
{
"id": "80",
"name": "Somme",
"northest_lat": 50.366290636763,
"southest_lat": 49.571762327,
"eastest_lon": 3.2030417908111,
"westest_lon": 1.3796981484469
},
{
"id": "81",
"name": "Tarn",
"northest_lat": 44.200834436147,
"southest_lat": 43.383508824887,
"eastest_lon": 2.93545676901,
"westest_lon": 1.5439759556659
},
{
"id": "82",
"name": "Tarn-et-Garonne",
"northest_lat": 44.393331095059,
"southest_lat": 43.770780304363,
"eastest_lon": 1.9963637896774,
"westest_lon": 0.73810974125492
},
{
"id": "83",
"name": "Var",
"northest_lat": 43.806770970879,
"southest_lat": 42.982043008785,
"eastest_lon": 6.9337211159516,
"westest_lon": 5.6559638228901
},
{
"id": "84",
"name": "Vaucluse",
"northest_lat": 44.431367227183,
"southest_lat": 43.658685905188,
"eastest_lon": 5.7573377215236,
"westest_lon": 4.649227423465
},
{
"id": "85",
"name": "Vendée",
"northest_lat": 47.083893903306,
"southest_lat": 46.266566974958,
"eastest_lon": -0.53779518169029,
"westest_lon": -2.3987614706025
},
{
"id": "86",
"name": "Vienne",
"northest_lat": 47.175754285742,
"southest_lat": 46.049008552161,
"eastest_lon": 1.2126877519811,
"westest_lon": -0.1021158452812
},
{
"id": "87",
"name": "Haute-Vienne",
"northest_lat": 46.401546863371,
"southest_lat": 45.437356928582,
"eastest_lon": 1.9094484810021,
"westest_lon": 0.62974117909144
},
{
"id": "88",
"name": "Vosges",
"northest_lat": 48.513587820739,
"southest_lat": 47.813051201983,
"eastest_lon": 7.1982872111206,
"westest_lon": 5.3944755711529
},
{
"id": "89",
"name": "Yonne",
"northest_lat": 48.39970411104,
"southest_lat": 47.312769315752,
"eastest_lon": 4.3403007872795,
"westest_lon": 2.8487899744432
},
{
"id": "90",
"name": "Territoire de Belfort",
"northest_lat": 47.824784354742,
"southest_lat": 47.433371743667,
"eastest_lon": 7.1398015507652,
"westest_lon": 6.7576409592057
},
{
"id": "91",
"name": "Essonne",
"northest_lat": 48.776101996393,
"southest_lat": 48.284688606385,
"eastest_lon": 2.5853737107586,
"westest_lon": 1.9149199821626
},
{
"id": "92",
"name": "Hauts-de-Seine",
"northest_lat": 48.950965864655,
"southest_lat": 48.72948996497,
"eastest_lon": 2.3363529889891,
"westest_lon": 2.1458760215967
},
{
"id": "93",
"name": "Seine-Saint-Denis",
"northest_lat": 49.012397786393,
"southest_lat": 48.807437551952,
"eastest_lon": 2.6025997962059,
"westest_lon": 2.2882536989787
},
{
"id": "94",
"name": "Val-de-Marne",
"northest_lat": 48.861405371284,
"southest_lat": 48.688326690701,
"eastest_lon": 2.6136517425679,
"westest_lon": 2.3102224901101
},
{
"id": "95",
"name": "Val-d'Oise",
"northest_lat": 49.232197221792,
"southest_lat": 48.908679329899,
"eastest_lon": 2.5905283926735,
"westest_lon": 1.608798807603
},
{
"id": "2A",
"name": "Corse-du-Sud",
"northest_lat": 42.381404942274,
"southest_lat": 41.362164776515,
"eastest_lon": 9.4073217319481,
"westest_lon": 8.5401025407511
},
{
"id": "2B",
"name": "Haute-Corse",
"northest_lat": 43.011724041684,
"southest_lat": 41.832143660252,
"eastest_lon": 9.5592262719626,
"westest_lon": 8.5734085639674
}
]

View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 98.15 95">
<circle cx="47.85" cy="48.04" r="34.72" style="fill:#fff;stroke:#65bba2;stroke-miterlimit:10;stroke-width:14px"/>
<circle cx="59.23" cy="41.74" r="3.57" class="cls-2"/>
<circle cx="36.76" cy="41.74" r="3.57" class="cls-2"/>
<path d="m35.27 59.3 26.81-.05"
style="fill:none;stroke:#1e1e1c;stroke-linecap:round;stroke-linejoin:round;stroke-width:3px"/>
</svg>

After

Width:  |  Height:  |  Size: 452 B

View File

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 98.15 95">
<circle cx="47.85" cy="48.04" r="34.72" style="stroke-miterlimit:10;fill:#fff;stroke:#ea5553;stroke-width:14px"/>
<path d="M34.46 63.64s4.34-8.68 13.28-8.68 12.34 4.86 14.81 8.43"
style="fill:none;stroke:#1e1e1c;stroke-linecap:round;stroke-width:3px;stroke-linejoin:round"/>
<circle cx="59.48" cy="41.74" r="3.57" class="cls-3"/>
<circle cx="37.01" cy="41.74" r="3.57" class="cls-3"/>
<path d="m32.51 30.33 16.13 7.93 15.59-7.93"
style="stroke-miterlimit:10;fill:none;stroke:#1e1e1c;stroke-linecap:round;stroke-width:3px"/>
</svg>

After

Width:  |  Height:  |  Size: 635 B

View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 98.15 95">
<circle cx="47.85" cy="48.04" r="34.72" style="fill:#fff;stroke:#ffe00a;stroke-miterlimit:10;stroke-width:14px"/>
<path d="M34.29 63.64s4.34-8.68 13.28-8.68 12.34 4.86 14.81 8.43"
style="fill:none;stroke:#1e1e1c;stroke-linecap:round;stroke-linejoin:round;stroke-width:3px"/>
<circle cx="59.32" cy="41.74" r="3.57" class="cls-2"/>
<circle cx="36.85" cy="41.74" r="3.57" class="cls-2"/>
</svg>

After

Width:  |  Height:  |  Size: 486 B

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 98.15 95">
<circle cx="47.85" cy="48.04" r="34.72" style="fill:#fff;stroke:#7b2681;stroke-miterlimit:10;stroke-width:14px"/>
<path
d="m63.81 60.51-4.15-4.06-4.15 3.99-4.07-4.23-3.83 4.23h-.01l-4.15-4.07-4.15 3.99-4.07-4.23-3.83 4.23M35.36 32.72l8.17 5.62-8.17 6.64M60.11 32.72l-8.17 5.62 8.17 6.64"
class="cls-1"/>
</svg>

After

Width:  |  Height:  |  Size: 401 B

View File

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 98.15 95">
<circle cx="47.85" cy="48.04" r="34.72" style="fill:#fff;stroke:#8bd1f5;stroke-miterlimit:10;stroke-width:14px"/>
<path
d="M47.45 65.74c-4.39 0-9.68-2.37-12.06-6.57-2.18-3.85 2.56-3.66 2.56-3.66h20.84s4.81-.25 2.56 3.66c-2.41 4.19-7.68 6.57-12.06 6.57h-1.83Z"
style="fill:none;stroke:#1e1e1c;stroke-linecap:round;stroke-linejoin:round;stroke-width:3px"/>
<circle cx="58.74" cy="41.74" r="3.57" class="cls-3"/>
<circle cx="36.27" cy="41.74" r="3.57" class="cls-3"/>
</svg>

After

Width:  |  Height:  |  Size: 566 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB