Skip to content

Commit 5e4d99a

Browse files
Add global alert bar, dynamic webapp version warning
Correct spelling error Add initial form of global alert workflow
1 parent aa4c2ba commit 5e4d99a

9 files changed

Lines changed: 342 additions & 112 deletions

File tree

amplipi/app.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -635,6 +635,12 @@ def play_media(media: models.PlayMedia, ctrl: Api = Depends(get_ctrl)) -> models
635635
raise HTTPException(404, f'source id not found')
636636
return code_response(ctrl, ctrl.play_media(media))
637637

638+
639+
@api.patch("/api/alert/hide", response_model=List[models.Alert])
640+
def hide_alert(alert: models.Alert):
641+
"""Hide an Alert based on the Alert's message"""
642+
return utils.hide_alert(message=alert.message)
643+
638644
# Info
639645

640646

amplipi/ctrl.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,8 @@ def reinit(self, settings: models.AppSettings = models.AppSettings(), change_not
252252
version=utils.detect_version(),
253253
stream_types_available=amplipi.streams.stream_types_available(),
254254
extra_fields=utils.load_extra_fields(),
255-
serial=str(self._serial)
255+
serial=str(self._serial),
256+
global_alerts=utils.load_alerts()
256257
)
257258
for major, minor, ghash, dirty in self._rt.read_versions():
258259
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:
553554
self.status.info.connected_drives = self._connected_drives_cache.get(throttled)
554555
self.status.info.latest_release = self._latest_release_cache.get(throttled)
555556
self.status.info.access_key = auth.get_access_key("admin") if auth.user_access_key_set("admin") else ""
557+
self.status.info.global_alerts = utils.load_alerts()
556558

557559
def sync_stream_info(self) -> None:
558560
"""Synchronize the stream list to the stream status"""

amplipi/models.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from types import SimpleNamespace
2626
from enum import Enum
2727
from pathlib import Path
28+
import datetime
2829

2930
# pylint: disable=no-name-in-module
3031
from pydantic import BaseSettings, BaseModel, Field
@@ -1013,6 +1014,43 @@ class FirmwareInfo(BaseModel):
10131014
git_dirty: bool = Field(default=False, description="True if local changes were made. Used for development.")
10141015

10151016

1017+
class AlertLevel(Enum):
1018+
"""What color should the alert be as per the Mui style guide: https://mui.com/material-ui/react-alert/#severity"""
1019+
WARNING = "warning"
1020+
ERROR = "error"
1021+
INFO = "info"
1022+
SUCCESS = "success"
1023+
1024+
1025+
class Alert(BaseModel):
1026+
message: str
1027+
severity: AlertLevel = AlertLevel.ERROR
1028+
"""What color should the alert be as per the Mui style guide: https://mui.com/material-ui/react-alert/#severity"""
1029+
hidden: bool = False
1030+
"""Has this Alert been hidden by the user?"""
1031+
timestamp: datetime.datetime = Field(
1032+
default_factory=lambda: datetime.datetime.now(datetime.timezone.utc)
1033+
)
1034+
1035+
@property
1036+
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.
1037+
return (datetime.datetime.now(datetime.timezone.utc) - self.timestamp) > datetime.timedelta(weeks=1)
1038+
1039+
class Config:
1040+
schema_extra = {
1041+
'examples': {
1042+
'Example Alert': {
1043+
'value': {
1044+
"message": "Writing data to the I2C bus has failed multiple times, please contact AmpliPi Support at mailto:support@micro-nova.com",
1045+
"severity": "error",
1046+
"hidden": False,
1047+
"timestamp": "2026-05-26T19:28:57.907099+00:00"
1048+
}
1049+
},
1050+
}
1051+
}
1052+
1053+
10161054
class Info(BaseModel):
10171055
""" AmpliPi System information """
10181056
version: str = Field(description="software version")
@@ -1033,6 +1071,7 @@ class Info(BaseModel):
10331071
default=[], description='The stream types available on this particular appliance')
10341072
extra_fields: Optional[Dict] = Field(default=None, description='Optional fields for customization')
10351073
connected_drives: List[str] = Field(default=[], description='A list of all external drives connected')
1074+
global_alerts: List[Alert] = Field(default=[], description='A list of alerts to be shown to all users via the frontend global alert bar')
10361075

