From 3cc00d62aa93048573f1b80be42f586d409da4dc Mon Sep 17 00:00:00 2001 From: SteveMicroNova Date: Wed, 25 Feb 2026 15:00:59 -0500 Subject: [PATCH 1/4] Add global alert bar, dynamic webapp version warning --- CHANGELOG.md | 3 +- NEW_RELEASE.md | 2 +- scripts/deploy | 1 + web/src/App.jsx | 19 ++++++++++- web/src/App.scss | 18 +++++++++- .../StreamModal/StreamModal.jsx | 1 - web/src/components/StatusBars/AlertBar.jsx | 23 +++++++------ web/src/components/StatusBars/ResponseBar.jsx | 2 +- web/src/components/StatusBars/StatusBar.jsx | 34 +++++++++++++------ web/src/pages/Browse/Browse.jsx | 1 - web/src/pages/Home/Home.jsx | 6 ++-- 11 files changed, 79 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd1894792..5d8152cbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,8 @@ * Upgraded volume calculations to preserve relative positions when hitting the min or max setting via source volume bar * Update our spotify provider `go-librespot` to `0.7.3` * Upgrade from Logitech Media Server 8.5.2 to Lyrion Music Server 9.0.3 +* Web App + * Add warning for older versions of the webapp running on newer backends # 0.4.11 * System @@ -12,7 +14,6 @@ * Web App * Changed caching rules to ensure that users don't get stuck with old versions of the webapp post update - ## 0.4.10 * Web App * Fixed internet radio search functionality diff --git a/NEW_RELEASE.md b/NEW_RELEASE.md index 2365ae2ce..6972b63f2 100644 --- a/NEW_RELEASE.md +++ b/NEW_RELEASE.md @@ -31,7 +31,7 @@ This project follows [Semantic Versioning](https://semver.org/). Here are some e - [ ] Update the API by running `scripts/create_spec` script. - [ ] Create & merge a branch/PR off `main` to bump the version in the CHANGELOG and also using `poetry version ${VERSION}` - [ ] Checkout main & create a detached HEAD: `git checkout main; git pull; git checkout --detach` -- [ ] Build the webapp in `web` with `npm run build` and force add the changes with `git add -f web/dist; git commit -m "Build web app for release"` +- [ ] CD into `web`, Run `echo "VITE_BACKEND_VERSION=$(poetry version -s)" > .env; npm run build; git add -f web/dist; git commit -m "Build web app for release"`. This will encode the version number into the frontend, build it, and add the build to git for release. - [ ] Tag the changes so we can make a release on GitHub: `git tag -as ${VERSION} -m '' && git push origin ${VERSION}` - [ ] Make a release using the GitHub interface - [ ] Use the AmpliPi updater to update to the release diff --git a/scripts/deploy b/scripts/deploy index 4361d20aa..5d60fbefb 100755 --- a/scripts/deploy +++ b/scripts/deploy @@ -159,6 +159,7 @@ poetry version ${git_info} echo "Building web app" pushd ../web # Change to web directory npm install # Install nodejs dependencies +echo "VITE_BACKEND_VERSION=$(poetry version -s)" > .env # Collect the version number and bake it into the frontend npm run build # Build the web app popd diff --git a/web/src/App.jsx b/web/src/App.jsx index b2678c287..a92f92363 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -13,6 +13,7 @@ import DisconnectedIcon from "./components/DisconnectedIcon/DisconnectedIcon"; import Browse from "@/pages/Browse/Browse"; import PropTypes from "prop-types"; +import AlertBar from "./components/StatusBars/AlertBar"; // holds onto the selectedSource state so that it persists between refreshes export const usePersistentStore = create( @@ -40,6 +41,12 @@ 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": () => {}, + "severity": "", + }, skipNextUpdate: () => { set({ skipUpdate: true }); }, @@ -133,6 +140,9 @@ export const useStatusStore = create((set, get) => ({ } }); }, + setAlert: (text, onClose) => { + set({alert: {"open": true, "text": text, "onClose": onClose}}); + }, getSystemState: () => { fetch("/api") @@ -144,6 +154,9 @@ export const useStatusStore = create((set, get) => ({ set({ skipUpdate: false }); } else { set({ status: s, loaded: true, disconnected: false }); + 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();}}}); + } } }); } else if (res.status == 401) { @@ -239,10 +252,14 @@ Page.propTypes = { }; const App = ({ selectedPage }) => { + const alert = useStatusStore((s) => s.alert); return (
-
{/* Used to make sure the background doesn't stretch or stop prematurely on scrollable pages */} +
{/* Used to make sure the background doesn't stretch or stop prematurely on scrollable pages */}
+
+ {alert["open"] == false; alert["onClose"]();}}/> +
diff --git a/web/src/App.scss b/web/src/App.scss index 6a7bfb696..b99b6ca38 100644 --- a/web/src/App.scss +++ b/web/src/App.scss @@ -20,6 +20,7 @@ } // unused + .app-container { display: flex; width: 100%; @@ -31,10 +32,25 @@ padding-bottom: general.$navbar-height; } + +.alert{ + position: fixed; + top: 10px; + left: 10px; + right: 10px; + z-index: 2; +} + @media (general.$is-landscape){ - // Themed scrollbar is overridden by 90% of mobile apps and browsers, but only partially overridden by one (andorid app) + // Themed scrollbar is overridden by 90% of mobile apps and browsers, but only partially overridden by one (android app) // This still looks better on desktop, so gate it to only being on desktop without running the risk for the weird android app situation + .alert{ + left: 25vw; + right: 25vw; + top: 10px; + } + .pill-scrollbar { max-height: inherit; overflow-y: auto; diff --git a/web/src/components/CreateStreamModal/StreamModal/StreamModal.jsx b/web/src/components/CreateStreamModal/StreamModal/StreamModal.jsx index 2d27fbb8f..685131d22 100644 --- a/web/src/components/CreateStreamModal/StreamModal/StreamModal.jsx +++ b/web/src/components/CreateStreamModal/StreamModal/StreamModal.jsx @@ -251,7 +251,6 @@ const StreamModal = ({ stream, onClose, apply, del }) => { renderAnimationState={renderAlertAnimation} open={hasError} onClose={() => {setHasError(false);}} - status={false} text={errorMessage} /> } diff --git a/web/src/components/StatusBars/AlertBar.jsx b/web/src/components/StatusBars/AlertBar.jsx index 8d5aaa682..2ae5e6706 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, - status, + success, text, onClose, renderAnimationState, @@ -19,34 +19,37 @@ export default function AlertBar(props) { if(alertRef.current != null){ const alertComp = alertRef.current; alertComp.classList.remove("error"); - if(status == false){ + if(!success){ alertComp.offsetWidth; alertComp.classList.add("error"); } } - }, [status, renderAnimationState]); + }, [success, renderAnimationState]); - if(open){ + const [closedText, setClosedText] = React.useState(""); // If a user has closed a given message, don't show it again until another message tries to appear + + if(open && text != closedText){ return( {onClose(); setClosedText(text);}} + severity={success ? "success" : "error"} variant="filled" style={{width: "100%",}} > {text} - ) + ); } -}; +} AlertBar.propTypes = { open: PropTypes.bool.isRequired, - status: PropTypes.bool.isRequired, + success: PropTypes.bool, text: PropTypes.string.isRequired, onClose: PropTypes.func.isRequired, renderAnimationState: PropTypes.number, }; AlertBar.defaultProps = { + success: false, renderAnimationState: 1, -} +}; diff --git a/web/src/components/StatusBars/ResponseBar.jsx b/web/src/components/StatusBars/ResponseBar.jsx index 337bfac29..d4c94f8a2 100644 --- a/web/src/components/StatusBars/ResponseBar.jsx +++ b/web/src/components/StatusBars/ResponseBar.jsx @@ -41,7 +41,7 @@ export default function ResponseBar(props) { return( {setOpen(false);}} /> diff --git a/web/src/components/StatusBars/StatusBar.jsx b/web/src/components/StatusBars/StatusBar.jsx index 0f20c8f76..c54326513 100644 --- a/web/src/components/StatusBars/StatusBar.jsx +++ b/web/src/components/StatusBars/StatusBar.jsx @@ -3,38 +3,50 @@ import PropTypes from "prop-types"; import "./StatusBars.scss"; import Snackbar from "@mui/material/Snackbar"; -import Alert from "@mui/material/Alert" +import Alert from "@mui/material/Alert"; export default function StatusBar(props) { const { open, - status, + success, text, onClose, + anchorOrigin, + autoHideDuration, } = props; + const [closedText, setClosedText] = React.useState(""); // If a user has closed a given message, don't show it again until another message tries to appear + return( {onClose(); setClosedText(text);}} > {onClose(); setClosedText(text);}} + severity={success ? "success" : "error"} variant="filled" style={{width: "100%"}} > {text} - ) -}; + ); +} StatusBar.propTypes = { open: PropTypes.bool.isRequired, - status: PropTypes.bool.isRequired, + success: PropTypes.bool, text: PropTypes.string.isRequired, onClose: PropTypes.func.isRequired, + autoHideDuration: PropTypes.number, + anchorOrigin: PropTypes.object, }; +StatusBar.defaultProps = { + success: false, + autoHideDuration: 3000, + anchorOrigin: {vertical: "bottom", horizontal: "left"}, +}; + diff --git a/web/src/pages/Browse/Browse.jsx b/web/src/pages/Browse/Browse.jsx index 3c8495175..cef246723 100644 --- a/web/src/pages/Browse/Browse.jsx +++ b/web/src/pages/Browse/Browse.jsx @@ -113,7 +113,6 @@ const Browse = () => { {setErrorOpen(false);}} /> diff --git a/web/src/pages/Home/Home.jsx b/web/src/pages/Home/Home.jsx index 0f28f48bc..6a91fd8ae 100644 --- a/web/src/pages/Home/Home.jsx +++ b/web/src/pages/Home/Home.jsx @@ -70,7 +70,7 @@ const Home = () => { const [showStatus, setShowStatus] = React.useState(false); const statusText = React.useRef(""); - const status = React.useRef(true); + const success = React.useRef(true); let nextAvailableSource = null; let cards = []; @@ -145,10 +145,10 @@ const Home = () => { )} {presetsModalOpen && ( {status.current = state; statusText.current = text; setShowStatus(true);}} + onApply={(state, text) => {success.current = state; statusText.current = text; setShowStatus(true);}} onClose={() => setPresetsModalOpen(false)} /> )} - {setShowStatus(false);}} /> + {setShowStatus(false);}} />
); }; From 721567e64ee235c9c9073743ecda815efc920f89 Mon Sep 17 00:00:00 2001 From: SteveMicroNova Date: Wed, 25 Feb 2026 15:00:59 -0500 Subject: [PATCH 2/4] Add user alert flow to the backend, attemtp to fix an I2C error state that notifies the user when the fix is unsuccessful --- amplipi/app.py | 6 + amplipi/ctrl.py | 4 +- amplipi/models.py | 39 ++++ amplipi/rt.py | 11 +- amplipi/utils.py | 85 +++++++ docs/amplipi_api.yaml | 254 +++++++++++++-------- tests/test_rest.py | 30 ++- web/src/App.jsx | 18 +- web/src/components/StatusBars/AlertBar.jsx | 12 +- 9 files changed, 349 insertions(+), 110 deletions(-) diff --git a/amplipi/app.py b/amplipi/app.py index bbee47331..6ff3c5c07 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..11541cf32 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 >= 3: + 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 d492a9264..d43451d21 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, }; From 4dbbeeb1168d1c4b0a504855e52650679f9b1a5a Mon Sep 17 00:00:00 2001 From: Stamate Viorel Date: Wed, 10 Jun 2026 14:33:34 +0200 Subject: [PATCH 3/4] Auto-recover a hung preamp on persistent I2C write failures When the preamp microcontroller hangs it stops ACKing and every I2C write fails with OSError 121 (EREMOTEIO). The existing fallback only reopens the SMBus handle, which recovers a transient bus glitch but not a hung preamp - zone control stays dead until someone power-cycles the unit. Escalate: when the reopened-bus retry also fails, reset the preamps in place, re-assign I2C addresses, reopen the bus and re-flush all cached register values so zone state (mute/source/volume) survives, then retry the write. Rate-limited to once per 20s so a benign one-off glitch never resets audio. Observed live on our unit 2026-06-04 (zone control dead until manual reboot); with this patch the same wedge self-heals in under a second. Signed-off-by: Stamate Viorel Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 1 + amplipi/rt.py | 63 +++++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d8152cbc..fc93bea5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ * Upgraded volume calculations to preserve relative positions when hitting the min or max setting via source volume bar * Update our spotify provider `go-librespot` to `0.7.3` * Upgrade from Logitech Media Server 8.5.2 to Lyrion Music Server 9.0.3 + * Added in-place preamp recovery when I2C writes fail persistently * Web App * Add warning for older versions of the webapp running on newer backends diff --git a/amplipi/rt.py b/amplipi/rt.py index 11541cf32..78e06a690 100644 --- a/amplipi/rt.py +++ b/amplipi/rt.py @@ -145,6 +145,11 @@ class _Preamps: preamps: Dict[int, List[int]] # Key: i2c address, Val: register values + # In-place preamp recovery — rate-limited so a benign I2C glitch + # never resets audio. See _recover_preamps() / write_byte_data(). + _RECOVERY_COOLDOWN_S = 20.0 + _last_recovery = 0.0 + def __init__(self, reset: bool = True, set_addr: bool = True, bootloader: bool = False, debug=True): self.preamps = dict() if not is_amplipi(): @@ -242,7 +247,40 @@ def new_preamp(self, addr: int): 0x4F, ] - write_byte_data_failures: int = 0 + def _recover_preamps(self) -> bool: + """ Recover a wedged/hung preamp IN-PLACE. + + The bare bus.write_byte_data retry in write_byte_data only reopens the + Linux SMBus handle — that recovers a transient bus glitch but NOT a hung + preamp microcontroller (which stops ACKing -> OSError 121 / EREMOTEIO). + The only thing that revives a hung preamp is pulsing its reset line, + which is exactly what a full reboot does. This does the same WITHOUT + rebooting: reset the preamp(s), re-assign I2C addresses, reopen the bus, + and re-flush every cached register so zone state (mute/source/vol) + survives the reset (self.preamps is the code's source of truth, updated + on every write). + + Rate-limited so a benign one-off glitch never resets audio. Returns True + if a recovery was performed (caller may retry the write). + """ + now = time.time() + if now - self._last_recovery < self._RECOVERY_COOLDOWN_S: + return False + self._last_recovery = now + logger.warning('Preamp I2C wedged (EREMOTEIO) - attempting in-place recovery (reset + re-flush)') + try: + self.reset_preamps() + self.set_i2c_addr() + self.bus = SMBus(1) + for addr, regs in list(self.preamps.items()): + for reg, val in enumerate(regs): + time.sleep(0.001) + self.bus.write_byte_data(addr, reg, val) + logger.info('Preamp in-place recovery complete') + return True + except Exception as exc: + logger.error(f'Preamp in-place recovery failed: {exc}') + return False def write_byte_data(self, preamp_addr, reg, data): assert preamp_addr in _DEV_ADDRS @@ -264,15 +302,20 @@ 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 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 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) + except Exception: + # Fallback 1: reopen the bus handle and retry (transient bus glitch). + try: + time.sleep(0.001) + self.bus = SMBus(1) + self.bus.write_byte_data(preamp_addr, reg, data) + except Exception: + # Fallback 2: a reopened fd can't revive a hung preamp MCU. + # Escalate to an in-place preamp reset + re-flush, then retry once more. + if self._recover_preamps(): + time.sleep(0.001) + self.bus.write_byte_data(preamp_addr, reg, data) + else: + raise def probe_preamp(self, addr: int): # Scan for preamps, and set source registers to be completely digital From c1b9da9bfa955f6cdf19fae9af3bb6881b1c8795 Mon Sep 17 00:00:00 2001 From: SteveMicroNova Date: Thu, 18 Jun 2026 16:00:19 -0400 Subject: [PATCH 4/4] Add alert surfacing workflow to @stamateviorel 's I2C self-healing fix --- amplipi/rt.py | 41 ++++++++++++++++++++++++++++------------- web/src/App.jsx | 6 +++--- 2 files changed, 31 insertions(+), 16 deletions(-) diff --git a/amplipi/rt.py b/amplipi/rt.py index 78e06a690..c789b3ee3 100644 --- a/amplipi/rt.py +++ b/amplipi/rt.py @@ -82,6 +82,13 @@ MAX_ZONES = 6 * len(_DEV_ADDRS) +_I2C_ALERT_MSG = ( + "Writing data to the I2C bus has failed and automatic recovery was unsuccessful. " + "Please go to Settings -> Config -> Hardware Reset.\n" + "If you see this message again in a short time period, " + "contact AmpliPi Support at support@micro-nova.com" +) + class FanCtrl(Enum): MAX6644 = 0 @@ -250,22 +257,27 @@ def new_preamp(self, addr: int): def _recover_preamps(self) -> bool: """ Recover a wedged/hung preamp IN-PLACE. - The bare bus.write_byte_data retry in write_byte_data only reopens the - Linux SMBus handle — that recovers a transient bus glitch but NOT a hung - preamp microcontroller (which stops ACKing -> OSError 121 / EREMOTEIO). - The only thing that revives a hung preamp is pulsing its reset line, - which is exactly what a full reboot does. This does the same WITHOUT - rebooting: reset the preamp(s), re-assign I2C addresses, reopen the bus, - and re-flush every cached register so zone state (mute/source/vol) - survives the reset (self.preamps is the code's source of truth, updated - on every write). - - Rate-limited so a benign one-off glitch never resets audio. Returns True - if a recovery was performed (caller may retry the write). + The bare bus.write_byte_data retry in write_byte_data only reopens the + Linux SMBus handle — that recovers a transient bus glitch but NOT a hung + preamp microcontroller (which stops ACKing -> OSError 121 / EREMOTEIO). + The only thing that revives a hung preamp is pulsing its reset line, + which is exactly what a full reboot does. This does the same WITHOUT + rebooting: reset the preamp(s), re-assign I2C addresses, reopen the bus, + and re-flush every cached register so zone state (mute/source/vol) + survives the reset (self.preamps is the code's source of truth, updated + on every write). + + Rate-limited to once per _RECOVERY_COOLDOWN_S so a benign one-off glitch + never resets audio. Returns True if a recovery was performed (caller may + retry the write), False if on cooldown or if the recovery itself failed — + in both cases an alert is surfaced to the user. """ now = time.time() if now - self._last_recovery < self._RECOVERY_COOLDOWN_S: + logger.warning('Preamp I2C write failed and recovery is still on cooldown — surfacing alert') + utils.add_alert(_I2C_ALERT_MSG) return False + self._last_recovery = now logger.warning('Preamp I2C wedged (EREMOTEIO) - attempting in-place recovery (reset + re-flush)') try: @@ -280,6 +292,7 @@ def _recover_preamps(self) -> bool: return True except Exception as exc: logger.error(f'Preamp in-place recovery failed: {exc}') + utils.add_alert(_I2C_ALERT_MSG) return False def write_byte_data(self, preamp_addr, reg, data): @@ -310,7 +323,9 @@ def write_byte_data(self, preamp_addr, reg, data): self.bus.write_byte_data(preamp_addr, reg, data) except Exception: # Fallback 2: a reopened fd can't revive a hung preamp MCU. - # Escalate to an in-place preamp reset + re-flush, then retry once more. + # Escalate to an in-place preamp reset + re-flush, then retry once + # more. If recovery is on cooldown or itself fails, an alert is + # surfaced to the user inside _recover_preamps(). if self._recover_preamps(): time.sleep(0.001) self.bus.write_byte_data(preamp_addr, reg, data) diff --git a/web/src/App.jsx b/web/src/App.jsx index 4c7939dc9..27a925178 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -140,8 +140,8 @@ export const useStatusStore = create((set, get) => ({ } }); }, - setAlert: (text, onClose) => { - set({alert: {"open": true, "text": text, "onClose": onClose}}); + setAlert: (text, onClose, severity = "error") => { + set({alert: {"open": true, "text": text, "severity": severity, "onClose": onClose}}); }, getSystemState: () => { @@ -155,7 +155,7 @@ export const useStatusStore = create((set, get) => ({ } else { set({ status: s, loaded: true, disconnected: false }); 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();}}}); + 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();}, "severity": "warning"}}); } for(let i = 0; i < s.info.global_alerts.length; i++){