diff --git a/custom_components/tibber_prices/services/get_apexcharts_yaml.py b/custom_components/tibber_prices/services/get_apexcharts_yaml.py index b04f325..019505f 100644 --- a/custom_components/tibber_prices/services/get_apexcharts_yaml.py +++ b/custom_components/tibber_prices/services/get_apexcharts_yaml.py @@ -138,11 +138,11 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: f"service: 'get_chartdata', " f"return_response: true, " f"service_data: {{ entry_id: '{entry_id}', day: ['{day}'], {filter_param}, " - f"output_format: 'array_of_arrays', insert_nulls: 'segments', minor_currency: true }} }}); " + f"output_format: 'array_of_arrays', insert_nulls: 'segments', minor_currency: true, " + f"connect_segments: true }} }}); " f"return response.response.data;" ) - # Only show extremas for HIGH and LOW levels (not NORMAL) - show_extremas = level_key != "NORMAL" + # All series use same configuration (no extremas on data_generator series) series.append( { "entity": sample_entity or "sensor.tibber_prices", @@ -150,12 +150,16 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: "type": "area", "color": color, "yaxis_id": "price", - "show": {"extremas": show_extremas, "legend_value": False}, + "show": {"legend_value": False}, "data_generator": data_generator, "stroke_width": 1, } ) + # Note: Extrema markers don't work with data_generator approach + # ApexCharts requires entity time-series data for extremas feature + # Min/Max sensors are single values, not time-series + # Get translated title based on level_type title_key = "title_rating_level" if level_type == "rating_level" else "title_level" title = get_translation(["apexcharts", title_key], user_language) or ( @@ -181,7 +185,7 @@ async def handle_apexcharts_yaml(call: ServiceCall) -> dict[str, Any]: "header": { "show": True, "title": title, - "show_states": False, + "show_states": True, }, "apex_config": { "chart": { diff --git a/custom_components/tibber_prices/services/get_chartdata.py b/custom_components/tibber_prices/services/get_chartdata.py index ec76e1f..7aa3901 100644 --- a/custom_components/tibber_prices/services/get_chartdata.py +++ b/custom_components/tibber_prices/services/get_chartdata.py @@ -380,44 +380,32 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091 ) if connect_segments and next_price is not None: - # Connect segments visually by adding transition points - # Convert next price for comparison and use + # Connect segments visually by adding bridge point + NULL + # Bridge point: extends current series to boundary with next price + # NULL point: stops series so it doesn't continue into next segment + converted_next_price = ( round(next_price * 100, 2) if minor_currency else round(next_price, 4) ) if round_decimals is not None: converted_next_price = round(converted_next_price, round_decimals) - if next_price < price: - # Price goes DOWN: Add point at end of current segment with lower price - # This draws the line downward from current level - connect_point = { - start_time_field: next_start_serialized, - price_field: converted_next_price, - } - if include_level and "level" in interval: - connect_point[level_field] = interval["level"] - if include_rating_level and "rating_level" in interval: - connect_point[rating_level_field] = interval["rating_level"] - if include_average and day in day_averages: - connect_point[average_field] = day_averages[day] - chart_data.append(connect_point) - else: - # Price goes UP or stays same: Add hold point with current price - # This extends the current level to the boundary before the gap - hold_point = { - start_time_field: next_start_serialized, - price_field: converted_price, - } - if include_level and "level" in interval: - hold_point[level_field] = interval["level"] - if include_rating_level and "rating_level" in interval: - hold_point[rating_level_field] = interval["rating_level"] - if include_average and day in day_averages: - hold_point[average_field] = day_averages[day] - chart_data.append(hold_point) + # 1. Bridge point: boundary with next price, still current level + # This makes the line go up/down to meet the next series + bridge_point = { + start_time_field: next_start_serialized, + price_field: converted_next_price, + } + if include_level and "level" in interval: + bridge_point[level_field] = interval["level"] + if include_rating_level and "rating_level" in interval: + bridge_point[rating_level_field] = interval["rating_level"] + if include_average and day in day_averages: + bridge_point[average_field] = day_averages[day] + chart_data.append(bridge_point) - # Add NULL point to create gap after transition + # 2. NULL point: stops the current series + # Without this, ApexCharts continues drawing within the series null_point = {start_time_field: next_start_serialized, price_field: None} chart_data.append(null_point) else: @@ -580,11 +568,13 @@ async def handle_chartdata(call: ServiceCall) -> dict[str, Any]: # noqa: PLR091 ) ) - # Remove trailing null values from chart_data (for proper ApexCharts header display). + # Remove trailing null values ONLY for insert_nulls='segments' mode. + # For 'all' mode, trailing nulls are intentional (show no-match until end of day). + # For 'segments' mode, trailing nulls cause ApexCharts header to show "N/A". # Internal nulls at segment boundaries are preserved for gap visualization. - # Only trailing nulls cause issues with in_header showing "N/A". - while chart_data and chart_data[-1].get(price_field) is None: - chart_data.pop() + if insert_nulls == "segments": + while chart_data and chart_data[-1].get(price_field) is None: + chart_data.pop() # Convert to array of arrays format if requested if output_format == "array_of_arrays": diff --git a/tests/services/__init__.py b/tests/services/__init__.py new file mode 100644 index 0000000..44b4876 --- /dev/null +++ b/tests/services/__init__.py @@ -0,0 +1 @@ +"""Tests for services package.""" diff --git a/tests/test_connect_segments.py b/tests/services/test_connect_segments.py similarity index 76% rename from tests/test_connect_segments.py rename to tests/services/test_connect_segments.py index b953c01..35aa068 100644 --- a/tests/test_connect_segments.py +++ b/tests/services/test_connect_segments.py @@ -162,7 +162,7 @@ class TestPriceConversion: class TestTrailingNullRemoval: - """Test trailing null value removal for ApexCharts header display.""" + """Test trailing null value removal for ApexCharts header display (segments mode only).""" def test_trailing_nulls_removed(self) -> None: """Test that trailing null values are removed from chart_data.""" @@ -225,3 +225,66 @@ class TestTrailingNullRemoval: chart_data.pop() assert chart_data == [], "Empty data should remain empty" + + +class TestTrailingNullModeSpecific: + """Test that trailing null removal respects insert_nulls mode.""" + + def test_segments_mode_removes_trailing_nulls(self) -> None: + """Test that insert_nulls='segments' removes trailing nulls for ApexCharts header fix.""" + price_field = "price_per_kwh" + insert_nulls = "segments" + chart_data = [ + {"start_time": "2025-12-01T00:00:00", price_field: 10.0}, + {"start_time": "2025-12-01T00:15:00", price_field: 12.0}, + {"start_time": "2025-12-01T00:30:00", price_field: None}, # Trailing null + {"start_time": "2025-12-01T00:45:00", price_field: None}, # Trailing null + ] + + # Simulate the conditional trailing null removal + if insert_nulls == "segments": + while chart_data and chart_data[-1].get(price_field) is None: + chart_data.pop() + + assert len(chart_data) == 2, "Segments mode should remove trailing nulls" + assert chart_data[-1][price_field] == 12.0, "Last item should be last non-null price" + + def test_all_mode_preserves_trailing_nulls(self) -> None: + """Test that insert_nulls='all' preserves trailing nulls (intentional gaps).""" + price_field = "price_per_kwh" + insert_nulls = "all" + chart_data = [ + {"start_time": "2025-12-01T00:00:00", price_field: 10.0}, + {"start_time": "2025-12-01T00:15:00", price_field: 12.0}, + {"start_time": "2025-12-01T00:30:00", price_field: None}, # Intentional gap + {"start_time": "2025-12-01T00:45:00", price_field: None}, # Intentional gap + ] + + original_length = len(chart_data) + + # Simulate the conditional trailing null removal + if insert_nulls == "segments": + while chart_data and chart_data[-1].get(price_field) is None: + chart_data.pop() + + assert len(chart_data) == original_length, "'all' mode should preserve trailing nulls" + assert chart_data[-1][price_field] is None, "Last item should remain null" + + def test_none_mode_no_trailing_nulls_expected(self) -> None: + """Test that insert_nulls='none' has no trailing nulls by design.""" + price_field = "price_per_kwh" + insert_nulls = "none" + # In 'none' mode, nulls are never inserted, so no trailing nulls exist + chart_data = [ + {"start_time": "2025-12-01T00:00:00", price_field: 10.0}, + {"start_time": "2025-12-01T00:15:00", price_field: 12.0}, + ] + + original_length = len(chart_data) + + # Simulate the conditional trailing null removal + if insert_nulls == "segments": + while chart_data and chart_data[-1].get(price_field) is None: + chart_data.pop() + + assert len(chart_data) == original_length, "'none' mode should have no nulls to remove"