Skip to content

Commit

Permalink
Add time to next read
Browse files Browse the repository at this point in the history
  • Loading branch information
TimOrme committed Apr 13, 2023
1 parent b6253bd commit eef379e
Show file tree
Hide file tree
Showing 3 changed files with 179 additions and 20 deletions.
41 changes: 29 additions & 12 deletions aqimon/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand All @@ -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.
Expand All @@ -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")
Expand All @@ -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.
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
}


Expand Down
66 changes: 66 additions & 0 deletions elm/src/DeviceStatus.elm
Original file line number Diff line number Diff line change
Expand Up @@ -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 []
Expand All @@ -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
Expand All @@ -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
Expand All @@ -62,6 +84,8 @@ deviceStatusColor deviceStatus =
"red"


{-| Convert the device status to a readable string.
-}
deviceStatusToString : DeviceState -> String
deviceStatusToString deviceStatus =
case deviceStatus of
Expand All @@ -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"
92 changes: 84 additions & 8 deletions elm/src/Main.elm
Original file line number Diff line number Diff line change
Expand Up @@ -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 (..)

Expand Down Expand Up @@ -65,6 +66,7 @@ type alias ErrorData =
-}
type alias Model =
{ currentTime : Maybe Posix
, lastStatusPoll : Maybe Posix
, readerState : DeviceInfo
, lastReads : ReadData
, allReads : List ReadData
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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 ]



Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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

0 comments on commit eef379e

Please sign in to comment.