Meade 1209 Focuser Upgrade

Meade 1209 Zero Image Shift MicroFocuser.
Meade 1209 Zero Image Shift Microfocuser

I've had a Meade 1209 Zero Image Shift microfocuser for about three years. It works great, but doesn't provide the benefits an absolute position focuser would provide, such as repeatability and auto-focus capability. The first goal of this project was to make an absolute position focuser out of the Meade 1209. I went through the idea of adding an encoder for positional feedback, but the encoder would require a fairly smart motor controller to make it work, and would be a real pain mechanically. The only other way I could think to do it was with a stepper motor, but they have little torque compared to the original gearmotor. I did find a geared stepper that was small and had enough torque.

The second goal was it needed to work with an existing Indi driver. There is a Moonlite compatible driver module for Indi, so I built a Moonlite compatible focus controller using an Arduino Nano.

The final goal was to do the above two without harming the focuser. I wanted to be able to back out the changes if it didn't work. All three goals were met with this design.

Meade 1209 Zero Image Shift Microfocuser main parts.
Meade 1209 Main Parts

The image shows the main parts of the focuser. The Crayford body with roller, DC gearmotor, and two external gears. The motor, its gear, and the motor mounting bracket were replaced by new ones. The brass (on my model) 48-tooth gear was re-used.

The Bracket

The part we need to fabricate.
The Old Motor Bracket

The aluminum bracket that holds the gearmotor and the roller assembly together. It is about 20mm wide x 35mm long, and 2mm thick. It tilts down a bit from the roller to the motor. I think that is so you can get to the screws that adjust the pressure on the roller. The new motor is too big for that, so I made the bracket straighter, to take up less overall height.

The way Meade made sure the gears aligned was to counter-bore the back side of the bracket's roller shaft hole with a ~10mm dia. spot face bit. That lets the bracket fit over a protruding lip on the roller body, aligning it. On the replacement I drilled the shaft hole 10mm in diameter to do the same thing.

The motor bracket
The New Motor Bracket

The new bracket is made from 1/8" x 1" flat aluminum bar 2" (50.8mm) long. I didn't cut it to length until all of the holes were drilled. It is too small to be handled otherwise. I taped the full-sized drawing to the bar and center punched the hole locations through the drawing. Here is the full-size PDF of the bracket.

