First Production Push
@@ -17,3 +17,7 @@ MOSQUITTO_PASSWORD_FILE=/etc/mosquitto/passwd
|
|||||||
# App
|
# App
|
||||||
BACKEND_CORS_ORIGINS=["http://localhost:5173"]
|
BACKEND_CORS_ORIGINS=["http://localhost:5173"]
|
||||||
DEBUG=true
|
DEBUG=true
|
||||||
|
|
||||||
|
# Local file storage (override if you want to store data elsewhere)
|
||||||
|
SQLITE_DB_PATH=./mqtt_data.db
|
||||||
|
BUILT_MELODIES_STORAGE_PATH=./storage/built_melodies
|
||||||
|
|||||||
8
.gitignore
vendored
@@ -2,6 +2,11 @@
|
|||||||
.env
|
.env
|
||||||
firebase-service-account.json
|
firebase-service-account.json
|
||||||
|
|
||||||
|
# Persistent runtime data (lives outside docker, not in git)
|
||||||
|
/data/*
|
||||||
|
!/data/.gitkeep
|
||||||
|
!/data/built_melodies/.gitkeep
|
||||||
|
|
||||||
# Python
|
# Python
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
@@ -20,5 +25,4 @@ dist/
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
MAIN-APP-REFERENCE/
|
.MAIN-APP-REFERENCE/
|
||||||
SecondaryApps/
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
Esperinos-Adamn-1k: 1,0,1,0,1,0,0,0,1,0,1,0,1,0,0,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,0,0
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
Doxology_Traditional: 1,2,1,2,1,3,0,0,1,2,1,2,1,4,0,0,1,2,1,2,1,2,1,2,1,2,1,3,1,4,0,0
|
|
||||||
Doxology_Alternative: 1,0,2,3,0,4+5,0,1,0,2,3,0,4+5,0,1,0,2,3,0,4+5,0,1,2,1,2,3,0,4+5,0
|
|
||||||
Doxology_Festive: 2,3,4+1,3,2,3,5+1,3,2,3,6+1,3,2,3,5+1,3
|
|
||||||
Vesper_Traditional: 1,2,3,0,1,2,3,0,1,2,1,2,1,2,3,0
|
|
||||||
Vesper_Alternative: 1,2,0,0,1,2,0,0,1,3,0,0,0,0,0,0,1,2,0,0,1,2,0,0,1,4,0,0,0,0,0,0,1,2,0,0,1,2,0,0,1,2,0,0,1,2,0,0,1,2,0,0,1,3,0,0,1,4,0,0,0,0,0,0
|
|
||||||
Catehetical: 1,2,3,4,5
|
|
||||||
Orthros_Traditional: 1,0,2,0,3,4,0,5,0,6,0,7,8,0
|
|
||||||
Orthros_Alternative: 1,0,2,1,0,2,0,1,0,1,2,1,0,3,0
|
|
||||||
Mournfull_Toll: 1,0,0,0,0,0,0,1,0,0,0,0,0,0,3,0,0,0,0,0,0,3,0,0,0,0,0,0,2,0,0,0,0,0,0,2,0,0,0,0,0,0,4,0,0,0,0,0,0,4,0,0,0,0,0,0
|
|
||||||
Mournfull_Toll_Alternative: 1,0,0,0,0,0,0,1,1,0,0,0,0,0,0,3,0,0,0,0,0,0,3,3,0,0,0,0,0,0,2,0,0,0,0,0,0,2,2,0,0,0,0,0,0,4,0,0,0,0,0,0,4,4,0,0,0,0,0,0
|
|
||||||
Mournfull_Toll_Meg_Par: 1,0,0,0,0,0,0,1,0,0,0,0,0,0,1,1,0,0,0,0,0,0,3,0,0,0,0,0,0,3,0,0,0,0,0,0,3,3,0,0,0,0,0,0,2,0,0,0,0,0,0,2,0,0,0,0,0,0,2,2,0,0,0,0,0,0,4,0,0,0,0,0,0,4,0,0,0,0,0,0,4,4,0,0,0,0,0,0
|
|
||||||
Sematron: 1,1,1,2,1,1,1,4,1,1,1,2,1,3,1,4
|
|
||||||
Sematron_Alternative: 1,1,1,2,1,1,1,4,1,2,1,3,1,1,1,4
|
|
||||||
Athonite_1_2_Voices: 1,2,1,1,2,1,1,2,1,1,2,1,2
|
|
||||||
Athonite_3_Voices: 2,1,0,0,2,1,0,0,2,1,2,1,2,1,0,1,2,1,0,1,2,1,0,1,2,1,2,1,2,1,0,1,2,1,0,1,2,1,0,1,2,1,2,1,2,1,3,1,2,1,3,1,2,1,3,1,2,1,2,1,2,1,3,1,2,1,3,1,2,1,3,1,2,1,2,1,2,1,3
|
|
||||||
Athonite_3_4_Voices: 2,1,0,0,2,1,0,0,2,1,2,1,2,1,0,1,2,1,0,1,2,1,0,1,2,1,2,1,2,1,0,1,2,1,0,1,2,1,0,1,2,1,2,1,2,1,3,1,2,1,0,3+1,2,1,0,3+1,2,1,0,3+1,2,1,2,3+1,2,1,4,3+1,2,1,0,3+1,2,1,0,3+1,2,1,2,3+1,2,1,4+1,2,1,3+1,2,1,4+2,2,1,3+2,2,1,4+1,2,1,3+1,2,1,4+2,2,1,3+2,2,1,4+1
|
|
||||||
Athonite_4_8_Voices: 2,1,0,0,2,1,0,0,2,1,2,1,2,1,0,1,2,1,0,1,2,1,0,1,2,1,2,1,2,1,0,1,2,1,0,1,2,1,0,1,2,1,2,1,2,1,3,1,2,1,0,3+1,2,1,0,3+1,2,1,0,3+1,2,1,2,3+1,2,1,4,3+1,2,1,0,3+1,2,1,0,3+1,2,1,2,3+1,2,1,4+1,2,1,5+1,2,1,6+2,2,1,8+1,2,1,4+2,2,1,7+1,2,1,5+2,2,1,6+1,2,1,8+2,2,1,4+1,2,1,7+2,2,1,5+1,2,1,6+2,2,1,8+1,2,1,4+2,2,1,7+1,2,1,0,3+1,2,1,0,3+1,2,1,2,3+1,2,1,0,0,0
|
|
||||||
OneByOne_2_3_Voices: 1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,3,1,2,1,3,1,2,1,3,1,2,1,3,1,2,1,3,1,2,1,3,1,2,1,3,1,2,1,3,1,2,1,3,1,2,1,3,1,2,1,3,1,2,1,3,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2
|
|
||||||
OneByOne_4_8_Voices: 1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,2,1,3,1,2,1,3,1,2,1,3,1,2,1,3,2,3,4,3,2,5+1,2,3,4,3,2,6+1,2,3,4,3,2,7+1,2,3,4,3,2,8+1,2,3,4,3,2,7+1,2,3,4,3,2,6+1,2,3,4,3,2,7+1,2,3,4,3,2,8+1,2,3,4,3,2,7+1,2,3,4,3,2,6+1,2,3,4,3,2,1,2,1,3,1,2,1,3,1,2,1,3,1,2,1,3,1,2,1,3,1,2,1,2,1,2,1,2,1,2,1,2,1,0
|
|
||||||
Festive_1Voice: 1,1,1,0,1,1,1,1,0,1,0,1,1,1,0,1,1,1,1,1,1,0,1,0
|
|
||||||
Festive_4Voices: 1,2,3,4+1,2,1,3,4+1
|
|
||||||
Festive_5Voices: 1,2,3,2,1,2,1,2,4,2,1,2,1,2,3,2,1,2,1,2,5,2,1,2
|
|
||||||
Festive_5Voice_Alternative: 3,2,4,1,3,3,2,4,1,5,3,2,4,1,3,3,2,4,1,5+1,3,2,4,1,3,3,2,4,1,5+1,3,2,4,1,3+1,3,2,4,1,5+1,3,2,4,1,3+1,3,2,4,1,5+1,3,2,4,1,3,3,2,4,1,5,3,2,4,1,3,3,2,4,1,5
|
|
||||||
Festive_6Voices: 1,2,3,2,1,2,1,2,4,2,1,2,1,2,3,2,1,2,1,2,5,2,1,2,1,2,4+1,2,1,2,5+1,2,1,2,3+1,2,1,2,6+1,2,1,2,4+1,2,1,2,5+1,2,1,2,3+1,2,1,2,6+1,2,1,2,4+1,2,1,2,5+1,2,1,2,3+1,2,1,2,6+1,2,1,2
|
|
||||||
Festive_8Voices: 1,2,3,4,5,6,7,8
|
|
||||||
Ormilia: 2,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,2,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,2,1,0,1,2,1,0,1,2,1,0,1,0,1,0,1,2,1,2,1,2,1,2,1,0,1,0,1,0,1,0,1,2,4+1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,2,3+1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,2,4+1,0,1,2,3+1,0,1,2,4+1,0,1,2,3+1,0,1,2,5+1,2,1,2,6+1,2,1,2,5+1,2,1,2,6+1,2,7+1,2,8+1,2,4+1,2,7+1,2,8+1,2,4+1,2,7+1,2,8+1,2,3+1,2,1,0
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Bell Melody Converter
|
|
||||||
Converts human-readable bell notation to binary .bsm files for ESP32
|
|
||||||
|
|
||||||
Format: MELODY_NAME: 1,2,3+4,0,5+6+7
|
|
||||||
Output: MELODY_NAME.bsm (binary file, uint16_t big-endian)
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import List, Tuple
|
|
||||||
|
|
||||||
|
|
||||||
def parse_bell_notation(notation: str) -> int:
|
|
||||||
"""
|
|
||||||
Convert human-readable bell notation to bit flag value
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
"2+8" → bells 2,8 → bits 1,7 → 0x0082 (130)
|
|
||||||
"4" → bell 4 → bit 3 → 0x0008 (8)
|
|
||||||
"1+2+3" → bells 1,2,3 → bits 0,1,2 → 0x0007 (7)
|
|
||||||
"0" → no bells → 0x0000 (0)
|
|
||||||
|
|
||||||
Formula: Bell #N → Bit (N-1) → Value = 1 << (N-1)
|
|
||||||
"""
|
|
||||||
notation = notation.strip()
|
|
||||||
|
|
||||||
# Handle zero/silence
|
|
||||||
if notation == '0' or not notation:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
# Split by + to get individual bell numbers
|
|
||||||
bell_numbers = notation.split('+')
|
|
||||||
|
|
||||||
value = 0
|
|
||||||
for bell_str in bell_numbers:
|
|
||||||
try:
|
|
||||||
bell_num = int(bell_str.strip())
|
|
||||||
|
|
||||||
if bell_num == 0:
|
|
||||||
continue # Bell 0 means silence, contributes nothing
|
|
||||||
|
|
||||||
if bell_num < 1 or bell_num > 16:
|
|
||||||
print(f"Warning: Bell number {bell_num} out of range (1-16), skipping")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Convert bell number to bit position (1-indexed to 0-indexed)
|
|
||||||
bit_position = bell_num - 1
|
|
||||||
bit_value = 1 << bit_position
|
|
||||||
value |= bit_value
|
|
||||||
|
|
||||||
except ValueError:
|
|
||||||
print(f"Warning: Invalid bell number '{bell_str}', skipping")
|
|
||||||
continue
|
|
||||||
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def parse_melody_line(line: str) -> Tuple[str, List[int]]:
|
|
||||||
"""
|
|
||||||
Parse a melody line in format: MELODY_NAME: step,step,step
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(melody_name, list of uint16_t values)
|
|
||||||
"""
|
|
||||||
line = line.strip()
|
|
||||||
|
|
||||||
if not line or line.startswith('#'):
|
|
||||||
return None, []
|
|
||||||
|
|
||||||
# Split by colon
|
|
||||||
if ':' not in line:
|
|
||||||
print(f"Warning: Invalid format (missing ':'): {line}")
|
|
||||||
return None, []
|
|
||||||
|
|
||||||
parts = line.split(':', 1)
|
|
||||||
melody_name = parts[0].strip()
|
|
||||||
steps_str = parts[1].strip()
|
|
||||||
|
|
||||||
if not melody_name:
|
|
||||||
print(f"Warning: Empty melody name in line: {line}")
|
|
||||||
return None, []
|
|
||||||
|
|
||||||
# Parse steps (comma-separated)
|
|
||||||
step_strings = steps_str.split(',')
|
|
||||||
values = []
|
|
||||||
|
|
||||||
for i, step_str in enumerate(step_strings):
|
|
||||||
value = parse_bell_notation(step_str)
|
|
||||||
values.append(value)
|
|
||||||
|
|
||||||
return melody_name, values
|
|
||||||
|
|
||||||
|
|
||||||
def write_binary_melody(filepath: str, values: List[int]):
|
|
||||||
"""
|
|
||||||
Write melody values as binary file (uint16_t, big-endian)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
filepath: Output file path
|
|
||||||
values: List of uint16_t values (0-65535)
|
|
||||||
"""
|
|
||||||
with open(filepath, 'wb') as f:
|
|
||||||
for value in values:
|
|
||||||
# Ensure value fits in uint16_t
|
|
||||||
if value > 0xFFFF:
|
|
||||||
print(f"Warning: Value {value} exceeds uint16_t range, truncating")
|
|
||||||
value = value & 0xFFFF
|
|
||||||
|
|
||||||
# Write as 2 bytes, big-endian (MSB first)
|
|
||||||
f.write(value.to_bytes(2, byteorder='big'))
|
|
||||||
|
|
||||||
|
|
||||||
def convert_melodies_file(input_path: str, output_dir: str = '.'):
|
|
||||||
"""
|
|
||||||
Convert multi-melody file to individual .bsm binary files
|
|
||||||
|
|
||||||
Args:
|
|
||||||
input_path: Path to input text file
|
|
||||||
output_dir: Directory for output .bsm files
|
|
||||||
"""
|
|
||||||
output_path = Path(output_dir)
|
|
||||||
output_path.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
melodies_created = 0
|
|
||||||
total_steps = 0
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(input_path, 'r', encoding='utf-8') as f:
|
|
||||||
lines = f.readlines()
|
|
||||||
|
|
||||||
print(f"Reading from: {input_path}")
|
|
||||||
print(f"Output directory: {output_path.absolute()}\n")
|
|
||||||
|
|
||||||
for line_num, line in enumerate(lines, 1):
|
|
||||||
melody_name, values = parse_melody_line(line)
|
|
||||||
|
|
||||||
if melody_name and values:
|
|
||||||
# Create output filename
|
|
||||||
output_file = output_path / f"{melody_name}.bsm"
|
|
||||||
|
|
||||||
# Write binary file
|
|
||||||
write_binary_melody(str(output_file), values)
|
|
||||||
|
|
||||||
# Calculate file size
|
|
||||||
file_size = len(values) * 2 # 2 bytes per uint16_t
|
|
||||||
|
|
||||||
# Show what bells are used
|
|
||||||
all_bells = set()
|
|
||||||
for value in values:
|
|
||||||
for bit in range(16):
|
|
||||||
if value & (1 << bit):
|
|
||||||
all_bells.add(bit + 1) # Convert back to 1-indexed
|
|
||||||
|
|
||||||
bells_str = ','.join(map(str, sorted(all_bells))) if all_bells else 'none'
|
|
||||||
|
|
||||||
print(f"✓ {melody_name}.bsm")
|
|
||||||
print(f" Steps: {len(values)}")
|
|
||||||
print(f" Size: {file_size} bytes")
|
|
||||||
print(f" Bells used: {bells_str}")
|
|
||||||
print()
|
|
||||||
|
|
||||||
melodies_created += 1
|
|
||||||
total_steps += len(values)
|
|
||||||
|
|
||||||
print(f"{'='*50}")
|
|
||||||
print(f"✓ Successfully created {melodies_created} melody files")
|
|
||||||
print(f" Total steps: {total_steps}")
|
|
||||||
print(f" Total size: {total_steps * 2} bytes")
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
except FileNotFoundError:
|
|
||||||
print(f"Error: Input file '{input_path}' not found")
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error: {e}")
|
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
"""Main entry point"""
|
|
||||||
print("=== Bell Melody Converter ===")
|
|
||||||
print("Creates binary .bsm files for ESP32\n")
|
|
||||||
|
|
||||||
# Default input file
|
|
||||||
input_file = "all_melodies.txt"
|
|
||||||
output_dir = "."
|
|
||||||
|
|
||||||
# Check if file exists
|
|
||||||
if not Path(input_file).exists():
|
|
||||||
print(f"Error: '{input_file}' not found in current directory!")
|
|
||||||
print("\nPlease create 'all_melodies.txt' with format:")
|
|
||||||
print(" MELODY_NAME: step,step,step,...")
|
|
||||||
print("\nStep notation:")
|
|
||||||
print(" 0 - Silence")
|
|
||||||
print(" 4 - Bell #4 only")
|
|
||||||
print(" 2+8 - Bells #2 and #8 together")
|
|
||||||
print(" 1+2+3 - Bells #1, #2, and #3 together")
|
|
||||||
print("\nExample:")
|
|
||||||
print(" JINGLE_BELLS: 4,4,4,0,4,4,4,0,4,8,1,2,4")
|
|
||||||
print(" ALARM: 2+8,0,2+8,0,2+8,0")
|
|
||||||
print(" HAPPY_BIRTHDAY: 1,1,2,1,4,3,0,1,1,2,1,8,4")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
success = convert_melodies_file(input_file, output_dir)
|
|
||||||
sys.exit(0 if success else 1)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,300 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Bell Melody to C Header Converter
|
|
||||||
Converts human-readable bell notation to C header file with PROGMEM arrays
|
|
||||||
|
|
||||||
Input Format: MELODY_NAME: 1,2,3+4,0,5+6+7
|
|
||||||
Output: melodies.h (C header with const uint16_t PROGMEM arrays)
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import List, Tuple
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
|
|
||||||
def parse_bell_notation(notation: str) -> int:
|
|
||||||
"""
|
|
||||||
Convert human-readable bell notation to bit flag value
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
"2+8" → bells 2,8 → bits 1,7 → 0x0082 (130)
|
|
||||||
"4" → bell 4 → bit 3 → 0x0008 (8)
|
|
||||||
"1+2+3" → bells 1,2,3 → bits 0,1,2 → 0x0007 (7)
|
|
||||||
"0" → no bells → 0x0000 (0)
|
|
||||||
|
|
||||||
Formula: Bell #N → Bit (N-1) → Value = 1 << (N-1)
|
|
||||||
"""
|
|
||||||
notation = notation.strip()
|
|
||||||
|
|
||||||
# Handle zero/silence
|
|
||||||
if notation == '0' or not notation:
|
|
||||||
return 0
|
|
||||||
|
|
||||||
# Split by + to get individual bell numbers
|
|
||||||
bell_numbers = notation.split('+')
|
|
||||||
|
|
||||||
value = 0
|
|
||||||
for bell_str in bell_numbers:
|
|
||||||
try:
|
|
||||||
bell_num = int(bell_str.strip())
|
|
||||||
|
|
||||||
if bell_num == 0:
|
|
||||||
continue # Bell 0 means silence, contributes nothing
|
|
||||||
|
|
||||||
if bell_num < 1 or bell_num > 16:
|
|
||||||
print(f"Warning: Bell number {bell_num} out of range (1-16), skipping")
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Convert bell number to bit position (1-indexed to 0-indexed)
|
|
||||||
bit_position = bell_num - 1
|
|
||||||
bit_value = 1 << bit_position
|
|
||||||
value |= bit_value
|
|
||||||
|
|
||||||
except ValueError:
|
|
||||||
print(f"Warning: Invalid bell number '{bell_str}', skipping")
|
|
||||||
continue
|
|
||||||
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def parse_melody_line(line: str) -> Tuple[str, List[int]]:
|
|
||||||
"""
|
|
||||||
Parse a melody line in format: MELODY_NAME: step,step,step
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
(melody_name, list of uint16_t values)
|
|
||||||
"""
|
|
||||||
line = line.strip()
|
|
||||||
|
|
||||||
if not line or line.startswith('#'):
|
|
||||||
return None, []
|
|
||||||
|
|
||||||
# Split by colon
|
|
||||||
if ':' not in line:
|
|
||||||
return None, []
|
|
||||||
|
|
||||||
parts = line.split(':', 1)
|
|
||||||
melody_name = parts[0].strip()
|
|
||||||
steps_str = parts[1].strip()
|
|
||||||
|
|
||||||
if not melody_name:
|
|
||||||
return None, []
|
|
||||||
|
|
||||||
# Parse steps (comma-separated)
|
|
||||||
step_strings = steps_str.split(',')
|
|
||||||
values = []
|
|
||||||
|
|
||||||
for step_str in step_strings:
|
|
||||||
value = parse_bell_notation(step_str)
|
|
||||||
values.append(value)
|
|
||||||
|
|
||||||
return melody_name, values
|
|
||||||
|
|
||||||
|
|
||||||
def format_melody_array(melody_name: str, values: List[int], values_per_line: int = 8) -> str:
|
|
||||||
"""
|
|
||||||
Format melody values as C PROGMEM array
|
|
||||||
|
|
||||||
Args:
|
|
||||||
melody_name: Name of the melody (will be prefixed with "builtin_")
|
|
||||||
values: List of uint16_t values
|
|
||||||
values_per_line: Number of hex values per line
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Formatted C array declaration
|
|
||||||
"""
|
|
||||||
array_name = f"melody_builtin_{melody_name.lower()}"
|
|
||||||
|
|
||||||
lines = []
|
|
||||||
lines.append(f"const uint16_t PROGMEM {array_name}[] = {{")
|
|
||||||
|
|
||||||
# Format values in rows
|
|
||||||
for i in range(0, len(values), values_per_line):
|
|
||||||
chunk = values[i:i + values_per_line]
|
|
||||||
hex_values = [f"0x{val:04X}" for val in chunk]
|
|
||||||
|
|
||||||
# Add comma after each value except the last one overall
|
|
||||||
if i + len(chunk) < len(values):
|
|
||||||
line = " " + ", ".join(hex_values) + ","
|
|
||||||
else:
|
|
||||||
line = " " + ", ".join(hex_values)
|
|
||||||
|
|
||||||
lines.append(line)
|
|
||||||
|
|
||||||
lines.append("};")
|
|
||||||
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def format_melody_info_entry(melody_name: str, display_name: str, array_size: int) -> str:
|
|
||||||
"""
|
|
||||||
Format a single MelodyInfo struct entry
|
|
||||||
|
|
||||||
Args:
|
|
||||||
melody_name: Technical name (will be prefixed with "builtin_")
|
|
||||||
display_name: Human-readable name
|
|
||||||
array_size: Number of elements in the melody array
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Formatted struct entry
|
|
||||||
"""
|
|
||||||
array_name = f"melody_builtin_{melody_name.lower()}"
|
|
||||||
id_name = f"builtin_{melody_name.lower()}"
|
|
||||||
|
|
||||||
return f""" {{
|
|
||||||
"{display_name}",
|
|
||||||
"{id_name}",
|
|
||||||
{array_name},
|
|
||||||
sizeof({array_name}) / sizeof(uint16_t)
|
|
||||||
}}"""
|
|
||||||
|
|
||||||
|
|
||||||
def convert_to_header(input_path: str, output_path: str = "melodies.h"):
|
|
||||||
"""
|
|
||||||
Convert multi-melody file to C header file
|
|
||||||
|
|
||||||
Args:
|
|
||||||
input_path: Path to input text file
|
|
||||||
output_path: Path to output .h file
|
|
||||||
"""
|
|
||||||
melodies = [] # List of (name, display_name, values)
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(input_path, 'r', encoding='utf-8') as f:
|
|
||||||
lines = f.readlines()
|
|
||||||
|
|
||||||
print(f"Reading from: {input_path}")
|
|
||||||
print(f"Output file: {output_path}\n")
|
|
||||||
|
|
||||||
# Parse all melodies
|
|
||||||
for line_num, line in enumerate(lines, 1):
|
|
||||||
melody_name, values = parse_melody_line(line)
|
|
||||||
|
|
||||||
if melody_name and values:
|
|
||||||
# Create display name (convert underscores to spaces, title case)
|
|
||||||
display_name = melody_name.replace('_', ' ').title()
|
|
||||||
melodies.append((melody_name, display_name, values))
|
|
||||||
|
|
||||||
print(f"✓ Parsed: {display_name} ({len(values)} steps)")
|
|
||||||
|
|
||||||
if not melodies:
|
|
||||||
print("Error: No valid melodies found in input file")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Generate header file
|
|
||||||
print(f"\n{'='*50}")
|
|
||||||
print(f"Generating C header file...\n")
|
|
||||||
|
|
||||||
with open(output_path, 'w', encoding='utf-8') as f:
|
|
||||||
# Header guard and comments
|
|
||||||
f.write("/*\n")
|
|
||||||
f.write(" * Bell Melodies - Auto-generated\n")
|
|
||||||
f.write(f" * Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
|
|
||||||
f.write(f" * Source: {input_path}\n")
|
|
||||||
f.write(" * \n")
|
|
||||||
f.write(" * This file contains built-in melody definitions for the ESP32 bell controller\n")
|
|
||||||
f.write(" */\n\n")
|
|
||||||
f.write("#ifndef MELODIES_H\n")
|
|
||||||
f.write("#define MELODIES_H\n\n")
|
|
||||||
f.write("#include <Arduino.h>\n\n")
|
|
||||||
|
|
||||||
# Write melody arrays
|
|
||||||
f.write("// ========================================\n")
|
|
||||||
f.write("// Melody Data Arrays\n")
|
|
||||||
f.write("// ========================================\n\n")
|
|
||||||
|
|
||||||
for melody_name, display_name, values in melodies:
|
|
||||||
f.write(f"// {display_name}\n")
|
|
||||||
f.write(format_melody_array(melody_name, values))
|
|
||||||
f.write("\n\n")
|
|
||||||
|
|
||||||
# Write MelodyInfo structure definition
|
|
||||||
f.write("// ========================================\n")
|
|
||||||
f.write("// Melody Information Structure\n")
|
|
||||||
f.write("// ========================================\n\n")
|
|
||||||
f.write("struct MelodyInfo {\n")
|
|
||||||
f.write(" const char* display_name;\n")
|
|
||||||
f.write(" const char* id;\n")
|
|
||||||
f.write(" const uint16_t* data;\n")
|
|
||||||
f.write(" size_t length;\n")
|
|
||||||
f.write("};\n\n")
|
|
||||||
|
|
||||||
# Write melody library array
|
|
||||||
f.write("// ========================================\n")
|
|
||||||
f.write("// Melody Library\n")
|
|
||||||
f.write("// ========================================\n\n")
|
|
||||||
f.write("const MelodyInfo MELODY_LIBRARY[] = {\n")
|
|
||||||
|
|
||||||
for i, (melody_name, display_name, values) in enumerate(melodies):
|
|
||||||
entry = format_melody_info_entry(melody_name, display_name, len(values))
|
|
||||||
|
|
||||||
# Add comma except for last entry
|
|
||||||
if i < len(melodies) - 1:
|
|
||||||
f.write(entry + ",\n")
|
|
||||||
else:
|
|
||||||
f.write(entry + "\n")
|
|
||||||
|
|
||||||
f.write("};\n\n")
|
|
||||||
|
|
||||||
# Add library size constant
|
|
||||||
f.write(f"const size_t MELODY_LIBRARY_SIZE = {len(melodies)};\n\n")
|
|
||||||
|
|
||||||
# Close header guard
|
|
||||||
f.write("#endif // MELODIES_H\n")
|
|
||||||
|
|
||||||
# Summary
|
|
||||||
print(f"✓ Successfully created {output_path}")
|
|
||||||
print(f" Melodies: {len(melodies)}")
|
|
||||||
|
|
||||||
total_steps = sum(len(values) for _, _, values in melodies)
|
|
||||||
print(f" Total steps: {total_steps}")
|
|
||||||
print(f" Estimated PROGMEM usage: {total_steps * 2} bytes")
|
|
||||||
|
|
||||||
print(f"\n{'='*50}")
|
|
||||||
print("Done! Include this file in your ESP32 project.")
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
except FileNotFoundError:
|
|
||||||
print(f"Error: Input file '{input_path}' not found")
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error: {e}")
|
|
||||||
import traceback
|
|
||||||
traceback.print_exc()
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
"""Main entry point"""
|
|
||||||
print("=== Bell Melody to C Header Converter ===")
|
|
||||||
print("Creates melodies.h for ESP32 firmware\n")
|
|
||||||
|
|
||||||
# Default input file
|
|
||||||
input_file = "builtin_melodies.txt"
|
|
||||||
output_file = "melodies.h"
|
|
||||||
|
|
||||||
# Check if file exists
|
|
||||||
if not Path(input_file).exists():
|
|
||||||
print(f"Error: '{input_file}' not found in current directory!")
|
|
||||||
print("\nPlease create 'builtin_melodies.txt' with format:")
|
|
||||||
print(" MELODY_NAME: step,step,step,...")
|
|
||||||
print("\nStep notation:")
|
|
||||||
print(" 0 - Silence")
|
|
||||||
print(" 4 - Bell #4 only")
|
|
||||||
print(" 2+8 - Bells #2 and #8 together")
|
|
||||||
print(" 1+2+3 - Bells #1, #2, and #3 together")
|
|
||||||
print("\nExample:")
|
|
||||||
print(" JINGLE_BELLS: 4,4,4,0,4,4,4,0,4,8,1,2,4")
|
|
||||||
print(" ALARM: 2+8,0,2+8,0,2+8,0")
|
|
||||||
print(" HAPPY_BIRTHDAY: 1,1,2,1,4,3,0,1,1,2,1,8,4")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
success = convert_to_header(input_file, output_file)
|
|
||||||
sys.exit(0 if success else 1)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,475 +0,0 @@
|
|||||||
/*
|
|
||||||
* Bell Melodies - Auto-generated
|
|
||||||
* Generated: 2026-01-20 09:19:43
|
|
||||||
* Source: builtin_melodies.txt
|
|
||||||
*
|
|
||||||
* This file contains built-in melody definitions for the ESP32 bell controller
|
|
||||||
*/
|
|
||||||
|
|
||||||
#ifndef MELODIES_H
|
|
||||||
#define MELODIES_H
|
|
||||||
|
|
||||||
#include <Arduino.h>
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Melody Data Arrays
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
// Doxology Traditional
|
|
||||||
const uint16_t PROGMEM melody_builtin_doxology_traditional[] = {
|
|
||||||
0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0004, 0x0000, 0x0000,
|
|
||||||
0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0008, 0x0000, 0x0000,
|
|
||||||
0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002,
|
|
||||||
0x0001, 0x0002, 0x0001, 0x0004, 0x0001, 0x0008, 0x0000, 0x0000
|
|
||||||
};
|
|
||||||
|
|
||||||
// Doxology Alternative
|
|
||||||
const uint16_t PROGMEM melody_builtin_doxology_alternative[] = {
|
|
||||||
0x0001, 0x0000, 0x0002, 0x0004, 0x0000, 0x0018, 0x0000, 0x0001,
|
|
||||||
0x0000, 0x0002, 0x0004, 0x0000, 0x0018, 0x0000, 0x0001, 0x0000,
|
|
||||||
0x0002, 0x0004, 0x0000, 0x0018, 0x0000, 0x0001, 0x0002, 0x0001,
|
|
||||||
0x0002, 0x0004, 0x0000, 0x0018, 0x0000
|
|
||||||
};
|
|
||||||
|
|
||||||
// Doxology Festive
|
|
||||||
const uint16_t PROGMEM melody_builtin_doxology_festive[] = {
|
|
||||||
0x0002, 0x0004, 0x0009, 0x0004, 0x0002, 0x0004, 0x0011, 0x0004,
|
|
||||||
0x0002, 0x0004, 0x0021, 0x0004, 0x0002, 0x0004, 0x0011, 0x0004
|
|
||||||
};
|
|
||||||
|
|
||||||
// Vesper Traditional
|
|
||||||
const uint16_t PROGMEM melody_builtin_vesper_traditional[] = {
|
|
||||||
0x0001, 0x0002, 0x0004, 0x0000, 0x0001, 0x0002, 0x0004, 0x0000,
|
|
||||||
0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0004, 0x0000
|
|
||||||
};
|
|
||||||
|
|
||||||
// Vesper Alternative
|
|
||||||
const uint16_t PROGMEM melody_builtin_vesper_alternative[] = {
|
|
||||||
0x0001, 0x0002, 0x0000, 0x0000, 0x0001, 0x0002, 0x0000, 0x0000,
|
|
||||||
0x0001, 0x0004, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000,
|
|
||||||
0x0001, 0x0002, 0x0000, 0x0000, 0x0001, 0x0002, 0x0000, 0x0000,
|
|
||||||
0x0001, 0x0008, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000,
|
|
||||||
0x0001, 0x0002, 0x0000, 0x0000, 0x0001, 0x0002, 0x0000, 0x0000,
|
|
||||||
0x0001, 0x0002, 0x0000, 0x0000, 0x0001, 0x0002, 0x0000, 0x0000,
|
|
||||||
0x0001, 0x0002, 0x0000, 0x0000, 0x0001, 0x0004, 0x0000, 0x0000,
|
|
||||||
0x0001, 0x0008, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000
|
|
||||||
};
|
|
||||||
|
|
||||||
// Catehetical
|
|
||||||
const uint16_t PROGMEM melody_builtin_catehetical[] = {
|
|
||||||
0x0001, 0x0002, 0x0004, 0x0008, 0x0010
|
|
||||||
};
|
|
||||||
|
|
||||||
// Orthros Traditional
|
|
||||||
const uint16_t PROGMEM melody_builtin_orthros_traditional[] = {
|
|
||||||
0x0001, 0x0000, 0x0002, 0x0000, 0x0004, 0x0008, 0x0000, 0x0010,
|
|
||||||
0x0000, 0x0020, 0x0000, 0x0040, 0x0080, 0x0000
|
|
||||||
};
|
|
||||||
|
|
||||||
// Orthros Alternative
|
|
||||||
const uint16_t PROGMEM melody_builtin_orthros_alternative[] = {
|
|
||||||
0x0001, 0x0000, 0x0002, 0x0001, 0x0000, 0x0002, 0x0000, 0x0001,
|
|
||||||
0x0000, 0x0001, 0x0002, 0x0001, 0x0000, 0x0004, 0x0000
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mournfull Toll
|
|
||||||
const uint16_t PROGMEM melody_builtin_mournfull_toll[] = {
|
|
||||||
0x0001, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0001,
|
|
||||||
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0004, 0x0000,
|
|
||||||
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0004, 0x0000, 0x0000,
|
|
||||||
0x0000, 0x0000, 0x0000, 0x0000, 0x0002, 0x0000, 0x0000, 0x0000,
|
|
||||||
0x0000, 0x0000, 0x0000, 0x0002, 0x0000, 0x0000, 0x0000, 0x0000,
|
|
||||||
0x0000, 0x0000, 0x0008, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000,
|
|
||||||
0x0000, 0x0008, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mournfull Toll Alternative
|
|
||||||
const uint16_t PROGMEM melody_builtin_mournfull_toll_alternative[] = {
|
|
||||||
0x0001, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0001,
|
|
||||||
0x0001, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0004,
|
|
||||||
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0004, 0x0004,
|
|
||||||
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0002, 0x0000,
|
|
||||||
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0002, 0x0002, 0x0000,
|
|
||||||
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0008, 0x0000, 0x0000,
|
|
||||||
0x0000, 0x0000, 0x0000, 0x0000, 0x0008, 0x0008, 0x0000, 0x0000,
|
|
||||||
0x0000, 0x0000, 0x0000, 0x0000
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mournfull Toll Meg Par
|
|
||||||
const uint16_t PROGMEM melody_builtin_mournfull_toll_meg_par[] = {
|
|
||||||
0x0001, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0001,
|
|
||||||
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0001, 0x0001,
|
|
||||||
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0004, 0x0000,
|
|
||||||
0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0004, 0x0000, 0x0000,
|
|
||||||
0x0000, 0x0000, 0x0000, 0x0000, 0x0004, 0x0004, 0x0000, 0x0000,
|
|
||||||
0x0000, 0x0000, 0x0000, 0x0000, 0x0002, 0x0000, 0x0000, 0x0000,
|
|
||||||
0x0000, 0x0000, 0x0000, 0x0002, 0x0000, 0x0000, 0x0000, 0x0000,
|
|
||||||
0x0000, 0x0000, 0x0002, 0x0002, 0x0000, 0x0000, 0x0000, 0x0000,
|
|
||||||
0x0000, 0x0000, 0x0008, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000,
|
|
||||||
0x0000, 0x0008, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000,
|
|
||||||
0x0008, 0x0008, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000, 0x0000
|
|
||||||
};
|
|
||||||
|
|
||||||
// Sematron
|
|
||||||
const uint16_t PROGMEM melody_builtin_sematron[] = {
|
|
||||||
0x0001, 0x0001, 0x0001, 0x0002, 0x0001, 0x0001, 0x0001, 0x0008,
|
|
||||||
0x0001, 0x0001, 0x0001, 0x0002, 0x0001, 0x0004, 0x0001, 0x0008
|
|
||||||
};
|
|
||||||
|
|
||||||
// Sematron Alternative
|
|
||||||
const uint16_t PROGMEM melody_builtin_sematron_alternative[] = {
|
|
||||||
0x0001, 0x0001, 0x0001, 0x0002, 0x0001, 0x0001, 0x0001, 0x0008,
|
|
||||||
0x0001, 0x0002, 0x0001, 0x0004, 0x0001, 0x0001, 0x0001, 0x0008
|
|
||||||
};
|
|
||||||
|
|
||||||
// Athonite 1 2 Voices
|
|
||||||
const uint16_t PROGMEM melody_builtin_athonite_1_2_voices[] = {
|
|
||||||
0x0001, 0x0002, 0x0001, 0x0001, 0x0002, 0x0001, 0x0001, 0x0002,
|
|
||||||
0x0001, 0x0001, 0x0002, 0x0001, 0x0002
|
|
||||||
};
|
|
||||||
|
|
||||||
// Athonite 3 Voices
|
|
||||||
const uint16_t PROGMEM melody_builtin_athonite_3_voices[] = {
|
|
||||||
0x0002, 0x0001, 0x0000, 0x0000, 0x0002, 0x0001, 0x0000, 0x0000,
|
|
||||||
0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0000, 0x0001,
|
|
||||||
0x0002, 0x0001, 0x0000, 0x0001, 0x0002, 0x0001, 0x0000, 0x0001,
|
|
||||||
0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0000, 0x0001,
|
|
||||||
0x0002, 0x0001, 0x0000, 0x0001, 0x0002, 0x0001, 0x0000, 0x0001,
|
|
||||||
0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0004, 0x0001,
|
|
||||||
0x0002, 0x0001, 0x0004, 0x0001, 0x0002, 0x0001, 0x0004, 0x0001,
|
|
||||||
0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0004, 0x0001,
|
|
||||||
0x0002, 0x0001, 0x0004, 0x0001, 0x0002, 0x0001, 0x0004, 0x0001,
|
|
||||||
0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0004
|
|
||||||
};
|
|
||||||
|
|
||||||
// Athonite 3 4 Voices
|
|
||||||
const uint16_t PROGMEM melody_builtin_athonite_3_4_voices[] = {
|
|
||||||
0x0002, 0x0001, 0x0000, 0x0000, 0x0002, 0x0001, 0x0000, 0x0000,
|
|
||||||
0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0000, 0x0001,
|
|
||||||
0x0002, 0x0001, 0x0000, 0x0001, 0x0002, 0x0001, 0x0000, 0x0001,
|
|
||||||
0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0000, 0x0001,
|
|
||||||
0x0002, 0x0001, 0x0000, 0x0001, 0x0002, 0x0001, 0x0000, 0x0001,
|
|
||||||
0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0004, 0x0001,
|
|
||||||
0x0002, 0x0001, 0x0000, 0x0005, 0x0002, 0x0001, 0x0000, 0x0005,
|
|
||||||
0x0002, 0x0001, 0x0000, 0x0005, 0x0002, 0x0001, 0x0002, 0x0005,
|
|
||||||
0x0002, 0x0001, 0x0008, 0x0005, 0x0002, 0x0001, 0x0000, 0x0005,
|
|
||||||
0x0002, 0x0001, 0x0000, 0x0005, 0x0002, 0x0001, 0x0002, 0x0005,
|
|
||||||
0x0002, 0x0001, 0x0009, 0x0002, 0x0001, 0x0005, 0x0002, 0x0001,
|
|
||||||
0x000A, 0x0002, 0x0001, 0x0006, 0x0002, 0x0001, 0x0009, 0x0002,
|
|
||||||
0x0001, 0x0005, 0x0002, 0x0001, 0x000A, 0x0002, 0x0001, 0x0006,
|
|
||||||
0x0002, 0x0001, 0x0009
|
|
||||||
};
|
|
||||||
|
|
||||||
// Athonite 4 8 Voices
|
|
||||||
const uint16_t PROGMEM melody_builtin_athonite_4_8_voices[] = {
|
|
||||||
0x0002, 0x0001, 0x0000, 0x0000, 0x0002, 0x0001, 0x0000, 0x0000,
|
|
||||||
0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0000, 0x0001,
|
|
||||||
0x0002, 0x0001, 0x0000, 0x0001, 0x0002, 0x0001, 0x0000, 0x0001,
|
|
||||||
0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0000, 0x0001,
|
|
||||||
0x0002, 0x0001, 0x0000, 0x0001, 0x0002, 0x0001, 0x0000, 0x0001,
|
|
||||||
0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0004, 0x0001,
|
|
||||||
0x0002, 0x0001, 0x0000, 0x0005, 0x0002, 0x0001, 0x0000, 0x0005,
|
|
||||||
0x0002, 0x0001, 0x0000, 0x0005, 0x0002, 0x0001, 0x0002, 0x0005,
|
|
||||||
0x0002, 0x0001, 0x0008, 0x0005, 0x0002, 0x0001, 0x0000, 0x0005,
|
|
||||||
0x0002, 0x0001, 0x0000, 0x0005, 0x0002, 0x0001, 0x0002, 0x0005,
|
|
||||||
0x0002, 0x0001, 0x0009, 0x0002, 0x0001, 0x0011, 0x0002, 0x0001,
|
|
||||||
0x0022, 0x0002, 0x0001, 0x0081, 0x0002, 0x0001, 0x000A, 0x0002,
|
|
||||||
0x0001, 0x0041, 0x0002, 0x0001, 0x0012, 0x0002, 0x0001, 0x0021,
|
|
||||||
0x0002, 0x0001, 0x0082, 0x0002, 0x0001, 0x0009, 0x0002, 0x0001,
|
|
||||||
0x0042, 0x0002, 0x0001, 0x0011, 0x0002, 0x0001, 0x0022, 0x0002,
|
|
||||||
0x0001, 0x0081, 0x0002, 0x0001, 0x000A, 0x0002, 0x0001, 0x0041,
|
|
||||||
0x0002, 0x0001, 0x0000, 0x0005, 0x0002, 0x0001, 0x0000, 0x0005,
|
|
||||||
0x0002, 0x0001, 0x0002, 0x0005, 0x0002, 0x0001, 0x0000, 0x0000,
|
|
||||||
0x0000
|
|
||||||
};
|
|
||||||
|
|
||||||
// Onebyone 2 3 Voices
|
|
||||||
const uint16_t PROGMEM melody_builtin_onebyone_2_3_voices[] = {
|
|
||||||
0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000,
|
|
||||||
0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000,
|
|
||||||
0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002,
|
|
||||||
0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002,
|
|
||||||
0x0001, 0x0002, 0x0001, 0x0004, 0x0001, 0x0002, 0x0001, 0x0004,
|
|
||||||
0x0001, 0x0002, 0x0001, 0x0004, 0x0001, 0x0002, 0x0001, 0x0004,
|
|
||||||
0x0001, 0x0002, 0x0001, 0x0004, 0x0001, 0x0002, 0x0001, 0x0004,
|
|
||||||
0x0001, 0x0002, 0x0001, 0x0004, 0x0001, 0x0002, 0x0001, 0x0004,
|
|
||||||
0x0001, 0x0002, 0x0001, 0x0004, 0x0001, 0x0002, 0x0001, 0x0004,
|
|
||||||
0x0001, 0x0002, 0x0001, 0x0004, 0x0001, 0x0002, 0x0001, 0x0004,
|
|
||||||
0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002,
|
|
||||||
0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002
|
|
||||||
};
|
|
||||||
|
|
||||||
// Onebyone 4 8 Voices
|
|
||||||
const uint16_t PROGMEM melody_builtin_onebyone_4_8_voices[] = {
|
|
||||||
0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000,
|
|
||||||
0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000,
|
|
||||||
0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002,
|
|
||||||
0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002,
|
|
||||||
0x0001, 0x0002, 0x0001, 0x0004, 0x0001, 0x0002, 0x0001, 0x0004,
|
|
||||||
0x0001, 0x0002, 0x0001, 0x0004, 0x0001, 0x0002, 0x0001, 0x0004,
|
|
||||||
0x0002, 0x0004, 0x0008, 0x0004, 0x0002, 0x0011, 0x0002, 0x0004,
|
|
||||||
0x0008, 0x0004, 0x0002, 0x0021, 0x0002, 0x0004, 0x0008, 0x0004,
|
|
||||||
0x0002, 0x0041, 0x0002, 0x0004, 0x0008, 0x0004, 0x0002, 0x0081,
|
|
||||||
0x0002, 0x0004, 0x0008, 0x0004, 0x0002, 0x0041, 0x0002, 0x0004,
|
|
||||||
0x0008, 0x0004, 0x0002, 0x0021, 0x0002, 0x0004, 0x0008, 0x0004,
|
|
||||||
0x0002, 0x0041, 0x0002, 0x0004, 0x0008, 0x0004, 0x0002, 0x0081,
|
|
||||||
0x0002, 0x0004, 0x0008, 0x0004, 0x0002, 0x0041, 0x0002, 0x0004,
|
|
||||||
0x0008, 0x0004, 0x0002, 0x0021, 0x0002, 0x0004, 0x0008, 0x0004,
|
|
||||||
0x0002, 0x0001, 0x0002, 0x0001, 0x0004, 0x0001, 0x0002, 0x0001,
|
|
||||||
0x0004, 0x0001, 0x0002, 0x0001, 0x0004, 0x0001, 0x0002, 0x0001,
|
|
||||||
0x0004, 0x0001, 0x0002, 0x0001, 0x0004, 0x0001, 0x0002, 0x0001,
|
|
||||||
0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001,
|
|
||||||
0x0002, 0x0001, 0x0000
|
|
||||||
};
|
|
||||||
|
|
||||||
// Festive 1Voice
|
|
||||||
const uint16_t PROGMEM melody_builtin_festive_1voice[] = {
|
|
||||||
0x0001, 0x0001, 0x0001, 0x0000, 0x0001, 0x0001, 0x0001, 0x0001,
|
|
||||||
0x0000, 0x0001, 0x0000, 0x0001, 0x0001, 0x0001, 0x0000, 0x0001,
|
|
||||||
0x0001, 0x0001, 0x0001, 0x0001, 0x0001, 0x0000, 0x0001, 0x0000
|
|
||||||
};
|
|
||||||
|
|
||||||
// Festive 4Voices
|
|
||||||
const uint16_t PROGMEM melody_builtin_festive_4voices[] = {
|
|
||||||
0x0001, 0x0002, 0x0004, 0x0009, 0x0002, 0x0001, 0x0004, 0x0009
|
|
||||||
};
|
|
||||||
|
|
||||||
// Festive 5Voices
|
|
||||||
const uint16_t PROGMEM melody_builtin_festive_5voices[] = {
|
|
||||||
0x0001, 0x0002, 0x0004, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002,
|
|
||||||
0x0008, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0004, 0x0002,
|
|
||||||
0x0001, 0x0002, 0x0001, 0x0002, 0x0010, 0x0002, 0x0001, 0x0002
|
|
||||||
};
|
|
||||||
|
|
||||||
// Festive 5Voice Alternative
|
|
||||||
const uint16_t PROGMEM melody_builtin_festive_5voice_alternative[] = {
|
|
||||||
0x0004, 0x0002, 0x0008, 0x0001, 0x0004, 0x0004, 0x0002, 0x0008,
|
|
||||||
0x0001, 0x0010, 0x0004, 0x0002, 0x0008, 0x0001, 0x0004, 0x0004,
|
|
||||||
0x0002, 0x0008, 0x0001, 0x0011, 0x0004, 0x0002, 0x0008, 0x0001,
|
|
||||||
0x0004, 0x0004, 0x0002, 0x0008, 0x0001, 0x0011, 0x0004, 0x0002,
|
|
||||||
0x0008, 0x0001, 0x0005, 0x0004, 0x0002, 0x0008, 0x0001, 0x0011,
|
|
||||||
0x0004, 0x0002, 0x0008, 0x0001, 0x0005, 0x0004, 0x0002, 0x0008,
|
|
||||||
0x0001, 0x0011, 0x0004, 0x0002, 0x0008, 0x0001, 0x0004, 0x0004,
|
|
||||||
0x0002, 0x0008, 0x0001, 0x0010, 0x0004, 0x0002, 0x0008, 0x0001,
|
|
||||||
0x0004, 0x0004, 0x0002, 0x0008, 0x0001, 0x0010
|
|
||||||
};
|
|
||||||
|
|
||||||
// Festive 6Voices
|
|
||||||
const uint16_t PROGMEM melody_builtin_festive_6voices[] = {
|
|
||||||
0x0001, 0x0002, 0x0004, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002,
|
|
||||||
0x0008, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0004, 0x0002,
|
|
||||||
0x0001, 0x0002, 0x0001, 0x0002, 0x0010, 0x0002, 0x0001, 0x0002,
|
|
||||||
0x0001, 0x0002, 0x0009, 0x0002, 0x0001, 0x0002, 0x0011, 0x0002,
|
|
||||||
0x0001, 0x0002, 0x0005, 0x0002, 0x0001, 0x0002, 0x0021, 0x0002,
|
|
||||||
0x0001, 0x0002, 0x0009, 0x0002, 0x0001, 0x0002, 0x0011, 0x0002,
|
|
||||||
0x0001, 0x0002, 0x0005, 0x0002, 0x0001, 0x0002, 0x0021, 0x0002,
|
|
||||||
0x0001, 0x0002, 0x0009, 0x0002, 0x0001, 0x0002, 0x0011, 0x0002,
|
|
||||||
0x0001, 0x0002, 0x0005, 0x0002, 0x0001, 0x0002, 0x0021, 0x0002,
|
|
||||||
0x0001, 0x0002
|
|
||||||
};
|
|
||||||
|
|
||||||
// Festive 8Voices
|
|
||||||
const uint16_t PROGMEM melody_builtin_festive_8voices[] = {
|
|
||||||
0x0001, 0x0002, 0x0004, 0x0008, 0x0010, 0x0020, 0x0040, 0x0080
|
|
||||||
};
|
|
||||||
|
|
||||||
// Ormilia
|
|
||||||
const uint16_t PROGMEM melody_builtin_ormilia[] = {
|
|
||||||
0x0002, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001,
|
|
||||||
0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001,
|
|
||||||
0x0002, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001,
|
|
||||||
0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001,
|
|
||||||
0x0002, 0x0001, 0x0000, 0x0001, 0x0002, 0x0001, 0x0000, 0x0001,
|
|
||||||
0x0002, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001,
|
|
||||||
0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001, 0x0002, 0x0001,
|
|
||||||
0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001,
|
|
||||||
0x0002, 0x0009, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001,
|
|
||||||
0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001,
|
|
||||||
0x0002, 0x0005, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001,
|
|
||||||
0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001, 0x0000, 0x0001,
|
|
||||||
0x0002, 0x0009, 0x0000, 0x0001, 0x0002, 0x0005, 0x0000, 0x0001,
|
|
||||||
0x0002, 0x0009, 0x0000, 0x0001, 0x0002, 0x0005, 0x0000, 0x0001,
|
|
||||||
0x0002, 0x0011, 0x0002, 0x0001, 0x0002, 0x0021, 0x0002, 0x0001,
|
|
||||||
0x0002, 0x0011, 0x0002, 0x0001, 0x0002, 0x0021, 0x0002, 0x0041,
|
|
||||||
0x0002, 0x0081, 0x0002, 0x0009, 0x0002, 0x0041, 0x0002, 0x0081,
|
|
||||||
0x0002, 0x0009, 0x0002, 0x0041, 0x0002, 0x0081, 0x0002, 0x0005,
|
|
||||||
0x0002, 0x0001, 0x0000
|
|
||||||
};
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Melody Information Structure
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
struct MelodyInfo {
|
|
||||||
const char* display_name;
|
|
||||||
const char* id;
|
|
||||||
const uint16_t* data;
|
|
||||||
size_t length;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Melody Library
|
|
||||||
// ========================================
|
|
||||||
|
|
||||||
const MelodyInfo MELODY_LIBRARY[] = {
|
|
||||||
{
|
|
||||||
"Doxology Traditional",
|
|
||||||
"builtin_doxology_traditional",
|
|
||||||
melody_builtin_doxology_traditional,
|
|
||||||
sizeof(melody_builtin_doxology_traditional) / sizeof(uint16_t)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Doxology Alternative",
|
|
||||||
"builtin_doxology_alternative",
|
|
||||||
melody_builtin_doxology_alternative,
|
|
||||||
sizeof(melody_builtin_doxology_alternative) / sizeof(uint16_t)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Doxology Festive",
|
|
||||||
"builtin_doxology_festive",
|
|
||||||
melody_builtin_doxology_festive,
|
|
||||||
sizeof(melody_builtin_doxology_festive) / sizeof(uint16_t)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Vesper Traditional",
|
|
||||||
"builtin_vesper_traditional",
|
|
||||||
melody_builtin_vesper_traditional,
|
|
||||||
sizeof(melody_builtin_vesper_traditional) / sizeof(uint16_t)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Vesper Alternative",
|
|
||||||
"builtin_vesper_alternative",
|
|
||||||
melody_builtin_vesper_alternative,
|
|
||||||
sizeof(melody_builtin_vesper_alternative) / sizeof(uint16_t)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Catehetical",
|
|
||||||
"builtin_catehetical",
|
|
||||||
melody_builtin_catehetical,
|
|
||||||
sizeof(melody_builtin_catehetical) / sizeof(uint16_t)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Orthros Traditional",
|
|
||||||
"builtin_orthros_traditional",
|
|
||||||
melody_builtin_orthros_traditional,
|
|
||||||
sizeof(melody_builtin_orthros_traditional) / sizeof(uint16_t)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Orthros Alternative",
|
|
||||||
"builtin_orthros_alternative",
|
|
||||||
melody_builtin_orthros_alternative,
|
|
||||||
sizeof(melody_builtin_orthros_alternative) / sizeof(uint16_t)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Mournfull Toll",
|
|
||||||
"builtin_mournfull_toll",
|
|
||||||
melody_builtin_mournfull_toll,
|
|
||||||
sizeof(melody_builtin_mournfull_toll) / sizeof(uint16_t)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Mournfull Toll Alternative",
|
|
||||||
"builtin_mournfull_toll_alternative",
|
|
||||||
melody_builtin_mournfull_toll_alternative,
|
|
||||||
sizeof(melody_builtin_mournfull_toll_alternative) / sizeof(uint16_t)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Mournfull Toll Meg Par",
|
|
||||||
"builtin_mournfull_toll_meg_par",
|
|
||||||
melody_builtin_mournfull_toll_meg_par,
|
|
||||||
sizeof(melody_builtin_mournfull_toll_meg_par) / sizeof(uint16_t)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Sematron",
|
|
||||||
"builtin_sematron",
|
|
||||||
melody_builtin_sematron,
|
|
||||||
sizeof(melody_builtin_sematron) / sizeof(uint16_t)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Sematron Alternative",
|
|
||||||
"builtin_sematron_alternative",
|
|
||||||
melody_builtin_sematron_alternative,
|
|
||||||
sizeof(melody_builtin_sematron_alternative) / sizeof(uint16_t)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Athonite 1 2 Voices",
|
|
||||||
"builtin_athonite_1_2_voices",
|
|
||||||
melody_builtin_athonite_1_2_voices,
|
|
||||||
sizeof(melody_builtin_athonite_1_2_voices) / sizeof(uint16_t)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Athonite 3 Voices",
|
|
||||||
"builtin_athonite_3_voices",
|
|
||||||
melody_builtin_athonite_3_voices,
|
|
||||||
sizeof(melody_builtin_athonite_3_voices) / sizeof(uint16_t)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Athonite 3 4 Voices",
|
|
||||||
"builtin_athonite_3_4_voices",
|
|
||||||
melody_builtin_athonite_3_4_voices,
|
|
||||||
sizeof(melody_builtin_athonite_3_4_voices) / sizeof(uint16_t)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Athonite 4 8 Voices",
|
|
||||||
"builtin_athonite_4_8_voices",
|
|
||||||
melody_builtin_athonite_4_8_voices,
|
|
||||||
sizeof(melody_builtin_athonite_4_8_voices) / sizeof(uint16_t)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Onebyone 2 3 Voices",
|
|
||||||
"builtin_onebyone_2_3_voices",
|
|
||||||
melody_builtin_onebyone_2_3_voices,
|
|
||||||
sizeof(melody_builtin_onebyone_2_3_voices) / sizeof(uint16_t)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Onebyone 4 8 Voices",
|
|
||||||
"builtin_onebyone_4_8_voices",
|
|
||||||
melody_builtin_onebyone_4_8_voices,
|
|
||||||
sizeof(melody_builtin_onebyone_4_8_voices) / sizeof(uint16_t)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Festive 1Voice",
|
|
||||||
"builtin_festive_1voice",
|
|
||||||
melody_builtin_festive_1voice,
|
|
||||||
sizeof(melody_builtin_festive_1voice) / sizeof(uint16_t)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Festive 4Voices",
|
|
||||||
"builtin_festive_4voices",
|
|
||||||
melody_builtin_festive_4voices,
|
|
||||||
sizeof(melody_builtin_festive_4voices) / sizeof(uint16_t)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Festive 5Voices",
|
|
||||||
"builtin_festive_5voices",
|
|
||||||
melody_builtin_festive_5voices,
|
|
||||||
sizeof(melody_builtin_festive_5voices) / sizeof(uint16_t)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Festive 5Voice Alternative",
|
|
||||||
"builtin_festive_5voice_alternative",
|
|
||||||
melody_builtin_festive_5voice_alternative,
|
|
||||||
sizeof(melody_builtin_festive_5voice_alternative) / sizeof(uint16_t)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Festive 6Voices",
|
|
||||||
"builtin_festive_6voices",
|
|
||||||
melody_builtin_festive_6voices,
|
|
||||||
sizeof(melody_builtin_festive_6voices) / sizeof(uint16_t)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Festive 8Voices",
|
|
||||||
"builtin_festive_8voices",
|
|
||||||
melody_builtin_festive_8voices,
|
|
||||||
sizeof(melody_builtin_festive_8voices) / sizeof(uint16_t)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Ormilia",
|
|
||||||
"builtin_ormilia",
|
|
||||||
melody_builtin_ormilia,
|
|
||||||
sizeof(melody_builtin_ormilia) / sizeof(uint16_t)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const size_t MELODY_LIBRARY_SIZE = 26;
|
|
||||||
|
|
||||||
#endif // MELODIES_H
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
Esperinos-Adamn-1k [min 1426 / mid 572 / max 194]: 1,0,1,0,1,0,0,0,1,0,1,0,1,0,0,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,0,0
|
|
||||||
Esperinos-Eortastikos-1k: 1,1,0,0,1,1,0,0,1,1,0,0,0,0,0,0,1,1,0,0,1,1,0,0,1,1,0,0,0,0,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,1,1,0,0,0,0,0,0
|
|
||||||
Orthros-1k [min 552 / mid 1402 / max 2229]: 1,0,1,0,1,1,0
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
PyQt6>=6.4.0
|
|
||||||
numpy>=1.21.0
|
|
||||||
sounddevice>=0.4.6
|
|
||||||
@@ -7,11 +7,12 @@ from typing import List, Optional
|
|||||||
from builder import database as db
|
from builder import database as db
|
||||||
from builder.models import BuiltMelodyCreate, BuiltMelodyUpdate, BuiltMelodyInDB
|
from builder.models import BuiltMelodyCreate, BuiltMelodyUpdate, BuiltMelodyInDB
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
from config import settings
|
||||||
|
|
||||||
logger = logging.getLogger("builder.service")
|
logger = logging.getLogger("builder.service")
|
||||||
|
|
||||||
# Storage directory for built .bsm files
|
# Storage directory for built .bsm files — configurable via BUILT_MELODIES_STORAGE_PATH env var
|
||||||
STORAGE_DIR = Path(__file__).parent.parent / "storage" / "built_melodies"
|
STORAGE_DIR = Path(settings.built_melodies_storage_path)
|
||||||
|
|
||||||
|
|
||||||
def _ensure_storage_dir():
|
def _ensure_storage_dir():
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ class Settings(BaseSettings):
|
|||||||
sqlite_db_path: str = "./mqtt_data.db"
|
sqlite_db_path: str = "./mqtt_data.db"
|
||||||
mqtt_data_retention_days: int = 90
|
mqtt_data_retention_days: int = 90
|
||||||
|
|
||||||
|
# Local file storage
|
||||||
|
built_melodies_storage_path: str = "./storage/built_melodies"
|
||||||
|
|
||||||
# App
|
# App
|
||||||
backend_cors_origins: str = '["http://localhost:5173"]'
|
backend_cors_origins: str = '["http://localhost:5173"]'
|
||||||
debug: bool = True
|
debug: bool = True
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from typing import List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
@@ -133,9 +133,10 @@ class DeviceUpdate(BaseModel):
|
|||||||
device_photo: Optional[str] = None
|
device_photo: Optional[str] = None
|
||||||
device_location: Optional[str] = None
|
device_location: Optional[str] = None
|
||||||
is_Online: Optional[bool] = None
|
is_Online: Optional[bool] = None
|
||||||
device_attributes: Optional[DeviceAttributes] = None
|
# Use raw dicts so only the fields actually sent are present — no Pydantic defaults
|
||||||
device_subscription: Optional[DeviceSubInformation] = None
|
device_attributes: Optional[Dict[str, Any]] = None
|
||||||
device_stats: Optional[DeviceStatistics] = None
|
device_subscription: Optional[Dict[str, Any]] = None
|
||||||
|
device_stats: Optional[Dict[str, Any]] = None
|
||||||
events_on: Optional[bool] = None
|
events_on: Optional[bool] = None
|
||||||
device_location_coordinates: Optional[str] = None
|
device_location_coordinates: Optional[str] = None
|
||||||
device_melodies_all: Optional[List[MelodyMainItem]] = None
|
device_melodies_all: Optional[List[MelodyMainItem]] = None
|
||||||
|
|||||||
@@ -15,6 +15,33 @@ COLLECTION = "devices"
|
|||||||
SN_CHARS = string.ascii_uppercase + string.digits
|
SN_CHARS = string.ascii_uppercase + string.digits
|
||||||
SN_SEGMENT_LEN = 4
|
SN_SEGMENT_LEN = 4
|
||||||
|
|
||||||
|
# Clock/silence/backlight fields stored as Firestore Timestamps (written as datetime)
|
||||||
|
_TIMESTAMP_FIELD_NAMES = {
|
||||||
|
"daySilenceFrom", "daySilenceTo",
|
||||||
|
"nightSilenceFrom", "nightSilenceTo",
|
||||||
|
"backlightTurnOnTime", "backlightTurnOffTime",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _restore_timestamps(d: dict) -> dict:
|
||||||
|
"""Recursively convert ISO 8601 strings for known timestamp fields to datetime objects.
|
||||||
|
|
||||||
|
Firestore stores Python datetime objects as native Timestamps, which Flutter
|
||||||
|
reads as DateTime. Plain strings would break the Flutter app.
|
||||||
|
"""
|
||||||
|
result = {}
|
||||||
|
for k, v in d.items():
|
||||||
|
if isinstance(v, dict):
|
||||||
|
result[k] = _restore_timestamps(v)
|
||||||
|
elif isinstance(v, str) and k in _TIMESTAMP_FIELD_NAMES:
|
||||||
|
try:
|
||||||
|
result[k] = datetime.fromisoformat(v.replace("Z", "+00:00"))
|
||||||
|
except ValueError:
|
||||||
|
result[k] = v
|
||||||
|
else:
|
||||||
|
result[k] = v
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _generate_serial_number() -> str:
|
def _generate_serial_number() -> str:
|
||||||
"""Generate a unique serial number in the format BS-XXXX-XXXX."""
|
"""Generate a unique serial number in the format BS-XXXX-XXXX."""
|
||||||
@@ -139,6 +166,17 @@ def create_device(data: DeviceCreate) -> DeviceInDB:
|
|||||||
return DeviceInDB(id=doc_ref.id, **doc_data)
|
return DeviceInDB(id=doc_ref.id, **doc_data)
|
||||||
|
|
||||||
|
|
||||||
|
def _deep_merge(base: dict, overrides: dict) -> dict:
|
||||||
|
"""Recursively merge overrides into base, preserving unmentioned nested keys."""
|
||||||
|
result = dict(base)
|
||||||
|
for k, v in overrides.items():
|
||||||
|
if isinstance(v, dict) and isinstance(result.get(k), dict):
|
||||||
|
result[k] = _deep_merge(result[k], v)
|
||||||
|
else:
|
||||||
|
result[k] = v
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def update_device(device_doc_id: str, data: DeviceUpdate) -> DeviceInDB:
|
def update_device(device_doc_id: str, data: DeviceUpdate) -> DeviceInDB:
|
||||||
"""Update an existing device document. Only provided fields are updated."""
|
"""Update an existing device document. Only provided fields are updated."""
|
||||||
db = get_db()
|
db = get_db()
|
||||||
@@ -149,16 +187,16 @@ def update_device(device_doc_id: str, data: DeviceUpdate) -> DeviceInDB:
|
|||||||
|
|
||||||
update_data = data.model_dump(exclude_none=True)
|
update_data = data.model_dump(exclude_none=True)
|
||||||
|
|
||||||
# For nested structs, merge with existing data rather than replacing
|
# Deep-merge nested structs so unmentioned sub-fields are preserved
|
||||||
existing = doc.to_dict()
|
existing = doc.to_dict()
|
||||||
nested_keys = (
|
nested_keys = (
|
||||||
"device_attributes", "device_subscription", "device_stats",
|
"device_attributes", "device_subscription", "device_stats",
|
||||||
)
|
)
|
||||||
for key in nested_keys:
|
for key in nested_keys:
|
||||||
if key in update_data and key in existing:
|
if key in update_data and isinstance(existing.get(key), dict):
|
||||||
merged = {**existing[key], **update_data[key]}
|
update_data[key] = _deep_merge(existing[key], update_data[key])
|
||||||
update_data[key] = merged
|
|
||||||
|
|
||||||
|
update_data = _restore_timestamps(update_data)
|
||||||
doc_ref.update(update_data)
|
doc_ref.update(update_data)
|
||||||
|
|
||||||
updated_doc = doc_ref.get()
|
updated_doc = doc_ref.get()
|
||||||
|
|||||||
0
data/.gitkeep
Normal file
@@ -5,6 +5,10 @@ services:
|
|||||||
env_file: .env
|
env_file: .env
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
|
# Persistent data - lives outside the container
|
||||||
|
- ./data/mqtt_data.db:/app/mqtt_data.db
|
||||||
|
- ./data/built_melodies:/app/storage/built_melodies
|
||||||
|
- ./data/firebase-service-account.json:/app/firebase-service-account.json:ro
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
depends_on: []
|
depends_on: []
|
||||||
|
|||||||
BIN
frontend/public/devices/Strikers/striker_size_1.png
Normal file
|
After Width: | Height: | Size: 9.1 KiB |
BIN
frontend/public/devices/Strikers/striker_size_2.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
frontend/public/devices/Strikers/striker_size_3.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
frontend/public/devices/Strikers/striker_size_4.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
frontend/public/devices/Strikers/striker_size_5.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
frontend/public/devices/Strikers/striker_size_6.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
frontend/public/image-assets/certified_logo.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
@@ -176,11 +176,15 @@ export default function DeviceForm() {
|
|||||||
device_stats: stats,
|
device_stats: stats,
|
||||||
events_on: eventsOn,
|
events_on: eventsOn,
|
||||||
device_location_coordinates: locationCoordinates,
|
device_location_coordinates: locationCoordinates,
|
||||||
|
websocket_url: websocketUrl,
|
||||||
|
churchAssistantURL,
|
||||||
|
// device_melodies_all, device_melodies_favorites, user_list are managed
|
||||||
|
// elsewhere and must NOT be sent here to avoid overwriting them on edit
|
||||||
|
...(!isEdit && {
|
||||||
device_melodies_all: [],
|
device_melodies_all: [],
|
||||||
device_melodies_favorites: [],
|
device_melodies_favorites: [],
|
||||||
user_list: [],
|
user_list: [],
|
||||||
websocket_url: websocketUrl,
|
}),
|
||||||
churchAssistantURL,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let deviceId = id;
|
let deviceId = id;
|
||||||
|
|||||||
@@ -468,13 +468,6 @@ export default function DeviceList() {
|
|||||||
className="flex gap-2"
|
className="flex gap-2"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<button
|
|
||||||
onClick={() => navigate(`/devices/${device.id}/edit`)}
|
|
||||||
className="hover:opacity-80 text-xs cursor-pointer"
|
|
||||||
style={{ color: "var(--text-link)" }}
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setDeleteTarget(device)}
|
onClick={() => setDeleteTarget(device)}
|
||||||
className="hover:opacity-80 text-xs cursor-pointer"
|
className="hover:opacity-80 text-xs cursor-pointer"
|
||||||
|
|||||||
@@ -171,6 +171,940 @@ input[type="range"]::-moz-range-thumb {
|
|||||||
gap: 1.5rem;
|
gap: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.device-tabs-wrap {
|
||||||
|
border-bottom: 1px solid var(--border-primary);
|
||||||
|
padding: 0 0 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-tabs-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-tab-item {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-tab-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 16px;
|
||||||
|
background-color: var(--border-primary);
|
||||||
|
margin: 0 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-tab-btn {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-radius: 0.45rem;
|
||||||
|
padding: 0.4rem 0.7rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
position: relative;
|
||||||
|
--tab-tone: rgba(116, 184, 22, 0.24);
|
||||||
|
--tab-glow: rgba(116, 184, 22, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-tab-btn:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: color-mix(in srgb, var(--tab-tone) 42%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-tab-btn--active {
|
||||||
|
background-color: color-mix(in srgb, var(--tab-tone) 70%, transparent);
|
||||||
|
color: var(--tab-text);
|
||||||
|
box-shadow: 0 0 18px var(--tab-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-tab-btn--dashboard {
|
||||||
|
--tab-tone: rgba(136, 201, 94, 0.34);
|
||||||
|
--tab-glow: rgba(136, 201, 94, 0.32);
|
||||||
|
--tab-text: #baff53;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-tab-btn--general {
|
||||||
|
--tab-tone: rgba(92, 158, 189, 0.34);
|
||||||
|
--tab-glow: rgba(92, 161, 189, 0.32);
|
||||||
|
--tab-text: #7dd6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-tab-btn--bells {
|
||||||
|
--tab-tone: rgba(210, 218, 226, 0.34);
|
||||||
|
--tab-glow: rgba(230, 236, 243, 0.28);
|
||||||
|
--tab-text: #f3f9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-tab-btn--clock {
|
||||||
|
--tab-tone: rgba(233, 199, 99, 0.34);
|
||||||
|
--tab-glow: rgba(233, 199, 99, 0.3);
|
||||||
|
--tab-text: #ffde4d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-tab-btn--warranty {
|
||||||
|
--tab-tone: rgba(177, 94, 194, 0.33);
|
||||||
|
--tab-glow: rgba(182, 98, 185, 0.29);
|
||||||
|
--tab-text: rgba(214, 131, 253, 0.99);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-tab-btn--control {
|
||||||
|
--tab-tone: rgba(216, 106, 106, 0.34);
|
||||||
|
--tab-glow: rgba(215, 105, 105, 0.28);
|
||||||
|
--tab-text: #ff5353;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-tab-stack {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-tab-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-tab-grid--wide > * {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-dashboard-hero {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-dashboard-main-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||||
|
gap: 1.1rem 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-dashboard-subsection {
|
||||||
|
padding-bottom: 0.2rem;
|
||||||
|
border-bottom: 1px solid var(--border-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Dashboard Hero Card ──────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Layout strategy (desktop):
|
||||||
|
* Card is a flex row. Each section is a flex-column with
|
||||||
|
* justify-content: space-between and padding: 10px 0, so the 3 rows
|
||||||
|
* are evenly distributed top-to-bottom regardless of content height.
|
||||||
|
* The card itself has a fixed min-height so all columns are the same.
|
||||||
|
* Row alignment across columns is achieved by keeping the same
|
||||||
|
* flex rhythm — each section always has exactly 3 flex children.
|
||||||
|
* Sections with 2 logical items use a "grow" wrapper for the last one.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ── Outer row: main card + notes card, same height ── */
|
||||||
|
.db-hero-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1100px) {
|
||||||
|
.db-hero-row {
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Main info card ── */
|
||||||
|
.db-hero-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1.75rem 1.5rem;
|
||||||
|
gap: 0;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1100px) {
|
||||||
|
.db-hero-card {
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: stretch;
|
||||||
|
padding: 1.3rem 0;
|
||||||
|
min-height: 320px;
|
||||||
|
row-gap: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Notes sidebar card ── */
|
||||||
|
.db-notes-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: var(--bg-card);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 3.30rem 2.5rem 2.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 100%;
|
||||||
|
gap: 1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1100px) {
|
||||||
|
.db-notes-card {
|
||||||
|
width: 22%;
|
||||||
|
min-width: 200px;
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Vertical/horizontal divider between columns */
|
||||||
|
.db-hero-divider {
|
||||||
|
width: 100%;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border-primary);
|
||||||
|
opacity: 0.6;
|
||||||
|
margin: 1.25rem 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1100px) {
|
||||||
|
.db-hero-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: auto;
|
||||||
|
margin: 1.9rem 0.8rem;
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Photo column ── */
|
||||||
|
.db-hero-photo {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2.5rem 4.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1100px) {
|
||||||
|
.db-hero-photo {
|
||||||
|
padding: 0rem 2rem 0rem 3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pulsing glow animations */
|
||||||
|
@keyframes glow-pulse-online {
|
||||||
|
0%, 100% { opacity: 0.55; transform: translateX(-52%) scale(1); }
|
||||||
|
50% { opacity: 1; transform: translateX(-52%) scale(1.12); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes glow-pulse-offline {
|
||||||
|
0%, 100% { opacity: 0.45; transform: translateX(-52%) scale(1); }
|
||||||
|
50% { opacity: 0.9; transform: translateX(-52%) scale(1.1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Shared glow base */
|
||||||
|
.db-hero-photo--online::before,
|
||||||
|
.db-hero-photo--offline::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
width: 230px;
|
||||||
|
height: 230px;
|
||||||
|
border-radius: 50%;
|
||||||
|
bottom: 10%;
|
||||||
|
left: 53%;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Green pulsing glow when online */
|
||||||
|
.db-hero-photo--online::before {
|
||||||
|
background: radial-gradient(ellipse at center, rgba(169, 247, 95, 0.7) 10%, transparent 55%);
|
||||||
|
animation: glow-pulse-online 2.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Orange-red pulsing glow when offline */
|
||||||
|
.db-hero-photo--offline::before {
|
||||||
|
background: radial-gradient(ellipse at center, rgba(255, 120, 50, 0.65) 10%, transparent 55%);
|
||||||
|
animation: glow-pulse-offline 3.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-hero-photo__img {
|
||||||
|
position: relative; /* sits above the ::before glow */
|
||||||
|
max-width: 160px;
|
||||||
|
max-height: 210px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Info sections ── */
|
||||||
|
/*
|
||||||
|
* Each section is a 3-row grid with fixed row height.
|
||||||
|
* Every child lands in one row slot — identical height regardless of content
|
||||||
|
* (text, progress bar, etc.) — so rows align perfectly across all sections.
|
||||||
|
*/
|
||||||
|
.db-hero-section {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: repeat(3, var(--hero-row-h, 65px));
|
||||||
|
align-items: center; /* centers content within each row */
|
||||||
|
align-content: space-between; /* ← centers the whole row group vertically in the section */
|
||||||
|
padding: 1rem 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1100px) {
|
||||||
|
.db-hero-section {
|
||||||
|
padding: 1.5rem 3rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section 3 – Subscription: wide enough for both fields on one line */
|
||||||
|
.db-hero-section--sub {
|
||||||
|
min-width: 400px;
|
||||||
|
max-width: 560px;
|
||||||
|
flex: 1 1 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section 4 – Uptime/Issue */
|
||||||
|
.db-hero-section--uptime {
|
||||||
|
min-width: 240px;
|
||||||
|
max-width: 340px;
|
||||||
|
flex: 1 1 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-uptime-issue {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Row wrappers ── */
|
||||||
|
.db-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Subscription row 1: Tier left, Warranty right */
|
||||||
|
.db-sub-header-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1.5rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Warranty column: pushed right by space-between, but its own content left-aligned */
|
||||||
|
.db-sub-header-row .db-info-field:last-child {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Info field (label + value pair) ── */
|
||||||
|
.db-info-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-info-label {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
opacity: 0.75;
|
||||||
|
text-transform: uppercase;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* All values use identical styling — no special mono variant */
|
||||||
|
.db-info-value {
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--text-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Progress bars ── */
|
||||||
|
.db-progress-track {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.35);
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 999px;
|
||||||
|
transition: width 0.35s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Issue text ── */
|
||||||
|
.db-issue-source {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #63B3ED;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-issue-body {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 4;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
line-clamp: 4;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Admin Notes ── */
|
||||||
|
.db-notes-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-notes-edit-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.85;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-notes-edit-btn:hover {
|
||||||
|
opacity: 1;
|
||||||
|
color: orange;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-notes-body {
|
||||||
|
font-size: 0.80rem;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.5;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0rem 0.5rem 0rem 0rem;
|
||||||
|
flex: 1;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
min-height: 0; /* important — lets flex item shrink below content size */
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-notes-body:hover {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-notes-textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0.3rem 0.65rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
border: 1px solid var(--border-input);
|
||||||
|
background-color: var(--bg-input);
|
||||||
|
color: var(--text-primary);
|
||||||
|
resize: vertical;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section cards (used in all tabs) ── */
|
||||||
|
.device-section-card {
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background-color: var(--bg-card);
|
||||||
|
padding: 2.25rem 2.5rem 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-section-card__title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid var(--border-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-section-card__title {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.07em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding-bottom: 0;
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pencil edit button on section card titles */
|
||||||
|
.section-edit-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem 0.4rem;
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
transition: color 0.15s ease;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.section-edit-btn:hover {
|
||||||
|
color: #ecc94b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Toggle Switch ── */
|
||||||
|
.toggle-switch {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.toggle-switch__track {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 1.35rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background-color: #374151;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.toggle-switch__track[data-on="true"] {
|
||||||
|
background-color: var(--accent);
|
||||||
|
}
|
||||||
|
.toggle-switch__thumb {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.175rem;
|
||||||
|
left: 0.175rem;
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #fff;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
.toggle-switch__track[data-on="true"] .toggle-switch__thumb {
|
||||||
|
transform: translateX(1.15rem);
|
||||||
|
}
|
||||||
|
.toggle-switch__label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.toggle-switch__label[data-on="true"] {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Leaflet map isolation — prevents map z-indices bleeding over modals ── */
|
||||||
|
.device-map-wrap {
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Bell master strip (between Overview and bell cards) ── */
|
||||||
|
.bell-master-strip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
background-color: var(--bg-card);
|
||||||
|
}
|
||||||
|
.bell-master-strip__left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.bell-master-strip__right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.bell-master-strip__warning-badge {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
border-radius: 0.3rem;
|
||||||
|
background-color: rgba(99, 179, 237, 0.1);
|
||||||
|
color: #63b3ed;
|
||||||
|
border: 1px solid rgba(99, 179, 237, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Status text warning tone (for voided warranty) ── */
|
||||||
|
.device-status-text--warning {
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-subtitle {
|
||||||
|
font-size: 0.74rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-hero-image-wrap {
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: radial-gradient(circle at 20% 20%, rgba(116, 184, 22, 0.18), rgba(17, 24, 39, 0.6));
|
||||||
|
min-height: 180px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-hero-image-wrap--square {
|
||||||
|
min-height: 280px;
|
||||||
|
max-width: 400px;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-hero-image {
|
||||||
|
max-height: 280px;
|
||||||
|
max-width: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-snapshot-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1rem 1.2rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-progress-track {
|
||||||
|
width: 100%;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background-color: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 999px;
|
||||||
|
transition: width 0.35s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-field-row {
|
||||||
|
display: grid;
|
||||||
|
gap: 0 1.1rem; /* no vertical gap — row height handles spacing */
|
||||||
|
margin-bottom: 0;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Each field is a fixed-height slot — same as --hero-row-h so tabs match the dashboard rhythm */
|
||||||
|
.device-field {
|
||||||
|
height: var(--field-row-h, 75px);
|
||||||
|
padding-right: 0.3rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-field-value {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-value-secondary {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-status-text {
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-status-text--positive {
|
||||||
|
color: var(--success-text);
|
||||||
|
text-shadow: 0 0 14px rgba(116, 184, 22, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-status-text--negative {
|
||||||
|
color: #ff9e4a;
|
||||||
|
text-shadow: 0 0 14px rgba(243, 75, 75, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-status-text--info {
|
||||||
|
color: var(--badge-blue-text);
|
||||||
|
text-shadow: 0 0 16px rgba(99, 179, 237, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-status-text--muted {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-empty-cell {
|
||||||
|
height: var(--field-row-h, 65px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-inline-facts {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6rem;
|
||||||
|
padding: 1rem 0px 0rem 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-inline-facts > div {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-inline-facts__label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.74rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-inline-facts--overview .device-inline-facts__label::after {
|
||||||
|
content: "|";
|
||||||
|
color: var(--border-primary);
|
||||||
|
opacity: 0.65;
|
||||||
|
margin: 0 0.6rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-inline-facts--overview > div {
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-location-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-bottom-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-mechanism-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(500px, 1fr));
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-mechanism-card {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #1F2937;
|
||||||
|
padding: 2.5rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) 160px;
|
||||||
|
gap: 0 2rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-mechanism-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: -160px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 330px;
|
||||||
|
height: 330px;
|
||||||
|
opacity: 0.02;
|
||||||
|
pointer-events: none;
|
||||||
|
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='white'%3E%3Cpath d='M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6V11c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z'/%3E%3C/svg%3E") no-repeat center / contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Left column: title at top, info at bottom */
|
||||||
|
.bell-mechanism-card__left {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-mechanism-card__left-top {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-mechanism-card__title {
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 24px;
|
||||||
|
color: #63B3ED;
|
||||||
|
text-shadow: 5px 5px 8px rgba(0, 0, 0, 0.25);
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-mechanism-card__divider {
|
||||||
|
border: none;
|
||||||
|
border-top: 1px solid #374151;
|
||||||
|
opacity: 0.5;
|
||||||
|
width: 179px;
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-mechanism-card__info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-mechanism-card__info p {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 18px;
|
||||||
|
color: #9CA3AF;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-mechanism-card__highlight {
|
||||||
|
font-weight: 700;
|
||||||
|
color: #74B816;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-mechanism-card__highlight--strikes {
|
||||||
|
color: #F97316;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Right column: striker image at top, cert at bottom */
|
||||||
|
.bell-mechanism-card__right {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-end;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-mechanism-card__right-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-mechanism-card__striker-img {
|
||||||
|
width: 150px;
|
||||||
|
height: 110px;
|
||||||
|
object-fit: contain;
|
||||||
|
object-position: top right;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-mechanism-card__cert {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
justify-content: flex-end;
|
||||||
|
opacity: 0.52;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-mechanism-card__cert-text {
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 16px;
|
||||||
|
text-align: right;
|
||||||
|
color: #9CA3AF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-mechanism-card__cert-brand {
|
||||||
|
color: #63B3ED;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bell-mechanism-card__cert-badge {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
object-fit: contain;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.device-tab-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-dashboard-main-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-location-grid {
|
||||||
|
grid-template-columns: 1.3fr 1fr;
|
||||||
|
column-gap: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-snapshot-grid {
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-bottom-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@media (min-width: 1500px) {
|
||||||
|
.device-tab-grid {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-dashboard-main-grid {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-bottom-grid {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-bottom-grid__notes {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-bottom-grid__users {
|
||||||
|
grid-column: span 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
/* File input */
|
/* File input */
|
||||||
input[type="file"]::file-selector-button {
|
input[type="file"]::file-selector-button {
|
||||||
background-color: var(--bg-card) !important;
|
background-color: var(--bg-card) !important;
|
||||||
|
|||||||
@@ -7,6 +7,13 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
port: 5173,
|
port: 5173,
|
||||||
|
hmr: {
|
||||||
|
clientPort: 80,
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
usePolling: true,
|
||||||
|
interval: 500,
|
||||||
|
},
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://backend:8000',
|
target: 'http://backend:8000',
|
||||||
|
|||||||