From eef379ef87f65e57f57cfd0764e3cd6c4c52afb4 Mon Sep 17 00:00:00 2001 From: Tim Orme Date: Wed, 12 Apr 2023 22:11:48 -0700 Subject: [PATCH 1/3] Add time to next read --- aqimon/server.py | 41 ++++++++++++------ elm/src/DeviceStatus.elm | 66 ++++++++++++++++++++++++++++ elm/src/Main.elm | 92 ++++++++++++++++++++++++++++++++++++---- 3 files changed, 179 insertions(+), 20 deletions(-) diff --git a/aqimon/server.py b/aqimon/server.py index c896d2e..f368cf0 100644 --- a/aqimon/server.py +++ b/aqimon/server.py @@ -21,6 +21,8 @@ from .config import Config, get_config_from_env import logging from functools import lru_cache +from dataclasses import dataclass +from typing import Optional log = logging.getLogger(__name__) @@ -31,6 +33,14 @@ app.mount("/static", StaticFiles(directory=static_dir), name="static") +@dataclass +class ScheduledReader: + """Simple state wrapper to track reader, and next schedule time.""" + + next_schedule: Optional[datetime] + reader: Reader + + @lru_cache(maxsize=1) def get_config(): """Retrieve config from environment. @@ -52,19 +62,22 @@ def get_database(config: Config = Depends(get_config)) -> databases.Database: return databases.Database(f"sqlite+aiosqlite:///{config.database_path}") -def build_reader() -> Reader: +def build_reader() -> ScheduledReader: """Retrieve the reader class. Uses LRU cache to simulate a singleton. """ conf = get_config() if conf.reader_type == "MOCK": - return MockReader() + return ScheduledReader(None, MockReader()) elif conf.reader_type == "NOVAPM": - return NovaPmReader( - usb_path=conf.usb_path, - iterations=conf.sample_count_per_read, - sleep_time=conf.usb_sleep_time_sec, + return ScheduledReader( + None, + NovaPmReader( + usb_path=conf.usb_path, + iterations=conf.sample_count_per_read, + sleep_time=conf.usb_sleep_time_sec, + ), ) else: raise Exception("Invalid reader type specified") @@ -73,7 +86,7 @@ def build_reader() -> Reader: reader = build_reader() -def get_reader() -> Reader: +def get_reader() -> ScheduledReader: """Retrieve the global reader to find state from. TODO: Change this to not rely on global module-level state. @@ -102,11 +115,13 @@ async def read_from_device() -> None: """Background cron task to read from the device.""" config = get_config_from_env() database = get_database(config) - reader = get_reader() + scheduled_reader = get_reader() async def read_function() -> None: try: - result: AqiRead = await reader.read() + # Set the approximate time of the next read + scheduled_reader.next_schedule = datetime.now() + timedelta(seconds=config.poll_frequency_sec) + result: AqiRead = await scheduled_reader.reader.read() event_time = datetime.now() epa_aqi_pm25 = aqi_common.calculate_epa_aqi(result.pmtwofive) await add_entry( @@ -157,12 +172,14 @@ async def all_data( @app.get("/api/status") -async def status(reader: Reader = Depends(get_reader)): +async def status(reader: ScheduledReader = Depends(get_reader)): """Get the system status.""" - last_exception = reader.get_state().last_exception + last_exception = reader.reader.get_state().last_exception + return { - "reader_status": str(reader.get_state().status.name), + "reader_status": str(reader.reader.get_state().status.name), "reader_exception": str(last_exception) if last_exception else None, + "next_schedule": int(reader.next_schedule.timestamp() * 1000) if reader.next_schedule else None, } diff --git a/elm/src/DeviceStatus.elm b/elm/src/DeviceStatus.elm index a9bdd21..e2910d7 100644 --- a/elm/src/DeviceStatus.elm +++ b/elm/src/DeviceStatus.elm @@ -5,20 +5,33 @@ import Bootstrap.Grid.Col as Col import Bootstrap.Grid.Row as Row import Html exposing (Attribute, Html, div, h1, h5, img, text) import Html.Attributes exposing (alt, class, src, style) +import Maybe +import Time exposing (..) +{-| Possible device states +-} type DeviceState = Reading | Idle | Failing +{-| Model for device info widget +-} type alias DeviceInfo = { state : DeviceState , lastException : Maybe String + , currentTime : Maybe Posix + , nextSchedule : Maybe Posix } +{-| HTML widget for displaying the device status. + +Includes general status, time to next read, and exception info if applicable. + +-} getDeviceInfo : DeviceInfo -> Html msg getDeviceInfo deviceInfo = Grid.container [] @@ -31,11 +44,18 @@ getDeviceInfo deviceInfo = ] , Grid.row [] [ Grid.col [] [ text (deviceInfo.lastException |> Maybe.withDefault "") ] ] + , htmlIf + (Grid.row [] + [ Grid.col [] [ text ("Next read in: " ++ (Maybe.map2 formatDuration deviceInfo.currentTime deviceInfo.nextSchedule |> Maybe.withDefault "")) ] ] + ) + (shouldShowTimer deviceInfo.state) ] ] ] +{-| Get the image icon for the device status box +-} deviceStatusImage : DeviceState -> String deviceStatusImage deviceStatus = case deviceStatus of @@ -49,6 +69,8 @@ deviceStatusImage deviceStatus = "/static/images/failing.png" +{-| Get the color of the device status box +-} deviceStatusColor : DeviceState -> String deviceStatusColor deviceStatus = case deviceStatus of @@ -62,6 +84,8 @@ deviceStatusColor deviceStatus = "red" +{-| Convert the device status to a readable string. +-} deviceStatusToString : DeviceState -> String deviceStatusToString deviceStatus = case deviceStatus of @@ -73,3 +97,45 @@ deviceStatusToString deviceStatus = Failing -> "Failing" + + +{-| Determine if we should show the countdown timer. +-} +shouldShowTimer : DeviceState -> Bool +shouldShowTimer deviceState = + deviceState == Idle || deviceState == Failing + + +{-| Conditionally display some block of HTML +-} +htmlIf : Html msg -> Bool -> Html msg +htmlIf el cond = + if cond then + el + + else + text "" + + +{-| Format a unix timestamp as a string like MM/DD HH:MM:SS +-} +formatDuration : Posix -> Posix -> String +formatDuration currentTime scheduledTime = + let + durationMillis = + posixToMillis scheduledTime - posixToMillis currentTime + + hour = + durationMillis // 1000 // 60 // 60 + + minute = + modBy 60 (durationMillis // 1000 // 60) + + second = + modBy 60 (durationMillis // 1000) + in + if durationMillis > 0 then + String.padLeft 2 '0' (String.fromInt hour) ++ ":" ++ String.padLeft 2 '0' (String.fromInt minute) ++ ":" ++ String.padLeft 2 '0' (String.fromInt second) + + else + "00:00:00" diff --git a/elm/src/Main.elm b/elm/src/Main.elm index 21771b4..2cb24ce 100644 --- a/elm/src/Main.elm +++ b/elm/src/Main.elm @@ -8,12 +8,13 @@ import Bootstrap.Grid.Row as Row import Bootstrap.Text as Text import Browser import Chart.Item as CI +import Debug import DeviceStatus as DS exposing (..) import Graph as G exposing (..) import Html exposing (Attribute, Html, div, h1, h5, text) import Html.Attributes exposing (class, style) import Http -import Json.Decode exposing (Decoder, andThen, fail, field, float, list, map2, map4, maybe, string, succeed) +import Json.Decode exposing (Decoder, andThen, fail, field, float, int, list, map3, map4, maybe, string, succeed) import Task import Time exposing (..) @@ -65,6 +66,7 @@ type alias ErrorData = -} type alias Model = { currentTime : Maybe Posix + , lastStatusPoll : Maybe Posix , readerState : DeviceInfo , lastReads : ReadData , allReads : List ReadData @@ -80,7 +82,8 @@ type alias Model = init : () -> ( Model, Cmd Msg ) init _ = ( { currentTime = Nothing - , readerState = { state = Idle, lastException = Nothing } + , lastStatusPoll = Nothing + , readerState = { state = Idle, lastException = Nothing, currentTime = Nothing, nextSchedule = Nothing } , lastReads = { time = 0, epa = 0, pm25 = 0.0, pm10 = 0.0 } , allReads = [] , windowDuration = Hour @@ -135,9 +138,10 @@ type Msg = FetchData Posix | FetchStatus Posix | GotData (Result Http.Error (List ReadData)) - | GotStatus (Result Http.Error DS.DeviceInfo) + | GotStatus (Result Http.Error DeviceInfoResponse) | ChangeWindow WindowDuration | OnHover (List (CI.One ReadData CI.Dot)) + | Tick Posix {-| Core update handler. @@ -161,14 +165,22 @@ update msg model = GotStatus result -> case result of Ok data -> - ( { model | readerState = data, errorData = { hasError = False, errorTitle = "", errorMessage = "" } }, Cmd.none ) + let + deviceInfo = + { state = data.readerStatus + , lastException = data.readerException + , currentTime = model.currentTime + , nextSchedule = Maybe.map Time.millisToPosix data.nextSchedule + } + in + ( { model | readerState = deviceInfo, errorData = { hasError = False, errorTitle = "", errorMessage = "" } }, Cmd.none ) Err e -> ( { model | errorData = { hasError = True, errorTitle = "Failed to retrieve device status", errorMessage = errorToString e } }, Cmd.none ) FetchStatus newTime -> -- Status Requested - ( { model | currentTime = Just newTime }, getStatus ) + ( { model | currentTime = Just newTime, lastStatusPoll = Just newTime }, getStatus ) ChangeWindow window -> -- Window duration changed @@ -177,6 +189,23 @@ update msg model = OnHover hovering -> ( { model | hovering = hovering }, Cmd.none ) + Tick newTime -> + let + readerState = + model.readerState + + updatedReaderState = + { readerState | currentTime = Just newTime } + + cmd = + if shouldFetchStatus model newTime then + Task.perform FetchStatus Time.now + + else + Cmd.none + in + ( { model | currentTime = Just newTime, readerState = updatedReaderState }, cmd ) + -- SUBSCRIPTIONS @@ -186,7 +215,7 @@ update msg model = -} subscriptions : Model -> Sub Msg subscriptions model = - Sub.batch [ Time.every 5000 FetchData, Time.every 5000 FetchStatus ] + Sub.batch [ Time.every 5000 FetchData, Time.every 500 Tick ] @@ -302,13 +331,21 @@ dataDecoder = ) +type alias DeviceInfoResponse = + { readerStatus : DS.DeviceState + , readerException : Maybe String + , nextSchedule : Maybe Int + } + + {-| Decoder function for JSON status data -} -statusDecoder : Decoder DS.DeviceInfo +statusDecoder : Decoder DeviceInfoResponse statusDecoder = - map2 DS.DeviceInfo + map3 DeviceInfoResponse (field "reader_status" stateDecoder) (maybe (field "reader_exception" string)) + (maybe (field "next_schedule" int)) {-| JSON decoder to convert a device state to its type. @@ -390,3 +427,42 @@ htmlIf el cond = else text "" + + +{-| Format a unix timestamp as a string like MM/DD HH:MM:SS +-} +getDuration : Posix -> Posix -> Int +getDuration currentTime scheduledTime = + let + durationMillis = + posixToMillis scheduledTime - posixToMillis currentTime + in + durationMillis + + +{-| Determine if we should fetch device status. +-} +shouldFetchStatus : Model -> Posix -> Bool +shouldFetchStatus model currentTime = + let + maxTimeBetweenPolls = + 15000 + + timeSinceLastPoll = + Maybe.map2 getDuration model.lastStatusPoll (Just currentTime) |> Maybe.withDefault (maxTimeBetweenPolls + 1) + + timeToNextRead = + Maybe.map2 getDuration model.readerState.nextSchedule (Just currentTime) |> Maybe.withDefault 1 + in + model.readerState.state + == Reading + -- Reader state is active, we always want to see when it finishes ASAP. + || timeSinceLastPoll + > maxTimeBetweenPolls + -- We're overdue for a poll + || timeToNextRead + > -1 + + + +-- We're overdue for a read From 102ed3c0eb656698f1fdf8a6d77a984bd9cacd9d Mon Sep 17 00:00:00 2001 From: Tim Orme Date: Wed, 12 Apr 2023 22:13:54 -0700 Subject: [PATCH 2/3] Remove debug import --- elm/src/Main.elm | 1 - 1 file changed, 1 deletion(-) diff --git a/elm/src/Main.elm b/elm/src/Main.elm index 2cb24ce..4d50459 100644 --- a/elm/src/Main.elm +++ b/elm/src/Main.elm @@ -8,7 +8,6 @@ import Bootstrap.Grid.Row as Row import Bootstrap.Text as Text import Browser import Chart.Item as CI -import Debug import DeviceStatus as DS exposing (..) import Graph as G exposing (..) import Html exposing (Attribute, Html, div, h1, h5, text) From 562c21c5b5d86180879247391d28ee11510cf759 Mon Sep 17 00:00:00 2001 From: Tim Orme Date: Wed, 12 Apr 2023 22:15:26 -0700 Subject: [PATCH 3/3] Fix formatting --- elm/src/Main.elm | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/elm/src/Main.elm b/elm/src/Main.elm index 4d50459..3a28df2 100644 --- a/elm/src/Main.elm +++ b/elm/src/Main.elm @@ -453,15 +453,9 @@ shouldFetchStatus model currentTime = timeToNextRead = Maybe.map2 getDuration model.readerState.nextSchedule (Just currentTime) |> Maybe.withDefault 1 in - model.readerState.state - == Reading - -- Reader state is active, we always want to see when it finishes ASAP. - || timeSinceLastPoll - > maxTimeBetweenPolls + -- Reader state is active, we always want to see when it finishes ASAP. + (model.readerState.state == Reading) -- We're overdue for a poll - || timeToNextRead - > -1 - - - --- We're overdue for a read + || (timeSinceLastPoll > maxTimeBetweenPolls) + -- We're overdue for a read + || (timeToNextRead > -1)