├── LICENSE ├── README.md ├── config.py ├── dns_stats.py ├── dns_stats.service ├── images └── sense_hat.gif ├── joystick.py ├── requests.py ├── requirements.txt └── utils.py /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Sam Lindley 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pi-hole Visualizer 2 | Pi-hole Visualizer displays Pi-hole statistics on the Raspberry Pi Sense-HAT with multiple animations. 3 | 4 | ![sense-hat display](https://github.com/simianAstronaut/pi-hole-visualizer/blob/master/images/sense_hat.gif) 5 | 6 | ### Details 7 | - Pi-hole Visualizer cycles between five different customizable animations at regular intervals. 8 | 9 | - The first animation(icon) displays the global connection status. A green pulsating icon represents a functioning internet connection. 10 | 11 | - The second animation(vertical bar chart) depicts the overall volume of DNS queries generated on the network. Each column represents a specific and adjustable time interval relative to the previous 24-hour timeframe. The time interval ranges from 10 minutes to 3 hours. The chart can be color coded to represent the level of DNS traffic or percentage of ads blocked. 12 | 13 | - In the third animation(spiral graph), the daily ad block percentage is represented by the number of red pixels displayed. 14 | 15 | - The fourth animation(horizontal bar chart) displays the relative level of DNS queries generated by top clients on the network in descending order. 16 | 17 | - The last animation(pie chart) displays the proportion of each DNS record type. 18 | 19 | - Options include manual chart selection, randomization of pixel generation, specifying the orientation of the display, and low-light mode. 20 | 21 | - Joystick controls allow for adjustment of program options interactively. 22 | 23 | - Pi-hole Visualizer is either run from the command line or enabled as a systemd service to run automatically at boot. 24 | --- 25 | 26 | ### Requirements 27 | * To install Pi-hole, run `curl -sSL https://install.pi-hole.net | bash`. 28 | * The Sense-HAT package can be installed with `sudo apt-get install sense-hat`. 29 | 30 | --- 31 | 32 | ### Authorization 33 | To view statistics regarding top clients and query types, authorization from the Pi-hole web server is required. If you are running Pi-hole Visualizer on the same machine that is running Pi-hole, it is assumed a configuration file containing the password hash is located at (`/etc/pihole/setupVars.conf`) and no action is required. 34 | If you are on a remote machine, you can enable authorization by creating an environment variable containing the password hash. Append the following line to (`~/.bash_profile`) or (`~/.profile`): `export WEBPASSWORD="hash_value"`. 35 | 36 | --- 37 | 38 | ### Usage 39 | **`dns_stats.py [OPTION]`** 40 | 41 | #### Options 42 | `-h, --help` 43 | Show this help message and exit. 44 | 45 | `-i {10, 30, 60, 120, 180}, --interval {10, 30, 60, 120, 180}` 46 | Specify interval time in minutes. Defaults to one hour. 47 | 48 | `-c {basic, traffic, ads}, --color {basic, traffic, ads}` 49 | Enter 'basic' to generate bar charts in the default red color, 'traffic' to color code based on level of DNS queries, or 'ads' to color code by ad block percentage. 50 | 51 | `-a ADDRESS, --address ADDRESS` 52 | Specify address of DNS server, defaults to localhost. 53 | 54 | `-o {0, 90, 180, 270}, --orientation {0, 90, 180, 270}` 55 | Specify orientation of display so that RPi may be installed in non-default orientation. 56 | 57 | `-ll, --lowlight` 58 | Lower LED matrix brightness for use in low light environments. 59 | 60 | `-r, --randomize` 61 | Randomize order of pixels displayed. 62 | 63 | `-s {1, 2, 3, 4, 5}, --select {1, 2, 3, 4, 5}` 64 | Specify which animation(s) to display, with multiple items separated by a space. 65 | 66 | #### Joystick Controls 67 | - _UP - PUSH_ 68 | Cycle color mode. 69 | 70 | - _RIGHT - PUSH_ 71 | Cycle interval selection. 72 | 73 | - _DOWN - PUSH_ 74 | Toggle low-light mode. 75 | 76 | - _LEFT - PUSH_ 77 | Cycle display orientation. 78 | 79 | - _MIDDLE - PUSH_ 80 | Toggle randomization. 81 | 82 | - _MIDDLE - HOLD_ 83 | Exit program. 84 | 85 | --- 86 | 87 | ### To Install As a System Service 88 | 1. Make the script and unit file executable: 89 | `sudo chmod +x dns_stats.py` 90 | `sudo chmod +x dns_stats.service` 91 | 92 | 2. Check that the path in the unit file after `ExecStart` matches the path of your script. 93 | 94 | 3. Copy the unit file to the system directory: 95 | `sudo cp dns_stats.service /lib/systemd/system` 96 | 97 | 4. Enable the service to run at startup: 98 | `sudo systemctl enable dns_stats` 99 | 100 | 5. Reboot: 101 | `sudo reboot` 102 | 103 | 6. To check the status of the service: 104 | `sudo systemctl status dns_stats` 105 | -------------------------------------------------------------------------------- /config.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Pi-hole DNS traffic visualizer for the Raspberry Pi Sense HAT 3 | By Sam Lindley, 2/21/2018 4 | ''' 5 | 6 | import logging 7 | import os 8 | 9 | from sense_hat import SenseHat 10 | 11 | SENSE = SenseHat() 12 | RIPPLE_SPEED = 0.025 13 | 14 | if os.geteuid() == 0: 15 | LOGGER = logging.getLogger(__name__) 16 | LOGGER.setLevel(logging.INFO) 17 | 18 | HANDLER = logging.FileHandler('/var/log/pihole-visualizer.log') 19 | FORMATTER = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') 20 | HANDLER.setFormatter(FORMATTER) 21 | LOGGER.addHandler(HANDLER) 22 | -------------------------------------------------------------------------------- /dns_stats.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | ''' 4 | Pi-hole DNS traffic visualizer for the Raspberry Pi Sense HAT 5 | By Sam Lindley, 2/21/2018 6 | ''' 7 | 8 | import argparse 9 | from itertools import cycle 10 | import operator 11 | import random 12 | import time 13 | 14 | import config 15 | import joystick 16 | import requests 17 | import utils 18 | 19 | def generate_interval_data(raw_data, interval): 20 | interval_data = [] 21 | domains = 0 22 | ads = 0 23 | 24 | #sort and reverse data so that latest time intervals appear first in list 25 | for counter, key in enumerate(sorted(raw_data['domains_over_time'].keys(), reverse=True)): 26 | if interval == 10: 27 | domains = raw_data['domains_over_time'][key] 28 | ads = raw_data['ads_over_time'][key] 29 | interval_data.append([domains, (ads / domains) * 100 if domains > 0 else 0]) 30 | else: 31 | if interval == 30: 32 | if counter > 0 and counter % 3 == 0: 33 | interval_data.append([domains, (ads / domains) * 100 if domains > 0 else 0]) 34 | domains = 0 35 | ads = 0 36 | elif interval == 60: 37 | if counter > 0 and counter % 6 == 0: 38 | interval_data.append([domains, (ads / domains) * 100 if domains > 0 else 0]) 39 | domains = 0 40 | ads = 0 41 | elif interval == 120: 42 | if counter > 0 and counter % 12 == 0: 43 | interval_data.append([domains, (ads / domains) * 100 if domains > 0 else 0]) 44 | domains = 0 45 | ads = 0 46 | elif interval == 180: 47 | if counter > 0 and counter % 18 == 0: 48 | interval_data.append([domains, (ads / domains) * 100 if domains > 0 else 0]) 49 | domains = 0 50 | ads = 0 51 | 52 | domains += raw_data['domains_over_time'][key] 53 | ads += raw_data['ads_over_time'][key] 54 | 55 | #extract a slice of the previous 24 hours 56 | if interval == 10: 57 | interval_data = interval_data[:144] 58 | elif interval == 30: 59 | interval_data = interval_data[:48] 60 | elif interval == 60: 61 | interval_data = interval_data[:24] 62 | elif interval == 120: 63 | interval_data = interval_data[:12] 64 | elif interval == 180: 65 | interval_data = interval_data[:8] 66 | 67 | return interval_data 68 | 69 | 70 | def connectivity_icon(status, orientation, lowlight, randomize): 71 | color = (0, 255, 0) if status else (255, 0, 0) 72 | icon = [ 73 | [1, 2, 3, 4, 5, 6], 74 | [0, 7], 75 | [2, 3, 4, 5], 76 | [1, 6], 77 | [3, 4], 78 | [2, 5], 79 | [3, 4] 80 | ] 81 | 82 | config.SENSE.clear() 83 | config.SENSE.set_rotation(orientation) 84 | config.SENSE.low_light = lowlight 85 | 86 | for row in random.sample(range(0, 7), 7) if randomize else range(6, -1, -1): 87 | for col in random.sample(icon[row], len(icon[row])) if randomize else icon[row]: 88 | config.SENSE.set_pixel(col, row, color) 89 | if randomize: 90 | time.sleep(config.RIPPLE_SPEED) 91 | if not randomize: 92 | time.sleep(config.RIPPLE_SPEED * 8) 93 | 94 | 95 | def bar_chart_vertical(interval_data, color, orientation, lowlight, randomize): 96 | info_chart = [] 97 | domain_min = interval_data[0][0] 98 | domain_max = interval_data[0][0] 99 | ad_min = interval_data[0][1] 100 | ad_max = interval_data[0][1] 101 | 102 | #calculate minimum, maximum, and interval values to scale graph appropriately 103 | for i in interval_data: 104 | if i[0] > domain_max: 105 | domain_max = i[0] 106 | elif i[0] < domain_min: 107 | domain_min = i[0] 108 | 109 | if i[1] > ad_max: 110 | ad_max = i[1] 111 | elif i[1] < ad_min: 112 | ad_min = i[1] 113 | 114 | domain_interval = (domain_max - domain_min) / 8 115 | ad_interval = (ad_max - ad_min) / 8 116 | 117 | #append scaled values to new list 118 | for i in interval_data: 119 | info_chart.append([int((i[0] - domain_min) / domain_interval) if domain_interval > 0 \ 120 | else 0, int((i[1] - ad_min) / ad_interval) if ad_interval > 0 else 0]) 121 | 122 | #handles cases of incomplete data 123 | while len(info_chart) < 8: 124 | info_chart.append([0, 0]) 125 | 126 | info_chart = list(reversed(info_chart[:8])) 127 | 128 | config.SENSE.clear() 129 | config.SENSE.set_rotation(orientation) 130 | config.SENSE.low_light = lowlight 131 | 132 | #set pixel values on rgb display 133 | for col in random.sample(range(0, 8), 8) if randomize else range(0, 8): 134 | if info_chart[col][0] > 0: 135 | for row in random.sample(range(0, info_chart[col][0]), info_chart[col][0]) if \ 136 | randomize else range(0, info_chart[col][0]): 137 | #if color not set, default to red for all values 138 | if color == 'traffic': 139 | config.SENSE.set_pixel(col, 7 - row, utils.color_dict(info_chart[col][0])) 140 | time.sleep(config.RIPPLE_SPEED) 141 | elif color == 'ads': 142 | config.SENSE.set_pixel(col, 7 - row, utils.color_dict(info_chart[col][1])) 143 | time.sleep(config.RIPPLE_SPEED) 144 | elif color == 'basic': 145 | config.SENSE.set_pixel(col, 7 - row, (255, 0, 0)) 146 | time.sleep(config.RIPPLE_SPEED) 147 | 148 | 149 | def spiral_graph(block_percentage, orientation, lowlight, randomize, x=3, y=3): 150 | grid_size = 64 151 | grid_list = [] 152 | dx = 0 153 | dy = 1 154 | pivot_index = 0 155 | pivot_point = 1 156 | 157 | grid_units = int(grid_size * block_percentage) 158 | 159 | if not randomize: 160 | config.SENSE.clear() 161 | config.SENSE.set_rotation(orientation) 162 | config.SENSE.low_light = lowlight 163 | 164 | for i in range(grid_size): 165 | if i < grid_units: 166 | if randomize: 167 | grid_list.append((x, 7 - y, (255, 0, 0))) 168 | else: 169 | config.SENSE.set_pixel(x, 7 - y, (255, 0, 0)) 170 | else: 171 | if randomize: 172 | grid_list.append((x, 7 - y, (0, 0, 255))) 173 | else: 174 | config.SENSE.set_pixel(x, 7 - y, (0, 0, 255)) 175 | 176 | if not randomize: 177 | time.sleep(config.RIPPLE_SPEED) 178 | 179 | if pivot_index == pivot_point: 180 | if dx == 0: 181 | dx, dy = dy, dx 182 | pivot_index = 0 183 | elif dy == 0: 184 | dx, dy = dy, -dx 185 | pivot_index = 0 186 | pivot_point += 1 187 | 188 | x += dx 189 | y += dy 190 | pivot_index += 1 191 | 192 | if randomize: 193 | config.SENSE.clear() 194 | 195 | for pixel in random.sample(grid_list, grid_size): 196 | config.SENSE.set_pixel(pixel[0], pixel[1], pixel[2]) 197 | 198 | time.sleep(config.RIPPLE_SPEED) 199 | 200 | 201 | def bar_chart_horizontal(top_sources, color, orientation, lowlight, randomize): 202 | source_list = [] 203 | info_chart = [] 204 | 205 | if len(top_sources) > 0: 206 | for source in sorted(top_sources.items(), key=operator.itemgetter(1), reverse=True): 207 | source_list.append(source[1]) 208 | 209 | source_max = source_list[0] 210 | source_min = source_list[-1] if len(source_list) > 1 else 0 211 | source_interval = (source_max - source_min) / 8 212 | 213 | for source in source_list: 214 | info_chart.append(int((source - source_min) / source_interval)) 215 | 216 | #handles cases of incomplete data 217 | while len(info_chart) < 8: 218 | info_chart.append(0) 219 | 220 | config.SENSE.clear() 221 | config.SENSE.set_rotation(orientation) 222 | config.SENSE.low_light = lowlight 223 | 224 | #set pixel values on rgb display 225 | for row in random.sample(range(0, 8), 8) if randomize else range(0, 8): 226 | if info_chart[row] > 0: 227 | for col in random.sample(range(0, info_chart[row]), info_chart[row]) if \ 228 | randomize else range(0, info_chart[row]): 229 | if color == 'basic': 230 | config.SENSE.set_pixel(col, row, (255, 0, 0)) 231 | else: 232 | config.SENSE.set_pixel(col, row, utils.color_dict(info_chart[row])) 233 | time.sleep(config.RIPPLE_SPEED) 234 | 235 | 236 | def pie_chart(query_types, orientation, lowlight, randomize): 237 | query_colors = { 238 | "A (IPv4)": (0, 26, 65), #navy 239 | "AAAA (IPv6)": (0, 180, 251), #sky blue 240 | "ANY": (255, 132, 34), #orange 241 | "SRV": (250, 101, 96), #pink 242 | "SOA": (67, 108, 52), #moss green 243 | "PTR": (142, 58, 137), #purple 244 | "TXT": (255, 255, 255), #white 245 | } 246 | grid_size = 64 247 | grid_list = [] 248 | counter = 0 249 | 250 | for category in query_types: 251 | query_types[category] = int((query_types[category] / 100) * grid_size) 252 | 253 | current_type = max(query_types, key=query_types.get) 254 | 255 | if not randomize: 256 | config.SENSE.clear() 257 | config.SENSE.set_rotation(orientation) 258 | config.SENSE.low_light = lowlight 259 | 260 | for row in range(0, 8): 261 | for col in range(4, 8): 262 | if counter < query_types[current_type]: 263 | if randomize: 264 | grid_list.append((col, row, query_colors[current_type])) 265 | else: 266 | config.SENSE.set_pixel(col, row, query_colors[current_type]) 267 | last_valid = current_type 268 | else: 269 | del query_types[current_type] 270 | counter = 0 271 | if query_types: 272 | current_type = max(query_types, key=query_types.get) 273 | if query_types[current_type]: 274 | last_valid = current_type 275 | 276 | if randomize: 277 | grid_list.append((col, row, query_colors[current_type] if \ 278 | query_types[current_type] else query_colors[last_valid])) 279 | else: 280 | config.SENSE.set_pixel(col, row, query_colors[current_type] if \ 281 | query_types[current_type] else query_colors[last_valid]) 282 | 283 | if not randomize: 284 | time.sleep(config.RIPPLE_SPEED) 285 | 286 | counter += 1 287 | 288 | for row in range(7, -1, -1): 289 | for col in range(3, -1, -1): 290 | if counter < query_types[current_type]: 291 | if randomize: 292 | grid_list.append((col, row, query_colors[current_type])) 293 | else: 294 | config.SENSE.set_pixel(col, row, query_colors[current_type]) 295 | last_valid = current_type 296 | else: 297 | del query_types[current_type] 298 | counter = 0 299 | if query_types: 300 | current_type = max(query_types, key=query_types.get) 301 | if query_types[current_type]: 302 | last_valid = current_type 303 | 304 | if randomize: 305 | grid_list.append((col, row, query_colors[current_type] if \ 306 | query_types[current_type] else query_colors[last_valid])) 307 | else: 308 | config.SENSE.set_pixel(col, row, query_colors[current_type] if \ 309 | query_types[current_type] else query_colors[last_valid]) 310 | 311 | if not randomize: 312 | time.sleep(config.RIPPLE_SPEED) 313 | 314 | counter += 1 315 | 316 | if randomize: 317 | config.SENSE.clear() 318 | 319 | for pixel in random.sample(grid_list, grid_size): 320 | config.SENSE.set_pixel(pixel[0], pixel[1], pixel[2]) 321 | 322 | time.sleep(config.RIPPLE_SPEED) 323 | 324 | 325 | def event_loop(args, pw_hash): 326 | modes = ['icon', 'vertical', 'spiral'] 327 | cycler = cycle(modes) 328 | 329 | 330 | while True: 331 | joystick_event = False 332 | 333 | status = requests.global_access() 334 | raw_data = requests.api_request(args.address, pw_hash) 335 | interval_data = generate_interval_data(raw_data, args.interval) 336 | 337 | block_percentage = float(raw_data['ads_percentage_today']) / 100 338 | 339 | if 'top_sources' in raw_data and 'querytypes' in raw_data: 340 | if len(modes) != 5: 341 | modes.extend(['horizontal', 'pie']) 342 | else: 343 | if len(modes) == 5: 344 | modes = [mode for mode in modes if mode not in ('horizontal', 'pie')] 345 | 346 | included = args.select 347 | if included: 348 | included = set(included) 349 | excluded = {1, 2, 3, 4, 5} - included 350 | 351 | for chart in sorted(excluded, reverse=True): 352 | if chart <= len(modes): 353 | modes.pop(chart - 1) 354 | 355 | for _ in range(0, 15): 356 | mode = next(cycler) 357 | if mode == 'icon': 358 | connectivity_icon(status, args.orientation, args.lowlight, args.randomize) 359 | elif mode == 'vertical': 360 | bar_chart_vertical(interval_data, args.color, args.orientation, args.lowlight, \ 361 | args.randomize) 362 | elif mode == 'spiral': 363 | spiral_graph(block_percentage, args.orientation, args.lowlight, args.randomize) 364 | elif mode == 'horizontal': 365 | bar_chart_horizontal(raw_data['top_sources'], args.color, args.orientation, \ 366 | args.lowlight, args.randomize) 367 | elif mode == 'pie': 368 | pie_chart(raw_data['querytypes'].copy(), args.orientation, args.lowlight, args.randomize) 369 | 370 | for _ in range(0, 2): 371 | events = config.SENSE.stick.get_events() 372 | if events: 373 | joystick_event = True 374 | last_event = events[-1] 375 | 376 | if last_event.direction == 'up': 377 | args.color = joystick.up_pushed(args.color) 378 | print("Color mode switched to '%s'." % args.color.capitalize()) 379 | break 380 | elif last_event.direction == 'right': 381 | args.interval = joystick.right_pushed(args.interval) 382 | print("Time interval switched to %d minutes." % args.interval) 383 | break 384 | elif last_event.direction == 'down': 385 | args.lowlight = joystick.down_pushed(args.lowlight) 386 | print("Low-light mode", "enabled." if args.lowlight else \ 387 | "disabled.") 388 | break 389 | elif last_event.direction == 'left': 390 | args.orientation = joystick.left_pushed(args.orientation) 391 | print("Orientation switched to %d degrees." % args.orientation) 392 | break 393 | elif last_event.direction == 'middle' and last_event.action == 'released': 394 | args.randomize = joystick.middle_pushed(args.randomize) 395 | print("Randomization", "enabled." if args.randomize else "disabled.") 396 | break 397 | elif last_event.direction == 'middle' and last_event.action == 'held': 398 | joystick.middle_held() 399 | 400 | time.sleep(1) 401 | 402 | if joystick_event: 403 | break 404 | 405 | 406 | def main(): 407 | parser = argparse.ArgumentParser(description="Displays Pi-hole statistics on the Raspberry Pi \ 408 | Sense-HAT with multiple animations") 409 | 410 | parser.add_argument('-i', '--interval', action="store", choices=[10, 30, 60, 120, 180], \ 411 | type=int, default='60', help="specify interval time in minutes") 412 | parser.add_argument('-c', '--color', action="store", choices=['basic', 'traffic', 'ads'], \ 413 | default='basic', help="enter 'basic' to generate bar charts in the \ 414 | default red color, 'traffic' to color code based on level of DNS queries, \ 415 | or 'ads' to color code by ad block percentage.") 416 | parser.add_argument('-a', '--address', action="store", default='127.0.0.1', help="specify \ 417 | address of DNS server, defaults to localhost") 418 | parser.add_argument('-o', '--orientation', action="store", choices=[0, 90, 180, 270], \ 419 | type=int, default='0', help="rotate graph to match orientation of RPi") 420 | parser.add_argument('-ll', '--lowlight', action="store_true", help="set LED matrix to \ 421 | low-light mode for use in dark environments") 422 | parser.add_argument('-r', '--randomize', action="store_true", help="randomize order of \ 423 | pixels displayed") 424 | parser.add_argument('-s', '--select', nargs='+', choices=range(1, 6), type=int, \ 425 | help="specify which animations to display(1-5), with multiple items \ 426 | separated by a space") 427 | 428 | args = parser.parse_args() 429 | 430 | pw_hash = utils.retrieve_hash(args.address) 431 | 432 | event_loop(args, pw_hash) 433 | 434 | 435 | if __name__ == '__main__': 436 | main() 437 | -------------------------------------------------------------------------------- /dns_stats.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Pi-hole Visualizer Service 3 | After=pihole-FTL.service lighttpd.service 4 | 5 | [Service] 6 | Type=idle 7 | ExecStart=/home/pi/pi-hole-visualizer/dns_stats.py -c ads -i 180 8 | 9 | [Install] 10 | WantedBy=multi-user.target 11 | -------------------------------------------------------------------------------- /images/sense_hat.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simianAstronaut/pi-hole-visualizer/34ecbb21c2b0e47a730c05a4d2d0d064d78c427e/images/sense_hat.gif -------------------------------------------------------------------------------- /joystick.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Pi-hole DNS traffic visualizer for the Raspberry Pi Sense HAT 3 | By Sam Lindley, 2/21/2018 4 | ''' 5 | 6 | import os 7 | import sys 8 | 9 | import config 10 | 11 | def up_pushed(color): 12 | color_options = ('basic', 'traffic', 'ads') 13 | color_index = color_options.index(color) 14 | 15 | if color_index == 2: 16 | color_index = 0 17 | else: 18 | color_index += 1 19 | 20 | color = color_options[color_index] 21 | 22 | return color 23 | 24 | 25 | def right_pushed(interval): 26 | interval_options = (10, 30, 60, 120, 180) 27 | interval_index = interval_options.index(interval) 28 | 29 | if interval_index == 4: 30 | interval_index = 0 31 | else: 32 | interval_index += 1 33 | 34 | interval = interval_options[interval_index] 35 | 36 | return interval 37 | 38 | 39 | def down_pushed(lowlight): 40 | lowlight = False if lowlight else True 41 | 42 | return lowlight 43 | 44 | 45 | def left_pushed(orientation): 46 | orientation_options = (0, 90, 180, 270) 47 | orientation_index = orientation_options.index(orientation) 48 | 49 | if orientation_index == 3: 50 | orientation_index = 0 51 | else: 52 | orientation_index += 1 53 | 54 | orientation = orientation_options[orientation_index] 55 | 56 | return orientation 57 | 58 | 59 | def middle_pushed(randomize): 60 | randomize = False if randomize else True 61 | 62 | return randomize 63 | 64 | 65 | def middle_held(): 66 | if os.geteuid() == 0: 67 | config.LOGGER.info('Program terminated by user.') 68 | print('Program terminated by user.') 69 | 70 | config.SENSE.clear() 71 | 72 | sys.exit() 73 | -------------------------------------------------------------------------------- /requests.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Pi-hole DNS traffic visualizer for the Raspberry Pi Sense HAT 3 | By Sam Lindley, 2/21/2018 4 | ''' 5 | 6 | import json 7 | import os 8 | import socket 9 | import sys 10 | import time 11 | import urllib.request 12 | 13 | import config 14 | 15 | def global_access(host="8.8.8.8", port=53, timeout=3): 16 | """ 17 | Host: 8.8.8.8 (Google Public DNS A) 18 | Port: 53/TCP 19 | Service: domain 20 | """ 21 | try: 22 | socket.setdefaulttimeout(timeout) 23 | socket.socket(socket.AF_INET, socket.SOCK_STREAM).connect((host, port)) 24 | return True 25 | except socket.error: 26 | return False 27 | 28 | 29 | def api_request(address, pw_hash): 30 | if not hasattr(api_request, "initial_connection"): 31 | api_request.initial_connection = True 32 | max_attempts = 100 if api_request.initial_connection else 10 33 | attempts = 0 34 | query = "?summary&overTimeData10mins&getQueryTypes&getQuerySources&auth=%s" % pw_hash 35 | 36 | #retrieve and decode json data from server 37 | while True: 38 | try: 39 | if attempts == 0 and api_request.initial_connection: 40 | if os.geteuid() == 0: 41 | config.LOGGER.info('Initiating connection with server.') 42 | print('Initiating connection with server.') 43 | with urllib.request.urlopen("http://%s/admin/api.php%s" % (address, query)) as url: 44 | attempts += 1 45 | raw_data = json.loads(url.read().decode()) 46 | break 47 | except json.decoder.JSONDecodeError: 48 | if attempts < max_attempts: 49 | time.sleep(1) 50 | continue 51 | else: 52 | if os.geteuid() == 0: 53 | config.LOGGER.error('Exceeded max attempts to connect with server.') 54 | print('Error: Exceeded max attempts to connect with server.') 55 | sys.exit(1) 56 | except urllib.error.URLError: 57 | if os.geteuid() == 0: 58 | config.LOGGER.error('Web server offline or invalid address entered.') 59 | print("Error: Web server offline or invalid address entered.") 60 | 61 | if attempts < max_attempts: 62 | time.sleep(1) 63 | continue 64 | else: 65 | sys.exit(1) 66 | 67 | if 'domains_over_time' not in raw_data or 'ads_over_time' not in raw_data or \ 68 | 'ads_percentage_today' not in raw_data: 69 | if os.geteuid() == 0: 70 | config.LOGGER.error('Invalid data returned from server. Check if pihole-FTL service is \ 71 | running.') 72 | print('Error: Invalid data returned from server. Check if pihole-FTL service is running.') 73 | sys.exit(1) 74 | 75 | if api_request.initial_connection: 76 | if os.geteuid() == 0: 77 | config.LOGGER.info('Successful connection with server.') 78 | print('Successful connection with server.') 79 | 80 | api_request.initial_connection = False 81 | 82 | return raw_data 83 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | sense_hat==2.2.0 2 | -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Pi-hole DNS traffic visualizer for the Raspberry Pi Sense HAT 3 | By Sam Lindley, 2/21/2018 4 | ''' 5 | 6 | import os 7 | import re 8 | 9 | import config 10 | 11 | def color_dict(level): 12 | return { 13 | 0 : (0, 0, 255), 14 | 1 : (0, 128, 255), 15 | 2 : (0, 255, 255), 16 | 3 : (255, 128, 0), 17 | 4 : (0, 255, 0), 18 | 5 : (128, 255, 0), 19 | 6 : (255, 255, 0), 20 | 7 : (255, 128, 0), 21 | 8 : (255, 0, 0), 22 | }[level] 23 | 24 | 25 | def parse_config(config_path): 26 | pw_hash = '' 27 | 28 | with open(config_path, 'r') as fp: 29 | try: 30 | for line in fp: 31 | hex_pattern = re.compile(r'WEBPASSWORD=([0-9a-fA-F]+)') 32 | match = re.match(hex_pattern, line) 33 | if match: 34 | pw_hash = match.group(1) 35 | return pw_hash 36 | except Exception: 37 | return pw_hash 38 | 39 | 40 | def retrieve_hash(address): 41 | pw_hash = '' 42 | 43 | if address == '127.0.0.1': 44 | config_path = '/etc/pihole/setupVars.conf' 45 | 46 | if os.getegid() != 0: 47 | return pw_hash 48 | else: 49 | if os.path.exists(config_path): 50 | return parse_config(config_path) 51 | else: 52 | if os.geteuid() == 0: 53 | config.LOGGER.error("Pi-hole configuration file not found in default location '%s'." \ 54 | % config_path) 55 | print("Pi-hole configuration file not found in default location '%s'." \ 56 | % config_path) 57 | 58 | return pw_hash 59 | else: 60 | env_pw = os.environ.get("WEBPASSWORD", None) 61 | 62 | if env_pw is None: 63 | if os.geteuid() == 0: 64 | config.LOGGER.warning("Environment variable containing password hash could not be found.") 65 | print("Environment variable containing password hash could not be found.") 66 | else: 67 | pw_hash = env_pw 68 | 69 | return pw_hash 70 | --------------------------------------------------------------------------------