Skip to content

Commit fe8b3d0

Browse files
author
Kame
committed
Add full ElkSmart subtype support and fix RC5/RC6 protocol handling
1 parent 20dec70 commit fe8b3d0

23 files changed

Lines changed: 539 additions & 138 deletions

.flutter-plugins-dependencies

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -165,8 +165,8 @@ The Signal Tester is designed to help discover unknown working IR commands.
165165
| NRC17 | 4 hex (CCAS) | 38,000 | Pre-pulse 500/2500; start bit fixed to 1; then command(8) + address(4) + subcode(4), all LSB-first, encoded as 1ms bi-phase cells where 1=burst first half and 0=burst second half | Packed as command(2 hex) + address(1 hex) + subcode(1 hex) |
166166
| Pioneer | 8 hex → 32 bits (4 bytes MSB-first) | 40,000 | Preamble ~8500/4225; each bit mark≈500 + space≈500 (0) or ≈1500 (1); stop bit is implicit; trailing silence ≈26000; full frame sent twice | Typical 4-byte payload layout is Address + ~Address + Command + ~Command |
167167
| Proton | 4 hex (16 bits) | 38,500 | Header 8000/4000; send last 8 bits; separator 500/8000; then first 8 bits; final 500; pad to 63,000µs | Bit mark=500; 0=500; 1=1500; strict 4 hex |
168-
| RC5 | up to 3 hex | 36,000 | Manchester coding, unit ≈889µs; start bits + toggle bit (flips each encode); 11-bit payload MSB-first; frame padded/replaced to 114,000µs | Toggle bit maintained internally (repeat detection depends on toggle changes) |
169-
| RC6 | hex (last 4 hex used → 16-bit payload) | 36,000 | Leader 2664/888; Manchester-like with mode bits + toggle field (toggle is double-time); payload uses T=444 timing pairs; overall layout is start+mode+toggle+addr+cmd | Uses last 4 hex digits as payload; internal toggle flips each encode |
168+
| RC5 | 2+2 hex (AA CC) | 36,000 | RC5 bi-phase coding, unit ≈889µs; fixed start bits `11`, then toggle bit, then address(5) + command(6) MSB-first; encoded as `0 = mark/space`, `1 = space/mark`, with the implicit leading half-bit of the first start bit skipped; rapid same-key repeats keep the same toggle, new presses flip it; frame gap padded to 114,000µs | UI exposes Address and Command separately; legacy packed 3-hex payloads are still accepted internally for compatibility |
169+
| RC6 | hex (last 4 hex used → 16-bit payload) | 36,000 | Leader 2664/888; `1 = mark/space`, `0 = space/mark`; Mode 0 layout is start `1` + mode `000` + double-width toggle + 16-bit payload (addr+cmd) MSB-first using `T=444`; includes the required 6t trailing silence | Uses last 4 hex digits as payload; rapid same-key repeats keep the same toggle, new presses flip it |
170170
| RCA_38 | 3 hex → 12 bits (address nibble + command byte) | 56,000 | Preamble 4000/4000; payload = address(4) + command(8) + ~address(4) + ~command(8), sent MSB-first; each bit mark=500 and space=1000 (0) or 2000 (1); trailer 500/8000 | Strict 3 hex digits |
171171
| RCC0082 | 3 hex (nibbles) | 30,300 | Prefix 22 ints [BIT=528,GAP=2640,BIT×19,END=21120], then [BIT,GAP,BIT,BIT]; build 10-bit: "0" + n0(last3) + n1(all4) + n2(first2); transition-based emission; parity-based tail then suffix (same 22) | Tail even=111,408, odd=110,880 |
172172
| RCC2026 | 11 hex → 42 bits (from 44 padded) | 38,222 | Header 8800/4400; bit mark=550 + space 550 (0) or 1650 (1); final mark 550 + 23100; then tail [8800, 4400, 550, 90750] | Strict 11 hex; takes last 42 bits |

android/app/src/main/AndroidManifest.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232

3333
<!-- IR transmission permission -->
3434
<uses-permission android:name="android.permission.TRANSMIT_IR" />
35+
<uses-permission android:name="android.permission.VIBRATE" />
3536

3637
<!-- Legacy storage permissions for Android 9 and below.
3738
Not requested on Android 10+ due to maxSdkVersion. -->

