Closed Kattihatt closed 6 years ago
Adding support wouldn't be too difficult. The easiest option by far is to create a script (of your programming language of choice) that returns a value when executed. Then in Mycodo add a "Linux Command" Input that will execute that script at a predefined interval and store the return value in the database for use elsewhere within Mycodo. This is the cheap man's way of adding support for a new device.
To add support for a new device within Mycodo, it's a little more involved. However, if you can provide Python code to read the ADC, I can add support for it (as a new Input type) fairly easily.
I see Adafruit has a writeup and code examples for using this chip with a Pi, at https://learn.adafruit.com/reading-a-analog-in-and-controlling-audio-volume-with-the-raspberry-pi
If you can test whether this code works, I'll add a new input for you to test.
Such a quick response :) I shall look into it and fiddle around a bit. I'll come back when I got something.
I think this will be easier than anticipated! Apparently WiringPi has support for the MCP3008, and you can grab the data from each channel through the command "gpio -x mcp3004:200:0 aread 200" Where the end digit is the channel. So if I want to grab data from channel 1, I do "gpio -x mcp3004:200:0 aread 201". The guy who told me about it is the maker of WiringPi and he says there is no specific library for MCP3008, but it uses the same as 3004, you just add more channels.
I started fiddling with spidev and making a script for grabbing the data. This should be doable but for some reason I got 0 returned every time and I talked to people in the #raspberrypi channel who told me that wiringPi could handle this easier. I don't know the pro's or con's to WiringPi vs spidev, but I'll look into that more.
I tried it here and on channel 0 I have a soil moisture sensor that is in air, this could make sense but I'm looking into it more. On channel 1 I have a simple voltage divider with two 10k resistors, which should of course then return 1024/2, which is almost exactly does. So this option is working and is easy, I will just need to make a script that grabs the data in order and formats it more nicely. I don't know how you would've gone about this, but I'll at least try to find a solution for myself to use meanwhile.
"gpio -x mcp3004:200:0 aread 200 0 gpio -x mcp3004:200:0 aread 201 511"
So I made this that works and takes an argument that is the channel. I am not very good at coding so I don't know how to put conditions on the command line arguments so that it won't take anything < 0 or > 7, but it works somewhat. Here's the code I use for now: http://paste.debian.net/1011504/
I just use "python3 adc.py 0" to grab the first channel and I get this output: "pi@GreenhousePi:~ $ python3 adc.py 0 455 pi@GreenhousePi:~ $ python3 adc.py 1 511"
Now I'm on the next step of formatting it to show a useful value for Mycodo, but I'm trying to get it to show these readings as I write this, but I can't get it to output anything from the Input linux command. Just says please wait. When I su to mycodo user, I can't start anything because it doesn't even run bash. trying to look into this now but its a lot of problem solving for my messy mind :)
Final solution I think: http://paste.debian.net/1011547/ Converts it into a percentage roughly estimated on values I measured in dry or wet conditions. Uses the same for the LDR which of course isn't the best solution but could be adjusted through the resistor, but the minimum value is fairly close so it's acceptable for now, it's not an important measurement either since I won't be using it for any conditions.
Here's a image of the graph: https://i.imgur.com/9nKcx2R.png?1
I created a pure python test script. If this works, I'll integrate this code into a Mycodo Input module.
~/Mycodo/env/bin/python3 ./test_adc.py -h
~/Mycodo/env/bin/python3 ./test_adc.py [Clock pin] [MISO pin] [MOSI pin] [CS pin] [ADC Channel]
~/Mycodo/env/bin/python3 ./test_adc.py 18 23 24 25 0
#!/usr/bin/python
# -*- coding: utf-8 -*-
#
# Written by Limor "Ladyada" Fried for Adafruit Industries, (c) 2015
# This code is released into the public domain
#
# Modified into test script by Kyle Gabriel
import argparse
import RPi.GPIO as GPIO
class MCP3008:
"""
Class to read the MCP3008 analog to digital converter
"""
def __init__(self, spiclk, spimiso, spimosi, spics):
# Set the class SPI variables
self.spiclk = spiclk
self.spimiso = spimiso
self.spimosi = spimosi
self.spics = spics
# Set up the SPI interface pins
GPIO.setmode(GPIO.BCM)
GPIO.setup(self.spimosi, GPIO.OUT)
GPIO.setup(self.spimiso, GPIO.IN)
GPIO.setup(self.spiclk, GPIO.OUT)
GPIO.setup(self.spics, GPIO.OUT)
def read_adc(self, adcnum):
"""
Read the ADC
:param adcnum: channel to read from the ADC (0 - 7)
:return: digital value
"""
if adcnum > 7 or adcnum < 0:
# Channel is not in the 0 - 7 range, return before querying the ADC
return -1
GPIO.output(self.spics, True)
GPIO.output(self.spiclk, False) # start clock low
GPIO.output(self.spics, False) # bring CS low
commandout = adcnum
commandout |= 0x18 # start bit + single-ended bit
commandout <<= 3 # we only need to send 5 bits here
for i in range(5):
if commandout & 0x80:
GPIO.output(self.spimiso, True)
else:
GPIO.output(self.spimiso, False)
commandout <<= 1
GPIO.output(self.spiclk, True)
GPIO.output(self.spiclk, False)
adcout = 0
# read in one empty bit, one null bit and 10 ADC bits
for i in range(12):
GPIO.output(self.spiclk, True)
GPIO.output(self.spiclk, False)
adcout <<= 1
if GPIO.input(self.spimosi):
adcout |= 0x1
GPIO.output(self.spics, True)
adcout >>= 1 # first bit is 'null' so drop it
return adcout
def parse_args(parser):
""" Add arguments for argparse """
parser.add_argument('--clockpin', metavar='CLOCKPIN', type=int,
help='SPI Clock Pin',
required=True)
parser.add_argument('--misopin', metavar='MISOPIN', type=int,
help='SPI MISO Pin',
required=True)
parser.add_argument('--mosipin', metavar='MOSIPIN', type=int,
help='SPI MOSI Pin',
required=True)
parser.add_argument('--cspin', metavar='CSPIN', type=int,
help='SPI CS Pin',
required=True)
parser.add_argument('--adcchannel', metavar='ADCCHANNEL', type=int,
help='channel to read from the ADC (0 - 7)',
required=True)
return parser.parse_args()
if __name__ == '__main__':
# Run the main program is the script is executes independently (via command line)
# Get arguments from the executed command
parser = argparse.ArgumentParser(description='MCP3008 Test Script')
args = parse_args(parser)
# Example SPI pins
# SPICLK = 18, SPIMISO = 23, SPIMOSI = 24, SPICS = 25
# Initialize MCP3008 class
mcp_adc = MCP3008(args.clockpin, args.misopin, args.mosipin, args.cspin)
# Conduct measurement
adc_measurement = mcp_adc.read_adc(args.adcchannel)
# Print measurement
print("ADC Channel: {chan}, Output: {out}".format(chan=args.adcchannel, out=adc_measurement))
Looks a lot more complex than what I did, this is why I shouldn't bother and actually focus on learning a programming language decent enough to use it before I try these things x) I'll look into it tomorrow. Just put everything away for the day.
I didn't mean to intimidate you with the new code. I just wanted to find a solution that used only Python and not execute a command line program (less requirements since RPi.GPIO in the test script is already installed with Mycodo). I think it makes it a tidier package if it's all in one language, unless there is a compelling reason to do otherwise. I actually got most of that code from here.
There's nothing wrong with your code. If it works, it works, and I'm glad you figured out the system so quickly and produced your own code to start gathering data. Most people would stop before producing a working example. I would have responded sooner with a test script, but work got in the way.
There's no rush with testing, so take your time. We may need to tweak my test script is it doesn't work or could use improvements. Since I don't have this chip, I'll rely on your testing to get it to a state ready to integrate into Mycodo. Once we do that, I'll issue a new release, you can upgrade your system from the web UI, and begin using the new Input.
Thanks for the help.
Also, I went back and edited my post with the test script to include a few more comments to help with understanding what's going on. I wouldn't worry about understanding the read_adc()
function unless you're feeling particularly ambitious, but the rest should be pretty straightforward with how the script executes.
Here's how to run the new test script:
~/Mycodo/env/bin/pip3 install adafruit-mcp3008
#!/usr/bin/python
# -*- coding: utf-8 -*-
#
# Simple example of reading the MCP3008 analog input channels and printing
# them all out.
# Author: Tony DiCola
# License: Public Domain
import argparse
import Adafruit_MCP3008
# Optional SPI Interface method
# import Adafruit_GPIO.SPI as SPI
# Hardware SPI configuration:
# SPI_PORT = 0
# SPI_DEVICE = 0
# mcp = Adafruit_MCP3008.MCP3008(spi=SPI.SpiDev(SPI_PORT, SPI_DEVICE))
def parse_args(parser):
""" Add arguments for argparse """
parser.add_argument('--clockpin', metavar='CLOCKPIN', type=int,
help='SPI Clock Pin',
required=True)
parser.add_argument('--misopin', metavar='MISOPIN', type=int,
help='SPI MISO Pin',
required=True)
parser.add_argument('--mosipin', metavar='MOSIPIN', type=int,
help='SPI MOSI Pin',
required=True)
parser.add_argument('--cspin', metavar='CSPIN', type=int,
help='SPI CS Pin',
required=True)
parser.add_argument('--adcchannel', metavar='ADCCHANNEL', type=int,
help='channel to read from the ADC (0 - 7)',
required=False, choices=range(0,8))
return parser.parse_args()
if __name__ == '__main__':
# Run the main program if the script is executed independently (i.e. via command line)
# Get arguments from the executed command
parser = argparse.ArgumentParser(description='MCP3008 Test Script')
args = parse_args(parser)
# Initialize MCP3008 class
# Example Software SPI pins: CLK = 18, MISO = 23, MOSI = 24, CS = 25
mcp = Adafruit_MCP3008.MCP3008(clk=args.clockpin, cs=args.cspin, miso=args.misopin, mosi=args.mosipin)
if -1 < args.adcchannel < 8:
# Read the specified channel
value = mcp.read_adc(args.adcchannel)
print("ADC Channel: {chan}, Output: {out}".format(chan=args.adcchannel, out=value))
else:
# Create a list for the ADC channel values
values = [0] * 8
# Conduct measurements of channels 0 - 7, add them to the list
for i in range(8):
values[i] = mcp.read_adc(i)
# Print the list of ADC values
print('| {0:>4} | {1:>4} | {2:>4} | {3:>4} | {4:>4} | {5:>4} | {6:>4} | {7:>4} |'.format(*values))
~/Mycodo/env/bin/python3 ./test_adc_01.py --clockpin 18 --misopin 23 --mosipin 24 --cspin 25
This will return the value for every channel. To return the value from one single channel, add the --adcchannel parameter:
~/Mycodo/env/bin/python3 ./test_adc_01.py --clockpin 18 --misopin 23 --mosipin 24 --cspin 25 --adcchannel 0
You'll notice the # Optional SPI Interface method
is commented out. If you would like to test that module for automatically selecting your SPI interface (so you don't have to enter the pins), you can run the following script.
#!/usr/bin/python
# -*- coding: utf-8 -*-
#
# Simple example of reading the MCP3008 analog input channels and printing
# them all out.
# Author: Tony DiCola
# License: Public Domain
import argparse
import Adafruit_GPIO.SPI as SPI
import Adafruit_MCP3008
def parse_args(parser):
""" Add arguments for argparse """
parser.add_argument('--adcchannel', metavar='ADCCHANNEL', type=int,
help='channel to read from the ADC (0 - 7)',
required=False, choices=range(0,8))
return parser.parse_args()
if __name__ == '__main__':
# Run the main program is the script is executes independently (via command line)
# Get arguments from the executed command
parser = argparse.ArgumentParser(description='MCP3008 Test Script')
args = parse_args(parser)
# Initialize MCP3008 class
SPI_PORT = 0
SPI_DEVICE = 0
mcp = Adafruit_MCP3008.MCP3008(spi=SPI.SpiDev(SPI_PORT, SPI_DEVICE))
if -1 < args.adcchannel < 8:
# Read the specified channel
value = mcp.read_adc(args.adcchannel)
print("ADC Channel: {chan}, Output: {out}".format(chan=args.adcchannel, out=value))
else:
# Create a list for the ADC channel values
values = [0] * 8
# Conduct measurements of channels 0 - 7, add them to the list
for i in range(8):
values[i] = mcp.read_adc(i)
# Print the list of ADC values
print('| {0:>4} | {1:>4} | {2:>4} | {3:>4} | {4:>4} | {5:>4} | {6:>4} | {7:>4} |'.format(*values))
~/Mycodo/env/bin/python3 ./test_adc_02.py
With python3 I get an error I can't understand, since I got RPi.GPIO and it works fine in other cases. If I just run python or python2, I get no complaints but the results are somewhat strange to me. Why do the channels with nothing connected to them show such strange values? Is it because they're floating and should I connect the unused channels to ground to avoid this or is it because I'm trying to read too often? I don't know how to look at the files inside a python egg-file, since I've never done that but I don't know where the error lies and if there's any point in me looking into that, doesn't feel like it should be the problem.
The --adcchannel doesn't seem to affect the result at all. It reads all the channels anyway. How come?
Also, will this easily support using a 3004? I don't know if it's common but I'm making an instructable later for my project where I'll definitely include Mycodo and me using the MCP3008, but maybe someone got a 3004 and it'd be great if it's possible to use either since they work the same way. But I guess you can just use the 3008 and use the first four channels? But I wonder what happens if you try to access a fifth channel with a 3004. Any idea?
pi@GreenhousePi:~ $ sudo python3 test_adc_01.py --clockpin 11 --misopin 09 --mosipin 10 --cspin 08
Traceback (most recent call last):
File "test_adc_01.py", line 49, in <module>
mcp = Adafruit_MCP3008.MCP3008(clk=args.clockpin, cs=args.cspin, miso=args.misopin, mosi=args.mosipin)
File "/usr/local/lib/python3.5/dist-packages/Adafruit_MCP3008-1.0.2-py3.5.egg/Adafruit_MCP3008/MCP3008.py", line 41, in __init__
File "/usr/local/lib/python3.5/dist-packages/Adafruit_GPIO-1.0.3-py3.5.egg/Adafruit_GPIO/GPIO.py", line 417, in get_platform_gpio
ImportError: No module named 'RPi'
pi@GreenhousePi:~ $ sudo python2 test_adc_01.py --clockpin 11 --misopin 09 --mosipin 10 --cspin 08
| 976 | 764 | 7 | 16 | 23 | 32 | 69 | 147 |
pi@GreenhousePi:~ $ sudo python2 test_adc_01.py --clockpin 11 --misopin 09 --mosipin 10 --cspin 08
| 976 | 764 | 84 | 155 | 204 | 247 | 269 | 258 |
pi@GreenhousePi:~ $ sudo python2 test_adc_01.py --clockpin 11 --misopin 09 --mosipin 10 --cspin 08 --adcchannel 0
| 975 | 763 | 0 | 0 | 0 | 3 | 7 | 12 |
pi@GreenhousePi:~ $ sudo python2 test_adc_01.py --clockpin 11 --misopin 09 --mosipin 10 --cspin 08 --adcchannel 0
| 976 | 763 | 246 | 263 | 239 | 233 | 232 | 210 |
If I do run the test_adc_02.py, I get no errors:
| 977 | 797 | 20 | 24 | 25 | 24 | 27 | 28 |
pi@GreenhousePi:~ $ python3 test_adc_02.py
| 977 | 796 | 0 | 0 | 0 | 0 | 0 | 0 |
pi@GreenhousePi:~ $ python3 test_adc_02.py
| 977 | 796 | 0 | 0 | 0 | 0 | 0 | 0 |
With python3 I get an error
There are separate modules for Python 2.7 and 3.5. This is why I specified the Python 3.5 in the virtualenv used by Mycodo to execute the test script:
~/Mycodo/env/bin/python3 ./test_adc_01.py --clockpin 18 --misopin 23 --mosipin 24 --cspin 25 --adcchannel 0
Why do the channels with nothing connected to them show such strange values? Is it because they're floating
Correct, they're floating. If you grounded them you should see 0, but that's generally not necessary.
The --adcchannel doesn't seem to affect the result at all. It reads all the channels anyway. How come?
I didn't include the correct conditional for args.adcchannel. If you tried to read channel 0, it wouldn't work, however the other channels should have worked. I just updated both 01 and 02 example codes to include a fix.
Also, will this easily support using a 3004?
I don't believe this Python module supports the 3004, but it's possible. I'll look into it.
There are separate modules for Python 2.7 and 3.5. This is why I specified the Python 3.5 in the virtualenv used by Mycodo to execute the test script:
Oh, I'm sorry, my mistake! Didn't know it was important to run it inside. Thought virtual environments were more of a sandbox thing for safety, where you were using the same modules as when just running python3 straight in the terminal. Now I know.
Now it works wonders! I'll fiddle around a bit more but I haven't discovered any problems so far.
pi@GreenhousePi:~ $ ~/Mycodo/env/bin/python3 ./test_adc_01.py --clockpin 11 --misopin 09 --mosipin 10 --cspin 08 --adcchannel 0
ADC Channel: 0, Output: 982
pi@GreenhousePi:~ $ ~/Mycodo/env/bin/python3 ./test_adc_01.py --clockpin 11 --misopin 09 --mosipin 10 --cspin 08 --adcchannel 1
ADC Channel: 1, Output: 964
pi@GreenhousePi:~ $ ~/Mycodo/env/bin/python3 ./test_adc_01.py --clockpin 11 --misopin 09 --mosipin 10 --cspin 08 --adcchannel 3
ADC Channel: 3, Output: 186
pi@GreenhousePi:~ $ ~/Mycodo/env/bin/python3 ./test_adc_01.py --clockpin 11 --misopin 09 --mosipin 10 --cspin 08 --adcchannel 3
ADC Channel: 3, Output: 4
Great! Thanks for testing. I'll put together a new module for the MCP3008 and try to make a release later today or early this weekend.
I'm very thankful! That's the only missing piece in Mycodo for my whole project. There are so many nice and easy to use functions.
By the way, I asked in the Adafruit_Python_MCP3008 github about using the MCP3002/3004, so hopefully we'll get an answer to that question as well.
I pushed a commit that adds support for the new ADC. I need to review my code for issues before I make a release. I'll let you know when I do so you can upgrade and test it.
I just released v5.5.24 with MCP3008 support. You can upgrade through the web UI under Config [Gear Icon] -> Upgrade Let me know if there are any errors when you get a chance to test.
Saw that it was available now. Having some issues though. This is what I get after adjusting my GPIO pins like on below picture. Also, as a question and suggestion in one, wouldn't it be better to set it to the GPIO pins designated for SPI communication as default settings?
I did set them to 08 and 09 in the settings, but when I save it automatically adjusts them to GPIO 8 and 9, I don't know if this is any cause for issues but I'll mention it anyway.
Code still runs fine from the script you made:
pi@GreenhousePi:~ $ ~/Mycodo/env/bin/python3 test_adc_01.py --clockpin 11 --misopin 09 --mosipin 10 --cspin 08 --adcchannel 0
ADC Channel: 0, Output: 977
pi@GreenhousePi:~ $ ~/Mycodo/env/bin/python3 test_adc_01.py --clockpin 11 --misopin 09 --mosipin 10 --cspin 08 --adcchannel 1
ADC Channel: 1, Output: 985
I missed changing that part. Change the following function from:
to this:
def input_activate(form_mod):
input_id = form_mod.input_id.data
input_dev = Input.query.filter(Input.id == input_id).first()
if (input_dev.device == 'MCP3008' and
(None in [form_mod.pin_clock.data,
form_mod.pin_cs.data,
form_mod.pin_mosi.data,
form_mod.pin_miso.data])):
flash("Cannot activate without SPI pins set.", "error")
elif (input_dev.device == 'LinuxCommand' and
input_dev.cmd_command is ''):
flash("Cannot activate Input without a command set.", "error")
return redirect(url_for('routes_page.page_data'))
elif (input_dev.device != 'LinuxCommand' and
not input_dev.location and
input_dev.device not in DEVICES_DEFAULT_LOCATION):
flash("Cannot activate Input without the GPIO/I2C Address/Port "
"to communicate with it set.", "error")
return redirect(url_for('routes_page.page_data'))
controller_activate_deactivate('activate', 'Input', input_id)
Then restart the frontend by selecting Config [Gear Icon] -> Restart Frontend
Unfortunately I have the same issue. Replaced the mentioned part in "Mycodo/mycodo/mycodo_flask/utils/utils_input.py" and restarted through GUI with no changes, restarted the Raspberry Pi but problem persists. It still changes from 08 and 09 to 8 and 9.
08/8 and 09/9 are the same pins. The leading 0 doesn't make a difference. The fix was for the error preventing you from activating the controller.
Then I don't know. I'm still getting this same error:
Error: Cannot activate Input without the GPIO/I2C Address/Port to communicate with it set.
Ah, there's an issue with the function. Change it to this and try:
def input_activate(form_mod):
input_id = form_mod.input_id.data
input_dev = Input.query.filter(Input.id == input_id).first()
if input_dev.device == 'MCP3008':
if None in [form_mod.pin_clock.data,
form_mod.pin_cs.data,
form_mod.pin_mosi.data,
form_mod.pin_miso.data]:
flash("Cannot activate without SPI pins set.", "error")
elif (input_dev.device == 'LinuxCommand' and
input_dev.cmd_command is ''):
flash("Cannot activate Input without a command set.", "error")
return redirect(url_for('routes_page.page_data'))
elif (input_dev.device != 'LinuxCommand' and
not input_dev.location and
input_dev.device not in DEVICES_DEFAULT_LOCATION):
flash("Cannot activate Input without the GPIO/I2C Address/Port "
"to communicate with it set.", "error")
return redirect(url_for('routes_page.page_data'))
controller_activate_deactivate('activate', 'Input', input_id)
Pretty sure it shouldn't be requiring an i2c-adress :)
When activating: When deactivating:
Try changing this:
to this:
# Set up analog-to-digital converter
if self.device in LIST_DEVICES_ADC:
if self.device in ['ADS1x15', 'MCP342x']:
self.adc_lock_file = "/var/lock/mycodo_adc_bus{bus}_0x{i2c:02X}.pid".format(
bus=self.i2c_bus, i2c=self.i2c_address)
elif self.device == 'MCP3008':
self.adc_lock_file = "/var/lock/mycodo_adc_uart-{clock}-{cs}-{miso}-{mosi}".format(
clock=self.pin_clock, cs=self.pin_cs, miso=self.pin_miso, mosi=self.pin_mosi)
I had to fix the code in my last comment. Go ahead and try that and see if it works. The edit to the input controller will require restarting the backend, Config [Gear Icon] -> Restart Backend
There are no issues with activating or deactivating any longer! So that's one step in the right direction. However I get no measurements from it. I've double checked that the GPIO configuration is correct. I tried to see if I could grab any useful information through journalctl but apparently I can't. Is there any way I can help figuring out why it's not working?
Can you see if there are errors in the daemon log? Config -> Mycodo Logs -> Daemon
I find no such file, but I checked /var/log/mycodo/mycodo.log: http://paste.debian.net/1011795/
Using the web UI' Config -> Mycodo Logs -> Daemon reads that file. It make sit easier to read the logs.
After editing the input controller, did you restart it from these instructions?
The edit to the input controller will require restarting the backend, Config [Gear Icon] -> Restart Backend
Also make sure you're looking at the most recent edit of my comment with the correct code. Refresh your browser to see if you may be viewing an older version than the current edit. I edited it moments after I first created this comment.
Yes! Now it works! Apparently I didn't see the edit! Thank you! It seems to be working fine!
Great! Test it for a while and let me know if anything should be changed or fixed. I added a conversion to voltage, which is how the other ADC modules operate.
Of course that voltage is also mapped to a scale of your choosing, to make the information more digestible.
Hello everyone!
I just discovered this option of making my automated greenhouse, through an IRC channel for Raspberry pi where someone pointed me in this direction. Looks great, open source, locally ran and lots of benefits I wouldn't have with Mydevices cayenne that was my other option. But I'm wondering about support. I'm so confused about many of these things, I can't grasp the bigger picture and what needs to be done. I really do prefer working with Arduino and learning from the basics where I know how things work, here I have to rely on so many things working that I don't know how, which I why I feel the need to ask here.
I got 2 x DHT22, this should be easy setting up. I'll have 6 MOSFET:s controlling one solenoid valve each, one for the pump. One relay controlling a heating fan. But the problem is my 5 moisture sensors + an LDR, that I COULD accept using digitally, but I would very much prefer to be able to read an analog value and decide from the Raspberry pi when to turn on/off. This is where the MCP3008 comes into play. Will there be support or is it doable with limited coding experience to pull a project like this off? What it needs to do is monitor at least five plant groups individually and with this data, control the MOSFET:s.
Thank you for a great software! I hope it comes in handy for my project since it seems like the best one I've encountered so far.