Skip to content

Commit

Permalink
Merge pull request #28 from slaxor505/main
Browse files Browse the repository at this point in the history
1.3-beta6
  • Loading branch information
Olen authored Sep 10, 2024
2 parents 10c6415 + d186d98 commit b79c4a7
Show file tree
Hide file tree
Showing 6 changed files with 366 additions and 11 deletions.
4 changes: 3 additions & 1 deletion TODO.txt
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# TODO N: Create service to upload plants data for last x days
# TODO N: Remove milliseconds from JTS timestamps as such precision is not needed
# TODO V: Notify existing installations "please consider enabling sensor sharing" OR Prompt for sensor sharing on new installations
# TODO ?: OptionsFlow make separate step for uploader.
# TODO ?: UI option to disable location detection
# TODO V: Config_flow error messages not translated
# TODO V: Config_flow error messages not translated
# TODO V: Sanity limit for values
4 changes: 2 additions & 2 deletions custom_components/openplantbook/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
"openplantbook_sdk"
],
"requirements": [
"json-timeseries==0.1.4",
"json-timeseries==0.1.7",
"openplantbook-sdk==0.4.7"
],
"version": "1.3.0-beta5"
"version": "1.3.0-beta6"
}
157 changes: 149 additions & 8 deletions custom_components/openplantbook/uploader.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@
from homeassistant.helpers import entity_registry
from homeassistant.helpers.event import async_track_time_interval, async_call_later
from homeassistant.util import dt
from homeassistant.const import (
UnitOfTemperature,
UnitOfConductivity,
LIGHT_LUX,
PERCENTAGE,
)
from .plantbook_exception import OpenPlantbookException
from .const import (
DOMAIN,
Expand All @@ -33,6 +39,87 @@
_LOGGER = logging.getLogger(__name__)


# Take HASS state and verify if it is sane and supported by OPB and convert if necessary
def get_supported_state_value(state) -> tuple:
def validate_measurement(supported_unit, value_range):
nonlocal state_error

if unit_of_measurement != supported_unit:
_LOGGER.debug(
"Unit '%s' of '%s' measurement is not supported. Its value '%s' disregarded"
% (unit_of_measurement, current_measurement, supported_state)
)
state_error = current_measurement

elif supported_state < value_range[0] or supported_state > value_range[1]:
_LOGGER.debug(
"Value '%s' of %s is out of range %s - disregarded"
% (supported_state, current_measurement, value_range)
)
state_error = current_measurement

return

current_measurement = state.attributes.get("device_class")
unit_of_measurement = state.attributes.get("unit_of_measurement")
state_error = None

try:
supported_state = round(float(state.state))
except:
_LOGGER.debug(
"State is not a number - disregarded: state_value: '%s', state: %s"
% (state.state, state)
)
return None, current_measurement

# temperature
if current_measurement == "temperature":

# Convert Fahrenheit to Celsius
if unit_of_measurement == UnitOfTemperature.FAHRENHEIT:
supported_state = round((supported_state - 32) * 5 / 9)
_LOGGER.debug(
"Temperature converted from %s °F to %s °C"
% (state.state, supported_state)
)
unit_of_measurement = UnitOfTemperature.CELSIUS

# Convert Kelvin to Celsius
elif unit_of_measurement == UnitOfTemperature.KELVIN:
supported_state = round(supported_state - 273.15)
_LOGGER.debug(
"Temperature converted from %s K to %s °C"
% (state.state, supported_state)
)
unit_of_measurement = UnitOfTemperature.CELSIUS

validate_measurement("°C", (-50, 70))

# humidity
elif current_measurement == "humidity":
validate_measurement(PERCENTAGE, (0, 100))

# illuminance
elif current_measurement == "illuminance":
validate_measurement(LIGHT_LUX, (0, 200000))

# moisture
elif current_measurement == "moisture":
validate_measurement(PERCENTAGE, (0, 100))

# conductivity
elif current_measurement == "conductivity":
validate_measurement(UnitOfConductivity.MICROSIEMENS, (0, 3000))

# unsupported device_class
else:
_LOGGER.debug("Unsupported device_class: %s" % state)
state_error = "device_class"

return supported_state, state_error


async def plant_data_upload(hass, entry, call=None) -> dict[str, Any] | None:
if DOMAIN not in hass.data:
raise OpenPlantbookException("no data found for domain %s", DOMAIN)
Expand Down Expand Up @@ -94,6 +181,7 @@ async def plant_data_upload(hass, entry, call=None) -> dict[str, Any] | None:
continue

# Corresponding PID(Plant_ID)
_LOGGER.debug("Plant_device_state: %s" % (plant_device_state))
opb_pid = plant_device_state[plant_entity_id][0].attributes["species_original"]

# Plant-instance ID
Expand All @@ -115,10 +203,12 @@ async def plant_data_upload(hass, entry, call=None) -> dict[str, Any] | None:

# OPB ValidationFailure
except ValidationError as ex:

caught_exception = ex
opb_errors = ex.errors

if opb_errors[0]["code"] == "invalid_pid":

# workaround for case when HASS original_species is set to DISPLAY_PID rather than PID attempt to find
# the plant using PID as DISPLAY_PID and if found only 1 plant and DISPLAY_PID match they retry
try:
Expand All @@ -127,6 +217,7 @@ async def plant_data_upload(hass, entry, call=None) -> dict[str, Any] | None:
)

