refactor(strategy): extract auto-tune core logic into pwnagotchi/strategy module

Extracted intelligent channel selection strategy from auto-tune plugin into core
as pwnagotchi/strategy/ module with 3 focused components:

1. channels.py: ChannelStrategy
   - Selects channels for each epoch (active + extra unscanned)
   - Builds on active_channels and explores new areas
   - Handles channel list repopulation from config/agent

2. statistics.py: ChannelStatistics
   - Tracks per-channel statistics (AP counts, interactions)
   - Maintains known AP database
   - Manages active vs unscanned channel lists
   - Records interactions (assoc, deauth, handshakes)

3. __init__.py: Strategy facade
   - Unified entry point for agent integration
   - Event handler dispatch
   - Public API for stats access

Key changes:
- Strategy is now ALWAYS active (not optional like plugin)
- Personality/config loaded from config.toml, not plugin options
- No web UI or preset management (core only)
- Clean event integration with agent
- Removed ~600 lines of UI/preset code

Auto-tune.py is now DISABLED. Original backed up as auto-tune.py.backup.

The strategy replaces the removed RL-based AI with intelligent heuristics:
- Always scan active channels (high capture probability)
- Explore unscanned channels (discovery)
- Track statistics to guide behavior
- Adapt to environment changes

Configuration in config.toml:
    [main]
    extra_channels = 15
    restrict_channels = [1, 6, 11]  # optional

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Jeroen Oudshoorn
2026-07-27 17:27:38 +02:00
co-authored by Claude Haiku 4.5
parent 5c1d342893
commit 9fa3264440
6 changed files with 1108 additions and 4 deletions
+6 -1
View File
@@ -9,7 +9,12 @@
</option>
<option name="sessionPaths">
<map>
<entry key="123f4047-331a-4e76-8584-87841039c403" value="/sessions/new" />
<entry key="123f4047-331a-4e76-8584-87841039c403" value="/sessions/98eaed9d-7530-4741-8693-6dc6f901b758" />
</map>
</option>
<option name="sessionTitles">
<map>
<entry key="123f4047-331a-4e76-8584-87841039c403" value="What do you think of the Pwnagotchi codebase" />
</map>
</option>
</component>
@@ -0,0 +1,773 @@
import logging
import random
import time
import html
import os
import json
import pwnagotchi.plugins as plugins
from pwnagotchi.ui.components import LabeledValue
from pwnagotchi.ui.view import BLACK
import pwnagotchi.ui.fonts as fonts
import pwnagotchi.utils
from pwnagotchi.utils import save_config, merge_config
from flask import abort
from flask import render_template_string
class auto_tune(plugins.Plugin):
__author__ = 'Sniffleupagus'
__version__ = '1.0.1'
__license__ = 'GPL3'
__description__ = 'A plugin that adjust AUTO mode parameters'
# Chistos - should really be an object, but I'm being lazy
#
# Channel histograms maintained per session
# - stat = string : name of the statistic. It is the chart label for this stat
# - channel = the channel where the stat happened
# - count (default +1) - how much to add to the count for this chisto[stat][channel]
# for example, on_bcap_wifi_ap_new, use the default value, but on_bcap_wifi_ap_lost use -1
# to count current APs per channel
def __init__(self):
self._histogram = {'loops': 0} # count APs per epoch
self._chistos = {'_all_actions': {-1: 0}} # arbitrary session stats per channel
# plugin data
self._unscanned_channels = [] # temporary set of channels to pull "extra_channels" from
self._active_channels = [] # list of channels with APs found in last scan
self._known_aps = {} # dict of all APs by normalized name+mac
self._known_clients = {} # dict of all clients by normalized APmac+STAmac (many clients to not have names)
self._agent = None
self.descriptions = { # descriptions of personality variables displayed in webui
"advertise": "enable/disable advertising to mesh peers",
"deauth": "enable/disable deauthentication attacks",
"associate": "enable/disable association attacks",
"throttle_a": "delay after an associate. Some delay seems to reduce nexmon crashes",
"throttle_d": "delay after a deauthenticate. Delay helps reduce nexmon crashes",
"assoc_prob": "probability of trying an associate attack. Set lower to spread out interaction instead of hitting all APs every time until max_interactions",
"deauth_prob": "probability of trying a deauth. will spread the 'max_interactions' over a longer time",
"min_rssi": "ignore APs with signal weaker than this value. lower values will attack more distant APs",
"recon_time": "duration of the bettercap channel hopping scan phase, to discover APs before sending attacks",
"min_recon_time": "time spent on each occupied channel per epoch, sending attacks and waiting for handshakes. and epoch is recon_time + #channels * min_recon_time seconds long",
"ap_ttl": "APs that have not been seen since this many seconds are ignored. Shorten this if you are moving, to not try to scan APs that are no longer in range.",
"sta_ttl": "Clients older than this will ignored",
}
self.options = dict()
self.presets_dir = os.path.expanduser("~/auto-tune-presets")
self._ensure_presets_dir()
def _ensure_presets_dir(self):
"""Ensure the presets directory exists"""
try:
if not os.path.exists(self.presets_dir):
os.makedirs(self.presets_dir, mode=0o755)
logging.info(f"Created presets directory: {self.presets_dir}")
except OSError as e:
logging.error(f"Failed to create presets directory {self.presets_dir}: {str(e)}")
raise e
def _get_preset_files(self):
"""Get list of available preset files"""
try:
self._ensure_presets_dir()
preset_files = []
for filename in os.listdir(self.presets_dir):
if filename.endswith('.json'):
preset_files.append(filename[:-5]) # Remove .json extension
return sorted(preset_files)
except OSError as e:
logging.error(f"Error reading presets directory: {str(e)}")
return []
def _save_preset(self, preset_name):
"""Save current configuration as a preset"""
try:
self._ensure_presets_dir()
preset_data = {
'personality': {},
'plugin_settings': {},
'timestamp': time.time(),
'version': '1.0'
}
# Save personality settings
for param in self._agent._config['personality']:
if type(self._agent._config['personality'][param]) in [int, str, float, bool]:
preset_data['personality'][param] = self._agent._config['personality'][param]
# Save plugin settings
for param in self.options:
if type(self.options[param]) in [int, str, float, bool]:
preset_data['plugin_settings'][param] = self.options[param]
preset_file = os.path.join(self.presets_dir, f"{preset_name}.json")
with open(preset_file, 'w') as f:
json.dump(preset_data, f, indent=2)
logging.info(f"Preset '{preset_name}' saved successfully to {preset_file}")
return True
except Exception as e:
logging.error(f"Error saving preset '{preset_name}': {str(e)}")
raise e
def _load_preset(self, preset_name):
"""Load a preset configuration"""
preset_file = os.path.join(self.presets_dir, f"{preset_name}.json")
if not os.path.exists(preset_file):
return False, "Preset file not found"
try:
with open(preset_file, 'r') as f:
preset_data = json.load(f)
changes_made = []
# Load personality settings
if 'personality' in preset_data:
for param, value in preset_data['personality'].items():
if param in self._agent._config['personality']:
old_value = self._agent._config['personality'][param]
if old_value != value:
self._agent._config['personality'][param] = value
changes_made.append(f"personality.{param}: {old_value} -> {value}")
# Load plugin settings
if 'plugin_settings' in preset_data:
for param, value in preset_data['plugin_settings'].items():
if param in self.options:
old_value = self.options[param]
if old_value != value:
self.options[param] = value
changes_made.append(f"plugin.{param}: {old_value} -> {value}")
if changes_made:
logging.info(f"Preset '{preset_name}' loaded with changes: {', '.join(changes_made)}")
return True, f"Preset '{preset_name}' loaded successfully with {len(changes_made)} changes"
else:
return True, f"Preset '{preset_name}' loaded (no changes needed)"
except Exception as e:
logging.error(f"Error loading preset '{preset_name}': {str(e)}")
return False, f"Error loading preset: {str(e)}"
def _delete_preset(self, preset_name):
"""Delete a preset file"""
try:
self._ensure_presets_dir()
preset_file = os.path.join(self.presets_dir, f"{preset_name}.json")
if os.path.exists(preset_file):
os.remove(preset_file)
logging.info(f"Preset '{preset_name}' deleted successfully")
return True
else:
logging.warning(f"Preset file '{preset_file}' not found for deletion")
return False
except Exception as e:
logging.error(f"Error deleting preset '{preset_name}': {str(e)}")
return False
def incrementChisto(self, stat, channel, count=1):
if stat not in self._chistos:
self._chistos[stat] = {-1: 0}
if channel not in self._chistos[stat]:
self._chistos[stat][channel] = count
else:
self._chistos[stat][channel] += count
# count all actions per channel, to get a full channel list
if channel not in self._chistos['_all_actions']:
self._chistos['_all_actions'][channel] = 1
else:
self._chistos['_all_actions'][channel] += 1
# track total on channel -1
self._chistos[stat][-1] += count
self._chistos['_all_actions'][-1] += 1
def showChistos(self, stats=None, sort_key='_all_actions'): # stats is list of specific to show, else all
ret = ""
if not stats:
stats = self._chistos.keys()
logging.debug("Using keys: %s" % repr(stats))
try:
if sort_key in self._chistos:
channel_order = sorted(self._chistos[sort_key].items(), key=lambda x: x[1], reverse=True)
else:
channel_order = self._agent._supported_channels
logging.debug("Channel Order: %s" % repr(channel_order))
ret += "<h2>Channel Statistics</h2>\n"
ret += "<table border=1 cellspacing=4 cellpadding=4>\n"
ret += "<tr><th>Channel</th>"
for (ch, count) in channel_order:
if ch == -1:
ret += "<th>All</th>"
else:
ret += "<th>%d</th>" % ch
ret += "</tr>\n"
if not stats:
ret += "</table>\n"
return ret
for s in stats:
if s == sort_key:
ret += "<tr><th>%s</th>" % s
else:
ret += "<tr><td>%s</td>" % s
if s not in self._chistos:
ret += "<td colspan=%s>No data</td>" % len(channel_order)
else:
chisto = self._chistos[s]
for (ch, dummy) in channel_order:
if ch in chisto:
ret += "<td align=right>%s</td>" % chisto[ch]
else:
ret += "<td align=center>-</td>"
ret += "</tr>\n"
ret += "</table>\n"
return ret
except Exception as e:
eret = "<h2>Channel Statistics Error</h2>\n"
eret += "<h3>Progress:</h3>\n<pre>%s</pre>\n<h3>Exception dump:</h3>\n<pre>%s</pre>\n" % (
html.escape(ret), html.escape(repr(e)))
logging.exception(e)
return eret
def normalize(self, name):
"""
Only allow alpha/nums
"""
if not name or name == '':
return 'EMPTY'
if name == '<hidden>':
return 'HIDDEN'
return str.lower(''.join(c for c in name if c.isalnum()))
def showEditForm(self, request):
path = request.path if request.path.endswith("/update") else "%s/update" % request.path
ret = '<form method=post action="%s">' % path
ret += '<input id="csrf_token" name="csrf_token" type="hidden" value="{{ csrf_token() }}">'
# Add presets section
ret += '<div class="preset-section">'
ret += '<h2>Presets! (Use "update" below before saving and after loading.)</h2>'
ret += '<table class="preset-table">'
ret += '<tr><td style="width: 150px;">Preset Name:</td><td><input type="text" name="preset_name" size="30" placeholder="Enter preset name"></td></tr>'
ret += '<tr><td>Available Presets:</td><td>'
ret += '<select name="selected_preset" size="1" style="width: 200px;">'
ret += '<option value="">Select a preset...</option>'
for preset in self._get_preset_files():
ret += '<option value="%s">%s</option>' % (preset, preset)
ret += '</select></td></tr>'
ret += '<tr><td colspan="2" class="preset-buttons">'
ret += '<input type="submit" name="save_preset" value="Save Preset" onclick="return validatePresetName();"> '
ret += '<input type="submit" name="load_preset" value="Load Preset" onclick="return validatePresetSelection();"> '
ret += '<input type="submit" name="delete_preset" value="Delete Preset" onclick="return validatePresetSelection() && confirm(\'Are you sure you want to delete this preset?\');">'
ret += '</td></tr>'
ret += '</table>'
ret += '</div><hr>'
form_data = request.values.items()
for secname, sec in [["Personality", self._agent._config['personality']],
["AUTO Tune", self._agent._config['main']['plugins']['auto-tune']]]:
ret += '<h2>%s Variables</h2>' % secname
ret += '<table>\n'
ret += '<tr align=left><th>Parameter</th><th>Value</th><th>Description</th></tr>\n'
for p in sorted(sec):
if type(sec[p]) in [int, str, float, bool]:
cls = type(sec[p]).__name__
iname = "newval,%s,%s,%s" % (sec[p], p, cls)
ret += "<tr align=left>"
if cls == "bool":
ret += '<th>%s</th><td style="white-space:nowrap; vertical-align:top;">' % (p)
checked = " checked" if sec[p] else ""
ret += '<input type=radio id="%s" name="%s" value="%s" %s>&nbsp;True<br>' % (
iname, iname, "True", checked)
checked = " checked" if not sec[p] else ""
ret += '<input type=radio id="%s" name="%s" value="%s" %s>&nbsp;False' % (
iname, iname, "False", checked)
ret += "</td>"
else:
ret += '<th>%s</th>' % p
ret += '<td><input type=text id="%s" name="%s" size="5" value="%s"></td>' % (
iname, iname, sec[p])
# ret += '<tr><th>%s</th>' % ("" if p not in self.descriptions else self.descriptions[p])
if p in self.descriptions:
ret += "<td>%s</td>" % self.descriptions[p]
ret += '</tr>\n'
else:
ret += '<tr align=left><th>%s</th><td>%s</td><td><i>uneditable</i></tr>' % (p, repr(sec[p]))
ret += "</table>"
ret += '<input type=submit name=submit value="update"></form><p>'
return ret
def showHistogram(self):
ret = ""
histo = self._histogram
nloops = int(histo["loops"])
if nloops > 0:
ret += "<h2>APs per Channel over %s epochs</h2>" % nloops
ret += "<table border=1 spacing=4 cellspacing=1>"
chans = "<tr><th>Channel</th>"
totals = "<tr><th>APs seen</th>"
vals = "<tr><th>Avg APs/epoch</th>"
for (ch, count) in sorted(histo.items(), key=lambda x: x[1], reverse=True):
if ch == "loops":
pass
else:
weight = float(count) / nloops
# ret +="<tr><th>%d</th><td>%0.2f</td>" % (ch, count)
chans += "<th>%s</th>" % ch
totals += "<td align=right>%d</td>" % count
vals += "<td align=right>%0.1f</td>" % weight
chans += "</tr>"
totals += "</tr>"
vals += "</tr>"
ret += chans + totals + vals
ret += "</table>"
else:
ret += "<h2>No channel data collected yet</h2>"
return ret
def showInteractions(self):
ret = ""
numHidden = 0
numVisible = 0
if self._agent:
now = time.time()
ret += "<h2>Interactions per endpoint</h2>"
ret += "<p><b>Encounters</b> is how many different times this AP has been seen, then not seen, then seen again. Interactions should be the sum of assoc and deauth attacks. All are per session stats. <b>Age</b> is seconds since AP was last seen by the plugin.</p>"
ret += "<table border=1 spacing=4 cellspacing=4 cellpadding=4>"
ret += "<tr><th>Hostname</th><th>MAC</th><th>Channel</th><th>Age</th><th>RSSI</th><th>Encounters</th><th>Associates</th><th>Deauths</th><th>Handshakes</th><th>Interactions</th></tr>"
for (id, ap) in sorted(self._known_aps.items(), key=lambda x: x[1]['AT_lastseen'], reverse=True):
lmac = ap['mac'].lower()
if ap['hostname'] == "<hidden>" and not self.options['show_hidden']:
logging.debug("Skipping %s '%s'" % (ap['hostname'], lmac))
numHidden += 1
continue # skip hidden APs
elif ap['hostname'] == '' and not self.options['show_hidden']:
logging.debug("Skipping no-name %s '%s'" % (ap['hostname'], lmac))
numHidden += 1
continue # skip hidden APs
elif ap['hostname'] is None and not self.options['show_hidden']:
logging.debug("Skipping None %s '%s'" % (ap['hostname'], lmac))
numHidden += 1
continue # skip hidden APs
else:
numVisible += 1
logging.debug("Not skipping '%s'" % ap['hostname'])
if ap['AT_visible']:
ret += "<tr><td>%s</td>" % html.escape(ap['hostname'])
else:
ret += "<tr><td><i>%s</i></td>" % html.escape(
ap['hostname']) # italicise hosts not currently visible
ret += "<td>%s</td><td>%s</td>" % (ap['mac'], ap['channel'])
ret += "<td>%d</td>" % int(now - ap['AT_lastseen']) # time since last interaction
ret += "<td>%s</td>" % ap['rssi']
for t in ['seen', 'assoc', 'deauth', 'handshake']:
tag = 'AT_' + t
if tag in ap:
ret += "<td>%s</td>" % ap[tag]
else:
ret += "<td></td>"
if lmac in self._agent._history:
ret += "<td>%s</td>" % self._agent._history[lmac]
else:
ret += "<td>no attacks yet</td>"
ret += "</tr>\n"
# for (mac, count) in sorted(self._agent._history.items(), key=lambda x:x[1], reverse = True):
# ret += "<tr><td>%s</td><td>%s</td><td></td><td>%s</td></tr>" % (mac, mac, count)
ret += "</table>\n"
if numHidden:
ret += "%s visible, %s hidden networks<p>" % (numVisible, numHidden)
return ret
def update_parameter(self, cfg, parameter, vtype, val, ret):
changed = False
if parameter in cfg:
old_val = cfg[parameter]
if val == old_val:
pass
elif vtype == "int":
cfg[parameter] = int(val)
changed = True
elif vtype == "float":
cfg[parameter] = float(val)
ret += "Updated float %s: %s -> %s<br>\n" % (parameter, old_val, val)
changed = True
elif vtype == "bool":
cfg[parameter] = bool(val == "True")
ret += "Updated boolean %s: %s -> %s<br>\n" % (parameter, old_val, val)
changed = True
elif vtype == "str":
cfg[parameter] = val
ret += "Updated string %s: %s -> %s<br>\n" % (parameter, old_val, val)
changed = True
else:
ret += "No update %s (%s): %s -> %s<br>\n" % (parameter, type, old_val, val)
return changed
# called when http://<host>:<port>/plugins/<plugin>/ is called
# must return a html page
# IMPORTANT: If you use "POST"s, add a csrf-token (via csrf_token() and render_template_string)
def on_webhook(self, path, request):
# display personality parameters for editing
# show statistic per channel, etc
if not self._agent:
ret = "<html><head><title>AUTO Tune not ready</title></head><body><h1>AUTO Tune not ready</h1></body></html>"
return render_template_string(ret)
try:
if request.method == "GET":
if path == "/" or not path:
logging.debug("webhook called")
ret = '<html><head><title>AUTO Tune</title><meta name="csrf_token" content="{{ csrf_token() }}"></head>'
ret += '<style>'
ret += '.preset-section { background-color: #f0f0f0; padding: 10px; margin: 10px 0; border-radius: 5px; }'
ret += '.preset-table { width: 100%; }'
ret += '.preset-table td { padding: 5px; }'
ret += '.preset-buttons { margin-top: 10px; }'
ret += '.preset-buttons input { margin-right: 10px; padding: 5px 10px; }'
ret += '.success { color: green; font-weight: bold; padding: 10px; background-color: #d4edda; border: 1px solid #c3e6cb; border-radius: 3px; }'
ret += '.error { color: red; font-weight: bold; padding: 10px; background-color: #f8d7da; border: 1px solid #f5c6cb; border-radius: 3px; }'
ret += '</style>'
ret += '<script>'
ret += 'function validatePresetName() {'
ret += ' var presetName = document.getElementsByName("preset_name")[0].value.trim();'
ret += ' if (presetName === "") {'
ret += ' alert("Please enter a preset name");'
ret += ' return false;'
ret += ' }'
ret += ' return true;'
ret += '}'
ret += 'function validatePresetSelection() {'
ret += ' var selectedPreset = document.getElementsByName("selected_preset")[0].value;'
ret += ' if (selectedPreset === "") {'
ret += ' alert("Please select a preset");'
ret += ' return false;'
ret += ' }'
ret += ' return true;'
ret += '}'
ret += '</script>'
ret += "<body><h1>AUTO Tune</h1><p>"
ret += self.showEditForm(request)
ret += self.showHistogram()
ret += self.showChistos()
if 'show_interactions' in self.options and self.options['show_interactions']:
ret += self.showInteractions()
ret += "</body></html>"
return render_template_string(ret)
# other paths here
elif request.method == "POST":
ret = '<html><head><title>AUTO Tune</title><meta name="csrf_token" content="{{ csrf_token() }}"></head>'
if path == "update": # update settings that changed, save to json file
ret = '<html><head><title>AUTO Tune Update!</title><meta name="csrf_token" content="{{ csrf_token() }}"></head>'
ret += "<body><h1>AUTO Tune Update</h1>"
# Handle preset operations
if 'save_preset' in request.values and 'preset_name' in request.values:
preset_name = request.values['preset_name'].strip()
if preset_name:
try:
self._save_preset(preset_name)
ret += "<div class='success'>Preset '%s' saved successfully!</div>" % preset_name
except Exception as e:
ret += "<div class='error'>Error saving preset: %s</div>" % str(e)
else:
ret += "<div class='error'>Please enter a preset name</div>"
elif 'load_preset' in request.values and 'selected_preset' in request.values:
preset_name = request.values['selected_preset']
if preset_name:
success, message = self._load_preset(preset_name)
if success:
ret += "<div class='success'>%s</div>" % message
save_config(self._agent._config, "/etc/pwnagotchi/config.toml")
else:
ret += "<div class='error'>%s</div>" % message
else:
ret += "<div class='error'>Please select a preset to load</div>"
elif 'delete_preset' in request.values and 'selected_preset' in request.values:
preset_name = request.values['selected_preset']
if preset_name:
if self._delete_preset(preset_name):
ret += "<div class='success'>Preset '%s' deleted successfully!</div>" % preset_name
else:
ret += "<div class='error'>Error deleting preset '%s'</div>" % preset_name
else:
ret += "<div class='error'>Please select a preset to delete</div>"
ret += "<h2>Processing changes</h2><ul>"
changed = False
for (key, val) in request.values.items():
if key != "":
# ret += "%s -> %s<br>\n" % (key,val)
try:
if key.startswith('newval,'):
(tag, value, parameter, vtype) = key.split(",", 4)
if value == val:
logging.debug("Skip unchanged value")
continue
if parameter in self._agent._config['personality']:
logging.debug("Personality update")
chg = self.update_parameter(self._agent._config['personality'], parameter,
vtype, val, ret)
elif parameter in self.options:
logging.debug("plugin settings update")
chg = self.update_parameter(self.options, parameter, vtype, val, ret)
else:
ret += "<li><b>Skipping unknown %s</b> -> %s\n" % (key, val)
if chg:
ret += "<li>%s: %s -> %s\n" % (parameter, value, val)
changed = changed or chg
else:
pass # ret += "No update %s -> %s<br>\n" % (key, val)
except Exception as e:
ret += "</code><h2>Error</h2><pre>%s</pre><p><code>" % repr(e)
logging.exception(e)
ret += "</ul>"
if changed:
save_config(self._agent._config, "/etc/pwnagotchi/config.toml")
ret += self.showEditForm(request)
ret += self.showHistogram()
ret += self.showChistos()
ret += "</body></html>"
else:
ret += "<body><h1>Unknown request</h1>"
ret += '<img src="/ui?%s">' % int(time.time())
ret += "<h2>Path</h2><code>%s</code><p>" % repr(path)
ret += "<h2>Request</h2><code>%s</code><p>" % repr(request.values)
ret += "</body></html>"
return render_template_string(ret)
except Exception as e:
ret = "<html><head><title>AUTO Tune error</title></head>"
ret += "<body><h1>%s</h1></body></html>" % repr(e)
logging.exception("AUTO Tune error: %s" % repr(e))
return render_template_string(ret)
# called when the plugin is loaded
def on_loaded(self):
try:
defaults = {'show_hidden': False,
'reset_history': True,
'extra_channels': 15,
'show_interactions': False,
}
for d in defaults:
if d not in self.options:
self.options[d] = defaults[d]
except Exception as e:
logging.exception(e)
def on_ready(self, agent):
self._agent = agent
if self.options['reset_history']:
self._agent._history = {} # clear "max_interactions" data
self._agent.run("wifi.recon clear")
self._agent.run("wifi.clear")
# called when the agent refreshed its access points list
def on_wifi_update(self, agent, access_points):
# check aps and update active channels
try:
active_channels = []
self._histogram["loops"] = self._histogram["loops"] + 1
for ap in access_points:
self.markAPSeen(ap, 'wifi_update')
ch = ap['channel']
logging.debug("%s %d" % (ap['hostname'], ch))
if ch not in active_channels:
active_channels.append(ch)
if ch in self._unscanned_channels:
self._unscanned_channels.remove(ch)
self._histogram[ch] = 1 if ch not in self._histogram else self._histogram[ch] + 1
self._active_channels = active_channels
logging.info("Histo: %s" % repr(self._histogram))
except Exception as e:
logging.exception(e)
# called when the agent refreshed an unfiltered access point list
# this list contains all access points that were detected BEFORE filtering
# def on_unfiltered_ap_list(self, agent, access_points):
# pass
# called when an epoch is over (where an epoch is a single loop of the main algorithm)
def on_epoch(self, agent, epoch, epoch_data):
# pick set of channels for next time
try:
next_channels = self._active_channels.copy()
n = 3 if "extra_channels" not in self.options else self.options["extra_channels"]
if len(self._unscanned_channels) == 0:
if "restrict_channels" in self.options:
logging.info("Repopulating from restricted list")
self._unscanned_channels = self.options["restrict_channels"].copy()
elif hasattr(agent, "_allowed_channels"):
logging.info("Repopulating from allowed list: %s" % agent._allowed_channels)
self._unscanned_channels = agent._allowed_channels.copy()
elif hasattr(agent, "_supported_channels"):
logging.info("Repopulating from supported list")
self._unscanned_channels = agent._supported_channels.copy()
else:
logging.info("Repopulating unscanned list")
self._unscanned_channels = pwnagotchi.utils.iface_channels(agent._config['main']['iface'])
for i in range(n):
if len(self._unscanned_channels):
ch = random.choice(list(self._unscanned_channels))
self._unscanned_channels.remove(ch)
next_channels.append(ch)
# update live config
agent._config['personality']['channels'] = next_channels
logging.info("Active: %s, Next scan: %s, yet unscanned: %d %s" % (
self._active_channels, next_channels, len(self._unscanned_channels), self._unscanned_channels))
except Exception as e:
logging.exception(e)
def markAPSeen(self, access_point, context=None):
try:
apname = self.normalize(access_point['hostname'])
apmac = self.normalize(access_point['mac'])
apID = apname + '-' + apmac
channel = access_point['channel']
contextlabel = " on " + context if context else ""
tag = 'AT_' + context if context else 'AT_seen'
if apID not in self._known_aps:
# first time seen this AP
self._known_aps[apID] = access_point.copy()
self._known_aps[apID]['AT_seen'] = 1
self._known_aps[apID][tag] = 1
self._known_aps[apID]['AT_visible'] = True
self.incrementChisto('Unique APs', channel)
self.incrementChisto('Current APs', channel)
logging.info("New AP%s: %s" % (contextlabel, apID))
else:
# seen before, merge info
for p in access_point:
self._known_aps[apID][p] = access_point[p]
# if wasn't visible, increment current count
if not self._known_aps[apID]['AT_visible']:
self._known_aps[apID]['AT_visible'] = True
self._known_aps[apID]['AT_seen'] += 1
self.incrementChisto('Current APs', channel)
# increment context count in the AP data
self._known_aps[apID][tag] = 1 if tag not in self._known_aps[apID] else self._known_aps[apID][tag] + 1
if not context:
logging.info("Returning AP: %s" % apID)
self._known_aps[apID]['AT_lastseen'] = time.time()
return True
except Exception as e:
logging.exception(e)
return False
# called when the agent is sending an association frame
def on_association(self, agent, access_point):
try:
self.incrementChisto('Associations', access_point['channel'])
self.markAPSeen(access_point, "assoc")
except Exception as e:
logging.exception(e)
# called when the agent is deauthenticating a client station from an AP
def on_deauthentication(self, agent, access_point, client_station):
try:
self.incrementChisto('Deauths', access_point['channel'])
self.markAPSeen(access_point, "deauth")
except Exception as e:
logging.exception(e)
# callend when the agent is tuning on a specific channel
def on_channel_hop(self, agent, channel):
pass
# called when a new handshake is captured, access_point and client_station are json objects
# if the agent could match the BSSIDs to the current list, otherwise they are just the strings of the BSSIDs
def on_handshake(self, agent, filename, access_point, client_station):
try:
self.incrementChisto('Handshakes', access_point['channel'])
self.markAPSeen(access_point, "handshake")
except Exception as e:
logging.exception(e)
def on_bcap_wifi_ap_new(self, agent, event):
try:
ap = event['data']
apname = self.normalize(ap['hostname'])
apmac = self.normalize(ap['mac'])
apID = apname + '-' + apmac
channel = ap['channel']
self.markAPSeen(ap)
except Exception as e:
logging.exception(repr(e))
def on_bcap_wifi_ap_lost(self, agent, event):
try:
ap = event['data']
apname = self.normalize(ap['hostname'])
apmac = self.normalize(ap['mac'])
apID = apname + '-' + apmac
channel = ap['channel']
if apID not in self._known_aps:
self.incrementChisto('Missed joins', channel)
logging.warn("Unknown AP '%s' seen leaving" % apID)
else:
if not self._known_aps[apID]['AT_visible']:
self.incrementChisto('Missed rejoins', channel)
logging.warn("AP '%s' already gone", apID)
else:
self._known_aps[apID]['AT_visible'] = False
self.incrementChisto('Current APs', channel, -1)
except Exception as e:
logging.exception(repr(e))
def on_bcap_wifi_client_new(self, agent, event):
try:
ap = event['data']['AP']
cl = event['data']['Client']
apmac = self.normalize(ap['mac'])
clmac = self.normalize(cl['mac'])
clID = clmac + '-' + apmac
channel = ap['channel']
except Exception as e:
logging.exception(repr(e))
def on_bcap_wifi_client_lost(self, agent, event):
try:
ap = event['data']['AP']
cl = event['data']['Client']
except Exception as e:
logging.exception(repr(e))
+71
View File
@@ -0,0 +1,71 @@
"""Pwnagotchi channel selection strategy - core to agent behavior.
This module replaces the removed RL-based AI with intelligent heuristics:
- Track active channels (have APs) and prioritize them
- Explore unscanned channels randomly
- Maintain statistics per channel
- Guide agent behavior through channel selection
Originally extracted from auto-tune plugin to make it core functionality.
"""
import logging
from .channels import ChannelStrategy
from .statistics import ChannelStatistics
class Strategy:
"""
Main strategy facade.
Integrates channel selection with agent lifecycle and event handling.
"""
def __init__(self, config, logger=None):
self.logger = logger or logging.getLogger(__name__)
self.channels = ChannelStrategy(config, logger=self.logger)
self.config = config
def start(self):
"""Initialize strategy when agent starts."""
self.logger.info("Channel selection strategy initialized")
def select_next_channels(self, agent, access_points):
"""
Select channels for next epoch.
Called after WiFi scan completes. Returns list of channels to scan.
"""
return self.channels.select_channels(agent, access_points)
# Event handlers - connect to agent event system
def on_wifi_update(self, agent, access_points):
"""WiFi list updated."""
self.channels.on_wifi_update(agent, access_points)
def on_association(self, agent, access_point):
"""Association sent."""
self.channels.on_association(agent, access_point)
def on_deauthentication(self, agent, access_point, client_station):
"""Deauthentication sent."""
self.channels.on_deauthentication(agent, access_point, client_station)
def on_handshake(self, agent, filename, access_point, client_station):
"""Handshake captured."""
self.channels.on_handshake(agent, filename, access_point, client_station)
def on_bcap_wifi_ap_new(self, agent, event):
"""Bettercap: new AP detected."""
self.channels.on_bcap_wifi_ap_new(agent, event)
def on_bcap_wifi_ap_lost(self, agent, event):
"""Bettercap: AP lost."""
self.channels.on_bcap_wifi_ap_lost(agent, event)
def get_stats(self):
"""Get current strategy statistics."""
return self.channels.get_stats()
__all__ = ["Strategy", "ChannelStrategy", "ChannelStatistics"]
+137
View File
@@ -0,0 +1,137 @@
"""Channel selection strategy - core to agent behavior."""
import random
import logging
import pwnagotchi.utils
from .statistics import ChannelStatistics
class ChannelStrategy:
"""
Intelligent channel selection strategy.
Strategy:
- Always scan channels with active APs (high probability of captures)
- Add random unscanned channels each epoch (explore new areas)
- Track statistics per channel to guide future decisions
"""
def __init__(self, config, logger=None):
"""
Initialize channel strategy.
Args:
config: pwnagotchi configuration dict
logger: optional logger instance
"""
self.logger = logger or logging.getLogger(__name__)
self.config = config
self.stats = ChannelStatistics()
# Configuration options
self.extra_channels = config.get("main", {}).get("extra_channels", 15)
self.restrict_channels = config.get("main", {}).get("restrict_channels", None)
self.reset_history = config.get("main", {}).get("reset_history", True)
def select_channels(self, agent, access_points):
"""
Select next set of channels to scan based on current APs and unscanned channels.
Returns list of channels to scan in next epoch.
"""
try:
# Update active channels from current scan
self.stats.update_active_channels(access_points)
# Build next channel list: active + extra unscanned
next_channels = self.stats.active_channels.copy()
# Add random unscanned channels for exploration
n_extra = self.extra_channels
if len(self.stats.unscanned_channels) == 0:
self._repopulate_unscanned_channels(agent)
for _ in range(n_extra):
if len(self.stats.unscanned_channels):
ch = random.choice(list(self.stats.unscanned_channels))
self.stats.unscanned_channels.remove(ch)
next_channels.append(ch)
# Update agent config
if hasattr(agent, "_config"):
agent._config["personality"]["channels"] = next_channels
self.logger.info(
f"Active: {self.stats.active_channels}, "
f"Next: {next_channels}, "
f"Unscanned: {len(self.stats.unscanned_channels)}"
)
return next_channels
except Exception as e:
self.logger.error(f"Error selecting channels: {e}")
return self.stats.active_channels
def _repopulate_unscanned_channels(self, agent):
"""Repopulate unscanned channel list from config or agent."""
try:
# Try restrict_channels first
if self.restrict_channels:
self.logger.info("Repopulating from restrict_channels")
self.stats.unscanned_channels = self.restrict_channels.copy()
# Try agent's allowed channels
elif hasattr(agent, "_allowed_channels"):
self.logger.info(f"Repopulating from allowed: {agent._allowed_channels}")
self.stats.unscanned_channels = agent._allowed_channels.copy()
# Try agent's supported channels
elif hasattr(agent, "_supported_channels"):
self.logger.info("Repopulating from supported")
self.stats.unscanned_channels = agent._supported_channels.copy()
# Fall back to all channels for interface
else:
self.logger.info("Repopulating from interface channels")
iface = self.config.get("main", {}).get("iface", "wlan0")
self.stats.unscanned_channels = pwnagotchi.utils.iface_channels(iface)
except Exception as e:
self.logger.warning(f"Error repopulating unscanned channels: {e}")
def on_wifi_update(self, agent, access_points):
"""Called when agent updates its AP list."""
self.stats.update_active_channels(access_points)
def on_association(self, agent, access_point):
"""Called when sending association frame."""
self.stats.record_interaction("Associations", access_point.get("channel", -1))
self.stats.mark_ap_seen(access_point, "assoc")
def on_deauthentication(self, agent, access_point, client_station):
"""Called when sending deauth."""
self.stats.record_interaction("Deauths", access_point.get("channel", -1))
self.stats.mark_ap_seen(access_point, "deauth")
def on_handshake(self, agent, filename, access_point, client_station):
"""Called when handshake is captured."""
self.stats.record_interaction("Handshakes", access_point.get("channel", -1))
self.stats.mark_ap_seen(access_point, "handshake")
def on_bcap_wifi_ap_new(self, agent, event):
"""Called when bettercap detects new AP."""
try:
ap = event.get("data", {})
self.stats.mark_ap_seen(ap)
except Exception as e:
self.logger.debug(f"Error on_bcap_wifi_ap_new: {e}")
def on_bcap_wifi_ap_lost(self, agent, event):
"""Called when bettercap loses AP."""
try:
ap = event.get("data", {})
self.stats.record_ap_lost(ap)
except Exception as e:
self.logger.debug(f"Error on_bcap_wifi_ap_lost: {e}")
def get_stats(self):
"""Get current strategy statistics."""
return self.stats.get_stats()
+120
View File
@@ -0,0 +1,120 @@
"""Channel and interaction statistics tracking."""
import time
import logging
class ChannelStatistics:
"""Tracks AP counts, interactions, and handshakes per channel."""
def __init__(self):
self.histogram = {"loops": 0} # AP count per channel per epoch
self.chistos = {"_all_actions": {-1: 0}} # Interaction stats per channel
self.known_aps = {} # All APs seen (key: normalized_name-mac)
self.active_channels = [] # Channels with APs in current scan
self.unscanned_channels = [] # Channels to explore next
def mark_ap_seen(self, access_point, context=None):
"""Track an AP sighting and update stats."""
try:
apname = self._normalize(access_point.get("hostname", ""))
apmac = self._normalize(access_point.get("mac", ""))
apid = f"{apname}-{apmac}"
channel = access_point.get("channel", -1)
tag = f"AT_{context}" if context else "AT_seen"
if apid not in self.known_aps:
# First time seeing this AP
self.known_aps[apid] = access_point.copy()
self.known_aps[apid]["AT_seen"] = 1
self.known_aps[apid][tag] = 1
self.known_aps[apid]["AT_visible"] = True
self.increment_chisto("Unique APs", channel)
self.increment_chisto("Current APs", channel)
logging.info(f"New AP: {apid} on channel {channel}")
else:
# Update existing AP
for key in access_point:
self.known_aps[apid][key] = access_point[key]
if not self.known_aps[apid]["AT_visible"]:
self.known_aps[apid]["AT_visible"] = True
self.known_aps[apid]["AT_seen"] += 1
self.increment_chisto("Current APs", channel)
tag_count = self.known_aps[apid].get(tag, 0)
self.known_aps[apid][tag] = tag_count + 1
self.known_aps[apid]["AT_lastseen"] = time.time()
return True
except Exception as e:
logging.debug(f"Error marking AP seen: {e}")
return False
def increment_chisto(self, stat_name, channel, count=1):
"""Increment a statistic for a channel."""
if stat_name not in self.chistos:
self.chistos[stat_name] = {}
if channel not in self.chistos[stat_name]:
self.chistos[stat_name][channel] = 0
self.chistos[stat_name][channel] += count
def update_active_channels(self, access_points):
"""Update list of channels with active APs."""
active_channels = []
self.histogram["loops"] = self.histogram.get("loops", 0) + 1
for ap in access_points:
self.mark_ap_seen(ap, "wifi_update")
channel = ap.get("channel")
if channel and channel not in active_channels:
active_channels.append(channel)
if channel in self.unscanned_channels:
self.unscanned_channels.remove(channel)
self.histogram[channel] = self.histogram.get(channel, 0) + 1
self.active_channels = active_channels
logging.debug(f"Active channels: {active_channels}, Histogram: {self.histogram}")
def record_interaction(self, interaction_type, channel):
"""Record an interaction (association, deauth, handshake) on a channel."""
self.increment_chisto(interaction_type, channel)
def record_ap_lost(self, access_point):
"""Record an AP going offline."""
try:
apname = self._normalize(access_point.get("hostname", ""))
apmac = self._normalize(access_point.get("mac", ""))
apid = f"{apname}-{apmac}"
channel = access_point.get("channel", -1)
if apid in self.known_aps:
if self.known_aps[apid]["AT_visible"]:
self.known_aps[apid]["AT_visible"] = False
self.increment_chisto("Current APs", channel, -1)
else:
self.increment_chisto("Missed joins", channel)
except Exception as e:
logging.debug(f"Error recording AP lost: {e}")
@staticmethod
def _normalize(text):
"""Normalize text for comparison."""
if not text:
return ""
return text.lower().replace(" ", "_")
def get_stats(self):
"""Get current statistics snapshot."""
return {
"active_channels": self.active_channels.copy(),
"unscanned_count": len(self.unscanned_channels),
"known_aps": len(self.known_aps),
"histogram": self.histogram.copy(),
"chistos": self.chistos.copy(),
}
+1 -3
View File
@@ -217,9 +217,7 @@ class Handler:
if name is None:
# Determine which plugins are from the default folder
default_plugins = set()
default_path = os.path.join(
os.path.dirname(os.path.realpath(plugins.__file__)), "default"
)
default_path = os.path.join(os.path.dirname(os.path.realpath(plugins.__file__)), "default")
for plugin_name, plugin_path in plugins.database.items():
if plugin_path.startswith(default_path):
default_plugins.add(plugin_name)