├── Alpicool.md ├── CONFIG_EXAMPLES.md ├── ChangeLog ├── DATAFORMATS.md ├── FORMATS.md ├── LICENSE ├── README.md ├── data └── littlefs │ ├── index.html │ ├── known_tags.txt │ ├── known_wifis.txt │ ├── mqtt.html │ ├── mqtt.txt │ ├── ok.html │ ├── sensors.html │ ├── style.css │ └── wifis.html ├── esp32_ble2mqtt.ino ├── esp32_ble2mqtt.littlefs.bin └── s ├── alpicool_nx42.jpg ├── mqtt_config.jpg ├── portal.jpg └── sensors_config.jpg /Alpicool.md: -------------------------------------------------------------------------------- 1 | ## About Alpicool manufactured fridges 2 | 3 | [Alpicool](https://www.alpicool.com/) is a Chinese manufacturer that makes eg. portable fridges. 4 | These exactly same fridges are sold around the World with many different brands. Eg. I myself have 5 | a "Frezzer" branded one, but it is clearly an Alpicool NX42. I have seen at least 8 different brands. 6 | 7 | The Bluetooth protocol of these fridges has been reverse engineered and 8 | [Ben Peddell has documented it](https://github.com/klightspeed/BrassMonkeyFridgeMonitor). 9 | 10 | Because I have a version without freezer unit, I could not implement support for them, because it 11 | most likely would have caused some bugs when I don't have accurate data for to debug against. 12 | So at the moment there's support for fridges without freezer only and only one at a time. 13 | 14 | Example photo of a similar fridge as mine: 15 | 16 | ![NX42](s/alpicool_nx42.jpg) 17 | -------------------------------------------------------------------------------- /CONFIG_EXAMPLES.md: -------------------------------------------------------------------------------- 1 | # Example configuration for Mosquitto and Telegraf 2 | 3 | ### [Mosquitto](https://mosquitto.org/) 4 | 5 | With this configuration Mosquitto listens its default port tcp/1883 6 | 7 | Full documentation for `mosquitto.conf` is [available at mosquitto.org](https://mosquitto.org/man/mosquitto-conf-5.html) 8 | 9 | Remember to setup pwfile for passwords too. 10 | 11 | 12 | ``` 13 | allow_duplicate_messages true 14 | password_file /etc/mosquitto/pwfile 15 | socket_domain ipv4 16 | ``` 17 | 18 | ### [Telegraf](https://docs.influxdata.com/telegraf/v1.17/) 19 | 20 | #### InfluxDB output section 21 | 22 | It's assumed here that you have set up database `home` and user `telegraf` with appropriate permissions 23 | to your InfluxDB. 24 | 25 | 26 | ``` 27 | [[outputs.influxdb]] 28 | urls = ["https://127.0.0.1:8086"] 29 | 30 | database = "home" 31 | exclude_database_tag = false 32 | skip_database_creation = true 33 | 34 | username = "telegraf" 35 | password = "telegraf_influxdb_user_password_here" 36 | 37 | insecure_skip_verify = true 38 | 39 | ``` 40 | 41 | #### MQTT input section 42 | 43 | It's assumed here that you have added user `telegraf` to Mosquitto's pwfile. 44 | 45 | This example configuration subscribes to all topics and sets `type` parameter in data as a tag. 46 | See parameters etc. from [DATAFORMATS.md](DATAFORMATS.md). 47 | 48 | The MQTT topic is inserted as tag `sensor` to InfluxDB 49 | 50 | The full documentation of the MQTT Consumer input plugin is 51 | [available at influxdata's github](https://github.com/influxdata/telegraf/blob/release-1.17/plugins/inputs/mqtt_consumer/README.md) 52 | 53 | 54 | ``` 55 | [[inputs.mqtt_consumer]] 56 | servers = ["tcp://127.0.0.1:1883"] 57 | topics = ["#"] 58 | 59 | tag_keys = ["type"] 60 | 61 | topic_tag = "sensor" 62 | qos = 0 63 | 64 | username = "telegraf" 65 | password = "telegraf_password_from_mosquitto_pwfile" 66 | 67 | data_format = "json" 68 | name_override = "sensors" 69 | json_strict = false 70 | 71 | ``` -------------------------------------------------------------------------------- /ChangeLog: -------------------------------------------------------------------------------- 1 | ChangeLog for OH2MP ESP32 BLE2MQTT 2 | 3 | 2021-01-17 4 | - Fixed bootlooping if config files were unexistent. Should start portal automatically in that case. 5 | 6 | 2021-01-01 7 | - Added RSSI to the data 8 | - Added last half to the MAC address to concat to the default hostname 9 | - Now the MQTT client disconnects nicely 10 | - Added watchdog 11 | - The unit tells desired hostname in DHCP request 12 | 13 | 2020-12-22 14 | - Initial release 15 | 16 | 17 | **** 18 | -------------------------------------------------------------------------------- /DATAFORMATS.md: -------------------------------------------------------------------------------- 1 | # Dataformats used with OH2MP ESP32 BLE2MQTT 2 | 3 | #### All of these should stay as they are specified, but they _may_ change. 4 | Some beacon types and data fields are already specified for future use. 5 | 6 | ------------ 7 | 8 | ### Beacon type 9 | 10 | In every MQTT packet an information about the beacon type is sent. It is just numerical and the types 11 | supported at the moment are: 12 | 13 | | Name | Number | Description | 14 | | ------------ |:------:| ----------- | 15 | | TAG_RUUVI | 1 | Ruuvi tag | 16 | | TAG_MIJIA | 2 | Xiaomi Mijia Thermometer 2 | 17 | | TAG_ENERGY | 3 | OH2MP energy meter beacon | 18 | | TAG_WATER | 4 | OH2MP water gauge beacon | 19 | | TAG_THCPL | 5 | OH2MP thermocouple beacon | 20 | | TAG_DS1820 | 6 | 1wire DS18x20 based thermometer | 21 | | TAG_DHT | 7 | DHT based thermometer/hygrometer | 22 | | TAG_WATTSON | 8 | Diy Kyoto Wattson with ESP8266 | 23 | | TAG_MOPEKA | 9 | Mopeka✓ gas tank sensors | 24 | | TAG_IBSTH2 | 10 | Inkbird IBS-TH2 temperature sensor | 25 | | TAG_ALPICOOL | 11 | Alpicool portable fridges | 26 | 27 | These same numbers are used internally in [OH2MP Smart RV](https://github.com/oh2mp/esp32_smart_rv) 28 | 29 | ------------ 30 | 31 | ### Fields in JSON messages 32 | 33 | These field names are chosen so that they are short to make messages more compact. 34 | 35 | | Fieldname | Unit | Description | 36 | | ---------- | ------- | ----------- | 37 | | t | 1/10 °C | temperature in deciCelsius | 38 | | rh | % | relative humidity | 39 | | ap | hPa | athmospheric pressure | 40 | | bu | mV | battery voltage | 41 | | bp | % | battery percentage | 42 | | e | Wh | energy consumption since last reset | 43 | | et | Wh | energy consumption total | 44 | | lv | liter | liquid volume | 45 | | u | mV | electric voltage | 46 | | i | mA | electric current | 47 | | p | mW | electric power | 48 | | m | g | mass (or weight in spoken language) | 49 | | s | dBm | signal strength as RSSI, abs() value. | 50 | | tt | 1/10°C | thermostat target temperature in deciCelsius | 51 | | gh | cm | LPG level height in gas tank (see [About Mopeka✓](#about_mopeka)) | 52 | 53 | -------------- 54 | 55 | ### An example JSON message from a Ruuvi tag data: 56 | 57 | ``` 58 | {"type":1,"t":243,"rh":32,"bu":2821,"ap":1003,"s":42} 59 | ``` 60 | 61 | Here we see that type is 1 meaning that this is a Ruuvi tag. The temperature is 24.3°C, relative humidity 32%, 62 | battery voltage 2.821 volts and athmospheric pressure 1003 hPa. RSSI is -42 dBm. 63 | 64 | -------------- 65 | 66 | 67 | 68 | ### About Mopeka✓ 69 | 70 | [Mopeka✓ sensors](https://www.mopeka.com/product-category/sensor/) are a family of gas tank level sensors 71 | that are mounted in the bottom of gas tanks. They use ultrasound on measuring the distance to the surface 72 | of the liquified gas in the container. The exact amount of gas depends mostly on geometry of the container 73 | but also gas composition and temperature affect a little. That kind of calculations should be done elsewhere 74 | than in this gateway, eg. in Grafana. It's best to just store the centimeters as-is. 75 | 76 | -------------------------------------------------------------------------------- /FORMATS.md: -------------------------------------------------------------------------------- 1 | # ESP32 BLE2MQTT configuration file formats 2 | 3 | Here is the specification of the configuration files that the ESP32 BLE2MQTT uses. 4 | All files use only newline aka Unix line break. Windows line break CRLF will cause problems. 5 | 6 | ## mqtt.txt 7 | 8 | row 1: ip:port or host:port. Must be separated by colon. 9 | 10 | row 2: username:password for the MQTT broker. They must be separated by colon. 11 | 12 | row 3: topic prefix. This is the base for the topic and sensor name is added automatically after this. 13 | 14 | row 4: MQTT publish interval in minutes. 15 | 16 | All rows must end in newline. 17 | 18 | **Example mqtt.txt file:** 19 | 20 | ``` 21 | 192.168.36.99:1883 22 | publisher:password123 23 | home/sensors 24 | 5 25 | ``` 26 | 27 | ## known_tags.txt 28 | 29 | One known tag per row. First the MAC address in lowercase hex and colons between bytes, then TAB, 30 | then name of the tag and newline. 31 | 32 | **Example known_tags.txt file:** 33 | 34 | ``` 35 | f4:01:83:12:ce:95 foo 36 | e3:28:8c:99:47:ae bar 37 | ``` 38 | 39 | ## known_wifis.txt 40 | 41 | One known WiFi network per row. First the SSID, then TAB, then password and newline. 42 | 43 | **Example known_wifis.txt** 44 | 45 | ``` 46 | OH2MP MyVerySecretPass123 47 | OH2MP-5 AnotherVerySecretPass456 48 | ``` 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Mikko Pikarinen 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 | # OH2MP ESP32 BLE2MQTT 2 | 3 | ### An ESP32 based gateway that listens BLE beacons and sends the data via MQTT 4 | 5 | Web-configurable BLE data collector that sends data to a MQTT broker. In my own configuration I have 6 | Mosquitto as a broker and InfluxDB + Telegraf with MQTT plugin. See [CONFIG_EXAMPLES.md](CONFIG_EXAMPLES.md). 7 | 8 | This software sends data as JSON to the broker. The data is specified to be compact to avoid high bills 9 | when this is used with a mobile internet with some data plan. See [DATAFORMATS.md](DATAFORMATS.md) 10 | 11 | The idea for this is home or RV use, not scientific environment. Because of that eg. the temperatures are 12 | only with 0.1°C precision and acceleration sensors of Ruuvi tags are simply ignored. It's not very 13 | important to know the acceleration while the tag is in a fridge and we want to keep data compact. 14 | 15 | 16 | BLE beacons that are currently supported: 17 | 18 | - [Ruuvi tag](https://ruuvi.com/) (Data format V5 aka RAWv2 only) 19 | - [Xiaomi Mijia Bluetooth Thermometer 2 with ATC_MiThermometr firmware](https://github.com/atc1441/ATC_MiThermometer) (stock firmware not supported) 20 | - [Inkbird IBS-TH2](https://inkbird.com/products/ibs-th2-temp) (version without humidity and external sensor) 21 | - [Mopeka✓ gas tank sensor](https://www.mopeka.com/product-category/sensor/) 22 | - [ESP32 Water sensor](https://github.com/oh2mp/esp32_watersensor) 23 | - [ESP32 Energy meter](https://github.com/oh2mp/esp32_energymeter) 24 | - [ESP32 MAX6675 beacon for thermocouples](https://github.com/oh2mp/esp32_max6675_beacon) 25 | - [ESP32 DS18x20 beacon](https://github.com/oh2mp/esp32_ds1820_ble) 26 | - [Alpicool portable fridges](https://www.alpicool.com) (most of the models, I believe. [Read more](Alpicool.md)) 27 | 28 | This is partly based on the same code as [OH2MP ESP32 Smart RV](https://github.com/oh2mp/esp32_smart_rv) 29 | and [OH2MP ESP32 Ruuvicollector](https://github.com/oh2mp/esp32_ruuvicollector) 30 | 31 | ------ 32 | 33 | ## Software prerequisities 34 | 35 | - Some MQTT broker like Mosquitto running somewhere. 36 | - [Arduino IDE](https://www.arduino.cc/en/main/software) – __The current tested IDE version is 2.3.6__ 37 | - [Arduino LITTLEFS uploader](https://github.com/earlephilhower/arduino-littlefs-upload) – Optional if you use the ready made image 38 | 39 | ### Libraries needed 40 | 41 | __Make sure your esp32 board version installed is 2.0.x__ 42 | 43 | This has been tested with version 2.0.17 (see Boards Manager in Arduino IDE) 44 | 45 | Install these from the IDE library manager. I have added the versions which have tested and confirmed to be working. 46 | 47 | - EspMQTTClient 1.13.3 48 | - LittleFS_esp32 1.0.6 (1.0.7 is buggy) 49 | - PubSubClient 2.8 50 | 51 | ## Installation and configuration 52 | 53 | Choose correct ESP32 board and change partitioning setting:
**Tools -> Partition Scheme -> Huge APP(3MB No OTA)** 54 | 55 | You can use the filesystem uploader tool to upload the contents of data library. It contains the html pages for 56 | the configuring portal. Or you can just upload the provided image with esptool: 57 | 58 | `esptool --chip esp32 --port /dev/ttyUSB0 --baud 921600 --before default_reset --after hard_reset write_flash -z --flash_mode dio --flash_freq 80m --flash_size detect 3211264 esp32_ble2mqtt.littlefs.bin` 59 | 60 | By default the software assumes that there are maximum 16 beacons or tags, but this can be changed from the code, 61 | see row `#define MAX_TAGS 16` 62 | 63 | ## Configuration option 64 | 65 | The portal saves all configurations onto the LITTLEFS filesystem. They are just text files, so you can 66 | precreate them and then your ESP32 Ruuvi Collector is preconfigured and you dont' have to use the portal 67 | at all. Just place yout configuration files into the data/littlefs directory along the html files and 68 | upload them with ESP filesystem uploader. 69 | 70 | See [FORMATS.md](FORMATS.md). 71 | 72 | ## LED behavior 73 | 74 | Optionally an RGB LED can be connected to the board. It acts as a status indicator. At boot the LED 75 | shows a short color effect to see that it's working. Colors and meanings in operating mode: 76 | 77 | - off = nothing happening just now 78 | - cyan = BLE scanning active, but no beacons heard yet 79 | - blue = BLE beacon(s) heard 80 | - purple = end of BLE scanning 81 | - green = sending data to MQTT broker 82 | - red = cannot connect to WiFi 83 | - orange = WiFi connection works but cannot send data to MQTT broker 84 | 85 | The LED pins are configurable from `#define` rows. The defaults are 21 red, 22 green and 23 blue. 86 | Every one should be connected with a eg. 1kΩ resistor. 87 | 88 | __TIP:__ connect an LDR to the cathode side of the LED. Then it will illuminate brighly in daylight 89 | but will be dimmed in the dark. 90 | 91 | ------ 92 | 93 | ## Portal mode 94 | 95 | If the GPIO0 is grounded (same as BOOT button is pressed), the ESP32 starts portal mode. 96 | The pin can be also changed from the code, see row `#define APREQUEST 0` 97 | 98 | In the start of portal mode the ESP32 is scanning 11 seconds for beacons. During the scan the color 99 | behavior of the LED is similar like in operating mode. 100 | 101 | WiFi AP is not listening yet at the scanning period. After the LED starts illuminating green, 102 | connect to WiFi **ESP32 BLE2MQTT**, accept that there's no internet connection 103 | and take your browser to `http://192.168.4.1/` 104 | 105 | The web GUI should be self explanatory. 106 | 107 | It's a good idea to find out the Bluetooth MAC addresses of the beacons beforehand. For Ruuvi tags the 108 | easiest way is to use Ruuvi software. For other beacons eg. 109 | [BLE Scanner by Bluepixel Technologies](https://play.google.com/store/apps/details?id=com.macdom.ble.blescanner) 110 | is a suitable app for Android. 111 | 112 | The portal mode has a timeout. The unit will reboot after 2 minutes of inactivity and the remaining time 113 | is visible on the screen. This timeout can be changed from line #define APTIMEOUT 114 | The LED changes its color slowly from green to yellow and then red depending how near the timeout is. 115 | 116 | There's almost no sanity checks for the data sent from the forms. This is not a public web service and if 117 | you want to mess up your board or try to make a denial of service using eg. buffer overflows, feel free to 118 | do so. 119 | 120 | ### Sample screenshots from the portal 121 | 122 | ![Portal main](s/portal.jpg) 123 | ![Sensors config](s/sensors_config.jpg) 124 | ![MQTT config](s/mqtt_config.jpg) 125 | 126 | ------ 127 | 128 | -------------------------------------------------------------------------------- /data/littlefs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |

