Add Auto mode (network auto-detection) and Start with Windows

- Auto mode (Auto button or --auto): picks the profile matching the
  connected network instead of forcing one profile's SSID. Wi-Fi is
  matched by SSID; Ethernet profiles by whether their portal is reachable.
  Unknown networks are left alone. config.json is reloaded each check.
- Watches for network changes every 5 seconds while waiting, so switching
  between Ethernet and Wi-Fi is handled right away.
- Auto mode backs off instead of exiting after repeated errors.
- Start with Windows checkbox (HKCU Run key, --auto --background).
- --background flag to start without the log window.
- Resolve the program directory for both script and PyInstaller builds,
  so startup launches find config.json.
- Ignore Hyper-V/WSL vEthernet adapters when detecting Ethernet.
- Fix crash when pressing Enter in the profile list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Stone_Red
2026-10-07 19:30:29 +02:00
co-authored by Claude Opus 5.5
parent bbbb76d577
commit 53bf01733e
2 changed files with 292 additions and 57 deletions
+9
View File
@@ -26,6 +26,15 @@ HotspotAutoLogin is a script that designed to automate the login process for Wi-
- Any important events or actions taken by the script are logged both in a log file (log.txt) and in the log window that can be accessed via the system tray icon.
- The script reads its configuration from a config.json file. This file contains the necessary information, such as your login credentials, the URL of the authentication portal, the SSID (network name) to which you want to connect, and the frequency of network checks in seconds.
## `Auto Mode & Start with Windows`
- **Auto mode:** Click the **Auto** button in the profile selection window (or run with `--auto`). Instead of using a single profile, the program looks at the network you are actually connected to and picks the matching profile automatically:
- **Wi-Fi:** the profile whose `ssid` matches the connected Wi-Fi (case-insensitive).
- **Ethernet:** profiles without an `ssid` are Ethernet profiles. The one whose login `url` is reachable on the current network is used.
- If the connected network doesn't match any profile, nothing is sent and the program just waits for a known network. It never forces a connection to another network in this mode.
- `config.json` is re-read on every check, so edits apply without restarting.
- **Start with Windows:** Tick **Start with Windows (Auto)** in the profile selection window. The program will then start at sign-in in Auto mode, in the background (tray icon only). Untick it to disable. This uses the current user's `Run` registry key, so no admin rights are needed.
- Command line flags: `--auto` (skip profile selection, use Auto mode) and `--background` (don't open the log window at startup). E.g. `run.bat --auto`.
## `Usage`
- Edit the config.json file with your specific information, including your login credentials, the portal URL, the SSID of the network you want to connect to, and the check interval in seconds. [Click to learn how to Configure the config.json](#how-to-configure-the-configjson)
- When you run the script, a system tray icon will appear. Right-click on the icon to access options like showing the log or exiting the application.
+276 -50
View File
@@ -1,5 +1,8 @@
import json
import os
import sys
import argparse
import winreg
import requests
import time
import subprocess
@@ -21,17 +24,66 @@ dns.resolver.default_resolver = dns.resolver.Resolver()
from urllib3.exceptions import InsecureRequestWarning
requests.packages.urllib3.disable_warnings(category=InsecureRequestWarning)
################################# REMOVE THIS BEFORE USING PYINSTALLER #################################
# Change the current working directory to the script's directory
script_dir = os.path.dirname(os.path.abspath(__file__))
# Change the current working directory to the program's directory.
# Works for both the script and a PyInstaller build, and is required when launched at Windows startup,
# where the working directory is not the program folder (so config.json and icon.ico would not be found).
if getattr(sys, "frozen", False):
script_dir = os.path.dirname(sys.executable)
else:
script_dir = os.path.dirname(os.path.abspath(__file__))
os.chdir(script_dir)
################################# REMOVE THIS BEFORE USING PYINSTALLER #################################
# Program Information
program_name = "HotspotAutoLogin"
version = "v1.90"
github_link = "https://github.com/denizsafak/HotspotAutoLogin"
# Command line arguments
# --auto Skip profile selection and auto-detect which profile to use from the connected network
# --background Don't open the log window at startup (used when starting with Windows)
arg_parser = argparse.ArgumentParser(add_help=False)
arg_parser.add_argument("--auto", action="store_true")
arg_parser.add_argument("--background", action="store_true")
args, _ = arg_parser.parse_known_args()
auto_mode = args.auto
# Start with Windows (HKCU Run key, no admin rights needed)
AUTOSTART_KEY = r"Software\Microsoft\Windows\CurrentVersion\Run"
def get_autostart_command():
if getattr(sys, "frozen", False):
return f'"{sys.executable}" --auto --background'
# Use pythonw.exe so no console window is shown at startup
pythonw = os.path.join(os.path.dirname(sys.executable), "pythonw.exe")
if not os.path.exists(pythonw):
pythonw = sys.executable
return f'"{pythonw}" "{os.path.abspath(__file__)}" --auto --background'
def is_autostart_enabled():
try:
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, AUTOSTART_KEY) as key:
winreg.QueryValueEx(key, program_name)
return True
except OSError:
return False
def set_autostart(enabled):
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, AUTOSTART_KEY, 0, winreg.KEY_SET_VALUE) as key:
if enabled:
winreg.SetValueEx(key, program_name, 0, winreg.REG_SZ, get_autostart_command())
else:
try:
winreg.DeleteValue(key, program_name)
except FileNotFoundError:
pass
# If autostart is enabled, keep the registered path up to date in case the program folder was moved
if is_autostart_enabled():
try:
set_autostart(True)
except OSError:
pass
# Function to center a window on the screen
def center_window(window, width, height):
screen_width = window.winfo_screenwidth()
@@ -40,7 +92,7 @@ def center_window(window, width, height):
y = (screen_height - height) // 2
window.geometry(f"{width}x{height}+{x}+{y}")
def run_profile():
def run_profile(event=None):
global selected_profile
refresh_profile_details()
selected_index = listbox.curselection()
@@ -49,6 +101,24 @@ def run_profile():
selected_profile = profiles[selected_index]
root.destroy()
# Run in auto mode: the profile is picked automatically based on the connected network
def run_auto():
global auto_mode, selected_profile
refresh_profile_details()
if not profiles:
messagebox.showerror("No Profiles", "Add at least one profile before using Auto mode.")
return
auto_mode = True
selected_profile = None
root.destroy()
def toggle_autostart():
try:
set_autostart(autostart_var.get())
except OSError as e:
autostart_var.set(is_autostart_enabled())
messagebox.showerror("Start with Windows", f"Could not change the startup setting: {e}")
# Define a function to update the profile details
def update_profile_details(event):
global selected_profile
@@ -107,6 +177,8 @@ with open("config.json", "r") as f:
profiles = config.get("profiles", [])
# Create the main window
root = tk.Tk()
if auto_mode:
root.withdraw() # Started with --auto, the profile selection window is not needed
root.title(f"Profile Selection - {program_name} {version}")
root.iconbitmap("icon.ico")
# Set a maximum size for the window
@@ -552,6 +624,16 @@ github_button = tk.Button(
)
github_button.pack(side=tk.RIGHT, padx=(0, 5))
# Create the "Start with Windows" checkbox (starts in Auto mode in the background)
autostart_var = tk.BooleanVar(value=is_autostart_enabled())
autostart_check = tk.Checkbutton(
profile_name_frame,
text="Start with Windows (Auto)",
variable=autostart_var,
command=toggle_autostart,
)
autostart_check.pack(side=tk.RIGHT, padx=(0, 5))
# Create the "Config" button
config_button = tk.Button(
details_frame,
@@ -568,6 +650,22 @@ config_button = tk.Button(
)
config_button.pack(side=tk.LEFT, fill="x", padx=(0, 2), pady=(10, 0), expand=False)
# Create the "Auto" button (detects the profile from the connected network)
auto_button = tk.Button(
details_frame,
text="Auto",
command=run_auto,
padx=30,
pady=10,
fg="white",
bg="lightskyblue4",
activebackground="lightsteelblue4",
activeforeground="white",
relief=tk.RAISED,
borderwidth=2,
)
auto_button.pack(side=tk.LEFT, fill="x", padx=2, pady=(10, 0), expand=False)
# Create the "Run" button
run_button = tk.Button(
details_frame,
@@ -595,24 +693,34 @@ if profiles:
listbox.select_set(0) # Highlight the first item in the list
update_profile_details(None) # Update the profile details
root.mainloop()
if auto_mode:
root.destroy()
else:
root.mainloop()
# Load the given profile's data into the variables used by the network checker
def apply_profile(profile):
global selected_profile, payload, url, internet_check_url, ssid, check_every_second, dialog_geometry, headers, expected_ssid, expected_ssid_lower
selected_profile = profile
payload = profile.get('payload')
url = profile.get('url', "")
internet_check_url = profile.get('internet_check_url', "")
ssid = profile.get('ssid', "") or "Ethernet"
check_every_second = profile.get('check_every_second', 600)
dialog_geometry = profile.get('dialog_geometry', {"width": 1024, "height": 500})
headers = profile.get('headers', "")
expected_ssid = ssid
expected_ssid_lower = expected_ssid.lower()
# Access the selected profile's data after the window closes
if selected_profile:
payload = selected_profile.get('payload')
url = selected_profile.get('url', "")
internet_check_url = selected_profile.get('internet_check_url', "")
ssid = selected_profile.get('ssid', "")
check_every_second = selected_profile.get('check_every_second', "")
dialog_geometry = selected_profile.get('dialog_geometry', "")
headers = selected_profile.get('headers', "")
if ssid:
expected_ssid = ssid
expected_ssid_lower = expected_ssid.lower()
else:
ssid = "Ethernet"
expected_ssid = ssid
expected_ssid_lower = expected_ssid.lower()
if auto_mode:
# The profile is chosen later, based on the connected network
selected_profile = None
expected_ssid = expected_ssid_lower = None
check_every_second = 600
dialog_geometry = profiles[0].get('dialog_geometry', {"width": 1024, "height": 500}) if profiles else {"width": 1024, "height": 500}
elif selected_profile:
apply_profile(selected_profile)
# Function to send the request
def send_request():
@@ -641,35 +749,96 @@ def is_internet_available():
except Exception as e:
return False
# Function to get the currently connected SSID using system commands (for Windows)
def get_connected_network():
try:
# Function to get all currently connected networks (for Windows)
# Returns (ethernet_connected, wifi_ssid, wifi_error)
def get_connected_networks():
startupinfo = subprocess.STARTUPINFO()
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
# Check if connected to Ethernet
ethernet_output = subprocess.check_output(["netsh", "interface", "show", "interface"], startupinfo=startupinfo).decode("utf-8")
ethernet_lines = ethernet_output.split("\n")
for line in ethernet_lines:
if "Connected" in line:
if "Ethernet" in line:
return "Ethernet"
# If not connected to Ethernet, check Wi-Fi SSID
ethernet_connected = False
try:
wifi_output = subprocess.check_output(["netsh", "wlan", "show", "interfaces"], startupinfo=startupinfo).decode("utf-8")
wifi_lines = wifi_output.split("\n")
wifi_ssid = None
for line in wifi_lines:
if "SSID" in line:
wifi_ssid = line.strip().split(": ")[1]
ethernet_output = subprocess.check_output(["netsh", "interface", "show", "interface"], startupinfo=startupinfo).decode("utf-8", errors="replace")
for line in ethernet_output.splitlines():
# Columns: Admin State, State, Type, Interface Name
parts = line.split(None, 3)
if len(parts) == 4 and parts[1] == "Connected" and "Ethernet" in parts[3] and not parts[3].startswith("vEthernet"):
ethernet_connected = True # vEthernet adapters (Hyper-V / WSL) are virtual and always "Connected"
break
if wifi_ssid is not None:
return wifi_ssid
else:
return False
except subprocess.CalledProcessError as e:
return "Error: " + str(e)
except subprocess.CalledProcessError:
pass
wifi_ssid = None
wifi_error = None
try:
wifi_output = subprocess.check_output(["netsh", "wlan", "show", "interfaces"], startupinfo=startupinfo).decode("utf-8", errors="replace")
match = re.search(r"^\s*SSID\s*:\s*(.+)$", wifi_output, re.MULTILINE)
if match:
wifi_ssid = match.group(1).strip()
except subprocess.CalledProcessError as e:
wifi_error = str(e)
return ethernet_connected, wifi_ssid, wifi_error
# Function to get the currently connected SSID using system commands (for Windows)
def get_connected_network():
ethernet_connected, wifi_ssid, wifi_error = get_connected_networks()
if ethernet_connected:
return "Ethernet"
if wifi_error:
return "Error: " + wifi_error
return wifi_ssid or False
# Check whether the login portal's host can be reached from the current network
def is_portal_reachable(portal_url, timeout=3):
parsed = urlparse(portal_url)
if not parsed.hostname:
return False
port = parsed.port or (443 if parsed.scheme == "https" else 80)
try:
with socket.create_connection((parsed.hostname, port), timeout=timeout):
return True
except OSError:
return False
# Reload profiles from config.json, so edits are picked up without restarting (auto mode)
def load_profiles():
global profiles
try:
with open("config.json", "r") as f:
profiles = json.load(f).get("profiles", [])
except (OSError, json.JSONDecodeError):
pass # Keep using the last valid profiles
return profiles
# Sleep for the given seconds, but check the connected networks every few seconds and return early
# if they changed (e.g. switching between Ethernet and Wi-Fi), so the new network is handled right away (auto mode)
NETWORK_POLL_SECONDS = 5
def wait_or_network_change(seconds, networks):
deadline = time.time() + seconds
while True:
remaining = deadline - time.time()
if remaining <= 0:
return False
time.sleep(min(NETWORK_POLL_SECONDS, remaining))
ethernet_connected, wifi_ssid, _ = get_connected_networks()
if (ethernet_connected, wifi_ssid) != tuple(networks):
add_to_log("Network change detected. Checking again now...")
return True
# Find the profile that belongs to the currently connected network (auto mode)
# Returns (profile, connected_network_name) or (None, None)
def find_matching_profile(ethernet_connected, wifi_ssid):
# Wi-Fi: match by SSID. If several profiles share the SSID, prefer one whose portal is reachable.
if wifi_ssid:
candidates = [p for p in profiles if p.get('ssid', "").strip().lower() == wifi_ssid.lower()]
if len(candidates) > 1:
reachable = [p for p in candidates if is_portal_reachable(p.get('url', ""))]
candidates = reachable or candidates
if candidates:
return candidates[0], wifi_ssid
# Ethernet: profiles have no SSID, so use the one whose login portal is reachable on this network
if ethernet_connected:
for p in profiles:
if not p.get('ssid') and is_portal_reachable(p.get('url', "")):
return p, "Ethernet"
return None, None
# Is connected to wifi
def is_connected_to_wifi():
@@ -708,18 +877,25 @@ def update_window_geometry(event):
x, y = event.x, event.y
width, height = event.width, event.height
# Title of the log dialog, with the profile currently in use
def get_log_title():
if auto_mode:
name = selected_profile['name'] if selected_profile else "waiting for a known network"
return f"Log Messages (Auto: {name})"
return f"Log Messages ({selected_profile['name']})"
# Function to show the log dialog with the currently selected profile name
def show_log_dialog():
global log_dialog, log_text, log_dialog_open, successful_logins_label
if not log_dialog_open:
log_dialog_open = True
if log_dialog:
log_dialog.title(f"Log Messages ({selected_profile['name']})") # Update title with profile name
log_dialog.title(get_log_title()) # Update title with profile name
log_dialog.deiconify()
update_log()
else:
log_dialog = tk.Tk()
log_dialog.title(f"Log Messages ({selected_profile['name']})") # Update title with profile name
log_dialog.title(get_log_title()) # Update title with profile name
log_text = tk.Text(log_dialog)
log_frame = tk.Frame(log_dialog)
log_frame.pack(fill=tk.BOTH, expand=True)
@@ -778,6 +954,8 @@ def add_to_log(message, style=None):
# Function to update the log
def update_log():
if log_text:
if auto_mode and log_dialog:
log_dialog.title(get_log_title()) # The profile can change in auto mode
log_text.delete(1.0, tk.END)
for message, style in get_last_log_messages():
if style:
@@ -820,9 +998,52 @@ request_success = False
request_errorcount = 0
sleepcount = check_every_second
connected_ssid_lower = None
last_auto_message = None
def check_network_status():
global running, errorcount, sleepcount, connected_ssid_lower, ssid, response, request_success, request_errorcount, successful_logins_count
while running and errorcount < 10:
global running, errorcount, sleepcount, connected_ssid_lower, ssid, response, request_success, request_errorcount, successful_logins_count, last_auto_message, selected_profile
# In auto mode the program keeps running (e.g. started with Windows), so it never exits on errors
while running and (auto_mode or errorcount < 10):
if auto_mode:
if errorcount >= 10:
sleepcount = 300
message = "Too many errors. Pausing for {} seconds before trying again...".format(str(sleepcount))
add_to_log(message, "bold_red")
save_to_file(message)
errorcount = 0
time.sleep(sleepcount)
continue
# Detect which profile belongs to the connected network
load_profiles()
ethernet_connected, wifi_ssid, wifi_error = get_connected_networks()
detected_profile, connected_ssid = find_matching_profile(ethernet_connected, wifi_ssid)
if detected_profile is None:
sleepcount = 30
if wifi_error and not ethernet_connected:
message = "{}\nFailed to get SSID. If location services are disabled, please enable them. Checking again in {} seconds...".format(wifi_error, str(sleepcount))
elif wifi_ssid or ethernet_connected:
network = " and ".join(n for n in [wifi_ssid, "Ethernet" if ethernet_connected else None] if n)
message = "Connected to {}, which does not match any profile. Waiting for a known network...".format(network)
else:
message = "Not connected to any network. Waiting for a known network..."
# Only log when the situation changes, so the log isn't flooded while idle
if message != last_auto_message:
add_to_log(message, "red" if wifi_error else None)
last_auto_message = message
selected_profile = None # So the next known network is logged as a new detection
wait_or_network_change(sleepcount, (ethernet_connected, wifi_ssid))
continue
last_auto_message = None
if selected_profile is None or selected_profile.get('name') != detected_profile.get('name'):
errorcount = 0
request_success = False
request_errorcount = 0
apply_profile(detected_profile)
message = "Detected network {}. Using profile '{}'.".format(connected_ssid, detected_profile.get('name'))
add_to_log(message, "bold_green")
save_to_file(message)
else:
apply_profile(detected_profile) # Pick up config.json edits
else:
connected_ssid = get_connected_network()
if isinstance(connected_ssid, str) and "error" in connected_ssid.lower():
sleepcount = 60
@@ -834,7 +1055,8 @@ def check_network_status():
if (connected_ssid):
connected_ssid_lower = connected_ssid.lower()
if (connected_ssid) and ((connected_ssid_lower == expected_ssid_lower)):
if is_connected_to_wifi() and (connected_ssid == "Ethernet" and expected_ssid == "Ethernet"):
# Not in auto mode: there, Wi-Fi may be a separate network the user wants to keep
if not auto_mode and is_connected_to_wifi() and (connected_ssid == "Ethernet" and expected_ssid == "Ethernet"):
message = "You are both connected to Wi-Fi and Ethernet. Disconnecting from Wi-Fi and continuing with Ethernet..."
add_to_log(message)
startupinfo = subprocess.STARTUPINFO()
@@ -1000,6 +1222,9 @@ def check_network_status():
message = "Something went wrong. Please check your Wi-Fi or Ethernet connection. Checking again in {} seconds... (Errors: {}/10)".format(str(sleepcount), errorcount)
save_to_file(message)
add_to_log(message, "red")
if auto_mode:
wait_or_network_change(sleepcount, (ethernet_connected, wifi_ssid))
else:
time.sleep(sleepcount) # Sleep ... seconds before trying again
message = "Maximum error count reached. Exiting in 5 seconds..."
add_to_log(message, "bold_red")
@@ -1012,7 +1237,8 @@ if __name__ == '__main__':
# Create a thread for the system tray icon
tray_thread = threading.Thread(target=create_system_tray_icon)
tray_thread.start()
# Open Log Messages at startup
# Open Log Messages at startup (unless started in the background, e.g. with Windows)
if not args.background:
open_log_messages_startup = threading.Thread(target=show_log_dialog)
open_log_messages_startup.start()
# Start the network status checking in the main thread