Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions Changelog.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,29 @@
0.2.0
- Ported to the Pi-hole v6 REST API (api.php was removed in Pi-hole v6, Feb 2025).
Tested against Core v6.4.3 / Web v6.6 / FTL v6.7.
- Login via POST /api/auth with an app password; session id sent as X-FTL-SID,
automatically renewed on 401.
- Stats read from GET /api/stats/summary, status from GET /api/dns/blocking.
- Pause/enable buttons use POST /api/dns/blocking with a timer.
- Setting "Authenticatie token" renamed to "App-wachtwoord"; an existing
authtoken value is migrated on first start.
- Remaining pause time shown next to the status on the details screen.
- Pi-hole v5 and older are no longer supported (use 0.1.13 for those).

0.1.13
- Tile colored red completely when connection with PiHole has been lost
Tile colored orange when PiHole is disabled
(not in dim state a colored status message is shown)

0.1.8/9
- redesigned tile to show more user friendly data
- added last refresh time to the details screen
- fixed broken auto update function
- data reset when connection lost

0.1.7
- fixed show/hide system tray icon based on the usersetting

0.1.6
- added working code for buttons

Expand Down
250 changes: 208 additions & 42 deletions PiholestatsApp.qml
Original file line number Diff line number Diff line change
Expand Up @@ -13,31 +13,61 @@ App {
property url piholeTileUrl : "PiholestatsTile.qml"
property PiholestatsSettings piholeSettings
property PiholestatsScreen piholeScreen
property PiholestatsTile piholeTile
property bool dialogShown : false //shown when changes have been. Shown only once.

property SystrayIcon piholeTray
property bool showAppIcon : true
property bool firstTimeShown : true

// Data model used by the Tile and Screen. The key names are the ones the
// legacy (v5) api.php used; the v6 REST responses are mapped onto them in
// buildConfigJSON() so the UI files only need minimal changes.
property variant piholeConfigJSON
property variant emptyPiholeConfigJSON : {
"domains_being_blocked":0,
"dns_queries_today":0,
"ads_blocked_today":0,
"ads_percentage_today":0,
"unique_domains":0,
"queries_forwarded":0,
"queries_cached":0,
"clients_ever_seen":0,
"unique_clients":0,
"privacy_level":0,
"status":"geen connectie",
"timer":null,
"gravity_last_updated":{"file_exists":true,"absolute":0,"relative":{"days":0,"hours":0,"minutes":0}}
}
property bool piholeDataRead: false

// app settings
property string connectionPath
property string ipadres
property string poortnummer : "80"
property int refreshrate : 60 // interval to retrieve data
property string authtoken
property int refreshrate : 60 // interval to retrieve data
property string password // Pi-hole v6 app password (or admin password)

// Pi-hole v6 API session
property string sid // session id returned by POST /api/auth
property bool loginInProgress : false

//data vars
property string tmp_ads_blocked_today
property string tmp_ads_percentage_today
property string lastupdated
property string status

property string tileColor
property string textBgColor
property string textColor

// user settings from config file
property variant userSettingsJSON : {
'connectionPath': [],
'ShowTrayIcon': "",
'refreshrate': "",
'authtoken': ""
'password': ""
}

// location of settings file
Expand All @@ -56,6 +86,7 @@ App {

//this function needs to be started after the app is booted.
Component.onCompleted: {
piholeConfigJSON = emptyPiholeConfigJSON;
// read user settings
try {
userSettingsJSON = JSON.parse(userSettingsFile.read());
Expand All @@ -66,26 +97,33 @@ App {
poortnummer = splitVar[1];
if (poortnummer.length < 2) poortnummer = "80";
refreshrate = userSettingsJSON['refreshrate'];
authtoken = userSettingsJSON['authtoken'];
// 'authtoken' is the key used by versions < 0.2.0; keep reading it so
// existing installs do not lose their setting after the upgrade.
if (userSettingsJSON['password'] !== undefined) {
password = userSettingsJSON['password'];
} else if (userSettingsJSON['authtoken'] !== undefined) {
password = userSettingsJSON['authtoken'];
}
} catch(e) {
}
refreshScreen();
datetimeTimer.start()
}

// refresh screen
function refreshScreen() {
piholeDataRead = false;
readPiHolePHPData();
readPiHoleData();
}

// save user settings
function saveSettings(){
connectionPath = ipadres + ":" + poortnummer;
sid = ""; // credentials or host may have changed: force a new login

var tmpUserSettingsJSON = {
"connectionPath" : ipadres + ":" + poortnummer,
"refreshrate" : refreshrate,
"authtoken" : authtoken,
"password" : password,
"ShowTrayIcon" : (showAppIcon) ? "yes" : "no"
}

Expand All @@ -94,49 +132,177 @@ App {
doc3.send(JSON.stringify(tmpUserSettingsJSON));
}

// read json file
function readPiHolePHPData() {
// console.log("*****PiHole connectionPath:" + connectionPath);
if ( connectionPath.length > 4 ) {
var xmlhttp = new XMLHttpRequest();
xmlhttp.open("GET", "http://"+connectionPath+"/admin/api.php", true);
xmlhttp.onreadystatechange = function() {
if (xmlhttp.readyState == XMLHttpRequest.DONE) {

if (xmlhttp.status === 200) {
// console.log("*****PiHole response:" + xmlhttp.responseText);
// saveJSON(xmlhttp.responseText);
piholeConfigJSON = JSON.parse(xmlhttp.responseText);

tmp_ads_blocked_today = piholeConfigJSON['ads_blocked_today'];
// console.log("*****PiHole tmp_ads_blocked_today: " + tmp_ads_blocked_today);
// last tmp_ads_percentage_today = piholeConfigJSON['ads_percentage_today'];
tmp_ads_percentage_today = Math.round(piholeConfigJSON['ads_percentage_today']) + " %";
// console.log("*****PiHole tmp_ads_percentage_today: " + tmp_ads_percentage_today);
} else {
tmp_ads_blocked_today = "server incorrect";
// console.log("*****PiHole tmp_ads_blocked_today: "+ tmp_ads_blocked_today);
tmp_ads_percentage_today = "server incorrect";
// console.log("*****PiHole tmp_ads_percentage_today: "+ tmp_ads_percentage_today);
// ---------------------------------------------------------------------------
// Pi-hole v6 REST API helpers
// ---------------------------------------------------------------------------

function apiUrl(path) {
return "http://" + connectionPath + "/api" + path;
}

// POST /api/auth with the app password. Calls callback(success).
function login(callback) {
if (loginInProgress) {
// another request already triggered a login; let it finish
if (callback) callback(false);
return;
}
loginInProgress = true;
var xmlhttp = new XMLHttpRequest();
xmlhttp.open("POST", apiUrl("/auth"), true);
xmlhttp.setRequestHeader("Content-Type", "application/json");
xmlhttp.onreadystatechange = function() {
if (xmlhttp.readyState == XMLHttpRequest.DONE) {
loginInProgress = false;
var ok = false;
if (xmlhttp.status === 200) {
try {
var resp = JSON.parse(xmlhttp.responseText);
if (resp['session'] && resp['session']['valid'] && resp['session']['sid']) {
sid = resp['session']['sid'];
ok = true;
}
} catch(e) {
console.log("PiHole: could not parse auth response: " + e);
}
} else {
console.log("PiHole: login failed, HTTP " + xmlhttp.status);
sid = "";
}
if (callback) callback(ok);
}
}
xmlhttp.send(JSON.stringify({"password": password}));
}

// Generic authenticated request. Calls callback(status, jsonOrNull).
// On a 401 the session is renewed once and the request is retried.
function apiRequest(method, path, body, callback, isRetry) {
var xmlhttp = new XMLHttpRequest();
xmlhttp.open(method, apiUrl(path), true);
if (sid.length > 0) xmlhttp.setRequestHeader("X-FTL-SID", sid);
if (body !== null && body !== undefined) xmlhttp.setRequestHeader("Content-Type", "application/json");
xmlhttp.onreadystatechange = function() {
if (xmlhttp.readyState == XMLHttpRequest.DONE) {
if (xmlhttp.status === 401 && !isRetry) {
// session expired, missing or never created: (re)login and try once more
login(function(ok) {
if (ok) {
apiRequest(method, path, body, callback, true);
} else {
callback(401, null);
}
});
return;
}
var json = null;
if (xmlhttp.status === 200) {
try {
json = JSON.parse(xmlhttp.responseText);
} catch(e) {
console.log("PiHole: could not parse response for " + path + ": " + e);
}
}
callback(xmlhttp.status, json);
}
}
if (body !== null && body !== undefined) {
xmlhttp.send(JSON.stringify(body));
} else {
xmlhttp.send();
}
}

// Map GET /api/stats/summary and GET /api/dns/blocking onto the legacy keys.
function buildConfigJSON(summary, blocking) {
var now = Math.floor(Date.now() / 1000);
var gravityTs = (summary['gravity'] && summary['gravity']['last_update']) ? summary['gravity']['last_update'] : 0;
var age = (gravityTs > 0) ? Math.max(0, now - gravityTs) : 0;

return {
"domains_being_blocked": summary['gravity']['domains_being_blocked'],
"dns_queries_today": summary['queries']['total'],
"ads_blocked_today": summary['queries']['blocked'],
"ads_percentage_today": Math.round(summary['queries']['percent_blocked'] * 10) / 10,
"unique_domains": summary['queries']['unique_domains'],
"queries_forwarded": summary['queries']['forwarded'],
"queries_cached": summary['queries']['cached'],
"clients_ever_seen": summary['clients']['total'],
"unique_clients": summary['clients']['active'],
"privacy_level": 0,
"status": blocking['blocking'], // "enabled" | "disabled" | "failed" | "unknown"
"timer": blocking['timer'],
"gravity_last_updated": {
"file_exists": gravityTs > 0,
"absolute": gravityTs,
"relative": {
"days": Math.floor(age / 86400),
"hours": Math.floor((age % 86400) / 3600),
"minutes": Math.floor((age % 3600) / 60)
}
}
};
}

function applyConfigJSON(json) {
piholeConfigJSON = json;
piholeDataRead = (json['status'] !== "geen connectie");
tmp_ads_blocked_today = json['ads_blocked_today'];
tmp_ads_percentage_today = Math.round(json['ads_percentage_today']) + "%";
status = json['status'];
var tmp = new Date();
lastupdated = tmp.getFullYear() + "-" + ("0" + (tmp.getMonth() + 1)).slice(-2) + "-" + ("0" + tmp.getDate()).slice(-2) + " " + ("0" + tmp.getHours() ).slice(-2) + ":" + ("0" + tmp.getMinutes()).slice(-2);
updateColors();
}

function updateColors() {
if (piholeConfigJSON['status'] == "geen connectie") {
tileColor = "#FF0000";
textBgColor = "#FF0000";
textColor = "#FFFFFF";
} else if (piholeConfigJSON['status'] == "disabled") {
tileColor = "#FFA500";
textBgColor = "#FFA500";
textColor = "#000000";
} else {
tileColor = "#FFFFFF";
textBgColor = dimmableColors.tileBackground;
textColor = dimmableColors.clockTileColor;
}
}

// read statistics and blocking state from Pi-hole
function readPiHoleData() {

if ( connectionPath.length > 4 ) {
apiRequest("GET", "/stats/summary", null, function(st, summary) {
if (st !== 200 || summary === null) {
console.log("PiHole: summary request failed, HTTP " + st);
applyConfigJSON(emptyPiholeConfigJSON);
return;
}
apiRequest("GET", "/dns/blocking", null, function(st2, blocking) {
if (st2 !== 200 || blocking === null) {
console.log("PiHole: blocking request failed, HTTP " + st2);
applyConfigJSON(emptyPiholeConfigJSON);
return;
}
applyConfigJSON(buildConfigJSON(summary, blocking));
});
});
} else {
tmp_ads_blocked_today = "empty settings";
// console.log("*****PiHole tmp_ads_blocked_today: "+ tmp_ads_blocked_today);
tmp_ads_percentage_today = "empty settings";
// console.log("*****PiHole tmp_ads_percentage_today: "+ tmp_ads_percentage_today);
}
xmlhttp.send();
}

// save json data in json file. Optional, see readPiHolePHPData
function saveJSON(text) {

var doc3 = new XMLHttpRequest();
doc3.open("PUT", "file:///var/volatile/tmp/pihole_retrieved_data.json");
doc3.send(text);
// enable/disable blocking. timerSeconds = 0 or null means permanent.
function setBlocking(enable, timerSeconds) {
var body = {"blocking": enable, "timer": (timerSeconds > 0) ? timerSeconds : null};
apiRequest("POST", "/dns/blocking", body, function(st, resp) {
if (st !== 200) console.log("PiHole: setBlocking failed, HTTP " + st);
refreshScreen();
});
}

// Timer in s * 1000
Expand Down
Loading