Moving Two Things

Learning G-Code is quite interesting: it’s really old, really simple, and yet surprisingly complex if you want to get CNC cuts 100% perfect. Luckily my use-cases are much simpler, but I still would like to move 2 axis concurrently: e.g. move along the x-axis from 0 to 400mm, and rotate the a axis from 0 to 180 degrees so that they start and end together.

Adding a Servo

Step 1 is to connect a servo, which was not as easy as I thought since there’s no dedicated servo connector I can readily use on the DLC32. Luckily there are plenty pins to get GND, 5V and a PWM signal. This is the section I added to the config.yaml:

  a:
    steps_per_mm: 10
    max_rate_mm_per_min: 100000.0
    acceleration_mm_per_sec2: 100000.0
    max_travel_mm: 180.0
    soft_limits: true
    homing:
      cycle: 0
      mpos_mm: 0
      positive_direction: false
    motor0:
      rc_servo:
        pwm_hz: 50
        output_pin: gpio.25
        min_pulse_us: 1000
        max_pulse_us: 2000

IO25 is readily available on the EXP1 connector, so I used 5V, GND and IO25 from EXP1:

EXP1 and EXP2 (unused with FluidNC)

Now I can use G-Code like this:

  • G0A90 to move the servo to the neutral position 90° (assuming 1ms=0°, 2ms=180°). The actual movement depends on the servo. Most move only 90° in total.
  • G1X300A10F200 now does:
    • Move to x=300mm and rotate a to 10° at a feed-rate of 200 mm/minute. If x=100 at the start, this movement will take 1 minute.
    • During the movement, a ?<ENTER> will show the current positions:
?
<Run|MPos:221.533,0.000,0.000,126.500|FS:200,0|Pn:PYZ>
ok
[...about 30 seconds later...]
?
<Idle|MPos:300.000,0.000,0.000,10.000|FS:0,0|Pn:PYZ>
ok

Moving Things

Servos are great to rotate/move things around, but they are limited in their capabilities. Steppers are more versatile and controlling them is not hard with the help of stepper driver modules. But since they do expect a fairly high rate of step pulses, a dedicated controller is needed. This is a solved problem though: GRBL takes care of that and it accepts G-Code which looks like this:

G0X100

To move the X axis to the position at 100mm. Generating movements are simply a stream of such strings. Sending a

G0X100.5

after 1 second will results in a moving speed of 0.5mm/s. A nice part of GRBL is that it also controls acceleration and deceleration. Important for moving heavy objects for long distances at high speed.

But traditional GRBL uses an Arduino which is not network connected. Luckily GRBL was ported to the ESP32 CPU with its WiFi interface. Even better: FluidNC was created improving on a lot of areas, like configuration (no need to recompile for a config change) and connectivity (IP or Bluetooth and of course serial).

Naturally that looked like an interesting thing to try out.

Hardware

  • A NEMA17 stepper (200 steps/rotation) with a timing belt moving a slider along an aluminium profile
  • A stepper driver (DRV8825 I think I use)
  • Makerbase MKS DLC32
  • A end-stop sensor (microswitch in my case)

Configuration

  • Get the FluidNC firmware from here
  • Erase the FLASH on the ESP32 with the included erase script (on Windows: run erase.bat)
  • Flash the WiFi version (on Windows: run install-wifi.bat)
  • You should now be able to connect via fluidterm.bat and for any debugging this is very helpful as you can see the boot process and early errors.
  • Configure WiFi according to this. That should be it as most default are sensible and thus not much to configure beside the SSID and the password:
$Sta/SSID=myssid
$Sta/Password=mypasswordforthessid
  • Reboot ($Bye) and check network parameters ($I):
$I
[VER:3.4 FluidNC v3.4.3:]
[OPT:PHS]
[MSG: Machine: Slider]
[MSG: Mode=STA:SSID=myssid:Status=Connected:IP=192.168.3.18:MAC=66-55-44-33-22-11]
ok
  • Connect to the Web UI at http://192.168.3.18 (the IP you get via $I obviously)
  • Upload a file for the configuration for the MKS DLC32 and the hardware setup you have. In my case: I only use the x-axis, so my config file looks like below. It’s almost 100% of the example config and the main changes are:
    • idle_ms=255 which keeps the stepper powered forever so it can hold things in place
    • steps_per_mm and max)travel_mm for the x-axis to match my hardware
    • turn off homing for y and z axis since I don’t use them
