diff --git a/CODEOWNERS b/CODEOWNERS
index 59b492dac40..d2c062713ec 100755
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -282,6 +282,7 @@
/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
diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml
index fd550b65969..a572a4b2a52 100644
--- a/bom/openhab-addons/pom.xml
+++ b/bom/openhab-addons/pom.xml
@@ -1401,6 +1401,11 @@
org.openhab.binding.phc
${project.version}
+
+ org.openhab.addons.bundles
+ org.openhab.binding.pihole
+ ${project.version}
+
org.openhab.addons.bundles
org.openhab.binding.pilight
diff --git a/bundles/org.openhab.binding.pihole/NOTICE b/bundles/org.openhab.binding.pihole/NOTICE
new file mode 100644
index 00000000000..38d625e3492
--- /dev/null
+++ b/bundles/org.openhab.binding.pihole/NOTICE
@@ -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
index 00000000000..27c2d05ff00
--- /dev/null
+++ b/bundles/org.openhab.binding.pihole/README.md
@@ -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
index 00000000000..e50274c64d9
--- /dev/null
+++ b/bundles/org.openhab.binding.pihole/pom.xml
@@ -0,0 +1,31 @@
+
+
+
+ 4.0.0
+
+
+ org.openhab.addons.bundles
+ org.openhab.addons.reactor.bundles
+ 4.3.0-SNAPSHOT
+
+
+ org.openhab.binding.pihole
+
+ openHAB Add-ons :: Bundles :: Pi-hole Binding
+
+
+
+ org.assertj
+ assertj-core
+ 3.25.3
+ test
+
+
+ org.mockito
+ mockito-core
+ 5.11.0
+ test
+
+
+
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
index 00000000000..06efbe8f4af
--- /dev/null
+++ b/bundles/org.openhab.binding.pihole/src/main/feature/feature.xml
@@ -0,0 +1,9 @@
+
+
+ mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features
+
+
+ openhab-runtime-base
+ mvn:org.openhab.addons.bundles/org.openhab.binding.pihole/${project.version}
+
+
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
index 00000000000..a89f9f16fda
--- /dev/null
+++ b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/PiHoleActions.java
@@ -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
index 00000000000..3adc0f87fba
--- /dev/null
+++ b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/PiHoleBindingConstants.java
@@ -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
index 00000000000..1f8eef48087
--- /dev/null
+++ b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/PiHoleConfiguration.java
@@ -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
index 00000000000..27b9c2b1a19
--- /dev/null
+++ b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/PiHoleException.java
@@ -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
index 00000000000..4ed38f200d2
--- /dev/null
+++ b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/PiHoleHandler.java
@@ -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> 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 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
index 00000000000..3ff0c29fcf0
--- /dev/null
+++ b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/PiHoleHandlerFactory.java
@@ -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 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
index 00000000000..64b65703173
--- /dev/null
+++ b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/AdminService.java
@@ -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 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
index 00000000000..5379999f3c2
--- /dev/null
+++ b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/JettyAdminService.java
@@ -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 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
index 00000000000..530043d48f6
--- /dev/null
+++ b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/model/DnsStatistics.java
@@ -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
index 00000000000..071713af37d
--- /dev/null
+++ b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/model/GravityLastUpdated.java
@@ -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
index 00000000000..5b3a67ec2f4
--- /dev/null
+++ b/bundles/org.openhab.binding.pihole/src/main/java/org/openhab/binding/pihole/internal/rest/model/Relative.java
@@ -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
index 00000000000..16712479dc8
--- /dev/null
+++ b/bundles/org.openhab.binding.pihole/src/main/resources/OH-INF/addon/addon.xml
@@ -0,0 +1,11 @@
+
+
+
+ binding
+ Pi-hole Binding
+ This is the binding for Pi-hole.
+ cloud
+
+
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
index 00000000000..757af4a58d8
--- /dev/null
+++ b/bundles/org.openhab.binding.pihole/src/main/resources/OH-INF/i18n/pihole.properties
@@ -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
index 00000000000..b5ba080748e
--- /dev/null
+++ b/bundles/org.openhab.binding.pihole/src/main/resources/OH-INF/thing/thing-types.xml
@@ -0,0 +1,164 @@
+
+
+
+
+
+ This thing represents a Pi-hole server and is used for the Pi-hole binding.
+
+
+
+
+ The total number of domains currently being blocked.
+
+
+
+ The count of DNS queries made today.
+
+
+
+ The number of ads blocked today.
+
+
+
+ The percentage of ads blocked today.
+
+
+
+ The count of unique domains queried.
+
+
+
+ The number of queries forwarded to an external DNS server.
+
+
+
+ The number of queries served from the cache.
+
+
+
+ The total number of unique clients ever seen.
+
+
+
+ The current count of unique clients.
+
+
+
+ The total number of DNS queries of all types.
+
+
+
+ DNS replies with an unknown status.
+
+
+
+ DNS replies indicating no data.
+
+
+
+ DNS replies indicating non-existent domain.
+
+
+
+ DNS replies with a CNAME record.
+
+
+
+ DNS replies with an IP address.
+
+
+
+ DNS replies with a domain name.
+
+
+
+ DNS replies with a resource record name.
+
+
+
+ DNS replies indicating a server failure.
+
+
+
+ DNS replies indicating refusal.
+
+
+
+ DNS replies indicating not implemented.
+
+
+
+ DNS replies with other statuses.
+
+
+
+ DNS replies with DNSSEC information.
+
+
+
+ DNS replies with no data.
+
+
+
+ DNS replies with a BLOB (binary large object).
+
+
+
+ The total number of DNS queries with all reply types.
+
+
+
+ The privacy level setting.
+
+
+
+
+
+
+
+ network-address
+
+ Hostname or IP address of the device
+
+
+ password
+
+ Token to access the device. To generate token go to `settings` > `API` > `Show API token`
+
+
+
+ Interval the device is polled in sec.
+ 600
+ true
+
+
+
+
+
+ Number
+
+
+
+
+ Switch
+
+ The current status of blocking
+
+
+
+ String
+
+
+
+
+
+
+
+
+
+
+
+
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
index 00000000000..cdbcdb2c1e9
--- /dev/null
+++ b/bundles/org.openhab.binding.pihole/src/test/java/org/openhab/binding/pihole/internal/rest/JettyAdminServiceTest.java
@@ -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);
+ }
+}
diff --git a/bundles/pom.xml b/bundles/pom.xml
index 35531774ce5..56d2ddf3b86 100644
--- a/bundles/pom.xml
+++ b/bundles/pom.xml
@@ -316,6 +316,7 @@
org.openhab.binding.pegelonline
org.openhab.binding.pentair
org.openhab.binding.phc
+ org.openhab.binding.pihole
org.openhab.binding.pilight
org.openhab.binding.pioneeravr
org.openhab.binding.pixometer