Skip to content

Commit

Permalink
Make weather entity optional & add irradiance mode option (#69)
Browse files Browse the repository at this point in the history
Make weather input entity optional.
Fallback default is 1 if no weather entity selected, or 10 when one is selected (as before.)
Add irradiance mode option.
Fix scan interval broken by 2024.6.
  • Loading branch information
pnbruckner authored Aug 28, 2024
1 parent a9d7d5a commit 70be580
Show file tree
Hide file tree
Showing 11 changed files with 139 additions and 73 deletions.
43 changes: 30 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# <img src="https://brands.home-assistant.io/illuminance/icon.png" alt="Sun2 Sensor" width="50" height="50"/> Illuminance Sensor
Creates a `sensor` entity that estimates outdoor illuminance based on either sun elevation or time of day.
In either case, the value is adjusted based on current weather conditions obtained from another, existing entity.
In either case, the value can be further adjusted based on current weather conditions or cloud coverage obtained from another, existing entity.


## Modes of operation
Expand All @@ -16,7 +16,7 @@ This mode uses an algorithm from the US Naval Observatory[^1] for estimating sun
[^1]: Janiczek, P. M., and DeYoung, J. A. _Computer Programs for Sun and Moon Illuminance With Contingent Tables and Diagrams_. Circular No. 171. Washington, D. C.: United States Naval Observatory, 1987 [Google Scholar](https://scholar.google.com/scholar_lookup?title=Computer%20programs%20for%20sun%20and%20moon%20illuminance%20with%20contingent%20tables%20and%20diagrams&author=P.%20M.%20Janiczek&author=J.%20A.%20Deyoung&publication_year=1987&book=Computer%20programs%20for%20sun%20and%20moon%20illuminance%20with%20contingent%20tables%20and%20diagrams)

### Simple mode - Time of day
At night the value is 10 lx. From a little before sunrise to a little after the value is ramped up to whatever the current conditions indicate. The same happens around sunset, except the value is ramped down. The maximum value is 10,000 lx. Below is an example of what that might look like over a three day period.
At night the value is 10 lx. From a little before sunrise to a little after the value is ramped up to whatever the current conditions indicate. The same happens around sunset, except the value is ramped down. For historical reasons, the maximum value is 10,000 lx. Below is an example of what that might look like over a three day period.

<p align="center">
<img src=images/simple.png>
Expand All @@ -35,27 +35,44 @@ Integration | Notes
[OpenWeatherMap](https://www.home-assistant.io/integrations/openweathermap/) | `weather`; cloud_coverage & condition `sensor`

## Installation
### With HACS
[![hacs_badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg)](https://hacs.xyz/)

The integration software must first be installed as a custom component.
You can use HACS to manage the installation and provide update notifications.
Or you can manually install the software.

<details>
<summary>With HACS</summary>

[![hacs_badge](https://img.shields.io/badge/HACS-Custom-41BDF5.svg)](https://hacs.xyz/)

1. Add this repo as a [custom repository](https://hacs.xyz/docs/faq/custom_repositories/):
It should then appear as a new integration. Click on it. If necessary, search for "illuminance".
```text
https://github.com/pnbruckner/ha-illuminance
```
Or use this button:

[![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=pnbruckner&repository=ha-illuminance&category=integration)

```text
https://github.com/pnbruckner/ha-illuminance
```
1. Download the integration using the appropriate button.

2. Install the integration using the appropriate button on the HACS Integrations page. Search for "illuminance".
</details>

### Manual
<details>
<summary>Manual Installation</summary>

Place a copy of the files from [`custom_components/illuminance`](custom_components/illuminance)
in `<config>/custom_components/illuminance`,
where `<config>` is your Home Assistant configuration directory.

>__NOTE__: When downloading, make sure to use the `Raw` button from each file's page.
</details>

### Post Installation

After it has been downloaded you will need to restart Home Assistant.

### Versions

This custom integration supports HomeAssistant versions 2023.4.0 or newer.
Expand All @@ -75,11 +92,11 @@ A list of configuration options for one or more sensors. Each sensor is defined
Key | Optional | Description
-|-|-
`unique_id` | no | Unique identifier for sensor. This allows any of the remaining options to be changed without looking like a new sensor. (Only required for YAML-based configuration.)
`entity_id` | no | Entity ID of another entity that indicates current weather conditions
`fallback` | yes | Illuminance divisor to use when weather data is not available. Must be in the range of 1 (clear) through 10 (dark.) Default is 10.
`mode` | yes | Mode of operation. Choices are `normal` (default) which uses sun elevation, and `simple` which uses time of day.
`entity_id` | yes | Entity ID of another entity that indicates current weather conditions or cloud coverage percentage
`fallback` | yes | Illuminance divisor to use when weather data is not available. Must be in the range of 1 (clear) through 10 (dark.) Default is 10 if `entity_id` is used, or 1 if not.
`mode` | yes | Mode of operation. Choices are `normal` (default) which uses sun elevation, `simple` which uses time of day and `irradiance` which is the same as `normal`, except the value is expressed as irradiance in Watts/M².
`name` | yes | Name of the sensor. Default is `Illuminance`.
`scan_interval` | yes | Update interval. Minimum is 5 minutes. Default is 5 minutes.
`scan_interval` | yes | Update interval. Minimum is 30 seconds. Default is 5 minutes.

## Converting from `platform` configuration

Expand Down
65 changes: 39 additions & 26 deletions custom_components/illuminance/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,35 +57,48 @@ async def async_step_options(
) -> FlowResult:
"""Get sensor options."""
if user_input is not None:
self.options.clear()
self.options.update(user_input)
return await self.async_step_done()

schema = {
vol.Required(
CONF_MODE, default=self.options.get(CONF_MODE, MODES[0])
): SelectSelector(
SelectSelectorConfig(options=MODES, translation_key="mode")
),
vol.Required(
CONF_ENTITY_ID, default=self.options.get(CONF_ENTITY_ID, vol.UNDEFINED)
): EntitySelector(EntitySelectorConfig(domain=["sensor", "weather"])),
vol.Required(
CONF_SCAN_INTERVAL,
default=self.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL_MIN),
): NumberSelector(
NumberSelectorConfig(
min=MIN_SCAN_INTERVAL_MIN, step=0.5, mode=NumberSelectorMode.BOX
)
),
vol.Required(
CONF_FALLBACK, default=self.options.get(CONF_FALLBACK, DEFAULT_FALLBACK)
): NumberSelector(
NumberSelectorConfig(
min=1, max=10, step="any", mode=NumberSelectorMode.BOX
)
),
}
return self.async_show_form(step_id="options", data_schema=vol.Schema(schema))
data_schema = vol.Schema(
{
vol.Required(CONF_MODE): SelectSelector(
SelectSelectorConfig(options=MODES, translation_key="mode")
),
vol.Required(CONF_SCAN_INTERVAL): NumberSelector(
NumberSelectorConfig(
min=MIN_SCAN_INTERVAL_MIN, step=0.5, mode=NumberSelectorMode.BOX
)
),
vol.Optional(CONF_ENTITY_ID): EntitySelector(
EntitySelectorConfig(domain=["sensor", "weather"])
),
vol.Optional(CONF_FALLBACK): NumberSelector(
NumberSelectorConfig(
min=1, max=10, step="any", mode=NumberSelectorMode.BOX
)
),
}
)
data_schema = self.add_suggested_values_to_schema(
data_schema,
{
CONF_MODE: self.options.get(CONF_MODE, MODES[0]),
CONF_SCAN_INTERVAL: self.options.get(
CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL_MIN
),
},
)
if (entity_id := self.options.get(CONF_ENTITY_ID)) is not None:
data_schema = self.add_suggested_values_to_schema(
data_schema, {CONF_ENTITY_ID: entity_id}
)
if (fallback := self.options.get(CONF_FALLBACK)) is not None:
data_schema = self.add_suggested_values_to_schema(
data_schema, {CONF_FALLBACK: fallback}
)
return self.async_show_form(step_id="options", data_schema=data_schema)

@abstractmethod
async def async_step_done(self, _: dict[str, Any] | None = None) -> FlowResult:
Expand Down
2 changes: 2 additions & 0 deletions custom_components/illuminance/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,7 @@
DEFAULT_SCAN_INTERVAL_MIN = 5
DEFAULT_SCAN_INTERVAL = timedelta(minutes=DEFAULT_SCAN_INTERVAL_MIN)
DEFAULT_FALLBACK = 10
# Lux per Watts/M²
LUX_PER_WPSM = 120

CONF_FALLBACK = "fallback"
4 changes: 2 additions & 2 deletions custom_components/illuminance/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
"codeowners": ["@pnbruckner"],
"config_flow": true,
"dependencies": [],
"documentation": "https://github.com/pnbruckner/ha-illuminance/5.4.3/master/README.md",
"documentation": "https://github.com/pnbruckner/ha-illuminance/5.5.0b1/master/README.md",
"iot_class": "calculated",
"issue_tracker": "https://github.com/pnbruckner/ha-illuminance/issues",
"requirements": [],
"version": "5.4.3"
"version": "5.5.0b1"
}
79 changes: 53 additions & 26 deletions custom_components/illuminance/sensor.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from dataclasses import dataclass
from datetime import date, datetime, timedelta
from enum import Enum, IntEnum, auto
from functools import cached_property
import logging
from math import asin, cos, exp, radians, sin
import re
Expand Down Expand Up @@ -54,6 +55,7 @@
LIGHT_LUX,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
UnitOfIrradiance,
)
from homeassistant.core import Event, HomeAssistant, State, callback
import homeassistant.helpers.config_validation as cv
Expand All @@ -77,6 +79,7 @@
DEFAULT_NAME,
DEFAULT_SCAN_INTERVAL,
DOMAIN,
LUX_PER_WPSM,
MIN_SCAN_INTERVAL,
)

Expand Down Expand Up @@ -129,6 +132,7 @@ class Mode(Enum):

normal = auto()
simple = auto()
irradiance = auto()


MODES = list(Mode.__members__)
Expand All @@ -138,11 +142,9 @@ class Mode(Enum):
vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): vol.All(
cv.time_period, vol.Range(min=MIN_SCAN_INTERVAL)
),
vol.Required(CONF_ENTITY_ID): cv.entity_id,
vol.Optional(CONF_ENTITY_ID): cv.entity_id,
vol.Optional(CONF_MODE, default=MODES[0]): vol.In(MODES),
vol.Optional(CONF_FALLBACK, default=DEFAULT_FALLBACK): vol.All(
vol.Coerce(float), vol.Range(1, 10)
),
vol.Optional(CONF_FALLBACK): vol.All(vol.Coerce(float), vol.Range(1, 10)),
}
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(ILLUMINANCE_SCHEMA)

Expand All @@ -169,15 +171,29 @@ def _sensor(
scan_interval: timedelta | None = None,
) -> Entity:
"""Create entity to add."""
weather_entity = config.get(CONF_ENTITY_ID)
fallback = cast(
float,
config.get(CONF_FALLBACK, DEFAULT_FALLBACK if weather_entity else 1)
)
if (mode := Mode.__getitem__(cast(str, config[CONF_MODE]))) is Mode.irradiance:
device_class = SensorDeviceClass.IRRADIANCE
native_unit_of_measurement = UnitOfIrradiance.WATTS_PER_SQUARE_METER
suggested_display_precision = 1
else:
device_class = SensorDeviceClass.ILLUMINANCE
native_unit_of_measurement = LIGHT_LUX
suggested_display_precision = 0
entity_description = IlluminanceSensorEntityDescription(
key=DOMAIN,
device_class=SensorDeviceClass.ILLUMINANCE,
device_class=device_class,
name=cast(str, config[CONF_NAME]),
native_unit_of_measurement=LIGHT_LUX,
native_unit_of_measurement=native_unit_of_measurement,
state_class=SensorStateClass.MEASUREMENT,
weather_entity=cast(str, config[CONF_ENTITY_ID]),
mode=Mode.__getitem__(cast(str, config[CONF_MODE])),
fallback=cast(float, config[CONF_FALLBACK]),
suggested_display_precision=suggested_display_precision,
weather_entity=weather_entity,
mode=mode,
fallback=fallback,
unique_id=unique_id,
scan_interval=scan_interval,
)
Expand Down Expand Up @@ -267,17 +283,17 @@ def __init__(self, entity_description: IlluminanceSensorEntityDescription) -> No
else:
self._attr_unique_id = cast(str, entity_description.name)

@property
def weather_entity(self) -> str:
@cached_property
def weather_entity(self) -> str | None:
"""Input weather entity ID."""
return cast(str, self.entity_description.weather_entity)
return self.entity_description.weather_entity

@property
@cached_property
def mode(self) -> Mode:
"""Illuminance calculation mode."""
return cast(Mode, self.entity_description.mode)

@property
@cached_property
def fallback(self) -> float:
"""Fallback illuminance divisor."""
return cast(float, self.entity_description.fallback)
Expand All @@ -292,13 +308,19 @@ def add_to_platform_start(
"""Start adding an entity to a platform."""
# This method is called before first call to async_update.

if self.entity_description.scan_interval:
platform.scan_interval = self.entity_description.scan_interval
if (scan_interval := self.entity_description.scan_interval) is not None:
platform.scan_interval = scan_interval
if hasattr(platform, "scan_interval_seconds"):
platform.scan_interval_seconds = scan_interval.total_seconds()
super().add_to_platform_start(hass, platform, parallel_updates)

# Now that parent method has been called, self.hass has been initialized.

self._get_divisor_from_weather_data(hass.states.get(self.weather_entity))
self._get_divisor_from_weather_data(
hass.states.get(self.weather_entity) if self.weather_entity else None
)
if not self.weather_entity:
return

@callback
def sensor_state_listener(event: Event) -> None:
Expand All @@ -324,28 +346,33 @@ def sensor_state_listener(event: Event) -> None:
async def async_update(self) -> None:
"""Update state."""
if (
self._entity_status <= EntityStatus.NO_ATTRIBUTION
self.weather_entity
and self._entity_status <= EntityStatus.NO_ATTRIBUTION
and not self.hass.is_running
):
return

try:
illuminance = self._calculate_illuminance(
value = self._calculate_illuminance(
dt_util.now().replace(microsecond=0)
)
except AbortUpdate:
return

# Calculate final illuminance.
if self.mode is Mode.irradiance:
value /= LUX_PER_WPSM

# Calculate final value.

self._attr_native_value = round(illuminance / self._sk)
self._attr_native_value = value / self._sk
display_precision = self._sensor_option_display_precision or 0
_LOGGER.debug(
"%s: Updating %s -> %i / %0.1f = %i",
"%s: Updating %s -> %s / %0.2f = %s",
self.name,
self._cond_desc,
round(illuminance),
f"{value:0.{display_precision}f}",
self._sk,
self._attr_native_value,
f"{self._attr_native_value:0.{display_precision}f}",
)

def _get_divisor_from_weather_data(self, entity_state: State | None) -> None:
Expand All @@ -355,7 +382,7 @@ def _get_divisor_from_weather_data(self, entity_state: State | None) -> None:
self._cond_desc = "without weather data"
self._sk = self.fallback

if self._entity_status == EntityStatus.BAD:
if not self.weather_entity or self._entity_status == EntityStatus.BAD:
return

condition = entity_state and entity_state.state
Expand Down Expand Up @@ -447,7 +474,7 @@ def _get_mappings(self, attribution: str | None, domain: str) -> None:

def _calculate_illuminance(self, now: datetime) -> Num:
"""Calculate sunny illuminance."""
if self.mode is Mode.normal:
if self.mode is not Mode.simple:
return _illumiance(cast(Num, self._astral_event("solar_elevation", now)))

sun_factor = self._sun_factor(now)
Expand Down
5 changes: 4 additions & 1 deletion custom_components/illuminance/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"step": {
"options": {
"title": "Illuminance Options",
"description": "Default divider is 1 if no weather data source selected, and 10 if one is selected.",
"data": {
"mode": "Mode",
"entity_id": "Weather data source",
Expand All @@ -23,6 +24,7 @@
"step": {
"options": {
"title": "Illuminance Options",
"description": "Default divider is 1 if no weather data source selected, and 10 if one is selected.",
"data": {
"mode": "Mode",
"entity_id": "Weather data source",
Expand All @@ -36,7 +38,8 @@
"mode": {
"options": {
"normal": "Normal",
"simple": "Simple"
"simple": "Simple",
"irradiance": "Irradiance"
}
}
},
Expand Down
Loading

0 comments on commit 70be580

Please sign in to comment.