board: MKS-DLC32 V2.1
name: Slider
meta: (01.01.2022) by Skorpi

kinematics:
  Cartesian:

stepping:
  engine: I2S_STREAM
  idle_ms: 255
  pulse_us: 4
  dir_delay_us: 1
  disable_delay_us: 0
axes:
  shared_stepper_disable_pin: I2SO.0
  x:
    steps_per_mm: 40.7
    max_rate_mm_per_min: 15000.000
    acceleration_mm_per_sec2: 500.000
    max_travel_mm: 440.000
    soft_limits: true
    homing:
      cycle: 1
      positive_direction: false
      mpos_mm: 0.000
      feed_mm_per_min: 300.000
      seek_mm_per_min: 5000.000
      settle_ms: 500
      seek_scaler: 1.100
      feed_scaler: 1.100

    motor0:
      limit_neg_pin: gpio.36
      hard_limits: true
      pulloff_mm: 2.000
      stepstick:
        step_pin: I2SO.1
        direction_pin: I2SO.2

  y:
    steps_per_mm: 428.0
    max_rate_mm_per_min: 12000.000
    acceleration_mm_per_sec2: 300.000
    max_travel_mm: 440.000
    soft_limits: true
    homing:
      cycle: 0
      positive_direction: false
      mpos_mm: 0.000
      feed_mm_per_min: 300.000
      seek_mm_per_min: 5000.000
      settle_ms: 500
      seek_scaler: 1.100
      feed_scaler: 1.100

    motor0:
      limit_neg_pin: gpio.35
      hard_limits: false
      pulloff_mm: 2.000
      stepstick:
        step_pin: I2SO.5
        direction_pin: I2SO.6:low

  z:
    steps_per_mm: 157.750
    max_rate_mm_per_min: 12000.000
    acceleration_mm_per_sec2: 500.000
    max_travel_mm: 80.000
    soft_limits: true
    homing:
      cycle: 0
      positive_direction: false
      mpos_mm: 0.000
      feed_mm_per_min: 300.000
      seek_mm_per_min: 1000.000
      settle_ms: 500
      seek_scaler: 1.100
      feed_scaler: 1.100

    motor0:
      limit_neg_pin: gpio.34
      hard_limits: false
      pulloff_mm: 1.000
      stepstick:
        step_pin: I2SO.3
        direction_pin: I2SO.4

i2so:
  bck_pin: gpio.16
  data_pin: gpio.21
  ws_pin: gpio.17

spi:
  miso_pin: gpio.12
  mosi_pin: gpio.13
  sck_pin: gpio.14

sdcard:
  cs_pin: gpio.15
  card_detect_pin: NO_PIN

control:
  safety_door_pin: NO_PIN
  reset_pin: NO_PIN
  feed_hold_pin: NO_PIN
  cycle_start_pin: NO_PIN
  macro0_pin: gpio.33:low:pu
  macro1_pin: NO_PIN
  macro2_pin: NO_PIN
  macro3_pin: NO_PIN

macros:
  startup_line0:
  startup_line1:
  macro0: $SD/Run=lasertest.gcode
  macro1: $SD/Run=home.gcode
  macro2:
  macro3:

coolant:
  flood_pin: NO_PIN
  mist_pin: NO_PIN
  delay_ms: 0

probe:
  pin: gpio.22
  check_mode_start: true

Laser:
  pwm_hz: 5000
  #L on Beeper / IN on TTL
  output_pin: gpio.32
  enable_pin: I2SO.7
  disable_with_s0: false
  s0_with_disable: false
  tool_num: 0
  speed_map: 0=0.000% 0=12.500% 1700=100.000%
# 135=0mA 270=5mA 400=10mA 700=16mA
user_outputs:
  analog0_pin: NO_PIN
  analog1_pin: NO_PIN
  analog2_pin: NO_PIN
  analog3_pin: NO_PIN
  analog0_hz: 5000
  analog1_hz: 5000
  analog2_hz: 5000
  analog3_hz: 5000
  digital0_pin: NO_PIN
  digital1_pin: NO_PIN
  digital2_pin: NO_PIN
  digital3_pin: NO_PIN

start:
  must_home: false

  • When done, name the file you just uploaded:
$Config/Filename=config2.yaml

  • Then you have to “Home” once so the controller knows where everything is (using telnet for a change since network is up now):
