├── custom_components └── daily_min_max │ ├── __init__.py │ ├── services.yaml │ ├── manifest.json │ └── sensor.py └── readme.md /custom_components/daily_min_max/__init__.py: -------------------------------------------------------------------------------- 1 | DOMAIN = "daily_min_max" 2 | PLATFORMS = ["sensor"] 3 | 4 | async def async_setup(hass, config): 5 | """Set up the Daily Min/Max integration from YAML.""" 6 | return True 7 | -------------------------------------------------------------------------------- /custom_components/daily_min_max/services.yaml: -------------------------------------------------------------------------------- 1 | reload: 2 | name: Reload 3 | description: Reload all daily min max entities. 4 | 5 | reset: 6 | name: Reset 7 | description: Resets the counter of a daily min max sensor. 8 | target: 9 | entity: 10 | domain: sensor -------------------------------------------------------------------------------- /custom_components/daily_min_max/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "domain": "daily_min_max", 3 | "name": "Daily Min/Max", 4 | "documentation": "https://github.com/philsson/HomeAssistantCustomComponents", 5 | "issue_tracker": "https://github.com/philsson/HomeAssistantCustomComponents/issues", 6 | "repository": "https://github.com/larrelandin/home_assistant_daily_min_max", 7 | "codeowners": [ 8 | "@philsson", "@larrelandin" 9 | ], 10 | "quality_scale": "internal", 11 | "iot_class": "local_push", 12 | "version": "1.2.0" 13 | } -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Daily Min Max 2 | Track the min or max value of a sensor over the course of a day. The sensor will reset its value every 24h, by default at midnight. 3 | 4 | ## Installation 5 | Place the folder for the custom component `daily_min_max` in your `config/custom_components/` directory and restart home assistant. 6 | 7 | ## Example Configuration 8 | ```yaml 9 | - sensor: 10 | 11 | - platform: daily_min_max 12 | name: Pan Daily Max 13 | type: max 14 | entity_ids: 15 | - sensor.pan_temperature 16 | 17 | - platform: daily_min_max 18 | name: Outdoor Daily Max 19 | type: max 20 | entity_ids: 21 | - sensor.outdoor_temp 22 | - sensor.indoor_temp 23 | time: "03:30:00" 24 | 25 | - platform: daily_min_max 26 | name: Manually reset Only 27 | type: min 28 | entity_ids: 29 | - sensor.fuel_consumption 30 | manual_reset_only: True 31 | ``` 32 | 33 | To reset a sensor manually invoke the service e.g. 34 | ```yaml 35 | service: daily_min_max.reset 36 | target: 37 | entity_id: sensor.outdoor_daily_max 38 | ``` -------------------------------------------------------------------------------- /custom_components/daily_min_max/sensor.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from datetime import time as dtime 3 | import hashlib 4 | 5 | import voluptuous as vol 6 | from homeassistant.components.sensor import ( 7 | PLATFORM_SCHEMA, 8 | SensorEntity, 9 | RestoreSensor 10 | ) 11 | from homeassistant.const import ( 12 | ATTR_UNIT_OF_MEASUREMENT, 13 | CONF_NAME, 14 | CONF_TYPE, 15 | STATE_UNAVAILABLE, 16 | STATE_UNKNOWN 17 | ) 18 | from homeassistant.core import callback 19 | import homeassistant.helpers.config_validation as cv 20 | from homeassistant.helpers.event import ( 21 | async_track_state_change_event, 22 | async_track_time_change 23 | ) 24 | from homeassistant.helpers.reload import async_setup_reload_service 25 | from . import DOMAIN, PLATFORMS 26 | 27 | _LOGGER = logging.getLogger(__name__) 28 | 29 | ATTR_MIN_VALUE = "min_value" 30 | ATTR_MIN_ENTITY_ID = "min_entity_id" 31 | ATTR_MAX_VALUE = "max_value" 32 | ATTR_MAX_ENTITY_ID = "max_entity_id" 33 | ATTR_COUNT_SENSORS = "count_sensors" 34 | ATTR_LAST = "last" 35 | ATTR_LAST_ENTITY_ID = "last_entity_id" 36 | 37 | ATTR_TO_PROPERTY = [ 38 | ATTR_COUNT_SENSORS, 39 | ATTR_MAX_VALUE, 40 | ATTR_MAX_ENTITY_ID, 41 | ATTR_MIN_VALUE, 42 | ATTR_MIN_ENTITY_ID, 43 | ATTR_LAST, 44 | ATTR_LAST_ENTITY_ID, 45 | ] 46 | 47 | CONF_ENTITY_IDS = "entity_ids" 48 | CONF_ROUND_DIGITS = "round_digits" 49 | CONF_TIME = "time" 50 | CONF_MANUAL_RESET_ONLY = "manual_reset_only" 51 | CONF_UNIQUE_ID = "unique_id" 52 | 53 | ICON = "mdi:calculator" 54 | 55 | SENSOR_TYPES = { 56 | ATTR_MIN_VALUE: "min", 57 | ATTR_MAX_VALUE: "max", 58 | } 59 | 60 | SERVICE_RESET = "reset" 61 | 62 | PLATFORM_SCHEMA = vol.All( 63 | PLATFORM_SCHEMA.extend( 64 | { 65 | vol.Optional(CONF_TYPE, default=SENSOR_TYPES[ATTR_MAX_VALUE]): vol.All( 66 | cv.string, vol.In(SENSOR_TYPES.values()) 67 | ), 68 | vol.Optional(CONF_NAME): cv.string, 69 | vol.Optional(CONF_UNIQUE_ID): cv.string, 70 | vol.Required(CONF_ENTITY_IDS): cv.entity_ids, 71 | vol.Optional(CONF_ROUND_DIGITS, default=2): vol.Coerce(int), 72 | vol.Optional(CONF_TIME, default="00:00:00"): cv.string, 73 | vol.Optional(CONF_MANUAL_RESET_ONLY, default=False): cv.boolean 74 | } 75 | ) 76 | ) 77 | 78 | 79 | def _calc_min(sensor_values): 80 | val, entity_id = None, None 81 | for sid, sval in sensor_values: 82 | if sval not in [STATE_UNKNOWN, STATE_UNAVAILABLE] and ( 83 | val is None or val > sval 84 | ): 85 | entity_id, val = sid, sval 86 | return entity_id, val 87 | 88 | 89 | def _calc_max(sensor_values): 90 | val, entity_id = None, None 91 | for sid, sval in sensor_values: 92 | if sval not in [STATE_UNKNOWN, STATE_UNAVAILABLE] and ( 93 | val is None or val < sval 94 | ): 95 | entity_id, val = sid, sval 96 | return entity_id, val 97 | 98 | 99 | async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): 100 | entity_ids = config[CONF_ENTITY_IDS] 101 | name = config.get(CONF_NAME) 102 | sensor_type = config[CONF_TYPE] 103 | round_digits = config[CONF_ROUND_DIGITS] 104 | time_str = config[CONF_TIME] 105 | manual_reset_only = config[CONF_MANUAL_RESET_ONLY] 106 | yaml_unique = config.get(CONF_UNIQUE_ID) 107 | 108 | reset_time = dtime.fromisoformat(time_str) 109 | 110 | await async_setup_reload_service(hass, DOMAIN, PLATFORMS) 111 | 112 | # Create a stable unique_id for this aggregated sensor so it can be tracked 113 | # in the entity registry. Use YAML value if provided, otherwise a short sha1 114 | # of the sorted entity_ids + sensor_type. 115 | if yaml_unique: 116 | unique_id = yaml_unique 117 | else: 118 | hash_source = ",".join(sorted(entity_ids)) + "|" + sensor_type 119 | unique_id = f"{DOMAIN}_{sensor_type}_{hashlib.sha1(hash_source.encode()).hexdigest()[:12]}" 120 | 121 | entity = DailyMinMaxSensor( 122 | entity_ids, 123 | name, 124 | sensor_type, 125 | round_digits, 126 | reset_time, 127 | manual_reset_only, 128 | unique_id, 129 | ) 130 | async_add_entities([entity]) 131 | 132 | hass.services.async_register( 133 | DOMAIN, SERVICE_RESET, entity.async_reset 134 | ) 135 | 136 | 137 | class DailyMinMaxSensor(RestoreSensor, SensorEntity): 138 | _attr_should_poll = False 139 | _attr_icon = ICON 140 | 141 | def __init__( 142 | self, 143 | entity_ids, 144 | name, 145 | sensor_type, 146 | round_digits, 147 | reset_time, 148 | manual_reset_only, 149 | unique_id=None, 150 | ): 151 | self._entity_ids = entity_ids 152 | self._sensor_type = sensor_type 153 | self._reset_time = reset_time 154 | self._round_digits = round_digits 155 | self._manual_reset_only = manual_reset_only 156 | self._name = name or f"{sensor_type.capitalize()} sensor" 157 | self._unit_of_measurement = None 158 | self._unit_of_measurement_mismatch = False 159 | # Set stable unique id so Home Assistant's entity registry can manage the entity 160 | if unique_id: 161 | self._attr_unique_id = unique_id 162 | self.min_value = self.max_value = self.last = None 163 | self.min_entity_id = self.max_entity_id = self.last_entity_id = None 164 | self.count_sensors = len(entity_ids) 165 | self.states = {} 166 | 167 | @property 168 | def name(self): 169 | return self._name 170 | 171 | @property 172 | def native_value(self): 173 | if self._unit_of_measurement_mismatch: 174 | self._attr_available = False 175 | return None 176 | if self._sensor_type == "min": 177 | return self.min_value 178 | return self.max_value 179 | 180 | @property 181 | def native_unit_of_measurement(self): 182 | return self._unit_of_measurement 183 | 184 | @property 185 | def extra_state_attributes(self): 186 | return { 187 | attr: getattr(self, attr) 188 | for attr in ATTR_TO_PROPERTY 189 | if getattr(self, attr) is not None 190 | } 191 | 192 | async def async_added_to_hass(self): 193 | self.async_on_remove( 194 | async_track_state_change_event( 195 | self.hass, self._entity_ids, self._async_sensor_state_listener 196 | ) 197 | ) 198 | self.async_on_remove( 199 | async_track_time_change( 200 | self.hass, self.reset, hour=[self._reset_time.hour], 201 | minute=[self._reset_time.minute], 202 | second=[self._reset_time.second] 203 | ) 204 | ) 205 | 206 | if (last_state := await self.async_get_last_state()): 207 | try: 208 | self.min_value = float(last_state.attributes.get("min_value")) 209 | except (TypeError, ValueError): 210 | pass 211 | try: 212 | self.max_value = float(last_state.attributes.get("max_value")) 213 | except (TypeError, ValueError): 214 | pass 215 | try: 216 | self.last = float(last_state.attributes.get("last")) 217 | except (TypeError, ValueError): 218 | pass 219 | self.min_entity_id = last_state.attributes.get("min_entity_id") 220 | self.max_entity_id = last_state.attributes.get("max_entity_id") 221 | self.last_entity_id = last_state.attributes.get("last_entity_id") 222 | 223 | self._calc_values() 224 | 225 | @callback 226 | def _async_sensor_state_listener(self, event): 227 | new_state = event.data.get("new_state") 228 | entity = event.data.get("entity_id") 229 | 230 | if not new_state or new_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE]: 231 | self.states[entity] = STATE_UNKNOWN 232 | self._calc_values() 233 | self.async_write_ha_state() 234 | return 235 | 236 | if self._unit_of_measurement is None: 237 | self._unit_of_measurement = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) 238 | 239 | if self._unit_of_measurement != new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT): 240 | _LOGGER.warning("Unit mismatch for %s", self.entity_id) 241 | self._unit_of_measurement_mismatch = True 242 | self._attr_available = False 243 | return 244 | 245 | try: 246 | val = round(float(new_state.state), self._round_digits) 247 | self.states[entity] = val 248 | self.last = val 249 | self.last_entity_id = entity 250 | except ValueError: 251 | _LOGGER.warning("Non-numeric state for %s", entity) 252 | return 253 | 254 | self._calc_values() 255 | self.async_write_ha_state() 256 | 257 | @callback 258 | def _calc_values(self): 259 | sensor_values = [ 260 | (eid, self.states[eid]) 261 | for eid in self._entity_ids 262 | if eid in self.states 263 | ] 264 | min_id, min_val = _calc_min(sensor_values) 265 | max_id, max_val = _calc_max(sensor_values) 266 | 267 | if min_val is not None and (self.min_value is None or min_val < self.min_value): 268 | self.min_entity_id, self.min_value = min_id, min_val 269 | if max_val is not None and (self.max_value is None or max_val > self.max_value): 270 | self.max_entity_id, self.max_value = max_id, max_val 271 | 272 | @callback 273 | def reset(self, _now): 274 | if self._manual_reset_only: 275 | return 276 | self.min_value = self.max_value = self.last 277 | self.min_entity_id = self.max_entity_id = self.last_entity_id = None 278 | self.async_write_ha_state() 279 | 280 | async def async_reset(self, _call=None): 281 | _LOGGER.debug("Manual reset %s", self._name) 282 | self.reset(None) 283 | --------------------------------------------------------------------------------