From fd5dfcb8fc979c735a8173868e173369026b77e5 Mon Sep 17 00:00:00 2001 From: Christian Ruschke Date: Sat, 6 Dec 2025 19:06:29 +0100 Subject: [PATCH 1/4] Update changes --- apps/tibberprice/README.md | 92 ++ apps/tibberprice/manifest.yaml | 6 + apps/tibberprice/screenshot.png | Bin 0 -> 11118 bytes apps/tibberprice/tibberprice.star | 1571 +++++++++++++++++++++++++++++ 4 files changed, 1669 insertions(+) create mode 100644 apps/tibberprice/README.md create mode 100644 apps/tibberprice/manifest.yaml create mode 100644 apps/tibberprice/screenshot.png create mode 100644 apps/tibberprice/tibberprice.star diff --git a/apps/tibberprice/README.md b/apps/tibberprice/README.md new file mode 100644 index 000000000..925bc4019 --- /dev/null +++ b/apps/tibberprice/README.md @@ -0,0 +1,92 @@ +# Tibber Price Forecast + +![Tibber Price Forecast](screenshot.png) + +Display real-time electricity price forecast and consumption intelligence from [Tibber](https://tibber.com/) on your Tidbyt. + +## Features + +- πŸ“Š **30-Minute Granularity**: 60 half-hourly bars showing prices from midnight today through tomorrow morning +- 🎨 **Gradient Price Coloring**: Smooth color transitions from green (cheap) to red (expensive) matching the Tibber mobile app +- πŸ’‘ **Consumption Intelligence**: Smart efficiency color coding shows when you're using power wisely +- πŸ”„ **Smart Caching**: Reliable display with automatic fallback if API is temporarily unavailable + +## Configuration + +### Step 1: Get Your Tibber API Token + +1. Visit [Tibber Developer Portal](https://developer.tibber.com/) +2. Log in with your Tibber account +3. Generate a personal access token (it's free!) +4. Copy the token - you'll need it in Step 3 + +### Step 2: Install the App + +1. Open the Tidbyt mobile app on your phone +2. Tap **+** to add a new app +3. Search for "Tibber Price Forecast" +4. Tap to install + +### Step 3: Configure Your Token + +1. When prompted, paste your Tibber API token +2. The app will automatically load your electricity data +3. Your Tidbyt will start displaying your price forecast! + +## What You'll See + +### Upper Chart: Price Forecast +Shows electricity prices for the next 30 hours with gradient coloring: +- 🟒 **Green**: Cheapest prices (0-30th percentile) +- 🟑 **Yellow**: Average prices (30-70th percentile) +- πŸ”΄ **Red**: Most expensive prices (70-100th percentile) + +The gradient smoothly transitions through the spectrum, with bright contour lines highlighting price peaks for easy identification. + +### Lower Chart: Consumption (Past Hours Only) +Shows your actual electricity usage with intelligent efficiency coloring: +- 🟒 **Green**: Excellent - Low usage or usage during cheap prices +- 🟑 **Yellow**: Moderate efficiency +- πŸ”΄ **Red**: Consider shifting - High usage during expensive prices + +Future hours show gray bars since consumption data isn't available yet. + +## Requirements + +- Active Tibber subscription +- Tibber API token (free from [developer.tibber.com](https://developer.tibber.com/)) +- Tidbyt device + +## Supported Countries + +This app works with Tibber in: +- πŸ‡³πŸ‡΄ Norway +- πŸ‡ΈπŸ‡ͺ Sweden +- πŸ‡©πŸ‡ͺ Germany +- πŸ‡³πŸ‡± Netherlands + +## Technical Details + +- Updates hourly with smart caching +- Uses Tibber's quarter-hourly price API for precise 30-minute intervals +- Consumption data split from hourly to 30-minute slots +- Handles PV systems and grid export (negative consumption) +- Graceful fallback with stale data indicator if API is unavailable + +## About + +Created by [cruschke](https://github.com/cruschke) + +**Links:** +- [Source Code](https://github.com/cruschke/tidbyt-tibber) +- [Developer Documentation](https://github.com/cruschke/tidbyt-tibber/blob/main/README.dev.md) +- [Tibber](https://tibber.com/) +- [Tibber Developer Portal](https://developer.tibber.com/) + +## License + +Apache-2.0 License - Free to use and modify + +--- + +**Tip**: The app works great for timing energy-intensive tasks like EV charging, washing machines, or dishwashers to run during cheap price periods! diff --git a/apps/tibberprice/manifest.yaml b/apps/tibberprice/manifest.yaml new file mode 100644 index 000000000..b2d2fe1a8 --- /dev/null +++ b/apps/tibberprice/manifest.yaml @@ -0,0 +1,6 @@ +--- +id: tibberprice +name: Tibber Price +author: cruschke +summary: Tibber price forecast +desc: Electricity price forecast for Tibber customers, designed to match the look and feel of the official Tibber mobile app. See when power is cheap and save money! \ No newline at end of file diff --git a/apps/tibberprice/screenshot.png b/apps/tibberprice/screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..855b8d015b50b52fdf3cc38447f82b87821dca95 GIT binary patch literal 11118 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 literal 0 HcmV?d00001 diff --git a/apps/tibberprice/tibberprice.star b/apps/tibberprice/tibberprice.star new file mode 100644 index 000000000..56ebb4f1a --- /dev/null +++ b/apps/tibberprice/tibberprice.star @@ -0,0 +1,1571 @@ +""" +Tibber Price Forecast App for Tidbyt + +Displays hourly electricity price forecast from Tibber with color-coded bars. + +Author: cruschke +Repository: https://github.com/cruschke/tidbyt-tibber +""" + +load("cache.star", "cache") +load("encoding/json.star", "json") +load("http.star", "http") +load("render.star", "render") +load("schema.star", "schema") +load("time.star", "time") + +# Constants +TIBBER_API_URL = "https://api.tibber.com/v1-beta/gql" +TIBBER_DEMO_TOKEN = "3A77EECF61BD445F47241A5A36202185C35AF3AF58609E19B53F3A8872AD7BE1-1" +CACHE_TTL_FRESH = 3600 # 1 hour +CACHE_TTL_STALE = 21600 # 6 hours + +# Color scheme (Tibber's official colors) +COLOR_CHEAP = "#00D88A" # Green +COLOR_NORMAL = "#FFCC00" # Yellow +COLOR_EXPENSIVE = "#FF9500" # Orange +COLOR_VERY_EXPENSIVE = "#FF3B30" # Red +COLOR_UNKNOWN = "#808080" # Gray fallback + +def build_tibber_query(): + """ + Build GraphQL query for Tibber API. + + Requests quarter-hourly (15-minute) prices and hourly consumption data. + - Prices: QUARTER_HOURLY resolution (96 entries per day) + - Consumption: HOURLY resolution (24 entries per day) + + Test case: + - Expected: Query contains "priceInfo(resolution: QUARTER_HOURLY)" + - Expected: Query contains "consumption(resolution: HOURLY, last: 24)" + - Expected: Query includes nodes { from, to, consumption, consumptionUnit } + - Note: consumption is on homes level, not currentSubscription level + + Returns: + String containing GraphQL query + """ + return """{ + viewer { + homes { + currentSubscription { + priceInfo(resolution: QUARTER_HOURLY) { + today { + total + level + startsAt + } + tomorrow { + total + level + startsAt + } + } + } + consumption(resolution: HOURLY, last: 24) { + nodes { + from + to + consumption + consumptionUnit + } + } + } + } +}""" + +def fetch_tibber_prices(api_token): + """ + Fetch price data from Tibber GraphQL API. + + Args: + api_token: Tibber API authentication token + + Returns: + Dict with structure: + { + "success": bool, + "data": dict or None, + "error_code": str or None, + "error_message": str or None + } + """ + + # Build request + query = build_tibber_query() + headers = { + "Authorization": "Bearer " + api_token, + "Content-Type": "application/json", + } + body = json.encode({"query": query}) + + # Make API request + response = http.post( + url = TIBBER_API_URL, + headers = headers, + body = body, + ttl_seconds = 0, # Don't cache HTTP requests (we have our own caching) + ) + + # Handle HTTP errors + if response.status_code == 401: + return { + "success": False, + "data": None, + "error_code": "unauthorized", + "error_message": "Invalid API token", + } + elif response.status_code == 429: + return { + "success": False, + "data": None, + "error_code": "rate_limited", + "error_message": "Rate limit exceeded", + } + elif response.status_code >= 500: + return { + "success": False, + "data": None, + "error_code": "server_error", + "error_message": "Tibber API server error", + } + elif response.status_code != 200: + return { + "success": False, + "data": None, + "error_code": "unknown", + "error_message": "HTTP " + str(response.status_code), + } + + # Parse response + data = response.json() + + # Check for GraphQL errors + if data.get("errors"): + error_msg = data["errors"][0].get("message", "Unknown GraphQL error") + return { + "success": False, + "data": None, + "error_code": "graphql_error", + "error_message": error_msg, + } + + # Extract price data with safe navigation + if not data.get("data"): + return { + "success": False, + "data": None, + "error_code": "parse_error", + "error_message": "No data in response", + } + + viewer = data["data"].get("viewer") + if not viewer: + return { + "success": False, + "data": None, + "error_code": "parse_error", + "error_message": "No viewer in response", + } + + homes = viewer.get("homes") + if not homes or len(homes) == 0: + return { + "success": False, + "data": None, + "error_code": "no_homes", + "error_message": "No homes found in Tibber account", + } + + subscription = homes[0].get("currentSubscription") + if not subscription: + return { + "success": False, + "data": None, + "error_code": "parse_error", + "error_message": "No subscription found", + } + + price_info = subscription.get("priceInfo") + if not price_info: + return { + "success": False, + "data": None, + "error_code": "parse_error", + "error_message": "No price info found", + } + + # Extract consumption data from homes level (optional) + consumption_data = homes[0].get("consumption") + + return { + "success": True, + "data": { + "priceInfo": price_info, + "consumption": consumption_data, + }, + "error_code": None, + "error_message": None, + } + +def get_cache_key(api_token): + """ + Generate cache key from API token. + + Args: + api_token: API token string + + Returns: + Cache key string + """ + + # Use hash to avoid storing token in cache key + # Starlark doesn't have hash(), so use first/last chars as simple key + if len(api_token) > 8: + key_part = api_token[:4] + api_token[-4:] + else: + key_part = api_token + return "tibber:prices:" + key_part + +def get_cached_prices(api_token): + """ + Get prices with dual-cache strategy (fresh + stale fallback). + + Args: + api_token: Tibber API token + + Returns: + Dict with structure: + { + "success": bool, + "data": dict or None, + "cache_status": "fresh" | "cached" | "stale" | "error", + "error_code": str or None, + "error_message": str or None + } + """ + cache_key = get_cache_key(api_token) + stale_key = cache_key + ":stale" + + # Try fresh cache first + cached_data = cache.get(cache_key) + if cached_data: + data = json.decode(cached_data) + return { + "success": True, + "data": data, + "cache_status": "cached", + "error_code": None, + "error_message": None, + } + + # Cache miss - fetch from API + result = fetch_tibber_prices(api_token) + + if result["success"]: + # Cache the successful result + data_json = json.encode(result["data"]) + cache.set(cache_key, data_json, ttl_seconds = CACHE_TTL_FRESH) + cache.set(stale_key, data_json, ttl_seconds = CACHE_TTL_STALE) + + return { + "success": True, + "data": result["data"], + "cache_status": "fresh", + "error_code": None, + "error_message": None, + } + + # API failed - try stale cache + stale_data = cache.get(stale_key) + if stale_data: + data = json.decode(stale_data) + return { + "success": True, + "data": data, + "cache_status": "stale", + "error_code": None, + "error_message": None, + } + + # No cache available - return error + return { + "success": False, + "data": None, + "cache_status": "error", + "error_code": result["error_code"], + "error_message": result["error_message"], + } + +def calculate_current_slot(timezone): + """ + Calculate current 30-minute slot index (0-47) based on time. + + Slot mapping: + - Slot 0: 00:00-00:30 + - Slot 1: 00:30-01:00 + - Slot 28: 14:00-14:30 + - Slot 29: 14:30-15:00 + - Slot 47: 23:30-00:00 + + Args: + timezone: Timezone string for time.now() + + Returns: + Slot index (0-47) + + Examples: + 14:25 β†’ slot 28 (14*2 + 0) + 14:35 β†’ slot 29 (14*2 + 1) + """ + now = time.now().in_location(timezone) + hour = now.hour + minute = now.minute + + # Calculate slot: hour * 2 + (1 if minute >= 30 else 0) + slot = hour * 2 + if minute >= 30: + slot = slot + 1 + + return slot + +def slot_to_hour_minute(slot): + """ + Convert slot index (0-47) to (hour, minute) tuple. + + Args: + slot: Slot index (0-47) + + Returns: + Tuple of (hour, minute) where minute is 0 or 30 + + Examples: + 0 β†’ (0, 0) + 1 β†’ (0, 30) + 28 β†’ (14, 0) + 29 β†’ (14, 30) + 47 β†’ (23, 30) + """ + hour = slot // 2 + minute = 0 if slot % 2 == 0 else 30 + return (hour, minute) + +def hour_minute_to_slot(hour, minute): + """ + Convert hour and minute to slot index (0-47). + + Args: + hour: Hour (0-23) + minute: Minute (0-59) + + Returns: + Slot index (0-47) + + Examples: + (14, 0) β†’ 28 + (14, 25) β†’ 28 + (14, 30) β†’ 29 + (14, 45) β†’ 29 + """ + slot = hour * 2 + if minute >= 30: + slot = slot + 1 + return slot + +def parse_time_from_timestamp(timestamp): + """ + Extract hour and minute from ISO 8601 timestamp. + + Args: + timestamp: ISO 8601 string like "2025-12-05T14:30:00+01:00" + + Returns: + Tuple of (hour, minute) as integers + + Examples: + "2025-12-05T14:00:00+01:00" β†’ (14, 0) + "2025-12-05T14:30:00+01:00" β†’ (14, 30) + """ + + # Parse timestamp - format: YYYY-MM-DDTHH:MM:SS+TZ + parts = timestamp.split("T") + if len(parts) < 2: + return (0, 0) + + time_parts = parts[1].split(":") + if len(time_parts) < 2: + return (0, 0) + + hour = int(time_parts[0]) + minute = int(time_parts[1]) + return (hour, minute) + +def parse_hour_from_timestamp(timestamp): + """ + Extract hour from ISO 8601 timestamp. + DEPRECATED: Use parse_time_from_timestamp() for new code. + + Args: + timestamp: ISO 8601 string like "2025-12-05T14:00:00+01:00" + + Returns: + Hour as integer (0-23) + """ + hour, _ = parse_time_from_timestamp(timestamp) + return hour + +def parse_price_point(raw_point): + """ + Parse a single quarter-hourly price point into a 30-minute slot entry. + + Tibber API provides 15-minute resolution (96 entries/day) via QUARTER_HOURLY. + We select only :00 and :30 minute entries to create 48 half-hourly slots. + + Args: + raw_point: Dict with keys: total, level, startsAt + + Returns: + Dict or None: + - Dict with slot data if minute is 0 or 30 + - None if minute is 15 or 45 (filtered out) + + Example: + Input: {"total": 0.25, "level": "CHEAP", "startsAt": "2025-12-05T14:00:00+01:00"} + Output: {"price": 0.25, "level": "CHEAP", "slot": 28, "hour": 14, "minute": 0} + + Input: {"total": 0.27, "level": "CHEAP", "startsAt": "2025-12-05T14:15:00+01:00"} + Output: None (filtered - we only want :00 and :30) + """ + hour, minute = parse_time_from_timestamp(raw_point["startsAt"]) + + # Only process :00 and :30 entries (skip :15 and :45 for 30-minute granularity) + if minute not in [0, 30]: + return None + + price = raw_point["total"] + level = raw_point["level"] + slot = hour * 2 + (1 if minute >= 30 else 0) + + return { + "price": price, + "level": level, + "slot": slot, + "hour": hour, + "minute": minute, + } + +def parse_tibber_response(response_data): + """ + Parse Tibber API response into forecast data structure. + + Processes quarter-hourly price data (96 entries) and selects only + :00 and :30 entries to create 48 half-hourly slots. + + Test cases: + - Input: response_data with priceInfo (today/tomorrow) and consumption + - Expected: Returns dict with "prices" (48 slots from 96 quarter-hourly) and "currency" keys + - Input: response_data with consumption nodes + - Expected: Returns dict with "consumption" key mapping slot β†’ kWh + - Edge case: Empty consumption array returns empty dict + + Args: + response_data: Dict with keys: priceInfo, consumption (optional) + + Returns: + Dict with structure: + { + "prices": [{"price": float, "level": str, "slot": int, "hour": int, "minute": int}], + "currency": str, + "consumption": {slot: kWh_value, ...} + } + """ + prices = [] + + # Extract priceInfo from response_data + price_info = response_data.get("priceInfo", response_data) + + # Parse today's quarter-hourly prices and filter to 30-minute slots + # API returns 96 entries (15-min), we select only :00 and :30 β†’ 48 slots + if price_info.get("today"): + for point in price_info["today"]: + # parse_price_point returns dict or None (filters :15 and :45 entries) + slot_data = parse_price_point(point) + if slot_data: # Only add if not filtered out + prices.append(slot_data) + + # Parse tomorrow's prices with offset slots (48-95) + # This allows showing a rolling 60-slot window that spans into tomorrow + if price_info.get("tomorrow"): + for point in price_info["tomorrow"]: + slot_data = parse_price_point(point) + if slot_data: + # Offset tomorrow's slots by 48 to continue from today + slot_data["slot"] = slot_data["slot"] + 48 + prices.append(slot_data) + + # Parse consumption data from top level + consumption_dict = parse_consumption_data(response_data.get("consumption")) + + return { + "prices": prices, + "currency": "NOK", # Default, could be detected from first home + "consumption": consumption_dict, + } + +def parse_consumption_data(consumption_response): + """ + Parse consumption data from Tibber API response. + + Converts 24 hourly consumption values into 48 half-hourly slots + by dividing each hour's consumption equally between the two 30-minute periods. + + Test cases: + - Input: 24 consumption nodes with ISO timestamps + - Expected: Returns dict mapping slot (0-47) β†’ kWh value (hourly/2) + - Edge case: Empty consumption array returns {} + - Edge case: Malformed timestamps returns {} + - Example: Input "2025-12-05T14:00:00.000+01:00" with 1.2 kWh + β†’ slot 28: 0.6 kWh, slot 29: 0.6 kWh + + Args: + consumption_response: Dict with "nodes" array or None + + Returns: + Dict mapping slot (0-47) to consumption in kWh + Example: {0: 0.6, 1: 0.6, 2: 0.4, 3: 0.4, ...} + """ + if not consumption_response: + return {} + + nodes = consumption_response.get("nodes", []) + if not nodes: + return {} + + consumption_by_slot = {} + for node in nodes: + # Extract hour from "from" timestamp + timestamp = node.get("from", "") + if not timestamp: + continue + + hour, _ = parse_time_from_timestamp(timestamp) + consumption_value = node.get("consumption") + + if consumption_value != None: + # Split hourly consumption into 2 slots (each gets half) + half_hourly_value = float(consumption_value) / 2.0 + + slot1 = hour * 2 # HH:00-HH:30 + slot2 = hour * 2 + 1 # HH:30-HH+1:00 + + consumption_by_slot[slot1] = half_hourly_value + consumption_by_slot[slot2] = half_hourly_value + + return consumption_by_slot + +def get_color_for_level(level): + """ + Map Tibber price level to color hex code. + + DEPRECATED: Use calculate_gradient_color() for smooth gradient coloring. + This function is kept for backward compatibility and potential fallback scenarios. + + Args: + level: Price level string (CHEAP, NORMAL, EXPENSIVE, VERY_EXPENSIVE) + + Returns: + Hex color code string + """ + colors = { + "CHEAP": COLOR_CHEAP, + "NORMAL": COLOR_NORMAL, + "EXPENSIVE": COLOR_EXPENSIVE, + "VERY_EXPENSIVE": COLOR_VERY_EXPENSIVE, + "VERY_CHEAP": COLOR_CHEAP, # Map VERY_CHEAP to same as CHEAP + } + return colors.get(level, COLOR_UNKNOWN) + +def calculate_percentile(value, all_values): + """ + Calculate percentile of a value within a list. + + Test cases: + - calculate_percentile(3.5, [1.0, 2.0, 3.0, 4.0]) β†’ 75 + - calculate_percentile(1.0, [1.0, 2.0, 3.0, 4.0]) β†’ 25 + - calculate_percentile(4.0, [1.0, 2.0, 3.0, 4.0]) β†’ 100 + - Edge case: All equal values β†’ 50 + - Edge case: Empty list β†’ 50 + + Args: + value: Value to find percentile for + all_values: List of all values in dataset + + Returns: + Integer percentile (0-100) + """ + if not all_values or len(all_values) == 0: + return 50 + + # Check if all values are equal + if len(set(all_values)) == 1: + return 50 + + # Sort values + sorted_values = sorted(all_values) + + # Find position of value (or closest position) + count_below = 0 + for v in sorted_values: + if v < value: + count_below += 1 + + # Calculate percentile + percentile = int((count_below / float(len(sorted_values))) * 100) + + return percentile + +def calculate_efficiency_color(consumption_pct, price_pct, consumption_value = None): + """ + Calculate efficiency color based on consumption and price percentiles. + + Special cases: + - Negative or zero consumption (PV export) is always green (excellent) + - Very low consumption (<= 1 kWh) with low percentile is green (PV systems) + + 3Γ—3 Matrix: + - High cons (>75%) + High price (>75%) β†’ Red #FF3B30 (inefficient) + - High cons + Med price (25-75%) β†’ Orange #FF9500 + - High cons + Low price (<25%) β†’ Yellow #FFCC00 (acceptable) + - Med cons (25-75%) + High price β†’ Orange #FF9500 + - Med cons + Med price β†’ Yellow #FFCC00 (neutral) + - Med cons + Low price β†’ Light Green #66E5A3 + - Low cons (<25%) + High price β†’ Light Green #66E5A3 (excellent) + - Low cons + Med price β†’ Green #00D88A + - Low cons + Low price β†’ Green #00D88A (efficient) + + Test cases: + - (80, 85, 5.0) β†’ "#FF3B30" (high usage, high price) + - (20, 20, 0.5) β†’ "#00D88A" (low usage, low price) + - (50, 50, 2.0) β†’ "#FFCC00" (medium both) + - (0, any, -1.0) β†’ "#00D88A" (PV export is always excellent) + - (100, 50, 0.8) β†’ "#00D88A" (low absolute value with PV system) + + Args: + consumption_pct: Consumption percentile (0-100) + price_pct: Price percentile (0-100) + consumption_value: Actual consumption in kWh (optional, for absolute checks) + + Returns: + Hex color code string + """ + + # Special case 1: Negative or zero consumption (PV export) - always excellent + if consumption_value != None and consumption_value <= 0: + return "#00D88A" # Green - PV export + + # Special case 2: Very low absolute consumption (<= 1 kWh) regardless of percentile + # This handles PV systems where all values are low but one might be relatively higher + if consumption_value != None and consumption_value <= 1.0: + return "#00D88A" # Green - minimal consumption (likely PV system) + + # Special case 3: Low percentile (bottom 5%) - always excellent + if consumption_pct <= 5: + return "#00D88A" # Green - lowest consumption in dataset + + # Determine consumption level + if consumption_pct > 75: + cons_level = "high" + elif consumption_pct >= 25: + cons_level = "med" + else: + cons_level = "low" + + # Determine price level + if price_pct > 75: + price_level = "high" + elif price_pct >= 25: + price_level = "med" + else: + price_level = "low" + + # Apply matrix + if cons_level == "high": + if price_level == "high": + return "#FF3B30" # Red - inefficient + elif price_level == "med": + return "#FF9500" # Orange + else: + return "#FFCC00" # Yellow - acceptable + elif cons_level == "med": + if price_level == "high": + return "#FF9500" # Orange + elif price_level == "med": + return "#FFCC00" # Yellow - neutral + else: + return "#66E5A3" # Light green + else: # low consumption + if price_level == "high": + return "#66E5A3" # Light green - excellent + else: + return "#00D88A" # Green - efficient + +def adjust_brightness(hex_color, factor): + """ + Adjust the brightness of a hex color by a multiplicative factor. + + Used for highlighting the current hour by making colors brighter. + Formula: new_value = min(255, old_value * factor) + + Args: + hex_color: Hex color string (e.g., "#FF9500") + factor: Brightness multiplier (e.g., 1.3 for 30% brighter) + + Returns: + Adjusted hex color string with # prefix + + Examples: + adjust_brightness("#FF9500", 1.3) β†’ "#FFC200" (brighter orange) + adjust_brightness("#00D88A", 1.3) β†’ "#00FFB3" (brighter green) + """ + if not hex_color or len(hex_color) != 7 or hex_color[0] != "#": + return hex_color # Return unchanged if invalid format + + # Extract RGB components + r = int(hex_color[1:3], 16) + g = int(hex_color[3:5], 16) + b = int(hex_color[5:7], 16) + + # Apply factor and clamp to 255 + r = int(min(255, r * factor)) + g = int(min(255, g * factor)) + b = int(min(255, b * factor)) + + # Format back to hex with proper padding + def to_hex(val): + hex_str = "%x" % val + if len(hex_str) == 1: + return "0" + hex_str + return hex_str + + return "#" + to_hex(r).upper() + to_hex(g).upper() + to_hex(b).upper() + +def interpolate_color(color1, color2, ratio): + """ + Linearly interpolate between two hex colors. + + Used for gradient coloring to create smooth transitions between color stops. + + Args: + color1: Start color (hex string like "#00D88A") + color2: End color (hex string like "#FF3B30") + ratio: Interpolation ratio (0.0 = color1, 1.0 = color2) + + Returns: + Interpolated hex color string + + Examples: + interpolate_color("#00D88A", "#FF3B30", 0.0) β†’ "#00D88A" + interpolate_color("#00D88A", "#FF3B30", 1.0) β†’ "#FF3B30" + interpolate_color("#000000", "#FFFFFF", 0.5) β†’ "#7F7F7F" (gray) + """ + if not color1 or not color2: + return color1 or "#FFCC00" + + # Extract RGB from color1 + r1 = int(color1[1:3], 16) + g1 = int(color1[3:5], 16) + b1 = int(color1[5:7], 16) + + # Extract RGB from color2 + r2 = int(color2[1:3], 16) + g2 = int(color2[3:5], 16) + b2 = int(color2[5:7], 16) + + # Interpolate each component + r = int(r1 + (r2 - r1) * ratio) + g = int(g1 + (g2 - g1) * ratio) + b = int(b1 + (b2 - b1) * ratio) + + # Format as hex + def to_hex(val): + hex_str = "%x" % val + if len(hex_str) == 1: + return "0" + hex_str + return hex_str + + return "#" + to_hex(r).upper() + to_hex(g).upper() + to_hex(b).upper() + +def create_gradient_bar(height, max_height, prev_height = None, next_height = None, use_grey = False): + """ + Create a vertical gradient bar from green (bottom) to red (top), or grey gradient. + + Each bar displays a fixed vertical gradient regardless of price value. + The height parameter determines how much of the gradient is visible: + - Low prices (short bars): Only green portion visible + - Medium prices: Green + yellow + orange visible + - High prices (tall bars): Full gradient including red at top + + For sharp spikes, pixels that form the contour line (where a neighbor bar + meets or exceeds the current height) remain bright to emphasize the shape. + + Gradient zones (from bottom to top): + - Bottom 30%: Green (#00D88A) or Dark Grey (#404040) + - 30-50%: Yellow-green to yellow transition or Grey gradient + - 50-75%: Yellow to orange transition or Grey gradient + - Top 25%: Orange to red (#FF3B30) or Light Grey (#808080) + + Args: + height: Bar height in pixels (1 to max_height) + max_height: Maximum possible bar height (typically 14px) + prev_height: Height of previous bar (left neighbor) or None + next_height: Height of next bar (right neighbor) or None + use_grey: If True, use grey gradient instead of color gradient + + Returns: + render.Stack widget with layered colored boxes creating gradient effect + + Examples: + create_gradient_bar(3, 14) β†’ short bar, only green visible + create_gradient_bar(7, 14) β†’ medium bar, green + yellow visible + create_gradient_bar(14, 14) β†’ tall bar, full gradient with red top + create_gradient_bar(7, 14, use_grey=True) β†’ grey gradient bar + """ + if height <= 0: + return render.Box(width = 1, height = 1, color = "#404040" if use_grey else "#00D88A") + + # Define gradient stops (colors at specific positions from bottom) + # Each tuple is (position_ratio, color) + if use_grey: + gradient_stops = [ + (0.0, "#404040"), # Bottom: Dark grey + (0.3, "#505050"), # Darker grey + (0.5, "#606060"), # Medium grey + (0.65, "#707070"), # Medium-light grey + (0.8, "#787878"), # Light grey + (0.9, "#7C7C7C"), # Lighter grey + (1.0, "#808080"), # Top: Light grey + ] + else: + gradient_stops = [ + (0.0, "#00D88A"), # Bottom: Green + (0.3, "#33E5A0"), # Light green + (0.5, "#FFCC00"), # Yellow + (0.65, "#FFB733"), # Orange-yellow + (0.8, "#FF9500"), # Orange + (0.9, "#FF6B33"), # Red-orange + (1.0, "#FF3B30"), # Top: Red + ] + + # Build vertical gradient by stacking colored pixels + pixels = [] + for pixel_index in range(height): + # Calculate position in gradient (0.0 = top/red, 1.0 = bottom/green) + # pixel_index 0 is top of bar, should be red + # pixel_index height-1 is bottom of bar, should be green + position_from_bottom = (height - 1 - pixel_index) / float(max_height) + + # Find which gradient segment this pixel falls into + color = "#00D88A" # Default green + for i in range(len(gradient_stops) - 1): + pos1, color1 = gradient_stops[i] + pos2, color2 = gradient_stops[i + 1] + + if position_from_bottom >= pos1 and position_from_bottom <= pos2: + # Interpolate between these two stops + ratio = (position_from_bottom - pos1) / (pos2 - pos1) + color = interpolate_color(color1, color2, ratio) + break + + # Determine if this pixel should be bright (contour pixel) + # A pixel is part of the contour if: + # 1. It's the topmost pixel (pixel_index 0), OR + # 2. This pixel is at the edge where a neighbor's height ends + # (neighbor is shorter and this pixel is at or just above neighbor's top) + is_contour = False + if pixel_index == 0: + is_contour = True + else: + # Check if this pixel is at the contour line + # A pixel at height H from bottom is contour if a neighbor has height < H + # meaning this pixel is exposed/visible from the side + pixel_height_from_bottom = height - pixel_index + + # Check left neighbor - if it's shorter, we're on the contour + if prev_height != None and prev_height < pixel_height_from_bottom: + is_contour = True + + # Check right neighbor - if it's shorter, we're on the contour + if next_height != None and next_height < pixel_height_from_bottom: + is_contour = True + + # Apply brightness reduction if not a contour pixel + # Interior pixels get darker from top to bottom for depth effect + if not is_contour: + # Calculate brightness based on position: darker at bottom, brighter at top + # pixel_index 0 = top (would be contour anyway) + # pixel_index height-1 = bottom (darkest interior pixel) + # Use ratio from 0.05 (very dark at bottom) to 0.3 (dimmer at top, but still distinct from contour) + position_ratio = float(pixel_index) / float(height - 1) if height > 1 else 0 + brightness_factor = 0.3 - (position_ratio * 0.25) # 0.3 at top interior β†’ 0.05 at bottom + color = adjust_brightness(color, brightness_factor) + + # Create 1px high colored box + pixels.append(render.Box( + width = 1, + height = 1, + color = color, + )) + + # Stack pixels vertically + return render.Column( + main_align = "start", + cross_align = "start", + children = pixels, + ) + +def calculate_bar_height(price, all_prices, max_height): + """ + Calculate proportional bar height for a price. + + Uses proportional scaling with minimum height of 2px. + Formula: (price - min) / (max - min) * (max_height - 2) + 2 + + Args: + price: Target price value + all_prices: List of all price values for scaling + max_height: Maximum bar height in pixels + + Returns: + Bar height in pixels (2 to max_height) + """ + if not all_prices or len(all_prices) == 0: + return 2 + + min_price = min(all_prices) + max_price = max(all_prices) + + # If all prices are the same, return middle height + if max_price == min_price: + return int(max_height / 2) + + # Proportional scaling: map price range to height range (2 to max_height) + ratio = (price - min_price) / (max_price - min_price) + height = ratio * (max_height - 2) + 2 + + return int(height) + +def select_24h_forecast(prices, current_slot, consumption_data = None): + """ + Select 60 half-hourly slots starting from 00:00 today for forward-looking forecast. + + Shows slots 0-59 (today 00:00 through tomorrow 05:30), filling the full 60px + display width. Always anchored to start of day regardless of current time. + + Args: + prices: List of price dicts with 'slot' key (0-95 for today+tomorrow) + current_slot: Current slot (0-47) for marking is_current_slot + consumption_data: Dict mapping slot β†’ kWh (optional, from API) + + Returns: + List of 60 price dicts (slots 0-59) with: + - is_current_slot: Boolean flag for current slot + - consumption_kwh: Float (kWh) or None + - efficiency_color: Hex color based on consumption vs price percentiles + """ + + # Select slots 0-59 (starting from 00:00 today) + forecast_slots = [] + for target_slot in range(60): + # Find price for this slot + price_entry = None + for price in prices: + if price["slot"] == target_slot: + price_entry = price + break + + if price_entry: + # Mark current slot + price_entry["is_current_slot"] = (target_slot == current_slot) + price_entry["is_placeholder"] = False + + # Add consumption data if available (only for today's slots) + if consumption_data and target_slot < 48 and target_slot in consumption_data: + price_entry["consumption_kwh"] = consumption_data[target_slot] + else: + price_entry["consumption_kwh"] = None + + forecast_slots.append(price_entry) + else: + # No data for this slot - create placeholder at last known price level + # This ensures we always have 60 slots even if tomorrow's prices aren't available + # Use the last known price (slot 47) if available, otherwise average + last_known_price = 0.5 + if len(forecast_slots) > 0: + # Use the last slot's price + last_known_price = forecast_slots[-1]["price"] + else: + # Fallback to average of available prices + total_price = 0.0 + for p in prices: + total_price = total_price + p["price"] + last_known_price = total_price / len(prices) if len(prices) > 0 else 0.5 + + forecast_slots.append({ + "price": last_known_price, + "level": "NORMAL", + "slot": target_slot, + "hour": target_slot // 2, + "minute": 30 if target_slot % 2 else 0, + "is_current_slot": False, + "is_placeholder": True, + "consumption_kwh": None, + }) + + # Calculate efficiency colors if we have consumption data + if consumption_data and len(consumption_data) > 0: + # Extract all consumption and price values for percentile calculation + all_consumptions = [] + all_prices = [] + for p in forecast_slots: + if p["consumption_kwh"] != None: + all_consumptions.append(p["consumption_kwh"]) + all_prices.append(p["price"]) + + # Calculate efficiency color for each slot + for price in forecast_slots: + if price["consumption_kwh"] != None: + cons_pct = calculate_percentile(price["consumption_kwh"], all_consumptions) + price_pct = calculate_percentile(price["price"], all_prices) + price["efficiency_color"] = calculate_efficiency_color( + cons_pct, + price_pct, + price["consumption_kwh"], + ) + else: + price["efficiency_color"] = None + else: + # No consumption data - set all to None (gray placeholder) + for price in forecast_slots: + price["efficiency_color"] = None + + return forecast_slots + +def format_current_price(price): + """ + Format price for display. + + Args: + price: Price as float + + Returns: + Formatted string like "0.25 kr" + """ + + # Round to 2 decimals and format manually (Starlark doesn't support .2f) + rounded = int(price * 100 + 0.5) / 100.0 + + # Convert to string with 2 decimal places + price_str = str(rounded) + + # Ensure we have 2 decimal places + if "." not in price_str: + price_str = price_str + ".00" + else: + parts = price_str.split(".") + if len(parts[1]) == 1: + price_str = price_str + "0" + elif len(parts[1]) > 2: + price_str = parts[0] + "." + parts[1][:2] + + return price_str + " kr" + +def format_hour_label(hour): + """ + Format hour for display. + + Args: + hour: Hour as integer (0-23) + + Returns: + Hour string without leading zero + """ + return str(hour) + +def render_current_price(price_point): + """ + Render current price widget. + + Args: + price_point: Dict with keys: price, level, hour + + Returns: + render widget for current price display + """ + color = get_color_for_level(price_point["level"]) + price_text = format_current_price(price_point["price"]) + + return render.Column( + main_align = "center", + cross_align = "start", + children = [ + render.Text("NOW", font = "tom-thumb"), + render.Text(price_text, color = color), + ], + ) + +def render_bar_chart(forecast, max_height): + """ + Render bar chart for price forecast with 60 half-hourly bars. + + Shows next 30 hours (60 Γ— 30-minute slots) starting from current time, + filling the full 60px width. + + Args: + forecast: List of price dicts (up to 60 slots) + max_height: Maximum bar height in pixels + + Returns: + render widget with bar chart + """ + if not forecast or len(forecast) == 0: + return render.Box(width = 1, height = max_height) + + # Extract prices for scaling + prices = [p["price"] for p in forecast] + + # Pre-calculate all bar heights for neighbor access + heights = [] + for price_point in forecast: + if len(heights) >= 60: + break + height = calculate_bar_height(price_point["price"], prices, max_height) + heights.append(height) + + # Create bars split into two groups (30+30) to avoid Row's 24-child limit + bars_left = [] + bars_right = [] + + for i in range(len(heights)): + if i >= 60: + break + + height = heights[i] + prev_height = heights[i - 1] if i > 0 else None + next_height = heights[i + 1] if i < len(heights) - 1 else None + + # Check if this slot is a placeholder (unknown price) + is_placeholder = forecast[i].get("is_placeholder", False) + + bar = create_gradient_bar(height, max_height, prev_height, next_height, use_grey = is_placeholder) + + if i < 30: + bars_left.append(bar) + else: + bars_right.append(bar) + + # Create two Rows side by side (no spacer needed - 60px exactly) + return render.Row( + main_align = "start", + cross_align = "end", + children = [ + render.Row( + main_align = "start", + cross_align = "end", + children = bars_left, + ), + render.Row( + main_align = "start", + cross_align = "end", + children = bars_right, + ), + ], + ) + +def render_hour_labels(forecast): + """ + Render hour labels below bar chart. + + Shows labels for every 3rd hour to avoid crowding. + + Args: + forecast: List of price dicts + + Returns: + render widget with hour labels + """ + if not forecast or len(forecast) == 0: + return render.Box(width = 1, height = 1) + + labels = [] + for i, price_point in enumerate(forecast): + if i >= 12: # Limit to 12 + break + + # Show label every 3 hours + if i % 3 == 0: + label_text = format_hour_label(price_point["hour"]) + label = render.Text(label_text, font = "tom-thumb") + + # Calculate position (4px bar + 1px spacing) + offset = i * 5 + labels.append(render.Padding( + pad = (offset, 0, 0, 0), + child = label, + )) + + return render.Stack(children = labels) + +def create_consumption_gradient_bar(height, max_height, base_color, prev_height = None, next_height = None): + """ + Create a consumption bar with contour highlighting and depth gradient. + + Similar to price gradient bars but uses a single base color (efficiency color) + with brightness variations for contour and depth effects. + + Args: + height: Bar height in pixels (1 to max_height) + max_height: Maximum possible bar height (typically 14px) + base_color: Base efficiency color for this bar + prev_height: Height of previous bar (left neighbor) or None + next_height: Height of next bar (right neighbor) or None + + Returns: + render.Column widget with contour highlighting and depth gradient + """ + if height <= 0: + return render.Box(width = 1, height = 1, color = base_color) + + # Build vertical bar by stacking colored pixels + pixels = [] + for pixel_index in range(height): + # Start with base color + color = base_color + + # Determine if this pixel should be bright (contour pixel) + is_contour = False + if pixel_index == 0: + is_contour = True + else: + # Check if this pixel is at the contour line + pixel_height_from_bottom = height - pixel_index + + # Check left neighbor - if it's shorter, we're on the contour + if prev_height != None and prev_height < pixel_height_from_bottom: + is_contour = True + + # Check right neighbor - if it's shorter, we're on the contour + if next_height != None and next_height < pixel_height_from_bottom: + is_contour = True + + # Apply brightness reduction if not a contour pixel + # Interior pixels get darker from top to bottom for depth effect + if not is_contour: + # Calculate brightness based on position: darker at bottom, brighter at top + position_ratio = float(pixel_index) / float(height - 1) if height > 1 else 0 + brightness_factor = 0.3 - (position_ratio * 0.25) # 0.3 at top interior β†’ 0.05 at bottom + color = adjust_brightness(color, brightness_factor) + + # Create 1px high colored box + pixels.append(render.Box( + width = 1, + height = 1, + color = color, + )) + + # Stack pixels vertically + return render.Column( + main_align = "start", + cross_align = "start", + children = pixels, + ) + +def render_consumption_chart(forecast, max_height, current_slot): + """ + Render consumption chart with 60 bars matching price chart width. + + Shows consumption where available (past slots only) and future slots as + dark gray placeholders. Accepts that consumption will never be complete + for forward-looking forecast. + + Args: + forecast: List of price dicts with consumption_kwh and efficiency_color + max_height: Maximum bar height in pixels + current_slot: Current slot (0-47) to determine past vs future + + Returns: + render widget with consumption chart + """ + if not forecast or len(forecast) == 0: + return render.Box(width = 1, height = max_height) + + # Extract consumption values for scaling (only from past slots) + consumptions = [] + for p in forecast: + if p.get("consumption_kwh") != None: + consumptions.append(p["consumption_kwh"]) + + # Pre-calculate all bar heights for neighbor access + heights = [] + for i, price_point in enumerate(forecast): + if i >= 60: + break + + slot = price_point.get("slot", 9999) + + # Only show consumption for past slots (< current_slot) + if slot < current_slot: + consumption_kwh = price_point.get("consumption_kwh") + if consumption_kwh != None and len(consumptions) > 0: + min_cons = min(consumptions) + max_cons = max(consumptions) + if max_cons > min_cons: + ratio = (consumption_kwh - min_cons) / (max_cons - min_cons) + height = int(ratio * (max_height - 1) + 1) + if height < 1: + height = 1 + else: + height = int(max_height / 2) + else: + height = 1 + else: + # Future slot - show minimal gray bar + height = 1 + + heights.append(height) + + # Create bars split into two groups (30+30) + bars_left = [] + bars_right = [] + + for i in range(len(heights)): + if i >= 60: + break + + price_point = forecast[i] + slot = price_point.get("slot", 9999) + height = heights[i] + prev_height = heights[i - 1] if i > 0 else None + next_height = heights[i + 1] if i < len(heights) - 1 else None + + # Only show consumption for past slots (< current_slot) + if slot < current_slot: + color = price_point.get("efficiency_color") or "#808080" + bar = create_consumption_gradient_bar(height, max_height, color, prev_height, next_height) + else: + # Future slot - show minimal gray bar (no gradient) + bar = render.Box( + width = 1, + height = height, + color = "#404040", + ) + + if i < 30: + bars_left.append(bar) + else: + bars_right.append(bar) + + return render.Row( + main_align = "start", + cross_align = "end", + children = [ + render.Row( + main_align = "start", + cross_align = "end", + children = bars_left, + ), + render.Row( + main_align = "start", + cross_align = "end", + children = bars_right, + ), + ], + ) + +def render_main_display(forecast_data, current_slot, cache_status, is_demo = False): + """ + Compose main display with two horizontal charts. + + Args: + forecast_data: Dict with keys: prices, currency, consumption (optional) + current_slot: Current slot (0-47) for 30-minute granularity + cache_status: Cache status string + is_demo: Boolean flag indicating demo mode + + Returns: + render.Root widget + """ + + # Extract consumption data if available + consumption_data = forecast_data.get("consumption", {}) + + # Select 48-slot forecast for current day with consumption and efficiency colors + forecast = select_24h_forecast( + forecast_data["prices"], + current_slot, + consumption_data, + ) + + if not forecast or len(forecast) == 0: + return render_error("no_data") + + # Render dual horizontal charts - full width, no sidebar or legend + price_chart = render_bar_chart(forecast, max_height = 14) + consumption_chart = render_consumption_chart(forecast, max_height = 14, current_slot = current_slot) + + # Add stale indicator if needed + stale_indicator = None + if cache_status == "stale": + stale_indicator = render.Padding( + pad = (0, 0, 2, 2), + child = render.Text("*", font = "tom-thumb", color = "#FF9500"), + ) + + # Compose dual horizontal layout + layout_children = [ + render.Padding( + pad = (2, 2, 2, 0), + child = price_chart, + ), + render.Padding( + pad = (2, 2, 2, 2), + child = consumption_chart, + ), + ] + + if stale_indicator: + layout_children.append(stale_indicator) + + # Create base display + base_display = render.Column( + main_align = "start", + cross_align = "start", + children = layout_children, + ) + + # Add demo overlay if in demo mode + if is_demo: + return render.Root( + child = render.Stack( + children = [ + base_display, + render.Box( + width = 64, + height = 32, + child = render.Column( + main_align = "center", + cross_align = "center", + children = [ + render.Text( + "DEMO", + font = "6x13", + color = "#FFFFFF", + ), + ], + ), + ), + ], + ), + ) + + return render.Root(child = base_display) + +def main(config): + """ + Main entry point for the Tidbyt app. + + Args: + config: Configuration dict with api_token + + Returns: + render.Root widget + """ + + # Validate configuration + api_token = config.get("api_token") + if not api_token: + # Use demo token if no token provided + api_token = TIBBER_DEMO_TOKEN + is_demo = True + else: + is_demo = (api_token == TIBBER_DEMO_TOKEN) + + # Get cached or fresh data + result = get_cached_prices(api_token) + + # Handle errors + if not result["success"]: + return render_error(result["error_code"]) + + # Parse data + forecast_data = parse_tibber_response(result["data"]) + + # Get current 30-minute slot using device's configured timezone + # The device's timezone is automatically provided by Tidbyt in config + # Slot calculation: hour * 2 + (1 if minute >= 30 else 0) + timezone = config.get("timezone") or config.get("$tz") or "UTC" + current_slot = calculate_current_slot(timezone) + + # Render main display + return render_main_display( + forecast_data, + current_slot, + result["cache_status"], + is_demo, + ) + +def render_error(error_code): + """ + Render error message for user. + + Args: + error_code: Error type (missing_token, unauthorized, rate_limited, etc.) + + Returns: + render.Root widget with error message + """ + error_messages = { + "missing_token": "Configure API Token", + "unauthorized": "Invalid Token", + "rate_limited": "Rate Limited - Wait 10s", + "timeout": "Network Error", + "server_error": "Tibber API Error", + "no_data": "No Price Data", + "unknown": "HTTP Error", + "graphql_error": "GraphQL Error", + "parse_error": "Data Parse Error", + "no_homes": "No Tibber Home Found", + } + + message = error_messages.get(error_code, "Unknown Error") + + return render.Root( + child = render.Box( + child = render.Column( + main_align = "center", + cross_align = "center", + children = [ + render.Text("Error", color = "#FF0000"), + render.Text(message, font = "tom-thumb"), + ], + ), + ), + ) + +def get_schema(): + """ + Define configuration schema for the app. + + Returns: + schema.Schema with required fields + """ + return schema.Schema( + version = "1", + fields = [ + schema.Text( + id = "api_token", + name = "Tibber API Token", + desc = "Your Tibber API token from developer.tibber.com. Leave empty for demo mode.", + icon = "key", + default = TIBBER_DEMO_TOKEN, + ), + ], + ) From 907844d23de34c1f56b63925e625790c133eda25 Mon Sep 17 00:00:00 2001 From: Christian Ruschke Date: Sat, 6 Dec 2025 19:13:50 +0100 Subject: [PATCH 2/4] Fix lint error: remove unused max_height parameter --- apps/tibberprice/tibberprice.star | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/tibberprice/tibberprice.star b/apps/tibberprice/tibberprice.star index 56ebb4f1a..7b98a8a1f 100644 --- a/apps/tibberprice/tibberprice.star +++ b/apps/tibberprice/tibberprice.star @@ -1008,7 +1008,7 @@ def select_24h_forecast(prices, current_slot, consumption_data = None): for p in prices: total_price = total_price + p["price"] last_known_price = total_price / len(prices) if len(prices) > 0 else 0.5 - + forecast_slots.append({ "price": last_known_price, "level": "NORMAL", @@ -1214,7 +1214,7 @@ def render_hour_labels(forecast): return render.Stack(children = labels) -def create_consumption_gradient_bar(height, max_height, base_color, prev_height = None, next_height = None): +def create_consumption_gradient_bar(height, base_color, prev_height = None, next_height = None): """ Create a consumption bar with contour highlighting and depth gradient. @@ -1222,8 +1222,7 @@ def create_consumption_gradient_bar(height, max_height, base_color, prev_height with brightness variations for contour and depth effects. Args: - height: Bar height in pixels (1 to max_height) - max_height: Maximum possible bar height (typically 14px) + height: Bar height in pixels base_color: Base efficiency color for this bar prev_height: Height of previous bar (left neighbor) or None next_height: Height of next bar (right neighbor) or None @@ -1349,7 +1348,7 @@ def render_consumption_chart(forecast, max_height, current_slot): # Only show consumption for past slots (< current_slot) if slot < current_slot: color = price_point.get("efficiency_color") or "#808080" - bar = create_consumption_gradient_bar(height, max_height, color, prev_height, next_height) + bar = create_consumption_gradient_bar(height, color, prev_height, next_height) else: # Future slot - show minimal gray bar (no gradient) bar = render.Box( From 5a3ee616bbda3cc972ee36f59fc0ae89f9f5dc20 Mon Sep 17 00:00:00 2001 From: Christian Ruschke Date: Sat, 6 Dec 2025 19:23:00 +0100 Subject: [PATCH 3/4] Final version --- apps/tibberprice/tibberprice.star | 47 +++++++++++++++++++------------ 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/apps/tibberprice/tibberprice.star b/apps/tibberprice/tibberprice.star index 7b98a8a1f..d5338b5e1 100644 --- a/apps/tibberprice/tibberprice.star +++ b/apps/tibberprice/tibberprice.star @@ -836,18 +836,18 @@ def create_gradient_bar(height, max_height, prev_height = None, next_height = No # Each tuple is (position_ratio, color) if use_grey: gradient_stops = [ - (0.0, "#404040"), # Bottom: Dark grey - (0.3, "#505050"), # Darker grey - (0.5, "#606060"), # Medium grey - (0.65, "#707070"), # Medium-light grey - (0.8, "#787878"), # Light grey - (0.9, "#7C7C7C"), # Lighter grey - (1.0, "#808080"), # Top: Light grey + (0.0, "#505050"), # Bottom: Medium-dark grey (brighter) + (0.3, "#606060"), # Medium grey + (0.5, "#707070"), # Medium-light grey + (0.65, "#787878"), # Light grey + (0.8, "#808080"), # Lighter grey + (0.9, "#888888"), # Very light grey + (1.0, "#909090"), # Top: Light grey (brighter) ] else: gradient_stops = [ - (0.0, "#00D88A"), # Bottom: Green - (0.3, "#33E5A0"), # Light green + (0.0, "#1AE89D"), # Bottom: Brighter green (more visible) + (0.3, "#4DEBB0"), # Light green (0.5, "#FFCC00"), # Yellow (0.65, "#FFB733"), # Orange-yellow (0.8, "#FF9500"), # Orange @@ -861,10 +861,15 @@ def create_gradient_bar(height, max_height, prev_height = None, next_height = No # Calculate position in gradient (0.0 = top/red, 1.0 = bottom/green) # pixel_index 0 is top of bar, should be red # pixel_index height-1 is bottom of bar, should be green - position_from_bottom = (height - 1 - pixel_index) / float(max_height) + # Use max_height for consistent gradient colors across all bars + position_from_bottom = (height - 1 - pixel_index) / float(max_height - 1) if max_height > 1 else 0.0 + + # Clamp to 0.0-1.0 range + position_from_bottom = max(0.0, min(1.0, position_from_bottom)) # Find which gradient segment this pixel falls into - color = "#00D88A" # Default green + color = gradient_stops[0][1] # Default to bottom color + found_color = False for i in range(len(gradient_stops) - 1): pos1, color1 = gradient_stops[i] pos2, color2 = gradient_stops[i + 1] @@ -873,8 +878,13 @@ def create_gradient_bar(height, max_height, prev_height = None, next_height = No # Interpolate between these two stops ratio = (position_from_bottom - pos1) / (pos2 - pos1) color = interpolate_color(color1, color2, ratio) + found_color = True break + # If no segment matched and position > last stop, use top color + if not found_color and position_from_bottom > gradient_stops[-1][0]: + color = gradient_stops[-1][1] + # Determine if this pixel should be bright (contour pixel) # A pixel is part of the contour if: # 1. It's the topmost pixel (pixel_index 0), OR @@ -903,9 +913,9 @@ def create_gradient_bar(height, max_height, prev_height = None, next_height = No # Calculate brightness based on position: darker at bottom, brighter at top # pixel_index 0 = top (would be contour anyway) # pixel_index height-1 = bottom (darkest interior pixel) - # Use ratio from 0.05 (very dark at bottom) to 0.3 (dimmer at top, but still distinct from contour) + # Use ratio from 0.15 (darker at bottom but visible) to 0.35 (dimmer at top, but still distinct from contour) position_ratio = float(pixel_index) / float(height - 1) if height > 1 else 0 - brightness_factor = 0.3 - (position_ratio * 0.25) # 0.3 at top interior β†’ 0.05 at bottom + brightness_factor = 0.35 - (position_ratio * 0.20) # 0.35 at top interior β†’ 0.15 at bottom color = adjust_brightness(color, brightness_factor) # Create 1px high colored box @@ -1214,7 +1224,7 @@ def render_hour_labels(forecast): return render.Stack(children = labels) -def create_consumption_gradient_bar(height, base_color, prev_height = None, next_height = None): +def create_consumption_gradient_bar(height, max_height, base_color, prev_height = None, next_height = None): """ Create a consumption bar with contour highlighting and depth gradient. @@ -1222,7 +1232,8 @@ def create_consumption_gradient_bar(height, base_color, prev_height = None, next with brightness variations for contour and depth effects. Args: - height: Bar height in pixels + height: Bar height in pixels (1 to max_height) + max_height: Maximum possible bar height (typically 14px) base_color: Base efficiency color for this bar prev_height: Height of previous bar (left neighbor) or None next_height: Height of next bar (right neighbor) or None @@ -1348,7 +1359,7 @@ def render_consumption_chart(forecast, max_height, current_slot): # Only show consumption for past slots (< current_slot) if slot < current_slot: color = price_point.get("efficiency_color") or "#808080" - bar = create_consumption_gradient_bar(height, color, prev_height, next_height) + bar = create_consumption_gradient_bar(height, max_height, color, prev_height, next_height) else: # Future slot - show minimal gray bar (no gradient) bar = render.Box( @@ -1455,8 +1466,8 @@ def render_main_display(forecast_data, current_slot, cache_status, is_demo = Fal children = [ render.Text( "DEMO", - font = "6x13", - color = "#FFFFFF", + font = "10x20", + color = "#FF0000", ), ], ), From eaa762a001570b4b3dab121b4ba5be2176f77f21 Mon Sep 17 00:00:00 2001 From: Christian Ruschke Date: Sat, 6 Dec 2025 19:28:35 +0100 Subject: [PATCH 4/4] The return of the lint errors :) --- apps/tibberprice/tibberprice.star | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/tibberprice/tibberprice.star b/apps/tibberprice/tibberprice.star index d5338b5e1..646541bcf 100644 --- a/apps/tibberprice/tibberprice.star +++ b/apps/tibberprice/tibberprice.star @@ -1224,7 +1224,7 @@ def render_hour_labels(forecast): return render.Stack(children = labels) -def create_consumption_gradient_bar(height, max_height, base_color, prev_height = None, next_height = None): +def create_consumption_gradient_bar(height, base_color, prev_height = None, next_height = None): """ Create a consumption bar with contour highlighting and depth gradient. @@ -1232,8 +1232,7 @@ def create_consumption_gradient_bar(height, max_height, base_color, prev_height with brightness variations for contour and depth effects. Args: - height: Bar height in pixels (1 to max_height) - max_height: Maximum possible bar height (typically 14px) + height: Bar height in pixels base_color: Base efficiency color for this bar prev_height: Height of previous bar (left neighbor) or None next_height: Height of next bar (right neighbor) or None @@ -1359,7 +1358,7 @@ def render_consumption_chart(forecast, max_height, current_slot): # Only show consumption for past slots (< current_slot) if slot < current_slot: color = price_point.get("efficiency_color") or "#808080" - bar = create_consumption_gradient_bar(height, max_height, color, prev_height, next_height) + bar = create_consumption_gradient_bar(height, color, prev_height, next_height) else: # Future slot - show minimal gray bar (no gradient) bar = render.Box(