From 17224a0b2bf9cec9693a84aa59987464d2e97086 Mon Sep 17 00:00:00 2001
From: rsteinberg83 <75146364+rsteinberg83@users.noreply.github.com>
Date: Wed, 2 Feb 2022 09:34:32 -0800
Subject: [PATCH] Auger Support added

---
 main.py                 | 273 ++++++++++++++++++++++++++++++++++++++++
 motors.py               | 145 +++++++++++++++++++++
 opentrickler_config.ini |  76 +++++++++++
 3 files changed, 494 insertions(+)
 create mode 100644 main.py
 create mode 100644 motors.py
 create mode 100644 opentrickler_config.ini

diff --git a/main.py b/main.py
new file mode 100644
index 0000000..ce5b523
--- /dev/null
+++ b/main.py
@@ -0,0 +1,273 @@
+#!/usr/bin/env python3
+"""
+Copyright (c) Ammolytics and contributors. All rights reserved.
+Released under the MIT license. See LICENSE file in the project root for details.
+
+OpenTrickler
+https://github.com/ammolytics/projects/tree/develop/trickler
+"""
+
+import datetime
+import decimal
+import logging
+import time
+
+import constants
+import helpers
+import PID
+import motors
+import scales
+
+
+# Components:
+# 0. Server (Pi)
+# 1. Scale (serial)
+# 2. Trickler (gpio/PWM)
+# 3. Dump (gpio/servo?)
+# 4. API
+# 6. Bluetooth?
+# 7: Powder pan/cup?
+
+# TODO
+# - document specific python version
+# - handle case where scale is booted with pan on -- shows error instead of negative value
+# - detect scale that's turned off (blank values)
+# - validate inputs (target weight)
+
+
+def auger_loop(memcache, auger_motor, scale, target_weight, target_unit, auger_dir, auger_speed, auger_steps):
+    logging.info('Starting auger motor...')
+
+    while 1:
+        # Stop running if auto mode is disabled.
+        if not memcache.get(constants.AUTO_MODE):
+            logging.debug('auto mode disabled.')
+            break
+
+        # Read scale values (weight/unit/stable)
+        scale.update()
+
+        # Stop running if scale's unit no longer matches target unit.
+        if scale.unit != target_unit:
+            logging.debug('Target unit does not match scale unit.')
+            break
+
+        # Stop running if pan removed.
+        if scale.weight < 0:
+            logging.debug('Pan removed.')
+            break
+
+        # Spin the Auger as defined by init file
+        auger_motor.moveSteps(memcache, auger_dir, auger_speed, auger_steps)
+
+        # Read scale values (weight/unit/stable)
+        scale.update()
+
+        remainder_weight = target_weight - scale.weight
+        logging.debug('remainder_weight: %r', remainder_weight)
+
+
+        # Augering complete.
+        if remainder_weight <= 0:
+            logging.debug('Augering complete, motor turned off.')
+            break
+
+
+    # Clean up tasks.
+    auger_motor.motorStop()
+    logging.info('Augering process stopped.')
+
+
+def trickler_loop(memcache, pid, trickler_motor, scale, target_weight, target_unit, pidtune_logger):
+    pidtune_logger.info('timestamp, input (motor %), output (weight %)')
+    logging.info('Starting trickling process...')
+
+    while 1:
+        # Stop running if auto mode is disabled.
+        if not memcache.get(constants.AUTO_MODE):
+            logging.debug('auto mode disabled.')
+            break
+
+        # Read scale values (weight/unit/stable)
+        scale.update()
+
+        # Stop running if scale's unit no longer matches target unit.
+        if scale.unit != target_unit:
+            logging.debug('Target unit does not match scale unit.')
+            break
+
+        # Stop running if pan removed.
+        if scale.weight < 0:
+            logging.debug('Pan removed.')
+            break
+
+        remainder_weight = target_weight - scale.weight
+        logging.debug('remainder_weight: %r', remainder_weight)
+
+        pidtune_logger.info(
+            '%s, %s, %s',
+            datetime.datetime.now().timestamp(),
+            trickler_motor.speed,
+            scale.weight / target_weight)
+
+        # Trickling complete.
+        if remainder_weight <= 0:
+            logging.debug('Trickling complete, motor turned off and PID reset.')
+            break
+
+        # PID controller requires float value instead of decimal.Decimal
+        pid.update(float(scale.weight / target_weight) * 100)
+        trickler_motor.update(pid.output)
+        logging.debug('trickler_motor.speed: %r, pid.output: %r', trickler_motor.speed, pid.output)
+        logging.info(
+            'remainder: %s %s scale: %s %s motor: %s',
+            remainder_weight,
+            target_unit,
+            scale.weight,
+            scale.unit,
+            trickler_motor.speed)
+
+    # Clean up tasks.
+    trickler_motor.off()
+    # Clear PID values.
+    pid.clear()
+    logging.info('Trickling process stopped.')
+
+
+def main(config, args, pidtune_logger):
+    memcache = helpers.get_mc_client()
+
+    pid = PID.PID(
+        float(config['PID']['Kp']),
+        float(config['PID']['Ki']),
+        float(config['PID']['Kd']))
+    logging.debug('pid: %r', pid)
+
+    trickler_motor = motors.TricklerMotor(
+        memcache=memcache,
+        motor_pin=int(config['motors']['trickler_pin']),
+        min_pwm=int(config['motors']['trickler_min_pwm']),
+        max_pwm=int(config['motors']['trickler_max_pwm']))
+    logging.debug('trickler_motor: %r', trickler_motor)
+ 
+    auger_in1=int(config['motors']['auger_in1'])
+    auger_in2=int(config['motors']['auger_in2'])
+    auger_in3=int(config['motors']['auger_in3'])
+    auger_in4=int(config['motors']['auger_in4'])
+    auger_dir=int(config['motors']['auger_dir'])
+    auger_speed=int(config['motors']['auger_speed'])
+    auger_steps=int(config['motors']['auger_steps'])
+    auger_lpct=int(config['motors']['auger_lpct'])
+    auger_rsteps=int(config['motors']['auger_rsteps'])
+    auger_wait=int(config['motors']['auger_wait'])
+
+    auger_motor = motors.AugerMotor(
+        memcache=memcache,
+        auger_in1=auger_in1,
+        auger_in2=auger_in2,
+        auger_in3=auger_in3,
+        auger_in4=auger_in4,
+        auger_dir=auger_dir)
+
+    if auger_dir == 0:
+        auger_rdir = 1
+    else:
+        auger_rdir = 0
+        
+    logging.debug('auger motor driver pins [in1 - in4]: %r %r %r %r', auger_in1,auger_in2,auger_in3,auger_in4,)
+    logging.debug('auger motor direction, reverse direction speed, step, load percent: %r %r %r %r %r', auger_dir, auger_rdir, auger_speed, auger_steps, auger_lpct)
+
+    #servo_motor = gpiozero.AngularServo(int(config['motors']['servo_pin']))
+
+    scale = scales.SCALES[config['scale']['model']](
+        memcache=memcache,
+        port=config['scale']['port'],
+        baudrate=int(config['scale']['baudrate']),
+        timeout=float(config['scale']['timeout']))
+    logging.debug('scale: %r', scale)
+
+    memcache.set(constants.AUTO_MODE, args.auto_mode or False)
+    memcache.set(constants.TARGET_WEIGHT, args.target_weight or decimal.Decimal('0.0'))
+    memcache.set(constants.TARGET_UNIT, scales.UNIT_MAP.get(args.target_unit, 'GN'))
+
+    while 1:
+        # Update settings.
+        auto_mode = memcache.get(constants.AUTO_MODE)
+        target_weight = memcache.get(constants.TARGET_WEIGHT)
+        target_unit = memcache.get(constants.TARGET_UNIT)
+        # Use percentages for PID control to avoid complexity w/ different units of weight.
+        pid.SetPoint = 100.0
+        scale.update()
+
+        # Set scale to match target unit.
+        if target_unit != scale.unit:
+            logging.info('scale.unit: %r, target_unit: %r', scale.unit, target_unit)
+            scale.change_unit()
+
+        logging.info(
+            'target: %s %s scale: %s %s auto_mode: %s',
+            target_weight,
+            target_unit,
+            scale.weight,
+            scale.unit,
+            auto_mode)
+
+        # Powder pan in place, scale stable, ready to trickle.
+        if (scale.weight >= 0 and
+                scale.weight < target_weight and
+                scale.unit == target_unit and
+                scale.is_stable and
+                auto_mode):
+            # Wait a second to start trickling.
+            time.sleep(1)
+            
+            # If reverse cycle is wanted...
+            if auger_rsteps > 0 :
+                auger_motor.moveSteps(memcache, auger_rdir, auger_speed, auger_rsteps)
+                time.sleep(.250)
+                auger_motor.motorStop()
+                time.sleep(.250)
+
+            # Run Auger loop to auger_lpct
+            atarget_weight = int(float(target_weight) * (0.01 * float(auger_lpct)) )
+            logging.debug('Starting auger process - target weight: %r',  atarget_weight)
+            auger_loop(memcache, auger_motor, scale, atarget_weight, target_unit, auger_dir, auger_speed, auger_steps)
+
+            # Wait a bit
+            time.sleep(auger_wait *.001)
+
+            # Run trickler loop.
+            trickler_loop(memcache, pid, trickler_motor, scale, target_weight, target_unit, pidtune_logger)
+
+
+if __name__ == '__main__':
+    import argparse
+    import configparser
+
+    parser = argparse.ArgumentParser(description='Run OpenTrickler.')
+    parser.add_argument('config_file')
+    parser.add_argument('--verbose', action='store_true')
+    parser.add_argument('--auto_mode', action='store_true')
+    parser.add_argument('--pid_tune', action='store_true')
+    parser.add_argument('--target_weight', type=decimal.Decimal, default=0)
+    parser.add_argument('--target_unit', choices=scales.UNIT_MAP.keys(), default='GN')
+    args = parser.parse_args()
+
+    config = configparser.ConfigParser()
+    config.read_file(open(args.config_file))
+
+    log_level = logging.INFO
+    if args.verbose or config['general'].getboolean('verbose'):
+        log_level = logging.DEBUG
+
+    helpers.setup_logging(log_level)
+
+    pidtune_logger = logging.getLogger('pid_tune')
+    pid_handler = logging.StreamHandler()
+    pid_handler.setFormatter(logging.Formatter('%(message)s'))
+
+    pidtune_logger.setLevel(logging.ERROR)
+    if args.pid_tune or config['PID'].getboolean('pid_tuner_mode'):
+        pidtune_logger.setLevel(logging.INFO)
+
+    main(config, args, pidtune_logger)
diff --git a/motors.py b/motors.py
new file mode 100644
index 0000000..9340716
--- /dev/null
+++ b/motors.py
@@ -0,0 +1,145 @@
+#!/usr/bin/env python3
+"""
+Copyright (c) Ammolytics and contributors. All rights reserved.
+Released under the MIT license. See LICENSE file in the project root for details.
+
+OpenTrickler
+https://github.com/ammolytics/projects/tree/develop/trickler
+"""
+
+import atexit
+import logging
+
+import RPi.GPIO as GPIO
+import gpiozero # pylint: disable=import-error;
+
+import time 
+import constants
+
+
+
+class AugerMotor:
+    """Controls a stepper motor on the Pi."""
+
+
+    def __init__(self, memcache, auger_in1 = 22, auger_in2 = 23, auger_in3 = 24, auger_in4 = 25, auger_dir = 0):
+        """Constructor."""
+        self._memcache = memcache
+        logging.debug ('Init Auger Motor')
+        #GPIO.setmode(GPIO.BOARD)       # Numbers GPIOs by physical location
+        self.motorPins = (auger_in1,auger_in2,auger_in3,auger_in4)
+        for pin in self.motorPins:
+            GPIO.setup(pin,GPIO.OUT)
+        self.auger_dir = auger_dir
+        atexit.register(self._graceful_exit)
+
+    def _graceful_exit(self):
+        """Graceful exit function, turn off motor and close GPIO pin."""
+        logging.debug('Closing AUGER motor...')
+
+    def moveOnePeriod(self, direction,ms):    
+        self.CCWStep = (0x01,0x02,0x04,0x08)
+        self.CWStep = (0x08,0x04,0x02,0x01)
+        for j in range(0,4,1):      #cycle for power supply order
+            for i in range(0,4,1):  #assign to each pin, a total of 4 pins
+                if (direction == 1):#power supply order clockwise
+                    GPIO.output(self.motorPins[i],((self.CCWStep[j] == 1<<i) and GPIO.HIGH or GPIO.LOW))
+                else :              #power supply order anticlockwise
+                    GPIO.output(self.motorPins[i],((self.CWStep[j] == 1<<i) and GPIO.HIGH or GPIO.LOW))
+            if(ms<0):       #the delay can not be less than 3ms, otherwise it will exceed speed limit of the motor
+                ms = 0
+            time.sleep(ms*0.001)    
+
+    def moveSteps(self, memcache, direction, ms, steps):
+        for i in range(steps):
+            # Stop running if auto mode is disabled.
+            if not memcache.get(constants.AUTO_MODE):
+                logging.debug('auto mode disabled.')
+                break            
+            self.moveOnePeriod(direction, ms)
+
+    def motorStop(self):
+        for i in range(0,4,1):
+            GPIO.output(self.motorPins[i],GPIO.LOW)
+
+
+class TricklerMotor:
+    """Controls a small vibration DC motor with the PWM controller on the Pi."""
+
+    def __init__(self, memcache, motor_pin=18, min_pwm=15, max_pwm=100):
+        """Constructor."""
+        self._memcache = memcache
+        self.pwm = gpiozero.PWMOutputDevice(motor_pin)
+        self.min_pwm = min_pwm
+        self.max_pwm = max_pwm
+        logging.debug(
+            'Created pwm motor on PIN %r with min %r and max %r: %r',
+            motor_pin,
+            self.min_pwm,
+            self.max_pwm,
+            self.pwm)
+        atexit.register(self._graceful_exit)
+
+    def _graceful_exit(self):
+        """Graceful exit function, turn off motor and close GPIO pin."""
+        logging.debug('Closing trickler motor...')
+        self.pwm.off()
+        self.pwm.close()
+
+    def update(self, target_pwm):
+        """Change PWM speed of motor (int), enforcing clamps."""
+        logging.debug('Updating target_pwm to %r', target_pwm)
+        target_pwm = max(min(int(target_pwm), self.max_pwm), self.min_pwm)
+        logging.debug('Adjusted clamped target_pwm to %r', target_pwm)
+        self.set_speed(target_pwm / 100)
+
+    def set_speed(self, speed):
+        """Sets the PWM speed (float) and circumvents any clamps."""
+        # TODO(eric): must be 0 - 1.
+        logging.debug('Setting speed from %r to %r', self.speed, speed)
+        self.pwm.value = speed
+        self._memcache.set(constants.TRICKLER_MOTOR_SPEED, self.speed)
+
+    def off(self):
+        """Turns motor off."""
+        self.set_speed(0)
+
+    @property
+    def speed(self):
+        """Returns motor speed (float)."""
+        return self.pwm.value
+
+
+
+if __name__ == '__main__':
+    import argparse
+    import time
+
+    import helpers
+
+    parser = argparse.ArgumentParser(description='Test motors.')
+    parser.add_argument('--trickler_motor_pin', type=int, default=18)
+    #parser.add_argument('--servo_motor_pin', type=int)
+    parser.add_argument('--max_pwm', type=float, default=100)
+    parser.add_argument('--min_pwm', type=float, default=15)
+    args = parser.parse_args()
+
+    memcache_client = helpers.get_mc_client()
+
+    helpers.setup_logging()
+
+    motor = TricklerMotor(
+        memcache=memcache_client,
+        motor_pin=args.trickler_motor_pin,
+        min_pwm=args.min_pwm,
+        max_pwm=args.max_pwm)
+    print('Spinning up trickler motor in 3 seconds...')
+    time.sleep(3)
+    for x in range(1, 101):
+        motor.set_speed(x / 100)
+        time.sleep(.05)
+    for x in range(100, 0, -1):
+        motor.set_speed(x / 100)
+        time.sleep(.05)
+    motor.off()
+    print('Done.')
diff --git a/opentrickler_config.ini b/opentrickler_config.ini
new file mode 100644
index 0000000..209a8d2
--- /dev/null
+++ b/opentrickler_config.ini
@@ -0,0 +1,76 @@
+[general]
+verbose = True
+
+
+[bluetooth]
+name = Trickler
+
+
+[scale]
+# See the SCALES variable in scales.py for options.
+model = and-fx120
+port = /dev/ttyUSB0
+baudrate = 19200
+timeout = 0.1
+
+
+[motors]
+trickler_pin = 18
+trickler_max_pwm = 100
+trickler_min_pwm = 32
+
+# Auger Motor Drive Pins
+auger_in1 = 22
+auger_in2 = 23
+auger_in3 = 24
+auger_in4 = 25
+
+# Auger Direction 0 = CCW, 1 = CW  
+auger_dir = 0
+# Auger Speed - mSec between steps, min = 3 / the higher the slower the motor spins
+auger_speed = 3 
+# Auger Step - The number of steps at drive intervals, 512 steps = One rotation
+auger_steps = 32
+# Auger Load Percent 
+auger_lpct = 80
+# Auger Reverse Steps - Number of steps to drive in reverse before going forward with auger
+auger_rsteps = 10
+# Auger Wait = Wait time between auger stop and tricker stop
+auger_wait = 2000
+
+#servo_pin = 
+
+[leds]
+# https://gpiozero.readthedocs.io/en/stable/recipes_advanced.html#controlling-the-pi-s-own-leds
+status_led_pin = 47
+active_high = False
+enable_status_leds = True
+ready_status_led_mode = pulse
+running_status_led_mode = on
+done_status_led_mode = fast_blink
+
+
+[PID]
+# Higher Kp values will:
+# - decrease rise time
+# - increase overshoot
+# - slightly increase settling time
+# - decrease steady-state error
+# - degrade stability
+Kp = 10
+# Higher Ki values will:
+# - slightly decrease rise time
+# - increase overshoot
+# - increase settling time
+# - largely decrease steady-state error
+# - degrade stability
+Ki = 2.3
+# Higher Kd values will:
+# - slightly decrease rise time
+# - decrease overshoot
+# - decrease settling time
+# - minorly affect steady-state error
+# - improve stability
+Kd = 3.75
+# Enable for use with pidtuner.com
+pid_tuner_mode = False