Skip to content

Feature: Add Triac Phase Control Plugin (_P184_Triac.ino)#5426

Open
thalesmaoa wants to merge 13 commits into
letscontrolit:megafrom
thalesmaoa:feature/zero-crossing-dimmer
Open

Feature: Add Triac Phase Control Plugin (_P184_Triac.ino)#5426
thalesmaoa wants to merge 13 commits into
letscontrolit:megafrom
thalesmaoa:feature/zero-crossing-dimmer

Conversation

@thalesmaoa

@thalesmaoa thalesmaoa commented Nov 8, 2025

Copy link
Copy Markdown

This PR introduces a new plugin, _P184_Triac.ino, designed for AC phase control (dimming).

This plugin uses a zero-crossing detection (ZCD) signal to perform its function. It monitors a specific GPIO for this signal, which is typically provided by an external ZCD circuit (e.g., one using an H11AA1 or similar optocoupler).

Based on the timing of the zero-cross signal, the plugin manages the precise firing of a Triac on a separate output GPIO.

This functionality is the fundamental building block for AC phase control, enabling applications such as:

  • AC light dimmers (leading-edge).
  • AC motor speed control.
  • Heating element control (with resistive loads).
  • Synchronizing actions with the AC power line frequency (50/60 Hz).

Control Methods

The plugin can be controlled in two different ways via commands, making it flexible for integration into control loops.

  1. Trigger (Phase Angle Control): triac,trigger,<value>
    • This command directly controls the firing delay within the AC half-wave.
    • The value is a percentage from 0 to 100.
    • This control is inverted:
      • 0 = 100% Power (Triac triggers immediately at the start of the wave).
      • 100 = 0% Power (Triac never triggers).
      • 50 = Triggers at the halfway point of the cycle.
  2. Power (Linearized Power Control): triac,power,[value]
    • This command attempts to provide a linearized power output.
    • The value is a percentage from 0 to 100.
    • This control is direct:
      • 0 = 0% Power.
      • 100 = 100% Power.
    • The plugin calculates the required trigger delay to achieve the desired power output. This calculation assumes a purely resistive load.

Commands and Usage

The plugin can be easily controlled via ESPEasy rules or other controllers (like MQTT or HTTP).

Assuming the plugin's Task Name is triac:

// Set control using the inverted "trigger" (phase angle) method
triac,trigger,50   // Triggers at 50% of the half-wave
triac,trigger,0    // Triggers at 0% (full power)

// Set control using the "power" (linearized) method
triac,power,70   // Sets output to 70% power (for resistive loads)
triac,power,100  // Sets output to 100% power

This command-based interface allows the plugin to be easily coupled to any external or internal control loop (e.g., a PID controller, a web UI slider, or an MQTT topic).

Comment thread src/_P184_Triac.ino Outdated
#ifdef USES_P184
#define PLUGIN_184
#define PLUGIN_ID_184 184 // plugin id
#define PLUGIN_NAME_184 "Triac" // "Plugin Name" is what will be dislpayed in the selection list

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The name convention is to use a category, a dash (-) and the plugin name, so this could be Output - Triac.

(The Output category seems most fitting for this plugin)

Comment thread src/_P184_Triac.ino Outdated
Comment on lines +55 to +56
P184_data_struct P184_data;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Defining this instance globally prevents having multiple concurrent instances of this plugin. Please have a look at how most other plugins have this done, f.e. P140 (a simple plugin example 😃)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And/or have a look at the P003 Pulse Counter plugin, as you need to act on GPIO interrupts. This is a special case where you need to also register a pointer to go along with the callback function to store the runtime data.

Comment thread src/_P184_Triac.ino Outdated
Comment on lines +101 to +104
dev.PullUpOption = false; // Allow to set internal pull-up resistors.
dev.InverseLogicOption = false; // Allow to invert the boolean state (e.g. a switch)
dev.FormulaOption = false; // Allow to enter a formula to convert values during read. (not possible with Custom enabled)
dev.Custom = false;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All Device flags are explicitly initialized to false, so no need to have cpu cycles spent on setting them again. (also a few below)

