From bca0d18bfc21cb255549cb4f03c3b418da61f868 Mon Sep 17 00:00:00 2001 From: Will Date: Tue, 30 Apr 2024 21:18:40 -0700 Subject: [PATCH] Add keypad, song status, volume, initial enclosure --- SmartAirSpeaker.ino | 168 ++++++++++++++++++++++++++++++++++++------- SmartAirSpeaker.scad | 88 +++++++++++++++++++++++ 2 files changed, 231 insertions(+), 25 deletions(-) create mode 100644 SmartAirSpeaker.scad diff --git a/SmartAirSpeaker.ino b/SmartAirSpeaker.ino index 2a73e2d..311e3cc 100644 --- a/SmartAirSpeaker.ino +++ b/SmartAirSpeaker.ino @@ -5,6 +5,7 @@ #include #include "BluetoothA2DPSink.h" #include //http://librarymanager/All#SparkFun_Qwiic_OLED +#include "SparkFun_Qwiic_Keypad_Arduino_Library.h" //Click here to get the library: http://librarymanager/All#SparkFun_keypad EspMQTTClient client( "YourSSID", @@ -29,14 +30,25 @@ WM8960 codec; // http://librarymanager/All#SparkFun_WM8960 BluetoothA2DPSink a2dp_sink; // https://github.com/pschatzmann/ESP32-A2DP Bsec iaqSensor; // Bsec Bosch sensors QwiicMicroOLED myOLED; // OLED +KEYPAD keypad1; //Keypad + +// via http://en.radzio.dxp.pl/bitmap_converter/ +uint8_t volumeIcon[] = { + 0x38, 0x38, 0x7C, 0x00, 0x10, 0x44, 0x38, 0x82, 0x7C, +}; String output; String header = "Timestamp [ms], IAQ, IAQ accuracy, Static IAQ, CO2 equivalent, breath VOC equivalent, raw temp[°C], pressure [hPa], raw relative humidity [%], gas [Ohm], Stab Status, run in status, comp temp[°C], comp humidity [%], gas percentage"; unsigned long last_header = 0; -unsigned long last_report = 0; +unsigned long last_sensor_run = 0; int width, height; -uint8_t * playtime; +uint32_t playtime, play_position = 0; // milliseconds String artist, title; +int speakerVolume = 0; +int speakerAcGain = 0; +int speakerDcGain = 0; +bool useCelsius = false; +int playback_status = esp_avrc_playback_stat_t::ESP_AVRC_PLAYBACK_STOPPED; // Helper functions declarations void checkIaqSensorStatus(void); @@ -61,6 +73,8 @@ void setup() a2dp_sink.set_avrc_metadata_attribute_mask(ESP_AVRC_MD_ATTR_TITLE | ESP_AVRC_MD_ATTR_ARTIST | ESP_AVRC_MD_ATTR_PLAYING_TIME ); a2dp_sink.set_avrc_metadata_callback(avrc_metadata_callback); + a2dp_sink.set_avrc_rn_playstatus_callback(avrc_rn_playstatus_callback); + a2dp_sink.set_avrc_rn_play_pos_callback(avrc_rn_play_pos_callback); a2dp_sink.start("WillTooth Audio"); // Note, you can give your device any name! // OLED setup @@ -71,6 +85,11 @@ void setup() width = myOLED.getWidth(); height = myOLED.getHeight(); + if (keypad1.begin() == false) + { + Serial.println("Keypad setup failed."); + } + pinMode(LED_BUILTIN, OUTPUT); digitalWrite(LED_BUILTIN, LOW); iaqSensor.begin(BME68X_I2C_ADDR_LOW, Wire); @@ -99,13 +118,25 @@ void setup() } void avrc_metadata_callback(uint8_t data1, const uint8_t *data2) { - Serial.printf("AVRC metadata rsp: attribute id 0x%x, %s\n", data1, data2); + Serial.printf("AVRC metadata rsp: attribute id 0x%x \"%s\"\n", data1, data2); if (data1 == 0x1) title = String((char*)data2); else if (data1 == 0x2) artist = String((char*)data2); - // else if (data1 == 0x40) - // playtime = data2; + else if (data1 == 0x40) { + playtime = String((char*)data2).toInt(); // data2 is milliseconds as a text string + Serial.printf("Playtime \"%s\" stored as %d\n", data2, playtime); + } +} + +void avrc_rn_play_pos_callback(uint32_t play_pos) { + Serial.printf("Got play_pos %d\n", play_pos); + play_position = play_pos; +} + +void avrc_rn_playstatus_callback(esp_avrc_playback_stat_t playback) { + Serial.printf("Got playstatus %d\n", playback); + playback_status = playback; } void onConnectionEstablished() { @@ -114,10 +145,58 @@ void onConnectionEstablished() { void loop(void) { + keypad1.updateFIFO(); // necessary for keypad to pull button from stack to readable register + char button = keypad1.getButton(); + if (button != 0 && button != -1) { + if (button == 0x32) { // '2' + if (speakerVolume < 9) speakerVolume += 1; + codec.setSpeakerVolumeDB(speakerVolume*2); + Serial.print("vol: "); + Serial.println(speakerVolume); + } else if (button == 0x38) { // '8' + if (speakerVolume > -10) speakerVolume -= 1; + codec.setSpeakerVolumeDB(speakerVolume*2); + Serial.print("vol: "); + Serial.println(speakerVolume); + } else if (button == 0x36) { // '6' + a2dp_sink.next(); + Serial.print("next"); + } else if (button == 0x34) { // '4' + a2dp_sink.previous(); + Serial.print("prev"); + } else if (button == 0x35) { // '5' + Serial.println("play/pause"); + if (playback_status == esp_avrc_playback_stat_t::ESP_AVRC_PLAYBACK_PLAYING) a2dp_sink.pause(); + else if (playback_status == esp_avrc_playback_stat_t::ESP_AVRC_PLAYBACK_PAUSED) a2dp_sink.play(); + else if (playback_status == esp_avrc_playback_stat_t::ESP_AVRC_PLAYBACK_STOPPED) a2dp_sink.play(); + else Serial.printf("pause err: %d\n", playback_status); + } else if (button == 0x23) { // '#' + useCelsius = !useCelsius; + Serial.print("celsius: "); + Serial.println(useCelsius); + } else { + Serial.print("Unknown: "); + Serial.println(button); + } + /*if( button == 0x31 ) Serial.println("(1)"); + if( button == 0x32 ) Serial.println("(2)"); + if( button == 0x33 ) Serial.println("(3)"); + if( button == 0x34 ) Serial.println("(4)"); + if( button == 0x35 ) Serial.println("(5)"); + if( button == 0x36 ) Serial.println("(6)"); + if( button == 0x37 ) Serial.println("(7)"); + if( button == 0x38 ) Serial.println("(8)"); + if( button == 0x39 ) Serial.println("(9)"); + if( button == 0x2a ) Serial.println("(*)"); + if( button == 0x30 ) Serial.println("(0)"); + if( button == 0x23 ) Serial.println("(#)");*/ + } + unsigned long time_trigger = millis(); - if (iaqSensor.run()) { // Do IAQ work and proceed unless there's an issue - if (last_report == 0 || time_trigger-last_report > 60000) { - if (last_header == 0 || time_trigger-last_header > 600000) { + if (last_sensor_run == 0 || time_trigger-last_sensor_run > 2000) { + last_sensor_run = time_trigger; + if (iaqSensor.run()) { // Do IAQ work and proceed unless there's an issue + if (last_header == 0 || time_trigger-last_header > 20000) { Serial.println(header); last_header = time_trigger; } @@ -167,40 +246,79 @@ void loop(void) String json; serializeJson(obj,json); client.publish("esp/status", json); - + // Serial.printf("Took %d ms\n", millis()-last_sensor_run); digitalWrite(LED_BUILTIN, HIGH); - last_report = time_trigger; - drawGraph(iaqSensor.temperature, iaqSensor.humidity, iaqSensor.co2Equivalent, iaqSensor.co2Accuracy); } } else { checkIaqSensorStatus(); } client.loop(); - sleep(1); + drawGraph(iaqSensor.temperature, iaqSensor.humidity, iaqSensor.co2Equivalent, iaqSensor.co2Accuracy); + delay(10); } void drawGraph(int temp, int humid, int co2e, int co2acc) { - myOLED.erase(); + myOLED.erase(); + int lineHeight = myOLED.getStringHeight("H"); - String out = "Temp: "+String(temp)+"'\n"+ - "Hum: "+String(humid)+"%\n"; + String out; + if (useCelsius) + out += String(temp)+"C "; + else + out += String((int)round(temp*1.8+32))+"F "; + + out += String(humid)+"% "; + // out += String(speakerVolume+11); //+String(speakerAcGain)+" "+String(speakerDcGain)+"\n"; + myOLED.setCursor(0, 0); + myOLED.print(out); - if(co2acc>0) - out += "CO2: "+String(co2e); - else - out += "CO2: --"; + int offset=0; + if (speakerVolume < -5) offset=6; + else if (speakerVolume < 0) offset=4; + else if (speakerVolume < 5) offset=2; + // else leave at 0 - if(!title.isEmpty() && !artist.isEmpty()) - out += "\n"+title+" - "+artist; + myOLED.bitmap(myOLED.getWidth()-9, 0, 9-offset, 8, volumeIcon, 9, 8); - myOLED.setCursor(0, 0); + if(co2acc>0) + out = "CO2: "+String(co2e); + else + out = "CO2: --"; + + myOLED.setCursor(0, lineHeight*2); + myOLED.print(out); + + if(!title.isEmpty() && !artist.isEmpty()) { + unsigned long time = millis(); + int titlePos, artistPos = 0; + if (title.length() > 10) { + titlePos = (time/1000)%(title.length()-9); + } + if (artist.length() > 10) { + artistPos = (time/1000)%(artist.length()-9); + } + out = title.substring(titlePos,titlePos+10); + + myOLED.setCursor(0, lineHeight*4-2); myOLED.print(out); - //myOLED.line(xS, yS, xE, yE); - myOLED.display(); - delay(10); + out = artist.substring(artistPos,artistPos+10); + + myOLED.setCursor(0, lineHeight*5-2); + myOLED.print(out); + } + + if (playtime>0) { + float playpct = (float)play_position/(float)playtime; + int height = myOLED.getHeight()-1; + // Serial.printf("(%d/%d = %.1fpct)\n", play_position, playtime, 100.0*playpct); + myOLED.line(0, height, round(myOLED.getWidth()*playpct), height); + } + + myOLED.display(); + delay(10); } // Helper function definitions diff --git a/SmartAirSpeaker.scad b/SmartAirSpeaker.scad new file mode 100644 index 0000000..1588ce0 --- /dev/null +++ b/SmartAirSpeaker.scad @@ -0,0 +1,88 @@ +include + +$fn=40; + +leeway=0.5; + +bodyFaceW=120; +bodyFaceH=100; +bodyBackW=20; +bodyBackH=15; +bodyDepth=50; + +screenWidth=16.5+leeway; // visible +screenHeight=13+leeway; // visible +screenY=-35; +screenX=30; +screenCutoutWidth=19+leeway; +screenCutoutHeight=19.5+leeway; +screenMountWidth=21+leeway; +screenMountHeight=21+leeway; + +keypadWidth=46+leeway; // visible +keypadHeight=57+leeway; // visible +keypadDepth=4; +keypadY=10; +keypadX=30; +keypadMountDia=1; + +speakerWidth=45; // visible, 50 OD +speakerConeWidth=60; +speakerDepth=2.2; +speakerY=0; +speakerX=-27; + +sensorWidth=4; // visible +sensorDepth=4; +sensorY=-15; + +powerWidth=4; // visible +powerDepth=10; +powerY=0; + +right_half() +diff("hole","mount") translate([0, 0, 0]) rotate([50,0,0]) { + // body + prismoid([bodyBackW, bodyBackH], [bodyFaceW, bodyFaceH], bodyDepth, rounding=4) + tag("hole") { + attach([TOP]) { + // screen + translate([screenX,-screenY,0.1]) cuboid([screenWidth,screenHeight,4], anchor=TOP); + // screen thickness cutout + translate([screenX,-screenY,-1]) cuboid([screenCutoutWidth,screenCutoutHeight,2], anchor=TOP) + tag("mount") { + position(FRONT+LEFT) translate([-2,2,-0]) cylinder(h=2, d=keypadMountDia, anchor=TOP); + position(BACK+LEFT) translate([-2,-4,-0]) cylinder(h=2, d=keypadMountDia, anchor=TOP); + position(FRONT+RIGHT) translate([2,2,-0]) cylinder(h=2, d=keypadMountDia, anchor=TOP); + position(BACK+RIGHT) translate([2,-4,-0]) cylinder(h=2, d=keypadMountDia, anchor=TOP); + } + + // keypad + translate([keypadX,-keypadY,0.1]) cuboid([keypadWidth,keypadHeight,keypadDepth], rounding=4, edges=["Z"], anchor=TOP) + tag("mount") { + //TODO: actually measure these, consider screws + position(FRONT+LEFT) translate([-.2,-1,0]) cylinder(h=2, d=keypadMountDia, anchor=TOP); + position(BACK+LEFT) translate([-.2,1,0]) cylinder(h=2, d=keypadMountDia, anchor=TOP); + position(FRONT+RIGHT) translate([.2,-1,0]) cylinder(h=1, d=keypadMountDia, anchor=TOP); + position(BACK+RIGHT) translate([.2,1,0]) cylinder(h=1, d=keypadMountDia, anchor=TOP); + } + + // speaker + translate([speakerX,-speakerY,0.1]) cylinder(h=speakerDepth, d1=speakerWidth, d2=speakerConeWidth, anchor=TOP) + tag("mount") { + zrot_copies([90, 180, 270]) left(speakerConeWidth/2) + cylinder(h=5, d1=2, d2=2, anchor=TOP); + } + } + attach(BACK) { + // sensor + translate([0,-sensorY,0]) cylinder(h=sensorDepth, d=sensorWidth, center=true); + } + attach(BOTTOM) { + // power + translate([0,-powerY,0]) cylinder(h=powerDepth, d=powerWidth, center=true); + } + // hollow + prismoid([bodyBackW-5, bodyBackH-5], [bodyFaceW-6, bodyFaceH-6], bodyDepth-4, rounding=3, center=true); + } +} \ No newline at end of file