- 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>
2023 lines
82 KiB
Python
2023 lines
82 KiB
Python
#!/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
|