This shows you the differences between two versions of the page.
| en:tech:gardenled [2022/11/10 20:08] – created bullar | en:tech:gardenled [2026/08/12 13:42] (current) – claude | ||
|---|---|---|---|
| Line 1: | Line 1: | ||
| ====== Garden LED ====== | ====== Garden LED ====== | ||
| - | Coming soon… | + | ===== Motivation ===== |
| + | |||
| + | Our property is - as is common here in Thailand - enclosed by a two-meter-high wall. It keeps out animals like stray dogs but also snakes and the like. From the inside, you can of course plant this wall lushly. | ||
| + | |||
| + | {{ : | ||
| + | |||
| + | A nice visual treat would be indirect lighting with dimmable LED spotlights behind the plants. We're talking about a length of roughly 40m that needs to be lit. A solution was needed... | ||
| + | |||
| + | ===== Approach ===== | ||
| + | |||
| + | We have a 230V supply running through the garden, but it's neither switchable nor dimmable, since other loads such as outdoor outlets and the ground lighting are connected to it. | ||
| + | I ruled out adding separate wiring from the house after failing to trace the path of the underground conduits. So something wireless was needed instead, which (as always) also needs to be integrable into [[https:// | ||
| + | |||
| + | Beforehand I'd already picked the LED spots based on looks, output, price, and size. I settled on a 5W/600lm floodlight with 12V AC/DC supply in an IP65 housing from the Thai Amazon: LAZADA. | ||
| + | |||
| + | {{ : | ||
| + | |||
| + | At a price of under €4 each, it really does come in a waterproof aluminum housing with an internal control board, and it works! The light source is a 3mm LED driven at 21.5V with a constant current of 266mA. The higher voltage is produced by a boost regulator [[http:// | ||
| + | |||
| + | <WRAP group> | ||
| + | <WRAP half column> | ||
| + | {{ : | ||
| + | </ | ||
| + | <WRAP half column> | ||
| + | | ||
| + | </ | ||
| + | </ | ||
| + | |||
| + | Now we just need an idea for how to provide the dimming level selected in Home Assistant as a PWM signal for each spotlight. The LED's housing doesn' | ||
| + | |||
| + | Early attempts at communicating over the low-voltage lines failed for reliability and scalability reasons. There is actually a clever approach for that: [[https:// | ||
| + | |||
| + | For data transmission over several tens of meters, [[https:// | ||
| + | |||
| + | |||
| + | ===== Hardware ===== | ||
| + | |||
| + | The LED boost driver with the BP1808 needs to be extended with a circuit for receiving serial data over RS485, and it needs to be as small as possible. The interior of the lamp housing only holds about 40mm x 20mm. For the control CPU I went with an [[https:// | ||
| + | |||
| + | |||
| + | ==== Schematic ==== | ||
| + | |||
| + | Put together, the schematic looks like this: | ||
| + | |||
| + | {{ : | ||
| + | |||
| + | The LED boost driver is identical to the original version and just extended with PWM control. Since my early attempts caused a couple of hardware failures (ATTINY85 + BP1808) due to overvoltage, | ||
| + | |||
| + | |||
| + | ==== PCB ==== | ||
| + | |||
| + | Given the geometric constraints, | ||
| + | |||
| + | {{ : | ||
| + | |||
| + | At 39.3mm x 19mm, the board size is already pushing the mechanical limit, but it fits. In reality the front side of the board does look pretty densely packed. The hand-written **4** on the CPU is the programmed ID of the LED. This number lets you address the spotlight individually. More on that later. | ||
| + | |||
| + | {{ : | ||
| + | |||
| + | To install the board electrically isolated inside the housing, I 3D-print a two-part cover from flexible TPU. Slid over both ends of the board, it keeps everything protected against unwanted short circuits. The STL file is available in the downloads section. | ||
| + | |||
| + | {{ : | ||
| + | |||
| + | |||
| + | ===== Firmware ===== | ||
| + | |||
| + | We've settled on RS485 for the physical layer, but we still need a data link layer that increases transmission reliability - after all, the spotlights shouldn' | ||
| + | |||
| + | In my implementation a frame consists of 13 bytes: | ||
| + | * 3x header bytes (0xAA) | ||
| + | * 1x ID byte (0..15) | ||
| + | * 3x payload bytes | ||
| + | * Function (0...255) | ||
| + | * Value (0...255) | ||
| + | * Delay (0...255) | ||
| + | * 4x CRC bytes (32-bit) | ||
| + | * 1x EOF byte (0x55) | ||
| + | |||
| + | which is answered by the addressed spotlight depending on the function. Broadcast commands don't get a response from the other side. Here's a quick command overview: | ||
| + | |||
| + | < | ||
| + | // min_id (8-bit): | ||
| + | // 0x00-0x0f ID LED lamp 1 - 16 -> function + value | ||
| + | // 0x10-0x1F Response ID LED lamp 1 - 16 | ||
| + | // 0x3E all LED -> function + value (no response) | ||
| + | // 0X3F - | ||
| + | // | ||
| + | // Payload / Response: | ||
| + | // Byte 1 Function | ||
| + | // 0x00 LED Off | ||
| + | // 0x01 LED Off (stored delay) | ||
| + | // 0x02 LED On | ||
| + | // 0x03 LED On (stored value/ | ||
| + | // 0x04 Set stored value/ | ||
| + | // 0x05 Get stored value/ | ||
| + | // 0x06 Status | ||
| + | // 0x07 Status LED on/ | ||
| + | // Byte 2 Value 0..255 LED brightness | ||
| + | // Byte 3 Delay 0..255 LED fade up/down delay | ||
| + | |||
| + | // Payload Response Error: | ||
| + | // Byte 1 0xFF | ||
| + | // Byte 2 Error code | ||
| + | // 0x00 Payload size wrong (byte 3 = wrong size value)) | ||
| + | // 0x01 Unknown Function | ||
| + | // Byte 3 <not used> | ||
| + | </ | ||
| + | |||
| + | Individual brightness and fade values can also be stored in the spotlights, which then allows different brightness levels in response to broadcast commands. The protocol is implemented in the '' | ||
| + | |||
| + | |||
| + | ==== Programming ==== | ||
| + | |||
| + | Out of the factory, the ATTINY85' | ||
| + | |||
| + | {{ : | ||
| + | |||
| + | The debug and programming tool of choice remains the //SNAP (PG164100)// | ||
| + | |||
| + | {{ : | ||
| + | |||
| + | |||
| + | ===== Integration ===== | ||
| + | |||
| + | Once the spotlights are programmed as described above, the LEDs can already be addressed from a PC via a USB-RS485 converter. A Python variant is also available for the MIN protocol. | ||
| + | |||
| + | My goal, though, is to integrate the spotlights into Home Assistant (HA). For that we need a gateway that exposes the spotlights as //Lights// in HA and also includes a UART server so we can send arbitrary commands to the LEDs too. Sounds complicated, | ||
| + | |||
| + | For the UART server there' | ||
| + | |||
| + | <code C> | ||
| + | void StreamServerComponent:: | ||
| + | digitalWrite(DIR_PORT, | ||
| + | #if ESPHOME_VERSION_CODE >= VERSION_CODE(2021, | ||
| + | this-> | ||
| + | this-> | ||
| + | #else | ||
| + | size_t len; | ||
| + | while ((len = this-> | ||
| + | this-> | ||
| + | this-> | ||
| + | } | ||
| + | #endif | ||
| + | this-> | ||
| + | digitalWrite(DIR_PORT, | ||
| + | } | ||
| + | </ | ||
| + | |||
| + | To expose the LEDs as lights we again write a //custom component// | ||
| + | |||
| + | ++++ garden-leds.h | | ||
| + | <file C garden-leds.h> | ||
| + | #include " | ||
| + | |||
| + | using namespace esphome; | ||
| + | |||
| + | #define LED_GLOBAL | ||
| + | #define LED_ON | ||
| + | #define LED_FADE | ||
| + | #define LED_BRIGHTNESS | ||
| + | |||
| + | #define DIR_PORT | ||
| + | #define BAUDRATE | ||
| + | |||
| + | #define MAX_BYTES_PER_FRAME 16 | ||
| + | #define MAX_PAYLOAD 3 | ||
| + | |||
| + | uint8_t min_payload[3] = { LED_ON, LED_BRIGHTNESS, | ||
| + | |||
| + | // MIN Protocol v2.0 Copyright (c) 2014-2017 JK Energy Ltd. | ||
| + | // Use authorized under the MIT license. | ||
| + | |||
| + | // Special protocol bytes | ||
| + | enum { | ||
| + | HEADER_BYTE = 0xaaU, | ||
| + | STUFF_BYTE = 0x55U, | ||
| + | EOF_BYTE = 0x55U, | ||
| + | }; | ||
| + | |||
| + | // Number of bytes needed for a frame with a given payload length, excluding stuff bytes | ||
| + | // 3 header bytes, ID/control byte, length byte, seq byte, 4 byte CRC, EOF byte | ||
| + | #define ON_WIRE_SIZE(p) | ||
| + | |||
| + | struct crc32_context { | ||
| + | uint32_t crc; | ||
| + | }; | ||
| + | |||
| + | struct min_context { | ||
| + | uint8_t rx_frame_payload_buf[MAX_PAYLOAD]; | ||
| + | uint32_t rx_frame_checksum; | ||
| + | struct crc32_context rx_checksum; | ||
| + | struct crc32_context tx_checksum; | ||
| + | uint8_t rx_header_bytes_seen; | ||
| + | uint8_t rx_frame_state; | ||
| + | uint8_t rx_frame_payload_bytes; | ||
| + | uint8_t rx_frame_id_control; | ||
| + | uint8_t rx_frame_seq; | ||
| + | uint8_t rx_frame_length; | ||
| + | uint8_t rx_control; | ||
| + | uint8_t tx_header_byte_countdown; | ||
| + | uint8_t port; // Number of the port associated with the context | ||
| + | }; | ||
| + | |||
| + | // MIN calback functions | ||
| + | // --------------------------------------------------------------------------- | ||
| + | void min_tx_start(uint8_t port) { | ||
| + | digitalWrite(DIR_PORT, | ||
| + | } | ||
| + | |||
| + | // --------------------------------------------------------------------------- | ||
| + | void min_tx_finished(uint8_t port) { | ||
| + | Serial.flush(); | ||
| + | digitalWrite(DIR_PORT, | ||
| + | } | ||
| + | |||
| + | // --------------------------------------------------------------------------- | ||
| + | // Tell MIN how much space there is to write to the serial port. This is used | ||
| + | // inside MIN to decide whether to bother sending a frame or not. | ||
| + | uint16_t min_tx_space(uint8_t port) | ||
| + | { | ||
| + | return MAX_BYTES_PER_FRAME; | ||
| + | } | ||
| + | |||
| + | // --------------------------------------------------------------------------- | ||
| + | // Send a character on the designated port. | ||
| + | void min_tx_byte(uint8_t port, uint8_t byte) | ||
| + | { | ||
| + | // Ignore ' | ||
| + | Serial.write(byte); | ||
| + | } | ||
| + | |||
| + | // --------------------------------------------------------------------------- | ||
| + | static void crc32_init_context(struct crc32_context *context) | ||
| + | { | ||
| + | context-> | ||
| + | } | ||
| + | |||
| + | // --------------------------------------------------------------------------- | ||
| + | static void crc32_step(struct crc32_context *context, uint8_t byte) | ||
| + | { | ||
| + | uint32_t j; | ||
| + | context-> | ||
| + | for(j = 0; j < 8; j++) { | ||
| + | uint32_t mask = (uint32_t) -(context-> | ||
| + | context-> | ||
| + | } | ||
| + | } | ||
| + | |||
| + | // --------------------------------------------------------------------------- | ||
| + | static uint32_t crc32_finalize(struct crc32_context *context) | ||
| + | { | ||
| + | return ~context-> | ||
| + | } | ||
| + | |||
| + | // --------------------------------------------------------------------------- | ||
| + | static void stuffed_tx_byte(struct min_context *self, uint8_t byte, bool crc) | ||
| + | { | ||
| + | // Transmit the byte | ||
| + | min_tx_byte(self-> | ||
| + | if(crc) { | ||
| + | crc32_step(& | ||
| + | } | ||
| + | |||
| + | // See if an additional stuff byte is needed | ||
| + | if(byte == HEADER_BYTE) { | ||
| + | if(--self-> | ||
| + | min_tx_byte(self-> | ||
| + | self-> | ||
| + | } | ||
| + | } | ||
| + | else { | ||
| + | self-> | ||
| + | } | ||
| + | } | ||
| + | |||
| + | // --------------------------------------------------------------------------- | ||
| + | // Send frame on wire | ||
| + | static void on_wire_bytes(struct min_context *self, uint8_t id_control, uint8_t seq, uint8_t const *payload_base, | ||
| + | { | ||
| + | uint8_t n, i; | ||
| + | uint32_t checksum; | ||
| + | |||
| + | self-> | ||
| + | crc32_init_context(& | ||
| + | |||
| + | min_tx_start(self-> | ||
| + | |||
| + | // Header is 3 bytes; because unstuffed will reset receiver immediately | ||
| + | min_tx_byte(self-> | ||
| + | min_tx_byte(self-> | ||
| + | min_tx_byte(self-> | ||
| + | |||
| + | stuffed_tx_byte(self, | ||
| + | if(id_control & 0x80U) { | ||
| + | // Send the sequence number if it is a transport frame | ||
| + | stuffed_tx_byte(self, | ||
| + | } | ||
| + | |||
| + | stuffed_tx_byte(self, | ||
| + | |||
| + | for(i = 0, n = payload_len; | ||
| + | stuffed_tx_byte(self, | ||
| + | payload_offset++; | ||
| + | payload_offset &= payload_mask; | ||
| + | } | ||
| + | |||
| + | checksum = crc32_finalize(& | ||
| + | |||
| + | // Network order is big-endian. A decent C compiler will spot that this | ||
| + | // is extracting bytes and will use efficient instructions. | ||
| + | stuffed_tx_byte(self, | ||
| + | stuffed_tx_byte(self, | ||
| + | stuffed_tx_byte(self, | ||
| + | stuffed_tx_byte(self, | ||
| + | |||
| + | // Ensure end-of-frame doesn' | ||
| + | min_tx_byte(self-> | ||
| + | |||
| + | min_tx_finished(self-> | ||
| + | } | ||
| + | |||
| + | // --------------------------------------------------------------------------- | ||
| + | // Sends an application MIN frame on the wire (do not put into the transport queue) | ||
| + | void min_send_frame(struct min_context *self, uint8_t min_id, uint8_t const *payload, uint8_t payload_len) | ||
| + | { | ||
| + | if((ON_WIRE_SIZE(payload_len) <= min_tx_space(self-> | ||
| + | on_wire_bytes(self, | ||
| + | } | ||
| + | } | ||
| + | |||
| + | struct min_context min_ctx; | ||
| + | |||
| + | // --------------------------------------------------------------------------- | ||
| + | // float output class | ||
| + | class Min_Led : public Component, public FloatOutput { | ||
| + | | ||
| + | void setup() override { | ||
| + | // switch RS485 driver to receive mode | ||
| + | digitalWrite(DIR_PORT, | ||
| + | Serial.begin(BAUDRATE); | ||
| + | } | ||
| + | |||
| + | void write_state(float state) override { | ||
| + | // state is the amount this output should be on, from 0.0 to 1.0 | ||
| + | // we need to convert it to an integer first | ||
| + | int value = state * 255; | ||
| + | min_payload[1] = value; | ||
| + | min_send_frame(& | ||
| + | } | ||
| + | }; | ||
| + | </ | ||
| + | ++++ | ||
| + | |||
| + | ---- | ||
| + | |||
| + | |||
| + | Now we create a new device in ESPHome again and point it at the two //custom codes// '' | ||
| + | We already copied the first part into '' | ||
| + | |||
| + | |||
| + | <code yaml> | ||
| + | esphome: | ||
| + | name: garden-leds | ||
| + | includes: | ||
| + | - garden-leds.h | ||
| + | |||
| + | external_components: | ||
| + | - source: | ||
| + | type: local | ||
| + | path: my_components | ||
| + | components: [stream_server] | ||
| + | </ | ||
| + | |||
| + | |||
| + | |||
| + | And finally, the setup of the //light// and the // | ||
| + | |||
| + | <code yaml> | ||
| + | uart: | ||
| + | id: uart_min | ||
| + | tx_pin: GPIO1 | ||
| + | rx_pin: GPIO3 | ||
| + | baud_rate: 9600 | ||
| + | |||
| + | stream_server: | ||
| + | uart_id: uart_min | ||
| + | port: 6638 | ||
| + | |||
| + | output: | ||
| + | - platform: custom | ||
| + | type: float | ||
| + | lambda: |- | ||
| + | auto min_led_pwm = new Min_Led(); | ||
| + | App.register_component(min_led_pwm); | ||
| + | return{min_led_pwm}; | ||
| + | outputs: | ||
| + | id: led_pwm | ||
| + | |||
| + | light: | ||
| + | - platform: monochromatic | ||
| + | name: " | ||
| + | output: led_pwm | ||
| + | </ | ||
| + | |||
| + | If needed, the pins '' | ||
| + | |||
| + | |||
| + | ==== Hardware Gateway ==== | ||
| + | |||
| + | For the hardware, besides the ESP module of choice, we need an AC/DC step-down regulator from ~12VAC to +5VDC, a 5V< | ||
| + | |||
| + | ^ ESP8266 ^ RS485 Driver ^ | ||
| + | | GPIO1 | DI | | ||
| + | | GPIO3 | RO | | ||
| + | | GPIO13 | RE+DE | | ||
| + | | +5V | VCC | | ||
| + | | GND | GND | | ||
| + | |||
| + | Soldered onto a piece of prototype board and packed into a waterproof housing, the whole thing looks like this: | ||
| + | |||
| + | {{ : | ||
| + | |||
| + | |||
| + | ==== Overall Wiring ==== | ||
| + | |||
| + | Between the spotlights and the gateway we need a 4-core cable. Since I route it outside inside a waterproof conduit, a PVC hose cable H03VV-F4G0.75 is enough for me. When wiring, simply connect the 12VAC and the two signals A + B in a chain from one fixture to the next: | ||
| + | |||
| + | ^ Gateway | ||
| + | | ~12V | ~12V | ~12V | ... | ~12V | | ||
| + | | ~12V | ~12V | ~12V | ... | ~12V | | ||
| + | | A | A | A | ... | A | | ||
| + | | B | B | B | ... | B | | ||
| + | |||
| + | Depending on the number of spots, you'll also need a sufficiently powerful transformer for ~230V to ~12V. Here you can, for example, reuse a used halogen transformer - these can be found plentifully in second-hand markets. I got my 200W ABB transformer, | ||
| + | |||
| + | |||
| + | ===== Commissioning ===== | ||
| + | |||
| + | Once we connect up the fully wired assembly, the light with dimming function is available in Home Assistant. The fade time can also be set there and defaults to 1s. Thanks to the stream server we can also access the LEDs from, say, a PC. For that I wrote a small Python program (source code in the downloads section). | ||
| + | |||
| + | {{ : | ||
| + | |||
| + | This lets you run any function on all or individual LEDs. **CHK** searches for connected LEDs, and at the bottom there' | ||
| + | |||
| + | |||
| + | ===== Experience ===== | ||
| + | |||
| + | At the moment there' | ||
| + | |||
| + | |||
| + | ===== Downloads ===== | ||
| + | |||
| + | * {{: | ||
| + | * {{: | ||
| + | * {{: | ||
| + | * {{: | ||
| + | * {{: | ||
| + | * {{: | ||
| + | * {{| LED Test Tool Wifi (.py)}} | ||
| + | |||
| + | |||
| + | |||
| + | |||
| + | |||
| + | |||
| + | |||
| + | |||
| + | |||
| + | =====Links===== | ||
| + | * [[https:// | ||
| + | * [[https:// | ||
| + | * [[https:// | ||
| + | |||
| + | ===== Donate ===== | ||
| + | |||
| + | If you'd like to support my work, feel free to buy me a cappuccino or so: . | ||
| + | |||
| + | < | ||
| + | |||
| + | <form action=" | ||
| + | <input type=" | ||
| + | <input type=" | ||
| + | <img alt="" | ||
| + | </ | ||
| + | |||
| + | </ | ||