From f90ba9583977a32e679c1a59aebf281d894bd5bc Mon Sep 17 00:00:00 2001 From: Navendu Pottekkat Date: Tue, 22 Sep 2026 17:46:58 +0530 Subject: [PATCH] irremote: add a NEC transmitter Signed-off-by: Navendu Pottekkat --- examples/irremote/sender/main.go | 38 ++++++ irremote/irprotocol/proto_nec.go | 61 +++++++++ irremote/irprotocol/proto_nec_test.go | 99 +++++++++++++++ irremote/receiver.go | 21 +--- irremote/sender.go | 174 ++++++++++++++++++++++++++ smoketest.sh | 1 + 6 files changed, 378 insertions(+), 16 deletions(-) create mode 100644 examples/irremote/sender/main.go create mode 100644 irremote/irprotocol/proto_nec.go create mode 100644 irremote/irprotocol/proto_nec_test.go create mode 100644 irremote/sender.go diff --git a/examples/irremote/sender/main.go b/examples/irremote/sender/main.go new file mode 100644 index 000000000..5a056ab41 --- /dev/null +++ b/examples/irremote/sender/main.go @@ -0,0 +1,38 @@ +package main + +import ( + "machine" + "time" + + "tinygo.org/x/drivers/irremote" +) + +var ( + pinIROut = machine.GPIO17 + pwmIROut = machine.PWM0 + ir irremote.SenderDevice +) + +const ( + irAddress = 0x00 + irCmdPwr = 0x45 +) + +func main() { + ir = irremote.NewSender(irremote.SenderConfig{ + Pin: pinIROut, + PWM: pwmIROut, + DutyCycle: 33, + }) + if err := ir.Configure(); err != nil { + println(err.Error()) + return + } + for { + // Send one frame, then hold the button down for half a second. + ir.SendNEC(irAddress, irCmdPwr, true) + time.Sleep(time.Millisecond * 500) + ir.StopNECRepeats() + time.Sleep(time.Second * 2) + } +} diff --git a/irremote/irprotocol/proto_nec.go b/irremote/irprotocol/proto_nec.go new file mode 100644 index 000000000..955ffdd21 --- /dev/null +++ b/irremote/irprotocol/proto_nec.go @@ -0,0 +1,61 @@ +package irprotocol // import "tinygo.org/x/drivers/irremote/irprotocol" + +import "time" + +// NEC protocol reference +// https://www.sbprojects.net/knowledge/ir/nec.php + +// NEC timings. +const ( + NECCarrierHz = 38000 + + NECUnit = time.Nanosecond * 562_500 + NECLeadMark = NECUnit * 16 + NECLeadSpace = NECUnit * 8 + NECRepeatSpace = NECUnit * 4 + NECBitMark = NECUnit + NECZeroSpace = NECUnit + NECOneSpace = NECUnit * 3 + NECTrailMark = NECUnit + + // NECRepeatPeriod is the time from the start of one frame to the start of + // the next repeat frame while a button stays down. + NECRepeatPeriod = NECUnit * 192 +) + +// SplitRawNECData breaks a raw NEC code into its parts and reports whether the +// command and its inverse agree. +func SplitRawNECData(data uint32) (valid bool, address uint16, command byte) { + addrLow := byte(data) + addrHigh := byte(data >> 8) + command = byte(data >> 16) + invCmd := byte(data >> 24) + address = MakeNECAddress(addrLow, addrHigh) + valid = command == ^invCmd + return +} + +// MakeRawNECData assembles a raw NEC code from an address and a command. +func MakeRawNECData(address uint16, command byte) uint32 { + addrLow, addrHigh := SplitNECAddress(address) + return uint32(^command)<<24 | uint32(command)<<16 | uint32(addrHigh)<<8 | uint32(addrLow) +} + +// SplitNECAddress splits an NEC address into low and high bytes. +func SplitNECAddress(address uint16) (addrLow, addrHigh byte) { + addrLow = byte(address) + addrHigh = byte(address >> 8) + if addrHigh == 0 { + addrHigh = ^addrLow + } + return addrLow, addrHigh +} + +// MakeNECAddress assembles an NEC address from low and high bytes. A high byte +// that is the inverse of the low byte is the 8 bit form, not an extended address. +func MakeNECAddress(addrLow, addrHigh byte) uint16 { + if addrHigh == ^addrLow { + return uint16(addrLow) + } + return uint16(addrHigh)<<8 | uint16(addrLow) +} diff --git a/irremote/irprotocol/proto_nec_test.go b/irremote/irprotocol/proto_nec_test.go new file mode 100644 index 000000000..5f9f4500e --- /dev/null +++ b/irremote/irprotocol/proto_nec_test.go @@ -0,0 +1,99 @@ +package irprotocol + +import ( + "fmt" + "testing" + + qt "github.com/frankban/quicktest" +) + +type necTestData struct { + Code uint32 + Address uint16 + Command byte +} + +func decodeTests(t *testing.T, tests []necTestData, expectedValid bool) { + c := qt.New(t) + + for _, data := range tests { + name := fmt.Sprintf("Decode:Code:%08x Addr:%04x Cmd:%02x", + data.Code, data.Address, data.Command) + c.Run(name, func(c *qt.C) { + valid, addr, cmd := SplitRawNECData(data.Code) + c.Assert(valid, qt.Equals, expectedValid) + if valid { + c.Assert(addr, qt.Equals, data.Address) + c.Assert(cmd, qt.Equals, data.Command) + } + }) + } +} + +func encodeTests(t *testing.T, tests []necTestData) { + c := qt.New(t) + + for _, data := range tests { + name := fmt.Sprintf("Encode:Code:%08x Addr:%04x Cmd:%02x", + data.Code, data.Address, data.Command) + c.Run(name, func(c *qt.C) { + code := MakeRawNECData(data.Address, data.Command) + c.Assert(code, qt.Equals, data.Code) + }) + } +} + +// TestRawNECDataNonExtendedAddr encodes and decodes codes with 8 bit addresses. +func TestRawNECDataNonExtendedAddr(t *testing.T) { + tests := []necTestData{ + {Code: 0xFF00FF00, Address: 0x0000, Command: 0x00}, + {Code: 0x00FFFF00, Address: 0x0000, Command: 0xFF}, + {Code: 0xFF0000FF, Address: 0x00FF, Command: 0x00}, + {Code: 0x00FF00FF, Address: 0x00FF, Command: 0xFF}, + {Code: 0xFF00DF20, Address: 0x0020, Command: 0x00}, + {Code: 0xFF0020DF, Address: 0x00DF, Command: 0x00}, + {Code: 0xDF20FF00, Address: 0x0000, Command: 0x20}, + {Code: 0x20DFFF00, Address: 0x0000, Command: 0xDF}, + } + decodeTests(t, tests, true) + encodeTests(t, tests) +} + +// TestRawNECDataExtendedAddr encodes and decodes codes with 16 bit extended +// addresses. +func TestRawNECDataExtendedAddr(t *testing.T) { + tests := []necTestData{ + {Code: 0xFF000100, Address: 0x0100, Command: 0x00}, + {Code: 0xFF00FE00, Address: 0xFE00, Command: 0x00}, + {Code: 0xFF00F00D, Address: 0xF00D, Command: 0x00}, + } + decodeTests(t, tests, true) + encodeTests(t, tests) +} + +// TestSplitRawNECDataInvalidCommand checks that a command that does not match +// its inverse fails validation. +func TestSplitRawNECDataInvalidCommand(t *testing.T) { + decodeTests(t, + []necTestData{ + // One wrong bit in each position of the inverse command. + {Code: 0x01FFFF00, Address: 0x0000, Command: 0xFF}, + {Code: 0x02FFFF00, Address: 0x0000, Command: 0xFF}, + {Code: 0x04FFFF00, Address: 0x0000, Command: 0xFF}, + {Code: 0x08FFFF00, Address: 0x0000, Command: 0xFF}, + {Code: 0x10FFFF00, Address: 0x0000, Command: 0xFF}, + {Code: 0x20FFFF00, Address: 0x0000, Command: 0xFF}, + {Code: 0x40FFFF00, Address: 0x0000, Command: 0xFF}, + {Code: 0x80FFFF00, Address: 0x0000, Command: 0xFF}, + // One wrong bit in each position of the command. + {Code: 0xFF01FF00, Address: 0x0000, Command: 0xFF}, + {Code: 0xFF02FF00, Address: 0x0000, Command: 0xFF}, + {Code: 0xFF04FF00, Address: 0x0000, Command: 0xFF}, + {Code: 0xFF08FF00, Address: 0x0000, Command: 0xFF}, + {Code: 0xFF10FF00, Address: 0x0000, Command: 0xFF}, + {Code: 0xFF20FF00, Address: 0x0000, Command: 0xFF}, + {Code: 0xFF40FF00, Address: 0x0000, Command: 0xFF}, + {Code: 0xFF80FF00, Address: 0x0000, Command: 0xFF}, + }, + false) +} diff --git a/irremote/receiver.go b/irremote/receiver.go index 2ba44d449..11f10ea5a 100644 --- a/irremote/receiver.go +++ b/irremote/receiver.go @@ -3,6 +3,8 @@ package irremote // import "tinygo.org/x/drivers/irremote" import ( "machine" "time" + + "tinygo.org/x/drivers/irremote/irprotocol" ) // NEC protocol references @@ -200,25 +202,12 @@ const ( ) func (ir *ReceiverDevice) decode() irDecodeError { - // Decode cmd and inverse cmd and perform validation check - cmd := uint8((ir.data.Code & 0x00ff0000) >> 16) - invCmd := uint8((ir.data.Code & 0xff000000) >> 24) - if cmd != ^invCmd { - // Validation failure. cmd and inverse cmd do not match + valid, addr, cmd := irprotocol.SplitRawNECData(ir.data.Code) + if !valid { return irDecodeErrorInverseCheckFail } - // cmd validation pass, decode address + ir.data.Address = addr ir.data.Command = uint16(cmd) - addrLow := uint8(ir.data.Code & 0xff) - addrHigh := uint8((ir.data.Code & 0xff00) >> 8) - if addrHigh == ^addrLow { - // addrHigh is inverse of addrLow. This is not a valid 16-bit address in extended NEC coding - // since it is indistinguishable from 8-bit address with inverse validation. Use the 8-bit address - ir.data.Address = uint16(addrLow) - } else { - // 16-bit extended NEC address - ir.data.Address = (uint16(addrHigh) << 8) | uint16(addrLow) - } // Clear repeat flag ir.data.Flags &^= DataFlagIsRepeat return irDecodeErrorNone diff --git a/irremote/sender.go b/irremote/sender.go new file mode 100644 index 000000000..f6e7842fb --- /dev/null +++ b/irremote/sender.go @@ -0,0 +1,174 @@ +package irremote // import "tinygo.org/x/drivers/irremote" + +import ( + "errors" + "machine" + "sync" + "time" + + "tinygo.org/x/drivers/irremote/irprotocol" +) + +// ErrNoPWM is returned when the SenderConfig has no PWM. +var ErrNoPWM = errors.New("irremote: no PWM") + +// PWM is the interface necessary for driving the IR carrier. +type PWM interface { + Configure(config machine.PWMConfig) error + Channel(pin machine.Pin) (channel uint8, err error) + Top() uint32 + Set(channel uint8, value uint32) +} + +// SenderConfig is used to configure the SenderDevice. +type SenderConfig struct { + Pin machine.Pin + PWM PWM + // DutyCycle is the carrier duty in percent. Values outside 1 to 100 give 33. + DutyCycle uint8 +} + +// SenderDevice is the device for sending IR commands. +type SenderDevice struct { + pwm PWM + pin machine.Pin + dutyPct uint8 + channel uint8 + duty uint32 + mu sync.Mutex + stop chan struct{} +} + +// NewSender returns a new IR sender device. +func NewSender(config SenderConfig) SenderDevice { + return SenderDevice{pwm: config.PWM, pin: config.Pin, dutyPct: config.DutyCycle} +} + +// Configure sets the PWM to the 38kHz carrier and leaves it off. +func (ir *SenderDevice) Configure() error { + ir.mu.Lock() + defer ir.mu.Unlock() + if ir.pwm == nil { + return ErrNoPWM + } + err := ir.pwm.Configure(machine.PWMConfig{Period: uint64(time.Second / irprotocol.NECCarrierHz)}) + if err != nil { + return err + } + ch, err := ir.pwm.Channel(ir.pin) + if err != nil { + return err + } + ir.channel = ch + pct := ir.dutyPct + if pct < 1 || pct > 100 { + pct = 33 + } + // Multiply first, so a small PWM top does not truncate the duty to zero. + ir.duty = uint32(uint64(ir.pwm.Top()) * uint64(pct) / 100) + ir.pwm.Set(ir.channel, 0) + return nil +} + +// SendNEC sends one NEC frame. With autoRepeat set, repeat frames go out +// until StopNECRepeats is called. +func (ir *SenderDevice) SendNEC(address uint16, command byte, autoRepeat bool) { + ir.mu.Lock() + defer ir.mu.Unlock() + if ir.stop != nil { + close(ir.stop) + ir.stop = nil + } + start := time.Now() + ir.sendNECRawCode(irprotocol.MakeRawNECData(address, command)) + if !autoRepeat { + return + } + stop := make(chan struct{}) + ir.stop = stop + go func(next time.Time) { + for { + time.Sleep(time.Until(next)) + ir.mu.Lock() + if ir.stop != stop { + ir.mu.Unlock() + return + } + next = time.Now().Add(irprotocol.NECRepeatPeriod) + ir.sendNECRepeat() + ir.mu.Unlock() + } + }(start.Add(irprotocol.NECRepeatPeriod)) +} + +// StopNECRepeats ends the repeat frames started by SendNEC. +func (ir *SenderDevice) StopNECRepeats() { + ir.mu.Lock() + defer ir.mu.Unlock() + if ir.stop != nil { + close(ir.stop) + ir.stop = nil + } +} + +// SendNECRawCode sends one NEC frame from a 32 bit code, least significant bit +// first, and returns the time it took. +func (ir *SenderDevice) SendNECRawCode(code uint32) time.Duration { + ir.mu.Lock() + defer ir.mu.Unlock() + if ir.stop != nil { + close(ir.stop) + ir.stop = nil + } + return ir.sendNECRawCode(code) +} + +// SendNECRepeat sends one repeat frame and returns the time it took. +func (ir *SenderDevice) SendNECRepeat() time.Duration { + ir.mu.Lock() + defer ir.mu.Unlock() + return ir.sendNECRepeat() +} + +func (ir *SenderDevice) sendNECRawCode(code uint32) time.Duration { + start := time.Now() + ir.mark(irprotocol.NECLeadMark) + ir.space(irprotocol.NECLeadSpace) + for i := 0; i < 32; i++ { + ir.mark(irprotocol.NECBitMark) + if code&(1<