adds code from: https://github.com/JeroenDelcour/spincoater without mods
This commit is contained in:
parent
5551bbda50
commit
dd2a5bf637
0
src/boot.py
Normal file
0
src/boot.py
Normal file
10
src/config.json
Normal file
10
src/config.json
Normal file
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"PID": {
|
||||
"Kp": 1e-5,
|
||||
"Ki": 1e-4,
|
||||
"Kd": 0
|
||||
},
|
||||
"deposit_rpm": 1000,
|
||||
"coating_rpm": 2000,
|
||||
"coating_time": 10
|
||||
}
|
113
src/dshot.py
Normal file
113
src/dshot.py
Normal file
|
@ -0,0 +1,113 @@
|
|||
import time
|
||||
|
||||
from esp32 import RMT
|
||||
|
||||
|
||||
class Dshot:
|
||||
def __init__(self, pin):
|
||||
# https://brushlesswhoop.com/dshot-and-bidirectional-dshot/
|
||||
# clock freq = 80 # Mhz
|
||||
# clock divider = 7
|
||||
# pulse time = 1 / (80 / 7) = 0.0875 us
|
||||
# bit duration = 3.33 us
|
||||
# T1H = 2.50 us
|
||||
# T0H = 1.25 us
|
||||
self._ON_PULSES = 57 # number of pulses for an ON bit
|
||||
self._OFF_PULSES = 29 # number of pulses for an OFF bit
|
||||
self._BIT_PULSES = 76 # total number of pulses per bit
|
||||
self._PAUSE_PULSES = 21 # pause after sending packet
|
||||
|
||||
self.rmt = RMT(0, pin=pin, clock_div=7)
|
||||
|
||||
self._arm()
|
||||
self._enable_telemetry()
|
||||
|
||||
def _arm(self, duration=2):
|
||||
"""
|
||||
Send arming sequence.
|
||||
|
||||
Args:
|
||||
duration: How long to send the arming sequence, in seconds.
|
||||
"""
|
||||
t0 = time.time()
|
||||
while time.time() - t0 < duration:
|
||||
self.set_throttle(0)
|
||||
# time.sleep(0.01)
|
||||
|
||||
def _enable_telemetry(self):
|
||||
for i in range(6):
|
||||
value = self._create_packet(33)
|
||||
self._send(value)
|
||||
for i in range(6):
|
||||
value = self._create_packet(34)
|
||||
self._send(value)
|
||||
for i in range(6):
|
||||
value = self._create_packet(35)
|
||||
self._send(value)
|
||||
for i in range(6):
|
||||
value = self._create_packet(12)
|
||||
self._send(value)
|
||||
time.sleep(0.040)
|
||||
|
||||
# for i in range(10):
|
||||
# self._send(self._create_packet(6))
|
||||
# time.sleep(0.012)
|
||||
|
||||
# self._send(self._create_packet(42))
|
||||
# self._send(self._create_packet(43))
|
||||
# self._send(self._create_packet(44))
|
||||
# self._send(self._create_packet(45))
|
||||
# self._send(self._create_packet(46))
|
||||
# self._send(self._create_packet(47))
|
||||
|
||||
# beep
|
||||
# time.sleep(0.050)
|
||||
# for i in range(5):
|
||||
# self._send(self._create_packet(i + 1))
|
||||
# time.sleep(0.250)
|
||||
|
||||
def _encode_throttle(self, value):
|
||||
# 11 bit throttle: 2048 possible values.
|
||||
# 0 is reserved for disarmed. 1-47 are reserved for special commands.
|
||||
# Leaving 48 to 2047 (2000 steps) for the actual throttle value
|
||||
value = min(max(value, 0.0), 1.0) # clamp to between 0 and 1
|
||||
if value == 0:
|
||||
value = 0 # disarmed
|
||||
else:
|
||||
value = int(value * 1999 + 48)
|
||||
return value
|
||||
|
||||
def set_throttle(self, value: float, telemetry=True):
|
||||
"""
|
||||
Set throttle to a value between 0 and 1.
|
||||
"""
|
||||
value = self._encode_throttle(value)
|
||||
value = self._create_packet(value)
|
||||
self._send(value)
|
||||
|
||||
def _create_packet(self, value, telemetry=True):
|
||||
# add telemetry bit
|
||||
value = (value << 1) | telemetry
|
||||
|
||||
# add CRC (Cyclic Redundancy Check)
|
||||
crc = (value ^ (value >> 4) ^ (value >> 8)) & 0x0F
|
||||
value = (value << 4) | crc
|
||||
|
||||
return value
|
||||
|
||||
def _send(self, value):
|
||||
"""
|
||||
Send value to ESC.
|
||||
"""
|
||||
duration = []
|
||||
|
||||
for i in reversed(range(16)):
|
||||
bit = (value & (2**i)) == 2**i # select bit
|
||||
if bit == 1:
|
||||
duration += [self._ON_PULSES, self._BIT_PULSES - self._ON_PULSES]
|
||||
else:
|
||||
duration += [self._OFF_PULSES, self._BIT_PULSES - self._OFF_PULSES]
|
||||
|
||||
duration[-1] += self._PAUSE_PULSES
|
||||
|
||||
self.rmt.write_pulses(duration, True)
|
299
src/main.py
Normal file
299
src/main.py
Normal file
|
@ -0,0 +1,299 @@
|
|||
from machine import Pin, I2C, Timer, UART
|
||||
import uasyncio
|
||||
import json
|
||||
|
||||
import ssd1306
|
||||
from rotary_irq_esp import RotaryIRQ
|
||||
from dshot import Dshot
|
||||
from pid import PID
|
||||
|
||||
|
||||
def splash():
|
||||
# display.fill(0)
|
||||
# display.fill_rect(0, 0, 32, 32, 1)
|
||||
# display.fill_rect(2, 2, 28, 28, 0)
|
||||
# display.vline(9, 8, 22, 1)
|
||||
# display.vline(16, 2, 22, 1)
|
||||
# display.vline(23, 8, 22, 1)
|
||||
# display.fill_rect(26, 24, 2, 4, 1)
|
||||
# display.text("MicroPython", 40, 0, 1)
|
||||
# display.text("SSD1306", 40, 12, 1)
|
||||
# display.text("OLED 128x64", 40, 24, 1)
|
||||
|
||||
display.fill(0)
|
||||
display.text("SPIN", 34, 4, 1)
|
||||
display.text("COATER", 50, 14, 1)
|
||||
|
||||
display.show()
|
||||
|
||||
|
||||
def start_view(state, rotary):
|
||||
display.text("SPIN", 34, 4, 1)
|
||||
display.text("COATER", 50, 14, 1)
|
||||
|
||||
before = "> " if rotary.value() == 0 else " "
|
||||
display.text(before + "Edit", 12, 36, 1)
|
||||
before = "> " if rotary.value() == 1 else " "
|
||||
display.text(before + "Start", 12, 46, 1)
|
||||
|
||||
|
||||
def draw_edit_menu(state, rotary):
|
||||
display.text("Deposit speed:", 0, 0, 1)
|
||||
display.text("{: >{w}} RPM".format(config["deposit_rpm"], w=5), 56, 10, 1)
|
||||
display.text("Coating speed:", 0, 21, 1)
|
||||
display.text("{: >{w}} RPM".format(config["coating_rpm"], w=5), 56, 31, 1)
|
||||
display.text("Coating time:", 0, 42, 1)
|
||||
display.text("{: >{w}} sec".format(config["coating_time"], w=5), 56, 52, 1)
|
||||
|
||||
|
||||
def edit_deposit_view(state, rotary):
|
||||
config["deposit_rpm"] = rotary.value() * 100
|
||||
draw_edit_menu(state, rotary)
|
||||
display.text(">", 40, 10, 1)
|
||||
|
||||
|
||||
def edit_coating_rpm_view(state, rotary):
|
||||
config["coating_rpm"] = rotary.value() * 100
|
||||
draw_edit_menu(state, rotary)
|
||||
display.text(">", 40, 32, 1)
|
||||
|
||||
|
||||
def edit_coating_time_view(state, rotary):
|
||||
config["coating_time"] = rotary.value()
|
||||
draw_edit_menu(state, rotary)
|
||||
display.text(">", 40, 54, 1)
|
||||
|
||||
|
||||
def draw_rpm(rpm):
|
||||
display.text("RPM:{: >{w}.0f}".format(rpm, w=5), 30, 27, 1)
|
||||
|
||||
|
||||
def deposit_view(state, rotary):
|
||||
display.fill_rect(0, 0, 127, 14, 1)
|
||||
display.text("Deposit", 36, 3, 0)
|
||||
draw_rpm(state["rpm"])
|
||||
display.text("Press to", 32, 42, 1)
|
||||
display.text("continue", 32, 52, 1)
|
||||
|
||||
|
||||
def coating_view(state, rotary):
|
||||
display.fill_rect(0, 0, 127, 14, 1)
|
||||
display.text("Coating", 36, 3, 0)
|
||||
draw_rpm(state["rpm"])
|
||||
display.text("{: >{w}} sec".format(state["timer"], w=4), 30, 48, 1)
|
||||
|
||||
|
||||
def decode_ESC_telemetry(data, motor_poles=14):
|
||||
if len(data) > 10:
|
||||
# use latest telemetry
|
||||
data = data[-10:]
|
||||
|
||||
temperature = int(data[0]) # degrees Celsius
|
||||
voltage = int((data[1] << 8) | data[2]) * 0.01 # Volt
|
||||
current = (
|
||||
int((data[3] << 8) | data[4]) * 0.01
|
||||
) # Amps, only available if the ESC has a current meter
|
||||
consumption = int(
|
||||
(data[5] << 8) | data[6]
|
||||
) # mAh, only available if the ESC has a current meter
|
||||
erpm = int((data[7] << 8) | data[8]) * 100
|
||||
rpm = erpm / (motor_poles / 2)
|
||||
crc = data[9]
|
||||
|
||||
# print(" Temp (C):", temperature)
|
||||
# print(" Voltage (V):", voltage)
|
||||
# print(" Current (A):", current)
|
||||
# print("Consumption (mAh):", consumption)
|
||||
# print(" Erpm:", erpm)
|
||||
# print(" RPM:", rpm)
|
||||
# print(" CRC:", crc)
|
||||
# print()
|
||||
|
||||
return temperature, voltage, current, consumption, erpm, rpm
|
||||
|
||||
|
||||
async def update_display():
|
||||
global state
|
||||
global rotary
|
||||
while True:
|
||||
display.fill(0)
|
||||
state["view"](state, rotary)
|
||||
display.show()
|
||||
await uasyncio.sleep_ms(33)
|
||||
|
||||
|
||||
async def update_motor():
|
||||
global state
|
||||
dshot = Dshot(pin=Pin(18))
|
||||
rpm_pid = PID(
|
||||
Kp=config["PID"]["Kp"],
|
||||
Ki=config["PID"]["Ki"],
|
||||
Kd=config["PID"]["Kd"],
|
||||
setpoint=0,
|
||||
sample_time=None,
|
||||
output_limits=(0.0, 1.0),
|
||||
# proportional_on_measurement=True,
|
||||
)
|
||||
while True:
|
||||
rpm_pid.setpoint = state["target_rpm"]
|
||||
|
||||
# read ESC telemetry
|
||||
if uart.any() >= 10:
|
||||
telemetry = decode_ESC_telemetry(uart.read())
|
||||
if telemetry is not None:
|
||||
state["rpm"] = telemetry[5]
|
||||
throttle = rpm_pid(state["rpm"])
|
||||
# print(
|
||||
# "Throttle:",
|
||||
# throttle,
|
||||
# "pid components:",
|
||||
# rpm_pid.components,
|
||||
# "RPM:",
|
||||
# state["rpm"],
|
||||
# )
|
||||
|
||||
if state["target_rpm"] == 0 and state["rpm"] < 1000:
|
||||
throttle = 0
|
||||
rpm_pid.reset()
|
||||
dshot.set_throttle(throttle)
|
||||
|
||||
await uasyncio.sleep_ms(1)
|
||||
|
||||
|
||||
def debounce_button(p):
|
||||
p.irq(trigger=Pin.IRQ_FALLING, handler=None) # remove irq
|
||||
timer0 = Timer(0)
|
||||
timer0.init(period=20, mode=Timer.ONE_SHOT, callback=lambda t: on_button_press(p))
|
||||
|
||||
|
||||
def on_button_press(p):
|
||||
p.irq(trigger=Pin.IRQ_FALLING, handler=debounce_button) # restore irq
|
||||
if p.value() == 1: # debounce
|
||||
return
|
||||
global state
|
||||
global config
|
||||
global rotary
|
||||
if state["view"] == start_view:
|
||||
if rotary.value() == 0:
|
||||
state["view"] = edit_deposit_view
|
||||
rotary.set(
|
||||
min_val=0,
|
||||
max_val=1000,
|
||||
range_mode=RotaryIRQ.RANGE_BOUNDED,
|
||||
value=int(0.01 * config["deposit_rpm"]),
|
||||
)
|
||||
return
|
||||
if rotary.value() == 1:
|
||||
state["view"] = deposit_view
|
||||
state["target_rpm"] = config["deposit_rpm"]
|
||||
return
|
||||
if state["view"] == edit_deposit_view:
|
||||
state["view"] = edit_coating_rpm_view
|
||||
rotary.set(
|
||||
min_val=0,
|
||||
max_val=1000,
|
||||
range_mode=RotaryIRQ.RANGE_BOUNDED,
|
||||
value=int(0.01 * config["coating_rpm"]),
|
||||
)
|
||||
return
|
||||
if state["view"] == edit_coating_rpm_view:
|
||||
state["view"] = edit_coating_time_view
|
||||
rotary.set(
|
||||
min_val=0,
|
||||
max_val=9999,
|
||||
range_mode=RotaryIRQ.RANGE_BOUNDED,
|
||||
value=config["coating_time"],
|
||||
)
|
||||
return
|
||||
if state["view"] == edit_coating_time_view:
|
||||
save_config()
|
||||
rotary.set(min_val=0, max_val=1, range_mode=RotaryIRQ.RANGE_BOUNDED, value=0)
|
||||
state["view"] = start_view
|
||||
return
|
||||
if state["view"] == deposit_view:
|
||||
state["view"] = coating_view
|
||||
start_coating(state)
|
||||
return
|
||||
if state["view"] == coating_view:
|
||||
stop_coating()
|
||||
return
|
||||
|
||||
|
||||
def start_coating(state):
|
||||
global timer1
|
||||
global timer2
|
||||
|
||||
state["timer"] = config["coating_time"]
|
||||
|
||||
timer1.init(
|
||||
period=config["coating_time"] * 1000,
|
||||
mode=Timer.ONE_SHOT,
|
||||
callback=lambda t: stop_coating(),
|
||||
)
|
||||
|
||||
def decrement_timer(t):
|
||||
state["timer"] -= 1
|
||||
|
||||
timer2.init(period=1000, mode=Timer.PERIODIC, callback=decrement_timer)
|
||||
|
||||
# state["throttle"] = 0.10
|
||||
state["target_rpm"] = config["coating_rpm"]
|
||||
|
||||
|
||||
def stop_coating():
|
||||
global state
|
||||
global rotary
|
||||
global timer1
|
||||
global timer2
|
||||
timer1.deinit()
|
||||
timer2.deinit()
|
||||
state["target_rpm"] = 0
|
||||
rotary.set(min_val=0, max_val=1, range_mode=RotaryIRQ.RANGE_BOUNDED, value=0)
|
||||
state["view"] = start_view
|
||||
|
||||
|
||||
def save_config():
|
||||
global config
|
||||
with open("config.json", "w") as f:
|
||||
json.dump(config, f)
|
||||
|
||||
|
||||
# using default address 0x3c
|
||||
i2c = I2C(1, sda=Pin(21), scl=Pin(22))
|
||||
display = ssd1306.SSD1306_I2C(128, 64, i2c)
|
||||
display.rotate(0)
|
||||
|
||||
timer1 = Timer(1)
|
||||
timer2 = Timer(2)
|
||||
|
||||
|
||||
splash()
|
||||
|
||||
rotary = RotaryIRQ(
|
||||
pin_num_clk=14,
|
||||
pin_num_dt=13,
|
||||
min_val=0,
|
||||
max_val=1,
|
||||
range_mode=RotaryIRQ.RANGE_BOUNDED,
|
||||
pull_up=True,
|
||||
)
|
||||
|
||||
button = Pin(19, Pin.IN, Pin.PULL_UP)
|
||||
button.irq(trigger=Pin.IRQ_FALLING, handler=on_button_press)
|
||||
|
||||
uart = UART(1, baudrate=115200, rx=5) # to receive ESC telemetry
|
||||
|
||||
state = {
|
||||
"view": start_view,
|
||||
"rpm": 0,
|
||||
"target_rpm": 0,
|
||||
"timer": 0,
|
||||
}
|
||||
|
||||
with open("config.json", "r") as f:
|
||||
config = json.load(f)
|
||||
|
||||
event_loop = uasyncio.get_event_loop()
|
||||
event_loop.create_task(update_display())
|
||||
event_loop.create_task(update_motor())
|
||||
event_loop.run_forever()
|
256
src/pid.py
Normal file
256
src/pid.py
Normal file
|
@ -0,0 +1,256 @@
|
|||
# adapted from https://github.com/m-lundberg/simple-pid
|
||||
|
||||
|
||||
import time
|
||||
|
||||
|
||||
def _clamp(value, limits):
|
||||
lower, upper = limits
|
||||
if value is None:
|
||||
return None
|
||||
elif (upper is not None) and (value > upper):
|
||||
return upper
|
||||
elif (lower is not None) and (value < lower):
|
||||
return lower
|
||||
return value
|
||||
|
||||
|
||||
class PID(object):
|
||||
"""A simple PID controller."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
Kp=1.0,
|
||||
Ki=0.0,
|
||||
Kd=0.0,
|
||||
setpoint=0,
|
||||
sample_time=0.01,
|
||||
output_limits=(None, None),
|
||||
auto_mode=True,
|
||||
proportional_on_measurement=False,
|
||||
differetial_on_measurement=True,
|
||||
error_map=None,
|
||||
):
|
||||
"""
|
||||
Initialize a new PID controller.
|
||||
|
||||
:param Kp: The value for the proportional gain Kp
|
||||
:param Ki: The value for the integral gain Ki
|
||||
:param Kd: The value for the derivative gain Kd
|
||||
:param setpoint: The initial setpoint that the PID will try to achieve
|
||||
:param sample_time: The time in seconds which the controller should wait before generating
|
||||
a new output value. The PID works best when it is constantly called (eg. during a
|
||||
loop), but with a sample time set so that the time difference between each update is
|
||||
(close to) constant. If set to None, the PID will compute a new output value every time
|
||||
it is called.
|
||||
:param output_limits: The initial output limits to use, given as an iterable with 2
|
||||
elements, for example: (lower, upper). The output will never go below the lower limit
|
||||
or above the upper limit. Either of the limits can also be set to None to have no limit
|
||||
in that direction. Setting output limits also avoids integral windup, since the
|
||||
integral term will never be allowed to grow outside of the limits.
|
||||
:param auto_mode: Whether the controller should be enabled (auto mode) or not (manual mode)
|
||||
:param proportional_on_measurement: Whether the proportional term should be calculated on
|
||||
the input directly rather than on the error (which is the traditional way). Using
|
||||
proportional-on-measurement avoids overshoot for some types of systems.
|
||||
:param differetial_on_measurement: Whether the differential term should be calculated on
|
||||
the input directly rather than on the error (which is the traditional way).
|
||||
:param error_map: Function to transform the error value in another constrained value.
|
||||
"""
|
||||
self.Kp, self.Ki, self.Kd = Kp, Ki, Kd
|
||||
self.setpoint = setpoint
|
||||
self.sample_time = sample_time
|
||||
|
||||
self._min_output, self._max_output = None, None
|
||||
self._auto_mode = auto_mode
|
||||
self.proportional_on_measurement = proportional_on_measurement
|
||||
self.differetial_on_measurement = differetial_on_measurement
|
||||
self.error_map = error_map
|
||||
|
||||
self._proportional = 0
|
||||
self._integral = 0
|
||||
self._derivative = 0
|
||||
|
||||
self._last_time = None
|
||||
self._last_output = None
|
||||
self._last_error = None
|
||||
self._last_input = None
|
||||
|
||||
# try:
|
||||
# # Get monotonic time to ensure that time deltas are always positive
|
||||
# self.time_fn = time.monotonic
|
||||
# except AttributeError:
|
||||
# # time.monotonic() not available (using python < 3.3), fallback to time.time()
|
||||
# self.time_fn = time.time
|
||||
self.time_fn = lambda: 0.001 * time.ticks_ms()
|
||||
|
||||
self.output_limits = output_limits
|
||||
self.reset()
|
||||
|
||||
def __call__(self, input_, dt=None):
|
||||
"""
|
||||
Update the PID controller.
|
||||
|
||||
Call the PID controller with *input_* and calculate and return a control output if
|
||||
sample_time seconds has passed since the last update. If no new output is calculated,
|
||||
return the previous output instead (or None if no value has been calculated yet).
|
||||
|
||||
:param dt: If set, uses this value for timestep instead of real time. This can be used in
|
||||
simulations when simulation time is different from real time.
|
||||
"""
|
||||
if not self.auto_mode:
|
||||
return self._last_output
|
||||
|
||||
now = self.time_fn()
|
||||
if dt is None:
|
||||
dt = now - self._last_time if (now - self._last_time) else 1e-16
|
||||
elif dt <= 0:
|
||||
raise ValueError('dt has negative value {}, must be positive'.format(dt))
|
||||
|
||||
if self.sample_time is not None and dt < self.sample_time and self._last_output is not None:
|
||||
# Only update every sample_time seconds
|
||||
return self._last_output
|
||||
|
||||
# Compute error terms
|
||||
error = self.setpoint - input_
|
||||
d_input = input_ - (self._last_input if (self._last_input is not None) else input_)
|
||||
d_error = error - (self._last_error if (self._last_error is not None) else error)
|
||||
|
||||
# Check if must map the error
|
||||
if self.error_map is not None:
|
||||
error = self.error_map(error)
|
||||
|
||||
# Compute the proportional term
|
||||
if not self.proportional_on_measurement:
|
||||
# Regular proportional-on-error, simply set the proportional term
|
||||
self._proportional = self.Kp * error
|
||||
else:
|
||||
# Add the proportional error on measurement to error_sum
|
||||
self._proportional -= self.Kp * d_input
|
||||
|
||||
# Compute integral and derivative terms
|
||||
self._integral += self.Ki * error * dt
|
||||
self._integral = _clamp(self._integral, self.output_limits) # Avoid integral windup
|
||||
|
||||
if self.differetial_on_measurement:
|
||||
self._derivative = -self.Kd * d_input / dt
|
||||
else:
|
||||
self._derivative = self.Kd * d_error / dt
|
||||
|
||||
# Compute final output
|
||||
output = self._proportional + self._integral + self._derivative
|
||||
output = _clamp(output, self.output_limits)
|
||||
|
||||
# Keep track of state
|
||||
self._last_output = output
|
||||
self._last_input = input_
|
||||
self._last_error = error
|
||||
self._last_time = now
|
||||
|
||||
return output
|
||||
|
||||
def __repr__(self):
|
||||
return (
|
||||
'{self.__class__.__name__}('
|
||||
'Kp={self.Kp!r}, Ki={self.Ki!r}, Kd={self.Kd!r}, '
|
||||
'setpoint={self.setpoint!r}, sample_time={self.sample_time!r}, '
|
||||
'output_limits={self.output_limits!r}, auto_mode={self.auto_mode!r}, '
|
||||
'proportional_on_measurement={self.proportional_on_measurement!r}, '
|
||||
'differetial_on_measurement={self.differetial_on_measurement!r}, '
|
||||
'error_map={self.error_map!r}'
|
||||
')'
|
||||
).format(self=self)
|
||||
|
||||
@property
|
||||
def components(self):
|
||||
"""
|
||||
The P-, I- and D-terms from the last computation as separate components as a tuple. Useful
|
||||
for visualizing what the controller is doing or when tuning hard-to-tune systems.
|
||||
"""
|
||||
return self._proportional, self._integral, self._derivative
|
||||
|
||||
@property
|
||||
def tunings(self):
|
||||
"""The tunings used by the controller as a tuple: (Kp, Ki, Kd)."""
|
||||
return self.Kp, self.Ki, self.Kd
|
||||
|
||||
@tunings.setter
|
||||
def tunings(self, tunings):
|
||||
"""Set the PID tunings."""
|
||||
self.Kp, self.Ki, self.Kd = tunings
|
||||
|
||||
@property
|
||||
def auto_mode(self):
|
||||
"""Whether the controller is currently enabled (in auto mode) or not."""
|
||||
return self._auto_mode
|
||||
|
||||
@auto_mode.setter
|
||||
def auto_mode(self, enabled):
|
||||
"""Enable or disable the PID controller."""
|
||||
self.set_auto_mode(enabled)
|
||||
|
||||
def set_auto_mode(self, enabled, last_output=None):
|
||||
"""
|
||||
Enable or disable the PID controller, optionally setting the last output value.
|
||||
|
||||
This is useful if some system has been manually controlled and if the PID should take over.
|
||||
In that case, disable the PID by setting auto mode to False and later when the PID should
|
||||
be turned back on, pass the last output variable (the control variable) and it will be set
|
||||
as the starting I-term when the PID is set to auto mode.
|
||||
|
||||
:param enabled: Whether auto mode should be enabled, True or False
|
||||
:param last_output: The last output, or the control variable, that the PID should start
|
||||
from when going from manual mode to auto mode. Has no effect if the PID is already in
|
||||
auto mode.
|
||||
"""
|
||||
if enabled and not self._auto_mode:
|
||||
# Switching from manual mode to auto, reset
|
||||
self.reset()
|
||||
|
||||
self._integral = last_output if (last_output is not None) else 0
|
||||
self._integral = _clamp(self._integral, self.output_limits)
|
||||
|
||||
self._auto_mode = enabled
|
||||
|
||||
@property
|
||||
def output_limits(self):
|
||||
"""
|
||||
The current output limits as a 2-tuple: (lower, upper).
|
||||
|
||||
See also the *output_limits* parameter in :meth:`PID.__init__`.
|
||||
"""
|
||||
return self._min_output, self._max_output
|
||||
|
||||
@output_limits.setter
|
||||
def output_limits(self, limits):
|
||||
"""Set the output limits."""
|
||||
if limits is None:
|
||||
self._min_output, self._max_output = None, None
|
||||
return
|
||||
|
||||
min_output, max_output = limits
|
||||
|
||||
if (None not in limits) and (max_output < min_output):
|
||||
raise ValueError('lower limit must be less than upper limit')
|
||||
|
||||
self._min_output = min_output
|
||||
self._max_output = max_output
|
||||
|
||||
self._integral = _clamp(self._integral, self.output_limits)
|
||||
self._last_output = _clamp(self._last_output, self.output_limits)
|
||||
|
||||
def reset(self):
|
||||
"""
|
||||
Reset the PID controller internals.
|
||||
|
||||
This sets each term to 0 as well as clearing the integral, the last output and the last
|
||||
input (derivative calculation).
|
||||
"""
|
||||
self._proportional = 0
|
||||
self._integral = 0
|
||||
self._derivative = 0
|
||||
|
||||
self._integral = _clamp(self._integral, self.output_limits)
|
||||
|
||||
self._last_time = self.time_fn()
|
||||
self._last_output = None
|
||||
self._last_input = None
|
170
src/rotary.py
Normal file
170
src/rotary.py
Normal file
|
@ -0,0 +1,170 @@
|
|||
# The MIT License (MIT)
|
||||
# Copyright (c) 2022 Mike Teachman
|
||||
# https://opensource.org/licenses/MIT
|
||||
|
||||
# Platform-independent MicroPython code for the rotary encoder module
|
||||
|
||||
# Documentation:
|
||||
# https://github.com/MikeTeachman/micropython-rotary
|
||||
|
||||
import micropython
|
||||
|
||||
_DIR_CW = const(0x10) # Clockwise step
|
||||
_DIR_CCW = const(0x20) # Counter-clockwise step
|
||||
|
||||
# Rotary Encoder States
|
||||
_R_START = const(0x0)
|
||||
_R_CW_1 = const(0x1)
|
||||
_R_CW_2 = const(0x2)
|
||||
_R_CW_3 = const(0x3)
|
||||
_R_CCW_1 = const(0x4)
|
||||
_R_CCW_2 = const(0x5)
|
||||
_R_CCW_3 = const(0x6)
|
||||
_R_ILLEGAL = const(0x7)
|
||||
|
||||
_transition_table = [
|
||||
|
||||
# |------------- NEXT STATE -------------| |CURRENT STATE|
|
||||
# CLK/DT CLK/DT CLK/DT CLK/DT
|
||||
# 00 01 10 11
|
||||
[_R_START, _R_CCW_1, _R_CW_1, _R_START], # _R_START
|
||||
[_R_CW_2, _R_START, _R_CW_1, _R_START], # _R_CW_1
|
||||
[_R_CW_2, _R_CW_3, _R_CW_1, _R_START], # _R_CW_2
|
||||
[_R_CW_2, _R_CW_3, _R_START, _R_START | _DIR_CW], # _R_CW_3
|
||||
[_R_CCW_2, _R_CCW_1, _R_START, _R_START], # _R_CCW_1
|
||||
[_R_CCW_2, _R_CCW_1, _R_CCW_3, _R_START], # _R_CCW_2
|
||||
[_R_CCW_2, _R_START, _R_CCW_3, _R_START | _DIR_CCW], # _R_CCW_3
|
||||
[_R_START, _R_START, _R_START, _R_START]] # _R_ILLEGAL
|
||||
|
||||
_transition_table_half_step = [
|
||||
[_R_CW_3, _R_CW_2, _R_CW_1, _R_START],
|
||||
[_R_CW_3 | _DIR_CCW, _R_START, _R_CW_1, _R_START],
|
||||
[_R_CW_3 | _DIR_CW, _R_CW_2, _R_START, _R_START],
|
||||
[_R_CW_3, _R_CCW_2, _R_CCW_1, _R_START],
|
||||
[_R_CW_3, _R_CW_2, _R_CCW_1, _R_START | _DIR_CW],
|
||||
[_R_CW_3, _R_CCW_2, _R_CW_3, _R_START | _DIR_CCW],
|
||||
[_R_START, _R_START, _R_START, _R_START],
|
||||
[_R_START, _R_START, _R_START, _R_START]]
|
||||
|
||||
_STATE_MASK = const(0x07)
|
||||
_DIR_MASK = const(0x30)
|
||||
|
||||
|
||||
def _wrap(value, incr, lower_bound, upper_bound):
|
||||
range = upper_bound - lower_bound + 1
|
||||
value = value + incr
|
||||
|
||||
if value < lower_bound:
|
||||
value += range * ((lower_bound - value) // range + 1)
|
||||
|
||||
return lower_bound + (value - lower_bound) % range
|
||||
|
||||
|
||||
def _bound(value, incr, lower_bound, upper_bound):
|
||||
return min(upper_bound, max(lower_bound, value + incr))
|
||||
|
||||
|
||||
def _trigger(rotary_instance):
|
||||
for listener in rotary_instance._listener:
|
||||
listener()
|
||||
|
||||
|
||||
class Rotary(object):
|
||||
|
||||
RANGE_UNBOUNDED = const(1)
|
||||
RANGE_WRAP = const(2)
|
||||
RANGE_BOUNDED = const(3)
|
||||
|
||||
def __init__(self, min_val, max_val, reverse, range_mode, half_step, invert):
|
||||
self._min_val = min_val
|
||||
self._max_val = max_val
|
||||
self._reverse = -1 if reverse else 1
|
||||
self._range_mode = range_mode
|
||||
self._value = min_val
|
||||
self._state = _R_START
|
||||
self._half_step = half_step
|
||||
self._invert = invert
|
||||
self._listener = []
|
||||
|
||||
def set(self, value=None, min_val=None,
|
||||
max_val=None, reverse=None, range_mode=None):
|
||||
# disable DT and CLK pin interrupts
|
||||
self._hal_disable_irq()
|
||||
|
||||
if value is not None:
|
||||
self._value = value
|
||||
if min_val is not None:
|
||||
self._min_val = min_val
|
||||
if max_val is not None:
|
||||
self._max_val = max_val
|
||||
if reverse is not None:
|
||||
self._reverse = -1 if reverse else 1
|
||||
if range_mode is not None:
|
||||
self._range_mode = range_mode
|
||||
self._state = _R_START
|
||||
|
||||
# enable DT and CLK pin interrupts
|
||||
self._hal_enable_irq()
|
||||
|
||||
def value(self):
|
||||
return self._value
|
||||
|
||||
def reset(self):
|
||||
self._value = 0
|
||||
|
||||
def close(self):
|
||||
self._hal_close()
|
||||
|
||||
def add_listener(self, l):
|
||||
self._listener.append(l)
|
||||
|
||||
def remove_listener(self, l):
|
||||
if l not in self._listener:
|
||||
raise ValueError('{} is not an installed listener'.format(l))
|
||||
self._listener.remove(l)
|
||||
|
||||
def _process_rotary_pins(self, pin):
|
||||
old_value = self._value
|
||||
clk_dt_pins = (self._hal_get_clk_value() <<
|
||||
1) | self._hal_get_dt_value()
|
||||
|
||||
if self._invert:
|
||||
clk_dt_pins = ~clk_dt_pins & 0x03
|
||||
|
||||
# Determine next state
|
||||
if self._half_step:
|
||||
self._state = _transition_table_half_step[self._state &
|
||||
_STATE_MASK][clk_dt_pins]
|
||||
else:
|
||||
self._state = _transition_table[self._state &
|
||||
_STATE_MASK][clk_dt_pins]
|
||||
direction = self._state & _DIR_MASK
|
||||
|
||||
incr = 0
|
||||
if direction == _DIR_CW:
|
||||
incr = 1
|
||||
elif direction == _DIR_CCW:
|
||||
incr = -1
|
||||
|
||||
incr *= self._reverse
|
||||
|
||||
if self._range_mode == self.RANGE_WRAP:
|
||||
self._value = _wrap(
|
||||
self._value,
|
||||
incr,
|
||||
self._min_val,
|
||||
self._max_val)
|
||||
elif self._range_mode == self.RANGE_BOUNDED:
|
||||
self._value = _bound(
|
||||
self._value,
|
||||
incr,
|
||||
self._min_val,
|
||||
self._max_val)
|
||||
else:
|
||||
self._value = self._value + incr
|
||||
|
||||
try:
|
||||
if old_value != self._value and len(self._listener) != 0:
|
||||
micropython.schedule(_trigger, self)
|
||||
except:
|
||||
pass
|
76
src/rotary_irq_esp.py
Normal file
76
src/rotary_irq_esp.py
Normal file
|
@ -0,0 +1,76 @@
|
|||
# The MIT License (MIT)
|
||||
# Copyright (c) 2020 Mike Teachman
|
||||
# https://opensource.org/licenses/MIT
|
||||
|
||||
# Platform-specific MicroPython code for the rotary encoder module
|
||||
# ESP8266/ESP32 implementation
|
||||
|
||||
# Documentation:
|
||||
# https://github.com/MikeTeachman/micropython-rotary
|
||||
|
||||
from machine import Pin
|
||||
from rotary import Rotary
|
||||
from sys import platform
|
||||
|
||||
_esp8266_deny_pins = [16]
|
||||
|
||||
|
||||
class RotaryIRQ(Rotary):
|
||||
|
||||
def __init__(self, pin_num_clk, pin_num_dt, min_val=0, max_val=10,
|
||||
reverse=False, range_mode=Rotary.RANGE_UNBOUNDED, pull_up=False, half_step=False, invert=False):
|
||||
|
||||
if platform == 'esp8266':
|
||||
if pin_num_clk in _esp8266_deny_pins:
|
||||
raise ValueError(
|
||||
'%s: Pin %d not allowed. Not Available for Interrupt: %s' %
|
||||
(platform, pin_num_clk, _esp8266_deny_pins))
|
||||
if pin_num_dt in _esp8266_deny_pins:
|
||||
raise ValueError(
|
||||
'%s: Pin %d not allowed. Not Available for Interrupt: %s' %
|
||||
(platform, pin_num_dt, _esp8266_deny_pins))
|
||||
|
||||
super().__init__(min_val, max_val, reverse, range_mode, half_step, invert)
|
||||
|
||||
if pull_up == True:
|
||||
self._pin_clk = Pin(pin_num_clk, Pin.IN, Pin.PULL_UP)
|
||||
self._pin_dt = Pin(pin_num_dt, Pin.IN, Pin.PULL_UP)
|
||||
else:
|
||||
self._pin_clk = Pin(pin_num_clk, Pin.IN)
|
||||
self._pin_dt = Pin(pin_num_dt, Pin.IN)
|
||||
|
||||
self._enable_clk_irq(self._process_rotary_pins)
|
||||
self._enable_dt_irq(self._process_rotary_pins)
|
||||
|
||||
def _enable_clk_irq(self, callback=None):
|
||||
self._pin_clk.irq(
|
||||
trigger=Pin.IRQ_RISING | Pin.IRQ_FALLING,
|
||||
handler=callback)
|
||||
|
||||
def _enable_dt_irq(self, callback=None):
|
||||
self._pin_dt.irq(
|
||||
trigger=Pin.IRQ_RISING | Pin.IRQ_FALLING,
|
||||
handler=callback)
|
||||
|
||||
def _disable_clk_irq(self):
|
||||
self._pin_clk.irq(handler=None)
|
||||
|
||||
def _disable_dt_irq(self):
|
||||
self._pin_dt.irq(handler=None)
|
||||
|
||||
def _hal_get_clk_value(self):
|
||||
return self._pin_clk.value()
|
||||
|
||||
def _hal_get_dt_value(self):
|
||||
return self._pin_dt.value()
|
||||
|
||||
def _hal_enable_irq(self):
|
||||
self._enable_clk_irq(self._process_rotary_pins)
|
||||
self._enable_dt_irq(self._process_rotary_pins)
|
||||
|
||||
def _hal_disable_irq(self):
|
||||
self._disable_clk_irq()
|
||||
self._disable_dt_irq()
|
||||
|
||||
def _hal_close(self):
|
||||
self._hal_disable_irq()
|
163
src/ssd1306.py
Normal file
163
src/ssd1306.py
Normal file
|
@ -0,0 +1,163 @@
|
|||
# MicroPython SSD1306 OLED driver, I2C and SPI interfaces
|
||||
|
||||
from micropython import const
|
||||
import framebuf
|
||||
|
||||
|
||||
# register definitions
|
||||
SET_CONTRAST = const(0x81)
|
||||
SET_ENTIRE_ON = const(0xA4)
|
||||
SET_NORM_INV = const(0xA6)
|
||||
SET_DISP = const(0xAE)
|
||||
SET_MEM_ADDR = const(0x20)
|
||||
SET_COL_ADDR = const(0x21)
|
||||
SET_PAGE_ADDR = const(0x22)
|
||||
SET_DISP_START_LINE = const(0x40)
|
||||
SET_SEG_REMAP = const(0xA0)
|
||||
SET_MUX_RATIO = const(0xA8)
|
||||
SET_IREF_SELECT = const(0xAD)
|
||||
SET_COM_OUT_DIR = const(0xC0)
|
||||
SET_DISP_OFFSET = const(0xD3)
|
||||
SET_COM_PIN_CFG = const(0xDA)
|
||||
SET_DISP_CLK_DIV = const(0xD5)
|
||||
SET_PRECHARGE = const(0xD9)
|
||||
SET_VCOM_DESEL = const(0xDB)
|
||||
SET_CHARGE_PUMP = const(0x8D)
|
||||
|
||||
# Subclassing FrameBuffer provides support for graphics primitives
|
||||
# http://docs.micropython.org/en/latest/pyboard/library/framebuf.html
|
||||
class SSD1306(framebuf.FrameBuffer):
|
||||
def __init__(self, width, height, external_vcc):
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.external_vcc = external_vcc
|
||||
self.pages = self.height // 8
|
||||
self.buffer = bytearray(self.pages * self.width)
|
||||
super().__init__(self.buffer, self.width, self.height, framebuf.MONO_VLSB)
|
||||
self.init_display()
|
||||
|
||||
def init_display(self):
|
||||
for cmd in (
|
||||
SET_DISP, # display off
|
||||
# address setting
|
||||
SET_MEM_ADDR,
|
||||
0x00, # horizontal
|
||||
# resolution and layout
|
||||
SET_DISP_START_LINE, # start at line 0
|
||||
SET_SEG_REMAP | 0x01, # column addr 127 mapped to SEG0
|
||||
SET_MUX_RATIO,
|
||||
self.height - 1,
|
||||
SET_COM_OUT_DIR | 0x08, # scan from COM[N] to COM0
|
||||
SET_DISP_OFFSET,
|
||||
0x00,
|
||||
SET_COM_PIN_CFG,
|
||||
0x02 if self.width > 2 * self.height else 0x12,
|
||||
# timing and driving scheme
|
||||
SET_DISP_CLK_DIV,
|
||||
0x80,
|
||||
SET_PRECHARGE,
|
||||
0x22 if self.external_vcc else 0xF1,
|
||||
SET_VCOM_DESEL,
|
||||
0x30, # 0.83*Vcc
|
||||
# display
|
||||
SET_CONTRAST,
|
||||
0xFF, # maximum
|
||||
SET_ENTIRE_ON, # output follows RAM contents
|
||||
SET_NORM_INV, # not inverted
|
||||
SET_IREF_SELECT,
|
||||
0x30, # enable internal IREF during display on
|
||||
# charge pump
|
||||
SET_CHARGE_PUMP,
|
||||
0x10 if self.external_vcc else 0x14,
|
||||
SET_DISP | 0x01, # display on
|
||||
): # on
|
||||
self.write_cmd(cmd)
|
||||
self.fill(0)
|
||||
self.show()
|
||||
|
||||
def poweroff(self):
|
||||
self.write_cmd(SET_DISP)
|
||||
|
||||
def poweron(self):
|
||||
self.write_cmd(SET_DISP | 0x01)
|
||||
|
||||
def contrast(self, contrast):
|
||||
self.write_cmd(SET_CONTRAST)
|
||||
self.write_cmd(contrast)
|
||||
|
||||
def invert(self, invert):
|
||||
self.write_cmd(SET_NORM_INV | (invert & 1))
|
||||
|
||||
def rotate(self, rotate):
|
||||
self.write_cmd(SET_COM_OUT_DIR | ((rotate & 1) << 3))
|
||||
self.write_cmd(SET_SEG_REMAP | (rotate & 1))
|
||||
|
||||
def show(self):
|
||||
x0 = 0
|
||||
x1 = self.width - 1
|
||||
if self.width != 128:
|
||||
# narrow displays use centred columns
|
||||
col_offset = (128 - self.width) // 2
|
||||
x0 += col_offset
|
||||
x1 += col_offset
|
||||
self.write_cmd(SET_COL_ADDR)
|
||||
self.write_cmd(x0)
|
||||
self.write_cmd(x1)
|
||||
self.write_cmd(SET_PAGE_ADDR)
|
||||
self.write_cmd(0)
|
||||
self.write_cmd(self.pages - 1)
|
||||
self.write_data(self.buffer)
|
||||
|
||||
|
||||
class SSD1306_I2C(SSD1306):
|
||||
def __init__(self, width, height, i2c, addr=0x3C, external_vcc=False):
|
||||
self.i2c = i2c
|
||||
self.addr = addr
|
||||
self.temp = bytearray(2)
|
||||
self.write_list = [b"\x40", None] # Co=0, D/C#=1
|
||||
super().__init__(width, height, external_vcc)
|
||||
|
||||
def write_cmd(self, cmd):
|
||||
self.temp[0] = 0x80 # Co=1, D/C#=0
|
||||
self.temp[1] = cmd
|
||||
self.i2c.writeto(self.addr, self.temp)
|
||||
|
||||
def write_data(self, buf):
|
||||
self.write_list[1] = buf
|
||||
self.i2c.writevto(self.addr, self.write_list)
|
||||
|
||||
|
||||
class SSD1306_SPI(SSD1306):
|
||||
def __init__(self, width, height, spi, dc, res, cs, external_vcc=False):
|
||||
self.rate = 10 * 1024 * 1024
|
||||
dc.init(dc.OUT, value=0)
|
||||
res.init(res.OUT, value=0)
|
||||
cs.init(cs.OUT, value=1)
|
||||
self.spi = spi
|
||||
self.dc = dc
|
||||
self.res = res
|
||||
self.cs = cs
|
||||
import time
|
||||
|
||||
self.res(1)
|
||||
time.sleep_ms(1)
|
||||
self.res(0)
|
||||
time.sleep_ms(10)
|
||||
self.res(1)
|
||||
super().__init__(width, height, external_vcc)
|
||||
|
||||
def write_cmd(self, cmd):
|
||||
self.spi.init(baudrate=self.rate, polarity=0, phase=0)
|
||||
self.cs(1)
|
||||
self.dc(0)
|
||||
self.cs(0)
|
||||
self.spi.write(bytearray([cmd]))
|
||||
self.cs(1)
|
||||
|
||||
def write_data(self, buf):
|
||||
self.spi.init(baudrate=self.rate, polarity=0, phase=0)
|
||||
self.cs(1)
|
||||
self.dc(1)
|
||||
self.cs(0)
|
||||
self.spi.write(buf)
|
||||
self.cs(1)
|
Loading…
Reference in a new issue