diff --git a/README.md b/README.md index 73c7b47..b45142e 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,6 @@ A simple Air Quality Index monitor, designed to work on the Raspberry Pi with th ### Pre-Requisites - Python 3.9+ -- [uhubctl](https://github.com/mvp/uhubctl) must be installed and on your PATH. ### Install diff --git a/aqimon/config.py b/aqimon/config.py index b2bd166..0f13a44 100644 --- a/aqimon/config.py +++ b/aqimon/config.py @@ -19,6 +19,7 @@ class Config: usb_path: str usb_sleep_time_sec: int sample_count_per_read: int + warm_up_sec: int # EPA Properties epa_lookback_minutes: int @@ -36,6 +37,7 @@ class Config: reader_type="NOVAPM", usb_path="/dev/ttyUSB0", usb_sleep_time_sec=5, + warm_up_sec=15, sample_count_per_read=5, server_port=8000, server_host="0.0.0.0", @@ -51,6 +53,7 @@ def get_config_from_env() -> Config: reader_type=os.environ.get("AQIMON_READER_TYPE", DEFAULT_CONFIG.reader_type), usb_path=os.environ.get("AQIMON_USB_PATH", DEFAULT_CONFIG.usb_path), usb_sleep_time_sec=int(os.environ.get("AQIMON_USB_SLEEP_TIME_SEC", DEFAULT_CONFIG.usb_sleep_time_sec)), + warm_up_sec=int(os.environ.get("AQIMON_WARM_UP_SEC", DEFAULT_CONFIG.warm_up_sec)), sample_count_per_read=int(os.environ.get("AQIMON_SAMPLE_COUNT_PER_READ", DEFAULT_CONFIG.sample_count_per_read)), server_port=int(os.environ.get("AQIMON_SERVER_PORT", DEFAULT_CONFIG.server_port)), server_host=os.environ.get("AQIMON_SERVER_HOST", DEFAULT_CONFIG.server_host), diff --git a/aqimon/read/__init__.py b/aqimon/read/__init__.py index 1874929..d7de40a 100644 --- a/aqimon/read/__init__.py +++ b/aqimon/read/__init__.py @@ -8,8 +8,9 @@ class ReaderStatus(Enum): """Enum of possible reader states.""" IDLE = 1 - READING = 2 - ERRORING = 3 + WARM_UP = 2 + READING = 3 + ERRORING = 4 @dataclass(frozen=True) diff --git a/aqimon/read/novapm.py b/aqimon/read/novapm.py index 178307b..0475004 100644 --- a/aqimon/read/novapm.py +++ b/aqimon/read/novapm.py @@ -2,30 +2,56 @@ https://www.amazon.com/SDS011-Quality-Detection-Conditioning-Monitor/dp/B07FSDMRR5 """ -from aqimon import usb -from . import AqiRead, ReaderState, ReaderStatus import asyncio + +from . import AqiRead, ReaderState, ReaderStatus import serial +from typing import Union +from .sds011 import NovaPmReader +from .sds011.constants import ReportingState from statistics import mean -class NovaPmReader: +class OpinionatedReader: """NOVA PM SDS011 Reader.""" - def __init__(self, usb_path: str, iterations: int = 5, sleep_time: int = 60): + def __init__( + self, ser_dev: Union[str, serial.Serial], warm_up_secs: int = 15, iterations: int = 5, sleep_time: int = 3 + ): """Create the device.""" - self.usb_path = usb_path + if isinstance(ser_dev, str): + ser_dev = serial.Serial(ser_dev, timeout=2) + + self.reader = NovaPmReader(ser_dev=ser_dev) + + # Initial the reader to be in the mode we want. + self.reader.wake() + self.reader.set_reporting_mode(ReportingState.QUERYING) + self.reader.set_working_period(0) + + self.warm_up_secs = warm_up_secs self.iterations = iterations self.sleep_time = sleep_time + self.state = ReaderState(ReaderStatus.IDLE, None) async def read(self) -> AqiRead: """Read from the device.""" try: + self.reader.wake() + self.state = ReaderState(ReaderStatus.WARM_UP, None) + await asyncio.sleep(self.warm_up_secs) self.state = ReaderState(ReaderStatus.READING, None) - result = await self._power_saving_read() + pm25_reads = [] + pm10_reads = [] + for x in range(0, self.iterations): + await asyncio.sleep(self.sleep_time) + result = self.reader.query() + pm25_reads.append(result.pm25) + pm10_reads.append(result.pm10) + self.reader.sleep() self.state = ReaderState(ReaderStatus.IDLE, None) - return result + return AqiRead(pmtwofive=mean(pm25_reads), pmten=mean(pm10_reads)) except Exception as e: self.state = ReaderState(ReaderStatus.ERRORING, e) raise e @@ -33,59 +59,3 @@ async def read(self) -> AqiRead: def get_state(self) -> ReaderState: """Get the current state of the reader.""" return self.state - - async def _power_saving_read(self) -> AqiRead: - try: - await usb.turn_on_usb() - await asyncio.sleep(5) - except usb.UhubCtlNotInstalled: - pass - result = await self._averaged_read() - try: - await usb.turn_off_usb() - await asyncio.sleep(5) - except usb.UhubCtlNotInstalled: - pass - - return AqiRead(result.pmtwofive, result.pmten) - - async def _averaged_read(self) -> AqiRead: - pm25_reads = [] - pm10_reads = [] - - for x in range(self.iterations): - data = self._read() - pm25_reads.append(data.pmtwofive) - pm10_reads.append(data.pmten) - await asyncio.sleep(self.sleep_time) - - avg_pm25 = mean(pm25_reads) - avg_pm10 = mean(pm10_reads) - - return AqiRead(pmtwofive=avg_pm25, pmten=avg_pm10) - - def _read(self) -> AqiRead: - ser = serial.Serial(self.usb_path) - data = ser.read(10) - pmtwofive = int.from_bytes(data[2:4], byteorder="little") / 10 - pmten = int.from_bytes(data[4:6], byteorder="little") / 10 - - checksum = data[8] - checksum_vals = sum([data[x] for x in range(2, 8)]) & 255 - - if data[0:1] != b"\xaa": - raise Exception("Incorrect header read.") - - if data[9:10] != b"\xab": - raise Exception("Incorrect footer read.") - - if checksum_vals != checksum: - raise Exception(f"Expected read checksum of {checksum}, but got {checksum_vals}") - - if pmten > 999: - raise Exception("PM10 value out of range!") - - if pmtwofive > 999: - raise Exception("PM2.5 value out of range!") - - return AqiRead(pmtwofive, pmten) diff --git a/aqimon/read/sds011/__init__.py b/aqimon/read/sds011/__init__.py new file mode 100644 index 0000000..e56e2fb --- /dev/null +++ b/aqimon/read/sds011/__init__.py @@ -0,0 +1,170 @@ +"""Nova PM SDS011 Reader module. + +Device: https://www.amazon.com/SDS011-Quality-Detection-Conditioning-Monitor/dp/B07FSDMRR5 + +Spec: https://cdn.sparkfun.com/assets/parts/1/2/2/7/5/Laser_Dust_Sensor_Control_Protocol_V1.3.pdf +Spec: https://cdn-reichelt.de/documents/datenblatt/X200/SDS011-DATASHEET.pdf +""" + +import serial +from .responses import ( + QueryReadResponse, + ReportingModeReadResponse, + SleepWakeReadResponse, + DeviceIdResponse, + CheckFirmwareReadResponse, + WorkingPeriodReadResponse, +) +from . import constants as con +from .exceptions import IncompleteReadException, InvalidDeviceIdException + + +class NovaPmReader: + """NOVA PM SDS011 Reader.""" + + def __init__(self, ser_dev: serial.Serial): + """Create the device.""" + self.ser = ser_dev + + def query(self, query_mode: bool = True) -> QueryReadResponse: + """Query the device for pollutant data.""" + if query_mode: + cmd = con.Commands.QUERY.value + (b"\x00" * 12) + con.ALL_SENSOR_ID + self._send_command(cmd) + return QueryReadResponse(self._read_response()) + + def get_reporting_mode(self) -> ReportingModeReadResponse: + """Get the current reporting mode of the device.""" + cmd = ( + con.Commands.SET_REPORTING_MODE.value + + con.ReportingMode.QUERY.value + + con.ReportingState.ACTIVE.value + + (b"\x00" * 10) + + con.ALL_SENSOR_ID + ) + self._send_command(cmd) + return ReportingModeReadResponse(self._read_response()) + + def set_reporting_mode(self, reporting_mode: con.ReportingState) -> ReportingModeReadResponse: + """Set the reporting mode, either ACTIVE or QUERYING. + + ACTIVE mode means the device will always return a Query command response when data is asked for, regardless of + what command was sent. + + QUERYING mode means the device will only return responses to submitted commands, even for Query commands. + + ACTIVE mode is the factory default, but generally, QUERYING mode is preferrable for the longevity of the device. + """ + cmd = ( + con.Commands.SET_REPORTING_MODE.value + + con.ReportingMode.SET_MODE.value + + reporting_mode.value + + (b"\x00" * 10) + + con.ALL_SENSOR_ID + ) + self._send_command(cmd) + # Switching between reporting modes is finicky; resetting the serial connection seems to address issues. + self.ser.close() + self.ser.open() + return ReportingModeReadResponse(self._read_response()) + + def get_sleep_state(self) -> SleepWakeReadResponse: + """Get the current sleep state.""" + cmd = ( + con.Commands.SET_SLEEP.value + + con.SleepMode.QUERY.value + + con.SleepState.WORK.value + + (b"\x00" * 10) + + con.ALL_SENSOR_ID + ) + self._send_command(cmd) + return SleepWakeReadResponse(self._read_response()) + + def set_sleep_state(self, sleep_state: con.SleepState) -> SleepWakeReadResponse: + """Set the sleep state, either wake or sleep.""" + cmd = ( + con.Commands.SET_SLEEP.value + + con.SleepMode.SET_MODE.value + + sleep_state.value + + (b"\x00" * 10) + + con.ALL_SENSOR_ID + ) + self._send_command(cmd) + return SleepWakeReadResponse(self._read_response()) + + def sleep(self) -> SleepWakeReadResponse: + """Put the device to sleep, turning off fan and diode.""" + return self.set_sleep_state(con.SleepState.SLEEP) + + def wake(self) -> SleepWakeReadResponse: + """Wake the device up to start reading.""" + return self.set_sleep_state(con.SleepState.WORK) + + def set_device_id(self, device_id: bytes, target_device_id: bytes = con.ALL_SENSOR_ID) -> DeviceIdResponse: + """Set the device ID.""" + if len(device_id) != 2 or len(target_device_id) != 2: + raise AttributeError(f"Device ID must be 4 bytes, found {len(device_id)}, and {len(target_device_id)}") + cmd = con.Commands.SET_DEVICE_ID.value + (b"\x00" * 10) + device_id + target_device_id + self._send_command(cmd) + try: + return DeviceIdResponse(self._read_response()) + except IncompleteReadException: + raise InvalidDeviceIdException(f"Unable to find device ID of {target_device_id!s}") + + def get_working_period(self) -> WorkingPeriodReadResponse: + """Retrieve the current working period for the device.""" + cmd = ( + con.Commands.SET_WORKING_PERIOD.value + + con.WorkingPeriodMode.QUERY.value + + (b"\x00" * 11) + + con.ALL_SENSOR_ID + ) + self._send_command(cmd) + return WorkingPeriodReadResponse(self._read_response()) + + def set_working_period(self, working_period: int) -> WorkingPeriodReadResponse: + """Set the working period for the device. + + Working period must be between 0 and 30. + + 0 means the device will read continuously. + Any value 1-30 means the device will wake and read for 30 seconds every n*60-30 seconds. + """ + if 0 >= working_period >= 30: + raise AttributeError("Working period must be between 0 and 30") + cmd = ( + con.Commands.SET_WORKING_PERIOD.value + + con.WorkingPeriodMode.SET_MODE.value + + bytes([working_period]) + + (b"\x00" * 10) + + con.ALL_SENSOR_ID + ) + self._send_command(cmd) + return WorkingPeriodReadResponse(self._read_response()) + + def get_firmware_version(self) -> CheckFirmwareReadResponse: + """Retrieve the firmware version from the device.""" + cmd = con.Commands.CHECK_FIRMWARE_VERSION.value + (b"\x00" * 12) + con.ALL_SENSOR_ID + self._send_command(cmd) + return CheckFirmwareReadResponse(self._read_response()) + + def _send_command(self, cmd: bytes): + """Send a command to the device as bytes.""" + head = con.HEAD + con.SUBMIT_TYPE + full_command = head + cmd + bytes([self._cmd_checksum(cmd)]) + con.TAIL + if len(full_command) != 19: + raise Exception(f"Command length must be 19, but was {len(full_command)}") + self.ser.write(full_command) + + def _read_response(self) -> bytes: + """Read a response from the device.""" + result = self.ser.read(10) + if len(result) != 10: + raise IncompleteReadException(len(result)) + return result + + def _cmd_checksum(self, data: bytes) -> int: + """Generate a checksum for the data bytes of a command.""" + if len(data) != 15: + raise AttributeError("Invalid checksum length.") + return sum(d for d in data) % 256 diff --git a/aqimon/read/sds011/constants.py b/aqimon/read/sds011/constants.py new file mode 100644 index 0000000..eaa6e4a --- /dev/null +++ b/aqimon/read/sds011/constants.py @@ -0,0 +1,87 @@ +"""Byte constants for the SDS011 device.""" +from enum import Enum + + +# Message head constant +HEAD = b"\xaa" +# Message tail constant +TAIL = b"\xab" + +# ID to send if the command should be issued to all sensor IDs. +ALL_SENSOR_ID = b"\xff\xff" + +# The submit type +SUBMIT_TYPE = b"\xb4" + + +class ResponseTypes(Enum): + """Response types for commands. + + GENERAL_RESPONSE is for all commands except query. + QUERY_RESPONSE only applies to the query command. + """ + + GENERAL_RESPONSE = b"\xc5" + # Query command has its own response type. + QUERY_RESPONSE = b"\xc0" + + +class Commands(Enum): + """Possible commands for the device.""" + + SET_REPORTING_MODE = b"\x02" + QUERY = b"\x04" + SET_DEVICE_ID = b"\x05" + SET_SLEEP = b"\x06" + SET_WORKING_PERIOD = b"\x08" + CHECK_FIRMWARE_VERSION = b"\x07" + + +class ReportingMode(Enum): + """Sub command for reporting mode state. + + Can either query or set the value for the state. + """ + + QUERY = b"\x00" + SET_MODE = b"\x01" + + +class ReportingState(Enum): + """Reporting mode for the device. + + ACTIVE mode means that the device is constantly returning read data from the device, and won't respond correctly + to other query requests. + + QUERYING mode means that the device won't return read data unless explicitly asked for it. + """ + + ACTIVE = b"\x00" + QUERYING = b"\x01" + + +class WorkingPeriodMode(Enum): + """Sub command for working period state. + + Can either query or set the value for the state. + """ + + QUERY = b"\x00" + SET_MODE = b"\x01" + + +class SleepMode(Enum): + """Sub command for sleep state. + + Can either query or set the value for the state. + """ + + QUERY = b"\x00" + SET_MODE = b"\x01" + + +class SleepState(Enum): + """State of the device, either working or sleeping.""" + + SLEEP = b"\x00" + WORK = b"\x01" diff --git a/aqimon/read/sds011/exceptions.py b/aqimon/read/sds011/exceptions.py new file mode 100644 index 0000000..d79710a --- /dev/null +++ b/aqimon/read/sds011/exceptions.py @@ -0,0 +1,61 @@ +"""All exception classes for the SDS011.""" + + +class Sds011Exception(Exception): + """Base exception for SDS011 device.""" + + pass + + +class ChecksumFailedException(Sds011Exception): + """Thrown if the checksum value in a response is incorrect.""" + + def __init__(self, expected: int, actual: int): + """Create exception.""" + super().__init__() + self.expected = expected + self.actual = actual + + +class IncorrectCommandException(Sds011Exception): + """Thrown if the command ID in a response is incorrect.""" + + def __init__(self, expected: int, actual: int): + """Create exception.""" + super().__init__(f"Expected command {expected}, found {actual}") + self.expected = expected + self.actual = actual + + +class IncorrectCommandCodeException(Sds011Exception): + """Thrown if the command code in a response is incorrect.""" + + def __init__(self, expected: int, actual: int): + """Create exception.""" + super().__init__(f"Expected code {expected}, found {actual}") + self.expected = expected + self.actual = actual + + +class IncorrectWrapperException(Sds011Exception): + """Thrown if the wrapper of a response (either HEAD or TAIL) is incorrect.""" + + pass + + +class IncompleteReadException(Sds011Exception): + """Thrown if the device didn't return complete data when asking for a response.""" + + pass + + +class QueryInActiveModeException(Sds011Exception): + """Thrown if any query is issued while the device is in ACTIVE mode.""" + + pass + + +class InvalidDeviceIdException(Sds011Exception): + """Thrown if the trying to set the device ID on an invalid device.""" + + pass diff --git a/aqimon/read/sds011/responses.py b/aqimon/read/sds011/responses.py new file mode 100644 index 0000000..db15040 --- /dev/null +++ b/aqimon/read/sds011/responses.py @@ -0,0 +1,131 @@ +"""Response objects for SDS011. + +Creates and validates typed classes from binary responses from the device. +""" +from .constants import ( + HEAD, + TAIL, + Commands, + ResponseTypes, + SleepMode, + SleepState, + ReportingMode, + ReportingState, + WorkingPeriodMode, +) +from .exceptions import ( + ChecksumFailedException, + IncorrectCommandException, + IncorrectCommandCodeException, + IncorrectWrapperException, + IncompleteReadException, + QueryInActiveModeException, +) + + +class ReadResponse: + """Generic read response object for responses from SDS011.""" + + def __init__( + self, data: bytes, command_code: Commands, response_type: ResponseTypes = ResponseTypes.GENERAL_RESPONSE + ): + """Create a read response.""" + if len(data) != 10: + raise IncompleteReadException() + + self.head = data[0:1] + self.cmd_id = data[1:2] + self.data = data[2:8] + self.device_id = data[6:8] + self.checksum: int = data[8] + self.tail = data[9:10] + self.expected_command_code = command_code + self.expected_response_type = response_type + # Check it! + self.verify() + + def verify(self): + """Verify the read data.""" + if self.head != HEAD: + raise IncorrectWrapperException() + if self.tail != TAIL: + raise IncorrectWrapperException() + if self.checksum != self.calc_checksum(): + raise ChecksumFailedException(expected=self.checksum, actual=self.calc_checksum()) + if self.cmd_id != self.expected_response_type.value: + if self.cmd_id == ResponseTypes.QUERY_RESPONSE.value: + raise QueryInActiveModeException( + "Tried to retrieve response, but it looks like device is in ACTIVE mode." + ) + raise IncorrectCommandException(expected=self.expected_response_type.value, actual=self.cmd_id) + + # Query responses don't validate the command code + if ( + self.expected_response_type != ResponseTypes.QUERY_RESPONSE + and bytes([self.data[0]]) != self.expected_command_code.value + ): + raise IncorrectCommandCodeException(expected=self.expected_command_code.value, actual=self.data[0]) + + def calc_checksum(self) -> int: + """Calculate the checksum for the read data.""" + return sum(d for d in self.data) % 256 + + +class QueryReadResponse(ReadResponse): + """Query read response.""" + + def __init__(self, data: bytes): + """Create a query read response.""" + super().__init__(data, command_code=Commands.QUERY, response_type=ResponseTypes.QUERY_RESPONSE) + + self.pm25: float = int.from_bytes(data[2:4], byteorder="little") / 10 + self.pm10: float = int.from_bytes(data[4:6], byteorder="little") / 10 + + +class ReportingModeReadResponse(ReadResponse): + """Reporting mode response.""" + + def __init__(self, data: bytes): + """Create a reporting mode response.""" + super().__init__(data, command_code=Commands.SET_REPORTING_MODE) + self.mode_type = ReportingMode(self.data[1:2]) + self.state = ReportingState(self.data[2:3]) + + +class DeviceIdResponse(ReadResponse): + """Device ID response.""" + + def __init__(self, data: bytes): + """Create a device ID response.""" + super().__init__(data, command_code=Commands.SET_DEVICE_ID) + + +class SleepWakeReadResponse(ReadResponse): + """Sleep/Wake Response.""" + + def __init__(self, data: bytes): + """Create a sleep/wake response.""" + super().__init__(data, command_code=Commands.SET_SLEEP) + self.mode_type = SleepMode(self.data[1:2]) + self.state = SleepState(self.data[2:3]) + + +class WorkingPeriodReadResponse(ReadResponse): + """Working period response.""" + + def __init__(self, data: bytes): + """Create a working period response.""" + super().__init__(data, command_code=Commands.SET_WORKING_PERIOD) + self.mode_type = WorkingPeriodMode(self.data[1:2]) + self.interval: int = self.data[2] + + +class CheckFirmwareReadResponse(ReadResponse): + """Firmware response.""" + + def __init__(self, data: bytes): + """Create a firmware response.""" + super().__init__(data, command_code=Commands.CHECK_FIRMWARE_VERSION) + self.year = self.data[1] + self.month = self.data[2] + self.day = self.data[3] diff --git a/aqimon/server.py b/aqimon/server.py index 914a4af..a0db87f 100644 --- a/aqimon/server.py +++ b/aqimon/server.py @@ -21,7 +21,7 @@ ) from .read import AqiRead, Reader from .read.mock import MockReader -from .read.novapm import NovaPmReader +from .read.novapm import OpinionatedReader from . import aqi_common from .config import Config, get_config_from_env import logging @@ -80,8 +80,9 @@ def build_reader() -> ScheduledReader: elif conf.reader_type == "NOVAPM": return ScheduledReader( None, - NovaPmReader( - usb_path=conf.usb_path, + OpinionatedReader( + ser_dev=conf.usb_path, + warm_up_secs=conf.warm_up_sec, iterations=conf.sample_count_per_read, sleep_time=conf.usb_sleep_time_sec, ), @@ -127,7 +128,6 @@ async def read_from_device() -> None: async def read_function() -> None: try: # 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() await add_read( @@ -165,6 +165,7 @@ async def read_function() -> None: log.warning("No EPA Value was calculated.") await clean_old(dbconn=database, retention_minutes=config.retention_minutes) + scheduled_reader.next_schedule = datetime.now() + timedelta(seconds=config.poll_frequency_sec) except Exception as e: log.exception("Failed to retrieve data from reader", e) diff --git a/aqimon/usb.py b/aqimon/usb.py deleted file mode 100644 index 1009086..0000000 --- a/aqimon/usb.py +++ /dev/null @@ -1,46 +0,0 @@ -"""Generic USB functions. - -Used to control the USB hub for power savings, and ideally wear on the air quality monitor. This lets us turn the -fan off when we're not using it. -""" -import pathlib -import asyncio - -UHUB_CTL_PATH = "uhubctl" - - -class UhubCtlNotInstalled(Exception): - """Exception raised if ububctl is not installed on the users path.""" - - def __init__(self): - """Init exception.""" - super().__init__("Unable to find uhubctl on path") - - -async def turn_on_usb(): - """Turn on the USB hub controller.""" - if not _uhubctl_installed(): - raise UhubCtlNotInstalled() - # TODO: Device needs to be configurable here. - await _run(UHUB_CTL_PATH, "-l", "1-1", "-a", "on", "-p", "2") - - -async def turn_off_usb(): - """Turn off the USB hub controller.""" - if not _uhubctl_installed(): - raise UhubCtlNotInstalled() - # TODO: Device needs to be configurable here. - await _run(UHUB_CTL_PATH, "-l", "1-1", "-a", "off", "-p", "2") - - -def _uhubctl_installed() -> bool: - return pathlib.Path(UHUB_CTL_PATH).exists() - - -async def _run(*args): - proc = await asyncio.create_subprocess_exec(*args, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE) - - stdout, stderr = await proc.communicate() - - if proc.returncode != 0: - raise Exception(f"Error running {args}") diff --git a/elm/src/DeviceStatus.elm b/elm/src/DeviceStatus.elm index e2910d7..0d555c9 100644 --- a/elm/src/DeviceStatus.elm +++ b/elm/src/DeviceStatus.elm @@ -13,6 +13,7 @@ import Time exposing (..) -} type DeviceState = Reading + | WarmingUp | Idle | Failing @@ -62,6 +63,9 @@ deviceStatusImage deviceStatus = Reading -> "/static/images/loading.gif" + WarmingUp -> + "/static/images/loading.gif" + Idle -> "/static/images/idle.png" @@ -77,6 +81,9 @@ deviceStatusColor deviceStatus = Reading -> "green" + WarmingUp -> + "orange" + Idle -> "gray" @@ -92,6 +99,9 @@ deviceStatusToString deviceStatus = Reading -> "Reading" + WarmingUp -> + "Warming Up" + Idle -> "Idle" diff --git a/elm/src/Main.elm b/elm/src/Main.elm index aa44e28..8416cfb 100644 --- a/elm/src/Main.elm +++ b/elm/src/Main.elm @@ -419,6 +419,9 @@ stateDecoder = "IDLE" -> succeed DS.Idle + "WARM_UP" -> + succeed DS.WarmingUp + "ERRORING" -> succeed DS.Failing @@ -527,7 +530,7 @@ shouldFetchStatus model currentTime = Maybe.map2 getDuration model.readerState.nextSchedule (Just currentTime) |> Maybe.withDefault 1 in -- Reader state is active, we always want to see when it finishes ASAP. - (model.readerState.state == Reading) + (model.readerState.state == Reading || model.readerState.state == WarmingUp) -- We're overdue for a poll || (timeSinceLastPoll > maxTimeBetweenPolls) -- We're overdue for a read