diff --git a/Changelog.txt b/Changelog.txt index 767364f..9dd5c17 100644 --- a/Changelog.txt +++ b/Changelog.txt @@ -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 diff --git a/PiholestatsApp.qml b/PiholestatsApp.qml index 667b083..b31ad29 100644 --- a/PiholestatsApp.qml +++ b/PiholestatsApp.qml @@ -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 @@ -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()); @@ -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" } @@ -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 diff --git a/PiholestatsScreen.qml b/PiholestatsScreen.qml index b5bb62d..f5a68a6 100644 --- a/PiholestatsScreen.qml +++ b/PiholestatsScreen.qml @@ -24,12 +24,9 @@ Screen { } } -//send state change - function changeState(request) { - var xmlhttp = new XMLHttpRequest(); - xmlhttp.open("GET", request, true); - xmlhttp.send(); - app.refreshScreen(); +//send state change (Pi-hole v6: POST /api/dns/blocking, handled in app.setBlocking) + function changeState(enable, timerSeconds) { + app.setBlocking(enable, timerSeconds); } // header @@ -40,7 +37,7 @@ Screen { Text { id: headerText - text: "Pi-Hole live gegevens:" + text: "Pi-Hole live gegevens (per " + app.lastupdated + ")" font.family: qfont.semiBold.name font.pixelSize: isNxt ? 25 : 20 anchors { @@ -95,7 +92,7 @@ Screen { // line 1 value Text { id: line1value - text: app.piholeConfigJSON['status']; + text: app.piholeConfigJSON['status'] + ((app.piholeConfigJSON['timer'] > 0) ? " (nog " + app.piholeConfigJSON['timer'] + "s)" : "") color: colors.clockTileColor font.family: qfont.italic.name font.pixelSize: isNxt ? 23 : 18 @@ -161,7 +158,7 @@ Screen { // line 4 text Text { id: line4text - text: "Reclame vandaag geblokkeerd: " + text: "Reclame geblokkeerd laatste 24h: " font.family: qfont.italic.name font.pixelSize: isNxt ? 23 : 18 anchors { @@ -373,8 +370,8 @@ Screen { bottomMargin: isNxt ? 20 : 16 } onClicked: { - changeState("http://"+app.connectionPath+"/admin/api.php?disable=30&auth="+app.authtoken); - } + changeState(false, 30); + } } // button 60s @@ -393,7 +390,7 @@ Screen { bottomMargin: isNxt ? 20 : 16 } onClicked: { - changeState("http://"+app.connectionPath+"/admin/api.php?disable=60&auth="+app.authtoken); + changeState(false, 60); } } @@ -413,7 +410,7 @@ Screen { bottomMargin: isNxt ? 20 : 16 } onClicked: { - changeState("http://"+app.connectionPath+"/admin/api.php?disable=300&auth="+app.authtoken); + changeState(false, 300); } } @@ -433,7 +430,7 @@ Screen { bottomMargin: isNxt ? 20 : 16 } onClicked: { - changeState("http://"+app.connectionPath+"/admin/api.php?disable=600&auth="+app.authtoken); + changeState(false, 600); } } @@ -453,7 +450,7 @@ Screen { bottomMargin: isNxt ? 20 : 16 } onClicked: { - changeState("http://"+app.connectionPath+"/admin/api.php?disable&auth="+app.authtoken); + changeState(false, 0); } } @@ -473,7 +470,7 @@ Screen { bottomMargin: isNxt ? 20 : 16 } onClicked: { - changeState("http://"+app.connectionPath+"/admin/api.php?enable&auth="+app.authtoken); + changeState(true, 0); } } // end lines diff --git a/PiholestatsSettings.qml b/PiholestatsSettings.qml index 44d7ebf..4ad0d1b 100644 --- a/PiholestatsSettings.qml +++ b/PiholestatsSettings.qml @@ -16,7 +16,7 @@ Screen { ipadresLabel.inputText = app.ipadres; poortnummerLabel.inputText = app.poortnummer; refreshrateLabel.inputText = app.refreshrate; - authtokenLabel.inputText = app.authtoken; + passwordLabel.inputText = app.password; messageShown = false; } @@ -24,7 +24,7 @@ Screen { app.saveSettings(); app.firstTimeShown = true; app.piholeDataRead = false; - app.readPiHolePHPData(); + app.readPiHoleData(); hide(); } @@ -51,11 +51,11 @@ Screen { } } -// Save Authentication token - function saveAuthToken(text) { +// Save app password + function savePassword(text) { if (text) { - authtokenLabel.inputText = text; - app.authtoken = text; + passwordLabel.inputText = text; + app.password = text; } } @@ -125,7 +125,7 @@ Screen { height: editportNumberButton.height width: isNxt ? 800 : 600 leftTextAvailableWidth: isNxt ? 600 : 480 - leftText: qsTr("Port (standaard is 80)") + leftText: qsTr("Port (standaard is 80, soms 8080)") anchors { left:parent.left leftMargin: isNxt ? 62 : 50 @@ -175,13 +175,13 @@ Screen { qkeyboard.open("Voer hier de refresh rate in", refreshrateLabel.inputText, saveRefreshRate); } } -// authentication token +// app password (Pi-hole v6: Settings > Web interface / API > app password) EditTextLabel4421 { - id: authtokenLabel - height: editAuthTokenButton.height + id: passwordLabel + height: editPasswordButton.height width: isNxt ? 800 : 600 leftTextAvailableWidth: isNxt ? 600 : 480 - leftText: qsTr("Authenticatie token") + leftText: qsTr("App-wachtwoord (Pi-hole v6)") anchors { left:parent.left leftMargin: isNxt ? 62 : 50 @@ -189,18 +189,18 @@ Screen { topMargin: isNxt ? 25 : 20 } } - + IconButton { - id: editAuthTokenButton + id: editPasswordButton width: isNxt ? 50 : 40 anchors { - left:authtokenLabel.right + left:passwordLabel.right leftMargin: isNxt ? 12 : 10 - top: authtokenLabel.top + top: passwordLabel.top } iconSource: "qrc:/tsc/edit.png" onClicked: { - qkeyboard.open("Voer hier de authentication token in", authtokenLabel.inputText, saveAuthToken); + qkeyboard.open("Voer hier het Pi-hole app-wachtwoord in", passwordLabel.inputText, savePassword); } } // end diff --git a/PiholestatsTile.qml b/PiholestatsTile.qml index 439a8b8..62621fd 100644 --- a/PiholestatsTile.qml +++ b/PiholestatsTile.qml @@ -6,14 +6,50 @@ Tile { id: piholeTile property bool dimState: screenStateController.dimmedColors + onDimStateChanged: { + resetBackgroundColor(); + } + + function resetBackgroundColor() { + if (app.piholeConfigJSON['status'] == "enabled") { + fullRectangle.color = dimState ? "#000000" : "#FFFFFF" + console.log("PiHole enabled, dim:" + dimState); + } else { + if (app.piholeConfigJSON['status'] == "disabled") { + fullRectangle.color = dimState ? "#000000" : "#FFA500" + console.log("PiHole disabled, dim:" + dimState); + } else { + fullRectangle.color = dimState ? "#000000" : "#FF0000" + console.log("PiHole other, dim:" + dimState); + } + } + } + onClicked: { stage.openFullscreen(app.piholeScreenUrl); } + Item { //listener to respond realtime to property changes + property string tileColor: app.tileColor + property string textColor: app.textColor + property string textBgColor: app.textBgColor + + onTileColorChanged: resetBackgroundColor(); + onTextColorChanged: tileline5.color = textColor; + onTextBgColorChanged: text5Rect.color = textBgColor; + } + + Rectangle { + id:fullRectangle + width: piholeTile.width + height: piholeTile.height + radius: 5 + } + // Title Text { id: tiletitle - text: "PiHole Stats" + text: "PiHole Status" anchors { baseline: parent.top baselineOffset: isNxt ? 30 : 24 @@ -24,57 +60,77 @@ Tile { pixelSize: isNxt ? 25 : 20 } color: (typeof dimmableColors !== 'undefined') ? dimmableColors.waTileTextColor : colors.waTileTextColor + visible: !dimState || (app.piholeConfigJSON['status'] == "geen connectie") } // line 1 text Text { id: tileline1 - text: "Ads blocked today: " + text: "In de laatste 24 uur is" color: (typeof dimmableColors !== 'undefined') ? dimmableColors.clockTileColor : colors.clockTileColor anchors { top: tiletitle.bottom - left: parent.left - leftMargin: isNxt ? 10 : 8 + topMargin: isNxt ? 25 : 20 + horizontalCenter: parent.horizontalCenter + } + font { + family: qfont.regular.name + pixelSize: isNxt ? 22 : 18 } - font.pixelSize: isNxt ? 25 : 20 - font.family: qfont.italic.name + visible: (app.piholeConfigJSON['status'] !== "geen connectie") } // line 2 text Text { id: tileline2 - text: app.tmp_ads_blocked_today + text: app.tmp_ads_percentage_today + " van het DNS" color: (typeof dimmableColors !== 'undefined') ? dimmableColors.clockTileColor : colors.clockTileColor anchors { - left: tileline1.left top: tileline1.bottom + horizontalCenter: parent.horizontalCenter + } + font { + family: qfont.regular.name + pixelSize: isNxt ? 22 : 18 } - font.pixelSize: isNxt ? 25 : 20 - font.family: qfont.italic.name + visible: (app.piholeConfigJSON['status'] !== "geen connectie") } -// line 3 text +// line 4 text Text { - id: tileline3 - text: "Percentage blocked: " + id: tileline4 + text: "verkeer geblokkeerd." color: (typeof dimmableColors !== 'undefined') ? dimmableColors.clockTileColor : colors.clockTileColor anchors { - left: tileline2.left top: tileline2.bottom + horizontalCenter: parent.horizontalCenter } - font.pixelSize: isNxt ? 25 : 20 - font.family: qfont.italic.name + font { + family: qfont.regular.name + pixelSize: isNxt ? 22 : 18 + } + visible: (app.piholeConfigJSON['status'] !== "geen connectie") } -// line 4 text - Text { - id: tileline4 - text: app.tmp_ads_percentage_today - color: (typeof dimmableColors !== 'undefined') ? dimmableColors.clockTileColor : colors.clockTileColor +// line 5 text + Rectangle { + id: text5Rect + color: app.textBgColor anchors { - left: tileline3.left - top: tileline3.bottom + top: tileline4.bottom + topMargin: isNxt ? 15 : 10 + horizontalCenter: parent.horizontalCenter } - font.pixelSize: isNxt ? 25 : 20 - font.family: qfont.italic.name - } + Text { + id: tileline5 + text: "status: " + app.piholeConfigJSON['status'] + color: app.textColor + font { + family: qfont.regular.name + pixelSize: isNxt ? 22 : 18 + } + } + width: childrenRect.width + height: childrenRect.height + visible: (app.piholeConfigJSON['status'] !== "enabled") + } } diff --git a/PiholestatsTray.qml b/PiholestatsTray.qml index f825c1d..f0b0278 100644 --- a/PiholestatsTray.qml +++ b/PiholestatsTray.qml @@ -5,7 +5,7 @@ import qb.base 1.0 SystrayIcon { id: piholeSystrayIcon - visible: true + visible: app.showAppIcon posIndex: 8000 property string objectName: "piholeSystrayIcon" diff --git a/README.md b/README.md index 44aee55..d4916cb 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,9 @@ Full view: - gravity_last_updated # Requirements: -- PiHole installation within your network +- Pi-hole v6 or newer within your network (uses the v6 REST API; version 0.1.13 is the last release for Pi-hole v5) +- An app password, created in the Pi-hole web interface under Settings > Web interface / API > "Configure app password". Enter it in the app settings on the Toon. If your Pi-hole has no password set, leave the field empty. +- Port is normally 80. If lighttpd is still running on the Pi-hole host, Pi-hole v6 falls back to port 8080. # Bugs / issues: Please report on forum if any bugs/issues are found OR submit a Pull Request directly in this repo. diff --git a/version.txt b/version.txt index a192233..341cf11 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.1.6 \ No newline at end of file +0.2.0 \ No newline at end of file