10371076
class Config:
10381077
schema_extra = {

amplipi/rt.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030

3131
from smbus2 import SMBus
3232
from serial import Serial
33-
from amplipi import models # TODO: importing this takes ~0.5s, reduce
33+
from amplipi import models, utils # TODO: importing this takes ~0.5s, reduce
3434

3535
# TODO: move constants like this to their own file
3636
DEBUG_PREAMPS = False # print out preamp state after register write
@@ -242,6 +242,8 @@ def new_preamp(self, addr: int):
242242
0x4F,
243243
]
244244

245+
write_byte_data_failures: int = 0
246+
245247
def write_byte_data(self, preamp_addr, reg, data):
246248
assert preamp_addr in _DEV_ADDRS
247249
assert type(preamp_addr) == int
@@ -262,8 +264,13 @@ def write_byte_data(self, preamp_addr, reg, data):
262264
try:
263265
time.sleep(0.001) # space out sequential calls to avoid bus errors
264266
self.bus.write_byte_data(preamp_addr, reg, data)
265-
except Exception:
267+
except Exception as e:
268+
logger.exception(f"Writing preamp failed: {e}")
266269
time.sleep(0.001)
270+
self.bus.close()
271+
self.write_byte_data_failures += 1
272+
if self.write_byte_data_failures >= 3:
273+
utils.add_alert("Writing data to the I2C bus has failed multiple times, please contact AmpliPi Support at support@micro-nova.com")
267274
self.bus = SMBus(1)
268275
self.bus.write_byte_data(preamp_addr, reg, data)
269276

amplipi/utils.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,3 +495,80 @@ def clear_custom_configs():
495495
os.remove(path)
496496
except Exception as e:
497497
logger.exception(f"failed to clear device configuration: {e}")
498+
499+
500+
# 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
501+
502+
503+
def load_alerts() -> List[models.Alert]:
504+
alert_file = f"{get_folder('config')}/alerts.json"
505+
try:
506+
with open(alert_file, 'r', encoding='utf-8') as file:
507+
data = json.load(file)
508+
509+
alerts: List[models.Alert] = [models.Alert(**item) for item in data]
510+
for alert in alerts:
511+
if alert.expired:
512+
alert.hidden = True # Frontend can't see expired property, so autohide any expired alerts as to not have to close the same alert twice
513+
return alerts
514+
515+
except (FileNotFoundError, json.JSONDecodeError):
516+
return []
517+
518+
except Exception as e:
519+
logger.exception(e)
520+
return []
521+
522+
523+
def select_alert(message: str, alerts: Optional[List[models.Alert]] = None) -> Optional[models.Alert]:
524+
"""
525+
Selects the most recent non-expired instance of a specific alert message. Takes two arguments:
526+
message: the string that makes up the Alert's message
527+
528+
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.
529+
Generally useful when mutating an alert before saving the full list.
530+
"""
531+
if alerts is None:
532+
alerts = load_alerts()
533+
return next(
534+
(
535+
item for item in alerts
536+
if item.message == message and not item.expired
537+
),
538+
None
539+
)
540+
541+
542+
def add_alert(message: str, severity: models.AlertLevel = models.AlertLevel.ERROR) -> List[models.Alert]:
543+
alerts = load_alerts()
544+
search = select_alert(message)
545+
if search is None:
546+
alert = models.Alert(message=message, severity=severity)
547+
alerts.append(alert)
548+
save_alerts(alerts)
549+
return alerts # Only returns anything to make the unit test for the hide endpoint easier
550+
551+
552+
def hide_alert(message: str) -> List[models.Alert]:
553+
alerts = load_alerts()
554+
selected_alert = select_alert(message, alerts)
555+
if selected_alert is not None:
556+
selected_alert.hidden = True
557+
save_alerts(alerts)
558+
else:
559+
add_alert("Alert not found, could not be hidden!")
560+
logger.exception("Alert not found, could not be hidden!")
561+
return alerts
562+
563+
564+
def save_alerts(alerts: List[models.Alert]):
565+
alert_file = f"{get_folder('config')}/alerts.json"
566+
try:
567+
with open(alert_file, 'w', encoding='utf-8') as file:
568+
json.dump(
569+
[json.loads(alert.json()) for alert in alerts],
570+
file,
571+
indent=2
572+
)
573+
except Exception as e:
574+
logger.exception(e)

0 commit comments

Comments
 (0)