ESP32 BLE2MQTT

9 |
10 | 11 | 12 | 13 | 14 | 15 |
WiFi config

MQTT config

Sensors config

Exit & reboot
16 |
17 |

OH2MP 2020-2024

18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /data/littlefs/known_tags.txt: -------------------------------------------------------------------------------- 1 | 49:23:04:08:02:00 jääkaappi 2 | 49:23:04:08:04:61 pakastelokero 3 | a4:c1:38:08:a3:c8 sisätila 4 | -------------------------------------------------------------------------------- /data/littlefs/known_wifis.txt: -------------------------------------------------------------------------------- 1 | OH2MP-12 TodellaSuuriSalaisuus 2 | -------------------------------------------------------------------------------- /data/littlefs/mqtt.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 |
10 | 11 | 12 | 13 | 15 | 17 | 19 | 21 |
MQTT broker settings
host:port or ip:port
14 |

username:password
16 |

topic prefix
18 |

Send interval in minutes
20 |
22 |
23 | 24 | 25 | 26 | 27 |
cancel
28 |
29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /data/littlefs/mqtt.txt: -------------------------------------------------------------------------------- 1 | mqtt.pikarinen.com:1883 2 | nmm-509:g0atse42 3 | ble2mqtt/nmm-509 4 | 1 5 | -------------------------------------------------------------------------------- /data/littlefs/ok.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 37 | 38 | 39 |
40 |
41 |
42 | 43 | 44 | -------------------------------------------------------------------------------- /data/littlefs/sensors.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 23 | 24 | 25 |
26 |
27 | 28 | ###TABLEROWS### 29 |
30 |
31 | 32 | 33 | 34 | 37 |
cancel
To forget a tag, erase
the name and save 35 | 36 |
38 |
39 | 40 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /data/littlefs/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: Verdana, Arial, Helvetica, sans-serif; 3 | margin: auto; 4 | background: #121212; 5 | color: #ffffff; 6 | font-size: 18px; 7 | border: none; 8 | margin-bottom: 20px; 9 | } 10 | 11 | small { 12 | font-size: 12px; 13 | } 14 | 15 | h1 { 16 | text-align: center; 17 | font-size: 24px; 18 | } 19 | h3 { 20 | text-align: center; 21 | font-size: 18px; 22 | } 23 | 24 | hr { 25 | margin-top: 15px; 26 | margin-bottom: 10px; 27 | } 28 | 29 | input[type=text] { 30 | color: #03dac6; 31 | background: #333333; 32 | font-size: 24px; 33 | margin-top: 10px; 34 | border: 1px solid rgba(255,255,255,0.3); 35 | border-radius: 5px; 36 | padding: 5px; 37 | width: 90%; 38 | box-shadow: 2px 2px 4px #333333; 39 | } 40 | 41 | input:focus { 42 | outline: none !important; 43 | border-color: #719ECE; 44 | box-shadow: 0 0 10px #719ECE; 45 | background: #505050; 46 | } 47 | 48 | input[type=submit] { 49 | display: inline-block; 50 | position: relative; 51 | color: #fffffa; 52 | background: #0f4c75; 53 | text-shadow: 1px 1px #333333; 54 | font-size: 22px; 55 | line-height: 24px; 56 | width: 80%; 57 | padding: 5px 0px 5px 0px; 58 | margin: 0; 59 | border: 1px solid rgba(255,255,255,0.3); 60 | box-shadow: 2px 2px 4px #333333; 61 | border-radius: 5px; 62 | } 63 | 64 | .fakebutton { 65 | display: inline-block; 66 | position: relative; 67 | color: #fffffa; 68 | font-size: 22px; 69 | background: #0f4c75; 70 | text-shadow: 1px 1px #333333; 71 | width: 80%; 72 | line-height: 24px; 73 | padding: 5px 0px 5px 0px; 74 | margin 0; 75 | text-decoration: none; 76 | border: 1px solid rgba(255,255,255,0.3); 77 | border-radius: 5px; 78 | box-shadow: 2px 2px 4px #333333; 79 | } 80 | 81 | table { 82 | padding: 0; 83 | margin: 0; 84 | width: 100%; 85 | } 86 | 87 | td { 88 | margin: 0; 89 | text-align: center; 90 | padding: 5px 0px 0px 0px; 91 | position: relative; 92 | bottom: 0px; 93 | } 94 | 95 | svg { 96 | padding: 0px; 97 | margin: 5px 0 0 0; 98 | bottom: 0px; 99 | position: relative; 100 | } 101 | 102 | .even { 103 | display: table-cell; 104 | width: 50%; 105 | } 106 | 107 | .wifiinfo { 108 | width: 80%; 109 | margin: 0 10% 10% 10%; 110 | border: 1px solid #0f4c75; 111 | border-radius: 5px; 112 | border-collapse: collapse; 113 | } 114 | .wifiinfo td { 115 | border: 1px solid #0f4c75; 116 | width: 50%; 117 | padding: 5px; 118 | } 119 | 120 | @media only screen and (max-width: 767px) { 121 | input[type=text] { 122 | width: 90%; 123 | } 124 | } 125 | 126 | @media only screen and (min-device-width : 768px) and (max-device-width: 1024px) { 127 | input[type=text] { 128 | width: 90%; 129 | } 130 | body { 131 | width: 90%; 132 | font-size: 22px; 133 | } 134 | } 135 | 136 | @media only screen and (min-width: 1024px) { 137 | body { 138 | width: 320px; 139 | } 140 | } 141 | 142 | -------------------------------------------------------------------------------- /data/littlefs/wifis.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 |
10 | 11 | 12 | 13 | 14 |
Own hostname

