za/ch

Tamagometer

A thinkpad with the web page https://zacharesmer.github.io/tamagometer/ open, a Flipper Zero, and a pink Tamagotchi Connection (2024 version) with two tamagotchis hanging out on the screen

My partner got some Tamagotchi Connections (20th anniversary edition), and I wanted to understand the infrared protocol they use to talk to each other. The Flipper Zero wasn’t able to record their interactions by itself, so the Tamagometer was born.

Investigating the physical layer

Is it IrDA?

I remembered reading that some Tamagotchis used the IrDA specification (IrDA = Infrared Data Association) This format is totally different from the protocols used in most remotes, which are in the nebulous category of CIR (Consumer infrared).1 If the Tamagotchis do use IrDA, it would be nice because there’s a well defined protocol, but not so nice because it’s complicated and I don’t have any of the hardware to interact with it.

According to the IrDA physical layer specification: A data “0” is a frame with a pulse taking up no more than 3/16 of that frame, a data “1” is an empty frame with no pulse. If it’s using this I’d expect to see long empty periods with short spikes of IR pretty far apart.

I recorded the Tamagotchi signal using a Flipper Zero, and that description didn’t match what I saw. The message starts with 9485us on 6071us off (presumably some kind of preamble), then there are various pulses and empty space of ~650 and ~1200 microseconds. The Flipper can’t actually record and reproduce IrDA signals correctly, but it does record something when it sees one. As a sanity check, I recorded an IrDA signal by beaming something from PalmOS, and it doesn’t look similar at all.

Looks like it’s not IrDA.

Manchester?

Manchester code was the next thing I checked. It would look like marks and spaces of two different lengths, one twice as long, and that’s what the Tamagotchi signal is (minus the preamble). Let’s see how that goes.

Flipper IR recording (run lengths in microseconds starting with a high signal):

9485 6071 639 652 677 625 624 686 643 650 677 1207 640 1252 650 1255 592 705 676 611 637 683 646 652 676 608 640 681 648 650 599 680 674 650 625 1257 670 651 651 1237 610 1251 677 1224 623 1254 674 1223 625 1276 677 624 625 686 642 1223 651 661 614 678 677 625 624 1253 674 678 597 684 643 651 678 652 597 684 645 650 678 608 640 678 651 1260 614 679 675 626 623 686 642 1225 675 1207 641 680 648 652 624 1279 647 653 623 658 644 679 623 682 619 656 646 679 621 661 642 679 648 664 611 682 620 680 622 658 669 654 621 680 622 1253 647 665 637 684 617 680 597 688 638 654 647 681 595 1284 616 1253 622 1280 645 1254 622 686 614 681 647 654 622 657 644 681 596 686 640 1280 647 635 641 682 619 680 624 659 641 654 647 665 611 1280 621 689 639 655 646 680 597 685 641 655 646 681 596 685 616 680 623 682 644 681 620 663 639 1254 646 655 622 685 615 680 648 1264 611 707 647 639 637 685 616 681 647 636 640 684 617 680 596 684 643 681 622 686 642 652 648 654 623 685 616 680 648 638 638 682 619 706 623 658 643 654 647 684 592 682 646 654 623 686 642 652 648 660 642 683 619 680 623 657 670 653 621 681 595 683 644 654 623 709 617 680 648 637 639 684 617 681 647 635 640 1253 648 1234 641 1279 648 636 640 683 618 680 597 685 669 626 648 680 596 681 646 664 639 1255 645 681 596 1257 670 1226 647 1236 639 1254 646 1236 639 635 1191

(from here on we ignore the preamble)

Split up the signal into periods of 600us. Emit a 1 if the carrier is present, and 0 if not:

10101010100100100101010101010101010100101001001001001001001010100101010100101010101010101010010101010010010101001010101010101010101010101010100101010101010100100100100101010101010100101010101010100101010101010101010101010010101010010101010101010101010101010101010101010101010101010101010101010101010101010101001001001010101010101010100101001001001001001011