I drilled 2.5mm holes (2mm clearance) at each punch for the motor, and 3mm holes (#4 clearance) for each punch on the roller end of the bracket. Then I drilled countersinks for the screw holes, and finally a 10mm hole for the roller shaft and a 13mm hole for the stepper motor. The aluminum bar had a mill finish, so it needed a little filing to smooth it up.

The first attempt was a success. All of the holes lined up perfectly. I had to ream the 13mm hole a little with a hand reamer. The stepper center is exactly 13mm, so the hole needed to be a very little bit larger. The same was true for the 10mm hole. With punching, drilling, countersinking, filing, and cursing my cheap metric drill bits, I spent around 45 minutes on the bracket.

Motor

The motor and gear
The Motor and Gear

The stepper came from OMC-StepperOnline.com, and is the 8HS15-0604S-PG64 NEMA 8 stepper with a 64:1 planetary gear reducer, and a 6mm output shaft. Total focuser range is ~13mm, and it takes 11000 steps to get there, so each step is approximately 0.000044" (1.2 microns) at the focal plane. OMC claims backlash is "1° or less" in the gear motor. When I tested it, the gear backlash was approximately 117 steps, 210.6° at the motor shaft, 3.3° at the output shaft, or 0.0051" at the focal plane.

The drive gear is the same pitch diameter as the brass gear on the roller shaft, (module 0.5, 24mm pitch dia) but is made of acetal with a brass hub. It is the A 1Z 2MYZ0504806 from SDP-SI.com.

Assembled

The focuser with motor installed
The Motor Attached

The assembled focuser in the half-out position (5,500 steps). It easily lifts the 2.7 lb. weight of the camera and filter wheel and holds it in position with the motor powered off. The maximum speed is 2500 steps per second. It takes about 3 seconds to go from zero to the halfway point. Most moves are much smaller than that, and so are done at a lower speed, due to acceleration.

Controller

The focuser controller board
The Controller

The focuser's controller is an Arduino Nano driving an A4988 stepper controller board. The Nano has a USB port for the computer interface. I found code that looked like exactly what I wanted, but it had a few problems driving the stepper reliably, so I changed the stepper driver code to use AccelStepper. That caused a little refactoring because the temperature compensation was built into the motor code. You can find a page on the original code at Hansastro Focuser. There is nothing wrong with that code - I just couldn't tune it to my stepper motor, so I opted for something with which I was already familiar. I have the LM335 hooked up for temperature compensation, and when I get some numbers that describe the relationship between temperature and focus position, I'll try temperature compensation.

The program is burned with a programmer. That is because the Indi driver opens the port, which causes a reset on the Arduino, and then hits it right away. The Arduino takes a couple of seconds to get past the bootloader, so the driver gets a timeout. When you burn the program with the programmer (Shift-Upload), the bootloader is gone, and your program starts right out of reset.

I added an EEPROM wear-leveling routine I found on the internet and shouldn't have an EEPROM failure until sometime after 6,400,000 moves. It saves the position after every move that changes the position counter, so whenever I refocus, the temperature changes by more than 0.5°C, or Indi issues a sync command. I believe the sync is only sent when you click on "Sync" in the Indi Control Panel.

Because there is a large, predictable amount of backlash, I put in a routine to take it out on every direction change. Rather than add the backlash to the new position, I move the current position. That means there are two moves - one the size of the backlash and one the size of the move. I'm still thinking about that one. I chose to do it this way because otherwise there is no way to determine experimentally exactly what the backlash is. Backlash may even be non-existent when the camera is hanging off of the focuser, or different in different parts of the sky. I'm not sure where to go with this yet. Experience will tell.

An OLED display shows the temperature in celsius, a flag showing whether temperature compensation is enabled (t or T), one showing step size (H or F), and the compensation coefficient on a small line at the top. The 5-digit position in steps in a larger font takes the rest of the display. It updates every five seconds. The display is on the top of the box, but I doubt it will ever be used beyond the troubleshooting I did to get the controller working. The Ekos client shows the same information, and more, updating every second. Besides, I won't be at the telescope when I focus.

/**************************************************************************************************
 *
 * AccelFocuser
 *
 * Indi compatible Moonlite focuser lookalike using Meade 1209 and Arduino Nano.
 *
 * Significant portions of this code were taken from Focuser by hansastro.
 * https://hansastro.github.io/Focuser/
 *
 * It was modified to use the AccelStepper library, which necessitated moving the temp comp
 * into the main code.
 *
 **************************************************************************************************/

#include <Wire.h>
#include <LM335.h>
#include <Moonlite.h>
#include <AccelStepper.h>
#include <U8x8lib.h>
#include <WearLeveling.h>

// A4988 pins 
// organized to wire straight across to a Nano
const int enablePin = 9;
const int stepMode1 = 8;
const int stepMode2 = 7;
const int stepMode3 = 6;
const int resetPin = 5;
const int sleepPin = 4;
const int stepPin = 3;
const int directionPin = 2;

// LM335 temp sensor pin
const int temperatureSensorPin = A0;

// Objects for motor, display, etc.
LM335 TemperatureSensor(temperatureSensorPin);
AccelStepper Motor(AccelStepper::DRIVER, stepPin, directionPin);
Moonlite SerialProtocol;
U8X8_SSD1306_128X64_NONAME_HW_I2C u8x8(U8X8_PIN_NONE);

#define FULL_STEP 1
#define HALF_STEP 2
#define FULL_BACKLASH 117
#define HALF_BACKLASH 234

long currentPosition = 0;
long targetPosition = 0;
long stepMode = HALF_STEP;
bool enabled = false;
long setSpeed = 0x02;
unsigned long display_timer;
long eeprom_position;
long backlash_in = 0;
long backlash_out = 0;
bool going_out = false;
bool temperature_compensation = false;
unsigned long temperature_timer;
float last_comp_temperature; // Last temperature compensated.
float temperature_coefficient;  // Steps per degree C.

/*
 * Return the step mode (FULL_STEP or HALF_STEP).
 */
long getStepMode() {
    return stepMode;
}

/*
 * Set the step mode. Also sets acceleration and max speed.
 */
void setStepMode(uint8_t mode) {
    stepMode = mode;
    switch (mode) {
        case FULL_STEP:
            digitalWrite(stepMode1, LOW);
            digitalWrite(stepMode2, LOW);
            digitalWrite(stepMode3, LOW);
            Motor.setMaxSpeed(2500);
            Motor.setAcceleration(2000);
            backlash_in = backlash_out = FULL_BACKLASH;
            break;
        case HALF_STEP:
            digitalWrite(stepMode1, HIGH);
            digitalWrite(stepMode2, LOW);
            digitalWrite(stepMode3, LOW);
            Motor.setMaxSpeed(2000);
            Motor.setAcceleration(2000);
            backlash_in = backlash_out = HALF_BACKLASH;
            break;
        default:
            break;
    }
}

/*
 * Apply backlash compensation.
 */
void eat_backlash() {
    long cp = Motor.currentPosition();
 
    Motor.enableOutputs();
    if (going_out) {
        Motor.moveTo(cp + backlash_out);
        do {
            Motor.run();
        } while(Motor.distanceToGo());
    } else {
        // Can't move below zero.
        if (cp >= backlash_in) {
            Motor.moveTo(cp - backlash_in);
            do {
                Motor.run();
            } while(Motor.distanceToGo());
        }
    }
    Motor.setCurrentPosition(cp);
    Motor.disableOutputs();
}

/*
 * Setup the move and start it.
 */
void gotoTargetPosition() {
    if (currentPosition != targetPosition) {
        if (currentPosition > targetPosition) {
            if (going_out) {
                going_out = false;
                eat_backlash();
            }
        } else {
            if (!going_out) {
                going_out = true;
                eat_backlash();
            }
        }
        Motor.moveTo(targetPosition);
        Motor.enableOutputs();
        enabled = true;
    }
}

/*
 * Apply temperature compensation to the current position.
 * Updates every 30 seconds.
 */
void tempCompensate() {
    float curr_temp = TemperatureSensor.getTemperature();
    float diff_temp = last_comp_temperature-curr_temp;
    if (fabs(diff_temp) >= 0.5) {
        long new_position = currentPosition + (long)(temperature_coefficient * diff_temp);
        if (new_position > 0) {
            last_comp_temperature = curr_temp;
            targetPosition = new_position;
            gotoTargetPosition();
        }
    }
    temperature_timer = millis() + 30000;
}

/*
 * Process incoming and outgoing computer messages
 */
void processCommand() {
    MoonliteCommand_t command;
    long commandID = SerialProtocol.getCommand().commandID;

    switch (commandID) {
        case ML_C:
            // Initiate temperature conversion
            break;
        case ML_FG:
            // Goto target position
            gotoTargetPosition();
            break;
        case ML_FQ:
            // Stop motor movement and clear the move by resetting the
            // current and target positions to the current motor position
            Motor.stop();
            Motor.disableOutputs();
            enabled = false;
            targetPosition = currentPosition = Motor.currentPosition();
            Motor.moveTo(currentPosition);
            break;
        case ML_GB:
            // Set the Red Led backlight value
            // Dump value necessary to run the official moonlite software
            SerialProtocol.setAnswer(2, (long)0x00);
            break;
        case ML_GC:
            // Return the temperature coefficient
            SerialProtocol.setAnswer(2, (long)(temperature_coefficient * 2));
            break;
        case ML_GD:
            // Return the current motor speed. Returns the set speed,
            // not the actual speed. Compatibility only. We don't use it.
            SerialProtocol.setAnswer(2, (long)setSpeed);
            break;
        case ML_GH:
            // Return the current stepping mode (half or full step)
            // See ML_SH, ML_SF, below.
            SerialProtocol.setAnswer(2, (long) (getStepMode() == HALF_STEP ? 0xFF : 0x00));
            break;
        case ML_GI:
            // Get motor is moving
            SerialProtocol.setAnswer(2, (long) (Motor.isRunning() ? 0x01 : 0x00));
            break;
        case ML_GN:
            // Get the target position
            targetPosition = Motor.targetPosition();
            SerialProtocol.setAnswer(4, targetPosition);
            break;
        case ML_GP:
            // Return the current position
            currentPosition = Motor.currentPosition();
            SerialProtocol.setAnswer(4, currentPosition);
            break;
        case ML_GT:
            // Return the temperature
            SerialProtocol.setAnswer(4, (long)(TemperatureSensor.getTemperature()));
            break;
        case ML_GV:
            // Get the version of the firmware
            SerialProtocol.setAnswer(2, (long) (0x01));
            break;
        case ML_SC:
            // Set the temperature coefficient
            temperature_coefficient = (float)(SerialProtocol.getCommand().parameter / 2.0);
            break;
        case ML_SD:
            // Set the motor speed
            setSpeed = (int)SerialProtocol.getCommand().parameter;
            break;
        case ML_SF:
            // Set the stepping mode to full step
            setStepMode(FULL_STEP);
            break;
        case ML_SH:
            // Set the stepping mode to half step
            setStepMode(HALF_STEP);
            break;
        case ML_SN:
            // Set the target position if we aren't currently moving.
            if (!Motor.isRunning()) {
                targetPosition = SerialProtocol.getCommand().parameter;
                Motor.moveTo(targetPosition);
            }
            break;
        case ML_SP:
            // Set the current motor position, EEPROM, and target position.
            currentPosition = SerialProtocol.getCommand().parameter;
            targetPosition = currentPosition;
            Motor.setCurrentPosition(currentPosition);
            writeValue(0, currentPosition);
            eeprom_position = currentPosition;
            break;
        case ML_PLUS:
            // Activate temperature compensation focusing and start timer.
            temperature_compensation = true;
            last_comp_temperature = TemperatureSensor.getTemperature();
            temperature_timer = millis();
            break;
        case ML_MINUS:
            // Disable temperature compensation focusing
            temperature_compensation = false;
            break;
        case ML_PO:
            // Temperature calibration
            break;
        default:
            break;
    }
}

/*
 * Show some data on the display
 */
void display() {
    char tempbuf[17] = {0};
    float tempc = TemperatureSensor.getTemperature() / 2;
    dtostrf(tempc, 4, 2, tempbuf);
    tempbuf[8] = temperature_compensation?'T':'t';
    tempbuf[9] = stepMode==HALF_STEP?'H':'F';
    dtostrf(temperature_coefficient, 4, 1, tempbuf+11);
    for (int x=0; x < sizeof(tempbuf) - 1; ++x) {
        if (tempbuf[x] == '\0') tempbuf[x] = ' ';
    }
    u8x8.setFont(u8x8_font_amstrad_cpc_extended_f);
    u8x8.drawString(0, 1, tempbuf);
    u8x8.setFont(u8x8_font_inb33_3x6_n);
    u8x8.drawString(0, 2, u8x8_u16toa(Motor.currentPosition(), 5));
    display_timer = millis() + 1000;
}

void setup() {

    // Moonlite interface.
    SerialProtocol.init(9600);

    // Stepper interface.
    pinMode(stepMode1, OUTPUT);
    pinMode(stepMode2, OUTPUT);
    pinMode(stepMode3, OUTPUT);
    pinMode(sleepPin, OUTPUT);
    digitalWrite(sleepPin, HIGH);
    pinMode(resetPin, OUTPUT);
    digitalWrite(resetPin, HIGH);
    Motor.setEnablePin(enablePin);
    Motor.setPinsInverted(false, false, true);
    setStepMode(FULL_STEP);

    // Display.
    u8x8.begin();
    u8x8.setFont(u8x8_font_amstrad_cpc_extended_f);
    u8x8.clear();

    // EEPROM.
    loadEeprom(0);
    eeprom_position = getValue(0);
    Motor.setCurrentPosition(eeprom_position);
    currentPosition = eeprom_position;
    targetPosition = currentPosition;

    // LM335 and compensation.
    TemperatureSensor.setNumberOfIntegration(10000);
}

void loop() {

    // Motor shutdown housekeeping
    if (Motor.distanceToGo() == 0) {
        if (enabled) {
            enabled = false;
            delay(10);
            Motor.disableOutputs();
            long mtr_position = Motor.currentPosition();
            if (mtr_position != eeprom_position) {
                writeValue(0, mtr_position);
                eeprom_position = mtr_position;
            }
        }
        // Temperature compensation housekeeping
        TemperatureSensor.Manage();
        if (temperature_compensation) {
            if (millis() > temperature_timer) {
                tempCompensate();
            }
        }
        // Display housekeeping
        if (millis() > display_timer) {
            display();
        }
    } else {
        Motor.run();
    }
    // Interface housekeeping
    SerialProtocol.Manage();
    if (SerialProtocol.isNewCommandAvailable()) {
        processCommand();
    }
}

The Enclosure

The laser cut 1/8" acrylic enclosure. The bottom has a lip to which the top fastens. Four screws hold the enclosure to the focuser, and two screws fasten the two parts together. The signal cable plugs in the bottom of the enclosure.

I spent last night auto-focusing. It actually works. Pinpoint stars, like I get when I luck out and get it right, only no luck required. I spent about $120.00 on the upgrade, so instead of a $250.00 motorized focuser I have a $370.00 absolute position focuser.