├── AcaiaArduinoBLE.cpp ├── AcaiaArduinoBLE.h ├── JLCPCB_submit_V2.1.zip ├── JLCPCB_submit_V3.0.zip ├── LICENSE ├── README.md ├── examples ├── bare_minimum │ └── bare_minimum.ino └── shotStopper │ └── shotStopper.ino ├── halfCase_V2.1.stl ├── halfCase_V3.0.stl ├── keywords.txt └── library.properties /AcaiaArduinoBLE.cpp: -------------------------------------------------------------------------------- 1 | /* 2 | AcaiaArduinoBLE.cpp - Library for connecting to 3 | an Acaia Scale using the ArduinoBLE library. 4 | Created by Tate Mazer, December 13, 2023. 5 | Released into the public domain. 6 | 7 | Adding Generic Scale Support, Pio Baettig 8 | */ 9 | #include "Arduino.h" 10 | #include "AcaiaArduinoBLE.h" 11 | #include 12 | 13 | byte IDENTIFY[20] = { 0xef, 0xdd, 0x0b, 0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x30, 0x31, 0x32, 0x33, 0x34, 0x9a, 0x6d }; 14 | byte HEARTBEAT[7] = { 0xef, 0xdd, 0x00, 0x02, 0x00, 0x02, 0x00 }; 15 | byte NOTIFICATION_REQUEST[14] = { 0xef, 0xdd, 0x0c, 0x09, 0x00, 0x01, 0x01, 0x02, 0x02, 0x05, 0x03, 0x04, 0x15, 0x06 }; 16 | byte START_TIMER[7] = { 0xef, 0xdd, 0x0d, 0x00, 0x00, 0x00, 0x00 }; 17 | byte STOP_TIMER[7] = { 0xef, 0xdd, 0x0d, 0x00, 0x02, 0x00, 0x02 }; 18 | byte RESET_TIMER[7] = { 0xef, 0xdd, 0x0d, 0x00, 0x01, 0x00, 0x01 }; 19 | byte TARE_ACAIA[6] = { 0xef, 0xdd, 0x04, 0x00, 0x00, 0x00 }; 20 | byte TARE_GENERIC[6] = { 0x03, 0x0a, 0x01, 0x00, 0x00, 0x08 }; 21 | byte START_TIMER_GENERIC[6] = { 0x03, 0x0a, 0x04, 0x00, 0x00, 0x0a }; 22 | byte STOP_TIMER_GENERIC[6] = { 0x03, 0x0a, 0x05, 0x00, 0x00, 0x0d }; 23 | byte RESET_TIMER_GENERIC[6] = { 0x03, 0x0a, 0x06, 0x00, 0x00, 0x0c }; 24 | 25 | /* Generic commands from 26 | https://github.com/graphefruit/Beanconqueror/blob/master/src/classes/devices/felicita/constants.ts 27 | */ 28 | 29 | AcaiaArduinoBLE::AcaiaArduinoBLE(bool debug){ 30 | _debug = debug; 31 | _currentWeight = 0; 32 | _connected = false; 33 | _packetPeriod = 0; 34 | } 35 | 36 | bool AcaiaArduinoBLE::init(String mac){ 37 | unsigned long start = millis(); 38 | _lastPacket = 0; 39 | 40 | if (mac == ""){ 41 | BLE.scan(); 42 | }else if (!BLE.scanForAddress(mac)){ 43 | Serial.print("Failed to find "); 44 | Serial.println(mac); 45 | return false; 46 | } 47 | 48 | do{ 49 | BLEDevice peripheral = BLE.available(); 50 | 51 | if(_debug && peripheral){ 52 | // discovered a peripheral, print out address, local name, and advertised service 53 | Serial.print("Found "); 54 | Serial.print(peripheral.address()); 55 | Serial.print(" '"); 56 | Serial.print(peripheral.localName()); 57 | Serial.print("' "); 58 | Serial.print(peripheral.advertisedServiceUuid()); 59 | Serial.println(); 60 | } 61 | 62 | if (peripheral && isScaleName(peripheral.localName())) { 63 | BLE.stopScan(); 64 | 65 | Serial.println("Connecting ..."); 66 | if (peripheral.connect()) { 67 | Serial.println("Connected"); 68 | } else { 69 | Serial.println("Failed to connect!"); 70 | return false; 71 | } 72 | 73 | Serial.println("Discovering attributes ..."); 74 | if (peripheral.discoverAttributes()) { 75 | Serial.println("Attributes discovered");; 76 | } else { 77 | Serial.println("Attribute discovery failed!"); 78 | peripheral.disconnect(); 79 | return false; 80 | } 81 | 82 | if(_debug){ 83 | // read and print device name of peripheral 84 | Serial.println(); 85 | Serial.print("Device name: "); 86 | Serial.println(peripheral.deviceName()); 87 | Serial.print("Appearance: 0x"); 88 | Serial.println(peripheral.appearance(), HEX); 89 | Serial.println(); 90 | 91 | // loop the services of the peripheral and explore each 92 | for (int i = 0; i < peripheral.serviceCount(); i++) { 93 | BLEService service = peripheral.service(i); 94 | exploreService(service); 95 | } 96 | } 97 | 98 | // Determine type of scale 99 | if(peripheral.characteristic(READ_CHAR_OLD_VERSION).canSubscribe()){ 100 | Serial.println("Old version Acaia Detected"); 101 | _type = OLD; 102 | _write = peripheral.characteristic(WRITE_CHAR_OLD_VERSION); 103 | _read = peripheral.characteristic(READ_CHAR_OLD_VERSION); 104 | }else if(peripheral.characteristic(READ_CHAR_NEW_VERSION).canSubscribe()){ 105 | Serial.println("New version Acaia Detected"); 106 | _type = NEW; 107 | _write = peripheral.characteristic(WRITE_CHAR_NEW_VERSION); 108 | _read = peripheral.characteristic(READ_CHAR_NEW_VERSION); 109 | } else if(peripheral.characteristic(READ_CHAR_GENERIC).canSubscribe()){ 110 | Serial.println("Generic Scale Detected"); 111 | _type = GENERIC; 112 | _write = peripheral.characteristic(WRITE_CHAR_GENERIC); 113 | _read = peripheral.characteristic(READ_CHAR_GENERIC); 114 | } 115 | else{ 116 | Serial.println("unable to determine scale type"); 117 | return false; 118 | } 119 | 120 | if(!_read.canSubscribe()){ 121 | Serial.println("unable to subscribe to READ"); 122 | return false; 123 | }else if(!_read.subscribe()){ 124 | Serial.println("subscription failed"); 125 | return false; 126 | }else { 127 | Serial.println("subscribed!"); 128 | } 129 | 130 | if(_write.writeValue(IDENTIFY, 20)){ 131 | Serial.println("identify write successful"); 132 | }else{ 133 | Serial.println("identify write failed"); 134 | return false; 135 | } 136 | if(_write.writeValue(NOTIFICATION_REQUEST,14)){ 137 | Serial.println("notification request write successful"); 138 | }else{ 139 | Serial.println("notification request write failed"); 140 | return false; 141 | } 142 | _connected = true; 143 | _packetPeriod = 0; 144 | return true; 145 | } 146 | }while(millis() - start < 10000); 147 | 148 | Serial.println("failed to find scale"); 149 | return false; 150 | } 151 | 152 | bool AcaiaArduinoBLE::tare(){ 153 | if(_write.writeValue((_type == GENERIC ? TARE_GENERIC : TARE_ACAIA), 6)){ 154 | Serial.println("tare write successful"); 155 | return true; 156 | }else{ 157 | _connected = false; 158 | Serial.println("tare write failed"); 159 | return false; 160 | } 161 | } 162 | 163 | bool AcaiaArduinoBLE::startTimer(){ 164 | if(_write.writeValue((_type == GENERIC ? START_TIMER_GENERIC : START_TIMER), 165 | (_type == GENERIC ? 6 : 7))){ 166 | Serial.println("start timer write successful"); 167 | return true; 168 | }else{ 169 | _connected = false; 170 | Serial.println("start timer write failed"); 171 | return false; 172 | } 173 | } 174 | 175 | bool AcaiaArduinoBLE::stopTimer(){ 176 | if(_write.writeValue((_type == GENERIC ? STOP_TIMER_GENERIC : STOP_TIMER), 177 | (_type == GENERIC ? 6 : 7 ))){ 178 | Serial.println("stop timer write successful"); 179 | return true; 180 | }else{ 181 | _connected = false; 182 | Serial.println("stop timer write failed"); 183 | return false; 184 | } 185 | } 186 | 187 | bool AcaiaArduinoBLE::resetTimer(){ 188 | if(_write.writeValue((_type == GENERIC ? RESET_TIMER_GENERIC : RESET_TIMER), 189 | (_type == GENERIC ? 6 : 7 ))){ 190 | Serial.println("reset timer write successful"); 191 | return true; 192 | }else{ 193 | _connected = false; 194 | Serial.println("reset timer write failed"); 195 | return false; 196 | } 197 | } 198 | 199 | bool AcaiaArduinoBLE::heartbeat(){ 200 | if(_write.writeValue(HEARTBEAT, 7)){ 201 | _lastHeartBeat = millis(); 202 | return true; 203 | }else{ 204 | _connected = false; 205 | return false; 206 | } 207 | } 208 | float AcaiaArduinoBLE::getWeight(){ 209 | return _currentWeight; 210 | } 211 | 212 | bool AcaiaArduinoBLE::heartbeatRequired(){ 213 | if(_type == OLD || _type == NEW){ 214 | return (millis() - _lastHeartBeat) > HEARTBEAT_PERIOD_MS; 215 | }else{ 216 | return 0; 217 | } 218 | } 219 | bool AcaiaArduinoBLE::isConnected(){ 220 | return _connected; 221 | } 222 | bool AcaiaArduinoBLE::newWeightAvailable(){ 223 | bool newWeightPacket = false; 224 | 225 | //check how long its been since we last got a response 226 | if(_lastPacket && millis()-_lastPacket > MAX_PACKET_PERIOD_MS){ 227 | Serial.println("timeout!"); 228 | //reset connection 229 | _connected = false; 230 | BLE.disconnect(); 231 | return false; 232 | }else if(_read.valueUpdated()){ 233 | byte input[] = {0,0,0,0,0,0,0,0,0,0,0,0,0}; 234 | int l = _read.valueLength(); 235 | 236 | // Get packet 237 | if(10 >= l || //10 byte packets for pre-2021 lunar 238 | (13 >= l && OLD != _type) || //13 byte packets for pyxis and older lunar 2021 fw 239 | (14 == l && OLD == _type) || //14 byte packets for lunar 2021 AL008 240 | (17 == l && NEW == _type) || //17 byte packets for newer lunar 2021 fw 241 | (20 == l && GENERIC == _type) //18 byte packets for generic scales 242 | ){ 243 | _read.readValue(input, (l > 13) ? 13 : l); // readValue() seems to crash whenever l > weight packet (10, 13 or 18) 244 | 245 | if(_debug){ 246 | Serial.print(l); 247 | Serial.print(": 0x"); 248 | 249 | printData(input, l); 250 | Serial.println(); 251 | } 252 | } 253 | 254 | // Parse New style data packet 255 | if (NEW == _type && (13 == l || 17 == l) && input[4] == 0x05) 256 | { 257 | //Grab weight bytes (5 and 6) 258 | // apply scaling based on the unit byte (9) 259 | // get sign byte (10) 260 | _currentWeight = (((input[6] & 0xff) << 8) + (input[5] & 0xff)) 261 | / pow(10,input[9]) 262 | * ((input[10] & 0x02) ? -1 : 1); 263 | newWeightPacket = true; 264 | 265 | // Parse old style data packet 266 | }else if( OLD == _type && (l == 10 || l == 14)){ 267 | //Grab weight bytes (2 and 3), 268 | // apply scaling based on the unit byte (6) 269 | // get sign byte (7) 270 | _currentWeight = (((input[3] & 0xff) << 8) + (input[2] & 0xff)) 271 | / pow(10, input[6]) 272 | * ((input[7] & 0x02) ? -1 : 1); 273 | newWeightPacket = true; 274 | 275 | }else if( GENERIC == _type && l == 20){ 276 | //Grab weight bytes (3-8), 277 | // get sign byte (2) 278 | _currentWeight = (( input[7] << 16) | (input[8] << 8) | input[9]); 279 | 280 | if (input[6] == 45) { // Check if the value is negative 281 | _currentWeight = -_currentWeight; 282 | } 283 | _currentWeight = _currentWeight / 100; 284 | newWeightPacket = true; 285 | } 286 | if(newWeightPacket){ 287 | if(_lastPacket){ 288 | _packetPeriod = millis() - _lastPacket; 289 | } 290 | _lastPacket = millis(); 291 | } 292 | return newWeightPacket; 293 | } 294 | else{ 295 | return false; 296 | } 297 | } 298 | bool AcaiaArduinoBLE::isScaleName(String name){ 299 | String nameShort = name.substring(0,5); 300 | 301 | return nameShort == "CINCO" 302 | || nameShort == "ACAIA" 303 | || nameShort == "PYXIS" 304 | || nameShort == "LUNAR" 305 | || nameShort == "PEARL" 306 | || nameShort == "PROCH" 307 | || nameShort == "BOOKO"; 308 | } 309 | 310 | void AcaiaArduinoBLE::exploreService(BLEService service) { 311 | // print the UUID of the service 312 | Serial.print("Service "); 313 | Serial.println(service.uuid()); 314 | 315 | // loop the characteristics of the service and explore each 316 | for (int i = 0; i < service.characteristicCount(); i++) { 317 | BLECharacteristic characteristic = service.characteristic(i); 318 | 319 | exploreCharacteristic(characteristic); 320 | } 321 | } 322 | 323 | void AcaiaArduinoBLE::exploreCharacteristic(BLECharacteristic characteristic) { 324 | // print the UUID and properties of the characteristic 325 | Serial.print("\tCharacteristic "); 326 | Serial.print(characteristic.uuid()); 327 | Serial.print(", properties 0x"); 328 | Serial.print(characteristic.properties(), HEX); 329 | 330 | // check if the characteristic is readable 331 | if (characteristic.canRead()) { 332 | // read the characteristic value 333 | characteristic.read(); 334 | 335 | if (characteristic.valueLength() > 0) { 336 | // print out the value of the characteristic 337 | Serial.print(", value 0x"); 338 | printData(characteristic.value(), characteristic.valueLength()); 339 | } 340 | } 341 | Serial.println(); 342 | 343 | // loop the descriptors of the characteristic and explore each 344 | for (int i = 0; i < characteristic.descriptorCount(); i++) { 345 | BLEDescriptor descriptor = characteristic.descriptor(i); 346 | 347 | exploreDescriptor(descriptor); 348 | } 349 | } 350 | 351 | void AcaiaArduinoBLE::exploreDescriptor(BLEDescriptor descriptor) { 352 | // print the UUID of the descriptor 353 | Serial.print("\t\tDescriptor "); 354 | Serial.print(descriptor.uuid()); 355 | 356 | // read the descriptor value 357 | descriptor.read(); 358 | 359 | // print out the value of the descriptor 360 | Serial.print(", value 0x"); 361 | printData(descriptor.value(), descriptor.valueLength()); 362 | 363 | Serial.println(); 364 | } 365 | 366 | void AcaiaArduinoBLE::printData(const unsigned char data[], int length) { 367 | for (int i = 0; i < length; i++) { 368 | unsigned char b = data[i]; 369 | 370 | if (b < 16) { 371 | Serial.print("0"); 372 | } 373 | 374 | Serial.print(b, HEX); 375 | } 376 | } 377 | -------------------------------------------------------------------------------- /AcaiaArduinoBLE.h: -------------------------------------------------------------------------------- 1 | /* 2 | AcaiaArduinoBLE.h - Library for connecting to 3 | an Acaia Scale using the ArduinoBLE library. 4 | Created by Tate Mazer, December 13, 2023. 5 | Released into the public domain. 6 | 7 | Pio Baettig: Adding Felicita Arc support 8 | 9 | Known Bugs: 10 | * Only supports Grams 11 | */ 12 | #ifndef AcaiaArduinoBLE_h 13 | #define AcaiaArduinoBLE_h 14 | 15 | #define WRITE_CHAR_OLD_VERSION "2a80" 16 | #define READ_CHAR_OLD_VERSION "2a80" 17 | #define WRITE_CHAR_NEW_VERSION "49535343-8841-43f4-a8d4-ecbe34729bb3" 18 | #define READ_CHAR_NEW_VERSION "49535343-1e4d-4bd9-ba61-23c647249616" 19 | #define WRITE_CHAR_GENERIC "ff12" 20 | #define READ_CHAR_GENERIC "ff11" 21 | #define HEARTBEAT_PERIOD_MS 2750 22 | #define MAX_PACKET_PERIOD_MS 5000 23 | 24 | #include "Arduino.h" 25 | #include 26 | 27 | enum scale_type{ 28 | OLD, // Lunar (pre-2021) 29 | NEW, // Lunar (2021), Pyxis 30 | GENERIC // Felicita Arc, etc 31 | }; 32 | 33 | class AcaiaArduinoBLE{ 34 | public: 35 | AcaiaArduinoBLE(bool debug); 36 | bool init(String = ""); 37 | bool tare(); 38 | bool startTimer(); 39 | bool stopTimer(); 40 | bool resetTimer(); 41 | bool heartbeat(); 42 | float getWeight(); 43 | bool heartbeatRequired(); 44 | bool isConnected(); 45 | bool newWeightAvailable(); 46 | private: 47 | bool isScaleName(String); 48 | 49 | //debug functions 50 | void exploreService(BLEService service); 51 | void exploreCharacteristic(BLECharacteristic characteristic); 52 | void exploreDescriptor(BLEDescriptor descriptor); 53 | void printData(const unsigned char data[], int length); 54 | 55 | float _currentWeight; 56 | BLECharacteristic _write; 57 | BLECharacteristic _read; 58 | long _lastHeartBeat; 59 | bool _connected; 60 | scale_type _type; 61 | bool _debug; 62 | long _packetPeriod; 63 | long _lastPacket; 64 | }; 65 | 66 | #endif 67 | -------------------------------------------------------------------------------- /JLCPCB_submit_V2.1.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tatemazer/AcaiaArduinoBLE/7fb341970b2f563e646292dc447c1f254bf9fcf5/JLCPCB_submit_V2.1.zip -------------------------------------------------------------------------------- /JLCPCB_submit_V3.0.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tatemazer/AcaiaArduinoBLE/7fb341970b2f563e646292dc447c1f254bf9fcf5/JLCPCB_submit_V3.0.zip -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Tate Mazer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AcaiaArduinoBLE 2 | Acaia / Bookoo Scale Gateway using the ArduinoBLE library for devices such as the esp32, arduino nano esp32, and arduino nano iot 33. 3 | This is an Arduino Library which can be found in the Arduino IDE Library Manager. 4 | 5 | ## Scale Compatibility 6 | 7 | | Mfr | Model | Submodel | Firmware | Connection Performance | Auto-Tare | Auto-Start/Stop Timer | Auto-Reset Timer | 8 | | ------ | ------- | ------- |------- |------ | ------ |------ |------ | 9 | | Acaia | Lunar | USB-Micro
(Pre-2021) | v2.6.019 | Great | Yes | Yes | Untested 10 | | Acaia | Lunar | USB-C
(2021 version) | v1.0.016 | Ok | Yes | Yes | Yes 11 | | Acaia | Pearl S | USB-Micro | v1.0.056 | Ok | Yes | Yes | Yes 12 | | Acaia | Pearl S | USB-C | ---- | Ok | Yes | Yes | Yes 13 | | Acaia | Pyxis | ---- | v1.0.022 | Good | Not Recommended (too sensitive) | Yes | Yes 14 | | Bookoo | Themis | ---- | v1.0.5 | Great | Yes | Yes | Yes 15 | 16 | 17 | ## Requirements 18 | This library is intended to be used with any arduino device which is compatible with the [ArduinoBLE](https://www.arduino.cc/reference/en/libraries/arduinoble/) library. 19 | 20 | As of version V2.0.0, non-volatile storage for the setpoint and offset is only available for ESP32-based devices. 21 | 22 | ## Printed Circuit Board 23 | ![shotStopperV3 screenshot](https://github.com/user-attachments/assets/a09fe8fb-3705-44c0-88a2-07c61d67b8f6) 24 | 25 | The included "shotStopper" example code uses the ShotStopper PCB to make it simple to control your espresso machine using the scale. Files are hosted on [altium 365](https://365.altium.com/files/A15F83F1-2418-4843-B2E7-787275773560). 26 | 27 | A kit can also be ordered by visiting [tatemazer.com](https://tatemazer.com/store) 28 | 29 | Join the discord for updates and support: https://discord.gg/NMXb5VYtre 30 | 31 | [![Video showing developmnent of the shotStopper](https://img.youtube.com/vi/434hrQDGtxo/0.jpg)](https://youtu.be/434hrQDGtxo) 32 | 33 | ## Espresso Machine Compatibility* 34 | 35 | | Model | Powered by Machine (5V) | Brew State Detection Method | Officially Documented | 36 | | ----- | ----------------------- | --------------------------- | ---------------- | 37 | | GS3 | No, requires included power supply | Brew Button | Yes | 38 | | Linea Micra | Yes | Brew Switch | Yes | 39 | | Linea Mini | Older, non-IoT machines may require a power supply | Brew Switch | Yes | 40 | | Silvia Pro (X) | Yes | Brew Button | Yes | 41 | | Stone Espresso | Yes | Solenoid Valve (Reed Switch) | Yes | 42 | | Ascaso Steel Duo PID | Untested | Brew Button | No 43 | 44 | *Ace Dotshot cannot be combined with the shotStopper at this time. Fortunately, shot duration is automated at the scale with the shotStopper, making the dotShot redundant. 45 | 46 | ## ShotStopper Example Code Configuration 47 | 48 | The following variables at the top of the shotStopper.ino file can be configured by the user: 49 | 50 | `MOMENTARY` 51 | * true for momentary switches such as GS3 AV, Rancilio Silvia Pro, etc. 52 | * false for latching switches such as Linea Mini/Micra, stone, etc. 53 | 54 | `REEDSWITCH` 55 | * true if a reed switch on the brew solenoid is being used to determine the brew state. This is typically not necessary so set to FALSE by default. This feature is only available for non-momentary-switches. 56 | 57 | `AUTOTARE` 58 | * true by default. The scale will automatically tare when the shot is started, and, if MOMENTARY is false, will perform another tare at 3 seconds to notify the user that the switch is latched and should be returned to the home position. 59 | * if set to false, the shotStopper will never send a tare command. It is the user's responsibility to tare before each shot. This may be helpful if the scale is not stable when the shot begins, and thus the scale is unable to tare reliably. 60 | 61 | ## Demo 62 | 63 | You can find a demo on Youtube: 64 | 65 | [![Video showing an shotStopper pulling a shot on a silvia pro](https://img.youtube.com/vi/oP3Cmke6daE/0.jpg)](https://www.youtube.com/shorts/oP3Cmke6daE) 66 | 67 | ## Project Status 68 | 69 | Firmware: 70 | 71 | ☑ Connect Acaia Pyxis to ESP32 72 | 73 | ☑ Tare Command 74 | 75 | ☑ Receive Weight Data 76 | 77 | ☑ shotStopper Espresso Machine Brew-By-Weight Firmware 78 | 79 | ☑ Compatibility with Lunar (Pre-2021) 80 | 81 | ☑ Compatibility with Lunar 2021 82 | 83 | ☑ Positive *and* negative weight support 84 | 85 | ☑ Latching-switch support (LM Mini, LM Micra, etc) 86 | 87 | ☑ Auto-reconnect 88 | 89 | ☑ change setpoint over bluetooth 90 | 91 | ☑ maintain setpoint and offset after powercycle 92 | 93 | ☑ auto start/stop timer 94 | 95 | ☑ flowrate-based shot end-time 96 | 97 | ☑ auto timer reset 98 | 99 | ⬜ Improve Pyxis Tare Command Reliability 100 | 101 | 102 | 103 | Scale Compatibility: 104 | 105 | ☑ Acaia Pyxis 106 | 107 | ☑ Acaia Lunar (usb-micro) 108 | 109 | ☑ Acaia Lunar 2021 (usb-c) 110 | 111 | ☑ Pearl S 112 | 113 | ❌ Felicita Arc (buggy, see bug report) 114 | 115 | ☑ Bookoo 116 | 117 | Hardware: 118 | 119 | ☑ PCB Design for Low Voltage Switches (V1.1) 120 | 121 | ☑ 3D-Printed Half Case 122 | 123 | ☑ Compatibility with La Marzocco GS3 AV 124 | 125 | ☑ Compatibility with Rancilio Silvia Pro (and Pro X) 126 | 127 | ❌ Compatibility with La Marzocco Linea Classic S (Not Compatible, requires investigation) 128 | 129 | ☑ Compatibility with Stone Espresso (requires reed switch) 130 | 131 | ☑ Compatibility with La Marzocco Mini 132 | 133 | ☑ Compatibility with La Marzocco Micra (V2.0) 134 | 135 | ☑ Powered by espresso machine (V2.0) 136 | 137 | ☑ Reed switch input (V2.0) 138 | 139 | ☑ on-board esp32 module (V3.0) 140 | 141 | ⬜ Compatibility with Breville (presumed but untested) 142 | 143 | ⬜ Support for High-Voltage Switches (Hall-Effect Sensor and SSR?) 144 | 145 | Sales: 146 | 147 | ☑ Beta Users Determined 148 | 149 | ☑ Beta Units Built 150 | 151 | ☑ Beta Units Shipped 152 | 153 | ☑ Beta Test Complete 154 | 155 | ☑ Sales Open For GS3, Silvia, and Micra In the US 156 | 157 | ☑ Sales Open for Linea Mini 158 | 159 | ☑ International Sales Open 160 | 161 | ## Bugs/Missing 162 | 1. Tare command is less reliable than pressing the tare button. 163 | 2. Only supports grams. 164 | 165 | # Acknowledgement 166 | This is largely a basic port of the [LunarGateway](https://github.com/frowin/LunarGateway/) library written for the ESP32. 167 | 168 | In addition to some minor notes from [pyacaia](https://github.com/lucapinello/pyacaia) library written for raspberryPI. 169 | 170 | Felicita Arc support contributions from baettigp 171 | Bookoo contributions from philgood 172 | lunar 2019 contributions from jniebuhr 173 | -------------------------------------------------------------------------------- /examples/bare_minimum/bare_minimum.ino: -------------------------------------------------------------------------------- 1 | #include 2 | 3 | #define DEBUG false 4 | 5 | AcaiaArduinoBLE scale(DEBUG); 6 | void setup() { 7 | Serial.begin(115200); 8 | while (!Serial) {} 9 | Serial.println("Scale Interface test"); 10 | 11 | // initialize the Bluetooth® Low Energy hardware 12 | BLE.begin(); 13 | // Optionally add your Mac Address as an argument: acaia.init("##:##:##:##:##:##"); 14 | scale.init(); 15 | scale.tare(); 16 | scale.tare(); 17 | } 18 | 19 | void loop() { 20 | // Send a heartbeat message to the acaia periodically to maintain connection 21 | if (scale.heartbeatRequired()) { 22 | scale.heartbeat(); 23 | } 24 | 25 | // always call newWeightAvailable to actually receive the datapoint from the scale, 26 | // otherwise getWeight() will return stale data 27 | if (scale.newWeightAvailable()) { 28 | Serial.println(scale.getWeight()); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /examples/shotStopper/shotStopper.ino: -------------------------------------------------------------------------------- 1 | /* 2 | shotStopper.ino - Example of using an acaia scale to brew by weight with an espresso machine 3 | 4 | Immediately Connects to a nearby acaia scale, 5 | tare's the scale when the "in" gpio is triggered (active low), 6 | and then triggers the "out" gpio to stop the shot once ( goalWeight - weightOffset ) is achieved. 7 | 8 | Tested on a Acaia Pyxis, Arduino nano ESP32, and La Marzocco GS3. 9 | 10 | Note that only the EEPROM library only supports ESP32-based controllers. 11 | 12 | To set the Weight over BLE, use a BLE app such as LightBlue to connect 13 | to the "shotStopper" BLE device and read/write to the weight characteristic, 14 | otherwise the weight is defaulted to 36g. 15 | 16 | Created by Tate Mazer, 2023. 17 | 18 | Released under the MIT license. 19 | 20 | https://github.com/tatemazer/AcaiaArduinoBLE 21 | 22 | */ 23 | 24 | #include 25 | #include 26 | 27 | #define MAX_OFFSET 5 // In case an error in brewing occured 28 | #define MIN_SHOT_DURATION_S 3 //Useful for flushing the group. 29 | // This ensure that the system will ignore 30 | // "shots" that last less than this duration 31 | #define MAX_SHOT_DURATION_S 50 //Primarily useful for latching switches, since user 32 | // looses control of the paddle once the system 33 | // latches. 34 | #define BUTTON_READ_PERIOD_MS 5 35 | #define DRIP_DELAY_S 3 // Time after the shot ended to measure the final weight 36 | 37 | #define EEPROM_SIZE 2 // This is 1-Byte 38 | #define WEIGHT_ADDR 0 // Use the first byte of EEPROM to store the goal weight 39 | #define OFFSET_ADDR 1 40 | 41 | #define DEBUG false 42 | 43 | #define N 10 // Number of datapoints used to calculate trend line 44 | 45 | //User defined*** 46 | #define MOMENTARY false //Define brew switch style. 47 | // True for momentary switches such as GS3 AV, Silvia Pro 48 | // false for latching switches such as Linea Mini/Micra 49 | #define REEDSWITCH false // Set to true if the brew state is being determined 50 | // by a reed switch attached to the brew solenoid 51 | #define AUTOTARE true // Automatically tare when shot is started 52 | // and 3 seconds after a latching switch brew 53 | // (as defined by MOMENTARY) 54 | //*************** 55 | 56 | // Board Hardware 57 | #ifdef ARDUINO_ESP32S3_DEV 58 | #define LED_RED 46 59 | #define LED_BLUE 45 60 | #define LED_GREEN 47 61 | #define LED_BUILTIN 48 62 | #define IN 21 63 | #define OUT 38 64 | #define REED_IN 18 65 | #else //todo: find nano esp32 identifier 66 | //LED's are defined by framework 67 | #define IN 10 68 | #define OUT 11 69 | #define REED_IN 9 70 | #endif 71 | 72 | #define BUTTON_STATE_ARRAY_LENGTH 31 73 | 74 | typedef enum {BUTTON, WEIGHT, TIME, UNDEF} ENDTYPE; 75 | 76 | // RGB Colors {Red,Green,Blue} 77 | int RED[3] = {255, 0, 0}; 78 | int GREEN[3] = {0, 255, 0}; 79 | int BLUE[3] = {0, 0, 255}; 80 | int MAGENTA[3] = {255, 0, 255}; 81 | int CYAN[3] = {0, 255, 255}; 82 | int YELLOW[3] = {255, 255, 0}; 83 | int WHITE[3] = {255, 255, 255}; 84 | int OFF[3] = {0,0,0}; 85 | int currentColor[3] = {0,0,0}; 86 | 87 | AcaiaArduinoBLE scale(DEBUG); 88 | float currentWeight = 0; 89 | uint8_t goalWeight = 0; // Goal Weight to be read from EEPROM 90 | float weightOffset = 0; 91 | float error = 0; 92 | int buttonArr[BUTTON_STATE_ARRAY_LENGTH]; // last 4 readings of the button 93 | 94 | // button 95 | int in = REEDSWITCH ? REED_IN : IN; 96 | bool buttonPressed = false; //physical status of button 97 | bool buttonLatched = false; //electrical status of button 98 | unsigned long lastButtonRead_ms = 0; 99 | int newButtonState = 0; 100 | 101 | struct Shot { 102 | float start_timestamp_s; // Relative to runtime 103 | float shotTimer; // Reset when the final drip measurement is made 104 | float end_s; // Number of seconds after the shot started 105 | float expected_end_s; // Estimated duration of the shot 106 | float weight[1000]; // A scatter plot of the weight measurements, along with time_s[] 107 | float time_s[1000]; // Number of seconds after the shot starte 108 | int datapoints; // Number of datapoitns in the scatter plot 109 | bool brewing; // True when actively brewing, otherwise false 110 | ENDTYPE end; 111 | }; 112 | 113 | //Initialize shot 114 | Shot shot = {0,0,0,0,{},{},0,false,ENDTYPE::UNDEF}; 115 | 116 | //BLE peripheral device 117 | BLEService weightService("0x0FFE"); // create service 118 | BLEByteCharacteristic weightCharacteristic("0xFF11", BLEWrite | BLERead); 119 | 120 | void setup() { 121 | setCpuFrequencyMhz(80); 122 | Serial.begin(9600); 123 | EEPROM.begin(EEPROM_SIZE); 124 | 125 | // Get stored setpoint and offset 126 | goalWeight = EEPROM.read(WEIGHT_ADDR); 127 | weightOffset = EEPROM.read(OFFSET_ADDR)/10.0; 128 | Serial.print("Goal Weight retrieved: "); 129 | Serial.println(goalWeight); 130 | Serial.print("offset retrieved: "); 131 | Serial.println(goalWeight); 132 | 133 | //If eeprom isn't initialized and has an 134 | // unreasonable weight/offset, default to 36g/1.5g 135 | if( (goalWeight < 10) || (goalWeight > 200) ){ 136 | goalWeight = 36; 137 | Serial.print("Goal Weight set to: "); 138 | Serial.println(goalWeight); 139 | } 140 | if(weightOffset > MAX_OFFSET){ 141 | weightOffset = 1.5; 142 | Serial.print("Offset set to: "); 143 | Serial.println(weightOffset); 144 | } 145 | 146 | // initialize the GPIO hardware 147 | pinMode(LED_BUILTIN, OUTPUT); 148 | pinMode(in, INPUT_PULLUP); 149 | pinMode(OUT, OUTPUT); 150 | pinMode(LED_RED, OUTPUT); 151 | pinMode(LED_GREEN, OUTPUT); 152 | pinMode(LED_BLUE, OUTPUT); 153 | setColor(OFF); 154 | 155 | // initialize the BLE hardware 156 | BLE.begin(); 157 | BLE.setLocalName("shotStopper"); 158 | BLE.setAdvertisedService(weightService); 159 | weightService.addCharacteristic(weightCharacteristic); 160 | BLE.addService(weightService); 161 | weightCharacteristic.writeValue(goalWeight); 162 | BLE.advertise(); 163 | Serial.println("Bluetooth® device active, waiting for connections..."); 164 | } 165 | 166 | void loop() { 167 | 168 | // Connect to scale 169 | while(!scale.isConnected()){ 170 | 171 | setColor(RED); 172 | scale.init(); 173 | currentWeight = 0; 174 | if(shot.brewing){ 175 | setBrewingState(false); 176 | } 177 | if(scale.isConnected()){ 178 | setColor(YELLOW); 179 | } 180 | } 181 | 182 | // Check for setpoint updates 183 | BLE.poll(); 184 | if (weightCharacteristic.written()) { 185 | Serial.print("goal weight updated from "); 186 | Serial.print(goalWeight); 187 | Serial.print(" to "); 188 | goalWeight = weightCharacteristic.value(); 189 | Serial.println(goalWeight); 190 | EEPROM.write(WEIGHT_ADDR, goalWeight); //1 byte, 0-255 191 | EEPROM.commit(); 192 | } 193 | 194 | // Send a heartbeat message to the scale periodically to maintain connection 195 | if(scale.heartbeatRequired()){ 196 | scale.heartbeat(); 197 | } 198 | 199 | // always call newWeightAvailable to actually receive the datapoint from the scale, 200 | // otherwise getWeight() will return stale data 201 | if(scale.newWeightAvailable()){ 202 | currentWeight = scale.getWeight(); 203 | 204 | Serial.print(currentWeight); 205 | 206 | if(!shot.brewing){ 207 | setColor(GREEN); 208 | } 209 | 210 | // update shot trajectory 211 | if(shot.brewing){ 212 | shot.time_s[shot.datapoints] = seconds_f()-shot.start_timestamp_s; 213 | shot.weight[shot.datapoints] = currentWeight; 214 | shot.shotTimer = shot.time_s[shot.datapoints]; 215 | shot.datapoints++; 216 | 217 | Serial.print(" "); 218 | Serial.print(shot.shotTimer); 219 | 220 | //get the likely end time of the shot 221 | calculateEndTime(&shot); 222 | Serial.print(" "); 223 | Serial.print(shot.expected_end_s); 224 | } 225 | Serial.println(); 226 | } 227 | 228 | // Read button every period 229 | if(millis() > (lastButtonRead_ms + BUTTON_READ_PERIOD_MS) ){ 230 | lastButtonRead_ms = millis(); 231 | 232 | //push back for new entry 233 | for(int i = BUTTON_STATE_ARRAY_LENGTH - 2;i>=0;i--){ 234 | buttonArr[i+1] = buttonArr[i]; 235 | } 236 | buttonArr[0] = !digitalRead(in); //Active Low 237 | 238 | //only return 1 if contains 1 239 | // Also assume the button is off for a few milliseconds 240 | // after the shot is done, there can be residual noise 241 | // from the reed switch 242 | newButtonState = 0; 243 | for(int i=0; i MIN_SHOT_DURATION_S) 270 | ){ 271 | buttonLatched = true; 272 | Serial.println("Button Latched"); 273 | digitalWrite(OUT,HIGH); Serial.println("wrote high"); 274 | // Get the scale to beep to inform user. 275 | if(AUTOTARE){ 276 | scale.tare(); 277 | } 278 | } 279 | 280 | //button released 281 | else if(!buttonLatched 282 | && !newButtonState 283 | && buttonPressed == true 284 | ){ 285 | Serial.println("Button Released"); 286 | buttonPressed = false; 287 | shot.brewing = !shot.brewing; 288 | if(!shot.brewing){ 289 | shot.end = ENDTYPE::BUTTON; 290 | } 291 | setBrewingState(shot.brewing); 292 | } 293 | 294 | //Max duration reached 295 | else if(shot.brewing && shot.shotTimer > MAX_SHOT_DURATION_S ){ 296 | shot.brewing = false; 297 | Serial.println("Max brew duration reached"); 298 | shot.end = ENDTYPE::TIME; 299 | setBrewingState(shot.brewing); 300 | } 301 | 302 | //Blink LED while brewing 303 | if(shot.brewing){ 304 | setColor( (millis()/1000)%2 ? GREEN : BLUE ); 305 | } 306 | 307 | //End shot 308 | if(shot.brewing 309 | && shot.shotTimer >= shot.expected_end_s 310 | && shot.shotTimer > MIN_SHOT_DURATION_S 311 | ){ 312 | Serial.println("weight achieved"); 313 | shot.brewing = false; 314 | shot.end = ENDTYPE::WEIGHT; 315 | setBrewingState(shot.brewing); 316 | } 317 | 318 | //Detect error of shot 319 | if(shot.start_timestamp_s 320 | && shot.end_s 321 | && currentWeight >= (goalWeight - weightOffset) 322 | && seconds_f() > shot.start_timestamp_s + shot.end_s + DRIP_DELAY_S){ 323 | shot.start_timestamp_s = 0; 324 | shot.end_s = 0; 325 | 326 | Serial.print("I detected a final weight of "); 327 | Serial.print(currentWeight); 328 | Serial.print("g. The goal was "); 329 | Serial.print(goalWeight); 330 | Serial.print("g with a negative offset of "); 331 | Serial.print(weightOffset); 332 | 333 | if( abs(currentWeight - goalWeight + weightOffset) > MAX_OFFSET ){ 334 | Serial.print("g. Error assumed. Offset unchanged. "); 335 | } 336 | else{ 337 | Serial.print("g. Next time I'll create an offset of "); 338 | weightOffset += currentWeight - goalWeight; 339 | Serial.print(weightOffset); 340 | 341 | EEPROM.write(OFFSET_ADDR, weightOffset*10); //1 byte, 0-255 342 | EEPROM.commit(); 343 | } 344 | Serial.println(); 345 | } 346 | } 347 | 348 | void setBrewingState(bool brewing){ 349 | if(brewing){ 350 | Serial.println("shot started"); 351 | shot.start_timestamp_s = seconds_f(); 352 | shot.shotTimer = 0; 353 | shot.datapoints = 0; 354 | scale.resetTimer(); 355 | scale.startTimer(); 356 | if(AUTOTARE){ 357 | scale.tare(); 358 | } 359 | Serial.println("Weight Timer End"); 360 | }else{ 361 | Serial.print("ShotEnded by "); 362 | switch (shot.end) { 363 | case ENDTYPE::TIME: 364 | Serial.println("time"); 365 | break; 366 | case ENDTYPE::WEIGHT: 367 | Serial.println("weight"); 368 | break; 369 | case ENDTYPE::BUTTON: 370 | Serial.println("button"); 371 | break; 372 | case ENDTYPE::UNDEF: 373 | Serial.println("undef"); 374 | break; 375 | } 376 | 377 | shot.end_s = seconds_f() - shot.start_timestamp_s; 378 | scale.stopTimer(); 379 | if(MOMENTARY && 380 | (ENDTYPE::WEIGHT == shot.end || ENDTYPE::TIME == shot.end)){ 381 | //Pulse button to stop brewing 382 | digitalWrite(OUT,HIGH);Serial.println("wrote high"); 383 | delay(300); 384 | digitalWrite(OUT,LOW);Serial.println("wrote low"); 385 | }else if(!MOMENTARY){ 386 | buttonLatched = false; 387 | buttonPressed = false; 388 | Serial.println("Button Unlatched and not pressed"); 389 | digitalWrite(OUT,LOW); Serial.println("wrote low"); 390 | } 391 | } 392 | 393 | // Reset 394 | shot.end = ENDTYPE::UNDEF; 395 | } 396 | void calculateEndTime(Shot* s){ 397 | 398 | // Do not predict end time if there aren't enough espresso measurements yet 399 | if( (s->datapoints < N) || (s->weight[s->datapoints-1] < 10) ){ 400 | s->expected_end_s = MAX_SHOT_DURATION_S; 401 | } 402 | else{ 403 | //Get line of best fit (y=mx+b) from the last 10 measurements 404 | float sumXY = 0, sumX = 0, sumY = 0, sumSquaredX = 0, m = 0, b = 0, meanX = 0, meanY = 0; 405 | 406 | for(int i = s->datapoints - N; i < s->datapoints; i++){ 407 | sumXY+=s->time_s[i]*s->weight[i]; 408 | sumX+=s->time_s[i]; 409 | sumY+=s->weight[i]; 410 | sumSquaredX += ( s->time_s[i] * s->time_s[i] ); 411 | } 412 | 413 | m = (N*sumXY-sumX*sumY) / (N*sumSquaredX-(sumX*sumX)); 414 | meanX = sumX/N; 415 | meanY = sumY/N; 416 | b = meanY-m*meanX; 417 | 418 | //Calculate time at which goal weight will be reached (x = (y-b)/m) 419 | s->expected_end_s = (goalWeight - weightOffset - b)/m; 420 | } 421 | } 422 | 423 | float seconds_f(){ 424 | return millis()/1000.0; 425 | } 426 | 427 | void setColor(int rgb[3]){ 428 | analogWrite(LED_RED, 255-rgb[0] ); 429 | analogWrite(LED_GREEN, 255-rgb[1] ); 430 | analogWrite(LED_BLUE, 255-rgb[2] ); 431 | currentColor[0] = rgb[0]; 432 | currentColor[1] = rgb[1]; 433 | currentColor[2] = rgb[2]; 434 | } 435 | -------------------------------------------------------------------------------- /halfCase_V2.1.stl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tatemazer/AcaiaArduinoBLE/7fb341970b2f563e646292dc447c1f254bf9fcf5/halfCase_V2.1.stl -------------------------------------------------------------------------------- /halfCase_V3.0.stl: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tatemazer/AcaiaArduinoBLE/7fb341970b2f563e646292dc447c1f254bf9fcf5/halfCase_V3.0.stl -------------------------------------------------------------------------------- /keywords.txt: -------------------------------------------------------------------------------- 1 | AcaiaArduinoBLE KEYWORD1 2 | init KEYWORD2 3 | tare KEYWORD2 4 | heartbeat KEYWORD2 5 | getWeight KEYWORD2 6 | heartbeatRequired KEYWORD2 7 | isConnected KEYWORD2 8 | newWeightAvailable KEYWORD2 -------------------------------------------------------------------------------- /library.properties: -------------------------------------------------------------------------------- 1 | name=AcaiaArduinoBLE 2 | version=3.1.2 3 | author=Tate Mazer 4 | maintainer=Tate Mazer 5 | sentence=A library that connects BLE devices to Acaia Scales. 6 | paragraph=Uses the ArduinoBLE library and should support any BLE module. 7 | category=Device Control 8 | url=https://github.com/tatemazer/AcaiaArduinoBLE 9 | architectures=esp32,samd 10 | includes=AcaiaArduinoBLE.h 11 | depends=ArduinoBLE --------------------------------------------------------------------------------