Files
esphome-lsm6ds/esphome/components/lvgl/widgets/meter.py
T

639 lines
22 KiB
Python

from esphome import automation
import esphome.codegen as cg
from esphome.components.image import get_image_metadata
import esphome.config_validation as cv
from esphome.const import (
CONF_COLOR,
CONF_COUNT,
CONF_HEIGHT,
CONF_ID,
CONF_ITEMS,
CONF_LENGTH,
CONF_LOCAL,
CONF_RANGE_FROM,
CONF_RANGE_TO,
CONF_ROTATION,
CONF_VALUE,
CONF_WIDTH,
CONF_X,
)
from esphome.cpp_generator import MockObj
from esphome.cpp_types import nullptr
from .. import obj_spec, set_obj_properties
from ..automation import action_to_code
from ..defines import (
CHILD_ALIGNMENTS,
CONF_ALIGN,
CONF_CONTAINER,
CONF_END_VALUE,
CONF_INDICATOR,
CONF_LINE_WIDTH,
CONF_MAIN,
CONF_OPA,
CONF_PIVOT_X,
CONF_PIVOT_Y,
CONF_RADIUS,
CONF_SCALE,
CONF_SRC,
CONF_START_VALUE,
CONF_TICKS,
LV_OBJ_FLAG,
LV_PART,
LV_SCALE_MODE,
get_remapped_uses,
get_warnings,
)
from ..helpers import add_lv_use
from ..lv_validation import (
LV_OPA,
LV_RADIUS,
get_end_value,
get_start_value,
lv_angle_degrees,
lv_bool,
lv_color,
lv_float,
lv_image,
lv_int,
opacity,
padding,
pixels,
pixels_or_percent,
pixels_or_percent_validator,
requires_component,
size,
)
from ..lvcode import LambdaContext, LocalVariable, lv, lv_add, lv_expr, lv_obj
from ..schemas import STATE_SCHEMA
from ..styles import LVStyle
from ..types import (
LV_EVENT,
LvCompound,
LvType,
ObjUpdateAction,
lv_event_t,
lv_image_t,
lv_obj_t,
)
from . import Widget, WidgetType, get_widgets, widget_to_code
from .arc import CONF_ARC
from .img import CONF_IMAGE
from .label import CONF_LABEL
from .line import CONF_LINE
CONF_ANGLE_RANGE = "angle_range"
CONF_COLOR_END = "color_end"
CONF_COLOR_START = "color_start"
CONF_DRAW_TICKS_ON_TOP = "draw_ticks_on_top"
CONF_IMAGE_ID = "image_id"
CONF_INDICATORS = "indicators"
CONF_LINE_ID = "line_id"
CONF_LABEL_GAP = "label_gap"
CONF_MAJOR = "major"
CONF_METER = "meter"
CONF_PIVOT = "pivot"
CONF_R_MOD = "r_mod"
CONF_RADIAL_OFFSET = "radial_offset"
CONF_SCALES = "scales"
CONF_STRIDE = "stride"
CONF_TICK_STYLE = "tick_style"
# LVGL 9.4 Migration: Use scale widget instead of removed meter widget
#
# The lv_meter widget was removed in LVGL 9.4 and replaced with the more
# flexible lv_scale widget. This implementation emulates meter functionality
# using the scale widget with the following mappings:
#
# - lv_meter -> lv_scale (set to LV_SCALE_MODE_ROUND_OUTER for circular meters)
# - lv_meter_scale -> scale configuration (range, ticks, etc.)
# - lv_meter_indicator -> lv_scale_section (colored ranges on the scale)
#
# For compatibility, keep meter types but map to scale
lv_scale_t = LvType("lv_obj_t")
lv_meter_t = LvType("lv_meter_t")
lv_scale_section_t = LvType("lv_scale_section_t")
lv_meter_indicator_t = LvType("lv_meter_indicator_t")
lv_meter_indicator_ticks_t = LvType(
"lv_scale_section_t", parents=(lv_meter_indicator_t,)
)
lv_meter_indicator_arc_t = LvType("lv_scale_section_t", parents=(lv_meter_indicator_t,))
lv_meter_indicator_line_t = LvType(
"IndicatorLine",
parents=(
LvCompound,
lv_meter_indicator_t,
),
)
lv_meter_indicator_image_t = LvType("lv_image_t", parents=(lv_meter_indicator_t,))
DEFAULT_LABEL_GAP = 10 # Default label gap for major ticks added by LVGL
INDICATOR_LINE_SCHEMA = cv.Schema(
{
cv.Optional(CONF_WIDTH, default=4): cv.int_,
cv.Optional(CONF_COLOR, default=0): lv_color,
cv.Optional(CONF_R_MOD): padding,
cv.Optional(CONF_LENGTH): pixels_or_percent_validator,
cv.Optional(CONF_RADIAL_OFFSET, 0): pixels_or_percent_validator,
cv.Optional(CONF_VALUE, default=0.0): lv_float,
cv.Optional(CONF_OPA, default=1.0): opacity,
}
).add_extra(cv.has_at_most_one_key(CONF_R_MOD, CONF_LENGTH))
class ScaleType(WidgetType):
"""
Will migrate to scale.py in due course
"""
def __init__(self):
super().__init__(
CONF_SCALE,
lv_scale_t,
(CONF_MAIN, CONF_ITEMS, CONF_INDICATOR),
{},
is_mock=True,
)
scale_spec = ScaleType()
INDICATOR_IMG_SCHEMA = cv.Schema(
{
cv.Required(CONF_SRC): lv_image,
cv.Optional(CONF_PIVOT_X, default=0): pixels,
cv.Optional(CONF_PIVOT_Y): pixels,
cv.Optional(CONF_VALUE): lv_float,
cv.Optional(CONF_OPA, default=1.0): opacity,
}
)
INDICATOR_ARC_SCHEMA = cv.Schema(
{
cv.Optional(CONF_WIDTH, default=4): cv.int_,
cv.Optional(CONF_COLOR, default=0): lv_color,
cv.Optional(CONF_R_MOD): padding,
cv.Optional(CONF_VALUE): lv_float,
cv.Optional(CONF_START_VALUE): lv_float,
cv.Optional(CONF_END_VALUE): lv_float,
cv.Optional(CONF_OPA, default=1.0): opacity,
}
).add_extra(cv.has_at_most_one_key(CONF_VALUE, CONF_START_VALUE))
INDICATOR_TICKS_SCHEMA = cv.Schema(
{
cv.Optional(CONF_WIDTH, default=4): cv.int_,
cv.Optional(CONF_COLOR_START, default=0): lv_color,
cv.Optional(CONF_COLOR_END): lv_color,
cv.Optional(CONF_VALUE): lv_float,
cv.Optional(CONF_START_VALUE): lv_float,
cv.Optional(CONF_END_VALUE): lv_float,
cv.Optional(CONF_LOCAL, default=False): lv_bool,
}
).add_extra(cv.has_at_most_one_key(CONF_VALUE, CONF_START_VALUE))
INDICATOR_SCHEMA = cv.Schema(
{
cv.Exclusive(CONF_LINE, CONF_INDICATORS): INDICATOR_LINE_SCHEMA.extend(
{
cv.GenerateID(): cv.declare_id(lv_meter_indicator_line_t),
}
),
cv.Exclusive(CONF_IMAGE, CONF_INDICATORS): cv.All(
INDICATOR_IMG_SCHEMA.extend(
{
cv.GenerateID(): cv.declare_id(lv_meter_indicator_image_t),
cv.GenerateID(CONF_IMAGE_ID): cv.declare_id(lv_image_t),
}
),
requires_component("image"),
),
cv.Exclusive(CONF_ARC, CONF_INDICATORS): INDICATOR_ARC_SCHEMA.extend(
{
cv.GenerateID(): cv.declare_id(lv_meter_indicator_arc_t),
}
),
cv.Exclusive(CONF_TICK_STYLE, CONF_INDICATORS): INDICATOR_TICKS_SCHEMA.extend(
{
cv.GenerateID(): cv.declare_id(lv_meter_indicator_ticks_t),
}
),
}
)
def _scale_validate(config):
if indicators := config.get(CONF_INDICATORS):
style_index = next(
(
i
for i, indicator in enumerate(indicators)
if CONF_TICK_STYLE in indicator
),
-1,
)
if style_index >= 0 and CONF_TICKS not in config:
raise cv.Invalid(
"'tick_style' can't be applied if the enclosing scale has no 'ticks' configured",
path=[CONF_INDICATORS, style_index],
)
return config
SCALE_SCHEMA = cv.Schema(
{
cv.GenerateID(): cv.declare_id(lv_scale_t),
cv.Optional(CONF_TICKS): cv.Schema(
{
cv.Optional(CONF_COUNT, default=12): cv.int_range(min=2),
cv.Optional(CONF_WIDTH, default=2): cv.positive_int,
cv.Optional(CONF_LENGTH, default=10): size,
cv.Optional(CONF_RADIAL_OFFSET, default=0): size,
cv.Optional(CONF_COLOR, default=0x808080): lv_color,
cv.Optional(CONF_MAJOR): cv.Schema(
{
cv.Optional(CONF_STRIDE, default=3): cv.positive_int,
cv.Optional(CONF_WIDTH, default=5): size,
cv.Optional(CONF_LENGTH, default="15%"): size,
cv.Optional(CONF_RADIAL_OFFSET, default=0): size,
cv.Optional(CONF_COLOR, default=0): lv_color,
cv.Optional(CONF_LABEL_GAP, default=4): size,
}
),
}
),
cv.Optional(CONF_RANGE_FROM, default=0.0): lv_int,
cv.Optional(CONF_RANGE_TO, default=100.0): lv_int,
cv.Optional(CONF_ANGLE_RANGE, default=270): lv_angle_degrees,
cv.Optional(CONF_ROTATION): lv_angle_degrees,
cv.Optional(CONF_INDICATORS): cv.ensure_list(INDICATOR_SCHEMA),
cv.Optional(CONF_DRAW_TICKS_ON_TOP, default=True): bool,
}
).add_extra(_scale_validate)
METER_SCHEMA = {
cv.Optional(CONF_PIVOT): STATE_SCHEMA,
cv.Optional(CONF_INDICATOR): STATE_SCHEMA,
cv.Optional(CONF_SCALES): cv.ensure_list(SCALE_SCHEMA),
}
# Only handling light style at the moment
LIGHT_STYLE = LVStyle(
"lv_meter_light",
{
"bg_opa": 1.0,
"bg_color": 0xFFFFFF,
"pad_all": 10,
"border_width": 3,
"border_color": 0xEEEEEE,
"radius": "LV_RADIUS_CIRCLE",
},
)
PIVOT_STYLE = {
CONF_RADIUS: LV_RADIUS.CIRCLE,
CONF_ALIGN: CHILD_ALIGNMENTS.CENTER,
"bg_color": 0x000000,
"bg_opa": 1.0,
CONF_WIDTH: 15,
CONF_HEIGHT: 15,
}
line_indicator_type = WidgetType(
CONF_INDICATOR,
lv_meter_indicator_line_t,
(CONF_MAIN,),
lv_name=CONF_LINE,
is_mock=True,
)
class SectionType(WidgetType):
def __init__(self):
super().__init__(
"scale_section",
lv_meter_indicator_arc_t,
(CONF_MAIN,),
is_mock=True,
lv_name="scale_section",
)
arc_indicator_type = SectionType()
image_indicator_type = WidgetType(
CONF_INDICATOR,
lv_meter_indicator_image_t,
(CONF_MAIN,),
lv_name=CONF_IMAGE,
is_mock=True,
)
class MeterType(WidgetType):
def __init__(self):
super().__init__(
CONF_METER,
lv_meter_t,
# Note that mapping from 8.x to 9.x, indicator styling is applied to needles, and tick styling
# is migrated to indicator
(CONF_MAIN, CONF_INDICATOR, CONF_TICKS, CONF_ITEMS),
METER_SCHEMA,
lv_name=CONF_CONTAINER,
)
def get_uses(self):
return CONF_SCALE, CONF_LINE, CONF_IMAGE, CONF_LABEL
def validate(self, value):
return cv.has_at_most_one_key(CONF_INDICATOR, CONF_PIVOT)(value)
async def on_create(self, var: MockObj, config: dict):
# Remove theme styling from outer container
lv.obj_add_style(var, await LIGHT_STYLE.get_var(), LV_PART.MAIN)
async def create_to_code(self, config: dict, parent: MockObj):
"""For a meter object using scale widget, create and set parameters"""
add_lv_use(*self.get_uses())
outer_config = config.copy()
indicator_config = {CONF_INDICATOR: outer_config.pop(CONF_TICKS, {})}
w = await super().create_to_code(outer_config, parent)
var = w.obj
# LVGL 9.4 scale widget setup
# Background style will be applied.
for scale_conf in config.get(CONF_SCALES, ()):
scale_var = cg.Pvariable(scale_conf[CONF_ID], lv_expr.scale_create(var))
percent100 = await pixels_or_percent.process(1.0)
lv_obj.set_style_height(scale_var, percent100, LV_PART.MAIN)
lv_obj.set_style_width(scale_var, percent100, LV_PART.MAIN)
lv_obj.set_style_align(scale_var, CHILD_ALIGNMENTS.CENTER, LV_PART.MAIN)
lv_obj.set_style_bg_opa(scale_var, LV_OPA.TRANSP, LV_PART.MAIN)
lv_obj.set_style_radius(scale_var, LV_RADIUS.CIRCLE, 0)
await set_obj_properties(Widget(scale_var, scale_spec), indicator_config)
lv.scale_set_mode(scale_var, LV_SCALE_MODE.ROUND_INNER)
# Set the scale range
range_from = await lv_int.process(scale_conf[CONF_RANGE_FROM])
range_to = await lv_int.process(scale_conf[CONF_RANGE_TO])
lv.scale_set_range(scale_var, range_from, range_to)
angle_range = await lv_angle_degrees.process(scale_conf[CONF_ANGLE_RANGE])
if (rotation := scale_conf.get(CONF_ROTATION)) is not None:
rotation = await lv_angle_degrees.process(rotation)
else:
rotation = 90 + (360 - angle_range) // 2
# Set angle range
lv.scale_set_angle_range(
scale_var,
angle_range,
)
lv.scale_set_rotation(scale_var, rotation)
# Handle indicators as sections
for indicator in scale_conf.get(CONF_INDICATORS, ()):
(t, v) = next(iter(indicator.items()))
iid = v[CONF_ID]
# Enable getting the meter to which this belongs.
# Set section range based on indicator values
start_value = await get_start_value(v) or scale_conf[CONF_RANGE_FROM]
end_value = await get_end_value(v) or scale_conf[CONF_RANGE_TO]
# Create and apply styles based on indicator type
if t == CONF_ARC:
props = {
"arc_width": v[CONF_WIDTH],
"arc_color": v[CONF_COLOR],
"arc_opa": v[CONF_OPA],
"arc_rounded": v.get("arc_rounded", False),
}
if CONF_R_MOD in v:
get_warnings().add(
"The 'r_mod' indicator property is not supported in LVGL 9.x and will be ignored."
)
arc_style = LVStyle(f"meter_arc_{iid.id}", props)
tvar = cg.Pvariable(iid, lv_expr.scale_add_section(scale_var))
lv.scale_section_set_style(
tvar, LV_PART.MAIN, await arc_style.get_var()
)
lw = Widget.create(iid, tvar, arc_indicator_type)
await set_indicator_values(lw, v)
if t == CONF_TICK_STYLE:
# No object created for this
color_start = await lv_color.process(v[CONF_COLOR_START])
color_end = await lv_color.process(v[CONF_COLOR_END])
local = v[CONF_LOCAL]
if color_start and color_end:
async with LambdaContext(
[(lv_event_t.operator("ptr"), "e")]
) as lambda_:
lv.scale_draw_event_cb(
lambda_.get_parameter(0),
start_value,
end_value,
color_start,
color_end,
v[CONF_WIDTH],
local,
)
lv_obj.add_event_cb(
scale_var,
await lambda_.get_lambda(),
LV_EVENT.DRAW_TASK_ADDED,
nullptr,
)
lv.obj_add_flag(scale_var, LV_OBJ_FLAG.SEND_DRAW_TASK_EVENTS)
if t == CONF_LINE:
# Needle represented by a line
if CONF_LENGTH in v:
length = v[CONF_LENGTH]
elif r_mod := v.get(CONF_R_MOD):
get_remapped_uses().add(CONF_R_MOD)
length = -abs(r_mod)
else:
length = 1.0
props = {
CONF_ID: v[CONF_ID],
CONF_OPA: v[CONF_OPA],
CONF_LINE_WIDTH: v[CONF_WIDTH],
"line_color": v[CONF_COLOR],
"line_rounded": True,
CONF_ALIGN: CHILD_ALIGNMENTS.TOP_LEFT,
CONF_LENGTH: length,
CONF_RADIAL_OFFSET: v[CONF_RADIAL_OFFSET],
}
lw = await widget_to_code(props, line_indicator_type, scale_var)
await set_indicator_values(lw, v)
if t == CONF_IMAGE:
add_lv_use(CONF_IMAGE)
src = v[CONF_SRC]
src_data = get_image_metadata(src.id)
pivot_x = await pixels.process(v[CONF_PIVOT_X])
pivot_y = await pixels.process(
v.get(CONF_PIVOT_Y, src_data.height // 2)
)
props = {
CONF_X: src_data.width // 2 - pivot_x,
"transform_pivot_x": pivot_x,
"transform_pivot_y": pivot_y,
CONF_SRC: src,
CONF_OPA: v[CONF_OPA],
CONF_ID: v[CONF_ID],
CONF_ALIGN: CHILD_ALIGNMENTS.CENTER,
}
iw = await widget_to_code(props, image_indicator_type, scale_var)
await iw.set_property(CONF_SRC, await lv_image.process(src))
await set_indicator_values(iw, v)
# Hide the scale line
lv.obj_set_style_arc_opa(scale_var, LV_OPA.TRANSP, LV_PART.MAIN)
if ticks := scale_conf.get(CONF_TICKS):
# Set total tick count
lv.scale_set_total_tick_count(scale_var, ticks[CONF_COUNT])
lv.scale_set_draw_ticks_on_top(
scale_var, scale_conf[CONF_DRAW_TICKS_ON_TOP]
)
# Set tick styling
lv_obj.set_style_length(
scale_var, await size.process(ticks[CONF_LENGTH]), LV_PART.ITEMS
)
lv_obj.set_style_line_width(
scale_var, await size.process(ticks[CONF_WIDTH]), LV_PART.ITEMS
)
lv_obj.set_style_radial_offset(
scale_var,
await size.process(ticks[CONF_RADIAL_OFFSET]),
LV_PART.ITEMS,
)
lv_obj.set_style_line_color(
scale_var,
await lv_color.process(ticks[CONF_COLOR]),
LV_PART.ITEMS,
)
if CONF_MAJOR in ticks:
major = ticks[CONF_MAJOR]
# Set major tick frequency
lv.scale_set_major_tick_every(scale_var, major[CONF_STRIDE])
# Enable labels for major ticks
lv.scale_set_label_show(scale_var, True)
# Set major tick styling
lv_obj.set_style_length(
scale_var,
await size.process(major[CONF_LENGTH]),
LV_PART.INDICATOR,
)
lv_obj.set_style_radial_offset(
scale_var,
await size.process(ticks[CONF_RADIAL_OFFSET]),
LV_PART.INDICATOR,
)
lv_obj.set_style_line_width(
scale_var,
await size.process(major[CONF_WIDTH]),
LV_PART.INDICATOR,
)
lv_obj.set_style_line_color(
scale_var,
await lv_color.process(major[CONF_COLOR]),
LV_PART.INDICATOR,
)
# Set label gap (padding)
label_gap = await size.process(major[CONF_LABEL_GAP])
if isinstance(label_gap, int):
label_gap -= DEFAULT_LABEL_GAP
lv_obj.set_style_pad_radial(
scale_var,
label_gap,
LV_PART.INDICATOR,
)
else:
lv.scale_set_major_tick_every(scale_var, 0)
else:
# Must have at least 2 ticks otherwise the scale isn't even drawn
lv.scale_set_total_tick_count(scale_var, 2)
# Hide the ticks by making them 0 width
lv_obj.set_style_line_width(scale_var, 0, LV_PART.ITEMS)
lv.scale_set_major_tick_every(scale_var, 0)
# Add a pivot
# Get the default style
pivot_style = PIVOT_STYLE.copy()
pivot_style.update(config.get(CONF_INDICATOR, config.get(CONF_PIVOT, {})))
with LocalVariable("pivot", lv_obj_t, lv_expr.container_create(var)) as pivot:
pw = Widget(pivot, obj_spec, pivot_style)
await set_obj_properties(pw, pivot_style)
meter_spec = MeterType()
@automation.register_action(
"lvgl.indicator.update",
ObjUpdateAction,
cv.Schema(
{
cv.Required(CONF_ID): cv.use_id(lv_meter_indicator_t),
cv.Exclusive(CONF_VALUE, CONF_VALUE): lv_float,
cv.Exclusive(CONF_START_VALUE, CONF_VALUE): lv_float,
cv.Optional(CONF_END_VALUE): lv_float,
cv.Optional(CONF_OPA): opacity,
}
),
synchronous=True,
)
async def indicator_update_to_code(config, action_id, template_arg, args):
widget = await get_widgets(config)
async def set_value(w: Widget):
await set_indicator_values(w, config)
return await action_to_code(
widget, set_value, action_id, template_arg, args, config
)
async def set_indicator_values(indicator: Widget, config):
"""Update scale section values (replaces meter indicator values)"""
start_value = await get_start_value(config)
end_value = await get_end_value(config)
if indicator.type is arc_indicator_type:
# For scale sections, we update the range
if start_value is not None and end_value is not None:
lv.scale_section_set_range(indicator.obj, start_value, end_value)
elif start_value is not None:
# If only start value, use it as both start and end (single point)
lv.scale_section_set_range(indicator.obj, start_value, start_value)
elif end_value is not None:
# If only end value, assume range from 0 to end_value
lv.scale_section_set_range(indicator.obj, 0, end_value)
return
if start_value is None:
return
if indicator.type is line_indicator_type:
# Line needle
lv_add(indicator.var.set_value(start_value))
return
if indicator.type is image_indicator_type:
# Needle represented by an image
lv_obj.set_style_transform_rotation(
indicator.obj,
lv.get_needle_angle_for_value(indicator.obj, start_value) * 10,
LV_PART.MAIN,
)