Transitions from high to low are “1”, low to high is “0”. If there is anything else, it can’t be valid manchester code and is marked with x:

111110x10000000000x110x10x10x1110000x1111111110000x1000x1111111111111110000000x10x10000000x1111111000000000000x1111000000000000000000000000000000000000000x10x11111111100x10x10x1x

Nope.

Some other kind of NRZ-I code?

In a NRZ-I (Non-return to zero inverted) code, 1 is represented by a transition, 0 is represented by no transition. This ended up looking really similar to the manchester attempt, but conveyed less information. Nope.

“Nearly NEC”

At this point, I found an absolutely fantastic CCC talk by Natalie Silvanovich about hacking Tamagotchis, “Many Tamagotchis were harmed in the making of this presentation”. Right at the end she mentions she calls the IR format “Nearly NEC”. She also has a github repo full of tools and ROM dumps related to this.

NEC protocol has a preamble like the Tamagotchis do. 1 is represented as a pulse and a long space, and 0 is a pulse and a short space.

Applying these rules (timings fudged a little bit from the standard, because it is only “nearly”2) I get:

1111000111111111010000001101110111111100111001101111111111111101111110000111111011111101111111111101110111111111111111111111111111111111111110001111111100111111

This is it!

Building a transceiver

The Flipper was not able to capture a full back and forth exchange between two Tamagotchis, because it was detecting the gap between the first two messages and stopping the recording. I needed something that could continuously record, so I made a transceiver with an IR transmitter, an IR receiver, and a raspberry pi pico running MicroPython.

a raspberry pi pico with blue, green, purple, white, grey, and black jumper wires connected to an infrared transmitter and an infrared receiver

I really liked using MicroPython for the prototyping stage. It was very chill, and I could think very abstractly about what I was doing without getting too caught up in the usual realities of embedded systems. This is how people sound when they talk about vibe coding with LLMs, and maybe the arduino people are looking over with a similar level of disgust about this. I really liked it, though.

In this exploratory stage, I was working entirely on the pico, including replaying a complete 4 message interaction with the Tamagotchi. Eventually I decided to split it up and do most of the interesting stuff in Typescript in the web app. The final version of the pico transceiver (and Flipper app) is basically a modem, only responsible for encoding and decoding the physical “nearly NEC” format into logical 1s and 0s.

A much cuter transceiver

Dani (my aforementioned partner, who got the Tamagotchis) made a custom PCB shaped like an actual tamagotchi. Not only is it adorable, they also made new firmware so you can interact with a Tamagotchi using the buttons, so it is self-contained and works without the web app. It’s got an SAO3 plug on the back for power.

A red PCB in the shape of the outline of a Tamagotchi with buttons, a little seeed xiao rp2040, and IR receiver and transmitter soldered to it

Decoding the data layer

Hooray, we have logical 1s and 0s. What do they mean?

This is an overwhelming question. In a previous project when trying to answer it for PixMob infrared signals, it’s where we got stuck until someone dumped the ROM and reverse engineered the decoding logic. Fortunately, I didn’t have to go nearly as far for answers in this case.

A wild patent appears

The Tamagotchi Connection is patented as “Communication Game Device”, US8545324. I was initially not hopeful that there would be anything useful in the patent, because in the Pixmob project, the patents hadn’t helped much at all. Fortunately, this one describes the data format in great detail. It’s not a precise match, but it’s very close.

The patent says the name is stored in in bytes 5-9 of the transmission, and I found it in bytes 6-10. The patent said the last byte is for parity, and yup, it’s a checksum (I was reading about CRC and starting to sweat, but fortunately it is just a sum of the other bytes mod 256). It looks like we are in business.

Making the Tamagometer

At this point, I wanted to try editing the signals and re-sending them to see what happens when I change different parts. I was doing this in MicroPython directly on the pico for basic testing, but it was a very manual process and got tedious pretty quickly.

