Skip to content

Conversation

@thalesmaoa
Copy link

@thalesmaoa thalesmaoa commented Nov 8, 2025

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).

#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
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 on lines 55 to 56
P184_data_struct P184_data;

Copy link
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
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 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
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 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
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 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
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
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
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.


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
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 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
Contributor

Choose a reason for hiding this comment

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

Ditto.

Comment on lines 290 to 291
String valueStr = parseString(string, 3);
long value = valueStr.toInt();
Copy link
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 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
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
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 on lines 27 to 28
// int8_t P184_TRIGGER_PIN();
// uint8_t p184_trigger;
Copy link
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 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
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
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
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 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
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.

{
pinMode(P184_data.trigger_pin, OUTPUT);
digitalWrite(P184_data.trigger_pin, LOW);
P184_data.p184_timer = timerBegin(1000000);
Copy link
Contributor

Choose a reason for hiding this comment

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

Magic number?

Choose a reason for hiding this comment

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

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
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
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
Copy link
Contributor

tonhuisman commented Nov 8, 2025

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.

// 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);
Copy link
Member

@TD-er TD-er Nov 8, 2025

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
Copy link
Member

TD-er commented Nov 8, 2025

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
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)

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
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.

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
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)


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

@TD-er TD-er Nov 8, 2025

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.

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
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?

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
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.

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
Copy link

thalesmaoanz commented Nov 9, 2025

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
Copy link
Member

TD-er commented Nov 9, 2025

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
Author

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

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.

4 participants