Files
openclaw/skills/kamoer-watering/web-control.py.bak
Clawd Bot ca9b510922 chore: align with upstream openclaw/openclaw and overlay local additions
- Reset master to upstream/main (16,697 commits)
- Overlay 2,271 local-only files (skills, tools, workspace, configs, apps)
- Restore IDENTITY.md and USER.md templates
- Build verified, gateway running, Discord working

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 07:40:46 +01:00

2023 lines
82 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
Kamoer Pump Control API with Scheduling and Volume Tracking
Mobile-Optimized Version with Working JavaScript
"""
from flask import Flask, jsonify, request, render_template_string
import btdripper
import threading
import time
import logging
import json
import os
import subprocess
import signal
import requests
from datetime import datetime
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from plant_assistant import calculate_watering, get_plant_suggestions, PLANT_DATABASE
app = Flask(__name__)
logging.basicConfig(level=logging.INFO)
# Configuration
DATA_FILE = '/home/alex/kamoer-web/pump_data.json'
MAC_ADDRESS = '00:35:ff:1e:c8:3e'
FLOW_RATE_ML_PER_SEC = 200.0 # Kamoer Dripper Pro: 12000ml/min = 200ml/s
# Global state
pump_state = {
'running': False,
'mac_address': MAC_ADDRESS,
'last_run': None,
'total_runtime': 0,
'total_volume_ml': 0,
'tank_capacity_ml': 1000, # Default 1L tank
'tank_remaining_ml': 1000,
'schedules': [],
'my_plants': [] # User's plant collection
}
pump_lock = threading.Lock()
scheduler = BackgroundScheduler()
scheduler.start()
def load_data():
"""Load persistent data from file"""
global pump_state
if os.path.exists(DATA_FILE):
try:
with open(DATA_FILE, 'r') as f:
saved = json.load(f)
pump_state.update(saved)
logging.info(f"Loaded data: {pump_state['total_runtime']}s runtime, {pump_state['total_volume_ml']:.1f}ml used")
except Exception as e:
logging.error(f"Error loading data: {e}")
def save_data():
"""Save persistent data to file"""
try:
save_state = {k: v for k, v in pump_state.items() if k != 'running'}
with open(DATA_FILE, 'w') as f:
json.dump(save_state, f, indent=2)
except Exception as e:
logging.error(f"Error saving data: {e}")
def _discord_low_tank_alert(tank_remaining_ml, threshold_ml=200):
"""Send a Discord webhook notification when tank is critically low."""
webhook_url = os.environ.get('DISCORD_WEBHOOK_URL')
if not webhook_url:
return # No webhook configured
if tank_remaining_ml >= threshold_ml:
return # Tank not low enough
try:
payload = {
'content': (
f':warning: **Low Tank Alert** — Only **{tank_remaining_ml:.0f}ml** remaining '
f'(below {threshold_ml}ml). Please refill the water tank!'
)
}
resp = requests.post(webhook_url, json=payload, timeout=5)
if resp.status_code not in (200, 204):
logging.warning(f"Discord alert returned status {resp.status_code}")
else:
logging.info(f"Discord low-tank alert sent ({tank_remaining_ml:.0f}ml remaining)")
except Exception as exc:
logging.error(f"Failed to send Discord alert: {exc}")
def run_pump(duration_seconds, scheduled=False):
"""Run the pump for specified duration with retry logic and timeout"""
global pump_state
with pump_lock:
if pump_state['running']:
return {'error': 'Pump is already running'}
volume_needed = duration_seconds * FLOW_RATE_ML_PER_SEC
if pump_state['tank_remaining_ml'] < volume_needed:
logging.warning(f"Low tank: {pump_state['tank_remaining_ml']:.1f}ml < {volume_needed:.1f}ml needed")
return {'error': f'Not enough liquid in tank ({pump_state["tank_remaining_ml"]:.1f}ml remaining, need {volume_needed:.1f}ml)'}
pump_state['running'] = True
pump_state['last_run'] = time.strftime('%Y-%m-%d %H:%M:%S')
def pump_worker():
"""Worker thread for pump operation"""
logging.info(f"💧 pump_worker thread started for {duration_seconds}s")
max_retries = 3
for attempt in range(max_retries):
try:
logging.info(f"Starting pump for {duration_seconds}s ({'scheduled' if scheduled else 'manual'}) - attempt {attempt+1}/{max_retries}")
# Try daemon first (fast path - no reconnection)
try:
import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(2)
sock.connect(('127.0.0.1', 18788))
sock.send(f'RUN:{duration_seconds}\n'.encode())
response = sock.recv(1024).decode().strip()
sock.close()
if response == 'OK':
logging.info("✓ Pump run via daemon (fast path)")
volume_used = duration_seconds * FLOW_RATE_ML_PER_SEC
with pump_lock:
pump_state['running'] = False
pump_state['total_runtime'] += duration_seconds
pump_state['total_volume_ml'] += volume_used
pump_state['tank_remaining_ml'] -= volume_used
save_data()
logging.info(f"Pump finished: {volume_used:.1f}ml used, {pump_state['tank_remaining_ml']:.1f}ml remaining")
_discord_low_tank_alert(pump_state['tank_remaining_ml'])
return
else:
logging.warning("Daemon returned error, falling back to subprocess")
except Exception as daemon_err:
logging.warning(f"Daemon not available ({daemon_err}), using subprocess fallback")
# Fallback: Use subprocess (slow path - reconnection overhead)
pump_script = os.path.join(os.path.dirname(__file__), 'pump_runner.py')
timeout_total = duration_seconds + 15
try:
result = subprocess.run(
['python3', pump_script, str(duration_seconds)],
timeout=timeout_total,
capture_output=True,
text=True
)
except subprocess.TimeoutExpired as e:
logging.error(f"Pump runner timed out after {timeout_total}s")
raise Exception(f"Bluetooth connection timeout")
logging.info(f"Pump runner output: {result.stdout.strip()}")
if result.returncode != 0:
raise Exception(f"Pump runner failed: {result.stderr.strip()}")
if "SUCCESS" not in result.stdout:
raise Exception(f"Pump did not complete successfully")
volume_used = duration_seconds * FLOW_RATE_ML_PER_SEC
with pump_lock:
pump_state['running'] = False
pump_state['total_runtime'] += duration_seconds
pump_state['total_volume_ml'] += volume_used
pump_state['tank_remaining_ml'] -= volume_used
save_data()
logging.info(f"Pump finished: {volume_used:.1f}ml used, {pump_state['tank_remaining_ml']:.1f}ml remaining")
return
except Exception as e:
logging.error(f"Pump attempt {attempt+1} failed: {e}")
if attempt < max_retries - 1:
logging.info(f"Retrying in 2 seconds...")
time.sleep(2)
else:
with pump_lock:
pump_state['running'] = False
logging.error(f"Pump failed after {max_retries} attempts: {e}")
return
# Run in background thread
logging.info(f"🚀 Creating background thread for {duration_seconds}s pump run...")
thread = threading.Thread(target=pump_worker, daemon=True)
thread.start()
logging.info(f"✓ Background thread started (alive: {thread.is_alive()})")
return {'success': True, 'message': f'Pump starting for {duration_seconds} seconds'}
def scheduled_run(schedule_id, duration):
"""Callback for scheduled pump runs"""
logging.info(f"Scheduled run triggered: {schedule_id} ({duration}s)")
result = run_pump(duration, scheduled=True)
if 'error' in result:
logging.error(f"Scheduled run failed: {result['error']}")
@app.route('/')
def index():
"""Web interface"""
return render_template_string(HTML_TEMPLATE)
@app.route('/api/settings/refill', methods=['POST'])
def refill_tank():
"""Refill tank to capacity"""
global pump_state
with pump_lock:
pump_state['tank_remaining_ml'] = pump_state['tank_capacity_ml']
save_data()
logging.info(f"Tank refilled to {pump_state['tank_capacity_ml']}ml")
return jsonify({'success': True, 'tank_remaining_ml': pump_state['tank_remaining_ml']})
def call_plantnet_api(image_path):
"""Call PlantNet API and return raw results"""
# Local import to avoid requiring requests at module import time for other runtime paths
import requests
api_key = os.environ.get('PLANTNET_API_KEY', '2b104ICBTEvVRRp9BDx63RaY6e')
api_endpoint = f'https://my-api.plantnet.org/v2/identify/all?api-key={api_key}'
with open(image_path, 'rb') as image_file:
files = [('images', (os.path.basename(image_path), image_file, 'image/jpeg'))]
data = {'organs': 'auto'}
response = requests.post(api_endpoint, files=files, data=data, timeout=30)
if response.status_code != 200:
logging.error(f"PlantNet API error: {response.status_code} - {response.text}")
raise Exception('Plant identification service unavailable')
return response.json()
def estimate_pot_size(database_key):
"""Estimate pot size based on plant type"""
if 'cactus' in database_key or 'succulent' in database_key:
return 2.0
elif 'tomato' in database_key or 'basil' in database_key:
return 3.0
elif 'monstera' in database_key or 'ficus' in database_key or 'rubber_plant' in database_key:
return 8.0
elif 'orchid' in database_key:
return 1.5
else:
return 5.0
def match_plant_to_database(display_name, scientific_name, common_names):
"""Try to match PlantNet result to our database"""
for key, plant_data in PLANT_DATABASE.items():
plant_name_lower = plant_data['name'].lower()
search_terms = [display_name.lower(), scientific_name.lower()] + [cn.lower() for cn in common_names]
search_terms.append(key.lower())
# Check for partial matches
for term in search_terms:
words_in_term = term.split()
words_in_db = plant_name_lower.split()
# Normalize values for comparison
term_l = term.lower()
key_l = key.lower()
# Match if any word overlaps (min 4 chars)
if (term_l in plant_name_lower or
plant_name_lower in term_l or
key_l in term_l or
term_l in key_l or
any(w in words_in_db for w in words_in_term if len(w) > 3)):
return key, plant_data['name']
return None, None
def build_suggestion(idx, match):
"""Build a suggestion object from PlantNet match"""
plant_name = match['species']['scientificNameWithoutAuthor']
common_names = match['species'].get('commonNames', [])
score = match['score'] * 100
display_name = common_names[0] if common_names else plant_name
# Match to database
database_key, matched_plant = match_plant_to_database(display_name, plant_name, common_names)
estimated_pot_size = estimate_pot_size(database_key) if database_key else 5.0
suggestion = {
'rank': idx + 1,
'display_name': display_name,
'scientific_name': plant_name,
'common_names': common_names,
'confidence': round(score, 1),
'matched_in_database': matched_plant is not None,
'database_key': database_key,
'database_name': matched_plant,
'estimated_pot_size': estimated_pot_size
}
# Add care info if matched
if database_key and database_key in PLANT_DATABASE:
db_info = PLANT_DATABASE[database_key]
suggestion['care_info'] = {
'water_frequency_days': db_info['water_frequency_days'],
'light': db_info['light'],
'soil': db_info['soil_preference'],
'notes': db_info['notes']
}
return suggestion
def auto_add_plant_to_database(suggestion):
"""Auto-add high-confidence plant to runtime database"""
# Only auto-add when confidence is high and it's not already matched
if suggestion.get('matched_in_database'):
return suggestion
if suggestion.get('confidence', 0) < 70:
return suggestion
auto_key = suggestion['scientific_name'].lower().split()[0]
# Avoid key collisions
if auto_key not in PLANT_DATABASE:
PLANT_DATABASE[auto_key] = {
'name': f'{suggestion["display_name"]} ({suggestion["scientific_name"]})',
'water_frequency_days': 7,
'water_ml_per_liter_soil': 200,
'soil_preference': 'well-draining',
'light': 'bright indirect',
'notes': 'Auto-detected plant - generic care recommendations'
}
suggestion['matched_in_database'] = True
suggestion['database_key'] = auto_key
suggestion['database_name'] = PLANT_DATABASE[auto_key]['name']
suggestion['care_info'] = {
'water_frequency_days': 7,
'light': 'bright indirect',
'soil': 'well-draining',
'notes': 'Auto-detected plant - generic care recommendations'
}
logging.info(f"Auto-added plant to runtime database: {auto_key} ({suggestion['display_name']})")
return suggestion
@app.route('/api/identify-plant', methods=['POST'])
def identify_plant():
"""Identify plant using PlantNet API with enhanced matching"""
import requests
try:
if 'image' not in request.files:
return jsonify({'error': 'No image uploaded'}), 400
file = request.files['image']
if file.filename == '':
return jsonify({'error': 'No image selected'}), 400
# Save temp file
import os.path
original_ext = os.path.splitext(file.filename)[1] or '.jpg'
temp_path = f'/tmp/plant_photo_{int(time.time())}{original_ext}'
file.save(temp_path)
try:
# Call PlantNet API
result = call_plantnet_api(temp_path)
if not result.get('results'):
return jsonify({
'suggestions': [],
'message': 'Could not identify this plant. Please try a clearer photo or select manually.'
})
# Process top 5 matches
suggestions = [build_suggestion(idx, match) for idx, match in enumerate(result['results'][:5])]
# Auto-add top match if high confidence
if suggestions:
suggestions[0] = auto_add_plant_to_database(suggestions[0])
return jsonify({
'suggestions': suggestions,
'message': f'Found {len(suggestions)} possible matches'
})
finally:
if os.path.exists(temp_path):
os.remove(temp_path)
except requests.Timeout:
return jsonify({'error': 'Plant identification timed out. Please try again.'}), 504
except Exception as e:
logging.error(f"Plant identification error: {e}", exc_info=True)
return jsonify({'error': f'Identification failed: {str(e)}'}), 500
@app.route('/api/plants')
def get_plants():
"""Get list of available plants"""
return jsonify(get_plant_suggestions())
@app.route('/api/calculate-watering', methods=['POST'])
def calc_watering():
"""Calculate watering recommendation"""
data = request.get_json()
result = calculate_watering(
data['plant_type'],
data['pot_size_liters'],
data.get('season', 'normal'),
data.get('temperature', 'normal')
)
return jsonify(result)
@app.route('/health')
def health():
"""Simple healthcheck endpoint"""
return {'status': 'ok', 'timestamp': time.time()}, 200
@app.route('/api/status')
def status():
"""Get pump status"""
schedules_display = []
for s in pump_state['schedules']:
days_map = {'*': 'Every day', '1-5': 'Weekdays', '0,6': 'Weekends'}
days_text = days_map.get(s['day_of_week'], s['day_of_week'])
schedules_display.append({
'id': s['id'],
'name': s['name'],
'time': f"{s['hour']:02d}:{s['minute']:02d}",
'days': days_text,
'duration': s['duration']
})
return jsonify({**pump_state, 'schedules': schedules_display})
@app.route('/api/schedules')
def get_schedules():
"""Get schedules list"""
schedules_display = []
for s in pump_state['schedules']:
days_map = {'*': 'Every day', '1-5': 'Weekdays', '0,6': 'Weekends'}
days_text = days_map.get(s['day_of_week'], s['day_of_week'])
schedules_display.append({
'id': s['id'],
'name': s['name'],
'time': f"{s['hour']:02d}:{s['minute']:02d}",
'days': days_text,
'duration': s['duration']
})
return jsonify(schedules_display)
@app.route('/api/run', methods=['POST'])
def run():
"""Run pump for specified duration"""
data = request.get_json()
duration = data.get('duration', 1)
if not isinstance(duration, (int, float)) or duration <= 0 or duration > 300:
return jsonify({'error': 'Duration must be between 1 and 300 seconds'}), 400
thread = threading.Thread(target=run_pump, args=(duration,))
thread.daemon = True
thread.start()
return jsonify({'success': True, 'message': f'Pump starting for {duration} seconds'})
@app.route('/api/stop', methods=['POST'])
def emergency_stop():
"""Emergency stop - force pump off"""
global pump_state
try:
logging.warning("🛑 EMERGENCY STOP requested")
# Kill any running pump_runner processes
subprocess.run(['pkill', '-9', '-f', 'pump_runner.py'], check=False)
# Try daemon first
try:
import socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(2)
sock.connect(('127.0.0.1', 18788))
sock.send(b'STOP\n')
response = sock.recv(1024).decode().strip()
sock.close()
if response == 'OK':
logging.info("✓ Emergency stop via daemon")
with pump_lock:
pump_state['running'] = False
return jsonify({'success': True, 'message': 'Pump stopped via daemon'})
except Exception as daemon_err:
logging.warning(f"Daemon stop failed ({daemon_err}), using fallback")
# Fallback: Direct Bluetooth
result = subprocess.run(
['python3', '-c', '''
import btdripper
dripper = btdripper.BtDripper(adapter_name="hci0")
dripper.off()
dripper.disconnect()
print("STOPPED")
'''],
timeout=10,
capture_output=True,
text=True
)
with pump_lock:
pump_state['running'] = False
logging.info("✓ Emergency stop completed (fallback)")
return jsonify({'success': True, 'message': 'Pump stopped'})
except Exception as e:
logging.error(f"Emergency stop failed: {e}")
return jsonify({'error': str(e)}), 500
@app.route('/api/schedule', methods=['POST'])
def add_schedule():
"""Add a watering schedule"""
data = request.get_json()
schedule_id = f"sched_{int(time.time())}"
schedule = {
'id': schedule_id,
'name': data['name'],
'hour': data['hour'],
'minute': data['minute'],
'day_of_week': data['day_of_week'],
'duration': data['duration']
}
try:
trigger = CronTrigger(
hour=schedule['hour'],
minute=schedule['minute'],
day_of_week=schedule['day_of_week']
)
scheduler.add_job(
scheduled_run,
trigger=trigger,
args=[schedule_id, schedule['duration']],
id=schedule_id,
replace_existing=True
)
pump_state['schedules'].append(schedule)
save_data()
logging.info(f"Schedule added: {schedule['name']} at {schedule['hour']}:{schedule['minute']}")
return jsonify({'success': True, 'schedule': schedule})
except Exception as e:
logging.error(f"Error adding schedule: {e}")
return jsonify({'error': str(e)}), 500
@app.route('/api/schedule/<schedule_id>', methods=['DELETE'])
def delete_schedule(schedule_id):
"""Delete a schedule"""
try:
scheduler.remove_job(schedule_id)
pump_state['schedules'] = [s for s in pump_state['schedules'] if s['id'] != schedule_id]
save_data()
return jsonify({'success': True})
except Exception as e:
return jsonify({'error': str(e)}), 500
@app.route('/api/tank', methods=['POST'])
def update_tank():
"""Update tank settings"""
data = request.get_json()
pump_state['tank_capacity_ml'] = data['capacity']
pump_state['tank_remaining_ml'] = data['remaining']
save_data()
return jsonify({'success': True})
@app.route('/api/reset', methods=['POST'])
def reset_stats():
"""Reset statistics"""
pump_state['total_runtime'] = 0
pump_state['total_volume_ml'] = 0
save_data()
return jsonify({'success': True})
# My Plants endpoints
@app.route('/api/my-plants', methods=['GET'])
def get_my_plants():
"""Get user's saved plants"""
return jsonify(pump_state.get('my_plants', []))
@app.route('/api/my-plants', methods=['POST'])
def add_my_plant():
"""Add plant to collection"""
data = request.get_json()
today = datetime.now().strftime('%Y-%m-%d')
plant = {
'id': f'plant_{int(time.time())}',
'name': data['name'],
'plant_type': data['plant_type'],
'pot_size': data['pot_size'],
'location': data.get('location', ''),
'notes': data.get('notes', ''),
'added': today,
'last_watered': today # Track watering date
}
if 'my_plants' not in pump_state:
pump_state['my_plants'] = []
pump_state['my_plants'].append(plant)
save_data()
return jsonify({'success': True, 'plant': plant})
@app.route('/api/my-plants/<plant_id>', methods=['DELETE'])
def delete_my_plant(plant_id):
"""Remove plant from collection"""
if 'my_plants' in pump_state:
pump_state['my_plants'] = [p for p in pump_state['my_plants'] if p['id'] != plant_id]
save_data()
return jsonify({'success': True})
@app.route('/api/my-plants/<plant_id>/water', methods=['POST'])
def water_plant(plant_id):
"""Mark a plant as watered today"""
today = datetime.now().strftime('%Y-%m-%d')
for p in pump_state.get('my_plants', []):
if p['id'] == plant_id:
p['last_watered'] = today
save_data()
return jsonify({'success': True, 'last_watered': today})
return jsonify({'error': 'Plant not found'}), 404
# HTML Template (separate for readability)
HTML_TEMPLATE = r'''<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Kamoer Pump Control</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
min-height: 100vh;
padding: 15px;
}
.container {
max-width: 900px;
margin: 0 auto;
background: white;
border-radius: 20px;
padding: 25px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
}
h1 { color: #333; text-align: center; margin-bottom: 8px; font-size: 28px; }
.subtitle { text-align: center; color: #666; margin-bottom: 25px; font-size: 14px; }
.tabs {
display: flex;
gap: 8px;
margin-bottom: 20px;
border-bottom: 2px solid #f0f0f0;
overflow-x: auto;
}
.tab {
padding: 12px 20px;
cursor: pointer;
background: none;
border: none;
border-bottom: 3px solid transparent;
color: #666;
font-size: 15px;
transition: all 0.2s;
white-space: nowrap;
}
.tab.active {
border-bottom-color: #11998e;
color: #11998e;
font-weight: 600;
}
.tab-content { display: none; }
.tab-content.active { display: block; }
.status {
background: #f8f9fa;
padding: 18px;
border-radius: 12px;
margin-bottom: 20px;
}
.status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 15px;
}
.status-item {
display: flex;
flex-direction: column;
gap: 6px;
}
.status-label {
font-size: 11px;
font-weight: 700;
color: #6c757d;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.status-value {
font-size: 17px;
color: #212529;
font-weight: 600;
}
.running { color: #28a745; }
.stopped { color: #999; }
.tank-gauge-wrap {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 12px;
}
.tank-gauge-wrap svg {
overflow: visible;
}
#tank-gauge-arc {
transition: stroke-dashoffset 0.6s ease, stroke 0.6s ease;
}
#tank-gauge-label {
font-size: 14px;
color: #6c757d;
margin-top: 4px;
text-align: center;
}
.controls {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(90px, 1fr));
gap: 12px;
margin: 20px 0;
}
button {
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
color: white;
border: none;
padding: 18px 16px;
border-radius: 12px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 4px 12px rgba(17, 153, 142, 0.3);
-webkit-tap-highlight-color: transparent;
}
button:active {
transform: scale(0.97);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none !important;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
margin-bottom: 6px;
font-weight: 600;
color: #495057;
font-size: 14px;
}
.form-group input, .form-group select {
width: 100%;
padding: 12px;
border: 2px solid #dee2e6;
border-radius: 10px;
font-size: 15px;
font-family: inherit;
}
.form-group input:focus, .form-group select:focus {
outline: none;
border-color: #11998e;
}
.schedule-form, .settings-form {
background: #f8f9fa;
padding: 20px;
border-radius: 12px;
margin-bottom: 20px;
}
.schedule-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.schedule-item {
background: #f8f9fa;
padding: 16px;
border-radius: 12px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.schedule-info {
flex: 1;
min-width: 0;
}
.schedule-name {
font-weight: 600;
color: #212529;
margin-bottom: 4px;
}
.schedule-details {
font-size: 13px;
color: #6c757d;
}
.btn-delete {
background: #dc3545;
padding: 10px 18px;
font-size: 14px;
flex-shrink: 0;
}
.message {
margin-top: 20px;
padding: 14px;
border-radius: 10px;
text-align: center;
font-weight: 600;
display: none;
}
.message.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.message.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.message.show { display: block; }
.info-box {
background: #e3f2fd;
padding: 16px;
border-radius: 10px;
margin-bottom: 18px;
border-left: 4px solid #1976d2;
}
.info-box h4 {
margin: 0 0 8px 0;
color: #1976d2;
font-size: 16px;
}
.info-box p {
margin: 0 0 12px 0;
font-size: 14px;
color: #555;
}
.recommendation {
background: #e8f5e9;
padding: 20px;
border-radius: 12px;
margin-top: 20px;
border-left: 4px solid #2e7d32;
}
.recommendation h3 {
color: #2e7d32;
margin-bottom: 16px;
font-size: 18px;
}
.rec-grid {
display: grid;
gap: 14px;
}
.rec-item {
background: white;
padding: 12px;
border-radius: 8px;
}
.rec-label {
font-size: 11px;
color: #666;
text-transform: uppercase;
font-weight: 700;
margin-bottom: 6px;
}
.rec-value {
font-size: 17px;
font-weight: 600;
color: #212529;
}
.rec-value.large {
font-size: 22px;
color: #2e7d32;
}
@media (max-width: 600px) {
body { padding: 10px; }
.container { padding: 18px; }
h1 { font-size: 24px; }
.tab { padding: 10px 14px; font-size: 14px; }
.controls { grid-template-columns: repeat(2, 1fr); }
button { padding: 16px 12px; font-size: 15px; }
}
</style>
</head>
<body>
<div class="container">
<h1>🌿 Kamoer Pump Control</h1>
<div class="subtitle">Smart Plant Watering System</div>
<div class="tabs">
<button class="tab active" data-tab="manual">Manual</button>
<button class="tab" data-tab="my-plants">🪴 My Plants</button>
<button class="tab" data-tab="assistant">🌿 Assistant</button>
<button class="tab" data-tab="schedule">Schedule</button>
<button class="tab" data-tab="settings">Settings</button>
</div>
<!-- Manual Control Tab -->
<div class="tab-content active" id="manual">
<div class="status">
<div class="status-grid">
<div class="status-item">
<span class="status-label">Status</span>
<span class="status-value stopped" id="status">Loading...</span>
</div>
<div class="status-item">
<span class="status-label">Last Run</span>
<span class="status-value" id="last-run">Never</span>
</div>
<div class="status-item">
<span class="status-label">Total Runtime</span>
<span class="status-value" id="total-runtime">0s</span>
</div>
<div class="status-item">
<span class="status-label">Total Volume</span>
<span class="status-value" id="total-volume">0ml</span>
</div>
</div>
<div class="status-item" style="margin-top: 16px;">
<span class="status-label">Tank Level</span>
<div class="tank-gauge-wrap">
<svg id="tank-gauge-svg" width="120" height="120" viewBox="0 0 120 120">
<!-- Background circle -->
<circle cx="60" cy="60" r="50" fill="none" stroke="#e9ecef" stroke-width="12"/>
<!-- Coloured arc (stroke-dasharray circumference ~314) -->
<circle id="tank-gauge-arc" cx="60" cy="60" r="50"
fill="none" stroke="#28a745" stroke-width="12"
stroke-linecap="round"
stroke-dasharray="314" stroke-dashoffset="314"
transform="rotate(-90 60 60)"/>
<!-- Percent text -->
<text id="tank-gauge-pct" x="60" y="55" text-anchor="middle"
dominant-baseline="middle" font-size="22" font-weight="700"
fill="#212529">0%</text>
<!-- Volume text -->
<text id="tank-gauge-vol" x="60" y="75" text-anchor="middle"
dominant-baseline="middle" font-size="10" fill="#6c757d">Loading...</text>
</svg>
</div>
</div>
</div>
<div class="controls">
<button data-duration="1">1s</button>
<button data-duration="3">3s</button>
<button data-duration="5">5s</button>
<button data-duration="10">10s</button>
<button data-duration="30">30s</button>
<button data-duration="60">1min</button>
</div>
<div id="countdown-display" style="display: none; background: #fff3cd; border: 2px solid #ffc107; border-radius: 10px; padding: 16px; margin: 12px 0; text-align: center;">
<div style="font-size: 14px; color: #856404; margin-bottom: 8px;">Pump Running</div>
<div style="font-size: 32px; font-weight: bold; color: #856404;" id="countdown-timer">0:00</div>
<button id="emergency-stop-btn" style="background: #dc3545; color: white; margin-top: 12px; font-weight: bold;">🛑 STOP NOW</button>
</div>
<div style="display: flex; gap: 12px;">
<input type="number" id="custom-duration" placeholder="Custom (seconds)" min="1" max="300" style="flex: 1; padding: 14px; border: 2px solid #dee2e6; border-radius: 10px; font-size: 15px;">
<button id="custom-run-btn" style="width: 110px;">Run</button>
</div>
</div>
<!-- My Plants Tab -->
<div class="tab-content" id="my-plants">
<h3 style="margin-bottom: 16px; color: #212529;">🪴 My Plant Collection</h3>
<p style="color: #666; margin-bottom: 18px; font-size: 14px;">Manage your plants and track their watering needs.</p>
<div class="schedule-form" style="margin-bottom: 24px;">
<h4 style="margin-bottom: 12px;"> Add New Plant</h4>
<div class="form-group">
<label>Plant Name (e.g. "Kitchen Monstera")</label>
<input type="text" id="new-plant-name" placeholder="Give your plant a nickname">
</div>
<div class="form-group">
<label>Plant Type</label>
<select id="new-plant-type">
<option value="">Select type...</option>
</select>
</div>
<div class="form-group">
<label>Pot Size (liters)</label>
<input type="number" id="new-plant-pot" min="0.5" max="50" step="0.5" value="5">
</div>
<div class="form-group">
<label>Location (optional)</label>
<input type="text" id="new-plant-location" placeholder="e.g. Living room window">
</div>
<div class="form-group">
<label>Notes (optional)</label>
<textarea id="new-plant-notes" rows="2" placeholder="Care notes, last repot date, etc."></textarea>
</div>
<button id="add-plant-btn" style="width: 100%; background: linear-gradient(135deg, #38ef7d 0%, #11998e 100%);">Add Plant</button>
</div>
<div id="my-plants-list">
<p style="color: #999; text-align: center; padding: 30px;">No plants added yet. Add your first plant above! 🌱</p>
</div>
</div>
<!-- Plant Assistant Tab -->
<div class="tab-content" id="assistant">
<div class="schedule-form">
<h3 style="margin-bottom: 16px; color: #212529;">🌿 AI Watering Calculator</h3>
<p style="color: #666; margin-bottom: 18px; font-size: 14px;">Get personalized watering recommendations based on your plant type and conditions.</p>
<div class="info-box">
<h4>📸 Don't know your plant?</h4>
<p>Upload a photo and AI will identify it!</p>
<!-- Image preview canvas -->
<div id="image-preview-container" style="display: none; margin: 12px 0;">
<canvas id="image-preview" style="max-width: 100%; border: 2px solid #1976d2; border-radius: 8px;"></canvas>
<div style="display: flex; gap: 8px; margin-top: 8px; flex-wrap: wrap;">
<button id="rotate-left-btn" style="flex: 1; min-width: 80px; background: #6c757d; padding: 8px;">↶ Rotate</button>
<button id="rotate-right-btn" style="flex: 1; min-width: 80px; background: #6c757d; padding: 8px;">↷ Rotate</button>
<button id="clear-image-btn" style="flex: 1; min-width: 80px; background: #dc3545; padding: 8px;">✕ Clear</button>
</div>
</div>
<input type="file" id="plant-photo" accept="image/*" style="margin-bottom: 12px;">
<button id="identify-btn" style="width: 100%; background: linear-gradient(135deg, #1976d2 0%, #42a5f5 100%);" disabled>🔍 Identify Plant</button>
<!-- Identification results -->
<div id="identify-result" style="margin-top: 12px; display: none;"></div>
</div>
<div class="form-group">
<label>Plant Type</label>
<select id="plant-type">
<option value="">Loading plants...</option>
</select>
</div>
<div class="form-group">
<label>Pot Size (liters)</label>
<input type="number" id="pot-size" min="0.5" max="50" step="0.5" value="5">
</div>
<div class="form-group">
<label>Season</label>
<select id="season">
<option value="normal">Normal</option>
<option value="spring">Spring</option>
<option value="summer">Summer</option>
<option value="fall">Fall</option>
<option value="winter">Winter</option>
</select>
</div>
<div class="form-group">
<label>Temperature</label>
<select id="temperature">
<option value="normal">Normal</option>
<option value="hot">Hot</option>
<option value="cold">Cold</option>
</select>
</div>
<button id="calc-btn">Calculate Watering</button>
</div>
<div class="recommendation" id="recommendation" style="display: none;">
<h3>💧 Recommendation</h3>
<div class="rec-grid">
<div class="rec-item">
<div class="rec-label">Plant</div>
<div class="rec-value" id="rec-plant"></div>
</div>
<div class="rec-item">
<div class="rec-label">Water Amount</div>
<div class="rec-value large" id="rec-amount"></div>
</div>
<div class="rec-item">
<div class="rec-label">Frequency</div>
<div class="rec-value" id="rec-frequency"></div>
</div>
<div class="rec-item">
<div class="rec-label">Pump Duration</div>
<div class="rec-value" id="rec-duration" style="color: #1976d2;"></div>
</div>
<div class="rec-item">
<div class="rec-label">Care Notes</div>
<div style="font-size: 14px; color: #555; line-height: 1.6; margin-bottom: 10px;" id="rec-notes"></div>
<div style="font-size: 13px; color: #777;">
<strong>Light:</strong> <span id="rec-light"></span><br>
<strong>Soil:</strong> <span id="rec-soil"></span>
</div>
</div>
<div style="display: flex; gap: 12px;">
<button id="water-now-btn" style="flex: 1;">💧 Water Now</button>
<button id="schedule-btn" style="flex: 1; background: linear-gradient(135deg, #1976d2 0%, #42a5f5 100%);">📅 Schedule It</button>
</div>
</div>
</div>
</div>
<!-- Schedule Tab -->
<div class="tab-content" id="schedule">
<div class="schedule-form">
<h3 style="margin-bottom: 16px; color: #212529;">Add Schedule</h3>
<div class="form-group">
<label>Name</label>
<input type="text" id="sched-name" placeholder="e.g. Morning watering">
</div>
<div class="form-group">
<label>Time</label>
<input type="time" id="sched-time">
</div>
<div class="form-group">
<label>Duration (seconds)</label>
<input type="number" id="sched-duration" min="1" max="300" value="10">
</div>
<div class="form-group">
<label>Days</label>
<select id="sched-days" multiple style="height: 120px;">
<option value="*">Every day</option>
<option value="1-5">Weekdays (Mon-Fri)</option>
<option value="0,6">Weekends (Sat-Sun)</option>
<option value="0">Sunday</option>
<option value="1">Monday</option>
<option value="2">Tuesday</option>
<option value="3">Wednesday</option>
<option value="4">Thursday</option>
<option value="5">Friday</option>
<option value="6">Saturday</option>
</select>
</div>
<button id="add-schedule-btn">Add Schedule</button>
</div>
<h3 style="margin: 20px 0 12px; color: #212529;">Active Schedules</h3>
<div class="schedule-list" id="schedule-list">
<p style="color: #999; text-align: center; padding: 20px;">No schedules yet</p>
</div>
</div>
<!-- Settings Tab -->
<div class="tab-content" id="settings">
<div class="settings-form">
<h3 style="margin-bottom: 16px; color: #212529;">Tank Settings</h3>
<p style="color: #666; margin-bottom: 18px; font-size: 14px;">💡 <strong>Auto-tracking enabled:</strong> The system automatically deducts water usage from tank level.</p>
<div class="form-group">
<label>Tank Capacity (ml)</label>
<input type="number" id="tank-capacity" min="100" max="10000" step="100">
</div>
<div class="form-group">
<label>Current Level (ml) - Refill Tank Here</label>
<input type="number" id="tank-current" min="0" step="10">
</div>
<button id="update-tank-btn">Update Tank</button>
<h3 style="margin: 28px 0 16px; color: #212529;">Statistics</h3>
<div class="form-group">
<label>Total Volume Dispensed</label>
<input type="text" id="stats-volume" readonly>
</div>
<div class="form-group">
<label>Total Runtime</label>
<input type="text" id="stats-runtime" readonly>
</div>
<button id="reset-stats-btn">Reset Statistics</button>
</div>
</div>
<div class="message" id="message"></div>
</div>
<script>
(function() {
'use strict';
console.log('🌿 Kamoer Control v2.0 - JavaScript loading...');
// State
let statusInterval = null;
let currentRecommendation = null;
// Expose plant action functions globally for onclick handlers
window.deleteMyPlant = deleteMyPlant;
window.markPlantWatered = markPlantWatered;
// DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
function init() {
console.log('✓ DOM ready, initializing app...');
// Tab switching
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', function() {
const targetTab = this.getAttribute('data-tab');
switchTab(targetTab);
});
});
// Manual control buttons
document.querySelectorAll('.controls button').forEach(btn => {
console.log('✓ Attaching click listener to pump button:', btn.textContent);
btn.addEventListener('click', function() {
const duration = parseInt(this.getAttribute('data-duration'));
console.log('🚿 Pump button clicked:', duration, 'seconds');
runPump(duration);
});
});
document.getElementById('custom-run-btn').addEventListener('click', runCustom);
document.getElementById('emergency-stop-btn').addEventListener('click', emergencyStop);
// My Plants
document.getElementById('add-plant-btn').addEventListener('click', addMyPlant);
// Plant assistant - image upload and preview
document.getElementById('plant-photo').addEventListener('change', function(e) {
if (e.target.files && e.target.files[0]) {
previewImage(e.target.files[0]);
}
});
document.getElementById('identify-btn').addEventListener('click', identifyPlant);
document.getElementById('rotate-left-btn').addEventListener('click', function() { rotateImage(-90); });
document.getElementById('rotate-right-btn').addEventListener('click', function() { rotateImage(90); });
document.getElementById('clear-image-btn').addEventListener('click', clearImage);
// Plant assistant - watering calculation
document.getElementById('calc-btn').addEventListener('click', calculateWatering);
document.getElementById('plant-type').addEventListener('change', calculateWatering);
document.getElementById('pot-size').addEventListener('change', calculateWatering);
document.getElementById('season').addEventListener('change', calculateWatering);
document.getElementById('temperature').addEventListener('change', calculateWatering);
// Schedule
document.getElementById('add-schedule-btn').addEventListener('click', addSchedule);
// Settings
document.getElementById('update-tank-btn').addEventListener('click', updateTank);
document.getElementById('reset-stats-btn').addEventListener('click', resetStats);
// Load plants
loadPlants();
// Start status updates
updateStatus();
statusInterval = setInterval(updateStatus, 2000);
console.log('✓ App initialized successfully');
}
function switchTab(tabName) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
document.querySelector('.tab[data-tab="' + tabName + '"]').classList.add('active');
document.getElementById(tabName).classList.add('active');
if (tabName === 'settings') {
updateSettingsForm();
} else if (tabName === 'my-plants') {
loadMyPlants();
}
}
function loadPlants() {
console.log('📋 Loading plants...');
fetch('/api/plants')
.then(r => {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
})
.then(plants => {
console.log('✓ Loaded ' + plants.length + ' plants');
const select = document.getElementById('plant-type');
const newSelect = document.getElementById('new-plant-type');
select.innerHTML = '<option value="">Select a plant...</option>';
newSelect.innerHTML = '<option value="">Select type...</option>';
plants.forEach(p => {
const opt1 = document.createElement('option');
opt1.value = p.id;
opt1.textContent = p.name;
select.appendChild(opt1);
const opt2 = document.createElement('option');
opt2.value = p.id;
opt2.textContent = p.name;
newSelect.appendChild(opt2);
});
})
.catch(err => {
console.error('❌ Failed to load plants:', err);
showMessage('Failed to load plants: ' + err.message, 'error');
});
}
function loadMyPlants() {
fetch('/api/my-plants')
.then(r => r.json())
.then(plants => {
const listDiv = document.getElementById('my-plants-list');
if (plants.length === 0) {
listDiv.innerHTML = '<p style="color: #999; text-align: center; padding: 30px;">No plants added yet. Add your first plant above! 🌱</p>';
return;
}
const today = new Date();
today.setHours(0, 0, 0, 0);
listDiv.innerHTML = plants.map(p => {
// Compute days since last watered (fall back to added date)
const lastWateredStr = p.last_watered || p.added;
const lastWateredDate = lastWateredStr ? new Date(lastWateredStr) : null;
let daysSince = null;
if (lastWateredDate && !isNaN(lastWateredDate)) {
lastWateredDate.setHours(0, 0, 0, 0);
daysSince = Math.floor((today - lastWateredDate) / 86400000);
}
// Colour coding: green < 2 days, yellow 2-5 days, red > 5 days
let statusColor, statusLabel, borderColor;
if (daysSince === null) {
statusColor = '#6c757d'; statusLabel = 'Unknown'; borderColor = '#dee2e6';
} else if (daysSince < 2) {
statusColor = '#28a745'; statusLabel = 'Recently watered'; borderColor = '#28a745';
} else if (daysSince <= 5) {
statusColor = '#fd7e14'; statusLabel = 'Water soon'; borderColor = '#fd7e14';
} else {
statusColor = '#dc3545'; statusLabel = 'Overdue!'; borderColor = '#dc3545';
}
const daysText = daysSince !== null ? `${daysSince}d ago` : '';
return `
<div style="background: white; border: 2px solid ${borderColor}; border-radius: 12px; padding: 16px; margin-bottom: 12px;">
<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 8px;">
<div style="display: flex; align-items: center; gap: 10px;">
<div style="width: 14px; height: 14px; border-radius: 50%; background: ${statusColor}; flex-shrink: 0; margin-top: 3px;" title="${statusLabel}"></div>
<div>
<h4 style="margin: 0; color: #212529;">${p.name}</h4>
<div style="font-size: 13px; color: #6c757d; margin-top: 2px;">${p.plant_type}</div>
</div>
</div>
<button onclick="deleteMyPlant('${p.id}')" style="background: #dc3545; color: white; border: none; padding: 6px 12px; border-radius: 6px; font-size: 12px; cursor: pointer;">🗑️</button>
</div>
<div style="font-size: 13px; color: #495057; line-height: 1.6;">
<div>🪴 Pot: ${p.pot_size}L</div>
${p.location ? `<div>📍 ${p.location}</div>` : ''}
${p.notes ? `<div style="margin-top: 6px; padding: 8px; background: #f8f9fa; border-radius: 6px;">💬 ${p.notes}</div>` : ''}
<div style="margin-top: 8px; display: flex; justify-content: space-between; align-items: center;">
<span style="color: ${statusColor}; font-weight: 600; font-size: 12px;">● ${statusLabel}${daysText ? ' (' + daysText + ')' : ''}</span>
<button onclick="markPlantWatered('${p.id}')" style="background: #17a2b8; color: white; border: none; padding: 4px 10px; border-radius: 6px; font-size: 12px; cursor: pointer;">💧 Watered</button>
</div>
<div style="color: #adb5bd; font-size: 11px; margin-top: 4px;">Added: ${p.added}</div>
</div>
</div>
`;
}).join('');
})
.catch(err => showMessage('Error loading plants: ' + err.message, 'error'));
}
function addMyPlant() {
const name = document.getElementById('new-plant-name').value.trim();
const plantType = document.getElementById('new-plant-type').value;
const potSize = parseFloat(document.getElementById('new-plant-pot').value);
const location = document.getElementById('new-plant-location').value.trim();
const notes = document.getElementById('new-plant-notes').value.trim();
if (!name || !plantType) {
showMessage('Please enter plant name and type', 'error');
return;
}
fetch('/api/my-plants', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
name: name,
plant_type: plantType,
pot_size: potSize,
location: location,
notes: notes
})
})
.then(r => r.json())
.then(data => {
if (data.success) {
showMessage('✓ Plant added!', 'success');
document.getElementById('new-plant-name').value = '';
document.getElementById('new-plant-type').value = '';
document.getElementById('new-plant-pot').value = '5';
document.getElementById('new-plant-location').value = '';
document.getElementById('new-plant-notes').value = '';
loadMyPlants();
}
})
.catch(err => showMessage('Error: ' + err.message, 'error'));
}
function deleteMyPlant(plantId) {
if (!confirm('Delete this plant?')) return;
fetch('/api/my-plants/' + plantId, {
method: 'DELETE'
})
.then(r => r.json())
.then(data => {
if (data.success) {
showMessage('✓ Plant deleted', 'success');
loadMyPlants();
}
})
.catch(err => showMessage('Error: ' + err.message, 'error'));
}
function markPlantWatered(plantId) {
fetch('/api/my-plants/' + plantId + '/water', {method: 'POST'})
.then(r => r.json())
.then(data => {
if (data.success) {
showMessage('✓ Plant marked as watered!', 'success');
loadMyPlants();
}
})
.catch(err => showMessage('Error: ' + err.message, 'error'));
}
function updateTankGauge(pct, remainingMl, capacityMl) {
// SVG circle: r=50, circumference = 2*pi*50 ~= 314.16
const circumference = 314.16;
const arc = document.getElementById('tank-gauge-arc');
const pctText = document.getElementById('tank-gauge-pct');
const volText = document.getElementById('tank-gauge-vol');
if (!arc) return;
const clampedPct = Math.max(0, Math.min(100, pct));
const offset = circumference - (clampedPct / 100) * circumference;
arc.style.strokeDashoffset = offset;
// Colour by level
let colour;
if (clampedPct > 50) {
colour = '#28a745'; // green
} else if (clampedPct >= 20) {
colour = '#fd7e14'; // yellow/orange
} else {
colour = '#dc3545'; // red
}
arc.style.stroke = colour;
pctText.textContent = Math.round(clampedPct) + '%';
pctText.style.fill = colour;
if (remainingMl !== undefined && capacityMl !== undefined) {
volText.textContent = Math.round(remainingMl) + '/' + capacityMl + 'ml';
}
}
function updateStatus() {
fetch('/api/status')
.then(r => {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
})
.then(data => {
const statusEl = document.getElementById('status');
statusEl.textContent = data.running ? 'RUNNING' : 'Stopped';
statusEl.className = 'status-value ' + (data.running ? 'running' : 'stopped');
document.getElementById('last-run').textContent = data.last_run || 'Never';
document.getElementById('total-runtime').textContent = data.total_runtime + 's';
document.getElementById('total-volume').textContent = data.total_volume_ml.toFixed(1) + 'ml';
// Tank circular gauge
const percent = data.tank_capacity_ml > 0
? Math.min(100, (data.tank_remaining_ml / data.tank_capacity_ml) * 100)
: 0;
updateTankGauge(percent, data.tank_remaining_ml, data.tank_capacity_ml);
// Disable buttons while running
document.querySelectorAll('.controls button, #custom-run-btn').forEach(btn => {
btn.disabled = data.running;
});
updateScheduleList(data.schedules);
})
.catch(err => {
console.error('Status update failed:', err);
});
}
let countdownInterval = null;
let pumpEndTime = null;
function runPump(duration) {
showMessage('Starting pump for ' + duration + ' seconds...', 'success');
fetch('/api/run', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({duration: duration})
})
.then(r => r.json())
.then(data => {
if (data.error) {
showMessage('Error: ' + data.error, 'error');
} else {
showMessage('Pump started!', 'success');
startCountdown(duration);
updateStatus();
}
})
.catch(err => showMessage('Error: ' + err.message, 'error'));
}
function startCountdown(duration) {
// Show exact duration (daemon is fast, no overhead)
const totalSeconds = duration;
pumpEndTime = Date.now() + (totalSeconds * 1000);
document.getElementById('countdown-display').style.display = 'block';
if (countdownInterval) clearInterval(countdownInterval);
countdownInterval = setInterval(function() {
const remaining = Math.max(0, Math.ceil((pumpEndTime - Date.now()) / 1000));
if (remaining <= 0) {
clearInterval(countdownInterval);
document.getElementById('countdown-display').style.display = 'none';
updateStatus();
return;
}
const minutes = Math.floor(remaining / 60);
const seconds = remaining % 60;
document.getElementById('countdown-timer').textContent =
minutes + ':' + (seconds < 10 ? '0' : '') + seconds;
}, 100);
}
function emergencyStop() {
console.log('🛑 Emergency stop triggered');
fetch('/api/stop', {
method: 'POST'
})
.then(r => r.json())
.then(data => {
if (data.error) {
showMessage('Stop failed: ' + data.error, 'error');
} else {
showMessage('✓ Pump stopped!', 'success');
if (countdownInterval) clearInterval(countdownInterval);
document.getElementById('countdown-display').style.display = 'none';
updateStatus();
}
})
.catch(err => showMessage('Error: ' + err.message, 'error'));
}
function runCustom() {
const duration = parseInt(document.getElementById('custom-duration').value);
if (duration && duration > 0 && duration <= 300) {
runPump(duration);
} else {
showMessage('Duration must be 1-300 seconds', 'error');
}
}
// Image preview and rotation state
let uploadedImage = null;
let imageRotation = 0;
function identifyPlant() {
const canvas = document.getElementById('image-preview');
if (!uploadedImage) {
showMessage('Please select a photo first', 'error');
return;
}
const resultDiv = document.getElementById('identify-result');
resultDiv.innerHTML = '<div style="color: #1976d2; font-weight: 600;">🔍 Analyzing image...</div>';
resultDiv.style.display = 'block';
// Convert canvas to blob with current rotation
canvas.toBlob(function(blob) {
const formData = new FormData();
formData.append('image', blob, 'plant.jpg');
fetch('/api/identify-plant', {
method: 'POST',
body: formData
})
.then(r => r.json())
.then(data => {
if (data.error) {
resultDiv.innerHTML = '<div style="color: #dc3545; font-weight: 600;">❌ ' + data.error + '</div>';
} else if (data.suggestions && data.suggestions.length > 0) {
// Display multiple suggestions with confidence scores
let html = '<div style="background: white; padding: 14px; border-radius: 8px; border-left: 4px solid #28a745;">';
html += '<div style="font-weight: 600; color: #2e7d32; margin-bottom: 10px;">✅ ' + data.message + '</div>';
data.suggestions.forEach((sugg, idx) => {
const bgColor = idx === 0 ? '#e8f5e9' : '#f8f9fa';
const borderColor = sugg.matched_in_database ? '#28a745' : '#fd7e14';
html += '<div style="background: ' + bgColor + '; padding: 12px; margin-bottom: 8px; border-radius: 6px; border-left: 3px solid ' + borderColor + ';">';
html += '<div style="display: flex; justify-content: space-between; align-items: start; margin-bottom: 6px;">';
html += '<div style="flex: 1;">';
html += '<div style="font-weight: 600; color: #212529;">#' + sugg.rank + ' ' + sugg.display_name + '</div>';
html += '<div style="font-size: 12px; color: #666; font-style: italic;">' + sugg.scientific_name + '</div>';
html += '</div>';
html += '<div style="background: ' + (sugg.confidence > 70 ? '#28a745' : sugg.confidence > 40 ? '#fd7e14' : '#dc3545') + '; color: white; padding: 4px 8px; border-radius: 4px; font-weight: 600; font-size: 12px;">' + sugg.confidence + '%</div>';
html += '</div>';
if (sugg.common_names && sugg.common_names.length > 1) {
html += '<div style="font-size: 11px; color: #777; margin-bottom: 6px;">Also: ' + sugg.common_names.slice(1, 3).join(', ') + '</div>';
}
if (sugg.matched_in_database) {
html += '<div style="font-size: 12px; color: #28a745; margin-bottom: 6px;">✓ Found in database: ' + sugg.database_name + '</div>';
if (sugg.care_info) {
html += '<div style="font-size: 11px; color: #555; margin-bottom: 6px;">Water every ' + sugg.care_info.water_frequency_days + ' days • ' + sugg.care_info.light + '</div>';
}
html += '<button onclick="selectIdentifiedPlant(\'' + sugg.database_key + '\', ' + sugg.estimated_pot_size + ')" style="width: 100%; padding: 8px; font-size: 13px; cursor: pointer; background: #28a745; color: white; border: none; border-radius: 4px; margin-top: 6px;">Use This Plant (Est. pot: ' + sugg.estimated_pot_size + 'L)</button>';
} else {
html += '<div style="font-size: 11px; color: #fd7e14;">⚠️ Not in database - generic recommendations</div>';
}
html += '</div>';
});
html += '</div>';
resultDiv.innerHTML = html;
} else {
resultDiv.innerHTML = '<div style="color: #666; font-weight: 600;">🤷 ' + (data.message || 'Could not identify plant') + '</div>';
}
})
.catch(err => {
resultDiv.innerHTML = '<div style="color: #dc3545; font-weight: 600;">❌ Error: ' + err.message + '</div>';
});
}, 'image/jpeg', 0.9);
}
function selectIdentifiedPlant(plantKey, estimatedPotSize) {
console.log('Selecting plant:', plantKey, 'pot size:', estimatedPotSize);
document.getElementById('plant-type').value = plantKey;
if (estimatedPotSize) {
document.getElementById('pot-size').value = estimatedPotSize;
}
calculateWatering();
showMessage('✓ Plant selected! Scroll down to see recommendations.', 'success');
}
// Make function global for onclick handlers
window.selectIdentifiedPlant = selectIdentifiedPlant;
function previewImage(file) {
if (!file) return;
const reader = new FileReader();
reader.onload = function(e) {
const img = new Image();
img.onload = function() {
uploadedImage = img;
imageRotation = 0;
renderImagePreview();
document.getElementById('image-preview-container').style.display = 'block';
document.getElementById('identify-btn').disabled = false;
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
}
function renderImagePreview() {
if (!uploadedImage) return;
const canvas = document.getElementById('image-preview');
const ctx = canvas.getContext('2d');
// Calculate canvas size based on rotation
const isRotated = imageRotation % 180 !== 0;
const canvasWidth = isRotated ? uploadedImage.height : uploadedImage.width;
const canvasHeight = isRotated ? uploadedImage.width : uploadedImage.height;
// Max width for mobile
const maxWidth = 400;
const scale = canvasWidth > maxWidth ? maxWidth / canvasWidth : 1;
canvas.width = canvasWidth * scale;
canvas.height = canvasHeight * scale;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.save();
// Translate to center and rotate
ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.rotate((imageRotation * Math.PI) / 180);
ctx.drawImage(uploadedImage, -uploadedImage.width * scale / 2, -uploadedImage.height * scale / 2, uploadedImage.width * scale, uploadedImage.height * scale);
ctx.restore();
}
function rotateImage(degrees) {
imageRotation = (imageRotation + degrees) % 360;
renderImagePreview();
}
function clearImage() {
uploadedImage = null;
imageRotation = 0;
document.getElementById('image-preview-container').style.display = 'none';
document.getElementById('plant-photo').value = '';
document.getElementById('identify-btn').disabled = true;
document.getElementById('identify-result').style.display = 'none';
}
function calculateWatering() {
const plantType = document.getElementById('plant-type').value;
const potSize = parseFloat(document.getElementById('pot-size').value);
const season = document.getElementById('season').value;
const temperature = document.getElementById('temperature').value;
if (!plantType || !potSize) {
return;
}
fetch('/api/calculate-watering', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
plant_type: plantType,
pot_size_liters: potSize,
season: season,
temperature: temperature
})
})
.then(r => r.json())
.then(data => {
currentRecommendation = data;
document.getElementById('rec-plant').textContent = data.plant_name;
// Show split watering info if needed
if (data.split_watering) {
document.getElementById('rec-amount').innerHTML =
'<strong>' + data.ml_per_session + ' ml</strong> × ' + data.sessions_per_cycle + ' gange<br>' +
'<small style="color: #666;">(Total: ' + data.total_ml_per_cycle + 'ml fordelt over ' + data.recommended_frequency_days + ' dage)</small>';
document.getElementById('rec-frequency').textContent = 'Hver ' + data.days_between_sessions + '. dag (' + data.sessions_per_cycle + ' gange per cyklus)';
document.getElementById('rec-duration').innerHTML =
'<strong>' + data.seconds_per_session + ' sekunder</strong> per vanding<br>' +
'<small style="color: #666;">(Total: ' + data.pump_duration_seconds + 's per cyklus)</small>';
} else {
document.getElementById('rec-amount').textContent = data.recommended_ml + ' ml';
document.getElementById('rec-frequency').textContent = 'Hver ' + data.recommended_frequency_days + '. dag';
document.getElementById('rec-duration').textContent = data.pump_duration_seconds + ' sekunder';
}
document.getElementById('rec-notes').textContent = data.notes;
document.getElementById('rec-light').textContent = data.light;
document.getElementById('rec-soil').textContent = data.soil;
document.getElementById('recommendation').style.display = 'block';
// Attach event listeners to recommendation buttons
document.getElementById('water-now-btn').onclick = function() {
if (currentRecommendation) {
const duration = currentRecommendation.split_watering
? currentRecommendation.seconds_per_session
: currentRecommendation.pump_duration_seconds;
runPump(duration);
}
};
document.getElementById('schedule-btn').onclick = function() {
if (currentRecommendation) {
document.getElementById('sched-name').value = currentRecommendation.plant_name + ' watering';
const duration = currentRecommendation.split_watering
? Math.round(currentRecommendation.seconds_per_session)
: Math.round(currentRecommendation.pump_duration_seconds);
document.getElementById('sched-duration').value = duration;
switchTab('schedule');
if (currentRecommendation.split_watering) {
showMessage('💡 Tip: Denne plante skal vandes ' + currentRecommendation.sessions_per_cycle + ' gange hver ' + currentRecommendation.days_between_sessions + '. dag. Tilføj flere schedules!', 'success');
} else {
showMessage('Fill in the time and days, then click Add Schedule', 'success');
}
}
};
})
.catch(err => showMessage('Error: ' + err.message, 'error'));
}
function addSchedule() {
const name = document.getElementById('sched-name').value;
const time = document.getElementById('sched-time').value;
const duration = parseInt(document.getElementById('sched-duration').value);
const daysSelect = document.getElementById('sched-days');
const days = Array.from(daysSelect.selectedOptions).map(o => o.value).join(',');
if (!name || !time || !duration || !days) {
showMessage('Please fill all schedule fields', 'error');
return;
}
const parts = time.split(':');
const hour = parseInt(parts[0]);
const minute = parseInt(parts[1]);
fetch('/api/schedule', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
name: name,
hour: hour,
minute: minute,
day_of_week: days,
duration: duration
})
})
.then(r => r.json())
.then(data => {
if (data.error) {
showMessage('Error: ' + data.error, 'error');
} else {
showMessage('Schedule added!', 'success');
document.getElementById('sched-name').value = '';
document.getElementById('sched-time').value = '';
document.getElementById('sched-duration').value = 10;
daysSelect.selectedIndex = -1;
updateStatus();
}
})
.catch(err => showMessage('Error: ' + err.message, 'error'));
}
function updateScheduleList(schedules) {
const list = document.getElementById('schedule-list');
if (!schedules || schedules.length === 0) {
list.innerHTML = '<p style="color: #999; text-align: center; padding: 20px;">No schedules yet</p>';
return;
}
list.innerHTML = schedules.map(s =>
'<div class="schedule-item">' +
'<div class="schedule-info">' +
'<div class="schedule-name">' + s.name + '</div>' +
'<div class="schedule-details">' + s.time + '' + s.days + '' + s.duration + 's (' + (s.duration * 0.2).toFixed(1) + 'ml)</div>' +
'</div>' +
'<button class="btn-delete" data-schedule-id="' + s.id + '">Delete</button>' +
'</div>'
).join('');
// Fix: show actual ml dispensed using FLOW_RATE (200 ml/s)
// The client doesn't have the constant; use 200 ml/s which matches server FLOW_RATE_ML_PER_SEC
list.querySelectorAll('.schedule-item .schedule-details').forEach(function(el, idx) {
try {
const text = el.textContent;
const parts = text.split('');
const durationPart = parts[2] || '';
const matches = durationPart.match(/(\d+)s/);
if (matches) {
const secs = parseInt(matches[1], 10);
const ml = secs * 200;
el.textContent = parts[0].trim() + '' + parts[1].trim() + '' + secs + 's (' + ml + 'ml)';
}
} catch (e) { console.warn('Could not update schedule ml display', e); }
});
// Attach event listeners to delete buttons
list.querySelectorAll('.btn-delete').forEach(btn => {
btn.addEventListener('click', function() {
deleteSchedule(this.getAttribute('data-schedule-id'));
});
});
}
function deleteSchedule(id) {
console.log('Deleting schedule:', id);
if (!confirm('Delete this schedule?')) return;
fetch('/api/schedule/' + id, {
method: 'DELETE'
})
.then(r => r.json())
.then(data => {
if (data.error) {
showMessage('Error: ' + data.error, 'error');
} else {
showMessage('Schedule deleted', 'success');
updateStatus();
}
})
.catch(err => showMessage('Error: ' + err.message, 'error'));
};
function updateSettingsForm() {
fetch('/api/status')
.then(r => r.json())
.then(data => {
document.getElementById('tank-capacity').value = data.tank_capacity_ml;
document.getElementById('tank-current').value = data.tank_remaining_ml.toFixed(0);
document.getElementById('stats-volume').value = data.total_volume_ml.toFixed(1) + ' ml';
document.getElementById('stats-runtime').value = data.total_runtime + ' seconds';
})
.catch(err => console.error('Settings update failed:', err));
}
function updateTank() {
const capacity = parseInt(document.getElementById('tank-capacity').value);
const current = parseInt(document.getElementById('tank-current').value);
fetch('/api/tank', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
capacity: capacity,
remaining: current
})
})
.then(r => r.json())
.then(data => {
if (data.error) {
showMessage('Error: ' + data.error, 'error');
} else {
showMessage('Tank updated!', 'success');
updateStatus();
}
})
.catch(err => showMessage('Error: ' + err.message, 'error'));
}
function resetStats() {
if (!confirm('Reset all statistics? This cannot be undone.')) return;
fetch('/api/reset', {
method: 'POST'
})
.then(r => r.json())
.then(data => {
showMessage('Statistics reset', 'success');
updateStatus();
updateSettingsForm();
})
.catch(err => showMessage('Error: ' + err.message, 'error'));
}
function showMessage(text, type) {
const msgEl = document.getElementById('message');
msgEl.textContent = text;
msgEl.className = 'message ' + type + ' show';
setTimeout(function() {
msgEl.className = 'message';
}, 5000);
}
})();
</script>
</body>
</html>'''
# Load saved data and restore schedules on startup
load_data()
for schedule in pump_state['schedules']:
try:
trigger = CronTrigger(
hour=schedule['hour'],
minute=schedule['minute'],
day_of_week=schedule['day_of_week']
)
scheduler.add_job(
scheduled_run,
trigger=trigger,
args=[schedule['id'], schedule['duration']],
id=schedule['id'],
replace_existing=True
)
logging.info(f"Restored schedule: {schedule['name']}")
except Exception as e:
logging.error(f"Error restoring schedule {schedule['id']}: {e}")
if __name__ == '__main__':
try:
logging.info("Starting Kamoer Pump Control v2.1")
logging.info(f"Bluetooth MAC: {MAC_ADDRESS}")
logging.info(f"Web interface: http://0.0.0.0:5000")
app.run(host='0.0.0.0', port=5001, debug=False, threaded=True)
except KeyboardInterrupt:
logging.info("Shutting down gracefully...")
scheduler.shutdown()
except Exception as e:
logging.error(f"Fatal error: {e}", exc_info=True)
raise