Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions amplipi/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -635,6 +635,11 @@ def play_media(media: models.PlayMedia, ctrl: Api = Depends(get_ctrl)) -> models
raise HTTPException(404, f'source id not found')
return code_response(ctrl, ctrl.play_media(media))


@api.patch("/api/alert/hide")
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This pathing might need some work. It made more sense when I had multiple alert endpoints being added here but I found 2/3 of the endpoints didn't make sense as I kept devving on this

def hide_alert(alert: models.Alert, ctrl: Api = Depends(get_ctrl)):
utils.hide_alert(alert)

# Info


Expand Down
4 changes: 3 additions & 1 deletion amplipi/ctrl.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"""
Expand Down
24 changes: 24 additions & 0 deletions amplipi/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1004,6 +1005,28 @@ class FirmwareInfo(BaseModel):
git_dirty: bool = Field(default=False, description="True if local changes were made. Used for development.")


class AlertLevel(Enum):
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"""
Comment on lines +1008 to +1018
Copy link
Copy Markdown
Contributor Author

@SteveMicroNova SteveMicroNova May 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docstring under the severity var would probably also make sense as a docstring for the AlertLevel

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 Info(BaseModel):
""" AmpliPi System information """
version: str = Field(description="software version")
Expand All @@ -1024,6 +1047,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 = {
Expand Down
11 changes: 9 additions & 2 deletions amplipi/rt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 >= 3:
utils.add_alert("Writing data to the I2C bus has failed multiple times, please contact AmpliPi Support at support@micro-nova.com")
self.bus = SMBus(1)
self.bus.write_byte_data(preamp_addr, reg, data)

Expand Down
61 changes: 61 additions & 0 deletions amplipi/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -495,3 +495,64 @@ 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]:
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 add_alert(message: str, severity: models.AlertLevel = models.AlertLevel.ERROR):
alerts = load_alerts()
search = [alert for alert in alerts if alert.message == message and not alert.expired]
if len(search) == 0:
alert = models.Alert(message=message, severity=severity)
alerts.append(alert)
save_alerts(alerts)


def hide_alert(alert: models.Alert):
alerts = load_alerts()
selected_alert = next(
(
item for item in alerts
if item.message == alert.message and not item.expired
),
None
)
if selected_alert is not None:
selected_alert.hidden = True
save_alerts(alerts)
else:
add_alert("Alert not found, could not be hidden!")
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This probably makes sense as an Exception as well



def save_alerts(alerts: List[models.Alert]):
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)
20 changes: 18 additions & 2 deletions web/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export const useStatusStore = create((set, get) => ({
skipUpdate: false,
loaded: false, // using this instead of (status === null) because it fixes the re-rendering issue
disconnected: true,
alert: {"open": false, "text": "", "onClose": () => {}},
alert: {"open": false, "text": "", "onClose": () => {}, severity: ""},
skipNextUpdate: () => {
set({ skipUpdate: true });
},
Expand Down Expand Up @@ -141,6 +141,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/alert/hide", {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(current_alert),
});
} }});
i = s.info.global_alerts.length;
}
}
}
});
} else if (res.status == 401) {
Expand Down Expand Up @@ -239,7 +255,7 @@ const App = ({ selectedPage }) => {
<DisconnectedIcon />
<div className="background-gradient">{/* Used to make sure the background doesn't stretch or stop prematurely on scrollable pages */}</div>
<div className="alert">
<AlertBar open={alert["open"]} text={alert["text"]} onClose={() => {alert["open"] == false; alert["onClose"]();}}/>
<AlertBar open={alert["open"]} text={alert["text"]} onClose={() => {alert["open"] == false; alert["onClose"]();}} severity={alert["severity"]} />
</div>
<div className="app-body">
<Page selectedPage={selectedPage} />
Expand Down
12 changes: 6 additions & 6 deletions web/src/components/StatusBars/AlertBar.jsx
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The MUI alert component that we use has a "severity" property that has 4 possible values - error, success, warning, and info. I have had no particular use for warning and info thus far, so I flattened it into just success or error. Within that, I generally only needed error so I had it be a store_true arg for whether it shows success or fail, I've upgraded that with full granularity to future proof these changes.

Luckily the other two AlertBar style components we use don't have the same controls so I didn't need to upkeep those for full feature parity in this PR

Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import Alert from "@mui/material/Alert";
export default function AlertBar(props) {
const {
open,
success,
severity,
text,
onClose,
renderAnimationState,
Expand All @@ -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

Expand All @@ -33,7 +33,7 @@ export default function AlertBar(props) {
<Alert
ref={alertRef}
onClose={() => {onClose(); setClosedText(text);}}
severity={success ? "success" : "error"}
severity={severity}
variant="filled"
style={{width: "100%",}}
>
Expand All @@ -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,
};
Loading