Snapmaker U1 Integration with full camera wakeup support

If anyone is interested in me putting all what ive done together to get the camera feed working consistently and the card setup, then let me know

I have some nice python to keep the camera feed alive (works with hassos also) and some nice visual cards.
This means i can keep my printer in lan mode and still see it from and control it as i want to

yes please! Dashboard is looking good, great work!

I'll put something together hopefully this weekend

You will need:

  • python on script to keep the camera alive
  • automation that detects the printer running and calls the python script (heartbeat)
  • custom cards from hacs
    • custom:button-card
    • custom:mushroom-template-card
    • custom:bar-card
    • card-mod

in either the config or homeassistant folder (has or hassos)
i have hassos so it will be from this perspective
create a folder /homeassistant/shell/snapmaker_cam.py

import json
import socket
import base64
import os
import sys
import time

def send_pulse():
    printer_ip = "192.168.86.78"
    # Replace this string with your actual static printer API key - go to your printer IP to get it
    api_key = "4563256576532876328765268732"
    
    try:
        s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        s.settimeout(3)
        s.connect((printer_ip, 80))
        
        # Fresh WebSocket connection handshake
        ws_key = base64.b64encode(os.urandom(16)).decode('utf-8')
        handshake = (
            f"GET /websocket?token={api_key} HTTP/1.1\r\n"
            f"Host: {printer_ip}\r\n"
            "Upgrade: websocket\r\n"
            "Connection: Upgrade\r\n"
            f"Sec-WebSocket-Key: {ws_key}\r\n"
            "Sec-WebSocket-Version: 13\r\n\r\n"
        )
        s.send(handshake.encode('utf-8'))
        
        response = s.recv(1024)
        if b"101" not in response:
            s.close()
            return

        def send_frame(payload_dict):
            payload = json.dumps(payload_dict, separators=(',', ':')).encode('utf-8')
            payload_len = len(payload)
            mask_key = b'\x11\x22\x33\x44'
            
            frame = bytearray([0x81])
            if payload_len <= 125:
                frame.append(payload_len | 0x80)
            else:
                frame.append(126 | 0x80)
                frame.extend(payload_len.to_bytes(2, byteorder='big'))
                
            frame.extend(mask_key)
            masked_payload = bytearray(payload_len)
            for i in range(payload_len):
                masked_payload[i] = payload[i] ^ mask_key[i % 4]
            frame.extend(masked_payload)
            s.send(frame)

        # Explicit configuration parameters for standalone server calls
        camera_payload = {
            "jsonrpc": "2.0",
            "method": "camera.start_monitor",
            "params": {
                "domain": "lan",
                "interval": 0
            },
            "id": 1
        }
        
        send_frame(camera_payload)
        
        # Read the buffer briefly to let the printer process the command
        s.settimeout(1.0)
        raw_output = []
        start_time = time.time()
        
        while time.time() - start_time < 1.0:
            try:
                data = s.recv(4096)
                if not data:
                    break
                raw_output.append(data.decode('utf-8', errors='ignore'))
            except socket.timeout:
                break
                
        s.close()
        print("Raw Printer Output: " + "".join(raw_output))
        
    except Exception as e:
        print(f"Connection Error: {str(e)}", file=sys.stderr)

if __name__ == '__main__':
    send_pulse()

create an entry in configuration.yaml

shell_command:
  send_snapmaker_pulse: "python3 /config/shell/snapmaker_cam.py"

create an automation to detect running from the moonraker addin

alias: Snapmaker Camera Heartbeat Loop
description: Fires the local python script every 5 seconds to maintain monitoring status
triggers:
  - seconds: /5
    trigger: time_pattern
conditions:
  - condition: numeric_state
    entity_id: sensor.u1_progress
    above: 0
actions:
  - action: shell_command.send_snapmaker_pulse
    data: {}
mode: single

create a generic camera

the cards need a little explaining.
the camera card with overlays uses a % derived from total layers / layers done as the progress % was way out for me.
the vertical bar on the left is an autosizing vertical of layer slicers

