mirror of
https://github.com/koenieee/DigitalPianoPicnic.git
synced 2026-04-28 03:29:36 +00:00
Backup
This commit is contained in:
+10
-1
@@ -6,6 +6,15 @@ ha:
|
||||
# Token source: 'env' to use HA_TOKEN environment variable, or 'file' for secrets.yaml
|
||||
token_source: env
|
||||
|
||||
# Picnic API connection
|
||||
picnic:
|
||||
# Country code: NL, DE, etc.
|
||||
country_code: NL
|
||||
|
||||
# Credentials are set via environment variables:
|
||||
# PICNIC_USERNAME (email or phone number)
|
||||
# PICNIC_PASSWORD
|
||||
|
||||
# MIDI input configuration
|
||||
midi:
|
||||
# Port name: empty string for auto-select, or exact name like "Digital Piano USB"
|
||||
@@ -85,7 +94,7 @@ announce:
|
||||
preannounce: false
|
||||
|
||||
# Message template (use {product_name} placeholder)
|
||||
message_template: "{product_name} was added to basket"
|
||||
message_template: "{product_name} toegevoegd aan mandje!"
|
||||
|
||||
# Path to note mapping file
|
||||
mapping_file: config/mapping.yaml
|
||||
|
||||
@@ -23,13 +23,18 @@ fi
|
||||
|
||||
# Prompt for HA token if not already in service file
|
||||
if grep -q "your_token_here" "$SERVICE_FILE"; then
|
||||
echo "⚠️ Warning: Service file contains placeholder token"
|
||||
echo "⚠️ Warning: Service file contains placeholder credentials"
|
||||
echo ""
|
||||
read -p "Enter your HA_TOKEN: " HA_TOKEN
|
||||
read -p "Enter your PICNIC_USERNAME (email/phone): " PICNIC_USERNAME
|
||||
read -s -p "Enter your PICNIC_PASSWORD: " PICNIC_PASSWORD
|
||||
echo ""
|
||||
read -p "Enter your HA_TOKEN (or press Enter to edit manually): " HA_TOKEN
|
||||
|
||||
if [ ! -z "$HA_TOKEN" ]; then
|
||||
if [ ! -z "$HA_TOKEN" ] && [ ! -z "$PICNIC_USERNAME" ] && [ ! -z "$PICNIC_PASSWORD" ]; then
|
||||
sed -i "s/your_token_here/$HA_TOKEN/" "$SERVICE_FILE"
|
||||
echo " ✓ Token updated in service file"
|
||||
sed -i "s/your_email_or_phone/$PICNIC_USERNAME/" "$SERVICE_FILE"
|
||||
sed -i "s/your_password/$PICNIC_PASSWORD/" "$SERVICE_FILE"
|
||||
echo " ✓ Credentials updated in service file"
|
||||
else
|
||||
echo " ⊙ Edit $SERVICE_FILE manually before enabling service"
|
||||
fi
|
||||
|
||||
@@ -9,6 +9,8 @@ User=pi
|
||||
Group=pi
|
||||
WorkingDirectory=/home/pi/DigitalPianoPicnic
|
||||
Environment="HA_TOKEN=your_token_here"
|
||||
Environment="PICNIC_USERNAME=your_email_or_phone"
|
||||
Environment="PICNIC_PASSWORD=your_password"
|
||||
ExecStart=/home/pi/DigitalPianoPicnic/venv/bin/python3 /home/pi/DigitalPianoPicnic/src/bridge.py
|
||||
Restart=on-failure
|
||||
RestartSec=10
|
||||
|
||||
+118
-40
@@ -29,6 +29,7 @@ except ImportError:
|
||||
|
||||
from midi import MidiInput, MidiEvent
|
||||
from ha_client import HAClient, ServiceCallResult
|
||||
from picnic_client import PicnicClient, ProductAddResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -52,7 +53,7 @@ class ProductMapping:
|
||||
class ArmingStateMachine:
|
||||
"""Manages arming/disarming state with sequence and/or chord detection."""
|
||||
|
||||
def __init__(self, config: Dict[str, Any], ha_client: Optional['HAClient'] = None):
|
||||
def __init__(self, config: Dict[str, Any], announce_config: Dict[str, Any] = None, ha_client: Optional['HAClient'] = None):
|
||||
self.enabled = config.get('enabled', True)
|
||||
self.sequence = config.get('sequence', [])
|
||||
self.sequence_timeout_ms = config.get('sequence_timeout_ms', 3000)
|
||||
@@ -63,6 +64,7 @@ class ArmingStateMachine:
|
||||
self.disarm_after_add = config.get('disarm_after_add', False)
|
||||
|
||||
# Announcement config
|
||||
self.announce_config = announce_config or {}
|
||||
self.announce_on_arm = config.get('announce_on_arm', True)
|
||||
self.announce_on_disarm = config.get('announce_on_disarm', True)
|
||||
self.arm_message = config.get('arm_message', 'Piano is now armed and ready for shopping')
|
||||
@@ -85,7 +87,9 @@ class ArmingStateMachine:
|
||||
"""Send announcement via HA satellite."""
|
||||
if self.ha_client:
|
||||
try:
|
||||
result = await self.ha_client.announce(message, device_id=None, preannounce=False)
|
||||
device_id = self.announce_config.get('device_id')
|
||||
preannounce = self.announce_config.get('preannounce', False)
|
||||
result = await self.ha_client.announce(message, device_id=device_id, preannounce=preannounce)
|
||||
if result.success:
|
||||
logger.info(f"Arming announcement sent: {message}")
|
||||
else:
|
||||
@@ -120,9 +124,7 @@ class ArmingStateMachine:
|
||||
if not self.enabled:
|
||||
return ArmingState.ARMED # Always armed if disabled
|
||||
|
||||
self.last_activity = timestamp
|
||||
|
||||
# Check auto-disarm timeout
|
||||
# Check auto-disarm timeout BEFORE updating last_activity
|
||||
if self.state == ArmingState.ARMED:
|
||||
if self.disarm_after_ms > 0:
|
||||
inactive_ms = (timestamp - self.last_activity) * 1000
|
||||
@@ -130,6 +132,9 @@ class ArmingStateMachine:
|
||||
logger.info(f"Auto-disarm after {inactive_ms:.0f}ms inactivity")
|
||||
self.reset()
|
||||
|
||||
# Update last activity timestamp
|
||||
self.last_activity = timestamp
|
||||
|
||||
# If already armed, stay armed
|
||||
if self.state == ArmingState.ARMED:
|
||||
return self.state
|
||||
@@ -138,28 +143,29 @@ class ArmingStateMachine:
|
||||
if self.sequence:
|
||||
self._process_sequence(note, timestamp)
|
||||
|
||||
# Check if we should arm
|
||||
# Check if we should arm (only check in on_note if sequence is configured)
|
||||
# If only chord is configured, arming happens in on_chord()
|
||||
needs_sequence = bool(self.sequence)
|
||||
needs_chord = bool(self.chord)
|
||||
|
||||
if self.require_both:
|
||||
# Need both sequence and chord
|
||||
if self.armed_by_sequence and self.armed_by_chord:
|
||||
previous_state = self.state
|
||||
self.state = ArmingState.ARMED
|
||||
logger.info("System ARMED (sequence + chord)")
|
||||
if previous_state != ArmingState.ARMED and self.announce_on_arm:
|
||||
asyncio.create_task(self._announce(self.arm_message))
|
||||
else:
|
||||
# Need either sequence or chord
|
||||
if (needs_sequence and self.armed_by_sequence) or \
|
||||
(needs_chord and self.armed_by_chord):
|
||||
previous_state = self.state
|
||||
self.state = ArmingState.ARMED
|
||||
trigger = "sequence" if self.armed_by_sequence else "chord"
|
||||
logger.info(f"System ARMED ({trigger})")
|
||||
if previous_state != ArmingState.ARMED and self.announce_on_arm:
|
||||
asyncio.create_task(self._announce(self.arm_message))
|
||||
# Only check arming logic here if we have a sequence
|
||||
if needs_sequence:
|
||||
if self.require_both:
|
||||
# Need both sequence and chord
|
||||
if self.armed_by_sequence and self.armed_by_chord:
|
||||
previous_state = self.state
|
||||
self.state = ArmingState.ARMED
|
||||
logger.info("System ARMED (sequence + chord)")
|
||||
if previous_state != ArmingState.ARMED and self.announce_on_arm:
|
||||
asyncio.create_task(self._announce(self.arm_message))
|
||||
else:
|
||||
# Need either sequence or chord (but we know sequence exists here)
|
||||
if self.armed_by_sequence:
|
||||
previous_state = self.state
|
||||
self.state = ArmingState.ARMED
|
||||
logger.info("System ARMED (sequence)")
|
||||
if previous_state != ArmingState.ARMED and self.announce_on_arm:
|
||||
asyncio.create_task(self._announce(self.arm_message))
|
||||
|
||||
return self.state
|
||||
|
||||
@@ -179,10 +185,12 @@ class ArmingStateMachine:
|
||||
|
||||
self.last_activity = timestamp
|
||||
|
||||
logger.info(f"Chord detected: {sorted(chord_notes)}, Expected: {sorted(self.chord)}")
|
||||
|
||||
# Check if chord matches
|
||||
if chord_notes == self.chord:
|
||||
self.armed_by_chord = True
|
||||
logger.info(f"Arming chord detected: {sorted(chord_notes)}")
|
||||
logger.info(f"Arming chord MATCHED: {sorted(chord_notes)}")
|
||||
|
||||
# Check if we should arm now
|
||||
if not self.require_both or self.armed_by_sequence:
|
||||
@@ -192,6 +200,8 @@ class ArmingStateMachine:
|
||||
logger.info(f"System ARMED ({trigger})")
|
||||
if previous_state != ArmingState.ARMED and self.announce_on_arm:
|
||||
asyncio.create_task(self._announce(self.arm_message))
|
||||
else:
|
||||
logger.info(f"Chord did NOT match (got {sorted(chord_notes)}, expected {sorted(self.chord)})")
|
||||
|
||||
return self.state
|
||||
|
||||
@@ -275,6 +285,7 @@ class Bridge:
|
||||
|
||||
self.midi: Optional[MidiInput] = None
|
||||
self.ha_client: Optional[HAClient] = None
|
||||
self.picnic_client: Optional[PicnicClient] = None
|
||||
|
||||
self.arming_sm: Optional[ArmingStateMachine] = None
|
||||
self.rate_limiter: Optional[RateLimiter] = None
|
||||
@@ -282,6 +293,10 @@ class Bridge:
|
||||
self.running = False
|
||||
self.loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
self.midi_reconnect_delay = 5 # default, overridden from config
|
||||
|
||||
# File watching for mapping file
|
||||
self.mapping_path: Optional[str] = None
|
||||
self.mapping_last_modified = 0.0
|
||||
|
||||
def load_config(self):
|
||||
"""Load configuration from YAML files."""
|
||||
@@ -291,14 +306,45 @@ class Bridge:
|
||||
self.config = yaml.safe_load(f)
|
||||
|
||||
# Load mapping file
|
||||
mapping_path = self.config.get('mapping_file', 'config/mapping.yaml')
|
||||
logger.info(f"Loading mapping from {mapping_path}")
|
||||
|
||||
with open(mapping_path, 'r') as f:
|
||||
self.mapping = yaml.safe_load(f)
|
||||
self.mapping_path = self.config.get('mapping_file', 'config/mapping.yaml')
|
||||
self.reload_mapping()
|
||||
|
||||
logger.info("Configuration loaded successfully")
|
||||
|
||||
def reload_mapping(self):
|
||||
"""Reload mapping file and update last modified time."""
|
||||
try:
|
||||
logger.info(f"Loading mapping from {self.mapping_path}")
|
||||
|
||||
with open(self.mapping_path, 'r') as f:
|
||||
self.mapping = yaml.safe_load(f)
|
||||
|
||||
# Update mapped keys
|
||||
note_mappings = self.mapping.get('notes') or self.mapping.get('note_mappings', {})
|
||||
mapped_count = len(note_mappings)
|
||||
logger.info(f"Loaded {mapped_count} note mappings")
|
||||
|
||||
# Update last modified time
|
||||
import os
|
||||
self.mapping_last_modified = os.path.getmtime(self.mapping_path)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to reload mapping: {e}")
|
||||
|
||||
def check_mapping_file_changed(self):
|
||||
"""Check if mapping file has been modified and reload if needed."""
|
||||
try:
|
||||
import os
|
||||
if self.mapping_path and os.path.exists(self.mapping_path):
|
||||
current_mtime = os.path.getmtime(self.mapping_path)
|
||||
if current_mtime > self.mapping_last_modified:
|
||||
logger.info("Mapping file changed, reloading...")
|
||||
self.reload_mapping()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking mapping file: {e}")
|
||||
return False
|
||||
|
||||
def setup_logging(self):
|
||||
"""Configure logging based on config."""
|
||||
log_config = self.config.get('logging', {})
|
||||
@@ -336,7 +382,8 @@ class Bridge:
|
||||
|
||||
# Initialize arming state machine
|
||||
arming_config = self.config.get('arming', {})
|
||||
self.arming_sm = ArmingStateMachine(arming_config)
|
||||
announce_config = self.config.get('announce', {})
|
||||
self.arming_sm = ArmingStateMachine(arming_config, announce_config)
|
||||
|
||||
# Initialize rate limiter
|
||||
rate_limit_ms = midi_config.get('rate_limit_per_note_ms', 500)
|
||||
@@ -350,7 +397,8 @@ class Bridge:
|
||||
|
||||
def get_product_mapping(self, note: int) -> Optional[ProductMapping]:
|
||||
"""Get product mapping for a note."""
|
||||
note_mappings = self.mapping.get('notes', {})
|
||||
# Support both 'notes' and 'note_mappings' keys for backward compatibility
|
||||
note_mappings = self.mapping.get('notes') or self.mapping.get('note_mappings', {})
|
||||
defaults = self.mapping.get('defaults', {})
|
||||
|
||||
# Try both integer and string keys (YAML can parse as either)
|
||||
@@ -375,17 +423,20 @@ class Bridge:
|
||||
note = event.note
|
||||
timestamp = event.timestamp
|
||||
|
||||
logger.info(f"Note pressed: {note} (velocity={event.velocity})")
|
||||
|
||||
# Update arming state
|
||||
self.arming_sm.on_note(note, timestamp)
|
||||
|
||||
# Check if armed
|
||||
if self.arming_sm.state != ArmingState.ARMED:
|
||||
logger.debug(f"Ignoring note {note}: system not armed")
|
||||
logger.info(f"Note {note} ignored: system not armed (state={self.arming_sm.state.name})")
|
||||
return
|
||||
|
||||
# Get product mapping
|
||||
mapping = self.get_product_mapping(note)
|
||||
if not mapping:
|
||||
logger.info(f"Note {note} ignored: no product mapping found")
|
||||
return
|
||||
|
||||
# Check confirmation (double-tap)
|
||||
@@ -428,11 +479,10 @@ class Bridge:
|
||||
logger.info(f" └─ message: '{message}'")
|
||||
logger.info(f" └─ preannounce: {preannounce}")
|
||||
else:
|
||||
# Real mode: actual HA calls
|
||||
result = await self.ha_client.add_product(
|
||||
# Real mode: actual API calls
|
||||
result = self.picnic_client.add_product(
|
||||
product_id=mapping.product_id,
|
||||
amount=mapping.amount,
|
||||
config_entry_id=mapping.config_entry_id
|
||||
amount=mapping.amount
|
||||
)
|
||||
result_success = result.success
|
||||
|
||||
@@ -471,6 +521,9 @@ class Bridge:
|
||||
logger.info("MIDI device connected successfully")
|
||||
|
||||
# Process events
|
||||
last_mapping_check = time.time()
|
||||
mapping_check_interval = 2.0 # Check every 2 seconds
|
||||
|
||||
for event in self.midi.read_events():
|
||||
if not self.running:
|
||||
logger.info("Shutdown requested, stopping MIDI processing")
|
||||
@@ -478,6 +531,11 @@ class Bridge:
|
||||
|
||||
# Skip None events (polling timeouts)
|
||||
if event is None:
|
||||
# Check if mapping file changed (during polling timeout)
|
||||
current_time = time.time()
|
||||
if current_time - last_mapping_check > mapping_check_interval:
|
||||
self.check_mapping_file_changed()
|
||||
last_mapping_check = current_time
|
||||
continue
|
||||
|
||||
try:
|
||||
@@ -550,7 +608,7 @@ class Bridge:
|
||||
self.loop = asyncio.get_event_loop()
|
||||
|
||||
if self.test_mode:
|
||||
logger.info("Bridge starting in TEST MODE (no Home Assistant connection)")
|
||||
logger.info("Bridge starting in TEST MODE (no Home Assistant or Picnic connection)")
|
||||
else:
|
||||
logger.info("Bridge starting...")
|
||||
|
||||
@@ -559,7 +617,27 @@ class Bridge:
|
||||
self.initialize()
|
||||
|
||||
if not self.test_mode:
|
||||
# Get HA credentials
|
||||
# Get Picnic credentials
|
||||
picnic_username = os.getenv('PICNIC_USERNAME')
|
||||
picnic_password = os.getenv('PICNIC_PASSWORD')
|
||||
|
||||
if not picnic_username or not picnic_password:
|
||||
logger.error("PICNIC_USERNAME and PICNIC_PASSWORD environment variables not set")
|
||||
return
|
||||
|
||||
# Connect to Picnic
|
||||
picnic_config = self.config.get('picnic', {})
|
||||
country_code = picnic_config.get('country_code', 'NL')
|
||||
|
||||
self.picnic_client = PicnicClient(picnic_username, picnic_password, country_code)
|
||||
|
||||
if not await self.picnic_client.connect():
|
||||
logger.error("Failed to connect to Picnic API")
|
||||
return
|
||||
|
||||
logger.info("Connected to Picnic API successfully")
|
||||
|
||||
# Get HA credentials for announcements
|
||||
ha_config = self.config.get('ha', {})
|
||||
ha_url = ha_config.get('url')
|
||||
|
||||
@@ -573,7 +651,7 @@ class Bridge:
|
||||
logger.error("Only 'env' token_source is currently supported")
|
||||
return
|
||||
|
||||
# Connect to HA
|
||||
# Connect to HA (for announcements only)
|
||||
runtime_config = self.config.get('runtime', {})
|
||||
reconnect_backoff = runtime_config.get('reconnect_backoff_ms', [500, 1000, 2000, 5000])
|
||||
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""
|
||||
Picnic API client for direct product additions.
|
||||
|
||||
Uses python-picnic-api2 library to add products directly to cart
|
||||
without going through Home Assistant.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
from dataclasses import dataclass
|
||||
|
||||
try:
|
||||
from python_picnic_api2 import PicnicAPI
|
||||
except ImportError:
|
||||
raise ImportError("python-picnic-api2 required. Install with: pip install python-picnic-api2")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProductAddResult:
|
||||
"""Result of adding a product to cart."""
|
||||
success: bool
|
||||
error_message: Optional[str] = None
|
||||
|
||||
|
||||
class PicnicClient:
|
||||
"""Direct Picnic API client."""
|
||||
|
||||
def __init__(self, username: str, password: str, country_code: str = "NL"):
|
||||
"""
|
||||
Initialize Picnic client.
|
||||
|
||||
Args:
|
||||
username: Picnic account email/phone
|
||||
password: Picnic account password
|
||||
country_code: Country code (NL, DE, etc.)
|
||||
"""
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.country_code = country_code
|
||||
self.api: Optional[PicnicAPI] = None
|
||||
self.authenticated = False
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""
|
||||
Connect and authenticate with Picnic API.
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Connecting to Picnic API for user: {self.username}")
|
||||
|
||||
# Create API instance
|
||||
self.api = PicnicAPI(
|
||||
username=self.username,
|
||||
password=self.password,
|
||||
country_code=self.country_code
|
||||
)
|
||||
|
||||
# Test authentication by getting cart
|
||||
self.api.get_cart()
|
||||
|
||||
self.authenticated = True
|
||||
logger.info("Successfully connected to Picnic API")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to connect to Picnic API: {e}")
|
||||
self.authenticated = False
|
||||
return False
|
||||
|
||||
def add_product(self, product_id: str, amount: int = 1) -> ProductAddResult:
|
||||
"""
|
||||
Add a product to the Picnic cart.
|
||||
|
||||
Args:
|
||||
product_id: Picnic product ID (e.g., 's1018231')
|
||||
amount: Quantity to add
|
||||
|
||||
Returns:
|
||||
ProductAddResult
|
||||
"""
|
||||
if not self.authenticated or not self.api:
|
||||
return ProductAddResult(
|
||||
success=False,
|
||||
error_message="Not authenticated with Picnic API"
|
||||
)
|
||||
|
||||
try:
|
||||
logger.info(f"Adding product to Picnic cart: {product_id} x{amount}")
|
||||
|
||||
# Add product to cart
|
||||
self.api.add_product(product_id, amount)
|
||||
|
||||
logger.info(f"Successfully added {product_id} x{amount} to cart")
|
||||
return ProductAddResult(success=True)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
logger.error(f"Failed to add product {product_id}: {error_msg}")
|
||||
return ProductAddResult(success=False, error_message=error_msg)
|
||||
|
||||
def get_cart(self):
|
||||
"""
|
||||
Get current cart contents.
|
||||
|
||||
Returns:
|
||||
Cart data dictionary
|
||||
"""
|
||||
if not self.authenticated or not self.api:
|
||||
raise RuntimeError("Not authenticated with Picnic API")
|
||||
|
||||
return self.api.get_cart()
|
||||
@@ -366,6 +366,57 @@ def save_mapping():
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/mapping/<int:note>', methods=['DELETE'])
|
||||
def delete_mapping(note):
|
||||
"""Delete a key mapping from config"""
|
||||
try:
|
||||
import yaml
|
||||
|
||||
print(f"\n🗑️ Deleting mapping for note: {note}")
|
||||
print(f"Config path: {config_path}")
|
||||
|
||||
if not config_path.exists():
|
||||
print(f"✗ Config file not found at: {config_path}")
|
||||
return jsonify({'error': 'Config file not found'}), 404
|
||||
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
config = yaml.safe_load(f) or {}
|
||||
|
||||
note_mappings = config.get('note_mappings', {})
|
||||
note_str = str(note)
|
||||
|
||||
print(f"Available mappings: {list(note_mappings.keys())}")
|
||||
print(f"Looking for note: '{note_str}' (type: {type(note_str)})")
|
||||
|
||||
if note_str not in note_mappings:
|
||||
# Try as integer key as well
|
||||
if note not in note_mappings:
|
||||
print(f"✗ Mapping not found. Keys in file: {list(note_mappings.keys())[:10]}")
|
||||
return jsonify({'error': f'No mapping found for note {note}'}), 404
|
||||
# Found as integer, use that
|
||||
note_key = note
|
||||
else:
|
||||
note_key = note_str
|
||||
|
||||
# Delete the mapping
|
||||
print(f"Deleting key: '{note_key}'")
|
||||
del note_mappings[note_key]
|
||||
config['note_mappings'] = note_mappings
|
||||
|
||||
# Save updated config with proper formatting
|
||||
with open(config_path, 'w', encoding='utf-8') as f:
|
||||
yaml.safe_dump(config, f, default_flow_style=False, allow_unicode=True, sort_keys=False, width=120)
|
||||
|
||||
print(f"✓ Deleted mapping for note {note}")
|
||||
|
||||
return jsonify({'success': True, 'message': f'Deleted mapping for note {note}'})
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Delete error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
def download_and_cache_image(product_id, image_id):
|
||||
"""Download product image from Picnic CDN and cache as base64"""
|
||||
try:
|
||||
|
||||
+278
-20
@@ -27,14 +27,14 @@ function initPianoKeys() {
|
||||
const container = document.getElementById('pianoDisplay');
|
||||
|
||||
// Create full 88-key keyboard (A0 to C8) in one horizontal line
|
||||
html += '<div class="piano-container">';
|
||||
let html = '<div class="piano-container">';
|
||||
html += '<div style="text-align: center; color: #999; margin-bottom: 10px;">88 Keys • A0 (21) to C8 (108) • Scroll horizontally → <span style="color: #4caf50;">■ Green = Already Mapped</span><br><span style="color: #667eea; font-weight: bold;">⌨️ Use your keyboard: QWERTY rows = different octaves, ZXC/ASD/QWE keys = white keys</span></div>';
|
||||
html += '<div class="piano-keys">';
|
||||
|
||||
// Count total white keys (52 for 88-key piano: A0-B0 + 7 octaves + C8)
|
||||
const whiteKeys = [];
|
||||
for (let midi = 21; midi <= 108; midi++) {
|
||||
const noteIndex = (midi - 12) % 12;
|
||||
const noteIndex = midi % 12;
|
||||
const isWhiteKey = [0, 2, 4, 5, 7, 9, 11].includes(noteIndex);
|
||||
if (isWhiteKey) {
|
||||
whiteKeys.push(midi);
|
||||
@@ -43,47 +43,79 @@ function initPianoKeys() {
|
||||
const totalWhiteKeys = whiteKeys.length; // Should be 52
|
||||
|
||||
// Render all white keys with equal spacing
|
||||
const debugKeys = [];
|
||||
const allKeys = [];
|
||||
for (let midi = 21; midi <= 108; midi++) {
|
||||
const noteIndex = (midi - 12) % 12;
|
||||
const noteIndex = midi % 12;
|
||||
const isWhiteKey = [0, 2, 4, 5, 7, 9, 11].includes(noteIndex);
|
||||
|
||||
if (isWhiteKey) {
|
||||
const octave = Math.floor((midi - 12) / 12);
|
||||
const octave = Math.floor(midi / 12) - 1;
|
||||
const noteName = notes[noteIndex] + octave;
|
||||
const isMiddleC = midi === 60;
|
||||
const isMapped = mappedKeys.has(midi);
|
||||
html += `<div class="white-key ${isMiddleC ? 'middle-c' : ''} ${isMapped ? 'mapped' : ''}" onclick="selectKey(${midi})" data-note="${midi}" title="${isMapped ? 'Already mapped' : 'Click to assign'}">
|
||||
|
||||
// Debug: Store first 10 keys for logging
|
||||
if (debugKeys.length < 10) {
|
||||
debugKeys.push(`${noteName}=${midi}`);
|
||||
}
|
||||
|
||||
// Store ALL keys for additional debugging
|
||||
allKeys.push({midi, noteName, noteIndex, octave});
|
||||
|
||||
html += `<div class="white-key ${isMiddleC ? 'middle-c' : ''} ${isMapped ? 'mapped' : ''}" onclick="selectKey(${midi})" data-note="${midi}" title="MIDI ${midi} = ${noteName} - ${isMapped ? 'Already mapped' : 'Click to assign'}">
|
||||
<span class="key-label">${noteName}</span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('First 10 white keys rendered:', debugKeys.join(', '));
|
||||
console.log('Keys around Middle C:', allKeys.slice(20, 28).map(k => `${k.noteName}=${k.midi}`).join(', '));
|
||||
|
||||
// Black keys disabled for now (only white keys can be assigned)
|
||||
// Then, render black keys positioned on top
|
||||
for (let midi = 21; midi <= 108; midi++) {
|
||||
const noteIndex = (midi - 12) % 12;
|
||||
const noteIndex = midi % 12;
|
||||
const isBlackKey = [1, 3, 6, 8, 10].includes(noteIndex);
|
||||
|
||||
if (isBlackKey) {
|
||||
const octave = Math.floor((midi - 12) / 12);
|
||||
const octave = Math.floor(midi / 12) - 1;
|
||||
const noteName = notes[noteIndex] + octave;
|
||||
const isMapped = mappedKeys.has(midi);
|
||||
|
||||
// Calculate position: count white keys before this black key
|
||||
// Calculate which white key this black key comes after
|
||||
// For black keys, we need to count white keys up to and including the previous white key
|
||||
let whiteKeysBefore = 0;
|
||||
for (let note = 21; note < midi; note++) {
|
||||
const nIdx = (note - 12) % 12;
|
||||
for (let note = 21; note <= midi; note++) {
|
||||
const nIdx = note % 12;
|
||||
if ([0, 2, 4, 5, 7, 9, 11].includes(nIdx)) {
|
||||
whiteKeysBefore++;
|
||||
}
|
||||
}
|
||||
// Subtract 1 because we want the white key BEFORE this black key
|
||||
whiteKeysBefore = whiteKeysBefore - 1;
|
||||
|
||||
// Position between two white keys using pixel offset
|
||||
// Each white key is 40px + 2px margin = 42px total
|
||||
// Black key should be centered between white keys
|
||||
// Position black key based on real piano layout
|
||||
// Each white key is 40px + 2px margin = 42px
|
||||
const whiteKeyWidth = 42;
|
||||
const blackKeyWidth = 28;
|
||||
const leftPosition = (whiteKeysBefore * whiteKeyWidth) + (whiteKeyWidth - blackKeyWidth / 2);
|
||||
|
||||
// Black keys are positioned to create visual groups: [C# D#] gap [F# G# A#] gap
|
||||
// C# is between C-D, D# between D-E, F# between F-G, G# between G-A, A# between A-B
|
||||
let positionOffset;
|
||||
if (noteIndex === 1) { // C# - slightly left of center between C and D
|
||||
positionOffset = whiteKeyWidth * 0.75;
|
||||
} else if (noteIndex === 3) { // D# - slightly right of center between D and E
|
||||
positionOffset = whiteKeyWidth * 0.85;
|
||||
} else if (noteIndex === 6) { // F# - slightly left in the group of 3
|
||||
positionOffset = whiteKeyWidth * 0.72;
|
||||
} else if (noteIndex === 8) { // G# - center of the group of 3
|
||||
positionOffset = whiteKeyWidth * 0.80;
|
||||
} else { // A# (10) - slightly right in the group of 3
|
||||
positionOffset = whiteKeyWidth * 0.88;
|
||||
}
|
||||
|
||||
const leftPosition = (whiteKeysBefore * whiteKeyWidth) + positionOffset;
|
||||
|
||||
html += `<div class="black-key ${isMapped ? 'mapped' : ''}" style="left: ${leftPosition}px; pointer-events: none; opacity: 0.5;" title="Black keys disabled for now">
|
||||
<span class="key-label">${noteName}</span>
|
||||
@@ -348,6 +380,224 @@ function closeCart() {
|
||||
document.getElementById('cartModal').style.display = 'none';
|
||||
}
|
||||
|
||||
async function viewConfiguredKeys() {
|
||||
const modal = document.getElementById('configuredKeysModal');
|
||||
const content = document.getElementById('configuredKeysContent');
|
||||
modal.style.display = 'flex';
|
||||
content.innerHTML = '<p style="text-align: center; color: #666;">Loading configured keys...</p>';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/print-data');
|
||||
if (!response.ok) throw new Error('Failed to load configured keys');
|
||||
|
||||
const data = await response.json();
|
||||
const mappings = data.mappings || [];
|
||||
|
||||
if (mappings.length === 0) {
|
||||
content.innerHTML = '<p style="text-align: center; color: #999; padding: 40px;">🎹 No keys configured yet</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by MIDI note number
|
||||
mappings.sort((a, b) => a.note - b.note);
|
||||
|
||||
let html = '<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 15px;">';
|
||||
|
||||
for (const mapping of mappings) {
|
||||
html += '<div style="border: 2px solid #667eea; border-radius: 12px; padding: 15px; background: linear-gradient(to bottom, #ffffff, #f8f9ff); position: relative;">';
|
||||
|
||||
// Key info header
|
||||
html += '<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">';
|
||||
html += '<div style="font-weight: bold; color: #667eea; font-size: 1.1em;">🎹 ' + mapping.note_name + ' (MIDI ' + mapping.note + ')</div>';
|
||||
html += '<button onclick="deleteMapping(' + mapping.note + ')" style="background: #e74c3c; color: white; border: none; padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 12px; font-weight: bold;">🗑️ Delete</button>';
|
||||
html += '</div>';
|
||||
|
||||
// Product image
|
||||
if (mapping.image) {
|
||||
html += '<div style="text-align: center; margin: 10px 0;">';
|
||||
html += '<img src="' + mapping.image + '" alt="' + mapping.product_name + '" style="width: 100px; height: 100px; object-fit: contain; border: 1px solid #e0e0e0; border-radius: 8px; background: white;" />';
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
// Product details
|
||||
html += '<div style="margin-top: 10px;">';
|
||||
html += '<div style="font-weight: bold; color: #333; margin-bottom: 5px;">' + mapping.product_name + '</div>';
|
||||
html += '<div style="color: #666; font-size: 0.85em;">Product ID: ' + mapping.product_id + '</div>';
|
||||
html += '<div style="color: #666; font-size: 0.85em; margin-top: 3px;">Amount: ' + mapping.amount + '</div>';
|
||||
html += '</div>';
|
||||
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += '</div>';
|
||||
content.innerHTML = html;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading configured keys:', error);
|
||||
content.innerHTML = '<p style="text-align: center; color: #e74c3c; padding: 40px;">❌ Error loading configured keys: ' + error.message + '</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function closeConfiguredKeys() {
|
||||
document.getElementById('configuredKeysModal').style.display = 'none';
|
||||
}
|
||||
|
||||
let removeMappingData = {};
|
||||
|
||||
async function openRemoveMappingMode() {
|
||||
const modal = document.getElementById('removeMappingModal');
|
||||
const container = document.getElementById('removePianoDisplay');
|
||||
modal.style.display = 'flex';
|
||||
container.innerHTML = '<p style="text-align: center; color: #666;">Loading mappings...</p>';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/print-data');
|
||||
if (!response.ok) throw new Error('Failed to load mappings');
|
||||
|
||||
const data = await response.json();
|
||||
const mappings = data.mappings || [];
|
||||
|
||||
// Create a map of note -> mapping
|
||||
removeMappingData = {};
|
||||
mappings.forEach(m => {
|
||||
removeMappingData[m.note] = {
|
||||
product_name: m.product_name,
|
||||
product_id: m.product_id,
|
||||
image: m.image,
|
||||
amount: m.amount
|
||||
};
|
||||
});
|
||||
|
||||
// Build piano keyboard
|
||||
let html = '<div class="piano-container">';
|
||||
html += '<div style="text-align: center; color: #999; margin-bottom: 10px;">Click mapped keys to remove products • Hover to see product details</div>';
|
||||
html += '<div class="piano-keys">';
|
||||
|
||||
// Render white keys
|
||||
for (let midi = 21; midi <= 108; midi++) {
|
||||
const noteIndex = midi % 12;
|
||||
const isWhiteKey = [0, 2, 4, 5, 7, 9, 11].includes(noteIndex);
|
||||
|
||||
if (isWhiteKey) {
|
||||
const octave = Math.floor(midi / 12) - 1;
|
||||
const noteName = notes[noteIndex] + octave;
|
||||
const hasMapp = removeMappingData[midi] !== undefined;
|
||||
|
||||
let tooltip = `MIDI ${midi} = ${noteName}`;
|
||||
if (hasMapp) {
|
||||
const mapping = removeMappingData[midi];
|
||||
tooltip += `\n${mapping.product_name}\nAmount: ${mapping.amount}\nClick to remove`;
|
||||
}
|
||||
|
||||
html += `<div class="white-key ${hasMapp ? 'mapped' : ''}"
|
||||
onclick="removeMappingFromKey(${midi})"
|
||||
data-note="${midi}"
|
||||
title="${tooltip}"
|
||||
onmouseenter="showRemoveMappingPreview(${midi}, event)"
|
||||
onmouseleave="hideRemoveMappingPreview()">
|
||||
<span class="key-label">${noteName}</span>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
html += '</div></div>';
|
||||
|
||||
// Add preview tooltip container
|
||||
html += '<div id="removeMappingPreview" style="display: none; position: fixed; background: white; border: 2px solid #333; border-radius: 8px; padding: 10px; box-shadow: 0 4px 8px rgba(0,0,0,0.3); z-index: 10000; max-width: 250px;"></div>';
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading remove mapping mode:', error);
|
||||
container.innerHTML = '<p style="text-align: center; color: #e74c3c; padding: 40px;">❌ Error: ' + error.message + '</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function showRemoveMappingPreview(midiNote, event) {
|
||||
const mapping = removeMappingData[midiNote];
|
||||
if (!mapping) return;
|
||||
|
||||
const preview = document.getElementById('removeMappingPreview');
|
||||
if (!preview) return;
|
||||
|
||||
let html = '<div style="text-align: center;">';
|
||||
if (mapping.image) {
|
||||
html += `<img src="${mapping.image}" style="width: 100px; height: 100px; object-fit: contain; margin-bottom: 8px;" />`;
|
||||
}
|
||||
html += `<div style="font-weight: bold; margin-bottom: 4px;">${mapping.product_name}</div>`;
|
||||
html += `<div style="font-size: 0.9em; color: #666;">Amount: ${mapping.amount}</div>`;
|
||||
html += '<div style="margin-top: 8px; color: #e74c3c; font-weight: bold;">Click to remove</div>';
|
||||
html += '</div>';
|
||||
|
||||
preview.innerHTML = html;
|
||||
preview.style.display = 'block';
|
||||
preview.style.left = (event.pageX + 15) + 'px';
|
||||
preview.style.top = (event.pageY - 50) + 'px';
|
||||
}
|
||||
|
||||
function hideRemoveMappingPreview() {
|
||||
const preview = document.getElementById('removeMappingPreview');
|
||||
if (preview) {
|
||||
preview.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function removeMappingFromKey(midiNote) {
|
||||
const mapping = removeMappingData[midiNote];
|
||||
if (!mapping) return;
|
||||
|
||||
if (!confirm(`Remove "${mapping.product_name}" from this key?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/mapping/' + midiNote, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to delete mapping');
|
||||
}
|
||||
|
||||
// Reload the remove mapping view
|
||||
openRemoveMappingMode();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error deleting mapping:', error);
|
||||
alert('Error deleting mapping: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function closeRemoveMappingMode() {
|
||||
document.getElementById('removeMappingModal').style.display = 'none';
|
||||
hideRemoveMappingPreview();
|
||||
}
|
||||
|
||||
async function deleteMapping(midiNote) {
|
||||
if (!confirm('Are you sure you want to delete this key mapping?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/mapping/' + midiNote, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error || 'Failed to delete mapping');
|
||||
}
|
||||
|
||||
// Reload the configured keys view
|
||||
viewConfiguredKeys();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error deleting mapping:', error);
|
||||
alert('Error deleting mapping: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function assignKeyFromCart(productId, productName, quantity, imageId) {
|
||||
console.log('assignKeyFromCart:', productId, productName, quantity, imageId);
|
||||
// Close cart modal and open key picker
|
||||
@@ -379,9 +629,9 @@ async function openPrintableOverlay() {
|
||||
.cut-line-bottom { position: absolute; width: 100%; height: 0; border-top: 2px dashed #ff0000; bottom: 0; left: 0; z-index: 1; }
|
||||
.key-marker { position: absolute; width: 1px; height: 100%; border-left: 1px dotted #999; top: 0; z-index: 2; }
|
||||
.key-marker-label { position: absolute; bottom: 2px; left: 50%; transform: translateX(-50%); font-size: 6px; color: #666; background: white; padding: 1px 2px; border-radius: 2px; }
|
||||
.product-box { position: absolute; display: flex; flex-direction: column; align-items: center; padding: 1mm; background: white; border: 1.5px solid #333; border-radius: 2mm; box-shadow: 0 1px 3px rgba(0,0,0,0.2); width: 17mm; top: 2mm; transform: translateX(-50%); z-index: 3; }
|
||||
.product-box .product-name { font-size: 7px; color: #333; margin-bottom: 0.5mm; word-wrap: break-word; line-height: 1.0; font-weight: bold; text-align: center; max-height: 5mm; overflow: hidden; }
|
||||
.product-box img { width: 15mm; height: 15mm; object-fit: contain; display: block; }
|
||||
.product-box { position: absolute; display: flex; flex-direction: column; align-items: center; padding: 0.5mm; background: white; border: 1.5px solid #333; border-radius: 2mm; box-shadow: 0 1px 3px rgba(0,0,0,0.2); width: 13mm; top: 2mm; transform: translateX(-50%); z-index: 3; }
|
||||
.product-box .product-name { font-size: 6px; color: #333; margin-bottom: 0.3mm; word-wrap: break-word; line-height: 1.0; font-weight: bold; text-align: center; max-height: 4mm; overflow: hidden; }
|
||||
.product-box img { width: 12mm; height: 12mm; object-fit: contain; display: block; }
|
||||
.connector-line { position: absolute; background: #333; z-index: 2; }
|
||||
.connector-vertical { width: 1px; height: 3mm; top: 18mm; }
|
||||
.connector-horizontal { height: 1px; top: 21mm; }
|
||||
@@ -452,8 +702,8 @@ async function openPrintableOverlay() {
|
||||
const relativePos = keyPositionMm - pageStartMm;
|
||||
const positionPercent = (relativePos / pageWidth) * 100;
|
||||
const notes = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||||
const octave = Math.floor((midiNote - 12) / 12);
|
||||
const noteIndex = (midiNote - 12) % 12;
|
||||
const octave = Math.floor(midiNote / 12) - 1;
|
||||
const noteIndex = midiNote % 12;
|
||||
const noteName = notes[noteIndex] + octave;
|
||||
|
||||
keyPositionsOnPage.push({
|
||||
@@ -558,6 +808,14 @@ function getKeyPositionMm(midiNote) {
|
||||
}
|
||||
|
||||
function selectKey(noteNumber) {
|
||||
console.log('selectKey called with noteNumber:', noteNumber, 'type:', typeof noteNumber);
|
||||
|
||||
// Calculate note name for debugging
|
||||
const noteIndex = noteNumber % 12;
|
||||
const octave = Math.floor(noteNumber / 12) - 1;
|
||||
const noteName = notes[noteIndex] + octave;
|
||||
console.log('This corresponds to:', noteName, '(MIDI', noteNumber + ')');
|
||||
|
||||
closeKeyPicker();
|
||||
|
||||
// If this is from a search result (index >= 0), update the input field
|
||||
@@ -569,7 +827,7 @@ function selectKey(noteNumber) {
|
||||
}
|
||||
|
||||
// Auto-save with confirmation
|
||||
if (confirm(`Assign "${currentProductName}" to piano key ${noteNumber}?`)) {
|
||||
if (confirm(`Assign "${currentProductName}" to piano key ${noteName} (MIDI ${noteNumber})?`)) {
|
||||
// For cart items, use the stored quantity
|
||||
const amount = window.currentCartQuantity || 1;
|
||||
const doubleTap = true; // Default to true for safety
|
||||
|
||||
@@ -32,10 +32,36 @@
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
|
||||
margin-right: 10px;
|
||||
margin: 5px;
|
||||
">
|
||||
🖨️ Generate Printable Piano Overlay (122cm)
|
||||
</button>
|
||||
<button onclick="viewConfiguredKeys(); return false;" style="
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 15px 30px;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 15px rgba(240, 147, 251, 0.4);
|
||||
margin: 5px;
|
||||
">
|
||||
🎹 View Configured Keys
|
||||
</button>
|
||||
<button onclick="openRemoveMappingMode(); return false;" style="
|
||||
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 15px 30px;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 15px rgba(231, 76, 60, 0.4);
|
||||
margin: 5px;
|
||||
">
|
||||
🗑️ Remove Key Mappings
|
||||
</button>
|
||||
<button onclick="viewCart(); return false;" style="
|
||||
background: linear-gradient(135deg, #56ab2f 0%, #a8e063 100%);
|
||||
color: white;
|
||||
@@ -45,6 +71,7 @@
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 15px rgba(86, 171, 47, 0.4);
|
||||
margin: 5px;
|
||||
">
|
||||
🛒 View Current Cart
|
||||
</button>
|
||||
@@ -130,6 +157,35 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Configured Keys Modal -->
|
||||
<div class="modal" id="configuredKeysModal" style="display: none;">
|
||||
<div class="modal-content" style="max-width: 1000px; max-height: 90vh; overflow-y: auto; padding: 20px;">
|
||||
<div class="modal-header">
|
||||
<h2 style="color: #333; margin: 0;">🎹 Configured Piano Keys</h2>
|
||||
<button class="modal-close" onclick="closeConfiguredKeys()">×</button>
|
||||
</div>
|
||||
<p style="color: #666; text-align: center; margin: 10px 0;">All products assigned to piano keys</p>
|
||||
<div id="configuredKeysContent" style="margin-top: 20px;">
|
||||
<p style="text-align: center; color: #666;">Loading configured keys...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Remove Mapping Modal -->
|
||||
<div class="modal" id="removeMappingModal" style="display: none;">
|
||||
<div class="modal-content" style="max-width: 95%; padding: 20px;">
|
||||
<div class="modal-header">
|
||||
<h2 style="color: #333; margin: 0;">🗑️ Remove Key Mappings - Hover keys to see products</h2>
|
||||
<button class="modal-close" onclick="closeRemoveMappingMode()">×</button>
|
||||
</div>
|
||||
<p style="color: #666; margin-bottom: 10px; text-align: center;">
|
||||
Click any mapped key (green) to remove its product assignment<br>
|
||||
<strong style="color: #e74c3c;">Red keys have products assigned - hover to see details</strong>
|
||||
</p>
|
||||
<div id="removePianoDisplay"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style="background: white; margin: 20px auto; max-width: 800px; padding: 20px; border-radius: 16px; box-shadow: 0 10px 30px rgba(0,0,0,0.2);">
|
||||
<h3 style="margin-bottom: 15px; color: #333;">🎹 Piano Key Reference (Casio CDP-130)</h3>
|
||||
|
||||
Reference in New Issue
Block a user