IR-controlled A/C (3/many) - System design

7 minute read

Summary of previous episodes: I have reached a point where I can control one unit through lircd, with a USB “IR Toy”.

I hade decided that I would like something “not too bad looking”, which isn’t setting the bar too high, but was still ruling out a full size Raspberry Pi with a USB Cable to a USB IR Toy. After all, I would have 3 of these un my apartment.

Ordering stuff

So I settled for for 3 Raspberry Pi Zero W , which are more than powerful enough. I looked at buying 3 IR LEDs to connect to the RPI GPIO pins, but it all looked complicated, I would have to use a transistor, and a resistance, which means more soldering to do, and to potential to make a mess.

At this point, I should mention that I went with RPis and not with wifi-enabled microcontrollers, because it’s easy. I totally agree that using what is essentially a fully functional albeit slow-ish computer just to send IR pulses is a total waste of technology. Additionally, it’s consuming way too much power for what I’m doing with it — but half the year, that power contributes to heating the place, so I guess that’s all right.
So, yes I know.

I then proceeded to order

  • 3 IR transmitters like this one (it wasn’t on that site, but it’s pretty much the same thing - the original link I had is dead.)
  • 3 Raspberry Pi Zero W

Well, I tried to order 3 Raspberry Pi Zero W, but back in July 2021, there were limits to 1 per order and per person… I ended up ordering one Pi Zero W, and on Pi Zero W with headers, and I figured I could still use a Raspberry Pi 3 for one of the rooms, given that I would need a “gateway to the outside world”.

That was also the right time to buy a soldering iron and a few accessories.

The wait

What I was not expecting is to have to wait several weeks for the IR transmitter to come.

Well, as I said, it gave me time for designing the whole system now.

The design

I needed to have something accessible from anywhere, ie. a web server. But I don’t love the idea of having port forwarding and a web server at home: dynamic DNS works, but I find it clunky (and apparently we do change public IP every now and then), and there’s still a little bit of a security concern, it just feels wrong.

With than in mind, I do have a host web server, let’s use that.
However, this server still needs to send a signal to something inside my home. Instead of doing the port forwarding again (which I didn’t want to do initially), I decided to have one of the RPIs open a Wireguard tunnel to my server, and my server just needs to send the information to that RPi. There’s an option in Wireguard so that the tunnel remains open even without data going through, that part was fairly easy.

I also didn’t want the web server to have any hardcoded value like the list of my 3 RPIs, and also ideally I would set up only one tunnel, not 3 - in retrospect that may have been a little overkill, but that’s the way I went. So off I go, with something essentially like this.

Systems Diagram

The frontend is just a website, powered by Php (because it’s easy to write and that’s what I know). It speaks through a Wireguard tunnel to a binary running on one of the Raspberry Pis, which in turn broadcasts a message to all the Pis listening on the network.

The HTTP to UDP

I’m going out of chronological order here, I actually started by implementing the UDP to IR, and to test it I also had a command line client that would just broadcast over UDP. But it makes more send to describe this first.

I wrote this piece in Go. I enjoy Go, it’s fairly easy when you started your career programming in C a long time ago and you hate memory management (and even if you’re not programming daily, and haven’t been for a decade). I just enjoy it.

On a GET request, it would just send back a simple json message to the PHP backend describing the state of each room.
On a POST request the same simple json message would be received from the PHP backend, converted to a Go structure, I would perform a few checks like checking that the rooms do exist, but actually mostly just broadcast over on UDP to whomever is listening.

Essentially the structure looked like this

// SingleACSetting represent the setting for a single AC unit
type SingleACSetting struct {
	Mode   ACMode  // The operating mode
	Temp   float32 // The target temperature in Celsium
	VSwing bool    // Vertical swing
	HSwing bool    // Horizontal swing
}

And describing the whole system is essentially a map[string]*SingleACSetting.

The UDP to IR

As I mentioned previously, lircd has a linux socket interface, so the only thing to do here is to make the command name based on the parameters, like we created in the first episode, and to send that command over to lircd.

I added some rounding of the temperature to the smaller half degree (I never could setting between the view that if the frontend sends bad data, too bar for them, and the view that each level should do the best it can ). And the name of the command became really straightforward.

// ToLircCommandName returns the unique LIRC command name
func (s SingleACSetting) ToLircCommandName() string {
	if s.Mode == Off {
		return "OFF"
	}
	return fmt.Sprintf("ON_%s_%d_V%s_H%s",
		strings.ToUpper(s.Mode.String()),
		int(10*RoundDownTemp(s.Temp)),
		boolToLircStr(s.VSwing),
		boolToLircStr(s.HSwing))
}

All you need to do then is establish a connection

Connecting to the socket

That’s pretty straightforward too.

const defaultLircdSocket = "/run/lirc/lircd"
const lircRemoteName = "daikin-pi"
// [...]

irc, err = irConn(conf.lircdSock)
if err != nil {
  glog.Error(err)
  return err
}
// The reason to not just defer irc.Close() will come in a minute
defer func() { irc.Close() }()

Sending out the command

And with that connection, just send out the command. There’s not too much documentation on what exactly to send down the pipe, but looking at the irsend command source code, you can see it’s really easy, it all lies down to

// This snippet in C
r = lirc_command_init(&ctx, "%s %s %s %lu\n",
                      directive, remote, code, count);

So, in Go, it ends up being as follows

func sendLircCmd(m *message.SingleACSetting, irc net.Conn) error {
	cmd := fmt.Sprintf("SEND_ONCE %s %s\n",
		lircRemoteName,
		m.ToLircCommandName())
	if irc != nil {
		glog.Infof("Sending command %q", cmd)
		n, err := irc.Write([]byte(cmd))
		if n != len(cmd) {
			glog.Warningf("wrote %d bytes to lirc, expected %d", n, len(cmd))
		}
		if err != nil {
			glog.Error(err)
			return err
		}
	} else {
    // Used for testing without actually sending  anything out.
		glog.Infof("Would be sending command %q", cmd)
	}

	return nil
}

The only issue I hit was that the connection would drop sometimes with a broken pipe error. I didn’t spend too much time investigating, and just redialed. This explains the weird closure in the defer statement, so that only the last value for irc gets to call Close().

In the end, the code in the handler ended up looking like this.

err = sendLircCmd(forUs, irc)
if err != nil {
  if errors.Is(err, syscall.EPIPE) {
    irc.Close()
    glog.Warning("got a broken pipe sending IR command, redialing and retrying")
    irc, err = irConn(conf.lircdSock)
    if err != nil {
      glog.Error(err)
      break
    }
    err = sendLircCmd(forUs, irc)
    if err != nil {
      glog.Errorf("even resending failed: %v", err)
    }
  } else {
    glog.Errorf("error sending IR commands: %v", err)
  }
}

Where we’re at now

We have

  • a daemon running on one of the Raspberry Pis, which can receive calls from the Wireguard tunnel
  • a daemon running on each Pi that waits for UDP broadcast (including the one running the daemon, there’s no reason not to us it for both)

I didn’t mention a quick command line utility to directly create a broadcast packet. Allowed me to test.

A word about security: with the current setup, anyone on my local network could set my AC/Heating to anything. I usually don’t have anyone connected to my Wifi network expect my partner and myself, and the myriad of connected things (Apple TV, etc.). If we’re going to give the Wifi key to anyone, it’s probably someone staying with us. If we trust them to stay at our place, we can trust them with that too I guess.
If in the future I want to tighten it a little bit, I guess I have several solutions, one is a shared symmetric key, another would be hash+timestamp+signature and a shared public key/certificate.

Up next

Well, we need a UI next.