Comment thread src/_P184_Triac.ino Outdated
Comment on lines +128 to +134
case PLUGIN_WEBFORM_SHOW_CONFIG:
{
// Called to show non default pin assignment or addresses like for plugins using serial or 1-Wire
// string += serialHelper_getSerialTypeLabel(event);
success = true;
break;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused / inactive cases can be removed.

Comment thread src/_P184_Triac.ino Outdated
Comment on lines +136 to +145
case PLUGIN_GET_DEVICEVALUECOUNT:
{
// This is only called when dev.OutputDataType is not Output_Data_type_t::Default
// The position in the config parameters used in this example is PCONFIG(P184_OUTPUT_TYPE_INDEX)
// Must match the one used in case PLUGIN_GET_DEVICEVTYPE (best to use a define for it)
// see P026_Sysinfo.ino for more examples.
event->Par1 = 2; //getValueCountFromSensorType(static_cast<Sensor_VType>(PCONFIG(P184_OUTPUT_TYPE_INDEX)));
success = true;
break;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This case is useful when dynamically changing the number of device values, that's not used in this plugin, so this code can be removed.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To be honest. I can't understand how to use this field.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For this plugin, the marked code can be removed, as you're not changing the number of Values based on some configuration setting.

Comment thread src/_P184_Triac.ino Outdated

P184_data.trigger_value = P184_TRIGGER_CONFIG();
// Recalculate power value based on the new trigger value from the form
for (int i = 0; i <= 100; ++i) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Magic number used here, better would be to use:
constexpr uint8_t power_to_trigger_lut_size = NR_ELEMENTS(power_to_trigger_lut);
(just below the power_to_trigger_lut array, and the 101 size in that array definition can also be removed. The size is determined at compile-time, this way)
and here, use:
for (uint8_t i = 0; i < power_to_trigger_lut_size; ++i) {

Comment thread src/_P184_Triac.ino Outdated
Comment on lines +245 to +246
for (int i = 0; i <= 100; ++i) {
if (pgm_read_byte(&power_to_trigger_lut[i]) <= P184_data.trigger_value) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditto.

Comment thread src/_P184_Triac.ino Outdated
Comment on lines +290 to +291
String valueStr = parseString(string, 3);
long value = valueStr.toInt();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Value is already available in event->Par2, no need to re-do a string to int conversion.

Comment thread src/_P184_Triac.ino Outdated
Comment on lines +361 to +368
// case PLUGIN_ONCE_A_SECOND:
// {
// // code to be executed once a second. Tasks which do not require fast response can be added here

// success = true;
// }

// case PLUGIN_TEN_PER_SECOND:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commented code can be removed

Comment on lines 1497 to 1499

#define USES_P184

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will enable the plugin in all builds, unconditionally. That's not correct, it should probably go in the Collection H build (that will be available soon, for now put it in Collection G), the Energy collection and the MAX build definition.

Comment thread src/_P184_Triac.ino Outdated
Comment on lines +27 to +28
// int8_t P184_TRIGGER_PIN();
// uint8_t p184_trigger;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More commented code.

Comment thread src/_P184_Triac.ino Outdated
Comment on lines +57 to +60
void IRAM_ATTR P184_zero_crossing() {
// This is only necessary for debounce
// detachInterrupt(P184_data.zero_crossing_pin);
if (P184_data.trigger_pin != -1 && P184_data.p184_timer != NULL) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For an example of how instance-independent interrupt handlers can be implemented, please have a look at plugin P008. By moving this code to the plugin_struct sources, you will also avoid compilation warnings about the iram section being ignored...

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moving it, make sense use attachInterruptArg?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, if you need to keep some kind of state as static variable, then it makes sense to have a pointer attached to the interrupt, so you can access those variables without creating some elaborate structures to keep track of states per task.

See P003 and P098 for some examples on how to use this attachInterruptArg to implement for use with multiple instances of the same plugin.

Comment thread src/_P184_Triac.ino Outdated
Comment on lines +222 to +223
P184_data.trigger_value = P184_TRIGGER_CONFIG();
// Recalculate power value based on the new trigger value from the form

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The plugin is stopped before the PLUGIN_WEBFORM_SAVE function is called, so no need to update settings here, that should (only) be done in PLUGIN_INIT.

Comment thread src/_P184_Triac.ino Outdated
{
pinMode(P184_data.trigger_pin, OUTPUT);
digitalWrite(P184_data.trigger_pin, LOW);
P184_data.p184_timer = timerBegin(1000000);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Magic number?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread src/_P184_Triac.ino Outdated
Comment on lines +333 to +334
String log = strformat(F("P184 CMD : Trigger %d%% . Power %d%%"), P184_data.trigger_value, P184_data.power_value);
addLogMove(LOG_LEVEL_INFO, log);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be a single statement, no need to instantiate a new String, only to pass it on.
Then you can also use addLog instead of addLogMove.

@tonhuisman

Copy link
Copy Markdown
Contributor

@thalesmaoa Thanks for this plugin!

I've added a few comments 😅 but that's all positive 👍
Some work to be done, but it's looking nice already.

There's no reference to hardware or schematics, but that can be addressed in the documentation 😉

@tonhuisman

tonhuisman commented Nov 8, 2025

Copy link
Copy Markdown
Contributor

In your example you use this command: [TaskName],trigger,[value], but that's not correct, the taskname prefix is optional, the command always starts with triac, and value is required, so the command description could look like this:
[TaskName.]triac,trigger,<value>

  • Taskname is separated from triac by a dot, and in square brackets, indicating it's optional, and intended to address a second or more instance of the plugin (current code doesn't support that yet). To add some confusion, the taskname can be wrapped in square brackets when used 😆 (this feature is globally available for over 3 years already).
  • <value> is in range 0..100.

Comment thread src/_P184_Triac.ino Outdated
// detachInterrupt(P184_data.zero_crossing_pin);
if (P184_data.trigger_pin != -1 && P184_data.p184_timer != NULL) {
if (P184_data.trigger_value == 0) {
digitalWrite(P184_data.trigger_pin, HIGH);

@TD-er TD-er Nov 8, 2025

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please look at the code for DIRECT_pinWrite_ISR as that's way faster than digitalWrite.
And with "way faster" I mean about a factor 100x - 1000x on ESP32-xx

@TD-er

TD-er commented Nov 8, 2025

Copy link
Copy Markdown
Member

Looking at the code and the description, it seems like you turn the Triac 'on' with some delay after the zero-crossing and not on immediately and 'off' after some delay.

I think this will cause way more EMI noise and will introduce way higher peak currents compared to the other way around.

I would expect 'starting' at zero-crossing and turning 'off' after some delay will be way better for your device.
Only thing to really look into is when switching some inductive device as those may generate really high voltages when suddenly having their current interrupted.

@tonhuisman

Copy link
Copy Markdown
Contributor

This could probably be implemented by adding a setting to use either the current way or starting at zero-crossing (IIRC this was a 'thing' many years ago, when household lighting dimmers became popular)

Comment thread src/_P184_Triac.ino Outdated
uint8_t power_value = 0;
uint8_t dead_zone = 0;
uint32_t freq_timing_val = P184_60HZ_HALF_WAVE_TIME_US_ONE_PERCENT;
uint64_t time_us = 0;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No idea why this should be a 64 bit int.
Even if the time was in nano-seconds, it would perfectly fit in an uint32_t as it is a sub-second timer.

The reason why it is better to use a 32bit value is because the 64-bit values are dealt with in software and thus are better not to be used in interrupt-handler callback functions.
For sure not for callbacks as frequent as these.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No idea why this should be a 64 bit int.

Me neither.
https://espressif-docs.readthedocs-hosted.com/projects/arduino-esp32/en/latest/api/timer.html#timeralarm

Also, I've tried using 32bit and I got some strange behavior.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe because it isn't declared volatile (or std::atomic<uint32_t> for modern compilers as used for ESP32)

Comment thread src/_P184_Triac.ino Outdated

void IRAM_ATTR P184_timer_handler() {
if (P184_data.trigger_pin != -1) {
if (P184_data.trigger_value != 100) {

@TD-er TD-er Nov 8, 2025

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Normally it would be good to have these checks for trigger pin and value, but here you can also make sure not to start the timer at all if these values are unusable.
This makes the callback functions even smaller.
Also these callback functions will be linked to the iram by the compiler and that's a really limited resource. So better make those as small as possible or else other unrelated builds may fail due to insufficient iRAM size.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. You are right. Despite of my best efforts, I was having panicked when deleting the plugin.
Not sure if hardware timer and interrupt takes more time than other to execute. My workaround was to add the check for the pin.

Also, the check for the trigger value is due to grid frequency variations. If frequency is a bit lower, it still triggers. Thus, I need to force it not to.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps you explicitly need to disable the pending timer when deleting the task/plugin?

Comment thread src/_P184_Triac.ino Outdated
void IRAM_ATTR P184_zero_crossing() {
// This is only necessary for debounce
// detachInterrupt(P184_data.zero_crossing_pin);
if (P184_data.trigger_pin != -1 && P184_data.p184_timer != NULL) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same for this callback function. You don't need to check for those trigger pin and timer pointers, as you should not even attach to an interrupt when those conditions are not met.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here. Panicked when deleting the plugin. Not always, but one in four.

@thalesmaoanz

thalesmaoanz commented Nov 9, 2025

Copy link
Copy Markdown

Triac 'on' with some delay after the zero-crossing

yes! From my previous experience, some devices need some dead time after zero-cross. This can also come due to inductive loads.

'off' after some delay

It really doesn't matter. A triac only needs a trigger. It will only block again when current reaches zero.

I think this will cause way more EMI noise and will introduce way higher peak currents compared to the other way around.

Not sure if I follow.

I would expect 'starting' at zero-crossing and turning 'off' after some delay will be way better for your device.

Can't do for a triac.
image

Only thing to really look into is when switching some inductive device as those may generate really high voltages when suddenly having their current interrupted.

Inductive loads will affect next trigger point. It only turn off when current reaches zero.

@TD-er

TD-er commented Nov 9, 2025

Copy link
Copy Markdown
Member

Inductive loads will affect next trigger point. It only turn off when current reaches zero.

That's what I meant... if you would cut off the current through an inductive load, it will generate a high (opposite) voltage as it tries to keep the magnetic field it had.
When you let it 'turn off' at the zero crossing, there is hardly any current left (voltage and current will be out-of-phase) so it will cause less issues when you do it like you're doing now.

However with resistive loads, there is a clear difference as the resistance of the load often is temperature dependent.
Meaning the resistance will be (much) lower at lower temperatures.
So if you would turn it on at the peak voltage, the current will be much higher compared to when you start a cold resistive load at the zero crossing. (or shortly after)
This can also be turned into a 'soft-start' using the triac switching you have right now by slowly increasing the 'on' time.

A capacitive load, like those cheap LED bulbs (or nearly any switching power supply without power factor correction) also may draw way too much when turned on at the peak voltage, as a discharged capacitor acts like a short circuit.
Not sure if those will benefit from a 'slow start' or the opposite and get damaged.

Remove global struct
Remove comments and unnecessary code
Add fixed lut size
REG write for fast pin write
Make interrupt static
@thalesmaoa

Copy link
Copy Markdown
Author

Hi @tonhuisman and @TD-er . Tried to fill up all the requests.

Comment thread platformio_esp32_envs.ini Outdated
Comment on lines +314 to +318
[env:normal_ESP32_IRExt_4M316k_LittleFS_ETH_P184]
extends = env:normal_ESP32_IRExt_4M316k_LittleFS_ETH
board = esp32_4M
build_flags = ${env:normal_ESP32_IRExt_4M316k_LittleFS_ETH.build_flags}
-DUSES_P184

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We've dropped the _LittleFS_ETH part from all env names, so when rebasing with mega, this env might cause issues, but these are easy to fix 😃

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ehh a few remarks....
Looks like you may be using mains voltage on the headers at the bottom of that screenshot?
Those pins are way too close to the low voltage parts of the PCB.
The area under the dimmer circuit should also be isolated, so rather not have a copper pour on the top layer of the PCB, under the intended dimmer circuit.
If you would like to shield it, then you could add copper pour on the bottom side of your own PCB.
What happens if you scratch the green solder mask with some sharp pieces of the dimmer circuit? Then you expose the GND of the ESP to mains voltage.
The heat sink of the dimmer circuit is also way too close to the Wemos pins and is probably not isolated from mains.

Also you seem to be using a Wemos ESP32 form factor, which then has the WiFi antenna right above the copper pour area and thus will likely have a very sub-optimal WiFi performance, unless you will be using some external WiFi antenna.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @TD-er , I really appreciate your feedback. Thanks!

Looks like you may be using mains voltage on the headers at the bottom of that screenshot?

Yes. That is correct.

Those pins are way too close to the low voltage parts of the PCB.

I performed some calculations based on IEC 60664. For 500V, the minimum suggested clearance is 1.5mm, and I’ve allowed for 1.9mm. However, you’ve brought up a good point: I should have included an isolation slot (milling) in that area, which I missed.

The area under the dimmer circuit should also be isolated, so rather not have a copper pour on the top layer of the PCB, under the intended dimmer circuit.

I understand. I've decided to use a 3D spacer between them for now. The EMI is significant in Triac-based PCBs, and my intention was for the ground plane to act as a shield.

Also you seem to be using a Wemos ESP32 form factor, which then has the WiFi antenna right above the copper pour area and thus will likely have a very sub-optimal WiFi performance, unless you will be using some external WiFi antenna.

Do you have any specific tips for improving Wi-Fi performance in this layout?

I have already sent this batch to production, but I will definitely incorporate all of your suggestions into version 2.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In open air the rule of thumb is 1 mm per 100V.
So for mains voltage you should keep 3 mm.
An encapsulated PCB trace (covered in solder mask) does have better insulation, so that figure of 1.5mm might be correct.... however... ;)
You're switching some load which can also be inductive. This means the voltages can be much much higher. Just looking at the EMI is also a good indicator there might be some inductive behavior.
N.B. you also have a trace quite close to the other pad of the screw terminal. I doubt that's at a minimal distance of 1.5 mm of the square pad of J1.

Regarding the antenna.
What you can do for testing, or at least proving the effect of the ground plane on the antenna, is using one or more stacked pin headers to lift the Wemos from the board.
If your Wemos board does have an IPEX connector, you may want to consider connecting an external antenna to it. Make sure the IPEX connector is actually wired as quite often there is a 0 Ohm resistor which needs to be moved (or 90 degree rotated) to either connect the PCB trace antenna or the IPEX connector.

If you have some PCB material left which doesn't have an area with copper on it (at least on one side), you can place it between the triac circuit board and the Wemos. Make sure no copper is on the side facing the triac board.
1.6 mm PCB material can isolate upto either 16 or 60 kV (not sure about the number), so it may prevent sparking between the triac board and the Wemos.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh and if you let the boards assemble at for example JLCPCB, you can also use the ESP32 modules they have.
I typically use the 16M module with an UFL connector, so I can use the "MAX" builds and can use whatever antenna I like and place it wherever I like.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh and if you let the boards assemble at for example JLCPCB, you can also use the ESP32 modules they have. I typically use the 16M module with an UFL connector, so I can use the "MAX" builds and can use whatever antenna I like and place it wherever I like.

Do mean like this:
https://jlcpcb.com/partdetail/EspressifSystems-ESP32_WROOM_32UEN4/C701344

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep, but then the 16M version so you don't have to worry about build sizes
https://jlcpcb.com/partdetail/736354-ESP32_WROOM_32UEN16/C701346

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just add a few capacitors close to the module and 2 transistors for toggling GPIO-0 and reset so you can use those Wemos USB to UART boards for flashing. (CH340 chip, micro USB, 6 pin header)
Just make sure not to use the Vcc pin of those boards as the voltage regulator is a bit underdimensioned and by default wired to just forward the 5V which you need to scratch away, etc...
So just add a row of 6 pins in the correct order accessible on the side to allow you to use a programmer clamp with pogo pins to program the board.

II would just add an 1117 as linear voltage regulator as they are no-nonsense and stable. Not the most power-efficient, but they just work.

Check the datasheet to see if you need to pull-up or -down some GPIO pins (e.g. GPIO-0) and double check this: https://espeasy.readthedocs.io/en/latest/Reference/GPIO.html#best-pins-to-use-on-esp32

Make sure to have a good ground from the pads below the module to the GND plane on the other side.

@uzi18

uzi18 commented Jan 28, 2026

Copy link
Copy Markdown
Contributor

@thalesmaoa it should be possible to detect 50/60 Hz mains - count zero crossing per 1s. if more than lets say 110 than it is 60 Hz.
Also without proper counts per second you may warn user about malfunction and disable triac firing.

TD-er added 4 commits April 13, 2026 08:53
Using the older (longer) env name, the linker will fail due to loo long argument list.

e.g.
```
[.pio/build/normal_ESP32_IRExt_4M316k_LittleFS_ETH_P184/ESP_Easy_mega_20260413_normal_ESP32_IRExt_4M316k_LittleFS_ETH_P184.elf] sh: Argument list too long
```
Comment thread platformio_esp32_envs.ini Outdated
@@ -345,10 +346,10 @@ extends = esp32_IRExt
board = esp32_4M
build_flags = ${esp32_IRExt.build_flags}

[env:normal_ESP32_IRExt_4M316k_LittleFS_ETH_P184]
extends = env:normal_ESP32_IRExt_4M316k_LittleFS_ETH
[env:normal_ESP32_IRExt_4M316k_P184]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMHO this env should be marked as // FIXME To remove before merging, as this should be seen as a Custom build.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure but I was just curious whether it would build anyway as it caused the argument length too long error.

I don't see why there should be a separate build for this anyway as it is already present in a number of other builds.
During development it is useful to have, but before we merge it should be removed indeed.

Comment thread src/_P184_Triac.ino
Comment thread src/_P184_Triac.ino
Comment thread src/_P184_Triac.ino
Comment thread src/_P184_Triac.ino
@TD-er

TD-er commented Apr 16, 2026

Copy link
Copy Markdown
Member

Do you have a pull-up resistor present on the trigger pin? I

No. I'm using it according to the schematic diagram above. Later, I'll try adding a pull-up (or down?) resistor and replacing one diode with a diode bridge.

You need a pull down.

image

Ehhh? Are you sure?

@thalesmaoanz

Copy link
Copy Markdown

Do you have a pull-up resistor present on the trigger pin? I

No. I'm using it according to the schematic diagram above. Later, I'll try adding a pull-up (or down?) resistor and replacing one diode with a diode bridge.

You need a pull down.

image Ehhh? Are you sure?

Sorry, I was talking about the trigger pin to avoid floating conditions. Interrupt pin is a pull up.

@drzorg82

Copy link
Copy Markdown

add a counter parallel diode to the opto

I tried installing a 10k pull-up and pull-down resistor. I also tried installing a diode bridge and a counter parallel . It didn't work. The lamp either glows at full power or at half power. In short, it behaves incorrectly.
Could you provide a diagram of how everything works for you? Maybe my diagram is incorrect?

@thalesmaoa

Copy link
Copy Markdown
Author

@drzorg82 , did you remove the series diode?

The code expect a semi-cycle trigger. In order to debug, I suggest you follow those steps:

  1. Check if trigger pin is correctly set.
    https://github.com/letscontrolit/ESPEasy/pull/5426/changes/BASE..9c6d9758b127bb4ba8e616b36f862bd8594eb1a9#diff-9073429ccfba79cbced777c1274845ea919d3d22d4fd550231ee35468e893ef8R38
    It should activate high. Just play with simple gpio,<your_GPIO>,<1 or 0>

If it is working as expected, you should debug your zero cross pin.

  1. Check ZC pin using generic pulse counter
    https://espeasy.readthedocs.io/en/latest/Plugin/P003.html
    Measure the time it takes to trigger and if it working as expected for 50Hz (10ms) or 60Hz (8.3ms).

If, both conditions as satisfied, the code must have a bug.
I will test it today and let you know.

Regarding the circuit it self, there is no mistake. You only need to be aware of trigger time.
Did you remove the series diode?

Here are some examples:

image Trigger at half cycle. image Trigger at half cycle. image image

However, if you only trigger at half cycle, you must trigger by CHANGE mode.
image

The circuit is extremely simple.

I've tested using a simples Aliexpress board

Lastly, I was looking your schematic and it really doesn't look good. How big is your 100k resistor? It must withstand ~500mW of power. Also, at peak, it reaches 3.1mA. PC817 is a bit strange. First, it states a CTR fo 50%. However, fig 6 presents a CTR of 500%.

image image

Try to test continuity in the opto, you can easily burn if you skipped the series diode for high reverse voltage.

@drzorg82

Copy link
Copy Markdown

did you remove the series diode

yes.
I turned off the devices. I gave the "gpio,21,1" command, the light is on, I gave the "gpio,21,0" command, the light is off.
.
.
.
Снимок экрана 2026-04-22 073414
Снимок экрана 2026-04-22 073522
.
.
.
When I turn on the pulse count (gpio18 and 19 are connected together), I have this
.
.
.
Снимок экрана 2026-04-22 073818
Снимок экрана 2026-04-22 073903
.
.
.
sometimes the time jumps from 9 to 11 ms

A little later I'll try to assemble it according to your 1st circuit diagram, although my circuit is roughly similar, but I'll still try yours
thank yuo

@TD-er

TD-er commented Jun 5, 2026

Copy link
Copy Markdown
Member

Due to lots of other things to do the last week(s), I lost a bit what has to do with this PR.
Maybe one of you can quickly summarize what still needs my attention? (will also post this on other open PRs)

@thalesmaoa

Copy link
Copy Markdown
Author

Hi @TD-er. I've replied to all of your comments. I've changed everything in accordance. I've tested the code and it is working fine. Due to the lack of updates from @drzorg82 , I suppose things are working fine.

@drzorg82

drzorg82 commented Jun 5, 2026

Copy link
Copy Markdown

I apologize for the silence, but I haven't tried your recommendations yet. Unfortunately, I don't have enough time for my hobby. Now I probably won't be able to try it until fall. But I'll definitely let you know once I try it.

@SuksAE

SuksAE commented Jun 11, 2026

Copy link
Copy Markdown

I just stumbled across this, so I don't know if I am too late for a feature request:

It would be great if you could implement a pulse packet mode.
In this mode, only complete halve sine waves are switched. You switch the triac on at (or a very short time after) zero crossing and keep it on for n% halve sine waves. Then follows a pause of 100%-n half sine waves where the triac is off.

As phase control is limited to loads of less than 400W (at least in europe) this would enalbe the use of this plugin for larger resistive heaters (like water heating or electric HVAC heaters) where there is no big difference to phase control because of the thermal mass. Furthermore the electromagnetic noise will be reduced to a minimum.

Additionally it would be great to also have a configuration factor (e.g. y) to set the number of halve waves the triac is on or off for each percent of dimming (so 100% would be y100 sine halve waves, 75% would be 25y sine halve waves off and 75*y sine halve waves on, ...)

I hope my request makes sense and as mentioned - I hope I am not too late...

Thanks

@thalesmaoa

Copy link
Copy Markdown
Author

Not sure if I follow your request.

I've already built a three phase 25kW with the same structure. If I sleep for n-cycles, is the same as solid state relays. PWM with SSR should be enough for if.

Can you explain better?

@TD-er

TD-er commented Jun 11, 2026

Copy link
Copy Markdown
Member

What I understood from the request is that it is better to have X full sine periods on and then Y full sine periods off.
By switching during zero-crossing, you introduce nearly no noise and the grid is being used for the full sine periods.
Turning on/off during a sine will cause noise and extra imbalance on the grid. (or at least local mains net).

And since the heat-capacity of the water is so big, there is no benefit of switching in PWM mode during a sine.

@thalesmaoa

Copy link
Copy Markdown
Author

A SSR has a zero cross circuit built in. A PWM should be enough to solve it.

@SuksAE

SuksAE commented Jun 11, 2026

Copy link
Copy Markdown

Not sure if I follow your request.

I've already built a three phase 25kW with the same structure. If I sleep for n-cycles, is the same as solid state relays. PWM with SSR should be enough for if.

Can you explain better?

You are right - using a zero crossing solid state relais does exactly the same, but at a higher cost (money wise). I fully understand if you want to finish this as is and do not accept any feature requests at this late time...

Thanks anyway.

@thalesmaoa

Copy link
Copy Markdown
Author

It's not like that @SuksAE . I want to help you. I just can't understand your request.

@SuksAE

SuksAE commented Jun 11, 2026

Copy link
Copy Markdown

What I understood from the request is that it is better to have X full sine periods on and then Y full sine periods off. By switching during zero-crossing, you introduce nearly no noise and the grid is being used for the full sine periods. Turning on/off during a sine will cause noise and extra imbalance on the grid. (or at least local mains net).

And since the heat-capacity of the water is so big, there is no benefit of switching in PWM mode during a sine.

and this is exactly the reason why phase control is limited to light loads with <400W by law - at least in europe...
but thalesmaoa is right that a SSR can do the job almost the same way as I described it. And I don't know if the 400W limitation is relevant for the rest of the world (I would guess yes, but I don't know for sure).

@SuksAE

SuksAE commented Jun 11, 2026

Copy link
Copy Markdown

It's not like that @SuksAE . I want to help you. I just can't understand your request.

you are right, using a SSR does in fact the same thing. But I am not sure if ESP Easy supports generating a PWM signal <100Hz. I have to look into that...

And by the way - I currently have no imminent need for such mechanism - just about a year ago I had a discussion with a friend about controlling a water heating element using PV excess energy and this would by handy back then - now he uses a much simpler approach but with much less granularity in dimming resolution...
We both did not see the solution with a SSR controlled by a PWM signal - I will forward this idea - maybe he wants to change his system once more to get a better resolution...

Thanks again

@TD-er

TD-er commented Jun 11, 2026

Copy link
Copy Markdown
Member

For longer pulses, you may want to have a look at the longpulse and longpulse_ms commands.
.. or at least at its implementation :)

@SuksAE

SuksAE commented Jun 11, 2026

Copy link
Copy Markdown

For longer pulses, you may want to have a look at the longpulse and longpulse_ms commands. .. or at least at its implementation :)

I will - thanks

@thalesmaoa

Copy link
Copy Markdown
Author

Good to know, however, let me insist that there is a better way to solve your problem. It is uses this exactly plugin.

Heaters are generally a slow system. You can easily deploy a PI using rules (there are some examples). Using the already presented plug-in, you just control the power based on temperature. triac,power,<from 0 up to 100>. This is performed with zero cross, which can be used for any power. It smooth the control and avoid overshooting.

This topology avoid pulsing the heater and improve its life. The drawback is that you will start to have grid quality problems as the power increase. For higher powers you may need some snubber and filters to trigger the thyristor, like I did for 25kW.

@TD-er

TD-er commented Jun 12, 2026

Copy link
Copy Markdown
Member

Hmm this triggers an idea which has been lingering in my mind for quite some time...

I always wanted to have some PID control mechanism, but had not yet a good idea on how to implement it in ESPEasy without having it implemented multiple times in several plugins.

Like for a PID you need one or more inputs and an output to create some feedback loop and prevent overshoots and with some tuning of the PID values, you can even let it act as a 'slow start'.

Let me think about this a bit more, but I think we can make this feature request suggestion also work with some PID like approach.

For this the triac,power command could also have like a second optional argument, where you can set the time in half sine periods over which to distribute the power duty cycle.
Like default for this 2nd parameter then is 0 (cycle) for PWM within a cycle. With the second parameter > 0, you set the minimum 'on' time in half since cycles.

Then with a PID control (in rules I guess?) you can then control the power parameter.

So I'm thinking about a new 'type' of variable in rules, which you initialize with the PID parameters and some initial state/value.
This way it is easier to keep all required values/states together and you can just 'feed' the PID controller periodically with new inputs like the measured temperature.

Only thing that may make this a bit more complex is what to do when the power command is called mid-dutycycle? Should that one be finished first and the next call using the new values? When having the 'minimal cycle' value set to a relative high value and the 'power' low (e.g. 1%), the full cycle can take some time. On the other hand, you only would set those for a rather slow process anyway...

@thalesmaoa

Copy link
Copy Markdown
Author

That would be nice, but I'd like to share some of my experiences.

What I love about ESPEasy:

  • It's easy and plug-and-play.
  • I can edit configurations right from my phone.
  • It covers 90% of the features I need.

What bothers me about ESPEasy:

For the remaining 10% of my use cases, I need speed. A 1-second delay is too high, and the rules engine introduces too much overhead. I love the concept of rules, but they are just too slow.

Why I prefer to avoid ESPHome:

After years of working with ESP devices, I know I eventually forget how or why I configured something, and I lose track. ESPEasy is strictly standalone, which is its greatest strength. While it would be nice to pull in some external features, keeping it standalone is the best approach.

Final thoughts:

If I were to suggest the next step, I'd look into opening up the scope for fine-tuning. It would be great to replace the standard rules with a faster Virtual Machine or allow for pre-compiled plugins. Right now, it's difficult to understand the execution flow (even with AI assistance) when trying to develop a new plugin or controller.
If we could build our own custom logic—like a PID controller—using raw C code or a compiled .wasm file, it would solve many of these limitations.

@TD-er

TD-er commented Jun 12, 2026

Copy link
Copy Markdown
Member

Well there is someone who was working on increasing rules processing speed significantly by pre-parsing the rules and building a complete tree with parsed rules code.
The main problem why this has not yet been implemented in ESPEasy is that it requires some changes in the rules syntax/grammar which would render existing rules incompatible without some conversion.

However I do see why this would be the way forward as the current rules implementation can only handle upto 30-ish event calls per sec. (which is quite optimistic)

If you need some extra speed for a PID which needs to run at like a few-100 times a second, then I totally get it why it should be part of the plugin. (or maybe called from within the plugin while being set and configured via rules)

Under the hood it is all C++ code, so I see no reason why for speed reasons you can't run some code directly from the plugin code, or maybe even some callback function.
As long as we can keep the amount of code duplication to an absolute minimum, as I have been spending (and likely will spend a lot more) countless hours trying to minimize the build size of ESPEasy. (and minimizing memory requirements)

@thalesmaoa

Copy link
Copy Markdown
Author

Don't get me wrong, things are perfect. I can't complain at all. After some time using a tool, you want to get the best out of it. No doubt. I'm just trying to share some of my personal experience with ESPEasy, trying to push it to its limits.

I just wanted to share. Here are some examples:

  1. I do have an industrial PID running in some places. ESPEasy is only for collecting data. Since I'm limited to a 1s publish rate over MQTT, UDP, etc., my control bandwidth can't be faster than that. Also, I need to integrate other sensors with other ESPEasy instances. It acts on many different drivers, so I can't deploy the control logic inside the ESP. For this specific reason, I would need a faster sensor response.

  2. Filter implementation. It is better and easier to implement filters inside ESPEasy. For a simple first-order filter, it's fine, but depending on the sensor, it has become too difficult to handle it using rules.

  3. Calibration. ESPEasy has some features for calibration, but it still has some limitations. I had a board reading a 4-20mA pressure sensor. However, with the pressure and the custom venturi I've developed, I am actually measuring flow. I need ADC calibration, then pressure calibration, and after that, flow calibration. I need to access those 3 values and implement the filter. I considered creating a plugin, but I wasn't convinced that made sense.

  4. I2C or Serial. The proposed plugins work. But I wasn't able to use them without building my own new plugin. It was extremely slow.

I'm on a new project now where I need to use the HC-12. I'm still struggling with whether I should dev a new controller or just create a simple program. I love ESPEasy and just want to use it for everything.

@TD-er

TD-er commented Jun 13, 2026

Copy link
Copy Markdown
Member

About the response-rate to communicate to other nodes, if it has hard-realtime requirements, then I doubt MQTT is the best solution as the MQTT broker doesn't really have real-time promises on when to notify subscribed clients.
In ESPEasy you can use looptimer with msec resolution, but it is limited to about 30x per sec as absolute max.
The only guarantee I can give you with the msec resolution timer is that it will be rescheduled with the given interval from the previous set timer.
So let's assume 100 msec interval and the first call was scheduled with millis() at exactly 10000, then any next scheduled timer will be at 10000 + N* 100. So there will only be jitter in the looptimer, no drift (apart from the inaccuracy of the internal clock of the ESP).
Whenever the intended new scheduled time had already passed, the next interval will be used. This means there isn't even a guaranteed number of loops per sec.
That's also why I highlight rows in the timingstats page which take over 100 msec as those will for sure mess up scheduled timings and need special attention for optimization.

ESPEasy p2p commands are probably faster than MQTT as every controller in ESPEasy has a process queue to not overload the receiving end. p2p commands are sent immediately.
N.B. you can try to tweak the controller queue settings for MQTT by reducing the minimum send interval. This is set to 100 msec by default, which may even be a bit too short for some setups. For WiFi it may not make much of a difference when setting it to a lower value, simply because of the 102.4 msec beacon interval used by WiFi. For Ethernet this delay in transactions does not exist. This is also why loading a webpage via Ethernet typically feels much faster than via WiFi.

Filter implementation.
I think we both mean exactly the same without realizing :)
What I mentioned with a new filter object, like how variables are now used in ESPEasy rules is simply that there would be a C++ struct which keeps all parameters as member and when feeding a new value (e.g. filterSet,foo,1234 ), it would update its internal state and when referenced (e.g. [filter#foo] ) will be replaced by the latest output value.
This can be just about anything, like an IIR, FIR, sliding window, PID, etc.

But I don't see why you shouldn't be able to call those filter objects from within the C++ code of a plugin, if speed is required.
This way you can refer to the filter output value from various points in ESPEasy (e.g. logging, showing on a display, etc) and still update it way more frequent than would be possible via rules.

@TD-er

TD-er commented Jun 13, 2026

Copy link
Copy Markdown
Member

Have you seen the calibration features offered by the internal ADC plugin (P002) ?
I still have plans to separate that code into some separate code which then could be used by any plugin.
But as I mentioned I didn't have it clear yet how to make this generic.

The way I see it, conceptually there isn't that much of a difference between calibration or a filter.
The main difference is that calibration doesn't typically have a timing component (thus no response delay) and is device specific.

Also you may want to apply calibration first and then a filter.

So I was thinking you may want to have some selectors to enable some calibration and/or a filter within a task.
Perhaps even per task-value, like we now also have the formula fields. However this may become quite cluttered quite fast.
So maybe some formula like reference to a defined filter would be nice to have and then some other place in the web UI to tune/view a filter and/or calibration.

@TD-er

TD-er commented Jun 13, 2026

Copy link
Copy Markdown
Member

I2C or Serial. The proposed plugins work. But I wasn't able to use them without building my own new plugin. It was extremely slow.

Do you have a link or some more info on this?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants