A Python library and toolkit for controlling Balboa hot tubs/spas via their WiFi module.
This project wraps the excellent pybalboa library with a cleaner API, CLI tool, REST API server, and a secure web interface with OAuth authentication.
- Python Library: Clean async interface for programmatic control
- CLI Tool: Command-line control with rich terminal output
- REST API: Flask-based HTTP API for integration with other systems
- Web Interface: Modern, mobile-friendly web UI with OAuth authentication
- Discord Integration: Log commands and periodic status updates to Discord
- Production Ready: systemd services, nginx config, Let's Encrypt SSL
- Python 3.10+
- A Balboa spa with WiFi module (bwa™ Wi-Fi Module 50350)
- The spa must be connected to your local network
# Clone or copy the project
cd balboapal
# Create virtual environment
python -m venv .venv
source .venv/bin/activate # On Windows: .venv\Scripts\activate
# Install dependencies
pip install -r requirements.txt
# Copy and configure environment
cp .env.template .env
# Edit .env with your spa's IP address
# Test connection
python cli.py statusEdit .env file with your settings:
# IP address of your Balboa spa WiFi module
SPA_HOST=192.168.0.167
# Flask/Web settings
FLASK_HOST=0.0.0.0
FLASK_PORT=5055
FLASK_DEBUG=false
SECRET_KEY=your-random-secret-key-here
# Domain for the web interface
SITE_DOMAIN=spa.dibona.com
# Google OAuth 2.0 credentials
GOOGLE_CLIENT_ID=your-client-id.apps.googleusercontent.com
GOOGLE_CLIENT_SECRET=your-client-secret
# Permitted users (comma-separated email addresses)
PERMITTED_USERS=user1@gmail.com,user2@gmail.com
# Discord webhook for logging (optional)
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/...
# Status report interval in minutes
STATUS_REPORT_INTERVAL=30Generate a secure SECRET_KEY:
python3 -c "import secrets; print(secrets.token_hex(32))"The web interface uses OAuth for authentication. You can use Google, GitHub, or both.
-
Go to Google Cloud Console
- Visit https://console.cloud.google.com/
- Create a new project or select an existing one
-
Enable the API
- Go to "APIs & Services" → "Library"
- Search for "Google+ API" and enable it (or "Google Identity" for newer projects)
-
Configure OAuth Consent Screen
- Go to "APIs & Services" → "OAuth consent screen"
- Choose "External" (or "Internal" if using Google Workspace)
- Fill in required fields:
- App name:
Spa Control - User support email: your email
- Developer contact: your email
- App name:
- Add scopes:
email,profile,openid - Add test users if in testing mode
-
Create OAuth Credentials
- Go to "APIs & Services" → "Credentials"
- Click "Create Credentials" → "OAuth 2.0 Client IDs"
- Application type: Web application
- Name:
Spa Control Web - Authorized JavaScript origins:
https://spa.dibona.com - Authorized redirect URIs:
https://spa.dibona.com/authorize - Click "Create"
-
Copy Credentials to .env
GOOGLE_CLIENT_ID=123456789-abcdefg.apps.googleusercontent.com GOOGLE_CLIENT_SECRET=GOCSPX-your-secret-here
-
Go to GitHub Developer Settings
- Visit https://github.com/settings/developers
- Click "OAuth Apps" → "New OAuth App"
-
Register the Application
- Application name:
Spa Control - Homepage URL:
https://spa.dibona.com - Authorization callback URL:
https://spa.dibona.com/authorize/github - Click "Register application"
- Application name:
-
Get Credentials
- Copy the Client ID
- Click "Generate a new client secret"
- Copy the Client Secret (shown only once!)
-
Add to .env
GITHUB_CLIENT_ID=your-github-client-id GITHUB_CLIENT_SECRET=your-github-client-secret
-
Update webapp.py (if using GitHub OAuth)
Add to the OAuth setup section:
github = oauth.register( name="github", client_id=os.getenv("GITHUB_CLIENT_ID"), client_secret=os.getenv("GITHUB_CLIENT_SECRET"), access_token_url="https://github.com/login/oauth/access_token", authorize_url="https://github.com/login/oauth/authorize", api_base_url="https://api.github.com/", client_kwargs={"scope": "user:email"}, )
-
Go to Discord Developer Portal
- Visit https://discord.com/developers/applications
- Click "New Application"
- Name:
Spa Control
-
Configure OAuth2
- Go to "OAuth2" → "General"
- Add redirect:
https://spa.dibona.com/authorize/discord - Copy Client ID and Client Secret
-
Add to .env
DISCORD_CLIENT_ID=your-discord-client-id DISCORD_CLIENT_SECRET=your-discord-client-secret
The Discord webhook is separate from OAuth - it's used to log spa commands and status updates to a Discord channel.
-
Open Discord and go to your server
-
Create a Webhook
- Click on the server name → "Server Settings"
- Go to "Integrations" → "Webhooks"
- Click "New Webhook"
- Name:
Spa Control - Channel: Select where you want notifications
- Copy the Webhook URL
-
Add to .env
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/1234567890/abcdefghijklmnop
-
What Gets Logged
- User logins/logouts
- All spa control commands (with user attribution)
- Periodic status reports (every 30 minutes by default)
- Errors and service start/stop events
# Run the installation script
sudo ./deploy/install.sh
# Configure the application
sudo nano /opt/balboapal/.env
# Set up SSL certificate
sudo ./deploy/setup-ssl.sh spa.dibona.com your@email.com
# Start services
sudo systemctl start spa-control spa-status-reporter
# Enable on boot
sudo systemctl enable spa-control spa-status-reporter-
Install System Dependencies
sudo apt update sudo apt install -y python3 python3-venv python3-pip nginx certbot python3-certbot-nginx
-
Copy Application Files
sudo mkdir -p /opt/balboapal sudo cp *.py requirements.txt /opt/balboapal/ sudo cp .env /opt/balboapal/ -
Create Virtual Environment
cd /opt/balboapal sudo python3 -m venv .venv sudo .venv/bin/pip install -r requirements.txt -
Set Permissions
sudo chown -R www-data:www-data /opt/balboapal sudo chmod 600 /opt/balboapal/.env sudo mkdir -p /var/log/spa-control sudo chown www-data:www-data /var/log/spa-control
-
Install systemd Services
sudo cp deploy/spa-control.service /etc/systemd/system/ sudo cp deploy/spa-status-reporter.service /etc/systemd/system/ sudo systemctl daemon-reload
-
Configure nginx
sudo cp deploy/nginx-spa.conf /etc/nginx/sites-available/spa.dibona.com sudo ln -s /etc/nginx/sites-available/spa.dibona.com /etc/nginx/sites-enabled/ sudo nginx -t sudo systemctl reload nginx
-
Obtain SSL Certificate
sudo certbot --nginx -d spa.dibona.com
-
Start Services
sudo systemctl start spa-control spa-status-reporter sudo systemctl enable spa-control spa-status-reporter
# Check status
sudo systemctl status spa-control
sudo systemctl status spa-status-reporter
# View logs
sudo journalctl -u spa-control -f
sudo journalctl -u spa-status-reporter -f
# Restart after config changes
sudo systemctl restart spa-control spa-status-reporter
# Stop services
sudo systemctl stop spa-control spa-status-reporterCertbot automatically renews certificates. Verify with:
# Check renewal timer
sudo systemctl status certbot.timer
# Test renewal
sudo certbot renew --dry-run
# Check certificate expiry
echo | openssl s_client -connect spa.dibona.com:443 2>/dev/null | openssl x509 -noout -dates# Get spa status
python cli.py status
# Set temperature
python cli.py set-temp 102
# Control pumps (0-indexed: pump 0 = Pump 1)
python cli.py pump 0 toggle # Toggle pump 1
python cli.py pump 0 high # Set pump 1 to high
python cli.py pump 1 off # Turn off pump 2
# Toggle lights
python cli.py light # Toggle light 1
python cli.py light --index 1 # Toggle light 2
# Toggle blower
python cli.py blower
# Set heat mode
python cli.py heat-mode ready # Ready mode (maintains temp)
python cli.py heat-mode rest # Rest mode (heats during filtration only)
# Set temperature range
python cli.py temp-range high # High range (up to 104°F)
python cli.py temp-range low # Low range (economy mode)
# Specify spa host directly
python cli.py --host 192.168.1.50 statusStart the API server:
python api.pyOr with gunicorn for production:
gunicorn -w 1 -b 0.0.0.0:5000 api:appAPI Endpoints:
# Get status
curl http://localhost:5000/api/status
# Set temperature
curl -X POST http://localhost:5000/api/temperature \
-H "Content-Type: application/json" \
-d '{"temp": 102}'
# Toggle pump (0-indexed)
curl -X POST http://localhost:5000/api/pump/0/toggle
# Set pump state (0=off, 1=low, 2=high)
curl -X POST http://localhost:5000/api/pump/0/state \
-H "Content-Type: application/json" \
-d '{"state": 2}'
# Toggle light
curl -X POST http://localhost:5000/api/light/toggle
# Toggle blower
curl -X POST http://localhost:5000/api/blower/toggle
# Set heat mode
curl -X POST http://localhost:5000/api/heat-mode \
-H "Content-Type: application/json" \
-d '{"mode": "ready"}'
# Set temperature range
curl -X POST http://localhost:5000/api/temp-range \
-H "Content-Type: application/json" \
-d '{"range": "high"}'import asyncio
from spa_control import BalboaSpa
async def main():
async with BalboaSpa("192.168.1.100") as spa:
# Get current status
status = await spa.get_status()
print(f"Current temp: {status.current_temp}°{status.temp_unit}")
print(f"Target temp: {status.target_temp}°{status.temp_unit}")
print(f"Heating: {status.heating}")
# Set temperature
await spa.set_temperature(102)
# Control pumps
await spa.toggle_pump(0) # Toggle pump 1
await spa.set_pump(1, 2) # Set pump 2 to high
# Control lights
await spa.toggle_light(0)
# Change modes
await spa.set_heat_mode("ready")
await spa.set_temp_range("high")
asyncio.run(main())# Run all tests
pytest
# Run with coverage
pytest --cov=. --cov-report=html
# Run specific test file
pytest test_spa_control.py -v ┌─────────────┐
│ Client │
│ (Browser) │
└──────┬──────┘
│ HTTPS
▼
┌─────────────┐
│ nginx │
│ (SSL/TLS) │
└──────┬──────┘
│ HTTP (localhost)
▼
┌─────────────┐
│ gunicorn │
│ (webapp) │
└──────┬──────┘
│
┌────────────┴────────────┐
│ │
▼ ▼
┌─────────────┐ ┌─────────────┐
│ Balboa Spa │ │ Discord │
│ (WiFi) │ │ Webhook │
└─────────────┘ └─────────────┘
┌─────────────────────────────────────┐
│ status_reporter.py │
│ (Separate service for periodic │
│ status updates to Discord) │
└─────────────────────────────────────┘
balboapal/
├── spa_control.py # Core library for spa communication
├── webapp.py # Web application with OAuth
├── api.py # REST API (basic, no auth)
├── cli.py # Command-line interface
├── discord_logger.py # Discord webhook integration
├── status_reporter.py # Periodic status reporting daemon
├── test_spa_control.py # Unit tests
├── requirements.txt # Python dependencies
├── .env # Configuration (create from template)
├── .env.template # Configuration template
├── README.md # This file
├── DEPLOYMENT.md # Detailed deployment guide
└── deploy/
├── install.sh # Installation script
├── setup-ssl.sh # Let's Encrypt setup
├── nginx-spa.conf # nginx configuration
├── spa-control.service # systemd service (web app)
└── spa-status-reporter.service # systemd service (reporter)
- Verify the spa's IP address (check your router's DHCP leases)
- Ensure the WiFi module is connected (LED should be solid)
- Try the official Balboa app first to verify connectivity
- The spa must be on your local network (not cloud-only)
This is normal when no water is flowing. The spa needs flow to measure temperature. Run a pump briefly or wait for a filter cycle.
- Verify redirect URI matches exactly (including https://)
- Check that SITE_DOMAIN in .env matches your actual domain
- Ensure SSL is working properly
- For Google: make sure the OAuth consent screen is configured
Your email is not in the PERMITTED_USERS list. Add your email to .env:
PERMITTED_USERS=your.email@gmail.com,another@example.comThen restart the service.
- Check DISCORD_WEBHOOK_URL is set correctly
- Test the webhook manually:
curl -X POST "YOUR_WEBHOOK_URL" \ -H "Content-Type: application/json" \ -d '{"content": "Test message"}'
- Ready: Spa maintains set temperature continuously
- Rest: Spa only heats during filtration cycles (economy mode)
- High: Full temperature range (typically up to 104°F / 40°C)
- Low: Economy range (typically up to 99°F / 36°C)
- pybalboa - The underlying Python library
- balboa_worldwide_app - Protocol documentation
- The Home Assistant community for reverse-engineering efforts
MIT License - feel free to use and modify as needed.