type: picture-elements
camera_image: camera.snapmaker_u1
elements:
  - type: custom:button-card
    entity: sensor.u1_current_layer
    show_name: false
    show_icon: false
    show_state: false
    style:
      left: 10px
      top: 50%
      transform: translate(0, -50%)
      z-index: 2
      pointer-events: none
    styles:
      card:
        - background: transparent
        - box-shadow: none
        - border: none
        - padding: 0px
        - width: 12px
        - height: 180px
    custom_fields:
      bar: |
        [[[
          var current = parseFloat(states['sensor.u1_current_layer'].state);
          var total = parseFloat(states['sensor.u1_total_layer'].state);
          
          if (isNaN(total) || total <= 0) total = 10; 
          if (isNaN(current) || current < 0) current = 0;
          if (current > total) current = total;

          var svgHeight = 180;
          var svgWidth = 12;
          
          var gap = 1.5; 
          if (total > 1) {
            var blockHeight = (svgHeight - (gap * (total - 1))) / total;
            if (blockHeight < 1) {
              gap = 0.5;
              blockHeight = (svgHeight - (gap * (total - 1))) / total;
            }
            if (blockHeight < 0.5) {
              gap = 0;
              blockHeight = svgHeight / total;
            }
          } else {
            var blockHeight = svgHeight;
            var gap = 0;
          }

          var svgHtml = `<svg width="${svgWidth}" height="${svgHeight}" style="display: block;">`;
          
          for (var i = 0; i < total; i++) {
            var layerIndex = total - 1 - i;
            var color = (layerIndex < current) ? '#4caf50' : '#757575';
            var yPos = i * (blockHeight + gap);
            var rx = blockHeight > 2 ? 1 : 0;
            
            svgHtml += `<rect x="0" y="${yPos}" width="${svgWidth}" height="${blockHeight}" fill="${color}" rx="${rx}" />`;
          }
          
          svgHtml += '</svg>';
          return svgHtml;
        ]]]
  - type: custom:button-card
    entity: sensor.u1_print_time_left
    show_state: false
    show_icon: false
    show_title: false
    show_name: false
    style:
      right: 10px
      top: 10px
      transform: none
      z-index: 2
    styles:
      card:
        - background: transparent
        - box-shadow: none
        - border: none
        - padding: 0px
        - width: max-content
    custom_fields:
      finish_time: |
        [[[
          var hours = parseFloat(states['sensor.u1_print_time_left'].state);
          var current = parseFloat(states['sensor.u1_current_layer'].state);
          var total = parseFloat(states['sensor.u1_total_layer'].state);
          
          var progress = 0;
          if (!isNaN(current) && !isNaN(total) && total > 0) {
            progress = Math.round((current / total) * 100);
            progress = Math.min(100, Math.max(0, progress));
          }

          var bgStyle = 'background-color: rgba(0, 0, 0, 0.6); padding: 4px 8px; border-radius: 4px; display: inline-block; margin-bottom: 4px; color: white; font-size: 12px; font-weight: bold;';
          var percentBgStyle = 'background-color: rgba(0, 0, 0, 0.6); padding: 4px 8px; border-radius: 4px; display: inline-block; color: white; font-size: 12px; font-weight: bold;';

          if (isNaN(hours) || hours <= 0) {
            return `<div style="text-align: right;">
                      <span style="${bgStyle}">Ready</span><br>
                      <span style="${percentBgStyle}">${progress}%</span>
                    </div>`;
          }
          
          var totalMins = Math.round(hours * 60);
          var remHours = Math.floor(totalMins / 60);
          var remMins = totalMins % 60;
          var remainingText = remHours > 0 ? `${remHours}h ${remMins}m` : `${remMins}m`;
          var target = new Date(Date.now() + (hours * 3600000));
          var hrs = target.getHours().toString().padStart(2, '0');
          var mins = target.getMinutes().toString().padStart(2, '0');
          
          return `<div style="text-align: right;">
                    <span style="${bgStyle}">${remainingText} left • Finish: ${hrs}:${mins}</span><br>
                    <span style="${percentBgStyle}">${progress}%</span>
                  </div>`;
        ]]]
  - type: custom:button-card
    icon: mdi:alarm
    show_name: false
    tap_action:
      action: call-service
      service: shell_command.send_snapmaker_pulse
    styles:
      card:
        - background-color: rgba(0, 0, 0, 0.6)
        - backdrop-filter: blur(4px)
        - box-shadow: none
        - border: none
        - padding: 6px
        - border-radius: 4px
        - width: 32px
        - height: 32px
      icon:
        - color: white
        - width: 20px
        - height: 20px
    style:
      left: 32px
      top: 10px
      transform: none
      z-index: 2
  - type: custom:button-card
    entity: sensor.u1_current_print_state
    show_name: false
    show_icon: false
    show_state: false
    style:
      right: 10px
      bottom: 10px
      transform: none
      z-index: 2
    styles:
      card:
        - background-color: rgba(0, 0, 0, 0.6)
        - padding: 6px 10px
        - border-radius: 4px
        - border: none
        - box-shadow: none
        - width: max-content
        - height: max-content
    custom_fields:
      status_msg: |
        [[[
          var s = entity.state;
          var s_low = s.toLowerCase();
          var color = '#757575';
          if (s_low === 'printing') color = '#e0b43c';
          else if (s_low === 'complete') color = '#4caf50';
          else if (s_low === 'standby') color = '#5c6bc0';
          else if (s_low === 'error') color = '#e91e63';
          else if (s_low === 'unavailable') color = '#ff7043';

          return `
            <div style="display: flex; align-items: center;">
              <div style="width: 10px; height: 10px; background-color: ${color}; border-radius: 50%; margin-right: 8px; flex-shrink: 0;"></div>
              <span style="color: white; font-size: 13px; font-weight: 500; text-transform: capitalize; line-height: 1;">${s}</span>
            </div>
          `;
        ]]]
  - type: image
    image: >-
      data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg'
      width='100' height='100'></svg>
    camera_image: camera.snapmaker_u1
    tap_action:
      action: more-info
    style:
      top: 50%
      left: 50%
      width: 100%
      height: 100%
      transform: translate(-50%, -50%)
      z-index: 1

