Commit e5d92d57 authored by Brendan O'Leary 🐢's avatar Brendan O'Leary 🐢 🦊

Forget node...we're going full python

parent 581f5b5b
# sous-vide
## pyCirculate
Node.js API to control Anova water circulators.
This is a Python wrapper library for interacting with the Anova 2 over Bluetooth LE.
**tl;dr** [go check out the `example.js`.][1]
It should work on any Linux with a working [BlueZ](http://www.bluez.org/) install. Most of my testing and development took place on a Raspberry Pi.
> Note: only tested on a 2nd generation Anova Precision Cooker.
## Installation
## Prerequisites
* Install libglib (`sudo apt-get install libglib2.0-dev`)
* Install [bluepy](https://github.com/IanHarvey/bluepy) (`pip install bluepy`).
* `pip install pycirculate`
Make sure to meet all prerequisites set out by [`noble`][2] to use the Bluetooth Low Energy (BLE) API.
## Usage
```python
from pycirculate.anova import AnovaController
## APIs
# Your device's MAC address can be found with `sudo hcitool lescan`
anova = AnovaController("84:EB:18:6E:xx:xx")
### Bluetooth Low Energy (BLE)
anova.read_unit()
# -> 'c'
anova.read_temp()
# -> '14.9'
#### `anova.ble.connect([macAddress])`
anova.set_temp(63.5)
anova.start_anova()
Function to connect to an Anova device. Returns a promise that resolves to
another function `sendCommand`. Providing a `macAddress` string, you may filter
to connect to specific devices.
anova.anova_status()
# -> 'running'
```
##### `sendCommand(commandString)`
Additional [examples](https://github.com/erikcw/pycirculate/tree/master/examples) can be found in the examples subdirectory.
Function to queue a command to be sent to the Anova device. Returns a promise
with the response from the device.
## Status
#### `anova.ble.commands`
*Alpha* -- everything seems to work, but needs more testing.
An object with command functions that return strings to be sent to the Anova
device with `sendCommand`. [Check out the docs][3] to understand more about the
available commands.
## TODO
#### `anova.ble.constants`
* Add more features to the [REST API example](https://github.com/erikcw/pycirculate/blob/master/examples/rest/).
* More testing.
An object with constants that are used as constraints when configuring the
Anova device with commands.
#### `anova.ble.responses`
### Credits
Functions that check the response from a few commands. Can be useful for
checking whether timers are running, the device is cooking, or if there is an
error with the device.
#### `anova.ble.randomString`
Function to be used in conjuntion with `anova.ble.commands.SET_NUMBER`.
Generates valid random strings, given a length as an integer. The valid length
for `anova.ble.commands.SET_NUMBER` is `10`.
### REST
##### `anova.rest.connect([credentials])`
Function to set the credentials to be used for REST API calls.
* `credentials`: An optional object with the following required properties.
* `id`: Anova device ID. Can be obtained with the BLE command
`anova.ble.commands.GET_ID_CARD` (see example).
* `secretKey`: Anova device secret key. Can be obtained by
looking at the API calls made by the Anova app or by setting a new one
with the `anova.ble.commands.SET_NUMBER` command in conjunction with
`anova.ble.randomString`.
Returns `undefined` if passing `credentials` object, otherwise returns an array
of `[id, secretKey]`.
##### `anova.rest.sendCommand(url, [postBody])`
Wrapper function for `fetch` to call Anova REST API.
* `url`: A URL to send the call to. Call will be a `GET`, unless a `postBody`
is given in which case it will be a `POST`.
* `postBody`: An optional object that will be stringified and sent as the
`POST` body.
Returns a promise that resolves to the JSON response of the API call.
#### `anova.rest.commands`
An object with properties of different API commands to be sent to Anova's REST
API. Each command calls `anova.rest.sendCommand` with the proper URL, HTTP
method, and `POST` body if required and returns a promise.
[Check out the docs][4] to understand more about the available commands.
#### `anova.rest.constants`
An object with constants that are used as constraints when calling the Anova
REST API.
#### `anova.rest.responses`
Functions that check the response from a few commands. Can be useful for
checking whether timers are running, the device is cooking, or if there is an
error with the device.
### Recipes
Recipes API coming soon...
[1]: https://github.com/dfrankland/sous-vide/blob/master/example.js
[2]: https://github.com/sandeepmistry/noble#prerequisites
[3]: https://github.com/dfrankland/sous-vide/blob/master/docs/ble.md
[4]: https://github.com/dfrankland/sous-vide/blob/master/docs/rest.md
I used the [Circulate](https://github.com/neilpa/circulate/) iOS library as a reference implementation for Anova commands.
import anova from './lib';
// Replace the `SECRET_KEY` with your devices secret key. Can be obtained by
// looking at the API calls made by the Anova app or by setting a new one with
// the `anova.ble.commands.SET_NUMBER` command in conjunction with
// `anova.ble.randomString`.
const SECRET_KEY = '0000000000';
const log = prefix => async promise => (
console.log(`${prefix}:`, await promise) // eslint-disable-line no-console
);
const logBle = log('BLE');
const logRest = log('REST');
(async () => {
const sendCommand = await anova.ble.connect();
const id = await sendCommand(anova.ble.commands.GET_ID_CARD());
anova.rest.connect({ id: id.replace(/\r/g, ''), secretKey: SECRET_KEY });
const promises = [
logBle(sendCommand(anova.ble.commands.GET_ID_CARD())),
logBle(sendCommand(anova.ble.commands.VERSION())),
logBle(sendCommand(anova.ble.commands.READ_STATUS())),
logRest(anova.rest.commands.READ_STATUS()),
logRest(anova.rest.commands.READ_JOBS()),
];
await Promise.all(promises);
process.exit();
})().catch(
err => {
console.error(err); // eslint-disable-line no-console
process.exit(1);
},
);
This diff is collapsed.
# Anova Bluetooth API
These commands and notes have been extracted from the Anova Culinary app v2.1.0
APK. Anova devices use Bluetooth Low Energy (BLE), and can only work with other
devices that have this capability.
## How to Connect and Communicate
1. Use the local name to recognize an Anova device.
2. Use the device discovered and find the service on it that matches the listed
service UUID.
3. Use the service discovered and find the characteristics on it that matches
the listed characteristic UUID.
4. Use the characteristic discovered and listen for data, subscribe, and then
write commands to it.
## How to Send Commands
* Commands should be executed in a queue.
* The queue should have an allotted timeout for each command.
* Each command should be delimited by an "empty" command.
* Each command must be written in sets of max 20 bytes with `\r` signaling the
end of a single command.
## How to Receive Data
As data is received from the discovered characteristic, it must be concatenated
into a string until `\r` is received signaling the end.
## Bluetooth Constants
* Local name: `Anova`
* Device service UUID: `ffe0`
* Device characteristic UUID: `ffe1`
## Commands
| APK Source File | Command String | Notes |
|----------------------------------|---------------------------------|-------|
| `commands/SetCalibrationFactor` | `cal %.1f` | where `%.1f` is a float value with a precision of 1 decimal place (rounds up), defaults to `0.0`, max of 9.9, min of -9.9 |
| `commands/ClearAlarm` | `clear alarm` | |
| `commands/CheckWifiSupport` | `get id card` | same as `GetIdCard` |
| `commands/GetIdCard` | `get id card` | returns the `cooker_id` of the Anova device |
| `commands/CalibrationFactor` | `read cal` | |
| `commands/TemperatureHistory` | `read data` | |
| `commands/Date` | `read date` | |
| `commands/ReadTargetTemperature` | `read set temp` | same as `TargetTemperature` |
| `commands/TargetTemperature` | `read set temp` | |
| `commands/CurrentTemperature` | `read temp` | |
| `commands/ReadTime` | `read timer` | same as `TimerStatus` |
| `commands/TimerStatus` | `read timer` | has a return status of `running` if running otherwise stopped |
| `commands/ReadUnitCommand` | `read unit` | |
| `commands/ServerPara` | `server para %s %d` | where `%s` is the server IP of `pc.anovaculinary.com` and `%d` is the port as an integer value defaulting to `8080` |
| `commands/SetLed` | `set led %d %d %d` | where `%d` is the value of red, green, and blue as an integer value (**only found in APK version 0.0.180 and does not work**) |
| `commands/SetDeviceName` | `set name %s` | where `%s` is the device name |
| `commands/SetSecretKey` | `set number %s` | where `%s` is the secret key, secret key should be 10 lowercase alphanumeric characters |
| `commands/SpeakerOff` | `set speaker off` | |
| `commands/SetTargetTemperature` | `set temp %f` | where `%f` is the temperature as a float value (should only work with a precision of 1 decimal place), max of 99.9C or 211.8F, min of 5.0C or 41.0F |
| `commands/SetTimer` | `set timer %d` | where `%d` is the time in minutes as an integer value, max of 6000, min of 0 |
| `commands/SetTemperatureUnit` | `set unit %s` | where `%s` is the short version of the temperature unit (`c` or `f`) |
| `commands/SmartLinkStart` | `smartlink start` | returns `smart link run` if successful |
| `commands/StartDevice` | `start` | |
| `commands/StartTimer` | `start time` | |
| `commands/DeviceStatus` | `status` | should return one of the following: `running`, `stopped`, `low water`, `heater error`, `power loss`, `user change parameter` |
| `commands/StopDevice` | `stop` | |
| `commands/StopTimer` | `stop time` | |
| `commands/VersionCommand` | `version` | |
| `commands/wifi/WifiPara` | `wifi para 2 %s %s WPA2PSK AES` | where the first `%s` is the wifi SSID and the second `%s` is the wifi password |
| `commands/EmptyCommand` | (EMPTY STRING) | usually queued in between commands |
| `commands/Command` | | This is a class that unifies all the other commands |
| `commands/TemperatureUnit` | | This is a class that returns long and short values of `c` and `f` |
# Anova REST API
These commands and notes have been extracted from the Anova Culinary app v2.1.0
APK after sniffing traffic from the app.
## API Endpoints
### `api.anovaculinary.com/cookers/${idCard}`
```js
`https://api.anovaculinary.com/cookers/${idCard}?secret=${secretKey}&requestKey=${unixTimeStamp}`
```
#### API Calls
##### GET
Returns the current status of the Anova device.
Example response body, without a job running:
```json
{
"status": {
"cooker_id": "anova f00-00000000000",
"is_running": false,
"current_temp": 70.6,
"target_temp": 145,
"temp_unit": "f",
"speaker_mode": true,
"alarm_active": false
}
}
```
Example response body, with a job running:
```json
{
"status": {
"cooker_id": "anova f00-00000000000",
"is_running": true,
"current_temp": 160,
"target_temp": 160,
"temp_unit": "f",
"speaker_mode": true,
"current_job_id": "f0000000-0000-0000-0000-000000000000",
"current_job": {
"job_id": "f0000000-0000-0000-0000-000000000000",
"job_type": "manual_cook",
"job_stage": "cooking",
"is_running": true,
"target_temp": 160,
"temp_unit": "f",
"timer_length": 2880,
"job_start_time": "2017-04-24T04:21:19.750381Z",
"job_update_time": "2017-04-24T04:21:20.750383Z",
"max_circulation_interval": 600,
"threshold_temp": 40,
"job_info": null
},
"alarm_active": true
}
}
```
##### POST
Sets the status of the Anova device to that of the properties sent. Returns the
newly updated status in the response.
Example request body:
```json
{
"target_temp": 150.0,
"temp_unit": "f"
}
```
Example response body:
```json
{
"status": {
"cooker_id": "anova f00-00000900000",
"is_running": false,
"current_temp": 70.6,
"target_temp": 150,
"temp_unit": "f",
"speaker_mode": true,
"alarm_active": false
}
}
```
### `api.anovaculinary.com/cookers/${idCard}/jobs`
```js
`https://api.anovaculinary.com/cookers/${idCard}/jobs?secret=${secretKey}&requestKey=${unixTimeStamp}`
```
#### API Calls
##### GET
Returns a list of all jobs run.
Example:
```json
{
"jobs": [
{
"job_id": "f0000000-0000-0000-0000-000000000000",
"job_type": "manual_cook",
"job_stage": "preheating",
"is_running": true,
"target_temp": 129,
"temp_unit": "f",
"timer_length": 3600,
"job_start_time": "2017-04-20T05:10:35.414946Z",
"job_update_time": "2017-04-20T05:10:36.414948Z",
"max_circulation_interval": 600,
"threshold_temp": 40,
"job_info": {
"display_item_identifier": "668",
"duration": 3600,
"job_type": "manual_cook",
"source": "1",
"source_identifier": "668",
"temperature": 129,
"temperature_unit": "F"
}
},
{
"job_id": "f0000000-0000-0000-0000-000000000001",
"job_type": "manual_cook",
"job_stage": "completed",
"is_running": false,
"completion_type": "canceled",
"target_temp": 100,
"temp_unit": "f",
"job_start_time": "2017-04-20T05:03:01.904247Z",
"job_update_time": "2017-04-20T05:03:02.90425Z",
"max_circulation_interval": 600,
"threshold_temp": 40,
"job_info": null
}
]
}
```
##### POST
Send a request to start a new job.
* `job_info`: An object with any number of custom properties. **These will be
saved to the job history so be careful what you put in there!** Typically
used to set a specific recipe by the Anova app (the
`display_item_identifier` / `source_identifier` is the ID of the recipe and
one could assume that `source` is where the recipe ID came from). The value
will default to `null` if no object is given.
Example object:
```json
{
"duration": 3600,
"display_item_identifier": "668",
"job_type": "manual_cook",
"source": "1",
"temperature": 129.0,
"temperature_unit": "F",
"source_identifier": "668"
}
```
* `job_type`: A required value which doesn't seem to ever be different than
`manual_cook`.
* `target_temp`: A temperature to preheat and cook at. Can be a float with one
decimal place of precision (rounds up small decimals).
* `temp_unit`: A temperature unit `f` or `c` for Fahrenheit or Celsius
respectively. This is usually sent along with `target_temp` by the Anova
app.
* `timer_length`: The length of time in seconds to cook after the water has
been preheated. Required.
* `threshold_temp`: Not really sure what this is, but it's a configurable
integer.
* `max_circulation_interval`: Not really sure what this is either, but it's
also a configurable integer.
Example request body:
```json
{
"job_info": {
"display_item_identifier": "668",
"duration": 3600,
"job_type": "manual_cook",
"source": "1",
"source_identifier": "668",
"temperature": 129,
"temperature_unit": "F"
},
"job_type": "manual_cook",
"target_temp": 129.0,
"temp_unit": "f",
"timer_length": 3600
}
```
Example response body:
```json
{
"job": {
"job_id": "f0000000-0000-0000-0000-000000000002",
"job_type": "manual_cook",
"job_stage": "preheating",
"is_running": true,
"target_temp": 129,
"temp_unit": "f",
"timer_length": 3600,
"job_start_time": "2017-04-20T05:10:35.414946Z",
"job_update_time": "2017-04-20T05:10:36.414948Z",
"max_circulation_interval": 600,
"threshold_temp": 40,
"job_info": {
"display_item_identifier": "668",
"duration": 3600,
"job_type": "manual_cook",
"source": "1",
"source_identifier": "668",
"temperature": 129,
"temperature_unit": "F"
}
}
}
```
* `job_stage`: indicates the status of the job. So far the possible values
are:
* `preheating`
* `cooking`
* `completed`
Bad response, if required values are not sent:
```json
{
"job": null
}
```
## GET / POST Error
If the Anova device is not connected to wifi the, the response will not include
a `status`, `job`, `jobs` or another other normal property, instead it will give
you JSON with an `error` property with an accompanying `code`.
Example error response body:
```json
{
"error": {
"code": 404
}
}
```
## API URL Variables
* `idCard`: the ID response from the BLE `GET_ID_CARD` command. Should look
something similar to: `anova f00-a0000d00000`.
* `secretKey`: the secret key sent from the BLE `SET_NUMBER` command. The
secret key must be a string of 10 lowercase alphanumeric character. Setting
this will make your phone "unsync" itself because it will no longer have the
secret key. It's best to get the secret key from your phone so everything
works nicely.
* `unixTimeStamp`: an optional query string parameter using the current Unix
timestamp.
> NOTE: HTTPS is not enforced or redirected, please make sure to only use HTTPS
> otherwise you are exposing people to prying eyes.
import anova from './lib';
// Replace the `SECRET_KEY` with your devices secret key. Can be obtained by
// looking at the API calls made by the Anova app or by setting a new one with
// the `anova.ble.commands.SET_NUMBER` command in conjunction with
// `anova.ble.randomString`.
const SECRET_KEY = '0000000000';
const log = prefix => async promise => (
console.log(`${prefix}:`, await promise) // eslint-disable-line no-console
);
const logBle = log('BLE');
const logRest = log('REST');
(async () => {
const sendCommand = await anova.ble.connect();
const id = await sendCommand(anova.ble.commands.GET_ID_CARD());
anova.rest.connect({ id: id.replace(/\r/g, ''), secretKey: SECRET_KEY });
const promises = [
logBle(sendCommand(anova.ble.commands.GET_ID_CARD())),
logBle(sendCommand(anova.ble.commands.VERSION())),
logBle(sendCommand(anova.ble.commands.READ_STATUS())),
logRest(anova.rest.commands.READ_STATUS()),
logRest(anova.rest.commands.READ_JOBS()),
];
await Promise.all(promises);
process.exit();
})().catch(
err => {
console.error(err); // eslint-disable-line no-console
process.exit(1);
},
);
import {
UNIT_CELCIUS,
UNIT_FAHRENHEIT,
TEMPERATURE_FAHRENHEIT_MAX,
TEMPERATURE_CELCIUS_MIN,
TIMER_MAX,
TIMER_MIN,
} from '../constants';
import {
CALIBRATION_MAX,
CALIBRATION_MIN,
SECRET_KEY_REGEX,
} from './constants';
import {
checkIsFloat,
checkIsInteger,
checkIsInBetween,
checkIsString,
checkIsPassingRegEx,
checkIsOneOf,
} from '../lib/inputChecks';
import oneDecimalPlacePrecision from '../lib/oneDecimalPlacePrecision';
// Cooking Actions
export const START = () => 'start';
export const STOP = () => 'stop';
// Cooking Configuration
export const SET_UNIT = unit => {
checkIsString(unit);
checkIsOneOf([UNIT_CELCIUS, UNIT_FAHRENHEIT])(unit);
return `set unit ${unit}`;
};
export const SET_TARGET_TEMPERATURE = targetTemperature => {
checkIsFloat(targetTemperature);
checkIsInBetween(TEMPERATURE_FAHRENHEIT_MAX, TEMPERATURE_CELCIUS_MIN)(targetTemperature);
return `set temp ${oneDecimalPlacePrecision(targetTemperature)}`;
};
export const SET_CALIBRATION_FACTOR = calibrationFactor => {
checkIsFloat(calibrationFactor);
checkIsInBetween(CALIBRATION_MAX, CALIBRATION_MIN)(calibrationFactor);
return `cal ${oneDecimalPlacePrecision(calibrationFactor)}`;
};
// Cooking Info
export const READ_STATUS = () => 'status';
export const READ_TEMPERATURE = () => 'read temp';
export const READ_DATA = () => 'read data';
export const READ_UNIT = () => 'read unit';
export const READ_TARGET_TEMPERATURE = () => 'read set temp';
export const READ_CALIBRATION_FACTOR = () => 'read cal';
// Timer/Alarm Actions
export const START_TIME = () => 'start timer';
export const STOP_TIME = () => 'stop time';
export const CLEAR_ALARM = () => 'clear alarm';
// Timer/Alarm Configuration
export const SET_TIMER = minutes => {
checkIsInteger(minutes);
checkIsInBetween(TIMER_MAX, TIMER_MIN)(minutes);
return `set timer ${minutes}`;
};
export const SET_SPEAKER_OFF = () => 'set speaker off';
// Timer/Alarm Info
export const READ_TIMER = () => 'read timer';
// Utils
export const GET_ID_CARD = () => 'get id card';
export const SMARTLINK_START = () => 'smartlink start';
export const SET_NAME = name => {
checkIsString(name);
return `set name ${name}`;
};
export const SET_NUMBER = secretKey => {
checkIsString(secretKey);
checkIsPassingRegEx(SECRET_KEY_REGEX)(secretKey);
return `set number ${secretKey}`;
};
export const SET_SERVER_PARAMETERS = (ip, port) => {
checkIsString(ip);
checkIsInteger(port);
// TODO: Check that ip and port are valid
return `server para ${ip} ${port}`;
};
export const SET_WIFI_PARAMETERS = (ssid, password) => {
checkIsString(ssid);
checkIsString(password);
// TODO: Check that ssid and password are valid
return `wifi para 2 ${ssid} ${password} WPA2PSK AES`;
};
// Other Info
export const VERSION = () => 'version';
export const READ_DATE = () => 'read date';
// Deprecated
// This probably won't work
export const SET_LED = (r, g, b) => `set led ${r} ${g} ${b}`;
import noble from 'noble';
import Crowd from 'crowd-control';
import {
DEVICE_SERVICE_UUID,
DEVICE_CHARACTERISTIC_UUID,
} from './constants';
const crowd = new Crowd();
const checkError = callback => (error, ...args) => {
if (error) throw error;
if (typeof callback === 'function') callback(...args);
};
const sendCommand = characteristic => (command, timeout) => (
crowd.control(
() => (
new Promise(
async resolve => {
const getData = () => {
let allData = '';
return data => {
const newData = data.toString('utf8');
allData += newData;
if (!/\r/g.test(newData)) return;
resolve(allData);
characteristic.removeListener('data', getData);
};
};
characteristic.on('data', getData());
characteristic.write(
Buffer.from(`${command}\r`, 'utf8'),
true,
checkError(),
);
},
)
),
)(timeout)
);
export default addressFilter => (
new Promise(
resolve => {
// Find the proper characteristic