I decided to make a web app using the web serial API to help record, edit, and send the messages. It’s a web app because I wanted it to be portable and easy for non-technical people to use. The alternative would have been a native app of some sort, and that would have been okay, but I think people will be more likely to use it if there’s nothing to download or install. It was also a good excuse to get some practice doing web development.

Unfortunately, it only works in Chromium browsers because of the web serial API, but there were enough other benefits that it felt like a reasonable tradeoff. 4

Interfacing with the transceiver

Unfortunately, most computers these days don’t come with a built in IR transciever, so I needed a separate device to get the infrared signals from the Tamagotchi into the web app.

I decided to handle the physical layer in the transceiver, so by the time the signal makes it to the web app it’s already in the form of logical 1s and 0s. The microcontroller emits strings on 1s and 0s as text, which the web app decodes. If I were worried about maximal efficiency and speed I could send it over encoded as actual bits instead of characters 1 and 0, but it hasn’t caused problems as far as I can tell.

The web app polls the microcontroller, which listens for signals for about 1 second, then responds with either a string of decoded bits, or if it sees nothing, a message that it timed out. I went back and forth on this choice and considered redesigning it a couple of times, but I kept talking myself back into keeping it like this.

Web serial uses the streams API, which was the alternative I kept considering. Streams are a very natural fit for recording, but got complicated when I tried to apply them to back and forth messaging. The streams API has a way for the stream to “pull” data out of the source, but it didn’t offer much control over when new data was requested. It would just pull when the stream was empty (or below some “water mark”).

Using streams also introduced the problem of receiving multiple messages and then figuring out what to do with them. Since I’m polling, I can just not look for a new message if I don’t need one. If every message came through in a stream, though, I’d need to sort through them, figure out which ones I care about, and worry about flushing the stream so I’m getting the most recent message. The main reasons I liked the idea of streams were elegance and simplicity, but that’s not what I found in this case.

A while later I happened to watch to a talk about interrupts which mentioned similar tradeoffs to the ones I’d considered in this design. If I had a continuous stream of data coming in from the Tamagotchis, I would have needed some notion of an interrupt to tell the web app a message was received. Since I was polling, that wasn’t necessary.

Making a front end

A screenshot of the edit page of the tamagometer, showing a visit named “Test” between “HIYA★” and “ZACH★”

The very first attempt at hacking together web serial for this was in typescript, but I immediately ran into a lot of issues. The types for the web serial API didn’t work right, and then I kept having to turn off different things I didn’t understand in the tsconfig file. Ultimately I realized this was a terrible way to try and learn both a new API and a new language, so I trashed that and started again in plain old javascript. This worked to produce a very bare-bones prototype, but eventually I wanted to do more.

Thinking about the ever increasing number of callbacks I’d have to hook up, reactive design beckoned. There were not enough tests in the world to let me refactor what I’d made into a Vue project if I kept it in vanilla javascript, so I switched back over to Typescript.

I started this right after working on a Rust project, so I am obliged to complain a little bit. In general, it felts like I fought more with Typescript than I did with the Rust compiler. Maybe the issue is that in Rust I wasn’t doing anything weird enough that I would know better than the compiler, and in this project I was.

For example, I put auto-incrementing keys on the objects I’m storing in indexedDB. If I declare a property “id” on the object, the database complained that its value was invalid when it tried to add the auto-incremented ID. If I don’t declare it, typescript tries to tell me that it doesn’t exist when I access it on an object pulled out of the database. But if I’m getting the object out of the database, it will definitely have an ID because it needs one to exist in the database. Maybe if I understood IndexedDB or Typescript better I’d be able to cajole it into understanding that, and someone probably already has, but for now a couple of carefully placed // @ts-ignore comments worked.

Web workers

Eventually, it became necessary to move the serial polling and decoding out of the main thread. The inciting incident was when I added a callback to animate a status indicator every time I polled the microcontroller, and that added enough of a delay that it was dropping messages.

