Skip to content

dyson_tp02.tc

dyson_tp02.tc — local control of a Dyson Pure Cool Link (Tower TP02 / Desk DP01 …)

Source on GitHub

// dyson_tp02.tc — local control of a Dyson Pure Cool Link (Tower TP02 / Desk DP01 …)
// over the purifier's OWN on-device MQTT broker (port 1883, plaintext on the LAN).
//
// Tasmota's built-in mqttPublish only reaches Tasmota's own broker, so we hand-roll a
// minimal MQTT v3.1.1 client (CONNECT / SUBSCRIBE / PUBLISH QoS0 / PINGREQ) over the raw
// TCP client. Reads live state (mode / speed / oscillation / night / dust / VOC) into
// share vars + a web tile, and writes it back via STATE-SET from web widgets.
//
// ── SETUP ─────────────────────────────────────────────────────────────────────────────
// 1. The MQTT username is the device SERIAL; the password is the device LOCAL credential
//    (an 88-char base64, NOT your Dyson account password). Extract it once from the Dyson
//    cloud with e.g. the `libdyson`/`libdyson-neon` Python lib (DysonAccount login →
//    devices() → .credential / .product_type / .serial). It is a SECRET — keep it out of
//    this source.
// 2. Store ONLY the credential in /dyson.cfg on the device FS (one line, trailing newline
//    ok):  Backlog … or upload via the file manager. This script reads it at start.
// 3. Set HOST (the purifier's LAN IP), SERIAL and PRODUCT_TYPE below to match your unit.
//    Product type selects the MQTT topic prefix: 475 = Pure Cool Link Tower, 469 = Link
//    Desk, 455 = Hot+Cool Link, 438/520 = newer Pure Cool, etc. Field names differ on the
//    non-"Link" models (fpwr instead of fmod) — adjust parse_status/state_set if needed.
//
// Start by hand with TinyCRun (no autostart needed). Verify in the console log:
//   "dyson: CONNACK rc=0" then "dyson: subscribed", then the web tile fills in.
//
// ── ARCHITECTURE ──────────────────────────────────────────────────────────────────────
// The MQTT socket is owned by the VM task (main + TaskLoop). Web widgets (WebCall, which
// runs on the web task) only set the c_* control vars; TaskLoop notices c_X != c_X_prev
// and is the ONLY caller that writes the socket — single-task I/O, no cross-task tcpWrite.

char host[] = "192.168.188.45";    // <-- SET: Dyson LAN IP (DHCP — update if it changes)
char sid[]  = "NN2-EU-HKA2458A";   // <-- SET: device serial (= MQTT username)
char pt[]   = "475";               // <-- SET: product type (475 = Pure Cool Link Tower)
char cid[]  = "tctc";              // MQTT client id (any short unique string)
char cred[120];                    // MQTT password, loaded from /dyson.cfg
char tcmd[44];                     // command topic  <pt>/<serial>/command
char tsta[48];                     // status topic   <pt>/<serial>/status/current

int body[400]; int bln;            // MQTT packet body builder
int out[700];                      // full packet (header + body)
int rxd[768];                      // RX scratch (status burst ~450 bytes)
int connected; int subscribed;
int t_ping;

// live state (read from the device)
int  d_mode;   // 0=Off 1=Fan 2=Auto
int  d_speed;  // 1..10 (0 if AUTO/off)
int  d_osc;    // 0/1
int  d_night;  // 0/1
int  d_dust;   // pact (particulate)
int  d_voc;    // vact (volatile organics)

// control (set by web widgets, dispatched by TaskLoop on change vs *_prev shadow)
int  c_mode = 2;   // pulldown 0=Off 1=Fan 2=Auto
int  c_speed = 5;  // slider 1..10
int  c_osc;        // toggle 0/1
int  c_night;      // toggle 0/1
int  c_mode_prev = 2; int c_speed_prev = 5; int c_osc_prev; int c_night_prev;
int  c_init;       // 0 until first status syncs the widgets to the device