the temp and control, ive just kept simple

type: grid
square: false
columns: 1
cards:
  - type: custom:button-card
    entity: sensor.u1_bed_temperature
    show_name: true
    name: Heatbed
    show_icon: false
    show_state: false
    styles:
      card:
        - padding: 12px 16px
      grid:
        - grid-template-areas: "\"n\" \"bar\""
        - grid-row-gap: 8px
      name:
        - justify-self: start
        - font-size: 14px
        - font-weight: bold
    custom_fields:
      bar: |
        [[[
          var temp = parseFloat(entity.state);
          if (isNaN(temp)) temp = 0;
          if (temp > 100) temp = 100;
          if (temp < 0) temp = 0;

          return `
            <div style="position: relative; width: 100%; height: 36px; display: flex; align-items: center;">
              <div style="
                position: absolute;
                left: 0;
                bottom: 0;
                width: 100%;
                height: 20px;
                border-radius: 4px;
                background: linear-gradient(to right, 
                  #3078c2 0%, #3078c2 45%, 
                  #e7a916 45%, #e7a916 55%, 
                  #31a354 55%, #31a354 85%, 
                  #de2d26 85%, #de2d26 100%
                );
              "></div>
              
              <div style="
                position: absolute;
                left: ${temp}%;
                bottom: -2px;
                display: flex;
                flex-direction: column;
                align-items: center;
                transform: translateX(-50%);
                z-index: 2;
                transition: left 0.5s ease-out;
              ">
                <span style="
                  color: var(--primary-text-color);
                  font-size: 11px;
                  font-weight: bold;
                  margin-bottom: 2px;
                  white-space: nowrap;
                ">${entity.state}°C</span>
                
                <div style="
                  width: 4px;
                  height: 24px;
                  background-color: #ffffff;
                  box-shadow: 0 0 4px rgba(0,0,0,0.6);
                  border-radius: 2px;
                "></div>
              </div>
            </div>
          `;
        ]]]
  - type: custom:bar-card
    direction: up
    columns: 4
    min: 0
    max: 300
    title_position: bottom
    positions:
      name: outside
      icon: "off"
      value: inside
      indicator: "on"
    severity:
      - color: "#3078c2"
        from: 0
        to: 149
      - color: "#e7a916"
        from: 150
        to: 189
      - color: "#31a354"
        from: 190
        to: 245
      - color: "#de2d26"
        from: 246
        to: 300
    entities:
      - entity: sensor.u1_extruder_temperature
        name: E0
      - entity: sensor.u1_extruder1_temperature
        name: E1
      - entity: sensor.u1_extruder2_temperature
        name: E2
      - entity: sensor.u1_extruder3_temperature
        name: E3
  - type: grid
    square: false
    columns: 4
    cards:
      - type: custom:mushroom-template-card
        primary: Pause
        icon: mdi:pause
        icon_color: orange
        layout: vertical
        tap_action:
          action: call-service
          service: button.press
          target:
            entity_id: button.u1_pause_print
      - type: custom:mushroom-template-card
        primary: Resume
        icon: mdi:play
        icon_color: green
        layout: vertical
        tap_action:
          action: call-service
          service: button.press
          target:
            entity_id: button.u1_resume_print
      - type: custom:mushroom-template-card
        primary: Cancel
        icon: mdi:stop
        icon_color: red
        layout: vertical
        tap_action:
          action: call-service
          service: button.press
          target:
            entity_id: button.u1_cancel_print
      - type: custom:mushroom-template-card
        primary: E-STOP
        icon: mdi:alert-octagon
        icon_color: red
        layout: vertical
        tap_action:
          action: call-service
          service: button.press
          target:
            entity_id: button.u1_emergency_stop
        card_mod:
          style: |
            ha-card {
              border: 1px solid rgba(255, 0, 0, 0.4);
            }

i doubt i can offer much support on it, im just here to share what i did.
lots of trial and error, hence why i share it.
just a note on using the emergency stop, i used it once and it powered everything off, lights, boards, everything, i had to disconnect power for 30 seconds to to get it back on.

there are many more people on here that can do more than I.
the key piece for me was the camera to keep that running

hope it helps someone :slight_smile:

Thanks! Looking great