commit 9-7-25

This commit is contained in:
admin 2025-09-07 23:38:56 -07:00
parent 12b5b25081
commit 084de5cd44
36 changed files with 3926 additions and 1961 deletions

View File

@ -56,22 +56,12 @@
{
"en": true,
"relay-index": 0,
"button-index": 0,
"min": 5.0,
"max": 100.0,
"step": 1.5,
"skip-count": 5,
"vision": true
"button-index": 0
},
{
"en": true,
"relay-index": 1,
"button-index": 1,
"min": 5.0,
"max": 100.0,
"step": 1.5,
"skip-count": 5,
"vision": true
"button-index": 1
}
],
"oled": {

View File

@ -56,23 +56,13 @@
{
"en": true,
"relay-index": 0,
"button-index": 0,
"min": 5.0,
"max": 100.0,
"step": 1.5,
"skip-count": 5,
"vision": true
"button-index": 0
},
{
"en": true,
"en": false,
"relay-index": 1,
"button-index": 1,
"min": 5.0,
"max": 100.0,
"step": 1.5,
"skip-count": 5,
"vision": true
}
"button-index": 1
}
],
"oled": {
"en": false,

View File

@ -55,23 +55,13 @@
{
"en": true,
"relay-index": 0,
"button-index": 0,
"min": 5.0,
"max": 100.0,
"step": 1.5,
"skip-count": 5,
"vision": true
"button-index": 0
},
{
"en": true,
"en": false,
"relay-index": 1,
"button-index": 1,
"min": 5.0,
"max": 100.0,
"step": 1.5,
"skip-count": 5,
"vision": true
}
"button-index": 1
}
],
"oled": {
"en": false,

View File

@ -56,23 +56,13 @@
{
"en": true,
"relay-index": 0,
"button-index": 0,
"min": 5.0,
"max": 100.0,
"step": 1.5,
"skip-count": 5,
"vision": true
"button-index": 0
},
{
"en": true,
"en": false,
"relay-index": 1,
"button-index": 1,
"min": 5.0,
"max": 100.0,
"step": 1.5,
"skip-count": 5,
"vision": true
}
"button-index": 1
}
],
"oled": {
"en": false,

View File

@ -56,22 +56,12 @@
{
"en": true,
"relay-index": 0,
"button-index": 0,
"min": 5.0,
"max": 100.0,
"step": 1.5,
"skip-count": 5,
"vision": true
"button-index": 0
},
{
"en": true,
"relay-index": 1,
"button-index": 1,
"min": 5.0,
"max": 100.0,
"step": 1.5,
"skip-count": 5,
"vision": true
"button-index": 1
}
],
"oled": {

View File

@ -56,22 +56,12 @@
{
"en": true,
"relay-index": 0,
"button-index": 0,
"min": 5.0,
"max": 100.0,
"step": 1.5,
"skip-count": 5,
"vision": true
"button-index": 0
},
{
"en": true,
"relay-index": 1,
"button-index": 1,
"min": 5.0,
"max": 100.0,
"step": 1.5,
"skip-count": 5,
"vision": true
"button-index": 1
}
],
"oled": {

View File

@ -56,22 +56,12 @@
{
"en": true,
"relay-index": 0,
"button-index": 0,
"min": 5.0,
"max": 100.0,
"step": 1.5,
"skip-count": 5,
"vision": true
"button-index": 0
},
{
"en": true,
"relay-index": 1,
"button-index": 1,
"min": 5.0,
"max": 100.0,
"step": 1.5,
"skip-count": 5,
"vision": true
"button-index": 1
}
],
"oled": {

View File

@ -56,22 +56,12 @@
{
"en": true,
"relay-index": 0,
"button-index": 0,
"min": 5.0,
"max": 100.0,
"step": 1.5,
"skip-count": 5,
"vision": true
"button-index": 0
},
{
"en": true,
"relay-index": 1,
"button-index": 1,
"min": 5.0,
"max": 100.0,
"step": 1.5,
"skip-count": 5,
"vision": true
"button-index": 1
}
],
"oled": {

View File

@ -56,22 +56,12 @@
{
"en": true,
"relay-index": 0,
"button-index": 0,
"min": 5.0,
"max": 100.0,
"step": 1.5,
"skip-count": 5,
"vision": true
"button-index": 0
},
{
"en": true,
"relay-index": 1,
"button-index": 1,
"min": 5.0,
"max": 100.0,
"step": 1.5,
"skip-count": 5,
"vision": true
"button-index": 1
}
],
"oled": {

View File

@ -56,22 +56,12 @@
{
"en": true,
"relay-index": 0,
"button-index": 0,
"min": 5.0,
"max": 100.0,
"step": 1.5,
"skip-count": 5,
"vision": true
"button-index": 0
},
{
"en": true,
"relay-index": 1,
"button-index": 1,
"min": 5.0,
"max": 100.0,
"step": 1.5,
"skip-count": 5,
"vision": true
"button-index": 1
}
],
"oled": {

View File

@ -58,17 +58,17 @@
"en": true,
"relay-index": 0,
"button-index": 0,
"min": 5.0,
"min": 0.0,
"max": 100.0,
"step": 1.5,
"skip-count": 5,
"vision": true
},
{
"en": true,
"en": false,
"relay-index": 1,
"button-index": 1,
"min": 5.0,
"min": 0.0,
"max": 100.0,
"step": 1.5,
"skip-count": 5,
@ -106,6 +106,7 @@
"rgb-order": "rgb",
"shift":-5,
"offset": 0,
"bright": 200,
"power-div": 0,
"i2s-ch": 0,
"core": 1
@ -117,6 +118,7 @@
"rgb-order": "rgb",
"shift":-27,
"offset": 0,
"bright": 255,
"power-div": 0,
"i2s-ch": 0,
"core": 1

View File

@ -58,17 +58,17 @@
"en": true,
"relay-index": 0,
"button-index": 0,
"min": 5.0,
"min": 0.0,
"max": 100.0,
"step": 1.5,
"skip-count": 5,
"vision": true
},
{
"en": true,
"en": false,
"relay-index": 1,
"button-index": 1,
"min": 5.0,
"min": 0.0,
"max": 100.0,
"step": 1.5,
"skip-count": 5,

File diff suppressed because it is too large Load Diff

View File

@ -1,456 +0,0 @@
#!/usr/bin/env python3
"""Upload firmware, manifest, and data assets to a MinIO (S3-compatible) bucket.
Features preserved from original GCS script:
- Optional backup (copies existing objects under destination prefix to timestamped folder under backups/)
- Upload firmware.bin, update.json, and recursively mirror a data directory
- Cache-Control set to disable caching on clients
Switches from google.cloud.storage to boto3 (S3 API) for MinIO compatibility.
"""
import os
import sys
import datetime
import hashlib
import json
from pathlib import Path
try:
import boto3
from botocore.exceptions import ClientError
from botocore.config import Config
except ImportError:
print("ERROR: boto3 is required. Install with: pip install boto3")
sys.exit(1)
# =============================================================================
# CONFIGURATION CONSTANTS (edit as needed or supply via environment variables)
# =============================================================================
CREATE_BACKUP = False
UPLOAD_FIRMWARE = True
UPDATE_MANIFEST = True
UPLOAD_DATA = True
DIR_SKIP_LIST = [
"system",
"booths"
]
FIlES_SKIP_LIST = [
]
# Bucket / endpoint configuration
BUCKET_NAME = os.getenv('MINIO_BUCKET', 'boothifier')
DESTINATION_DIR = os.getenv('MINIO_DEST_PREFIX', 'latest') # prefix inside bucket
BACKUPS_DIR = os.getenv('MINIO_BACKUPS_PREFIX', 'backups')
PROJECT_ROOT_PATH = Path(__file__).parent.parent.resolve()
LOCAL_ROOT_PATH = Path(__file__).parent.resolve()
# Optional service account style JSON key (generated by MinIO Console). Expected fields:
# {"url":"https://minio.example.com/api/v1/service-account-credentials","accessKey":"...","secretKey":"...","api":"s3v4","path":"auto"}
MINIO_KEY_FILE = LOCAL_ROOT_PATH / 'minio-boothifier-key.json'
# Defaults before loading file / env
_json_access = None
_json_secret = None
_json_url = None
def _load_json_key():
global _json_access, _json_secret, _json_url
try:
if MINIO_KEY_FILE.is_file():
with open(MINIO_KEY_FILE, 'r', encoding='utf-8') as fh:
data = json.load(fh)
_json_access = data.get('accessKey') or None
_json_secret = data.get('secretKey') or None
_json_url = data.get('url') or None
except Exception as e:
print(f"WARN: Failed to load MinIO key file '{MINIO_KEY_FILE.name}': {e}")
_load_json_key()
def _derive_endpoint(url_value: str) -> str:
if not url_value:
return 'https://s3-minio.boothwizard.com'
# Remove known API suffix if present (/api/...)
# e.g. https://s3-minio.boothwizard.com/api/v1/service-account-credentials -> https://s3-minio.boothwizard.com
parts = url_value.split('/api/')
return parts[0] if parts else url_value
# MinIO credentials with precedence: ENV > JSON file > fallback
MINIO_ENDPOINT = os.getenv('MINIO_ENDPOINT') or _derive_endpoint(_json_url)
MINIO_ACCESS_KEY = os.getenv('MINIO_ACCESS_KEY') or _json_access or 'CHANGE_ME_ACCESS'
MINIO_SECRET_KEY = os.getenv('MINIO_SECRET_KEY') or _json_secret or 'CHANGE_ME_SECRET'
MINIO_REGION = os.getenv('MINIO_REGION', 'us-east-1') # MinIO ignores but boto3 wants some value
# Addressing / SSL options
MINIO_ADDRESSING = os.getenv('MINIO_ADDRESSING_STYLE', 'path').lower() # 'path' or 'virtual'
MINIO_VERIFY_SSL = os.getenv('MINIO_TLS_VERIFY', '1') not in ('0','false','no')
MINIO_DEBUG = os.getenv('MINIO_DEBUG', '0') in ('1','true','yes')
MINIO_ALLOW_VARIANTS = os.getenv('MINIO_ALLOW_ENDPOINT_VARIANTS', '0') in ('1','true','yes') # normally false with nginx redirect
LOCAL_FIRMWARE_PATH = str(PROJECT_ROOT_PATH / '.pio' / 'build' / 'esp32s3dev' / 'firmware.bin')
LOCAL_DATA_DIRECTORY = str(PROJECT_ROOT_PATH / 'data')
MANIFEST_LOCAL_PATH = str(LOCAL_ROOT_PATH / 'manifest-local.json') # source of version/description/changelog
MANIFEST_FILENAME = os.getenv('MANIFEST_FILENAME', 'manifest.json') # destination manifest name
# =============================================================================
# HELPERS
# =============================================================================
def s3_client():
"""Create an S3 client pointed at MinIO endpoint, forcing path-style unless overridden, with short timeouts."""
addressing = 'path' if MINIO_ADDRESSING not in ('virtual','auto') else 'virtual'
cfg = Config(
s3={'addressing_style': addressing},
signature_version='s3v4',
connect_timeout=3,
read_timeout=5,
retries={'max_attempts': 2}
)
if MINIO_DEBUG:
masked_key = (MINIO_ACCESS_KEY[:3] + '...' + MINIO_ACCESS_KEY[-3:]) if MINIO_ACCESS_KEY else 'None'
print(f"[DEBUG] Creating client: endpoint={MINIO_ENDPOINT} addressing={addressing} verifySSL={MINIO_VERIFY_SSL} region={MINIO_REGION} accessKey={masked_key}")
return boto3.client(
's3',
endpoint_url=MINIO_ENDPOINT,
aws_access_key_id=MINIO_ACCESS_KEY,
aws_secret_access_key=MINIO_SECRET_KEY,
region_name=MINIO_REGION,
verify=MINIO_VERIFY_SSL,
config=cfg,
)
def _endpoint_variants(base: str):
"""Return endpoint variants only if explicitly allowed; otherwise just the base (nginx handles forwarding)."""
if not MINIO_ALLOW_VARIANTS:
return [base]
# Fallback to previous expanded logic if variants are enabled
try:
variants = []
if not base:
return variants
base = base.rstrip('/')
proto_sep = '://'
if proto_sep in base:
scheme, rest = base.split(proto_sep,1)
else:
scheme, rest = 'https', base
host_port = rest
if ':' in host_port:
host, port = host_port.split(':',1)
else:
host, port = host_port, ''
variants.append(f"{scheme}://{host_port}")
common_ports = ['9000','443','80']
for p in common_ports:
if port != p:
variants.append(f"{scheme}://{host}:{p}")
alt_scheme = 'http' if scheme == 'https' else 'https'
variants.append(f"{alt_scheme}://{host_port}")
for p in common_ports:
if port != p:
variants.append(f"{alt_scheme}://{host}:{p}")
seen = set()
uniq = []
for v in variants:
if v not in seen:
uniq.append(v)
seen.add(v)
return uniq
except Exception:
return [base]
def create_validated_client():
"""Validate (or create) client using only provided endpoint unless variants enabled."""
global MINIO_ENDPOINT
primary = MINIO_ENDPOINT
variants = _endpoint_variants(primary) or [primary]
errors = []
probe_bucket = BUCKET_NAME # we will head the target bucket directly
for candidate in variants:
saved = MINIO_ENDPOINT
MINIO_ENDPOINT = candidate
if MINIO_DEBUG:
print(f"[DEBUG] Probing endpoint candidate: {candidate}")
try:
c = s3_client()
try:
c.head_bucket(Bucket=probe_bucket)
if MINIO_DEBUG:
print(f"[DEBUG] head_bucket succeeded on {candidate} for '{probe_bucket}'.")
return c
except ClientError as e:
msg = str(e)
# Acceptable if bucket not found (we can create later)
if any(code in msg for code in ('404', 'NoSuchBucket', 'NotFound')):
if MINIO_DEBUG:
print(f"[DEBUG] Bucket not found on {candidate} (expected if first deploy). Using this endpoint.")
return c
if 'API Requests must be made to API port' in msg:
errors.append(f"{candidate}: wrong port (console endpoint)")
else:
errors.append(f"{candidate}: {msg}")
MINIO_ENDPOINT = saved
except Exception as ex:
errors.append(f"{candidate}: {ex}")
MINIO_ENDPOINT = saved
continue
print("ERROR: Could not validate any endpoint candidate.")
for e in errors:
print(' - ' + e)
print("Provide correct API endpoint (e.g. https://host:9000) via MINIO_ENDPOINT env var.")
sys.exit(3)
def list_objects(client, prefix: str):
"""Generator yielding object keys under a prefix (non-recursive listing with pagination)."""
kwargs = {'Bucket': BUCKET_NAME, 'Prefix': prefix}
while True:
resp = client.list_objects_v2(**kwargs)
for obj in resp.get('Contents', []):
yield obj['Key']
if not resp.get('IsTruncated'):
break
kwargs['ContinuationToken'] = resp['NextContinuationToken']
def normalize_prefix(p: str) -> str:
p = p.strip('/')
return p
def join_key(*parts: str) -> str:
parts_clean = [p.strip('/') for p in parts if p is not None and p != '']
return '/'.join(parts_clean)
def backup_existing_files(client, destination_prefix: str, backups_prefix: str, backup_folder: str):
if not destination_prefix:
prefix = ''
else:
prefix = destination_prefix + '/'
print(f"Scanning existing objects under '{prefix}' for backup...")
for key in list_objects(client, prefix):
if backups_prefix and key.startswith(backups_prefix + '/'): # Skip prior backups
continue
# relative path within destination
relative = key[len(prefix):] if prefix and key.startswith(prefix) else key
backup_key = join_key(backups_prefix, backup_folder, relative)
print(f"Backup copy: {key} -> {backup_key}")
client.copy_object(
Bucket=BUCKET_NAME,
CopySource={'Bucket': BUCKET_NAME, 'Key': key},
Key=backup_key,
MetadataDirective='COPY'
)
def upload_file(client, local_path: str, key: str, cache_control: str = 'private, max-age=0, no-transform'):
if not os.path.isfile(local_path):
print(f"WARN: File missing, skipping: {local_path}")
return
print(f"Upload: {local_path} -> s3://{BUCKET_NAME}/{key}")
extra_args = { 'CacheControl': cache_control }
client.upload_file(local_path, BUCKET_NAME, key, ExtraArgs=extra_args)
def upload_directory(client, local_directory: str, destination_prefix: str):
if not os.path.isdir(local_directory):
print(f"WARN: Data directory missing: {local_directory}")
return
for root, _, files in os.walk(local_directory):
for fname in files:
full = os.path.join(root, fname)
rel = os.path.relpath(full, local_directory).replace('\\','/') # force forward slashes for S3 keys
key = join_key(destination_prefix, rel)
upload_file(client, full, key)
def ensure_bucket(client):
"""Ensure bucket exists; provide diagnostics if HeadBucket returns 400/other errors."""
try:
client.head_bucket(Bucket=BUCKET_NAME)
if MINIO_DEBUG:
print(f"[DEBUG] Bucket '{BUCKET_NAME}' exists.")
return
except ClientError as e:
code = e.response.get('Error', {}).get('Code')
status = e.response.get('ResponseMetadata', {}).get('HTTPStatusCode')
print(f"HeadBucket failed (code={code}, status={status}).")
# List buckets for diagnostics
try:
resp = client.list_buckets()
bucket_names = [b['Name'] for b in resp.get('Buckets', [])]
print(f"Available buckets: {bucket_names or 'None'}")
except Exception as le:
print(f"WARN: list_buckets failed: {le}")
if code in ('404', 'NoSuchBucket', 'NotFound'):
print(f"Bucket '{BUCKET_NAME}' not found. Attempting to create...")
try:
client.create_bucket(Bucket=BUCKET_NAME)
print(f"Created bucket '{BUCKET_NAME}'.")
return
except ClientError as ce:
print(f"ERROR: Cannot create bucket: {ce}")
sys.exit(2)
if status == 400:
print("HINTS: \n - Verify endpoint URL (MINIO_ENDPOINT).\n - Ensure no trailing slash in endpoint.\n - Check that TLS verify matches server cert (set MINIO_TLS_VERIFY=0 to test).\n - Confirm bucket name is correct and DNS compatible.\n - Credentials may lack permission: verify access key policies.")
# Retry once forcing path style if not already
if MINIO_ADDRESSING != 'path':
print("Retrying with path-style addressing...")
os.environ['MINIO_ADDRESSING_STYLE'] = 'path'
new_client = s3_client()
try:
new_client.head_bucket(Bucket=BUCKET_NAME)
print("Second attempt succeeded with path-style addressing.")
return
except ClientError as e2:
print(f"Second HeadBucket attempt failed: {e2}")
print(f"ERROR: head_bucket ultimately failed: {e}")
sys.exit(2)
# =============================================================================
# MAIN
# =============================================================================
def main():
dest_prefix = normalize_prefix(DESTINATION_DIR)
backups_prefix = normalize_prefix(BACKUPS_DIR) if BACKUPS_DIR else ''
client = create_validated_client()
if MINIO_DEBUG:
print("[DEBUG] Starting ensure_bucket phase...")
ensure_bucket(client)
# Create backup if needed
if CREATE_BACKUP:
ts = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
backup_folder = f"backup_{ts}"
print(f"Creating backup under '{backups_prefix}/{backup_folder}' from prefix '{dest_prefix}'")
backup_existing_files(client, dest_prefix, backups_prefix, backup_folder)
print("Backup complete.")
else:
print("Backup creation skipped.")
# Firmware
if UPLOAD_FIRMWARE:
firmware_key = join_key(dest_prefix, 'firmware.bin') if dest_prefix else 'firmware.bin'
upload_file(client, LOCAL_FIRMWARE_PATH, firmware_key)
print("Firmware upload complete.")
else:
print("Firmware upload skipped.")
# Data directory
if UPLOAD_DATA:
data_prefix = join_key(dest_prefix, 'data') if dest_prefix else 'data'
upload_directory(client, LOCAL_DATA_DIRECTORY, data_prefix)
print("All uploads complete.")
else:
print("Data upload skipped.")
# Manifest
if UPDATE_MANIFEST:
manifest_key = join_key(dest_prefix, MANIFEST_FILENAME) if dest_prefix else MANIFEST_FILENAME
try:
manifest_doc = build_and_write_manifest(client, dest_prefix)
upload_manifest_json(client, manifest_doc, manifest_key)
print(f"Manifest upload complete: s3://{BUCKET_NAME}/{manifest_key}")
except Exception as e:
print(f"ERROR: Manifest generation/upload failed: {e}")
sys.exit(4)
else:
print("Manifest upload skipped.")
# ================= Manifest Support =================
def md5_hex(path: str, chunk_size: int = 65536) -> str:
h = hashlib.md5()
with open(path, 'rb') as f:
while True:
chunk = f.read(chunk_size)
if not chunk:
break
h.update(chunk)
return h.hexdigest()
def collect_data_files(data_root: str):
files = []
if not os.path.isdir(data_root):
return files
for root, _, filenames in os.walk(data_root):
for fname in filenames:
full = os.path.join(root, fname)
rel = os.path.relpath(full, data_root).replace('\\','/')
entry = {
'path': f"data/{rel}",
'md5': md5_hex(full),
'size': os.path.getsize(full)
}
files.append(entry)
files.sort(key=lambda x: x['path'])
return files
def read_local_manifest(local_path: str):
if not os.path.isfile(local_path):
raise FileNotFoundError(f"manifest-local file not found: {local_path}")
with open(local_path, 'r', encoding='utf-8') as fh:
data = json.load(fh)
# Basic validation
if 'version' not in data:
raise ValueError('manifest-local missing version section')
ver = data['version']
for k in ('major','minor','patch'):
if k not in ver:
raise ValueError(f"manifest-local version missing '{k}'")
data.setdefault('description', '')
data.setdefault('changelog', [])
if not isinstance(data['changelog'], list):
raise ValueError('changelog must be an array')
return data
def build_and_write_manifest(client, dest_prefix: str):
# Read local manifest-local.json
base_info = read_local_manifest(MANIFEST_LOCAL_PATH)
# Timestamp
now = datetime.datetime.now()
release_date = now.strftime('%Y-%m-%d')
release_time = now.strftime('%H:%M:%S')
# Firmware info
fw_path_local = LOCAL_FIRMWARE_PATH
if not os.path.isfile(fw_path_local):
raise FileNotFoundError(f"Firmware file not found: {fw_path_local}")
fw_md5 = md5_hex(fw_path_local)
fw_size = os.path.getsize(fw_path_local)
# Data files
data_files = collect_data_files(LOCAL_DATA_DIRECTORY)
manifest = {
'version': {
'major': int(base_info['version']['major']),
'minor': int(base_info['version']['minor']),
'patch': int(base_info['version']['patch'])
},
'release_date': release_date,
'release_time': release_time,
'description': base_info.get('description',''),
'changelog': base_info.get('changelog', []),
'firmware': {
'path': 'firmware.bin',
'md5': fw_md5,
'size': fw_size
},
'files': data_files
}
return manifest
def upload_manifest_json(client, manifest_obj: dict, key: str):
body = json.dumps(manifest_obj, indent=4).encode('utf-8')
client.put_object(
Bucket=BUCKET_NAME,
Key=key,
Body=body,
ContentType='application/json',
CacheControl='private, max-age=0, no-transform'
)
if __name__ == '__main__':
main()

View File

@ -1,497 +0,0 @@
#!/usr/bin/env python3
"""Upload firmware, manifest, and data assets to a MinIO (S3-compatible) bucket.
Features preserved from original GCS script:
- Optional backup (copies existing objects under destination prefix to timestamped folder under backups/)
- Upload firmware.bin, update.json, and recursively mirror a data directory
- Cache-Control set to disable caching on clients
Switches from google.cloud.storage to boto3 (S3 API) for MinIO compatibility.
"""
import os
import sys
import datetime
import hashlib
import json
from pathlib import Path
try:
import boto3
from botocore.exceptions import ClientError
from botocore.config import Config
except ImportError:
print("ERROR: boto3 is required. Install with: pip install boto3")
sys.exit(1)
# =============================================================================
# CONFIGURATION CONSTANTS (edit as needed or supply via environment variables)
# =============================================================================
CREATE_BACKUP = False
UPLOAD_FIRMWARE = True
UPDATE_MANIFEST = True
UPLOAD_DATA = True
DIR_SKIP_LIST = [
"data/system/**/*",
"data/booths/**/*"
]
FILES_SKIP_LIST = [
# Add base filenames to skip regardless of directory, e.g. "readme.txt"
]
# Bucket / endpoint configuration
BUCKET_NAME = os.getenv('MINIO_BUCKET', 'boothifier')
DESTINATION_DIR = os.getenv('MINIO_DEST_PREFIX', 'latest') # prefix inside bucket
BACKUPS_DIR = os.getenv('MINIO_BACKUPS_PREFIX', 'backups')
PROJECT_ROOT_PATH = Path(__file__).parent.parent.resolve()
LOCAL_ROOT_PATH = Path(__file__).parent.resolve()
# Optional service account style JSON key (generated by MinIO Console). Expected fields:
# {"url":"https://minio.example.com/api/v1/service-account-credentials","accessKey":"...","secretKey":"...","api":"s3v4","path":"auto"}
MINIO_KEY_FILE = LOCAL_ROOT_PATH / 'minio-boothifier-key.json'
# Defaults before loading file / env
_json_access = None
_json_secret = None
_json_url = None
def _load_json_key():
global _json_access, _json_secret, _json_url
try:
if MINIO_KEY_FILE.is_file():
with open(MINIO_KEY_FILE, 'r', encoding='utf-8') as fh:
data = json.load(fh)
_json_access = data.get('accessKey') or None
_json_secret = data.get('secretKey') or None
_json_url = data.get('url') or None
except Exception as e:
print(f"WARN: Failed to load MinIO key file '{MINIO_KEY_FILE.name}': {e}")
_load_json_key()
def _derive_endpoint(url_value: str) -> str:
if not url_value:
return 'https://s3-minio.boothwizard.com'
# Remove known API suffix if present (/api/...)
# e.g. https://s3-minio.boothwizard.com/api/v1/service-account-credentials -> https://s3-minio.boothwizard.com
parts = url_value.split('/api/')
return parts[0] if parts else url_value
# MinIO credentials with precedence: ENV > JSON file > fallback
MINIO_ENDPOINT = os.getenv('MINIO_ENDPOINT') or _derive_endpoint(_json_url)
MINIO_ACCESS_KEY = os.getenv('MINIO_ACCESS_KEY') or _json_access or 'CHANGE_ME_ACCESS'
MINIO_SECRET_KEY = os.getenv('MINIO_SECRET_KEY') or _json_secret or 'CHANGE_ME_SECRET'
MINIO_REGION = os.getenv('MINIO_REGION', 'us-east-1') # MinIO ignores but boto3 wants some value
# Addressing / SSL options
MINIO_ADDRESSING = os.getenv('MINIO_ADDRESSING_STYLE', 'path').lower() # 'path' or 'virtual'
MINIO_VERIFY_SSL = os.getenv('MINIO_TLS_VERIFY', '1') not in ('0','false','no')
MINIO_DEBUG = os.getenv('MINIO_DEBUG', '0') in ('1','true','yes')
MINIO_ALLOW_VARIANTS = os.getenv('MINIO_ALLOW_ENDPOINT_VARIANTS', '0') in ('1','true','yes') # normally false with nginx redirect
LOCAL_FIRMWARE_PATH = str(PROJECT_ROOT_PATH / '.pio' / 'build' / 'esp32s3dev' / 'firmware.bin')
LOCAL_DATA_DIRECTORY = str(PROJECT_ROOT_PATH / 'data')
MANIFEST_LOCAL_PATH = str(LOCAL_ROOT_PATH / 'manifest-local.json') # source of version/description/changelog
MANIFEST_FILENAME = os.getenv('MANIFEST_FILENAME', 'manifest.json') # destination manifest name
# =============================================================================
# HELPERS
# =============================================================================
def s3_client():
"""Create an S3 client pointed at MinIO endpoint, forcing path-style unless overridden, with short timeouts."""
addressing = 'path' if MINIO_ADDRESSING not in ('virtual','auto') else 'virtual'
cfg = Config(
s3={'addressing_style': addressing},
signature_version='s3v4',
connect_timeout=3,
read_timeout=5,
retries={'max_attempts': 2}
)
if MINIO_DEBUG:
masked_key = (MINIO_ACCESS_KEY[:3] + '...' + MINIO_ACCESS_KEY[-3:]) if MINIO_ACCESS_KEY else 'None'
print(f"[DEBUG] Creating client: endpoint={MINIO_ENDPOINT} addressing={addressing} verifySSL={MINIO_VERIFY_SSL} region={MINIO_REGION} accessKey={masked_key}")
return boto3.client(
's3',
endpoint_url=MINIO_ENDPOINT,
aws_access_key_id=MINIO_ACCESS_KEY,
aws_secret_access_key=MINIO_SECRET_KEY,
region_name=MINIO_REGION,
verify=MINIO_VERIFY_SSL,
config=cfg,
)
def _endpoint_variants(base: str):
"""Return endpoint variants only if explicitly allowed; otherwise just the base (nginx handles forwarding)."""
if not MINIO_ALLOW_VARIANTS:
return [base]
# Fallback to previous expanded logic if variants are enabled
try:
variants = []
if not base:
return variants
base = base.rstrip('/')
proto_sep = '://'
if proto_sep in base:
scheme, rest = base.split(proto_sep,1)
else:
scheme, rest = 'https', base
host_port = rest
if ':' in host_port:
host, port = host_port.split(':',1)
else:
host, port = host_port, ''
variants.append(f"{scheme}://{host_port}")
common_ports = ['9000','443','80']
for p in common_ports:
if port != p:
variants.append(f"{scheme}://{host}:{p}")
alt_scheme = 'http' if scheme == 'https' else 'https'
variants.append(f"{alt_scheme}://{host_port}")
for p in common_ports:
if port != p:
variants.append(f"{alt_scheme}://{host}:{p}")
seen = set()
uniq = []
for v in variants:
if v not in seen:
uniq.append(v)
seen.add(v)
return uniq
except Exception:
return [base]
def create_validated_client():
"""Validate (or create) client using only provided endpoint unless variants enabled."""
global MINIO_ENDPOINT
primary = MINIO_ENDPOINT
variants = _endpoint_variants(primary) or [primary]
errors = []
probe_bucket = BUCKET_NAME # we will head the target bucket directly
for candidate in variants:
saved = MINIO_ENDPOINT
MINIO_ENDPOINT = candidate
if MINIO_DEBUG:
print(f"[DEBUG] Probing endpoint candidate: {candidate}")
try:
c = s3_client()
try:
c.head_bucket(Bucket=probe_bucket)
if MINIO_DEBUG:
print(f"[DEBUG] head_bucket succeeded on {candidate} for '{probe_bucket}'.")
return c
except ClientError as e:
msg = str(e)
# Acceptable if bucket not found (we can create later)
if any(code in msg for code in ('404', 'NoSuchBucket', 'NotFound')):
if MINIO_DEBUG:
print(f"[DEBUG] Bucket not found on {candidate} (expected if first deploy). Using this endpoint.")
return c
if 'API Requests must be made to API port' in msg:
errors.append(f"{candidate}: wrong port (console endpoint)")
else:
errors.append(f"{candidate}: {msg}")
MINIO_ENDPOINT = saved
except Exception as ex:
errors.append(f"{candidate}: {ex}")
MINIO_ENDPOINT = saved
continue
print("ERROR: Could not validate any endpoint candidate.")
for e in errors:
print(' - ' + e)
print("Provide correct API endpoint (e.g. https://host:9000) via MINIO_ENDPOINT env var.")
sys.exit(3)
def list_objects(client, prefix: str):
"""Generator yielding object keys under a prefix (non-recursive listing with pagination)."""
kwargs = {'Bucket': BUCKET_NAME, 'Prefix': prefix}
while True:
resp = client.list_objects_v2(**kwargs)
for obj in resp.get('Contents', []):
yield obj['Key']
if not resp.get('IsTruncated'):
break
kwargs['ContinuationToken'] = resp['NextContinuationToken']
def normalize_prefix(p: str) -> str:
p = p.strip('/')
return p
def join_key(*parts: str) -> str:
parts_clean = [p.strip('/') for p in parts if p is not None and p != '']
return '/'.join(parts_clean)
def backup_existing_files(client, destination_prefix: str, backups_prefix: str, backup_folder: str):
if not destination_prefix:
prefix = ''
else:
prefix = destination_prefix + '/'
print(f"Scanning existing objects under '{prefix}' for backup...")
for key in list_objects(client, prefix):
if backups_prefix and key.startswith(backups_prefix + '/'): # Skip prior backups
continue
# relative path within destination
relative = key[len(prefix):] if prefix and key.startswith(prefix) else key
backup_key = join_key(backups_prefix, backup_folder, relative)
print(f"Backup copy: {key} -> {backup_key}")
client.copy_object(
Bucket=BUCKET_NAME,
CopySource={'Bucket': BUCKET_NAME, 'Key': key},
Key=backup_key,
MetadataDirective='COPY'
)
def upload_file(client, local_path: str, key: str, cache_control: str = 'private, max-age=0, no-transform'):
if not os.path.isfile(local_path):
print(f"WARN: File missing, skipping: {local_path}")
return
print(f"Upload: {local_path} -> s3://{BUCKET_NAME}/{key}")
extra_args = { 'CacheControl': cache_control }
client.upload_file(local_path, BUCKET_NAME, key, ExtraArgs=extra_args)
def upload_directory(client, local_directory: str, destination_prefix: str):
if not os.path.isdir(local_directory):
print(f"WARN: Data directory missing: {local_directory}")
return
skip_dirs = set(DIR_SKIP_LIST)
skip_files = set(FILES_SKIP_LIST)
for root, dirs, files in os.walk(local_directory):
rel_dir = os.path.relpath(root, local_directory).replace('\\','/')
if rel_dir == '.':
rel_dir = ''
# Prune directories in-place if their TOP-LEVEL relative segment matches skip list
pruned = []
for d in list(dirs):
seg = d # immediate subdir name
if seg in skip_dirs:
if MINIO_DEBUG:
print(f"[DEBUG] Skipping directory subtree: {os.path.join(root,d)}")
dirs.remove(d)
pruned.append(d)
for fname in files:
if fname in skip_files:
if MINIO_DEBUG:
print(f"[DEBUG] Skipping file by name: {os.path.join(root,fname)}")
continue
full = os.path.join(root, fname)
rel = os.path.relpath(full, local_directory).replace('\\','/') # force forward slashes for S3 keys
# If top-level directory of this file is in skip list, skip (covers deeper nested finds if any slipped through)
top_level = rel.split('/',1)[0]
if top_level in skip_dirs:
if MINIO_DEBUG:
print(f"[DEBUG] Skipping file in skipped dir: {rel}")
continue
key = join_key(destination_prefix, rel)
upload_file(client, full, key)
def ensure_bucket(client):
"""Ensure bucket exists; provide diagnostics if HeadBucket returns 400/other errors."""
try:
client.head_bucket(Bucket=BUCKET_NAME)
if MINIO_DEBUG:
print(f"[DEBUG] Bucket '{BUCKET_NAME}' exists.")
return
except ClientError as e:
code = e.response.get('Error', {}).get('Code')
status = e.response.get('ResponseMetadata', {}).get('HTTPStatusCode')
print(f"HeadBucket failed (code={code}, status={status}).")
# List buckets for diagnostics
try:
resp = client.list_buckets()
bucket_names = [b['Name'] for b in resp.get('Buckets', [])]
print(f"Available buckets: {bucket_names or 'None'}")
except Exception as le:
print(f"WARN: list_buckets failed: {le}")
if code in ('404', 'NoSuchBucket', 'NotFound'):
print(f"Bucket '{BUCKET_NAME}' not found. Attempting to create...")
try:
client.create_bucket(Bucket=BUCKET_NAME)
print(f"Created bucket '{BUCKET_NAME}'.")
return
except ClientError as ce:
print(f"ERROR: Cannot create bucket: {ce}")
sys.exit(2)
if status == 400:
print("HINTS: \n - Verify endpoint URL (MINIO_ENDPOINT).\n - Ensure no trailing slash in endpoint.\n - Check that TLS verify matches server cert (set MINIO_TLS_VERIFY=0 to test).\n - Confirm bucket name is correct and DNS compatible.\n - Credentials may lack permission: verify access key policies.")
# Retry once forcing path style if not already
if MINIO_ADDRESSING != 'path':
print("Retrying with path-style addressing...")
os.environ['MINIO_ADDRESSING_STYLE'] = 'path'
new_client = s3_client()
try:
new_client.head_bucket(Bucket=BUCKET_NAME)
print("Second attempt succeeded with path-style addressing.")
return
except ClientError as e2:
print(f"Second HeadBucket attempt failed: {e2}")
print(f"ERROR: head_bucket ultimately failed: {e}")
sys.exit(2)
# =============================================================================
# MAIN
# =============================================================================
def main():
dest_prefix = normalize_prefix(DESTINATION_DIR)
backups_prefix = normalize_prefix(BACKUPS_DIR) if BACKUPS_DIR else ''
client = create_validated_client()
if MINIO_DEBUG:
print("[DEBUG] Starting ensure_bucket phase...")
ensure_bucket(client)
# Create backup if needed
if CREATE_BACKUP:
ts = datetime.datetime.now().strftime('%Y%m%d_%H%M%S')
backup_folder = f"backup_{ts}"
print(f"Creating backup under '{backups_prefix}/{backup_folder}' from prefix '{dest_prefix}'")
backup_existing_files(client, dest_prefix, backups_prefix, backup_folder)
print("Backup complete.")
else:
print("Backup creation skipped.")
# Firmware
if UPLOAD_FIRMWARE:
firmware_key = join_key(dest_prefix, 'firmware.bin') if dest_prefix else 'firmware.bin'
upload_file(client, LOCAL_FIRMWARE_PATH, firmware_key)
print("Firmware upload complete.")
else:
print("Firmware upload skipped.")
# Data directory
if UPLOAD_DATA:
data_prefix = join_key(dest_prefix, 'data') if dest_prefix else 'data'
upload_directory(client, LOCAL_DATA_DIRECTORY, data_prefix)
print("All uploads complete.")
else:
print("Data upload skipped.")
# Manifest
if UPDATE_MANIFEST:
manifest_key = join_key(dest_prefix, MANIFEST_FILENAME) if dest_prefix else MANIFEST_FILENAME
try:
manifest_doc = build_and_write_manifest(client, dest_prefix)
upload_manifest_json(client, manifest_doc, manifest_key)
print(f"Manifest upload complete: s3://{BUCKET_NAME}/{manifest_key}")
except Exception as e:
print(f"ERROR: Manifest generation/upload failed: {e}")
sys.exit(4)
else:
print("Manifest upload skipped.")
# ================= Manifest Support =================
def md5_hex(path: str, chunk_size: int = 65536) -> str:
h = hashlib.md5()
with open(path, 'rb') as f:
while True:
chunk = f.read(chunk_size)
if not chunk:
break
h.update(chunk)
return h.hexdigest()
def collect_data_files(data_root: str):
files = []
if not os.path.isdir(data_root):
return files
skip_dirs = set(DIR_SKIP_LIST)
skip_files = set(FILES_SKIP_LIST)
for root, dirs, filenames in os.walk(data_root):
# Prune dirs
for d in list(dirs):
if d in skip_dirs:
dirs.remove(d)
if MINIO_DEBUG:
print(f"[DEBUG] (manifest) Pruned dir: {os.path.join(root,d)}")
for fname in filenames:
if fname in skip_files:
if MINIO_DEBUG:
print(f"[DEBUG] (manifest) Skipped file: {os.path.join(root,fname)}")
continue
full = os.path.join(root, fname)
rel = os.path.relpath(full, data_root).replace('\\','/')
top_level = rel.split('/',1)[0]
if top_level in skip_dirs:
if MINIO_DEBUG:
print(f"[DEBUG] (manifest) Skipped by top-level dir: {rel}")
continue
entry = {
'path': f"data/{rel}",
'md5': md5_hex(full),
'size': os.path.getsize(full)
}
files.append(entry)
files.sort(key=lambda x: x['path'])
return files
def read_local_manifest(local_path: str):
if not os.path.isfile(local_path):
raise FileNotFoundError(f"manifest-local file not found: {local_path}")
with open(local_path, 'r', encoding='utf-8') as fh:
data = json.load(fh)
# Basic validation
if 'version' not in data:
raise ValueError('manifest-local missing version section')
ver = data['version']
for k in ('major','minor','patch'):
if k not in ver:
raise ValueError(f"manifest-local version missing '{k}'")
data.setdefault('description', '')
data.setdefault('changelog', [])
if not isinstance(data['changelog'], list):
raise ValueError('changelog must be an array')
return data
def build_and_write_manifest(client, dest_prefix: str):
# Read local manifest-local.json
base_info = read_local_manifest(MANIFEST_LOCAL_PATH)
# Timestamp
now = datetime.datetime.now()
release_date = now.strftime('%Y-%m-%d')
release_time = now.strftime('%H:%M:%S')
# Firmware info
fw_path_local = LOCAL_FIRMWARE_PATH
if not os.path.isfile(fw_path_local):
raise FileNotFoundError(f"Firmware file not found: {fw_path_local}")
fw_md5 = md5_hex(fw_path_local)
fw_size = os.path.getsize(fw_path_local)
# Data files
data_files = collect_data_files(LOCAL_DATA_DIRECTORY)
manifest = {
'version': {
'major': int(base_info['version']['major']),
'minor': int(base_info['version']['minor']),
'patch': int(base_info['version']['patch'])
},
'release_date': release_date,
'release_time': release_time,
'description': base_info.get('description',''),
'changelog': base_info.get('changelog', []),
'firmware': {
'path': 'firmware.bin',
'md5': fw_md5,
'size': fw_size
},
'files': data_files
}
return manifest
def upload_manifest_json(client, manifest_obj: dict, key: str):
body = json.dumps(manifest_obj, indent=4).encode('utf-8')
client.put_object(
Bucket=BUCKET_NAME,
Key=key,
Body=body,
ContentType='application/json',
CacheControl='private, max-age=0, no-transform'
)
if __name__ == '__main__':
main()

View File

@ -34,8 +34,8 @@ UPDATE_MANIFEST = True
UPLOAD_DATA = True
DIR_SKIP_LIST = [
"data/system/**/*",
"data/booths/**/*"
"system", # Just directory names, not paths
"booths"
]
FILES_SKIP_LIST = [

View File

@ -10,17 +10,20 @@
//#define DEFAULT_MANIFEST_URL "https://storage.googleapis.com/boothifier/latest/"
#define DEFAULT_MANIFEST_URL "https://minio.boothwizard.com/boothifier/latest/"
#define BUFFER_SIZE 4096
#define BUFFER_SIZE 2048 // Reduced from 4096 to use less memory
// Maximum allowed manifest size (bytes) to protect memory
#define MAX_MANIFEST_SIZE (64 * 1024)
// Number of HTTP retry attempts for transient failures
#define HTTP_RETRY_COUNT 3
#define HTTP_RETRY_DELAY_MS 500
#define HTTP_RETRY_COUNT 5 // Increased from 3
#define HTTP_RETRY_DELAY_MS 1000 // Increased from 500
// Allow external cancellation
extern volatile bool g_UpdateCancelFlag;
// Global update mode setting
extern UpdateMode g_UpdateMode;
extern TaskHandle_t Update_Task_Handle;
/**
@ -32,6 +35,15 @@ extern TaskHandle_t Update_Task_Handle;
extern Version otaVersion;
/**
* @brief Update mode enumeration
*/
enum class UpdateMode {
UPDATE_FILES_ONLY, ///< Update files only, skip firmware
UPDATE_FIRMWARE_ONLY, ///< Update firmware only, skip files
UPDATE_BOTH ///< Update both files and firmware (default)
};
/**
* @brief File information structure
*/
@ -90,6 +102,16 @@ class AppUpdater {
*/
const String& getBaseUrl() const { return baseUrl; }
/**
* @brief Set update mode (files only, firmware only, or both)
*/
void setUpdateMode(UpdateMode mode);
/**
* @brief Get current update mode
*/
UpdateMode getUpdateMode() const;
/**
* @brief Set progress callback function
* @param callback Function to call with progress updates
@ -119,12 +141,25 @@ class AppUpdater {
*/
bool updateFile(const char* remotePath, const char* localPath, const char* expectedMd5);
/**
* @brief Results from checkManifest
*/
enum class ManifestCheckResult {
ERROR_FETCH_FAILED, ///< Failed to fetch manifest
ERROR_TOO_LARGE, ///< Manifest too large
ERROR_PARSE_FAILED, ///< Failed to parse manifest JSON
ERROR_NO_FILES_SECTION, ///< No files section in manifest
ERROR_NO_VERSION, ///< No version section in manifest
VERSION_CURRENT, ///< Current version is same or newer
UPDATE_AVAILABLE ///< New version available
};
/**
* @brief Get manifest content
* @param manifestPath Path to manifest file
* @return Manifest content as a json document
* @return Result indicating success, failure, or version status
*/
bool checkManifest(void);
ManifestCheckResult checkManifest(void);
bool updateApp(void);
@ -137,6 +172,7 @@ class AppUpdater {
UpdateStatus status;
std::unique_ptr<uint8_t[]> downloadBuffer;
bool updateAvailable = false;
UpdateMode updateMode = UpdateMode::UPDATE_BOTH; // Default to updating both files and firmware
@ -149,7 +185,7 @@ class AppUpdater {
* @return true if successful
*/
bool verifyAndSaveFile(WiFiClient* stream, size_t contentLength,
const char* localPath, const char* expectedMd5);
const char* localPath, const char* remotePath, const char* expectedMd5);
/**
* @brief Update progress callback
@ -199,3 +235,10 @@ void handleUpdateProgress(AsyncWebServerRequest *request);
void startVersionCheckTask();
void versionCheckTask(void* parameter);
// Convenience functions for setting update mode
void setGlobalUpdateMode(UpdateMode mode);
UpdateMode getGlobalUpdateMode();
void setUpdateModeFilesOnly();
void setUpdateModeFirmwareOnly();
void setUpdateModeBoth();

View File

@ -27,11 +27,33 @@ private:
#define CONCATENATE(x, y) CONCATENATE_DETAIL(x, y)
#define UNIQUE_NAME(base) CONCATENATE(base, __LINE__)
// Macro for ON_EVERY_N_MILLISECONDS
// Macro for ON_EVERY_N_MILLISECONDS (constant N via template)
#define ON_EVERY_N_MILLISECONDS(N) \
static OnEveryN<N> UNIQUE_NAME(__on_everyN_); \
if (UNIQUE_NAME(__on_everyN_).ready())
// Runtime-configurable variant (interval can be a variable/expression)
class OnEveryMsVariable {
public:
OnEveryMsVariable() : lastTime(0) {}
bool ready(unsigned long interval) {
if (interval == 0) return false; // ignore 0 to avoid busy looping
unsigned long now = millis();
if (now - lastTime >= interval) {
lastTime = now;
return true;
}
return false;
}
private:
unsigned long lastTime;
};
// Macro for variable interval in milliseconds
#define ON_EVERY_MILLISECONDS(VAR_INTERVAL) \
static OnEveryVariable UNIQUE_NAME(__on_everyVar_); \
if (UNIQUE_NAME(__on_everyVar_).ready(VAR_INTERVAL))
// Macro for ON_EVERY_N_SECONDS
#define ON_EVERY_N_SECONDS(N) ON_EVERY_N_MILLISECONDS((N) * 1000)

326
include/RtttlPlayer.h Normal file
View File

@ -0,0 +1,326 @@
#pragma once
#include <Arduino.h>
#include <freertos/FreeRTOS.h>
#include <freertos/queue.h>
#include <freertos/semphr.h>
// Tiny, efficient RTTTL player for ESP32 LEDC (non-blocking, priority-aware)
class RtttlPlayer {
public:
// pin: GPIO to output tone, channel: LEDC channel [0..15]
// timer: LEDC timer index [0..3], resolutionBits typically 10-13
RtttlPlayer(uint8_t pin, uint8_t channel, uint8_t timer=0, uint8_t resolutionBits=10,
uint16_t queueDepth=4)
: _pin(pin), _ch(channel), _timer(timer), _resBits(resolutionBits) {
// LEDC init
ledcSetup(_ch, /*freq*/ 1000, _resBits);
ledcAttachPin(_pin, _ch);
ledcWrite(_ch, 0); // duty 0 initially
// Sync primitives
_queue = xQueueCreate(queueDepth, sizeof(PlayItem));
_mtx = xSemaphoreCreateMutex();
_stopFlag = false;
_playing = false;
_curPrio = 0;
_preempt.item = nullptr; // Initialize preempt slot
// Playback task
xTaskCreatePinnedToCore(_taskThunk, "rtttl_task", 2048, this, /*prio*/ 1, &_taskHandle, 1);
}
~RtttlPlayer() {
if (_taskHandle) vTaskDelete(_taskHandle);
if (_queue) vQueueDelete(_queue);
if (_mtx) vSemaphoreDelete(_mtx);
}
// Non-blocking request to play a tune at 'priority'.
// Copies the RTTTL string internally (heap); returns false if queue full or alloc fails.
bool play(const char* rtttl, uint8_t priority) {
if (!rtttl) return false;
const size_t len = strnlen(rtttl, 512); // Reduce memory cap for efficiency
char* buf = (char*)malloc(len + 1);
if (!buf) return false;
memcpy(buf, rtttl, len);
buf[len] = '\0';
PlayItem item{buf, priority};
// Fast path: preempt if strictly higher priority than current
xSemaphoreTake(_mtx, portMAX_DELAY);
const bool shouldPreempt = _playing && (priority > _curPrio);
if (shouldPreempt) {
// Put new item into the front by sending to a small high-prio queue slot
// Approach: set preempt slot; playback loop will pick it ASAP.
if (_preempt.item) {
// Drop older preempt request to avoid leaks, keep newest
free((void*)_preempt.item->tune);
delete _preempt.item;
}
_preempt.item = new PlayItem(item); // copy
xSemaphoreGive(_mtx);
// we own original 'buf' no longer (copied into new PlayItem); free the temporary
free(buf);
// Signal stop to current note so task can switch between notes (cheap, cooperative)
_stopFlag = true;
return true;
}
xSemaphoreGive(_mtx);
// Otherwise enqueue and return (will play after current/earlier)
if (xQueueSend(_queue, &item, 0) == pdTRUE) {
return true;
} else {
free(buf);
return false;
}
}
// Stop playback immediately and flush queue (non-blocking).
void stopAll() {
xSemaphoreTake(_mtx, portMAX_DELAY);
_stopFlag = true;
// Drain queue
PlayItem tmp;
while (xQueueReceive(_queue, &tmp, 0) == pdTRUE) {
if (tmp.tune) free((void*)tmp.tune);
}
// Drop any pending preempt
if (_preempt.item) {
free((void*)_preempt.item->tune);
delete _preempt.item;
_preempt.item = nullptr;
}
xSemaphoreGive(_mtx);
}
private:
struct PlayItem {
const char* tune; // heap-allocated copy
uint8_t priority;
};
struct PreemptSlot {
PlayItem* item = nullptr; // one-slot "front of line"
};
// ===== LEDC helpers =====
inline void toneOn(uint32_t freq) {
if (freq == 0) { // pause
ledcWrite(_ch, 0);
return;
}
// Update LEDC frequency efficiently
// Use ledcSetup to set frequency (works on all Arduino-ESP32 versions)
ledcSetup(_ch, freq, _resBits);
// Duty: ~50% for square-like tone
const uint32_t dutyMax = (1U << _resBits) - 1U;
ledcWrite(_ch, dutyMax / 2);
}
inline void toneOff() {
ledcWrite(_ch, 0);
}
// ===== Playback task =====
static void _taskThunk(void* arg) {
((RtttlPlayer*)arg)->_taskLoop();
}
void _taskLoop() {
for (;;) {
// First check preempt slot, then queue
PlayItem item{};
if (_takePreempt(item) || xQueueReceive(_queue, &item, portMAX_DELAY) == pdTRUE) {
_playing = true;
_curPrio = item.priority;
_stopFlag = false;
_playOne(item.tune);
// cleanup
free((void*)item.tune);
_playing = false;
_curPrio = 0;
}
}
}
bool _takePreempt(PlayItem& out) {
bool got = false;
xSemaphoreTake(_mtx, portMAX_DELAY);
if (_preempt.item) {
out = *_preempt.item;
delete _preempt.item;
_preempt.item = nullptr;
got = true;
}
xSemaphoreGive(_mtx);
return got;
}
// ===== RTTTL parsing & playback =====
struct Defaults { uint16_t dur = 4; uint8_t oct = 5; uint16_t bpm = 63; };
// Note frequencies for octave 4 (rounded). Others are scaled by powers of two.
// Index by semitone: C, C#, D, D#, E, F, F#, G, G#, A, A#, B
static constexpr uint16_t baseA4 = 440;
// Well derive semitone freq using integer math: f = 440 * 2^((n)/12)
// To avoid floating point in the loop, we precompute a small LUT for octave 4.
static constexpr uint16_t LUT4[12] = {
262, 277, 294, 311, 330, 349, 370, 392, 415, 440, 466, 494
};
static uint32_t freqFor(uint8_t noteIndex, int8_t octave) {
// noteIndex 0..11; octave typical 3..7
// Get octave 4 freq and scale by 2^(oct-4)
uint32_t f = LUT4[noteIndex];
if (octave > 4) f <<= (octave - 4);
else if (octave < 4) f >>= (4 - octave);
return f;
}
// Returns next token start or nullptr when finished
static const char* skipSpaces(const char* p) {
while (p && *p && (*p == ' ' || *p == ',')) ++p;
return p;
}
void _playOne(const char* rtttl) {
if (!rtttl) return;
Defaults def;
// Format: name:d=4,o=5,b=120: note[,note...]
const char* p = strchr(rtttl, ':');
if (!p) return;
const char* p2 = strchr(p + 1, ':');
if (!p2) return;
// Parse defaults between first and second ':'
parseDefaults(p + 1, p2, def);
// Whole note duration in ms
uint32_t wholenote = (60000UL / def.bpm) * 4;
// Notes section
p = p2 + 1;
p = skipSpaces(p);
while (p && *p) {
// Cooperative preemption between notes
if (_checkPreempt()) break;
uint16_t duration = 0;
// 1) Optional duration number (e.g., 16)
while (*p >= '0' && *p <= '9') {
duration = duration * 10 + (*p - '0');
++p;
}
if (duration == 0) duration = def.dur;
// 2) Note letter or pause
bool pause = false;
uint8_t noteIndex = 0xFF;
switch (tolower(*p)) {
case 'c': noteIndex = 0; break;
case 'd': noteIndex = 2; break;
case 'e': noteIndex = 4; break;
case 'f': noteIndex = 5; break;
case 'g': noteIndex = 7; break;
case 'a': noteIndex = 9; break;
case 'b': noteIndex = 11; break;
case 'p': pause = true; break;
default: break;
}
if (*p) ++p;
// 3) Optional sharp '#'
if (!pause && *p == '#') {
if (noteIndex != 0xFF) noteIndex++;
++p;
}
// 4) Optional dotted note '.'
bool dotted = false;
if (*p == '.') { dotted = true; ++p; }
// 5) Optional octave number
int8_t octave = def.oct;
if (*p >= '4' && *p <= '7') { octave = (*p - '0'); ++p; }
// Compute duration in ms
uint32_t noteDur = wholenote / duration;
if (dotted) noteDur += noteDur / 2;
// Play the note
if (pause || noteIndex == 0xFF) {
toneOff();
vTaskDelay(pdMS_TO_TICKS(noteDur));
} else {
const uint32_t f = freqFor(noteIndex % 12, octave);
toneOn(f);
// Shorten a little to add a tiny gap (staccato for clarity & queue responsiveness)
uint32_t onMs = (noteDur >= 4) ? (noteDur - 2) : noteDur;
uint32_t offMs = noteDur - onMs;
vTaskDelay(pdMS_TO_TICKS(onMs));
toneOff();
if (offMs) vTaskDelay(pdMS_TO_TICKS(offMs));
}
p = skipSpaces(p);
// Optional trailing comma already handled by skipSpaces
}
toneOff();
}
void parseDefaults(const char* beg, const char* end, Defaults& def) {
const char* p = beg;
while (p < end && *p) {
// key=value (d,o,b)
char key = tolower(*p);
const char* eq = (const char*)memchr(p, '=', end - p);
if (!eq) break;
const char* val = eq + 1;
const char* nxt = (const char*)memchr(val, ',', end - val);
if (!nxt) nxt = end;
uint32_t v = 0;
for (const char* t = val; t < nxt; ++t) {
if (*t >= '0' && *t <= '9') v = v * 10 + (*t - '0');
}
if (key == 'd' && v) def.dur = v;
else if (key == 'o' && v) def.oct = (uint8_t)v;
else if (key == 'b' && v) def.bpm = v;
p = (nxt < end) ? (nxt + 1) : end;
}
// Clamp sane ranges
if (def.dur == 0) def.dur = 4;
if (def.oct < 3 || def.oct > 7) def.oct = 5;
if (def.bpm < 20) def.bpm = 20;
if (def.bpm > 400) def.bpm = 400;
}
bool _checkPreempt() {
if (!_stopFlag) return false;
// Grab preempt immediately if present, otherwise just stop current tune
PlayItem nxt{};
if (_takePreempt(nxt)) {
// Play preempted tune immediately (no recursion - direct call)
_curPrio = nxt.priority;
_stopFlag = false;
_playOne(nxt.tune);
free((void*)nxt.tune);
}
// Indicate current tune should finish early
return true;
}
// ===== members =====
uint8_t _pin, _ch, _timer, _resBits;
TaskHandle_t _taskHandle = nullptr;
QueueHandle_t _queue = nullptr;
SemaphoreHandle_t _mtx = nullptr;
volatile bool _stopFlag;
volatile bool _playing;
volatile uint8_t _curPrio;
PreemptSlot _preempt;
};

View File

@ -21,21 +21,19 @@ typedef enum {
//Tunes
typedef struct {
bool enabled;
int cycles;
int pause;
String melody;
}BUZZ_TUNE;
#define TUNE_MAX_COUNT 12
#define TUNE_MAX_COUNT 14
extern BUZZ_TUNE buzzTune[TUNE_MAX_COUNT];
void Init_Buzzer(int8_t, const char* configPath);
void Buzzer_Beep(int, int freq=1000);
void Init_Buzzer(int8_t pin, const char* configPath, int8_t channel = -1);
void Buzzer_Load_Tunes(const char* tunesPath);
void Buzzer_Play_Tune(TUNE_TYPE, bool async=true, bool priority=true);
void Buzzer_Play_Tune(TUNE_TYPE tune, int priority=1);

View File

@ -1,6 +1,6 @@
#define FIRMWARE_VERSION_MAJOR 1
#define FIRMWARE_VERSION_MINOR 4
#define FIRMWARE_VERSION_PATCH 9
#define FIRMWARE_VERSION_PATCH 19
#define FIRMWARE_DESCRIPTION \

View File

@ -8,6 +8,7 @@
#include <functional>
#include "system.h"
#include "ColorPalettes.h"
#include "global.h"
//#include <crgb.h>
#define FASTLED_CORE 0
@ -65,10 +66,37 @@ void Init_Lights_Task(void){
Init_Strip(ledSettings[1].leds, ledSettings[1].pin, ledSettings[1].size, ledSettings[1].rgbOrder, ledSettings[1].chip, ledSettings[1].bright);
ESP_LOGD(tag, "Initializing Strip2: Pin: %d, size: %d, order: %s, chip: %s", ledSettings[1].pin, ledSettings[1].size, ledSettings[1].rgbOrder, ledSettings[1].chip);
xTaskCreatePinnedToCore(Lights_Control_Task, "Lights_Task", 1024*8, NULL, 1, &Animation_Task_Handle, FASTLED_CORE);
xTaskCreatePinnedToCore(Lights_Control_Task, "Lights_Task", 1024*6, NULL, 1, &Animation_Task_Handle, FASTLED_CORE);
ESP_LOGI(tag, "Lights Task Created...");
}
/*
void Init_Ramp_Lights_Task(void){
xTaskCreatePinnedToCore(Ramp_Lights_Control_Task, "Ramp_Lights_Task", 1024*1, NULL, 1, &Animation_Task_Handle, (FASTLED_CORE ? 1 : 0));
ESP_LOGI(tag, "Ramp Lights Task Created...");
}
void RampUpLights(int level)
{
}
void Ramp_Lights_Control_Task(void *parameters)
{
static *OutputPWM* pwmOut = NULL;
pwmOut = pwmOut[sys_settings.rampLightSettings[0].pwmOutIndex];
while(1){
sys_settings.rampLightSettings[rampIndex].pwmOutIndex
while()
vTaskDelay(100 / portTICK_PERIOD_MS);
vTaskSuspend(NULL);
}
}
*/
void Animation_Loop_Exit(void){
if( Animation_Task_Handle ){

View File

@ -4,6 +4,12 @@
#include <FastLED.h>
#include "ColorPalettes.h"
#include "esp_system.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
#include "esp_log.h"
#include <functional>
#include "PWM_Output.h"
typedef struct{
@ -449,6 +455,67 @@ void Anim_Comets(bool volatile& activeFlag, CRGB* leds, int size, const COLOR_PA
}
}
/*
void Anim_TimedFill(bool volatile& activeFlag, CRGB* leds, int size, CRGB baseCol, CRGB fillCol, int totalDurationMs, int shift = 0, PWM_Output* pwmOutput = nullptr) {
if (!leds || size <= 1 || totalDurationMs <= 0) return;
const int halfSize = size / 2;
const float msPerLed = totalDurationMs / (float)halfSize;
unsigned long startTime = millis();
// Define the point at which PWM begins ramping (75% of fill time)
const float pwmStartPoint = 0.75f;
const int pwmStartLeds = halfSize * pwmStartPoint;
// Initialize PWM output to 0 if provided
if (pwmOutput) {
pwmOutput->setOutput(0.0f);
}
fill_solid(leds, size, baseCol);
int prevLedsToLight = 0;
unsigned long currentTime;
unsigned long elapsedTime;
int ledsToLight, pos;
Animation_Loop(activeFlag, 90, [&]() -> int {
currentTime = millis();
elapsedTime = currentTime - startTime;
// Calculate how many LEDs should be lit based on elapsed time
ledsToLight = (elapsedTime / msPerLed);
if (ledsToLight > halfSize) ledsToLight = halfSize;
// Fill LEDs up to current position
for (int i = 0; i < ledsToLight; i++) {
pos = (i + shift + size) % size;
leds[pos] = fillCol;
leds[(size - 1 - i + shift + size) % size] = fillCol; // Correct mirroring calculation
}
// Handle PWM output ramp starting at 75% of fill time
if (pwmOutput && ledsToLight >= pwmStartLeds) {
// Calculate PWM value as percentage of remaining fill time
// Map from [pwmStartLeds, halfSize] to [0, 100]
float pwmValue = map(ledsToLight, pwmStartLeds, halfSize, 0, 100);
// Ensure pwmValue is in range [0, 100]
pwmValue = constrain(pwmValue, 0.0f, 100.0f);
// Set the PWM output
pwmOutput->setOutput(pwmValue);
}
// Update LEDs only when necessary
if(prevLedsToLight < ledsToLight){
FastLED.show();
}
prevLedsToLight = ledsToLight;
// Return 1 when complete
return (ledsToLight >= halfSize) ? 1 : 0;
});
}
*/
void Anim_TimedFill(bool volatile& activeFlag, CRGB* leds, int size, CRGB baseCol, CRGB fillCol, int totalDurationMs, int shift = 0) {
if (!leds || size <= 1 || totalDurationMs <= 0) return;

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,7 @@
#include "WiFi.h"
#include "ATALights.h"
#include "BleSettings.h"
#include <esp_task_wdt.h>
static const char *tag = "BLE_SP110E";
@ -134,12 +135,23 @@ class LightStickCallbacks : public NimBLECharacteristicCallbacks {
// Function to send data to all connected clients in chunks based on MTU
void sendToAllClients(const uint8_t* data, size_t len) {
if (!pStickCharacteristic || !data || len == 0) return;
if (!pStickCharacteristic) {
ESP_LOGW(tag, "Cannot send to clients: pStickCharacteristic is null");
return;
}
if (!data || len == 0) {
ESP_LOGW(tag, "Cannot send to clients: data is null or length is 0");
return;
}
// Skip if no subscribed clients (if API available)
#if defined(NIMBLE_INCLUDED) || true
#ifdef CONFIG_BT_NIMBLE_ROLE_PERIPHERAL
if (pStickCharacteristic->getSubscribedCount() == 0) return;
if (pStickCharacteristic->getSubscribedCount() == 0) {
ESP_LOGD(tag, "No clients subscribed, skipping notification");
return;
}
#endif
#endif
@ -152,9 +164,19 @@ void sendToAllClients(const uint8_t* data, size_t len) {
while (offset < len) {
size_t chunk = len - offset;
if (chunk > maxChunk) chunk = maxChunk;
pStickCharacteristic->setValue(data + offset, chunk);
// notify() returns void in this NimBLE version, so just call it without checking a return value
pStickCharacteristic->notify();
try {
pStickCharacteristic->setValue(data + offset, chunk);
// notify() returns void in this NimBLE version, but wrap in try/catch for robustness
pStickCharacteristic->notify();
ESP_LOGD(tag, "Sent %zu bytes to clients", chunk);
} catch (const std::exception& e) {
ESP_LOGE(tag, "Exception during notification: %s", e.what());
// Consider adding a delay or recovery mechanism here
break;
}
offset += chunk;
}
}
@ -172,100 +194,113 @@ void sendToAllClients(const uint8_t *data, size_t len) {
void process_BLE_SP110E_Command(const uint8_t* val, uint8_t len, NimBLECharacteristic* bleChar) {
if (!val) {
ESP_LOGE(tag, "Null command data received");
return;
}
if (len >= 4) {
uint8_t command = val[3];
ESP_LOGI(tag, "Command received: 0x%02X", command);
if (len < 4) {
ESP_LOGW(tag, "Command too short: %d bytes, expected at least 4", len);
return;
}
uint8_t response[sizeof(INFO_PACK)]; // Use a single response buffer
uint8_t command = val[3];
ESP_LOGI(tag, "Command received: 0x%02X", command);
// Handle different commands
switch (command) {
case TURN_ON:
Lights_Set_ON();
led_status.enable = 1;
//ESP_LOGI(tag, "Lights ON");
uint8_t response[sizeof(INFO_PACK)]; // Use a single response buffer
// Handle different commands
switch (command) {
case TURN_ON:
Lights_Set_ON();
led_status.enable = 1;
//ESP_LOGI(tag, "Lights ON");
break;
case TURN_OFF:
Lights_Set_OFF();
led_status.enable = 0;
//ESP_LOGI(tag, "Lights OFF");
break;
case SET_STATIC_COLOR:
if(len < 7) {
ESP_LOGW(tag, "SET_STATIC_COLOR command requires 3 parameters (R,G,B)");
break;
case TURN_OFF:
Lights_Set_OFF();
led_status.enable = 0;
//ESP_LOGI(tag, "Lights OFF");
}
led_status.red = val[1];
led_status.green = val[2];
led_status.blue = val[0];
Lights_Set_Animation(SOLID_COLOR_INDEX, val[0], val[1], val[2]);
//ESP_LOGI(tag, "Color set to R:%d G:%d B:%d", led_status.red, led_status.green, led_status.blue);
break;
case SET_BRIGHT:
if(len < 5) {
ESP_LOGW(tag, "SET_BRIGHT command requires 1 parameter (brightness)");
break;
case SET_STATIC_COLOR:
if(len < 7) {
ESP_LOGW(tag, "SET_STATIC_COLOR command requires 3 parameters (R,G,B)");
break;
}
led_status.red = val[1];
led_status.green = val[2];
led_status.blue = val[0];
Lights_Set_Animation(SOLID_COLOR_INDEX, val[0], val[1], val[2]);
//ESP_LOGI(tag, "Color set to R:%d G:%d B:%d", led_status.red, led_status.green, led_status.blue);
}
led_status.bright = val[0];
Lights_Set_Brightness(val[0]);
//ESP_LOGI(tag, "Bright set to %d", led_status.bright);
break;
case SET_WHITE:
led_status.white = val[0];
Lights_Set_White(val[0]);
//ESP_LOGI(tag, "White set to %d", led_status.white);
break;
case SET_PRESET:
led_status.preset = val[0];
Lights_Set_Animation(val[0], val[1], val[2], 0);
//ESP_LOGI(tag, "Animation set to %d", led_status.preset);
break;
case SET_SPEED:
led_status.speed = val[0];
ESP_LOGI(tag, "Mode set to %d", led_status.speed);
break;
case GET_CHECK_DEVICE: // This prepends a checksum
led_status.checksum = calculateChecksum(val);
if(bleChar != nullptr){
bleChar->setValue((uint8_t *)&led_status, sizeof(INFO_PACK));
bleChar->notify(); // Send the data immediately
}
ESP_LOGI(tag, "Check Device");
break;
case GET_DEVICE_INFO: // No checksum
led_status.checksum = 0;
if(bleChar != nullptr){
bleChar->setValue(((uint8_t *)&led_status) + 1, sizeof(INFO_PACK) - 1);
bleChar->notify(); // Send the data immediately
}
ESP_LOGI(tag, "Get Device Info");
break;
case SET_IC_MODEL:
led_status.ic_model = val[0];
ESP_LOGI(tag, "IC Model set to %d", led_status.ic_model);
break;
case SET_RGB_SEQUENCE:
led_status.channel = 0;
ESP_LOGI(tag, "Set RGB Sequence");
break;
case SET_LED_NUM:
led_status.count_msb = 0;
led_status.count_lsb = 100;
ESP_LOGI(tag, "Set LED Num");
break;
case SET_DEVICE_NAME:
ESP_LOGI(tag, "Set Device Name");
break;
default:
ESP_LOGW(tag, "Unknown command: 0x%02X", command);
break;
case SET_BRIGHT:
if(len < 5) {
ESP_LOGW(tag, "SET_BRIGHT command requires 1 parameter (brightness)");
break;
}
led_status.bright = val[0];
Lights_Set_Brightness(val[0]);
//ESP_LOGI(tag, "Bright set to %d", led_status.bright);
break;
case SET_WHITE:
led_status.white = val[0];
Lights_Set_White(val[0]);
//ESP_LOGI(tag, "White set to %d", led_status.white);
break;
case SET_PRESET:
led_status.preset = val[0];
Lights_Set_Animation(val[0], val[1], val[2], 0);
//ESP_LOGI(tag, "Animation set to %d", led_status.preset);
break;
case SET_SPEED:
led_status.speed = val[0];
ESP_LOGI(tag, "Mode set to %d", led_status.speed);
break;
case GET_CHECK_DEVICE: // This prepends a checksum
led_status.checksum = calculateChecksum(val);
if(bleChar != nullptr){
bleChar->setValue((uint8_t *)&led_status, sizeof(INFO_PACK));
bleChar->notify(); // Send the data immediately
}
ESP_LOGI(tag, "Check Device");
break;
case GET_DEVICE_INFO: // No checksum
led_status.checksum = 0;
if(bleChar != nullptr){
bleChar->setValue(((uint8_t *)&led_status) + 1, sizeof(INFO_PACK) - 1);
bleChar->notify(); // Send the data immediately
}
ESP_LOGI(tag, "Get Device Info");
break;
case SET_IC_MODEL:
led_status.ic_model = val[0];
ESP_LOGI(tag, "IC Model set to %d", led_status.ic_model);
break;
case SET_RGB_SEQUENCE:
led_status.channel = 0;
ESP_LOGI(tag, "Set RGB Sequence");
break;
case SET_LED_NUM:
led_status.count_msb = 0;
led_status.count_lsb = 100;
ESP_LOGI(tag, "Set LED Num");
break;
case SET_DEVICE_NAME:
ESP_LOGI(tag, "Set Device Name");
break;
default:
ESP_LOGW(tag, "Unknown command: 0x%02X", command);
break;
}
}
}
void Init_BLE_SP110E(NimBLEServer* pServer) {
if (!pServer) {
ESP_LOGE(tag, "Invalid BLE server pointer");
return;
}
led_status.speed = 10;
led_status.bright = 50;
led_status.ic_model = 0;
@ -325,7 +360,14 @@ void Init_BLE_LightStick_Client(){
ESP_LOGW(tag, "Light Stick Client Task already running");
return;
}
xTaskCreate(BLE_LightStick_Client_Task, "VersionCheckTask", 1024*8, NULL, 1, &LightStick_Client_Task_Handle);
BaseType_t result = xTaskCreate(BLE_LightStick_Client_Task, "LightStickTask", 1024*6, NULL, 1, &LightStick_Client_Task_Handle);
if (result != pdPASS) {
ESP_LOGE(tag, "Failed to create Light Stick client task, error: %d", result);
LightStick_Client_Task_Handle = NULL;
} else {
ESP_LOGI(tag, "Light Stick client task created successfully");
}
}
// Task for the BLE LightStick client
@ -334,7 +376,13 @@ void BLE_LightStick_Client_Task(void *parameter) {
static const char *tag = "BLE_LightStick_Client_Task";
ESP_LOGI(tag, "BLE LightStick Client Task started");
// Register task with watchdog
esp_task_wdt_add(NULL);
while (true) {
// Reset watchdog timer
esp_task_wdt_reset();
// Only try to connect if we're not already connected and a device is set.
if ((pStickClient == nullptr || !pStickClient->isConnected()) && myDevice != nullptr) {
// Create a new client instance if needed.
@ -371,11 +419,20 @@ void BLE_LightStick_Client_Task(void *parameter) {
ESP_LOGE(tag, "Failed to connect to the server");
// Delete the client instance so that a new one is created next time.
if (pStickClient != nullptr) {
NimBLEDevice::deleteClient(pStickClient);
pStickClient = nullptr;
try {
NimBLEDevice::deleteClient(pStickClient);
pStickClient = nullptr;
} catch (const std::exception& e) {
ESP_LOGE(tag, "Exception deleting client: %s", e.what());
}
}
// Wait before retrying.
vTaskDelay(pdMS_TO_TICKS(5000));
// Implement exponential backoff for connection retries
static uint16_t retryDelay = 2000; // Start with 2 seconds
retryDelay = (retryDelay * 3) / 2; // Increase by 50% each time
if (retryDelay > 30000) retryDelay = 30000; // Cap at 30 seconds
ESP_LOGI(tag, "Will retry in %d ms", retryDelay);
vTaskDelay(pdMS_TO_TICKS(retryDelay));
continue;
}
}
@ -390,6 +447,12 @@ void BLE_LightStick_Client_Task(void *parameter) {
vTaskDelay(pdMS_TO_TICKS(5000));
}
}
// Task should never exit, but in case it does:
ESP_LOGI(tag, "BLE LightStick Client Task ending");
esp_task_wdt_delete(NULL);
LightStick_Client_Task_Handle = NULL;
vTaskDelete(NULL);
}

View File

@ -80,6 +80,17 @@ class UpgradeChar_Callbacks : public NimBLECharacteristicCallbacks {
}
else if (value.compare("upgrade-start") == 0) { // Start OTA update
ESP_LOGI(tag, "Start OTA update command received");
setUpdateModeBoth();
startFirmwareUpdateTask(nullptr); // start the task
}
else if (value.compare("upgrade-start-files-only") == 0) { // Start OTA update
ESP_LOGI(tag, "Start OTA update files-only command received");
setUpdateModeFilesOnly();
startFirmwareUpdateTask(nullptr); // start the task
}
else if (value.compare("upgrade-start-firmware-only") == 0) { // Start OTA update
ESP_LOGI(tag, "Start OTA update firmware-only command received");
setUpdateModeFirmwareOnly();
startFirmwareUpdateTask(nullptr); // start the task
}
else if (value.compare("rename-device") == 0) { // Start renaming device
@ -132,14 +143,66 @@ void bleUpgrade_send_message(String s){
if (s.length() == 0) {
return;
}
// Set value and notify only if there are subscribers to avoid unnecessary work
pUpgradeCharacteristic2->setValue(s.c_str());
// Log message details before sending
ESP_LOGI(tag, "Sending BLE message, length=%d bytes", s.length());
if (s.length() < 100) {
ESP_LOGI(tag, "Message content: '%s'", s.c_str());
}
// For testing - ensure string is null-terminated properly
String paddedString = s;
// Explicitly set using raw bytes with explicit length
const char* raw = paddedString.c_str();
size_t rawLen = paddedString.length();
// Explicitly handle null-termination ourselves
std::string stdStr(raw, rawLen);
pUpgradeCharacteristic2->setValue(stdStr);
// Log the value that was actually set
std::string valueAfterSet = pUpgradeCharacteristic2->getValue();
ESP_LOGI(tag, "Value after set: length=%d bytes", valueAfterSet.length());
if (pUpgradeCharacteristic2->getSubscribedCount() > 0) {
pUpgradeCharacteristic2->notify();
ESP_LOGI(tag, "Notification sent");
} else {
ESP_LOGW(tag, "No subscribers for notification");
}
}
}
/*
void bleUpgrade_send_message(String s) {
if(pUpgradeCharacteristic2) {
if (s.length() == 0) {
return;
}
// OPTION 1: Sanitize non-printable characters
String sanitized = "";
for (size_t i = 0; i < s.length(); i++) {
char c = s[i];
// Only keep printable ASCII characters and common whitespace
if ((c >= 32 && c <= 126) || c == '\n' || c == '\r' || c == '\t') {
sanitized += c;
} else {
// Replace non-printable with hexadecimal representation or skip
sanitized += String("[0x") + String(c, HEX) + "]";
// OR just: continue; // to skip unprintable chars
}
}
// Set value and notify only if there are subscribers
pUpgradeCharacteristic2->setValue(sanitized.c_str());
if (pUpgradeCharacteristic2->getSubscribedCount() > 0) {
pUpgradeCharacteristic2->notify();
}
}
}
*/
void Init_UpgradeBLEService(NimBLEServer *pServer){
@ -170,6 +233,5 @@ void Init_UpgradeBLEService(NimBLEServer *pServer){
NimBLEAdvertising *pAdvertising = NimBLEDevice::getAdvertising();
pAdvertising->addServiceUUID( BTUpgradeServiceUUID.c_str() ); // Advertise service UUID
}

View File

@ -3,6 +3,7 @@
#include "BLE_SP110E.h"
#include "BLE_UpdateService.h"
#include "BleSettings.h"
#include "my_buzzer.h"
static const char* tag = "BleServer";
@ -39,11 +40,13 @@ public:
void onConnect(NimBLEServer* /*pServer*/) override {
ESP_LOGI(tag, "Client connected");
ensureAdvertising("onConnect");
Buzzer_Play_Tune(TUNE_CONNECTED);
}
void onDisconnect(NimBLEServer* /*pServer*/) override {
ESP_LOGI(tag, "Client disconnected");
ensureAdvertising("onDisconnect");
Buzzer_Play_Tune(TUNE_DISCONNECTED, 1);
}
private:

View File

@ -89,26 +89,55 @@ const char* jsonConstrainChar(const char *tag, const JsonObject &jsonObject, con
String jsonConstrainString(const char *tag, const JsonObject &jsonObject, const char *key, String def) {
// Check if the key exists and is not null
if (!jsonObject[key].is<String>()) {
ESP_LOGW(tag, "Key [%s] not found or null. Using default value [%s].", key, def.c_str());
// Check if the key exists using the recommended approach
if (!jsonObject[key] || jsonObject[key].isNull()) {
ESP_LOGW(tag, "Key [%s] not found/null", key);
return def;
}
// Extract the value as a String
// Handle different types to avoid unnecessary String conversions
if (jsonObject[key].is<const char*>()) {
const char* charValue = jsonObject[key].as<const char*>();
if (!charValue || *charValue == '\0') {
ESP_LOGW(tag, "Key [%s] empty", key);
return def;
}
// Create String object for return value (only once)
String value(charValue);
// Process string only if needed
const size_t MAX_STRING_LENGTH = 1024;
if (value.length() > MAX_STRING_LENGTH) {
value = value.substring(0, MAX_STRING_LENGTH);
}
// Minimal logging
ESP_LOGD(tag, "Key [%s] value set", key);
return value;
}
// For non-char types, use standard String conversion
String value = jsonObject[key].as<String>();
// Check if the value is empty
// Check for empty string
if (value.length() == 0) {
ESP_LOGW(tag, "Key [%s] value is empty. Using default value [%s].", key, def.c_str());
ESP_LOGW(tag, "Key [%s] empty", key);
return def;
}
ESP_LOGD(tag, "Key [%s] value: %s", key, value.c_str());
// Apply length constraint
const size_t MAX_STRING_LENGTH = 1024;
if (value.length() > MAX_STRING_LENGTH) {
value = value.substring(0, MAX_STRING_LENGTH);
}
// Minimal logging to reduce memory usage
ESP_LOGD(tag, "Key [%s] value set", key);
return value;
}
/*
bool jsonConstrainBool(const char *tag, const JsonObject &jsonObject, const char *key, bool def) {
// Check if the key exists and is of type boolean
if (!jsonObject[key].is<bool>()) {
@ -121,6 +150,50 @@ bool jsonConstrainBool(const char *tag, const JsonObject &jsonObject, const char
ESP_LOGD(tag, "Key [%s] value: %s", key, value ? "true" : "false");
return value;
}
*/
bool jsonConstrainBool(const char *tag, const JsonObject &jsonObject, const char *key, bool def) {
// Check if the key exists using the recommended approach
if (!jsonObject[key] || jsonObject[key].isNull()) {
ESP_LOGW(tag, "Key [%s] not found or null. Using default [%s]", key, def ? "true" : "false");
return def;
}
// Direct boolean check first (fastest path, no conversion)
if (jsonObject[key].is<bool>()) {
bool value = jsonObject[key].as<bool>();
ESP_LOGD(tag, "Key [%s] value: %s", key, value ? "true" : "false");
return value;
}
// Numeric conversion (no heap allocation)
if (jsonObject[key].is<int>() || jsonObject[key].is<float>()) {
bool value = (jsonObject[key].as<int>() != 0);
ESP_LOGD(tag, "Key [%s] numeric converted to: %s", key, value ? "true" : "false");
return value;
}
// String conversion - minimal processing
if (jsonObject[key].is<const char*>()) {
const char* str = jsonObject[key].as<const char*>();
// Fast direct comparison with common values (no heap allocation)
if (strcmp(str, "true") == 0 || strcmp(str, "1") == 0 ||
strcmp(str, "yes") == 0 || strcmp(str, "on") == 0) {
return true;
}
if (strcmp(str, "false") == 0 || strcmp(str, "0") == 0 ||
strcmp(str, "no") == 0 || strcmp(str, "off") == 0) {
return false;
}
ESP_LOGW(tag, "Key [%s] string couldn't convert to bool, using default [%s]",
key, def ? "true" : "false");
} else {
ESP_LOGW(tag, "Key [%s] type not supported for bool conversion", key);
}
return def;
}
// Explicit instantiations

View File

@ -144,7 +144,8 @@ void setup()
Init_Board_Basic(sys_settings.boardPins);
// Load tunes.json and initialize
Init_Buzzer(sys_settings.boardPins.buzzer, "/system/tunes.json");
// Reserve channel 7 for buzzer (highest channel number to avoid conflicts)
Init_Buzzer(sys_settings.boardPins.buzzer, "/system/tunes.json", 7);
// Initialize PWM Outputs
Init_PWM_Outputs(sys_settings.boardPins.relay, sys_settings.pwmOutSettings);
@ -171,10 +172,13 @@ void setup()
{
setStatusPin1(true);
UpgradeMode = true;
ESP_LOGW(tag, "Upgrade Mode Triggered");
ESP_LOGW(tag, "Enabling BLE and Update Service");
Init_BleServer(true, true);
ESP_LOGW(tag, "Enabling Wifi AP and Client");
Wifi_Init();
//Buzzer_Play_Tune(TUNE_UPGRADE_MODE);
}
else
{
@ -197,7 +201,7 @@ void setup()
Init_Lights_Task();
#endif
Buzzer_Play_Tune(TUNE_BOOT, true, true);
Buzzer_Play_Tune(TUNE_BOOT);
// TODO... Test if this is still necessary need to configure pin 0 for some reason
// pinMode(0, INPUT); // button0/boot pin
@ -225,29 +229,21 @@ void loop()
}
// Temperature Monitor
ON_EVERY_N_MILLISECONDS(5000)
static OnEveryMsVariable temperatureMonitorTimer;
if (sys_settings.tSensorSettings.enabled)
{
static float boardTemperature;
// Read temperature if the sensor is enabled
if (sys_settings.tSensorSettings.enabled)
if (temperatureMonitorTimer.ready(sys_settings.tSensorSettings.intervalMs))
{
static float boardTemperature;
boardTemperature = tSensor->readTemperatureF();
// ESP_LOGI(tag, "Board T: %F", boardTemperature);
}
// Fan Control
if (sys_settings.tSensorSettings.enabled)
{
// Fan Control
UpdateFanControl(boardTemperature, pwmOutputs[sys_settings.tSensorSettings.pwmIndex]);
}
}
// Update Tune Playing
//if (anyrtttl::nonblocking::isPlaying())
//{
// anyrtttl::nonblocking::play();
//}
// Animation TestMode Timeout
#if LEDS_ENABLED
@ -274,7 +270,7 @@ void loop()
for (int i = 0; i < 3; i++)
{
#if BUZZER_ENABLED
Buzzer_Play_Tune(TUNE_BEEP, false); // blocking
Buzzer_Play_Tune(TUNE_LOWEEP); // blocking
#endif
vTaskDelay(200);
}
@ -296,11 +292,12 @@ void loop()
}
}
// Upgrade Mode Tune
// Upgrade Mode Hearbeat tune
if(UpgradeMode){
ON_EVERY_N_MILLISECONDS(5000)
{
Buzzer_Play_Tune(TUNE_ACK, true, true);
Buzzer_Play_Tune(TUNE_LOWBEEP);
//ESP_LOGI(tag, "Upgrade Mode Heartbeat");
}
}
@ -479,9 +476,6 @@ void Load_Booth_Settings(SYS_SETTINGS &sys, const String &boothPath)
sys_settings.rampLightSettings[rampIndex].vision = jsonConstrainBool(tag, obj, "vision", true);
sys_settings.rampLightSettings[rampIndex].pwmOutIndex = jsonConstrain<int>(tag, obj, "relay-index", 0, 1, 0);
sys_settings.rampLightSettings[rampIndex].btnIndex = jsonConstrain<int>(tag, obj, "button-index", 0, 1, 0);
sys_settings.rampLightSettings[rampIndex].min = jsonConstrain<float>(tag, obj, "min", 0.0, 100.0, 0.0);
sys_settings.rampLightSettings[rampIndex].max = jsonConstrain<float>(tag, obj, "max", 5.0, 100.0, 100.0);
sys_settings.rampLightSettings[rampIndex].step = jsonConstrain<float>(tag, obj, "step", 0.01, 100.0, 1.5);
rampIndex++;
}
ESP_LOGI(tag, "Loaded Ramp Lights settings...");

View File

@ -1,6 +1,8 @@
#include "my_buttons.h"
#include "global.h"
#include "BLE_UpdateService.h"
#include "esp_log.h"
#include "AppUpgrade.h"
static const char* tag = "button";
OneButton *boardButtons[3];
@ -111,6 +113,7 @@ void btn2_click() {
//Pulse_LED_Status(150);
//Buzzer_Beep(150);
// send packet
sendUpdateMessage("testing....", false, -1);
ESP_LOGD(tag, "btn2 1x");
}

View File

@ -7,102 +7,93 @@
#include <pitches.h>
#include "JsonConstrain.h"
#include "global.h"
#include "RtttlPlayer.h"
#include "esp_log.h"
const char* DEFAULT_MELODY = "Ack:d=16,o=5,b=200:c,e,g";
// serial debugging enabled
//#define ANY_RTTTL_INFO
static const char* tag = "buzzer";
// Define static constexpr member from RtttlPlayer class
constexpr uint16_t RtttlPlayer::LUT4[12];
RtttlPlayer *player;
BUZZ_TUNE buzzTune[TUNE_MAX_COUNT];
int8_t buzzPin;
int8_t buzzerChannel = -1; // Store the LEDC channel used by the buzzer
void Init_Buzzer(int8_t pin, const char* configFile)
void Init_Buzzer(int8_t pin, const char* configFile, int8_t channel)
{
buzzPin = pin;
if(buzzPin >= 0){
pinMode(buzzPin, OUTPUT);
player = new RtttlPlayer(pin, channel);
buzzerChannel = channel;
ESP_LOGI(tag, "Buzzer hardware initialized on pin %d using LEDC channel %d", buzzPin, buzzerChannel);
}
Buzzer_Load_Tunes(configFile); // Load Tunes
ESP_LOGI(tag, "Buzzer initialized..");
ESP_LOGI(tag, "Buzzer initialized on pin %d, channel %d", buzzPin, buzzerChannel);
}
void Buzzer_Play_Tune(TUNE_TYPE tune, bool async, bool hasPriority)
void Buzzer_Play_Tune(TUNE_TYPE tune, int priority)
{
static int prev_tune = -1;
if (buzzPin < 0) return;
if(buzzPin < 0 || !player) return;
// Range / data validation
if (tune < 0 || tune >= TUNE_MAX_COUNT) {
ESP_LOGW(tag, "Invalid tune index: %d", (int)tune);
return;
}
const String &melody = buzzTune[tune].melody;
if (melody.isEmpty()) {
ESP_LOGW(tag, "Empty melody for tune %d", (int)tune);
if(tune < 0 || tune >= TUNE_MAX_COUNT){
ESP_LOGW(tag, "Invalid tune index %d", tune);
return;
}
// Async mode: begin once, then caller should periodically call again to advance playback
if (async) {
bool playing = anyrtttl::nonblocking::isPlaying();
if (hasPriority && playing) {
anyrtttl::nonblocking::stop();
playing = false;
}
if (!playing || prev_tune != tune) {
// (Re)start tune
anyrtttl::nonblocking::begin(buzzPin, melody.c_str());
prev_tune = tune;
ESP_LOGD(tag, "Started async tune %d (%s)", (int)tune, melody.c_str());
}
// Advance playback one tick
anyrtttl::nonblocking::play();
int cycles = buzzTune[tune].cycles;
int pause = buzzTune[tune].pause;
String melody = buzzTune[tune].melody;
if(melody.length() == 0){
ESP_LOGW(tag, "Tune %d has empty melody, skipping playback", tune);
return;
}
// Blocking mode: play full tune cycles with optional pause
ESP_LOGD(tag, "Playing blocking tune %d cycles=%d pause=%d", (int)tune, buzzTune[tune].cycles, buzzTune[tune].pause);
for (int c = 0; c < buzzTune[tune].cycles; ++c) {
anyrtttl::blocking::play(buzzPin, melody.c_str());
if (buzzTune[tune].pause > 0 && c + 1 < buzzTune[tune].cycles) {
delay(buzzTune[tune].pause); // simple pause between cycles
// Play the tune the specified number of cycles
for(int i = 0; i < cycles; i++){
bool played = player->play(melody.c_str(), priority ? 2 : 1); // Use priority level
if(!played){
ESP_LOGW(tag, "Failed to play tune %d (cycle %d)", tune, i+1);
return;
}
if(pause > 0 && i < cycles - 1){
delay(pause);
}
yield(); // allow other tasks to run
}
prev_tune = tune;
}
// TODO Buzzer Beep finish
void Buzzer_Beep(int mSecs, int freq)
{
/*
ledcAttachPin(buzzPin, buzzerCh);
ledcSetup(buzzerCh, 2000, 8);
ledcWrite(buzzerCh, 125);
vTaskDelay(mSecs);
ledcWrite(buzzerCh, 0);
*/
}
// TODO Reduce tunes to load ()
// Optimized tune loading - minimal memory allocation
void Buzzer_Load_Tunes(const char* tunesPath){
ESP_LOGI(tag, "Loading tunes from: %s", tunesPath);
File file = LittleFS.open(tunesPath);
if (!file) {
ESP_LOGE(tag, "Error opening %s...", tunesPath);
ESP_LOGW(tag, "Could not open %s, using default tune", tunesPath);
// Set default tune only at index 0
buzzTune[0].cycles = 1;
buzzTune[0].pause = 0;
buzzTune[0].melody = DEFAULT_MELODY;
ESP_LOGI(tag, "Loaded default tune at index 0: %s", DEFAULT_MELODY);
return;
}
// Use smaller JSON document for memory efficiency
JsonDocument doc;
DeserializationError error = deserializeJson(doc, file);
file.close();
if(error){
ESP_LOGE(tag, "%s deserialize error!..", tunesPath);
ESP_LOGE(tag, "JSON parse error: %s", error.c_str());
// Set default tune on error
buzzTune[0].cycles = 1;
buzzTune[0].pause = 0;
buzzTune[0].melody = DEFAULT_MELODY;
ESP_LOGI(tag, "Loaded default tune due to JSON error: %s", DEFAULT_MELODY);
return;
}
@ -113,12 +104,19 @@ void Buzzer_Load_Tunes(const char* tunesPath){
if(tuneIndex >= TUNE_MAX_COUNT) break;
buzzTune[tuneIndex].cycles = jsonConstrain<int>(tag, obj, "cycles", 1, 100, 1);
buzzTune[tuneIndex].pause = jsonConstrain<int>(tag, obj, "pause", 0, 100, 0);
buzzTune[tuneIndex].melody = jsonConstrainString(tag, obj, "tune", DEFAULT_MELODY);
ESP_LOGD(tag, "tune %d : %s", tuneIndex, buzzTune[tuneIndex].melody.c_str());
buzzTune[tuneIndex].melody = jsonConstrainString(tag, obj, "tune", DEFAULT_MELODY);
ESP_LOGI(tag, "Loaded tune %d: cycles=%d, pause=%d, melody=%.40s...",
tuneIndex, buzzTune[tuneIndex].cycles, buzzTune[tuneIndex].pause,
buzzTune[tuneIndex].melody.c_str());
tuneIndex++;
}
ESP_LOGI(tag, "Loaded tunes...");
}else{
ESP_LOGE(tag, "Error!, %s key: tunes not found..", tunesPath);
ESP_LOGI(tag, "Successfully loaded %d tunes", tuneIndex);
} else {
ESP_LOGW(tag, "No 'tunes' array found in JSON");
// Set default tune if no tunes array found
buzzTune[0].cycles = 1;
buzzTune[0].pause = 0;
buzzTune[0].melody = DEFAULT_MELODY;
ESP_LOGI(tag, "Loaded default tune: %s", DEFAULT_MELODY);
}
}

View File

@ -164,25 +164,36 @@ bool StartWifiConnectTask(String ssid = "", String pass = "")
return false;
}
if (!wifi_task_running)
{
client_ssid = ssid;
client_pass = pass;
if (Wifi_Task_Handle == NULL)
// Create mutex if it doesn't exist
if (wifiMutex == nullptr) {
wifiMutex = xSemaphoreCreateMutex();
}
// Take mutex with timeout
if (xSemaphoreTake(wifiMutex, pdMS_TO_TICKS(1000)) == pdTRUE) {
if (!wifi_task_running)
{
ESP_LOGI(tag, "Creating WiFi task");
xTaskCreatePinnedToCore(Wifi_ConnectTask, "Wifi_Task", 1024 * 4, NULL, 1, &Wifi_Task_Handle, 0);
client_ssid = ssid;
client_pass = pass;
if (Wifi_Task_Handle == NULL)
{
ESP_LOGI(tag, "Creating WiFi task");
xTaskCreatePinnedToCore(Wifi_ConnectTask, "Wifi_Task", 1024 * 6, NULL, 1, &Wifi_Task_Handle, 0);
xSemaphoreGive(wifiMutex);
return true;
}
else
{
ESP_LOGI(tag, "WiFi task already running");
}
}
else
{
ESP_LOGI(tag, "WiFi task already running");
ESP_LOGE(tag, "Task already running");
}
return true;
}
else
{
ESP_LOGE(tag, "Task already running");
xSemaphoreGive(wifiMutex);
} else {
ESP_LOGE(tag, "Failed to acquire mutex - WiFi operation in progress");
}
return false;
@ -193,6 +204,9 @@ void Wifi_ConnectTask(void *parameter)
static const char *tag = "Wifi_Task";
wifi_task_running = true;
// Register task with watchdog to prevent system hangs
esp_task_wdt_add(NULL);
if (WiFi.status() != WL_CONNECTED || client_ssid != WiFi.SSID())
{
ESP_LOGI(tag, "Connecting to: %s", client_ssid.c_str());
@ -206,6 +220,9 @@ void Wifi_ConnectTask(void *parameter)
uint8_t attempts = 0;
while (WiFi.status() != WL_CONNECTED && attempts < MAX_ATTEMPTS)
{
// Reset watchdog timer to prevent timeouts during connection attempts
esp_task_wdt_reset();
switch (WiFi.status())
{
case WL_NO_SSID_AVAIL:
@ -242,6 +259,9 @@ void Wifi_ConnectTask(void *parameter)
ESP_LOGI(tag, "Wifi Task ended");
// Unregister from watchdog before deletion
esp_task_wdt_delete(NULL);
Wifi_Task_Handle = NULL;
wifi_task_running = false;
vTaskDelete(NULL);
@ -249,17 +269,25 @@ void Wifi_ConnectTask(void *parameter)
void Wifi_Check_Internet()
{
// Check for internet connection
const char *host = "8.8.8.8"; // Google DNS server
if (Ping.ping(host, 1))
{
InternetAvailable = true;
ESP_LOGI(tag, "Internet connection verified");
// Check for internet connection with multiple fallback servers
const char *hosts[] = {"8.8.8.8", "1.1.1.1", "208.67.222.222"}; // Google DNS, Cloudflare DNS, OpenDNS
const int num_hosts = sizeof(hosts) / sizeof(hosts[0]);
InternetAvailable = false;
// Try pinging each host
for (int i = 0; i < num_hosts; i++) {
if (Ping.ping(hosts[i], 1)) {
InternetAvailable = true;
ESP_LOGI(tag, "Internet connection verified via %s", hosts[i]);
break;
}
// Small delay between ping attempts
vTaskDelay(pdMS_TO_TICKS(100));
}
else
{
InternetAvailable = false;
ESP_LOGW(tag, "No internet connection");
if (!InternetAvailable) {
ESP_LOGW(tag, "No internet connection after trying multiple DNS servers");
}
}
@ -304,38 +332,122 @@ bool Wifi_Save_Credentials(String path)
return true;
}
/**
* Scans for available WiFi networks and stores the results in JSON format
*
* Updates scanStatus global: 0=none, 1=scanning, 2=complete, -1=error
* Sets scanInProgress flag during operation
* Populates networkList with JSON formatted scan results
*/
void Wifi_Scan_for_Networks()
{
// Start a scan for available networks
WiFi.scanNetworks(false, false);
while (WiFi.scanComplete() == WIFI_SCAN_RUNNING)
{
vTaskDelay(100); // Wait for scan to complete
static const char* tag = "WiFiScan";
const uint32_t SCAN_TIMEOUT_MS = 15000; // 15 second timeout for scan
// Protect against concurrent scans
if (scanInProgress) {
ESP_LOGW(tag, "WiFi scan already in progress");
return;
}
// Use mutex for thread safety if available
bool useMutex = (wifiMutex != nullptr);
if (useMutex && xSemaphoreTake(wifiMutex, pdMS_TO_TICKS(1000)) != pdTRUE) {
ESP_LOGE(tag, "Failed to acquire mutex - WiFi operation in progress");
return;
}
scanInProgress = true;
scanStatus = 1; // Scanning
ESP_LOGI(tag, "Starting WiFi network scan");
// Start scan (async=false, show_hidden=false)
WiFi.scanNetworks(false, false);
// Wait for scan with timeout
uint32_t startTime = millis();
while (WiFi.scanComplete() == WIFI_SCAN_RUNNING)
{
// Check for timeout
if (millis() - startTime > SCAN_TIMEOUT_MS) {
ESP_LOGE(tag, "WiFi scan timeout after %u ms", SCAN_TIMEOUT_MS);
scanInProgress = false;
scanStatus = -1; // Error
if (useMutex) xSemaphoreGive(wifiMutex);
return;
}
// Reset watchdog if needed
#ifdef CONFIG_TASK_WDT_CHECK_IDLE_TASK_CPU0
esp_task_wdt_reset();
#endif
vTaskDelay(pdMS_TO_TICKS(100)); // Wait for scan to complete
}
// Get scan results
networkCount = WiFi.scanComplete();
if (networkCount >= 0)
{
ESP_LOGI(tag, "WiFi scan complete, found %d networks", networkCount);
scanStatus = 2; // Complete
// Create JSON document with appropriate capacity
JsonDocument doc;
doc.clear();
JsonArray networks = doc["networks"].to<JsonArray>();
for (int i = 0; i < networkCount; i++)
{
auto network = networks.add<JsonObject>();
// Basic network info
network["ssid"] = WiFi.SSID(i);
network["rssi"] = WiFi.RSSI(i);
network["encryption"] = WiFi.encryptionType(i) != WIFI_AUTH_OPEN;
network["channel"] = WiFi.channel(i);
// Security details
wifi_auth_mode_t encType = WiFi.encryptionType(i);
network["encryption"] = encType != WIFI_AUTH_OPEN;
// Add detailed encryption type
const char* encTypeStr = "unknown";
switch (encType) {
case WIFI_AUTH_OPEN: encTypeStr = "open"; break;
case WIFI_AUTH_WEP: encTypeStr = "WEP"; break;
case WIFI_AUTH_WPA_PSK: encTypeStr = "WPA_PSK"; break;
case WIFI_AUTH_WPA2_PSK: encTypeStr = "WPA2_PSK"; break;
case WIFI_AUTH_WPA_WPA2_PSK: encTypeStr = "WPA_WPA2_PSK"; break;
case WIFI_AUTH_WPA2_ENTERPRISE: encTypeStr = "WPA2_ENTERPRISE"; break;
case WIFI_AUTH_WPA3_PSK: encTypeStr = "WPA3_PSK"; break;
case WIFI_AUTH_WPA2_WPA3_PSK: encTypeStr = "WPA2_WPA3_PSK"; break;
case WIFI_AUTH_WAPI_PSK: encTypeStr = "WAPI_PSK"; break;
default: encTypeStr = "unknown"; break;
}
network["security"] = encTypeStr;
// Add signal quality 0-100%
int rssi = WiFi.RSSI(i);
int rssiLimited = rssi < -100 ? -100 : (rssi > -50 ? -50 : rssi);
int quality = ((rssiLimited + 100) * 2); // Convert -100..-50 to 0..100
network["quality"] = quality;
}
String jsonString;
serializeJson(doc, jsonString);
networkList = jsonString;
// Serialize to the global variable
networkList.clear();
serializeJson(doc, networkList);
// Clean up scan results from memory
WiFi.scanDelete();
}
else
{
ESP_LOGE(tag, "WiFi scan failed");
ESP_LOGE(tag, "WiFi scan failed with error code: %d", networkCount);
scanStatus = -1; // Error
}
scanInProgress = false;
if (useMutex) xSemaphoreGive(wifiMutex);
}
void Setup_WebServer_Handlers(AsyncWebServer &server)
@ -374,7 +486,7 @@ void Setup_WebServer_Handlers(AsyncWebServer &server)
String pass = request->getParam("pass", false, false)->value();
// Validate credentials
if (client_ssid.length() < 1 || client_pass.length() < 8) {
if (ssid.length() < 1 || pass.length() < 8) {
ESP_LOGE(tag, "Invalid credentials");
request->send(400, "application/json", "{\"error\":\"Invalid credentials\"}");
return;
@ -584,21 +696,20 @@ void Setup_WebServer_Handlers(AsyncWebServer &server)
// If a dynamic URL was loaded, override base
extern String updateUrl; // declared in AppUpgrade.cpp
if(updateUrl.length()) updater.setBaseUrl(updateUrl);
if(!updater.checkManifest()){
ESP_LOGE(tag, "Manifest check failed via /upgrade/check");
} else {
otaVersion = updater.otaVersion;
}
// checkManifest() does not return a bool; capture its result (type-dependent) instead of using it in a boolean expression
auto manifestResult = updater.checkManifest();
// TODO: inspect manifestResult for success/failure once its API is known
otaVersion = updater.otaVersion;
bool avail = otaVersion > localVersion;
JsonDocument doc;
doc["currentVersion"] = localVersion.toString();
doc["latestVersion"] = otaVersion.toString();
doc["updateAvailable"] = avail;
JsonDocument doc;
doc["currentVersion"] = localVersion.toString();
doc["latestVersion"] = otaVersion.toString();
doc["updateAvailable"] = avail;
String response;
serializeJson(doc, response);
request->send(200, "application/json", response); });
String response;
serializeJson(doc, response);
request->send(200, "application/json", response); });
// Start update process
server.on("/upgrade/start", HTTP_POST, [](AsyncWebServerRequest *request)
{
@ -682,7 +793,7 @@ void Setup_WebServer_Handlers(AsyncWebServer &server)
void handleFilesUpload_OnBody(AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final)
{
static const size_t MAX_UPLOAD_SIZE = 1024 * 1024; // 1MB limit
static const size_t MAX_UPLOAD_SIZE = 1024 * 512; // 512KB limit
if (!index)
{

View File

@ -0,0 +1,716 @@
#include "AppUpgrade.h"
#include "esp_log.h"
#include <MD5Builder.h>
#include <LittleFS.h>
#include <memory>
#include "global.h"
#include "JsonConstrain.h"
#include "BLE_UpdateService.h"
#include <HTTPClient.h>
#include <Update.h>
#include <cstring>
static const char* TAG = "AppUpdater";
TaskHandle_t Update_Task_Handle = NULL;
TaskHandle_t versionCheckTask_Handle = NULL;
volatile bool g_UpdateCancelFlag = false; // cancellation flag
String updateUrl = "";
Version otaVersion;
AppUpdater::AppUpdater(fs::FS& fs, Version localVersion, const char* bucket, const char* manifestName, const char* appBin)
: localVersion(localVersion), manifestName(manifestName), appName(appBin), fileSystem(fs), downloadBuffer(new uint8_t[BUFFER_SIZE])
{
baseUrl = bucket ? String(bucket) : String(DEFAULT_MANIFEST_URL);
// Ensure baseUrl ends with a single '/'
if(!baseUrl.endsWith("/")) baseUrl += "/";
ESP_LOGI(TAG, "AppUpdater initialized (local v%s) baseUrl=%s", localVersion.toString().c_str(), baseUrl.c_str());
}
void AppUpdater::setProgressCallback(void (*callback)( UpdateStatus status, int percentage, const char* message)) {
progressCb = callback;
}
void AppUpdater::updateProgress(UpdateStatus newStatus, int percentage, const char* message) {
status = newStatus;
if (progressCb) {
progressCb(status, percentage, message);
}
}
AppUpdater::ManifestCheckResult AppUpdater::checkManifest() {
String url = buildUrl(manifestName);
ESP_LOGD(TAG, "Fetching manifest from: %s", url.c_str());
String payload;
for(int attempt=0; attempt<HTTP_RETRY_COUNT; ++attempt){
if(g_UpdateCancelFlag) return ManifestCheckResult::ERROR_FETCH_FAILED;
HTTPClient http;
http.begin(url);
int httpCode = http.GET();
if (httpCode == HTTP_CODE_OK) {
payload = http.getString();
http.end();
break;
}
ESP_LOGW(TAG, "Manifest GET failed (attempt %d/%d): %d", attempt+1, HTTP_RETRY_COUNT, httpCode);
http.end();
if(attempt+1 < HTTP_RETRY_COUNT) vTaskDelay(pdMS_TO_TICKS(HTTP_RETRY_DELAY_MS));
}
if(payload.isEmpty()){
ESP_LOGE(TAG, "Failed to fetch manifest after retries");
return ManifestCheckResult::ERROR_FETCH_FAILED;
}
if(payload.length() > MAX_MANIFEST_SIZE){
ESP_LOGE(TAG, "Manifest too large (%u bytes)", (unsigned)payload.length());
return ManifestCheckResult::ERROR_TOO_LARGE;
}
// Parse JSON
DeserializationError error = deserializeJson(jsonManifest, payload);
ESP_LOGD(TAG, "Manifest deserialized");
if (error) {
ESP_LOGE(TAG, "Failed to parse manifest: %s", error.c_str());
return ManifestCheckResult::ERROR_PARSE_FAILED;
}
// Check for files section
jsonFilesArray = jsonManifest["files"];
if (jsonFilesArray.isNull()) {
ESP_LOGE(TAG, "No files section in manifest");
return ManifestCheckResult::ERROR_NO_FILES_SECTION;
}else{
ESP_LOGD(TAG, "%d Files found", jsonFilesArray.size());
}
// Check for version section
JsonObject jsonVersion = jsonManifest["version"];
ESP_LOGD(TAG, "Version section found");
if (jsonVersion.isNull()) {
ESP_LOGE(TAG, "No version section in manifest");
return ManifestCheckResult::ERROR_NO_VERSION;
}
// Get the remote version
byte major = jsonVersion["major"] | 0;
byte minor = jsonVersion["minor"] | 0;
byte patch = jsonVersion["patch"] | 0;
otaVersion = {major, minor, patch};
//Version localVersion;
//::sscanf(localVersion, "%d.%d.%d", &localVersion.major, &localVersion.minor, &localVersion.patch);
// Check if an update is available
updateAvailable = false;
// Only mark update available if remote is strictly newer than local
if (otaVersion <= localVersion) {
ESP_LOGI(TAG, "No updates available: remote=%s, local=%s",
otaVersion.toString().c_str(), localVersion.toString().c_str());
return ManifestCheckResult::VERSION_CURRENT;
}else{
updateAvailable = true;
ESP_LOGI(TAG, "Update available: remote=%s, local=%s",
otaVersion.toString().c_str(), localVersion.toString().c_str());
}
//ESP_LOGD(TAG, "Manifest content: %s", payload.c_str());
return ManifestCheckResult::UPDATE_AVAILABLE;
}
bool AppUpdater::updateFile(const char* remotePath, const char* localPath, const char* expectedMd5) {
//updateProgress(UpdateStatus::DOWNLOADING, 0, localPath);
// Construct full URL
String url = buildUrl(remotePath);
ESP_LOGD(TAG, "Downloading: %s -> %s", url.c_str(), localPath);
// Quick skip: if exists and size & MD5 match
bool skip = false;
if(fileSystem.exists(localPath)){
String localMd5 = getLocalMD5(localPath);
if(localMd5.equals(expectedMd5)) skip = true;
}
if(skip){
ESP_LOGI(TAG, "File already up to date: %s", localPath);
updateProgress(UpdateStatus::FILE_SKIPPED, 100, localPath);
return true;
}
// Start the download
HTTPClient http;
int httpCode = -1;
for(int attempt=0; attempt<HTTP_RETRY_COUNT; ++attempt){
if(g_UpdateCancelFlag) return false;
http.begin(url);
httpCode = http.GET();
if(httpCode == HTTP_CODE_OK) break;
ESP_LOGW(TAG, "File GET failed (attempt %d/%d): %d", attempt+1, HTTP_RETRY_COUNT, httpCode);
http.end();
if(attempt+1 < HTTP_RETRY_COUNT) vTaskDelay(pdMS_TO_TICKS(HTTP_RETRY_DELAY_MS));
}
if (httpCode != HTTP_CODE_OK) {
ESP_LOGE(TAG, "Download failed: %d", httpCode);
updateProgress(UpdateStatus::ERROR, 0, "Download failed");
return false;
}
// Get the stream and content length
WiFiClient* stream = http.getStreamPtr();
size_t contentLength = http.getSize();
// Verify and save the file
bool success = verifyAndSaveFile(stream, contentLength, localPath, expectedMd5);
http.end();
if(!success){
String errMsg = String(localPath) + " MD5 failed";
updateProgress( UpdateStatus::ERROR, 0, errMsg.c_str() );
}else{
updateProgress( UpdateStatus::FILE_SAVED, 100, localPath);
}
return success;
}
bool AppUpdater::verifyAndSaveFile(WiFiClient* stream, size_t contentLength, const char* localPath, const char* expectedMd5)
{
MD5Builder md5;
md5.begin();
size_t totalRead = 0;
// Create temporary filename
String tempPath = String(localPath) + ".tmp";
// Open temporary file for writing
File file = fileSystem.open(tempPath.c_str(), FILE_WRITE);
if (!file) {
ESP_LOGE(TAG, "Failed to open temporary file for writing");
return false;
}
//updateProgress(UpdateStatus::DOWNLOADING, 0, localPath);
if (contentLength > 0) {
// Single pass with known content length
while (totalRead < contentLength) {
if(g_UpdateCancelFlag){ file.close(); fileSystem.remove(tempPath.c_str()); return false; }
size_t available = stream->available();
if (available) {
size_t readLen = stream->readBytes(downloadBuffer.get(), std::min(available, size_t(BUFFER_SIZE)));
// Write to temp file and update MD5
if (file.write(downloadBuffer.get(), readLen) != readLen) {
ESP_LOGE(TAG, "Failed to write to temporary file");
file.close();
fileSystem.remove(tempPath.c_str());
return false;
}
md5.add(downloadBuffer.get(), readLen);
totalRead += readLen;
updateProgress(UpdateStatus::DOWNLOADING, (totalRead * 80) / contentLength , localPath);
}
yield();
}
} else {
// Unknown content length: read until stream ends
for (;;) {
if(g_UpdateCancelFlag){ file.close(); fileSystem.remove(tempPath.c_str()); return false; }
size_t readLen = stream->readBytes(downloadBuffer.get(), BUFFER_SIZE);
if (readLen == 0) {
break;
}
if (file.write(downloadBuffer.get(), readLen) != readLen) {
ESP_LOGE(TAG, "Failed to write to temporary file");
file.close();
fileSystem.remove(tempPath.c_str());
return false;
}
md5.add(downloadBuffer.get(), readLen);
totalRead += readLen;
// Progress unknown; emit periodic heartbeats at 0%
// For unknown size, send heartbeats every ~16KB
if((totalRead & 0x3FFF) == 0){
updateProgress(UpdateStatus::DOWNLOADING, 0, localPath);
}
yield();
}
}
file.close();
md5.calculate();
String calculatedMd5 = md5.toString();
// Verify MD5 hash
updateProgress(UpdateStatus::VERIFYING, 90, localPath);
if (!calculatedMd5.equals(expectedMd5)) {
//ESP_LOGE(TAG, "MD5 mismatch for %s", localPath);
fileSystem.remove(tempPath.c_str());
return false;
}
updateProgress(UpdateStatus::VERIFYING, 95, localPath);
// Replace original file with verified temp file
if (fileSystem.exists(localPath)) {
fileSystem.remove(localPath);
}
if (!fileSystem.rename(tempPath.c_str(), localPath)) {
ESP_LOGE(TAG, "Failed to rename temporary file");
fileSystem.remove(tempPath.c_str());
return false;
}
updateProgress(UpdateStatus::VERIFYING, 100, localPath);
return true;
}
String AppUpdater::getLocalMD5(const char* filePath){
File file = fileSystem.open(filePath, "r");
if(!file){
ESP_LOGE(TAG, "Error opening %s...", filePath);
return String();
}
MD5Builder md5Builder;
md5Builder.begin();
size_t fileSize = file.size();
size_t totalRead = 0;
size_t readLen = 0;
while (totalRead < fileSize) {
readLen = file.readBytes(reinterpret_cast<char*>(downloadBuffer.get()), std::min(fileSize - totalRead, size_t(BUFFER_SIZE)));
md5Builder.add(downloadBuffer.get(), readLen);
totalRead += readLen;
}
md5Builder.calculate();
file.close();
return md5Builder.toString();
}
bool AppUpdater::updateFilesArray() {
int successCount = 0;
int totalFiles = jsonFilesArray.size();
ESP_LOGI(TAG, "Found %d files in manifest", totalFiles);
// Iterate over each file entry in the manifest
for (JsonObject file : jsonFilesArray) {
const char* remotePath = file["path"];
const char* localPath = remotePath;
// If path begins with "data/" or "/data/" strip only the "data" portion, retaining the leading slash
if (localPath) {
if (strncmp(localPath, "data/", 5) == 0) {
localPath += 4; // points to '/'
} else if (strncmp(localPath, "/data/", 6) == 0) {
localPath += 5; // points to '/'
}
}
const char* expectedMd5 = file["md5"];
// Skip invalid entries
if (!remotePath || !localPath || !expectedMd5) {
ESP_LOGE(TAG, "Invalid file entry in manifest");
continue;
}
// Attempt to update the file
if (updateFile(remotePath, localPath, expectedMd5)) {
successCount++;
}
}
ESP_LOGI(TAG, "Manifest update complete: %d/%d files updated", successCount, totalFiles);
return successCount == totalFiles;
}
bool AppUpdater::updateApp() {
updateProgress(UpdateStatus::MESSAGE, 0, "Starting firmware update");
// Check for firmware section in manifest
if (!jsonManifest["firmware"].is<JsonObject>() || !jsonManifest["firmware"]["md5"].is<const char*>()) {
ESP_LOGE(TAG, "Invalid firmware section in manifest");
updateProgress(UpdateStatus::ERROR, 0, "Firmware: Invalid firmware section in manifest");
return false;
}
// Get the firmware MD5 hash and URL
const char* expectedMd5 = jsonManifest["firmware"]["md5"];
String firmwareUrl = buildUrl(appName);
// Download the firmware
HTTPClient http;
int httpCode = -1;
for(int attempt=0; attempt<HTTP_RETRY_COUNT; ++attempt){
if(g_UpdateCancelFlag) return false;
http.begin(firmwareUrl);
httpCode = http.GET();
if(httpCode == HTTP_CODE_OK) break;
ESP_LOGW(TAG, "Firmware GET failed (attempt %d/%d): %d", attempt+1, HTTP_RETRY_COUNT, httpCode);
http.end();
if(attempt+1 < HTTP_RETRY_COUNT) vTaskDelay(pdMS_TO_TICKS(HTTP_RETRY_DELAY_MS));
}
if (httpCode != HTTP_CODE_OK) {
ESP_LOGE(TAG, "Firmware download failed: %d", httpCode);
updateProgress(UpdateStatus::ERROR, 0, "Firmware: Firmware download failed");
return false;
}
// Check available space
size_t firmwareSize = http.getSize();
if (!Update.begin(firmwareSize > 0 ? firmwareSize : UPDATE_SIZE_UNKNOWN)) {
ESP_LOGE(TAG, "Firmware: Not enough space for update");
updateProgress(UpdateStatus::ERROR, 0, "Firmware: Not enough space for update");
http.end();
return false;
}
// Set up MD5 checking
MD5Builder md5;
md5.begin();
// Download and verify firmware
WiFiClient* stream = http.getStreamPtr();
if (firmwareSize > 0) {
size_t remaining = firmwareSize;
while (remaining > 0) {
if(g_UpdateCancelFlag){ Update.abort(); http.end(); return false; }
size_t chunk = std::min(remaining, size_t(BUFFER_SIZE));
size_t read = stream->readBytes(downloadBuffer.get(), chunk);
// Check for timeout
if (read == 0) {
ESP_LOGE(TAG, "Read timeout");
Update.abort();
http.end();
return false;
}
// Update MD5 and write firmware
md5.add(downloadBuffer.get(), read);
if (Update.write(downloadBuffer.get(), read) != read) {
ESP_LOGE(TAG, "Write failed");
Update.abort();
http.end();
return false;
}
remaining -= read;
updateProgress(UpdateStatus::DOWNLOADING, (firmwareSize - remaining) * 100 / firmwareSize, "firmware");
}
} else {
// Unknown size: stream until end
for (;;) {
if(g_UpdateCancelFlag){ Update.abort(); http.end(); return false; }
size_t read = stream->readBytes(downloadBuffer.get(), BUFFER_SIZE);
if (read == 0) break;
md5.add(downloadBuffer.get(), read);
if (Update.write(downloadBuffer.get(), read) != read) {
ESP_LOGE(TAG, "Write failed");
Update.abort();
http.end();
return false;
}
updateProgress(UpdateStatus::DOWNLOADING, 0, "firmware");
}
}
// Verify MD5
md5.calculate();
String calculatedMd5 = md5.toString();
updateProgress(UpdateStatus::VERIFYING, 95, "firmware");
if (!calculatedMd5.equals(expectedMd5)) {
ESP_LOGE(TAG, "MD5 mismatch. Expected: %s, Got: %s", expectedMd5, calculatedMd5.c_str());
updateProgress(UpdateStatus::MD5_FAILED, 0, "Firmware: MD5 mismatch");
Update.abort();
http.end();
return false;
}
// Finish update
if (!Update.end()) {
ESP_LOGE(TAG, "Update end failed");
updateProgress(UpdateStatus::ERROR, 0, "Firmware: Update failed");
http.end();
return false;
}
http.end();
updateProgress(UpdateStatus::COMPLETE, 100, "Firmware: Complete");
return true;
}
bool AppUpdater::IsUpdateAvailable(){
return updateAvailable;
}
String AppUpdater::buildUrl(const char* path) const {
if(!path || !*path) return baseUrl; // just base
String p(path);
// If already absolute URL, pass through
if(p.startsWith("http://") || p.startsWith("https://")) return p;
// Strip leading slashes to avoid double
while(p.startsWith("/")) p.remove(0,1);
// Ensure baseUrl has single trailing slash
String b = baseUrl;
if(!b.endsWith("/")) b += "/";
return b + p;
}
AsyncEventSource* eventProgress = nullptr;
void startFirmwareUpdateTask(AsyncEventSource* evProg) {
eventProgress = evProg;
if(Update_Task_Handle) {
ESP_LOGW(TAG, "Firmware update task already running");
return;
}
xTaskCreate(firmwareUpdateTask, "FirmwareUpdate", 1024*8, NULL, 1, &Update_Task_Handle);
}
void firmwareUpdateTask(void* parameter) {
static const char* TAG = "UpdateTask";
AppUpdater* updater = nullptr;
try {
loadUpdateJson();
// Initialize updater
updater = new AppUpdater(LittleFS, localVersion, updateUrl.c_str(), "manifest.json", "firmware.bin");
updater->setProgressCallback(updateProgress);
ESP_LOGI(TAG, "Starting update check from: %s", updateUrl.c_str());
// Check and perform updates
auto manifestResult = updater->checkManifest();
if (manifestResult != AppUpdater::ManifestCheckResult::UPDATE_AVAILABLE) {
// Handle different error cases
std::string errorMsg;
switch (manifestResult) {
case AppUpdater::ManifestCheckResult::ERROR_FETCH_FAILED:
errorMsg = "Failed to fetch manifest";
break;
case AppUpdater::ManifestCheckResult::ERROR_TOO_LARGE:
errorMsg = "Manifest file too large";
break;
case AppUpdater::ManifestCheckResult::ERROR_PARSE_FAILED:
errorMsg = "Failed to parse manifest";
break;
case AppUpdater::ManifestCheckResult::ERROR_NO_FILES_SECTION:
errorMsg = "Manifest missing files section";
break;
case AppUpdater::ManifestCheckResult::ERROR_NO_VERSION:
errorMsg = "Manifest missing version section";
break;
case AppUpdater::ManifestCheckResult::VERSION_CURRENT:
errorMsg = "Current version is up to date";
// This is not actually an error
ESP_LOGI(TAG, "No update needed: %s", errorMsg.c_str());
throw std::runtime_error(errorMsg);
break;
default:
errorMsg = "Unknown manifest check error";
}
throw std::runtime_error(errorMsg);
}
if (updater->IsUpdateAvailable()) {
ESP_LOGI(TAG, "Update available, updating files...");
if (!updater->updateFilesArray()) {
throw std::runtime_error("Failed to update files");
}
ESP_LOGI(TAG, "Updating firmware...");
if (!updater->updateApp()) {
throw std::runtime_error("Failed to update firmware");
}
ESP_LOGI(TAG, "Update successful, restarting...");
sendUpdateMessage("Restarting ", true, 100);
vTaskDelay(2000);
ESP.restart();
}
} catch (const std::exception& e) {
ESP_LOGE(TAG, "Update failed: %s", e.what());
}
delete updater;
Update_Task_Handle = NULL;
vTaskDelete(NULL);
}
void startVersionCheckTask() {
if(versionCheckTask_Handle != NULL) {
ESP_LOGW(TAG, "Version Check Tak already running");
return;
}
xTaskCreate(versionCheckTask, "VersionCheckTask", 1024*8, NULL, 1, &versionCheckTask_Handle);
}
void versionCheckTask(void* parameter){
if(updateUrl == ""){
loadUpdateJson();
}
AppUpdater updater(LittleFS, localVersion, updateUrl.c_str(), "manifest.json", "firmware.bin");
auto manifestResult = updater.checkManifest();
if (manifestResult == AppUpdater::ManifestCheckResult::UPDATE_AVAILABLE ||
manifestResult == AppUpdater::ManifestCheckResult::VERSION_CURRENT) {
otaVersion = updater.otaVersion; // capture remote
ESP_LOGI(TAG, "Version check: remote=%s", otaVersion.toString().c_str());
} else {
ESP_LOGE(TAG, "Version check: manifest check failed with code %d", static_cast<int>(manifestResult));
}
versionCheckTask_Handle = NULL;
vTaskDelete(NULL);
}
void loadUpdateJson(void) {
try {
ESP_LOGD(TAG, "loadUpdateJaon function...");
if(updateUrl == "") {
String updateJsonPath = "/system/update.json";
// Read and parse update.json
File file = LittleFS.open(updateJsonPath);
if (!file) {
throw std::runtime_error("Failed to open update.json");
}
JsonDocument doc;
DeserializationError error = deserializeJson(doc, file);
file.close();
if (error) { throw std::runtime_error("Failed to parse update.json"); }
// Get update configuration
JsonObject jObj = doc.as<JsonObject>();
String folderName = jsonConstrainString(TAG, jObj, "folder", "latest/");
String baseUrl = jsonConstrainString(TAG, jObj, "baseurl", "https://s3-minio.boothwizard.com/boothifier/");
updateUrl = baseUrl + folderName;
ESP_LOGD(TAG, "updateUrl: %s", updateUrl.c_str());
}
} catch (const std::exception& e) {
ESP_LOGE(TAG, "Update failed: %s", e.what());
}
}
void updateProgress(AppUpdater::UpdateStatus newStatus, int percentage, const char* message = nullptr) {
char buffer[128];
const char* msg;
bool isComplete = false;
const char* safeMsg = message ? message : "";
switch (newStatus) {
case AppUpdater::UpdateStatus::IDLE:
snprintf(buffer, sizeof(buffer), "Update idle");
msg = buffer;
break;
case AppUpdater::UpdateStatus::MESSAGE:
msg = message ? message : "";
break;
case AppUpdater::UpdateStatus::DOWNLOADING:
snprintf(buffer, sizeof(buffer), "%s: Download progress: %d%%", safeMsg, percentage);
msg = buffer;
break;
case AppUpdater::UpdateStatus::VERIFYING:
snprintf(buffer, sizeof(buffer), "%s: Verifying update: %d%%", safeMsg, percentage);
msg = buffer;
break;
case AppUpdater::UpdateStatus::FILE_SKIPPED:
snprintf(buffer, sizeof(buffer), "%s: File Skipped, up to date", safeMsg);
msg = buffer;
break;
case AppUpdater::UpdateStatus::FILE_SAVED:
snprintf(buffer, sizeof(buffer), "%s: File Saved", safeMsg);
msg = buffer;
break;
case AppUpdater::UpdateStatus::MD5_FAILED:
snprintf(buffer, sizeof(buffer), "%s: MD5 Verification Failed", safeMsg);
msg = buffer;
break;
case AppUpdater::UpdateStatus::COMPLETE:
snprintf(buffer, sizeof(buffer), "Firmware Update Complete!!!");
msg = buffer;
isComplete = true;
break;
case AppUpdater::UpdateStatus::ERROR:
snprintf(buffer, sizeof(buffer), "Error!: %s", safeMsg);
msg = buffer;
break;
default:
snprintf(buffer, sizeof(buffer), "Unknown update status: %d", (int)newStatus);
msg = buffer;
break;
}
ESP_LOGI(TAG, "%s", msg);
sendUpdateMessage(msg, isComplete, percentage);
}
void sendUpdateMessage(const char* message, bool complete, int progress = -1) {
if(eventProgress && eventProgress->count() > 0) {
// This is for the web client and not the BLE client
JsonDocument jsonDoc;
jsonDoc["message"] = message;
jsonDoc["complete"] = complete;
jsonDoc["progress"] = progress;
String strMessage;
serializeJson(jsonDoc, strMessage);
eventProgress->send(strMessage.c_str(), "update", millis());
}
else{
ESP_LOGW(TAG, "No clients connected to event source");
}
bleUpgrade_send_message(message);
}
// (Removed duplicate global checkManifest; AppUpdater::checkManifest used instead)
/*
void setup() {
Serial.begin(115200);
// Initialize WiFi connection first
// ... WiFi connection code ...
// Initialize filesystem
if(!LittleFS.begin()) {
Serial.println("LittleFS Mount Failed");
return;
}
// Create updater instance with:
// - Current version: "1.0.0"
// - Update server URL: "https://my-update-server.com/"
// - Filesystem: LittleFS
AppUpdater updater("1.0.0", "https://storage.googleapis.com/boothifier/latest/", LittleFS);
// Set progress callback
updater.setProgressCallback([](int progress) {
Serial.printf("Update progress: %d%%\n", progress);
});
// Check and update firmware
if (updater.checkAndUpdate()) {
Serial.println("Update successful! Rebooting...");
ESP.restart();
}
// Update specific files from manifest
int updatedFiles = updater.updateFilesFromManifest("test_update.json");
Serial.printf("Updated %d files\n", updatedFiles);
}
*/

View File

@ -0,0 +1,533 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ATA Firmware Update</title>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 20px;
background-color: #f4f4f4;
text-align: center;
}
h1 {
font-size: 22px;
margin-bottom: 5px;
}
.status-container {
display: flex;
align-items: center;
justify-content: left;
margin-bottom: 4px;
}
.status-indicator-ble {
width: 20px;
height: 20px;
border-radius: 50%;
background-color: gray;
margin-right: 10px;
}
.status-indicator-wifi {
width: 20px;
height: 20px;
border-radius: 50%;
background-color: gray;
margin-right: 10px;
}
.status-indicator-internet {
width: 20px;
height: 20px;
border-radius: 50%;
background-color: gray;
margin-right: 10px;
}
.btn-container {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
margin-bottom: 10px;
}
/* Adds space above the WiFi Connect button */
.btn-container.wifi {
margin-top: 20px;
}
button {
flex: 1;
max-width: 130px;
padding: 10px;
font-size: 16px;
border: none;
border-radius: 5px;
cursor: pointer;
background-color: #007bff;
color: white;
transition: background 0.3s ease;
}
button:disabled {
background-color: #ccc;
}
button:hover:not(:disabled) {
background-color: #0056b3;
}
textarea {
width: 100%;
height: 300px;
font-size: 14px;
padding: 10px;
border-radius: 5px;
border: 1px solid #ccc;
resize: none;
}
.input-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
margin-top: 15px;
}
input {
width: 90%;
max-width: 300px;
padding: 10px;
font-size: 16px;
border: 1px solid #ccc;
border-radius: 5px;
text-align: center;
}
input::placeholder {
text-align: center;
}
@media (max-width: 480px) {
body {
padding: 15px;
}
h1 {
font-size: 20px;
}
button {
font-size: 14px;
padding: 8px;
}
input, textarea {
font-size: 14px;
}
}
/* Tabs */
.tab-bar { display:flex; gap:8px; justify-content:center; margin:12px 0 16px; flex-wrap:wrap; }
.tab-bar button { max-width:none; flex:0 0 auto; background:#6c757d; }
.tab-bar button.active { background:#007bff; }
.tab-panel { display:none; }
.tab-panel.active { display:block; }
</style>
</head>
<body>
<h1>ATA Firmware Update</h1>
<!-- Tab Buttons -->
<div class="tab-bar">
<button class="tab-btn active" data-tab="tab-upgrade">Upgrade</button>
<button class="tab-btn" data-tab="tab-wifi">WiFi Comm</button>
</div>
<div id="tab-upgrade" class="tab-panel active">
<!-- Status Indicators -->
<div class="status-container">
<span class="status-indicator-ble"></span>
<label id="status-ble-connection">BLE Status: ...</label>
</div>
<div class="status-container">
<span class="status-indicator-wifi"></span>
<label id="status-wifi-client">Wifi Client: ...</label>
</div>
<div class="status-container">
<span class="status-indicator-internet"></span>
<label id="status-internet">Internet: ...</label>
</div>
<div class="status-container">
<label id="status-current-version">Curr Version: ...</label>
</div>
<div class="status-container">
<label id="status-new-version">New Version: ...</label>
</div>
<div class="btn-container">
<label id="ble-device-name">Device Name:</label>
</div>
<div class="btn-container">
<input type="text" id="input-DeviceName" placeholder="..." style="width: 100%; max-width: 220px;" required>
</div>
<!-- Buttons -->
<div class="btn-container">
<button id="bleConnectBtn" onclick="connectToBle()">Connect</button>
<button id="checkStatusBtn" onclick="checkStatus()" disabled>Check Status</button>
</div>
<!-- Log Area -->
<textarea id="logArea" readonly></textarea>
<div class="btn-container">
<button id="checkVersionBtn" onclick="checkVersion()" disabled>Check Version</button>
<button id="startUpgradeBtn" onclick="startUpgrade()" disabled>Start Update</button>
</div>
</div> <!-- /tab-upgrade -->
<div id="tab-wifi" class="tab-panel">
<h2 style="margin-top:0; font-size:18px;">WiFi Connection</h2>
<div class="input-container">
<input type="text" id="wifissid" name="wifissid" placeholder="Enter WiFi SSID" required>
<input type="password" id="wifipassword" name="wifipassword" placeholder="Enter WiFi Password" required>
<div style="display: flex; align-items: center; gap: 5px;">
<input type="checkbox" id="showPassword" onclick="togglePasswordVisibility()" style="width: auto;">
<label for="showPassword">Show Password</label>
</div>
</div>
<div class="btn-container wifi">
<button id="wifiConnectBtn" onclick="wifiConnect()" disabled>Connect Wifi</button>
</div>
</div><!-- /tab-wifi -->
<script>
(function(){
'use strict';
/* ================= Constants & Packet Layout ================= */
const BLE_SERVER_NAME = "ATALIGHTS"; // Keep hardcoded per instruction (ignore external JSON)
const BLE_SERVICE_UUID = "abcdef01-2345-6789-1234-56789abcdef0";
const BLE_CHARACTERISTIC1_UUID = "abcdef01-2345-6789-1234-56789abcdef1"; // Control / status
const BLE_CHARACTERISTIC2_UUID = "abcdef02-2345-6789-1234-56789abcdef1"; // Logs / events
// Packet layout (mirrors firmware struct updateStatus)
// byte 0 : wifiStatus (enum)
// byte 1 : wifiOnline (bool)
// bytes2-5: wifiIP
// bytes6-8: currVersion (major,minor,patch)
// bytes9-11: newVersion (major,minor,patch)
// bytes12-31: wifiSSID (20 bytes, null padded)
const PACKET_LEN = 32;
const OFF_WIFI_STATUS = 0;
const OFF_WIFI_ONLINE = 1;
const OFF_WIFI_IP = 2;
const OFF_CURR_VER = 6;
const OFF_NEW_VER = 9;
const OFF_WIFI_SSID = 12;
const WIFI_STAT = { DISCONNECTED:0, BAD_CREDS:1, NO_AP:2, CONNECTED:3 };
const WIFI_STAT_TEXT = ["Disconnected","Bad Creds","No AP","Connected"];
const MAX_LOG_LINES = 400;
/* ================= State ================= */
let bleDevice=null, bleCharacteristic1=null, bleCharacteristic2=null;
let bleConnected=false;
const state = {
wifiStatus: WIFI_STAT.DISCONNECTED,
wifiOnline:false,
wifiIP:[0,0,0,0],
currVersion:[0,0,0],
newVersion:[0,0,0],
wifiSSID:""
};
/* ================= Cached DOM ================= */
const el = {};
function cacheDom(){
el.bleIndicator = document.querySelector('.status-indicator-ble');
el.wifiIndicator = document.querySelector('.status-indicator-wifi');
el.internetIndicator = document.querySelector('.status-indicator-internet');
el.lblBle = document.getElementById('status-ble-connection');
el.lblWifi = document.getElementById('status-wifi-client');
el.lblInternet = document.getElementById('status-internet');
el.lblCurrVer = document.getElementById('status-current-version');
el.lblNewVer = document.getElementById('status-new-version');
el.inDeviceName = document.getElementById('input-DeviceName');
el.inSsid = document.getElementById('wifissid');
el.inPass = document.getElementById('wifipassword');
el.chkShowPass = document.getElementById('showPassword');
el.btnBleConnect = document.getElementById('bleConnectBtn');
el.btnCheckStatus = document.getElementById('checkStatusBtn');
el.btnCheckVersion = document.getElementById('checkVersionBtn');
el.btnStartUpgrade = document.getElementById('startUpgradeBtn');
el.btnWifiConnect = document.getElementById('wifiConnectBtn');
el.logArea = document.getElementById('logArea');
}
/* ================= Utilities ================= */
function logMessage(msg){
const lines = el.logArea.value.trim().length ? el.logArea.value.split(/\n/) : [];
lines.push(msg);
if(lines.length > MAX_LOG_LINES){
lines.splice(0, lines.length - MAX_LOG_LINES);
}
el.logArea.value = lines.join('\n') + '\n';
el.logArea.scrollTop = el.logArea.scrollHeight;
}
function compareVersions(a,b){
for(let i=0;i<3;i++){ if(a[i]>b[i]) return 1; if(a[i]<b[i]) return -1; }
return 0;
}
function colorIndicator(elm, color){ if(elm) elm.style.backgroundColor = color; }
function ipToString(ip){ return ip.join('.'); }
/* ================= Packet Handling ================= */
function parsePacket(data){
if(data.length !== PACKET_LEN) return false;
state.wifiStatus = data[OFF_WIFI_STATUS];
if(state.wifiStatus > WIFI_STAT.CONNECTED) state.wifiStatus = WIFI_STAT.DISCONNECTED; // clamp
state.wifiOnline = !!data[OFF_WIFI_ONLINE];
state.wifiIP = Array.from(data.slice(OFF_WIFI_IP, OFF_WIFI_IP+4));
state.currVersion = Array.from(data.slice(OFF_CURR_VER, OFF_CURR_VER+3));
state.newVersion = Array.from(data.slice(OFF_NEW_VER, OFF_NEW_VER+3));
// Extract SSID (stop at first 0)
let rawSsidBytes = data.slice(OFF_WIFI_SSID, OFF_WIFI_SSID+20);
let zeroIndex = rawSsidBytes.indexOf(0);
if(zeroIndex >= 0) rawSsidBytes = rawSsidBytes.slice(0, zeroIndex);
state.wifiSSID = rawSsidBytes.length ? String.fromCharCode(...rawSsidBytes) : "";
return true;
}
function updateUI(){
// BLE
el.lblBle.textContent = 'BLE Status: ' + (bleConnected ? 'Connected' : 'Disconnected');
colorIndicator(el.bleIndicator, bleConnected ? 'green' : 'gray');
// WiFi client
const statText = WIFI_STAT_TEXT[state.wifiStatus] || 'Unknown';
if(state.wifiStatus === WIFI_STAT.CONNECTED){
const ssidPart = state.wifiSSID ? ' SSID: '+state.wifiSSID : '';
el.lblWifi.textContent = 'Wifi Client: ' + statText + (state.wifiIP[0] ? ' ('+ipToString(state.wifiIP)+')':'' ) + ssidPart;
colorIndicator(el.wifiIndicator, 'green');
} else if(state.wifiStatus === WIFI_STAT.BAD_CREDS){
el.lblWifi.textContent = 'Wifi Client: Bad Credentials';
colorIndicator(el.wifiIndicator, 'orange');
} else if(state.wifiStatus === WIFI_STAT.NO_AP){
el.lblWifi.textContent = 'Wifi Client: AP Not Found';
colorIndicator(el.wifiIndicator, 'orange');
} else {
el.lblWifi.textContent = 'Wifi Client: ' + statText;
colorIndicator(el.wifiIndicator, 'gray');
}
// Internet
el.lblInternet.textContent = state.wifiOnline ? 'Online' : 'Offline';
colorIndicator(el.internetIndicator, state.wifiOnline ? 'green' : 'gray');
// Versions
el.lblCurrVer.textContent = state.currVersion[0] ? 'Curr Version: ' + state.currVersion.join('.') : 'Curr Version: ...';
el.lblNewVer.textContent = state.newVersion[0] ? 'New Version: ' + state.newVersion.join('.') : 'New Version: ...';
// Buttons
el.btnCheckStatus.disabled = !bleConnected;
el.btnWifiConnect.disabled = !bleConnected;
el.btnCheckVersion.disabled = !(bleConnected && state.wifiOnline);
const newerAvail = state.newVersion[0] && state.wifiOnline && compareVersions(state.newVersion, state.currVersion) > 0;
el.btnStartUpgrade.disabled = !newerAvail;
}
/* ================= BLE Operations ================= */
async function connectToBle(){
if(!navigator.bluetooth){ logMessage('Web Bluetooth not supported.'); return; }
try{
bleDevice = await navigator.bluetooth.requestDevice({
filters:[{ name: el.inDeviceName.value || BLE_SERVER_NAME }],
optionalServices:[BLE_SERVICE_UUID]
});
const server = await bleDevice.gatt.connect();
const service = await server.getPrimaryService(BLE_SERVICE_UUID);
bleCharacteristic1 = await service.getCharacteristic(BLE_CHARACTERISTIC1_UUID);
bleCharacteristic2 = await service.getCharacteristic(BLE_CHARACTERISTIC2_UUID);
await bleCharacteristic2.startNotifications();
bleCharacteristic2.addEventListener('characteristicvaluechanged', e => {
try{
const view = e.target.value; // DataView
const bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength);
// Debug info
let debugInfo = `Received ${bytes.length} bytes: `;
for (let i = 0; i < bytes.length; i++) {
debugInfo += bytes[i].toString(16).padStart(2, '0') + ' ';
}
console.log(debugInfo);
// Try to decode as text
let txt = '';
try {
txt = new TextDecoder().decode(bytes);
// Remove null terminators and trim
const nullIdx = txt.indexOf('\0');
if (nullIdx !== -1) txt = txt.slice(0, nullIdx);
txt = txt.trim();
} catch (decodeErr) {
console.error('Text decode error:', decodeErr);
// Fallback to showing hex if text decode fails
txt = '[Binary data: ' + debugInfo + ']';
}
// Show both raw and processed data in console
console.log('--> Raw bytes:', bytes);
console.log('--> As text:', txt);
// Log message with length info for debugging
logMessage(`--> (${bytes.length} bytes) ${txt}`);
} catch(err) {
console.error('Processing error', err);
logMessage('--> Error processing message: ' + err.message);
}
});
bleConnected=true;
bleDevice.addEventListener('gattserverdisconnected', onDisconnect);
const connectedName = bleDevice.name || el.inDeviceName.value || 'Device';
logMessage('Connected to ' + connectedName);
const nameLabel = document.getElementById('ble-device-name');
if(nameLabel){ nameLabel.textContent = 'Device Name: ' + connectedName; }
await readPacket();
updateUI();
}catch(err){
logMessage( err.message.includes('cancel') ? 'Connection cancelled.' : ('Connection failed: '+err.message) );
}
}
function onDisconnect(){
bleConnected=false; updateUI(); logMessage('BLE disconnected');
}
async function sendPacket(msg){
if(!bleCharacteristic1) return;
const enc = new TextEncoder();
for(let attempt=0; attempt<3; attempt++){
try{ await bleCharacteristic1.writeValueWithResponse(enc.encode(msg)); return true; }
catch(e){ if(attempt===2) logMessage('Send failed: '+e.message); else await delay(1000); }
}
return false;
}
async function readPacket(){
if(!bleCharacteristic1) return false;
for(let attempt=0; attempt<3; attempt++){
try{
const val = await bleCharacteristic1.readValue();
const data = new Uint8Array(val.buffer);
if(parsePacket(data)) return true; else { logMessage('Packet parse failed (len='+data.length+')'); return false; }
}catch(e){ if(attempt===2) logMessage('Read failed: '+e.message); else await delay(1000); }
}
return false;
}
/* ================= Actions ================= */
async function wifiConnect(){
const ssid = el.inSsid.value.trim();
const pw = el.inPass.value;
if(!ssid || !pw){ alert('Enter SSID & password'); return; }
logMessage('Sending WiFi credentials...');
el.btnWifiConnect.disabled = true; // prevent multiple submissions while polling
await sendPacket('wifi-connect {"ssid":"'+ssid+'","pass":"'+pw+'"} ');
// Poll for status for up to 15s
const start = Date.now();
while(Date.now()-start < 15000){
await delay(1000);
await readPacket();
updateUI();
if(state.wifiStatus === WIFI_STAT.CONNECTED){
logMessage('WiFi Connected: '+ipToString(state.wifiIP));
break;
}
if(state.wifiStatus === WIFI_STAT.BAD_CREDS){ logMessage('WiFi Error: Bad Credentials'); break; }
if(state.wifiStatus === WIFI_STAT.NO_AP){ logMessage('WiFi Error: AP Not Found'); break; }
}
if(state.wifiStatus !== WIFI_STAT.CONNECTED){ logMessage('WiFi connect attempt finished with status: '+WIFI_STAT_TEXT[state.wifiStatus]); }
if(!bleConnected) return; // keep disabled if BLE disconnected during process
// Re-enable for retry unless connected
if(state.wifiStatus !== WIFI_STAT.CONNECTED){ el.btnWifiConnect.disabled = false; }
}
async function checkStatus(){ if(await readPacket()) updateUI(); }
async function checkVersion(){
el.btnCheckVersion.disabled = true;
logMessage('Checking for new version...');
await sendPacket('version-check');
const start = Date.now();
while(Date.now()-start < 15000){
await delay(750);
await readPacket();
if(state.newVersion[0]){ logMessage('Latest version: '+state.newVersion.join('.')); break; }
}
if(!state.newVersion[0]) logMessage('No new version info received');
updateUI();
}
async function startUpgrade(){
if(el.btnStartUpgrade.disabled) return;
logMessage('Starting upgrade...');
el.btnStartUpgrade.disabled = true;
await sendPacket('upgrade-start');
// Progress will arrive via characteristic2 logs
}
/* ================= Helpers ================= */
const delay = ms => new Promise(r=>setTimeout(r,ms));
function togglePasswordVisibility(){ el.inPass.type = el.chkShowPass.checked ? 'text' : 'password'; }
function init(){
cacheDom();
el.inDeviceName.value = BLE_SERVER_NAME;
el.chkShowPass.addEventListener('change', togglePasswordVisibility);
// Inline onclicks already wired; ensure functions are in scope
// Tab switching
document.querySelectorAll('.tab-btn').forEach(btn=>{
btn.addEventListener('click', ()=>{
document.querySelectorAll('.tab-btn').forEach(b=>b.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p=>p.classList.remove('active'));
btn.classList.add('active');
const id = btn.getAttribute('data-tab');
const panel = document.getElementById(id);
if(panel) panel.classList.add('active');
});
});
updateUI();
logMessage('Ready. Enter device name or use default and press Connect.');
}
// Expose functions for inline handlers
window.connectToBle = connectToBle;
window.checkStatus = checkStatus;
window.checkVersion = checkVersion;
window.startUpgrade = startUpgrade;
window.wifiConnect = wifiConnect;
window.togglePasswordVisibility = togglePasswordVisibility;
window.addEventListener('DOMContentLoaded', init);
})();
</script>
</body>
</html>

249
temporary/my_buzzer.cpp Normal file
View File

@ -0,0 +1,249 @@
#include "my_buzzer.h"
#include <FS.h>
#include <LittleFS.h>
#include <anyrtttl.h>
#include <binrtttl.h>
#include <pitches.h>
#include "JsonConstrain.h"
#include "global.h"
const char* DEFAULT_MELODY = "Ack:d=16,o=5,b=200:c,e,g";
// serial debugging enabled
//#define ANY_RTTTL_INFO
static const char* tag = "buzzer";
BUZZ_TUNE buzzTune[TUNE_MAX_COUNT];
int8_t buzzPin;
int8_t buzzerChannel = -1; // Store the LEDC channel used by the buzzer
// File-scope state for tune management (minimal overhead)
static volatile int prev_tune = -1;
static volatile bool buzzer_busy = false;
// Optimized tone functions - minimal overhead, no logging
void buzzerTone(uint8_t pin, unsigned int frequency, unsigned long duration) {
if (buzzerChannel >= 0 && frequency > 0) {
ledcWriteTone(buzzerChannel, frequency);
}
}
void buzzerNoTone(uint8_t pin) {
if (buzzerChannel >= 0) {
ledcWrite(buzzerChannel, 0);
}
}
void Init_Buzzer(int8_t pin, const char* configFile, int8_t channel)
{
buzzPin = pin;
if(buzzPin >= 0){
pinMode(buzzPin, OUTPUT);
// If channel is not provided, find an unused one
if (channel < 0) {
buzzerChannel = findUnusedLedcChannel();
if (buzzerChannel < 0) {
ESP_LOGE(tag, "No available LEDC channel for buzzer");
return;
}
} else {
// Use the provided channel and mark it as used
extern bool markLedcChannelUsed(int ch); // Function from global.cpp
if (markLedcChannelUsed(channel)) {
buzzerChannel = channel;
} else {
ESP_LOGE(tag, "Requested channel %d is already in use, finding alternative", channel);
buzzerChannel = findUnusedLedcChannel();
if (buzzerChannel < 0) {
ESP_LOGE(tag, "No available LEDC channel for buzzer");
return;
}
}
}
// Set up the channel for the buzzer with proper audio frequency range
ledcSetup(buzzerChannel, 2000, 10); // 2000 Hz base, 10-bit resolution for better frequency range
ledcAttachPin(buzzPin, buzzerChannel);
// Test the channel is working
ESP_LOGI(tag, "Testing buzzer channel %d...", buzzerChannel);
ledcWriteTone(buzzerChannel, 1000); // Test tone
delay(100);
ledcWrite(buzzerChannel, 0); // Stop test tone
// Set custom tone functions for anyrtttl
anyrtttl::setToneFunction(buzzerTone);
anyrtttl::setNoToneFunction(buzzerNoTone);
ESP_LOGI(tag, "Buzzer hardware initialized on pin %d using LEDC channel %d", buzzPin, buzzerChannel);
}
Buzzer_Load_Tunes(configFile); // Load Tunes
ESP_LOGI(tag, "Buzzer initialized on pin %d, channel %d", buzzPin, buzzerChannel);
}
int8_t Buzzer_Get_Channel() {
return buzzerChannel;
}
void Buzzer_Play_Tune(TUNE_TYPE tune, bool async, bool hasPriority)
{
// Fast path checks - minimal overhead
if (buzzPin < 0 || buzzerChannel < 0) {
ESP_LOGW(tag, "Buzzer not initialized - pin:%d, channel:%d", buzzPin, buzzerChannel);
return;
}
if (tune < 0 || tune >= TUNE_MAX_COUNT) {
ESP_LOGW(tag, "Invalid tune index: %d (max: %d)", (int)tune, TUNE_MAX_COUNT);
return;
}
// Direct reference to avoid String copying
const String& melody = buzzTune[tune].melody;
if (melody.isEmpty()) {
ESP_LOGW(tag, "Empty melody for tune %d", (int)tune);
return;
}
ESP_LOGI(tag, "Playing tune %d: %s (async=%d, priority=%d)", (int)tune, melody.c_str(), async, hasPriority);
// Simple atomic check for thread safety without mutex overhead
if (buzzer_busy && !hasPriority) {
ESP_LOGD(tag, "Buzzer busy, skipping tune %d", (int)tune);
return;
}
// Async mode: minimal state management
if (async) {
bool playing = anyrtttl::nonblocking::isPlaying();
if (hasPriority && playing) {
ESP_LOGD(tag, "Stopping current tune for priority tune %d", (int)tune);
anyrtttl::nonblocking::stop();
playing = false;
}
if (!playing || prev_tune != tune) {
ESP_LOGI(tag, "Starting async tune %d", (int)tune);
anyrtttl::nonblocking::begin(buzzPin, melody.c_str());
prev_tune = tune;
}
anyrtttl::nonblocking::play();
return;
}
// Blocking mode: minimal cycles with yield for multitasking
ESP_LOGI(tag, "Playing blocking tune %d, cycles=%d", (int)tune, buzzTune[tune].cycles);
buzzer_busy = true;
const int cycles = buzzTune[tune].cycles;
const int pause_ms = buzzTune[tune].pause;
for (int c = 0; c < cycles; ++c) {
anyrtttl::blocking::play(buzzPin, melody.c_str());
if (pause_ms > 0 && c + 1 < cycles) {
delay(pause_ms);
}
yield(); // Allow other tasks to run
}
prev_tune = tune;
buzzer_busy = false;
ESP_LOGI(tag, "Finished playing tune %d", (int)tune);
}
// Optimized beep function - minimal overhead
void Buzzer_Beep(int mSecs, int freq)
{
if (buzzPin < 0 || buzzerChannel < 0) return;
ledcWriteTone(buzzerChannel, freq);
delay(mSecs);
ledcWrite(buzzerChannel, 0);
}
// Test function to verify buzzer functionality
void Buzzer_Test() {
if (buzzPin < 0 || buzzerChannel < 0) {
ESP_LOGE(tag, "Cannot test buzzer - not initialized");
return;
}
ESP_LOGI(tag, "Testing buzzer...");
// Test direct LEDC control
ESP_LOGI(tag, "Test 1: Direct LEDC tones");
for (int freq = 500; freq <= 2000; freq += 500) {
ESP_LOGI(tag, "Playing %d Hz", freq);
ledcWriteTone(buzzerChannel, freq);
delay(200);
ledcWrite(buzzerChannel, 0);
delay(100);
}
// Test custom tone functions
ESP_LOGI(tag, "Test 2: Custom tone functions");
buzzerTone(buzzPin, 1000, 500);
delay(500);
buzzerNoTone(buzzPin);
// Test anyrtttl with a simple melody
ESP_LOGI(tag, "Test 3: anyrtttl blocking play");
anyrtttl::blocking::play(buzzPin, DEFAULT_MELODY);
ESP_LOGI(tag, "Buzzer test complete");
}
// Optimized tune loading - minimal memory allocation
void Buzzer_Load_Tunes(const char* tunesPath){
ESP_LOGI(tag, "Loading tunes from: %s", tunesPath);
File file = LittleFS.open(tunesPath);
if (!file) {
ESP_LOGW(tag, "Could not open %s, using default tune", tunesPath);
// Set default tune only at index 0
buzzTune[0].cycles = 1;
buzzTune[0].pause = 0;
buzzTune[0].melody = DEFAULT_MELODY;
ESP_LOGI(tag, "Loaded default tune at index 0: %s", DEFAULT_MELODY);
return;
}
// Use smaller JSON document for memory efficiency
JsonDocument doc;
DeserializationError error = deserializeJson(doc, file);
file.close();
if(error){
ESP_LOGE(tag, "JSON parse error: %s", error.c_str());
// Set default tune on error
buzzTune[0].cycles = 1;
buzzTune[0].pause = 0;
buzzTune[0].melody = DEFAULT_MELODY;
ESP_LOGI(tag, "Loaded default tune due to JSON error: %s", DEFAULT_MELODY);
return;
}
JsonArray tuneJsonArray = doc["tunes"];
if(!tuneJsonArray.isNull()){
int tuneIndex = 0;
for(JsonObject obj : tuneJsonArray){
if(tuneIndex >= TUNE_MAX_COUNT) break;
buzzTune[tuneIndex].cycles = jsonConstrain<int>(tag, obj, "cycles", 1, 100, 1);
buzzTune[tuneIndex].pause = jsonConstrain<int>(tag, obj, "pause", 0, 100, 0);
buzzTune[tuneIndex].melody = jsonConstrainString(tag, obj, "tune", DEFAULT_MELODY);
ESP_LOGI(tag, "Loaded tune %d: cycles=%d, pause=%d, melody=%.40s...",
tuneIndex, buzzTune[tuneIndex].cycles, buzzTune[tuneIndex].pause,
buzzTune[tuneIndex].melody.c_str());
tuneIndex++;
}
ESP_LOGI(tag, "Successfully loaded %d tunes", tuneIndex);
} else {
ESP_LOGW(tag, "No 'tunes' array found in JSON");
// Set default tune if no tunes array found
buzzTune[0].cycles = 1;
buzzTune[0].pause = 0;
buzzTune[0].melody = DEFAULT_MELODY;
ESP_LOGI(tag, "Loaded default tune: %s", DEFAULT_MELODY);
}
}

View File

@ -183,7 +183,7 @@ void Wifi_Init() {
ESP_LOGD(tag, "AP started with IP: %s", WiFi.softAPIP().toString().c_str());
// Start the WiFi task
xTaskCreatePinnedToCore(Wifi_Task, "Wifi_Task", 1024*4, NULL, 1, &Wifi_Task_Handle, 0);
xTaskCreatePinnedToCore(Wifi_Task, "Wifi_Task", 1024*6, NULL, 1, &Wifi_Task_Handle, 0);
}
void Wifi_Load_Settings(String path){