void bpb(int v) { body[bln] = v & 0xff; bln = bln + 1; }
void bpstr(char s[]) {                       // MQTT string field: 2-byte BE length + bytes
  int n = strlen(s); bpb(n >> 8); bpb(n);
  int i; for (i = 0; i < n; i = i + 1) { bpb(s[i]); }
}
void send_pkt(int type) {                    // prepend fixed header + remaining-length, send
  int on = 0; out[on] = type; on = on + 1;
  if (bln < 128) { out[on] = bln; on = on + 1; }
  else { out[on] = (bln & 0x7f) | 0x80; on = on + 1; out[on] = bln >> 7; on = on + 1; }
  int i; for (i = 0; i < bln; i = i + 1) { out[on] = body[i]; on = on + 1; }
  tcpWriteArray(out, on, 0);
}
void mqtt_connect() {
  bln = 0;
  bpb(0); bpb(4); bpb('M'); bpb('Q'); bpb('T'); bpb('T');
  bpb(4); bpb(0xc2); bpb(0); bpb(60);              // protocol level 4, clean+user+pass, keepalive 60
  bpstr(cid); bpstr(sid); bpstr(cred);
  send_pkt(0x10);
}
void mqtt_sub(char topic[]) {
  bln = 0; bpb(0); bpb(1); bpstr(topic); bpb(0);   // packet id 1, QoS 0
  send_pkt(0x82);
}
void mqtt_pub(char topic[], char payload[]) {
  bln = 0; bpstr(topic);
  int n = strlen(payload); int i;
  for (i = 0; i < n; i = i + 1) { bpb(payload[i]); }
  send_pkt(0x30);                                  // PUBLISH QoS 0
}
void mqtt_ping() { out[0] = 0xc0; out[1] = 0x00; tcpWriteArray(out, 2, 0); }

void request_state() {                             // ask the device to push its state once
  char ts[24]; char req[96];
  sprintf(ts, "%04d-%02d-%02dT%02d:%02d:%02dZ", tasm_year, tasm_month, tasm_day, tasm_hour, tasm_minute, tasm_second);
  sprintf(req, "{\"msg\":\"REQUEST-CURRENT-STATE\",\"time\":\"%s\"}", ts);
  mqtt_pub(tcmd, req);
}

void state_set(char fields[]) {                    // {"msg":"STATE-SET",...,"data":{<fields>}}
  char ts[24]; char req[160];
  sprintf(ts, "%04d-%02d-%02dT%02d:%02d:%02dZ", tasm_year, tasm_month, tasm_day, tasm_hour, tasm_minute, tasm_second);
  sprintf(req, "{\"msg\":\"STATE-SET\",\"time\":\"%s\",\"mode-reason\":\"LAPP\",\"data\":{%s}}", ts, fields);
  mqtt_pub(tcmd, req);
  char lg[64]; sprintf(lg, "dyson: STATE-SET {%s}", fields); addLog(lg);
}

void parse_status(char b[]) {                      // tolerant substring parse of the JSON burst
  int p; char v[8];
  p = strFind(b, "\"fnsp\":\"");
  if (p >= 0) { strSub(v, b, p + 8, 4); d_speed = atof(v); shareSetInt("dyson_speed", d_speed); }  // "AUTO" -> 0
  p = strFind(b, "\"pact\":\"");
  if (p >= 0) { strSub(v, b, p + 8, 4); d_dust = atof(v); shareSetInt("dyson_dust", d_dust); }
  p = strFind(b, "\"vact\":\"");
  if (p >= 0) { strSub(v, b, p + 8, 4); d_voc = atof(v); shareSetInt("dyson_voc", d_voc); }
  if (strFind(b, "\"oson\":\"ON")  >= 0) { d_osc = 1;  shareSetInt("dyson_osc", 1); }
  else if (strFind(b, "\"oson\":\"OFF") >= 0) { d_osc = 0; shareSetInt("dyson_osc", 0); }
  if (strFind(b, "\"nmod\":\"ON")  >= 0) { d_night = 1; shareSetInt("dyson_night", 1); }
  else if (strFind(b, "\"nmod\":\"OFF") >= 0) { d_night = 0; shareSetInt("dyson_night", 0); }
  if (strFind(b, "\"fmod\":\"OFF")  >= 0) { d_mode = 0; }
  else if (strFind(b, "\"fmod\":\"AUTO") >= 0) { d_mode = 2; }
  else if (strFind(b, "\"fmod\":\"FAN")  >= 0) { d_mode = 1; }
  shareSetInt("dyson_mode", d_mode);

  // Mirror device state into the control widgets — but never overwrite a click that
  // TaskLoop hasn't dispatched yet (c_X != c_X_prev = pending). First status forces sync.
  if (c_init == 0 || c_mode  == c_mode_prev)  { c_mode  = d_mode;  c_mode_prev  = d_mode; }
  if (c_init == 0 || c_osc   == c_osc_prev)   { c_osc   = d_osc;   c_osc_prev   = d_osc; }
  if (c_init == 0 || c_night == c_night_prev) { c_night = d_night; c_night_prev = d_night; }
  if (d_speed > 0 && (c_init == 0 || c_speed == c_speed_prev)) { c_speed = d_speed; c_speed_prev = d_speed; }
  c_init = 1;
}

void do_connect() {
  subscribed = 0;
  int rc = tcpConnect(host, 1883);
  if (rc == 0) { mqtt_connect(); t_ping = millis(); addLog("dyson: TCP up, CONNECT sent"); }
  else { addLog("dyson: tcpConnect failed"); }
}

