├── 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 | 
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 | 
123 | 
124 | 
125 |
126 | ------
127 |
128 |
--------------------------------------------------------------------------------
/data/littlefs/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | ESP32 BLE2MQTT
9 |
10 |
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 |
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 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/data/littlefs/sensors.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
23 |
24 |
25 |
26 |
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 |
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
--------------------------------------------------------------------------------