From e63b997de9eb47e32a433059db5314ab4ef8d691 Mon Sep 17 00:00:00 2001 From: Connor Rigby Date: Tue, 24 Dec 2024 08:59:41 -0800 Subject: [PATCH] Working again --- .github/workflows/elixir.yaml | 3 +- .gitignore | 1 + examples/govee/.formatter.exs | 4 - examples/govee/.gitignore | 24 -- examples/govee/README.md | 70 ---- examples/govee/govee-ledstrip.log | Bin 2745 -> 0 bytes .../govee/hci_snoop_2020_09_08_18_54_17.cfa | Bin 39703 -> 0 bytes examples/govee/lib/govee_bulb.ex | 165 -------- examples/govee/lib/govee_ledstrip.ex | 171 -------- examples/govee/mix.exs | 29 -- examples/govee/mix.lock | 5 - examples/scanner.ex | 379 ------------------ lib/blue_heron.ex | 3 + lib/blue_heron/acl_buffer.ex | 2 - lib/blue_heron/application.ex | 2 + lib/blue_heron/central.ex | 28 -- lib/blue_heron/central/server.ex | 12 - lib/blue_heron/error_code.ex | 2 +- lib/blue_heron/gatt/server.ex | 105 +++-- lib/blue_heron/gatt/service.ex | 81 +++- lib/blue_heron/peripheral.ex | 156 ++++++- lib/blue_heron/registry.ex | 2 +- lib/blue_heron/smp.ex | 118 +++--- lib/blue_heron/smp/default_io_handler.ex | 22 + mix.exs | 2 +- test/blue_heron/gatt/server_test.exs | 36 +- .../transport/uart/framing_test.exs | 8 +- 27 files changed, 385 insertions(+), 1045 deletions(-) delete mode 100644 examples/govee/.formatter.exs delete mode 100644 examples/govee/.gitignore delete mode 100644 examples/govee/README.md delete mode 100644 examples/govee/govee-ledstrip.log delete mode 100644 examples/govee/hci_snoop_2020_09_08_18_54_17.cfa delete mode 100644 examples/govee/lib/govee_bulb.ex delete mode 100644 examples/govee/lib/govee_ledstrip.ex delete mode 100644 examples/govee/mix.exs delete mode 100644 examples/govee/mix.lock delete mode 100644 examples/scanner.ex delete mode 100644 lib/blue_heron/central.ex delete mode 100644 lib/blue_heron/central/server.ex create mode 100644 lib/blue_heron/smp/default_io_handler.ex diff --git a/.github/workflows/elixir.yaml b/.github/workflows/elixir.yaml index 74208cf..dbf9aa0 100644 --- a/.github/workflows/elixir.yaml +++ b/.github/workflows/elixir.yaml @@ -90,4 +90,5 @@ jobs: # Step: Execute the tests. - name: Run tests - run: mix test + run: mix test --no-start + diff --git a/.gitignore b/.gitignore index 51bec1b..e34a01f 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ blue_heron-*.tar config/ !config/config.exs +old diff --git a/examples/govee/.formatter.exs b/examples/govee/.formatter.exs deleted file mode 100644 index d2cda26..0000000 --- a/examples/govee/.formatter.exs +++ /dev/null @@ -1,4 +0,0 @@ -# Used by "mix format" -[ - inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] -] diff --git a/examples/govee/.gitignore b/examples/govee/.gitignore deleted file mode 100644 index b177b27..0000000 --- a/examples/govee/.gitignore +++ /dev/null @@ -1,24 +0,0 @@ -# The directory Mix will write compiled artifacts to. -/_build/ - -# If you run "mix test --cover", coverage assets end up here. -/cover/ - -# The directory Mix downloads your dependencies sources to. -/deps/ - -# Where third-party dependencies like ExDoc output generated docs. -/doc/ - -# Ignore .fetch files in case you like to edit your project deps locally. -/.fetch - -# If the VM crashes, it generates a dump, let's ignore it too. -erl_crash.dump - -# Also ignore archive artifacts (built via "mix archive.build"). -*.ez - -# Ignore package tarball (built via "mix hex.build"). -blue_heron_example_govee-*.tar - diff --git a/examples/govee/README.md b/examples/govee/README.md deleted file mode 100644 index 341e0d7..0000000 --- a/examples/govee/README.md +++ /dev/null @@ -1,70 +0,0 @@ -# GoveeBulb - -This is a sample ATT application that can control a [Govee LED Light -Bulb](https://www.amazon.com/MINGER-Dimmable-Changing-Equivalent-Multi-Color/dp/B07CL2RMR7/). -See -[github.com/Freemanium/govee_btled](https://github.com/Freemanium/govee_btled) -for details on the protocol used to control the bulb. - -## USB - -On a Linux PC where `bluetoothd` immediately grabs the Bluetooth USB module as -soon as it's inserted, here's the current procedure: (yes, this isn't ideal) - -Before you begin you need to find the vendor id (`vid`) and product id (`pid`) of your bluetooth adapter. One way of doing that is by inspecting the output of `dmesg` after plugging in your adapter: - -
- Example dmesg output - - ``` - [174634.130045] usb 1-9: new full-speed USB device number 8 using xhci_hcd - [174634.453638] usb 1-9: New USB device found, idVendor=0a5c, idProduct=21e8, bcdDevice= 1.12 - [174634.453643] usb 1-9: New USB device strings: Mfr=1, Product=2, SerialNumber=3 - [174634.453645] usb 1-9: Product: BCM20702A0 - [174634.453647] usb 1-9: Manufacturer: Broadcom Corp - [174634.453649] usb 1-9: SerialNumber: 00190E112B40 - [174634.513882] audit: type=1130 audit(1599509227.196:198): pid=1 uid=0 auid=4294967295 ses=4294967295 msg='unit=systemd-rfkill comm="systemd" exe="/usr/lib/systemd/systemd" hostname=? addr=? terminal=? res=success' - [174634.581454] Bluetooth: hci0: BCM: chip id 63 - [174634.584453] Bluetooth: hci0: BCM: features 0x07 - [174634.602454] Bluetooth: hci0: BCM20702A - [174634.602459] Bluetooth: hci0: BCM20702A1 (001.002.014) build 0000 - [174634.604527] Bluetooth: hci0: BCM20702A1 'brcm/BCM20702A1-0a5c-21e8.hcd' Patch - [174636.066728] Bluetooth: hci0: Broadcom Bluetooth Device - [174636.066733] Bluetooth: hci0: BCM20702A1 (001.002.014) build 1459 - [174639.517580] audit: type=1131 audit(1599509232.199:199): pid=1 uid=0 auid=4294967295 ses=4294967295 msg='unit=systemd-rfkill comm="systemd" exe="/usr/lib/systemd/systemd" hostname=? addr=? terminal=? res=success' - ``` - - In this example the vid is `0a5c`, and the pid is `21e8`, which can be written in elixir hex notation as `0x0a5c` and `0x21e8`. -
- -```sh -$ sudo systemctl stop bluetooth - -$ mix deps.get -$ mix compile - -# The "easiest" way of giving the port process low level access to the USB -# port is to setuid root it. This needs to be done on every recompile, so -# if you suddenly find that you don't have access, try running the following -# again. -$ sudo chown root:root ./_build/dev/lib/blue_heron_transport_usb/priv/hci_transport -$ sudo chmod +s ./_build/dev/lib/blue_heron_transport_usb/priv/hci_transport - -# Run Elixir interactively -$ iex -S mix -# Use the pid and vid you found earlier here: -iex> {:ok, pid} = GoveeBulb.start_link(:usb, %{vid: 0x0bda, pid: 0xb82c}) -iex> GoveeBulb.set_color(pid, 0xFFFF40) -``` - -## UART - -See [this -gist](https://gist.github.com/fhunleth/fae46998609814ae4a8abd44f6f08188#setting-up-a-test-environment) -for making a Raspberry Pi Zero W into a Bluetooth UART module for your laptop. - -```elixir -# Note: your uart device may be something different than /dev/ttyACM0, in that case substitute it here -{:ok, pid} = GoveeBulb.start_link(:uart, %{device: "ttyACM0"}) -GoveeBulb.set_color(pid, 0xFFFF40) -``` diff --git a/examples/govee/govee-ledstrip.log b/examples/govee/govee-ledstrip.log deleted file mode 100644 index ab31cc75070a5fa0aeee591de4c6848a2d4f0ad7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2745 zcmbW3O=uHA6vroD%_qrbH%)A+Fw$ym;|Fb}o(dW&2%cIG9`sZz(u)za9xSCd5m7H9 zh*Ge@4~W%+2Mq}AMGzV<>PfH{@uKY|RE2tp@9j)xcG#T~Gw^06Gr#}5H*aQlGkN9m z#Zu`Kf#M@1@Do~t8;%k3?bglJ$Ya0XPvRs@g2eEVMuY5w88;UT)+LwHbMdLFYAB5@mjxRO0rg8xdSItW<4D0E*dQox_NRY;_QTU^Bn)Qt7>ynGX zdN!AYT%)mq=V6q$fQyn4oJTMY92ECy7;BDQBwz_mbm<-UrB~!M(WNJrC70ZWyM04) zDU#LteSc5oG=9zCdC8?=Jso$nOLCn}&YeAT7H=ufo=Ggso(J{nnxwL@nrDv9jWg0` zj^uTFwfa7)oMsQ!x!02Gg7s|99i6o>CZo#(2PMS~`|P>3ew_v?Mmpf8SUx|Sa4(k0 zO^L-!6W;?~3CWJmO3KnvJGUk!m1Pu#_0U+s3*2)IuUt3D!3Cb^B$bZlq-d9*N4c>< zTQ@eS=z%UWJ-w%92$vD$&%2B`N8Y;$6*B?2q=wf2F*?9yV$_*MQ zs2eD3s5)0d7lEYQpn-zA!uVZ9hgVN6Pv9rz#%55re`ZtBW!RLXUgjDq#!z=)VV9zF zg-N{6{ou+MMHdvSHap(ue(3vEMHg~8^wtf=rWIY;rHg`N%8kW<`x{y;C_0x+v%jIb zsp!h;ZJ;0D>i@4h@wwKP?!p`V>$0uCe|UmD<^TWy diff --git a/examples/govee/hci_snoop_2020_09_08_18_54_17.cfa b/examples/govee/hci_snoop_2020_09_08_18_54_17.cfa deleted file mode 100644 index faf139c060aed507be5dcece9f039a664ec388b8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 39703 zcmeHQd3+Q__OG5RnIudm7X%3~Vz{pWiin7TfCjk&B6z^hprD`#Q9STKaAg(o*sOAd z5Fi|aMnqHuR1{S3NKjExSrz5lT|{(2aft%S@B6CysGjN>!>Ir0)`v=WUGIJC)$v{( zJzRD3_ghaOktch4#IbnFm8g4(d5>GQE=h3SXXsuk-Fq|=Q!=JdDNo@+Kl9)(FHeNRi1jJe zi4h?OarF64)3`X2kxpI$GH{e9kVMIT#{7-htAxRU#Gp}{pOdJEbn+63i@)&%5-54c z*tDOPj&UY1yhd|z0v95kyhPyg3{RjBCD+#0y5Fn)4<5DCzE3_-bN;b=YqOAdG)24` z`my#fCDl@6L~>pvke6h6`C82rq;_B_x|ettrh5Vbq@zS_&EINvo`1#t$mBq!@J?+w z1cgLmPhO%j_HpV9!z1;D@n2~-B)K>Qhr$y&QQ;eSVF$(&TN8XL>;S*$Q60YK5^#{s z5&n%yM1`c{KxJ}*PQ~TwYmss229s~qE!L9k8Kz&MvtXqY&pf;L?Fut2o zc1K9JzMDhiRYDa6s~Oq&t+o=Q_<5LJ}X@G`giq z+`G^dh@<4=jC!j>>A%j>vO-AW11W4U-TyQVCM5H2D#$vJn3<}%BaxVOgh(K{b}BL9 z&Vj^(xmueLB|6mq;l5OVBvS^5vJdak&Y&c7-jnM8=zl$dcuH=>@~O)?6ra6F>&}Z~ z#-Le47GLRLPrygXH_>e9P`GrDb}ldMfP3-xgd52ud1`p_E_i*?UouJ4Lh;jTXu9Uk zVJxk++D+#nj{}J(&*CH^^}LGLwM0IVIZUR{JVT9#WFNzL z^w3~-62hiKTI&#{IdI=Pp16x-2d%R8zgmZoYX-HLL*aLhQsEAqw!=(P*9cj&@PNshB)W(N{ zk!Q*uFNro&I*giQ&9!unvKGw9Ta~D0=&mR9WWw@ibSF@0@>aj8)@EvHN!5cVjxCsQ z%j8hkKc7_8{$3qFi@IzY?@2#GBIG5J#UIs%CLPfd{HD}2x8}Wv4}d?m{2XMJ{8y6I zP%bNVL{=y7(K16M6jcjUH)u;*0Lixx{H$rsbxrO;HZn8FOX6xg!`sX4W~qOM7ISE?(PORlQWe4RddsyINv=YYq+0lR=B-q? zMTCKIJoCG7L3`b)DSrl0JIJc(`Iey7N#uCb+2OK^gRGkV38dv9t5$QO$?9Z}gm78; z-AO5b25G6XI(fl=qRQ&Re}u~_(VdX;XONbItS(*>RaV0u371tpE~~&FzHd3mYUFZD zR>q>!(!w=YjnKL$VH@wkMUJYW=9*imcz3QE9Ex9iq1GhH!{Sq&HA~I)Eva}DUR+&O z)l6uoofc_jR5S5zDtlU_vXjcFtn*5+W^xm&tOM-HRUCFO671WaiwgU;1H_u7s0zI1 z4!)|22>Z?pscZysP2Fd~R+UT5-F?D4cCy*A>;IvZjDwtK6qBrkh3X)y86StsD#esl zM#>*qS`M&11N|6m7d@hEhQAkTEBF- z+iS~_MMdDr;08oW2&t^6?#)$1r6;3C%L{eqhqROa>hXCcN6R5d=F+oxPXr_Bf4D%B`J)0)*YMXt;tY$XeL?atp z!IF69_X3hcAM*}it_(VMMCMVt=N*Ggu1e417hGR-xReb;N&>QIY<+-D&u4jS{14Pi zXD#y9`X30;bA$fe=%3lPKEj!aB7Rulfv!P>FAdz+HK?`7o1i~81Rm&e4g*51TzKAD zxSdz&DSrn{7ija)YZGd#JsLaUVMY986EK9M6>AVFug0^G#>$hYYiw|U6$FVScg9ms zL2)C-cp7j3nx0Zb7MtF1OenO#|fg zxx`^sSlYnT(~~YbnZp=ccDml3eSFM1%p1!d*6N2`oPYy)oS@UBQ$GX{4r6P@7BRNG zp}GwCCvhOoClHNa8Zxe)NF(H_U7%Ob$9%iN5BDa-`KCj?o=Gw56=B$~$r8|fb%OSM zmI;k^SRCe}7n_3~CdjxSBF;sdaiXSAkc~maL0+$E0#sa`=AtceCMW_#z{pGTdTWu^ zD-^Gxf21n`{fK~G z+);Dwm|+ocX!K7qfvz5gr8d9e{D%Aws5t=nY>-tG2Rfe*ts|W;$60h7D)i+Gza)`8 z+pP*Y45_bYpp_%4;zMm@Rczd#lLY$ReiZlPHe$OPhJTXO-CW9CNqql;-Z$bRDdQii zL|==~t;^UvcG&D|Boeh&-cjBtwentxn@Z6P4DVG}pm;yxZH;$zN(+B;FV zdJ^g~(|B)OVS*y#R5kX!Hy#K#_8YprDRjI+TlQkN?vH8it7G1KR~H&;VqeoRsiZEm zJH^uzJMJ!a4rHl2hrYXev)0%XNy&Nlwc<*yu?Gay_K3UWjqvtpjHq+lUWb|u=?{GW5LA;01}G5%yT1>sF(=4Z*;jc zDD{nGmdoLZR1qY2w^6MSoX%_YK>Bf(A5Ygn1yLk`D7(vryXb-ng6_Lej8+J{lUWW{ z2$V83$U;a-)KTsY8YnY}7XF~ z=;FW#M?=AX&1xtrx5f|nRL$wX4$FI?=`_-G4g_u(E(p*Z=Rlwcmy2`)lclO7W5Si{ zwD1)HDkPZ_N4lPzO zPbcJyVDI@WJ`lj_)y1^(KV4}8BW~&v!T-!*(8KyNup8-7)|dY0jHO&8tIz-ZPysDa zCur-gHK941qW+gNKo7kuIpX@#zX{u#e2}^$n4^-^ArDG3fb}MCL7d zu@=Vaqu-G?<%swgbeN^^IuP&3= zH(;l+)Vi=w+LCRtZxoq*y&GyJ_GAK(w`w0~d=l*a462Q3!A5+-Q)L}E<7bl_wgKXp zi%cX{)`53lm$<1mQu1vpf1Qowpdgc{ysIk6t$-(7GzF=OBl*r}EODTwG`>}FBv0LC z7l)g~VJxvN-JLv*d3q+mZn8{JRc6WaizS(bK#086Ldom)0#YN^04?N?bFoy=Z-b?X zmuYQaL&9opgZvI4H=JgHj6<7okPiGmDv|g*h~#h|NhEQRL~^9J#f`>pgdP$M%#F$| z*yf;;2e9SbnuszXV=CBNv(xjwZz5(x1Rhj7m=;xWTC{7kuv}iI<@trP_Tq2fPx{ z`8?S4LX)%x8MUJXcS#+ECfrC(SN*8O&H!z(OUb~fFs73DqzFxyl38L2lDI_>WJ>2B zC2nfdr4-P-X*69u=EC;9bJL{^ebwR^h{ExP;SxuO`px=Isvq={qcl?VFVZR?-~ zQ$1rX{wb09r#5{!I{zL|OZ?NBFN3AjiGOZGi+@HG{+D?q{%YH%PFXEUJA+Hx$OS>c z(XdRtlS~eMSf;WxidTnaYH5PRi!zayWWDAr@S^@h!!i}a5?cl|G#wZ|cZbEWKB@=j z!0_ESB!=odka`e9E@aFD^#wC^9!NcOg(XWeL?ZOuG=GM~&H!!IFRghyNtO*F^xU+T zAK1A?=(%aVXGz>b5CD0J+vrqqGkR_&^EG6tb?G^2SFf}9H$-hC@t?Xx;_pz~(tk*` zZ5W~Fq%DLJHG0lz;0C8QWZL@kC0=SnrhVK=YRJ<>LmE?~wJu8g^fj^dmCX-E&8;tE zt!o)5X`j7gZE0ly6M0Ft-yZ|NAl8Ile0LI$)e|l3iYbEu4zF-DIMR-eKozN%d085^ z7`RjGn4bKzR7XReA~%sqZ!pG`RBEaK<3OdIY(~&bOx{AC-ft6-!$iUuqtU0@X6ZFw z>b6-n(z%RONoHsl3rR{fM{Zi6aBoR6BQan}QZ;-tj@+gizHbmI?tXc5(82J{IC_I> z_*yjUV)zQ~B=-8}-D!y(^AV+}Vy}PM-42asiM>H$g*6yb)A$#os^}Z-4CtaayOS!h ztZQef5<8XcR0oM=jXO=1m__r{B$oBlRhGmssX9xn@gqm0N~}rf3rS+o-yow3quHmB zSoq$u)zmR{pJtyv8LKct07+gr>ON=mBCAf!!b3Ayg|wxq=URnlNcSR&m+ONWO)4k4 zl1LYSpTa}!h-d7YBgD$*nGo`j7o~}A_Jm6yR)MEc3h;F5kdxD)6L#4Rw-7HO-Am;r z;f{h-&b(Y@6k`=qIT6YE-9nauRex--gt^rStM$mP9VvB>5b}_>@HBt4xeZU8n^%O6 z3v{;#&yem#6ya%JxFf1mJZS@n(y$*r&2%XiA*6f(wRWFMl46YuSBO3lwv?*x=KHv zb>+~ImMcLMK@(bjBRUi^p&|D5 z1dKu<-QxjA-LB==v{7RXOWLZnyO$AEyIre^(T726Eokd@DXdAd-02v59CSmv#{-V= zw2tpW7nO(Z<(fb6(#ygDE^n`C1$$U3fb*vNj(q#12MhLDTA5H~eM$1i8t z1m>whvdtAB6w}OkQ5pCUuS{k+$b+yL&!9|Kah|;%iXlv!*=jw`@}4EtLmhBk&nQs`9L;UQ#Wdi$w){%ffELYD8*p7; zwRTWx#jz9>w+oCa9@4!)O#(Z66&8SU!z)u1X+~9GXaBRMBrt=n)qv+L%Ck2h=$d#q z`ycRE6?9%wp(^NCcmetolc4jG|Dg)nq8UxlBn|RnIFP{dU;9+M>?ESJZ-tjeb^gBw z!K^=^?Z<7OgxVl%$Bu1T;aIk4(V~S5%gYy+mzS57End8M!2-mTm6h@5!i5W#lB2i< z$gvntp30v_Zar?{!i9?$gSqMGCY5kx!uDz>wsDQ*Twkt_j8s=gx=&S3ub#7L2d$iP zUxvk!Z15KN3V7~$J$Y{WR$=x9Y`Xfb0%D8LsOz^1lo_2-e5)|$0>*ydZxx6`E}M>? zNM3gHSU)+)rX$o0j!F!HM;kY6B7LiX_+XYi#K?=6a0G;I>9dO zADTB&eAsfdCo6bY%j?#yLEL>^lkllZF5WI{@4Byj3oSdZ4+1m0Cb1SGZG)V)(Vmuh z4yWL4kynhjneF3QwkQ)JTnlyuW=JdrPxTA*|-U$!Q)COj)J_z{S+s7 zt)gOFm|M)Z9_QJC@t`S`-3Xc>_EVY#;t+y@+L)d;8ej0BNuwG;o)0bFK=(q!@TuWI zcTXUlW(Vpl&2Yr=D!O3d`!s<#+^)?3`jCFR5;wCkH{z~iUsn8ymY04$=K?wAQ-Ltz91*wdFi$O85YRCbdJ9W>Gt{FQ?{hfj&^I zdrM2^AKZ*C@F2S%+xemkVA2WQ$?|5Rm;`?}51I`36Hz`s4pTyy=&LEtN%WozjG7hT z%|tv50ShrOs#>|N^I%h|wI1vWhYZdG7bBAUqZ!&i>YH>-T580-7K2XMxxqo<9aL*?M4eCwcW<` zy2hwsgiGe*`Vr#+fUI{10)yI{ z1PG%8fr1`_0Bs;T5E$0SBtRI|Rvy2incB*mkyW^uT6z4+b!sbbHb|c0d^_of1$CHj z)ru8mxz&ojVS@POiaD$Zike)juLy9Vkbn2mGT*-Py2y8tkCuy1Uc^FU+1$Bv=gq_N za9(NYy!qr1A^w(>l+0Ve7m14(FU0fNXJ34AjS%ri&$ZS%9>a>|_+{kYoYUk7X9 ze!0s#RQ%SG@6?K0G^15q9g|c2xGE5?QY>OI0i2pREmnXb$c4OcWC~2}O}BH+bG|O# zMB3__W@N#$H@w=ScVEyiYKz|e=}NgpyP(5|xvmJS0=xH7{9M9sEU~j?ysjrYAEj}yXi36H=3)beVA@!^)$dYfSVMTJ-~9t zvXc1=R<2yV`q^inedf96o?E?o@nYB)klSo7wvxq*mn>Pj6sb==HE-_h*>g%t)~|p0 z<(FT4@%iUhu0&3l5oAxm3Mnncj&xaEryegD;@|>EX1j&RE`OkoCIv6SMMiI7A_Hwz znJ)J?SNp3q22QQNR^0iP+Fv&tb&BnrpuT%GkM`ir#!u~Hl`_Z^@tf!r^wa+ zelDgda?Sc%esh7X*??=Q;Zx*hLkdwXepTtM?{J9t}BVpeNuZWwG_yK2R3>w68RL)x=jfBQf*k^0JZtSd&+zsfgAjpj1R_lH}cdO)?6E z%{2+)I%+L6T31lKv?dWjb4{}S0o$5H1o`DND*NcewlxWYSOuADlDJ3gSJMXi2J%V& z`ZD{Zf0gelz9yNrh)%!CX-V0vg64lxc%&su`5%7tNGr_Mt5=tn8mEz@9N}@qi06FD zT*lu^g#y50lvbl92+fxT%0<|e9j@I(>S=KW%azs8DBma_9VC9RiUrk!M8t;be2_?) zu^O5R<_@PRTc4{z`_pe#Bqock^bSk;Y088$+DbR~&WjN=x4vgnnzdQK6G5@{f5?36 zv5<Xi}?2(4=;P(kyBt7a7zhm~S`Z4b;4JZin-Vjb_3Q^KG~J)@m_jHhC2y zX!06EX%?@C5ERG{^Fk)x{Bk6S(;vKXYRVB1C0L|ZQ3-yyu)#=>&UH3ZoYUdL7AnTi zb>7CCiF2DlL!_}KMy^)oTQS}w2_NP`zab*eQlmr;-VD0v1M2zP*5~n)nf&~1Go?3S z>@0=@oXTGhWELN}B zVkU%^8E~`lCbiydGob`;Mm_S%D3>Mt6K1Y4%WWKrdC=?&)d-3%#2%NZc@U7OjYLp1 zJ2 zcr&T(pj;NULn0SOOu-X2YO@g(#gFm?lWzKxdIH)NU*M3Jn||FmG<`rz(CX<;kIAK= zhUIlbF%v`&1SAk^DQJl$Sb-pkPe?e+2#OMX0A*7g)q>}fPOn=bLBKAjEF@f$>vE%z z$gopnMb-N}W#c~sL6PMIPcW+t@3t<^Uk;Q(lOpbs+{U@_+-(HW(kGBnY?h+D%&c%#(PmF1Gu>I=~Rnu{+fjBnXcKf%RRnzfJlV&u5sV(~H=z}W2UwPj7>6l^_phff4 z0Pfo6umxq+HYVeH*Y=hwK#S(70o*@%xGF)*l8*`C{#zW@R~F4v19))HmXbAf`ckY~g-YV4DoYu?^Z|OR`jfSu~@y zsJh;(Zu*tJ-uoHa8(vc3)!pBMUiwixqT$C0_@UbU8|;gu%Y2vdMbd&d?eW+8uH`#p zJ1SZ3SVmh~SoqAtx!U5zxQ4l8$)ZKH&Y2??OtfE!gEVZF@l8Wo6(JEZw2jC&9>ubV zHWjfOIop&TUaXZ7UA(++JN>*Lzk@kw2mfhlS})zb!@hzW7`)28ZHYb`9!HUUro>l?{cBL{i!)!OnpXSK8+y&QjV2wUeoWrcm12 zesl(SxkvOIh#v8k_PG}H3VS|lJ1c%)%Nti|7(EZ+O)_OSQv$ctwo!sm-I&xUm*f&$78o?x1r2h4=*_l+z$cr)vC zCIS-oBFe>uewUFT%xJ!N+)r_2WY28uGp@vjwj-{w&r4T7`9(T)=~tbLUmoL2zdq;_ zqj2kC5T1`dLM9z;6oqXTRrel-u0oqdDZ2gt`o}EiBQ38Fu_AL^VxKSA1C3ZmQ;^@H zB!r@3;wmL2h(2UOFEPrR(kL@xB$1?lKwRyWobXXjNwab_FjjT1rE!el+ch>=)tHsS>9;0~47bRK8twVmRq>L&3W zMvO#!z{e`JEj-Rf4IN;d?!`1jnz*`8OSJ#h>`MG`!c| z-Hf1#vxgC5;RJ8h8m)2P_?v6q8t?X4HE#$Y$qNSoRVDm*MJ%RDRbqV95BsW;ws`7b zswkE~Rd!8@ZV6z8s?jRdY|N_WSv%2~JGC8H1i()o)PEm^VzmIB!dbLTz&_~XlPCkI=nFd{&Mh|HU3NSmIPp+Ml=hwVR> zd$aFmZW7e|%nDVLfMV-x5+HMtX%bN2_caN?VR|kbf*O*ST?6QR+2KQRZW5pb8v-wz z1Wq$}Rr4mJ^%*?fEk^K1-?I?S;D<0qEe*Png4U+Zncq?n6NH$No9Y>F|#b2)kyQ$>Z$6wo#m=9(IeM zG=uLqVx(G+{9Yx#gU8v3qiZM%zp8oDQeELw+9$Zf_%}SKG~o*WYjhcSd5#FZite&s z+pXcpwfJ5ydRfs9EieBE4qQSyz=)BMZlSm^G4wb}aWwMvsz#80GMiu6Ifn3%5tbELO*LlUMkisfMV_NlkE5*v0C~cY-moi z7AItFlKT(+o*A6JqZq;~dErQ*wrNd2*JEB0tLu%YswO!$>gF;m-kgA?v zzM@(nTMWc8t^KQd?vGb35R2w1t^KR|1_MIltL9Tx-+opSomdf7MHzqL%4n4M~pwW80pytn=6 z*tYp(n-OF6_q-h<&X`XIB%>KHq&+y! z2%7WM1S3Xby~c$E zBHs)OGk}w)Iy%_`#guB=0s`M=lK`i;-3Xd>++oB>bv$Im2^vT3!I3FS9rL#EIJ@w( zQ*Bl0W&};vJ&YKM^-LaT>a*EKP-Nk%tc>EE)Yd8_2*led3-u4Z0%pRQX_R0dIgdn8 zWGTm+32Zs#vh>piGgnr+jqYq7M6CslY6Qhwe1=606G1oKTiT}Y;MHjVF03Pdp;5Gd zp9~HED_mX@N=mGA5ELYP7;orLp9(L}D+T>Q??df9{PzuFk96uu`*tJ$@BpiwHF4T> z{lZ9*U&Cr=4z793n=Ljt=g+4*AJ{0xWkK5gEGt{KY}wMKi&m^yfhCS{pR$B*hLkN? zLhGQFNWwKiq|$~dE?T0hrn9ehIwoqi3nL?ZBlv0uH4mw)9mM9;d9_2C&D9PCe&5v& zIOOKC^HERovMUH%?PMU4Hb7B=`Qhc&4%rw)NwqP0Vpoe?L>nXjqpFx}jDF(|MYS>d z{e)lDIrR?+8uD|63UIA~FE%4+$jR<1 zK#S(70lf6e4JyFrejUI|uiB&nv}m3hz$;lV6`)nyn40)X_bwHnMf21Ej-B2~1^E20 zli=8U&r$(eG@}6wfirpGXkl>ediweEQ(qX28(J83(2msGl;HmZR2v}z diff --git a/examples/govee/lib/govee_bulb.ex b/examples/govee/lib/govee_bulb.ex deleted file mode 100644 index 47b7b33..0000000 --- a/examples/govee/lib/govee_bulb.ex +++ /dev/null @@ -1,165 +0,0 @@ -defmodule GoveeBulb do - @moduledoc """ - Sample ATT application that can control the Govee Light Bulb - - They can be found [here](https://www.amazon.com/MINGER-Dimmable-Changing-Equivalent-Multi-Color/dp/B07CL2RMR7/) - """ - - use GenServer - require Logger - - alias BlueHeron.HCI.Command.{ - ControllerAndBaseband.WriteLocalName, - LEController.SetScanEnable - } - - alias BlueHeron.HCI.Event.{ - LEMeta.ConnectionComplete, - DisconnectionComplete, - LEMeta.AdvertisingReport, - LEMeta.AdvertisingReport.Device - } - - # Sets the name of the BLE device - @write_local_name %WriteLocalName{name: "Govee Controller"} - - @default_uart_config %{ - device: "ttyACM0", - uart_opts: [speed: 115_200], - init_commands: [@write_local_name] - } - - @default_usb_config %{ - vid: 0x0BDA, - pid: 0xB82C, - init_commands: [@write_local_name] - } - - @doc """ - Start a linked connection to the bulb - - ## UART - - iex> {:ok, pid} = GoveeBulb.start_link(:uart, device: "ttyACM0") - {:ok, #PID<0.111.0>} - - ## USB - - iex> {:ok, pid} = GoveeBulb.start_link(:usb) - {:ok, #PID<0.111.0>} - """ - def start_link(transport_type, config \\ %{}) - - def start_link(:uart, config) do - config = struct(BlueHeronTransportUART, Map.merge(@default_uart_config, config)) - GenServer.start_link(__MODULE__, config, []) - end - - def start_link(:usb, config) do - config = struct(BlueHeronTransportUSB, Map.merge(@default_usb_config, config)) - GenServer.start_link(__MODULE__, config, []) - end - - @doc """ - Set the color of the bulb. - - iex> GoveeBulb.set_color(pid, 0xFFFFFF) # full white - :ok - iex> GoveeBulb.set_color(pid, 0xFF0000) # full red - :ok - iex> GoveeBulb.set_color(pid, 0x00FF00) # full green - :ok - iex> GoveeBulb.set_color(pid, 0x0000FF) # full blue - :ok - """ - def set_color(pid, rgb) do - GenServer.call(pid, {:set_color, rgb}) - end - - @impl GenServer - def init(config) do - # Create a context for BlueHeron to operate with - {:ok, ctx} = BlueHeron.transport(config) - - # Subscribe to HCI and ACL events - BlueHeron.add_event_handler(ctx) - - # Start the ATT Client (this is what we use to read/write data with) - {:ok, conn} = BlueHeron.ATT.Client.start_link(ctx) - - {:ok, %{conn: conn, ctx: ctx, connected?: false}} - end - - @impl GenServer - - # Sent when a transport connection is established - def handle_info({:BLUETOOTH_EVENT_STATE, :HCI_STATE_WORKING}, state) do - # Enable BLE Scanning. This will deliver messages to the process mailbox - # when other devices broadcast - BlueHeron.hci_command(state.ctx, %SetScanEnable{le_scan_enable: true}) - {:noreply, state} - end - - # Match for the Bulb. - def handle_info( - {:HCI_EVENT_PACKET, - %AdvertisingReport{devices: [%Device{address: addr, data: ["\tMinger" <> _]}]}}, - state - ) do - Logger.info("Trying to connect to GoveeBulb #{inspect(addr, base: :hex)}") - # Attempt to create a connection with it. - :ok = BlueHeron.ATT.Client.create_connection(state.conn, peer_address: addr) - {:noreply, state} - end - - # ignore other HCI Events - def handle_info({:HCI_EVENT_PACKET, _}, state), do: {:noreply, state} - - # ignore other HCI ACL data (ATT handles this for us) - def handle_info({:HCI_ACL_DATA_PACKET, _}, state), do: {:noreply, state} - - # Sent when create_connection/2 is complete - def handle_info({BlueHeron.ATT.Client, conn, %ConnectionComplete{}}, %{conn: conn} = state) do - Logger.info("GoveeBulb connection established") - {:noreply, %{state | connected?: true}} - end - - # Sent if a connection is dropped - def handle_info({BlueHeron.ATT.Client, _, %DisconnectionComplete{reason_name: reason}}, state) do - Logger.warn("GoveeBulb connection dropped: #{reason}") - {:noreply, %{state | connected?: false}} - end - - # Ignore other ATT data - def handle_info({BlueHeron.ATT.Client, _, _event}, state) do - {:noreply, state} - end - - @impl GenServer - # Assembles the raw RGB data into a binary that the bulb expects - # this was found here https://github.com/Freemanium/govee_btled#analyzing-the-traffic - def handle_call({:set_color, _rgb}, _from, %{connected?: false} = state) do - Logger.warn("Not currently connected to a bulb") - {:reply, {:error, :disconnected}, state} - end - - def handle_call({:set_color, rgb}, _from, state) do - value = <<0x33, 0x5, 0x2, rgb::24, 0, rgb::24, 0, 0, 0, 0, 0, 0, 0, 0, 0>> - checksum = calculate_xor(value, 0) - - case BlueHeron.ATT.Client.write(state.conn, 0x0015, <>) do - :ok -> - Logger.info("Setting GoveeBulb Color: ##{inspect(rgb, base: :hex)}") - {:reply, :ok, state} - - error -> - Logger.info("Failed to set GoveeBulb color") - {:reply, error, state} - end - end - - defp calculate_xor(<<>>, checksum), do: checksum - - defp calculate_xor(<>, checksum), - do: calculate_xor(rest, :erlang.bxor(checksum, x)) -end diff --git a/examples/govee/lib/govee_ledstrip.ex b/examples/govee/lib/govee_ledstrip.ex deleted file mode 100644 index 1f5a0de..0000000 --- a/examples/govee/lib/govee_ledstrip.ex +++ /dev/null @@ -1,171 +0,0 @@ -defmodule GoveeLEDStrip do - @moduledoc """ - Sample ATT application that can control the Govee H6125 LED Strip - - They can be found [here](https://www.amazon.com/MINGER-Dimmable-Changing-Equivalent-Multi-Color/dp/B07CL2RMR7/) - """ - - use GenServer - require Logger - - alias BlueHeron.HCI.Command.{ - ControllerAndBaseband.WriteLocalName, - LEController.SetScanEnable - } - - alias BlueHeron.HCI.Event.{ - LEMeta.ConnectionComplete, - DisconnectionComplete, - LEMeta.AdvertisingReport, - LEMeta.AdvertisingReport.Device - } - - # Sets the name of the BLE device - @write_local_name %WriteLocalName{name: "Govee Controller"} - - @default_uart_config %{ - device: "ttyACM0", - uart_opts: [speed: 115_200], - init_commands: [@write_local_name] - } - - @default_usb_config %{ - vid: 0x0BDA, - pid: 0xB82C, - init_commands: [@write_local_name] - } - - @doc """ - Start a linked connection to the bulb - - ## UART - - iex> {:ok, pid} = GoveeBulb.start_link(:uart, device: "ttyACM0") - {:ok, #PID<0.111.0>} - - ## USB - - iex> {:ok, pid} = GoveeBulb.start_link(:usb) - {:ok, #PID<0.111.0>} - """ - def start_link(transport_type, config \\ %{}) - - def start_link(:uart, config) do - config = struct(BlueHeronTransportUART, Map.merge(@default_uart_config, config)) - GenServer.start_link(__MODULE__, config, []) - end - - def start_link(:usb, config) do - config = struct(BlueHeronTransportUSB, Map.merge(@default_usb_config, config)) - GenServer.start_link(__MODULE__, config, []) - end - - @doc """ - Set the color of the bulb. - - iex> GoveeBulb.set_color(pid, 0xFFFFFF) # full white - :ok - iex> GoveeBulb.set_color(pid, 0xFF0000) # full red - :ok - iex> GoveeBulb.set_color(pid, 0x00FF00) # full green - :ok - iex> GoveeBulb.set_color(pid, 0x0000FF) # full blue - :ok - """ - def set_color(pid, rgb) do - GenServer.call(pid, {:set_color, rgb}) - end - - @impl GenServer - def init(config) do - # Create a context for BlueHeron to operate with - {:ok, ctx} = BlueHeron.transport(config) - - # Subscribe to HCI and ACL events - BlueHeron.add_event_handler(ctx) - - # Start the ATT Client (this is what we use to read/write data with) - {:ok, conn} = BlueHeron.ATT.Client.start_link(ctx) - - {:ok, %{conn: conn, ctx: ctx, connected?: false}} - end - - @impl GenServer - - # Sent when a transport connection is established - def handle_info({:BLUETOOTH_EVENT_STATE, :HCI_STATE_WORKING}, state) do - # Enable BLE Scanning. This will deliver messages to the process mailbox - # when other devices broadcast - Logger.info("Scanning for devices") - BlueHeron.hci_command(state.ctx, %SetScanEnable{le_scan_enable: true}) - {:noreply, state} - end - - # Match for the Bulb. - def handle_info( - {:HCI_EVENT_PACKET, - %AdvertisingReport{devices: [%Device{address: addr, data: ["\tihoment_H6125" <> _]}]}} = - _arp, - state - ) do - Logger.info("Trying to connect to GoveeLEDStrip #{inspect(addr, base: :hex)}") - # Attempt to create a connection with it. - :ok = BlueHeron.ATT.Client.create_connection(state.conn, peer_address: addr) - {:noreply, state} - end - - # ignore other HCI Events - def handle_info({:HCI_EVENT_PACKET, _}, state), do: {:noreply, state} - - # ignore other HCI ACL data (ATT handles this for us) - def handle_info({:HCI_ACL_DATA_PACKET, _}, state), do: {:noreply, state} - - # Sent when create_connection/2 is complete - def handle_info({BlueHeron.ATT.Client, conn, %ConnectionComplete{}}, %{conn: conn} = state) do - Logger.info("GoveeLEDStrip connection established") - {:noreply, %{state | connected?: true}} - end - - # Sent if a connection is dropped - def handle_info({BlueHeron.ATT.Client, _, %DisconnectionComplete{reason_name: reason}}, state) do - Logger.warn("GoveeLEDStrip connection dropped: #{reason}") - {:noreply, %{state | connected?: false}} - end - - # Ignore other ATT data - def handle_info({BlueHeron.ATT.Client, _, _event}, state) do - {:noreply, state} - end - - @impl GenServer - # Assembles the raw RGB data into a binary that the bulb expects - # this was found here https://github.com/Freemanium/govee_btled#analyzing-the-traffic - def handle_call({:set_color, _rgb}, _from, %{connected?: false} = state) do - Logger.warn("Not currently connected to a bulb") - {:reply, {:error, :disconnected}, state} - end - - def handle_call({:set_color, rgb}, _from, state) do - # color - payload = - <<0x33, 0x5, 0xB, rgb::24, 0xFF, 0x7F, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, - 0x0>> - - checksum = calculate_xor(payload, 0) - - case BlueHeron.ATT.Client.write(state.conn, 0x0015, <>) do - :ok -> - Logger.info("Setting GoveeLEDStrip Color: ##{inspect(rgb, base: :hex)}") - {:reply, :ok, state} - - error -> - Logger.info("Failed to set GoveeLEDStrip color") - {:reply, error, state} - end - end - - defp calculate_xor(<<>>, checksum), do: checksum - - defp calculate_xor(<>, checksum), - do: calculate_xor(rest, :erlang.bxor(checksum, x)) -end diff --git a/examples/govee/mix.exs b/examples/govee/mix.exs deleted file mode 100644 index c0d0ac0..0000000 --- a/examples/govee/mix.exs +++ /dev/null @@ -1,29 +0,0 @@ -defmodule BlueHeronExampleGovee.MixProject do - use Mix.Project - - def project do - [ - app: :blue_heron_example_govee, - version: "0.1.0", - elixir: "~> 1.10", - start_permanent: Mix.env() == :prod, - deps: deps() - ] - end - - # Run "mix help compile.app" to learn about applications. - def application do - [ - extra_applications: [:logger] - ] - end - - # Run "mix help deps" to learn about dependencies. - defp deps do - [ - {:blue_heron, path: "../../blue_heron", override: true}, - {:blue_heron_transport_usb, path: "../../blue_heron_transport_usb"}, - {:blue_heron_transport_uart, path: "../../blue_heron_transport_uart"} - ] - end -end diff --git a/examples/govee/mix.lock b/examples/govee/mix.lock deleted file mode 100644 index f205b08..0000000 --- a/examples/govee/mix.lock +++ /dev/null @@ -1,5 +0,0 @@ -%{ - "circuits_uart": {:hex, :circuits_uart, "1.4.2", "520817daced77620bc036c168eb42b55ba4b4c141c2de47a199895a35a86e690", [:mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "d3da86c9ef9ef196150797c7de1c7a7d367ebcd0de44f325c36def8654976c06"}, - "elixir_make": {:hex, :elixir_make, "0.6.0", "38349f3e29aff4864352084fc736fa7fa0f2995a819a737554f7ebd28b85aaab", [:mix], [], "hexpm", "d522695b93b7f0b4c0fcb2dfe73a6b905b1c301226a5a55cb42e5b14d509e050"}, - "ex_bin": {:hex, :ex_bin, "0.4.0", "c86513598a6fd35f018966607f3e8996e6b16324094e8939d163e671ab7c184e", [:mix], [], "hexpm", "223ae53e25ac2874f1b8f469d11a75926bb068a35e8008603c2ede4277cece6c"}, -} diff --git a/examples/scanner.ex b/examples/scanner.ex deleted file mode 100644 index c638a41..0000000 --- a/examples/scanner.ex +++ /dev/null @@ -1,379 +0,0 @@ -defmodule BlueHeronScan do - @moduledoc """ - A scanner to collect Manufacturer Specific Data from AdvertisingReport packets. - - A useful reference: - [Overview of BLE device identification](https://reelyactive.github.io/ble-identifier-reference.html) - - Tested with: - - [Raspberry Pi Model Zero W](https://github.com/nerves-project/nerves_system_rpi0) - - /dev/ttyS0 is the BLE controller transport interface - - [BlueHeronTransportUART](https://github.com/blue-heron/blue_heron_transport_uart) - - [Govee H5102](https://fccid.io/2AQA6-H5102) Thermo-Hygrometer - - [Govee H5074](https://fccid.io/2AQA6-H5074) Thermo-Hygrometer - - Random devices from neighbors and passing cars.😉 - - ## Examples - - iex> {:ok, pid} = BlueHeronScan.start_link(:uart, %{device: "ttyS0"}) - {:ok, #PID<0.10860.0>} - iex> {:ok, devices} = BlueHeronScan.devices(pid) - {:ok, - %{ - 9049270267450 => %{name: "SS3", time: ~U[2021-12-09 15:59:01.392458Z]}, - 48660401950223 => %{ - 784 => <<64, 16, 2, 48>>, - :time => ~U[2021-12-09 15:59:09.606645Z] - }, - 181149778439893 => %{ - 1 => <<1, 1, 3, 112, 82, 73>>, - :name => "GVH5102_EED5", - :time => ~U[2021-12-09 15:59:09.457780Z] - }, - 181149781445015 => %{ - name: "ihoment_H6182_C997", - time: ~U[2021-12-09 15:59:09.545683Z] - }, - 209497230420943 => %{ - name: "ELK-BLEDOM ", - time: ~U[2021-12-09 15:59:09.631200Z] - }, - 246390811914386 => %{ - 60552 => <<0, 81, 2, 189, 25, 100, 2>>, - :name => "Govee_H5074_F092", - :time => ~U[2021-12-09 15:59:09.450767Z] - } - }} - iex> BlueHeronScan.ignore_cids(pid, MapSet.new([6, 76, 117, 784])) - {:ok, #MapSet<[6, 76, 117, 784]>} - iex> BlueHeronScan.clear_devices(pid) - :ok - iex> {:ok, devices} = BlueHeronScan.devices(pid) - {:ok, - %{ - 181149778439893 => %{ - 1 => <<1, 1, 3, 108, 106, 73>>, - :name => "GVH5102_EED5", - :time => ~U[2021-12-09 16:02:01.800281Z] - }, - 181149781445015 => %{ - name: "ihoment_H6182_C997", - time: ~U[2021-12-09 16:02:02.458660Z] - }, - 209497230420943 => %{ - name: "ELK-BLEDOM ", - time: ~U[2021-12-09 16:02:02.337530Z] - }, - 210003231250023 => %{ - name: "ELK-BLEDOM ", - time: ~U[2021-12-09 16:01:50.546539Z] - }, - 246390811914386 => %{ - 60552 => <<0, 84, 2, 182, 25, 100, 2>>, - :name => "Govee_H5074_F092", - :time => ~U[2021-12-09 16:02:01.408051Z] - } - }} - iex> BleAdMfgData.print(devices) - [ - ["6.0˚C 65.8% RH 100%🔋", "Govee_H5074_F092"], - ["22.4˚C 36.2% RH 73%🔋", "GVH5102_EED5"] - ] - iex> BlueHeronScan.disable(pid) - :scan_disable - iex> BlueHeronScan.clear_devices(pid) - :ok - iex> {:ok, devices} = BlueHeronScan.devices(pid) - {:ok, %{}} - iex> BlueHeronScan.enable(pid) - :ok - """ - - use GenServer - require Logger - - alias BlueHeron.HCI.Command.{ - ControllerAndBaseband.WriteLocalName, - LEController.SetScanEnable - } - - alias BlueHeron.HCI.Event.{ - LEMeta.AdvertisingReport, - LEMeta.AdvertisingReport.Device - } - - @init_commands [%WriteLocalName{name: "BlueHeronScan"}] - - @default_uart_config %{ - device: "ttyACM0", - uart_opts: [speed: 115_200], - init_commands: @init_commands - } - - @default_usb_config %{ - vid: 0x0BDA, - pid: 0xB82C, - init_commands: @init_commands - } - - @doc """ - Start a linked connection to the Bluetooth module and enable active scanning. - - ## UART - - iex> {:ok, pid} = BlueHeronScan.start_link(:uart, %{device: "ttyS0"}) - {:ok, #PID<0.111.0>} - - ## USB - - iex> {:ok, pid} = BlueHeronScan.start_link(:usb) - {:ok, #PID<0.111.0>} - """ - def start_link(transport_type, config \\ %{}) - - def start_link(:uart, config) do - config = struct(BlueHeronTransportUART, Map.merge(@default_uart_config, config)) - GenServer.start_link(__MODULE__, config, name: __MODULE__) - end - - def start_link(:usb, config) do - config = struct(BlueHeronTransportUSB, Map.merge(@default_usb_config, config)) - GenServer.start_link(__MODULE__, config, name: __MODULE__) - end - - @doc """ - Enable BLE scanning. This will deliver messages to the process mailbox - when other devices broadcast. - - Returns `:ok` or `{:error, :not_working}` if uninitialized. - """ - def enable(pid) do - GenServer.call(pid, :scan_enable) - end - - @doc """ - Disable BLE scanning. - """ - def disable(pid) do - send(pid, :scan_disable) - end - - @doc """ - Get devices. - - iex> BlueHeronScan.devices(pid) - {:ok, %{}} - """ - def devices(pid) do - GenServer.call(pid, :devices) - end - - @doc """ - Clear devices from the state. - - iex> BlueHeronScan.clear_devices(pid) - :ok - """ - def clear_devices(pid) when is_pid(pid) do - GenServer.call(pid, :clear_devices) - end - - @doc """ - Get or set the company IDs to ignore. - - https://www.bluetooth.com/specifications/assigned-numbers/company-identifiers - - Apple and Microsoft beacons, 76 & 6, are noisy. - - ## Examples - - iex> BlueHeronScan.ignore_cids(pid) - {:ok, [6, 76]} - iex> BlueHeronScan.ignore_cids(pid, [6, 76, 117]) - {:ok, [6, 76, 117]} - """ - def ignore_cids(pid, cids \\ nil) do - GenServer.call(pid, {:ignore_cids, cids}) - end - - @impl GenServer - def init(config) do - # Create a context for BlueHeron to operate with. - {:ok, ctx} = BlueHeron.transport(config) - - # Subscribe to HCI and ACL events. - BlueHeron.add_event_handler(ctx) - - {:ok, %{ctx: ctx, working: false, devices: %{}, ignore_cids: [6, 76]}} - end - - # Sent when a transport connection is established. - @impl GenServer - def handle_info({:BLUETOOTH_EVENT_STATE, :HCI_STATE_WORKING}, state) do - # Enable BLE Scanning. This will deliver messages to the process mailbox - # when other devices broadcast. - state = %{state | working: true} - scan(state, true) - {:noreply, state} - end - - # Scan AdvertisingReport packets. - @impl GenServer - def handle_info( - {:HCI_EVENT_PACKET, %AdvertisingReport{devices: devices}}, - state - ) do - {:noreply, Enum.reduce(devices, state, &scan_device/2)} - end - - # Ignore other HCI Events. - @impl GenServer - def handle_info({:HCI_EVENT_PACKET, _val}, state) do - # Logger.debug("#{__MODULE__} ignore HCI Event #{inspect(val)}") - {:noreply, state} - end - - def handle_info(:scan_disable, state) do - scan(state, false) - {:noreply, state} - end - - @impl GenServer - def handle_call(:devices, _from, state) do - {:reply, {:ok, state.devices}, state} - end - - @impl GenServer - def handle_call(:clear_devices, _from, state) do - {:reply, :ok, %{state | devices: %{}}} - end - - @impl GenServer - def handle_call({:ignore_cids, cids}, _from, state) do - cond do - cids == nil -> - {:reply, {:ok, state.ignore_cids}, state} - - Enumerable.impl_for(cids) != nil -> - {:reply, {:ok, cids}, %{state | ignore_cids: cids}} - - true -> - {:reply, {:error, :not_enumerable}, state} - end - end - - def handle_call(:scan_enable, _from, state) do - {:reply, scan(state, true), state} - end - - defp scan(%{working: false}, _enable) do - {:error, :not_working} - end - - defp scan(%{ctx: ctx = %BlueHeron.Context{}}, enable) do - BlueHeron.hci_command(ctx, %SetScanEnable{le_scan_enable: enable}) - status = if(enable, do: "enabled", else: "disabled") - Logger.info("#{__MODULE__} #{status} scanning") - end - - defp scan_device(device, state) do - case device do - %Device{address: addr, data: data} -> - Enum.reduce(data, state, fn e, acc -> - cond do - is_local_name?(e) -> store_local_name(acc, addr, e) - is_mfg_data?(e) -> store_mfg_data(acc, addr, e) - true -> acc - end - end) - - _ -> - state - end - end - - defp is_local_name?(val) do - is_binary(val) && String.starts_with?(val, "\t") && String.valid?(val) - end - - defp is_mfg_data?(val) do - is_tuple(val) && elem(val, 0) == "Manufacturer Specific Data" - end - - defp store_local_name(state, addr, "\t" <> name) do - device = Map.get(state.devices, addr, %{}) - device = Map.merge(device, %{name: name, time: DateTime.utc_now()}) - %{state | devices: Map.put(state.devices, addr, device)} - end - - defp store_mfg_data(state, addr, dt) do - {_, mfg_data} = dt - <> = mfg_data - - unless cid in state.ignore_cids do - device = Map.get(state.devices, addr, %{}) - device = Map.merge(device, %{cid => sdata, time: DateTime.utc_now()}) - %{state | devices: Map.put(state.devices, addr, device)} - else - state - end - end -end - -defmodule BleAdMfgData do - @moduledoc """ - Decode AdvertisingReport Manufacturer Specific Data. - - https://www.bluetooth.com/specifications/assigned-numbers/company-identifiers - """ - - @doc """ - Print device data collected by `BlueHeronScan`. - - ## Examples - - iex> {:ok, pid} = BlueHeronScan.start_link(:uart, %{device: "ttyS0"}) - {:ok, #PID<0.2012.0>} - iex> {:ok, devices} = BlueHeronScan.devices(pid) - ... - iex> BleAdMfgData.print(devices) - [ - ["26.9˚C 62.1% RH 100%🔋", "Govee_H5074_F092"], - ["27.2˚C 57.5% RH 92%🔋", "GVH5102_EED5"] - ] - iex> - """ - def print(devices) do - Enum.reduce(devices, [], fn {_, dmap}, list -> - Enum.reduce(dmap, list, fn {k, v}, acc -> - case print_device(k, v) do - nil -> acc - s -> [[s, Map.get(dmap, :name, "")] | acc] - end - end) - end) - end - - # https://github.com/Home-Is-Where-You-Hang-Your-Hack/sensor.goveetemp_bt_hci - # custom_components/govee_ble_hci/govee_advertisement.py - # GVH5102 - defp print_device(0x0001, <<_::16, temhum::24, bat::8>>) do - tem = Float.round(temhum / 10000, 1) - hum = rem(temhum, 1000) / 10 - "#{tem}˚C #{hum}% RH #{bat}%🔋" - end - - # https://github.com/wcbonner/GoveeBTTempLogger - # goveebttemplogger.cpp - # bool Govee_Temp::ReadMSG(const uint8_t * const data) - # Govee_H5074 - defp print_device(0xEC88, <<_::8, tem::little-16, hum::little-16, bat::8, _::8>>) do - tem = Float.round(tem / 100, 1) - hum = Float.round(hum / 100, 1) - "#{tem}˚C #{hum}% RH #{bat}%🔋" - end - - defp print_device(_cid, _data) do - nil - end -end diff --git a/lib/blue_heron.ex b/lib/blue_heron.ex index 4c4b5ff..60949b3 100644 --- a/lib/blue_heron.ex +++ b/lib/blue_heron.ex @@ -1,2 +1,5 @@ defmodule BlueHeron do + @moduledoc """ + BLE + """ end diff --git a/lib/blue_heron/acl_buffer.ex b/lib/blue_heron/acl_buffer.ex index bdfde98..d66b5ea 100644 --- a/lib/blue_heron/acl_buffer.ex +++ b/lib/blue_heron/acl_buffer.ex @@ -37,8 +37,6 @@ defmodule BlueHeron.ACLBuffer do # there are already items in the queue, so don't send yet {:noreply, new_state} end - - {:noreply, new_state} end @impl GenServer diff --git a/lib/blue_heron/application.ex b/lib/blue_heron/application.ex index f7d472d..e6e8519 100644 --- a/lib/blue_heron/application.ex +++ b/lib/blue_heron/application.ex @@ -9,6 +9,7 @@ defmodule BlueHeron.Application do def start(_type, _args) do all_env = Application.get_all_env(:blue_heron) transport_args = Keyword.get(all_env, :transport, []) + smp_args = Keyword.get(all_env, :smp, []) children = [ {PropertyTable, name: BlueHeron.GATT}, @@ -19,6 +20,7 @@ defmodule BlueHeron.Application do partitions: System.schedulers_online() ]}, BlueHeron.ACLBuffer, + {BlueHeron.SMP, smp_args}, BlueHeron.Peripheral, {BlueHeron.HCI.Transport, transport_args} ] diff --git a/lib/blue_heron/central.ex b/lib/blue_heron/central.ex deleted file mode 100644 index fd90ced..0000000 --- a/lib/blue_heron/central.ex +++ /dev/null @@ -1,28 +0,0 @@ -defmodule BlueHeron.Central do - defmacro __using__(_args) do - quote location: :keep do - use GenServer - @behaviour BlueHeron.Central - end - end - - def start_link(module, args) do - BlueHeron.Central.Server.start_link(module, args) - end - - def start_scanning(central, args \\ []) do - GenServer.call(central, {:start_scanning, args}) - end - - def stop_scanning(central) do - GenServer.call(central, {:stop_scanning, []}) - end - - def connect(central, peripheral) do - GenServer.call(central, {:connect, [peripheral]}) - end - - def disconnect(central, peripheral) do - GenServer.call(central, {:disconnect, [peripheral]}) - end -end diff --git a/lib/blue_heron/central/server.ex b/lib/blue_heron/central/server.ex deleted file mode 100644 index 0247e63..0000000 --- a/lib/blue_heron/central/server.ex +++ /dev/null @@ -1,12 +0,0 @@ -defmodule BlueHeron.Central.Server do - use GenServer - - def start_link(module, args) do - GenServer.start_link(__MODULE__, [module, args]) - end - - @impl GenServer - def init([module, args]) do - module.init(args) - end -end diff --git a/lib/blue_heron/error_code.ex b/lib/blue_heron/error_code.ex index c47a00f..40d3a53 100644 --- a/lib/blue_heron/error_code.ex +++ b/lib/blue_heron/error_code.ex @@ -92,7 +92,7 @@ defmodule BlueHeron.ErrorCode do {0x45, :packet_too_long, "Packet Too Long"} ] - @spec to_atom(non_neg_integer()) :: atom() + @spec to_atom(non_neg_integer()) :: {0..0xFF, atom, String.t()} def to_atom(code) when is_integer(code) do List.keyfind(@error_codes, code, 0, :unknown) end diff --git a/lib/blue_heron/gatt/server.ex b/lib/blue_heron/gatt/server.ex index 2b3cd40..69813c4 100644 --- a/lib/blue_heron/gatt/server.ex +++ b/lib/blue_heron/gatt/server.ex @@ -88,30 +88,7 @@ defmodule BlueHeron.GATT.Server do alias BlueHeron.GATT.{Characteristic, Service} alias BlueHeron.SMP - @doc """ - Return the list of services that make up the GATT profile of the device. - - This callback is only invoked when the GATT server is started, as the profile - is assumed to be static. - - To comply with the Bluetooth specification, the profile must include a "GAP" - service (type UUID 0x1800), which must have characteristics for "Device Name" - (type UUID 0x2A00) and "Appearance" (type UUID 0x2A01). - """ - @callback profile() :: [Service.t()] - - @doc """ - Return the value of the characteristic given by `id`. - The value must be serialized as a binary. - """ - @callback read(id :: any()) :: binary() - - @doc """ - Handle a write to the characteristic given by `id`. - """ - @callback write(id :: any(), value :: binary()) :: :ok - - defstruct [:mod, :profile, :mtu, :read_buffer, :write_requests, :smp_server] + defstruct [:profile, :mtu, :read_buffer, :write_requests] @discover_all_primary_services 0x2800 @find_included_services 0x2802 @@ -122,20 +99,18 @@ defmodule BlueHeron.GATT.Server do profile: [Service.t()], mtu: non_neg_integer(), read_buffer: binary(), - write_requests: [binary()], - smp_server: GenServer.server() | nil + write_requests: [binary()] } @doc false - def init(profile, smp_server) do + def init(profile) do profile = hydrate(profile) %__MODULE__{ profile: profile, mtu: 23, read_buffer: <<>>, - write_requests: [], - smp_server: smp_server + write_requests: [] } end @@ -221,9 +196,11 @@ defmodule BlueHeron.GATT.Server do %ExecuteWriteRequest{} -> if require_permission?(state, request, :write_auth) do + [%{handle: handle} | _] = state.write_requests + {state, %ErrorResponse{ - handle: request.handle, + handle: handle, request_opcode: request.opcode, error: :insufficient_authentication }} @@ -318,18 +295,13 @@ defmodule BlueHeron.GATT.Server do IO.iodata_to_binary(table) end - # Disable permission checking when SMP is not active - defp require_permission?(%{smp_server: nil}, _request, _permission) do - false - end - - defp require_permission?(state, request, permission) do - p_list = find_characteristic_permissions(state.profile, request.handle) + defp require_permission?(state, %{handle: handle}, permission) do + p_list = find_characteristic_permissions(state.profile, handle) if p_list == nil do false else - if permission in p_list and not SMP.authenticated?(state.smp_server) do + if permission in p_list and not SMP.authenticated?() do true else false @@ -337,6 +309,13 @@ defmodule BlueHeron.GATT.Server do end end + defp require_permission?(state, _request, permission) do + # ExecuteWriteRequest doesn't have a handle, so look + # it up in the write_requests state + [req | _] = state.write_requests + require_permission?(state, req, permission) + end + defp exchange_mtu_request(state, _request) do {state, %ExchangeMTUResponse{server_rx_mtu: state.mtu}} end @@ -434,20 +413,23 @@ defmodule BlueHeron.GATT.Server do end defp discover_characteristics_by_uuid(state, request) do - characteristics = - state.profile - |> Enum.filter(fn service -> + services = + Enum.filter(state.profile, fn service -> service.handle >= request.starting_handle and service.handle <= request.ending_handle end) + + characteristics = + services |> Enum.flat_map(fn service -> service.characteristics end) |> Enum.filter(fn characteristic -> characteristic.type == request.uuid and characteristic.handle >= request.starting_handle and characteristic.handle <= request.ending_handle end) - case characteristics do - [] -> + case {services, characteristics} do + # no matching characteristics + {_, []} -> {state, %ErrorResponse{ handle: request.starting_handle, @@ -455,9 +437,9 @@ defmodule BlueHeron.GATT.Server do error: :attribute_not_found }} - [characteristic] -> + {[service | _], [characteristic]} -> # TODO: Handle exceptions and long values - value = state.mod.read(characteristic.id) + value = service.read.(characteristic.id) attr = %ReadByTypeResponse.AttributeData{ @@ -470,7 +452,7 @@ defmodule BlueHeron.GATT.Server do {state, %ReadByTypeResponse{attribute_data: [attr]}} - characteristics_in_range -> + {_, characteristics_in_range} -> attribute_data = characteristics_in_range |> Enum.map(fn characteristic -> @@ -555,8 +537,9 @@ defmodule BlueHeron.GATT.Server do def check_notification_mtu(_, _), do: {:error, :payload_too_large} defp read_characteristic_value(state, request) do + service = find_service_by_handle(state.profile, request.handle) id = find_characteristic_id(state.profile, request.handle) - value = state.mod.read(id) + value = service.read.(id) # We cache the value if it's longer than MTU - 1, to avoid inconsistent # reads if the value is updated during the read operation. We assume that @@ -577,12 +560,13 @@ defmodule BlueHeron.GATT.Server do end defp read_long_characteristic_value(state, request) do + service = find_service_by_handle(state.profile, request.handle) id = find_characteristic_id(state.profile, request.handle) read_result = case state.read_buffer do nil -> - value = state.mod.read(id) + value = service.read.(id) read_bytes(value, state.mtu - 1, request.offset) value -> @@ -601,25 +585,27 @@ defmodule BlueHeron.GATT.Server do end defp write_characteristic_value(state, request) do + service = find_service_by_handle(state.profile, request.handle) id = find_characteristic_id(state.profile, request.handle) - :ok = state.mod.write(id, request.value) + + :ok = service.write.(id, request.value) {state, %WriteResponse{}} end defp write_descriptor_value(state, handle, value) do - profile = Enum.map(state.profile, &map_service_chars(state.mod, &1, handle, value)) + profile = Enum.map(state.profile, &map_service_chars(&1, handle, value)) {%{state | profile: profile}, %WriteResponse{}} end - defp map_service_chars(mod, service, handle, value) do + defp map_service_chars(service, handle, value) do characteristics = Enum.map(service.characteristics, fn %{descriptor_handle: ^handle} = char -> # TODO: probably shouldn't be doing this in the map function, # but prevents having to itterate the entire service table again - if match?(<<0x1, 0>>, value), do: mod.subscribe(char.id) - if match?(<<0x0, 0>>, value), do: mod.unsubscribe(char.id) + if match?(<<0x1, 0>>, value), do: service.subscribe.(char.id) + if match?(<<0x0, 0>>, value), do: service.unsubscribe.(char.id) %{char | descriptor: %{char.descriptor | value: value}} char -> @@ -642,6 +628,7 @@ defmodule BlueHeron.GATT.Server do defp write_long_characteristic_value(state, %ExecuteWriteRequest{flags: 1}) do [req | _] = state.write_requests + service = find_service_by_handle(state.profile, req.handle) id = find_characteristic_id(state.profile, req.handle) value = @@ -650,7 +637,7 @@ defmodule BlueHeron.GATT.Server do |> Enum.map(fn req -> req.value end) |> Enum.into(<<>>) - :ok = state.mod.write(id, value) + :ok = service.write.(id, value) state = %{state | write_requests: []} {state, %ExecuteWriteResponse{}} @@ -662,7 +649,7 @@ defmodule BlueHeron.GATT.Server do {state, %ExecuteWriteResponse{}} end - defp hydrate(profile) do + defp hydrate(profile) when is_list(profile) do # TODO: Check that ID's are unique {_next_handle, profile} = Enum.reduce(profile, {1, []}, fn service, {next_handle, acc} -> @@ -747,6 +734,14 @@ defmodule BlueHeron.GATT.Server do end end + defp find_service_by_handle(profile, characteristic_value_handle) do + Enum.find(profile, fn service -> + Enum.find(service.characteristics, fn characteristic -> + characteristic.value_handle == characteristic_value_handle + end) + end) + end + defp find_characteristic_id(profile, characteristic_value_handle) do profile |> Enum.flat_map(fn service -> service.characteristics end) diff --git a/lib/blue_heron/gatt/service.ex b/lib/blue_heron/gatt/service.ex index f1ddf2c..5fe50ce 100644 --- a/lib/blue_heron/gatt/service.ex +++ b/lib/blue_heron/gatt/service.ex @@ -2,14 +2,23 @@ defmodule BlueHeron.GATT.Service do @moduledoc """ Struct that represents a GATT service. """ + require Logger + @type id :: term() + @type read_fn :: (BlueHeron.GATT.Characteristic.id() -> binary()) + @type write_fn :: (BlueHeron.GATT.Characteristic.id(), binary() -> any()) + @type subscribe_fn :: (BlueHeron.GATT.Characteristic.id() -> any()) + @opaque t() :: %__MODULE__{ id: id, type: non_neg_integer(), characteristics: [BlueHeron.GATT.Characteristic.t()], handle: any(), - end_group_handle: any() + end_group_handle: any(), + read: read_fn, + write: write_fn, + subscribe: subscribe_fn } defstruct [ @@ -17,7 +26,11 @@ defmodule BlueHeron.GATT.Service do :type, :characteristics, :handle, - :end_group_handle + :end_group_handle, + :read, + :write, + :subscribe, + :unsubscribe ] @doc """ @@ -28,26 +41,58 @@ defmodule BlueHeron.GATT.Service do Can be any Erlang term. - `type`: The service type UUID. Can be a 2- or 16-byte byte UUID. Integer. - `characteristics`: A list of characteristics. + - `read`: a 1 arity function called when the value of a characteristic should be read. + - `write`: a 2 arity function called when the value of a characteristic should be written. + - `subscribe`: a 1 arity function called when the value of a characteristic's value should be indicated. + - `unsubscribe`: a 1 arity function called when the value of a characteristic's value should stop indicating. - ## Example: - - iex> BlueHeron.GATT.Service.new(%{ - ...> id: :gap, - ...> type: 0x1800, - ...> characteristics: [ - ...> BlueHeron.GATT.Characteristic.new(%{ - ...> id: {:gap, :device_name}, - ...> type: 0x2A00, - ...> properties: 0b00000010 - ...> }) - ...> ] - ...> }) - %BlueHeron.GATT.Service{id: :gap, type: 0x1800, characteristics: [%BlueHeron.GATT.Characteristic{id: {:gap, :device_name}, type: 0x2A00, properties: 2}]} """ @spec new(args :: map()) :: t() def new(args) do - args = Map.take(args, [:id, :type, :characteristics]) + args = Map.take(args, [:id, :type, :characteristics, :read, :write, :subscribe, :unsubscribe]) + + __MODULE__ + |> struct!(args) + |> validate_callbacks() + end + + defp validate_callbacks(service) do + service + |> Map.update(:read, &default_read_callback/1, fn + fun when is_function(fun, 1) -> fun + _ -> &default_read_callback/1 + end) + |> Map.update(:write, &default_write_callback/2, fn + fun when is_function(fun, 2) -> fun + _ -> &default_write_callback/2 + end) + |> Map.update(:subscribe, &default_subscribe_callback/1, fn + fun when is_function(fun, 1) -> fun + _ -> &default_subscribe_callback/1 + end) + |> Map.update(:unsubscribe, &default_unsubscribe_callback/1, fn + fun when is_function(fun, 1) -> fun + _ -> &default_unsubscribe_callback/1 + end) + end + + defp default_read_callback(id) do + Logger.error("Service Read #{inspect(id)}") + <<0>> + end + + defp default_write_callback(id, value) do + Logger.error("Service Write #{inspect(id)} #{inspect(value)}") + :ok + end + + defp default_subscribe_callback(id) do + Logger.error("Service Subscribe #{inspect(id)}") + :ok + end - struct!(__MODULE__, args) + defp default_unsubscribe_callback(id) do + Logger.error("Service Unsubscribe #{inspect(id)}") + :ok end end diff --git a/lib/blue_heron/peripheral.ex b/lib/blue_heron/peripheral.ex index c149bfd..859add4 100644 --- a/lib/blue_heron/peripheral.ex +++ b/lib/blue_heron/peripheral.ex @@ -1,4 +1,19 @@ defmodule BlueHeron.Peripheral do + @moduledoc """ + Handles management of advertising and GATT server + + ## Advertisement Data and Scan Response Data + + both `set_advertising_data` and `set_scan_response_data` take the same binary + data as an argument. The format is called `AdvertisingData` or `AD` for short in + the official BLE spec. The format is + + <> + + Where `param` can be one of many values defined in the official BLE spec suplement, and each `param` + has it's own data. Both params have a hard limit of 31 bytes total. + """ + use GenServer require Logger @@ -16,7 +31,6 @@ defmodule BlueHeron.Peripheral do alias BlueHeron.HCI.Event.{ CommandComplete, CommandStatus, - NumberOfCompletedPackets, DisconnectionComplete, EncryptionChange } @@ -59,18 +73,31 @@ defmodule BlueHeron.Peripheral do GenServer.call(__MODULE__, :stop_advertising) end + @spec exchange_mtu(non_neg_integer()) :: :ok | {:error, term()} + def exchange_mtu(server_mtu) do + GenServer.call(__MODULE__, {:exchange_mtu, server_mtu}) + end + + @spec notify(Service.id(), Characteristic.id(), binary()) :: :ok | {:error, term()} + def notify(service_id, charateristic_id, data) do + GenServer.call(__MODULE__, {:notify, service_id, charateristic_id, data}) + end + def add_service(%Service{} = service) do - services = PropertyTable.get(BlueHeron.GATT, ["profile"]) + services = PropertyTable.get(BlueHeron.GATT, ["profile"], []) PropertyTable.put(BlueHeron.GATT, ["profile"], [service | services]) end - # def nofify() do - # GenServer.call(__MODULE__, {:nofify, }) - # end + def delete_service(service_id) do + services = PropertyTable.get(BlueHeron.GATT, ["profile"], []) - # def exchange_mtu() do + new_services = + Enum.reject(services, fn service -> + service.id == service_id + end) - # end + PropertyTable.put(BlueHeron.GATT, ["profile"], new_services) + end @impl GenServer def init(_) do @@ -81,7 +108,7 @@ defmodule BlueHeron.Peripheral do advertising: false, ready?: false, connection: nil, - gatt_server: GATT.Server.init(profile, nil) + gatt_server: GATT.Server.init(profile) } :ok = BlueHeron.Registry.subscribe() @@ -89,6 +116,26 @@ defmodule BlueHeron.Peripheral do end @impl GenServer + def handle_info( + %PropertyTable.Event{table: BlueHeron.GATT, property: ["profile"], value: profile}, + state + ) + when is_list(profile) do + Logger.info("Rebuilding GATT") + + new_state = + if state.connection do + command = Disconnect.new(connection_handle: state.connection.handle) + {:reply, _reply, new_state} = handle_command(command, state) + new_state + else + state + end + + gatt_server = GATT.Server.init(profile) + {:noreply, %{new_state | gatt_server: gatt_server}} + end + def handle_info({:BLUETOOTH_EVENT_STATE, :HCI_STATE_WORKING}, state) do {:noreply, %{state | ready?: true}} end @@ -102,22 +149,46 @@ defmodule BlueHeron.Peripheral do handle: event.connection_handle } + :ok = BlueHeron.SMP.set_connection(connection) + {:noreply, %{state | connection: connection}} end def handle_info({:HCI_EVENT_PACKET, %DisconnectionComplete{} = pkt}, state) do Logger.warning("Peripheral Disconnect #{inspect(pkt.reason)}") + :ok = BlueHeron.SMP.set_connection(nil) if state.advertising do Logger.info("Restarting advertising") command = SetAdvertisingEnable.new(advertising_enable: true) {:reply, _reply, new_state} = handle_command(command, state) - {:noreply, new_state} + {:noreply, %{new_state | connection: nil}} else - {:noreply, state} + {:noreply, %{state | connection: nil}} + end + end + + def handle_info({:HCI_EVENT_PACKET, %ConnectionUpdateComplete{} = pkt}, state) do + Logger.info("ConnectionUpdateComplete: #{inspect(pkt)}") + {:noreply, state} + end + + def handle_info({:HCI_EVENT_PACKET, %LongTermKeyRequest{} = request}, state) do + case SMP.long_term_key_request(request) do + %{} = command -> + {:reply, _reply, new_state} = handle_command(command, state) + {:noreply, new_state} + + _ -> + {:noreply, state} end end + def handle_info({:HCI_EVENT_PACKET, %EncryptionChange{} = request}, state) do + :ok = SMP.encryption_change(request) + {:noreply, state} + end + def handle_info({:HCI_EVENT_PACKET, _}, state) do {:noreply, state} end @@ -127,7 +198,7 @@ defmodule BlueHeron.Peripheral do state ) do Logger.info( - "Peripheral service discovery request: #{inspect(handle, base: :hex)}=> #{inspect(request, base: :hex)}" + "Peripheral GATT request: #{inspect(handle, base: :hex)}=> #{inspect(request, base: :hex)}" ) {gatt_server, response} = GATT.Server.handle(state.gatt_server, request) @@ -140,6 +211,20 @@ defmodule BlueHeron.Peripheral do {:noreply, %{state | gatt_server: gatt_server}} end + def handle_info( + {:HCI_ACL_DATA_PACKET, %ACL{handle: handle, data: %L2Cap{cid: 0x0006, data: request}}}, + state + ) do + response = SMP.handle(request) + + if response do + acl_response = build_l2cap_acl(handle, 0x0006, response) + BlueHeron.HCI.Transport.buffer_acl(acl_response) + end + + {:noreply, state} + end + def handle_info({:HCI_ACL_DATA_PACKET, acl}, state) do Logger.info("Unhandled ACL packet: #{inspect(acl)}") {:noreply, state} @@ -177,6 +262,48 @@ defmodule BlueHeron.Peripheral do {:reply, reply, %{new_state | advertising: false}} end + def handle_call({:exchange_mtu, _server_mtu}, _from, %{connection: nil} = state) do + {:reply, {:error, :no_connection}, state} + end + + def handle_call({:exchange_mtu, server_mtu}, _from, state) do + {:ok, request} = GATT.Server.exchange_mtu(state.gatt_server, server_mtu) + acl = build_l2cap_acl(state.connection.handle, 0x0004, request) + reply = BlueHeron.HCI.Transport.buffer_acl(acl) + {:reply, reply, state} + end + + def handle_call( + {:notify, _service_id, _characteristic_id, _notification_data}, + _from, + %{connection: nil} = state + ) do + {:reply, {:error, :no_connection}, state} + end + + def handle_call({:notify, service_id, characteristic_id, notification_data}, _from, state) do + notif = + GATT.Server.handle_value_notification( + state.gatt_server, + service_id, + characteristic_id, + notification_data + ) + + case notif do + {:ok, result} -> + acl = build_l2cap_acl(state.connection.handle, 0x0004, result) + + Logger.info("Sending notification: #{acl}") + reply = BlueHeron.HCI.Transport.buffer_acl(acl) + {:reply, reply, state} + + error -> + Logger.error("Failed to send notification: #{inspect(error)}") + {:reply, error, state} + end + end + defp handle_command(command, state) do case BlueHeron.HCI.Transport.send_hci(command) do {:ok, %CommandComplete{return_parameters: %{status: 0}}} -> @@ -185,6 +312,13 @@ defmodule BlueHeron.Peripheral do {:ok, %CommandComplete{return_parameters: %{status: error}}} -> {^error, reply, _} = BlueHeron.ErrorCode.to_atom(error) {:reply, reply, state} + + {:ok, %CommandStatus{status: 0x00}} -> + {:reply, :ok, state} + + {:ok, %CommandStatus{status: error}} -> + {^error, reply, _} = BlueHeron.ErrorCode.to_atom(error) + {:reply, reply, state} end end diff --git a/lib/blue_heron/registry.ex b/lib/blue_heron/registry.ex index f4d9b74..0b217b9 100644 --- a/lib/blue_heron/registry.ex +++ b/lib/blue_heron/registry.ex @@ -16,7 +16,7 @@ defmodule BlueHeron.Registry do end @doc false - @spec broadcast(term()) :: any() + @spec broadcast(term()) :: :ok def broadcast(message) do Registry.dispatch(__MODULE__, @pubsub, fn entries -> for {pid, _data} <- entries, do: send(pid, message) diff --git a/lib/blue_heron/smp.ex b/lib/blue_heron/smp.ex index a0b1390..6a75f7b 100644 --- a/lib/blue_heron/smp.ex +++ b/lib/blue_heron/smp.ex @@ -18,6 +18,7 @@ defmodule BlueHeron.SMP do @default_io_handler BlueHeron.SMP.DefaultIOHandler defstruct [ + :ready?, :pairing, :bd_address, :connection, @@ -29,35 +30,14 @@ defmodule BlueHeron.SMP do @doc false def start_link(args) do - GenServer.start_link(__MODULE__, args) - end - - @doc false - @impl GenServer - def init(args) do - io_handler = Keyword.get(args, :io_handler, @default_io_handler) - - with {:ok, keyfile} <- io_handler.keyfile(), - {:ok, key_manager} <- KeyManager.start_link(keyfile) do - {:ok, - %__MODULE__{ - key_manager: key_manager, - authenticated: false, - io_handler: io_handler - }} - end - end - - @doc "Set BR_ADDR." - def set_bd_address(smp, addr) do - GenServer.cast(smp, {:set_bd_address, addr}) + GenServer.start_link(__MODULE__, args, name: __MODULE__) end @doc """ Returns true if the current connection is authenticated. """ - def authenticated?(smp) do - GenServer.call(smp, :authenticated) + def authenticated?() do + GenServer.call(__MODULE__, :authenticated) end @doc """ @@ -65,8 +45,8 @@ defmodule BlueHeron.SMP do The information is needed for key generation. """ - def set_connection(smp, con) do - GenServer.cast(smp, {:set_connection, con}) + def set_connection(connection) do + GenServer.cast(__MODULE__, {:set_connection, connection}) end @doc """ @@ -74,26 +54,57 @@ defmodule BlueHeron.SMP do This function returns a Long Term Key Request Response or nil. """ - def long_term_key_request(smp, msg) do - GenServer.call(smp, {:long_term_key_request, msg}) + def long_term_key_request(msg) do + GenServer.call(__MODULE__, {:long_term_key_request, msg}) end @doc """ Inform the Security Manager about changes in the encryption. """ - def encryption_change(smp, event) do - GenServer.call(smp, {:encryption_change, event}) + def encryption_change(event) do + GenServer.call(__MODULE__, {:encryption_change, event}) + end + + @doc false + def handle(msg) do + GenServer.call(__MODULE__, {:handle, msg}) end - def handle(smp, msg) do - GenServer.call(smp, {:handle, msg}) + @doc false + @impl GenServer + def init(args) do + io_handler = Keyword.get(args, :io_handler, @default_io_handler) + :ok = BlueHeron.Registry.subscribe() + + with {:ok, keyfile} <- io_handler.keyfile(), + {:ok, key_manager} <- KeyManager.start_link(keyfile) do + {:ok, + %__MODULE__{ + ready?: false, + key_manager: key_manager, + authenticated: false, + io_handler: io_handler + }} + end end @impl GenServer - def handle_cast({:set_bd_address, addr}, state) do - {:noreply, %{state | bd_address: addr}} + def handle_info({:BLUETOOTH_EVENT_STATE, :HCI_STATE_WORKING}, state) do + case BlueHeron.HCI.Transport.get_setup_param(:bd_addr) do + {:ok, bd_addr} -> + {:noreply, %{state | ready?: true, bd_address: bd_addr}} + + _ -> + Logger.error("Failed to get BD ADDR for SMP") + {:noreply, state} + end + end + + def handle_info(_, state) do + {:noreply, state} end + @impl GenServer def handle_cast({:set_connection, con}, state) do {:noreply, %{state | connection: con, authenticated: false}} end @@ -203,6 +214,26 @@ defmodule BlueHeron.SMP do {:reply, nil, state} end + def handle_call( + {:handle, <<0x08, identity_resolving_key::binary-16>>}, + _from, + state + ) do + Logger.info("SMP Identity Resolving Key: #{inspect(identity_resolving_key)}") + + {:reply, nil, state} + end + + def handle_call( + {:handle, <<0x09, addr_type, addr::binary-6>>}, + _from, + state + ) do + addr = BlueHeron.Address.parse(addr) + Logger.info("SMP Identity Address Information: type: #{addr_type} address: #{addr}") + {:reply, nil, state} + end + def handle_call({:handle, <<0x0A, _csrk::binary>>}, _from, state) do # Got CSRK from central. We will not use it, it will connect to us in the future. {:reply, nil, state} @@ -250,35 +281,30 @@ defmodule BlueHeron.SMP do # generate and send LTK using "Encryption Information" ACL message frame = acl(event.connection_handle, <<0x06>> <> reverse(ltk)) - :ok = BlueHeron.HCI.Transport.send_acl(frame) - :timer.sleep(200) + :ok = BlueHeron.HCI.Transport.buffer_acl(frame) # generate and send EDIV and RAND using "Central Identification" ACL message frame = acl(event.connection_handle, <<0x07, ediv::little-16>> <> reverse(rand)) - :ok = BlueHeron.HCI.Transport.send_acl(frame) - :timer.sleep(200) + :ok = BlueHeron.HCI.Transport.buffer_acl(frame) # generate and send IRK using "Identity Information" ACL message frame = acl(event.connection_handle, <<0x08>> <> reverse(irk)) - :ok = BlueHeron.HCI.Transport.send_acl(frame) - :timer.sleep(200) + :ok = BlueHeron.HCI.Transport.buffer_acl(frame) # generate and send BD_ADDRESS using "Identity Address Information" ACL message frame = acl(event.connection_handle, <<0x09, 0>> <> reverse(state.bd_address.binary)) - :ok = BlueHeron.HCI.Transport.send_acl(frame) - :timer.sleep(200) + :ok = BlueHeron.HCI.Transport.buffer_acl(frame) # generate and send CSRK using "Signing Information" ACL message frame = acl(event.connection_handle, <<0x0A>> <> reverse(csrk)) - :ok = BlueHeron.HCI.Transport.send_acl(frame) - :timer.sleep(200) + :ok = BlueHeron.HCI.Transport.buffer_acl(frame) - {:reply, nil, %{state | authenticated: true}} + {:reply, :ok, %{state | authenticated: true}} end def handle_call({:encryption_change, _event}, _from, state) do # Authenticated using exchanged long term key - {:reply, nil, %{state | authenticated: true}} + {:reply, :ok, %{state | authenticated: true}} end @doc """ diff --git a/lib/blue_heron/smp/default_io_handler.ex b/lib/blue_heron/smp/default_io_handler.ex new file mode 100644 index 0000000..8e81196 --- /dev/null +++ b/lib/blue_heron/smp/default_io_handler.ex @@ -0,0 +1,22 @@ +defmodule BlueHeron.SMP.DefaultIOHandler do + @moduledoc """ + Default IO Handler for SMP + """ + + require Logger + + @behaviour BlueHeron.SMP.IOHandler + + @impl true + def keyfile, do: {:ok, "/data/blue_heron.term"} + + @impl true + def passkey(data) do + Logger.info("SMP Passkey: #{inspect(data)}") + end + + @impl true + def status_update(status) do + Logger.info("SMP Status update: #{inspect(status)}") + end +end diff --git a/mix.exs b/mix.exs index 8bd7286..83e733a 100644 --- a/mix.exs +++ b/mix.exs @@ -47,7 +47,7 @@ defmodule BlueHeron.MixProject do defp dialyzer() do [ - flags: [:race_conditions, :unmatched_returns, :error_handling, :underspecs], + flags: [:unmatched_returns, :error_handling, :underspecs], plt_add_apps: [:mix] ] end diff --git a/test/blue_heron/gatt/server_test.exs b/test/blue_heron/gatt/server_test.exs index b7e1548..0694e49 100644 --- a/test/blue_heron/gatt/server_test.exs +++ b/test/blue_heron/gatt/server_test.exs @@ -24,9 +24,6 @@ defmodule BlueHeron.GATT.ServerTest do } defmodule TestServer do - @behaviour Server - - @impl Server def profile() do [ Service.new(%{ @@ -52,7 +49,8 @@ defmodule BlueHeron.GATT.ServerTest do permissions: 0b00000010 }) }) - ] + ], + read: &read/1 }), Service.new(%{ id: :gatt, @@ -63,7 +61,8 @@ defmodule BlueHeron.GATT.ServerTest do type: 0x2A05, properties: 0x20 }) - ] + ], + read: &read/1 }), Service.new(%{ id: :custom_service_1, @@ -79,7 +78,9 @@ defmodule BlueHeron.GATT.ServerTest do type: 0xF018E00E0ECE45B09617B744833D89BA, properties: 0b0001010 }) - ] + ], + read: &read/1, + write: &write/2 }), Service.new(%{ id: :custom_service_2, @@ -95,12 +96,12 @@ defmodule BlueHeron.GATT.ServerTest do type: 0xF018E00E0ECE45B09617B744833D89BB, properties: 0b0001010 }) - ] + ], + read: &read/1 }) ] end - @impl Server def read({:gap, :device_name}) do "test-device" end @@ -121,7 +122,6 @@ defmodule BlueHeron.GATT.ServerTest do <<0x008D::little-16>> end - @impl Server def write({:custom_service_1, :short_uuid}, value) do send(self(), value) :ok @@ -129,7 +129,7 @@ defmodule BlueHeron.GATT.ServerTest do end test "discover all primary services" do - state = Server.init(TestServer, nil) + state = Server.init(TestServer.profile()) # First, request primary services in the entire handle range {state, response} = @@ -212,7 +212,7 @@ defmodule BlueHeron.GATT.ServerTest do end test "discover all characteristics" do - state = Server.init(TestServer, nil) + state = Server.init(TestServer.profile()) # Request all characteristics in the range of the :gap service {state, response} = @@ -307,7 +307,7 @@ defmodule BlueHeron.GATT.ServerTest do end test "discover characteristics by uuid" do - state = Server.init(TestServer, nil) + state = Server.init(TestServer.profile()) # Request all characteristics of type 0x2A00 (device name) in the range of # the :gap service @@ -382,7 +382,7 @@ defmodule BlueHeron.GATT.ServerTest do end test "read short characteristic value" do - state = Server.init(TestServer, nil) + state = Server.init(TestServer.profile()) {_state, response} = Server.handle(state, %ReadRequest{handle: 0x0003}) @@ -390,7 +390,7 @@ defmodule BlueHeron.GATT.ServerTest do end test "read long characteristic value" do - state = Server.init(TestServer, nil) + state = Server.init(TestServer.profile()) # Overhead per response is 1 byte chunk_size = state.mtu - 1 expected_value = "a-value-longer-than-22-bytes" @@ -414,7 +414,7 @@ defmodule BlueHeron.GATT.ServerTest do end test "write short characteristic value" do - state = Server.init(TestServer, nil) + state = Server.init(TestServer.profile()) {_state, response} = Server.handle(state, %WriteRequest{handle: 0x0000E, value: "short-value"}) @@ -425,7 +425,7 @@ defmodule BlueHeron.GATT.ServerTest do end test "write long characteristic value" do - state = Server.init(TestServer, nil) + state = Server.init(TestServer.profile()) # Overhead per request & response is 5 bytes chunk_size = state.mtu - 5 expected_value = "a-value-longer-than-22-bytes" @@ -456,7 +456,7 @@ defmodule BlueHeron.GATT.ServerTest do end test "discover characteristic descriptor" do - state = Server.init(TestServer, nil) + state = Server.init(TestServer.profile()) {_state, response} = Server.handle(state, %FindInformationRequest{ @@ -473,7 +473,7 @@ defmodule BlueHeron.GATT.ServerTest do end test "notifications" do - state = Server.init(TestServer, nil) + state = Server.init(TestServer.profile()) {state, response} = Server.handle(state, %WriteRequest{ diff --git a/test/blue_heron/transport/uart/framing_test.exs b/test/blue_heron/transport/uart/framing_test.exs index 804732e..9ea1463 100644 --- a/test/blue_heron/transport/uart/framing_test.exs +++ b/test/blue_heron/transport/uart/framing_test.exs @@ -1,6 +1,6 @@ defmodule BlueHeron.Transport.UART.FramingTest do use ExUnit.Case - alias BlueHeron.Transport.UART.Framing + alias BlueHeron.HCI.Transport.UART.Framing test "hci frame" do frame = <<0x04, 0x0E, 0x0A, 0x01, 0x09, 0x10, 0x00, 0xB2, 0xE2, 0x66, 0x1C, 0xFB, 0xE8>> @@ -9,7 +9,7 @@ defmodule BlueHeron.Transport.UART.FramingTest do assert {:ok, [<<0x04, 0x0E, 0x0A, 0x01, 0x09, 0x10, 0x00, 0xB2, 0xE2, 0x66, 0x1C, 0xFB, 0xE8>>], - %BlueHeron.Transport.UART.Framing.State{frame: "", type: nil, frames: []}} = + %BlueHeron.HCI.Transport.UART.Framing.State{frame: "", type: nil, frames: []}} = Framing.remove_framing(frame, state) end @@ -24,7 +24,7 @@ defmodule BlueHeron.Transport.UART.FramingTest do <<0x2, 0x80, 0x20, 0xB, 0x0, 0x7, 0x0, 0x4, 0x0, 0x10, 0x1, 0x0, 0xFF, 0xFF, 0x0, 0x28>> ], - %BlueHeron.Transport.UART.Framing.State{frame: "", type: nil, frames: []}} = + %BlueHeron.HCI.Transport.UART.Framing.State{frame: "", type: nil, frames: []}} = Framing.remove_framing(frame, state) end @@ -41,7 +41,7 @@ defmodule BlueHeron.Transport.UART.FramingTest do 0x28>>, <<0x4, 0x13, 0x5, 0x1, 0x80, 0x0, 0x1, 0x0>> ], - %BlueHeron.Transport.UART.Framing.State{frame: "", type: nil, frames: []}} = + %BlueHeron.HCI.Transport.UART.Framing.State{frame: "", type: nil, frames: []}} = Framing.remove_framing(frames, state) end end