int main() {
  connected = 0; subscribed = 0;
  sprintf(tcmd, "%s/%s/command", pt, sid);
  sprintf(tsta, "%s/%s/status/current", pt, sid);
  int fh = fileOpen("/dyson.cfg", "r");
  if (fh < 0) { addLog("dyson: /dyson.cfg missing"); return 0; }
  int n = fileRead(fh, cred, 119); fileClose(fh);
  if (n < 1) { addLog("dyson: empty credential"); return 0; }
  int done = 0;                                    // trim trailing whitespace/newline
  while (n > 0 && done == 0) {
    int c = cred[n - 1];
    if (c == 10 || c == 13 || c == 32 || c == 9) { n = n - 1; } else { done = 1; }
  }
  cred[n] = 0;
  do_connect();
  return 0;
}

void TaskLoop() {
  if (tcpConnected() == 0) { do_connect(); delay(3000); return; }

  if (tcpAvailable() > 0) {
    int n = tcpReadArray(rxd);
    if (subscribed == 0 && n >= 4 && rxd[0] == 0x20) {           // CONNACK
      char m[48]; sprintf(m, "dyson: CONNACK rc=%d", rxd[3]); addLog(m);
      if (rxd[3] == 0) { mqtt_sub(tsta); request_state(); subscribed = 1; addLog("dyson: subscribed"); }
    } else {                                                     // PUBLISH (status burst)
      char buf[768]; int i; int j = 0;
      for (i = 0; i < n && j < 767; i = i + 1) {
        int c = rxd[i]; if (c == 0) { c = 32; }   // keep binary 0x00 from cutting the string
        buf[j] = c; j = j + 1;
      }
      buf[j] = 0;
      parse_status(buf);
    }
  }

  // dispatch one pending control change per tick (web widgets set c_*, we own the socket)
  if (subscribed == 1 && c_init == 1) {
    char f[40];
    if (c_mode != c_mode_prev) {
      if (c_mode == 0) { strcpy(f, "\"fmod\":\"OFF\""); }
      else if (c_mode == 2) { strcpy(f, "\"fmod\":\"AUTO\""); }
      else { sprintf(f, "\"fmod\":\"FAN\",\"fnsp\":\"%04d\"", c_speed); }
      state_set(f); c_mode_prev = c_mode;
    } else if (c_speed != c_speed_prev) {
      sprintf(f, "\"fmod\":\"FAN\",\"fnsp\":\"%04d\"", c_speed);
      state_set(f); c_speed_prev = c_speed; c_mode = 1; c_mode_prev = 1;
    } else if (c_osc != c_osc_prev) {
      if (c_osc) { strcpy(f, "\"oson\":\"ON\""); } else { strcpy(f, "\"oson\":\"OFF\""); }
      state_set(f); c_osc_prev = c_osc;
    } else if (c_night != c_night_prev) {
      if (c_night) { strcpy(f, "\"nmod\":\"ON\""); } else { strcpy(f, "\"nmod\":\"OFF\""); }
      state_set(f); c_night_prev = c_night;
    }
  }

  if (millis() - t_ping > 45000) { mqtt_ping(); t_ping = millis(); }   // keepalive
  delay(250);
}

void onoff(char dst[], int v) { if (v) { strcpy(dst, "On"); } else { strcpy(dst, "Off"); } }

void WebCall() {
  char m[96]; char a[8]; char b2[8];
  char md[8];
  if (d_mode == 0) { strcpy(md, "Off"); } else if (d_mode == 2) { strcpy(md, "Auto"); } else { strcpy(md, "Fan"); }
  sprintf(m, "{s}<b>Dyson TP02</b>{m}%s{e}", md); webSend(m);
  sprintf(m, "{s}Speed{m}%d / 10{e}", d_speed); webSend(m);
  onoff(a, d_osc); sprintf(m, "{s}Oscillation{m}%s{e}", a); webSend(m);
  onoff(b2, d_night); sprintf(m, "{s}Night mode{m}%s{e}", b2); webSend(m);
  sprintf(m, "{s}Dust (pact){m}%d{e}", d_dust); webSend(m);
  sprintf(m, "{s}VOC (vact){m}%d{e}", d_voc); webSend(m);

  // Controls. webPulldown/webSlider/webButton emit a raw <div><button>, illegal inside
  // the sensor <table> — wrap each in <tr><td colspan=2> or it gets foster-parented out.
  webSend("<tr><td colspan=2>"); webPulldown(c_mode, "Mode", "Off|Fan|Auto"); webSend("</td></tr>");
  webSend("<tr><td colspan=2>"); webSlider(c_speed, 1, 10, "Speed"); webSend("</td></tr>");
  webSend("<tr><td colspan=2>"); webButton(c_osc, "Oscillation"); webSend("</td></tr>");
  webSend("<tr><td colspan=2>"); webButton(c_night, "Night mode"); webSend("</td></tr>");
}