🦆

Navigation

🧑‍🦯

Defining Your Home

Part 4 - Writing a Server Service

says ⮞ I use 20x magnification when I code and debug. I use emoji to simplify logs for myself. If you can't handle my code style you can disable most of it on this website by toggling the button in the navbar. Shall duck continue?

Originally written in Bash, but sadly once again I overestimated what heavylifting Bash could handle.
It's now in RUst and I am very happy with the performance.
I will very briefly explain how it works and show some example code. (Please note that the code blocks on this page are just example snippets)

The Rust code is written in a Nix string and is generated upom build time.
Defined devices, automations and other configurations are inserted into variables as json files and loaded into the runtime.

You will have the link to the source code on GitHub at the bottom of this page.
There's basically nothing in the code that needs to be changed if you would like to try it.

If you want to run the service without 'yo' you would simply build it in the zigduck-rs systemd service's preStart definition.

File Structure

Pretty straightforward structure, that's easy to follow.

⮞ View Nix File Structure

  # 🦆 says ⮞ Generate automations configuration
  automationsJSON = builtins.toJSON config.house.zigbee.automations;
  automationsFile = pkgs.writeText "automations.json" automationsJSON;

  # 🦆 says ⮞ Dark time enabled flag
  darkTimeEnabled = if config.house.zigbee.darkTime.enable then "1" else "0";

  # 🦆 needz 4 rust  
  devices-json = pkgs.writeText "devices.json" deviceMeta;
  # 🦆 says ⮞ RUSTY SMART HOME qwack qwack     
  zigduck-rs = pkgs.writeText "zigduck-rs" ''        
    use rumqttc::{MqttOptions, Client, QoS, Event, Incoming};
    #🦆...
  '';

  # 🦆 says ⮞ Cargo.toml
  zigduck-toml = pkgs.writeText "zigduck.toml" ''    
    [package]
    name = "zigduck-rs"
    #🦆...
  '';
  environment.variables."ZIGBEE_DEVICES" = deviceMeta;
  environment.variables."ZIGBEE_DEVICES_FILE" = devices-json;
  environment.variables."AUTOMATIONS_FILE" = automationsFile;