The main challenges here were how to handle the web worker across multiple different “pages” (not real pages, because it’s all happening in a single page Vue app), and how to communicate with it from the main application.

My first strategy for navigating between pages was to shut down the webworker and make a new one on every navigation. This had the advantage of keeping the interactive conversation and non-interactive snooping logic in separate files. That worked fine until I wanted to add an interactive type of snooping that overlapped a lot with the conversations. Even though I later abandoned it, this was a useful detour because it made me get more disciplined about how the web serial streams handle errors, and how to gracefully close them when I needed to.

The current version passes around one web worker that is created when the page loads. I had actually tried this before anything else, but got an unrelated error and thought it was not possible. Later, I found an example of it on lab.flipper.net; they use web serial for the CLI and they pass a web worker around in a Pinia store. I’m not using Pinia in this project because it seemed like overkill, but the general approach still works.

The other challenge was how to talk to the web worker. Because of when they happened to be introduced in the history of async javascript, web workers use callbacks instead of promises or async/await. I wanted some way (ideally using promises) to abstract away the fact that I was dispatching a task to the web worker, so I made some functions to wrap messages to the web worker in promises. Each time a message is sent to the web worker, it registers a promise with a request ID in a registry (just a map). The worker is guaranteed to send back a message indicating whether the promise should resolve or reject.5 This felt like an absurd thing to need to implement myself, so maybe I missed some other more obvious way to handle it. It works though!

IndexedDB

I decided to store recorded conversations locally in IndexedDB instead of making a back end. There were several reasons for this.

Flipper app

There are two other posts about this here and here if you want the gory details.

Basically, this was for ease of use–it’s a lot easier to download an app than it is to order parts and assemble hardware. And, as an added bonus, a Flipper app might lead some more people to find my project; the first thing I tried after googling “Tamagotchi infrared data format” was searching for “Tamagotchi” in the Flipper app catalog.

The reason I finally got around to it, though, was that my homemade transceiver was very fiddly and inconsistent.

The Future

the futuristic city from the meme “The world if [something]”

The original vision for this project was a completely self contained Flipper app where you can impersonate an arbitrary Tamagotchi and have whatever arbitrary interactions you want. This may still happen someday, but it’s less of a priority because the web app will do that first, and it already exists. If anyone else wants to make this, please use what I’ve found! That’s why it’s all open source.

I want to add support for the original Tamagotchi Connection, but its messages are different enough that I will have to modify the Flipper/MicroPython firmware to handle both message types. I need to either add a way to select the Tamagotchi type, or add some mechanism to detect the message type.

I also don’t understand the signal fully, and the front end is not completely updated with what I do know. I need to add a gift selector to the front end, and learn more about the games so I can add those as well. This will be the next update when I get a chance to work on it.


  1. IrDA does not use a carrier wave, and CIR generally does have a carrier wave. The carrier wave is demodulated in hardware, which is the main reason they’re incompatible. If these were physically compatible, I would be writing an app to beam files to and from Palm OS on the Flipper Zero right now, because wouldn’t that be fun? ↩︎

  2. My guess is that they changed it so that it doesn’t interfere with any other devices that use proper NEC. It would be really annoying if a Tamagotchi kept adjusting the TV or something. Another difference from the real protocol is that NEC repeats parts of the signal with the 1s and 0s inverted for error detection, and the Tamagotchis don’t do that. ↩︎

  3. “Shitty Add On”, or possibly “simple add on” depending who you ask. NB: I don’t think this one is shitty :). It’s for compatibility with electronic badges at large gatherings of nerds, like DEF CON. ↩︎

  4. Mozilla declined to implement webserial in Firefox because it was considered “harmful to the web”. Their official position has changed to “neutral”, but it is still not implemented. I would love to help fix this someday, but I suspect it would be a massive undertaking. ↩︎

  5. And oh boy would I have liked a way to express that in the actual language, rather than going through by hand and checking every possible path through the web worker. Maybe I missed a way to do that; if I did I want to know, please tell me. ↩︎