diff --git a/amplipi/app.py b/amplipi/app.py index 7b37658b1..5d6a05d0c 100644 --- a/amplipi/app.py +++ b/amplipi/app.py @@ -657,6 +657,12 @@ def debug() -> models.DebugResponse: logging.exception("couldn't load debug file: {e}") return models.DebugResponse() + +@api.patch("/api/info/alerts/hide", response_model=List[models.Alert]) +def hide_alert(alert: models.Alert): + """Hide an Alert based on the Alert's message""" + return utils.hide_alert(message=alert.message) + # include all routes above diff --git a/amplipi/ctrl.py b/amplipi/ctrl.py index d4d79b2ab..36e167f04 100644 --- a/amplipi/ctrl.py +++ b/amplipi/ctrl.py @@ -252,7 +252,8 @@ def reinit(self, settings: models.AppSettings = models.AppSettings(), change_not version=utils.detect_version(), stream_types_available=amplipi.streams.stream_types_available(), extra_fields=utils.load_extra_fields(), - serial=str(self._serial) + serial=str(self._serial), + global_alerts=utils.load_alerts() ) for major, minor, ghash, dirty in self._rt.read_versions(): fw_info = models.FirmwareInfo(version=f'{major}.{minor}', git_hash=f'{ghash:x}', git_dirty=dirty) @@ -553,6 +554,7 @@ def _update_sys_info(self, throttled=True) -> None: self.status.info.connected_drives = self._connected_drives_cache.get(throttled) self.status.info.latest_release = self._latest_release_cache.get(throttled) self.status.info.access_key = auth.get_access_key("admin") if auth.user_access_key_set("admin") else "" + self.status.info.global_alerts = utils.load_alerts() def sync_stream_info(self) -> None: """Synchronize the stream list to the stream status""" diff --git a/amplipi/models.py b/amplipi/models.py index 11f1804af..06ce9dfc2 100644 --- a/amplipi/models.py +++ b/amplipi/models.py @@ -25,6 +25,7 @@ from types import SimpleNamespace from enum import Enum from pathlib import Path +import datetime # pylint: disable=no-name-in-module from pydantic import BaseSettings, BaseModel, Field @@ -1013,6 +1014,43 @@ class FirmwareInfo(BaseModel): git_dirty: bool = Field(default=False, description="True if local changes were made. Used for development.") +class AlertLevel(Enum): + """What color should the alert be as per the Mui style guide: https://mui.com/material-ui/react-alert/#severity""" + WARNING = "warning" + ERROR = "error" + INFO = "info" + SUCCESS = "success" + + +class Alert(BaseModel): + message: str + severity: AlertLevel = AlertLevel.ERROR + """What color should the alert be as per the Mui style guide: https://mui.com/material-ui/react-alert/#severity""" + hidden: bool = False + """Has this Alert been hidden by the user?""" + timestamp: datetime.datetime = Field( + default_factory=lambda: datetime.datetime.now(datetime.timezone.utc) + ) + + @property + def expired(self) -> bool: # Used to limit alerts to have only a single instance per week. If the state that caused the alert is still valid after a week, the same alert will be made. + return (datetime.datetime.now(datetime.timezone.utc) - self.timestamp) > datetime.timedelta(weeks=1) + + class Config: + schema_extra = { + 'examples': { + 'Example Alert': { + 'value': { + "message": "Writing data to the I2C bus has failed multiple times, please contact AmpliPi Support at mailto:support@micro-nova.com", + "severity": "error", + "hidden": False, + "timestamp": "2026-05-26T19:28:57.907099+00:00" + } + }, + } + } + + class Info(BaseModel): """ AmpliPi System information """ version: str = Field(description="software version") @@ -1033,6 +1071,7 @@ class Info(BaseModel): default=[], description='The stream types available on this particular appliance') extra_fields: Optional[Dict] = Field(default=None, description='Optional fields for customization') connected_drives: List[str] = Field(default=[], description='A list of all external drives connected') + global_alerts: List[Alert] = Field(default=[], description='A list of alerts to be shown to all users via the frontend global alert bar') class Config: schema_extra = { diff --git a/amplipi/rt.py b/amplipi/rt.py index 29571097a..8a3d5cc3c 100644 --- a/amplipi/rt.py +++ b/amplipi/rt.py @@ -30,7 +30,7 @@ from smbus2 import SMBus from serial import Serial -from amplipi import models # TODO: importing this takes ~0.5s, reduce +from amplipi import models, utils # TODO: importing this takes ~0.5s, reduce # TODO: move constants like this to their own file DEBUG_PREAMPS = False # print out preamp state after register write @@ -242,6 +242,8 @@ def new_preamp(self, addr: int): 0x4F, ] + write_byte_data_failures: int = 0 + def write_byte_data(self, preamp_addr, reg, data): assert preamp_addr in _DEV_ADDRS assert type(preamp_addr) == int @@ -262,8 +264,13 @@ def write_byte_data(self, preamp_addr, reg, data): try: time.sleep(0.001) # space out sequential calls to avoid bus errors self.bus.write_byte_data(preamp_addr, reg, data) - except Exception: + except Exception as e: + logger.exception(f"Writing preamp failed: {e}") time.sleep(0.001) + self.bus.close() + self.write_byte_data_failures += 1 + if self.write_byte_data_failures >= 1: + utils.add_alert("Writing data to the I2C bus has failed multiple times, please go to Settings -> Config -> Hardware Reset.\nIf you see this message again in a short time period, contact AmpliPi Support at support@micro-nova.com") self.bus = SMBus(1) self.bus.write_byte_data(preamp_addr, reg, data) diff --git a/amplipi/utils.py b/amplipi/utils.py index 6cfd5e66e..c4777673d 100644 --- a/amplipi/utils.py +++ b/amplipi/utils.py @@ -495,3 +495,88 @@ def clear_custom_configs(): os.remove(path) except Exception as e: logger.exception(f"failed to clear device configuration: {e}") + + +# Every alert(s) function was in ctrl.py, but due to many files needing access to the add_alert flow they had to be here in utils + + +def load_alerts() -> List[models.Alert]: + """Load the contents of the alerts file into memory as a dict. Automatically hides expired alerts before serving the list.""" + alert_file = f"{get_folder('config')}/alerts.json" + try: + with open(alert_file, 'r', encoding='utf-8') as file: + data = json.load(file) + + alerts: List[models.Alert] = [models.Alert(**item) for item in data] + for alert in alerts: + if alert.expired: + alert.hidden = True # Frontend can't see expired property, so autohide any expired alerts as to not have to close the same alert twice + return alerts + + except (FileNotFoundError, json.JSONDecodeError): + return [] + + except Exception as e: + logger.exception(e) + return [] + + +def select_alert(message: str, alerts: Optional[List[models.Alert]] = None) -> Optional[models.Alert]: + """ + Selects the most recent non-expired instance of a specific alert message. Takes two arguments: + message: the string that makes up the Alert's message + + alerts: An optional list of alerts, for use when you want the returned alert to be a pointer to the same alert in that instance of the list. + Generally useful when mutating an alert before saving the full list. + """ + if alerts is None: + alerts = load_alerts() + return next( + ( + item for item in alerts + if item.message == message and not item.expired + ), + None + ) + + +def add_alert(message: str, severity: models.AlertLevel = models.AlertLevel.ERROR) -> List[models.Alert]: + """ + Add an alert to the alerts file + If an existing alert has the same message as the new one, the new one will only be added if the previous same alert has expired + See amplipi.models.Alert for expiration flow + """ + alerts = load_alerts() + search = select_alert(message) + if search is None: + alert = models.Alert(message=message, severity=severity) + alerts.append(alert) + save_alerts(alerts) + return alerts # Only returns anything to make the unit test for the hide endpoint easier + + +def hide_alert(message: str) -> List[models.Alert]: + """Set the hidden bool of a given alert to True. Hidden alerts are not shown on the frontend.""" + alerts = load_alerts() + selected_alert = select_alert(message, alerts) + if selected_alert is not None: + selected_alert.hidden = True + save_alerts(alerts) + else: + add_alert("Alert not found, could not be hidden!") + logger.exception("Alert not found, could not be hidden!") + return alerts + + +def save_alerts(alerts: List[models.Alert]): + """Saves the given list of alerts to the alerts file at .config/amplipi/alerts.json""" + alert_file = f"{get_folder('config')}/alerts.json" + try: + with open(alert_file, 'w', encoding='utf-8') as file: + json.dump( + [json.loads(alert.json()) for alert in alerts], + file, + indent=2 + ) + except Exception as e: + logger.exception(e) diff --git a/docs/amplipi_api.yaml b/docs/amplipi_api.yaml index 7fa803ca7..23141b9bb 100644 --- a/docs/amplipi_api.yaml +++ b/docs/amplipi_api.yaml @@ -2785,18 +2785,18 @@ paths: name: sid in: path examples: - Input 1: + Output 1: value: 0 - summary: Input 1 - Input 2: + summary: Output 1 + Output 2: value: 1 - summary: Input 2 - Input 3: + summary: Output 2 + Output 3: value: 2 - summary: Input 3 - Input 4: + summary: Output 3 + Output 4: value: 3 - summary: Input 4 + summary: Output 4 responses: '200': description: Successful Response @@ -2862,18 +2862,18 @@ paths: name: sid in: path examples: - Input 1: + Output 1: value: 0 - summary: Input 1 - Input 2: + summary: Output 1 + Output 2: value: 1 - summary: Input 2 - Input 3: + summary: Output 2 + Output 3: value: 2 - summary: Input 3 - Input 4: + summary: Output 3 + Output 4: value: 3 - summary: Input 4 + summary: Output 4 requestBody: content: application/json: @@ -3335,18 +3335,18 @@ paths: name: sid in: path examples: - Input 1: + Output 1: value: 0 - summary: Input 1 - Input 2: + summary: Output 1 + Output 2: value: 1 - summary: Input 2 - Input 3: + summary: Output 2 + Output 3: value: 2 - summary: Input 3 - Input 4: + summary: Output 3 + Output 4: value: 3 - summary: Input 4 + summary: Output 4 - description: Image Height in pixels required: true schema: @@ -7486,7 +7486,7 @@ paths: value: 999 summary: Input 4 - rca Groove Salad: - value: 1000 + value: 1003 summary: Groove Salad - internetradio - description: ID of the browsable item to browse required: true @@ -7567,7 +7567,7 @@ paths: value: 999 summary: Input 4 - rca Groove Salad: - value: 1000 + value: 1003 summary: Groove Salad - internetradio requestBody: content: @@ -7648,7 +7648,7 @@ paths: value: 999 summary: Input 4 - rca Groove Salad: - value: 1000 + value: 1003 summary: Groove Salad - internetradio requestBody: content: @@ -10432,6 +10432,31 @@ paths: security: - APIKeyCookie: [] - APIKeyQuery: [] + /api/alert/hide: + patch: + summary: Hide Alert + operationId: hide_alert_api_alert_hide_patch + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Alert' + required: true + responses: + '200': + description: Successful Response + content: + application/json: + schema: {} + '422': + description: Validation Error + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + security: + - APIKeyCookie: [] + - APIKeyQuery: [] /api/info: get: tags: @@ -10515,6 +10540,35 @@ paths: $ref: '#/components/schemas/HTTPValidationError' components: schemas: + Alert: + title: Alert + required: + - message + type: object + properties: + message: + title: Message + type: string + severity: + allOf: + - $ref: '#/components/schemas/AlertLevel' + default: error + hidden: + title: Hidden + type: boolean + default: false + timestamp: + title: Timestamp + type: string + format: date-time + AlertLevel: + title: AlertLevel + enum: + - warning + - error + - info + - success + description: 'What color should the alert be as per the Mui style guide: https://mui.com/material-ui/react-alert/#severity' Announcement: title: Announcement required: @@ -10740,9 +10794,10 @@ components: source_id: title: Source Id maximum: 3.0 - minimum: -1.0 + minimum: -2.0 type: integer - description: id of the connected source, or -1 for no connection + description: id of the connected source, or -1 for no connection, or -2 + for reflecting STATE_OFF in third party interfaces such as home assistant zones: title: Zones type: array @@ -10770,10 +10825,9 @@ components: Volume, mute, and source_id fields are aggregates of the member zones.' - examples: + creation_examples: Upstairs Group: value: - id: 101 name: Upstairs zones: - 1 @@ -10781,22 +10835,18 @@ components: - 3 - 4 - 5 - vol_delta: -65 - vol_f: 0.19 Downstairs Group: value: - id: 102 name: Downstairs zones: - 6 - 7 - 8 - 9 - vol_delta: -30 - vol_f: 0.63 - creation_examples: + examples: Upstairs Group: value: + id: 101 name: Upstairs zones: - 1 @@ -10804,14 +10854,19 @@ components: - 3 - 4 - 5 + vol_delta: -65 + vol_f: 0.19 Downstairs Group: value: + id: 102 name: Downstairs zones: - 6 - 7 - 8 - 9 + vol_delta: -30 + vol_f: 0.63 GroupUpdate: title: GroupUpdate type: object @@ -10823,9 +10878,10 @@ components: source_id: title: Source Id maximum: 3.0 - minimum: -1.0 + minimum: -2.0 type: integer - description: id of the connected source, or -1 for no connection + description: id of the connected source, or -1 for no connection, or -2 + for reflecting STATE_OFF in third party interfaces such as home assistant zones: title: Zones type: array @@ -10885,9 +10941,10 @@ components: source_id: title: Source Id maximum: 3.0 - minimum: -1.0 + minimum: -2.0 type: integer - description: id of the connected source, or -1 for no connection + description: id of the connected source, or -1 for no connection, or -2 + for reflecting STATE_OFF in third party interfaces such as home assistant zones: title: Zones type: array @@ -11035,6 +11092,14 @@ components: type: string description: A list of all external drives connected default: [] + global_alerts: + title: Global Alerts + type: array + items: + $ref: '#/components/schemas/Alert' + description: A list of alerts to be shown to all users via the frontend + global alert bar + default: [] description: 'AmpliPi System information ' examples: System info: @@ -11509,10 +11574,9 @@ components: In addition to most of the configuration found in Status, this can contain commands as well that configure the state of different streaming services.' - examples: - Mute All: + creation_examples: + Add Mute All: value: - id: 10000 name: Mute All state: zones: @@ -11528,9 +11592,10 @@ components: mute: true - id: 5 mute: true - creation_examples: - Add Mute All: + examples: + Mute All: value: + id: 10000 name: Mute All state: zones: @@ -12386,47 +12451,6 @@ components: type: boolean description: This stream can be paused, only used on FilePlayers description: 'Digital stream such as Pandora, AirPlay or Spotify ' - examples: - Regina Spektor Radio: - value: - id: 90890 - name: Regina Spektor Radio - password: '' - station: '4473713754798410236' - status: connected - type: pandora - user: example1@micro-nova.com - browsable: true - Matt and Kim Radio (disconnected): - value: - id: 90891 - info: - details: No info available - name: Matt and Kim Radio - password: '' - station: '4610303469018478727' - status: disconnected - type: pandora - user: example2@micro-nova.com - browsable: true - AirPlay (connected): - value: - id: 44590 - info: - details: No info available - name: Jason's iPhone - status: connected - type: airplay - browsable: false - AirPlay (disconnected): - value: - id: 4894 - info: - details: No info available - name: Rnay - status: disconnected - type: airplay - browsable: false creation_examples: Add Beatles Internet Radio Station: value: @@ -12504,6 +12528,47 @@ components: type: lms server: mylmsserver port: 9000 + examples: + Regina Spektor Radio: + value: + id: 90890 + name: Regina Spektor Radio + password: '' + station: '4473713754798410236' + status: connected + type: pandora + user: example1@micro-nova.com + browsable: true + Matt and Kim Radio (disconnected): + value: + id: 90891 + info: + details: No info available + name: Matt and Kim Radio + password: '' + station: '4610303469018478727' + status: disconnected + type: pandora + user: example2@micro-nova.com + browsable: true + AirPlay (connected): + value: + id: 44590 + info: + details: No info available + name: Jason's iPhone + status: connected + type: airplay + browsable: false + AirPlay (disconnected): + value: + id: 4894 + info: + details: No info available + name: Rnay + status: disconnected + type: airplay + browsable: false StreamCommand: title: StreamCommand enum: @@ -12624,9 +12689,10 @@ components: source_id: title: Source Id maximum: 3.0 - minimum: -1.0 + minimum: -2.0 type: integer - description: id of the connected source, or -1 for no connection + description: id of the connected source, or -1 for no connection, or -2 + for reflecting STATE_OFF in third party interfaces such as home assistant default: 0 mute: title: Mute @@ -12701,9 +12767,10 @@ components: source_id: title: Source Id maximum: 3.0 - minimum: -1.0 + minimum: -2.0 type: integer - description: id of the connected source, or -1 for no connection + description: id of the connected source, or -1 for no connection, or -2 + for reflecting STATE_OFF in third party interfaces such as home assistant mute: title: Mute type: boolean @@ -12782,9 +12849,10 @@ components: source_id: title: Source Id maximum: 3.0 - minimum: -1.0 + minimum: -2.0 type: integer - description: id of the connected source, or -1 for no connection + description: id of the connected source, or -1 for no connection, or -2 + for reflecting STATE_OFF in third party interfaces such as home assistant mute: title: Mute type: boolean diff --git a/tests/test_rest.py b/tests/test_rest.py index 867c003bd..2bfcaef3b 100644 --- a/tests/test_rest.py +++ b/tests/test_rest.py @@ -1486,18 +1486,18 @@ def test_api_doc_has_examples(client): if method in ['post', 'put', 'patch']: try: req_spec = m['requestBody']['content']['application/json'] - assert 'example' in req_spec or 'examples' in req_spec, f'{path_desc}: At least one exmaple request required' - if 'exmaples' in req_spec: - assert len(req_spec['examples']) > 0, f'{path_desc}: At least one exmaple request required' + assert 'example' in req_spec or 'examples' in req_spec, f'{path_desc}: At least one example request required' + if 'examples' in req_spec: + assert len(req_spec['examples']) > 0, f'{path_desc}: At least one example request required' except KeyError: pass # request could be different type or non-existent try: resp_spec = m['responses']['200']['content']['application/json'] - assert 'example' in resp_spec or 'examples' in resp_spec, f'{path_desc}: At least one exmaple response required' - if 'exmaples' in resp_spec: - assert len(resp_spec['examples']) > 0, f'{path_desc}: At least one exmaple response required' + assert 'example' in resp_spec or 'examples' in resp_spec, f'{path_desc}: At least one example response required' + if 'examples' in resp_spec: + assert len(resp_spec['examples']) > 0, f'{path_desc}: At least one example response required' except KeyError: - pass # reposnse could not be json + pass # response could not be json # TODO: this test will fail until we come up with a good scheme for specifying folder locations in a global config # The test below fails since the test and the app are run in different directories @@ -1771,3 +1771,19 @@ def patch_group(json: Dict, expect_failure: bool = no_groups) -> Optional[Dict]: if num_zones == 2: expected_vol = (zone0_vol + zone1_vol) / 2 assert find(jrv['groups'], gid)['vol_f'] == expected_vol + + +def test_alerts(client): + """Check if making and hiding global alerts works """ + message = "test message" + + alerts = amplipi.utils.add_alert(message=message) + alert = amplipi.utils.select_alert(message=message, alerts=alerts) + assert alert is not None + + rv = client.patch(f"/api/info/alerts/hide", json={'message': message}) + assert rv.status_code == HTTPStatus.OK + rvj: List[amplipi.models.Alert] = [amplipi.models.Alert(**item) for item in rv.json()] + hidden = amplipi.utils.select_alert(message=message, alerts=rvj) + assert hidden is not None + assert hidden.hidden == True diff --git a/web/src/App.jsx b/web/src/App.jsx index a92f92363..4c7939dc9 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -157,6 +157,22 @@ export const useStatusStore = create((set, get) => ({ if(s.info.version != import.meta.env.VITE_BACKEND_VERSION){ set({alert: {"open": true, "text": "Your webapp is out of date, closing this message will refresh the page. If this message persists post-refresh, clear your browser cache and try again.", "onClose": () => {window.location.reload();}}}); } + + for(let i = 0; i < s.info.global_alerts.length; i++){ + if(!s.info.global_alerts[i].hidden){ + let current_alert = s.info.global_alerts[i]; + set({alert: {"open": true, "text": current_alert.message, "severity": current_alert.severity, "onClose": () => { + fetch("/api/info/alerts/hide", { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({"message": current_alert.message}), + }); + } }}); + i = s.info.global_alerts.length; + } + } } }); } else if (res.status == 401) { @@ -258,7 +274,7 @@ const App = ({ selectedPage }) => {
{/* Used to make sure the background doesn't stretch or stop prematurely on scrollable pages */}
- {alert["open"] == false; alert["onClose"]();}}/> + {alert["open"] == false; alert["onClose"]();}} severity={alert["severity"]} />
diff --git a/web/src/components/StatusBars/AlertBar.jsx b/web/src/components/StatusBars/AlertBar.jsx index 2ae5e6706..981d27485 100644 --- a/web/src/components/StatusBars/AlertBar.jsx +++ b/web/src/components/StatusBars/AlertBar.jsx @@ -7,7 +7,7 @@ import Alert from "@mui/material/Alert"; export default function AlertBar(props) { const { open, - success, + severity, text, onClose, renderAnimationState, @@ -19,12 +19,12 @@ export default function AlertBar(props) { if(alertRef.current != null){ const alertComp = alertRef.current; alertComp.classList.remove("error"); - if(!success){ + if(severity == "error"){ alertComp.offsetWidth; alertComp.classList.add("error"); } } - }, [success, renderAnimationState]); + }, [severity, renderAnimationState]); const [closedText, setClosedText] = React.useState(""); // If a user has closed a given message, don't show it again until another message tries to appear @@ -33,7 +33,7 @@ export default function AlertBar(props) { {onClose(); setClosedText(text);}} - severity={success ? "success" : "error"} + severity={severity} variant="filled" style={{width: "100%",}} > @@ -44,12 +44,12 @@ export default function AlertBar(props) { } AlertBar.propTypes = { open: PropTypes.bool.isRequired, - success: PropTypes.bool, + severity: PropTypes.str, text: PropTypes.string.isRequired, onClose: PropTypes.func.isRequired, renderAnimationState: PropTypes.number, }; AlertBar.defaultProps = { - success: false, + severity: "error", renderAnimationState: 1, };