This commit is contained in:
koenieeee
2025-12-11 20:11:52 +01:00
parent 79a30f7d70
commit 1ec255fc80
8 changed files with 640 additions and 66 deletions
+10 -1
View File
@@ -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
+9 -4
View File
@@ -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
+2
View File
@@ -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
View File
@@ -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])
+115
View File
@@ -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()
+51
View File
@@ -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
View File
@@ -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
+57 -1
View File
@@ -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>