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