mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-06 15:25:08 +00:00
refactor(web): one error-response path for api_v3 (#624)
* refactor(web): answer unhandled api_v3 errors from one blueprint handler
Fifty-three api_v3 routes ended in a copy of the same catch-all: log the
traceback, return {status, "An error occurred; see logs for details",
details: describe_exception(e)} with a 500. They are replaced by one
errorhandler on the api_v3 blueprint that returns exactly that body.
It lives on the blueprint rather than falling through to app.py's global
handler because the two answers differ: the global one adds
error_code: UNKNOWN_ERROR, and api_client.js sends a body with an
error_code to the error modal and one without to a plain toast. A
blueprint handler also gives tests that mount api_v3 on a bare Flask app
the same answer the real app gives.
Only handlers that were byte-for-byte that shape were removed (matched on
the AST, and each rewritten function re-parsed and compared). Handlers
with their own message, extra keys, operation-history records or cleanup
stay, as does execute_plugin_action's step-1 handler, which sits inside
an `except subprocess.TimeoutExpired` arm that would otherwise turn a
plugin's timeout into a 408.
HTTPExceptions raised inside a route go back as themselves in the global
handler's 4xx shape. Where a removed catch-all used to swallow one (only
delete_plugin_asset's non-silent get_json() is reachable), a malformed
request now gets its 415/400 instead of a 500.
Most of the diff is re-indentation from unwrapping the try blocks;
`git diff -w` shows the real change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(web): plugin action errors name the real failure, not UnboundLocalError
execute_plugin_action bound a local `logger` in its JSON-parsing arm,
which made `logger` local to the whole function. Every other
`logger.error` in it then raised UnboundLocalError, so a failing OAuth
step-1 script was reported as "UnboundLocalError: cannot access local
variable 'logger'" -- from the step-1 handler, and before the previous
commit from the route's outer catch-all too. Use the module logger.
Found by comparing every api_v3 route's forced-failure response before
and after the catch-all consolidation.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(web): drop the error category and exception-name code guessing
WebInterfaceError derived an ErrorCategory from every error code and put
it in each structured error body as `error_category`. Nothing reads it:
not the web UI (static/ and templates/), not the tests beyond the ones
pinning the mapping itself, and not any plugin in ledmatrix-plugins. The
enum, the inference table and the JSON key go.
from_exception() could also guess an error code from the exception's
class name ("Config" -> CONFIG_LOAD_FAILED, and so on). Every caller
passes a code, so the guess never ran; error_code is now required.
suggested_fixes stays: the error dialog in static/v3/js/utils/
error_handler.js lists them.
The REST reference loses error_category and says what an unanticipated
exception in an /api/v3 route answers.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* refactor(web): one call for the from_exception error responses
Nine plugin routes built a structured error by hand:
from src.web_interface.errors import WebInterfaceError
error = WebInterfaceError.from_exception(e, ErrorCode.X)
return error_response(error.error_code, error.message,
details=error.details, context=error.context,
status_code=500)
That is now exception_error_response(e, ErrorCode.X) in api_helpers, so
error_response() is the only structured-error entry point the routes
use. The three operation-history routes never passed the context, and
with_context=False keeps their bodies exactly as they were; a test
compares the helper against the hand-written pair for both forms.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(changelog): one api_v3 error-response path
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,14 @@ accepts both, but the store flags the old spelling as deprecated
|
|||||||
`a/b` or `..` resolve to nothing everywhere. Installs where each directory is
|
`a/b` or `..` resolve to nothing everywhere. Installs where each directory is
|
||||||
named for its manifest id, the installer's layout, behave as before.
|
named for its manifest id, the installer's layout, behave as before.
|
||||||
|
|
||||||
|
- `/api/v3` routes answer an exception they don't handle themselves from one
|
||||||
|
blueprint error handler, with the same `{status, message, details}` body the
|
||||||
|
53 removed per-route catch-alls returned. `ErrorCategory` and the
|
||||||
|
`error_category` key are removed from `src.web_interface.errors` (nothing read
|
||||||
|
them); `exception_error_response()` replaces the `from_exception` +
|
||||||
|
`error_response` pairs. A failing plugin action script's error now names the
|
||||||
|
real failure instead of `UnboundLocalError`.
|
||||||
|
|
||||||
- `FontManager.get_font()` returns a BDF font at its native size when asked for
|
- `FontManager.get_font()` returns a BDF font at its native size when asked for
|
||||||
a size the file doesn't contain (5x7.bdf at 8 or 10px, say). It used to
|
a size the file doesn't contain (5x7.bdf at 8 or 10px, say). It used to
|
||||||
return PIL's default font, a different typeface, so a plugin that relied on
|
return PIL's default font, a different typeface, so a plugin that relied on
|
||||||
|
|||||||
@@ -2111,13 +2111,18 @@ Errors use one of two shapes. Most endpoints answer:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Endpoints built on the structured error helper add a code and category:
|
An exception no route anticipated gets this shape too, with a 500, the
|
||||||
|
message `An error occurred; see logs for details`, and `details` naming the
|
||||||
|
exception type and text (credentials redacted). The api_v3 blueprint's
|
||||||
|
error handler produces it, so it is the same for every `/api/v3` route.
|
||||||
|
|
||||||
|
Endpoints built on the structured error helper add a code, and usually
|
||||||
|
suggested fixes (the web UI's error dialog lists them):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"error_code": "CONFIG_SAVE_FAILED",
|
"error_code": "CONFIG_SAVE_FAILED",
|
||||||
"error_category": "configuration",
|
|
||||||
"message": "Error description",
|
"message": "Error description",
|
||||||
"details": "optional",
|
"details": "optional",
|
||||||
"context": { },
|
"context": { },
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from typing import Any, Optional, Dict, Tuple
|
|||||||
from flask import jsonify, request
|
from flask import jsonify, request
|
||||||
|
|
||||||
from src.web_interface.error_handler import create_error_response, create_success_response
|
from src.web_interface.error_handler import create_error_response, create_success_response
|
||||||
from src.web_interface.errors import ErrorCode
|
from src.web_interface.errors import ErrorCode, WebInterfaceError
|
||||||
|
|
||||||
|
|
||||||
def success_response(
|
def success_response(
|
||||||
@@ -75,6 +75,40 @@ def error_response(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def exception_error_response(
|
||||||
|
exc: Exception,
|
||||||
|
error_code: ErrorCode,
|
||||||
|
*,
|
||||||
|
with_context: bool = True,
|
||||||
|
status_code: int = 500
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
error_response() for a caught exception, built by WebInterfaceError.
|
||||||
|
|
||||||
|
The message is the code's fixed, user-facing one -- never the exception
|
||||||
|
text. `details` comes from the exception's own `context` dict when it has
|
||||||
|
one, and `context` records the exception type. with_context=False leaves
|
||||||
|
the context out, as the operation-history routes always have.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
exc: The exception being reported
|
||||||
|
error_code: Error code
|
||||||
|
with_context: Whether to include the context (exception type)
|
||||||
|
status_code: HTTP status code
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Flask jsonify response with status code
|
||||||
|
"""
|
||||||
|
error = WebInterfaceError.from_exception(exc, error_code)
|
||||||
|
return error_response(
|
||||||
|
error.error_code,
|
||||||
|
error.message,
|
||||||
|
details=error.details,
|
||||||
|
context=error.context if with_context else None,
|
||||||
|
status_code=status_code
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def validate_request_json(required_fields: list, data: Optional[Dict] = None) -> Tuple[Optional[Dict], Optional[Any]]:
|
def validate_request_json(required_fields: list, data: Optional[Dict] = None) -> Tuple[Optional[Dict], Optional[Any]]:
|
||||||
"""
|
"""
|
||||||
Validate request JSON has required fields.
|
Validate request JSON has required fields.
|
||||||
|
|||||||
@@ -7,9 +7,7 @@ Provides helpers for consistent error responses across API endpoints.
|
|||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
from flask import jsonify
|
from flask import jsonify
|
||||||
|
|
||||||
from src.web_interface.errors import (
|
from src.web_interface.errors import WebInterfaceError, ErrorCode
|
||||||
WebInterfaceError, ErrorCode, ErrorCategory
|
|
||||||
)
|
|
||||||
from src.logging_config import get_logger
|
from src.logging_config import get_logger
|
||||||
from src.redaction import redact_credentials
|
from src.redaction import redact_credentials
|
||||||
|
|
||||||
@@ -72,6 +70,39 @@ def redact_text(text: str, max_length: int = _MAX_DETAIL_LENGTH) -> str:
|
|||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
# What a failure nothing anticipated says. The detail beside it carries the
|
||||||
|
# actual diagnosis; this sentence only points at where the traceback went.
|
||||||
|
UNHANDLED_ERROR_MESSAGE = 'An error occurred; see logs for details'
|
||||||
|
|
||||||
|
|
||||||
|
def unhandled_exception_payload(exc: BaseException) -> dict:
|
||||||
|
"""JSON body for an exception no route handled: status, message, details.
|
||||||
|
|
||||||
|
Deliberately no `error_code`. The plugin API client (api_client.js) passes
|
||||||
|
a body that has one straight to the rich error modal, and wraps one that
|
||||||
|
has none as a plain API_ERROR toast; the api_v3 routes answered this shape
|
||||||
|
from their own catch-alls for years, so the UI is built around it.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
'status': 'error',
|
||||||
|
'message': UNHANDLED_ERROR_MESSAGE,
|
||||||
|
'details': describe_exception(exc),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def http_exception_payload(error) -> dict:
|
||||||
|
"""JSON body for a werkzeug HTTPException (405, 400, 415, 413...).
|
||||||
|
|
||||||
|
Same shape web_interface/app.py's global handler returns, so a 4xx raised
|
||||||
|
inside an api_v3 route reads the same as one raised anywhere else.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
'status': 'error',
|
||||||
|
'error_code': (error.name or 'HTTP_ERROR').upper().replace(' ', '_'),
|
||||||
|
'message': error.description,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def create_error_response(
|
def create_error_response(
|
||||||
error_code: ErrorCode,
|
error_code: ErrorCode,
|
||||||
message: str,
|
message: str,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Structured error handling for web interface.
|
Structured error handling for web interface.
|
||||||
|
|
||||||
Provides error codes, categories, and consistent error response formatting.
|
Provides error codes and consistent error response formatting.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
@@ -9,17 +9,6 @@ from typing import Dict, Any, Optional, List
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
class ErrorCategory(Enum):
|
|
||||||
"""Error categories for classification."""
|
|
||||||
CONFIGURATION = "configuration"
|
|
||||||
PLUGIN = "plugin"
|
|
||||||
VALIDATION = "validation"
|
|
||||||
NETWORK = "network"
|
|
||||||
PERMISSION = "permission"
|
|
||||||
SYSTEM = "system"
|
|
||||||
UNKNOWN = "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
class ErrorCode(Enum):
|
class ErrorCode(Enum):
|
||||||
"""Error codes for specific error types."""
|
"""Error codes for specific error types."""
|
||||||
# Configuration errors
|
# Configuration errors
|
||||||
@@ -63,12 +52,11 @@ class WebInterfaceError:
|
|||||||
"""
|
"""
|
||||||
Structured error for web interface responses.
|
Structured error for web interface responses.
|
||||||
|
|
||||||
Provides consistent error format with error codes, categories,
|
Provides consistent error format with error codes, messages, and
|
||||||
messages, and context.
|
context.
|
||||||
"""
|
"""
|
||||||
error_code: ErrorCode
|
error_code: ErrorCode
|
||||||
message: str
|
message: str
|
||||||
category: ErrorCategory
|
|
||||||
details: Optional[str] = None
|
details: Optional[str] = None
|
||||||
context: Optional[Dict[str, Any]] = None
|
context: Optional[Dict[str, Any]] = None
|
||||||
suggested_fixes: Optional[List[str]] = None
|
suggested_fixes: Optional[List[str]] = None
|
||||||
@@ -78,7 +66,6 @@ class WebInterfaceError:
|
|||||||
self,
|
self,
|
||||||
error_code: ErrorCode,
|
error_code: ErrorCode,
|
||||||
message: str,
|
message: str,
|
||||||
category: Optional[ErrorCategory] = None,
|
|
||||||
details: Optional[str] = None,
|
details: Optional[str] = None,
|
||||||
context: Optional[Dict[str, Any]] = None,
|
context: Optional[Dict[str, Any]] = None,
|
||||||
suggested_fixes: Optional[List[str]] = None,
|
suggested_fixes: Optional[List[str]] = None,
|
||||||
@@ -86,7 +73,6 @@ class WebInterfaceError:
|
|||||||
):
|
):
|
||||||
self.error_code = error_code
|
self.error_code = error_code
|
||||||
self.message = message
|
self.message = message
|
||||||
self.category = category or self._infer_category(error_code)
|
|
||||||
self.details = details
|
self.details = details
|
||||||
self.context = context or {}
|
self.context = context or {}
|
||||||
# `is None`, not truthiness: an explicit [] means "this caller has
|
# `is None`, not truthiness: an explicit [] means "this caller has
|
||||||
@@ -96,25 +82,6 @@ class WebInterfaceError:
|
|||||||
else self._get_default_suggestions(error_code))
|
else self._get_default_suggestions(error_code))
|
||||||
self.original_error = original_error
|
self.original_error = original_error
|
||||||
|
|
||||||
def _infer_category(self, error_code: ErrorCode) -> ErrorCategory:
|
|
||||||
"""Infer error category from error code."""
|
|
||||||
code_str = error_code.value
|
|
||||||
|
|
||||||
if code_str.startswith("CONFIG_"):
|
|
||||||
return ErrorCategory.CONFIGURATION
|
|
||||||
elif code_str.startswith("PLUGIN_"):
|
|
||||||
return ErrorCategory.PLUGIN
|
|
||||||
elif code_str.startswith("VALIDATION_") or code_str.startswith("SCHEMA_") or code_str == "INVALID_INPUT":
|
|
||||||
return ErrorCategory.VALIDATION
|
|
||||||
elif code_str.startswith("NETWORK_") or code_str == "API_ERROR" or code_str == "TIMEOUT":
|
|
||||||
return ErrorCategory.NETWORK
|
|
||||||
elif code_str.startswith("PERMISSION_") or code_str == "FILE_PERMISSION_ERROR":
|
|
||||||
return ErrorCategory.PERMISSION
|
|
||||||
elif code_str.startswith("SYSTEM_") or code_str == "SERVICE_UNAVAILABLE":
|
|
||||||
return ErrorCategory.SYSTEM
|
|
||||||
else:
|
|
||||||
return ErrorCategory.UNKNOWN
|
|
||||||
|
|
||||||
def _get_default_suggestions(self, error_code: ErrorCode) -> List[str]:
|
def _get_default_suggestions(self, error_code: ErrorCode) -> List[str]:
|
||||||
"""Get default suggested fixes for error code."""
|
"""Get default suggested fixes for error code."""
|
||||||
suggestions_map = {
|
suggestions_map = {
|
||||||
@@ -178,7 +145,6 @@ class WebInterfaceError:
|
|||||||
result = {
|
result = {
|
||||||
"status": "error",
|
"status": "error",
|
||||||
"error_code": self.error_code.value,
|
"error_code": self.error_code.value,
|
||||||
"error_category": self.category.value,
|
|
||||||
"message": self.message,
|
"message": self.message,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,7 +163,7 @@ class WebInterfaceError:
|
|||||||
def from_exception(
|
def from_exception(
|
||||||
cls,
|
cls,
|
||||||
exception: Exception,
|
exception: Exception,
|
||||||
error_code: Optional[ErrorCode] = None,
|
error_code: ErrorCode,
|
||||||
context: Optional[Dict[str, Any]] = None
|
context: Optional[Dict[str, Any]] = None
|
||||||
) -> 'WebInterfaceError':
|
) -> 'WebInterfaceError':
|
||||||
"""
|
"""
|
||||||
@@ -205,13 +171,9 @@ class WebInterfaceError:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
exception: Exception to convert
|
exception: Exception to convert
|
||||||
error_code: Optional specific error code
|
error_code: The error code to report
|
||||||
context: Optional additional context
|
context: Optional additional context
|
||||||
"""
|
"""
|
||||||
# Infer error code from exception type if not provided
|
|
||||||
if not error_code:
|
|
||||||
error_code = cls._infer_error_code(exception)
|
|
||||||
|
|
||||||
# Build context
|
# Build context
|
||||||
error_context = context or {}
|
error_context = context or {}
|
||||||
error_context['exception_type'] = type(exception).__name__
|
error_context['exception_type'] = type(exception).__name__
|
||||||
@@ -252,26 +214,6 @@ class WebInterfaceError:
|
|||||||
}
|
}
|
||||||
return messages.get(error_code, "An unexpected error occurred")
|
return messages.get(error_code, "An unexpected error occurred")
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def _infer_error_code(cls, exception: Exception) -> ErrorCode:
|
|
||||||
"""Infer error code from exception type."""
|
|
||||||
exception_name = type(exception).__name__
|
|
||||||
|
|
||||||
if "Config" in exception_name:
|
|
||||||
return ErrorCode.CONFIG_LOAD_FAILED
|
|
||||||
elif "Plugin" in exception_name:
|
|
||||||
return ErrorCode.PLUGIN_LOAD_FAILED
|
|
||||||
elif "Permission" in exception_name or "Access" in exception_name:
|
|
||||||
return ErrorCode.PERMISSION_DENIED
|
|
||||||
elif "Validation" in exception_name or "Schema" in exception_name:
|
|
||||||
return ErrorCode.VALIDATION_ERROR
|
|
||||||
elif "Network" in exception_name or "Connection" in exception_name:
|
|
||||||
return ErrorCode.NETWORK_ERROR
|
|
||||||
elif "Timeout" in exception_name:
|
|
||||||
return ErrorCode.TIMEOUT
|
|
||||||
else:
|
|
||||||
return ErrorCode.UNKNOWN_ERROR
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _get_exception_details(cls, exception: Exception) -> Optional[str]:
|
def _get_exception_details(cls, exception: Exception) -> Optional[str]:
|
||||||
"""Get additional details from exception."""
|
"""Get additional details from exception."""
|
||||||
|
|||||||
@@ -230,6 +230,52 @@ class TestHandlersCarryDetail:
|
|||||||
"handlers returning the generic message without %s: %r"
|
"handlers returning the generic message without %s: %r"
|
||||||
% ("both a traceback log and the detail", offenders))
|
% ("both a traceback log and the detail", offenders))
|
||||||
|
|
||||||
|
def test_no_api_v3_route_copies_the_blueprint_handler(self):
|
||||||
|
"""The generic catch-all lives once, on the blueprint.
|
||||||
|
|
||||||
|
Fifty-three routes carried their own copy of it -- log with exc_info,
|
||||||
|
return {status, "An error occurred; see logs for details",
|
||||||
|
describe_exception(e)}, 500 -- until they were folded into
|
||||||
|
`_api_v3_unhandled_exception`. A new copy changes nothing a caller
|
||||||
|
sees, so nothing else would notice it; this does. A handler that says
|
||||||
|
something *different* (its own message, extra keys, cleanup) is fine.
|
||||||
|
"""
|
||||||
|
import ast
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
generic = "An error occurred; see logs for details"
|
||||||
|
copies = []
|
||||||
|
for path in sorted(pathlib.Path("web_interface/blueprints/api_v3").glob("*.py")):
|
||||||
|
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||||
|
for fn in [n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef)]:
|
||||||
|
for h in ast.walk(fn):
|
||||||
|
if not (isinstance(h, ast.ExceptHandler)
|
||||||
|
and isinstance(h.type, ast.Name)
|
||||||
|
and h.type.id == "Exception"):
|
||||||
|
continue
|
||||||
|
for r in [n for n in h.body if isinstance(n, ast.Return)]:
|
||||||
|
v = r.value
|
||||||
|
if not (isinstance(v, ast.Tuple) and len(v.elts) == 2
|
||||||
|
and isinstance(v.elts[0], ast.Call)
|
||||||
|
and getattr(v.elts[0].func, "id", None) == "jsonify"
|
||||||
|
and v.elts[0].args
|
||||||
|
and isinstance(v.elts[0].args[0], ast.Dict)):
|
||||||
|
continue
|
||||||
|
d = v.elts[0].args[0]
|
||||||
|
keys = {k.value for k in d.keys if isinstance(k, ast.Constant)}
|
||||||
|
message = [val.value for k, val in zip(d.keys, d.values)
|
||||||
|
if isinstance(k, ast.Constant) and k.value == "message"
|
||||||
|
and isinstance(val, ast.Constant)]
|
||||||
|
if keys == {"status", "message", "details"} and message == [generic]:
|
||||||
|
copies.append((path.name, fn.name))
|
||||||
|
|
||||||
|
# One is not a copy: execute_plugin_action's step-1 handler sits
|
||||||
|
# inside the route's `except subprocess.TimeoutExpired` arm, which
|
||||||
|
# would turn a plugin's own timeout into a 408 if this let it through.
|
||||||
|
assert copies == [("plugins.py", "execute_plugin_action")], (
|
||||||
|
"these handlers duplicate the api_v3 blueprint's errorhandler; "
|
||||||
|
"delete them and let the exception propagate: %r" % copies)
|
||||||
|
|
||||||
def test_client_errors_keep_their_own_status(self):
|
def test_client_errors_keep_their_own_status(self):
|
||||||
"""A 405 must not be reported as a server-side UNKNOWN_ERROR.
|
"""A 405 must not be reported as a server-side UNKNOWN_ERROR.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
"""An exception no api_v3 route catches is answered once, by the blueprint.
|
||||||
|
|
||||||
|
Fifty-three routes used to end in a copy of the same catch-all:
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(..., exc_info=True)
|
||||||
|
return jsonify({'status': 'error',
|
||||||
|
'message': 'An error occurred; see logs for details',
|
||||||
|
'details': describe_exception(e)}), 500
|
||||||
|
|
||||||
|
They were removed in favour of one errorhandler on the api_v3 blueprint. These
|
||||||
|
tests pin that the answer did not change: the same status, exactly the same
|
||||||
|
keys and values, credentials still redacted, and the traceback still logged.
|
||||||
|
|
||||||
|
The exact-equality matters. web_interface/app.py's global handler answers with
|
||||||
|
an extra `error_code: UNKNOWN_ERROR`, and the plugin API client routes a body
|
||||||
|
that carries an error_code to a different UI path (api_client.js) -- so
|
||||||
|
"falls through to the global handler" would not have been the same answer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from flask import Flask
|
||||||
|
from werkzeug.exceptions import UnsupportedMediaType
|
||||||
|
|
||||||
|
from src.web_interface.error_handler import describe_exception
|
||||||
|
from web_interface.blueprints.api_v3 import api_v3
|
||||||
|
|
||||||
|
# A credential in three of the forms describe_exception redacts.
|
||||||
|
FORCED = RuntimeError(
|
||||||
|
"forced failure token=SECRET123 at https://u:pw1@example.com/x?api_key=K1")
|
||||||
|
|
||||||
|
# What every removed catch-all returned, written out rather than imported so
|
||||||
|
# a change to the shared payload cannot also change the expectation.
|
||||||
|
EXPECTED = {
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'An error occurred; see logs for details',
|
||||||
|
'details': describe_exception(FORCED),
|
||||||
|
}
|
||||||
|
|
||||||
|
MANAGERS = ("config_manager", "plugin_manager", "plugin_store_manager",
|
||||||
|
"saved_repositories_manager", "schema_manager", "operation_queue",
|
||||||
|
"plugin_state_manager", "operation_history", "cache_manager")
|
||||||
|
|
||||||
|
|
||||||
|
class Boom:
|
||||||
|
"""A manager that fails on any use -- attribute, truthiness, call."""
|
||||||
|
|
||||||
|
def _raise(self, *args, **kwargs):
|
||||||
|
raise FORCED
|
||||||
|
|
||||||
|
__getattr__ = _raise
|
||||||
|
__bool__ = _raise
|
||||||
|
__call__ = _raise
|
||||||
|
__iter__ = _raise
|
||||||
|
__len__ = _raise
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def exploding_managers(monkeypatch):
|
||||||
|
for name in MANAGERS:
|
||||||
|
monkeypatch.setattr(api_v3, name, Boom(), raising=False)
|
||||||
|
# The WiFi routes build their own manager rather than using one above.
|
||||||
|
import src.wifi_manager
|
||||||
|
monkeypatch.setattr(src.wifi_manager, "WiFiManager", Boom())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(exploding_managers):
|
||||||
|
"""The blueprint alone, on an app with no error handlers of its own."""
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.register_blueprint(api_v3, url_prefix="/api/v3")
|
||||||
|
return app.test_client()
|
||||||
|
|
||||||
|
|
||||||
|
# A sample of routes whose catch-all was removed, across every module that
|
||||||
|
# lost one. Each reaches a manager (or WiFiManager) inside what used to be
|
||||||
|
# the try block.
|
||||||
|
REMOVED_CATCH_ALLS = [
|
||||||
|
("GET", "/api/v3/config/main", None),
|
||||||
|
("GET", "/api/v3/config/secrets", None),
|
||||||
|
("GET", "/api/v3/display/modes", None),
|
||||||
|
("POST", "/api/v3/display/on-demand/stop", {}),
|
||||||
|
("GET", "/api/v3/cache/list", None),
|
||||||
|
("GET", "/api/v3/plugins/installed", None),
|
||||||
|
("GET", "/api/v3/plugins/health", None),
|
||||||
|
("GET", "/api/v3/plugins/metrics/some-plugin", None),
|
||||||
|
("GET", "/api/v3/plugins/schema?plugin_id=some-plugin", None),
|
||||||
|
("GET", "/api/v3/plugins/store/list", None),
|
||||||
|
("GET", "/api/v3/plugins/saved-repositories", None),
|
||||||
|
("POST", "/api/v3/plugins/install", {"plugin_id": "some-plugin"}),
|
||||||
|
("POST", "/api/v3/plugins/config/reset?plugin_id=some-plugin", {}),
|
||||||
|
("GET", "/api/v3/plugins/limits/some-plugin", None),
|
||||||
|
("GET", "/api/v3/wifi/status", None),
|
||||||
|
("POST", "/api/v3/wifi/disconnect", {}),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("method,url,body", REMOVED_CATCH_ALLS,
|
||||||
|
ids=[f"{m} {u}" for m, u, _ in REMOVED_CATCH_ALLS])
|
||||||
|
def test_the_answer_is_what_the_catch_all_returned(client, caplog, method, url, body):
|
||||||
|
with caplog.at_level(logging.ERROR, logger="web_interface.blueprints.api_v3"):
|
||||||
|
resp = client.open(url, method=method, json=body)
|
||||||
|
|
||||||
|
assert resp.status_code == 500
|
||||||
|
assert resp.get_json() == EXPECTED
|
||||||
|
|
||||||
|
# The promise in the message: the traceback is in the log.
|
||||||
|
records = [r for r in caplog.records
|
||||||
|
if r.name == "web_interface.blueprints.api_v3"
|
||||||
|
and r.levelno >= logging.ERROR and r.exc_info]
|
||||||
|
assert records, "the unhandled exception was not logged with its traceback"
|
||||||
|
assert records[-1].exc_info[1] is FORCED
|
||||||
|
|
||||||
|
|
||||||
|
def test_credentials_are_redacted_from_the_detail(client):
|
||||||
|
body = client.get("/api/v3/plugins/installed").get_json()
|
||||||
|
for secret in ("SECRET123", "pw1", "K1"):
|
||||||
|
assert secret not in body["details"]
|
||||||
|
assert "<redacted>" in body["details"]
|
||||||
|
assert body["details"].startswith("RuntimeError: forced failure")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_client_error_keeps_its_own_status(client):
|
||||||
|
"""HTTPExceptions subclass Exception; a 415 must not become a 500."""
|
||||||
|
resp = client.post("/api/v3/plugins/assets/delete", data="not json",
|
||||||
|
content_type="text/plain")
|
||||||
|
assert resp.status_code == 415
|
||||||
|
body = resp.get_json()
|
||||||
|
assert body == {
|
||||||
|
'status': 'error',
|
||||||
|
'error_code': 'UNSUPPORTED_MEDIA_TYPE',
|
||||||
|
'message': body['message'],
|
||||||
|
}
|
||||||
|
assert "Content-Type" in body['message']
|
||||||
|
|
||||||
|
|
||||||
|
class TestInTheRealApp:
|
||||||
|
"""Mounted in web_interface/app.py, beside its global handlers."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def web_app(self):
|
||||||
|
import web_interface.app as web_app
|
||||||
|
return web_app
|
||||||
|
|
||||||
|
def test_the_blueprint_handler_answers_not_the_global_one(
|
||||||
|
self, web_app, exploding_managers):
|
||||||
|
resp = web_app.app.test_client().get("/api/v3/plugins/installed")
|
||||||
|
assert resp.status_code == 500
|
||||||
|
assert resp.get_json() == EXPECTED
|
||||||
|
|
||||||
|
def test_client_errors_read_the_same_as_the_global_handler(
|
||||||
|
self, web_app, exploding_managers):
|
||||||
|
"""The blueprint's 4xx shape must not drift from app.py's."""
|
||||||
|
resp = web_app.app.test_client().post(
|
||||||
|
"/api/v3/plugins/assets/delete", data="not json",
|
||||||
|
content_type="text/plain")
|
||||||
|
with web_app.app.test_request_context():
|
||||||
|
global_resp, global_status = web_app.handle_exception(
|
||||||
|
UnsupportedMediaType(description=resp.get_json()['message']))
|
||||||
|
assert resp.status_code == global_status == 415
|
||||||
|
assert resp.get_json() == global_resp.get_json()
|
||||||
|
|
||||||
|
def test_global_handler_shape(self, web_app):
|
||||||
|
"""Everything outside api_v3 still gets app.py's answer."""
|
||||||
|
with web_app.app.test_request_context("/somewhere"):
|
||||||
|
resp, status = web_app.handle_exception(FORCED)
|
||||||
|
body = resp.get_json()
|
||||||
|
assert status == 500
|
||||||
|
assert body == {
|
||||||
|
'status': 'error',
|
||||||
|
'error_code': 'UNKNOWN_ERROR',
|
||||||
|
'message': 'An error occurred; see logs for details',
|
||||||
|
'details': describe_exception(FORCED),
|
||||||
|
}
|
||||||
|
assert "SECRET123" not in body["details"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestPluginActionStep1:
|
||||||
|
"""execute_plugin_action's OAuth step-1 handler reports the script's error.
|
||||||
|
|
||||||
|
The route bound a local `logger` in its JSON-parsing arm, which made
|
||||||
|
`logger` local to the whole function; every other `logger.error` in it
|
||||||
|
then raised UnboundLocalError. The step-1 handler therefore answered
|
||||||
|
"UnboundLocalError: cannot access local variable 'logger'" instead of
|
||||||
|
whatever the plugin's auth script actually raised.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def plugin_dir(self, tmp_path):
|
||||||
|
import json
|
||||||
|
d = tmp_path / "demo-plugin"
|
||||||
|
d.mkdir()
|
||||||
|
(d / "manifest.json").write_text(json.dumps({
|
||||||
|
"id": "demo-plugin",
|
||||||
|
"web_ui_actions": [{"id": "auth", "type": "script",
|
||||||
|
"script": "auth.py", "oauth_flow": True}],
|
||||||
|
}), encoding="utf-8")
|
||||||
|
(d / "auth.py").write_text(
|
||||||
|
"def get_auth_url():\n"
|
||||||
|
" raise RuntimeError('the auth script failed')\n",
|
||||||
|
encoding="utf-8")
|
||||||
|
return d
|
||||||
|
|
||||||
|
def test_the_script_error_reaches_the_response(self, plugin_dir, monkeypatch):
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
manager = MagicMock()
|
||||||
|
manager.get_plugin_directory.return_value = str(plugin_dir)
|
||||||
|
monkeypatch.setattr(api_v3, "plugin_manager", manager, raising=False)
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.register_blueprint(api_v3, url_prefix="/api/v3")
|
||||||
|
|
||||||
|
resp = app.test_client().post(
|
||||||
|
"/api/v3/plugins/action",
|
||||||
|
json={"plugin_id": "demo-plugin", "action_id": "auth"})
|
||||||
|
|
||||||
|
assert resp.status_code == 500
|
||||||
|
body = resp.get_json()
|
||||||
|
assert body["details"] == "RuntimeError: the auth script failed"
|
||||||
|
assert body["message"] == 'An error occurred; see logs for details'
|
||||||
@@ -15,7 +15,7 @@ every api_v3 endpoint actually calls.
|
|||||||
import pytest
|
import pytest
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
|
|
||||||
from src.web_interface.api_helpers import success_response
|
from src.web_interface.api_helpers import exception_error_response, success_response
|
||||||
from src.web_interface.error_handler import (
|
from src.web_interface.error_handler import (
|
||||||
create_error_response,
|
create_error_response,
|
||||||
create_success_response,
|
create_success_response,
|
||||||
@@ -64,6 +64,59 @@ class TestCreateErrorResponse:
|
|||||||
assert response.get_json()["suggested_fixes"] == ["Try again"]
|
assert response.get_json()["suggested_fixes"] == ["Try again"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestExceptionErrorResponse:
|
||||||
|
"""The one-call form of from_exception() + error_response().
|
||||||
|
|
||||||
|
Nine plugin routes spelled the pair out by hand; these pin that the helper
|
||||||
|
answers exactly what that spelling did, so folding them changed nothing a
|
||||||
|
client sees.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _by_hand(exc, code, with_context):
|
||||||
|
from src.web_interface.api_helpers import error_response
|
||||||
|
error = WebInterfaceError.from_exception(exc, code)
|
||||||
|
if with_context:
|
||||||
|
return error_response(error.error_code, error.message,
|
||||||
|
details=error.details, context=error.context,
|
||||||
|
status_code=500)
|
||||||
|
return error_response(error.error_code, error.message,
|
||||||
|
details=error.details, status_code=500)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("with_context", [True, False])
|
||||||
|
@pytest.mark.parametrize("code", [ErrorCode.SYSTEM_ERROR,
|
||||||
|
ErrorCode.CONFIG_SAVE_FAILED,
|
||||||
|
ErrorCode.PLUGIN_UPDATE_FAILED])
|
||||||
|
def test_same_answer_as_the_hand_written_pair(self, app, code, with_context):
|
||||||
|
exc = ValueError("token=SECRET boom")
|
||||||
|
exc.context = {"config_path": "/etc/x.json"}
|
||||||
|
with app.test_request_context():
|
||||||
|
got, got_status = exception_error_response(
|
||||||
|
exc, code, with_context=with_context)
|
||||||
|
want, want_status = self._by_hand(exc, code, with_context)
|
||||||
|
assert got_status == want_status == 500
|
||||||
|
assert got.get_json() == want.get_json()
|
||||||
|
|
||||||
|
def test_shape(self, app):
|
||||||
|
with app.test_request_context():
|
||||||
|
response, status = exception_error_response(
|
||||||
|
RuntimeError("token=SECRET"), ErrorCode.SYSTEM_ERROR)
|
||||||
|
assert status == 500
|
||||||
|
assert response.get_json() == {
|
||||||
|
"status": "error",
|
||||||
|
"error_code": "SYSTEM_ERROR",
|
||||||
|
"message": "A system error occurred",
|
||||||
|
"context": {"exception_type": "RuntimeError"},
|
||||||
|
"suggested_fixes": ["Review error details and try again"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_without_context(self, app):
|
||||||
|
with app.test_request_context():
|
||||||
|
response, _ = exception_error_response(
|
||||||
|
RuntimeError("x"), ErrorCode.SYSTEM_ERROR, with_context=False)
|
||||||
|
assert "context" not in response.get_json()
|
||||||
|
|
||||||
|
|
||||||
class TestCreateSuccessResponse:
|
class TestCreateSuccessResponse:
|
||||||
def test_bare_success(self):
|
def test_bare_success(self):
|
||||||
assert create_success_response() == {"status": "success"}
|
assert create_success_response() == {"status": "success"}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Tests for src/web_interface/errors.py — the structured error type behind
|
Tests for src/web_interface/errors.py — the structured error type behind
|
||||||
every API error response (category inference, default suggestions, the
|
every API error response (default suggestions, the JSON shape, and
|
||||||
JSON shape, and exception conversion).
|
exception conversion).
|
||||||
|
|
||||||
Pure logic; no Flask context needed.
|
Pure logic; no Flask context needed.
|
||||||
|
|
||||||
@@ -11,39 +11,7 @@ caller passing [] to mean "no suggestions" silently got the default list.
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from src.web_interface.errors import ErrorCategory, ErrorCode, WebInterfaceError
|
from src.web_interface.errors import ErrorCode, WebInterfaceError
|
||||||
|
|
||||||
|
|
||||||
class TestCategoryInference:
|
|
||||||
@pytest.mark.parametrize("code,expected", [
|
|
||||||
(ErrorCode.CONFIG_SAVE_FAILED, ErrorCategory.CONFIGURATION),
|
|
||||||
(ErrorCode.CONFIG_ROLLBACK_FAILED, ErrorCategory.CONFIGURATION),
|
|
||||||
(ErrorCode.PLUGIN_NOT_FOUND, ErrorCategory.PLUGIN),
|
|
||||||
(ErrorCode.PLUGIN_OPERATION_CONFLICT, ErrorCategory.PLUGIN),
|
|
||||||
(ErrorCode.VALIDATION_ERROR, ErrorCategory.VALIDATION),
|
|
||||||
(ErrorCode.SCHEMA_VALIDATION_FAILED, ErrorCategory.VALIDATION),
|
|
||||||
(ErrorCode.INVALID_INPUT, ErrorCategory.VALIDATION),
|
|
||||||
(ErrorCode.NETWORK_ERROR, ErrorCategory.NETWORK),
|
|
||||||
(ErrorCode.API_ERROR, ErrorCategory.NETWORK),
|
|
||||||
(ErrorCode.TIMEOUT, ErrorCategory.NETWORK),
|
|
||||||
(ErrorCode.PERMISSION_DENIED, ErrorCategory.PERMISSION),
|
|
||||||
(ErrorCode.FILE_PERMISSION_ERROR, ErrorCategory.PERMISSION),
|
|
||||||
(ErrorCode.SYSTEM_ERROR, ErrorCategory.SYSTEM),
|
|
||||||
(ErrorCode.SERVICE_UNAVAILABLE, ErrorCategory.SYSTEM),
|
|
||||||
(ErrorCode.UNKNOWN_ERROR, ErrorCategory.UNKNOWN),
|
|
||||||
])
|
|
||||||
def test_every_code_prefix_maps_to_its_category(self, code, expected):
|
|
||||||
assert WebInterfaceError(code, "msg").category is expected
|
|
||||||
|
|
||||||
def test_explicit_category_overrides_inference(self):
|
|
||||||
error = WebInterfaceError(
|
|
||||||
ErrorCode.CONFIG_SAVE_FAILED, "msg", category=ErrorCategory.SYSTEM)
|
|
||||||
assert error.category is ErrorCategory.SYSTEM
|
|
||||||
|
|
||||||
def test_every_error_code_gets_a_category(self):
|
|
||||||
# No code may fall through uncategorized as the enum grows.
|
|
||||||
for code in ErrorCode:
|
|
||||||
assert isinstance(WebInterfaceError(code, "msg").category, ErrorCategory)
|
|
||||||
|
|
||||||
|
|
||||||
class TestDefaultSuggestions:
|
class TestDefaultSuggestions:
|
||||||
@@ -79,8 +47,9 @@ class TestToDict:
|
|||||||
result = WebInterfaceError(ErrorCode.SYSTEM_ERROR, "boom").to_dict()
|
result = WebInterfaceError(ErrorCode.SYSTEM_ERROR, "boom").to_dict()
|
||||||
assert result["status"] == "error"
|
assert result["status"] == "error"
|
||||||
assert result["error_code"] == "SYSTEM_ERROR"
|
assert result["error_code"] == "SYSTEM_ERROR"
|
||||||
assert result["error_category"] == "system"
|
|
||||||
assert result["message"] == "boom"
|
assert result["message"] == "boom"
|
||||||
|
# No error_category: nothing in the UI, tests or plugins ever read it.
|
||||||
|
assert set(result) == {"status", "error_code", "message", "suggested_fixes"}
|
||||||
|
|
||||||
def test_details_included_when_set(self):
|
def test_details_included_when_set(self):
|
||||||
result = WebInterfaceError(
|
result = WebInterfaceError(
|
||||||
@@ -116,23 +85,7 @@ class TestToDict:
|
|||||||
|
|
||||||
|
|
||||||
class TestFromException:
|
class TestFromException:
|
||||||
@pytest.mark.parametrize("exc_name,expected", [
|
def test_the_given_code_is_reported(self):
|
||||||
("ConfigError", ErrorCode.CONFIG_LOAD_FAILED),
|
|
||||||
("PluginError", ErrorCode.PLUGIN_LOAD_FAILED),
|
|
||||||
("PermissionError", ErrorCode.PERMISSION_DENIED),
|
|
||||||
("AccessDenied", ErrorCode.PERMISSION_DENIED),
|
|
||||||
("ValidationError", ErrorCode.VALIDATION_ERROR),
|
|
||||||
("SchemaError", ErrorCode.VALIDATION_ERROR),
|
|
||||||
("NetworkError", ErrorCode.NETWORK_ERROR),
|
|
||||||
("ConnectionError", ErrorCode.NETWORK_ERROR),
|
|
||||||
("TimeoutError", ErrorCode.TIMEOUT),
|
|
||||||
("SomethingElse", ErrorCode.UNKNOWN_ERROR),
|
|
||||||
])
|
|
||||||
def test_code_inferred_from_exception_class_name(self, exc_name, expected):
|
|
||||||
exc = type(exc_name, (Exception,), {})("boom")
|
|
||||||
assert WebInterfaceError.from_exception(exc).error_code is expected
|
|
||||||
|
|
||||||
def test_explicit_code_skips_inference(self):
|
|
||||||
error = WebInterfaceError.from_exception(
|
error = WebInterfaceError.from_exception(
|
||||||
ValueError("boom"), error_code=ErrorCode.PLUGIN_NOT_FOUND)
|
ValueError("boom"), error_code=ErrorCode.PLUGIN_NOT_FOUND)
|
||||||
assert error.error_code is ErrorCode.PLUGIN_NOT_FOUND
|
assert error.error_code is ErrorCode.PLUGIN_NOT_FOUND
|
||||||
@@ -140,28 +93,28 @@ class TestFromException:
|
|||||||
def test_message_is_the_safe_one_not_the_exception_text(self):
|
def test_message_is_the_safe_one_not_the_exception_text(self):
|
||||||
# The raw exception text is not echoed into `message`; that field is
|
# The raw exception text is not echoed into `message`; that field is
|
||||||
# a fixed, user-facing string per code.
|
# a fixed, user-facing string per code.
|
||||||
error = WebInterfaceError.from_exception(ValueError("secret-ish detail"))
|
error = WebInterfaceError.from_exception(ValueError("secret-ish detail"), ErrorCode.UNKNOWN_ERROR)
|
||||||
assert error.message == "An unexpected error occurred"
|
assert error.message == "An unexpected error occurred"
|
||||||
assert "secret-ish" not in error.message
|
assert "secret-ish" not in error.message
|
||||||
|
|
||||||
def test_exception_type_recorded_in_context(self):
|
def test_exception_type_recorded_in_context(self):
|
||||||
error = WebInterfaceError.from_exception(ValueError("boom"))
|
error = WebInterfaceError.from_exception(ValueError("boom"), ErrorCode.UNKNOWN_ERROR)
|
||||||
assert error.context["exception_type"] == "ValueError"
|
assert error.context["exception_type"] == "ValueError"
|
||||||
|
|
||||||
def test_caller_context_is_preserved_alongside_type(self):
|
def test_caller_context_is_preserved_alongside_type(self):
|
||||||
error = WebInterfaceError.from_exception(
|
error = WebInterfaceError.from_exception(
|
||||||
ValueError("boom"), context={"plugin_id": "clock"})
|
ValueError("boom"), ErrorCode.UNKNOWN_ERROR, context={"plugin_id": "clock"})
|
||||||
assert error.context["plugin_id"] == "clock"
|
assert error.context["plugin_id"] == "clock"
|
||||||
assert error.context["exception_type"] == "ValueError"
|
assert error.context["exception_type"] == "ValueError"
|
||||||
|
|
||||||
def test_caller_supplied_exception_type_is_overwritten(self):
|
def test_caller_supplied_exception_type_is_overwritten(self):
|
||||||
error = WebInterfaceError.from_exception(
|
error = WebInterfaceError.from_exception(
|
||||||
ValueError("boom"), context={"exception_type": "Fake"})
|
ValueError("boom"), ErrorCode.UNKNOWN_ERROR, context={"exception_type": "Fake"})
|
||||||
assert error.context["exception_type"] == "ValueError"
|
assert error.context["exception_type"] == "ValueError"
|
||||||
|
|
||||||
def test_original_error_retained(self):
|
def test_original_error_retained(self):
|
||||||
exc = ValueError("boom")
|
exc = ValueError("boom")
|
||||||
assert WebInterfaceError.from_exception(exc).original_error is exc
|
assert WebInterfaceError.from_exception(exc, ErrorCode.UNKNOWN_ERROR).original_error is exc
|
||||||
|
|
||||||
def test_every_code_has_a_safe_message(self):
|
def test_every_code_has_a_safe_message(self):
|
||||||
for code in ErrorCode:
|
for code in ErrorCode:
|
||||||
@@ -205,4 +158,4 @@ class TestExceptionDetails:
|
|||||||
def test_details_flow_into_from_exception(self):
|
def test_details_flow_into_from_exception(self):
|
||||||
exc = ValueError("boom")
|
exc = ValueError("boom")
|
||||||
exc.context = {"config_path": "/etc/x.json"}
|
exc.context = {"config_path": "/etc/x.json"}
|
||||||
assert "config_path" in WebInterfaceError.from_exception(exc).details
|
assert "config_path" in WebInterfaceError.from_exception(exc, ErrorCode.UNKNOWN_ERROR).details
|
||||||
|
|||||||
@@ -35,13 +35,16 @@ from typing import Dict, Any, Optional, Tuple, Type
|
|||||||
from urllib.parse import urlparse, urlunparse
|
from urllib.parse import urlparse, urlunparse
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
# Import new infrastructure
|
# Import new infrastructure
|
||||||
from src.web_interface.api_helpers import success_response, error_response, validate_request_json
|
from src.web_interface.api_helpers import (success_response, error_response,
|
||||||
|
exception_error_response, validate_request_json)
|
||||||
from src.web_interface.errors import ErrorCode
|
from src.web_interface.errors import ErrorCode
|
||||||
from src.web_interface.secret_helpers import (find_secret_fields, mask_all_secret_values,
|
from src.web_interface.secret_helpers import (find_secret_fields, mask_all_secret_values,
|
||||||
merge_secrets, remove_empty_secrets,
|
merge_secrets, remove_empty_secrets,
|
||||||
separate_secrets,
|
separate_secrets,
|
||||||
strip_masked_values)
|
strip_masked_values)
|
||||||
from src.web_interface.error_handler import describe_exception, redact_text
|
from src.web_interface.error_handler import (describe_exception, http_exception_payload,
|
||||||
|
redact_text, unhandled_exception_payload)
|
||||||
|
from werkzeug.exceptions import HTTPException
|
||||||
from src.plugin_system.operation_types import OperationType
|
from src.plugin_system.operation_types import OperationType
|
||||||
from src.web_interface.validators import (
|
from src.web_interface.validators import (
|
||||||
validate_file_upload
|
validate_file_upload
|
||||||
@@ -114,6 +117,43 @@ SYSTEM_FONTS = frozenset([
|
|||||||
'clr6x12', 'helvr12', 'texgyre-27'
|
'clr6x12', 'helvr12', 'texgyre-27'
|
||||||
])
|
])
|
||||||
api_v3 = Blueprint('api_v3', __name__)
|
api_v3 = Blueprint('api_v3', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@api_v3.errorhandler(Exception)
|
||||||
|
def _api_v3_unhandled_exception(error):
|
||||||
|
"""The answer for any exception an api_v3 route does not handle itself.
|
||||||
|
|
||||||
|
Fifty-odd routes used to end in the same four lines -- log the traceback,
|
||||||
|
return {status, message: "An error occurred; see logs for details",
|
||||||
|
details: describe_exception(e)} with a 500. This is those four lines, once.
|
||||||
|
A route still catches for itself when its failure needs something else: a
|
||||||
|
specific message, extra keys, an operation-history record, or cleanup.
|
||||||
|
|
||||||
|
It is registered on the blueprint, not left to web_interface/app.py's
|
||||||
|
global handler, because the two answers differ: the global one adds
|
||||||
|
`error_code: UNKNOWN_ERROR`, and the plugin API client treats a body with
|
||||||
|
an error_code differently from one without (see api_client.js). Tests that
|
||||||
|
mount this blueprint on a bare Flask app get the same answer as the real
|
||||||
|
app does, too.
|
||||||
|
|
||||||
|
`details` is describe_exception(), which redacts credentials and caps the
|
||||||
|
length. CodeQL reads returning it as stack-trace exposure; it is the
|
||||||
|
project's deliberate trade-off, because a device whose storage is failing
|
||||||
|
otherwise answers "see logs for details" from the log viewer too
|
||||||
|
(test_web_error_detail.py).
|
||||||
|
|
||||||
|
Werkzeug's HTTPExceptions subclass Exception, so a 400/405/413/415 raised
|
||||||
|
inside a route lands here as well; it goes back as itself, in the global
|
||||||
|
handler's shape. A 404 or explicit 500 never arrives: Flask prefers the
|
||||||
|
app's code-specific handlers over a blueprint's class-based one.
|
||||||
|
"""
|
||||||
|
if isinstance(error, HTTPException):
|
||||||
|
return jsonify(http_exception_payload(error)), error.code or 500
|
||||||
|
logger.error("Unhandled exception in %s", request.endpoint or request.path,
|
||||||
|
exc_info=error)
|
||||||
|
return jsonify(unhandled_exception_payload(error)), 500
|
||||||
|
|
||||||
|
|
||||||
def _get_plugin_version(plugin_id: str) -> str:
|
def _get_plugin_version(plugin_id: str) -> str:
|
||||||
"""Read the installed version from a plugin's manifest.json.
|
"""Read the installed version from a plugin's manifest.json.
|
||||||
|
|
||||||
|
|||||||
@@ -47,15 +47,11 @@ def _day_setting(data, day, flat_key, nested_key):
|
|||||||
@api_v3.route('/config/main', methods=['GET'])
|
@api_v3.route('/config/main', methods=['GET'])
|
||||||
def get_main_config():
|
def get_main_config():
|
||||||
"""Get main configuration, with credentials redacted."""
|
"""Get main configuration, with credentials redacted."""
|
||||||
try:
|
if not api_v3.config_manager:
|
||||||
if not api_v3.config_manager:
|
return jsonify({'status': 'error', 'message': 'Config manager not initialized'}), 500
|
||||||
return jsonify({'status': 'error', 'message': 'Config manager not initialized'}), 500
|
|
||||||
|
|
||||||
config = api_v3.config_manager.load_config()
|
config = api_v3.config_manager.load_config()
|
||||||
return jsonify({'status': 'success', 'data': _redact_credentials(config)})
|
return jsonify({'status': 'success', 'data': _redact_credentials(config)})
|
||||||
except Exception as e:
|
|
||||||
logger.error('Unhandled exception', exc_info=True)
|
|
||||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
|
||||||
@api_v3.route('/config/schedule', methods=['GET'])
|
@api_v3.route('/config/schedule', methods=['GET'])
|
||||||
def get_schedule_config():
|
def get_schedule_config():
|
||||||
"""Get current schedule configuration"""
|
"""Get current schedule configuration"""
|
||||||
@@ -1165,20 +1161,16 @@ def save_main_config():
|
|||||||
@api_v3.route('/config/secrets', methods=['GET'])
|
@api_v3.route('/config/secrets', methods=['GET'])
|
||||||
def get_secrets_config():
|
def get_secrets_config():
|
||||||
"""Get secrets configuration"""
|
"""Get secrets configuration"""
|
||||||
try:
|
if not api_v3.config_manager:
|
||||||
if not api_v3.config_manager:
|
return jsonify({'status': 'error', 'message': 'Config manager not initialized'}), 500
|
||||||
return jsonify({'status': 'error', 'message': 'Config manager not initialized'}), 500
|
|
||||||
|
|
||||||
config = api_v3.config_manager.get_raw_file_content('secrets')
|
config = api_v3.config_manager.get_raw_file_content('secrets')
|
||||||
# This interface has no authentication, and this file is nothing but
|
# This interface has no authentication, and this file is nothing but
|
||||||
# credentials. It was handing all of them to anyone who could reach
|
# credentials. It was handing all of them to anyone who could reach
|
||||||
# the port. Values are masked; empty and YOUR_* placeholders are left
|
# the port. Values are masked; empty and YOUR_* placeholders are left
|
||||||
# alone so a client can still tell "set" from "not set".
|
# alone so a client can still tell "set" from "not set".
|
||||||
return jsonify({'status': 'success',
|
return jsonify({'status': 'success',
|
||||||
'data': mask_all_secret_values(config)})
|
'data': mask_all_secret_values(config)})
|
||||||
except Exception as e:
|
|
||||||
logger.error('Unhandled exception', exc_info=True)
|
|
||||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
|
||||||
@api_v3.route('/config/raw/main', methods=['POST'])
|
@api_v3.route('/config/raw/main', methods=['POST'])
|
||||||
def save_raw_main_config():
|
def save_raw_main_config():
|
||||||
"""Save raw main configuration JSON"""
|
"""Save raw main configuration JSON"""
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ endpoint names are unchanged by living here.
|
|||||||
from web_interface.blueprints.api_v3 import (
|
from web_interface.blueprints.api_v3 import (
|
||||||
_ensure_display_service_running,
|
_ensure_display_service_running,
|
||||||
_get_display_service_status, _stop_display_service, api_v3,
|
_get_display_service_status, _stop_display_service, api_v3,
|
||||||
describe_exception, jsonify, logger, os, request, uuid,
|
jsonify, logger, os, request, uuid,
|
||||||
)
|
)
|
||||||
import web_interface.blueprints.api_v3 as _pkg
|
import web_interface.blueprints.api_v3 as _pkg
|
||||||
# Read through the module rather than bound by value: tests patch these
|
# Read through the module rather than bound by value: tests patch these
|
||||||
@@ -31,45 +31,41 @@ def _cache_manager():
|
|||||||
@api_v3.route('/display/current', methods=['GET'])
|
@api_v3.route('/display/current', methods=['GET'])
|
||||||
def get_display_current():
|
def get_display_current():
|
||||||
"""Get current display state"""
|
"""Get current display state"""
|
||||||
|
import base64
|
||||||
|
from PIL import Image
|
||||||
|
import io
|
||||||
|
|
||||||
|
snapshot_path = "/tmp/led_matrix_preview.png"
|
||||||
|
|
||||||
|
# Get display dimensions from config: the logical size DisplayManager
|
||||||
|
# renders at, so double-sided setups preview one screen
|
||||||
|
from src.display_geometry import logical_size
|
||||||
try:
|
try:
|
||||||
import base64
|
config = api_v3.config_manager.load_config() if api_v3.config_manager else {}
|
||||||
from PIL import Image
|
width, height = logical_size(config)
|
||||||
import io
|
except Exception:
|
||||||
|
width, height = logical_size({})
|
||||||
|
|
||||||
snapshot_path = "/tmp/led_matrix_preview.png"
|
# Try to read snapshot file
|
||||||
|
image_data = None
|
||||||
# Get display dimensions from config: the logical size DisplayManager
|
if os.path.exists(snapshot_path):
|
||||||
# renders at, so double-sided setups preview one screen
|
|
||||||
from src.display_geometry import logical_size
|
|
||||||
try:
|
try:
|
||||||
config = api_v3.config_manager.load_config() if api_v3.config_manager else {}
|
with Image.open(snapshot_path) as img:
|
||||||
width, height = logical_size(config)
|
# Convert to PNG and encode as base64
|
||||||
except Exception:
|
buffer = io.BytesIO()
|
||||||
width, height = logical_size({})
|
img.save(buffer, format='PNG')
|
||||||
|
image_data = base64.b64encode(buffer.getvalue()).decode('utf-8')
|
||||||
|
except Exception as img_err:
|
||||||
|
# File might be being written or corrupted, return None
|
||||||
|
pass
|
||||||
|
|
||||||
# Try to read snapshot file
|
display_data = {
|
||||||
image_data = None
|
'timestamp': _pkg.time.time(),
|
||||||
if os.path.exists(snapshot_path):
|
'width': width,
|
||||||
try:
|
'height': height,
|
||||||
with Image.open(snapshot_path) as img:
|
'image': image_data # Base64 encoded image data or None if unavailable
|
||||||
# Convert to PNG and encode as base64
|
}
|
||||||
buffer = io.BytesIO()
|
return jsonify({'status': 'success', 'data': display_data})
|
||||||
img.save(buffer, format='PNG')
|
|
||||||
image_data = base64.b64encode(buffer.getvalue()).decode('utf-8')
|
|
||||||
except Exception as img_err:
|
|
||||||
# File might be being written or corrupted, return None
|
|
||||||
pass
|
|
||||||
|
|
||||||
display_data = {
|
|
||||||
'timestamp': _pkg.time.time(),
|
|
||||||
'width': width,
|
|
||||||
'height': height,
|
|
||||||
'image': image_data # Base64 encoded image data or None if unavailable
|
|
||||||
}
|
|
||||||
return jsonify({'status': 'success', 'data': display_data})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error('Unhandled exception', exc_info=True)
|
|
||||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
|
||||||
@api_v3.route('/display/modes', methods=['GET'])
|
@api_v3.route('/display/modes', methods=['GET'])
|
||||||
def get_display_modes():
|
def get_display_modes():
|
||||||
"""Every display mode that can be requested on-demand, with its plugin.
|
"""Every display mode that can be requested on-demand, with its plugin.
|
||||||
@@ -92,230 +88,208 @@ def get_display_modes():
|
|||||||
for the duration -- so they are reported with enabled: false
|
for the duration -- so they are reported with enabled: false
|
||||||
rather than omitted.
|
rather than omitted.
|
||||||
"""
|
"""
|
||||||
try:
|
if not api_v3.plugin_manager:
|
||||||
if not api_v3.plugin_manager:
|
return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500
|
||||||
return jsonify({'status': 'error', 'message': 'Plugin manager not initialized'}), 500
|
|
||||||
|
|
||||||
# Discovery is lazy and normally triggered by whichever endpoint runs
|
# Discovery is lazy and normally triggered by whichever endpoint runs
|
||||||
# first, which is a person opening the dashboard. A caller that never
|
# first, which is a person opening the dashboard. A caller that never
|
||||||
# visits it would otherwise see an empty list.
|
# visits it would otherwise see an empty list.
|
||||||
api_v3.plugin_manager.discover_plugins()
|
api_v3.plugin_manager.discover_plugins()
|
||||||
|
|
||||||
include_disabled = request.args.get('include_disabled') in ('1', 'true', 'True')
|
include_disabled = request.args.get('include_disabled') in ('1', 'true', 'True')
|
||||||
full_config = api_v3.config_manager.load_config() if api_v3.config_manager else {}
|
full_config = api_v3.config_manager.load_config() if api_v3.config_manager else {}
|
||||||
|
|
||||||
modes = []
|
modes = []
|
||||||
for plugin_id, manifest in sorted(api_v3.plugin_manager.plugin_manifests.items()):
|
for plugin_id, manifest in sorted(api_v3.plugin_manager.plugin_manifests.items()):
|
||||||
# A hand-edited or migrated config.json can hold a non-dict under a
|
# A hand-edited or migrated config.json can hold a non-dict under a
|
||||||
# plugin id; DisplayController._reconcile guards the same shape, so
|
# plugin id; DisplayController._reconcile guards the same shape, so
|
||||||
# it happens in practice. Without this, .get() raises AttributeError,
|
# it happens in practice. Without this, .get() raises AttributeError,
|
||||||
# the loop aborts and the endpoint answers 500 with no modes at all
|
# the loop aborts and the endpoint answers 500 with no modes at all
|
||||||
# -- one bad section would blank every entity the MQTT bridge builds
|
# -- one bad section would blank every entity the MQTT bridge builds
|
||||||
# from this list.
|
# from this list.
|
||||||
plugin_config = full_config.get(plugin_id)
|
plugin_config = full_config.get(plugin_id)
|
||||||
if not isinstance(plugin_config, dict):
|
if not isinstance(plugin_config, dict):
|
||||||
if plugin_config is not None:
|
if plugin_config is not None:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Config for plugin %r is %s, not an object; treating it as disabled",
|
"Config for plugin %r is %s, not an object; treating it as disabled",
|
||||||
plugin_id, type(plugin_config).__name__)
|
plugin_id, type(plugin_config).__name__)
|
||||||
plugin_config = {}
|
plugin_config = {}
|
||||||
enabled = bool(plugin_config.get('enabled', False))
|
enabled = bool(plugin_config.get('enabled', False))
|
||||||
if not enabled and not include_disabled:
|
if not enabled and not include_disabled:
|
||||||
continue
|
continue
|
||||||
plugin_name = (manifest or {}).get('name') or plugin_id
|
plugin_name = (manifest or {}).get('name') or plugin_id
|
||||||
plugin_modes = api_v3.plugin_manager.get_plugin_display_modes(plugin_id) or [plugin_id]
|
plugin_modes = api_v3.plugin_manager.get_plugin_display_modes(plugin_id) or [plugin_id]
|
||||||
for mode in plugin_modes:
|
for mode in plugin_modes:
|
||||||
# A single-mode plugin's mode is the plugin, so its own name is
|
# A single-mode plugin's mode is the plugin, so its own name is
|
||||||
# the readable label. Multi-mode plugins have no per-mode name
|
# the readable label. Multi-mode plugins have no per-mode name
|
||||||
# anywhere, so the raw mode string is the only thing to show.
|
# anywhere, so the raw mode string is the only thing to show.
|
||||||
modes.append({
|
modes.append({
|
||||||
'mode': mode,
|
'mode': mode,
|
||||||
'plugin_id': plugin_id,
|
'plugin_id': plugin_id,
|
||||||
'plugin_name': plugin_name,
|
'plugin_name': plugin_name,
|
||||||
'name': plugin_name if len(plugin_modes) == 1 else mode,
|
'name': plugin_name if len(plugin_modes) == 1 else mode,
|
||||||
'enabled': enabled,
|
'enabled': enabled,
|
||||||
})
|
})
|
||||||
|
|
||||||
return jsonify({'status': 'success', 'data': {'modes': modes}})
|
return jsonify({'status': 'success', 'data': {'modes': modes}})
|
||||||
except Exception as exc:
|
|
||||||
# describe_exception, not a bare message: test_web_error_detail.py
|
|
||||||
# enforces that every handler here returns it, because a device whose
|
|
||||||
# storage is failing otherwise answers "see logs for details" from the
|
|
||||||
# log viewer too. It redacts credentials out of the exception text.
|
|
||||||
# CodeQL flags this as stack-trace exposure across all ~75 handlers;
|
|
||||||
# it is the project's deliberate, reviewed trade-off.
|
|
||||||
logger.error('Error in get_display_modes', exc_info=True)
|
|
||||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(exc)}), 500
|
|
||||||
@api_v3.route('/display/on-demand/status', methods=['GET'])
|
@api_v3.route('/display/on-demand/status', methods=['GET'])
|
||||||
def get_on_demand_status():
|
def get_on_demand_status():
|
||||||
"""Return the current on-demand display state."""
|
"""Return the current on-demand display state."""
|
||||||
try:
|
cache = _cache_manager()
|
||||||
cache = _cache_manager()
|
# memory_ttl=0: the display service writes this key, so only the file
|
||||||
# memory_ttl=0: the display service writes this key, so only the file
|
# is current. This process's memory tier would keep serving the first
|
||||||
# is current. This process's memory tier would keep serving the first
|
# copy it read for the full max_age -- "active" for two minutes after
|
||||||
# copy it read for the full max_age -- "active" for two minutes after
|
# the display had already stopped.
|
||||||
# the display had already stopped.
|
state = cache.get('display_on_demand_state', max_age=120, memory_ttl=0)
|
||||||
state = cache.get('display_on_demand_state', max_age=120, memory_ttl=0)
|
if state is None:
|
||||||
if state is None:
|
state = {
|
||||||
state = {
|
'active': False,
|
||||||
'active': False,
|
'status': 'idle',
|
||||||
'status': 'idle',
|
'last_updated': None
|
||||||
'last_updated': None
|
}
|
||||||
}
|
service_status = _get_display_service_status()
|
||||||
service_status = _get_display_service_status()
|
return jsonify({
|
||||||
return jsonify({
|
'status': 'success',
|
||||||
'status': 'success',
|
'data': {
|
||||||
'data': {
|
'state': state,
|
||||||
'state': state,
|
'service': service_status
|
||||||
'service': service_status
|
}
|
||||||
}
|
})
|
||||||
})
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error('Error in get_on_demand_status', exc_info=True)
|
|
||||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(exc)}), 500
|
|
||||||
@api_v3.route('/display/on-demand/start', methods=['POST'])
|
@api_v3.route('/display/on-demand/start', methods=['POST'])
|
||||||
def start_on_demand_display():
|
def start_on_demand_display():
|
||||||
"""Request the display controller to run a specific plugin on-demand."""
|
"""Request the display controller to run a specific plugin on-demand."""
|
||||||
try:
|
data = request.get_json(silent=True) or {}
|
||||||
data = request.get_json(silent=True) or {}
|
plugin_id = data.get('plugin_id')
|
||||||
plugin_id = data.get('plugin_id')
|
mode = data.get('mode')
|
||||||
mode = data.get('mode')
|
duration = data.get('duration')
|
||||||
duration = data.get('duration')
|
pinned = bool(data.get('pinned', False))
|
||||||
pinned = bool(data.get('pinned', False))
|
start_service = data.get('start_service', True)
|
||||||
start_service = data.get('start_service', True)
|
|
||||||
|
|
||||||
if not plugin_id and not mode:
|
if not plugin_id and not mode:
|
||||||
return jsonify({'status': 'error', 'message': 'plugin_id or mode is required'}), 400
|
return jsonify({'status': 'error', 'message': 'plugin_id or mode is required'}), 400
|
||||||
|
|
||||||
resolved_plugin = plugin_id
|
resolved_plugin = plugin_id
|
||||||
resolved_mode = mode
|
resolved_mode = mode
|
||||||
|
|
||||||
if api_v3.plugin_manager:
|
if api_v3.plugin_manager:
|
||||||
if resolved_plugin and resolved_plugin not in _pkg._discovered_plugin_manifests(resolved_plugin):
|
if resolved_plugin and resolved_plugin not in _pkg._discovered_plugin_manifests(resolved_plugin):
|
||||||
return jsonify({'status': 'error', 'message': f'Plugin {resolved_plugin} not found'}), 404
|
return jsonify({'status': 'error', 'message': f'Plugin {resolved_plugin} not found'}), 404
|
||||||
|
|
||||||
if resolved_plugin and not resolved_mode:
|
if resolved_plugin and not resolved_mode:
|
||||||
modes = api_v3.plugin_manager.get_plugin_display_modes(resolved_plugin)
|
modes = api_v3.plugin_manager.get_plugin_display_modes(resolved_plugin)
|
||||||
resolved_mode = modes[0] if modes else resolved_plugin
|
resolved_mode = modes[0] if modes else resolved_plugin
|
||||||
elif resolved_mode and not resolved_plugin:
|
elif resolved_mode and not resolved_plugin:
|
||||||
_pkg._discovered_plugin_manifests()
|
_pkg._discovered_plugin_manifests()
|
||||||
|
resolved_plugin = api_v3.plugin_manager.find_plugin_for_mode(resolved_mode)
|
||||||
|
if not resolved_plugin:
|
||||||
|
# Not among what was discovered: the plugin that declares
|
||||||
|
# it may have been installed since. Scan once more.
|
||||||
|
_pkg._discovered_plugin_manifests(rescan=True)
|
||||||
resolved_plugin = api_v3.plugin_manager.find_plugin_for_mode(resolved_mode)
|
resolved_plugin = api_v3.plugin_manager.find_plugin_for_mode(resolved_mode)
|
||||||
if not resolved_plugin:
|
if not resolved_plugin:
|
||||||
# Not among what was discovered: the plugin that declares
|
return jsonify({'status': 'error', 'message': f'Mode {resolved_mode} not found'}), 404
|
||||||
# it may have been installed since. Scan once more.
|
|
||||||
_pkg._discovered_plugin_manifests(rescan=True)
|
|
||||||
resolved_plugin = api_v3.plugin_manager.find_plugin_for_mode(resolved_mode)
|
|
||||||
if not resolved_plugin:
|
|
||||||
return jsonify({'status': 'error', 'message': f'Mode {resolved_mode} not found'}), 404
|
|
||||||
|
|
||||||
# Note: On-demand can work with disabled plugins - the display controller
|
# Note: On-demand can work with disabled plugins - the display controller
|
||||||
# will temporarily enable them during initialization if needed
|
# will temporarily enable them during initialization if needed
|
||||||
# We don't block the request here, but log it for debugging
|
# We don't block the request here, but log it for debugging
|
||||||
if api_v3.config_manager and resolved_plugin:
|
if api_v3.config_manager and resolved_plugin:
|
||||||
config = api_v3.config_manager.load_config()
|
config = api_v3.config_manager.load_config()
|
||||||
plugin_config = config.get(resolved_plugin, {})
|
plugin_config = config.get(resolved_plugin, {})
|
||||||
if 'enabled' in plugin_config and not plugin_config.get('enabled', False):
|
if 'enabled' in plugin_config and not plugin_config.get('enabled', False):
|
||||||
logger.info(
|
logger.info(
|
||||||
"On-demand request for disabled plugin '%s' - will be temporarily enabled",
|
"On-demand request for disabled plugin '%s' - will be temporarily enabled",
|
||||||
resolved_plugin,
|
resolved_plugin,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Set the on-demand request in cache FIRST (before starting service)
|
# Set the on-demand request in cache FIRST (before starting service)
|
||||||
# This ensures the request is available when the service starts/restarts
|
# This ensures the request is available when the service starts/restarts
|
||||||
cache = _cache_manager()
|
cache = _cache_manager()
|
||||||
request_id = data.get('request_id') or str(uuid.uuid4())
|
request_id = data.get('request_id') or str(uuid.uuid4())
|
||||||
request_payload = {
|
request_payload = {
|
||||||
'request_id': request_id,
|
'request_id': request_id,
|
||||||
'action': 'start',
|
'action': 'start',
|
||||||
'plugin_id': resolved_plugin,
|
'plugin_id': resolved_plugin,
|
||||||
'mode': resolved_mode,
|
'mode': resolved_mode,
|
||||||
'duration': duration,
|
'duration': duration,
|
||||||
'pinned': pinned,
|
'pinned': pinned,
|
||||||
'timestamp': _pkg.time.time()
|
'timestamp': _pkg.time.time()
|
||||||
}
|
}
|
||||||
cache.set('display_on_demand_request', request_payload)
|
cache.set('display_on_demand_request', request_payload)
|
||||||
|
|
||||||
# Check if display service is running (or will be started)
|
# Check if display service is running (or will be started)
|
||||||
service_status = _get_display_service_status()
|
service_status = _get_display_service_status()
|
||||||
service_was_running = service_status.get('active', False)
|
service_was_running = service_status.get('active', False)
|
||||||
|
|
||||||
# Stop the display service first to ensure clean state when we will restart it
|
# Stop the display service first to ensure clean state when we will restart it
|
||||||
if service_was_running and start_service:
|
if service_was_running and start_service:
|
||||||
import time as time_module
|
import time as time_module
|
||||||
logger.debug("Stopping display service before starting on-demand mode")
|
logger.debug("Stopping display service before starting on-demand mode")
|
||||||
_stop_display_service()
|
_stop_display_service()
|
||||||
# Wait a brief moment for the service to fully stop
|
# Wait a brief moment for the service to fully stop
|
||||||
time_module.sleep(1.5)
|
time_module.sleep(1.5)
|
||||||
logger.debug("Display service stopped, now starting with on-demand request")
|
logger.debug("Display service stopped, now starting with on-demand request")
|
||||||
|
|
||||||
if not service_status.get('active') and not start_service:
|
if not service_status.get('active') and not start_service:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Display service is not running. Please start the display service or enable "Start Service" option.',
|
||||||
|
'service_status': service_status
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
service_result = None
|
||||||
|
if start_service:
|
||||||
|
service_result = _ensure_display_service_running()
|
||||||
|
# Check if service actually started
|
||||||
|
if service_result and not service_result.get('active'):
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'Display service is not running. Please start the display service or enable "Start Service" option.',
|
'message': 'Failed to start display service. Please check service logs or start it manually.',
|
||||||
'service_status': service_status
|
'service_result': service_result
|
||||||
}), 400
|
}), 500
|
||||||
|
|
||||||
service_result = None
|
|
||||||
if start_service:
|
|
||||||
service_result = _ensure_display_service_running()
|
|
||||||
# Check if service actually started
|
|
||||||
if service_result and not service_result.get('active'):
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': 'Failed to start display service. Please check service logs or start it manually.',
|
|
||||||
'service_result': service_result
|
|
||||||
}), 500
|
|
||||||
|
|
||||||
# Service was restarted (or started fresh) with on-demand request in cache
|
# Service was restarted (or started fresh) with on-demand request in cache
|
||||||
# The display controller will read the request during initialization or when it polls
|
# The display controller will read the request during initialization or when it polls
|
||||||
|
|
||||||
response_data = {
|
response_data = {
|
||||||
'request_id': request_id,
|
'request_id': request_id,
|
||||||
'plugin_id': resolved_plugin,
|
'plugin_id': resolved_plugin,
|
||||||
'mode': resolved_mode,
|
'mode': resolved_mode,
|
||||||
'duration': duration,
|
'duration': duration,
|
||||||
'pinned': pinned,
|
'pinned': pinned,
|
||||||
'service': service_result
|
'service': service_result
|
||||||
}
|
}
|
||||||
return jsonify({'status': 'success', 'data': response_data})
|
return jsonify({'status': 'success', 'data': response_data})
|
||||||
except Exception as exc:
|
|
||||||
logger.error('Error in start_on_demand_display', exc_info=True)
|
|
||||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(exc)}), 500
|
|
||||||
@api_v3.route('/display/on-demand/stop', methods=['POST'])
|
@api_v3.route('/display/on-demand/stop', methods=['POST'])
|
||||||
def stop_on_demand_display():
|
def stop_on_demand_display():
|
||||||
"""Request the display controller to stop on-demand mode."""
|
"""Request the display controller to stop on-demand mode."""
|
||||||
try:
|
data = request.get_json(silent=True) or {}
|
||||||
data = request.get_json(silent=True) or {}
|
stop_service = data.get('stop_service', False)
|
||||||
stop_service = data.get('stop_service', False)
|
|
||||||
|
|
||||||
# Set the stop request in cache FIRST
|
# Set the stop request in cache FIRST
|
||||||
# The display controller will poll this and restart without the on-demand filter
|
# The display controller will poll this and restart without the on-demand filter
|
||||||
cache = _cache_manager()
|
cache = _cache_manager()
|
||||||
request_id = data.get('request_id') or str(uuid.uuid4())
|
request_id = data.get('request_id') or str(uuid.uuid4())
|
||||||
request_payload = {
|
request_payload = {
|
||||||
|
'request_id': request_id,
|
||||||
|
'action': 'stop',
|
||||||
|
'timestamp': _pkg.time.time()
|
||||||
|
}
|
||||||
|
cache.set('display_on_demand_request', request_payload)
|
||||||
|
|
||||||
|
# Note: The display controller's _clear_on_demand() will handle the restart
|
||||||
|
# to restore normal operation with all plugins
|
||||||
|
|
||||||
|
service_result = None
|
||||||
|
if stop_service:
|
||||||
|
service_result = _stop_display_service()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'data': {
|
||||||
'request_id': request_id,
|
'request_id': request_id,
|
||||||
'action': 'stop',
|
'service': service_result
|
||||||
'timestamp': _pkg.time.time()
|
|
||||||
}
|
}
|
||||||
cache.set('display_on_demand_request', request_payload)
|
})
|
||||||
|
|
||||||
# Note: The display controller's _clear_on_demand() will handle the restart
|
|
||||||
# to restore normal operation with all plugins
|
|
||||||
|
|
||||||
service_result = None
|
|
||||||
if stop_service:
|
|
||||||
service_result = _stop_display_service()
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'data': {
|
|
||||||
'request_id': request_id,
|
|
||||||
'service': service_result
|
|
||||||
}
|
|
||||||
})
|
|
||||||
except Exception as exc:
|
|
||||||
logger.error('Error in stop_on_demand_display', exc_info=True)
|
|
||||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(exc)}), 500
|
|
||||||
@api_v3.route('/display/current-status', methods=['GET'])
|
@api_v3.route('/display/current-status', methods=['GET'])
|
||||||
def get_current_display_status():
|
def get_current_display_status():
|
||||||
"""Return the display mode/plugin currently intended to be shown.
|
"""Return the display mode/plugin currently intended to be shown.
|
||||||
@@ -325,17 +299,13 @@ def get_current_display_status():
|
|||||||
System Logs page) can show what's on screen without querying the display
|
System Logs page) can show what's on screen without querying the display
|
||||||
process directly.
|
process directly.
|
||||||
"""
|
"""
|
||||||
try:
|
cache = _cache_manager()
|
||||||
cache = _cache_manager()
|
# memory_ttl=0: written by the display service; see get_on_demand_status.
|
||||||
# memory_ttl=0: written by the display service; see get_on_demand_status.
|
state = cache.get('display_current_state', max_age=120, memory_ttl=0)
|
||||||
state = cache.get('display_current_state', max_age=120, memory_ttl=0)
|
if state is None:
|
||||||
if state is None:
|
state = {
|
||||||
state = {
|
'mode': None,
|
||||||
'mode': None,
|
'plugin_id': None,
|
||||||
'plugin_id': None,
|
'last_updated': None,
|
||||||
'last_updated': None,
|
}
|
||||||
}
|
return jsonify({'status': 'success', 'data': state})
|
||||||
return jsonify({'status': 'success', 'data': state})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error('Error in get_current_display_status', exc_info=True)
|
|
||||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Routes decorate the shared `api_v3` Blueprint from ._common, so their
|
|||||||
endpoint names are unchanged by living here.
|
endpoint names are unchanged by living here.
|
||||||
"""
|
"""
|
||||||
from web_interface.blueprints.api_v3 import (
|
from web_interface.blueprints.api_v3 import (
|
||||||
PROJECT_ROOT, Path, Response, SYSTEM_FONTS, api_v3, describe_exception,
|
PROJECT_ROOT, Path, Response, SYSTEM_FONTS, api_v3,
|
||||||
jsonify, logger, os, re, request, validate_file_upload,
|
jsonify, logger, os, re, request, validate_file_upload,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -52,439 +52,417 @@ def _catalog_response(catalog):
|
|||||||
@api_v3.route('/fonts/catalog', methods=['GET'])
|
@api_v3.route('/fonts/catalog', methods=['GET'])
|
||||||
def get_fonts_catalog():
|
def get_fonts_catalog():
|
||||||
"""Get fonts catalog"""
|
"""Get fonts catalog"""
|
||||||
|
# Check cache first (5 minute TTL)
|
||||||
try:
|
try:
|
||||||
# Check cache first (5 minute TTL)
|
from web_interface.cache import get_cached, set_cached
|
||||||
|
cached_result = get_cached('fonts_catalog', ttl_seconds=300)
|
||||||
|
if cached_result is not None:
|
||||||
|
return _catalog_response(cached_result)
|
||||||
|
except ImportError:
|
||||||
|
# Cache not available, continue without caching
|
||||||
|
get_cached = None
|
||||||
|
set_cached = None
|
||||||
|
|
||||||
|
# Try to import freetype, but continue without it if unavailable
|
||||||
|
try:
|
||||||
|
import freetype
|
||||||
|
freetype_available = True
|
||||||
|
except ImportError:
|
||||||
|
freetype_available = False
|
||||||
|
|
||||||
|
# Scan assets/fonts directory for actual font files
|
||||||
|
fonts_dir = PROJECT_ROOT / "assets" / "fonts"
|
||||||
|
catalog = {}
|
||||||
|
|
||||||
|
if fonts_dir.exists() and fonts_dir.is_dir():
|
||||||
|
for filename in os.listdir(fonts_dir):
|
||||||
|
if filename.endswith(('.ttf', '.otf', '.bdf')):
|
||||||
|
filepath = fonts_dir / filename
|
||||||
|
# Generate family name from filename (without extension)
|
||||||
|
family_name = os.path.splitext(filename)[0]
|
||||||
|
|
||||||
|
# Try to get font metadata using freetype (for TTF/OTF)
|
||||||
|
metadata = {}
|
||||||
|
if filename.endswith(('.ttf', '.otf')) and freetype_available:
|
||||||
|
try:
|
||||||
|
face = freetype.Face(str(filepath))
|
||||||
|
if face.valid:
|
||||||
|
# Get font family name from font file
|
||||||
|
family_name_from_font = face.family_name.decode('utf-8') if face.family_name else family_name
|
||||||
|
metadata = {
|
||||||
|
'family': family_name_from_font,
|
||||||
|
'style': face.style_name.decode('utf-8') if face.style_name else 'Regular',
|
||||||
|
'num_glyphs': face.num_glyphs,
|
||||||
|
'units_per_em': face.units_per_EM
|
||||||
|
}
|
||||||
|
# Use font's family name if available
|
||||||
|
if family_name_from_font:
|
||||||
|
family_name = family_name_from_font
|
||||||
|
except Exception:
|
||||||
|
# If freetype fails, use filename-based name
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Store relative path from project root
|
||||||
|
relative_path = str(filepath.relative_to(PROJECT_ROOT))
|
||||||
|
font_type = 'ttf' if filename.endswith('.ttf') else 'otf' if filename.endswith('.otf') else 'bdf'
|
||||||
|
|
||||||
|
# Generate human-readable display name from family_name
|
||||||
|
display_name = family_name.replace('-', ' ').replace('_', ' ')
|
||||||
|
# Add space before capital letters for camelCase names
|
||||||
|
display_name = re.sub(r'([a-z])([A-Z])', r'\1 \2', display_name)
|
||||||
|
# Add space before numbers that follow letters
|
||||||
|
display_name = re.sub(r'([a-zA-Z])(\d)', r'\1 \2', display_name)
|
||||||
|
# Clean up multiple spaces
|
||||||
|
display_name = ' '.join(display_name.split())
|
||||||
|
|
||||||
|
# Use filename (without extension) as unique key to avoid collisions
|
||||||
|
# when multiple files share the same family_name from font metadata
|
||||||
|
catalog_key = os.path.splitext(filename)[0]
|
||||||
|
|
||||||
|
# Check if this is a system font (cannot be deleted)
|
||||||
|
is_system = catalog_key.lower() in SYSTEM_FONTS
|
||||||
|
|
||||||
|
# BDF files are fixed-size bitmap strikes: FreeType
|
||||||
|
# accepts only the pixel size baked into the file. The
|
||||||
|
# UI needs to know that before offering a size control,
|
||||||
|
# or it offers a number that cannot take effect.
|
||||||
|
native_size = None
|
||||||
|
if font_type == 'bdf':
|
||||||
|
try:
|
||||||
|
from src.element_style import _read_bdf_native_size
|
||||||
|
native_size = _read_bdf_native_size(str(filepath))
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug("Could not read native size for BDF font %s: %s",
|
||||||
|
filepath, e)
|
||||||
|
native_size = None
|
||||||
|
|
||||||
|
catalog[catalog_key] = {
|
||||||
|
'filename': filename,
|
||||||
|
'family_name': family_name,
|
||||||
|
'display_name': display_name,
|
||||||
|
'path': relative_path,
|
||||||
|
'type': font_type,
|
||||||
|
'is_system': is_system,
|
||||||
|
'scalable': font_type != 'bdf',
|
||||||
|
'native_size': native_size,
|
||||||
|
'metadata': metadata if metadata else None
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache the result (5 minute TTL) if available
|
||||||
|
if set_cached:
|
||||||
try:
|
try:
|
||||||
from web_interface.cache import get_cached, set_cached
|
set_cached('fonts_catalog', catalog, ttl_seconds=300)
|
||||||
cached_result = get_cached('fonts_catalog', ttl_seconds=300)
|
except Exception:
|
||||||
if cached_result is not None:
|
logger.error("[FontCatalog] Failed to cache fonts_catalog", exc_info=True)
|
||||||
return _catalog_response(cached_result)
|
|
||||||
except ImportError:
|
|
||||||
# Cache not available, continue without caching
|
|
||||||
get_cached = None
|
|
||||||
set_cached = None
|
|
||||||
|
|
||||||
# Try to import freetype, but continue without it if unavailable
|
return _catalog_response(catalog)
|
||||||
try:
|
|
||||||
import freetype
|
|
||||||
freetype_available = True
|
|
||||||
except ImportError:
|
|
||||||
freetype_available = False
|
|
||||||
|
|
||||||
# Scan assets/fonts directory for actual font files
|
|
||||||
fonts_dir = PROJECT_ROOT / "assets" / "fonts"
|
|
||||||
catalog = {}
|
|
||||||
|
|
||||||
if fonts_dir.exists() and fonts_dir.is_dir():
|
|
||||||
for filename in os.listdir(fonts_dir):
|
|
||||||
if filename.endswith(('.ttf', '.otf', '.bdf')):
|
|
||||||
filepath = fonts_dir / filename
|
|
||||||
# Generate family name from filename (without extension)
|
|
||||||
family_name = os.path.splitext(filename)[0]
|
|
||||||
|
|
||||||
# Try to get font metadata using freetype (for TTF/OTF)
|
|
||||||
metadata = {}
|
|
||||||
if filename.endswith(('.ttf', '.otf')) and freetype_available:
|
|
||||||
try:
|
|
||||||
face = freetype.Face(str(filepath))
|
|
||||||
if face.valid:
|
|
||||||
# Get font family name from font file
|
|
||||||
family_name_from_font = face.family_name.decode('utf-8') if face.family_name else family_name
|
|
||||||
metadata = {
|
|
||||||
'family': family_name_from_font,
|
|
||||||
'style': face.style_name.decode('utf-8') if face.style_name else 'Regular',
|
|
||||||
'num_glyphs': face.num_glyphs,
|
|
||||||
'units_per_em': face.units_per_EM
|
|
||||||
}
|
|
||||||
# Use font's family name if available
|
|
||||||
if family_name_from_font:
|
|
||||||
family_name = family_name_from_font
|
|
||||||
except Exception:
|
|
||||||
# If freetype fails, use filename-based name
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Store relative path from project root
|
|
||||||
relative_path = str(filepath.relative_to(PROJECT_ROOT))
|
|
||||||
font_type = 'ttf' if filename.endswith('.ttf') else 'otf' if filename.endswith('.otf') else 'bdf'
|
|
||||||
|
|
||||||
# Generate human-readable display name from family_name
|
|
||||||
display_name = family_name.replace('-', ' ').replace('_', ' ')
|
|
||||||
# Add space before capital letters for camelCase names
|
|
||||||
display_name = re.sub(r'([a-z])([A-Z])', r'\1 \2', display_name)
|
|
||||||
# Add space before numbers that follow letters
|
|
||||||
display_name = re.sub(r'([a-zA-Z])(\d)', r'\1 \2', display_name)
|
|
||||||
# Clean up multiple spaces
|
|
||||||
display_name = ' '.join(display_name.split())
|
|
||||||
|
|
||||||
# Use filename (without extension) as unique key to avoid collisions
|
|
||||||
# when multiple files share the same family_name from font metadata
|
|
||||||
catalog_key = os.path.splitext(filename)[0]
|
|
||||||
|
|
||||||
# Check if this is a system font (cannot be deleted)
|
|
||||||
is_system = catalog_key.lower() in SYSTEM_FONTS
|
|
||||||
|
|
||||||
# BDF files are fixed-size bitmap strikes: FreeType
|
|
||||||
# accepts only the pixel size baked into the file. The
|
|
||||||
# UI needs to know that before offering a size control,
|
|
||||||
# or it offers a number that cannot take effect.
|
|
||||||
native_size = None
|
|
||||||
if font_type == 'bdf':
|
|
||||||
try:
|
|
||||||
from src.element_style import _read_bdf_native_size
|
|
||||||
native_size = _read_bdf_native_size(str(filepath))
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("Could not read native size for BDF font %s: %s",
|
|
||||||
filepath, e)
|
|
||||||
native_size = None
|
|
||||||
|
|
||||||
catalog[catalog_key] = {
|
|
||||||
'filename': filename,
|
|
||||||
'family_name': family_name,
|
|
||||||
'display_name': display_name,
|
|
||||||
'path': relative_path,
|
|
||||||
'type': font_type,
|
|
||||||
'is_system': is_system,
|
|
||||||
'scalable': font_type != 'bdf',
|
|
||||||
'native_size': native_size,
|
|
||||||
'metadata': metadata if metadata else None
|
|
||||||
}
|
|
||||||
|
|
||||||
# Cache the result (5 minute TTL) if available
|
|
||||||
if set_cached:
|
|
||||||
try:
|
|
||||||
set_cached('fonts_catalog', catalog, ttl_seconds=300)
|
|
||||||
except Exception:
|
|
||||||
logger.error("[FontCatalog] Failed to cache fonts_catalog", exc_info=True)
|
|
||||||
|
|
||||||
return _catalog_response(catalog)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("%s failed", request.path, exc_info=True)
|
|
||||||
return jsonify({'status': 'error',
|
|
||||||
'message': 'An error occurred; see logs for details',
|
|
||||||
'details': describe_exception(e)}), 500
|
|
||||||
@api_v3.route('/fonts/tokens', methods=['GET'])
|
@api_v3.route('/fonts/tokens', methods=['GET'])
|
||||||
def get_font_tokens():
|
def get_font_tokens():
|
||||||
"""Get font size tokens"""
|
"""Get font size tokens"""
|
||||||
try:
|
# This would integrate with the actual font system
|
||||||
# This would integrate with the actual font system
|
# For now, return sample tokens
|
||||||
# For now, return sample tokens
|
tokens = {
|
||||||
tokens = {
|
'xs': 6,
|
||||||
'xs': 6,
|
'sm': 8,
|
||||||
'sm': 8,
|
'md': 10,
|
||||||
'md': 10,
|
'lg': 12,
|
||||||
'lg': 12,
|
'xl': 14,
|
||||||
'xl': 14,
|
'xxl': 16
|
||||||
'xxl': 16
|
}
|
||||||
}
|
return jsonify({'status': 'success', 'data': {'tokens': tokens}})
|
||||||
return jsonify({'status': 'success', 'data': {'tokens': tokens}})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error('Unhandled exception', exc_info=True)
|
|
||||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
|
||||||
@api_v3.route('/fonts/upload', methods=['POST'])
|
@api_v3.route('/fonts/upload', methods=['POST'])
|
||||||
def upload_font():
|
def upload_font():
|
||||||
"""Upload font file"""
|
"""Upload font file"""
|
||||||
try:
|
if 'font_file' not in request.files:
|
||||||
if 'font_file' not in request.files:
|
return jsonify({'status': 'error', 'message': 'No font file provided'}), 400
|
||||||
return jsonify({'status': 'error', 'message': 'No font file provided'}), 400
|
|
||||||
|
|
||||||
font_file = request.files['font_file']
|
font_file = request.files['font_file']
|
||||||
if font_file.filename == '':
|
if font_file.filename == '':
|
||||||
return jsonify({'status': 'error', 'message': 'No file selected'}), 400
|
return jsonify({'status': 'error', 'message': 'No file selected'}), 400
|
||||||
|
|
||||||
# Validate filename. validate_file_upload takes max_size_mb but only
|
# Validate filename. validate_file_upload takes max_size_mb but only
|
||||||
# checks the filename/extension with it -- it never looks at the
|
# checks the filename/extension with it -- it never looks at the
|
||||||
# actual upload size, so the size limit below is enforced separately
|
# actual upload size, so the size limit below is enforced separately
|
||||||
# before the file is saved (same pattern as the .star upload above).
|
# before the file is saved (same pattern as the .star upload above).
|
||||||
MAX_FONT_SIZE_MB = 10
|
MAX_FONT_SIZE_MB = 10
|
||||||
is_valid, error_msg = validate_file_upload(
|
is_valid, error_msg = validate_file_upload(
|
||||||
font_file.filename,
|
font_file.filename,
|
||||||
max_size_mb=MAX_FONT_SIZE_MB,
|
max_size_mb=MAX_FONT_SIZE_MB,
|
||||||
allowed_extensions=['.ttf', '.otf', '.bdf']
|
allowed_extensions=['.ttf', '.otf', '.bdf']
|
||||||
)
|
)
|
||||||
if not is_valid:
|
if not is_valid:
|
||||||
return jsonify({'status': 'error', 'message': error_msg}), 400
|
return jsonify({'status': 'error', 'message': error_msg}), 400
|
||||||
|
|
||||||
# Check file size (stated limit is MAX_FONT_SIZE_MB)
|
|
||||||
font_file.seek(0, 2) # Seek to end
|
|
||||||
file_size = font_file.tell()
|
|
||||||
font_file.seek(0) # Reset to beginning
|
|
||||||
max_font_size_bytes = MAX_FONT_SIZE_MB * 1024 * 1024
|
|
||||||
if file_size > max_font_size_bytes:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': f'File too large (max {MAX_FONT_SIZE_MB}MB, got {file_size / 1024 / 1024:.1f}MB)'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
font_family = request.form.get('font_family', '')
|
|
||||||
|
|
||||||
if not font_family:
|
|
||||||
return jsonify({'status': 'error', 'message': 'Font file and family name required'}), 400
|
|
||||||
|
|
||||||
# Validate font family name
|
|
||||||
if not font_family.replace('_', '').replace('-', '').isalnum():
|
|
||||||
return jsonify({'status': 'error', 'message': 'Font family name must contain only letters, numbers, underscores, and hyphens'}), 400
|
|
||||||
|
|
||||||
# Save the font file to assets/fonts directory
|
|
||||||
fonts_dir = PROJECT_ROOT / "assets" / "fonts"
|
|
||||||
fonts_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# Create filename from family name
|
|
||||||
original_ext = os.path.splitext(font_file.filename)[1].lower()
|
|
||||||
safe_filename = f"{font_family}{original_ext}"
|
|
||||||
filepath = fonts_dir / safe_filename
|
|
||||||
|
|
||||||
# Check if file already exists
|
|
||||||
if filepath.exists():
|
|
||||||
return jsonify({'status': 'error', 'message': f'Font with name {font_family} already exists'}), 400
|
|
||||||
|
|
||||||
# Save the file
|
|
||||||
font_file.save(str(filepath))
|
|
||||||
|
|
||||||
# Clear font catalog cache
|
|
||||||
try:
|
|
||||||
from web_interface.cache import delete_cached
|
|
||||||
delete_cached('fonts_catalog')
|
|
||||||
except ImportError as e:
|
|
||||||
logger.warning("[FontUpload] Cache module not available: %s", e)
|
|
||||||
except Exception:
|
|
||||||
logger.error("[FontUpload] Failed to clear fonts_catalog cache", exc_info=True)
|
|
||||||
|
|
||||||
|
# Check file size (stated limit is MAX_FONT_SIZE_MB)
|
||||||
|
font_file.seek(0, 2) # Seek to end
|
||||||
|
file_size = font_file.tell()
|
||||||
|
font_file.seek(0) # Reset to beginning
|
||||||
|
max_font_size_bytes = MAX_FONT_SIZE_MB * 1024 * 1024
|
||||||
|
if file_size > max_font_size_bytes:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'success',
|
'status': 'error',
|
||||||
'message': f'Font {font_family} uploaded successfully',
|
'message': f'File too large (max {MAX_FONT_SIZE_MB}MB, got {file_size / 1024 / 1024:.1f}MB)'
|
||||||
'font_family': font_family,
|
}), 400
|
||||||
'filename': safe_filename,
|
|
||||||
'path': f'assets/fonts/{safe_filename}'
|
font_family = request.form.get('font_family', '')
|
||||||
})
|
|
||||||
except Exception as e:
|
if not font_family:
|
||||||
logger.error('Unhandled exception', exc_info=True)
|
return jsonify({'status': 'error', 'message': 'Font file and family name required'}), 400
|
||||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
|
||||||
|
# Validate font family name
|
||||||
|
if not font_family.replace('_', '').replace('-', '').isalnum():
|
||||||
|
return jsonify({'status': 'error', 'message': 'Font family name must contain only letters, numbers, underscores, and hyphens'}), 400
|
||||||
|
|
||||||
|
# Save the font file to assets/fonts directory
|
||||||
|
fonts_dir = PROJECT_ROOT / "assets" / "fonts"
|
||||||
|
fonts_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Create filename from family name
|
||||||
|
original_ext = os.path.splitext(font_file.filename)[1].lower()
|
||||||
|
safe_filename = f"{font_family}{original_ext}"
|
||||||
|
filepath = fonts_dir / safe_filename
|
||||||
|
|
||||||
|
# Check if file already exists
|
||||||
|
if filepath.exists():
|
||||||
|
return jsonify({'status': 'error', 'message': f'Font with name {font_family} already exists'}), 400
|
||||||
|
|
||||||
|
# Save the file
|
||||||
|
font_file.save(str(filepath))
|
||||||
|
|
||||||
|
# Clear font catalog cache
|
||||||
|
try:
|
||||||
|
from web_interface.cache import delete_cached
|
||||||
|
delete_cached('fonts_catalog')
|
||||||
|
except ImportError as e:
|
||||||
|
logger.warning("[FontUpload] Cache module not available: %s", e)
|
||||||
|
except Exception:
|
||||||
|
logger.error("[FontUpload] Failed to clear fonts_catalog cache", exc_info=True)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': f'Font {font_family} uploaded successfully',
|
||||||
|
'font_family': font_family,
|
||||||
|
'filename': safe_filename,
|
||||||
|
'path': f'assets/fonts/{safe_filename}'
|
||||||
|
})
|
||||||
@api_v3.route('/fonts/preview', methods=['GET'])
|
@api_v3.route('/fonts/preview', methods=['GET'])
|
||||||
def get_font_preview() -> tuple[Response, int] | Response:
|
def get_font_preview() -> tuple[Response, int] | Response:
|
||||||
"""Generate a preview image of text rendered with a specific font"""
|
"""Generate a preview image of text rendered with a specific font"""
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
import io
|
||||||
|
import base64
|
||||||
|
|
||||||
|
# Limits to prevent DoS via large image generation on constrained devices
|
||||||
|
MAX_TEXT_CHARS = 100
|
||||||
|
MAX_TEXT_LINES = 3
|
||||||
|
MAX_DIM = 1024 # Max width or height in pixels
|
||||||
|
MAX_PIXELS = 500000 # Max total pixels (e.g., ~700x700)
|
||||||
|
|
||||||
|
font_filename = request.args.get('font', '')
|
||||||
|
text = request.args.get('text', 'Sample Text 123')
|
||||||
|
bg_color = request.args.get('bg', '000000')
|
||||||
|
fg_color = request.args.get('fg', 'ffffff')
|
||||||
|
|
||||||
|
# Validate text length and line count early
|
||||||
|
if len(text) > MAX_TEXT_CHARS:
|
||||||
|
return jsonify({'status': 'error', 'message': f'Text exceeds maximum length of {MAX_TEXT_CHARS} characters'}), 400
|
||||||
|
if text.count('\n') >= MAX_TEXT_LINES:
|
||||||
|
return jsonify({'status': 'error', 'message': f'Text exceeds maximum of {MAX_TEXT_LINES} lines'}), 400
|
||||||
|
|
||||||
|
# Safe integer parsing for size
|
||||||
try:
|
try:
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
size = int(request.args.get('size', 12))
|
||||||
import io
|
except (ValueError, TypeError, OverflowError):
|
||||||
import base64
|
return jsonify({'status': 'error', 'message': 'Invalid font size'}), 400
|
||||||
|
|
||||||
# Limits to prevent DoS via large image generation on constrained devices
|
if not font_filename:
|
||||||
MAX_TEXT_CHARS = 100
|
return jsonify({'status': 'error', 'message': 'Font filename required'}), 400
|
||||||
MAX_TEXT_LINES = 3
|
|
||||||
MAX_DIM = 1024 # Max width or height in pixels
|
|
||||||
MAX_PIXELS = 500000 # Max total pixels (e.g., ~700x700)
|
|
||||||
|
|
||||||
font_filename = request.args.get('font', '')
|
# Validate size
|
||||||
text = request.args.get('text', 'Sample Text 123')
|
if size < 4 or size > 72:
|
||||||
bg_color = request.args.get('bg', '000000')
|
return jsonify({'status': 'error', 'message': 'Font size must be between 4 and 72'}), 400
|
||||||
fg_color = request.args.get('fg', 'ffffff')
|
|
||||||
|
|
||||||
# Validate text length and line count early
|
# Security: Validate font_filename to prevent path traversal
|
||||||
if len(text) > MAX_TEXT_CHARS:
|
# Only allow alphanumeric, hyphen, underscore, and dot (for extension)
|
||||||
return jsonify({'status': 'error', 'message': f'Text exceeds maximum length of {MAX_TEXT_CHARS} characters'}), 400
|
safe_name = Path(font_filename).name # Strip any directory components
|
||||||
if text.count('\n') >= MAX_TEXT_LINES:
|
if safe_name != font_filename or '..' in font_filename:
|
||||||
return jsonify({'status': 'error', 'message': f'Text exceeds maximum of {MAX_TEXT_LINES} lines'}), 400
|
return jsonify({'status': 'error', 'message': 'Invalid font filename'}), 400
|
||||||
|
|
||||||
# Safe integer parsing for size
|
# Validate extension
|
||||||
try:
|
allowed_extensions = ['.ttf', '.otf', '.bdf']
|
||||||
size = int(request.args.get('size', 12))
|
has_valid_ext = any(safe_name.lower().endswith(ext) for ext in allowed_extensions)
|
||||||
except (ValueError, TypeError, OverflowError):
|
name_without_ext = safe_name.rsplit('.', 1)[0] if '.' in safe_name else safe_name
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid font size'}), 400
|
|
||||||
|
|
||||||
if not font_filename:
|
# Find the font file
|
||||||
return jsonify({'status': 'error', 'message': 'Font filename required'}), 400
|
fonts_dir = PROJECT_ROOT / "assets" / "fonts"
|
||||||
|
if not fonts_dir.exists():
|
||||||
|
return jsonify({'status': 'error', 'message': 'Fonts directory not found'}), 404
|
||||||
|
|
||||||
# Validate size
|
font_path = fonts_dir / safe_name
|
||||||
if size < 4 or size > 72:
|
|
||||||
return jsonify({'status': 'error', 'message': 'Font size must be between 4 and 72'}), 400
|
|
||||||
|
|
||||||
# Security: Validate font_filename to prevent path traversal
|
if not font_path.exists() and not has_valid_ext:
|
||||||
# Only allow alphanumeric, hyphen, underscore, and dot (for extension)
|
# Try finding by family name (without extension)
|
||||||
safe_name = Path(font_filename).name # Strip any directory components
|
for ext in allowed_extensions:
|
||||||
if safe_name != font_filename or '..' in font_filename:
|
potential_path = fonts_dir / f"{name_without_ext}{ext}"
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid font filename'}), 400
|
if potential_path.exists():
|
||||||
|
font_path = potential_path
|
||||||
|
break
|
||||||
|
|
||||||
# Validate extension
|
# Final security check: ensure path is within fonts_dir
|
||||||
allowed_extensions = ['.ttf', '.otf', '.bdf']
|
try:
|
||||||
has_valid_ext = any(safe_name.lower().endswith(ext) for ext in allowed_extensions)
|
font_path.resolve().relative_to(fonts_dir.resolve())
|
||||||
name_without_ext = safe_name.rsplit('.', 1)[0] if '.' in safe_name else safe_name
|
except ValueError:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid font path'}), 400
|
||||||
|
|
||||||
# Find the font file
|
if not font_path.exists():
|
||||||
fonts_dir = PROJECT_ROOT / "assets" / "fonts"
|
return jsonify({'status': 'error', 'message': f'Font file not found: {font_filename}'}), 404
|
||||||
if not fonts_dir.exists():
|
|
||||||
return jsonify({'status': 'error', 'message': 'Fonts directory not found'}), 404
|
|
||||||
|
|
||||||
font_path = fonts_dir / safe_name
|
# Parse colors
|
||||||
|
try:
|
||||||
if not font_path.exists() and not has_valid_ext:
|
bg_rgb = tuple(int(bg_color[i:i+2], 16) for i in (0, 2, 4))
|
||||||
# Try finding by family name (without extension)
|
fg_rgb = tuple(int(fg_color[i:i+2], 16) for i in (0, 2, 4))
|
||||||
for ext in allowed_extensions:
|
except (ValueError, IndexError):
|
||||||
potential_path = fonts_dir / f"{name_without_ext}{ext}"
|
bg_rgb = (0, 0, 0)
|
||||||
if potential_path.exists():
|
fg_rgb = (255, 255, 255)
|
||||||
font_path = potential_path
|
|
||||||
break
|
|
||||||
|
|
||||||
# Final security check: ensure path is within fonts_dir
|
|
||||||
try:
|
|
||||||
font_path.resolve().relative_to(fonts_dir.resolve())
|
|
||||||
except ValueError:
|
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid font path'}), 400
|
|
||||||
|
|
||||||
if not font_path.exists():
|
|
||||||
return jsonify({'status': 'error', 'message': f'Font file not found: {font_filename}'}), 404
|
|
||||||
|
|
||||||
# Parse colors
|
|
||||||
try:
|
|
||||||
bg_rgb = tuple(int(bg_color[i:i+2], 16) for i in (0, 2, 4))
|
|
||||||
fg_rgb = tuple(int(fg_color[i:i+2], 16) for i in (0, 2, 4))
|
|
||||||
except (ValueError, IndexError):
|
|
||||||
bg_rgb = (0, 0, 0)
|
|
||||||
fg_rgb = (255, 255, 255)
|
|
||||||
|
|
||||||
# Load font
|
|
||||||
font = None
|
|
||||||
if str(font_path).endswith('.bdf'):
|
|
||||||
# BDF fonts require complex per-glyph rendering via freetype
|
|
||||||
# Return explicit error rather than showing misleading preview with default font
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': 'BDF font preview not supported. BDF fonts will render correctly on the LED matrix.'
|
|
||||||
}), 400
|
|
||||||
else:
|
|
||||||
# TTF/OTF fonts
|
|
||||||
try:
|
|
||||||
font = ImageFont.truetype(str(font_path), size)
|
|
||||||
except (IOError, OSError) as e:
|
|
||||||
# IOError/OSError raised for invalid/corrupt font files
|
|
||||||
logger.warning("[FontPreview] Failed to load font %s: %s", font_path, e)
|
|
||||||
font = ImageFont.load_default()
|
|
||||||
|
|
||||||
# Calculate text size
|
|
||||||
temp_img = Image.new('RGB', (1, 1))
|
|
||||||
temp_draw = ImageDraw.Draw(temp_img)
|
|
||||||
bbox = temp_draw.textbbox((0, 0), text, font=font)
|
|
||||||
text_width = bbox[2] - bbox[0]
|
|
||||||
text_height = bbox[3] - bbox[1]
|
|
||||||
|
|
||||||
# Create image with padding
|
|
||||||
padding = 10
|
|
||||||
img_width = max(text_width + padding * 2, 100)
|
|
||||||
img_height = max(text_height + padding * 2, 30)
|
|
||||||
|
|
||||||
# Validate resulting image size to prevent memory/CPU spikes
|
|
||||||
if img_width > MAX_DIM or img_height > MAX_DIM:
|
|
||||||
return jsonify({'status': 'error', 'message': 'Requested image too large'}), 400
|
|
||||||
if img_width * img_height > MAX_PIXELS:
|
|
||||||
return jsonify({'status': 'error', 'message': 'Requested image too large'}), 400
|
|
||||||
|
|
||||||
img = Image.new('RGB', (img_width, img_height), bg_rgb)
|
|
||||||
draw = ImageDraw.Draw(img)
|
|
||||||
|
|
||||||
# Center text
|
|
||||||
x = (img_width - text_width) // 2
|
|
||||||
y = (img_height - text_height) // 2
|
|
||||||
|
|
||||||
draw.text((x, y), text, font=font, fill=fg_rgb)
|
|
||||||
|
|
||||||
# Convert to base64
|
|
||||||
buffer = io.BytesIO()
|
|
||||||
img.save(buffer, format='PNG')
|
|
||||||
buffer.seek(0)
|
|
||||||
img_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
|
|
||||||
|
|
||||||
|
# Load font
|
||||||
|
font = None
|
||||||
|
if str(font_path).endswith('.bdf'):
|
||||||
|
# BDF fonts require complex per-glyph rendering via freetype
|
||||||
|
# Return explicit error rather than showing misleading preview with default font
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'success',
|
'status': 'error',
|
||||||
'data': {
|
'message': 'BDF font preview not supported. BDF fonts will render correctly on the LED matrix.'
|
||||||
'image': f'data:image/png;base64,{img_base64}',
|
}), 400
|
||||||
'width': img_width,
|
else:
|
||||||
'height': img_height
|
# TTF/OTF fonts
|
||||||
}
|
try:
|
||||||
})
|
font = ImageFont.truetype(str(font_path), size)
|
||||||
except Exception as e:
|
except (IOError, OSError) as e:
|
||||||
logger.error('Unhandled exception', exc_info=True)
|
# IOError/OSError raised for invalid/corrupt font files
|
||||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
logger.warning("[FontPreview] Failed to load font %s: %s", font_path, e)
|
||||||
|
font = ImageFont.load_default()
|
||||||
|
|
||||||
|
# Calculate text size
|
||||||
|
temp_img = Image.new('RGB', (1, 1))
|
||||||
|
temp_draw = ImageDraw.Draw(temp_img)
|
||||||
|
bbox = temp_draw.textbbox((0, 0), text, font=font)
|
||||||
|
text_width = bbox[2] - bbox[0]
|
||||||
|
text_height = bbox[3] - bbox[1]
|
||||||
|
|
||||||
|
# Create image with padding
|
||||||
|
padding = 10
|
||||||
|
img_width = max(text_width + padding * 2, 100)
|
||||||
|
img_height = max(text_height + padding * 2, 30)
|
||||||
|
|
||||||
|
# Validate resulting image size to prevent memory/CPU spikes
|
||||||
|
if img_width > MAX_DIM or img_height > MAX_DIM:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Requested image too large'}), 400
|
||||||
|
if img_width * img_height > MAX_PIXELS:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Requested image too large'}), 400
|
||||||
|
|
||||||
|
img = Image.new('RGB', (img_width, img_height), bg_rgb)
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
# Center text
|
||||||
|
x = (img_width - text_width) // 2
|
||||||
|
y = (img_height - text_height) // 2
|
||||||
|
|
||||||
|
draw.text((x, y), text, font=font, fill=fg_rgb)
|
||||||
|
|
||||||
|
# Convert to base64
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
img.save(buffer, format='PNG')
|
||||||
|
buffer.seek(0)
|
||||||
|
img_base64 = base64.b64encode(buffer.getvalue()).decode('utf-8')
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'data': {
|
||||||
|
'image': f'data:image/png;base64,{img_base64}',
|
||||||
|
'width': img_width,
|
||||||
|
'height': img_height
|
||||||
|
}
|
||||||
|
})
|
||||||
@api_v3.route('/fonts/<font_family>', methods=['DELETE'])
|
@api_v3.route('/fonts/<font_family>', methods=['DELETE'])
|
||||||
def delete_font(font_family: str) -> tuple[Response, int] | Response:
|
def delete_font(font_family: str) -> tuple[Response, int] | Response:
|
||||||
"""Delete a user-uploaded font file"""
|
"""Delete a user-uploaded font file"""
|
||||||
try:
|
# Security: Validate font_family to prevent path traversal
|
||||||
# Security: Validate font_family to prevent path traversal
|
# Reject if it contains path separators or ..
|
||||||
# Reject if it contains path separators or ..
|
if '..' in font_family or '/' in font_family or '\\' in font_family:
|
||||||
if '..' in font_family or '/' in font_family or '\\' in font_family:
|
return jsonify({'status': 'error', 'message': 'Invalid font family name'}), 400
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid font family name'}), 400
|
|
||||||
|
|
||||||
# Only allow safe characters: alphanumeric, hyphen, underscore, dot
|
# Only allow safe characters: alphanumeric, hyphen, underscore, dot
|
||||||
if not re.match(r'^[a-zA-Z0-9_\-\.]+$', font_family):
|
if not re.match(r'^[a-zA-Z0-9_\-\.]+$', font_family):
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid font family name'}), 400
|
return jsonify({'status': 'error', 'message': 'Invalid font family name'}), 400
|
||||||
|
|
||||||
# Check if this is a system font (uses module-level SYSTEM_FONTS frozenset)
|
# Check if this is a system font (uses module-level SYSTEM_FONTS frozenset)
|
||||||
if font_family.lower() in SYSTEM_FONTS:
|
if font_family.lower() in SYSTEM_FONTS:
|
||||||
return jsonify({'status': 'error', 'message': 'Cannot delete system fonts'}), 403
|
return jsonify({'status': 'error', 'message': 'Cannot delete system fonts'}), 403
|
||||||
|
|
||||||
# Find and delete the font file
|
# Find and delete the font file
|
||||||
fonts_dir = PROJECT_ROOT / "assets" / "fonts"
|
fonts_dir = PROJECT_ROOT / "assets" / "fonts"
|
||||||
|
|
||||||
# Ensure fonts directory exists
|
# Ensure fonts directory exists
|
||||||
if not fonts_dir.exists() or not fonts_dir.is_dir():
|
if not fonts_dir.exists() or not fonts_dir.is_dir():
|
||||||
return jsonify({'status': 'error', 'message': 'Fonts directory not found'}), 404
|
return jsonify({'status': 'error', 'message': 'Fonts directory not found'}), 404
|
||||||
|
|
||||||
deleted = False
|
deleted = False
|
||||||
deleted_filename = None
|
deleted_filename = None
|
||||||
|
|
||||||
# Only try valid font extensions (no empty string to avoid matching directories)
|
# Only try valid font extensions (no empty string to avoid matching directories)
|
||||||
for ext in ['.ttf', '.otf', '.bdf']:
|
for ext in ['.ttf', '.otf', '.bdf']:
|
||||||
potential_path = fonts_dir / f"{font_family}{ext}"
|
potential_path = fonts_dir / f"{font_family}{ext}"
|
||||||
|
|
||||||
# Security: Verify path is within fonts_dir
|
# Security: Verify path is within fonts_dir
|
||||||
try:
|
|
||||||
potential_path.resolve().relative_to(fonts_dir.resolve())
|
|
||||||
except ValueError:
|
|
||||||
continue # Path escapes fonts_dir, skip
|
|
||||||
|
|
||||||
if potential_path.exists() and potential_path.is_file():
|
|
||||||
potential_path.unlink()
|
|
||||||
deleted = True
|
|
||||||
deleted_filename = f"{font_family}{ext}"
|
|
||||||
break
|
|
||||||
|
|
||||||
if not deleted:
|
|
||||||
# Try case-insensitive match within fonts directory
|
|
||||||
font_family_lower = font_family.lower()
|
|
||||||
for filename in os.listdir(fonts_dir):
|
|
||||||
# Only consider files with valid font extensions
|
|
||||||
if not any(filename.lower().endswith(ext) for ext in ['.ttf', '.otf', '.bdf']):
|
|
||||||
continue
|
|
||||||
|
|
||||||
name_without_ext = os.path.splitext(filename)[0]
|
|
||||||
if name_without_ext.lower() == font_family_lower:
|
|
||||||
filepath = fonts_dir / filename
|
|
||||||
|
|
||||||
# Security: Verify path is within fonts_dir
|
|
||||||
try:
|
|
||||||
filepath.resolve().relative_to(fonts_dir.resolve())
|
|
||||||
except ValueError:
|
|
||||||
continue # Path escapes fonts_dir, skip
|
|
||||||
|
|
||||||
if filepath.is_file():
|
|
||||||
filepath.unlink()
|
|
||||||
deleted = True
|
|
||||||
deleted_filename = filename
|
|
||||||
break
|
|
||||||
|
|
||||||
if not deleted:
|
|
||||||
return jsonify({'status': 'error', 'message': f'Font not found: {font_family}'}), 404
|
|
||||||
|
|
||||||
# Clear font catalog cache
|
|
||||||
try:
|
try:
|
||||||
from web_interface.cache import delete_cached
|
potential_path.resolve().relative_to(fonts_dir.resolve())
|
||||||
delete_cached('fonts_catalog')
|
except ValueError:
|
||||||
except ImportError as e:
|
continue # Path escapes fonts_dir, skip
|
||||||
logger.warning("[FontDelete] Cache module not available: %s", e)
|
|
||||||
except Exception:
|
|
||||||
logger.error("[FontDelete] Failed to clear fonts_catalog cache", exc_info=True)
|
|
||||||
|
|
||||||
return jsonify({
|
if potential_path.exists() and potential_path.is_file():
|
||||||
'status': 'success',
|
potential_path.unlink()
|
||||||
'message': f'Font {deleted_filename} deleted successfully'
|
deleted = True
|
||||||
})
|
deleted_filename = f"{font_family}{ext}"
|
||||||
except Exception as e:
|
break
|
||||||
logger.error('Unhandled exception', exc_info=True)
|
|
||||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
if not deleted:
|
||||||
|
# Try case-insensitive match within fonts directory
|
||||||
|
font_family_lower = font_family.lower()
|
||||||
|
for filename in os.listdir(fonts_dir):
|
||||||
|
# Only consider files with valid font extensions
|
||||||
|
if not any(filename.lower().endswith(ext) for ext in ['.ttf', '.otf', '.bdf']):
|
||||||
|
continue
|
||||||
|
|
||||||
|
name_without_ext = os.path.splitext(filename)[0]
|
||||||
|
if name_without_ext.lower() == font_family_lower:
|
||||||
|
filepath = fonts_dir / filename
|
||||||
|
|
||||||
|
# Security: Verify path is within fonts_dir
|
||||||
|
try:
|
||||||
|
filepath.resolve().relative_to(fonts_dir.resolve())
|
||||||
|
except ValueError:
|
||||||
|
continue # Path escapes fonts_dir, skip
|
||||||
|
|
||||||
|
if filepath.is_file():
|
||||||
|
filepath.unlink()
|
||||||
|
deleted = True
|
||||||
|
deleted_filename = filename
|
||||||
|
break
|
||||||
|
|
||||||
|
if not deleted:
|
||||||
|
return jsonify({'status': 'error', 'message': f'Font not found: {font_family}'}), 404
|
||||||
|
|
||||||
|
# Clear font catalog cache
|
||||||
|
try:
|
||||||
|
from web_interface.cache import delete_cached
|
||||||
|
delete_cached('fonts_catalog')
|
||||||
|
except ImportError as e:
|
||||||
|
logger.warning("[FontDelete] Cache module not available: %s", e)
|
||||||
|
except Exception:
|
||||||
|
logger.error("[FontDelete] Failed to clear fonts_catalog cache", exc_info=True)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': f'Font {deleted_filename} deleted successfully'
|
||||||
|
})
|
||||||
|
|||||||
@@ -186,13 +186,6 @@ def get_logs():
|
|||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'Timeout while fetching logs'
|
'message': 'Timeout while fetching logs'
|
||||||
}), 500
|
}), 500
|
||||||
except Exception as e:
|
|
||||||
logger.error("%s failed", request.path, exc_info=True)
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': 'An error occurred; see logs for details',
|
|
||||||
'details': describe_exception(e)
|
|
||||||
}), 500
|
|
||||||
# Multi-Display Sync Endpoints
|
# Multi-Display Sync Endpoints
|
||||||
@api_v3.route('/sync/status', methods=['GET'])
|
@api_v3.route('/sync/status', methods=['GET'])
|
||||||
def get_sync_status():
|
def get_sync_status():
|
||||||
@@ -231,57 +224,49 @@ def get_sync_status():
|
|||||||
@api_v3.route('/cache/list', methods=['GET'])
|
@api_v3.route('/cache/list', methods=['GET'])
|
||||||
def list_cache_files():
|
def list_cache_files():
|
||||||
"""List all cache files with metadata"""
|
"""List all cache files with metadata"""
|
||||||
try:
|
if not api_v3.cache_manager:
|
||||||
if not api_v3.cache_manager:
|
# Initialize cache manager if not already initialized
|
||||||
# Initialize cache manager if not already initialized
|
from src.cache_manager import CacheManager
|
||||||
from src.cache_manager import CacheManager
|
api_v3.cache_manager = CacheManager()
|
||||||
api_v3.cache_manager = CacheManager()
|
|
||||||
|
|
||||||
cache_files = api_v3.cache_manager.list_cache_files()
|
cache_files = api_v3.cache_manager.list_cache_files()
|
||||||
cache_dir = api_v3.cache_manager.get_cache_dir()
|
cache_dir = api_v3.cache_manager.get_cache_dir()
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'data': {
|
'data': {
|
||||||
'cache_files': cache_files,
|
'cache_files': cache_files,
|
||||||
'cache_dir': cache_dir,
|
'cache_dir': cache_dir,
|
||||||
'total_files': len(cache_files)
|
'total_files': len(cache_files)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
except Exception as e:
|
|
||||||
logger.error('Error in list_cache_files', exc_info=True)
|
|
||||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
|
||||||
@api_v3.route('/cache/delete', methods=['POST'])
|
@api_v3.route('/cache/delete', methods=['POST'])
|
||||||
def delete_cache_file():
|
def delete_cache_file():
|
||||||
"""Delete a specific cache file by key"""
|
"""Delete a specific cache file by key"""
|
||||||
try:
|
if not api_v3.cache_manager:
|
||||||
if not api_v3.cache_manager:
|
# Initialize cache manager if not already initialized
|
||||||
# Initialize cache manager if not already initialized
|
from src.cache_manager import CacheManager
|
||||||
from src.cache_manager import CacheManager
|
api_v3.cache_manager = CacheManager()
|
||||||
api_v3.cache_manager = CacheManager()
|
|
||||||
|
|
||||||
data = request.get_json(silent=True)
|
data = request.get_json(silent=True)
|
||||||
if not data or 'key' not in data:
|
if not data or 'key' not in data:
|
||||||
return jsonify({'status': 'error', 'message': 'cache key is required'}), 400
|
return jsonify({'status': 'error', 'message': 'cache key is required'}), 400
|
||||||
|
|
||||||
cache_key = data['key']
|
cache_key = data['key']
|
||||||
|
|
||||||
# The key names the file about to be removed. DiskCache refuses an
|
# The key names the file about to be removed. DiskCache refuses an
|
||||||
# unusable key on its own, but silently: say so here instead of
|
# unusable key on its own, but silently: say so here instead of
|
||||||
# reporting a deletion that never happened.
|
# reporting a deletion that never happened.
|
||||||
if safe_path_component(cache_key) is None:
|
if safe_path_component(cache_key) is None:
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid cache key'}), 400
|
return jsonify({'status': 'error', 'message': 'Invalid cache key'}), 400
|
||||||
|
|
||||||
# Delete the cache file
|
# Delete the cache file
|
||||||
api_v3.cache_manager.clear_cache(cache_key)
|
api_v3.cache_manager.clear_cache(cache_key)
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'message': f'Cache file for key "{cache_key}" deleted successfully'
|
'message': f'Cache file for key "{cache_key}" deleted successfully'
|
||||||
})
|
})
|
||||||
except Exception as e:
|
|
||||||
logger.error('Error in delete_cache_file', exc_info=True)
|
|
||||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
|
||||||
def _errors_cache():
|
def _errors_cache():
|
||||||
"""The shared cache the display service publishes its errors to."""
|
"""The shared cache the display service publishes its errors to."""
|
||||||
if not api_v3.cache_manager:
|
if not api_v3.cache_manager:
|
||||||
|
|||||||
+1064
-1231
File diff suppressed because it is too large
Load Diff
@@ -24,95 +24,91 @@ import web_interface.blueprints.api_v3 as _pkg
|
|||||||
@api_v3.route('/system/status', methods=['GET'])
|
@api_v3.route('/system/status', methods=['GET'])
|
||||||
def get_system_status():
|
def get_system_status():
|
||||||
"""Get system status"""
|
"""Get system status"""
|
||||||
|
# Check cache first (10 second TTL for system status)
|
||||||
try:
|
try:
|
||||||
# Check cache first (10 second TTL for system status)
|
from web_interface.cache import get_cached, set_cached
|
||||||
try:
|
cached_result = get_cached('system_status', ttl_seconds=10)
|
||||||
from web_interface.cache import get_cached, set_cached
|
if cached_result is not None:
|
||||||
cached_result = get_cached('system_status', ttl_seconds=10)
|
return jsonify({'status': 'success', 'data': cached_result})
|
||||||
if cached_result is not None:
|
except ImportError:
|
||||||
return jsonify({'status': 'success', 'data': cached_result})
|
# Cache not available, continue without caching
|
||||||
except ImportError:
|
get_cached = None
|
||||||
# Cache not available, continue without caching
|
set_cached = None
|
||||||
get_cached = None
|
|
||||||
set_cached = None
|
|
||||||
|
|
||||||
# Import psutil for system monitoring
|
# Import psutil for system monitoring
|
||||||
try:
|
try:
|
||||||
import psutil
|
import psutil
|
||||||
except ImportError:
|
except ImportError:
|
||||||
# Fallback if psutil not available
|
# Fallback if psutil not available
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'psutil not available for system monitoring'
|
'message': 'psutil not available for system monitoring'
|
||||||
}), 503
|
}), 503
|
||||||
|
|
||||||
# Get system metrics using psutil
|
# Get system metrics using psutil
|
||||||
cpu_percent = psutil.cpu_percent(interval=0.1) # Short interval for responsiveness
|
cpu_percent = psutil.cpu_percent(interval=0.1) # Short interval for responsiveness
|
||||||
memory = psutil.virtual_memory()
|
memory = psutil.virtual_memory()
|
||||||
memory_percent = memory.percent
|
memory_percent = memory.percent
|
||||||
disk = psutil.disk_usage('/')
|
disk = psutil.disk_usage('/')
|
||||||
disk_percent = disk.percent
|
disk_percent = disk.percent
|
||||||
|
|
||||||
# Calculate uptime
|
# Calculate uptime
|
||||||
boot_time = psutil.boot_time()
|
boot_time = psutil.boot_time()
|
||||||
uptime_seconds = _pkg.time.time() - boot_time
|
uptime_seconds = _pkg.time.time() - boot_time
|
||||||
uptime_hours = uptime_seconds / 3600
|
uptime_hours = uptime_seconds / 3600
|
||||||
uptime_days = uptime_hours / 24
|
uptime_days = uptime_hours / 24
|
||||||
|
|
||||||
# Format uptime string
|
# Format uptime string
|
||||||
if uptime_days >= 1:
|
if uptime_days >= 1:
|
||||||
uptime_str = f"{int(uptime_days)}d {int(uptime_hours % 24)}h"
|
uptime_str = f"{int(uptime_days)}d {int(uptime_hours % 24)}h"
|
||||||
elif uptime_hours >= 1:
|
elif uptime_hours >= 1:
|
||||||
uptime_str = f"{int(uptime_hours)}h {int((uptime_seconds % 3600) / 60)}m"
|
uptime_str = f"{int(uptime_hours)}h {int((uptime_seconds % 3600) / 60)}m"
|
||||||
else:
|
else:
|
||||||
uptime_str = f"{int(uptime_seconds / 60)}m"
|
uptime_str = f"{int(uptime_seconds / 60)}m"
|
||||||
|
|
||||||
# Get CPU temperature (Raspberry Pi)
|
# Get CPU temperature (Raspberry Pi)
|
||||||
|
cpu_temp = None
|
||||||
|
try:
|
||||||
|
temp_file = '/sys/class/thermal/thermal_zone0/temp'
|
||||||
|
if os.path.exists(temp_file):
|
||||||
|
with open(temp_file, 'r') as f:
|
||||||
|
temp_millidegrees = int(f.read().strip())
|
||||||
|
cpu_temp = temp_millidegrees / 1000.0 # Convert to Celsius
|
||||||
|
except (IOError, ValueError, OSError):
|
||||||
|
# Temperature sensor not available or error reading
|
||||||
cpu_temp = None
|
cpu_temp = None
|
||||||
|
|
||||||
|
# Get display service status
|
||||||
|
service_status = _get_display_service_status()
|
||||||
|
|
||||||
|
status = {
|
||||||
|
'timestamp': _pkg.time.time(),
|
||||||
|
'uptime': uptime_str,
|
||||||
|
'uptime_seconds': int(uptime_seconds),
|
||||||
|
'service_active': service_status.get('active', False),
|
||||||
|
'cpu_percent': round(cpu_percent, 1),
|
||||||
|
'memory_used_percent': round(memory_percent, 1),
|
||||||
|
'memory_total_mb': round(memory.total / (1024 * 1024), 1),
|
||||||
|
'memory_used_mb': round(memory.used / (1024 * 1024), 1),
|
||||||
|
# MemAvailable, not total-minus-used: it accounts for reclaimable
|
||||||
|
# page cache, so it is what actually predicts memory trouble. A
|
||||||
|
# board can read 70% "used" and be fine, or read the same and be
|
||||||
|
# about to fail fork(), and only this number tells them apart.
|
||||||
|
'memory_available_mb': round(memory.available / (1024 * 1024), 1),
|
||||||
|
'cpu_temp': round(cpu_temp, 1) if cpu_temp is not None else None,
|
||||||
|
'disk_used_percent': round(disk_percent, 1),
|
||||||
|
'disk_total_gb': round(disk.total / (1024 * 1024 * 1024), 1),
|
||||||
|
'disk_used_gb': round(disk.used / (1024 * 1024 * 1024), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache the result if available
|
||||||
|
if set_cached:
|
||||||
try:
|
try:
|
||||||
temp_file = '/sys/class/thermal/thermal_zone0/temp'
|
set_cached('system_status', status, ttl_seconds=10)
|
||||||
if os.path.exists(temp_file):
|
except Exception:
|
||||||
with open(temp_file, 'r') as f:
|
pass # Cache write failed, but continue
|
||||||
temp_millidegrees = int(f.read().strip())
|
|
||||||
cpu_temp = temp_millidegrees / 1000.0 # Convert to Celsius
|
|
||||||
except (IOError, ValueError, OSError):
|
|
||||||
# Temperature sensor not available or error reading
|
|
||||||
cpu_temp = None
|
|
||||||
|
|
||||||
# Get display service status
|
return jsonify({'status': 'success', 'data': status})
|
||||||
service_status = _get_display_service_status()
|
|
||||||
|
|
||||||
status = {
|
|
||||||
'timestamp': _pkg.time.time(),
|
|
||||||
'uptime': uptime_str,
|
|
||||||
'uptime_seconds': int(uptime_seconds),
|
|
||||||
'service_active': service_status.get('active', False),
|
|
||||||
'cpu_percent': round(cpu_percent, 1),
|
|
||||||
'memory_used_percent': round(memory_percent, 1),
|
|
||||||
'memory_total_mb': round(memory.total / (1024 * 1024), 1),
|
|
||||||
'memory_used_mb': round(memory.used / (1024 * 1024), 1),
|
|
||||||
# MemAvailable, not total-minus-used: it accounts for reclaimable
|
|
||||||
# page cache, so it is what actually predicts memory trouble. A
|
|
||||||
# board can read 70% "used" and be fine, or read the same and be
|
|
||||||
# about to fail fork(), and only this number tells them apart.
|
|
||||||
'memory_available_mb': round(memory.available / (1024 * 1024), 1),
|
|
||||||
'cpu_temp': round(cpu_temp, 1) if cpu_temp is not None else None,
|
|
||||||
'disk_used_percent': round(disk_percent, 1),
|
|
||||||
'disk_total_gb': round(disk.total / (1024 * 1024 * 1024), 1),
|
|
||||||
'disk_used_gb': round(disk.used / (1024 * 1024 * 1024), 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
# Cache the result if available
|
|
||||||
if set_cached:
|
|
||||||
try:
|
|
||||||
set_cached('system_status', status, ttl_seconds=10)
|
|
||||||
except Exception:
|
|
||||||
pass # Cache write failed, but continue
|
|
||||||
|
|
||||||
return jsonify({'status': 'success', 'data': status})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error('Unhandled exception', exc_info=True)
|
|
||||||
return jsonify({'status': 'error', 'message': 'An error occurred; see logs for details', 'details': describe_exception(e)}), 500
|
|
||||||
@api_v3.route('/system/version', methods=['GET'])
|
@api_v3.route('/system/version', methods=['GET'])
|
||||||
def get_system_version():
|
def get_system_version():
|
||||||
"""Get LEDMatrix repository version"""
|
"""Get LEDMatrix repository version"""
|
||||||
|
|||||||
@@ -111,34 +111,26 @@ def _parse_bool_ish(value):
|
|||||||
@api_v3.route('/wifi/status', methods=['GET'])
|
@api_v3.route('/wifi/status', methods=['GET'])
|
||||||
def get_wifi_status():
|
def get_wifi_status():
|
||||||
"""Get current WiFi connection status"""
|
"""Get current WiFi connection status"""
|
||||||
try:
|
from src.wifi_manager import WiFiManager
|
||||||
from src.wifi_manager import WiFiManager
|
|
||||||
|
|
||||||
wifi_manager = WiFiManager()
|
wifi_manager = WiFiManager()
|
||||||
status = wifi_manager.get_wifi_status()
|
status = wifi_manager.get_wifi_status()
|
||||||
|
|
||||||
# Get auto-enable setting from config
|
# Get auto-enable setting from config
|
||||||
auto_enable_ap = wifi_manager.config.get("auto_enable_ap_mode", True) # Default: True (safe due to grace period)
|
auto_enable_ap = wifi_manager.config.get("auto_enable_ap_mode", True) # Default: True (safe due to grace period)
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'data': {
|
'data': {
|
||||||
'connected': status.connected,
|
'connected': status.connected,
|
||||||
'ssid': status.ssid,
|
'ssid': status.ssid,
|
||||||
'ip_address': status.ip_address,
|
'ip_address': status.ip_address,
|
||||||
'signal': status.signal,
|
'signal': status.signal,
|
||||||
'ap_mode_active': status.ap_mode_active,
|
'ap_mode_active': status.ap_mode_active,
|
||||||
'auto_enable_ap_mode': auto_enable_ap,
|
'auto_enable_ap_mode': auto_enable_ap,
|
||||||
'last_connect_attempt': _last_connect_snapshot(),
|
'last_connect_attempt': _last_connect_snapshot(),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
except Exception as e:
|
|
||||||
logger.error("%s failed", request.path, exc_info=True)
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': 'An error occurred; see logs for details',
|
|
||||||
'details': describe_exception(e)
|
|
||||||
}), 500
|
|
||||||
@api_v3.route('/wifi/scan', methods=['GET'])
|
@api_v3.route('/wifi/scan', methods=['GET'])
|
||||||
def scan_wifi_networks():
|
def scan_wifi_networks():
|
||||||
"""Scan for available WiFi networks
|
"""Scan for available WiFi networks
|
||||||
@@ -219,241 +211,188 @@ def connect_wifi():
|
|||||||
background (see _last_connect_attempt); otherwise it waits for the result.
|
background (see _last_connect_attempt); otherwise it waits for the result.
|
||||||
"""
|
"""
|
||||||
global _last_connect_attempt
|
global _last_connect_attempt
|
||||||
try:
|
from src.wifi_manager import WiFiManager
|
||||||
from src.wifi_manager import WiFiManager
|
|
||||||
|
|
||||||
data = request.get_json(silent=True)
|
data = request.get_json(silent=True)
|
||||||
if not data:
|
if not data:
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': 'Request body is required'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
if 'ssid' not in data:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': 'SSID is required'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
ssid = data['ssid']
|
|
||||||
if not ssid or not ssid.strip():
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': 'SSID cannot be empty'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
ssid = ssid.strip()
|
|
||||||
password = data.get('password', '') or ''
|
|
||||||
|
|
||||||
wifi_manager = WiFiManager()
|
|
||||||
ap_mode_active = wifi_manager._is_ap_mode_active()
|
|
||||||
|
|
||||||
# One attempt at a time on either path: concurrent connects fight over
|
|
||||||
# the radio, and the first to finish would clear the in-progress flag
|
|
||||||
# the monitor daemon still needs for the other. The check can't depend
|
|
||||||
# on AP state either -- a background attempt takes the AP down long
|
|
||||||
# before it finishes.
|
|
||||||
with _connect_lock:
|
|
||||||
if _last_connect_attempt and _last_connect_attempt['state'] == 'pending':
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': f"Already connecting to {_last_connect_attempt['ssid']}"
|
|
||||||
}), 409
|
|
||||||
_last_connect_attempt = {
|
|
||||||
'ssid': ssid, 'state': 'pending', 'message': None,
|
|
||||||
'error_type': None, 'finished_at': None,
|
|
||||||
}
|
|
||||||
|
|
||||||
if ap_mode_active:
|
|
||||||
try:
|
|
||||||
_spawn(lambda: _run_background_connect(ssid, password))
|
|
||||||
except Exception:
|
|
||||||
# Nothing will ever finish this attempt; don't leave every
|
|
||||||
# later request refused.
|
|
||||||
with _connect_lock:
|
|
||||||
_last_connect_attempt = None
|
|
||||||
raise
|
|
||||||
return jsonify({
|
|
||||||
'status': 'pending',
|
|
||||||
'message': (
|
|
||||||
f'Connecting to {ssid}. The LEDMatrix-Setup network will turn off, '
|
|
||||||
'so this page will lose its connection.'
|
|
||||||
),
|
|
||||||
'data': {'ssid': ssid},
|
|
||||||
}), 202
|
|
||||||
|
|
||||||
try:
|
|
||||||
success, message = wifi_manager.connect_to_network(ssid, password)
|
|
||||||
except Exception as e:
|
|
||||||
_record_connect_result(ssid, {'status': 'error', 'message': describe_exception(e)})
|
|
||||||
raise
|
|
||||||
payload = _connect_result_payload(ssid, success, message)
|
|
||||||
_record_connect_result(ssid, payload)
|
|
||||||
return jsonify(payload), (200 if success else 400)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Error connecting to WiFi", exc_info=True)
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'An error occurred; see logs for details', 'details': describe_exception(e)
|
'message': 'Request body is required'
|
||||||
}), 500
|
}), 400
|
||||||
|
|
||||||
|
if 'ssid' not in data:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'SSID is required'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
ssid = data['ssid']
|
||||||
|
if not ssid or not ssid.strip():
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'SSID cannot be empty'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
ssid = ssid.strip()
|
||||||
|
password = data.get('password', '') or ''
|
||||||
|
|
||||||
|
wifi_manager = WiFiManager()
|
||||||
|
ap_mode_active = wifi_manager._is_ap_mode_active()
|
||||||
|
|
||||||
|
# One attempt at a time on either path: concurrent connects fight over
|
||||||
|
# the radio, and the first to finish would clear the in-progress flag
|
||||||
|
# the monitor daemon still needs for the other. The check can't depend
|
||||||
|
# on AP state either -- a background attempt takes the AP down long
|
||||||
|
# before it finishes.
|
||||||
|
with _connect_lock:
|
||||||
|
if _last_connect_attempt and _last_connect_attempt['state'] == 'pending':
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f"Already connecting to {_last_connect_attempt['ssid']}"
|
||||||
|
}), 409
|
||||||
|
_last_connect_attempt = {
|
||||||
|
'ssid': ssid, 'state': 'pending', 'message': None,
|
||||||
|
'error_type': None, 'finished_at': None,
|
||||||
|
}
|
||||||
|
|
||||||
|
if ap_mode_active:
|
||||||
|
try:
|
||||||
|
_spawn(lambda: _run_background_connect(ssid, password))
|
||||||
|
except Exception:
|
||||||
|
# Nothing will ever finish this attempt; don't leave every
|
||||||
|
# later request refused.
|
||||||
|
with _connect_lock:
|
||||||
|
_last_connect_attempt = None
|
||||||
|
raise
|
||||||
|
return jsonify({
|
||||||
|
'status': 'pending',
|
||||||
|
'message': (
|
||||||
|
f'Connecting to {ssid}. The LEDMatrix-Setup network will turn off, '
|
||||||
|
'so this page will lose its connection.'
|
||||||
|
),
|
||||||
|
'data': {'ssid': ssid},
|
||||||
|
}), 202
|
||||||
|
|
||||||
|
try:
|
||||||
|
success, message = wifi_manager.connect_to_network(ssid, password)
|
||||||
|
except Exception as e:
|
||||||
|
_record_connect_result(ssid, {'status': 'error', 'message': describe_exception(e)})
|
||||||
|
raise
|
||||||
|
payload = _connect_result_payload(ssid, success, message)
|
||||||
|
_record_connect_result(ssid, payload)
|
||||||
|
return jsonify(payload), (200 if success else 400)
|
||||||
@api_v3.route('/wifi/disconnect', methods=['POST'])
|
@api_v3.route('/wifi/disconnect', methods=['POST'])
|
||||||
def disconnect_wifi():
|
def disconnect_wifi():
|
||||||
"""Disconnect from the current WiFi network"""
|
"""Disconnect from the current WiFi network"""
|
||||||
try:
|
from src.wifi_manager import WiFiManager
|
||||||
from src.wifi_manager import WiFiManager
|
|
||||||
|
|
||||||
wifi_manager = WiFiManager()
|
wifi_manager = WiFiManager()
|
||||||
success, message = wifi_manager.disconnect_from_network()
|
success, message = wifi_manager.disconnect_from_network()
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'message': message
|
'message': message
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': message or 'Failed to disconnect from network'
|
|
||||||
}), 400
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Error disconnecting from WiFi", exc_info=True)
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'An error occurred; see logs for details', 'details': describe_exception(e)
|
'message': message or 'Failed to disconnect from network'
|
||||||
}), 500
|
}), 400
|
||||||
@api_v3.route('/wifi/ap/enable', methods=['POST'])
|
@api_v3.route('/wifi/ap/enable', methods=['POST'])
|
||||||
def enable_ap_mode():
|
def enable_ap_mode():
|
||||||
"""Enable access point mode"""
|
"""Enable access point mode"""
|
||||||
try:
|
from src.wifi_manager import WiFiManager
|
||||||
from src.wifi_manager import WiFiManager
|
|
||||||
|
|
||||||
wifi_manager = WiFiManager()
|
wifi_manager = WiFiManager()
|
||||||
_force_raw = (request.get_json(silent=True) or {}).get('force', False)
|
_force_raw = (request.get_json(silent=True) or {}).get('force', False)
|
||||||
force = _force_raw is True or (isinstance(_force_raw, str) and _force_raw.lower() in ('true', '1'))
|
force = _force_raw is True or (isinstance(_force_raw, str) and _force_raw.lower() in ('true', '1'))
|
||||||
success, message = wifi_manager.enable_ap_mode(force=force)
|
success, message = wifi_manager.enable_ap_mode(force=force)
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'message': message
|
'message': message
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': message
|
|
||||||
}), 400
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("%s failed", request.path, exc_info=True)
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'An error occurred; see logs for details',
|
'message': message
|
||||||
'details': describe_exception(e)
|
}), 400
|
||||||
}), 500
|
|
||||||
@api_v3.route('/wifi/ap/disable', methods=['POST'])
|
@api_v3.route('/wifi/ap/disable', methods=['POST'])
|
||||||
def disable_ap_mode():
|
def disable_ap_mode():
|
||||||
"""Disable access point mode"""
|
"""Disable access point mode"""
|
||||||
try:
|
from src.wifi_manager import WiFiManager
|
||||||
from src.wifi_manager import WiFiManager
|
|
||||||
|
|
||||||
wifi_manager = WiFiManager()
|
wifi_manager = WiFiManager()
|
||||||
success, message = wifi_manager.disable_ap_mode()
|
success, message = wifi_manager.disable_ap_mode()
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'message': message
|
'message': message
|
||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': message
|
|
||||||
}), 400
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("%s failed", request.path, exc_info=True)
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'An error occurred; see logs for details',
|
'message': message
|
||||||
'details': describe_exception(e)
|
}), 400
|
||||||
}), 500
|
|
||||||
@api_v3.route('/wifi/ap/auto-enable', methods=['GET'])
|
@api_v3.route('/wifi/ap/auto-enable', methods=['GET'])
|
||||||
def get_auto_enable_ap_mode():
|
def get_auto_enable_ap_mode():
|
||||||
"""Get auto-enable AP mode setting"""
|
"""Get auto-enable AP mode setting"""
|
||||||
try:
|
from src.wifi_manager import WiFiManager
|
||||||
from src.wifi_manager import WiFiManager
|
|
||||||
|
|
||||||
wifi_manager = WiFiManager()
|
wifi_manager = WiFiManager()
|
||||||
auto_enable = wifi_manager.config.get("auto_enable_ap_mode", True) # Default: True (safe due to grace period)
|
auto_enable = wifi_manager.config.get("auto_enable_ap_mode", True) # Default: True (safe due to grace period)
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'data': {
|
'data': {
|
||||||
'auto_enable_ap_mode': auto_enable
|
'auto_enable_ap_mode': auto_enable
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
except Exception as e:
|
|
||||||
logger.error("%s failed", request.path, exc_info=True)
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': 'An error occurred; see logs for details',
|
|
||||||
'details': describe_exception(e)
|
|
||||||
}), 500
|
|
||||||
@api_v3.route('/wifi/ap/auto-enable', methods=['POST'])
|
@api_v3.route('/wifi/ap/auto-enable', methods=['POST'])
|
||||||
def set_auto_enable_ap_mode():
|
def set_auto_enable_ap_mode():
|
||||||
"""Set auto-enable AP mode setting"""
|
"""Set auto-enable AP mode setting"""
|
||||||
try:
|
from src.wifi_manager import WiFiManager
|
||||||
from src.wifi_manager import WiFiManager
|
|
||||||
|
|
||||||
data = request.get_json(silent=True)
|
data = request.get_json(silent=True)
|
||||||
if data is None or 'auto_enable_ap_mode' not in data:
|
if data is None or 'auto_enable_ap_mode' not in data:
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': 'auto_enable_ap_mode is required'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
auto_enable = _parse_bool_ish(data['auto_enable_ap_mode'])
|
|
||||||
if auto_enable is None:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': 'auto_enable_ap_mode must be a boolean'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
wifi_manager = WiFiManager()
|
|
||||||
wifi_manager.config["auto_enable_ap_mode"] = auto_enable
|
|
||||||
wifi_manager._save_config()
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'message': f'Auto-enable AP mode set to {auto_enable}',
|
|
||||||
'data': {
|
|
||||||
'auto_enable_ap_mode': auto_enable
|
|
||||||
}
|
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("%s failed", request.path, exc_info=True)
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'An error occurred; see logs for details',
|
'message': 'auto_enable_ap_mode is required'
|
||||||
'details': describe_exception(e)
|
}), 400
|
||||||
}), 500
|
|
||||||
|
auto_enable = _parse_bool_ish(data['auto_enable_ap_mode'])
|
||||||
|
if auto_enable is None:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'auto_enable_ap_mode must be a boolean'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
wifi_manager = WiFiManager()
|
||||||
|
wifi_manager.config["auto_enable_ap_mode"] = auto_enable
|
||||||
|
wifi_manager._save_config()
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': f'Auto-enable AP mode set to {auto_enable}',
|
||||||
|
'data': {
|
||||||
|
'auto_enable_ap_mode': auto_enable
|
||||||
|
}
|
||||||
|
})
|
||||||
@api_v3.route('/wifi/radio', methods=['GET'])
|
@api_v3.route('/wifi/radio', methods=['GET'])
|
||||||
def get_wifi_radio():
|
def get_wifi_radio():
|
||||||
"""Get current WiFi radio state (enabled/disabled) and wired-fallback status."""
|
"""Get current WiFi radio state (enabled/disabled) and wired-fallback status."""
|
||||||
try:
|
from src.wifi_manager import WiFiManager
|
||||||
from src.wifi_manager import WiFiManager
|
|
||||||
|
|
||||||
wifi_manager = WiFiManager()
|
wifi_manager = WiFiManager()
|
||||||
state = wifi_manager.get_wifi_radio_state()
|
state = wifi_manager.get_wifi_radio_state()
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'success',
|
'status': 'success',
|
||||||
'data': state
|
'data': state
|
||||||
})
|
})
|
||||||
except Exception as e:
|
|
||||||
logger.error("Error getting WiFi radio state", exc_info=True)
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': 'An error occurred; see logs for details', 'details': describe_exception(e)
|
|
||||||
}), 500
|
|
||||||
@api_v3.route('/wifi/radio', methods=['POST'])
|
@api_v3.route('/wifi/radio', methods=['POST'])
|
||||||
def set_wifi_radio():
|
def set_wifi_radio():
|
||||||
"""Turn the WiFi radio on or off.
|
"""Turn the WiFi radio on or off.
|
||||||
@@ -462,53 +401,46 @@ def set_wifi_radio():
|
|||||||
unless Ethernet is connected or force=True, to avoid locking the user out
|
unless Ethernet is connected or force=True, to avoid locking the user out
|
||||||
of this web interface.
|
of this web interface.
|
||||||
"""
|
"""
|
||||||
try:
|
from src.wifi_manager import WiFiManager
|
||||||
from src.wifi_manager import WiFiManager
|
|
||||||
|
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
if 'enabled' not in data:
|
if 'enabled' not in data:
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': 'enabled is required'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
# Parse defensively: bool("false") is True and a plain int never
|
|
||||||
# matches `is True`, so `_parse_bool_ish` handles bool, string and
|
|
||||||
# int 1/0 — the endpoint is a public contract, not just the shipped
|
|
||||||
# UI (which always sends real JSON booleans). An unrecognized value
|
|
||||||
# must be rejected, not silently disable the radio: this is the
|
|
||||||
# route that can drop the caller's own connection to this interface.
|
|
||||||
enabled = _parse_bool_ish(data['enabled'])
|
|
||||||
if enabled is None:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': 'enabled must be a boolean'
|
|
||||||
}), 400
|
|
||||||
force = _parse_bool_ish(data.get('force', False))
|
|
||||||
if force is None:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': 'force must be a boolean'
|
|
||||||
}), 400
|
|
||||||
|
|
||||||
wifi_manager = WiFiManager()
|
|
||||||
success, message, reason = wifi_manager.set_wifi_radio(enabled, force=force)
|
|
||||||
|
|
||||||
if success:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'message': message,
|
|
||||||
'data': wifi_manager.get_wifi_radio_state()
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
return jsonify({
|
|
||||||
'status': 'error',
|
|
||||||
'message': message,
|
|
||||||
'reason': reason
|
|
||||||
}), 400
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Error setting WiFi radio state", exc_info=True)
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'An error occurred; see logs for details', 'details': describe_exception(e)
|
'message': 'enabled is required'
|
||||||
}), 500
|
}), 400
|
||||||
|
|
||||||
|
# Parse defensively: bool("false") is True and a plain int never
|
||||||
|
# matches `is True`, so `_parse_bool_ish` handles bool, string and
|
||||||
|
# int 1/0 — the endpoint is a public contract, not just the shipped
|
||||||
|
# UI (which always sends real JSON booleans). An unrecognized value
|
||||||
|
# must be rejected, not silently disable the radio: this is the
|
||||||
|
# route that can drop the caller's own connection to this interface.
|
||||||
|
enabled = _parse_bool_ish(data['enabled'])
|
||||||
|
if enabled is None:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'enabled must be a boolean'
|
||||||
|
}), 400
|
||||||
|
force = _parse_bool_ish(data.get('force', False))
|
||||||
|
if force is None:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'force must be a boolean'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
wifi_manager = WiFiManager()
|
||||||
|
success, message, reason = wifi_manager.set_wifi_radio(enabled, force=force)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'message': message,
|
||||||
|
'data': wifi_manager.get_wifi_radio_state()
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': message,
|
||||||
|
'reason': reason
|
||||||
|
}), 400
|
||||||
|
|||||||
Reference in New Issue
Block a user