in {
  systemd.services.zigduck-rs = {
    serviceConfig = {
      User = "zigduck";
      Group = "zigduck";
      Exec = "./target/release/zigduck-rs";
      StateDirectory = "zigduck";
      StateDirectoryMode = "0755";
    };
    preStart = ''
      if [ ! -f "${zigduckDir}/state.json" ]; then
        echo "{}" > "${zigduckDir}/state.json"
      fi   
      
      mkdir -p "${zigduckDir}/timers"
      mkdir -p src
      # 🦆 says ⮞ create the source filez yo 
      cat ${zigduck-rs} > src/main.rs
      cat ${zigduck-toml} > Cargo.toml
      
      # 🦆 says ⮞ build
      ${pkgs.cargo}/bin/cargo generate-lockfile      
      ${pkgs.cargo}/bin/cargo build --release   
    '';
  };

  systemd.tmpfiles.rules = [
    "d /var/lib/zigduck 0755 ${config.this.user.me.name} ${config.this.user.me.name} - -"
    "d /var/lib/zigduck/timers 0755 ${config.this.user.me.name} ${config.this.user.me.name} - -"
    "f /var/lib/zigduck/state.json 0644 ${config.this.user.me.name} ${config.this.user.me.name} - -"
  ];

Main Event Loop

The main loop that maintains persistent Mosquitto connection, listens for all Zigbee device messages, and automatically recovers from connection failures.


⮞ View Main Event Loop Summary

// 🦆 says ⮞ i dont alwayz qwack i can listenz
async fn start_listening(&mut self) -> Result<(), Box> {
    let (mut client, mut connection) = Client::new(mqttoptions, 10);
    client.subscribe("zigbee2mqtt/#", QoS::AtMostOnce)?;
    
    loop {
        match connection.eventloop.poll().await {
            Ok(event) => {
                if let Event::Incoming(Incoming::Publish(publish)) = event {
                    let topic = publish.topic;
                    let payload = String::from_utf8_lossy(&publish.payload);
                    self.process_message(&topic, &payload).await?;
                }
            }
            Err(e) => {
                // 🦆 says ⮞ auto reconnect ..
                tokio::time::sleep(Duration::from_secs(5)).await;
                // 🦆 says ⮞ recreate connection & resubscribe
            }
        }
    }
}

says ⮞ datz good if ur zigstick is bad qwack!1

Message Processing & Device State Management

Parsing incoming Mosquitto messages and extracts device data.
Maintains it's own state file of all devices, and routes messages to correct handlers based on device type.

⮞ View Message Processing Handler

// 🦆 says ⮞ letzz do diz! 
async fn process_message(&mut self, topic: &str, payload: &str) -> Result<(), Box> {
    let data: Value = serde_json::from_str(payload)?;
    let device_name = topic.strip_prefix("zigbee2mqtt/").unwrap_or(topic);
    
    // 🦆 says ⮞ update device statez from all dem fields
    self.update_device_state_from_data(device_name, &data)?;
    
    if let Some(device) = self.devices.get(device_name) {
        let room = &device.room;
        // 🦆 says ⮞ handle all dem sensor typez triggerz!
        match device.device_type.as_str() {
            "motion" => {     //🦆says⮞MOTION SENSORS }
            "contact" => {    //🦆says⮞DOOR/WINDOW SENSORS }
            "water_leak" => { //🦆says⮞WATER LEAK SENSORS }
            // 🦆says⮞ MORE? qwack qwack
        }
    }
}

Smart Dimmer Handling

Smart dimmer handling that supports configurable behaviors per room.
Default actions already programmed in the Rust code.
Which means it works as you would expect out of the box.


As shown in part 3 - it is equipped to handle pretty complex automations.
The way it works is that it loads room specific dimmer configurations,
and executes defined override actions or default + defined extra actions.

⮞ View Dimmer Switch Action Handler

// 🦆 says ⮞ letz go yo!
fn handle_room_dimmer_action(
    &self, 
    action: &str, 
    device_name: &str, 
    room: &str,
    default_action: F
) -> Result<(), Box> 
{
    // 🦆 says ⮞any room configurationz?
    if let Some(room_actions) = self.automations.dimmer_actions.get(room) {
        let dimmer_action = match action {
            "on_press_release" => &room_actions.on_press_release,
            "on_hold_release" => &room_actions.on_hold_release,
            // ... 🦆 says ⮞ da rest actionz
        };
        
        // 🦆 says ⮞execute override actionz or default + extra actionz
        if !config.override_actions.is_empty() {
            for override_action in &config.override_actions {
                self.execute_automation_action(override_action, device_name, room)?;
            }
        } else {
            default_action(room)?;  // 🦆says⮞run da defaultz
            for extra_action in &config.extra_actions {
                self.execute_automation_action(extra_action, device_name, room)?;
            }
        }
    }
}

Automations & Conditions

⮞ View COnditions Code Snippet

async fn check_conditions(&self, conditions: &[Condition]) -> bool {
    for condition in conditions {
        if !self.check_condition(condition).await {
            return false;
        }
    }
    true
}

async fn check_condition(&self, condition: &Condition) -> bool {
    match condition.condition_type.as_str() {
        "dark_time" => self.is_dark_time(),
        "someone_home" => self.is_someone_home(),
        "room_occupied" => {
            if let Some(room) = &condition.room {
                self.is_motion_triggered(room) || self.has_recent_motion_in_room(room)
            } else { false }
        }
        _ => false,
    }
}

The Full Source

View source code on GitHub

Keep Reading

Part 1. The module, the options and defining devices
Part 2. Configure your Mosquitto/Z2MQTT
Part 3. Nix Configured Automations
Part 4. Writing a Server Service - in Rust⮜🦆here u are
Part 5. Writing a Client - With Voice Commands
Part 6. The Auto-Generated Dashboard


Comments on this blog post