]> git.basschouten.com Git - openhab-addons.git/commitdiff
[pihole] New binding PiHole (#16627)
authorMartin <martin.grzeslowski@gmail.com>
Tue, 9 Jul 2024 14:07:47 +0000 (16:07 +0200)
committerGitHub <noreply@github.com>
Tue, 9 Jul 2024 14:07:47 +0000 (16:07 +0200)
* Init Pi-hole binding

Signed-off-by: Martin GrzeĊ›lowski <martin.grzeslowski@gmail.com>
22 files changed:
CODEOWNERS
bom/openhab-addons/pom.xml
bundles/org.openhab.binding.pihole/NOTICE [new file with mode: 0644]
bundles/org.openhab.binding.pihole/README.md [new file with mode: 0644]
bundles/org.openhab.binding.pihole/pom.xml [new file with mode: 0644]
bundles/org.openhab.binding.pihole/src/main/feature/feature.xml [new file with mode: 0644]
bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/PiHoleActions.java [new file with mode: 0644]
bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/PiHoleBindingConstants.java [new file with mode: 0644]
bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/PiHoleConfiguration.java [new file with mode: 0644]
bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/PiHoleException.java [new file with mode: 0644]
bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/PiHoleHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/PiHoleHandlerFactory.java [new file with mode: 0644]
bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/AdminService.java [new file with mode: 0644]
bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/JettyAdminService.java [new file with mode: 0644]
bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/model/DnsStatistics.java [new file with mode: 0644]
bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/model/GravityLastUpdated.java [new file with mode: 0644]
bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/model/Relative.java [new file with mode: 0644]
bundles/org.openhab.binding.pihole/src/main/resources/OH-INF/addon/addon.xml [new file with mode: 0644]
bundles/org.openhab.binding.pihole/src/main/resources/OH-INF/i18n/pihole.properties [new file with mode: 0644]
bundles/org.openhab.binding.pihole/src/main/resources/OH-INF/thing/thing-types.xml [new file with mode: 0644]
bundles/org.openhab.binding.pihole/src/test/java/org/openhab/binding/pihole/internal/rest/JettyAdminServiceTest.java [new file with mode: 0644]
bundles/pom.xml

index 59b492dac404cb1c963deb9d4ca90d13849e63f5..d2c062713ece25c21f89c7f01683fb245b4e1e38 100755 (executable)
 /bundles/org.openhab.binding.pegelonline/ @weymann
 /bundles/org.openhab.binding.pentair/ @jsjames
 /bundles/org.openhab.binding.phc/ @gnlpfjh
+/bundles/org.openhab.binding.pihole/ @magx2
 /bundles/org.openhab.binding.pilight/ @stefanroellin @niklasdoerfler
 /bundles/org.openhab.binding.pioneeravr/ @Stratehm
 /bundles/org.openhab.binding.pixometer/ @Confectrician
index fd550b659695a80c7fed3ab09bf8527c4bfcdf43..a572a4b2a52ca6049a602c41f958469cc87bff31 100644 (file)
       <artifactId>org.openhab.binding.phc</artifactId>
       <version>${project.version}</version>
     </dependency>
+    <dependency>
+      <groupId>org.openhab.addons.bundles</groupId>
+      <artifactId>org.openhab.binding.pihole</artifactId>
+      <version>${project.version}</version>
+    </dependency>
     <dependency>
       <groupId>org.openhab.addons.bundles</groupId>
       <artifactId>org.openhab.binding.pilight</artifactId>
diff --git a/bundles/org.openhab.binding.pihole/NOTICE b/bundles/org.openhab.binding.pihole/NOTICE
new file mode 100644 (file)
index 0000000..38d625e
--- /dev/null
@@ -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
diff --git a/bundles/org.openhab.binding.pihole/README.md b/bundles/org.openhab.binding.pihole/README.md
new file mode 100644 (file)
index 0000000..27c2d05
--- /dev/null
@@ -0,0 +1,165 @@
+# Pi-hole Binding
+
+The Pi-hole Binding is a bridge between openHAB and Pi-hole, enabling users to integrate Pi-hole statistics and controls into their home automation setup. Pi-hole is a DNS-based ad blocker that can run on a variety of platforms, including Raspberry Pi.
+
+Pi-hole is a powerful network-level advertisement and internet tracker blocking application.
+By intercepting DNS requests, it can prevent unwanted content from being displayed on devices connected to your network.
+The Pi-hole Binding allows you to monitor Pi-hole statistics and control its functionality directly from your openHAB setup.
+
+### Features
+
+- Real-time Statistics: Monitor key metrics such as the number of domains being blocked, DNS queries made today, ads blocked today, and more.
+- Control: Enable or disable Pi-hole's blocking functionality, configure blocking options, and adjust privacy settings directly from openHAB.
+- Integration: Seamlessly integrate Pi-hole data and controls with other openHAB items and rules to create advanced automation scenarios.
+
+## Supported Things
+
+- `server`: Pi-hole server
+
+## Thing Configuration
+
+### `server` Thing Configuration
+
+| Name            | Type    | Description                                                                               | Default | Required | Advanced |
+|-----------------|---------|-------------------------------------------------------------------------------------------|---------|----------|----------|
+| hostname        | text    | Hostname or IP address of the device                                                      | N/A     | yes      | no       |
+| token           | text    | Token to access the device. To generate token go to `settings` > `API` > `Show API token` | N/A     | yes      | no       |
+| refreshInterval | integer | Interval the device is polled in sec.                                                     | 600     | no       | yes      |
+
+## Channels
+
+| Channel                 | Type   | Read/Write | Description                                                |
+|-------------------------|--------|------------|------------------------------------------------------------|
+| domains-being-blocked   | Number | RO         | The total number of domains currently being blocked.       |
+| dns-queries-today       | Number | RO         | The count of DNS queries made today.                       |
+| ads-blocked-today       | Number | RO         | The number of ads blocked today.                           |
+| ads-percentage-today    | Number | RO         | The percentage of ads blocked today.                       |
+| unique-domains          | Number | RO         | The count of unique domains queried.                       |
+| queries-forwarded       | Number | RO         | The number of queries forwarded to an external DNS server. |
+| queries-cached          | Number | RO         | The number of queries served from the cache.               |
+| clients-ever-seen       | Number | RO         | The total number of unique clients ever seen.              |
+| unique-clients          | Number | RO         | The current count of unique clients.                       |
+| dns-queries-all-types   | Number | RO         | The total number of DNS queries of all types.              |
+| reply-unknown           | Number | RO         | DNS replies with an unknown status.                        |
+| reply-nodata            | Number | RO         | DNS replies indicating no data.                            |
+| reply-nxdomain          | Number | RO         | DNS replies indicating non-existent domain.                |
+| reply-cname             | Number | RO         | DNS replies with a CNAME record.                           |
+| reply-ip                | Number | RO         | DNS replies with an IP address.                            |
+| reply-domain            | Number | RO         | DNS replies with a domain name.                            |
+| reply-rrname            | Number | RO         | DNS replies with a resource record name.                   |
+| reply-servfail          | Number | RO         | DNS replies indicating a server failure.                   |
+| reply-refused           | Number | RO         | DNS replies indicating refusal.                            |
+| reply-notimp            | Number | RO         | DNS replies indicating not implemented.                    |
+| reply-other             | Number | RO         | DNS replies with other statuses.                           |
+| reply-dnssec            | Number | RO         | DNS replies with DNSSEC information.                       |
+| reply-none              | Number | RO         | DNS replies with no data.                                  |
+| reply-blob              | Number | RO         | DNS replies with a BLOB (binary large object).             |
+| dns-queries-all-replies | Number | RO         | The total number of DNS queries with all reply types.      |
+| privacy-level           | Number | RO         | The privacy level setting.                                 |
+| enabled                 | Switch | RO         | The current status of blocking                             |
+| disable-enable          | String | RW         | Is blocking enabled/disabled                               |
+
+## Full Example
+
+### Thing Configuration
+
+```java
+Thing pihole:server:a4a077edb8 "Pi-hole" @ "Location"
+[
+    refreshIntervalSeconds=600,
+    hostname="http://123.456.7.89",
+    token="as654gadf3h1dsfh654dfh6fh7et654asd3g21fh654eth8t4swd4g3s1g65sfg5"
+] {
+    Channels:
+        Type number : domains_being_blocked "Domains Blocked" [ ]
+        Type number : dns_queries_today "DNS Queries Today" [ ]
+        Type number : ads_blocked_today "Ads Blocked Today" [ ]
+        Type number : ads_percentage_today "Ads Percentage Today" [ ]
+        Type number : unique_domains "Unique Domains" [ ]
+        Type number : queries_forwarded "Queries Forwarded" [ ]
+        Type number : queries_cached "Queries Cached" [ ]
+        Type number : clients_ever_seen "Clients Ever Seen" [ ]
+        Type number : unique_clients "Unique Clients" [ ]
+        Type number : dns_queries_all_types "DNS Queries (All Types)" [ ]
+        Type number : reply_UNKNOWN "Reply UNKNOWN" [ ]
+        Type number : reply_NODATA "Reply NODATA" [ ]
+        Type number : reply_NXDOMAIN "Reply NXDOMAIN" [ ]
+        Type number : reply_CNAME "Reply CNAME" [ ]
+        Type number : reply_IP "Reply IP" [ ]
+        Type number : reply_DOMAIN "Reply DOMAIN" [ ]
+        Type number : reply_RRNAME "Reply RRNAME" [ ]
+        Type number : reply_SERVFAIL "Reply SERVFAIL" [ ]
+        Type number : reply_REFUSED "Reply REFUSED" [ ]
+        Type number : reply_NOTIMP "Reply NOTIMP" [ ]
+        Type number : reply_OTHER "Reply OTHER" [ ]
+        Type number : reply_DNSSEC "Reply DNSSEC" [ ]
+        Type number : reply_NONE "Reply NONE" [ ]
+        Type number : reply_BLOB "Reply BLOB" [ ]
+        Type number : dns_queries_all_replies "DNS Queries (All Replies)" [ ]
+        Type number : privacy_level "Privacy Level" [ ]
+        Type switch : enabled "Status" [ ]
+        Type string : disable-enable "Disable Blocking" [ ]
+}
+```
+
+### Item Configuration
+
+```java
+Number domains_being_blocked "Domains Blocked" { channel="pihole:server:a4a077edb8:domains_being_blocked" }
+Number dns_queries_today "DNS Queries Today" { channel="pihole:server:a4a077edb8:dns_queries_today" }
+Number ads_blocked_today "Ads Blocked Today" { channel="pihole:server:a4a077edb8:ads_blocked_today" }
+Number ads_percentage_today "Ads Percentage Today" { channel="pihole:server:a4a077edb8:ads_percentage_today" }
+Number unique_domains "Unique Domains" { channel="pihole:server:a4a077edb8:unique_domains" }
+Number queries_forwarded "Queries Forwarded" { channel="pihole:server:a4a077edb8:queries_forwarded" }
+Number queries_cached "Queries Cached" { channel="pihole:server:a4a077edb8:queries_cached" }
+Number clients_ever_seen "Clients Ever Seen" { channel="pihole:server:a4a077edb8:clients_ever_seen" }
+Number unique_clients "Unique Clients" { channel="pihole:server:a4a077edb8:unique_clients" }
+Number dns_queries_all_types "DNS Queries (All Types)" { channel="pihole:server:a4a077edb8:dns_queries_all_types" }
+Number reply_UNKNOWN "Reply UNKNOWN" { channel="pihole:server:a4a077edb8:reply_UNKNOWN" }
+Number reply_NODATA "Reply NODATA" { channel="pihole:server:a4a077edb8:reply_NODATA" }
+Number reply_NXDOMAIN "Reply NXDOMAIN" { channel="pihole:server:a4a077edb8:reply_NXDOMAIN" }
+Number reply_CNAME "Reply CNAME" { channel="pihole:server:a4a077edb8:reply_CNAME" }
+Number reply_IP "Reply IP" { channel="pihole:server:a4a077edb8:reply_IP" }
+Number reply_DOMAIN "Reply DOMAIN" { channel="pihole:server:a4a077edb8:reply_DOMAIN" }
+Number reply_RRNAME "Reply RRNAME" { channel="pihole:server:a4a077edb8:reply_RRNAME" }
+Number reply_SERVFAIL "Reply SERVFAIL" { channel="pihole:server:a4a077edb8:reply_SERVFAIL" }
+Number reply_REFUSED "Reply REFUSED" { channel="pihole:server:a4a077edb8:reply_REFUSED" }
+Number reply_NOTIMP "Reply NOTIMP" { channel="pihole:server:a4a077edb8:reply_NOTIMP" }
+Number reply_OTHER "Reply OTHER" { channel="pihole:server:a4a077edb8:reply_OTHER" }
+Number reply_DNSSEC "Reply DNSSEC" { channel="pihole:server:a4a077edb8:reply_DNSSEC" }
+Number reply_NONE "Reply NONE" { channel="pihole:server:a4a077edb8:reply_NONE" }
+Number reply_BLOB "Reply BLOB" { channel="pihole:server:a4a077edb8:reply_BLOB" }
+Number dns_queries_all_replies "DNS Queries (All Replies)" { channel="pihole:server:a4a077edb8:dns_queries_all_replies" }
+Number privacy_level "Privacy Level" { channel="pihole:server:a4a077edb8:privacy_level" }
+Switch enabled "Status" { channel="pihole:server:a4a077edb8:enabled" }
+String disable_enable "Disable Blocking" { channel="pihole:server:a4a077edb8:disable-enable" }
+```
+
+### Actions
+
+Pi-hole binding provides actions to use in rules:
+
+```java
+import java.util.concurrent.TimeUnit
+        
+rule "test"
+when
+    /* when */
+then
+       val actions = getActions("pihole", "pihole:server:as8af03m38")
+       if (actions !== null) {
+            // disable blocking for 5 * 60 seconds (5 minutes)
+            actions.disableBlocking(5 * 60)
+    
+            // disable blocking for 5 minutes
+            actions.disableBlocking(5, TimeUnit.MINUTES)
+    
+            // disable blocking for infinity
+            actions.disableBlocking(0)
+            actions.disableBlocking()
+    
+            // enable blocking
+            actions.enableBlocking()
+       } 
+end
+```
diff --git a/bundles/org.openhab.binding.pihole/pom.xml b/bundles/org.openhab.binding.pihole/pom.xml
new file mode 100644 (file)
index 0000000..e50274c
--- /dev/null
@@ -0,0 +1,31 @@
+<?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.pihole</artifactId>
+
+  <name>openHAB Add-ons :: Bundles :: Pi-hole Binding</name>
+
+  <dependencies>
+    <dependency>
+      <groupId>org.assertj</groupId>
+      <artifactId>assertj-core</artifactId>
+      <version>3.25.3</version>
+      <scope>test</scope>
+    </dependency>
+    <dependency>
+      <groupId>org.mockito</groupId>
+      <artifactId>mockito-core</artifactId>
+      <version>5.11.0</version>
+      <scope>test</scope>
+    </dependency>
+  </dependencies>
+</project>
diff --git a/bundles/org.openhab.binding.pihole/src/main/feature/feature.xml b/bundles/org.openhab.binding.pihole/src/main/feature/feature.xml
new file mode 100644 (file)
index 0000000..06efbe8
--- /dev/null
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<features name="org.openhab.binding.pihole-${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-pihole" description="Pi-hole Binding" version="${project.version}">
+               <feature>openhab-runtime-base</feature>
+               <bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.pihole/${project.version}</bundle>
+       </feature>
+</features>
diff --git a/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/PiHoleActions.java b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/PiHoleActions.java
new file mode 100644 (file)
index 0000000..a89f9f1
--- /dev/null
@@ -0,0 +1,104 @@
+/**
+ * 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.pihole.internal;
+
+import static java.util.Objects.requireNonNull;
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.openhab.binding.pihole.internal.PiHoleBindingConstants.BINDING_ID;
+
+import java.util.concurrent.TimeUnit;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.automation.annotation.ActionInput;
+import org.openhab.core.automation.annotation.RuleAction;
+import org.openhab.core.thing.binding.ThingActions;
+import org.openhab.core.thing.binding.ThingActionsScope;
+import org.openhab.core.thing.binding.ThingHandler;
+
+/**
+ * @author Martin Grzeslowski - Initial contribution
+ */
+@ThingActionsScope(name = BINDING_ID)
+@NonNullByDefault
+public class PiHoleActions implements ThingActions {
+    private @Nullable PiHoleHandler handler;
+
+    @Override
+    public void setThingHandler(@Nullable ThingHandler handler) {
+        this.handler = (PiHoleHandler) handler;
+    }
+
+    @Override
+    public @Nullable ThingHandler getThingHandler() {
+        return handler;
+    }
+
+    @RuleAction(label = "@text/action.disable.label", description = "@text/action.disable.description")
+    public void disableBlocking(
+            @ActionInput(name = "time", label = "@text/action.disable.timeLabel", description = "@text/action.disable.timeDescription") long time,
+            @ActionInput(name = "timeUnit", label = "@text/action.disable.timeUnitLabel", description = "@text/action.disable.timeUnitDescription") @Nullable TimeUnit timeUnit)
+            throws PiHoleException {
+        if (time < 0) {
+            return;
+        }
+
+        if (timeUnit == null) {
+            timeUnit = SECONDS;
+        }
+
+        var local = handler;
+        if (local == null) {
+            return;
+        }
+        local.disableBlocking(timeUnit.toSeconds(time));
+    }
+
+    public static void disableBlocking(@Nullable ThingActions actions, long time, @Nullable TimeUnit timeUnit)
+            throws PiHoleException {
+        ((PiHoleActions) requireNonNull(actions)).disableBlocking(time, timeUnit);
+    }
+
+    @RuleAction(label = "@text/action.disable.label", description = "@text/action.disable.description")
+    public void disableBlocking(
+            @ActionInput(name = "time", label = "@text/action.disable.timeLabel", description = "@text/action.disable.timeDescription") long time)
+            throws PiHoleException {
+        disableBlocking(time, null);
+    }
+
+    public static void disableBlocking(@Nullable ThingActions actions, long time) throws PiHoleException {
+        ((PiHoleActions) requireNonNull(actions)).disableBlocking(time);
+    }
+
+    @RuleAction(label = "@text/action.disableInf.label", description = "@text/action.disableInf.description")
+    public void disableBlocking() throws PiHoleException {
+        disableBlocking(0, null);
+    }
+
+    public static void disableBlocking(@Nullable ThingActions actions) throws PiHoleException {
+        ((PiHoleActions) requireNonNull(actions)).disableBlocking(0);
+    }
+
+    @RuleAction(label = "@text/action.enable.label", description = "@text/action.enable.description")
+    public void enableBlocking() throws PiHoleException {
+        var local = handler;
+        if (local == null) {
+            return;
+        }
+        local.enableBlocking();
+    }
+
+    public static void enableBlocking(@Nullable ThingActions actions) throws PiHoleException {
+        ((PiHoleActions) requireNonNull(actions)).enableBlocking();
+    }
+}
diff --git a/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/PiHoleBindingConstants.java b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/PiHoleBindingConstants.java
new file mode 100644 (file)
index 0000000..3adc0f8
--- /dev/null
@@ -0,0 +1,70 @@
+/**
+ * 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.pihole.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.thing.ThingTypeUID;
+
+/**
+ * The {@link PiHoleBindingConstants} class defines common constants, which are
+ * used across the whole binding.
+ *
+ * @author Martin Grzeslowski - Initial contribution
+ */
+@NonNullByDefault
+public class PiHoleBindingConstants {
+
+    public static final String BINDING_ID = "pihole";
+
+    // List of all Thing Type UIDs
+    public static final ThingTypeUID PI_HOLE_TYPE = new ThingTypeUID(BINDING_ID, "server");
+
+    public static final class Channels {
+        public static final String DOMAINS_BEING_BLOCKED_CHANNEL = "domains-being-blocked";
+        public static final String DNS_QUERIES_TODAY_CHANNEL = "dns-queries-today";
+        public static final String ADS_BLOCKED_TODAY_CHANNEL = "ads-blocked-today";
+        public static final String ADS_PERCENTAGE_TODAY_CHANNEL = "ads-percentage-today";
+        public static final String UNIQUE_DOMAINS_CHANNEL = "unique-domains";
+        public static final String QUERIES_FORWARDED_CHANNEL = "queries-forwarded";
+        public static final String QUERIES_CACHED_CHANNEL = "queries-cached";
+        public static final String CLIENTS_EVER_SEEN_CHANNEL = "clients-ever-seen";
+        public static final String UNIQUE_CLIENTS_CHANNEL = "unique-clients";
+        public static final String DNS_QUERIES_ALL_TYPES_CHANNEL = "dns-queries-all-types";
+        public static final String REPLY_UNKNOWN_CHANNEL = "reply-unknown";
+        public static final String REPLY_NODATA_CHANNEL = "reply-nodata";
+        public static final String REPLY_NXDOMAIN_CHANNEL = "reply-nxdomain";
+        public static final String REPLY_CNAME_CHANNEL = "reply-cname";
+        public static final String REPLY_IP_CHANNEL = "reply-ip";
+        public static final String REPLY_DOMAIN_CHANNEL = "reply-domain";
+        public static final String REPLY_RRNAME_CHANNEL = "reply-rrname";
+        public static final String REPLY_SERVFAIL_CHANNEL = "reply-servfail";
+        public static final String REPLY_REFUSED_CHANNEL = "reply-refused";
+        public static final String REPLY_NOTIMP_CHANNEL = "reply-notimp";
+        public static final String REPLY_OTHER_CHANNEL = "reply-other";
+        public static final String REPLY_DNSSEC_CHANNEL = "reply-dnssec";
+        public static final String REPLY_NONE_CHANNEL = "reply-none";
+        public static final String REPLY_BLOB_CHANNEL = "reply-blob";
+        public static final String DNS_QUERIES_ALL_REPLIES_CHANNEL = "dns-queries-all-replies";
+        public static final String PRIVACY_LEVEL_CHANNEL = "privacy-level";
+        public static final String ENABLED_CHANNEL = "enabled";
+        public static final String DISABLE_ENABLE_CHANNEL = "disable-enable";
+
+        public static enum DisableEnable {
+            DISABLE,
+            FOR_10_SEC,
+            FOR_30_SEC,
+            FOR_5_MIN,
+            ENABLE
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/PiHoleConfiguration.java b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/PiHoleConfiguration.java
new file mode 100644 (file)
index 0000000..1f8eef4
--- /dev/null
@@ -0,0 +1,27 @@
+/**
+ * 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.pihole.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * The {@link PiHoleConfiguration} class contains fields mapping thing configuration parameters.
+ *
+ * @author Martin Grzeslowski - Initial contribution
+ */
+@NonNullByDefault
+public class PiHoleConfiguration {
+    public String hostname = "";
+    public String token = "";
+    public int refreshIntervalSeconds = 600;
+}
diff --git a/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/PiHoleException.java b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/PiHoleException.java
new file mode 100644 (file)
index 0000000..27b9c2b
--- /dev/null
@@ -0,0 +1,34 @@
+/**
+ * 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.pihole.internal;
+
+import java.io.Serial;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * @author Martin Grzeslowski - Initial contribution
+ */
+@NonNullByDefault
+public class PiHoleException extends Exception {
+    @Serial
+    private static final long serialVersionUID = 1L;
+
+    public PiHoleException(String message) {
+        super(message);
+    }
+
+    public PiHoleException(String message, Throwable cause) {
+        super(message, cause);
+    }
+}
diff --git a/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/PiHoleHandler.java b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/PiHoleHandler.java
new file mode 100644 (file)
index 0000000..4ed38f2
--- /dev/null
@@ -0,0 +1,282 @@
+/**
+ * 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.pihole.internal;
+
+import static java.util.concurrent.TimeUnit.*;
+import static org.openhab.binding.pihole.internal.PiHoleBindingConstants.Channels.ADS_BLOCKED_TODAY_CHANNEL;
+import static org.openhab.binding.pihole.internal.PiHoleBindingConstants.Channels.ADS_PERCENTAGE_TODAY_CHANNEL;
+import static org.openhab.binding.pihole.internal.PiHoleBindingConstants.Channels.CLIENTS_EVER_SEEN_CHANNEL;
+import static org.openhab.binding.pihole.internal.PiHoleBindingConstants.Channels.DISABLE_ENABLE_CHANNEL;
+import static org.openhab.binding.pihole.internal.PiHoleBindingConstants.Channels.DNS_QUERIES_ALL_REPLIES_CHANNEL;
+import static org.openhab.binding.pihole.internal.PiHoleBindingConstants.Channels.DNS_QUERIES_ALL_TYPES_CHANNEL;
+import static org.openhab.binding.pihole.internal.PiHoleBindingConstants.Channels.DNS_QUERIES_TODAY_CHANNEL;
+import static org.openhab.binding.pihole.internal.PiHoleBindingConstants.Channels.DOMAINS_BEING_BLOCKED_CHANNEL;
+import static org.openhab.binding.pihole.internal.PiHoleBindingConstants.Channels.DisableEnable;
+import static org.openhab.binding.pihole.internal.PiHoleBindingConstants.Channels.DisableEnable.ENABLE;
+import static org.openhab.binding.pihole.internal.PiHoleBindingConstants.Channels.ENABLED_CHANNEL;
+import static org.openhab.binding.pihole.internal.PiHoleBindingConstants.Channels.PRIVACY_LEVEL_CHANNEL;
+import static org.openhab.binding.pihole.internal.PiHoleBindingConstants.Channels.QUERIES_CACHED_CHANNEL;
+import static org.openhab.binding.pihole.internal.PiHoleBindingConstants.Channels.QUERIES_FORWARDED_CHANNEL;
+import static org.openhab.binding.pihole.internal.PiHoleBindingConstants.Channels.REPLY_BLOB_CHANNEL;
+import static org.openhab.binding.pihole.internal.PiHoleBindingConstants.Channels.REPLY_CNAME_CHANNEL;
+import static org.openhab.binding.pihole.internal.PiHoleBindingConstants.Channels.REPLY_DNSSEC_CHANNEL;
+import static org.openhab.binding.pihole.internal.PiHoleBindingConstants.Channels.REPLY_DOMAIN_CHANNEL;
+import static org.openhab.binding.pihole.internal.PiHoleBindingConstants.Channels.REPLY_IP_CHANNEL;
+import static org.openhab.binding.pihole.internal.PiHoleBindingConstants.Channels.REPLY_NODATA_CHANNEL;
+import static org.openhab.binding.pihole.internal.PiHoleBindingConstants.Channels.REPLY_NONE_CHANNEL;
+import static org.openhab.binding.pihole.internal.PiHoleBindingConstants.Channels.REPLY_NOTIMP_CHANNEL;
+import static org.openhab.binding.pihole.internal.PiHoleBindingConstants.Channels.REPLY_NXDOMAIN_CHANNEL;
+import static org.openhab.binding.pihole.internal.PiHoleBindingConstants.Channels.REPLY_OTHER_CHANNEL;
+import static org.openhab.binding.pihole.internal.PiHoleBindingConstants.Channels.REPLY_REFUSED_CHANNEL;
+import static org.openhab.binding.pihole.internal.PiHoleBindingConstants.Channels.REPLY_RRNAME_CHANNEL;
+import static org.openhab.binding.pihole.internal.PiHoleBindingConstants.Channels.REPLY_SERVFAIL_CHANNEL;
+import static org.openhab.binding.pihole.internal.PiHoleBindingConstants.Channels.REPLY_UNKNOWN_CHANNEL;
+import static org.openhab.binding.pihole.internal.PiHoleBindingConstants.Channels.UNIQUE_CLIENTS_CHANNEL;
+import static org.openhab.binding.pihole.internal.PiHoleBindingConstants.Channels.UNIQUE_DOMAINS_CHANNEL;
+import static org.openhab.core.library.unit.Units.PERCENT;
+import static org.openhab.core.thing.ThingStatus.OFFLINE;
+import static org.openhab.core.thing.ThingStatus.ONLINE;
+import static org.openhab.core.thing.ThingStatus.UNKNOWN;
+import static org.openhab.core.thing.ThingStatusDetail.*;
+
+import java.math.BigDecimal;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.Collection;
+import java.util.Optional;
+import java.util.Set;
+import java.util.concurrent.ScheduledFuture;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.eclipse.jetty.client.HttpClient;
+import org.openhab.binding.pihole.internal.rest.AdminService;
+import org.openhab.binding.pihole.internal.rest.JettyAdminService;
+import org.openhab.binding.pihole.internal.rest.model.DnsStatistics;
+import org.openhab.core.library.types.DecimalType;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.types.StringType;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.binding.BaseThingHandler;
+import org.openhab.core.thing.binding.ThingHandlerService;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.RefreshType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link PiHoleHandler} is responsible for handling commands, which are
+ * sent to one of the channels.
+ *
+ * @author Martin Grzeslowski - Initial contribution
+ */
+@NonNullByDefault
+public class PiHoleHandler extends BaseThingHandler implements AdminService {
+    private static final int HTTP_DELAY_SECONDS = 1;
+    private final Logger logger = LoggerFactory.getLogger(PiHoleHandler.class);
+    private final Object lock = new Object();
+    private final HttpClient httpClient;
+
+    private @Nullable AdminService adminService;
+    private @Nullable DnsStatistics dnsStatistics;
+    private @Nullable ScheduledFuture<?> scheduledFuture;
+
+    public PiHoleHandler(Thing thing, HttpClient httpClient) {
+        super(thing);
+        this.httpClient = httpClient;
+    }
+
+    @Override
+    public void initialize() {
+        // set the thing status to UNKNOWN temporarily and let the background task decide for the real status.
+        // the framework is then able to reuse the resources from the thing handler initialization.
+        // we set this upfront to reliably check status updates in unit tests.
+        updateStatus(UNKNOWN);
+
+        var config = getConfigAs(PiHoleConfiguration.class);
+
+        if (config.refreshIntervalSeconds <= 0) {
+            updateStatus(OFFLINE, CONFIGURATION_ERROR, "@text/handler.init.wrongInterval");
+            return;
+        }
+
+        URI hostname;
+        try {
+            hostname = new URI(config.hostname);
+        } catch (URISyntaxException e) {
+            updateStatus(OFFLINE, CONFIGURATION_ERROR,
+                    "@token/handler.init.invalidHostname[\"" + config.hostname + "\"]");
+            return;
+        }
+        if (config.token.isEmpty()) {
+            updateStatus(OFFLINE, CONFIGURATION_ERROR, "@token/handler.init.noToken");
+            return;
+        }
+        adminService = new JettyAdminService(config.token, hostname, httpClient);
+        scheduledFuture = scheduler.scheduleWithFixedDelay(this::update, 0, config.refreshIntervalSeconds, SECONDS);
+
+        // do not set status here, the background task will do it.
+    }
+
+    private void update() {
+        var local = adminService;
+        if (local == null) {
+            return;
+        }
+
+        // this block can be called from at least 2 threads
+        // check disableBlocking method
+        synchronized (lock) {
+            try {
+                logger.debug("Refreshing DnsStatistics from Pi-hole");
+                local.summary().ifPresent(statistics -> dnsStatistics = statistics);
+                refresh();
+                updateStatus(ONLINE);
+            } catch (Exception e) {
+                logger.debug("Error occurred when refreshing DnsStatistics from Pi-hole", e);
+                updateStatus(OFFLINE, COMMUNICATION_ERROR, e.getLocalizedMessage());
+            }
+        }
+    }
+
+    @Override
+    public void handleCommand(ChannelUID channelUID, Command command) {
+        if (command instanceof RefreshType) {
+            refresh();
+            return;
+        }
+
+        if (DISABLE_ENABLE_CHANNEL.equals(channelUID.getId())) {
+            if (command instanceof StringType stringType) {
+                var value = DisableEnable.valueOf(stringType.toString());
+                try {
+                    switch (value) {
+                        case DISABLE -> disableBlocking(0);
+                        case FOR_10_SEC -> disableBlocking(10);
+                        case FOR_30_SEC -> disableBlocking(30);
+                        case FOR_5_MIN -> disableBlocking(MINUTES.toSeconds(5));
+                        case ENABLE -> enableBlocking();
+                    }
+                } catch (PiHoleException ex) {
+                    logger.debug("Cannot invoke {} on channel {}", value, channelUID, ex);
+                    updateStatus(OFFLINE, COMMUNICATION_ERROR, ex.getLocalizedMessage());
+                }
+            }
+        }
+    }
+
+    private void refresh() {
+        var localDnsStatistics = dnsStatistics;
+        if (localDnsStatistics == null) {
+            return;
+        }
+
+        updateDecimalState(DOMAINS_BEING_BLOCKED_CHANNEL, localDnsStatistics.domainsBeingBlocked());
+        updateDecimalState(DNS_QUERIES_TODAY_CHANNEL, localDnsStatistics.dnsQueriesToday());
+        updateDecimalState(ADS_BLOCKED_TODAY_CHANNEL, localDnsStatistics.adsBlockedToday());
+        updateDecimalState(UNIQUE_DOMAINS_CHANNEL, localDnsStatistics.uniqueDomains());
+        updateDecimalState(QUERIES_FORWARDED_CHANNEL, localDnsStatistics.queriesForwarded());
+        updateDecimalState(QUERIES_CACHED_CHANNEL, localDnsStatistics.queriesCached());
+        updateDecimalState(CLIENTS_EVER_SEEN_CHANNEL, localDnsStatistics.clientsEverSeen());
+        updateDecimalState(UNIQUE_CLIENTS_CHANNEL, localDnsStatistics.uniqueClients());
+        updateDecimalState(DNS_QUERIES_ALL_TYPES_CHANNEL, localDnsStatistics.dnsQueriesAllTypes());
+        updateDecimalState(REPLY_UNKNOWN_CHANNEL, localDnsStatistics.replyUnknown());
+        updateDecimalState(REPLY_NODATA_CHANNEL, localDnsStatistics.replyNoData());
+        updateDecimalState(REPLY_NXDOMAIN_CHANNEL, localDnsStatistics.replyNXDomain());
+        updateDecimalState(REPLY_CNAME_CHANNEL, localDnsStatistics.replyCName());
+        updateDecimalState(REPLY_IP_CHANNEL, localDnsStatistics.replyIP());
+        updateDecimalState(REPLY_DOMAIN_CHANNEL, localDnsStatistics.replyDomain());
+        updateDecimalState(REPLY_RRNAME_CHANNEL, localDnsStatistics.replyRRName());
+        updateDecimalState(REPLY_SERVFAIL_CHANNEL, localDnsStatistics.replyServFail());
+        updateDecimalState(REPLY_REFUSED_CHANNEL, localDnsStatistics.replyRefused());
+        updateDecimalState(REPLY_NOTIMP_CHANNEL, localDnsStatistics.replyNotImp());
+        updateDecimalState(REPLY_OTHER_CHANNEL, localDnsStatistics.replyOther());
+        updateDecimalState(REPLY_DNSSEC_CHANNEL, localDnsStatistics.replyDNSSEC());
+        updateDecimalState(REPLY_NONE_CHANNEL, localDnsStatistics.replyNone());
+        updateDecimalState(REPLY_BLOB_CHANNEL, localDnsStatistics.replyBlob());
+        updateDecimalState(DNS_QUERIES_ALL_REPLIES_CHANNEL, localDnsStatistics.dnsQueriesAllTypes());
+        updateDecimalState(PRIVACY_LEVEL_CHANNEL, localDnsStatistics.privacyLevel());
+
+        var adsPercentageToday = localDnsStatistics.adsPercentageToday();
+        if (adsPercentageToday != null) {
+            var state = new QuantityType<>(new BigDecimal(adsPercentageToday.toString()), PERCENT);
+            updateState(ADS_PERCENTAGE_TODAY_CHANNEL, state);
+        }
+        updateState(ENABLED_CHANNEL, OnOffType.from(localDnsStatistics.enabled()));
+        if (localDnsStatistics.enabled()) {
+            updateState(DISABLE_ENABLE_CHANNEL, new StringType(ENABLE.toString()));
+        }
+    }
+
+    private void updateDecimalState(String channelID, @Nullable Integer value) {
+        if (value == null) {
+            return;
+        }
+        updateState(channelID, new DecimalType(value));
+    }
+
+    @Override
+    public Collection<Class<? extends ThingHandlerService>> getServices() {
+        return Set.of(PiHoleActions.class);
+    }
+
+    @Override
+    public void dispose() {
+        adminService = null;
+        dnsStatistics = null;
+        var localScheduledFuture = scheduledFuture;
+        if (localScheduledFuture != null) {
+            localScheduledFuture.cancel(true);
+            scheduledFuture = null;
+        }
+        super.dispose();
+    }
+
+    @Override
+    public Optional<DnsStatistics> summary() throws PiHoleException {
+        var local = adminService;
+        if (local == null) {
+            throw new IllegalStateException("AdminService not initialized");
+        }
+        return local.summary();
+    }
+
+    @Override
+    public void disableBlocking(long seconds) throws PiHoleException {
+        var local = adminService;
+        if (local == null) {
+            throw new IllegalStateException("AdminService not initialized");
+        }
+        local.disableBlocking(seconds);
+        // update the summary to get the value of DISABLED_CHANNEL channel
+        scheduler.schedule(this::update, HTTP_DELAY_SECONDS, SECONDS);
+        if (seconds > 0) {
+            // update the summary to get the value of ENABLED_CHANNEL channel
+            // after the X seconds it probably will be true again
+            scheduler.schedule(this::update, seconds + HTTP_DELAY_SECONDS, SECONDS);
+        }
+    }
+
+    @Override
+    public void enableBlocking() throws PiHoleException {
+        var local = adminService;
+        if (local == null) {
+            throw new IllegalStateException("AdminService not initialized");
+        }
+        local.enableBlocking();
+        // update the summary to get the value of DISABLED_CHANNEL channel
+        scheduler.schedule(this::update, HTTP_DELAY_SECONDS, SECONDS);
+    }
+}
diff --git a/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/PiHoleHandlerFactory.java b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/PiHoleHandlerFactory.java
new file mode 100644 (file)
index 0000000..3ff0c29
--- /dev/null
@@ -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.pihole.internal;
+
+import static org.openhab.binding.pihole.internal.PiHoleBindingConstants.PI_HOLE_TYPE;
+
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.io.net.http.HttpClientFactory;
+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 PiHoleHandlerFactory} is responsible for creating things and thing
+ * handlers.
+ *
+ * @author Martin Grzeslowski - Initial contribution
+ */
+@NonNullByDefault
+@Component(configurationPid = "binding.pihole", service = ThingHandlerFactory.class)
+public class PiHoleHandlerFactory extends BaseThingHandlerFactory {
+
+    private static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(PI_HOLE_TYPE);
+    private final HttpClientFactory httpClientFactory;
+
+    @Activate
+    public PiHoleHandlerFactory(@Reference HttpClientFactory httpClientFactory) {
+        this.httpClientFactory = httpClientFactory;
+    }
+
+    @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 (PI_HOLE_TYPE.equals(thingTypeUID)) {
+            return new PiHoleHandler(thing, httpClientFactory.getCommonHttpClient());
+        }
+
+        return null;
+    }
+}
diff --git a/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/AdminService.java b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/AdminService.java
new file mode 100644 (file)
index 0000000..64b6570
--- /dev/null
@@ -0,0 +1,48 @@
+/**
+ * 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.pihole.internal.rest;
+
+import java.util.Optional;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.pihole.internal.PiHoleException;
+import org.openhab.binding.pihole.internal.rest.model.DnsStatistics;
+
+/**
+ * @author Martin Grzeslowski - Initial contribution
+ */
+@NonNullByDefault
+public interface AdminService {
+    /**
+     * Retrieves a summary of DNS statistics.
+     *
+     * @return An optional containing the DNS statistics.
+     * @throws PiHoleException In case of error
+     */
+    Optional<DnsStatistics> summary() throws PiHoleException;
+
+    /**
+     * Disables blocking for a specified duration.
+     *
+     * @param seconds The duration in seconds for which blocking should be disabled.
+     * @throws PiHoleException In case of error
+     */
+    void disableBlocking(long seconds) throws PiHoleException;
+
+    /**
+     * Enables blocking.
+     *
+     * @throws PiHoleException In case of error
+     */
+    void enableBlocking() throws PiHoleException;
+}
diff --git a/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/JettyAdminService.java b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/JettyAdminService.java
new file mode 100644 (file)
index 0000000..5379999
--- /dev/null
@@ -0,0 +1,88 @@
+/**
+ * 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.pihole.internal.rest;
+
+import static java.util.concurrent.TimeUnit.SECONDS;
+
+import java.net.URI;
+import java.util.Optional;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.TimeoutException;
+
+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.openhab.binding.pihole.internal.PiHoleException;
+import org.openhab.binding.pihole.internal.rest.model.DnsStatistics;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.gson.FieldNamingPolicy;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+
+/**
+ * @author Martin Grzeslowski - Initial contribution
+ */
+@NonNullByDefault
+public class JettyAdminService implements AdminService {
+    private static final Gson GSON = new GsonBuilder()
+            .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES).create();
+    private static final long TIMEOUT_SECONDS = 10L;
+    private final Logger logger = LoggerFactory.getLogger(JettyAdminService.class);
+    private final String token;
+    private final URI baseUrl;
+    private final HttpClient client;
+
+    public JettyAdminService(String token, URI baseUrl, HttpClient client) {
+        this.token = token;
+        this.baseUrl = baseUrl;
+        this.client = client;
+    }
+
+    @Override
+    public Optional<DnsStatistics> summary() throws PiHoleException {
+        logger.debug("Getting summary");
+        var url = baseUrl.resolve("/admin/api.php?summaryRaw&auth=" + token);
+        var request = client.newRequest(url).timeout(TIMEOUT_SECONDS, SECONDS);
+        var response = send(request);
+        var content = response.getContentAsString();
+        return Optional.ofNullable(GSON.fromJson(content, DnsStatistics.class));
+    }
+
+    private static ContentResponse send(Request request) throws PiHoleException {
+        try {
+            return request.send();
+        } catch (InterruptedException | TimeoutException | ExecutionException e) {
+            throw new PiHoleException(
+                    "Exception while sending request to Pi-hole. %s".formatted(e.getLocalizedMessage()), e);
+        }
+    }
+
+    @Override
+    public void disableBlocking(long seconds) throws PiHoleException {
+        logger.debug("Disabling blocking for {} seconds", seconds);
+        var url = baseUrl.resolve("/admin/api.php?disable=%s&auth=%s".formatted(seconds, token));
+        var request = client.newRequest(url).timeout(TIMEOUT_SECONDS, SECONDS);
+        send(request);
+    }
+
+    @Override
+    public void enableBlocking() throws PiHoleException {
+        logger.debug("Enabling blocking");
+        var url = baseUrl.resolve("/admin/api.php?disable&auth=%s".formatted(token));
+        var request = client.newRequest(url).timeout(TIMEOUT_SECONDS, SECONDS);
+        send(request);
+    }
+}
diff --git a/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/model/DnsStatistics.java b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/model/DnsStatistics.java
new file mode 100644 (file)
index 0000000..530043d
--- /dev/null
@@ -0,0 +1,46 @@
+/**
+ * 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.pihole.internal.rest.model;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+import com.google.gson.annotations.SerializedName;
+
+/**
+ * @author Martin Grzeslowski - Initial contribution
+ */
+@NonNullByDefault
+public record DnsStatistics(@Nullable Integer domainsBeingBlocked, @Nullable Integer dnsQueriesToday,
+        @Nullable Integer adsBlockedToday, @Nullable Double adsPercentageToday, @Nullable Integer uniqueDomains,
+        @Nullable Integer queriesForwarded, @Nullable Integer queriesCached, @Nullable Integer clientsEverSeen,
+        @Nullable Integer uniqueClients, @Nullable Integer dnsQueriesAllTypes,
+        @SerializedName("reply_UNKNOWN") @Nullable Integer replyUnknown,
+        @SerializedName("reply_NODATA") @Nullable Integer replyNoData,
+        @SerializedName("reply_NXDOMAIN") @Nullable Integer replyNXDomain,
+        @SerializedName("reply_CNAME") @Nullable Integer replyCName,
+        @SerializedName("reply_IP") @Nullable Integer replyIP,
+        @SerializedName("reply_DOMAIN") @Nullable Integer replyDomain,
+        @SerializedName("reply_RRNAME") @Nullable Integer replyRRName,
+        @SerializedName("reply_SERVFAIL") @Nullable Integer replyServFail,
+        @SerializedName("reply_REFUSED") @Nullable Integer replyRefused,
+        @SerializedName("reply_NOTIMP") @Nullable Integer replyNotImp,
+        @SerializedName("reply_OTHER") @Nullable Integer replyOther,
+        @SerializedName("reply_DNSSEC") @Nullable Integer replyDNSSEC,
+        @SerializedName("reply_NONE") @Nullable Integer replyNone,
+        @SerializedName("reply_BLOB") @Nullable Integer replyBlob, @Nullable Integer dnsQueriesAllReplies,
+        @Nullable Integer privacyLevel, @Nullable String status, @Nullable GravityLastUpdated gravityLastUpdated) {
+    public boolean enabled() {
+        return "enabled".equalsIgnoreCase(status);
+    }
+}
diff --git a/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/model/GravityLastUpdated.java b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/model/GravityLastUpdated.java
new file mode 100644 (file)
index 0000000..071713a
--- /dev/null
@@ -0,0 +1,23 @@
+/**
+ * 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.pihole.internal.rest.model;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * @author Martin Grzeslowski - Initial contribution
+ */
+@NonNullByDefault
+public record GravityLastUpdated(@Nullable Boolean fileExists, @Nullable Long absolute, @Nullable Relative relative) {
+}
diff --git a/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/model/Relative.java b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/model/Relative.java
new file mode 100644 (file)
index 0000000..5b3a67e
--- /dev/null
@@ -0,0 +1,23 @@
+/**
+ * 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.pihole.internal.rest.model;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * @author Martin Grzeslowski - Initial contribution
+ */
+@NonNullByDefault
+public record Relative(@Nullable Integer days, @Nullable Integer hours, @Nullable Integer minutes) {
+}
diff --git a/bundles/org.openhab.binding.pihole/src/main/resources/OH-INF/addon/addon.xml b/bundles/org.openhab.binding.pihole/src/main/resources/OH-INF/addon/addon.xml
new file mode 100644 (file)
index 0000000..1671247
--- /dev/null
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<addon:addon id="pihole" 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>Pi-hole Binding</name>
+       <description>This is the binding for Pi-hole.</description>
+       <connection>cloud</connection>
+
+</addon:addon>
diff --git a/bundles/org.openhab.binding.pihole/src/main/resources/OH-INF/i18n/pihole.properties b/bundles/org.openhab.binding.pihole/src/main/resources/OH-INF/i18n/pihole.properties
new file mode 100644 (file)
index 0000000..757af4a
--- /dev/null
@@ -0,0 +1,167 @@
+# add-on
+
+addon.pihole.name = Pi-hole Binding
+addon.pihole.description = This is the binding for Pi-hole.
+
+# thing types
+
+thing-type.pihole.server.label = Pi-hole Server
+thing-type.pihole.server.description = This thing represents a Pi-hole server and is used for the Pi-hole binding.
+thing-type.pihole.server.channel.ads-blocked-today.label = Ads Blocked Today
+thing-type.pihole.server.channel.ads-blocked-today.description = The number of ads blocked today.
+thing-type.pihole.server.channel.ads-percentage-today.label = Ads Percentage Today
+thing-type.pihole.server.channel.ads-percentage-today.description = The percentage of ads blocked today.
+thing-type.pihole.server.channel.clients-ever-seen.label = Clients Ever Seen
+thing-type.pihole.server.channel.clients-ever-seen.description = The total number of unique clients ever seen.
+thing-type.pihole.server.channel.dns-queries-all-replies.label = DNS Queries (All Replies)
+thing-type.pihole.server.channel.dns-queries-all-replies.description = The total number of DNS queries with all reply types.
+thing-type.pihole.server.channel.dns-queries-all-types.label = DNS Queries (All Types)
+thing-type.pihole.server.channel.dns-queries-all-types.description = The total number of DNS queries of all types.
+thing-type.pihole.server.channel.dns-queries-today.label = DNS Queries Today
+thing-type.pihole.server.channel.dns-queries-today.description = The count of DNS queries made today.
+thing-type.pihole.server.channel.domains-being-blocked.label = Domains Blocked
+thing-type.pihole.server.channel.domains-being-blocked.description = The total number of domains currently being blocked.
+thing-type.pihole.server.channel.privacy-level.label = Privacy Level
+thing-type.pihole.server.channel.privacy-level.description = The privacy level setting.
+thing-type.pihole.server.channel.queries-cached.label = Queries Cached
+thing-type.pihole.server.channel.queries-cached.description = The number of queries served from the cache.
+thing-type.pihole.server.channel.queries-forwarded.label = Queries Forwarded
+thing-type.pihole.server.channel.queries-forwarded.description = The number of queries forwarded to an external DNS server.
+thing-type.pihole.server.channel.reply-blob.label = Reply BLOB
+thing-type.pihole.server.channel.reply-blob.description = DNS replies with a BLOB (binary large object).
+thing-type.pihole.server.channel.reply-cname.label = Reply CNAME
+thing-type.pihole.server.channel.reply-cname.description = DNS replies with a CNAME record.
+thing-type.pihole.server.channel.reply-dnssec.label = Reply DNSSEC
+thing-type.pihole.server.channel.reply-dnssec.description = DNS replies with DNSSEC information.
+thing-type.pihole.server.channel.reply-domain.label = Reply DOMAIN
+thing-type.pihole.server.channel.reply-domain.description = DNS replies with a domain name.
+thing-type.pihole.server.channel.reply-ip.label = Reply IP
+thing-type.pihole.server.channel.reply-ip.description = DNS replies with an IP address.
+thing-type.pihole.server.channel.reply-nodata.label = Reply NODATA
+thing-type.pihole.server.channel.reply-nodata.description = DNS replies indicating no data.
+thing-type.pihole.server.channel.reply-none.label = Reply NONE
+thing-type.pihole.server.channel.reply-none.description = DNS replies with no data.
+thing-type.pihole.server.channel.reply-notimp.label = Reply NOTIMP
+thing-type.pihole.server.channel.reply-notimp.description = DNS replies indicating not implemented.
+thing-type.pihole.server.channel.reply-nxdomain.label = Reply NXDOMAIN
+thing-type.pihole.server.channel.reply-nxdomain.description = DNS replies indicating non-existent domain.
+thing-type.pihole.server.channel.reply-other.label = Reply OTHER
+thing-type.pihole.server.channel.reply-other.description = DNS replies with other statuses.
+thing-type.pihole.server.channel.reply-refused.label = Reply REFUSED
+thing-type.pihole.server.channel.reply-refused.description = DNS replies indicating refusal.
+thing-type.pihole.server.channel.reply-rrname.label = Reply RRNAME
+thing-type.pihole.server.channel.reply-rrname.description = DNS replies with a resource record name.
+thing-type.pihole.server.channel.reply-servfail.label = Reply SERVFAIL
+thing-type.pihole.server.channel.reply-servfail.description = DNS replies indicating a server failure.
+thing-type.pihole.server.channel.reply-unknown.label = Reply UNKNOWN
+thing-type.pihole.server.channel.reply-unknown.description = DNS replies with an unknown status.
+thing-type.pihole.server.channel.unique-clients.label = Unique Clients
+thing-type.pihole.server.channel.unique-clients.description = The current count of unique clients.
+thing-type.pihole.server.channel.unique-domains.label = Unique Domains
+thing-type.pihole.server.channel.unique-domains.description = The count of unique domains queried.
+
+# thing types config
+
+thing-type.config.pihole.server.hostname.label = Hostname
+thing-type.config.pihole.server.hostname.description = Hostname or IP address of the device
+thing-type.config.pihole.server.refreshIntervalSeconds.label = Refresh Interval
+thing-type.config.pihole.server.refreshIntervalSeconds.description = Interval the device is polled in sec.
+thing-type.config.pihole.server.token.label = Token
+thing-type.config.pihole.server.token.description = Token to access the device. To generate token go to `settings` > `API` > `Show API token`
+
+# channel types
+
+channel-type.pihole.disable-enable-channel.label = Disable Blocking
+channel-type.pihole.disable-enable-channel.command.option.DISABLE = Disable Blocking Indefinitely
+channel-type.pihole.disable-enable-channel.command.option.FOR_10_SEC = Disable Blocking for 10 seconds
+channel-type.pihole.disable-enable-channel.command.option.FOR_30_SEC = Disable Blocking for 30 seconds
+channel-type.pihole.disable-enable-channel.command.option.FOR_5_MIN = Disable Blocking for 5 minutes
+channel-type.pihole.disable-enable-channel.command.option.ENABLE = Enable Blocking
+channel-type.pihole.enabled-channel.label = Status
+channel-type.pihole.enabled-channel.description = The current status of blocking
+channel-type.pihole.number-channel.label = Number channel
+
+# channel types
+
+channel.ads_blocked_today.label = Ads Blocked Today
+channel.ads_blocked_today.description = The number of ads blocked today.
+channel.ads_percentage_today.label = Ads Percentage Today
+channel.ads_percentage_today.description = The percentage of ads blocked today.
+channel.clients_ever_seen.label = Clients Ever Seen
+channel.clients_ever_seen.description = The total number of unique clients ever seen.
+channel.disable-enable.label = Disable Blocking
+channel.disable-enable.description = Commands to disable or enable blocking.
+channel.disable-enable.command.DISABLE = Disable Blocking Indefinitely
+channel.disable-enable.command.FOR_10_SEC = Disable Blocking for 10 seconds
+channel.disable-enable.command.FOR_30_SEC = Disable Blocking for 30 seconds
+channel.disable-enable.command.FOR_5_MIN = Disable Blocking for 5 minutes
+channel.disable-enable.command.ENABLE = Enable Blocking
+channel.dns_queries_all_replies.label = DNS Queries (All Replies)
+channel.dns_queries_all_replies.description = The total number of DNS queries with all reply types.
+channel.dns_queries_all_types.label = DNS Queries (All Types)
+channel.dns_queries_all_types.description = The total number of DNS queries of all types.
+channel.dns_queries_today.label = DNS Queries Today
+channel.dns_queries_today.description = The count of DNS queries made today.
+channel.domains_being_blocked.label = Domains Blocked
+channel.domains_being_blocked.description = The total number of domains currently being blocked.
+channel.enabled.label = Enabled
+channel.enabled.description = The current status of blocking.
+channel.privacy_level.label = Privacy Level
+channel.privacy_level.description = The privacy level setting.
+channel.queries_cached.label = Queries Cached
+channel.queries_cached.description = The number of queries served from the cache.
+channel.queries_forwarded.label = Queries Forwarded
+channel.queries_forwarded.description = The number of queries forwarded to an external DNS server.
+channel.reply_BLOB.label = Reply BLOB
+channel.reply_BLOB.description = DNS replies with a BLOB (binary large object).
+channel.reply_CNAME.label = Reply CNAME
+channel.reply_CNAME.description = DNS replies with a CNAME record.
+channel.reply_DNSSEC.label = Reply DNSSEC
+channel.reply_DNSSEC.description = DNS replies with DNSSEC information.
+channel.reply_DOMAIN.label = Reply DOMAIN
+channel.reply_DOMAIN.description = DNS replies with a domain name.
+channel.reply_IP.label = Reply IP
+channel.reply_IP.description = DNS replies with an IP address.
+channel.reply_NODATA.label = Reply NODATA
+channel.reply_NODATA.description = DNS replies indicating no data.
+channel.reply_NONE.label = Reply NONE
+channel.reply_NONE.description = DNS replies with no data.
+channel.reply_NOTIMP.label = Reply NOTIMP
+channel.reply_NOTIMP.description = DNS replies indicating not implemented.
+channel.reply_NXDOMAIN.label = Reply NXDOMAIN
+channel.reply_NXDOMAIN.description = DNS replies indicating non-existent domain.
+channel.reply_OTHER.label = Reply OTHER
+channel.reply_OTHER.description = DNS replies with other statuses.
+channel.reply_REFUSED.label = Reply REFUSED
+channel.reply_REFUSED.description = DNS replies indicating refusal.
+channel.reply_RRNAME.label = Reply RRNAME
+channel.reply_RRNAME.description = DNS replies with a resource record name.
+channel.reply_SERVFAIL.label = Reply SERVFAIL
+channel.reply_SERVFAIL.description = DNS replies indicating a server failure.
+channel.reply_UNKNOWN.label = Reply UNKNOWN
+channel.reply_UNKNOWN.description = DNS replies with an unknown status.
+channel.unique_clients.label = Unique Clients
+channel.unique_clients.description = The current count of unique clients.
+channel.unique_domains.label = Unique Domains
+channel.unique_domains.description = The count of unique domains queried.
+thing.server.label = Pi-hole Binding Thing
+thing.server.description = Sample thing for Pi-hole Binding
+
+# action
+
+action.disable.label = Disable blocking ads
+action.disable.description = Temporarily stop blocking advertisements.
+action.disableInf.label = Disable blocking ads (for infinity)
+action.disableInf.description = Stop blocking advertisements.
+action.disable.timeLabel = Duration
+action.disable.timeDescription = Specify the time for which ad blocking should be disabled (e.g., "for 30 minutes").
+action.disable.timeUnitLabel = Time Unit
+action.disable.timeUnitDescription = The unit of time for the specified duration.
+action.enable.label = Enable blocking ads
+action.enable.description = Resume blocking advertisements.
+
+# from code
+
+handler.init.wrongInterval = Refresh interval needs to be greater than 0!
+handler.init.noToken = Please provide token
+handler.init.invalidHostname = Invalid hostname "{0}"
diff --git a/bundles/org.openhab.binding.pihole/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.pihole/src/main/resources/OH-INF/thing/thing-types.xml
new file mode 100644 (file)
index 0000000..b5ba080
--- /dev/null
@@ -0,0 +1,164 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="pihole"
+       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="server">
+               <label>Pi-hole Server</label>
+               <description>This thing represents a Pi-hole server and is used for the Pi-hole binding.</description>
+
+               <channels>
+                       <channel id="domains-being-blocked" typeId="number-channel">
+                               <label>Domains Blocked</label>
+                               <description>The total number of domains currently being blocked.</description>
+                       </channel>
+                       <channel id="dns-queries-today" typeId="number-channel">
+                               <label>DNS Queries Today</label>
+                               <description>The count of DNS queries made today.</description>
+                       </channel>
+                       <channel id="ads-blocked-today" typeId="number-channel">
+                               <label>Ads Blocked Today</label>
+                               <description>The number of ads blocked today.</description>
+                       </channel>
+                       <channel id="ads-percentage-today" typeId="number-channel">
+                               <label>Ads Percentage Today</label>
+                               <description>The percentage of ads blocked today.</description>
+                       </channel>
+                       <channel id="unique-domains" typeId="number-channel">
+                               <label>Unique Domains</label>
+                               <description>The count of unique domains queried.</description>
+                       </channel>
+                       <channel id="queries-forwarded" typeId="number-channel">
+                               <label>Queries Forwarded</label>
+                               <description>The number of queries forwarded to an external DNS server.</description>
+                       </channel>
+                       <channel id="queries-cached" typeId="number-channel">
+                               <label>Queries Cached</label>
+                               <description>The number of queries served from the cache.</description>
+                       </channel>
+                       <channel id="clients-ever-seen" typeId="number-channel">
+                               <label>Clients Ever Seen</label>
+                               <description>The total number of unique clients ever seen.</description>
+                       </channel>
+                       <channel id="unique-clients" typeId="number-channel">
+                               <label>Unique Clients</label>
+                               <description>The current count of unique clients.</description>
+                       </channel>
+                       <channel id="dns-queries-all-types" typeId="number-channel">
+                               <label>DNS Queries (All Types)</label>
+                               <description>The total number of DNS queries of all types.</description>
+                       </channel>
+                       <channel id="reply-unknown" typeId="number-channel">
+                               <label>Reply UNKNOWN</label>
+                               <description>DNS replies with an unknown status.</description>
+                       </channel>
+                       <channel id="reply-nodata" typeId="number-channel">
+                               <label>Reply NODATA</label>
+                               <description>DNS replies indicating no data.</description>
+                       </channel>
+                       <channel id="reply-nxdomain" typeId="number-channel">
+                               <label>Reply NXDOMAIN</label>
+                               <description>DNS replies indicating non-existent domain.</description>
+                       </channel>
+                       <channel id="reply-cname" typeId="number-channel">
+                               <label>Reply CNAME</label>
+                               <description>DNS replies with a CNAME record.</description>
+                       </channel>
+                       <channel id="reply-ip" typeId="number-channel">
+                               <label>Reply IP</label>
+                               <description>DNS replies with an IP address.</description>
+                       </channel>
+                       <channel id="reply-domain" typeId="number-channel">
+                               <label>Reply DOMAIN</label>
+                               <description>DNS replies with a domain name.</description>
+                       </channel>
+                       <channel id="reply-rrname" typeId="number-channel">
+                               <label>Reply RRNAME</label>
+                               <description>DNS replies with a resource record name.</description>
+                       </channel>
+                       <channel id="reply-servfail" typeId="number-channel">
+                               <label>Reply SERVFAIL</label>
+                               <description>DNS replies indicating a server failure.</description>
+                       </channel>
+                       <channel id="reply-refused" typeId="number-channel">
+                               <label>Reply REFUSED</label>
+                               <description>DNS replies indicating refusal.</description>
+                       </channel>
+                       <channel id="reply-notimp" typeId="number-channel">
+                               <label>Reply NOTIMP</label>
+                               <description>DNS replies indicating not implemented.</description>
+                       </channel>
+                       <channel id="reply-other" typeId="number-channel">
+                               <label>Reply OTHER</label>
+                               <description>DNS replies with other statuses.</description>
+                       </channel>
+                       <channel id="reply-dnssec" typeId="number-channel">
+                               <label>Reply DNSSEC</label>
+                               <description>DNS replies with DNSSEC information.</description>
+                       </channel>
+                       <channel id="reply-none" typeId="number-channel">
+                               <label>Reply NONE</label>
+                               <description>DNS replies with no data.</description>
+                       </channel>
+                       <channel id="reply-blob" typeId="number-channel">
+                               <label>Reply BLOB</label>
+                               <description>DNS replies with a BLOB (binary large object).</description>
+                       </channel>
+                       <channel id="dns-queries-all-replies" typeId="number-channel">
+                               <label>DNS Queries (All Replies)</label>
+                               <description>The total number of DNS queries with all reply types.</description>
+                       </channel>
+                       <channel id="privacy-level" typeId="number-channel">
+                               <label>Privacy Level</label>
+                               <description>The privacy level setting.</description>
+                       </channel>
+                       <channel id="enabled" typeId="enabled-channel"/>
+                       <channel id="disable-enable" typeId="disable-enable-channel"/>
+               </channels>
+
+               <config-description>
+                       <parameter name="hostname" type="text" required="true">
+                               <context>network-address</context>
+                               <label>Hostname</label>
+                               <description>Hostname or IP address of the device</description>
+                       </parameter>
+                       <parameter name="token" type="text" required="true">
+                               <context>password</context>
+                               <label>Token</label>
+                               <description>Token to access the device. To generate token go to `settings` > `API` > `Show API token`</description>
+                       </parameter>
+                       <parameter name="refreshIntervalSeconds" type="integer" unit="s" min="1">
+                               <label>Refresh Interval</label>
+                               <description>Interval the device is polled in sec.</description>
+                               <default>600</default>
+                               <advanced>true</advanced>
+                       </parameter>
+               </config-description>
+       </thing-type>
+
+       <channel-type id="number-channel">
+               <item-type>Number</item-type>
+               <label>Number channel</label>
+               <state readOnly="true"/>
+       </channel-type>
+       <channel-type id="enabled-channel">
+               <item-type>Switch</item-type>
+               <label>Status</label>
+               <description>The current status of blocking</description>
+               <state readOnly="true"/>
+       </channel-type>
+       <channel-type id="disable-enable-channel">
+               <item-type>String</item-type>
+               <label>Disable Blocking</label>
+               <command>
+                       <options>
+                               <option value="DISABLE">Disable Blocking Indefinitely</option>
+                               <option value="FOR_10_SEC">Disable Blocking for 10 seconds</option>
+                               <option value="FOR_30_SEC">Disable Blocking for 30 seconds</option>
+                               <option value="FOR_5_MIN">Disable Blocking for 5 minutes</option>
+                               <option value="ENABLE">Enable Blocking</option>
+                       </options>
+               </command>
+       </channel-type>
+</thing:thing-descriptions>
diff --git a/bundles/org.openhab.binding.pihole/src/test/java/org/openhab/binding/pihole/internal/rest/JettyAdminServiceTest.java b/bundles/org.openhab.binding.pihole/src/test/java/org/openhab/binding/pihole/internal/rest/JettyAdminServiceTest.java
new file mode 100644 (file)
index 0000000..cdbcdb2
--- /dev/null
@@ -0,0 +1,130 @@
+/**
+ * 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.pihole.internal.rest;
+
+import static java.util.concurrent.TimeUnit.SECONDS;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+
+import java.net.URI;
+
+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.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.openhab.binding.pihole.internal.rest.model.DnsStatistics;
+import org.openhab.binding.pihole.internal.rest.model.GravityLastUpdated;
+import org.openhab.binding.pihole.internal.rest.model.Relative;
+
+/**
+ * @author Martin Grzeslowski - Initial contribution
+ */
+@NonNullByDefault
+public class JettyAdminServiceTest {
+    String content = """
+            {
+              "domains_being_blocked": 131355,
+              "dns_queries_today": 27459,
+              "ads_blocked_today": 2603,
+              "ads_percentage_today": 9.479588,
+              "unique_domains": 6249,
+              "queries_forwarded": 16030,
+              "queries_cached": 8525,
+              "clients_ever_seen": 2,
+              "unique_clients": 2,
+              "dns_queries_all_types": 27459,
+              "reply_UNKNOWN": 631,
+              "reply_NODATA": 3168,
+              "reply_NXDOMAIN": 492,
+              "reply_CNAME": 9819,
+              "reply_IP": 13224,
+              "reply_DOMAIN": 48,
+              "reply_RRNAME": 0,
+              "reply_SERVFAIL": 0,
+              "reply_REFUSED": 0,
+              "reply_NOTIMP": 0,
+              "reply_OTHER": 0,
+              "reply_DNSSEC": 0,
+              "reply_NONE": 0,
+              "reply_BLOB": 77,
+              "dns_queries_all_replies": 27459,
+              "privacy_level": 0,
+              "status": "enabled",
+              "gravity_last_updated": {
+                "file_exists": true,
+                "absolute": 1712457841,
+                "relative": {
+                  "days": 0,
+                  "hours": 7,
+                  "minutes": 3
+                }
+              }
+            }
+            """;
+
+    // Returns a DnsStatistics object when called with valid token and baseUrl
+    @Test
+    @DisplayName("Returns a DnsStatistics object when called with valid token and baseUrl")
+    public void testReturnsDnsStatisticsObjectWithValidTokenAndBaseUrl() throws Exception {
+        // Given
+        var token = "validToken";
+        var baseUrl = URI.create("https://example.com");
+        var client = mock(HttpClient.class);
+        var adminService = new JettyAdminService(token, baseUrl, client);
+        var dnsStatistics = new DnsStatistics(131355, // domains_being_blocked
+                27459, // dns_queries_today
+                2603, // ads_blocked_today
+                9.479588, // ads_percentage_today
+                6249, // unique_domains
+                16030, // queries_forwarded
+                8525, // queries_cached
+                2, // clients_ever_seen
+                2, // unique_clients
+                27459, // dns_queries_all_types
+                631, // reply_UNKNOWN
+                3168, // reply_NODATA
+                492, // reply_NXDOMAIN
+                9819, // reply_CNAME
+                13224, // reply_IP
+                48, // reply_DOMAIN
+                0, // reply_RRNAME
+                0, // reply_SERVFAIL
+                0, // reply_REFUSED
+                0, // reply_NOTIMP
+                0, // reply_OTHER
+                0, // reply_DNSSEC
+                0, // reply_NONE
+                77, // reply_BLOB
+                27459, // dns_queries_all_replies
+                0, // privacy_level
+                "enabled", // status
+                new GravityLastUpdated(true, 1712457841L, new Relative(0, 7, 3)));
+        var response = mock(ContentResponse.class);
+        var request = mock(Request.class);
+        given(request.timeout(10, SECONDS)).willReturn(request);
+
+        given(client.newRequest(URI.create("https://example.com/admin/api.php?summaryRaw&auth=validToken")))
+                .willReturn(request);
+        given(request.send()).willReturn(response);
+        given(response.getContentAsString()).willReturn(content);
+
+        // When
+        var result = adminService.summary();
+
+        // Then
+        assertThat(result).contains(dnsStatistics);
+    }
+}
index e59394feea77727f4223f235dece4117219ceb67..49293ac78ea89a81d25cd8b3f33a092d9ea22cf9 100644 (file)
     <module>org.openhab.binding.pegelonline</module>
     <module>org.openhab.binding.pentair</module>
     <module>org.openhab.binding.phc</module>
+    <module>org.openhab.binding.pihole</module>
     <module>org.openhab.binding.pilight</module>
     <module>org.openhab.binding.pioneeravr</module>
     <module>org.openhab.binding.pixometer</module>