diff --git a/documentation/api/change_log.rst b/documentation/api/change_log.rst index 76415e15d8..ab2a779cf7 100644 --- a/documentation/api/change_log.rst +++ b/documentation/api/change_log.rst @@ -10,6 +10,7 @@ v3.0-32 | July XX, 2026 - API endpoints are now rate-limited. A request which exceeds a limit is answered with a ``429 (Too Many Requests)`` status code and a ``Retry-After`` header stating how many seconds to wait. Responses also carry ``X-RateLimit-*`` headers, describing the limit that applied, how much of it is left, and when it resets. A stricter limit applies to ``POST /assets//schedules/trigger``, ``POST /sensors//schedules/trigger`` and ``POST /sensors//forecasts/trigger`` than to other endpoints; the health endpoints are exempt. Per-account overrides are set by assigning the account a plan (a ``Plan`` database row), rather than through an account attribute. - Introduced the ``inflexible-consumption`` and ``inflexible-production`` flex-context fields, which make explicit how the sign of each inflexible device's power data should be read: positive values denote consumption resp. production. Each entry is a sensor reference (``{"sensor": }``), optionally with source filters (``source-types``, ``exclude-source-types``, ``sources``, ``source-account``). Deprecated the ``inflexible-device-sensors`` field (a list of bare sensor IDs, whose sign convention is read from each sensor's ``consumption_is_positive`` attribute); it remains supported, but cannot be combined with the new fields in one flex-context. - Added a ``role`` query parameter to ``GET /api/v3_0/accounts`` for filtering accessible organisations by account role. +- Sensor references on variable-quantity flex-model and flex-context fields (such as ``soc-minima``, ``soc-maxima``, the capacity fields and the price fields) may now include a ``default`` fallback quantity, e.g. ``{"sensor": 50, "default": "0 kWh"}``. It fills the time slots for which the referenced sensor holds no value. Note that this fills *every* such slot, so a sensor recording only occasional setpoints becomes densely constrained. The field is not (yet) applied to sensor references on ``inflexible-consumption``/``inflexible-production`` or to forecaster regressors. Take particular care with a fallback of ``0`` on a ``consumption-capacity`` or ``production-capacity``: if the sensor holds no value for the whole scheduling window, the resulting all-zero capacity is read as a physical statement about the device and enforced strictly (see :ref:`the flex-model capacity fields `), rather than as a limit that may be breached at a price. - Extended ``GET /api/v3_0/jobs/`` with a ``result`` field containing ``unresolved`` and ``resolved`` arrays, each keyed by asset ID. For scheduling jobs, this surfaces soft state-of-charge constraint analysis: ``soc-minima`` and ``soc-maxima`` violations (with a ``violation`` magnitude) or satisfied constraints (with a ``margin`` headroom). Both arrays are empty when no SoC constraints were defined. - **Field canonicalization** for background job tracking: * The ``job`` field is now the canonical way to identify background jobs returned by `/sensors//schedules/trigger`, `/assets//schedules/trigger`, and `/sensors//forecasts/trigger` endpoints. If applicable, the triggered response now also returns a ``results-url`` pointing to the sensor-specific results endpoint, alongside the generic ``job-url``. diff --git a/documentation/changelog.rst b/documentation/changelog.rst index f78ee4785a..fc4cc18d7e 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -58,6 +58,7 @@ New features * Commodity contexts that omit grid-connection fields (prices and site capacities) now get smart defaults instead of failing or silently leaving the grid unconstrained — for instance, a bare ``{"commodity": "gas"}`` is treated as having no grid connection; see :ref:`commodity_context_defaults` for the full rules [see `PR #2272 `_] * CLI support for adding/editing account attributes [see `PR #2242 `_] * Improve chart axis domain for event values not around zero, with a per-sub-chart ``y-axis`` option in ``sensors_to_show`` (default ``zero``, which pads the axis out to include zero) that can be set to ``data`` to fit a sub-chart's y-axis to the values shown, to an explicit ``[min, max]`` domain that the axis will cover at least (expanding to fit data beyond it), or to a strict ``{"min": min, "max": max}`` domain that the axis will never exceed (clamping data beyond it, with a warning when that happens), editable from the graph editor [see `PR #2244 `_] +* Sensor references on variable-quantity flex-model and flex-context fields now accept a ``default`` fallback quantity, e.g. ``{"sensor": 50, "default": "0 kWh"}``, filling the time slots for which the referenced sensor holds no value; also settable from the flex-model UI. Note that a fallback of ``0`` on a directional capacity makes that capacity a hard bound wherever the sensor is silent for the whole scheduling window [see `PR #2405 `_] * Breaking behaviour change: the built-in storage fallback scheduler has been retired, so an infeasible storage problem now fails with its failure reason instead of silently saving a fallback schedule; ``FLEXMEASURES_FALLBACK_REDIRECT`` is now only relevant for custom schedulers that define a fallback scheduler [see `PR #2252 `_] * Breaking behaviour change: the specific ``relax-soc-constraints`` and ``relax-site-capacity-constraints`` flags now follow the umbrella ``relax-constraints`` flag (which now defaults to true, see above) unless they are set explicitly, in which case they take precedence for their respective constraints, in either direction (previously, these flags were independent opt-ins defaulting to false); also fixed: explicitly set breach prices (including zero prices) are no longer overwritten with default breach prices when relaxation is enabled, and when only one breach price of the ``soc-minima``/``soc-maxima`` (or site capacity) pair is set explicitly, the other one still gets its default [see `PR #2252 `_] * Extended ``GET /api/v3_0/jobs/`` with a ``result`` field containing ``unresolved`` and ``resolved`` soft state-of-charge constraint analysis (``soc-minima``/``soc-maxima`` violations or satisfied constraints, keyed by asset ID) for scheduling jobs; both arrays are empty when no SoC constraints were defined [see `PR #2072 `_] diff --git a/flexmeasures/data/models/planning/tests/test_utils_fresh_db.py b/flexmeasures/data/models/planning/tests/test_utils_fresh_db.py index d6d7fa3000..a4473409cb 100644 --- a/flexmeasures/data/models/planning/tests/test_utils_fresh_db.py +++ b/flexmeasures/data/models/planning/tests/test_utils_fresh_db.py @@ -10,6 +10,7 @@ from flexmeasures.data.schemas.sensors import SensorReference from flexmeasures.data.models.planning.storage import StorageScheduler from flexmeasures.data.models.planning.utils import get_series_from_quantity_or_sensor +from flexmeasures.utils.unit_utils import ur def test_get_series_from_sensor_reference_source_filter_integration(fresh_db): @@ -88,6 +89,48 @@ def test_get_series_from_sensor_reference_source_filter_integration(fresh_db): assert result_forecaster.iloc[0] == pytest.approx(200.0) +def test_get_series_from_sensor_reference_default_fills_missing_values(fresh_db): + """A SensorReference default fills query slots with no matching sensor belief.""" + query_window = ( + pd.Timestamp("2025-06-01 08:00:00+02:00"), + pd.Timestamp("2025-06-01 08:30:00+02:00"), + ) + source = DataSource(name="test-default-source", type="scheduler") + fresh_db.session.add(source) + asset_type = GenericAssetType(name="test-asset-type-default") + fresh_db.session.add(asset_type) + asset = GenericAsset(name="test-asset-default", generic_asset_type=asset_type) + fresh_db.session.add(asset) + sensor = Sensor( + name="test-sensor-default", + generic_asset=asset, + event_resolution=timedelta(minutes=15), + unit="MW", + ) + fresh_db.session.add(sensor) + fresh_db.session.flush() + fresh_db.session.add( + TimedBelief( + event_start=query_window[0], + belief_horizon=timedelta(0), + event_value=0.1, + source=source, + sensor=sensor, + ) + ) + fresh_db.session.commit() + + result = get_series_from_quantity_or_sensor( + variable_quantity=SensorReference(sensor=sensor, default=ur.Quantity("1 MW")), + query_window=query_window, + resolution=sensor.event_resolution, + unit="kW", + as_instantaneous_events=False, + ) + + assert list(result) == pytest.approx([100.0, 1000.0]) + + def test_get_series_from_sensor_reference_sources_filter_integration(fresh_db): """A :class:`SensorReference` with ``sources`` returns only beliefs from the specified source. diff --git a/flexmeasures/data/models/planning/utils.py b/flexmeasures/data/models/planning/utils.py index ce54cd3df8..87fdfe4944 100644 --- a/flexmeasures/data/models/planning/utils.py +++ b/flexmeasures/data/models/planning/utils.py @@ -326,6 +326,14 @@ def get_series_from_quantity_or_sensor( time_series = convert_units( time_series, variable_quantity.unit, unit, resolution ) + if variable_quantity.default is not None: + default_value = convert_units( + variable_quantity.default.magnitude, + str(variable_quantity.default.units), + unit, + resolution, + ) + time_series = time_series.fillna(default_value) elif isinstance(variable_quantity, Sensor): bdf: tb.BeliefsDataFrame = TimedBelief.search( variable_quantity, diff --git a/flexmeasures/data/schemas/sensors.py b/flexmeasures/data/schemas/sensors.py index ecfde3e581..db4926e93b 100644 --- a/flexmeasures/data/schemas/sensors.py +++ b/flexmeasures/data/schemas/sensors.py @@ -17,6 +17,7 @@ Schema, ValidationError, fields, + post_dump, post_load, pre_load, validates, @@ -24,6 +25,7 @@ ) import marshmallow.validate as validate from pandas.api.types import is_numeric_dtype +from pint.errors import PintError import timely_beliefs as tb from werkzeug.datastructures import FileStorage from marshmallow.validate import Validator @@ -365,6 +367,11 @@ def _serialize(self, value: Sensor, attr, obj, **kwargs) -> int: class VariableQuantityField(MarshmallowClickMixin, fields.Field): + _UNSUPPORTED_VALUE_TYPE_MESSAGE = ( + "Unsupported value type. `{value_type}` was provided but only dict, list, " + "str, pint Quantity, tuple, and numeric values with a default source unit are supported." + ) + def __init__( self, to_unit, @@ -434,20 +441,48 @@ def __init__( @with_appcontext_if_needed() def _deserialize( - self, value: dict[str, int] | list[dict] | str, attr, data, **kwargs - ) -> Sensor | list[dict] | ur.Quantity: + self, + value: ( + dict[str, Any] + | list[dict] + | str + | ur.Quantity + | tuple[Any, ...] + | numbers.Real + ), + attr, + data, + **kwargs, + ) -> Sensor | SensorReference | list[dict] | ur.Quantity: if isinstance(value, dict): - return self._deserialize_dict(value) + return self._deserialize_dict(value, attr, data, **kwargs) elif isinstance(value, list): return self._deserialize_list(value) elif isinstance(value, str): return self._deserialize_str(value) + elif isinstance(value, ur.Quantity): + return value.to(self.to_unit) + elif isinstance(value, tuple): + try: + return ur.Quantity.from_tuple(value).to(self.to_unit) + except (PintError, TypeError, ValueError, AttributeError, IndexError): + if ( + len(value) == 1 + and isinstance(value[0], numbers.Real) + and self.default_src_unit is not None + ): + return self._deserialize_numeric(value[0], attr, data, **kwargs) + if len(value) == 2: + return self._deserialize_str(f"{value[0]} {value[1]}") + raise FMValidationError( + self._UNSUPPORTED_VALUE_TYPE_MESSAGE.format(value_type=type(value)) + ) elif isinstance(value, numbers.Real) and self.default_src_unit is not None: return self._deserialize_numeric(value, attr, data, **kwargs) else: raise FMValidationError( - f"Unsupported value type. `{type(value)}` was provided but only dict, list and str are supported." + self._UNSUPPORTED_VALUE_TYPE_MESSAGE.format(value_type=type(value)) ) _SOURCE_FILTER_KEYS = SENSOR_REFERENCE_SOURCE_FILTER_KEYS @@ -504,13 +539,15 @@ def _deserialize_source_filters(self, value: dict[str, Any]) -> tuple[ return source_types, exclude_source_types, sources, source_account - def _deserialize_dict(self, value: dict[str, Any]) -> Sensor | SensorReference: + def _deserialize_dict( + self, value: dict[str, Any], attr, data, **kwargs + ) -> Sensor | SensorReference: """Deserialize a sensor reference to a Sensor or SensorReference. - Returns a plain :class:`Sensor` when no source filter keys are present - (backward compatible), and a :class:`SensorReference` when any of - ``source-types``, ``exclude-source-types``, ``sources``, or - ``source-account`` are provided. + Returns a plain :class:`Sensor` when no source filter or default keys are + present (backward compatible), and a :class:`SensorReference` when any of + ``source-types``, ``exclude-source-types``, ``sources``, ``source-account`` + or ``default`` are provided. """ if "sensor" not in value: raise FMValidationError("Dictionary provided but `sensor` key not found.") @@ -530,8 +567,12 @@ def _deserialize_dict(self, value: dict[str, Any]) -> Sensor | SensorReference: unit=self.to_unit if not self.to_unit.startswith("/") else None ).deserialize(value["sensor"], None, None) - # If source filter keys are present, return a SensorReference instead of a plain Sensor. - if self._SOURCE_FILTER_KEYS.isdisjoint(value.keys()): + default = None + if "default" in value: + default = self._deserialize_default(value["default"], attr, data, **kwargs) + + # If no source filter or default keys are present, keep returning a plain Sensor. + if self._SOURCE_FILTER_KEYS.isdisjoint(value.keys()) and default is None: return sensor # backward compat: no filters → plain Sensor source_types, exclude_source_types, sources, source_account = ( @@ -543,8 +584,23 @@ def _deserialize_dict(self, value: dict[str, Any]) -> Sensor | SensorReference: exclude_source_types=exclude_source_types, sources=sources, source_account=source_account, + default=default, ) + def _deserialize_default(self, value, attr, data, **kwargs) -> ur.Quantity: + """Deserialize a sensor reference fallback value.""" + if isinstance(value, str): + default = self._deserialize_str(value) + elif isinstance(value, numbers.Real) and self.default_src_unit is not None: + default = self._deserialize_numeric(value, attr, data, **kwargs) + else: + raise FMValidationError( + "Sensor reference `default` must be a quantity string or a numeric value with a known default source unit." + ) + if self.value_validator is not None: + self.value_validator(default) + return default + def _deserialize_list(self, value: list[dict]) -> list[dict]: """Deserialize a time series to a list of timed events.""" if self.return_magnitude is True: @@ -596,6 +652,15 @@ def _serialize( sensor_reference["source-account"] = [ account.id for account in value.source_account ] + if value.default is not None: + # `default` was already resolved to a concrete, compatible unit at + # deserialization time (see _deserialize_default), so for a + # denominator-only to_unit (e.g. "/MWh", not a valid pint unit on + # its own) there is nothing left to convert to. + if self.to_unit.startswith("/"): + sensor_reference["default"] = str(value.default) + else: + sensor_reference["default"] = str(value.default.to(self.to_unit)) return sensor_reference elif isinstance(value, Sensor): return dict(sensor=value.id) @@ -963,13 +1028,13 @@ class QuantitySchema(Schema): @dataclass class SensorReference: - """A sensor reference that wraps a Sensor with optional source filters for belief queries. + """A sensor reference that wraps a Sensor with optional query settings. Exposes the same ``unit``, ``id``, and ``event_resolution`` properties as a plain :class:`~flexmeasures.data.models.time_series.Sensor`, so code that reads those - properties works without modification. The source filters are passed through to - :meth:`TimedBelief.search ` - in :func:`~flexmeasures.data.models.planning.utils.get_series_from_quantity_or_sensor`. + properties works without modification. The source filters and optional default + value are passed through to + :func:`~flexmeasures.data.models.planning.utils.get_series_from_quantity_or_sensor`. """ sensor: Sensor @@ -977,6 +1042,7 @@ class SensorReference: exclude_source_types: list[str] | None = field(default=None) sources: list[DataSource] | None = field(default=None) source_account: list[Account] | None = field(default=None) + default: ur.Quantity | None = field(default=None) @property def unit(self) -> str: @@ -1011,7 +1077,7 @@ class OutputSensorReferenceSchema(SharedSensorReferenceSchema): class SensorReferenceSchema(SharedSensorReferenceSchema): - """Sensor reference with optional source filters.""" + """Sensor reference with optional source filters and fallback value.""" class Meta: description = "Sensor reference from which to look up a variable quantity." @@ -1047,6 +1113,25 @@ class Meta: description="Only use beliefs from data sources linked to these account IDs.", ), ) + default = fields.String( + required=False, + allow_none=False, + metadata=dict( + description="Fallback quantity to use when the referenced sensor has missing values, on variable-quantity flex-model and flex-context fields (such as soc-minima, soc-maxima, the capacity fields and the price fields). Note that every time slot the sensor leaves empty is filled with this value, so a sparse setpoint sensor becomes densely constrained. Take particular care with a fallback of 0 on a consumption-capacity or production-capacity: if the sensor holds no value for the whole scheduling window, the resulting all-zero capacity is read as a physical statement about the device and enforced strictly, rather than as a limit that may be breached at a price. This field is not (yet) applied to inflexible-device references or to forecaster regressors.", + example="0 kWh", + ), + ) + + @post_dump + def remove_unset_default(self, data: dict, **kwargs) -> dict: + """Leave out `default` entirely when the reference does not define one. + + Without this, references that set no fallback would serialize a + `default: None` key, which is not valid input on the way back in. + """ + if data.get("default") is None: + data.pop("default", None) + return data class InflexibleDeviceSchema(SensorReferenceSchema): @@ -1127,9 +1212,21 @@ class TimeSeriesSchema(Schema): fields.Dict, required=True, metadata=dict( - description="Time series specification containing a list of segments that together describe a variable quantity.", + description=( + "Time series specification containing a list of segments that together " + "describe a variable quantity. Each segment may specify either " + "`datetime`, `start` and `end`, `start` and `duration`, or `end` and " + "`duration`." + ), example=[ - {"value": "23 kW", "start": "2025-11-20T15:15+01", "duration": "PT1H"} + {"value": "23 kW", "datetime": "2025-11-20T15:15+01"}, + { + "value": "24 kW", + "start": "2025-11-20T16:00+01", + "end": "2025-11-20T17:00+01", + }, + {"value": "25 kW", "start": "2025-11-20T17:00+01", "duration": "PT1H"}, + {"value": "26 kW", "end": "2025-11-20T19:00+01", "duration": "PT1H"}, ], ), ) diff --git a/flexmeasures/data/schemas/tests/test_scheduling.py b/flexmeasures/data/schemas/tests/test_scheduling.py index e08160bdd3..c30714e9ea 100644 --- a/flexmeasures/data/schemas/tests/test_scheduling.py +++ b/flexmeasures/data/schemas/tests/test_scheduling.py @@ -816,7 +816,7 @@ def check_schema_loads_data(schema, data, fails): ( {"site-power-capacity": 100}, { - "site-power-capacity": f"Unsupported value type. `{type(100)}` was provided but only dict, list and str are supported." + "site-power-capacity": f"Unsupported value type. `{type(100)}` was provided but only dict, list, str, pint Quantity, tuple, and numeric values with a default source unit are supported." }, ), ( diff --git a/flexmeasures/data/schemas/tests/test_sensor.py b/flexmeasures/data/schemas/tests/test_sensor.py index f41805cb9a..93df41616c 100644 --- a/flexmeasures/data/schemas/tests/test_sensor.py +++ b/flexmeasures/data/schemas/tests/test_sensor.py @@ -5,6 +5,7 @@ from flexmeasures.data.schemas.sensors import ( QuantityOrSensor, SensorReference, + SensorReferenceSchema, VariableQuantityField, floor_bdf_event_starts, ) @@ -220,6 +221,41 @@ def test_sensor_reference_backward_compatible(setup_dummy_sensors): assert result.id == sensor1.id +def test_sensor_reference_with_default(setup_dummy_sensors): + """``{"sensor": , "default": ...}`` deserializes to a SensorReference.""" + sensor1, _, _, _ = setup_dummy_sensors + field = VariableQuantityField(to_unit="MWh", return_magnitude=False) + + result = field.deserialize({"sensor": sensor1.id, "default": "500 kWh"}) + + assert isinstance(result, SensorReference) + assert result.sensor == sensor1 + assert result.default == ur.Quantity("0.5 MWh") + + +def test_sensor_reference_schema_rejects_null_default(setup_dummy_sensors): + sensor1, _, _, _ = setup_dummy_sensors + + with pytest.raises(ValidationError) as exc_info: + SensorReferenceSchema().load({"sensor": sensor1.id, "default": None}) + + assert "default" in exc_info.value.messages + + +def test_sensor_reference_field_rejects_null_default(setup_dummy_sensors): + """``default`` must be a concrete fallback quantity when provided.""" + sensor1, _, _, _ = setup_dummy_sensors + field = VariableQuantityField(to_unit="MWh", return_magnitude=False) + + with pytest.raises(ValidationError) as exc_info: + field.deserialize({"sensor": sensor1.id, "default": None}) + + assert ( + "Sensor reference `default` must be a quantity string or a numeric value with a known default source unit." + in str(exc_info.value) + ) + + def test_sensor_reference_with_source_types(setup_dummy_sensors): """``{"sensor": , "source-types": [...]}`` deserializes to a :class:`SensorReference`. @@ -342,6 +378,18 @@ def test_sensor_reference_serialization_preserves_source_filters( } +def test_sensor_reference_serialization_preserves_default(setup_dummy_sensors): + sensor1, _, _, _ = setup_dummy_sensors + field = VariableQuantityField(to_unit="MWh", return_magnitude=False) + source_reference = field.deserialize({"sensor": sensor1.id, "default": "500 kWh"}) + + assert isinstance(source_reference, SensorReference) + assert serialize_variable_quantity(source_reference) == { + "sensor": sensor1.id, + "default": "0.5 MWh", + } + + def test_sensor_reference_filters_are_kept_per_reference( setup_dummy_sensors, setup_sources, db ): diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 9335fae673..006541b145 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -5030,6 +5030,11 @@ "items": { "type": "integer" } + }, + "default": { + "type": "string", + "description": "Fallback quantity to use when the referenced sensor has missing values, on variable-quantity flex-model and flex-context fields (such as soc-minima, soc-maxima, the capacity fields and the price fields). Note that every time slot the sensor leaves empty is filled with this value, so a sparse setpoint sensor becomes densely constrained. Take particular care with a fallback of 0 on a consumption-capacity or production-capacity: if the sensor holds no value for the whole scheduling window, the resulting all-zero capacity is read as a physical statement about the device and enforced strictly, rather than as a limit that may be breached at a price. This field is not (yet) applied to inflexible-device references or to forecaster regressors.", + "example": "0 kWh" } }, "required": [ @@ -5039,11 +5044,25 @@ }, "TimeSeries": { "type": "array", - "description": "Time series specification containing a list of segments that together describe a variable quantity.", + "description": "Time series specification containing a list of segments that together describe a variable quantity. Each segment may specify either `datetime`, `start` and `end`, `start` and `duration`, or `end` and `duration`.", "example": [ { "value": "23 kW", - "start": "2025-11-20T15:15+01", + "datetime": "2025-11-20T15:15+01" + }, + { + "value": "24 kW", + "start": "2025-11-20T16:00+01", + "end": "2025-11-20T17:00+01" + }, + { + "value": "25 kW", + "start": "2025-11-20T17:00+01", + "duration": "PT1H" + }, + { + "value": "26 kW", + "end": "2025-11-20T19:00+01", "duration": "PT1H" } ], @@ -5165,6 +5184,11 @@ "items": { "type": "integer" } + }, + "default": { + "type": "string", + "description": "Fallback quantity to use when the referenced sensor has missing values, on variable-quantity flex-model and flex-context fields (such as soc-minima, soc-maxima, the capacity fields and the price fields). Note that every time slot the sensor leaves empty is filled with this value, so a sparse setpoint sensor becomes densely constrained. Take particular care with a fallback of 0 on a consumption-capacity or production-capacity: if the sensor holds no value for the whole scheduling window, the resulting all-zero capacity is read as a physical statement about the device and enforced strictly, rather than as a limit that may be breached at a price. This field is not (yet) applied to inflexible-device references or to forecaster regressors.", + "example": "0 kWh" } }, "required": [ diff --git a/flexmeasures/ui/templates/assets/asset_properties.html b/flexmeasures/ui/templates/assets/asset_properties.html index 61a141eb82..7fd32d79cd 100644 --- a/flexmeasures/ui/templates/assets/asset_properties.html +++ b/flexmeasures/ui/templates/assets/asset_properties.html @@ -607,14 +607,18 @@ const card = activeCard(); const name = card.id.replace('-control', ''); const flexModel = getFlexModel() + const sensorReference = { "sensor": sensorId }; + const defaultInput = document.getElementById('flexSensorDefaultInput'); + if (defaultInput && defaultInput.value.trim()) { + sensorReference.default = defaultInput.value.trim(); + } if (FlexModelFieldValidTypes[name].includes(Array)) { const valueIndex = selectedIndex() const fieldValues = flexModel[name]; - fieldValues[valueIndex] = { "sensor": sensorId }; + fieldValues[valueIndex] = sensorReference; flexModel[name] = fieldValues; } else { - const value = flexModel[name]; - flexModel[name] = { "sensor": sensorId }; + flexModel[name] = sensorReference; } setFlexModel(flexModel); @@ -1120,6 +1124,7 @@ tabContent.querySelector('.flex-input-group').appendChild(boolTabContentSaveBtn); } else if (dataType == Object) { // Object/Senosr Tab ================= btn.textContent = 'A sensor'; + const supportsSensorDefault = FlexModelFieldValidTypes[name].includes(Object); tabContent.innerHTML = `
@@ -1154,8 +1159,54 @@
+ ${supportsSensorDefault ? ` + + + ` : ''} `; + if (supportsSensorDefault) { + const defaultInput = tabContent.querySelector('#flexSensorDefaultInput'); + defaultInput.value = value && typeof value === 'object' && value.default + ? value.default + : ''; + + const saveDefaultButton = document.createElement('button'); + saveDefaultButton.className = 'btn btn-secondary btn-sm me-2 mt-2'; + saveDefaultButton.textContent = 'Use fallback'; + saveDefaultButton.onclick = function () { + const currentFlexModel = getFlexModel(); + let sensorReference = currentFlexModel[name]; + if (Array.isArray(sensorReference)) { + sensorReference = sensorReference[selectedIndex()]; + } + if (!sensorReference || typeof sensorReference !== 'object' || !sensorReference.sensor) { + showToast("Select a sensor before setting a fallback", "info"); + return; + } + + const fallback = defaultInput.value.trim(); + if (fallback) { + sensorReference.default = fallback; + } else { + delete sensorReference.default; + } + setFlexModel(currentFlexModel); + }; + tabContent.querySelector('.flex-input-group').appendChild(saveDefaultButton); + } } else if (dataType == String) { // String Tab ================= btn.textContent = 'Fixed value';