diff --git a/README.md b/README.md
index 3e43f3e..16ddddd 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# 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
@@ -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.
@@ -35,20 +35,31 @@ 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.
+
+
+With HACS
+
+[![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".
+
-### Manual
+
+Manual Installation
Place a copy of the files from [`custom_components/illuminance`](custom_components/illuminance)
in `/custom_components/illuminance`,
@@ -56,6 +67,12 @@ where `` is your Home Assistant configuration directory.
>__NOTE__: When downloading, make sure to use the `Raw` button from each file's page.
+
+
+### 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.
@@ -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
diff --git a/custom_components/illuminance/config_flow.py b/custom_components/illuminance/config_flow.py
index de3356a..c5b587e 100644
--- a/custom_components/illuminance/config_flow.py
+++ b/custom_components/illuminance/config_flow.py
@@ -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:
diff --git a/custom_components/illuminance/const.py b/custom_components/illuminance/const.py
index 7f9834b..08a8927 100644
--- a/custom_components/illuminance/const.py
+++ b/custom_components/illuminance/const.py
@@ -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"
diff --git a/custom_components/illuminance/manifest.json b/custom_components/illuminance/manifest.json
index ed5fad4..b683c12 100644
--- a/custom_components/illuminance/manifest.json
+++ b/custom_components/illuminance/manifest.json
@@ -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"
}
diff --git a/custom_components/illuminance/sensor.py b/custom_components/illuminance/sensor.py
index 87f4b03..63fd492 100644
--- a/custom_components/illuminance/sensor.py
+++ b/custom_components/illuminance/sensor.py
@@ -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
@@ -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
@@ -77,6 +79,7 @@
DEFAULT_NAME,
DEFAULT_SCAN_INTERVAL,
DOMAIN,
+ LUX_PER_WPSM,
MIN_SCAN_INTERVAL,
)
@@ -129,6 +132,7 @@ class Mode(Enum):
normal = auto()
simple = auto()
+ irradiance = auto()
MODES = list(Mode.__members__)
@@ -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)
@@ -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,
)
@@ -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)
@@ -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:
@@ -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:
@@ -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
@@ -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)
diff --git a/custom_components/illuminance/translations/en.json b/custom_components/illuminance/translations/en.json
index 73c1423..27e6802 100644
--- a/custom_components/illuminance/translations/en.json
+++ b/custom_components/illuminance/translations/en.json
@@ -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",
@@ -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",
@@ -36,7 +38,8 @@
"mode": {
"options": {
"normal": "Normal",
- "simple": "Simple"
+ "simple": "Simple",
+ "irradiance": "Irradiance"
}
}
},
diff --git a/custom_components/illuminance/translations/it.json b/custom_components/illuminance/translations/it.json
index 1ab7445..15fd6a3 100644
--- a/custom_components/illuminance/translations/it.json
+++ b/custom_components/illuminance/translations/it.json
@@ -36,7 +36,8 @@
"mode": {
"options": {
"normal": "Normale",
- "simple": "Semplice"
+ "simple": "Semplice",
+ "irradiance": "Irradianza"
}
}
},
diff --git a/custom_components/illuminance/translations/nl.json b/custom_components/illuminance/translations/nl.json
index 8268bf5..315ba7a 100644
--- a/custom_components/illuminance/translations/nl.json
+++ b/custom_components/illuminance/translations/nl.json
@@ -36,7 +36,8 @@
"mode": {
"options": {
"normal": "Normaal",
- "simple": "Eenvoudig"
+ "simple": "Eenvoudig",
+ "irradiance": "Bestraling"
}
}
},
diff --git a/custom_components/illuminance/translations/pl.json b/custom_components/illuminance/translations/pl.json
index 1b9fc0a..13af831 100644
--- a/custom_components/illuminance/translations/pl.json
+++ b/custom_components/illuminance/translations/pl.json
@@ -36,7 +36,8 @@
"mode": {
"options": {
"normal": "Normalny",
- "simple": "Prosty"
+ "simple": "Prosty",
+ "irradiance": "Natężenie promieniowania"
}
}
},
diff --git a/custom_components/illuminance/translations/sv.json b/custom_components/illuminance/translations/sv.json
index e316e4d..264d354 100644
--- a/custom_components/illuminance/translations/sv.json
+++ b/custom_components/illuminance/translations/sv.json
@@ -36,7 +36,8 @@
"mode": {
"options": {
"normal": "Normal",
- "simple": "Simpel"
+ "simple": "Simpel",
+ "irradiance": "Bestrålning"
}
}
},
diff --git a/info.md b/info.md
index f454fca..ff0c18c 100644
--- a/info.md
+++ b/info.md
@@ -1,7 +1,7 @@
# 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 obtained from another, existing entity.
The weather data can be from any entity whose state is either a
[weather condition](https://www.home-assistant.io/integrations/weather/#condition-mapping)
or a cloud coverage percentage.