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 }) => {