From f2e38c6243d421582797ca25c4970ad62de3c7a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lutz=20B=C3=BCrkle?= Date: Fri, 3 Apr 2026 12:30:43 +0200 Subject: [PATCH 1/2] Add RC5X IR protocol support --- lib/ir/ir_protocol_registry.dart | 6 +- lib/ir/protocols/rc5x.dart | 179 +++++++++++++++++++++++++++++++ 2 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 lib/ir/protocols/rc5x.dart diff --git a/lib/ir/ir_protocol_registry.dart b/lib/ir/ir_protocol_registry.dart index 8f7fe57..324422c 100644 --- a/lib/ir/ir_protocol_registry.dart +++ b/lib/ir/ir_protocol_registry.dart @@ -12,6 +12,7 @@ import 'protocols/nrc17.dart'; import 'protocols/pioneer.dart'; import 'protocols/proton.dart'; import 'protocols/rc5.dart'; +import 'protocols/rc5x.dart'; import 'protocols/rc6.dart'; import 'protocols/rca_38.dart'; import 'protocols/rcc0082.dart'; @@ -42,6 +43,7 @@ class IrProtocolIds { static const String pioneer = 'pioneer'; static const String proton = 'proton'; static const String rc5 = 'rc5'; + static const String rc5x = 'rc5x'; static const String rc6 = 'rc6'; static const String rca38 = 'rca_38'; static const String rcc0082 = 'rcc0082'; @@ -49,7 +51,7 @@ class IrProtocolIds { static const String rec80 = 'rec80'; static const String recs80 = 'recs80'; static const String recs80L = 'recs80_l'; - static const String samsung32 = 'samsung32'; + static const String samsung32 = 'samsung32'; static const String samsung36 = 'samsung36'; static const String sharp = 'sharp'; static const String sony12 = 'sony12'; @@ -75,6 +77,7 @@ class IrProtocolRegistry { pioneerProtocolDefinition.id: pioneerProtocolDefinition, protonProtocolDefinition.id: protonProtocolDefinition, rc5ProtocolDefinition.id: rc5ProtocolDefinition, + rc5xProtocolDefinition.id: rc5xProtocolDefinition, rc6ProtocolDefinition.id: rc6ProtocolDefinition, rca38ProtocolDefinition.id: rca38ProtocolDefinition, rcc0082ProtocolDefinition.id: rcc0082ProtocolDefinition, @@ -109,6 +112,7 @@ class IrProtocolRegistry { PioneerProtocolEncoder.protocolId: const PioneerProtocolEncoder(), ProtonProtocolEncoder.protocolId: const ProtonProtocolEncoder(), Rc5ProtocolEncoder.protocolId: const Rc5ProtocolEncoder(), + Rc5xProtocolEncoder.protocolId: const Rc5xProtocolEncoder(), Rc6ProtocolEncoder.protocolId: const Rc6ProtocolEncoder(), Rca38ProtocolEncoder.protocolId: const Rca38ProtocolEncoder(), diff --git a/lib/ir/protocols/rc5x.dart b/lib/ir/protocols/rc5x.dart new file mode 100644 index 0000000..296baec --- /dev/null +++ b/lib/ir/protocols/rc5x.dart @@ -0,0 +1,179 @@ +import '../ir_protocol_types.dart'; + +const IrProtocolDefinition rc5xProtocolDefinition = IrProtocolDefinition( + id: 'rc5x', + displayName: 'RC5X', + description: + 'RC5X: bi-phase coding, unit=889us, carrier=36kHz. ' + 'Input: address(5 bits) + command(7 bits). Fixed start bit, inverted 7th ' + 'command bit, toggle bit, 11-bit payload (5 address bits + remaining ' + '6 command bits), MSB-first. Frame gap padded to 114000us.', + implemented: true, + defaultFrequencyHz: 36000, + fields: [ + IrFieldDef( + id: 'address', + label: 'Address (5 bits)', + type: IrFieldType.intHex, + required: true, + min: 0x00, + max: 0x1F, + maxLength: 2, + hint: 'e.g., 10', + helperText: 'RC5X device address (00..1F).', + maxLines: 1, + ), + IrFieldDef( + id: 'command', + label: 'Command (7 bits)', + type: IrFieldType.intHex, + required: true, + min: 0x00, + max: 0x7F, + maxLength: 2, + hint: 'e.g., 0C', + helperText: 'RC5X command (00..7F).', + maxLines: 1, + ), + ], +); + +class Rc5xProtocolEncoder implements IrProtocolEncoder { + static const String protocolId = 'rc5x'; + const Rc5xProtocolEncoder(); + + @override + String get id => protocolId; + + @override + IrProtocolDefinition get definition => rc5xProtocolDefinition; + + static const int defaultFrequencyHz = 36000; + + // Timings + static const int unit = 0x379; // 889us + static const int frameTargetUs = 0x1BD50; // 114000us + static const int repeatWindowMs = 180; + + // RC5X toggle changes on a new press, but stays constant while the same key + // is repeating. The app-level encoder is stateless, so we approximate that + // behavior here by keeping the same toggle for rapid repeats of the same + // payload and flipping it for a new press. + static bool _toggleFlag = false; + static int? _lastPayload; + static DateTime? _lastEncodeAt; + + @override + IrEncodeResult encode(Map params) { + // RC5X extends the RC5 command range to 7 bits. It uses a single start bit, + // while the second bit carries the inverted value of the 7th command bit. + // This ensures that the first 64 commands remain compatible with the + // original RC5 protocol (https://www.sbprojects.net/knowledge/ir/rc5.php) + final (int payload, bool extendedRange) = _readPackedPayload(params); + final bool toggle = _resolveToggle(payload, extendedRange); + + final String leader = '1${extendedRange ? '0' : '1'}'; + final String toggleBit = toggle ? '1' : '0'; + final String payload11 = payload.toRadixString(2).padLeft(11, '0'); + + final String bits = leader + toggleBit + payload11; // 14 bits total + + final List halfLevels = []; + for (int i = 0; i < bits.length; i++) { + final bool one = bits.codeUnitAt(i) == 0x31; // '1' + // RC5: 1 => space then mark, 0 => mark then space. + halfLevels.add(!one); + halfLevels.add(one); + } + + // The RC5X start bit is always 1, so the message starts halfway + // through an idle period. Skip that implicit leading space half-bit. + final List seq = []; + if (halfLevels.length > 1) { + bool currentLevel = halfLevels[1]; + int currentDuration = unit; + for (int i = 2; i < halfLevels.length; i++) { + if (halfLevels[i] == currentLevel) { + currentDuration += unit; + } else { + seq.add(currentDuration); + currentLevel = halfLevels[i]; + currentDuration = unit; + } + } + seq.add(currentDuration); + } + + // Pad the inter-frame gap to the nominal RC5 repeat period without + // destroying the transmitted tail. If the sequence already ends in a space, + // extend it. If it ends in a mark, append the trailing gap as a new space. + final int used = _sum(seq); + final int gap = frameTargetUs - used; + if (gap > 0) { + if (seq.length.isEven) { + seq[seq.length - 1] += gap; + } else { + seq.add(gap); + } + } + + return IrEncodeResult( + frequencyHz: defaultFrequencyHz, + pattern: seq, + ); + } + + bool _resolveToggle(int payload, bool extendedRange) { + final int extPayload = (payload << 1) | (extendedRange ? 0x1 : 0x0); + + final DateTime now = DateTime.now(); + final bool isRepeat = Rc5xProtocolEncoder._lastPayload == extPayload && + Rc5xProtocolEncoder._lastEncodeAt != null && + now.difference(Rc5xProtocolEncoder._lastEncodeAt!).inMilliseconds <= + Rc5xProtocolEncoder.repeatWindowMs; + if (!isRepeat) { + Rc5xProtocolEncoder._toggleFlag = !Rc5xProtocolEncoder._toggleFlag; + } + _rememberToggleState(Rc5xProtocolEncoder._toggleFlag, extPayload, now: now); + return Rc5xProtocolEncoder._toggleFlag; + } + + void _rememberToggleState(bool toggle, int payload, {DateTime? now}) { + Rc5xProtocolEncoder._toggleFlag = toggle; + Rc5xProtocolEncoder._lastPayload = payload; + Rc5xProtocolEncoder._lastEncodeAt = now ?? DateTime.now(); + } +} + +(int, bool) _readPackedPayload(Map params) { + final dynamic addressRaw = params['address']; + final dynamic commandRaw = params['command']; + + final int address = _readHexField(addressRaw, max: 0x1F, name: 'RC5X address'); + final int command = _readHexField(commandRaw, max: 0x7F, name: 'RC5X command'); + + return (((address & 0x1F) << 6) | (command & 0x3F), (command > 0x3F)); +} + +int _readHexField(dynamic raw, {required int max, required String name}) { + if (raw is int) { + if (raw < 0 || raw > max) throw ArgumentError('$name out of range'); + return raw; + } + if (raw is String) { + final String s = raw.trim(); + if (s.isEmpty) throw ArgumentError('$name must not be empty'); + final int value = int.parse(s, radix: 16); + if (value < 0 || value > max) throw ArgumentError('$name out of range'); + return value; + } + throw ArgumentError('$name must be hex'); +} + +int _sum(List xs) { + int s = 0; + for (final int v in xs) { + s += v; + } + return s; +} From ed033e38f5d96e6a05413708671ad1d16abbd763 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lutz=20B=C3=BCrkle?= Date: Fri, 3 Apr 2026 17:32:25 +0200 Subject: [PATCH 2/2] Add Marantz IR protocol support --- lib/ir/ir_protocol_registry.dart | 5 + lib/ir/protocols/marantz.dart | 206 +++++++++++++++++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 lib/ir/protocols/marantz.dart diff --git a/lib/ir/ir_protocol_registry.dart b/lib/ir/ir_protocol_registry.dart index 324422c..2165519 100644 --- a/lib/ir/ir_protocol_registry.dart +++ b/lib/ir/ir_protocol_registry.dart @@ -5,6 +5,7 @@ import 'protocols/raw_signal.dart'; import 'protocols/denon.dart'; import 'protocols/f12_relaxed.dart'; import 'protocols/jvc.dart'; +import 'protocols/marantz.dart'; import 'protocols/nec2.dart'; import 'protocols/necx1.dart'; import 'protocols/necx2.dart'; @@ -35,6 +36,7 @@ class IrProtocolIds { static const String denon = 'denon'; static const String f12Relaxed = 'f12_relaxed'; static const String jvc = 'jvc'; + static const String marantz = 'marantz'; static const String nec = 'nec'; static const String nec2 = 'nec2'; static const String necx1 = 'necx1'; @@ -70,6 +72,7 @@ class IrProtocolRegistry { denonProtocolDefinition.id: denonProtocolDefinition, f12RelaxedProtocolDefinition.id: f12RelaxedProtocolDefinition, jvcProtocolDefinition.id: jvcProtocolDefinition, + marantzProtocolDefinition.id: marantzProtocolDefinition, nec2ProtocolDefinition.id: nec2ProtocolDefinition, necx1ProtocolDefinition.id: necx1ProtocolDefinition, necx2ProtocolDefinition.id: necx2ProtocolDefinition, @@ -103,6 +106,8 @@ class IrProtocolRegistry { F12RelaxedProtocolEncoder.protocolId: const F12RelaxedProtocolEncoder(), JvcProtocolEncoder.protocolId: const JvcProtocolEncoder(), + MarantzProtocolEncoder.protocolId: const MarantzProtocolEncoder(), + NecProtocolEncoder.protocolId: const NecProtocolEncoder(), Nec2ProtocolEncoder.protocolId: const Nec2ProtocolEncoder(), Necx1ProtocolEncoder.protocolId: const Necx1ProtocolEncoder(), diff --git a/lib/ir/protocols/marantz.dart b/lib/ir/protocols/marantz.dart new file mode 100644 index 0000000..7e854c3 --- /dev/null +++ b/lib/ir/protocols/marantz.dart @@ -0,0 +1,206 @@ +import '../ir_protocol_types.dart'; + +const IrProtocolDefinition marantzProtocolDefinition = IrProtocolDefinition( + id: 'marantz', + displayName: 'Marantz', + description: + 'MARANTZ: bi-phase coding, unit=889us, carrier=36kHz. ' + 'Input: address(5 bits) + command(7 bits) + extension(6 bits). Fixed start bit,' + 'inverted 7th command bit, toggle bit, address, 4 unit gap, 12-bit payload ' + '(remaining 6 command bits + 6 extension bits), MSB-first. Frame gap padded to 114000us.', + implemented: true, + defaultFrequencyHz: 36000, + fields: [ + IrFieldDef( + id: 'address', + label: 'Address (5 bits)', + type: IrFieldType.intHex, + required: true, + min: 0x00, + max: 0x1F, + maxLength: 2, + hint: 'e.g., 10', + helperText: 'Marantz device address (00..1F).', + maxLines: 1, + ), + IrFieldDef( + id: 'command', + label: 'Command (7 bits)', + type: IrFieldType.intHex, + required: true, + min: 0x00, + max: 0x7F, + maxLength: 2, + hint: 'e.g., 0C', + helperText: 'Marantz command (00..7F).', + maxLines: 1, + ), + IrFieldDef( + id: 'extension', + label: 'Command (6 bits)', + type: IrFieldType.intHex, + required: true, + min: 0x00, + max: 0x3F, + maxLength: 2, + hint: 'e.g., 0C', + helperText: 'Marantz extension (00..3F).', + maxLines: 1, + ), + ], +); + +class MarantzProtocolEncoder implements IrProtocolEncoder { + static const String protocolId = 'marantz'; + const MarantzProtocolEncoder(); + + @override + String get id => protocolId; + + @override + IrProtocolDefinition get definition => marantzProtocolDefinition; + + static const int defaultFrequencyHz = 36000; + + // Timings + static const int unit = 0x379; // 889us + static const int frameTargetUs = 0x1BD50; // 114000us + static const int repeatWindowMs = 180; + + // Marantz toggle changes on a new press, but stays constant while the same key + // is repeating. The app-level encoder is stateless, so we approximate that + // behavior here by keeping the same toggle for rapid repeats of the same + // payload and flipping it for a new press. + static bool _toggleFlag = false; + static int? _lastPayload; + static DateTime? _lastEncodeAt; + + @override + IrEncodeResult encode(Map params) { + // Marantz is a variant of the RC5X IR protocol. After the address (i.e., the first 8 bits), + // it inserts a 3.5 ms pause, followed by the 6 command bits and a 6-bit command extension. + // (https://github.com/Arduino-IRremote/Arduino-IRremote/blob/master/src/ir_RC5_RC6.hpp) + final (int address, int payload, bool extendedRange) = _readPackedPayload(params); + final bool toggle = _resolveToggle(address, payload, extendedRange); + + final String leader = '1${extendedRange ? '0' : '1'}'; + final String toggleBit = toggle ? '1' : '0'; + final String address5 = address.toRadixString(2).padLeft(5, '0'); + final String payload12 = payload.toRadixString(2).padLeft(12, '0'); + + final String bits1 = leader + toggleBit + address5; // 8 bits total + final String bits2 = payload12; // 12 bits total + + final List halfLevels = []; + // MARANTZ: address + for (int i = 0; i < bits1.length; i++) { + final bool one = bits1.codeUnitAt(i) == 0x31; // '1' + // RC5: 1 => space then mark, 0 => mark then space. + halfLevels.add(!one); + halfLevels.add(one); + } + // MARANTZ: gap + for (int i=0; i<4; i++) { + halfLevels.add(false); + } + // MARANTZ: playload + for (int i = 0; i < bits2.length; i++) { + final bool one = bits2.codeUnitAt(i) == 0x31; // '1' + // RC5: 1 => space then mark, 0 => mark then space. + halfLevels.add(!one); + halfLevels.add(one); + } + + // The Marantz start bit is always 1, so the message starts halfway + // through an idle period. Skip that implicit leading space half-bit. + final List seq = []; + if (halfLevels.length > 1) { + bool currentLevel = halfLevels[1]; + int currentDuration = unit; + for (int i = 2; i < halfLevels.length; i++) { + if (halfLevels[i] == currentLevel) { + currentDuration += unit; + } else { + seq.add(currentDuration); + currentLevel = halfLevels[i]; + currentDuration = unit; + } + } + seq.add(currentDuration); + } + + // Pad the inter-frame gap to the nominal RC5 repeat period without + // destroying the transmitted tail. If the sequence already ends in a space, + // extend it. If it ends in a mark, append the trailing gap as a new space. + final int used = _sum(seq); + final int gap = frameTargetUs - used; + if (gap > 0) { + if (seq.length.isEven) { + seq[seq.length - 1] += gap; + } else { + seq.add(gap); + } + } + + return IrEncodeResult( + frequencyHz: defaultFrequencyHz, + pattern: seq, + ); + } + + bool _resolveToggle(int address, int payload, bool extendedRange) { + final int extPayload = (address << 13) | (payload << 1) | (extendedRange ? 0x1 : 0x0); + + final DateTime now = DateTime.now(); + final bool isRepeat = MarantzProtocolEncoder._lastPayload == extPayload && + MarantzProtocolEncoder._lastEncodeAt != null && + now.difference(MarantzProtocolEncoder._lastEncodeAt!).inMilliseconds <= + MarantzProtocolEncoder.repeatWindowMs; + if (!isRepeat) { + MarantzProtocolEncoder._toggleFlag = !MarantzProtocolEncoder._toggleFlag; + } + _rememberToggleState(MarantzProtocolEncoder._toggleFlag, extPayload, now: now); + return MarantzProtocolEncoder._toggleFlag; + } + + void _rememberToggleState(bool toggle, int payload, {DateTime? now}) { + MarantzProtocolEncoder._toggleFlag = toggle; + MarantzProtocolEncoder._lastPayload = payload; + MarantzProtocolEncoder._lastEncodeAt = now ?? DateTime.now(); + } +} + +(int, int, bool) _readPackedPayload(Map params) { + final dynamic addressRaw = params['address']; + final dynamic commandRaw = params['command']; + final dynamic extensionRaw = params['extension']; + + final int address = _readHexField(addressRaw, max: 0x1F, name: 'Marantz address'); + final int command = _readHexField(commandRaw, max: 0x7F, name: 'Marantz command'); + final int extension = _readHexField(extensionRaw, max: 0x3F, name: 'Marantz extension'); + + return ((address & 0x1F), ((command & 0x3F) << 6) | (extension & 0x3F), (command > 0x3F)); +} + +int _readHexField(dynamic raw, {required int max, required String name}) { + if (raw is int) { + if (raw < 0 || raw > max) throw ArgumentError('$name out of range'); + return raw; + } + if (raw is String) { + final String s = raw.trim(); + if (s.isEmpty) throw ArgumentError('$name must not be empty'); + final int value = int.parse(s, radix: 16); + if (value < 0 || value > max) throw ArgumentError('$name out of range'); + return value; + } + throw ArgumentError('$name must be hex'); +} + +int _sum(List xs) { + int s = 0; + for (final int v in xs) { + s += v; + } + return s; +}