From f3ec83fe310e04022710be59609898744d2226d3 Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Wed, 13 Mar 2019 16:40:09 +0100 Subject: [PATCH] Add ESP32 Camera (#475) * Add ESP32 Camera * Fixes * Updates * Fix substitutions not working for non-ASCII * Update docker base image to 1.3.0 --- .gitlab-ci.yml | 4 +- docker/Dockerfile | 2 +- docker/Dockerfile.hassio | 2 +- docker/hooks/build | 4 +- esphome/components/esp32_camera.py | 130 ++++++++++++++++++++++++++++ esphome/components/servo.py | 59 +++++++++++++ esphome/components/substitutions.py | 3 +- esphome/config_validation.py | 14 ++- esphome/const.py | 3 + 9 files changed, 213 insertions(+), 8 deletions(-) create mode 100644 esphome/components/esp32_camera.py create mode 100644 esphome/components/servo.py diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 7b2c6e4dec..ebdf51df99 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -41,11 +41,11 @@ stages: - | if [[ "${IS_HASSIO}" == "YES" ]]; then - BUILD_FROM=esphome/esphome-hassio-base-${BUILD_ARCH}:1.2.1 + BUILD_FROM=esphome/esphome-hassio-base-${BUILD_ARCH}:1.3.0 BUILD_TO=esphome/esphome-hassio-${BUILD_ARCH} DOCKERFILE=docker/Dockerfile.hassio else - BUILD_FROM=esphome/esphome-base-${BUILD_ARCH}:1.2.1 + BUILD_FROM=esphome/esphome-base-${BUILD_ARCH}:1.3.0 if [[ "${BUILD_ARCH}" == "amd64" ]]; then BUILD_TO=esphome/esphome else diff --git a/docker/Dockerfile b/docker/Dockerfile index 08c74bb2c2..91477cdfe8 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,4 +1,4 @@ -ARG BUILD_FROM=esphome/esphome-base-amd64:1.2.1 +ARG BUILD_FROM=esphome/esphome-base-amd64:1.3.0 FROM ${BUILD_FROM} COPY . . diff --git a/docker/Dockerfile.hassio b/docker/Dockerfile.hassio index f077b8ebf8..c0a507485c 100644 --- a/docker/Dockerfile.hassio +++ b/docker/Dockerfile.hassio @@ -1,4 +1,4 @@ -ARG BUILD_FROM=esphome/esphome-hassio-base-amd64:1.2.1 +ARG BUILD_FROM=esphome/esphome-hassio-base-amd64:1.3.0 FROM ${BUILD_FROM} # Copy root filesystem diff --git a/docker/hooks/build b/docker/hooks/build index 482dacafc4..65c27d10da 100755 --- a/docker/hooks/build +++ b/docker/hooks/build @@ -16,11 +16,11 @@ echo "PWD: $PWD" if [[ ${IS_HASSIO} = "YES" ]]; then docker build \ - --build-arg "BUILD_FROM=esphome/esphome-hassio-base-${BUILD_ARCH}:1.2.1" \ + --build-arg "BUILD_FROM=esphome/esphome-hassio-base-${BUILD_ARCH}:1.3.0" \ --build-arg "BUILD_VERSION=${CACHE_TAG}" \ -t "${IMAGE_NAME}" -f ../docker/Dockerfile.hassio .. else docker build \ - --build-arg "BUILD_FROM=esphome/esphome-base-${BUILD_ARCH}:1.2.1" \ + --build-arg "BUILD_FROM=esphome/esphome-base-${BUILD_ARCH}:1.3.0" \ -t "${IMAGE_NAME}" -f ../docker/Dockerfile .. fi diff --git a/esphome/components/esp32_camera.py b/esphome/components/esp32_camera.py new file mode 100644 index 0000000000..a41e686efb --- /dev/null +++ b/esphome/components/esp32_camera.py @@ -0,0 +1,130 @@ +import voluptuous as vol + +from esphome import config_validation as cv, pins +from esphome.const import CONF_FREQUENCY, CONF_ID, CONF_NAME, CONF_PIN, CONF_SCL, CONF_SDA, \ + ESP_PLATFORM_ESP32 +from esphome.cpp_generator import Pvariable, add +from esphome.cpp_types import App, Nameable, PollingComponent, esphome_ns + +ESP_PLATFORMS = [ESP_PLATFORM_ESP32] + +ESP32Camera = esphome_ns.class_('ESP32Camera', PollingComponent, Nameable) +ESP32CameraFrameSize = esphome_ns.enum('ESP32CameraFrameSize') +FRAME_SIZES = { + '160X120': ESP32CameraFrameSize.ESP32_CAMERA_SIZE_160X120, + 'QQVGA': ESP32CameraFrameSize.ESP32_CAMERA_SIZE_160X120, + '128x160': ESP32CameraFrameSize.ESP32_CAMERA_SIZE_128X160, + 'QQVGA2': ESP32CameraFrameSize.ESP32_CAMERA_SIZE_128X160, + '176X144': ESP32CameraFrameSize.ESP32_CAMERA_SIZE_176X144, + 'QCIF': ESP32CameraFrameSize.ESP32_CAMERA_SIZE_176X144, + '240X176': ESP32CameraFrameSize.ESP32_CAMERA_SIZE_240X176, + 'HQVGA': ESP32CameraFrameSize.ESP32_CAMERA_SIZE_240X176, + '320X240': ESP32CameraFrameSize.ESP32_CAMERA_SIZE_320X240, + 'QVGA': ESP32CameraFrameSize.ESP32_CAMERA_SIZE_320X240, + '400X296': ESP32CameraFrameSize.ESP32_CAMERA_SIZE_400X296, + 'CIF': ESP32CameraFrameSize.ESP32_CAMERA_SIZE_400X296, + '640X480': ESP32CameraFrameSize.ESP32_CAMERA_SIZE_640X480, + 'VGA': ESP32CameraFrameSize.ESP32_CAMERA_SIZE_640X480, + '800X600': ESP32CameraFrameSize.ESP32_CAMERA_SIZE_800X600, + 'SVGA': ESP32CameraFrameSize.ESP32_CAMERA_SIZE_800X600, + '1024X768': ESP32CameraFrameSize.ESP32_CAMERA_SIZE_1024X768, + 'XGA': ESP32CameraFrameSize.ESP32_CAMERA_SIZE_1024X768, + '1280x1024': ESP32CameraFrameSize.ESP32_CAMERA_SIZE_1280X1024, + 'SXGA': ESP32CameraFrameSize.ESP32_CAMERA_SIZE_1280X1024, + '1600X1200': ESP32CameraFrameSize.ESP32_CAMERA_SIZE_1600X1200, + 'UXGA': ESP32CameraFrameSize.ESP32_CAMERA_SIZE_1600X1200, +} + +CONF_DATA_PINS = 'data_pins' +CONF_VSYNC_PIN = 'vsync_pin' +CONF_HREF_PIN = 'href_pin' +CONF_PIXEL_CLOCK_PIN = 'pixel_clock_pin' +CONF_EXTERNAL_CLOCK = 'external_clock' +CONF_I2C_PINS = 'i2c_pins' +CONF_RESET_PIN = 'reset_pin' +CONF_POWER_DOWN_PIN = 'power_down_pin' + +CONF_MAX_FRAMERATE = 'max_framerate' +CONF_IDLE_FRAMERATE = 'idle_framerate' +CONF_RESOLUTION = 'resolution' +CONF_JPEG_QUALITY = 'jpeg_quality' +CONF_VERTICAL_FLIP = 'vertical_flip' +CONF_HORIZONTAL_MIRROR = 'horizontal_mirror' +CONF_CONTRAST = 'contrast' +CONF_BRIGHTNESS = 'brightness' +CONF_SATURATION = 'saturation' +CONF_TEST_PATTERN = 'test_pattern' + +camera_range_param = vol.All(cv.int_, vol.Range(min=-2, max=2)) + +CONFIG_SCHEMA = cv.Schema({ + cv.GenerateID(): cv.declare_variable_id(ESP32Camera), + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_DATA_PINS): vol.All([pins.input_pin], vol.Length(min=8, max=8)), + vol.Required(CONF_VSYNC_PIN): pins.input_pin, + vol.Required(CONF_HREF_PIN): pins.input_pin, + vol.Required(CONF_PIXEL_CLOCK_PIN): pins.input_pin, + vol.Required(CONF_EXTERNAL_CLOCK): vol.Schema({ + vol.Required(CONF_PIN): pins.output_pin, + vol.Optional(CONF_FREQUENCY, default='20MHz'): vol.All(cv.frequency, vol.In([20e6, 10e6])), + }), + vol.Required(CONF_I2C_PINS): vol.Schema({ + vol.Required(CONF_SDA): pins.output_pin, + vol.Required(CONF_SCL): pins.output_pin, + }), + vol.Optional(CONF_RESET_PIN): pins.output_pin, + vol.Optional(CONF_POWER_DOWN_PIN): pins.output_pin, + + vol.Optional(CONF_MAX_FRAMERATE, default='10 fps'): vol.All(cv.framerate, + vol.Range(min=0, min_included=False, + max=60)), + vol.Optional(CONF_IDLE_FRAMERATE, default='0.1 fps'): vol.All(cv.framerate, + vol.Range(min=0, max=1)), + vol.Optional(CONF_RESOLUTION, default='640X480'): cv.one_of(*FRAME_SIZES, upper=True), + vol.Optional(CONF_JPEG_QUALITY, default=10): vol.All(cv.int_, vol.Range(min=10, max=63)), + vol.Optional(CONF_CONTRAST, default=0): camera_range_param, + vol.Optional(CONF_BRIGHTNESS, default=0): camera_range_param, + vol.Optional(CONF_SATURATION, default=0): camera_range_param, + vol.Optional(CONF_VERTICAL_FLIP, default=True): cv.boolean, + vol.Optional(CONF_HORIZONTAL_MIRROR, default=True): cv.boolean, + vol.Optional(CONF_TEST_PATTERN, default=False): cv.boolean, +}).extend(cv.COMPONENT_SCHEMA.schema) + +SETTERS = { + CONF_DATA_PINS: 'set_data_pins', + CONF_VSYNC_PIN: 'set_vsync_pin', + CONF_HREF_PIN: 'set_href_pin', + CONF_PIXEL_CLOCK_PIN: 'set_pixel_clock_pin', + CONF_RESET_PIN: 'set_reset_pin', + CONF_POWER_DOWN_PIN: 'set_power_down_pin', + CONF_JPEG_QUALITY: 'set_jpeg_quality', + CONF_VERTICAL_FLIP: 'set_vertical_flip', + CONF_HORIZONTAL_MIRROR: 'set_horizontal_mirror', + CONF_CONTRAST: 'set_contrast', + CONF_BRIGHTNESS: 'set_brightness', + CONF_SATURATION: 'set_saturation', + CONF_TEST_PATTERN: 'set_test_pattern', +} + + +def to_code(config): + rhs = App.register_component(ESP32Camera.new(config[CONF_NAME])) + cam = Pvariable(config[CONF_ID], rhs) + + for key, setter in SETTERS.items(): + if key in config: + add(getattr(cam, setter)(config[key])) + + extclk = config[CONF_EXTERNAL_CLOCK] + add(cam.set_external_clock(extclk[CONF_PIN], extclk[CONF_FREQUENCY])) + i2c_pins = config[CONF_I2C_PINS] + add(cam.set_i2c_pins(i2c_pins[CONF_SDA], i2c_pins[CONF_SCL])) + add(cam.set_max_update_interval(1000 / config[CONF_MAX_FRAMERATE])) + if config[CONF_IDLE_FRAMERATE] == 0: + add(cam.set_idle_update_interval(0)) + else: + add(cam.set_idle_update_interval(1000 / config[CONF_IDLE_FRAMERATE])) + add(cam.set_frame_size(FRAME_SIZES[config[CONF_RESOLUTION]])) + + +BUILD_FLAGS = '-DUSE_ESP32_CAMERA' diff --git a/esphome/components/servo.py b/esphome/components/servo.py new file mode 100644 index 0000000000..0d3769d458 --- /dev/null +++ b/esphome/components/servo.py @@ -0,0 +1,59 @@ +import voluptuous as vol + +from esphome.automation import ACTION_REGISTRY +from esphome.components.output import FloatOutput +import esphome.config_validation as cv +from esphome.const import CONF_ID, CONF_IDLE_LEVEL, CONF_MAX_LEVEL, CONF_MIN_LEVEL, CONF_OUTPUT, \ + CONF_LEVEL +from esphome.cpp_generator import Pvariable, add, get_variable, templatable +from esphome.cpp_helpers import setup_component +from esphome.cpp_types import App, Component, esphome_ns, Action, float_ + +Servo = esphome_ns.class_('Servo', Component) +ServoWriteAction = esphome_ns.class_('ServoWriteAction', Action) + +MULTI_CONF = True + +CONFIG_SCHEMA = cv.Schema({ + vol.Required(CONF_ID): cv.declare_variable_id(Servo), + vol.Required(CONF_OUTPUT): cv.use_variable_id(FloatOutput), + vol.Optional(CONF_MIN_LEVEL, default='3%'): cv.percentage, + vol.Optional(CONF_IDLE_LEVEL, default='7.5%'): cv.percentage, + vol.Optional(CONF_MAX_LEVEL, default='12%'): cv.percentage, +}).extend(cv.COMPONENT_SCHEMA.schema) + + +def to_code(config): + for out in get_variable(config[CONF_OUTPUT]): + yield + + rhs = App.register_component(Servo.new(out)) + servo = Pvariable(config[CONF_ID], rhs) + + add(servo.set_min_level(config[CONF_MIN_LEVEL])) + add(servo.set_idle_level(config[CONF_IDLE_LEVEL])) + add(servo.set_max_level(config[CONF_MAX_LEVEL])) + + setup_component(servo, config) + + +BUILD_FLAGS = '-DUSE_SERVO' + +CONF_SERVO_WRITE = 'servo.write' +SERVO_WRITE_ACTION_SCHEMA = cv.Schema({ + vol.Required(CONF_ID): cv.use_variable_id(Servo), + vol.Required(CONF_LEVEL): cv.templatable(cv.possibly_negative_percentage), +}) + + +@ACTION_REGISTRY.register(CONF_SERVO_WRITE, SERVO_WRITE_ACTION_SCHEMA) +def servo_write_to_code(config, action_id, template_arg, args): + for var in get_variable(config[CONF_ID]): + yield None + rhs = ServoWriteAction.new(template_arg, var) + type = ServoWriteAction.template(template_arg) + action = Pvariable(action_id, rhs, type=type) + for template_ in templatable(config[CONF_LEVEL], args, float_): + yield None + add(action.set_value(template_)) + yield action diff --git a/esphome/components/substitutions.py b/esphome/components/substitutions.py index 9e7d7bce9d..7189428391 100644 --- a/esphome/components/substitutions.py +++ b/esphome/components/substitutions.py @@ -6,6 +6,7 @@ import voluptuous as vol from esphome import core import esphome.config_validation as cv from esphome.core import EsphomeError +from esphome.py_compat import string_types _LOGGER = logging.getLogger(__name__) @@ -93,7 +94,7 @@ def _substitute_item(substitutions, item, path): for old, new in replace_keys: item[new] = item[old] del item[old] - elif isinstance(item, str): + elif isinstance(item, string_types): sub = _expand_substitutions(substitutions, item, path) if sub != item: return sub diff --git a/esphome/config_validation.py b/esphome/config_validation.py index ed9ece9a20..a78f0ad503 100644 --- a/esphome/config_validation.py +++ b/esphome/config_validation.py @@ -28,6 +28,7 @@ port = vol.All(vol.Coerce(int), vol.Range(min=1, max=65535)) float_ = vol.Coerce(float) positive_float = vol.All(float_, vol.Range(min=0)) zero_to_one_float = vol.All(float_, vol.Range(min=0, max=1)) +negative_one_to_one_float = vol.All(float_, vol.Range(min=-1, max=1)) positive_int = vol.All(vol.Coerce(int), vol.Range(min=0)) positive_not_null_int = vol.All(vol.Coerce(int), vol.Range(min=0, min_included=False)) @@ -442,6 +443,7 @@ resistance = float_with_unit("resistance", r"(Ω|Ω|ohm|Ohm|OHM)?") current = float_with_unit("current", r"(a|A|amp|Amp|amps|Amps|ampere|Ampere)?") voltage = float_with_unit("voltage", r"(v|V|volt|Volts)?") distance = float_with_unit("distance", r"(m)") +framerate = float_with_unit("framerate", r"(FPS|fps|Fps|FpS|Hz)") def validate_bytes(value): @@ -606,6 +608,11 @@ i2c_address = hex_uint8_t def percentage(value): + value = possibly_negative_percentage(value) + return zero_to_one_float(value) + + +def possibly_negative_percentage(value): has_percent_sign = isinstance(value, string_types) and value.endswith('%') if has_percent_sign: value = float(value[:-1].rstrip()) / 100.0 @@ -614,7 +621,12 @@ def percentage(value): if not has_percent_sign: msg += " Please put a percent sign after the number!" raise vol.Invalid(msg) - return zero_to_one_float(value) + if value < -1: + msg = "Percentage must not be smaller than -100%." + if not has_percent_sign: + msg += " Please put a percent sign after the number!" + raise vol.Invalid(msg) + return negative_one_to_one_float(value) def percentage_int(value): diff --git a/esphome/const.py b/esphome/const.py index 1d08291fac..654f6f66cd 100644 --- a/esphome/const.py +++ b/esphome/const.py @@ -40,6 +40,9 @@ CONF_OTA = 'ota' CONF_MQTT = 'mqtt' CONF_BROKER = 'broker' CONF_USERNAME = 'username' +CONF_MIN_LEVEL = 'min_level' +CONF_IDLE_LEVEL = 'idle_level' +CONF_MAX_LEVEL = 'max_level' CONF_POWER_SUPPLY = 'power_supply' CONF_ID = 'id' CONF_MQTT_ID = 'mqtt_id'