commit 9-7-25
This commit is contained in:
parent
12b5b25081
commit
084de5cd44
@ -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": {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
@ -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()
|
||||
@ -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()
|
||||
@ -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 = [
|
||||
|
||||
@ -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
|
||||
@ -198,4 +234,11 @@ void handleUpdateProgress(AsyncWebServerRequest *request);
|
||||
|
||||
void startVersionCheckTask();
|
||||
|
||||
void versionCheckTask(void* parameter);
|
||||
void versionCheckTask(void* parameter);
|
||||
|
||||
// Convenience functions for setting update mode
|
||||
void setGlobalUpdateMode(UpdateMode mode);
|
||||
UpdateMode getGlobalUpdateMode();
|
||||
void setUpdateModeFilesOnly();
|
||||
void setUpdateModeFirmwareOnly();
|
||||
void setUpdateModeBoth();
|
||||
@ -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
326
include/RtttlPlayer.h
Normal 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;
|
||||
// We’ll 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;
|
||||
};
|
||||
@ -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);
|
||||
|
||||
|
||||
|
||||
@ -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 \
|
||||
|
||||
@ -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 ){
|
||||
|
||||
@ -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
@ -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) {
|
||||
ESP_LOGW(tag, "Command too short: %d bytes, expected at least 4", len);
|
||||
return;
|
||||
}
|
||||
|
||||
if (len >= 4) {
|
||||
uint8_t command = val[3];
|
||||
ESP_LOGI(tag, "Command received: 0x%02X", command);
|
||||
uint8_t command = val[3];
|
||||
ESP_LOGI(tag, "Command received: 0x%02X", command);
|
||||
|
||||
uint8_t response[sizeof(INFO_PACK)]; // Use a single response buffer
|
||||
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");
|
||||
// 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
|
||||
@ -333,8 +375,14 @@ void Init_BLE_LightStick_Client(){
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
38
src/main.cpp
38
src/main.cpp
@ -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...");
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
205
src/my_wifi.cpp
205
src/my_wifi.cpp
@ -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;
|
||||
@ -192,7 +203,10 @@ 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
|
||||
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)
|
||||
{
|
||||
vTaskDelay(100); // Wait for scan to complete
|
||||
// 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;
|
||||
|
||||
String response;
|
||||
serializeJson(doc, response);
|
||||
request->send(200, "application/json", response); });
|
||||
JsonDocument doc;
|
||||
doc["currentVersion"] = localVersion.toString();
|
||||
doc["latestVersion"] = otaVersion.toString();
|
||||
doc["updateAvailable"] = avail;
|
||||
|
||||
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)
|
||||
{
|
||||
|
||||
716
temporary/AppUpgrade_orig.cpp
Normal file
716
temporary/AppUpgrade_orig.cpp
Normal 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);
|
||||
}
|
||||
|
||||
*/
|
||||
533
temporary/ata-boothifier-upgradeV3_old.html
Normal file
533
temporary/ata-boothifier-upgradeV3_old.html
Normal 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
249
temporary/my_buzzer.cpp
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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){
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user