if search_res["count"] == 1:

if opb_pid == search_res["results"][0]["display_pid"]:
opb_disp_pid = opb_pid
opb_pid = search_res["results"][0]["pid"]
Expand Down Expand Up @@ -231,8 +322,12 @@ async def plant_data_upload(hass, entry, call=None) -> dict[str, Any] | None:
)

_LOGGER.debug("Parsing states of: %s " % entry)

measurement_errors = []

# Convert HASS state to JTS time_series excluding 'unknown' states
for entity_states in sensor_entity_states.values():

for state in entity_states:
# check if it is meaningful state
if state.state == "unknown" or state.state == "unavailable":
Expand All @@ -244,27 +339,50 @@ async def plant_data_upload(hass, entry, call=None) -> dict[str, Any] | None:
# This is last state without updates - skip it
continue

try:
float(state.state)
except:
# Get supported state value
supported_state_value, state_error = get_supported_state_value(
state
)

if state_error:
# _LOGGER.debug(
# "State value error detected: state_error - %s, state - %s"
# % (state_error, state)
# )
if state_error not in measurement_errors:
measurement_errors.append(state_error)
continue

# Add a state to TimeSeries
measurements[entry.original_device_class].insert(
TsRecord(dt_util.as_local(state.last_updated), state.state)
TsRecord(
dt_util.as_local(state.last_updated),
supported_state_value,
)
)
_LOGGER.debug(
"Added Time-Series: %s %s"
% (dt_util.as_local(state.last_updated), state.state)
"Added Time-Series Record: %s %s"
% (
dt_util.as_local(state.last_updated),
supported_state_value,
)
)

if measurement_errors:
_LOGGER.info(
"Plant (Entity) %s has errors in measurements: %s. The invalid values were disregarded. You may"
"enable debug logging for more information."
% (entry, measurement_errors)
)

# Remove empty measurements
for m in measurements.values():
if len(m) != 0:
jts_doc.addSeries(m)

if len(jts_doc) > 0:
_LOGGER.debug("An upload payload: %s" % jts_doc.toJSONString())
_LOGGER.debug("Payload to upload: %s" % jts_doc.toJSONString())
_LOGGER.debug("Calling OPB SDK to upload data")
res = await hass.data[DOMAIN][ATTR_API].async_plant_data_upload(
jts_doc, dry_run=False
)
Expand All @@ -274,7 +392,30 @@ async def plant_data_upload(hass, entry, call=None) -> dict[str, Any] | None:
)
return {"result": res}
else:
_LOGGER.info("Nothing to upload")
_LOGGER.info("Found no sensors data to upload")

if latest_data:
days_since_upload = dt_util.parse_datetime(latest_data).astimezone(
dt.UTC
) - dt_util.now(dt.UTC)
if (days_since_upload.days > 3) and dt_util.now(dt.UTC).weekday() == 4:
_LOGGER.warning(
"The last time plant sensors data was successfully uploaded %s days ago. This may indicate a "
"problem with Plants sensors or this integration. Please enable OpenPlantbook integration's debug "
"logging for more information. "
"You may report this issue via GitHub or support@plantbook.io attaching the debug log if you "
"believe it is a bug." % days_since_upload.days
)
else:
# no latest_data in the OPB API indicates that the data has never been uploaded successfully for the plant
if dt_util.now(dt.UTC).weekday() == 6:
_LOGGER.warning(
"Plants sensors data has never been uploaded successfully. This may indicate a problem with the sensors "
"or this integration. Please enable OpenPlantbook integration's debug logging for more information. "
"You may report this issue via GitHub or support@plantbook.io attaching the debug log if you "
"believe it is a bug."
)

return None


Expand Down
3 changes: 3 additions & 0 deletions requirements.test.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
pytest
pytest-cov
pytest-homeassistant-custom-component
Empty file added tests/__init__.py
Empty file.
Loading

0 comments on commit b79c4a7

Please sign in to comment.