diff --git a/CHANGELOG.md b/CHANGELOG.md index fd1894792..fc93bea5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ * 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 # 0.4.11 * System @@ -12,7 +15,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/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..c789b3ee3 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 @@ -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 @@ -145,6 +152,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,6 +254,47 @@ def new_preamp(self, addr: int): 0x4F, ] + 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 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: + 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}') + utils.add_alert(_I2C_ALERT_MSG) + return False + def write_byte_data(self, preamp_addr, reg, data): assert preamp_addr in _DEV_ADDRS assert type(preamp_addr) == int @@ -263,9 +316,21 @@ def write_byte_data(self, preamp_addr, reg, data): time.sleep(0.001) # space out sequential calls to avoid bus errors self.bus.write_byte_data(preamp_addr, reg, data) except Exception: - time.sleep(0.001) - self.bus = SMBus(1) - self.bus.write_byte_data(preamp_addr, reg, data) + # 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 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) + else: + raise def probe_preamp(self, addr: int): # Scan for preamps, and set source registers to be completely digital 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/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/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 b2678c287..27a925178 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, severity = "error") => { + set({alert: {"open": true, "text": text, "severity": severity, "onClose": onClose}}); + }, getSystemState: () => { fetch("/api") @@ -144,6 +154,25 @@ 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();}, "severity": "warning"}}); + } + + 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) { @@ -239,10 +268,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"]();}} severity={alert["severity"]} /> +
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..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, - status, + severity, 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(severity == "error"){ alertComp.offsetWidth; alertComp.classList.add("error"); } } - }, [status, renderAnimationState]); + }, [severity, 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={severity} variant="filled" style={{width: "100%",}} > {text} - ) + ); } -}; +} AlertBar.propTypes = { open: PropTypes.bool.isRequired, - status: PropTypes.bool.isRequired, + severity: PropTypes.str, text: PropTypes.string.isRequired, onClose: PropTypes.func.isRequired, renderAnimationState: PropTypes.number, }; AlertBar.defaultProps = { + severity: "error", 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);}} />
); };