15 | 16 | ###TABLEROWS### 17 | 18 | 19 | 20 | 21 |

Add new
SSID
PASS
22 |
23 | 24 | 25 | 26 | 29 |
cancel
To remove a connection,
erase the SSID and save 27 | 28 |
30 |
31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /esp32_ble2mqtt.ino: -------------------------------------------------------------------------------- 1 | /* 2 | OH2MP ESP32 BLE2MQTT 3 | 4 | See https://github.com/oh2mp/esp32_ble2mqtt 5 | 6 | */ 7 | 8 | #include 9 | #include 10 | #include 11 | #include 12 | #include 13 | #include 14 | 15 | #include 16 | #include 17 | #include 18 | #include 19 | 20 | #include 21 | 22 | #define BUTTON 0 // Push button for starting portal mode. On devkit this is BOOT button. 23 | #define APTIMEOUT 120000 // Portal timeout. Reboot after ms if no activity. 24 | 25 | // LED pins and channels 26 | #define LED_R_PIN 21 27 | #define LED_G_PIN 22 28 | #define LED_B_PIN 23 29 | #define LED_R 0 30 | #define LED_G 1 31 | #define LED_B 2 32 | 33 | #define MAX_TAGS 16 34 | 35 | // Tag type enumerations and names 36 | #define TAG_RUUVI 1 37 | #define TAG_MIJIA 2 38 | #define TAG_ENERGY 3 39 | #define TAG_WATER 4 40 | #define TAG_THCPL 5 41 | #define TAG_DS1820 6 42 | #define TAG_DHT 7 43 | #define TAG_WATTSON 8 44 | #define TAG_MOPEKA 9 45 | #define TAG_IBSTH2 10 46 | #define TAG_ALPICOOL 11 47 | 48 | const char type_name[12][10] PROGMEM = {"", "\u0550UUVi", "ATC_Mi", "Energy", "Water", "Flame", "DS18x20", "DHTxx", "Wattson", "Mopeka\u2713", "IBS-TH2", "Alpicool"}; 49 | // end of tag type enumerations and names 50 | 51 | char tagdata[MAX_TAGS][32]; // space for raw tag data unparsed 52 | char tagname[MAX_TAGS][24]; // tag names 53 | char tagmac[MAX_TAGS][18]; // tag macs 54 | int tagrssi[MAX_TAGS]; // RSSI for each tag 55 | 56 | uint8_t tagtype[MAX_TAGS]; // "cached" value for tag type 57 | uint8_t tagcount = 0; // total amount of known tags 58 | int interval = 1; 59 | time_t lastpublish = 0; 60 | hw_timer_t *timer = NULL; // for watchdog 61 | 62 | char gattcache[32]; // Space for caching GATT payload 63 | int task_counter = 0; 64 | TaskHandle_t bletask = NULL; 65 | 66 | // Default hostname base. Last 3 octets of MAC are added as hex. 67 | // The hostname can be changed explicitly from the portal. 68 | char myhostname[64] = "esp32-ble2mqtt-"; 69 | 70 | // placeholder values 71 | char topicbase[256] = "sensors"; 72 | char mqtt_user[64] = "foo"; 73 | char mqtt_pass[64] = "bar"; 74 | char mqtt_host[64] = "192.168.36.99"; 75 | int mqtt_port = 1883; 76 | 77 | WiFiMulti WiFiMulti; 78 | WiFiClient wificlient; 79 | PubSubClient client(wificlient); 80 | 81 | WebServer server(80); 82 | IPAddress apIP(192, 168, 4, 1); // portal ip address 83 | const char my_ssid[] PROGMEM = "ESP32 BLE2MQTT"; // AP SSID 84 | uint32_t portal_timer = 0; 85 | uint32_t ble_timer = 0; 86 | 87 | uint8_t alpicool_index = 0xFF; // If we have an Alpicool, store its tag index here. 88 | bool alpicool_heard = false; // Did we hear one on last iteration? 89 | bool scanning = false; 90 | 91 | char heardtags[MAX_TAGS][18]; 92 | uint8_t heardtagtype[MAX_TAGS]; 93 | 94 | File file; 95 | BLEScan* blescan; 96 | BLEScanResults foundDevices; 97 | BLEClient *pClient; 98 | BLERemoteService *pRemoteService; 99 | BLERemoteCharacteristic *rCharacteristic; 100 | BLERemoteCharacteristic *wCharacteristic; 101 | 102 | /* ------------------------------------------------------------------------------- */ 103 | /* Get known tag index from MAC address. Format: 12:34:56:78:9a:bc */ 104 | uint8_t getTagIndex(const char *mac) { 105 | for (uint8_t i = 0; i < MAX_TAGS; i++) { 106 | if (strcmp(tagmac[i], mac) == 0) { 107 | return i; 108 | } 109 | } 110 | return 0xFF; // no tag with this mac found 111 | } 112 | 113 | /* ------------------------------------------------------------------------------- */ 114 | /* Detect tag type from payload and mac 115 | 116 | Ruuvi tags (Manufacturer ID 0x0499) with data format V5 only 117 | 118 | Homemade tags (Manufacturer ID 0x02E5 Espressif Inc) 119 | The sketches are identified by next two bytes after MFID. 120 | 0xE948 water tank gauge https://github.com/oh2mp/esp32_watersensor/ 121 | 0x1A13 thermocouple sensor for gas flame https://github.com/oh2mp/esp32_max6675_beacon/ 122 | 0xACDC energy meter pulse counter 123 | 124 | Xiaomi Mijia thermometer with atc1441 custom firmware. 125 | https://github.com/atc1441/ATC_MiThermometer 126 | 127 | */ 128 | 129 | uint8_t tagTypeFromPayload(const uint8_t *payload, const uint8_t *mac) { 130 | // Has manufacturerdata? If so, check if this is known type. 131 | if (memcmp(payload, "\x02\x01\x06", 3) == 0 && payload[4] == 0xFF) { 132 | if (memcmp(payload + 5, "\x99\x04\x05", 3) == 0) return TAG_RUUVI; 133 | if (memcmp(payload + 5, "\xE5\x02\xDC\xAC", 4) == 0) return TAG_ENERGY; 134 | if (memcmp(payload + 5, "\xE5\x02\x48\xE9", 4) == 0) return TAG_WATER; 135 | if (memcmp(payload + 5, "\xE5\x02\x13\x1A", 4) == 0) return TAG_THCPL; 136 | if (memcmp(payload + 5, "\xE5\x02\x20\x18", 4) == 0) return TAG_DS1820; 137 | } 138 | // Alpicool fridge? 139 | if (memcmp(payload, "\x02\x01\x06", 3) == 0 && memcmp(payload + 9, "ZHJIELI", 7) == 0) return TAG_ALPICOOL; 140 | 141 | // ATC_MiThermometer? The data should contain 10161a18 in the beginning and mac at offset 4. 142 | if (memcmp(payload, "\x10\x16\x1A\x18", 4) == 0 && memcmp(mac, payload + 4, 6) == 0) return TAG_MIJIA; 143 | // Mopeka gas tank sensor? 144 | if (memcmp(payload, "\x1A\xFF\x0D\x00", 4) == 0 && payload[26] == mac[5]) return TAG_MOPEKA; 145 | 146 | return 0xFF; // unknown 147 | } 148 | 149 | /* ------------------------------------------------------------------------------- */ 150 | /* Known devices callback */ 151 | /* ------------------------------------------------------------------------------- */ 152 | 153 | class MyAdvertisedDeviceCallbacks: public BLEAdvertisedDeviceCallbacks { 154 | void onResult(BLEAdvertisedDevice advDev) { 155 | set_led(0, 0, 128); 156 | uint8_t payload[32]; 157 | uint8_t taginx = getTagIndex(advDev.getAddress().toString().c_str()); 158 | 159 | // we are interested about known and saved BLE devices only 160 | if (taginx == 0xFF || tagname[taginx][0] == 0) return; 161 | 162 | memset(payload, 0, 32); 163 | memcpy(payload, advDev.getPayload(), 32); 164 | memset(tagdata[taginx], 0, sizeof(tagdata[taginx])); 165 | 166 | // ignore if payload doesn't contain valid data. 167 | if (memcmp(payload+7, "\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0", 24) == 0) { 168 | return; 169 | } 170 | // Don't we know the type of this device yet? 171 | if (tagtype[taginx] == 0) { 172 | uint8_t mac[6]; 173 | tagtype[taginx] = tagTypeFromPayload(payload, mac); 174 | } 175 | // Inkbird IBS-TH2 or Alpicool? 176 | if (tagtype[taginx] == 0xFF) { 177 | if (advDev.haveServiceUUID()) { 178 | if (strcmp(advDev.getServiceUUID().toString().c_str(), "0000fff0-0000-1000-8000-00805f9b34fb") == 0) { 179 | tagtype[taginx] = TAG_IBSTH2; 180 | } 181 | } 182 | if (memcmp(payload, "\x02\x01\x06", 3) == 0 && memcmp(payload + 9, "ZHJIELI", 7) == 0) { 183 | tagtype[taginx] = TAG_ALPICOOL; 184 | } 185 | } 186 | 187 | // Copy the payload to tagdata 188 | memcpy(tagdata[taginx], payload, 32); 189 | if (tagtype[taginx] == TAG_ALPICOOL) { 190 | memcpy(tagdata[taginx], gattcache, 32); 191 | alpicool_index = taginx; 192 | alpicool_heard = true; 193 | } 194 | 195 | tagrssi[taginx] = advDev.getRSSI(); 196 | 197 | Serial.printf("BLE callback: payload="); 198 | for (uint8_t i = 0; i < 32; i++) { 199 | Serial.printf("%02x", payload[i]); 200 | } 201 | Serial.printf("; ID=%d; type=%d; addr=%s; name=%s\n", taginx, tagtype[taginx], tagmac[taginx], tagname[taginx]); 202 | set_led(0, 0, 0); 203 | } 204 | }; 205 | 206 | /* ------------------------------------------------------------------------------- */ 207 | /* Alpicool callback */ 208 | /* ------------------------------------------------------------------------------- */ 209 | void alpicoolCallback( 210 | BLERemoteCharacteristic* pBLERemoteCharacteristic, 211 | uint8_t* pData, 212 | size_t length, 213 | bool isNotify) { 214 | Serial.print("Alpicool GATT payload="); 215 | for (uint8_t i = 0; i < 32; i++) { 216 | Serial.printf("%02x", pData[i]); 217 | } 218 | Serial.println(""); 219 | // Client must disconnect or otherwise eg. mobile app can't connect. 220 | // These fridges can handle only one connection at a time. 221 | pClient->disconnect(); 222 | } 223 | 224 | /* ------------------------------------------------------------------------------- */ 225 | /* Find new devices when portal is started */ 226 | /* ------------------------------------------------------------------------------- */ 227 | class ScannedDeviceCallbacks: public BLEAdvertisedDeviceCallbacks { 228 | void onResult(BLEAdvertisedDevice advDev) { 229 | set_led(0, 0, 128); 230 | uint8_t payload[32]; 231 | uint8_t taginx = getTagIndex(advDev.getAddress().toString().c_str()); 232 | 233 | Serial.printf("Heard %s %s\nPayload: ", advDev.toString().c_str(), advDev.getName().c_str()); 234 | 235 | memcpy(payload, advDev.getPayload(), 32); 236 | for (uint8_t i = 0; i < 32; i++) { 237 | Serial.printf("%02x", payload[i]); 238 | } 239 | Serial.printf("\n"); 240 | 241 | // skip known tags, we are trying to find new 242 | if (taginx != 0xFF) return; 243 | 244 | /* we are interested only about Ruuvi tags (Manufacturer ID 0x0499) 245 | and self made tags that have Espressif ID 0x02E5 246 | and Xiaomi Mijia thermometer with atc1441 custom firmware 247 | */ 248 | uint8_t mac[6]; 249 | memcpy(mac, advDev.getAddress().getNative(), 6); 250 | uint8_t htype = tagTypeFromPayload(payload, mac); 251 | 252 | // Check if this is Inkbird IBS-TH2 253 | if (htype == 0xFF) { 254 | if (advDev.haveServiceUUID()) { 255 | if (strcmp(advDev.getServiceUUID().toString().c_str(), "0000fff0-0000-1000-8000-00805f9b34fb") == 0) { 256 | htype = TAG_IBSTH2; 257 | } 258 | } 259 | } 260 | 261 | if (htype != 0xFF && htype != 0) { 262 | for (uint8_t i = 0; i < MAX_TAGS; i++) { 263 | if (strlen(heardtags[i]) == 0) { 264 | strcpy(heardtags[i], advDev.getAddress().toString().c_str()); 265 | heardtagtype[i] = htype; 266 | Serial.printf("Heard new tag: %s %s\n", heardtags[i], type_name[htype]); 267 | break; 268 | } 269 | } 270 | } else { 271 | Serial.print("Ignoring unsupported device.\n"); 272 | } 273 | set_led(0, 0, 0); 274 | } 275 | }; 276 | 277 | /* ------------------------------------------------------------------------------- */ 278 | void loadWifis() { 279 | if (LittleFS.exists("/LittleFS/known_wifis.txt")) { 280 | char ssid[33]; 281 | char pass[65]; 282 | 283 | file = LittleFS.open("/LittleFS/known_wifis.txt"); 284 | while (file.available()) { 285 | memset(ssid, '\0', sizeof(ssid)); 286 | memset(pass, '\0', sizeof(pass)); 287 | file.readBytesUntil('\t', ssid, 32); 288 | file.readBytesUntil('\n', pass, 64); 289 | WiFiMulti.addAP(ssid, pass); 290 | Serial.printf("wifi loaded: %s / %s\n", ssid, pass); 291 | } 292 | file.close(); 293 | } 294 | if (LittleFS.exists("/LittleFS/myhostname.txt")) { 295 | file = LittleFS.open("/LittleFS/myhostname.txt"); 296 | memset(myhostname, 0, sizeof(myhostname)); 297 | file.readBytesUntil('\n', myhostname, sizeof(myhostname)); 298 | file.close(); 299 | } 300 | Serial.printf("My hostname: %s\n", myhostname); 301 | } 302 | /* ------------------------------------------------------------------------------- */ 303 | void loadSavedTags() { 304 | char sname[25]; 305 | char smac[18]; 306 | for (uint8_t i = 0; i < MAX_TAGS; i++) { 307 | tagtype[i] = 0; 308 | memset(tagname[i], 0, sizeof(tagname[i])); 309 | memset(tagdata[i], 0, sizeof(tagdata[i])); 310 | } 311 | 312 | if (LittleFS.exists("/LittleFS/known_tags.txt")) { 313 | uint8_t foo = 0; 314 | file = LittleFS.open("/LittleFS/known_tags.txt"); 315 | while (file.available()) { 316 | memset(sname, '\0', sizeof(sname)); 317 | memset(smac, '\0', sizeof(smac)); 318 | 319 | file.readBytesUntil('\t', smac, 18); 320 | file.readBytesUntil('\n', sname, 25); 321 | while (isspace(smac[strlen(smac) - 1]) && strlen(smac) > 0) smac[strlen(smac) - 1] = 0; 322 | while (isspace(sname[strlen(sname) - 1]) && strlen(sname) > 0) sname[strlen(sname) - 1] = 0; 323 | if (sname[strlen(sname) - 1] == 13) sname[strlen(sname) - 1] = 0; 324 | strcpy(tagmac[foo], smac); 325 | strcpy(tagname[foo], sname); 326 | foo++; 327 | if (foo >= MAX_TAGS) break; 328 | tagcount++; 329 | } 330 | file.close(); 331 | } 332 | } 333 | /* ------------------------------------------------------------------------------- */ 334 | void loadMQTT() { 335 | if (LittleFS.exists("/LittleFS/mqtt.txt")) { 336 | char tmpstr[8]; 337 | memset(tmpstr, 0, sizeof(tmpstr)); 338 | memset(mqtt_host, 0, sizeof(mqtt_host)); 339 | memset(mqtt_user, 0, sizeof(mqtt_user)); 340 | memset(mqtt_pass, 0, sizeof(mqtt_pass)); 341 | memset(topicbase, 0, sizeof(topicbase)); 342 | 343 | file = LittleFS.open("/LittleFS/mqtt.txt"); 344 | while (file.available()) { 345 | file.readBytesUntil(':', mqtt_host, sizeof(mqtt_host)); 346 | file.readBytesUntil('\n', tmpstr, sizeof(tmpstr)); 347 | mqtt_port = atoi(tmpstr); 348 | memset(tmpstr, 0, sizeof(tmpstr)); 349 | if (mqtt_port < 1 || mqtt_port > 65535) mqtt_port = 1883; // default 350 | file.readBytesUntil(':', mqtt_user, sizeof(mqtt_user)); 351 | file.readBytesUntil('\n', mqtt_pass, sizeof(mqtt_pass)); 352 | file.readBytesUntil('\n', topicbase, sizeof(topicbase)); 353 | file.readBytesUntil('\n', tmpstr, sizeof(tmpstr)); 354 | interval = atoi(tmpstr); 355 | } 356 | file.close(); 357 | Serial.printf("MQTT broker: %s:%d - topic prefix: %s\n", mqtt_host, mqtt_port, topicbase); 358 | } 359 | } 360 | /* ------------------------------------------------------------------------------- */ 361 | void set_led(uint8_t r, uint8_t g, uint8_t b) { 362 | ledcWrite(LED_R, r); 363 | ledcWrite(LED_G, g); 364 | ledcWrite(LED_B, b); 365 | } 366 | /* ------------------------------------------------------------------------------- */ 367 | // Color effect for boot 368 | void led_fx() { 369 | int r = 128; int g = 0; int b = 0; 370 | for (int i = 0; i < 3000; i++) { 371 | if (r > 0 && b == 0) { 372 | r--; 373 | g++; 374 | } 375 | if (g > 0 && r == 0) { 376 | g--; 377 | b++; 378 | } 379 | if (b > 0 && g == 0) { 380 | r++; 381 | b--; 382 | } 383 | r = constrain(r, 0, 128); g = constrain(g, 0, 128); b = constrain(b, 0, 128); 384 | set_led(r, g, b); 385 | delay(1); 386 | } 387 | } 388 | /* ------------------------------------------------------------------------------- */ 389 | // This happens if watchdog timer is triggered. See the end of the setup() function. 390 | void IRAM_ATTR reset_esp32() { 391 | ets_printf("Alarm. Reboot\n"); 392 | esp_restart(); 393 | } 394 | 395 | /* ------------------------------------------------------------------------------- */ 396 | void setup() { 397 | Serial.begin(115200); 398 | Serial.println("\n\nESP32 BLE2MQTT"); 399 | 400 | pinMode(BUTTON, INPUT_PULLUP); 401 | 402 | // Reset real time clock 403 | timeval epoch = {0, 0}; 404 | const timeval *tv = &epoch; 405 | settimeofday(tv, NULL); 406 | 407 | // Prepare watchdog 408 | timer = timerBegin(0, 240, true); 409 | timerAttachInterrupt(timer, &reset_esp32, true); 410 | if (interval > 0) { 411 | timerAlarmWrite(timer, interval * 180E+6 + 15E+6, false); // set time to 3x interval (µs) +15s 412 | } else { 413 | timerAlarmWrite(timer, 195E+6, false); // if interval < 1, set it to 3m 15s 414 | } 415 | timerAlarmEnable(timer); 416 | // Append last 3 octets of MAC to the default hostname 417 | uint8_t mymac[6]; 418 | esp_read_mac(mymac, (esp_mac_type_t)0); // 0:wifi station, 1:wifi softap, 2:bluetooth, 3:ethernet 419 | char mac_end[8]; 420 | sprintf(mac_end, "%02x%02x%02x", mymac[3], mymac[4], mymac[5]); 421 | strcat(myhostname, mac_end); 422 | 423 | ledcSetup(LED_R, 5000, 8); 424 | ledcAttachPin(LED_R_PIN, LED_R); 425 | ledcSetup(LED_G, 5000, 8); 426 | ledcAttachPin(LED_G_PIN, LED_G); 427 | ledcSetup(LED_B, 5000, 8); 428 | ledcAttachPin(LED_B_PIN, LED_B); 429 | led_fx(); 430 | set_led(0, 0, 0); 431 | 432 | for (uint8_t i = 0; i < MAX_TAGS; i++) { 433 | memset(tagname[i], 0, sizeof(tagname[i])); 434 | memset(tagdata[i], 0, sizeof(tagdata[i])); 435 | memset(tagmac[i], 0, sizeof(tagmac[i])); 436 | tagrssi[i] = 0; 437 | tagtype[i] = 0; 438 | } 439 | 440 | LittleFS.begin(false, "/LittleFS", 1); 441 | loadSavedTags(); 442 | loadMQTT(); 443 | memset(gattcache,0,sizeof(gattcache)); 444 | 445 | BLEDevice::init(""); 446 | blescan = BLEDevice::getScan(); 447 | pClient = BLEDevice::createClient(); 448 | 449 | if (tagcount == 0) { 450 | startPortal(); 451 | } else { 452 | 453 | blescan->setAdvertisedDeviceCallbacks(new MyAdvertisedDeviceCallbacks()); 454 | blescan->setActiveScan(true); 455 | blescan->setInterval(100); 456 | blescan->setWindow(99); 457 | 458 | loadWifis(); 459 | client.setServer(mqtt_host, mqtt_port); 460 | 461 | // https://github.com/espressif/arduino-esp32/issues/2537#issuecomment-508558849 462 | WiFi.config(INADDR_NONE, INADDR_NONE, INADDR_NONE); 463 | WiFi.setHostname(myhostname); 464 | 465 | xTaskCreate(ble_task, "bletask", 4096, NULL, 1, &bletask); 466 | } 467 | } 468 | 469 | /* ------------------------------------------------------------------------------- */ 470 | void loop() { 471 | uint8_t do_send = 0; 472 | 473 | if (portal_timer == 0) { 474 | if (time(NULL) == 0 || interval == 0) do_send = 1; 475 | if (interval > 0) { 476 | if ((time(NULL) - lastpublish) >= (interval * 60)) do_send = 1; 477 | } 478 | if (digitalRead(BUTTON) == LOW) { 479 | do_send = 0; 480 | startPortal(); 481 | } 482 | // Sometimes GATT client connecting hangs and in the library the timeout is something like 50 days. 483 | // We don't want to wait that long. 484 | if (millis() - ble_timer > 60000) { 485 | Serial.println("BLE looks to be hanged. Reboot."); 486 | ESP.restart(); 487 | } 488 | } 489 | if (do_send == 1) { 490 | while (scanning) delay(100); 491 | mqtt_send(); 492 | } 493 | 494 | if (portal_timer > 0) { // are we in portal mode? 495 | if (millis() % 500 < 250) { 496 | set_led(int((millis() - portal_timer) / (APTIMEOUT / 128)), 128 - int((millis() - portal_timer) / (APTIMEOUT / 64) * 2), 0); 497 | } else { 498 | set_led(0, 0, 0); 499 | } 500 | server.handleClient(); 501 | if (millis() - portal_timer > APTIMEOUT) { 502 | Serial.println("Portal timeout. Booting."); 503 | delay(1000); 504 | ESP.restart(); 505 | } 506 | } 507 | } 508 | 509 | /* ------------------------------------------------------------------------------- */ 510 | 511 | void mqtt_send() { 512 | char json[512]; 513 | char topic[512]; 514 | short temperature = 0; 515 | unsigned short humidity; 516 | unsigned short foo; 517 | int pressure; 518 | int voltage; 519 | uint32_t wh; 520 | uint32_t wht; 521 | boolean published = false; 522 | 523 | WiFi.mode(WIFI_STA); 524 | 525 | for (uint8_t curr_tag = 0; curr_tag < MAX_TAGS; curr_tag++) { 526 | if (strlen(tagname[curr_tag]) > 0 && tagdata[curr_tag][0] != 0) { 527 | // Ruuvi tags 528 | if (tagtype[curr_tag] == TAG_RUUVI) { 529 | if (tagdata[curr_tag][0] != 0) { 530 | temperature = ((short)tagdata[curr_tag][8] << 8) | (unsigned short)tagdata[curr_tag][9]; 531 | humidity = ((unsigned short)tagdata[curr_tag][10] << 8) | (unsigned short)tagdata[curr_tag][11]; 532 | foo = ((unsigned short)tagdata[curr_tag][20] << 8) + (unsigned short)tagdata[curr_tag][21]; 533 | voltage = ((double)foo / 32 + 1600); 534 | pressure = ((unsigned short)tagdata[curr_tag][12] << 8) + (unsigned short)tagdata[curr_tag][13] + 50000; 535 | 536 | sprintf(json, "{\"type\":%d,\"t\":%d,\"rh\":%d,\"bu\":%d,\"ap\":%d,\"s\":%d}", 537 | tagtype[curr_tag], int(temperature * .05), int((float)humidity * .0025), 538 | voltage, int(pressure / 100), abs(tagrssi[curr_tag])); 539 | } 540 | } 541 | // Other tags -------------------------------------------------------------------------------------- 542 | // water gauge 543 | if (tagtype[curr_tag] == TAG_WATER) { 544 | if (tagdata[curr_tag][0] != 0) { 545 | sprintf(json, "{\"type\":%d,\"lv\":%d,\"s\":%d}", 546 | tagtype[curr_tag], (unsigned int)tagdata[curr_tag][10], abs(tagrssi[curr_tag])); 547 | } 548 | } 549 | // flame thermocouple 550 | if (tagtype[curr_tag] == TAG_THCPL) { 551 | // get the temperature 552 | foo = (((unsigned short)tagdata[curr_tag][10] << 8) + (unsigned short)tagdata[curr_tag][9]) >> 2; 553 | sprintf(json, "{\"type\":%d,\"t\":%d,\"s\":%d}", tagtype[curr_tag], foo * 10, abs(tagrssi[curr_tag])); 554 | } 555 | // energy meter pulse counter 556 | if (tagtype[curr_tag] == TAG_ENERGY) { 557 | if (tagdata[curr_tag][0] != 0) { 558 | wh = (((uint32_t)tagdata[curr_tag][16] << 24) + ((uint32_t)tagdata[curr_tag][15] << 16) 559 | + ((uint32_t)tagdata[curr_tag][14] << 8) + (uint32_t)tagdata[curr_tag][13]); 560 | 561 | wht = (((uint32_t)tagdata[curr_tag][12] << 24) + ((uint32_t)tagdata[curr_tag][11] << 16) 562 | + ((uint32_t)tagdata[curr_tag][10] << 8) + (uint32_t)tagdata[curr_tag][9]); 563 | 564 | sprintf(json, "{\"type\":%d,\"e\":%d,\"et\":%d,\"s\":%d}", tagtype[curr_tag], wh, wht, abs(tagrssi[curr_tag])); 565 | } 566 | } 567 | // Xiaomi Mijia thermometer with ATC_MiThermometer custom firmware by atc1441 568 | if (tagtype[curr_tag] == TAG_MIJIA) { 569 | if (tagdata[curr_tag][0] != 0) { 570 | temperature = ((short)tagdata[curr_tag][10] << 8) | (unsigned short)tagdata[curr_tag][11]; 571 | humidity = (unsigned short)tagdata[curr_tag][12]; 572 | voltage = ((short)tagdata[curr_tag][14] << 8) | (unsigned short)tagdata[curr_tag][15]; 573 | sprintf(json, "{\"type\":%d,\"t\":%d,\"rh\":%d,\"bu\":%d,\"s\":%d}", 574 | tagtype[curr_tag], int(temperature), int(humidity), int(voltage), abs(tagrssi[curr_tag])); 575 | } 576 | } 577 | // esp32 + ds1820 beacon 578 | if (tagtype[curr_tag] == TAG_DS1820) { 579 | if (tagdata[curr_tag][0] != 0) { 580 | temperature = ((short)tagdata[curr_tag][10] << 8) | (unsigned short)tagdata[curr_tag][9]; 581 | sprintf(json, "{\"type\":%d,\"t\":%d,\"s\":%d}", 582 | tagtype[curr_tag], int(temperature), abs(tagrssi[curr_tag])); 583 | } 584 | } 585 | // Mopeka gas tank sensor 586 | if (tagtype[curr_tag] == TAG_MOPEKA) { 587 | // This algorithm has been got from Mopeka Products, LLC. 588 | uint8_t level = 0x35; 589 | for (uint8_t i = 8; i < 27; i++) { 590 | level ^= tagdata[curr_tag][i]; 591 | } 592 | voltage = int(((float)tagdata[curr_tag][6] / 256.0f * 2.0f + 1.5f)*1000); // Mopeka specification 593 | sprintf(json, "{\"type\":%d,\"gh\":%d,\"s\":%d,\"bu\":%d}", 594 | tagtype[curr_tag], int(level*.762), abs(tagrssi[curr_tag]),voltage); 595 | } 596 | // Inkbird IBS-TH2 597 | if (tagtype[curr_tag] == TAG_IBSTH2) { 598 | if (tagdata[curr_tag][0] != 0) { 599 | temperature = ((short)tagdata[curr_tag][15] << 8) | (unsigned short)tagdata[curr_tag][14]; 600 | temperature = round(temperature/10); 601 | voltage = (short)tagdata[curr_tag][21]; // in Inkbird this is percentage, not voltage. 602 | sprintf(json, "{\"type\":%d,\"t\":%d,\"bp\":%d,\"s\":%d}", 603 | tagtype[curr_tag], int(temperature), voltage, abs(tagrssi[curr_tag])); 604 | if (memcmp(tagdata[curr_tag]+7, "\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0\0", 25) == 0) { 605 | json[0] = 0; // no valid data got, so set this tag to be skipped 606 | } 607 | } 608 | } 609 | if (tagtype[curr_tag] == TAG_ALPICOOL && alpicool_heard) { 610 | if (tagdata[curr_tag][0] != 0) { 611 | temperature = (short)tagdata[curr_tag][18] * 10; 612 | voltage = int((float)tagdata[curr_tag][20] * 1000 + (float)tagdata[curr_tag][21] * 100); 613 | sprintf(json, "{\"type\":%d,\"t\":%d,\"tt\":%d,\"bu\":%d,\"s\":%d}", 614 | tagtype[curr_tag], int(temperature), (short)tagdata[curr_tag][8] * 10, voltage, abs(tagrssi[curr_tag])); 615 | } 616 | } 617 | 618 | if (json[0] != 0) { 619 | memset(topic, 0, sizeof(topic)); 620 | sprintf(topic, "%s/%s", topicbase, tagname[curr_tag]); 621 | 622 | // convert possible spaces to underscores in topic 623 | for (uint8_t i = 0; i < strlen(topic); i++) { 624 | if (topic[i] == 32) topic[i] = '_'; 625 | } 626 | 627 | if (WiFiMulti.run() == WL_CONNECTED) { 628 | if (curr_tag == 0) { 629 | Serial.printf("Connected to SSID=%s - My IP=%s\n", 630 | WiFi.SSID().c_str(), WiFi.localIP().toString().c_str()); 631 | Serial.flush(); 632 | } 633 | if (client.connect(myhostname, mqtt_user, mqtt_pass)) { 634 | if (client.publish(topic, json)) { 635 | set_led(0, 128, 0); 636 | timerWrite(timer, 0); //reset timer (feed watchdog) 637 | Serial.printf("%s %s\n", topic, json); 638 | Serial.flush(); 639 | memset(tagdata[curr_tag], 0, sizeof(tagdata[curr_tag])); 640 | lastpublish = time(NULL); 641 | published = true; 642 | } else { 643 | set_led(51, 0, 0); 644 | Serial.print("Failed to publish MQTT, rc="); 645 | Serial.println(client.state()); 646 | } 647 | } else { 648 | set_led(51, 0, 0); 649 | Serial.printf("Failed to connect MQTT broker, state=%d\n", client.state()); 650 | } 651 | } else { 652 | set_led(128, 0, 0); 653 | Serial.printf("Failed to connect WiFi, status=%d\n", WiFi.status()); 654 | } 655 | } 656 | } 657 | memset(json, 0, sizeof(json)); 658 | delay(100); 659 | } 660 | ble_timer = millis(); // prevent to get false timeout if sending MQTT took long time. 661 | // If we published something, disconnect the client here to clean session. 662 | if (published) { 663 | client.disconnect(); 664 | Serial.println("Sending MQTT complete"); 665 | } 666 | set_led(0, 0, 0); 667 | } 668 | 669 | /* ------------------------------------------------------------------------------- */ 670 | /* 671 | This task handles BLE scanning and possible GATT request to an Alpicool fridge 672 | */ 673 | void ble_task(void *parameter) { 674 | //BLEScanResults foundDevices; 675 | task_counter = 0; 676 | 677 | while (1) { 678 | ble_timer = millis(); 679 | alpicool_heard = false; 680 | 681 | Serial.printf("============= start scan at %d seconds\n", int(millis()/1000)); 682 | scanning = true; 683 | foundDevices = blescan->start(11, false); 684 | blescan->clearResults(); 685 | /* Something is wrong if zero known tags is heard, so then reboot. 686 | Possible if all of them are out of range too, but that should not happen anyway. 687 | */ 688 | if (foundDevices.getCount() == 0 && tagcount > 0) ESP.restart(); 689 | Serial.printf("============= end scan\n"); 690 | 691 | if (alpicool_index != 0xFF) { 692 | if (alpicool_heard) { 693 | if (!pClient->isConnected()) { 694 | Serial.println("Connecting to Alpicool fridge"); 695 | pClient->connect(BLEAddress(tagmac[alpicool_index])); 696 | pClient->setMTU(32); 697 | pRemoteService = pClient->getService("00001234-0000-1000-8000-00805F9B34FB"); 698 | rCharacteristic = pRemoteService->getCharacteristic("00001236-0000-1000-8000-00805F9B34FB"); 699 | rCharacteristic->registerForNotify(alpicoolCallback); 700 | wCharacteristic = pRemoteService->getCharacteristic("00001235-0000-1000-8000-00805F9B34FB"); 701 | } else { 702 | Serial.println("Was already connected. Disconnect."); 703 | pClient->disconnect(); 704 | wCharacteristic = nullptr; 705 | } 706 | } 707 | // Send query request 708 | // See: https://github.com/klightspeed/BrassMonkeyFridgeMonitor 709 | if (wCharacteristic != nullptr && alpicool_heard) { 710 | Serial.println("Sending query to Alpicool fridge: fefe03010200"); 711 | wCharacteristic->writeValue({0xfe, 0xfe, 3, 1, 2, 0}, 6); 712 | } 713 | vTaskDelay(1000 / portTICK_PERIOD_MS); // give one second 714 | pClient->disconnect(); 715 | } 716 | scanning = false; 717 | Serial.printf("Task iteration: %d, Free heap: %d\n", task_counter++, ESP.getFreeHeap()); 718 | 719 | vTaskDelay(30000 / portTICK_PERIOD_MS); 720 | yield(); 721 | } 722 | } 723 | 724 | /* ------------------------------------------------------------------------------- */ 725 | /* Portal code begins here 726 | 727 | Yeah, I know that String objects are pure evil 😈, but this is meant to be 728 | rebooted immediately after saving all parameters, so it is quite likely that 729 | the heap will not fragmentate yet. 730 | */ 731 | /* ------------------------------------------------------------------------------- */ 732 | 733 | void startPortal() { 734 | 735 | Serial.print("Starting portal..."); 736 | portal_timer = millis(); 737 | timerWrite(timer, 0); 738 | if (bletask != nullptr) vTaskDelete(bletask); 739 | if (pClient->isConnected()) pClient->disconnect(); 740 | 741 | for (uint8_t i = 0; i < MAX_TAGS; i++) { 742 | memset(heardtags[i], 0, sizeof(heardtags[i])); 743 | } 744 | Serial.print("\nListening 11 seconds for new tags...\n"); 745 | 746 | // First listen 11 seconds to find new tags. 747 | set_led(0, 128, 128); 748 | blescan->setAdvertisedDeviceCallbacks(new ScannedDeviceCallbacks()); 749 | blescan->setActiveScan(true); 750 | blescan->setInterval(100); 751 | blescan->setWindow(99); 752 | BLEScanResults foundDevices = blescan->start(11, false); 753 | blescan->stop(); 754 | blescan->clearResults(); 755 | blescan = NULL; 756 | BLEDevice::deinit(true); 757 | set_led(0, 0, 0); 758 | 759 | portal_timer = millis(); 760 | timerWrite(timer, 0); 761 | 762 | WiFi.disconnect(); 763 | delay(100); 764 | WiFi.mode(WIFI_AP); 765 | WiFi.softAP(my_ssid); 766 | delay(2000); 767 | WiFi.softAPConfig(apIP, apIP, IPAddress(255, 255, 255, 0)); 768 | 769 | server.on("/", httpRoot); 770 | server.on("/style.css", httpStyle); 771 | server.on("/sensors.html", httpSensors); 772 | server.on("/savesens", httpSaveSensors); 773 | server.on("/wifis.html", httpWifi); 774 | server.on("/savewifi", httpSaveWifi); 775 | server.on("/mqtt.html", httpMQTT); 776 | server.on("/savemqtt", httpSaveMQTT); 777 | server.on("/boot", httpBoot); 778 | 779 | server.onNotFound([]() { 780 | server.sendHeader("Refresh", "1;url=/"); 781 | server.send(404, "text/plain", "QSD QSY"); 782 | }); 783 | server.begin(); 784 | Serial.println("Portal running."); 785 | } 786 | /* ------------------------------------------------------------------------------- */ 787 | 788 | void httpRoot() { 789 | portal_timer = millis(); 790 | timerWrite(timer, 0); 791 | String html; 792 | 793 | file = LittleFS.open("/LittleFS/index.html"); 794 | html = file.readString(); 795 | file.close(); 796 | 797 | server.send(200, "text/html; charset=UTF-8", html); 798 | } 799 | 800 | /* ------------------------------------------------------------------------------- */ 801 | 802 | void httpWifi() { 803 | String html; 804 | char tablerows[1024]; 805 | char rowbuf[256]; 806 | char ssid[33]; 807 | char pass[64]; 808 | int counter = 0; 809 | 810 | portal_timer = millis(); 811 | timerWrite(timer, 0); 812 | 813 | memset(tablerows, '\0', sizeof(tablerows)); 814 | 815 | file = LittleFS.open("/LittleFS/wifis.html"); 816 | html = file.readString(); 817 | file.close(); 818 | 819 | if (LittleFS.exists("/LittleFS/known_wifis.txt")) { 820 | file = LittleFS.open("/LittleFS/known_wifis.txt"); 821 | while (file.available()) { 822 | memset(rowbuf, '\0', sizeof(rowbuf)); 823 | memset(ssid, '\0', sizeof(ssid)); 824 | memset(pass, '\0', sizeof(pass)); 825 | file.readBytesUntil('\t', ssid, 33); 826 | file.readBytesUntil('\n', pass, 33); 827 | sprintf(rowbuf, "SSID", counter, ssid); 828 | strcat(tablerows, rowbuf); 829 | sprintf(rowbuf, "PASS", counter, pass); 830 | strcat(tablerows, rowbuf); 831 | counter++; 832 | } 833 | file.close(); 834 | } 835 | if (LittleFS.exists("/LittleFS/myhostname.txt")) { 836 | file = LittleFS.open("/LittleFS/myhostname.txt"); 837 | memset(myhostname, '\0', sizeof(myhostname)); 838 | file.readBytesUntil('\n', myhostname, sizeof(myhostname)); 839 | file.close(); 840 | } 841 | 842 | html.replace("###TABLEROWS###", tablerows); 843 | html.replace("###COUNTER###", String(counter)); 844 | html.replace("###MYHOSTNAME###", String(myhostname)); 845 | 846 | if (counter > 3) { 847 | html.replace("table-row", "none"); 848 | } 849 | 850 | server.send(200, "text/html; charset=UTF-8", html); 851 | } 852 | /* ------------------------------------------------------------------------------- */ 853 | 854 | void httpSaveWifi() { 855 | portal_timer = millis(); 856 | timerWrite(timer, 0); 857 | String html; 858 | 859 | file = LittleFS.open("/LittleFS/known_wifis.txt", "w"); 860 | if (!file) { 861 | Serial.println("Failed to open file for writing"); 862 | } 863 | 864 | for (int i = 0; i < server.arg("counter").toInt(); i++) { 865 | if (server.arg("ssid" + String(i)).length() > 0) { 866 | file.print(server.arg("ssid" + String(i))); 867 | file.print("\t"); 868 | file.print(server.arg("pass" + String(i))); 869 | file.print("\n"); 870 | } 871 | } 872 | // Add new 873 | if (server.arg("ssid").length() > 0) { 874 | file.print(server.arg("ssid")); 875 | file.print("\t"); 876 | file.print(server.arg("pass")); 877 | file.print("\n"); 878 | } 879 | file.close(); 880 | 881 | if (server.arg("myhostname").length() > 0) { 882 | file = LittleFS.open("/LittleFS/myhostname.txt", "w"); 883 | file.print(server.arg("myhostname")); 884 | file.print("\n"); 885 | file.close(); 886 | } 887 | 888 | file = LittleFS.open("/LittleFS/ok.html"); 889 | html = file.readString(); 890 | file.close(); 891 | 892 | server.sendHeader("Refresh", "2;url=/"); 893 | server.send(200, "text/html; charset=UTF-8", html); 894 | } 895 | /* ------------------------------------------------------------------------------- */ 896 | 897 | void httpMQTT() { 898 | portal_timer = millis(); 899 | timerWrite(timer, 0); 900 | String html; 901 | 902 | file = LittleFS.open("/LittleFS/mqtt.html"); 903 | html = file.readString(); 904 | file.close(); 905 | 906 | html.replace("###HOSTPORT###", String(mqtt_host) + ":" + String(mqtt_port)); 907 | html.replace("###USERPASS###", String(mqtt_user) + ":" + String(mqtt_pass)); 908 | html.replace("###TOPICBASE###", String(topicbase)); 909 | html.replace("###INTERVAL###", String(interval)); 910 | 911 | server.send(200, "text/html; charset=UTF-8", html); 912 | } 913 | /* ------------------------------------------------------------------------------- */ 914 | void httpSaveMQTT() { 915 | portal_timer = millis(); 916 | timerWrite(timer, 0); 917 | String html; 918 | 919 | file = LittleFS.open("/LittleFS/mqtt.txt", "w"); 920 | file.printf("%s\n", server.arg("hostport").c_str()); 921 | file.printf("%s\n", server.arg("userpass").c_str()); 922 | file.printf("%s\n", server.arg("topicbase").c_str()); 923 | file.printf("%s\n", server.arg("interval").c_str()); 924 | file.close(); 925 | loadMQTT(); // reread 926 | 927 | file = LittleFS.open("/LittleFS/ok.html"); 928 | html = file.readString(); 929 | file.close(); 930 | 931 | server.sendHeader("Refresh", "2;url=/"); 932 | server.send(200, "text/html; charset=UTF-8", html); 933 | } 934 | /* ------------------------------------------------------------------------------- */ 935 | 936 | void httpSensors() { 937 | String html; 938 | String tablerows; //char tablerows[16384]; 939 | char rowbuf[1024]; 940 | int counter = 0; 941 | 942 | portal_timer = millis(); 943 | timerWrite(timer, 0); 944 | 945 | file = LittleFS.open("/LittleFS/sensors.html"); 946 | html = file.readString(); 947 | file.close(); 948 | 949 | loadSavedTags(); 950 | 951 | for (int i = 0 ; i < MAX_TAGS; i++) { 952 | if (strlen(tagmac[i]) == 0) continue; 953 | 954 | sprintf(rowbuf, "%s\n", 955 | counter, tagmac[i]); 956 | tablerows += String(rowbuf); 957 | sprintf(rowbuf, "", 958 | counter, counter, tagname[i]); 959 | tablerows += String(rowbuf); 960 | sprintf(rowbuf, "", counter, counter, tagmac[i]); 961 | tablerows += String(rowbuf); 962 | if (counter > 0) { 963 | sprintf(rowbuf, "
\u2191\n", counter); 964 | tablerows += String(rowbuf); 965 | } else { 966 | tablerows += "\n"; 967 | } 968 | counter++; 969 | } 970 | if (strlen(heardtags[0]) != 0 && counter < MAX_TAGS) { 971 | for (int i = 0; i < MAX_TAGS; i++) { 972 | if (strlen(heardtags[i]) == 0) continue; 973 | if (getTagIndex(heardtags[i]) != 0xFF) continue; 974 | 975 | sprintf(rowbuf, "%s   %s\n", 976 | counter, heardtags[i], type_name[heardtagtype[i]]); 977 | tablerows += String(rowbuf); 978 | sprintf(rowbuf, "", counter, counter); 979 | tablerows += String(rowbuf); 980 | sprintf(rowbuf, "", 981 | counter, counter, heardtags[i]); 982 | tablerows += String(rowbuf); 983 | if (counter > 0) { 984 | sprintf(rowbuf, "\u2191\n", counter); 985 | tablerows += String(rowbuf); 986 | } else { 987 | tablerows += "\n"; 988 | } 989 | counter++; 990 | if (counter > MAX_TAGS) break; 991 | } 992 | } 993 | 994 | html.replace("###TABLEROWS###", tablerows); 995 | html.replace("###COUNTER###", String(counter)); 996 | 997 | server.send(200, "text/html; charset=UTF-8", html); 998 | } 999 | /* ------------------------------------------------------------------------------- */ 1000 | 1001 | void httpSaveSensors() { 1002 | portal_timer = millis(); 1003 | timerWrite(timer, 0); 1004 | String html; 1005 | 1006 | file = LittleFS.open("/LittleFS/known_tags.txt", "w"); 1007 | 1008 | for (int i = 0; i < server.arg("counter").toInt(); i++) { 1009 | if (server.arg("sname" + String(i)).length() > 0) { 1010 | file.print(server.arg("saddr" + String(i))); 1011 | file.print("\t"); 1012 | file.print(server.arg("sname" + String(i))); 1013 | file.print("\n"); 1014 | } 1015 | } 1016 | file.close(); 1017 | loadSavedTags(); // reread 1018 | 1019 | file = LittleFS.open("/LittleFS/ok.html"); 1020 | html = file.readString(); 1021 | file.close(); 1022 | 1023 | server.sendHeader("Refresh", "2;url=/"); 1024 | server.send(200, "text/html; charset=UTF-8", html); 1025 | } 1026 | /* ------------------------------------------------------------------------------- */ 1027 | 1028 | void httpStyle() { 1029 | portal_timer = millis(); 1030 | timerWrite(timer, 0); 1031 | String css; 1032 | 1033 | file = LittleFS.open("/LittleFS/style.css"); 1034 | css = file.readString(); 1035 | file.close(); 1036 | server.send(200, "text/css", css); 1037 | } 1038 | 1039 | /* ------------------------------------------------------------------------------- */ 1040 | void httpBoot() { 1041 | portal_timer = millis(); 1042 | timerWrite(timer, 0); 1043 | String html; 1044 | 1045 | file = LittleFS.open("/LittleFS/ok.html"); 1046 | html = file.readString(); 1047 | file.close(); 1048 | 1049 | server.sendHeader("Refresh", "2;url=about:blank"); 1050 | server.send(200, "text/html; charset=UTF-8", html); 1051 | delay(1000); 1052 | 1053 | ESP.restart(); 1054 | } 1055 | /* ------------------------------------------------------------------------------- */ 1056 | -------------------------------------------------------------------------------- /esp32_ble2mqtt.littlefs.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oh2mp/esp32_ble2mqtt/eb0616e684e29f5e447b27fee1abf53121020c3f/esp32_ble2mqtt.littlefs.bin -------------------------------------------------------------------------------- /s/alpicool_nx42.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oh2mp/esp32_ble2mqtt/eb0616e684e29f5e447b27fee1abf53121020c3f/s/alpicool_nx42.jpg -------------------------------------------------------------------------------- /s/mqtt_config.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oh2mp/esp32_ble2mqtt/eb0616e684e29f5e447b27fee1abf53121020c3f/s/mqtt_config.jpg -------------------------------------------------------------------------------- /s/portal.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oh2mp/esp32_ble2mqtt/eb0616e684e29f5e447b27fee1abf53121020c3f/s/portal.jpg -------------------------------------------------------------------------------- /s/sensors_config.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/oh2mp/esp32_ble2mqtt/eb0616e684e29f5e447b27fee1abf53121020c3f/s/sensors_config.jpg --------------------------------------------------------------------------------