android/app/src/main/kotlin/org/nslabs/irblaster/MainActivity.kt

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,14 @@ import android.content.pm.PackageManager
99
import android.hardware.ConsumerIrManager
1010
import android.hardware.usb.UsbDevice
1111
import android.hardware.usb.UsbManager
12+
import android.os.Build
1213
import android.os.Handler
1314
import android.os.Looper
15+
import android.os.VibrationEffect
16+
import android.os.Vibrator
17+
import android.os.VibratorManager
1418
import android.util.Log
19+
import android.view.HapticFeedbackConstants
1520
import androidx.annotation.NonNull
1621
import io.flutter.embedding.android.FlutterActivity
1722
import io.flutter.embedding.engine.FlutterEngine
@@ -439,6 +444,7 @@ class MainActivity : FlutterActivity() {
439444
"transmit" -> handleTransmit(call, result)
440445
"transmitRaw" -> handleTransmitRaw(call, result)
441446
"transmitRawCycles" -> handleTransmitRawCycles(call, result)
447+
"performHaptic" -> handlePerformHaptic(call, result)
442448
"hasIrEmitter" -> handleHasAnyEmitter(result)
443449
"getTransmitterCapabilities" -> handleGetTxCaps(result)
444450
"setTransmitterType" -> handleSetTxType(call, result)
@@ -472,6 +478,105 @@ class MainActivity : FlutterActivity() {
472478
emitTxStatusDelayed("startup_done_delayed", 350L)
473479
}
474480

481+
private fun handlePerformHaptic(call: MethodCall, result: MethodChannel.Result) {
482+
val type = call.argument<String>("type") ?: "selection"
483+
val intensity = (call.argument<Int>("intensity") ?: 2).coerceIn(1, 3)
484+
runOnUiThread {
485+
try {
486+
result.success(performNativeHaptic(type, intensity))
487+
} catch (t: Throwable) {
488+
Log.w(TAG, "performNativeHaptic failed: ${t.message}")
489+
result.error("HAPTIC_FAILED", t.message, null)
490+
}
491+
}
492+
}
493+
494+
private fun performNativeHaptic(type: String, intensity: Int): Boolean {
495+
val decorView = window?.decorView
496+
val constant = when (type) {
497+
"selection" -> {
498+
if (Build.VERSION.SDK_INT >= 34) {
499+
HapticFeedbackConstants.SEGMENT_TICK
500+
} else {
501+
HapticFeedbackConstants.CLOCK_TICK
502+
}
503+
}
504+
"light" -> HapticFeedbackConstants.VIRTUAL_KEY
505+
"medium" -> HapticFeedbackConstants.KEYBOARD_TAP
506+
"heavy" -> {
507+
if (Build.VERSION.SDK_INT >= 23) {
508+
HapticFeedbackConstants.CONTEXT_CLICK
509+
} else {
510+
HapticFeedbackConstants.LONG_PRESS
511+
}
512+
}
513+
else -> HapticFeedbackConstants.CLOCK_TICK
514+
}
515+
516+
val viewOk = try {
517+
if (decorView != null) {
518+
decorView.isHapticFeedbackEnabled = true
519+
decorView.performHapticFeedback(constant)
520+
} else {
521+
false
522+
}
523+
} catch (_: Throwable) {
524+
false
525+
}
526+
if (viewOk) return true
527+
528+
return performVibratorFallback(type, intensity)
529+
}
530+
531+
private fun performVibratorFallback(type: String, intensity: Int): Boolean {
532+
val vibrator = getAppVibrator() ?: return false
533+
if (!vibrator.hasVibrator()) return false
534+
return try {
535+
when {
536+
Build.VERSION.SDK_INT >= 29 -> {
537+
val effectId = when (type) {
538+
"selection" -> VibrationEffect.EFFECT_TICK
539+
"light" -> VibrationEffect.EFFECT_CLICK
540+
"medium" -> VibrationEffect.EFFECT_CLICK
541+
"heavy" -> VibrationEffect.EFFECT_HEAVY_CLICK
542+
else -> VibrationEffect.EFFECT_CLICK
543+
}
544+
vibrator.vibrate(VibrationEffect.createPredefined(effectId))
545+
}
546+
Build.VERSION.SDK_INT >= 26 -> {
547+
val (duration, amplitude) = fallbackOneShotFor(intensity)
548+
vibrator.vibrate(VibrationEffect.createOneShot(duration, amplitude))
549+
}
550+
else -> {
551+
val (duration, _) = fallbackOneShotFor(intensity)
552+
@Suppress("DEPRECATION")
553+
vibrator.vibrate(duration)
554+
}
555+
}
556+
true
557+
} catch (_: Throwable) {
558+
false
559+
}
560+
}
561+
562+
private fun getAppVibrator(): Vibrator? {
563+
return if (Build.VERSION.SDK_INT >= 31) {
564+
val mgr = getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager
565+
mgr?.defaultVibrator
566+
} else {
567+
@Suppress("DEPRECATION")
568+
getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
569+
}
570+
}
571+
572+
private fun fallbackOneShotFor(intensity: Int): Pair<Long, Int> {
573+
return when (intensity.coerceIn(1, 3)) {
574+
1 -> 12L to 60
575+
2 -> 18L to 140
576+
else -> 24L to 255
577+
}
578+
}
579+
475580
override fun onNewIntent(intent: Intent) {
476581
super.onNewIntent(intent)
477582
handleControlIntent(intent)

android/app/src/main/kotlin/org/nslabs/irblaster/usb/ElkSmartUsbProtocolFormatter.kt

Lines changed: 147 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,29 @@ import android.util.Log
77
import kotlin.math.min
88
import kotlin.math.roundToLong
99

10-
object ElkSmartUsbProtocolFormatter : UsbWireProtocol {
10+
class ElkSmartUsbProtocolFormatter : UsbWireProtocol {
1111
override val name: String = "elksmart_bulk"
1212
override val strictHandshake: Boolean = true
1313
override val wantsBackgroundReader: Boolean = false
1414
override val interFrameDelayMs: Long = 2L
1515

16+
private enum class Subtype {
17+
D552,
18+
D226,
19+
}
20+
21+
private var subtype: Subtype? = null
22+
23+
companion object {
24+
private const val TAG = "ElkSmartUsbProtocol"
25+
private const val IDENT_PREFIX = 0xFC
26+
private const val TYPE_D552_HI = 0x70
27+
private const val TYPE_D552_LO = 0x01
28+
private const val TYPE_D226_HI = 0x02
29+
private const val TYPE_D226_LO = 0xAA
30+
private const val ODD_PATTERN_TRAILING_GAP_US = 10_000
31+
}
32+
1633
override fun openHandshake(
1734
connection: UsbDeviceConnection,
1835
inEndpoint: UsbEndpoint,
@@ -26,10 +43,10 @@ object ElkSmartUsbProtocolFormatter : UsbWireProtocol {
2643
}
2744

2845
val identify = byteArrayOf(
29-
0xFC.toByte(),
30-
0xFC.toByte(),
31-
0xFC.toByte(),
32-
0xFC.toByte()
46+
IDENT_PREFIX.toByte(),
47+
IDENT_PREFIX.toByte(),
48+
IDENT_PREFIX.toByte(),
49+
IDENT_PREFIX.toByte()
3350
)
3451

3552
val w = connection.bulkTransfer(outEndpoint, identify, identify.size, 200)
@@ -48,21 +65,51 @@ object ElkSmartUsbProtocolFormatter : UsbWireProtocol {
4865

4966
if (got < 6) return false
5067

51-
resp[0] == 0xFC.toByte() &&
52-
resp[1] == 0xFC.toByte() &&
53-
resp[2] == 0xFC.toByte() &&
54-
resp[3] == 0xFC.toByte() &&
55-
(resp[4].toInt() and 0xFF) == 0x70 &&
56-
(resp[5].toInt() and 0xFF) == 0x01
68+
val identified = identifySubtype(resp, got)
69+
if (identified == null) {
70+
Log.w(TAG, "Unexpected identify response: ${resp.copyOf(got).toHexString()}")
71+
return false
72+
}
73+
subtype = identified
74+
true
5775
} catch (t: Throwable) {
58-
Log.w("ElkSmartUsbProtocol", "openHandshake failed: ${t.message}")
76+
Log.w(TAG, "openHandshake failed: ${t.message}")
5977
false
6078
}
6179
}
6280

81+
private fun identifySubtype(resp: ByteArray, size: Int): Subtype? {
82+
if (size < 6) return null
83+
if (resp[0] != IDENT_PREFIX.toByte() ||
84+
resp[1] != IDENT_PREFIX.toByte() ||
85+
resp[2] != IDENT_PREFIX.toByte() ||
86+
resp[3] != IDENT_PREFIX.toByte()
87+
) {
88+
return null
89+
}
90+
91+
val typeHi = resp[4].toInt() and 0xFF
92+
val typeLo = resp[5].toInt() and 0xFF
93+
return when {
94+
typeHi == TYPE_D552_HI && typeLo == TYPE_D552_LO -> {
95+
Log.i(TAG, "Identified ElkSmart subtype 70 01")
96+
Subtype.D552
97+
}
98+
typeHi == TYPE_D226_HI && typeLo == TYPE_D226_LO -> {
99+
Log.i(TAG, "Identified ElkSmart subtype 02 AA")
100+
Subtype.D226
101+
}
102+
else -> null
103+
}
104+
}
105+
63106
override fun encode(frequencyHz: Int, patternUs: IntArray): List<ByteArray> {
64107
val pulses = toPulses(patternUs)
65-
val payload = compressPulses(pulses)
108+
val rawCompressed = compressPulses(pulses)
109+
val payload = when (subtype ?: Subtype.D552) {
110+
Subtype.D552 -> rawCompressed
111+
Subtype.D226 -> encodeD226Payload(rawCompressed)
112+
}
66113

67114
val f = (frequencyHz + 0x7FFFF)
68115
val len = payload.size
@@ -122,7 +169,6 @@ object ElkSmartUsbProtocolFormatter : UsbWireProtocol {
122169
}
123170

124171
private data class Pulse(val onUs: Int, val offUs: Int)
125-
private const val ODD_PATTERN_TRAILING_GAP_US = 10_000
126172

127173
private fun toPulses(patternUs: IntArray): List<Pulse> {
128174
if (patternUs.isEmpty()) return emptyList()
@@ -176,9 +222,90 @@ object ElkSmartUsbProtocolFormatter : UsbWireProtocol {
176222
return out.toByteArray()
177223
}
178224

225+
private fun encodeD226Payload(rawCompressed: ByteArray): ByteArray {
226+
if (rawCompressed.isEmpty()) return rawCompressed
227+
228+
val freq = IntArray(256)
229+
for (b in rawCompressed) {
230+
freq[b.toInt() and 0xFF]++
231+
}
232+
233+
val pq = java.util.PriorityQueue<Node>(compareBy<Node> { it.weight })
234+
for (symbol in 0..0xFF) {
235+
val weight = freq[symbol]
236+
if (weight > 0) {
237+
pq.offer(Leaf(weight, symbol))
238+
}
239+
}
240+
if (pq.isEmpty()) return rawCompressed
241+
242+
while (pq.size > 1) {
243+
val left = pq.poll()
244+
val right = pq.poll()
245+
pq.offer(Branch(left, right))
246+
}
247+
248+
val codes = ArrayList<CodeEntry>()
249+
buildCodes(pq.poll(), StringBuilder(), codes)
250+
codes.sortBy { it.symbol }
251+
252+
val codeBySymbol = HashMap<Int, String>(codes.size)
253+
val out = ByteArrayOutput()
254+
out.write((codes.size ushr 8) and 0xFF)
255+
out.write(codes.size and 0xFF)
256+
257+
for (entry in codes) {
258+
codeBySymbol[entry.symbol] = entry.bits
259+
out.write(entry.symbol and 0xFF)
260+
out.write((entry.weight ushr 8) and 0xFF)
261+
out.write(entry.weight and 0xFF)
262+
}
263+
264+
val bitString = StringBuilder()
265+
for (b in rawCompressed) {
266+
bitString.append(codeBySymbol[b.toInt() and 0xFF] ?: "")
267+
}
268+
269+
var tailBits = bitString.length % 8
270+
if (tailBits > 0) {
271+
repeat(8 - tailBits) { bitString.append('0') }
272+
}
273+
out.write(tailBits and 0xFF)
274+
275+
var i = 0
276+
while (i < bitString.length) {
277+
val chunk = bitString.substring(i, i + 8)
278+
out.write(chunk.toInt(2) and 0xFF)
279+
i += 8
280+
}
281+
return out.toByteArray()
282+
}
283+
284+
private sealed class Node(val weight: Int)
285+
private class Leaf(weight: Int, val symbol: Int) : Node(weight)
286+
private class Branch(val left: Node, val right: Node) : Node(left.weight + right.weight)
287+
private data class CodeEntry(val symbol: Int, val weight: Int, val bits: String)
288+
289+
private fun buildCodes(node: Node, prefix: StringBuilder, out: MutableList<CodeEntry>) {
290+
when (node) {
291+
is Leaf -> {
292+
val bits = if (prefix.isEmpty()) "0" else prefix.toString()
293+
out.add(CodeEntry(node.symbol, node.weight, bits))
294+
}
295+
is Branch -> {
296+
prefix.append('0')
297+
buildCodes(node.left, prefix, out)
298+
prefix.deleteCharAt(prefix.lastIndex)
299+
prefix.append('1')
300+
buildCodes(node.right, prefix, out)
301+
prefix.deleteCharAt(prefix.lastIndex)
302+
}
303+
}
304+
}
305+
179306
private fun compressValueUs(valueUs: Int, out: ByteArrayOutput) {
180307
if (valueUs <= 2032) {
181-
val q = ((valueUs + 8) / 16).coerceAtLeast(2)
308+
val q = if (valueUs == 0 || valueUs == 1) valueUs else ((valueUs / 16.0) + 0.5).toInt()
182309
out.write(q and 0xFF)
183310
return
184311
}
@@ -187,6 +314,7 @@ object ElkSmartUsbProtocolFormatter : UsbWireProtocol {
187314
var b = v and 0x7F
188315
v = v ushr 7
189316
if (v != 0) b = b or 0x80
317+
if ((b and 0xFF) == 0xFF) b = 0xFE
190318
out.write(b and 0xFF)
191319
if (v == 0) break
192320
}
@@ -227,4 +355,8 @@ object ElkSmartUsbProtocolFormatter : UsbWireProtocol {
227355
buf = buf.copyOf(newCap)
228356
}
229357
}
358+
359+
private fun ByteArray.toHexString(): String = joinToString(" ") { b ->
360+
(b.toInt() and 0xFF).toString(16).padStart(2, '0').uppercase()
361+
}
230362
}

android/app/src/main/kotlin/org/nslabs/irblaster/usb/UsbDeviceFilter.kt

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,13 @@ object UsbDeviceFilter {
88
private const val TIQIAA_PID = 0x8468
99

1010
private const val ELKSMART_VID = 0x045C
11-
private val ELKSMART_PIDS = setOf(0x0132, 0x0184, 0x0195, 0x02AA)
12-
13-
private val OTHER_KNOWN_ELKSMART_PIDS = setOf(0x014A)
11+
private val ELKSMART_PIDS = setOf(0x0132, 0x014A, 0x0184, 0x0195, 0x02AA)
1412

1513
fun hasKnownVidPid(device: UsbDevice): Boolean {
1614
val vid = device.vendorId
1715
val pid = device.productId
1816
return isTiqiaaTviewFamily(device) ||
19-
(vid == ELKSMART_VID && (pid in ELKSMART_PIDS || pid in OTHER_KNOWN_ELKSMART_PIDS))
17+
(vid == ELKSMART_VID && pid in ELKSMART_PIDS)
2018
}
2119

2220
fun isSupported(device: UsbDevice): Boolean = hasKnownVidPid(device)

android/app/src/main/kotlin/org/nslabs/irblaster/usb/UsbDiscoveryManager.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ class UsbDiscoveryManager(
4242
}
4343

4444
val protocol: UsbWireProtocol = when {
45-
UsbDeviceFilter.isElkSmart(device) -> ElkSmartUsbProtocolFormatter
45+
UsbDeviceFilter.isElkSmart(device) -> ElkSmartUsbProtocolFormatter()
4646
UsbDeviceFilter.isZaZaRemoteFamily(device) -> UsbProtocolFormatter
4747
UsbDeviceFilter.isTiqiaaTviewFamily(device) -> UsbProtocolFormatter
4848
else -> UsbProtocolFormatter

0 commit comments

Comments
 (0)