Site Tools


en:tech:gardenled

Differences

This shows you the differences between two versions of the page.

Link to this comparison view

en:tech:gardenled [2022/11/10 20:08] – created bullaren: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. 
 + 
 +{{ :media:tech:solarvilla:villa_garden.png?direct&400 |Garden wall}} 
 + 
 +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://www.home-assistant.io|Home Assistant]]. 
 + 
 +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. 
 + 
 +{{ :media:tech:gardenled:led_spotlight.png?direct&300 |}} 
 + 
 +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://www.bpsemi.com/en/product_result.php?id=236|(BP1808)]], which also has an unused DIM input. This can be driven either with an analog signal or a PWM signal. An initial test with a 500Hz PWM showed the desired dimming behavior. 
 + 
 +<WRAP group> 
 +<WRAP half column> 
 +{{ :tech:bp1808_front.png?direct&200|LED driver front side}} 
 +</WRAP> 
 +<WRAP half column> 
 + {{:media:tech:gardenled:bp1808_back.png?direct&200 |LED driver back side}} 
 +</WRAP> 
 +</WRAP> 
 + 
 +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't allow for a bulky solution. [[https://en.wikipedia.org/wiki/DMX512|DMX512]] seems a bit overkill here. So why not try a proprietary approach for once. 
 + 
 +Early attempts at communicating over the low-voltage lines failed for reliability and scalability reasons. There is actually a clever approach for that: [[https://www.electronicdesign.com/technologies/communications/article/21799727/simple-circuit-communicates-over-lowvoltage-power-lines|Simple Circuit Communicates Over Low-Voltage Power Lines]]. Implemented with an ATTINY85, it basically worked between two transceivers over 10m at 2400 baud, but it's not suitable out-of-the-box for this application. 
 + 
 +For data transmission over several tens of meters, [[https://en.wikipedia.org/wiki/RS-485|RS485]] always comes to mind too. Suitable driver ICs are widely available and a standard UART is enough as the communication interface. To cut to the chase, this idea did in fact lead to success. Here's the write-up... 
 + 
 + 
 +===== 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://www.microchip.com/en-us/product/ATtiny85|ATTINY85]] from Microchip, available in a small 8-pin SOIC package. It also has an EEPROM (e.g. for the lamp ID), an on-chip oscillator, and a debugWIRE interface, which allows in-place debugging and simplifies software development. The RS485 driver is available e.g. from [[https://www.maximintegrated.com/en/products/interface/transceivers/MAX485.html|MAXIM]], also in an SOIC8 package. 
 + 
 + 
 +==== Schematic ==== 
 + 
 +Put together, the schematic looks like this: 
 + 
 +{{ :media:tech:gardenled:gardenled_schematic.png?direct&400 |}} 
 + 
 +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, the PWM signal is galvanically isolated with an optocoupler (OC1). Optional termination resistors are also provided on the RS485 interface (R1, R3, R4). The MAXIM chip operates in half-duplex, so we also control the direction (receive or transmit) via the ATTINY85. The last free output on the CPU drives a status LED (LED1), which helps with debugging and commissioning, especially when you leave the POWER LED disconnected. 
 + 
 + 
 +==== PCB ==== 
 + 
 +Given the geometric constraints, there's no avoiding double-sided assembly. But the second side only holds the four SS14 diodes (D3-D6) of the bridge rectifier and the large through-hole capacitor C8. These components have to be hand-soldered, but the rest can be 'baked' in the reflow oven beforehand (I use a T-962). 
 + 
 +{{ :media:tech:gardenled:gardenled_pcb.png?direct&400 |}}  
 + 
 +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. 
 + 
 +{{ :media:tech:gardenled:garden_led_real_pcb.png?direct&500 |PCB fron & back side}} 
 + 
 +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. 
 + 
 +{{ :media:tech:gardenled:gardenled_case.png?direct&300 |Case}} 
 + 
 + 
 +===== 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't flicker just because there's an unwanted signal party going on on the bus. I found a lean solution online that also includes a Python version for the PC. It's called [[https://github.com/min-protocol/min|MIN (Microcontroller Interconnect Network)]]. The payload can be freely defined, and its contents are protected by a 32-bit CRC. 
 + 
 +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: 
 + 
 +<code> 
 +// 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/delay)  
 +//  0x04 Set stored value/delay 
 +//  0x05 Get stored value/delay 
 +//  0x06 Status     
 +//  0x07 Status LED on/off     
 +// 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> 
 +</code> 
 + 
 +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 ''min.c'' module. We also need a software UART emulation, since the ATTINY85 doesn't have a hardware variant. The baud rate is fixed at 9600 baud so nobody has to sweat, giving a frame length of about 14ms. Using the free MPLAB IDE and the free tier of Microchip's XC8 C compiler, the software can be compiled and flashed onto the chip. 
 + 
 + 
 +==== Programming ==== 
 + 
 +Out of the factory, the ATTINY85's //debugWIRE// interface is disabled and programming is only possible via ISP. That ties up several pins of the processor and can't be done on-board. So it's worth getting a small programming adapter (socket) for the SOIC8 (200mil) package and programming the //fuses// with it before assembly. After that the software can also be programmed via the 1-pin //dWire// interface. 
 +  
 +{{ :media:tech:gardenled:soic_adapter.png?direct&200 |SOIC8 Adapter}} 
 + 
 +The debug and programming tool of choice remains the //SNAP (PG164100)//. All of these tools are easy to find and affordable for a DIY budget on online marketplaces or auction sites. 
 + 
 +{{ :media:tech:gardenled:pg164100.png?direct&300 | SNAP (PG164100) Programer}} 
 + 
 + 
 +===== 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, but it isn't with ESPHome. 
 + 
 +For the UART server there's already a nice solution [[https://github.com/oxan/esphome-stream-server|here]]. We just need to slightly adapt the code, switching the direction of the RS485 driver before and after sending. The lines marked with **###** were added into the send routine. The full modified code is available in the downloads section. 
 + 
 +<code C> 
 +void StreamServerComponent::write() { 
 +    digitalWrite(DIR_PORT, HIGH);   // ### set direction to receive 
 +#if ESPHOME_VERSION_CODE >= VERSION_CODE(2021, 10, 0) 
 +    this->stream_->write_array(this->recv_buf_); 
 +    this->recv_buf_.clear(); 
 +#else 
 +    size_t len; 
 +    while ((len = this->recv_buf_.size()) > 0) { 
 +        this->stream_->write(this->recv_buf_.data(), len); 
 +        this->recv_buf_.erase(this->recv_buf_.begin(), this->recv_buf_.begin() + len); 
 +    } 
 +#endif 
 +    this->stream_->flush();         // ### Added by DIRB 
 +    digitalWrite(DIR_PORT, LOW);    // ### set direction to receive 
 +
 +</code> 
 + 
 +To expose the LEDs as lights we again write a //custom component//. The following C program ''garden-leds.h'' provides a //float output// for Home Assistant and includes a lean implementation of the MIN protocol. 
 + 
 +++++ garden-leds.h | 
 +<file C garden-leds.h> 
 +#include "esphome.h" 
 + 
 +using namespace esphome; 
 + 
 +#define LED_GLOBAL          0x3E        // all LEDs 
 +#define LED_ON              0x02         
 +#define LED_FADE            0x00        // no fade 
 +#define LED_BRIGHTNESS      0x00        // off 
 + 
 +#define DIR_PORT            13 
 +#define BAUDRATE            9600 
 + 
 +#define MAX_BYTES_PER_FRAME 16 
 +#define MAX_PAYLOAD 3 
 + 
 +uint8_t min_payload[3] = { LED_ON, LED_BRIGHTNESS, LED_FADE };  
 + 
 +// 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)                             ((p) + 11U) 
 + 
 +struct crc32_context { 
 +    uint32_t crc; 
 +}; 
 + 
 +struct min_context { 
 +    uint8_t rx_frame_payload_buf[MAX_PAYLOAD];      // Payload received so far 
 +    uint32_t rx_frame_checksum;                     // Checksum received over the wire 
 +    struct crc32_context rx_checksum;               // Calculated checksum for receiving frame 
 +    struct crc32_context tx_checksum;               // Calculated checksum for sending frame 
 +    uint8_t rx_header_bytes_seen;                   // Countdown of header bytes to reset state 
 +    uint8_t rx_frame_state;                         // State of receiver 
 +    uint8_t rx_frame_payload_bytes;                 // Length of payload received so far 
 +    uint8_t rx_frame_id_control;                    // ID and control bit of frame being received 
 +    uint8_t rx_frame_seq;                           // Sequence number of frame being received 
 +    uint8_t rx_frame_length;                        // Length of frame 
 +    uint8_t rx_control;                             // Control byte 
 +    uint8_t tx_header_byte_countdown;               // Count out the header bytes 
 +    uint8_t port;                                   // Number of the port associated with the context 
 +}; 
 + 
 +// MIN calback functions 
 +// --------------------------------------------------------------------------- 
 +void min_tx_start(uint8_t port) { 
 +    digitalWrite(DIR_PORT, HIGH);     // switch RS485 driver to send mode 
 +
 + 
 +// --------------------------------------------------------------------------- 
 +void min_tx_finished(uint8_t port) { 
 +    Serial.flush(); 
 +    digitalWrite(DIR_PORT, LOW);      // switch RS485 driver to send mode 
 +
 + 
 +// --------------------------------------------------------------------------- 
 +// 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 'port' because we have just one context. 
 +    Serial.write(byte);   
 +
 + 
 +// --------------------------------------------------------------------------- 
 +static void crc32_init_context(struct crc32_context *context) 
 +
 +    context->crc = 0xffffffffU; 
 +
 + 
 +// --------------------------------------------------------------------------- 
 +static void crc32_step(struct crc32_context *context, uint8_t byte) 
 +
 +    uint32_t j; 
 +    context->crc ^= byte; 
 +    for(j = 0; j < 8; j++) { 
 +        uint32_t mask = (uint32_t) -(context->crc & 1U); 
 +        context->crc = (context->crc >> 1) ^ (0xedb88320U & mask); 
 +    } 
 +
 + 
 +// --------------------------------------------------------------------------- 
 +static uint32_t crc32_finalize(struct crc32_context *context) 
 +
 +    return ~context->crc; 
 +
 + 
 +// --------------------------------------------------------------------------- 
 +static void stuffed_tx_byte(struct min_context *self, uint8_t byte, bool crc) 
 +
 +    // Transmit the byte 
 +    min_tx_byte(self->port, byte); 
 +    if(crc) { 
 +        crc32_step(&self->tx_checksum, byte); 
 +    } 
 + 
 +    // See if an additional stuff byte is needed 
 +    if(byte == HEADER_BYTE) { 
 +        if(--self->tx_header_byte_countdown == 0) { 
 +            min_tx_byte(self->port, STUFF_BYTE);        // Stuff byte 
 +            self->tx_header_byte_countdown = 2U; 
 +        } 
 +    } 
 +    else { 
 +        self->tx_header_byte_countdown = 2U; 
 +    } 
 +
 + 
 +// --------------------------------------------------------------------------- 
 +// 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, uint16_t payload_offset, uint16_t payload_mask, uint8_t payload_len) 
 +
 +    uint8_t n, i; 
 +    uint32_t checksum; 
 + 
 +    self->tx_header_byte_countdown = 2U; 
 +    crc32_init_context(&self->tx_checksum); 
 + 
 +    min_tx_start(self->port); 
 + 
 +    // Header is 3 bytes; because unstuffed will reset receiver immediately 
 +    min_tx_byte(self->port, HEADER_BYTE); 
 +    min_tx_byte(self->port, HEADER_BYTE); 
 +    min_tx_byte(self->port, HEADER_BYTE); 
 + 
 +    stuffed_tx_byte(self, id_control, true); 
 +    if(id_control & 0x80U) { 
 +        // Send the sequence number if it is a transport frame 
 +        stuffed_tx_byte(self, seq, true); 
 +    } 
 + 
 +    stuffed_tx_byte(self, payload_len, true); 
 + 
 +    for(i = 0, n = payload_len; n > 0; n--, i++) { 
 +        stuffed_tx_byte(self, payload_base[payload_offset], true); 
 +        payload_offset++; 
 +        payload_offset &= payload_mask; 
 +    } 
 + 
 +    checksum = crc32_finalize(&self->tx_checksum); 
 + 
 +    // 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, (uint8_t)((checksum >> 24) & 0xffU), false); 
 +    stuffed_tx_byte(self, (uint8_t)((checksum >> 16) & 0xffU), false); 
 +    stuffed_tx_byte(self, (uint8_t)((checksum >> 8) & 0xffU), false); 
 +    stuffed_tx_byte(self, (uint8_t)((checksum >> 0) & 0xffU), false); 
 + 
 +    // Ensure end-of-frame doesn't contain 0xaa and confuse search for start-of-frame 
 +    min_tx_byte(self->port, EOF_BYTE); 
 + 
 +    min_tx_finished(self->port); 
 +
 + 
 +// --------------------------------------------------------------------------- 
 +// 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->port))) { 
 +        on_wire_bytes(self, min_id & (uint8_t) 0x3fU, 0, payload, 0, 0xffffU, payload_len); 
 +    } 
 +
 + 
 +struct min_context min_ctx;         // min protocol context 
 + 
 +// --------------------------------------------------------------------------- 
 +// float output class 
 +class Min_Led : public Component, public FloatOutput { 
 + public: 
 +    void setup() override { 
 +        // switch RS485 driver to receive mode 
 +        digitalWrite(DIR_PORT, LOW); 
 +        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(&min_ctx, LED_GLOBAL, min_payload, MAX_PAYLOAD); 
 +    } 
 +}; 
 +</file> 
 +++++ 
 + 
 +---- 
 + 
 + 
 +Now we create a new device in ESPHome again and point it at the two //custom codes// ''stream-server'' and ''garden-leds.h''
 +We already copied the first part into ''config/esphome/my_components/stream-server'' earlier, and the second file into ''config/esphome''
 + 
 + 
 +<code yaml> 
 +esphome: 
 +  name: garden-leds 
 +  includes: 
 +    - garden-leds.h 
 + 
 +external_components: 
 +  - source: 
 +      type: local 
 +      path: my_components 
 +    components: [stream_server]   
 +</code> 
 + 
 + 
 + 
 +And finally, the setup of the //light// and the //server//: 
 + 
 +<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: "Garden Wall" 
 +    output: led_pwm 
 +</code> 
 + 
 +If needed, the pins ''tx_pin, rx_pin'' for the serial interface and the direction pin ''DIR_PORT'' in ''garden-leds.h'' will need to be adapted for your own hardware. 
 + 
 + 
 +==== 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<->3V level shifter, and an RS485 driver module. That's also readily available on auction sites. The wiring is simple - here's my version: 
 + 
 +^ 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: 
 +  
 +{{ :media:tech:gardenled:gateway_hardware.png?direct&200 | Gateway hardware in waterproof housing}} 
 + 
 + 
 +==== 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  ^  Spot 1  ^  Spot 2  ^  ...  ^  Spot n  ^ 
 +|  ~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, fuses included, there for €8. 
 + 
 + 
 +===== 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). 
 + 
 +{{ :media:tech:gardenled:gateway_python.png?direct&400 |}} 
 + 
 +This lets you run any function on all or individual LEDs. **CHK** searches for connected LEDs, and at the bottom there's a simple script editor that allows programming sequences, with a step time of 100ms, i.e. 10Hz. A nice gimmick for New Year's Eve or other parties. 
 + 
 + 
 +===== Experience =====   
 +  
 +At the moment there's only a test setup with two spotlights, and it works very well. The full outdoor installation won't happen until 2023. I'll report back... 
 + 
 + 
 +===== Downloads ===== 
 + 
 +  * {{:media:tech:gardenled:led_rs485.pdf| Schematic (.pdf)}} 
 +  * {{:media:tech:gardenled:led_rs485_bom.csv.zip| Bill of materials (BOM) (.csv)}} 
 +  * {{:media:tech:gardenled:led_rs485.brd.zip| PCB Eagle File (.brd)}} 
 +  * {{:media:tech:gardenled:led_case.stl.zip | PCB case (.stl)}} 
 +  * {{:media:tech:gardenled:firmware.zip| Firmware for ATTINY85}} 
 +  * {{:media:tech:gardenled:stream_server.zip | Stream-Server files}} 
 +  * {{| LED Test Tool Wifi (.py)}} 
 + 
 +  
 + 
 + 
 + 
 + 
 + 
 + 
 + 
 +=====Links===== 
 +  * [[https://www.home-assistant.io]] Home Assistant 
 +  * [[https://esphome.io]] ESPHome 
 +  * [[https://airbnb.com/h/villarollokohsamui]] You can also come stay with us... 
 + 
 +===== Donate ===== 
 + 
 +If you'd like to support my work, feel free to buy me a cappuccino or so: . 
 + 
 +<html> 
 + 
 +<form action="https://www.paypal.com/donate" method="post" target="_top"> 
 +<input type="hidden" name="hosted_button_id" value="49N24HL36GF9U" /> 
 +<input type="image" src="https://www.paypalobjects.com/en_US/i/btn/btn_donateCC_LG.gif" border="0" name="submit" title="PayPal - The safer, easier way to pay online!" alt="Donate with PayPal button" /> 
 +<img alt="" border="0" src="https://www.paypal.com/en_US/i/scr/pixel.gif" width="1" height="1" /> 
 +</form> 
 + 
 +</html> 
en/tech/gardenled.txt · Last modified: by claude