❯ telnet 192.168.3.18 23
Trying 192.168.3.18...
Connected to 192.168.3.18.
Escape character is '^]'.

Grbl 3.4 [FluidNC v3.4.3 (wifi) '$' for help]
$H
ok
?
<Idle|MPos:0.000,0.000,0.000|FS:0,0|Pn:PYZ|Ov:100,100,100>
ok
  • and now you should be able to move the slider via very simple G-Code (x axis to 100mm position):
G0X100
ok
  • If you get an error for the $H command, it’s likely that you don’t have a working end-stop for the axis which are supposed to have one. A quick fix is to use $X to disable end-stop checks. It’ll allow axis movements, but it does no checks for movements.

Node.js sending commands

GRBL has no single command to do a slow controlled motion, so in order to do that, a program needs to send G-Code commands to it. Node.js to the rescue! Below test program moves the slider 2 times back and forth and when done, it closes the connection:

// Test to send commands to GRBL (FluidNC)

const net=require('net');

let stateIsIdle=false;
let statusLine='';

function gotALine(s) {
  console.log('Got a line: '+s);
  if (s.startsWith('<Idle|')) {
    if (stateIsIdle==true) {
      console.log('Idle detected again');
      client.end();
      process.exit(0);
    } else {
      stateIsIdle=true;
      console.log('Idle detected');
    }
  }
}

let client=new net.Socket();
client.connect(23, '192.168.21.118', () => { console.log('Got connected'); });
client.on('data', (data) => {
  let s=data.toString();
  if (s.indexOf('\n') < 0) {
    statusLine+=s;
  } else {
    statusLine+=s;
    gotALine(statusLine.trim());
    statusLine='';
  }
});

client.on('close', () => { console.log('Closed connection'); });

function sendStatusRequest() {
  if (client) client.write('?\n');
}

setInterval(sendStatusRequest, 1000);

for (let i=0; i<2; ++i) {
  client.write('G0X0\n');
  client.write('G0X400\n');
}

Problems

  • When requesting a status via ‘?’, it seems the stepper steps take a short break which causes a jerky movement. This is very reproducible. Issue created for this. Using I2S_STREAM helps a lot, but it’s not 100% fixed. I2S_STREAM has another problem though…
  • I2S_STREAM seems to be inaccurate: moving 4 times 100mm and then moving back to 0 leaves several mm missing. The same test with I2S_STATIC shows zero error.

M5Stack & AWS IoT

Received my AWS IoT EduKit from M5Stack. First impression: it’s an improvement to the original M5Stack I have: display is nicer and power and reset button is now 2 instead of 1 button. Sensor touch instead of buttons too, but not sure this is an improvement or just cost cutting.

Time to test this via the Blinky Hello World example!

First notes: the instructions do clash with any Python environment you might have set up. It recommends to use miniconda. It’s not needed if you already have a virtual environment setup. Just activate your environment:

$ cd ~/git
$ git clone -b release/v4.2 --recursive https://github.com/espressif/esp-idf.git
$ cd esp-idf
$ . $HOME/esp/esp-idf/install.sh
ERROR: This script was called from a virtual environment, can not create a virtual environment again
$ . ./export.sh 
Detecting the Python interpreter
Checking "python" ...
Python 3.8.5
"python" has been detected
Adding ESP-IDF tools to PATH...
Using Python interpreter in /home/harald/venv/bin/python
Checking if Python packages are up to date...
Python requirements from /home/harald/git/esp-idf/requirements.txt are satisfied.
Added the following directories to PATH:
  /home/harald/git/esp-idf/components/esptool_py/esptool
  /home/harald/git/esp-idf/components/espcoredump
  /home/harald/git/esp-idf/components/partition_table
  /home/harald/git/esp-idf/components/app_update
Done! You can now compile ESP-IDF projects.
Go to the project directory and run:

  idf.py build

You can ignore the error after executing . ./install.sh . All requirements should have been installed as expected into your virtual environment.

In case you get odd errors, delete ~/.espressif/ as it has the compiler tool chain. See also here if your code crash loops.

The AWS CLI tools need to be installed and configured (of course). Then finally the fun starts:

$ cd ~/git
$ git clone https://github.com/m5stack/Core2-for-AWS-IoT-EduKit.git
$ cd Core2-for-AWS-IoT-EduKit/Blinky-Hello-World/utilities/AWS_IoT_registration_helper/
$ pip install -r requirements.txt
$ python registration_helper.py -p /dev/ttyUSB0
[...lots of lines...]
Manifest was loaded successfully

That’ll throw an error if you use Python other than 3.7. The fix is simple: remove the bold lines in the registration_helper.py:

def check_environment():
    """Checks to ensure environment is set per AWS IoT EduKit instructions

    Verifies Miniconda is installed and the 'edukit' virtual environment
    is activated.
    Verifies Python 3.7.x is installed and is being used to execute this script.
    Verifies that the AWS CLI is installed and configured correctly. Prints
    AWS IoT endpoint address.
    """
    conda_env = os.environ.get('CONDA_DEFAULT_ENV')
    if conda_env == None or conda_env == "base":
        print("The 'edukit' Conda environment is not created or activated:\n  To install miniconda, visit https://docs.conda.io/en/latest/minico
nda.html.\n  To create the environment, use the command 'conda create -n edukit python=3.7'\n  To activate the environment, use the command 'con
da activate edukit'\n")
    print("Conda 'edukit' environment active...")
    
    if sys.version_info[0] != 3 or sys.version_info[1] != 7:
        print(f"Python version {sys.version}")
        print("Incorrect version of Python detected. Must use Python version 3.7.x. You might want to try the command 'conda install python=3.7'
.")
        exit(0)
    print("Python 3.7.x detected...")

    aws_iot_endpoint = subprocess.run(["aws", "iot", "describe-endpoint", "--endpoint-type", "iot:Data-ATS"], universal_newlines=True, capture_o
utput=True)
    if aws_iot_endpoint.returncode != 0:

Now in the AWS console you can find it:

and you can also get its endpoint from the AWS CLI:

$ aws iot describe-endpoint --endpoint-type iot:Data-ATS
{
    "endpointAddress": "c7xxxxxxxxxx0f-ats.iot.ap-northeast-1.amazonaws.com"
}

Use idf.py menuconfig to configure the endpoint name and the WiFi connectivity, then idf.py build flash monitor -p /dev/ttyUSB0 to build, flash and then connect to the serial port to watch it. Then you should see incoming MQTT messages:

And you make the LEDs blink by publishing into CLIENT_ID/blink. Stop blinking by publishing to the same topic. If you don’t know your CLIENT_ID, look it up on the display.

Given that AWS charges you for IoT traffic like those messages, don’t keep it messaging all day long. It only takes 5 days 18h to hit the limit of free 500k messages to send (at 1/s).

Saleae Logic Analyzer – First Impressions

I bought myself a Saleae Logic 8 as I am constantly looking at oscilloscopes to help debugging problems with I/O on microcontrollers. It’s frustrating if you “see” nothing, beside you don’t get the results you want. Static signals are no problem: plug in an LED and you can see the state, but this does not work anymore as soon as frequencies larger than 10Hz are used. I2C runs at 100 or 400kHz…SPI even higher.

One fix would be a digital oscilloscope, but they are usually limited to 2 or 4 channels, with 4 channels being already expensive. Some can decode digital protocols too.

But there’s an alternative: Logic analyzers. And Saleae has a nice one; Logic 8: 8 channels, 100MHz digital and 10MHz analog sample rate, 8 channel. And for non-commercial use it’s 50% off!

Ordered one. 3 days later it arrived.

After few hours playing with it, it’s clear: I should have bought one much earlier. Seeing the I2C and SPI traffic or just pulses is so simple and so useful for debugging. Here an example:

1 is the I2C data channel, 2 is the clock. 3 is the same but recorded analog. 4 is the decoded I2C data and 5 is a decoder extension I wrote this afternoon for decoding the data for the PCF8583 I used here (in clock mode). This is easier and at the same time more useful than I thought.

PCF8583 and Espruino

Looking for a I2C device to test and play with it, I found an old PCF8583 board I bought a long time ago from Futurlec. I would have guess about 10 years ago. I remember it can handle 3.3V and has the needed pull-up resistors via jumpers. Perfect for testing!

Unexpectedly I found no library for that chip at http://www.espruino.com/modules/, but looking at the data sheet, it’s not really needed. Setting time and reading time is straightforward:

I2C1.setup({sda: D5, scl: D4});

pcf8583Addr=0x51;

function BCDToBinary(n) {
  return (n>>4)*10+(n&0x0f);
}
function binaryToBCD(n) {
  return ((n/10)<<4) + (n % 10);
}
function BCDToString(n) {
  return String.fromCharCode((n>>4)+48, (n&0x0f)+48);
}
    
function getPCFTime() {
  I2C1.writeTo(pcf8583Addr, 1);
  let d=I2C1.readFrom(pcf8583Addr, 4);
  return `${BCDToString(d[3])}:${BCDToString(d[2])}:${BCDToString(d[1])}.${BCDToString(d[0])}`;
}

function getPCFDate() {
  I2C1.writeTo(pcf8583Addr, 5);
  let d=I2C1.readFrom(pcf8583Addr, 2);
  let year=(2020+((d[0]&0xc0)>>6)).toString();
  let month=BCDToString(d[1] & 0x1f);
  let day=BCDToString(d[0] & 0x3f);
  return `${year}-${month}-${day}`;
}

function setPCFTime(h, m, s) {
  I2C1.writeTo(pcf8583Addr, [0, 0x80, 0, binaryToBCD(s), binaryToBCD(m), binaryToBCD(h)]);
  I2C1.writeTo(pcf8583Addr, 0, [0x00]);
}

function start(){
 g.clear();
 g.drawString("Starting...", 0, 0);
 g.flip(); 
}

var g = require("SSD1306").connect(I2C1, start, {height:64});
require("Font8x12").add(Graphics);
g.setFont8x12();

function updateDisplay() {
  g.clear();
  g.drawString(getPCFDate(), 0, 0);
  g.drawString(getPCFTime(), 0, 20);
  g.flip();
}

setInterval(updateDisplay, 1000);

By the way, the most amazing part of this test was that the included CR2032 Lithium battery still works after that many years.

openLRSng

Long time ago I purchased 3 of those pictured above. Originally for long distance remote control airplanes. I never used them as 2.4GHz took over and that worked for sufficient distance to my eyesight.

Where those modules can be used is for long range IoT uses though. LoRaWAN would be ok too but I don’t have any of those available. And for controlling servos, this receiver got everything out-of-the-box.

Setup

Software comes from the openLRSng repo, specifically the 433MHz release for hardware type 3: RX-3.hex and TX-3.hex from here. Yes, the receiver can be a transmitter, and for telemetry it’s even a requirement to send back data (e.g. battery or signal status).

Configuration is well described here and it uses a Chrome app for this. In order:

  • Connect the transmitter via an USB serial port converter (see Hardware Guide for pin-out details).
  • Flash one receiver with the receiver firmware RX-3.hex.
  • Flash one receiver with the transmitter firmware TX-3.hex.
  • Set up the transmitter to your liking.
  • Power up the receiver by connecting about 5V and GND to the receiver’s port 1. To force binding connect port 1 and 2 (the signal) via a jumper or a jumper cable. Should not be needed though. That power is used to power up the receiver and power the servos. It’s regulated down to 3.3V for the CPU.
  • Shortly after powering on the receiver it should bind to the transmitter and in the UI the receiver tab will be populated.
  • Set up all parameters (frequency) on the transmitter and save them in its EEPROM. Repeat for the receiver.

And you are done with the configuration work. From now on the two units don’t need a computer anymore: the transmitter will expect a CPPM (AKA PPMSum) input signal on port 5 and the receiver will have 8 channels of PWM data as output.

Now the part which took me longest: You have to create a CPPM signal on Port 5 on the transmitter (as per transmitter pin-out). That gets transmitted to the receiver which then shows servo-compatible PWM signals on its 8 ports (port 1 is used for RSS).

Since it’s quite time critical (it’s all about pulses in the 1000-2000µs range with ideally microsecond resolution and low jitter), a hardware timer solution is needed here. Luckily Arduino has PPMEncoder for exactly that. There was an unexpected snag though: the default of PPMEncoder is 500µs (0%) to 2500µs (100%) which causes the transmitter to not recognize the input CPPM signal anymore and then the receiver goes into “fail safe” mode (visible by the LEDs changing). If you vary the signal from 1000µs to 2000µs, all is well. In fact, 550..2450 works too. Since the initial pulse PPMEncoder creates is 500µs long, the total signal needs to be a bit more. However the servo I tested does not move smoothly outside the normal range: while it can move further left than 1000µs and further right than 2000µs, the movement is not linear anymore.

Here the rather simple Arduino program to create a CPPM signal on Pin 7:

#include "PPMEncoder.h"

#define OUTPUT_PIN 7

void setup() {
  ppmEncoder.begin(OUTPUT_PIN);
  for (int ch=0; ch<8; ++ch)
    ppmEncoder.setChannel(ch, 1500);
}

const int servoMax=2000;
const int servoMin=1000;

void loop() {
  while(1) {
  for (int i=servoMin; i<servoMax; ++i) {
    for (int ch=0; ch<4; ++ch)
      ppmEncoder.setChannel(ch, i);
    delay(20);
  }
  delay(500);
  for (int i=servoMax; i>=servoMin; i-=10) {
    for (int ch=2; ch<4; ++ch)
      ppmEncoder.setChannel(ch, i);
    delay(20);
  }
  delay(2000);
  }
}

A very boring video of the resulting servo movements is available here.

LEGO+ and M5Stack

When the Lego Mindstorm NXT came out, I bought one. Good fun, but I realized quite quickly that programming via GUI is…not fun at all. Attempts to program in NXC have been made, but it wasn’t as much fun as I hoped.

Enter the M5Stack with its LEGO+ module (now renamed to DC Motor) which can connect to the motors. Not the sensors, but there’s of course the way more useful M5Stack Units.

And since the M5Stack Core unit has WiFi, a display, buttons and can be programmed in microPython, it’s fun again!

However the latest firmware (v.1.7.2) misses the module for the LEGO+ module. v1.4.5 has it though.

Preview(opens in a new tab)

Task 1: Since you can only tell the motor to turn with a given direction and speed, make it so reach a certain amount of rotations. Classical PID control loop:

This rather simple program does the trick:

from m5stack import *
from m5ui import *
from uiflow import *
import module
import time

class PID:

    def __init__(self, P=0.2, I=0.0, D=0.0):
        self.Kp = P
        self.Ki = I
        self.Kd = D

        self.sample_time = 0.00
        self.current_time = time.ticks_ms()
        self.last_time = self.current_time
        self.clear()

    def clear(self):
        self.SetPoint = 0.0
        self.PTerm = 0.0
        self.ITerm = 0.0
        self.DTerm = 0.0
        self.last_error = 0.0

        # Windup Guard
        self.int_error = 0.0
        self.windup_guard = 10000.0

        self.output = 0.0

    def update(self, feedback_value):
        """Calculates PID value for given reference feedback
        .. math::
            u(t) = K_p e(t) + K_i \int_{0}^{t} e(t)dt + K_d {de}/{dt}
        .. figure:: images/pid_1.png
           :align:   center
           Test PID with Kp=1.2, Ki=1, Kd=0.001 (test_pid.py)
        """
        error = self.SetPoint - feedback_value

        self.current_time = time.ticks_ms()
        delta_time = self.current_time - self.last_time
        delta_error = error - self.last_error

        if (delta_time >= self.sample_time):
            self.PTerm = self.Kp * error
            self.ITerm += error * delta_time

            if (self.ITerm < -self.windup_guard):
                self.ITerm = -self.windup_guard
            elif (self.ITerm > self.windup_guard):
                self.ITerm = self.windup_guard

            self.DTerm = 0.0
            if delta_time > 0:
                self.DTerm = delta_error / delta_time

            # Remember last time and last error for next calculation
            self.last_time = self.current_time
            self.last_error = error

            self.output = self.PTerm + (self.Ki * self.ITerm) + (self.Kd * self.DTerm)
            lcd.print("PTerm: "+str(self.PTerm)+"    ", 0, 80, 0xffffff)
            lcd.print("ITerm: "+str(self.ITerm)+"    ", 0, 100, 0xffffff)
            lcd.print("DTerm: "+str(self.DTerm)+"    ", 0, 120, 0xffffff)

    def setWindup(self, windup):
        self.windup_guard = windup

lego_motor = module.get(module.LEGO)
setScreenColor(0x222222)

def buttonA_wasPressed():
  global speed
  lego_motor.M1.stop()
  pass
btnA.wasPressed(buttonA_wasPressed)

def buttonC_wasPressed():
  global speed
  lego_motor.M1.encode_clear()
  pass
btnC.wasPressed(buttonC_wasPressed)

speed = 0
lego_motor.M1.set_pwm(speed)

# Parameter tuned for motor@9V
pid=PID(0.3, 0.0, 40.0)

# Turn 1/8th of circle, that's 6 full rotations for the motor
pid.SetPoint=360*6
lego_motor.M1.encode_clear();

# If motor signal is less than about 50
# the motor won't turn. So stop then

too_small=50
too_small_count_max=20
too_small_count=0

for count in range(1000):
    pid.update(lego_motor.M1.encoder_read())
    speed=pid.output;
    lcd.print("Encoder: "+str(lego_motor.M1.encoder_read())+"      ", 0, 0, 0xffffff)
    lcd.print("Speed: "+str(speed)+" ", 0, 20, 0xffffff)
    lcd.print("Time: "+str(time.ticks_ms()), 0, 40, 0xffffff)
    if speed < -255:
        speed = -255
    if speed > 255:
        speed = 255
    lego_motor.M1.set_pwm(speed)
    if speed < too_small:
        too_small_count += 1
    else:
        too_small_count = 0
    if too_small_count > too_small_count_max:
        break;
    time.sleep(0.2)

lego_motor.M1.stop()

It was interesting to find usable PID parameters. In the end https://en.wikipedia.org/wiki/PID_controller#Ziegler%E2%80%93Nichols_method worked quite well. The motor has a lot of play and does not work at lowest speeds. I’ll instead use some servos I still have and see what I can PID with those.

Mijia LYWSD03MMC, BLE and Node.js

That hard-to-remember product name is a Bluetooth LE enabled small thermometer and hygrometer from Xiaomi. The special thing about it is that Aaron Christophel created a GitHub repo about how to re-flash it with firmware which makes it way more useful: it now advertises the temperature, humidity and battery level which makes it very easy to pick up.

So I got 4 of those and re-flashed them:

  1. Access https://atc1441.github.io/TelinkFlasher.html with a WebBluetooth capable browser
  2. Click on Connect and connect
  3. Click on Do Activation
  4. Click on Choose firmware and use the ATC_Thermometer.bin from above repo
  5. Click on Start Flashing
  6. Wait about 60s
  7. Wait until it reboot. The display will show the 3 last bytes of the MAC address for easier reference later on.

Connect to the newly flashed device. You can change some settings with the buttons at the end of the web page (e.g. disable the “Show battery in LCD” if you measure and record it anyway).

Scripts for finding them and reading data out of them is here.

Reading them out is very simple with above scripts:

❯ node read-thermometer.js -s ATC_C1CADA,ATC_D01337 -e 10 -t
environment,host=m75q,sensor=ATC_C1CADA temp=22.5,humidity=37,battery=100
environment,host=m75q,sensor=ATC_D01337 temp=20.7,humidity=44,battery=100

which you can feed via telegraf. Here the telegraf.conf snippet:

[[inputs.exec]]
  commands = ["node ~/git/LYWSD03MMC/read-thermometer/read-thermometer.js -s ATC_C1CADA,ATC_D01337 -t"]
  timeout = "12s"
  data_format = "influx"

And the result are very nice graphs in Grafana (3 sensors):

Temperatures recorded via BLE from 3 LYWSD03MMC sensors

ESP8266 and LEDs

After the short excurse with Blynk in the previous post I wanted to do the same without a company between. After all, controlling an LED is rather simple.

So back to basics: MQTT it is. Got a Mosquitto instance on the Internet with the following docker-compose.yml definition:

version: '2'
services:
  mqtt:
    image: toke/mosquitto
    ports:
      - "1883:1883"
    volumes:
      - "./mqtt/config:/mqtt/config"
      - "./mqtt/log:/mqtt/log"
      - "./mqtt/data:/mqtt/data"
    restart: always

Very basic configuration. No TLS, but I defined a user. Not secure, but good enough for now.

The Espruino program running on the ESP8266 is simple (slightly updated as I found out that properly reconnecting/re-subscribing is critical):

np=require("neopixel");

var debug=false;

var mqtt = require("MQTT").connect({
  host: "mqtt.my.domain.org",
  username: "MY_USERNAME",
  password: "MY_PASSWORD",
});

var myPixel=[0, 0, 0];
const ledPin=D2;

function updateLED(v) {
  np.write(ledPin, v);
}

// s: RxxxGxxxBxxx with xxx=0..255
// All valid: R0G0B0, G255R0B0, G10, G80B80R

function getRGB(s) {
    var rgb=[-1, -1, -1];
    var currentIndex=-1;

    for (let i=0; i<s.length; ++i) {
        switch(s[i]) {
            case 'r':
            case 'R': currentIndex=0; rgb[currentIndex]=0; break;
            case 'g':
            case 'G': currentIndex=1; rgb[currentIndex]=0; break;
            case 'b':
            case 'B': currentIndex=2; rgb[currentIndex]=0; break;
            case '0':
            case '1':
            case '2':
            case '3':
            case '4':
            case '5':
            case '6':
            case '7':
            case '8':
            case '9':
                if (currentIndex!=-1) {
                rgb[currentIndex]=rgb[currentIndex]*10+s.charCodeAt(i)-48;
                break;
            }
        }

    }
    var res={};
    if (rgb[0]!=-1) { res.r=Math.min(rgb[0], 255); }
    if (rgb[1]!=-1) { res.g=Math.min(rgb[1], 255); }
    if (rgb[2]!=-1) { res.b=Math.min(rgb[2], 255); }
    return res;
}

function setLED(s) {
  let values=getRGB(s);
  if (values.hasOwnProperty('r')) myPixel[1]=values.r;
  if (values.hasOwnProperty('g')) myPixel[0]=values.g;
  if (values.hasOwnProperty('b')) myPixel[2]=values.b;
  updateLED(myPixel);
}

function log(s) {
  if (debug) {
    console.log(s);
  }
}

// Program starts here

updateLED([20,0,0]);

mqtt.on("connected", () => {
  log("Subscribing to LED");
  mqtt.subscribe("LED");
});

mqtt.on('disconnected', () => {
  log("Disconnected...");
  updateLED([0,20,0]);
  //setInterval(()=>{
  //  updateLED([0,20,0]);
  //  setTimeout(()=>{updateLED([0,0,0]);},50);
  //}, 500);
  setTimeout(()=>{
    log("reconnecting...");
    mqtt.connect();
  }, 1000);
});

mqtt.on('error', (err) => {
  log("Got error: "+error);
  updateLED([0,20,0]);
});

mqtt.on('publish', function (pub) {
  log("topic: "+pub.topic);
  log("message: "+pub.message);
  setLED(pub.message);
});


mqtt.on('subscribed', () => {
  log("Subscription successful");
});

mqtt.on('subscribed_fail', () => {
  log("Subscription failed");
});

mqtt.on('unsubscribed', () => {
  log("unsubscribed received");
});

mqtt.on('ping_reply', () => {
  log("ping_reply received");
});

mqtt.on('puback', () => {
  log("puback received");
});

mqtt.on('pubcomp', () => {
  log("pubcomp received");
});

Test via plumber:

❯ plumber write mqtt --topic="LED" --client-id="Plumber-1" --address="tcp://MY_USERNAME:MY_PASSWORD@mqtt.my.domain.org:1883" --input-data "R200G0B200"

Testing also works with MQTTlens.

And there we go: An Internet controllable LED! Total costs: Wemos D1 mini: $3, WS2812B LED module: $1.

Blynk and M5Stack

Using my M5Stack with Blynk makes sense: it has plenty sensors and a display and LEDs.

❯ export AMPY_PORT=/dev/ttyUSB0
❯ export AMPY_BAUD=115200
❯ ampy ls /flash
/flash/apps
/flash/blocks
/flash/boot.py
/flash/emojiImg
/flash/img
/flash/main.py
/flash/res
❯ ampy mkdir /flash/lib
❯ ampy put BlynkLib.py /flash/lib/BlynkLib.py
❯ ampy get /flash/boot.py > boot.py
# Edit boot.py to include /flash/lib in the Python search path
# Like this:
#import machine, os, sys
#try:
#    sd = machine.SDCard(slot=3, miso=19, mosi=23, sck=18, cs=4)
#    sd.info()
#    os.mount(sd, '/sd')
#    print("SD card mounted at \"/sd\"")
#    sys.path.append('/flash/lib')
#except:
#    pass
❯ ampy put boot.py /flash/boot.py

Sample program:

from m5stack import *
from m5ui import *
from uiflow import *

import BlynkLib

BLYNK_AUTH = "YOUR_BLYNK_AUTH_KEY"
blynk = BlynkLib.Blynk(BLYNK_AUTH)

setScreenColor(0x000000)
lcd.clear()

# Register virtual pin handler
@blynk.on("V3")
def v3_write_handler(value):
  v=int(value[0])
  s='Current slider value:'+str(v)+'.'
  lcd.clear(0x000000)
  lcd.text(0, 20, s)

while True:
  blynk.run()

Using the Bylnk App update Virtual Pin 3 and the value should be displayed on the M5Stack’s LCD.