From 9a747d49b19822d1a9eb93bc04b418a4ed55eafa Mon Sep 17 00:00:00 2001 From: bishwasregmi Date: Thu, 18 Mar 2021 21:59:30 +0100 Subject: [PATCH 01/69] added example white label logo(of TU Delft) to dashboard toolbar --- .../dashboard-toolbar.component.html | 4 ++++ .../dashboard-toolbar.component.scss | 12 ++++++++++++ .../dashboard-toolbar.component.ts | 2 ++ ui-ngx/src/assets/white_label_logo.png | Bin 0 -> 36884 bytes ui-ngx/src/assets/white_label_logo.svg | 1 + 5 files changed, 19 insertions(+) create mode 100644 ui-ngx/src/assets/white_label_logo.png create mode 100644 ui-ngx/src/assets/white_label_logo.svg diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-toolbar.component.html b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-toolbar.component.html index de15c409ec..676fada4c3 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-toolbar.component.html +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-toolbar.component.html @@ -26,6 +26,10 @@ +
+ +
diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-toolbar.component.scss b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-toolbar.component.scss index e54edd8815..a5f9d9ef3f 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-toolbar.component.scss +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-toolbar.component.scss @@ -76,6 +76,18 @@ tb-dashboard-toolbar { height: $mobile-toolbar-height; min-height: $mobile-toolbar-height; } + & > div { + height: $toolbar-height; + .whitelabel-logo-db { + width: auto; + max-width: 100%; + height: 30px; + max-height: 100%; + margin-left: 20px; + margin-top: 5px; + margin-right: 30px; + } + } @media #{$mat-gt-sm} { height: $toolbar-height; diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-toolbar.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-toolbar.component.ts index 201f70787a..176375acb3 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-toolbar.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-toolbar.component.ts @@ -43,4 +43,6 @@ export class DashboardToolbarComponent implements OnInit { this.triggerClick.emit(); } + white_label_logo = 'assets/white_label_logo.png'; + } diff --git a/ui-ngx/src/assets/white_label_logo.png b/ui-ngx/src/assets/white_label_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..da558f7b7b5a60aaeecd4d3aa4a6952b56b2b1ed GIT binary patch literal 36884 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 literal 0 HcmV?d00001 diff --git a/ui-ngx/src/assets/white_label_logo.svg b/ui-ngx/src/assets/white_label_logo.svg new file mode 100644 index 0000000000..706ca443f0 --- /dev/null +++ b/ui-ngx/src/assets/white_label_logo.svg @@ -0,0 +1 @@ + \ No newline at end of file From e7fe89f589e008a85cd628e7ed0ecc934b6e379b Mon Sep 17 00:00:00 2001 From: mdheer Date: Mon, 22 Mar 2021 22:21:25 +0100 Subject: [PATCH 02/69] Added dashboard logo checkbox --- .../dashboard-page.component.html | 32 ++++++++++++------ .../dashboard-page.component.scss | 11 +++++- .../dashboard-page.component.ts | 11 ++++++ .../dashboard-settings-dialog.component.html | 3 ++ .../dashboard-settings-dialog.component.ts | 1 + .../dashboard-toolbar.component.html | 4 --- .../home/pages/api-usage/api_usage_json.raw | 3 +- .../src/app/shared/models/dashboard.models.ts | 1 + ui-ngx/src/assets/example_logo.png | Bin 0 -> 34158 bytes .../assets/locale/locale.constant-en_US.json | 1 + ui-ngx/src/assets/white_label_logo.png | Bin 36884 -> 0 bytes ui-ngx/src/assets/white_label_logo.svg | 1 - 12 files changed, 50 insertions(+), 18 deletions(-) create mode 100644 ui-ngx/src/assets/example_logo.png delete mode 100644 ui-ngx/src/assets/white_label_logo.png delete mode 100644 ui-ngx/src/assets/white_label_logo.svg diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.html b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.html index f82a7a82ca..4fa2c9cc7a 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.html +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.html @@ -46,16 +46,26 @@ view_compact - - + +
+ + + + + + +
@@ -286,4 +296,4 @@ -
+ \ No newline at end of file diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.scss b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.scss index c7d2367975..ed795e129e 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.scss +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.scss @@ -54,6 +54,14 @@ div.tb-dashboard-page { z-index: 13; pointer-events: none; + .db-logo-and-title { + height: $toolbar-height; + .whitelabel-logo-db{ + height: 100%; + width: auto; + } + } + &.tb-dashboard-toolbar-opened { right: 0; // transition: right .3s cubic-bezier(.55, 0, .55, .2); @@ -63,8 +71,9 @@ div.tb-dashboard-page { right: 18px; transition: right .3s cubic-bezier(.55, 0, .55, .2) .2s; } - } + + } .tb-dashboard-container { &.tb-dashboard-toolbar-opened { &.is-fullscreen { diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts index 5b8c7a17dc..2fc5dc98de 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts @@ -189,6 +189,8 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC currentDashboardScope: DashboardPageScope; addingLayoutCtx: DashboardPageLayoutContext; + + dashboard_logo = 'assets/example_logo.png'; dashboardCtx: DashboardContext = { @@ -480,6 +482,15 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC } } + public showDashboardLogo(): boolean { + if (this.dashboard.configuration.settings && + isDefined(this.dashboard.configuration.settings.showDashboardLogo)) { + return this.dashboard.configuration.settings.showDashboardLogo; + } else { + return true; + } + } + public showRightLayoutSwitch(): boolean { return this.isMobile && this.layouts.right.show; } diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.html b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.html index ede8052c68..89d4510c44 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.html @@ -53,6 +53,9 @@
+ + {{ 'dashboard.display-dashboard-showDashboardLogo' | translate }} + {{ 'dashboard.display-dashboards-selection' | translate }} diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.ts index 36bbe80fa0..195f7e6433 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.ts @@ -79,6 +79,7 @@ export class DashboardSettingsDialogComponent extends DialogComponent -
- -
diff --git a/ui-ngx/src/app/modules/home/pages/api-usage/api_usage_json.raw b/ui-ngx/src/app/modules/home/pages/api-usage/api_usage_json.raw index b54ce8ea99..0a037e80bb 100644 --- a/ui-ngx/src/app/modules/home/pages/api-usage/api_usage_json.raw +++ b/ui-ngx/src/app/modules/home/pages/api-usage/api_usage_json.raw @@ -4413,7 +4413,8 @@ "showDashboardExport": false, "toolbarAlwaysOpen": true, "titleColor": "rgba(0,0,0,0.870588)", - "showFilters": false + "showFilters": false, + "showDashboardLogo": false } }, "name": "Api Usage" diff --git a/ui-ngx/src/app/shared/models/dashboard.models.ts b/ui-ngx/src/app/shared/models/dashboard.models.ts index a92bc3d672..4b9fa0d582 100644 --- a/ui-ngx/src/app/shared/models/dashboard.models.ts +++ b/ui-ngx/src/app/shared/models/dashboard.models.ts @@ -86,6 +86,7 @@ export interface DashboardSettings { showDashboardsSelect?: boolean; showEntitiesSelect?: boolean; showFilters?: boolean; + showDashboardLogo?: boolean; showDashboardTimewindow?: boolean; showDashboardExport?: boolean; toolbarAlwaysOpen?: boolean; diff --git a/ui-ngx/src/assets/example_logo.png b/ui-ngx/src/assets/example_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..44cd0245616ead898328fe8c2549c8268d3d4a7a GIT binary patch literal 34158 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 literal 0 HcmV?d00001 diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index f5d2990b5e..9d58a0a715 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -725,6 +725,7 @@ "display-filters": "Display filters", "display-dashboard-timewindow": "Display timewindow", "display-dashboard-export": "Display export", + "display-dashboard-showDashboardLogo": "Display dashboard logo", "import": "Import dashboard", "export": "Export dashboard", "export-failed-error": "Unable to export dashboard: {{error}}", diff --git a/ui-ngx/src/assets/white_label_logo.png b/ui-ngx/src/assets/white_label_logo.png deleted file mode 100644 index da558f7b7b5a60aaeecd4d3aa4a6952b56b2b1ed..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 36884 zcmcG$i9gia8#q34FZCgk?1suMm7T0(YgG{;OBmY_MMP%oOt+GC+!nOhN+kwks4!zI zW+uC?b;=Txr7_W9EMxng_qgBR@AnV5{d@?*GaD4+ewn zK7a1C9SpY169(fi-1!IiB0_&(2>$bj--(MSU@&63(7M;3;P-yiIlGH6Skz${40{&_ zl61bw9nm0sPu^^W2qN0In+ZFW>6( z**)N+VA%PKX9UN02ul2M=uE575daN#{`85<5noxu;X`h|Ia_0MIcKNqmc3t}w>SFV zi?{oPj^ytn@K-{!xo=9b&?v47WR@a?;;Fo`@ z(Hr;U->_&Kj5svfep$4nl59lY1^u}C7T>>?r{%d}L}3wFcm-q7`e4g$kZuzqWn+ZMw)2hTzZf)9;Y zd`iDPb7 z^p-M63q2cqr}~~J7<*hK$oVcqBGbIY4C35A(B7rok@dS(vO zJHRKHa4IR#Y~y``aU3`!V(m`vXS0!jb1?H!d`jP3x_@mk2SNV@!C9;$Mb^dLUFhSW zzp$d9FI*R7xx$q~+|BxDcKR)|5dZTDeK%(Ml;jE5OF8`X4QSRPk>q1SvR0(TpwGe| z#R@>*xQ?;Ny6AljhtjHkiOMeMlX+foz`$tQb+Dn9O#?)Jz6$ds=eJSh-$k^`bSLyJ_HB?|*KA{i!;HpDpFD__SW|na;!(vuWsyxDxb z2b$LH#C*v4&An+s#k*(_EdBBze6gl>QpJkJ%i$2A2_(CvBxvfi96H9NdSrwMw)w_< zM)%{PW78YL!A7A30pI3|y1GmypsBGhtltWTM}XBIN!ONYc&qu*x1(;>y>0^JiS|nQ z=0S>!?Z*oZgb#c(GIj5Z3Ln;73xuY=b3+!zeHaTPtNSy&{g_VqkSItfe zzcygj`_n{*@MfT?vJ28Y^XSLiaqba;K>_Z?f$`QQlkq#Xtx!f|*ib zvm2T%)cczGk;77`^ZjkM>FCsD$`jshC#jO?zDLYQCb?xmKdR6~Ru8a6ux?NEM=q1w z!}b3Z<`7Wo@w?^@1iSqV^6l)E^o!5SuIIRkw#Pw`Vg9d3H;3^GSq4aOxYW>x z>KLCOy4LO|`y{l4++4NAK(|DD8lO zf=NN5$o^mDIM%q0Hh#Jow9l&|p}zB+w^5c$N@bJNMNtv(M;reG*fp+K^9zw)l}SAE zt{T*m{H*>og^U^vjoRKUk59;`L|sB7367#UtB`$iH>KAy$rX?t@;oB!+YDdu1^q*|HL9JC=Cm6=Q(DMBFw(q^SomFxUipY9+pavmwq@fMR8`JUpnAkUg z`u1d>Mv6m)Wy09ILBh~eIK-_cug1T<@F}zp#8WKkt}b<%H_D9i`H8o588E16ETAe~ zLGldT>q>bws802(N^R^Q1{>-lAoWVUHpeLlW(ijE@cm2J zSgp-XkYRG`LFFVRj###$8=2> z0aXDPq@G@SskdF=(ESF<&cuPNZ`9#sF3{tH!Sx}p2~a*Q@kwWm@C zz|9xuF6hr`O8tKs{PSf113mGQhw$`1eo6k9kl5^4@+rPqVZ)+~8xzFsF?oK;*fMp+ zZ`4<&T(^&rWaW{|*6lpbcWQfnDP(>kp0Q%~&?>t+ir5LE(L7m)ef_@PpUY=UqY9y> z2DRL!85OQkOm;;4>eXHzrPIa$YJocZ8|~GgiQ{MW^EV@F%iMB#LL2sKe92)x89Z{B z@+yLj9loWz@Mj`mQtKn0*t*pOR9%1`A(~$e>N>WnpAWN?zts7ehfxr~=mL6pQ1>te zqh(6|i{8a3o!H!_L!Do;)J`fHj!KWXqMC>N7bZ&i)@H-wU;dh~^SjeXBX?(sn&PfbO4F%b{t3@MiB;&kqf-a zk_NI;wqb^Yb#_R7Oxm!O)2-i45w4M{JOzkq!JcXAouZf${fsrp1rKI%cm5M`cYuJZ zY(d#x!cxM=NCRCF)o>pRAU*RrnR0(D$%iGmMiJSJ%f`2aIImvc=Hq8i&HWC@{Y#1b z-i6Xx$bbE%C^K!kJcap(f<5z$6vUDWPW7!s$d*wD#`z~&cvyc%H6A>HJm4-TNENC; zxO~qv$j^Sj<9&^=^WtT=IJwz%+;>FV;4ib3s?7U5diFo(OUW%#Y35^RI_r=N4*kFC z?!SXyvhEU7B;;o8L8{;Vchob);Qy5U*+=Y|tUd}OKf<--(eRgpJq$~>R{9NaA7SHl zJinYlml_z1?6vd>f7d!3;%Rt@$Dn7;U{P-= zdaX$P){5e>sM|_F)##Kp#9cX7okhV-AAx(dd;!I&pY4poV8naLd_tebAr)P z5epmQ&yKYZRt;?{`wh**2<%dk##O88N?F;5WAQvV(?Zziznz`s7CKxCW+lLs6u|J;U-5=ciN_0PNw_`o^)1TQjjvp z!qxwtF(@~FyuIXyjB~p!vNTS)Ed5T06I@ew#MS*8bZ=uHgbGS_UCK&Yrjb$J7>m4W zGj~_(ZS$Co@mS2g!x!+q z@8Wn0dz_VQddrgKqTa;=7B{ALm%p^kE*-Z3u*O^o^yT5)86w!x4tu6OWI^uYO6 zsBpjTVn3Vr;hnHWGk!;pVin2&Ljn*utIuX?Zy!p5+f{p(^6W%$0qsu6rM8IIJd~oE z;Lfpc%EU);QhZX8U#<)0%?fy zuVuY=7}^UCHbb(AXV8JkU#9vBYRaNHEAF*_k-g1_mKUB~$rB9zWF^N}r zx;OeUe)|^Jtl`JMW{!u1)#{PGv9*=!hY*n0Adta_&tOin9|1$>jg5I7qrCk&%jQ5} zj**_Jf?^7h4(I>zF5=qLXS}0vrw!YujqY0s34Daqy0odFp}NdFO^y2(_AEOVMN@qz z@wridVRWHGX|B%i7|{FYOx5@l(0dxz@ReIe4%ilUr_*((>&r3)v-FM%6@gBT)?7Li zbYc1M63nuY)M!G~Z2w^SP#$l~2q7;}v?^f;6a~n3orDXuf|N0WIL~b9N#NU#i&f@2i%$7dJpkr%wFgR4DPQq&{ zqQeP*>jqN1{?13w*%+K>$K^Wpe}2$4{Q_Y zQJVd7Y9-gZo_pDH3Xgx8GYlb6CG6Z`$5duiJ0C$5@NW|2b8|FL&MmKCz@Wcm@PWX8 z0CsRfSJ`yp0^=8bmr_^|xJ4i#Mi}usqIxsNn+N|Qzf_t{HUU0Xq8Qf)S_#uc@H?LB zouVBjx?s-u;r6a&xK=8io&Gd`p9T5Fo|xR*eLK+$29-9G_(a0Hh(EY_ENI>Lr^3#= zFL}tsCxdfi^kaVI9OhHvuq@4w7khO1G+uBLO=fs|W6s>SO4?`@r;8~qA@j^2fBK?N zW2*ymjGbc|wU_QSQNg+^hG#czZ)eT*GrIP|$h00V=;IXD{WGN3I4&#@GZK?+8`REY z&}1_<yo^Xb&nthBTW@hL_-k29V@L$~`-=nR8` zxk>ytM)_(CC202+;zm@q4mTL`r^D_e%A<^_7s-***2w^PKl1!s=(+MW7jzo!CA! zddxseuf1&gZ_DxT2V#f$=r2zFcK#0sh4B$z^v9KpfjP)8e}+>OCfEnEw^oT)Fw8y1 z58&N$=$gU93PR z&cLGU3E^O7nTl@~
0>l#^Zt0ptA;a6w=ASEZoe$|cO~KWaJfZaDhny6RF6Lsaeg zFq9`bHKleJc_~k#0hdIVK;E=qInOH0sD+65oY^8WonWLDC#LbdO;kOTm;O2h z#8^$TTK}LiHyvr1=6uhKykM5MH1o5=LvO_N$4XN(``Knel@F4;Spq*kd!`pzCK&DJf+V-p>R zhpWzF%nN~kA-=FOb}hsba{x>h2KrdQjHk?0$dNolS?-(1O9s4h1~m{-&i$<-(g7G@hCOs>Q_uIHagS5quL%4W{5c{S|4K^DF7Ai@!Ia;jI9rm(R3(7sX*aNE;B z*comoh4Y|}gX5)d=CG+l3;+Kk)Eo?Y!Jet3Wfoz+w#oFxSYp(?{n|k8A0xw*uN;RY0WhIM{5tw6HY2V*R zAlHu|Vo$%Jg-+D762BCwQ0THB(!V4ZZsvU> zCUn3GOl2%NcX#K%T zd{7k7tmvI{AUBjge;T6!(VmVdKl^%i-&{w~bh!uB@~oc6ndU+lV~;bcd!v1nQ>~C@ zo7&B*$HuF8KF(JfR`-ACnw1lIb$@TkMW=;*e=A3QUb$fLQ;p82WLuT0gQ6UWUnNvV zLt1lzeS-X=wM@mgUMYys%}mFmT3DyT8sA04fr!o9pZSs|2Lp5gbrQ6 znlZDDvJZ^Za>1kBNesn!MBjDFK2I?oz67$;*^h^ztd){iqwG^r zT1thu(PZ!7n|0Lj(frwc(SC|AQA?#7oz_npNYU$ua&=gFOL%QVvOHLF_7hfj2fAq` z0&|KMqwB6mnmX9`Cqs^YfBJ0!^DgUdWKqgMFol3(i>>$OU9AGwQR8Rn@`O@c%G{X( zW!!=1dxO>I)O*gZB28rj{1jgm=p{B)icgl+A1_PPPoJiv!)e#(9EcWefs(Q4_`t_G z-C|V~%mkuf<1h%;%$H6W&E7$iS$7SKEc@qZ!Ke(e%|%9QFAKVp8b7b>`{Y&Jg|l26 zXaDk}xKEVXHpnu@hX#+Y9YNjH3T{%rQG|#*tgh{$>ls=;k=#12ed`f3&9fX18vXUma+D=24t_^ z+(iAy;TeTSzpon;T5M8eU+*5TLO)<7CMJ?XHyWz{!pKZitp@e79zlDZixe8u?Db_l zb3R7)e!M9|I3|Vp(I+i8)tpk1-s9j$nVtC9jwLzAXpV>rrE%p+(CF=wCPV?m>p>&9m4$w~D4%o_3Wf{E7Im>H5AON@X!uk}H0)VTksKAMvO z(8a3jcH@?iu zX)E+Py+)7#9fGh2d;vRwm*Q}Lw6{+s90Wz}A`P;6i8j89Dt!_mR_m{Z^hm*WPEaEB z$-XvBU3hw^ce*>2lbqhm-@m7q0SxhOrO>l1`&riS0AfhC6r)l!vy4h%e_KJhT~bi& zw{bnDmvRx+GkDzbr|r)UB2JlCNWut|tQNak2#y}_C$kI=nvVSZ`BHt46g7%&5%5pp zcyLJU(4GMrC&b>^V9igW|B&|s=R^J3Y_kBGtck|@rPY|F6v+QA@NIlX;7$!5CVLIe zrf;+blYYC%%P6*+x-^;Wv1$I1=xiW~J5z!34lQw+-464=$Y#O2!#Ja83L(MmIs^JFmh1QH89){}{i>kwmn5RC(-hu!+!f3i+|1+MQJY?k&r(SCftkaaf7no>+f-Wlk#*Do z)G7<5ZDl_m&-1P}f6_bH54XT!C8Vf!>rtHU-nh9f5p{N@5yVjLKs2Z`Sn98Syo)?a_| zs2vIu7PGE)9Y4?fU&t+05P*w@)9NP{6>3Gu%}41LzJGj}U6Qi|`k!BA%pYJq@@0CJ z`S)PudgWSHQ3~}7G-K1BCxg)lp)sA_T_HCyO~&h*?n5cyOqwd`WFzdxu|^4$tAw@2S1_`{~FtxTaJUC_L1TxE15l zwm32G9JygUS2`>gQGV4vPwLxM{q`?ch}Q^^g>njf66<;P`}%*u8b~GgvqrvtMx@tE zARZ&4(L_>AjP_b%Y9g;r*q!yvp#QI8amJFfHrZP*o$|c3D~fweUVP10@tIh4b)c*+ zFQs7ArBj|8v`p=A(r?DMPSn+U4j*>Ui}gSILx*>*b01ahF&*Bd^Hr|is4b^0^i{7s zTbpqWnW|ue zcvfVpU)CA)WbE&VGc`(M63&KYDfcQwyLPUZ)gIs8hNe@#$B!4zv;ZGq92iTL zz7?PCEgtdBm@T!7Bqqu#s;oVx*{j!u%0G^cIr~Fu;_q!mI}8-E(kkvx>7 z6^`F#rRJ{Yg$8UR{2WofIWbY>w1sB59xjw*!Bt+lH`te@WIc`^e-(vUKY)0xJe#bv zeU8?KZgVuA^7n_Mx_+!4t|-&l8GP!bcfnok}bC}nTXo6+y5C-N8g zD2Fu;vpp);zw8H_e$>|Gu_o#x7C79uS{(|hCnlb-3M@`14fraDHPc#8ftgKoLu7c= z>O~%9iwS%x2kl184GclLu+uc+^y%Rvj%`8HSWCuM?h-BCR}f8K1g_mD$IFe zP?rWbg5_@AWx6U%_$)GwmA&7&^cRsxKaa#%)ojZ@ih`A3Kg>1bV}2QYpZzqJ^>hiE znP<%4v_}?SAsEZw_l10V^Ev7AgYN$_W=!NYRHl3sxueW~iE)^wwB{^V~zr_N61%jL|%?Zclnbo+gzD1mdy>X?+TVuYTYpJ1w8 zT2*IA=M^nPNdQixftMV@j>xWn)PaGgi2_+Ks6II&2-A3EBSO7P+*JorVyTe6yiE;3 zru@V77j$l_NgJJhKL?2{nFv2-BCGg}QRA3=sG^7T;}%GlLf6~$dp^Z-=E~qJWvOSf z1hqn+Jd8;HwN<4dGpC?DMqa!-#yM+fp9e4Vf*q0j&8O0zelO}~#!q&_{0yt0^NSsw zuOQSn=9;?M{5tEMdSrOy_OXZx?iMV$7#*4s{#emhF-#rYebV=?vEOL>s+%o+VpW%| zoG~=yX^FNl7yI5zw2VDE@tjV}#viKSKRbq>sIA!?OsQayzK=zO^BDg_dXaDDPh`U< z;sk?!Kl}dDCZ|E$)Tm0a*b?MXB9Xtpgt~4|^q<3i*>$LYZeC(jUhs5>>7=oh%tqBY zEl?tOG{r4={(k+-;;qmVF-pAsD})!Z4g{-&gTweNd-}^zF9s@2fFq8g@HxL0&mv{{9|MQRgn^_;TEUv8fWlFeqqIoHY4k zGr}P<#@*wRGPSZ&3BiJc>LVno&&LP(9hVbubD6<@ia|$O86kd{rgEz!r;g?N4``Gd z=BfqUHSQ5l_dxAx_~x}*l{NoUm^e(MW^cqT!&~(poWEJ^IFi0d3-2fg@*8{hO?W4j z#W1276275JKNDElEUedx^XeGL^V|V*qoP5U8FE@HcK^oqy;k$v%AfMkqaD()rR?z1F^`T1&H$5v~VBZ zffE;=$G*QVRX;@!IlV+oD6~pr02k$ZwLye`UY4^{`k_h`ViGyRYxWR>*xApXTk)@b zVbx~ZUk-l`*Yu!KW)jv(L7DJxTFRMu+lsFPLA^k0V31pP)9=gPt{k5PQ4`mT<@!cs z6|djY`RkvxaW!5ZxO<;KN$0BXYTVL;voEdE3E*073LkIu8t&`Y2=}Zb4?X2kbkVkg zf4+AO!F~4%;e97GcU#{a5rIlrUow=Z)`k3ugj z-99^kXZv721uCQuJRWO!AJW5OiM(Y~mvAq@-0t0X5T0s1ALlqXWA?8Z6xatP1^eh5 zcl=_H&!>p3*TzNc=pSgC+qNoK1i~9#NOwkOlqQx~jMT63lnweWogBsInGLvUzYkkF zI|)gm+l=dxE!z|U3%>JF5;XM zB{cRf!u4rp>=WEdl-r}6J?XT~i5tGHJL8w4i0790&i?!zYRfy1$Gf2UB>ow>4Q<26 z%BY5jW4dGW5w@#iN8&d7i#P@0*7E-``*<~f|6cg2`Z7(qLiAt5iBBtpcPRrap|$01 zTE%ljUzb>AHk zdA5;D*j#I_4vl6>iZ1{Ddw`PCwmETIB&P62l=JX4r{)*i(A^+RjG1#cQ8CJEdxkG< zN)mp4X&O>Y5~Xn`y5QU(SI(;%IUv!c(S1+?F-HdwIz89O^=a8?uP-~=kZ$tw+nv@$ zgTuM@I8_@A3-KYmG0Lgt81L};=SN+G=d@k-NRLOiMQt7NcoAt>Q=i{dm(pK^Wc_-h z=)3hEWDc=!W@f~$=2Fw0lIifrV7qF8aQa z8{)&KGX1uK1@ne`ohOp!XCr*q>y$=Ww+)Wvx?)tOFh?=>N9xq!Vg5CLZR1}O_3KEk z%c3*#7`fp-p?3#ogLB)q)}@D;Hxq~Uc9P4&_f#tS>h3W6ycqyB9vye73)yl0Fk;;1M z9Pi->M^y>S&h_Ioi}5^m*NUH;OcwR^#771F4U_)QamWPUD3;5A)yxml`rP0zc1rO( z*TO4%Ze_@gCv2_TB9Bv-qRO5jkz`*Gl^5~?4b3p19T#QBx}x{TJ!=rsQZazj(Jk@Y@<8&UjYg>5$5 zeqk&y3sHfm&D!L4%Dus*Wr66C&YrN}*5tDQR}+K3^KRu-#p`xT5(6=x_8_5K6bB$; z^*~yNmPG$z+@wI~I~C?v`|`gs@h_i7S={JViv{6dzygW0ljpm5#ZQ0yM|in{>{3^R zW@jgE@+XtZX0Z}p_yC7+eR~1{njpW_Wl&(80q+Q0>JX8B*d-~tB?z{&)%4swEJJTG zM#9CTLE!n3%}(Ooh5l7w)hryWQbIhX*BUs2bQUK~$^^Zo z{SD-v>n$Cz+3q3vwtE(fb`qYee{yTsqDK|&U28=4GM!CAw#9CTjdA=4&y2$HO|Ol+ z4OgXfo@dea(swN^0%sHW%@7;nG=arFJ)~7asNavsqSu-tJagYr16m`u*DHcsPu!vuks{qCi8=D=8acODWd$@ zL@Ax`7TTrugVnaoHnl(V_mAFZzaQ|U%`0*uL20GQEnAVGYQNo_GYai%X(cy5AmYb* z`E5ku5W)+$DXF2Bz72cp+T@;YCC;RC>^TpE8dX-&*^9PWxaX(`tYpLL10U4>>$)(L z{Q>e}aUOl69ZtJL{gtNht|(q+8b(5u3zo>;UqM%0y+0p}CPo0iMSe&ek-1%wHgAw+ z@y##~#0}@rU$ieSIUhuf$tWb|{MWnF-5~6IYOK_2x2-bu0DX7YZ%>w+`hN`tdV~D^ z4yV>hI$G$sjA-wcNsn!#OZ83}rL^U4UN~9$$-dpy~+AC(O+`LvHcXh#&#f#jr zL2}c4D%XX7wVV<4y}wsXY%aC0LOgf!#bAxw6JA|;BN!n5qCV;O7|ZG)^C9~IE1@v* z241<(*+KlUY7Ag{~9T?RO? z;|F5&S1aKu69&&;0sc5FsTkU}(YNW%OA=f3;`8rN;f zZ|X_up8fdj8QgG?`D6ujXD7=9rK+_}@MzU^7Iv1}Jr*GBTr(o6(Q5ryIcuzl(mO2|iFg9YnK7b6jT*2Ca$ZAs_EmyCy zw+G`=WWkB4=Q<$O*9O4=3~rle^6j_Qd-dnTy&lXJ?uJ6T)A9iSc1Lvg2~I*`nqn@t z&lb&&a!H@UD-KLl@|vZ)Z8>a5uREo_IWoy$Xi0Wp4-CpP0YtJ5N{JD5$SB{kLhA$s)NgB|)f?-o)Lc z?wqe@J>0Y%b;)DWBhc|C)u7E=3k;e|^&OglbF6I|n2(L(K1?N}dwRKQqEHheOu!uZ zrRR~+Yjuw<7RWZQk>ZvjA%OlFq>#EA3r3$Vn!xITj()c`x`7*EL&ZnWkY}p@`*sZN zX7W;*(f2s&GSYbM9he4tXN4r@_S|7JTe!bSQA<~N&~iwp>gog;eOl-o)*~J?BeeI% z$()fp2txm7)1%NrwaZm_r1_2lVA(B5R-7HB59ueN(Wr`RY#T#r7m4_u(s| z1Qnk=D3r!Z7Pk#3F;Pf3LA^+D;Lpk~s9nfhsP7_ElqrtPE_>7O(R-uP=D)9O^jws+ z(;PV}KklPA?;J|sCV6i})Rp3wksBXok;+%M!S)xSn6IMTVk=S@eGZ)8fDA((885&) zj!ShfQhNGKIC%7<|J#O-9*zo&tqthuJH=7vNT5$H9p%k_O}b_B-g@pU(rL6vgCOLS z2boB0X7TfZN|LtQMS|2oSFhU4AWNLb5EsCZ>pA`GpUe;3CT;@Bd+8@Med!3D@3=iu z+d8?_Xk~YBQbY4oc46p^r50%LgG?6O{8Vg}(n4QT$dz^7&An5Z-zr6+yumwQsh`~8 zmQ)8do2?dAA1%YFj8{~@8rrA3rV575zt!-k7+yg+du*5w?X7AaWn@-smEoSZ+S6B| zVKXgHE#-mJIa9-tPSiO@a<%eo5`vKnVnKB<+}%}s?;pcrGGz@_>3lQOjB|NNa)3q& zIiGDG)G-Skae@1MDK*6m?+NF_k~kVF)L1$^^BVmO82SJ0pX25&A`ox^SGZIgRh#Ie zx-W$~PP}QAns~;0JykJo{~M~3mZ3tbD`P3t38N+>>o=z@l(XZ5j(L4opJX;ai&s1E4?VzBX{veXpD z!)k*~M};oaL&K;>NS>sE-YIH7@k*fl4DGJEM}UhPT&g)T#NBP8tYmY0iV?ndH)?HA z??bZh-m=YeZqief)sVOvowa-U_S>7??>&T~4IaWINmN>bD_SwcJzFt8Ev2rm{1?bS~C#9Z59(R{pqkaIb+dezrP|@tF z>f>=&%DSu}CCwLP9_CDQ&)vui_iYFjwh<3zLE@7wQ{O8{C6CkTaN&dy4wdzXKhdP^ zP+h%GtV7I!wZWD+dyr0&Rz+v6L}5-b}Ame!!W#5e%}Mrm+q9fMx{aA~6CZcn9TH0@?u8^t8sF zrHHH(zD`u|3v_PPe z=WD|m)$G{CO~-+xbm~l9LCVOyzC6raM^#>kcC2fq(tc)`oxT!1WXy;UK5FB8e}`r@ zvl28U6Vz^xEa<~w=8w`+GQ3|8dQU3a;HxN{kc(~-1I-Q-oh)ORfCcifNctUA48XGm z@U{Ce3iaMu4YM+!*el3*R()(!5{emLu#UsHPM`@pDLraZA`%kPulJ_|6`ot1K3%F$ zIkd%9T=wPbzJj)k(dq9j^DLJs2QQ>fB)v!d8q_)XazxCJW*B1Yb}6^Lb4UI!=EIQF zwT2hKmf*<*tUK5-wa%N681ZgXw==17oCOZ!Uf8e8%gF><1zo}usNJ+jO0(toAu+A@ z?vjO+YaxzLALjgF9^HF}kpe1Je*#}d2$nyZmU7Yi(aI%b$F5|v=9UQ(Yd`E6AxOat z_r5~q`zX~1+zZ`GLNs;u>*^09m($D0eScQmg87$4-CU=~oOvV-P!{NT7IHINdbyV| z6W1sBl3(+=5iw2!hhx*>nkwLhV56tYad$Ya8OeD?ibozq2w>m5RiWiE8d?8)k^_+N z{6}pMnJPM778Poeubv6#H~+w0U$G3#MDl?i!W}TrleNb6is5|>W>DY#b(nAoDKc9I z@9Wf<4eh>?l|*#PmevQRf1%PIHeE-uSU;NCQicT$eta-ZdeL;nmkRGKn2DkvfGwVky|&&yCGxM20I-UafoubCif!& z4R?M84a$Uk3*Hb;vlm!y1*&acv1Lqa@Z`K}| z(FlJLXK8x^qMu2#t!TC6O zM*^f=jsB|p6q9FW4xrsIJ7G<~UkvJjt(tfvYT_1@*JwM80PK{#esWW#oM!|?RoEvI z`+k%iQU$?}T`fY5Ki4_{fYHDb^Fo%aQ@;(kk9A;A3ah9>mExY&yd|B@D-mwC;q9F; z&md+f`zcF6+7u!*=e;B7?`u%c$>D31k2TF~x5kb^vh2QgNF;HF`O&uG6fA;7ieqIB zLmf6dVEJ;H$pte<(G%b~NABcK1nk$3U^ifOeW$8la)Y^ivIRkKjkX}{11$)OiDk1E zrx0BPCId5CN$1nFgXB#Ln5(Xx1-<>|!^b|ffXNV-x^JjZ=9sqVlNf6U}unS4vm_HgDBay1vIj{mAEma?qg=Qo{1jRpj5#VM7;TCn{wLw8W zsLXoa>`n7#07k(fM%C#b*!0*KHPS=ydz!QI;IIN+b>RDA`8YUda{UM6>>R%C4L+t&> zZ^NKr)?NK`Cu}+(`l?_%%_Jl!TP{ASt@TvF+S!TvYK=|3nYFND z=pdTMqMl{~QuN&)!@+nJEuhtqF81y@&?Wy0Wkw`@SAm|EWNP9))XCGTvJ7t`5KGc) zRMFEdvN^`PfTbwTjPtz?D9rQ!ghgLuxdpZ0JU5x+;2qyyrz{P>to9@xD3_pC>*Q`?{R9jY^|eun~fNEfaghB^!_2kUzxX zrc+f~;dG51U6*MTN#ZTmUb**Eqw0|M?RK0^>R~K`zAm3Y30GP_@h8?;XC{Y}G=H15 z6Pp|PCP_0j~I_wHomerwz#C1!JkSD^T}W(+X%MQtM< zb1D`fS@X^XWc&hGLzaXZ8~T!nHvra!>CcoPqWv@GPp=zp%7glMsdrTb8gx_lp2;g+ zemnDb#*N@PXxRz;1v0RF*a6RMi4BiFpC4(2Jw)6JZq_EE` zSL^WE(9v`0vaDSi10DlI^aIJCwTIl?Nj!@=?*}dYT9PTm__X4KJhX({Ky9$HadJ|$ z{_~(+P8h2Ps+s>@lWNede-OKQ+kq+MJvDgfLn@noGzYp4r}-!PSG;40LDFmB)tnvX zqJ`Q%<-U%#-QYc-+Pe$m@69~k_$bEz?)CMC+(q0(r6uVjFvQ*Zt-c{wHZHODtWi`@ zW2|SmD@N93u8##PbYL!ieeYDv%#B6O0$ zkrA)5Z;h{zEWx7*{(RqgP#_73Jihk(&Wp0W2%0+alq&!<C;aPO`YPYBR~>#hPd5H)MO z9Q6P(*s8ru=DmKeN?R64Rk<9Rd78M~97(sB^Wa<|TC(h};>4D^If^9r zffscVn3lKjE6Tpguy?I?=Lbw`K)o<(t0<*+ct)R@7J`JjDkBy$z=83EJtnuUq0$Vr z5Bp`MwhkJ6D9eHKbsxOK-@l74IH!Rw<<4^NSL<6RR#9B&x;zh#|HY8Ksr*dy*4gJ~ z{V_!b^&;Qnr-U_6TE22aU&zjk{k?f9tBg=Oim#%ju`{0R6Y^R1fi5rM)X?ZiCu0>H zu84j}Y3c733}%OUzz^fEYpwInOCQ%x#D)w>n5FQeD6EHXAaxdgJl{3nKWNbTc~+Vj zJM#@>h+Pi)HIn{X8;te#PVKPvmjrh}W8=}RtgPnQkXsnq&e)nDaE)~DI3#IRGfSZh z7Mek}RKU*gXn&dwMVt;-DmRyRl>#kl{v@LVKR?YjzEpt@9e=U0yGS~kp%2Zlmd^n& zA{h2%MkeZArUo03hpXAxdeL_v;*{}|VXeUE0pQ@nj=ObY(cwVw-xrl3M6fEO=~I;R zbcv>=32rv)5A4}C(1%${r@isuVc}j=$r1}rvZz2L_>LL8!9iG*XUCr%c@P`Og`|N^ zbg7q+YtF0CNtn0?CHX-!+EJ%EleNJ`$cu!scR{%GArV$xv(gFay=tJ65h2SjiR8Is z3EFir$J}L?eZqz5gDY`JO~elD74QgfdsK3wchF$-AyUhyD;X-cXr^FHY#xC2b3WMb z;}iXZ>bTTr!&;~B_m={oPktt`VA~kc9~VtD5pAqGXQdx%v40t90jM%>d<44*5Zl^M z80xw24fk^rZn*`u|H9h8i>Q*CAvZ}rV*#~LeVFWhZp6dmpB$L^Y3cH5fGl`$VY{i| zN_|R~IHjqME8DD-%-;o`YPM7l9!I?Rf=TW2RsL)3TFg=K<12gg0)Jy-h9I+(T{oyq zM%BAZ2jdFBTdl-G@FLEM(Rzly>!Ra8i~70ntcFuC&+?3;Y*JJ}%ziM{)F~c0+X^Zb zq^8n-fU2vSVrrLcOm!VD&o<(Wglc7P0u9TZ(d=6-gE+FRRDlW9sv8ugG-5Rl`50ht$3y4uuMp2yi`P~mJA##x#sJOpe#_f4SNWE z7CTZ!!Fyl8P~0}cF(86D50QKJRkoC%pMHCvBZmTQH^n3DMW|Jb3E8X66#QKjSQrjk z{Qz-%^Kp)*lxrVovWH=LevPI#s25A<`#QB#;(b9-l*3cIeXX%X8{-V5mW69Sh)7}P zJmW5%0Xj>x$zJNF84%0_2<8_fZEHYkf!YiXny1X$`?a=-Na|XJ%H(tbo$vpCd>!0Z;i%4LzI|1`N-|?oNGgPY21I-7517{#Xf8ja8hI2{ZD#dfXS`0Ly^*omnZm*EM z?4#fQF&gBez;d3a8g|K0JuYYXw{QTp3?Sd=48Q~@9OGqUi$>Y9^g6Xw%}%R z!!+pxI_2d7S!_GS)@yb{|K$7@7KGTQ1bGShO|6fhN{XLBzB)J@u++y8hj1!p>1IGY zSAPydT{+O74S>OhZ~9vSwx#Kd0c#1V$MoN!=hyd>z4Q?M?;+uA`}Abf00ShnV{(^v`gh<^~UcRcl( zHPlvUv5t6_HSii@*LL)wJ28?ld`I`d(q|wQ=;Y@^&vonu*J$y zkBzI~2zpB*dVSkWfTLArotWWW<7||f{Mv7|0fk~GN-L(EZfc!^3#MiP0^c2iNMU9@N?N!2E%ZDUrm}WQk`>!~&?Y z#D6tMY4}+P z|4p(Egny$tg39m1!YjItX-2TeiBZ1U3Wc<%v!8ePYj4kgUagn=2(acV`hBP+=CoO{9B{*217#19 zeVqH#(2RecICiCY&J_0s(4p(tp)PZe zNOTC4@OT@a;z1#_`qB*-?~~B-4-cin7cY zocF{Co7YTA`Y>kj{Qqux3f^QHM@Q`z+)^gYhKW!Dcwp3&jg!PF>NCLcv8&9r6Fmv#2agI^<0oqu~|1Q!-KXu zpF)^0kJ)we@u1l^A`<>Qr`r@=5EZs=f=kS(w2TV+WpHrQHoZ8&=RQpM7!RRPE90j* znmrZlPprF+M>!dsl4=7HYpz@6VP{p5Bspf+ObQ6$1s*`BUg79+^Dg@C%HM}Sy*P$W zk2=4u{h?Fj8kR`(%XW%))o&BwlRnOifv0Eyfk9nFzQl#$Qg`VF-0sR#vJ`E}ekr&LI*okrPN`af1YKxjb5q z8uA){KZ{X>lwd9wl&E+M^A~wpfaiG}7?Qr1(<%T7;YS5BJgBV4i)zyYJ2!X%3*vfT z=nRB*UW566WPzy2%G!|PtVPq!1qtclz7^{NgPG2~l|?sQ0)r`5$X09&V;prLdyG|( z(4OEf41VzzP$R}nS6@qfDog_3BkGtL_mWr|I>2AOypj--=!pN0H~kJE2F3FFhLo$@ z7vyqxNg$n4hjv0Y-tKB9#*pqViaADm6H$dAVcK*IV1&GhdM&|mbWf)X!UdGa+lmOeNt05%5QKWc6GB?PBr>{vSo%9@jOi#~N= z%eQHh6yk>F_1nRCfLQtrOhzO3)vGMYC`Z;}RPkcGqrDVYjpEaC!34XX_O!R97QCZ5j4mA%1Y1(_~ zbeCF;7R$hA8AIZ5a*?tS2oXICl$b4TPX}Ltc6qza9p{OQs1uoK$WHyT?$s_9gT~H! zXf8VoZO>c&yz9|k(VY|k|j|BHI{_^oCxnrQxlj4wKFJmp=VX90>ajj zG-29Htq2)H$I+)7_w9=+Td!g+P<_iKdce#m`(!qS1Rv=&o&H&bB|M02y2M?W?G`T; z(BEnbi5Un(l|smW;R+vB?vP#AClHn8E{ChtzHd8X}a#puZ z|1}LOHQaR(pU?Yyr83Up^@Q0=ckNV`!BR@uJ3-&YEx}zwBJKXS{kkX;vm90$KXyS= zunM|GZe^Qr=+yn~grojMw$!V%8*LNx zxt9%no{e%&eVlK38)B87qVZ$f!zk+S6MkmK%?-~8#3Z~pI13#b;UV+Q?=V!%e?CFP zdFpXtcvKv98T-*Ys0^OjskZRuHB6omCb4N1Dya#o+D=((+cCp_U%I;57tmE-r627! zXMdHpEwZlGaN@E?hm3O>IDiBMNy%$8P4({7CO*owmupOD{sxOT2dXC(9TgnBtK}7o zh%H0S8ISBu;ZFL`9S8J6I{7xU0K|%oZO^#zV~W^98A(Pk`%Ex2z_zBo4l}K+_VMO6 zd}Sb<^7rHXYgs6?Bf^h6DxjN=^AYX$Wm5{vEuntx-#R(D_Sd+$gOJk-J1{hf603F6wAw6+WY(s^6#V5|_K+77t3? z2ijS$40gHA>;gtYWbQ+u`e^Xc0j`IDz{z#GEbF>S0nY#1dy*Cz3^^5*<0D&5-#BI= zm)ZJKzgk}Vj+Mj~nuP@#PPMt{krg}g|=w=!9bX18dfQgxkc8Y^l;)QM@6DV*% zCOCGBKXeZ5D5JlU71RO-Bhp(OjBnl0C0!;E+h$|mc z@nV1kXX`9yb8csfn)b>Y#@9|pxbu^Jh0qwa37O8sMG7@?cV0AKL$~jR>-q7N zZD5wMy=fj<3INSKhgg2EmRoWD^ber6Km9uYkw7aKu@qBH++dl}OZzJs5%LUwJVN`T zw!zmB4MLb=`?s4S(Il%Hio1(zdBjv}_vA~=BvJd97vk4Gf!8gs;9Xmo43wNdrep$o z!L-9xzI^Oz;W{TED~T4MHE$rExK~dHumfk$wB>>alze=xmafT3rB4XThY{!R*z!iB zm$_!{&-P&i25$93LfiiSK3&=UdJzgFA%qBTK>$(phd`S2L z?k{JO;o_k~v-R>YzXm1}kb&ShM)wMjh0UbNqb{We)EL?z4uie8wRgC| zd2aFMHZ7uxkK+~%!~|9UBk^)P)SO0rHI0s2!ZMOvmhF@pzw3xz+yny2B#!5WV{`35 zJ;3#~yUoD6$pVeXCueeR8}mUr#LcxzLU6WeJ6XeW=e9XEsKUxXX8#lcAen*b zND8y^#WM?11~otD9`W&e+$p~m`#K1kldoOErXHRq_;u|5pHxp$p@F+TrCOzyZ??&g zx6M4GPO?+TQFVHDQmf4Zio+fvOBKDL4frpkggn@|wXN3NX=b9M-!Jay%ut8u7+ps9 z`4SS8B2s1jysebTQdbmZ+`G7wJ+J9grXTCGWy#IeRuz)qr@XaG_@Bpe$aK}~h-~n> zd{ZAOTXV}Ov2v_;YPZxDoy(#qUm;mWIcl)CmwmW(4#V3jWwj-x`P*yUaTz|o{!GZR zNdEZ^wuxbvrm5P(Ht{J&Z)OR@eo{7OkKv zk;LJ>Q7if55ktO;9vK-qVq}Zd`#~rfB#ghmGb;+{I%2O+@mb5cIyn8lxyy_ClHjZV zap0N|%9w&e<-?w>hw%PSa`f%#$&#W%^3;cf)krK$j#|{B@d;K`bDED-r%uH8mCqke z9?G`90seYjTr`l5J&ur^)Q0?%^$-91l~Gn)`dc;@x3)SMR~DLW@F_m-|9wWCzBqZC zrs)NB;jXT?$o{Ch&JjaGj)8Yu^;2M@=l`aAbP@IQ_u{z{uO^EIos~Wrk241n%bnC_ z^SG}>8_9^i;YN)nwC>mxn{FfqifPH#QG`w`K(Bnas z@cyWsrhQZykgdmza2$J_20aNRF>K=7pp=<^s&ukb#?9uJ&$+2{CF)yKgr0eK{}Pfz zDEdjK|Mz@U@s{e&_U8V~4$iEyl{CbG?Fak*AA-7LuLcp#*JIg!^1bdvQ;txWVpa)l zoBX$HcI+{^Bnt(chiw2C@PY@g5s$exH~DJGv&5vy!Y{^W<$)yka;FW%d?+dV)gpGI ztRS%c$QfHblFDsY6kvnZ>g|<5=cOjPFEf`Sj~ok%u|6oePoh?ho$j6A?!2{Rtx@ls zbyOZ}A=hn``}YvczZ9#mVS}zssY+d5Xr)E+ziABFr|=*h^9!PhtbyWcI{R+vJb}*MNaEZ|7+J! zKaCc(N?e;PL|9d^TC+JjR%0!deKOKW>mhQ0a-gd57Emm3Cvk%%Or$hw;@12-gI&eD z$clK5SCq*OWxTy`hinJfq#Gm)Pk!p!eXL{nQ3i$Z7>P+{lG3zOBu<6k{DXt9i_QL4 z%C*X2N0m3>eE6Q&+S51N$_ zz5xnkRNq84ZZ6A-?uf9w={UA$CcX@WgutiI52Zk z-6Pm*k6G9C+vvrqQje%hcp{5OKjP42|=^0K_D5t&&TH(LgRnD8n1jnXBCBzEIFi}tae0g6v zO^gGPlq+hr6@+K#2#|%WU9)KH^bOc2RvoDm+5K_Y=zpsJ{{Mccr~Z9Pb!3yK>06gK z@h}6j$;SmByAOImVuyb%HC&wisNE&sF~33*OI=iV6O_w;DgW`zRc~$68GCc*>Ci9d z?H63sO; zsvItP8xFX#-j;T4Xs;!*@#anQ@%Fbr4hPu1!}%vUu}&Z5XD`XbcrN-zOB#E?B>+|B zP#2*m4OVkd=i3h>eGJ9IlLy1HPDYARjwE~O$Clm0uRXX@)~O>Ly!+aF<>-|c31lc1 zypYwG7J!xVbuzXn5^)~nN%o_;G6H&$f^~Vm{$Q~S$ABo;RZpK1RD~%`qUA8`PzMmrr4f{Oc zxc~dyU4x6OgWKsVi$fzO`uT~6C;Cl6#}fQ|N#@MAeZ~dQ7v8GdyCo#He=4&Du;(X_ zng93&FkcJbxOS3P2?C-U(#Y{o8b&0e|Wsg3{oFrIq zJ{C?}XtvT`VgEs{8y@df>@Rb?;Jz?WaMf(Pd&Im-ZFro&%si_z@bWR(rm=$=&pz31 zseyii0)jEf2om&jyXxD*lVr6h2u!{}LE$knFWvt#Z5Fmy{s019&o?sO{Nf{*TKYXO<~n)wNC94zRN_ zF~~3B&>kfL?|W5%FK_Z_&+05gF@bS9Kt_uVmJ$u{yhO-X>v)dipHrCy z2Nf{@%1?k4x0Ods-eN5F?BO}GMiXol%t5bz0*(OosvATZVoVkqzpoX2HgfI1;M$|h zUSk6I+7Ho)`kDi?#crl@;0)x}Nr^01asDnqqnqgbQC>qlR~a=3pP&OVuQg-C>quRG zlyM_(3iGSpqU#Hhb7Z>Ey|qDKLmKB1!n#TE<|s-AkPR4J}##j9?s~FV18{ zgRuRscDjSUiEv8>hLOScnl~<9-i8!*Cdv?`WUvZDNJS-tWPFlgLCZ7ZgErt@N-Er{R*9`nLk87)UdSM2#tlGQn5z5++EfbvpuPB$No<1z-=b%Hst?U$Wx{(Y#$ z-=gt(uT3xGKPl@c;T9w41_v3y!Y-_)!q*s@{6_^*nS}Oz6#hqAI^lK@R!(N{18?iy zDS|{2gr#Xmm&IG#T0xYiK{&~cg#&xXcv^uT7kdv0lnE!C`$nmVE((IcyxVzh%^xur zev3spms0N;830VSy)}WurGVZsS(Gsi7u@Vkev;p9p*R^ZDc-IQ@%n8vzN#;5eZjs{ z&Aqy#*-&`9*EgD=x(g)OC6ACttdD~3AH&(;U;t5+ll{Qh#tE4OL1WiR?bQW%S6521 zi&a|id6C!NpTXZnUSopyEf22~AR-oFAIeusJ>X%ZCje*@EZlAuTyjPp+UQ2!LV*!; zu5LE`N|HlM%U!FZZ?%EhBtcuQ7Q;nE{uDySHy9`4nEMIxhChXlWQ|w!P+0pf9f*-# z&*i7vhkWgHGtNKP!+T@WG{DzTqf17LpgSYzvpgfcJsTSu@Gjh3J4Ub6WDyp2QTW%d zjX{H*35~iZk%`oDgl*k5@HGhpA>?4qPV^vvFmE4i-Ou%M7AEDYlLQWJi{rk^fdvUj zKP3KHf(R{%%>Dnwe)N0pnh+ug;>mn3WBfgrM7mb;=BUdt>vxw~%QdSGA5&^D-&6B? zp_j0Xyh6;okXoq-nX8r#Nan!B@vp{tuM{gI&fNzMrHA>2j5b!{qM$LZXY*4zKtQ`i zdlk^gFo2~`FbB0L%P#lPa_qHisLZ0etEfz>l`E81Tym2V5;Q$9EWYanhP13>|NKo5 zsRHB1=V%CADdZbXun$SsSdoZ6-!ICPU)(|Z4+ohNnE#MPWgB4NyCv4AK&Su<#>g;< zxxZ-fV~HwSo}I2ZQ+q4?ei^l;kfcVthI=51ndiW3a$SOQAaAV0e^X%JoMfY*j&bw- ztkej|!lb(cx^eKJ!oz`*e{ec0Tg?jskO&nIu8O}qw6QO<)gS56SkT_LAQ6Q?Ct*M9 zVnxYgAmxA;WgNMpR=v`UdqLEw2@|J1(T`8-KN}d&|DLgJ4?|Aw_!#tU$hH-UR1f=V z3grE(Wq((znX@tx1i}W`xvHY&&+thhE(t_v9^O!S{kfF)$?-20mT(ajd!sBf-52W6fA8Q%ih z&@6ofy=2|(cw>Pu-`6)thG@lbM|mXEL%4vd_G%(H9&$WDdO=~Rav3#)>3p>Y`eud@ z>CyNpSSl?PLkOm1B!H@)J634GmiVjMGexjAAx><1TK}Cz8uI=8VySDreH#8-d3w$K zaIAJUfRc$M7lXdW6PXu4B^%Cz4sB&zn#Pd&{F=ZJMmVZ_TtpG>$qlYF?CCvT@jGjrTpuOm9+J_5l{li11RA5hwg7|$-)wYr1k93!bmy0J5(cTp? zzTpKZEvPIZtTt_j#sYN^pneCmK8<1z*J7qN>V|3p>eaUZCxNV1`d>MCl=p*i#8O?okIk<2D+S>K^D!%^HOh^W1 zTjA9K8Ls)RI3oh>t^L2m&nhLQZ_#bfmi18(Kw;%`F%&y0^TvbVZ7Z&F10O?N#zg-` zEB7$|@Bxz&Qvix6FmlM&fLGc95P91{g#`TS4(k$N|Nj-f=4T1 zC7LixdZ-yh#+EWvM-2E7De?o2Q3-bn!)#{tUGz=8?~bdl;gza)`cR2u_kgR#5LWZl zyNU;8Qg45|x{&aUKcvfV)Ex4oGVivwd~>x$E2!0RiM4B3$7us)J&QM2d#+$E^!<5_ z^S|_&`*$@M9go0=Sl-G_Oik;*lG5@8Luz+VC+v>7xM(-Nj41QF%mo`OE~&2%0j(+E z60$8K#RS%bu>40!OzM9X-Wl%aD_2D|jzF(+fRaaNinGbDMNUU+Xg~J8j51a^Vj0vf ztkE>!L^Pl*tOw=5=3RYxdY{~T=GNQq;?0Gyna&z;r4WvNj0|(Q_J>;iFAJAB7nx#G z%$8(8o+XrB&q1BLotqO{ZU%R6GA6B`(={UgM?$|^Ll1>NjhB&aYMLO45%*vzb>Ko^ z_L3*{B?8EKJdfuQZ@z#bk63Tn!{Gud-kVJ)>hjjSarA|vA-nq-j2R$qFGyy*09SI3 z8w?j(piZ^{lRnY3I#VqNob%?W=+LlE<+(M?e74^S(s2?r-GzxS@CbfzAn#I!Y+r|r z1k!$a2J)esw^Lem%BaUOSI!23e(e_k=6yPTtML7LpXwWeY|iG7grvf8N`r&jlcHUE)h8& zV90KexJ7hh)@O8NqW&Z;X05K^0t5H-I;yt`ALZLK{{{FH+O|)BMqGydKix%62o_EV zE19lynZZG=Mth`nyr-r&cTHBw!7ccvN?z;gw{AFsQGYu%=s@}ix}msX?`9Q$Z;!oo z{=~Eul>aL@4-w7i9bsf?2d9-Wr2S%miEII%-VmZ~i_a^iR{FV?y;L|a%R^`_))4Z{ zceM_bgqOuZHdIPl2UZ(-kI3%MFAgCvVCB7)Z$4z~TrYUp*AN=)U_Go72U*i@9C`|^KQl~1(}&oHrDoNVr%=QPxK$uB zFVC1g>U7O8Na&Hdj$mJ(+3WZV+P&6dW_vRe7i^ff)N-oC46Qhm_NcH6ZjkppEv%+h zxKNKB?<#4O$*)fKXy?J1;(E=cqkA$? z0N3vWc9DQ`3yR@DWnymhBL7ELexrzF<^gp8WPO~UVDRmn5s&1Z)MvvV&NMrcH{6*& zSR2Fz+(n7}u4WvzfO7sIhcrIZSX_VvABrH87SNUVZdGw>z*dHYtiIkGuU0}URSun6 zoN|obeG<$PiZ|zyLk+#0&K~8fF~x?grf>aCpq@?sEHc|(NR5jBeK`*D&E zg5(=16|&oJ6VM?%i(WRY*pdg8A!;+Rkk@>Bt^PhsFKiT~;X5M!ssv}eb}pm7o|!Z8 zVpcui>-KYqL4M{$D1x(N)mi1JftC;BWikp~hXxn=w0j9flc6FrRZvm0X6_AOG-H|k z=Ncjs%yqike(k_z8k8FCGLc}(T5GrXS?R~JZQ}`IFueBqXiJG6S;k7_vgnO;_X60m zu{3XCn7DEW|Kryg2zq}~z_yt>;z5F5})rb+OKbrsS*m+zdxkTS<^pU=0H&2I28d-TNNjZPo4`yc zlutd9pa@x7N*Le71h`J^ZGDFFkUm)GyWgvm$7794$fiU6=}B3h23Y9(!I;9DhX$AS z7Ya%Nm zy#3}6CHK%RXQk-yFkbPbl6uuj;;6{}!tz}%1!|dppo)a-ACQUMnF$S<(`IH-JMwj< z^|^(KX~N>JrW0l9Od~B`W+AwE8n3G|fC$#}fHI7=?F7_2uYHNx?0(PBUDD0b_eU3H#sh2avQKZUrQrhj+db8O#k+3e-d46kkrLZ<3<5HlDSj|VvUhLS z?f?`uWq3r|Opv3)p}dcqfg~zNBR_WW?q-IgWo?I#4)dSjD3pIyUSFuQ*?U+B(>KSEx7n z(2st*m38`+3hTgLt5uf)?9si=?~W$O2R_VMdBrWcTv|>!-Whnk-6P|P8VV`#K+Dq= z`x6R7=?vt66ZX2?@1qr2>8VrN=2ko0-=x!8HSap!{k`VEyZF4s#Fl2fwW36SYihrr z&+#7mgs3(%t8!}=pU0B1LNopBUw~FrPBOfsT7Aa;YxUzQ!TkfiV#ST4h9S=!St}5% zP!KE($@DCr$Buo@K1UP%^Ez#x9OXSq5U+ zaDr`0{rRmpvHi=Y&f1Fox*@~ErhvBQkFHe~9GNC8`?@CR&mKaa2TNuT^5_Zpa3 zdkYuLppRN)#WCf9L*&x*+-G+C5Y+VWXC9WT+Cgi4(7kjI5Vu-Gq0{LyX%EF%?(E%p>9e>GUn**Is7)(_9S+A4_+!CE_1m{) zqT?}FCWDeebpGLn?0wb_}P`VviKuw#ikFVst&F zK0C88%3fod7|GKH;Fa1Q`RPnWs#P1NB|6@+`v*M_MuK}v8NpQ{gYSWDd|FdEG~U>3 z_#r;ZR-%$0!~BKmu=D_`-jj3cIqO-v%yq~H3jlXFpb#bYxG~f{fzU=~%eL8%)Kv)$ zRc|}zZ!S2`2CW9+h^^y-;MzE89bbK0S}f9T!uuNX^#^lhmkI7wm4Vmdh2s{i^;4xe zEe{Q}SD@AyNic0RRJMYy=)cIyUkM=t=OLlZ2uBNcUmh9#4Eg{s?yg@FLd(hC|%Ed1gH=Vw0;qM@Zzt+(cIfzkqgn< zGhecK5+tDc2aF9+H&S%-Q0lM&HPpm%Nem9q>T;ou((Q>R@?TLl)#&jx3>fPra0pZQ2dKnFFd>*!YXw723AD81h#ui%?X4;#Z#8 z($Y-lP6kxwm^V`hyx3gjm{hG<3y|Q2>0OZ|l0qt??(~56^BKVnAhjQVJ3fUrbHXb4 zj-s1Bm29|`MM8K}QI}OIwP&6_Q$n`E4{2RzKv{eSbu&WW*?(l=zvKLK;PZO^SEAZ( zOkrfnN{iyl%BU~i|4-FOsSfZ-J{xi{S@ZtEGfFS zTmx_0gF9QhDevrA8Lh+>n#~Xl-hNjPqTx3fVXEp+6byOGa1cxK5| z`m)5%X{!YZ&5t2IGarFTF)CIFJNC+jte%{*kg6b(k z&lRg#@JRY?Ty1?G9vbLha0q(|on5~c)@E@5QnTGVdegHFx{Q04G1$D67I5IHrpN;X z=>Tt^5$H&CvR(8GJ5%STjbP7t=GkjDK<}4>>HHN8AM<(;L}hN4cJHo8IzrBhai)P` zHE*$SM;;b40%aqK$V$@P+OEO$B6nKqTNthA>0ICK)q6hFr@`ASHh4z%-(O7z;8`}z zotfU|Jk|#p35O>{Z%kZA_dYre))-lf+p43{0+AIV*{wJrqcadzhZ{Y6MB~OG9v5yxR&b9>UHU$FihHh>N->O z=Rb`16W#@Gx6Ug~1}kX2QqM|od!eEqUQCE3WmDu6rSNHTf3U`2b@xY2&Of%9Z~0au zg9GLu7lh-F=p@^v6ewM2lNm9q?B!y?b z$mTdL*?9fC*NpR9!g!UB)v-f$^i|n`_YYbsJd}f*4X@B@P#!kX4GV=XNe0IB7yUUPBzvRep6U~n z#2s2@RVUK>60gO|r?caBcllECa76gvDjn$595eM93ccdcgXjP)(;CM+MPIJ3NcF(@}y?}qIa&?v2IF04WxGCPCSe(3A zK`T|7^@&ri+e;NPpDxAtS>{VZ_jvVxrfFVj*(KLZE|rtKMRjc+~{^EP7~! z<&Q@2)fDDH#}J#F%vcKMo^6Z!OfQpb8Il@u1uzWwlT;yZ3kstQ!}F@fy=ZajM7+^) zR&Fo9{$2*M*~=De^=jEUa&E0vxIJ&iEN>ON-OS$1O#5Nd<`GU zgz{)c-B6f*a^{Pxzpsxw+aojQKE;cQ99NGzQ$VtXN`gh~3fuyM&)@t>98pBkJ15*; z^DIpJmzLO!g~nRs&iAm2A!LA#QWZDy_l*YFePag(}Lk-m@Yyp+kH(Sjz4Lt>@gZK|HtCZw2=c zUAf84Hr-J%_>#4jN8z+BkKe5?j5T2#v399uMPAAr;cX9bX#_u5-w3MkuuVN-*|Z=1 znym6YSf$oI@st(kYA0i1%6(Nz`tA%H|Uy=2esbZsgu{`5nIgwgEd)wG%PLs%HC~bC3kw#}lSk!v=mh^77dx_j zd(4dZ{!UIHgod%#9fm2SbO;c}Qo(Abs#M75=KiFDlK4Ejz)TP~^;L5aWr_%qIb}uY z;7)7JO%5Y5$M}fV$g|NmccSBSMEuuzo>D)=QW3b!hds@9Q@{n7PLZ;N4DFmQEK38v zOq;%RBOp*-oz(n>I)N<9XVkOzryBlrw3J@gZ?P|^n+m6=WXp#S<h(g_?)qhXG>_P)srhYIMxD7D9Ipl{oZP1KY^OUmYLo{Hy7-XM~o6`AA39u zRd1OcmWnh_;7lF0(~z9~+;0;PL>f_;zv)R9K zBBqh^;S8jyCk3HX;kV_Y%}Pm9EAM$5(X$NzZK)A@TTk=*_!>RyiQI)1(xa* z^~V4=_m^u}=KZ8*5rjimZV~ZH1PXk$OSd`s#Q!o|RjI+oFDl>=QSxmYy=mr4Y^TJM z=EQnYY}`MZuHplvhkmX9(R%+YE&2`(zXj*Kht)5(v~-$?cYBJ>$}aqueiU#}zPvjn zQ4}-+jG|~PT^93<{ylLiCahAT=gmfpy29GL;@G3|$g5nudVQN%axW6c3uRv|u=F0Z z3;}&~H?QF%^*2j0YN-{PW8alwYfNOB6IqVvJ3Xb!iFMhW&Tg@#js7&aRGQEzTTo-S z-3v?Y%uLuUWyaEXd<|Ux==N3SqmYC*{Uc8+rbq8t42>C<>rfQN;nK^%wiOz8l3#B` z-PW~t^scOR70Pcu^>sh+3v081rIT2gS%(bM$1IHApxpN|r{SZCK1fzO2_WOt2 zM(^O`uK|Blj=Hum1zbA=agyYXm4Ad3CwSU=&-V+ zbAf@ebs}4|k!SjfgU7k&4V=#E*t}Dwx>QZX>)*Ly;YdgA88~_VDC}iS*2B+iQuM7Q z&pt?Xy<@7u&ns*OzX(F0b{fI6dpyjWnwjp?FVmSS4`#r)!{ID*nTTP{-5rYqXKuSW=Jj_%rHm z&uFL!c=>q4{y6W~-|7#LejrO${ESyjJ3S_Ds<_F|gDP68RCS7jc$EvdDXI3ko%48E zJ()p=3(V1{ZSPAYIKK4r(2NTye{W|fJ#x#4p2G=izso8#j zv#roBPAb|sHc2J-TGvMi#DRm=mf0$3d^a~8#SwX=c1Ld>Icza4Z#Q}-J?XTbZW$~n zZj8{6`($+-UW(}33GBEXvWZd3x*D;|USO?A7JnhKCIzRB+#hivsq<-PFL+X2W!t5; zl}l_-VOnNp<2Ir7t0b&;(d4)RHk;29bMzzJ=F=0Eo;ttoy7JEmUt~XMr|h2M@-=JxLUT;N-4(LM@%TXhrnG<#Z926p8J%K@-DVUA!gLrzB~fC zd@g1TjMI23XLEL)o=CbajgLHWRh^x|L-})(l6cn|NcH;?oM8N!I-WOKiEjSE9Pf?$ z98VZ|TB*c5>X01Pp5ciOC=Xvj$G7VE+OnsqtQWrW)|2BJN7P&NM!lQdJ+MaEPFbE= z7A-W%a|_}~n-VRdtcAOqK-eQRP^=rp{vC6(*SyZ{<*zQM>!YV(?IG`@+QC#IhMmk7 zd`>>(p?gj{PjZsK{1cX*O^bl_TciDuB*$H2k)6RJ9%a>yf~*&O$CQ@xg>Tva``#?P7!irC;QQ12%4n3MI!keb`YZAEQCnob@{u~CH#ZO#iFTC+ zJ#ON{GROR+af~!Dwy70S;F$EyA>kUkf+xeI_%z=?i9aow?V9>n8}3|=X{@p=JDAA9 z&A~mD8mVpPK$&~gl+3Yg5ji2q>qKH1k7#Aw~34g1IL2soYBAd2sUhF1+ z9$}7<8UCBUU&&yN{JCD0WL=~2m9$H2yX7HA2fZwn%-Bm#7sX!jbgUJ=B6@U_F=Yyt zLOOLyIt9_ILKeT#;t_Ok36PpgN*9#%AWGYNtcf7Vm!5;2CQFX!owd+A@un_jgr96xv50Fv|H5mv+@Cuvwfa$bVr^&mW_C;;m#? zGDdFDLyEMM^IME0_QDC=ud6k8x0laHAC#Svnwj{ihU~0XMN?sx5W7oD1%Y5J|8OU# zyH&7?jP2aR7)T^?rrm3I*s}CJT zF%zsp_M=-0e^TI>0L+P+z~lp>34iHpw||n^SiMDrIx1e+-*b4_u%rjxcfp;}kn8JS z-QZWpnZb!rrKm_M)870P2U~W|tzvBNZi1KVf%u1I%*kJ12(9 zS;SfpkufHX`Zn+cGl^RoI&hS@_ON0?ndE_|8%Lc`sxdfg3LKcx{U3`HKasQPXqL1U z|0#!FAbMqjP3Rn*noi^=Kg<*Sc`C_ldMTANd3Kf7F0Yeu&J62;Zi?sh?z@_Z+$B-|qW_Z)r9neq15Q+$S*@ouVGy6Uoqa-s0?0PGVs}CS^)b&A}QaiYJVC!C(R2abPjUMZuJ!q;`pkhnf^E zN-qO{^xDO3@~c|YOMM}#z@X$6w1UVY2e$8IFB>!Ot{hz`l=Z zp{@P$&-fzjc-2^w`0ZadoET#Kc~z!(jfOwMlmpD`_Npom$!1yfra^0cXl)IoroW|! zk90?jdA80{`mRyU&@Go)fqSbfR0QZXdi)>Of~8C6ZrJP!&D*8-hQ~cGd8dCaeU8)@ zZxCZ#N~h$PvV@G(@gP8Cp^dpic2aeZ{Csq?C2BX|PSdCIcRcf26E$2f?X1q@|76)A zOqb3U399Ngzb-qbg{M=c*t+INmM{=?9ebISEyZM`QW9ZUr!DPvz_xodb)8yhJaXp} z@P?IylaD}NB~{%3pMJh9`i@6Xl|;DZCgdsQ@-LCRJg`2ug5xbn6DGPrRy-C zP*ZKbOZ+(Bm^91@8~-TBEhhWKc#Dn6KeW!`ZC;fMLr>CYCH0nJ&1+}ITfR<_?>Q=# zYIw%Dlqql|VZ77lerpB}yJWMI*LlVCGbo9~+}Sj%Qej#)mS)t=Sob}}^YF{9fpW5% zof6)_fGquH1JO16ki^GQEsXI~FlecO-i@L^blcAM!q`{xMENiDI-lZCT4~o2DNr;E z)Ad-}lU|byt0l(y7<#;uj^2!3!;0*4xb_0XWkSI?-xAIcD_ydn3Q@a)KN^WOD-(9 zJ?rIwzMR9O>>oD4yJTjGJ)o(nI*K%t%c@c%Nqr~^ke6{yCZca8dmd}}gnEo9++vtq z_8nxn5TY_h|`X_#(f8WGzxxA5R#4(2lj3peMptJ(ihw60!a9W%wB zrHX!YwsSBUT|Se2QunC^vq++%kzUQd(KYb8&tK>q@K+L@mRQsbI*pYUis|U%H768F zY9Z5`Nn>YY`*3k6hdRyrjwC+j=4eZ$ckle|;$)wugtM-_#>z2Yi z^WVE=&ZHv-$%%j`h(q9vWqmEz7s>dr{duamIU5_~Wp@N&n=o$h;0;WY%I4iPvea*iZRphgBTL22a?4qdaVs7GMaoxd#{{d^`RM-Fj diff --git a/ui-ngx/src/assets/white_label_logo.svg b/ui-ngx/src/assets/white_label_logo.svg deleted file mode 100644 index 706ca443f0..0000000000 --- a/ui-ngx/src/assets/white_label_logo.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file From bca450dbe1012f5c61b94815547fbb348ff2e4ac Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Fri, 26 Mar 2021 18:09:55 +0200 Subject: [PATCH 03/69] UI: Fixed error access is forbidden in the system admin change profile --- .../modules/home/pages/profile/profile.component.html | 4 ++-- .../app/modules/home/pages/profile/profile.component.ts | 9 ++++++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/ui-ngx/src/app/modules/home/pages/profile/profile.component.html b/ui-ngx/src/app/modules/home/pages/profile/profile.component.html index eaca7133a0..f1a42344fb 100644 --- a/ui-ngx/src/app/modules/home/pages/profile/profile.component.html +++ b/ui-ngx/src/app/modules/home/pages/profile/profile.component.html @@ -57,13 +57,13 @@ language.language - + {{ lang ? ('language.locales.' + lang | translate) : ''}} -
+
, private route: ActivatedRoute, @@ -55,6 +57,7 @@ export class ProfileComponent extends PageComponent implements OnInit, HasConfir public dialogService: DialogService, public fb: FormBuilder) { super(store); + this.authUser = getCurrentAuthUser(this.store); } ngOnInit() { @@ -134,4 +137,8 @@ export class ProfileComponent extends PageComponent implements OnInit, HasConfir return this.profile; } + isSysAdmin(): boolean { + return this.authUser.authority === Authority.SYS_ADMIN; + } + } From 903a8fdc27644445f11b180b8d3969bf79a8b8b0 Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Mon, 29 Mar 2021 15:01:10 +0300 Subject: [PATCH 04/69] UI: Fixed alarm rules validation in change keyType or valueType --- .../boolean-filter-predicate.component.ts | 36 ++++++-- .../complex-filter-predicate.component.ts | 8 +- .../filter/filter-predicate-list.component.ts | 18 +++- .../filter-predicate-value.component.ts | 84 +++++++++++++++---- .../filter/filter-predicate.component.ts | 29 +++++-- .../filter/key-filter-list.component.ts | 16 +++- .../numeric-filter-predicate.component.ts | 33 ++++++-- .../string-filter-predicate.component.ts | 31 +++++-- 8 files changed, 199 insertions(+), 56 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/filter/boolean-filter-predicate.component.ts b/ui-ngx/src/app/modules/home/components/filter/boolean-filter-predicate.component.ts index c6c8a5ed51..d5299a52ea 100644 --- a/ui-ngx/src/app/modules/home/components/filter/boolean-filter-predicate.component.ts +++ b/ui-ngx/src/app/modules/home/components/filter/boolean-filter-predicate.component.ts @@ -15,11 +15,21 @@ /// import { Component, forwardRef, Input, OnInit } from '@angular/core'; -import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; +import { + ControlValueAccessor, + FormBuilder, + FormGroup, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, + Validators +} from '@angular/forms'; import { BooleanFilterPredicate, BooleanOperation, - booleanOperationTranslationMap, EntityKeyValueType, + booleanOperationTranslationMap, + EntityKeyValueType, FilterPredicateType } from '@shared/models/query/query.models'; @@ -32,10 +42,15 @@ import { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => BooleanFilterPredicateComponent), multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => BooleanFilterPredicateComponent), + multi: true } ] }) -export class BooleanFilterPredicateComponent implements ControlValueAccessor, OnInit { +export class BooleanFilterPredicateComponent implements ControlValueAccessor, Validator, OnInit { @Input() disabled: boolean; @@ -73,7 +88,7 @@ export class BooleanFilterPredicateComponent implements ControlValueAccessor, On registerOnTouched(fn: any): void { } - setDisabledState?(isDisabled: boolean): void { + setDisabledState(isDisabled: boolean): void { this.disabled = isDisabled; if (this.disabled) { this.booleanFilterPredicateFormGroup.disable({emitEvent: false}); @@ -82,17 +97,20 @@ export class BooleanFilterPredicateComponent implements ControlValueAccessor, On } } + validate(): ValidationErrors | null { + return this.booleanFilterPredicateFormGroup ? null : { + booleanFilterPredicate: {valid: false} + }; + } + writeValue(predicate: BooleanFilterPredicate): void { this.booleanFilterPredicateFormGroup.get('operation').patchValue(predicate.operation, {emitEvent: false}); this.booleanFilterPredicateFormGroup.get('value').patchValue(predicate.value, {emitEvent: false}); } private updateModel() { - let predicate: BooleanFilterPredicate = null; - if (this.booleanFilterPredicateFormGroup.valid) { - predicate = this.booleanFilterPredicateFormGroup.getRawValue(); - predicate.type = FilterPredicateType.BOOLEAN; - } + const predicate: BooleanFilterPredicate = this.booleanFilterPredicateFormGroup.getRawValue(); + predicate.type = FilterPredicateType.BOOLEAN; this.propagateChange(predicate); } diff --git a/ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate.component.ts b/ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate.component.ts index 9f4acfc3c6..b177f118c0 100644 --- a/ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate.component.ts +++ b/ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate.component.ts @@ -16,11 +16,7 @@ import { Component, forwardRef, Input, OnInit } from '@angular/core'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; -import { - ComplexFilterPredicate, - ComplexFilterPredicateInfo, - EntityKeyValueType -} from '@shared/models/query/query.models'; +import { ComplexFilterPredicateInfo, EntityKeyValueType } from '@shared/models/query/query.models'; import { MatDialog } from '@angular/material/dialog'; import { ComplexFilterPredicateDialogComponent, @@ -71,7 +67,7 @@ export class ComplexFilterPredicateComponent implements ControlValueAccessor, On registerOnTouched(fn: any): void { } - setDisabledState?(isDisabled: boolean): void { + setDisabledState(isDisabled: boolean): void { this.disabled = isDisabled; } diff --git a/ui-ngx/src/app/modules/home/components/filter/filter-predicate-list.component.ts b/ui-ngx/src/app/modules/home/components/filter/filter-predicate-list.component.ts index 3c16b01319..75c9bd3ce8 100644 --- a/ui-ngx/src/app/modules/home/components/filter/filter-predicate-list.component.ts +++ b/ui-ngx/src/app/modules/home/components/filter/filter-predicate-list.component.ts @@ -21,7 +21,10 @@ import { FormArray, FormBuilder, FormGroup, + NG_VALIDATORS, NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, Validators } from '@angular/forms'; import { Observable, of, Subscription } from 'rxjs'; @@ -49,10 +52,15 @@ import { map } from 'rxjs/operators'; provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => FilterPredicateListComponent), multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => FilterPredicateListComponent), + multi: true } ] }) -export class FilterPredicateListComponent implements ControlValueAccessor, OnInit { +export class FilterPredicateListComponent implements ControlValueAccessor, Validator, OnInit { @Input() disabled: boolean; @@ -108,6 +116,12 @@ export class FilterPredicateListComponent implements ControlValueAccessor, OnIni } } + validate(control: AbstractControl): ValidationErrors | null { + return this.filterListFormGroup.valid ? null : { + filterList: {valid: false} + }; + } + writeValue(predicates: Array): void { if (this.valueChangeSubscription) { this.valueChangeSubscription.unsubscribe(); @@ -178,7 +192,7 @@ export class FilterPredicateListComponent implements ControlValueAccessor, OnIni private updateModel() { const predicates: Array = this.filterListFormGroup.getRawValue().predicates; - if (this.filterListFormGroup.valid && predicates.length) { + if (predicates.length) { this.propagateChange(predicates); } else { this.propagateChange(null); diff --git a/ui-ngx/src/app/modules/home/components/filter/filter-predicate-value.component.ts b/ui-ngx/src/app/modules/home/components/filter/filter-predicate-value.component.ts index 7417317dae..934a4774fb 100644 --- a/ui-ngx/src/app/modules/home/components/filter/filter-predicate-value.component.ts +++ b/ui-ngx/src/app/modules/home/components/filter/filter-predicate-value.component.ts @@ -19,7 +19,10 @@ import { ControlValueAccessor, FormBuilder, FormGroup, + NG_VALIDATORS, NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, ValidatorFn, Validators } from '@angular/forms'; @@ -39,10 +42,15 @@ import { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => FilterPredicateValueComponent), multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => FilterPredicateValueComponent), + multi: true } ] }) -export class FilterPredicateValueComponent implements ControlValueAccessor, OnInit { +export class FilterPredicateValueComponent implements ControlValueAccessor, Validator, OnInit { private readonly inheritModeForSources: DynamicValueSourceType[] = [ DynamicValueSourceType.CURRENT_CUSTOMER, @@ -62,7 +70,22 @@ export class FilterPredicateValueComponent implements ControlValueAccessor, OnIn } } - @Input() onlyUserDynamicSource = false; + private onlyUserDynamicSourceValue = false; + + @Input() + set onlyUserDynamicSource(dynamicMode: boolean) { + this.onlyUserDynamicSourceValue = dynamicMode; + if (this.filterPredicateValueFormGroup) { + this.updateValidationDynamicMode(); + setTimeout(() => { + this.updateModel(); + }, 0); + } + } + + get onlyUserDynamicSource(): boolean { + return this.onlyUserDynamicSourceValue; + } @Input() valueType: EntityKeyValueType; @@ -83,6 +106,7 @@ export class FilterPredicateValueComponent implements ControlValueAccessor, OnIn allow = true; private propagateChange = null; + private propagateChangePending = false; constructor(private fb: FormBuilder) { } @@ -126,6 +150,7 @@ export class FilterPredicateValueComponent implements ControlValueAccessor, OnIn this.updateShowInheritMode(sourceType); } ); + this.updateValidationDynamicMode(); this.filterPredicateValueFormGroup.valueChanges.subscribe(() => { this.updateModel(); }); @@ -133,12 +158,18 @@ export class FilterPredicateValueComponent implements ControlValueAccessor, OnIn registerOnChange(fn: any): void { this.propagateChange = fn; + if (this.propagateChangePending) { + this.propagateChangePending = false; + setTimeout(() => { + this.updateModel(); + }, 0); + } } registerOnTouched(fn: any): void { } - setDisabledState?(isDisabled: boolean): void { + setDisabledState(isDisabled: boolean): void { this.disabled = isDisabled; if (this.disabled) { this.filterPredicateValueFormGroup.disable({emitEvent: false}); @@ -147,28 +178,35 @@ export class FilterPredicateValueComponent implements ControlValueAccessor, OnIn } } + validate(): ValidationErrors | null { + return this.filterPredicateValueFormGroup.valid ? null : { + filterPredicateValue: {valid: false} + }; + } + writeValue(predicateValue: FilterPredicateValue): void { + this.propagateChangePending = false; this.filterPredicateValueFormGroup.get('defaultValue').patchValue(predicateValue.defaultValue, {emitEvent: false}); - this.filterPredicateValueFormGroup.get('dynamicValue.sourceType').patchValue(predicateValue.dynamicValue ? - predicateValue.dynamicValue.sourceType : null, {emitEvent: false}); - this.filterPredicateValueFormGroup.get('dynamicValue.sourceAttribute').patchValue(predicateValue.dynamicValue ? - predicateValue.dynamicValue.sourceAttribute : null, {emitEvent: false}); - this.filterPredicateValueFormGroup.get('dynamicValue.inherit').patchValue(predicateValue.dynamicValue ? - predicateValue.dynamicValue.inherit : false, {emitEvent: false}); + this.filterPredicateValueFormGroup.get('dynamicValue').patchValue({ + sourceType: predicateValue.dynamicValue ? predicateValue.dynamicValue.sourceType : null, + sourceAttribute: predicateValue.dynamicValue ? predicateValue.dynamicValue.sourceAttribute : null, + inherit: predicateValue.dynamicValue ? predicateValue.dynamicValue.inherit : false + }, {emitEvent: this.onlyUserDynamicSource}); this.updateShowInheritMode(predicateValue?.dynamicValue?.sourceType); } private updateModel() { - let predicateValue: FilterPredicateValue = null; - if (this.filterPredicateValueFormGroup.valid) { - predicateValue = this.filterPredicateValueFormGroup.getRawValue(); - if (predicateValue.dynamicValue) { - if (!predicateValue.dynamicValue.sourceType || !predicateValue.dynamicValue.sourceAttribute) { - predicateValue.dynamicValue = null; - } + const predicateValue: FilterPredicateValue = this.filterPredicateValueFormGroup.getRawValue(); + if (predicateValue.dynamicValue) { + if (!predicateValue.dynamicValue.sourceType || !predicateValue.dynamicValue.sourceAttribute) { + predicateValue.dynamicValue = null; } } - this.propagateChange(predicateValue); + if (this.propagateChange) { + this.propagateChange(predicateValue); + } else { + this.propagateChangePending = true; + } } private updateShowInheritMode(sourceType: DynamicValueSourceType) { @@ -179,4 +217,16 @@ export class FilterPredicateValueComponent implements ControlValueAccessor, OnIn this.inheritMode = false; } } + + private updateValidationDynamicMode() { + if (this.onlyUserDynamicSource) { + this.filterPredicateValueFormGroup.get('dynamicValue.sourceType').setValidators(Validators.required); + this.filterPredicateValueFormGroup.get('dynamicValue.sourceAttribute').setValidators(Validators.required); + } else { + this.filterPredicateValueFormGroup.get('dynamicValue.sourceType').clearValidators(); + this.filterPredicateValueFormGroup.get('dynamicValue.sourceAttribute').clearValidators(); + } + this.filterPredicateValueFormGroup.get('dynamicValue.sourceType').updateValueAndValidity({emitEvent: false}); + this.filterPredicateValueFormGroup.get('dynamicValue.sourceAttribute').updateValueAndValidity({emitEvent: false}); + } } diff --git a/ui-ngx/src/app/modules/home/components/filter/filter-predicate.component.ts b/ui-ngx/src/app/modules/home/components/filter/filter-predicate.component.ts index 69972b6a90..17361f55bd 100644 --- a/ui-ngx/src/app/modules/home/components/filter/filter-predicate.component.ts +++ b/ui-ngx/src/app/modules/home/components/filter/filter-predicate.component.ts @@ -15,11 +15,17 @@ /// import { Component, forwardRef, Input, OnInit } from '@angular/core'; -import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; import { - EntityKeyValueType, - FilterPredicateType, KeyFilterPredicate, KeyFilterPredicateInfo -} from '@shared/models/query/query.models'; + ControlValueAccessor, + FormBuilder, + FormGroup, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, + Validators +} from '@angular/forms'; +import { EntityKeyValueType, FilterPredicateType, KeyFilterPredicateInfo } from '@shared/models/query/query.models'; @Component({ selector: 'tb-filter-predicate', @@ -30,10 +36,15 @@ import { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => FilterPredicateComponent), multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => FilterPredicateComponent), + multi: true } ] }) -export class FilterPredicateComponent implements ControlValueAccessor, OnInit { +export class FilterPredicateComponent implements ControlValueAccessor, Validator, OnInit { @Input() disabled: boolean; @@ -75,7 +86,7 @@ export class FilterPredicateComponent implements ControlValueAccessor, OnInit { registerOnTouched(fn: any): void { } - setDisabledState?(isDisabled: boolean): void { + setDisabledState(isDisabled: boolean): void { this.disabled = isDisabled; if (this.disabled) { this.filterPredicateFormGroup.disable({emitEvent: false}); @@ -84,6 +95,12 @@ export class FilterPredicateComponent implements ControlValueAccessor, OnInit { } } + validate(): ValidationErrors | null { + return this.filterPredicateFormGroup.valid ? null : { + filterPredicate: {valid: false} + }; + } + writeValue(predicate: KeyFilterPredicateInfo): void { this.type = predicate.keyFilterPredicate.type; this.filterPredicateFormGroup.get('predicate').patchValue(predicate.keyFilterPredicate, {emitEvent: false}); diff --git a/ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.ts b/ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.ts index 1229f18ec2..c1d1a1db82 100644 --- a/ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.ts +++ b/ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.ts @@ -22,7 +22,10 @@ import { FormBuilder, FormControl, FormGroup, + NG_VALIDATORS, NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, Validators } from '@angular/forms'; import { Observable, Subscription } from 'rxjs'; @@ -46,10 +49,15 @@ import { EntityId } from '@shared/models/id/entity-id'; provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => KeyFilterListComponent), multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => KeyFilterListComponent), + multi: true } ] }) -export class KeyFilterListComponent implements ControlValueAccessor, OnInit { +export class KeyFilterListComponent implements ControlValueAccessor, Validator, OnInit { @Input() disabled: boolean; @@ -104,6 +112,12 @@ export class KeyFilterListComponent implements ControlValueAccessor, OnInit { } } + validate(): ValidationErrors | null { + return this.keyFilterListFormGroup.valid && this.keyFiltersControl.valid ? null : { + keyFilterList: {valid: false} + }; + } + writeValue(keyFilters: Array): void { if (this.valueChangeSubscription) { this.valueChangeSubscription.unsubscribe(); diff --git a/ui-ngx/src/app/modules/home/components/filter/numeric-filter-predicate.component.ts b/ui-ngx/src/app/modules/home/components/filter/numeric-filter-predicate.component.ts index 434c90fa80..2b24a5a6b1 100644 --- a/ui-ngx/src/app/modules/home/components/filter/numeric-filter-predicate.component.ts +++ b/ui-ngx/src/app/modules/home/components/filter/numeric-filter-predicate.component.ts @@ -15,7 +15,16 @@ /// import { Component, forwardRef, Input, OnInit } from '@angular/core'; -import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; +import { + ControlValueAccessor, + FormBuilder, + FormGroup, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, + Validators +} from '@angular/forms'; import { EntityKeyValueType, FilterPredicateType, @@ -33,10 +42,15 @@ import { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => NumericFilterPredicateComponent), multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => NumericFilterPredicateComponent), + multi: true } ] }) -export class NumericFilterPredicateComponent implements ControlValueAccessor, OnInit { +export class NumericFilterPredicateComponent implements ControlValueAccessor, Validator, OnInit { @Input() disabled: boolean; @@ -76,7 +90,7 @@ export class NumericFilterPredicateComponent implements ControlValueAccessor, On registerOnTouched(fn: any): void { } - setDisabledState?(isDisabled: boolean): void { + setDisabledState(isDisabled: boolean): void { this.disabled = isDisabled; if (this.disabled) { this.numericFilterPredicateFormGroup.disable({emitEvent: false}); @@ -85,17 +99,20 @@ export class NumericFilterPredicateComponent implements ControlValueAccessor, On } } + validate(): ValidationErrors | null { + return this.numericFilterPredicateFormGroup.valid ? null : { + numericFilterPredicate: {valid: false} + }; + } + writeValue(predicate: NumericFilterPredicate): void { this.numericFilterPredicateFormGroup.get('operation').patchValue(predicate.operation, {emitEvent: false}); this.numericFilterPredicateFormGroup.get('value').patchValue(predicate.value, {emitEvent: false}); } private updateModel() { - let predicate: NumericFilterPredicate = null; - if (this.numericFilterPredicateFormGroup.valid) { - predicate = this.numericFilterPredicateFormGroup.getRawValue(); - predicate.type = FilterPredicateType.NUMERIC; - } + const predicate: NumericFilterPredicate = this.numericFilterPredicateFormGroup.getRawValue(); + predicate.type = FilterPredicateType.NUMERIC; this.propagateChange(predicate); } diff --git a/ui-ngx/src/app/modules/home/components/filter/string-filter-predicate.component.ts b/ui-ngx/src/app/modules/home/components/filter/string-filter-predicate.component.ts index 11b4dad275..3caa282576 100644 --- a/ui-ngx/src/app/modules/home/components/filter/string-filter-predicate.component.ts +++ b/ui-ngx/src/app/modules/home/components/filter/string-filter-predicate.component.ts @@ -15,7 +15,16 @@ /// import { Component, forwardRef, Input, OnInit } from '@angular/core'; -import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; +import { + ControlValueAccessor, + FormBuilder, + FormGroup, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ValidationErrors, + Validator, + Validators +} from '@angular/forms'; import { EntityKeyValueType, FilterPredicateType, @@ -33,10 +42,15 @@ import { provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => StringFilterPredicateComponent), multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => StringFilterPredicateComponent), + multi: true } ] }) -export class StringFilterPredicateComponent implements ControlValueAccessor, OnInit { +export class StringFilterPredicateComponent implements ControlValueAccessor, Validator, OnInit { @Input() disabled: boolean; @@ -90,12 +104,15 @@ export class StringFilterPredicateComponent implements ControlValueAccessor, OnI this.stringFilterPredicateFormGroup.get('ignoreCase').patchValue(predicate.ignoreCase, {emitEvent: false}); } + validate(c): ValidationErrors { + return this.stringFilterPredicateFormGroup.valid ? null : { + stringFilterPredicate: {valid: false} + }; + } + private updateModel() { - let predicate: StringFilterPredicate = null; - if (this.stringFilterPredicateFormGroup.valid) { - predicate = this.stringFilterPredicateFormGroup.getRawValue(); - predicate.type = FilterPredicateType.STRING; - } + const predicate: StringFilterPredicate = this.stringFilterPredicateFormGroup.getRawValue(); + predicate.type = FilterPredicateType.STRING; this.propagateChange(predicate); } From 766594a7aa039c1433a4b0524d6d0fdb0e2a0c0d Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Mon, 29 Mar 2021 19:03:44 +0300 Subject: [PATCH 05/69] UI: Fixed switch control widget, didn't update state after get value in call rpc --- .../components/widget/lib/rpc/switch.component.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/switch.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/switch.component.ts index b1b5a3b336..d2cf53265f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/switch.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/switch.component.ts @@ -121,7 +121,7 @@ export class SwitchComponent extends PageComponent implements OnInit, OnDestroy this.switchResize$ = new ResizeObserver(() => { this.resize(); - }) + }); this.switchResize$.observe(this.switchContainerRef.nativeElement); this.init(); } @@ -202,13 +202,13 @@ export class SwitchComponent extends PageComponent implements OnInit, OnDestroy let width = this.switchContainer.width(); let height = this.switchContainer.height(); if (this.showOnOffLabels) { - height = height*2/3; + height = height * 2 / 3; } - const ratio = width/height; + const ratio = width / height; if (ratio > switchAspectRation) { - width = height*switchAspectRation; + width = height * switchAspectRation; } else { - height = width/switchAspectRation; + height = width / switchAspectRation; } this.switchElement.css({width, height}); this.matSlideToggle.css({width, height, minWidth: width}); @@ -232,11 +232,11 @@ export class SwitchComponent extends PageComponent implements OnInit, OnDestroy fontSize--; textWidth = this.measureTextWidth(text, fontSize); } - element.css({fontSize: fontSize+'px', lineHeight: fontSize+'px'}); + element.css({fontSize: fontSize + 'px', lineHeight: fontSize + 'px'}); } private measureTextWidth(text: string, fontSize: number): number { - this.textMeasure.css({fontSize: fontSize+'px', lineHeight: fontSize+'px'}); + this.textMeasure.css({fontSize: fontSize + 'px', lineHeight: fontSize + 'px'}); this.textMeasure.text(text); return this.textMeasure.width(); } @@ -260,6 +260,7 @@ export class SwitchComponent extends PageComponent implements OnInit, OnDestroy this.ctx.controlApi.sendTwoWayCommand(this.getValueMethod, null, this.requestTimeout).subscribe( (responseBody) => { this.setValue(this.parseValueFunction(responseBody)); + this.ctx.detectChanges(); }, () => { const errorText = this.ctx.defaultSubscription.rpcErrorText; From fa02d7629e7294daba71d7cba62db3812b3839bb Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Tue, 30 Mar 2021 18:15:37 +0300 Subject: [PATCH 06/69] UI: Improvement mobile view timeWindow; Add show time in 24 format --- .../time/datetime-period.component.html | 8 ++-- .../time/quick-time-interval.component.scss | 19 --------- .../time/quick-time-interval.component.ts | 1 - .../time/timewindow-panel.component.html | 39 +++++++++++-------- .../time/timewindow-panel.component.scss | 24 ++++++++---- ui-ngx/src/app/shared/shared.module.ts | 5 +++ 6 files changed, 49 insertions(+), 47 deletions(-) delete mode 100644 ui-ngx/src/app/shared/components/time/quick-time-interval.component.scss diff --git a/ui-ngx/src/app/shared/components/time/datetime-period.component.html b/ui-ngx/src/app/shared/components/time/datetime-period.component.html index 96b2e3d526..96bffe8696 100644 --- a/ui-ngx/src/app/shared/components/time/datetime-period.component.html +++ b/ui-ngx/src/app/shared/components/time/datetime-period.component.html @@ -15,8 +15,9 @@ limitations under the License. --> -
-
+
+
datetime.date-from @@ -30,7 +31,8 @@
-
+
datetime.date-to diff --git a/ui-ngx/src/app/shared/components/time/quick-time-interval.component.scss b/ui-ngx/src/app/shared/components/time/quick-time-interval.component.scss deleted file mode 100644 index 97c400525e..0000000000 --- a/ui-ngx/src/app/shared/components/time/quick-time-interval.component.scss +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Copyright © 2016-2021 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -:host { - min-width: 364px; -} diff --git a/ui-ngx/src/app/shared/components/time/quick-time-interval.component.ts b/ui-ngx/src/app/shared/components/time/quick-time-interval.component.ts index 3eab8c428d..3f9c712556 100644 --- a/ui-ngx/src/app/shared/components/time/quick-time-interval.component.ts +++ b/ui-ngx/src/app/shared/components/time/quick-time-interval.component.ts @@ -21,7 +21,6 @@ import { QuickTimeInterval, QuickTimeIntervalTranslationMap } from '@shared/mode @Component({ selector: 'tb-quick-time-interval', templateUrl: './quick-time-interval.component.html', - styleUrls: ['./quick-time-interval.component.scss'], providers: [ { provide: NG_VALUE_ACCESSOR, diff --git a/ui-ngx/src/app/shared/components/time/timewindow-panel.component.html b/ui-ngx/src/app/shared/components/time/timewindow-panel.component.html index 57c596bbc2..f947c51d3e 100644 --- a/ui-ngx/src/app/shared/components/time/timewindow-panel.component.html +++ b/ui-ngx/src/app/shared/components/time/timewindow-panel.component.html @@ -51,7 +51,7 @@ [fxShow]="timewindowForm.get('realtime.realtimeType').value === realtimeTypes.INTERVAL" [required]="timewindow.selectedTab === timewindowTypes.REALTIME && timewindowForm.get('realtime.realtimeType').value === realtimeTypes.INTERVAL" - style="padding-top: 8px; min-width: 364px"> + style="padding-top: 8px">
@@ -75,6 +75,7 @@ timewindow.time-period timewindow.interval + style="padding-top: 8px">
@@ -134,21 +137,23 @@ (ngModelChange)="onHideAggIntervalChanged()">
-
- aggregation.limit - - - - - +
+ +
+ + + + + +
diff --git a/ui-ngx/src/app/shared/components/time/timewindow-panel.component.scss b/ui-ngx/src/app/shared/components/time/timewindow-panel.component.scss index 6b25d0b1a7..fd8cb0f331 100644 --- a/ui-ngx/src/app/shared/components/time/timewindow-panel.component.scss +++ b/ui-ngx/src/app/shared/components/time/timewindow-panel.component.scss @@ -13,6 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +@import "../../../../scss/constants"; + :host { width: 100%; height: 100%; @@ -40,21 +42,29 @@ } .limit-slider-container { - >:first-child { - margin-right: 16px; - } - >:last-child { + .limit-slider-value { margin-left: 16px; - } - >:first-child, >:last-child { min-width: 25px; - max-width: 42px; + max-width: 80px; } mat-form-field input[type=number] { text-align: center; } } + @media #{$mat-gt-sm} { + .history-time-input { + min-width: 364px; + } + .limit-slider-container { + > label { + margin-right: 16px; + width: min-content; + max-width: 40%; + } + } + } + } :host ::ng-deep { diff --git a/ui-ngx/src/app/shared/shared.module.ts b/ui-ngx/src/app/shared/shared.module.ts index 02cd3b93d4..15d36d6753 100644 --- a/ui-ngx/src/app/shared/shared.module.ts +++ b/ui-ngx/src/app/shared/shared.module.ts @@ -141,6 +141,7 @@ import { FileSizePipe } from '@shared/pipe/file-size.pipe'; import { WidgetsBundleSearchComponent } from '@shared/components/widgets-bundle-search.component'; import { SelectableColumnsPipe } from '@shared/pipe/selectable-columns.pipe'; import { QuickTimeIntervalComponent } from '@shared/components/time/quick-time-interval.component'; +import { MAT_DATE_LOCALE } from '@angular/material/core'; @NgModule({ providers: [ @@ -154,6 +155,10 @@ import { QuickTimeIntervalComponent } from '@shared/components/time/quick-time-i { provide: FlowInjectionToken, useValue: Flow + }, + { + provide: MAT_DATE_LOCALE, + useValue: 'en-GB' } ], declarations: [ From 74e20e2c68a636ef55bf3a1d05290818adbdc887 Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Wed, 31 Mar 2021 16:49:56 +0300 Subject: [PATCH 07/69] UI: Fixed didn't save widget type filter in widgets selector --- .../dashboard-page/dashboard-page.component.ts | 13 +++++++------ .../dashboard-widget-select.component.ts | 4 ++++ 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts index 45ff291954..29e945f8df 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts @@ -90,7 +90,6 @@ import { } from '@home/components/alias/entity-aliases-dialog.component'; import { EntityAliases } from '@app/shared/models/alias.models'; import { EditWidgetComponent } from '@home/components/dashboard-page/edit-widget.component'; -import { WidgetsBundle } from '@shared/models/widgets-bundle.model'; import { AddWidgetDialogComponent, AddWidgetDialogData @@ -118,8 +117,7 @@ import { ComponentPortal } from '@angular/cdk/portal'; import { DISPLAY_WIDGET_TYPES_PANEL_DATA, DisplayWidgetTypesPanelComponent, - DisplayWidgetTypesPanelData, - WidgetTypes + DisplayWidgetTypesPanelData } from '@home/components/dashboard-page/widget-types-panel.component'; import { DashboardWidgetSelectComponent } from '@home/components/dashboard-page/dashboard-widget-select.component'; @@ -1183,13 +1181,16 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC overlayRef.dispose(); }); + const filterWidgetTypes = this.dashboardWidgetSelectComponent.filterWidgetTypes; + const widgetTypesList = Array.from(this.dashboardWidgetSelectComponent.widgetTypes.values()).map(type => { + return {type, display: filterWidgetTypes === null ? true : filterWidgetTypes.includes(type)}; + }); + const providers: StaticProvider[] = [ { provide: DISPLAY_WIDGET_TYPES_PANEL_DATA, useValue: { - types: Array.from(this.dashboardWidgetSelectComponent.widgetTypes.values()).map(type => { - return {type, display: true}; - }), + types: widgetTypesList, typesUpdated: (newTypes) => { this.filterWidgetTypes = newTypes.filter(type => type.display).map(type => type.type); } diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.ts index c51cc9c37f..645d955deb 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.ts @@ -77,6 +77,10 @@ export class DashboardWidgetSelectComponent implements OnInit { this.filterWidgetTypes$.next(widgetTypes); } + get filterWidgetTypes(): Array { + return this.filterWidgetTypes$.value; + } + @Output() widgetSelected: EventEmitter = new EventEmitter(); From b5849b6ceb0b00d360621959823bb26ae4733a50 Mon Sep 17 00:00:00 2001 From: blackstar-baba <535650957@qq.com> Date: Thu, 1 Apr 2021 11:14:13 +0800 Subject: [PATCH 08/69] fixed title ex when make rule chain root in chinese MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit when make rule chain root in chinese,title display expression like '{{RuleChainName}}' --- ui-ngx/src/assets/locale/locale.constant-zh_CN.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui-ngx/src/assets/locale/locale.constant-zh_CN.json b/ui-ngx/src/assets/locale/locale.constant-zh_CN.json index bbd7f7b61d..c720981fc6 100644 --- a/ui-ngx/src/assets/locale/locale.constant-zh_CN.json +++ b/ui-ngx/src/assets/locale/locale.constant-zh_CN.json @@ -1943,7 +1943,7 @@ "selected-rulechains": "已选择 { count, plural, 1 {# 个规则链} other {# 个规则链} }", "set-root": "设置为根规则链", "set-root-rulechain-text": "确认之后,规则链将变为根规格链,并将处理所有传入的传输消息。", - "set-root-rulechain-title": "您确定要生成规则链'{{RuleChainName}}'根吗?", + "set-root-rulechain-title": "您确定要生成规则链'{{ruleChainName}}'根吗?", "system": "系统" }, "rulenode": { From 48db9f50afe6e50c6e0f30dd268442e5fca64955 Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Thu, 1 Apr 2021 13:33:11 +0300 Subject: [PATCH 09/69] Fixed local environment variable resolve assets page 404 --- .../src/main/java/org/thingsboard/server/config/WebConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/src/main/java/org/thingsboard/server/config/WebConfig.java b/application/src/main/java/org/thingsboard/server/config/WebConfig.java index 81d3390dd2..500d1bd06e 100644 --- a/application/src/main/java/org/thingsboard/server/config/WebConfig.java +++ b/application/src/main/java/org/thingsboard/server/config/WebConfig.java @@ -21,7 +21,7 @@ import org.springframework.web.bind.annotation.RequestMapping; @Controller public class WebConfig { - @RequestMapping(value = "/{path:^(?!api$)(?!assets$)(?!static$)(?!webjars$)[^\\.]*}/**") + @RequestMapping(value = {"/assets", "/assets/", "/{path:^(?!api$)(?!assets$)(?!static$)(?!webjars$)[^\\.]*}/**"}) public String redirect() { return "forward:/index.html"; } From aabf5c792afe9e71f3aecc878339a0236dfe0caf Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Fri, 2 Apr 2021 17:06:01 +0300 Subject: [PATCH 10/69] UI: Fixed multiple datasource processing data in map widgets --- .../widget/lib/maps/common-maps-utils.ts | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/common-maps-utils.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/common-maps-utils.ts index 077d8dfb6d..32a9074ca0 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/common-maps-utils.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/common-maps-utils.ts @@ -19,7 +19,8 @@ import { createLabelFromDatasource, hashCode, isDefined, - isDefinedAndNotNull, isFunction, + isDefinedAndNotNull, + isFunction, isNumber, isUndefined, padValue @@ -30,7 +31,7 @@ import { Datasource, DatasourceData } from '@shared/models/widget.models'; import _ from 'lodash'; import { mapProviderSchema, providerSets } from '@home/components/widget/lib/maps/schemes'; import { addCondition, mergeSchemes } from '@core/schema-utils'; -import L, {Projection} from "leaflet"; +import L from 'leaflet'; export function getProviderSchema(mapProvider: MapProviders, ignoreImageMap = false) { const providerSchema = _.cloneDeep(mapProviderSchema); @@ -318,22 +319,24 @@ export const parseWithTranslation = { }; export function parseData(input: DatasourceData[]): FormattedData[] { - return _(input).groupBy(el => el?.datasource?.entityName) + return _(input).groupBy(el => el?.datasource.entityId + el?.datasource.entityType) .values().value().map((entityArray, i) => { const obj: FormattedData = { entityName: entityArray[0]?.datasource?.entityName, - entityId: entityArray[0]?.datasource?.entityId, - entityType: entityArray[0]?.datasource?.entityType, - $datasource: entityArray[0]?.datasource, + entityId: entityArray[0].datasource.entityId, + entityType: entityArray[0].datasource.entityType, + $datasource: entityArray[0].datasource, dsIndex: i, deviceType: null }; entityArray.filter(el => el.data.length).forEach(el => { - const indexDate = el?.data?.length ? el.data.length - 1 : 0; - obj[el?.dataKey?.label] = el?.data[indexDate][1]; - obj[el?.dataKey?.label + '|ts'] = el?.data[indexDate][0]; - if (el?.dataKey?.label === 'type') { - obj.deviceType = el?.data[indexDate][1]; + const indexDate = el.data.length ? el.data.length - 1 : 0; + if (!obj.hasOwnProperty(el.dataKey.label) || el.data[indexDate][1] !== '') { + obj[el.dataKey.label] = el.data[indexDate][1]; + obj[el.dataKey.label + '|ts'] = el.data[indexDate][0]; + if (el.dataKey.label === 'type') { + obj.deviceType = el.data[indexDate][1]; + } } }); return obj; From 9b5ec2e5696d33b1234ea842bd21c240b79078e4 Mon Sep 17 00:00:00 2001 From: mdheer Date: Mon, 5 Apr 2021 17:45:01 +0200 Subject: [PATCH 11/69] logo is only shown in fullscreen --- .../components/dashboard-page/dashboard-page.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.html b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.html index 4fa2c9cc7a..59cdf01c2a 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.html +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.html @@ -51,7 +51,7 @@ fxLayoutAlign.gt-sm="start center" fxLayoutAlign="end center" fxLayoutGap="12px" class="db-logo-and-title" > - Date: Mon, 5 Apr 2021 20:56:15 +0300 Subject: [PATCH 12/69] Add JSON input widget --- .../system/widget_bundles/input_widgets.json | 18 ++ ui-ngx/src/app/core/utils.ts | 4 + .../lib/json-input-widget.component.html | 69 ++++++ .../lib/json-input-widget.component.scss | 36 +++ .../widget/lib/json-input-widget.component.ts | 227 ++++++++++++++++++ .../widget/widget-components.module.ts | 3 + .../components/json-object-edit.component.ts | 7 +- 7 files changed, 362 insertions(+), 2 deletions(-) create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/json-input-widget.component.html create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/json-input-widget.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/widget/lib/json-input-widget.component.ts diff --git a/application/src/main/data/json/system/widget_bundles/input_widgets.json b/application/src/main/data/json/system/widget_bundles/input_widgets.json index b19c5d40b9..8a80aa80f1 100644 --- a/application/src/main/data/json/system/widget_bundles/input_widgets.json +++ b/application/src/main/data/json/system/widget_bundles/input_widgets.json @@ -455,6 +455,24 @@ "dataKeySettingsSchema": "{}\n", "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Random\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -1000) {\\n\\tvalue = -1000;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{},\"title\":\"Photo camera input\",\"showTitleIcon\":false,\"titleIcon\":\"more_horiz\",\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"titleTooltip\":\"\",\"dropShadow\":true,\"enableFullscreen\":false,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"useDashboardTimewindow\":true,\"displayTimewindow\":true,\"showLegend\":false,\"actions\":{}}" } + }, + { + "alias": "update_json_attribute", + "name": "Update JSON attribute", + "image": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAUoAAADhCAMAAACZWwyuAAAB71BMVEX////w8PDt7e3z8/Pg4ODAwMDc3Nz19fUZhJQ4lKK9vb17e3v9/f2mpqbj4+P4+Pi+vr58fHzT09P7+/uTxcz2+/ucy9GpqakCAgK93OHNzc0eh5b09PSw1dusrKwvkZ+ampqysrIzMzPV1dXCwsKurq4iiZnZ2dnX19fIyMgrj50ABsiKiopmZmbo8/XQ5+ro6OjPz89JnqqCgoLX6u2cnJwmjJv39/ff39+12N7ExMQ2NjaioqKYmJjs7OzD4OTS0tLKysrh8PLq6uq6uroCCMjDw8OOjo6hzdO0tLQuj56UlJS4uvDZ7O7l5eVvsrw0k6EchZWEhIRwcHDU6ezG4eaGv8d1tb+3t7dCmqeIiIj4+/3j8fO62+AKD8tWpbGgoKBqamouLi7u7u47lqTP0PWr0tgXHM1/u8N6uMFqsLpfqrVNoKxFRUXU1faYyM92dnY7Ozvz+Pry8vK5ubm4uLh+fn5sbGzGx/OipOtzduG42t8uM9KNw8pTo690dHT6+vrt9viOkefb29um0NYfHx/p6vu9vvGqrO2YmulXW9uhztRnrrgmJiYQEBDt7fzk5frf4PjL4+eMj+a/3eJpbN9MUNk7P9SQxMuNwslfX19TU1OcnurI4uY9QdYyN9ImK9BVVVVPT097fuNYWFjSJwPyAAALvElEQVR42uzbTWviQBjA8ad5qgNONI05FCSIshUNKERCSFlM2WIxSKCH5FI8RfoFCrvVYwv7xTfG4susxqgpqJkfHoLM6c9MTCYROI7jOC4doiIRoqrEkhQRuIOJEsEVROI1Dw2pIkPlMQ8PyWMez8ItLOD2QnArAlxyIsEYhK/x5CSMJQGX0Cfu8AmXTCEqbkWUPQaBiDtd8hJXMJ6SbNByeWd3iROMRxIPAhEx09MSd1ATDwIJMdPTEndJNIid4D/kkLk4yMbFZTop2fXdonSZktb2XuFKpwRnBzeoGoeklNZS1tjj+BVu2hDKfYhxKXUXTtnXHIqgXd+esk/pNSK+GrQcjZsbuotBVrKUFmzSGs++b1djZqVV1m01D6crqlSpVOldpYKFSmzKWUCN0nI0bq5bWAwiuHquNNnj+JOlSDWA144OJW86KUcp38oAtQZIPZ/mICQEBvWGcLpw7i6acu0iCkXDtQ1hcos4MtdTOt4VYs2h5dk42y4azj3ejhYpVUyEwEZ1F+BpBJKviQVfWUkZuOTZz0e5ba8FJ4xNWZcLPw0De97vezpYTzmUK1iiL18p5T+aEaymxGRU2OhhTMAbgFISATvCMqU0LgEUo4U/KOreKV9LsSlHAeKtgY+09STr6ylvzABfRuWvlC5i4DCz8oiU4Az6Uwug25blzsMyZb8zDpkQkgj04YSxKSf16FwpTOquzZwrnx5lvT3sLlP21lKSoxY4aM4wANCn10DmKemvaFZ2HuBMMCnDT5QSe205x6R8ufICWqowKQ/92WFJ/lsTID+9vhp29FlKpye1/AbUXELqTTgDbMq60S2PDMRHKutsSqzTGrIpD74YYvUohBr+x1D+O0tZMqaO0wASfPjmWdwjbfgFd0wDUZjY+F/Ke6ptSRl/iZ6Nm3Dc4sq7Se/GMRtbQ7hZvjrR+XZGKimrhoZ7pOSbbKntV/Kt39R20fkDiZSf7Yi4i3rBkzLVJ46g4M7mHH+l4Bjpv+hiXfTyTr9lVk+U38Diqzs1n1l8XeibiJaKLP7O74FEiYdMT/RfE0RVJZICe8txSSRKmU9KEIRm8V0QnitNIWvST6m93whhy3/sm/lTEmEYx7/SOLPbdFgjCDGEWm0FBUVEGRRF0WEmlCgxeFSaeKHpNF1ajWW3V4cddh9/aLs078PqCMOCxjrs55dn3mff9/3hM7x7fGfYrSs1ll9leUWFrmrK+ERXahSk0uc9srTKJ7c31+hKjQJUHnnorc2g0lijK9eVGgWovHF1fUaV5zSVyg54RUaVNzWVearcK5FWWWPcqanMU2VjU1NTI6kcNjbpNJX5HvDGq43pA7510/Q+TaVylcyl/F65z1iuqVSi8vgpX23VqaUfO5c1lUpUrq+V2LXUh6PxnPbhuExxhnHrfi3OKFylbseV8ic/tz/WlRoroFK8V9ZMGad0pUZuKstyBSWMpnJ1qfS0tXmk6hhFHmRfR/sXnQJUVswPJy8dy0Vlvd9fL1WDFXmRfR3tv5hn39oT9+JU5TyyNwBQi8rOz3vWVnurc1HZ5XR2rZhK2n8x68buNbwNPqIq43lCVSrLpB/k1aZcVD5wODoAXOQk9ND3hD39DrzhzR39QgiIhs22Xj1YH4EQL5hjQIQ3C5OO9DrbWaCSZ9dpPu2PjdeuHZK7BPD+JdU0g4nrQVWplPizJxeVAaAVgLvF5na7gT6+rnlgAJZ6p6ujWfCD72uesEWpDxP3qvlrGzA+6hwNh2gdU8mus/m0PzYYjYexkO/3qBLxsbtQnUpf8vDCRi4HNcBFADcXsPDoDcF1FnwlYOFaWV9UpQdjiGfrSCW7TvPTVB/FQl4knlIlfr+H6lRWDZ8uU66yjjOLcM2Syj64DCmV41wd65PK1sl6m9WWUSXNz8zd9jtUiaftg6pTuXX4VFkeKk2c84KIXlTZE2IqRzgT65PKrgF/pEem0jpEKuX7ZDPZQDXNC3swGLSnnkNqUbnfW6PwFb1bgEgr1w0JUSXAVJq4AOszVSNcHTBho3VwRUkl7bOQg9WQMdj+nOo/vnwBMDMoEnwxA0AtKm/MbxE5qUBlhGt5Z9AjKtw3WSwylTFLd7gfrM9Uge/wO3ts6XWfhFf+PlJJ84nFj52XH66LxKli1m5/DQl2wM9MX1aFyl//8koFKvHRY3Y5sO5+2Gxtkau0CpMjYH1SdaFDMNf3p9fpo8LF+hBdp/nEopehH3aJ11QRHxuLL1D5y3hbFSqXAvnAV6JIbJuu1lQuC0eStRs1lctC1aU16njsaCFb8VRW5pCxvWnBauW/5ZXZDvKQJ0K6ednYMarGXHIFVFZ99ib36BTklRlVRjmTXCWNDdaccsmgXaIBSlGPSt9BMa/0KcgrM6rUOyFTSWNSmT2XxB2RucQsFKMalRLztxTklbxVEKJ6FktQXhngOK4OIiOTgjXGszHllA5p1Mo1L5FLErPtd6EcFansXDt8TkFeyff7J4RPTCXllXG3+2JKXX/YYhFVsjHllLFewCLEs+WSc2MoOoWoXFtbu6kzg8pM74+9LlJJeSWQUhfg/Kk+G9MBb+GB3g5kySWfioFF0SlE5b4TV4YvKVQ5ZCWVLK9k6tq4kaVVOjk3YkPIwtuECv4gV+C90uc9pkzlWRvljiyvZOpMmVSiq2WciyALH+ZQfApUuV65SsodKRkCPN2p/NGS6rMx5ZQYCk+Es+SSmAk+Q/EpQOX5mgpf8mGZYpUsd5SpfDBgMugR8hikPhtTTolxwdqDzLkkntlHUHzyV9l5I+lNHmhUrpLljjKVzpg55kC8x2a2hsDGlFMCvVwEmXNJPEpABayOOMPggvpZBSrHnRaPCepnBVRuOFFefmIDlg3TX/buXqVhKIzD+FuonEEJJQ4eKogEOrjFQRRSisEiuIl3YPYOvQId7Kg34OSdGgd1SaD5aPmH8zwQsv8g5Jx3OOf08c0GUP+UUezMXDzA8/3lKHNnZS630Oqf8sAK//5QvkKrG+VVsqygXEQvGyibUc5WlZTl44+gbEJ5u5pm1ZT3/hjKBpSLr5NRDeWFf4WyyR78eVRHeecnUG5PebOOaynt0K8ttNpTzpMsy5LkvIry+uNpZqHVnjK6LMvm4yrKqXd84A2X6PzBd0mZux/KERvHfsYZE884o5ch2/LscxOe5C4orfDFxMJrAFP0oQQllHpBCaVeUEKpF5RQ6gUllHpBCaVeUEKpF5RQ6gUllHpBCaVeUEKpF5RQ6gUllHpBCaVeUEKpF5RQ6gUllHpBCaVeUEKpF5RQ6gUllHpBCaVeUEKpF5RQ6gUllHpBCaVeUEKpF5RQ6gUllHpBuWdK2qLwDj8lIiIiIiIKLpdG45ZFqTP6y407heV/aTfK1Oi3qBtlgNdmfLdrxypyw0AAhifJ2AeDZtcohUAEgZGFrdJVjIsgk2DY5urt9xFu9wn2xWMFLklzV3gb45u/GHD7YSML5s3wwUASSqHcbkIplNtLKIVyewll7onaZR7oB/yfJ/UYZfvLmbMXyscpy2CKxplOKB+mtBwRNRcflLJJvhh/Anybir4nBV9sMeJKyonnZaYO/cm4GX1+JC6xr02dPgAlpaFpFMTq7onUsRkx0WEd5RD42ucP3YWJeMJQI4YTJj7H2tz3TzkBlPR0oQEgkUK6AEzz2mPnxhx6LPsW0RA2XHYc8ebKwXPcP2UPgPTynAk9KV2pTHtcRZkxZ8Ma9dkZvqJmO3KLjnPFTimP1EEm/PyP8tMfSp8p7/R9FWWyy9BMrTnp1lwRA53zNx70UrtTSmjiMhIdXykvdADQpDCT2katoiTWiC1Xli0OmbJyZkQ8m4XR7/XYASQ7JNLwSglxfh4qUl+bETUNsIqyM66YAyfPdaz5hpiYuzzDWLHeKyUcYhEH9Zcy/wzZRApebDUOa2873dWZ2iJG54oQENHVuGRrE+Ju30q5g28qoRTK7SWUQrm9hFIot5dQCuX2kvUrWQrcXrKqKgvUkiRJkiRJkiS9328dj4CaN1dagAAAAABJRU5ErkJggg==", + "description": "Simple form to input new JSON value for pre-defined attribute/timeseries key.", + "descriptor": { + "type": "latest", + "sizeX": 7.5, + "sizeY": 3, + "resources": [], + "templateHtml": "\n", + "templateCss": ".attribute-update-form {\n overflow: hidden;\n height: 100%;\n display: flex;\n flex-direction: column;\n}\n\n.attribute-update-form__grid {\n display: flex;\n}\n.grid__element:first-child {\n flex: 1;\n}\n.grid__element:last-child {\n margin-top: 19px;\n margin-left: 7px;\n}\n.grid__element {\n display: flex;\n}\n\n.attribute-update-form .mat-button.mat-icon-button {\n width: 32px;\n min-width: 32px;\n height: 32px;\n min-height: 32px;\n padding: 0 !important;\n margin: 0 !important;\n line-height: 20px;\n}\n\n.attribute-update-form .mat-icon-button mat-icon {\n width: 20px;\n min-width: 20px;\n height: 20px;\n min-height: 20px;\n font-size: 20px;\n}\n\n.tb-toast {\n font-size: 14px!important;\n}", + "controllerScript": "self.onInit = function() {\n}\n\nself.onDataUpdated = function() {\n self.ctx.$scope.jsonInputWidget.onDataUpdated();\n}\n\nself.onResize = function() {\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n maxDataKeys: 1,\n singleEntity: true\n }\n}\n\nself.onDestroy = function() {\n}", + "settingsSchema": "{\n \"schema\": {\n \"type\": \"object\",\n \"title\": \"AdvancedSettings\",\n \"properties\": {\n \"widgetTitle\": {\n \"title\": \"Widget title\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"widgetMode\": {\n \"title\": \"Widget mode\",\n \"type\": \"string\",\n \"default\": \"ATTRIBUTE\"\n },\n \"attributeScope\": {\n \"title\": \"Attribute scope\",\n \"type\": \"string\",\n \"default\": \"SERVER_SCOPE\"\n },\n \"showLabel\":{\n \"title\": \"Show label\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"labelValue\": {\n \"title\": \"Label\",\n \"type\": \"string\",\n \"default\": \"\"\n },\n \"attributeRequired\": {\n \"title\": \"Value required\",\n \"type\": \"boolean\",\n \"default\": true\n },\n \"showResultMessage\": {\n \"title\": \"Show result message\",\n \"type\": \"boolean\",\n \"default\": true\n }\n },\n \"required\": []\n },\n \"form\": [\n \"widgetTitle\",\n {\n \"key\": \"widgetMode\",\n \"type\": \"rc-select\",\n \"multiple\": false,\n \"items\": [\n {\n \"value\": \"ATTRIBUTE\",\n \"label\": \"Update attribute\"\n },\n {\n \"value\": \"TIME_SERIES\",\n \"label\": \"Update timeseries\"\n }\n ]\n },\n {\n \"key\": \"attributeScope\",\n \"type\": \"rc-select\",\n \"multiple\": false,\n \"condition\": \"model.widgetMode === 'ATTRIBUTE'\",\n \"items\": [\n {\n \"value\": \"SERVER_SCOPE\",\n \"label\": \"Server attribute\"\n },\n {\n \"value\": \"SHARED_SCOPE\",\n \"label\": \"Shared attribute\"\n }\n ]\n },\n \"showLabel\",\n {\n \"key\": \"labelValue\",\n \"condition\": \"model.showLabel\"\n },\n \"attributeRequired\",\n \"showResultMessage\"\n ]\n}", + "dataKeySettingsSchema": "{}", + "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"Random\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -1000) {\\n\\tvalue = -1000;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\"}]}],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"attributeScope\":\"SERVER_SCOPE\",\"showLabel\":true,\"attributeRequired\":true,\"showResultMessage\":true},\"title\":\"Update JSON attribute\",\"showTitleIcon\":false,\"iconColor\":\"rgba(0, 0, 0, 0.87)\",\"iconSize\":\"24px\",\"titleTooltip\":\"\",\"dropShadow\":true,\"enableFullscreen\":false,\"widgetStyle\":{},\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400},\"showLegend\":false}" + } } ] } diff --git a/ui-ngx/src/app/core/utils.ts b/ui-ngx/src/app/core/utils.ts index b9c2697d0a..225a1398b0 100644 --- a/ui-ngx/src/app/core/utils.ts +++ b/ui-ngx/src/app/core/utils.ts @@ -127,6 +127,10 @@ export function isEmpty(obj: any): boolean { return true; } +export function isLiteralObject(value: any) { + return (!!value) && (value.constructor === Object); +} + export function formatValue(value: any, dec?: number, units?: string, showZeroDecimals?: boolean): string | undefined { if (isDefinedAndNotNull(value) && isNumeric(value) && (isDefinedAndNotNull(dec) || isDefinedAndNotNull(units) || Number(value).toString() === value)) { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/json-input-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/json-input-widget.component.html new file mode 100644 index 0000000000..81c06fe0f7 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/json-input-widget.component.html @@ -0,0 +1,69 @@ + + +
+
+
+
+ +
+
+ + +
+
+ +
+
+ {{ 'widgets.input-widgets.no-entity-selected' | translate }} +
+
+ {{ 'widgets.input-widgets.no-datakey-selected' | translate }} +
+
+ {{ errorMessage | translate }} +
+
+
+
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/json-input-widget.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/json-input-widget.component.scss new file mode 100644 index 0000000000..f097de5afb --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/json-input-widget.component.scss @@ -0,0 +1,36 @@ +/** + * Copyright © 2016-2021 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.tb-json-input { + width: 100%; + height: 100%; + padding: 5px; + + &__form { + overflow: auto; + height: 100%; + } + + &__error { + text-align: center; + font-size: 18px; + color: #a0a0a0; + } +} + +.tb-toast { + font-size: 14px!important; +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/json-input-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/json-input-widget.component.ts new file mode 100644 index 0000000000..77c0f907ce --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/json-input-widget.component.ts @@ -0,0 +1,227 @@ +/// +/// Copyright © 2016-2021 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, Input, OnInit } from '@angular/core'; +import { PageComponent } from '@shared/components/page.component'; +import { WidgetContext } from '@home/models/widget-component.models'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { UtilsService } from '@core/services/utils.service'; +import { TranslateService } from '@ngx-translate/core'; +import { Datasource, DatasourceData, DatasourceType, WidgetConfig } from '@shared/models/widget.models'; +import { IWidgetSubscription } from '@core/api/widget-api.models'; +import { FormBuilder, FormGroup, ValidatorFn, Validators } from '@angular/forms'; +import { AttributeService } from '@core/http/attribute.service'; +import { AttributeData, AttributeScope, DataKeyType, LatestTelemetry } from '@shared/models/telemetry/telemetry.models'; +import { EntityId } from '@shared/models/id/entity-id'; +import { EntityType } from '@shared/models/entity-type.models'; +import { createLabelFromDatasource } from '@core/utils'; +import { Observable } from 'rxjs'; + +enum JsonInputWidgetMode { + ATTRIBUTE = 'ATTRIBUTE', + TIME_SERIES = 'TIME_SERIES', +} + +interface JsonInputWidgetSettings { + widgetTitle: string; + widgetMode: JsonInputWidgetMode; + attributeScope?: AttributeScope; + showLabel: boolean; + labelValue?: string; + attributeRequired: boolean; + showResultMessage: boolean; +} + +@Component({ + selector: 'tb-json-input-widget ', + templateUrl: './json-input-widget.component.html', + styleUrls: ['./json-input-widget.component.scss'] +}) +export class JsonInputWidgetComponent extends PageComponent implements OnInit { + + @Input() + ctx: WidgetContext; + + public settings: JsonInputWidgetSettings; + private widgetConfig: WidgetConfig; + private subscription: IWidgetSubscription; + private datasource: Datasource; + + labelValue: string; + + entityDetected = false; + dataKeyDetected = false; + isValidParameter = false; + errorMessage: string; + + isFocused: boolean; + originalValue: any; + attributeUpdateFormGroup: FormGroup; + + toastTargetId = 'json-input-widget' + this.utils.guid(); + + constructor(protected store: Store, + private utils: UtilsService, + private fb: FormBuilder, + private attributeService: AttributeService, + private translate: TranslateService) { + super(store); + } + + ngOnInit(): void { + this.ctx.$scope.jsonInputWidget = this; + this.settings = this.ctx.settings; + this.widgetConfig = this.ctx.widgetConfig; + this.subscription = this.ctx.defaultSubscription; + this.datasource = this.subscription.datasources[0]; + this.initializeConfig(); + this.validateDatasources(); + this.buildForm(); + this.ctx.updateWidgetParams(); + } + + private initializeConfig() { + if (this.settings.widgetTitle && this.settings.widgetTitle.length) { + const title = createLabelFromDatasource(this.datasource, this.settings.widgetTitle); + this.ctx.widgetTitle = this.utils.customTranslation(title, title); + } else { + this.ctx.widgetTitle = this.ctx.widgetConfig.title; + } + + if (this.settings.labelValue && this.settings.labelValue.length) { + const label = createLabelFromDatasource(this.datasource, this.settings.labelValue); + this.labelValue = this.utils.customTranslation(label, label); + } else { + this.labelValue = this.translate.instant('widgets.input-widgets.value'); + } + } + + private validateDatasources() { + if (this.datasource?.type === DatasourceType.entity) { + this.entityDetected = true; + if (this.datasource.dataKeys.length) { + this.dataKeyDetected = true; + + if (this.settings.widgetMode === JsonInputWidgetMode.ATTRIBUTE) { + if (this.datasource.dataKeys[0].type === DataKeyType.attribute) { + if (this.settings.attributeScope === AttributeScope.SERVER_SCOPE || this.datasource.entityType === EntityType.DEVICE) { + this.isValidParameter = true; + } else { + this.errorMessage = 'widgets.input-widgets.not-allowed-entity'; + } + } else { + this.errorMessage = 'widgets.input-widgets.no-attribute-selected'; + } + } else { + if (this.datasource.dataKeys[0].type === DataKeyType.timeseries) { + this.isValidParameter = true; + } else { + this.errorMessage = 'widgets.input-widgets.no-timeseries-selected'; + } + } + + } + } + } + + private buildForm() { + const validators: ValidatorFn[] = []; + if (this.settings.attributeRequired) { + validators.push(Validators.required); + } + this.attributeUpdateFormGroup = this.fb.group({ + currentValue: [{}, validators] + }); + this.attributeUpdateFormGroup.valueChanges.subscribe( () => { + this.ctx.detectChanges(); + }); + } + + private updateWidgetData(data: Array) { + if (this.isValidParameter) { + let value = {}; + if (data[0].data[0][1] !== '') { + try { + value = JSON.parse(data[0].data[0][1]); + } catch (e) { + value = data[0].data[0][1]; + } + } + this.originalValue = value; + if (!this.isFocused) { + this.attributeUpdateFormGroup.get('currentValue').patchValue(this.originalValue); + this.ctx.detectChanges(); + } + } + } + + public onDataUpdated() { + this.updateWidgetData(this.subscription.data); + } + + public save() { + this.isFocused = false; + + const attributeToSave: AttributeData = { + key: this.datasource.dataKeys[0].name, + value: this.attributeUpdateFormGroup.get('currentValue').value + }; + + const entityId: EntityId = { + entityType: this.datasource.entityType, + id: this.datasource.entityId + }; + + let saveAttributeObservable: Observable; + if (this.settings.widgetMode === JsonInputWidgetMode.ATTRIBUTE) { + saveAttributeObservable = this.attributeService.saveEntityAttributes( + entityId, + this.settings.attributeScope, + [ attributeToSave ], + {} + ); + } else { + saveAttributeObservable = this.attributeService.saveEntityTimeseries( + entityId, + LatestTelemetry.LATEST_TELEMETRY, + [ attributeToSave ], + {} + ); + } + saveAttributeObservable.subscribe( + () => { + this.attributeUpdateFormGroup.markAsPristine(); + this.ctx.detectChanges(); + if (this.settings.showResultMessage) { + this.ctx.showSuccessToast(this.translate.instant('widgets.input-widgets.update-successful'), + 1000, 'bottom', 'left', this.toastTargetId); + } + }, + () => { + if (this.settings.showResultMessage) { + this.ctx.showErrorToast(this.translate.instant('widgets.input-widgets.update-failed'), + 'bottom', 'left', this.toastTargetId); + } + }); + } + + public discard() { + this.attributeUpdateFormGroup.reset({currentValue: this.originalValue}, {emitEvent: false}); + this.attributeUpdateFormGroup.markAsPristine(); + this.isFocused = false; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts index 2d080bc8e9..db501c15eb 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts @@ -37,6 +37,7 @@ import { GatewayFormComponent } from './lib/gateway/gateway-form.component'; import { ImportExportService } from '@home/components/import-export/import-export.service'; import { NavigationCardsWidgetComponent } from '@home/components/widget/lib/navigation-cards-widget.component'; import { NavigationCardWidgetComponent } from '@home/components/widget/lib/navigation-card-widget.component'; +import { JsonInputWidgetComponent } from '@home/components/widget/lib/json-input-widget.component'; @NgModule({ declarations: @@ -49,6 +50,7 @@ import { NavigationCardWidgetComponent } from '@home/components/widget/lib/navig EntitiesHierarchyWidgetComponent, DateRangeNavigatorWidgetComponent, DateRangeNavigatorPanelComponent, + JsonInputWidgetComponent, MultipleInputWidgetComponent, TripAnimationComponent, PhotoCameraInputWidgetComponent, @@ -69,6 +71,7 @@ import { NavigationCardWidgetComponent } from '@home/components/widget/lib/navig EntitiesHierarchyWidgetComponent, RpcWidgetsModule, DateRangeNavigatorWidgetComponent, + JsonInputWidgetComponent, MultipleInputWidgetComponent, TripAnimationComponent, PhotoCameraInputWidgetComponent, diff --git a/ui-ngx/src/app/shared/components/json-object-edit.component.ts b/ui-ngx/src/app/shared/components/json-object-edit.component.ts index 3573c69491..f5bc916ae4 100644 --- a/ui-ngx/src/app/shared/components/json-object-edit.component.ts +++ b/ui-ngx/src/app/shared/components/json-object-edit.component.ts @@ -22,7 +22,7 @@ import { ActionNotificationHide, ActionNotificationShow } from '@core/notificati import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { CancelAnimationFrame, RafService } from '@core/services/raf.service'; -import { guid } from '@core/utils'; +import { guid, isDefinedAndNotNull, isLiteralObject } from '@core/utils'; import { ResizeObserver } from '@juggle/resize-observer'; import { getAce } from '@shared/models/ace/ace.models'; @@ -224,7 +224,7 @@ export class JsonObjectEditComponent implements OnInit, ControlValueAccessor, Va this.contentValue = ''; this.objectValid = false; try { - if (this.modelValue) { + if (isDefinedAndNotNull(this.modelValue)) { this.contentValue = JSON.stringify(this.modelValue, undefined, 2); this.objectValid = true; } else { @@ -250,6 +250,9 @@ export class JsonObjectEditComponent implements OnInit, ControlValueAccessor, Va if (this.contentValue && this.contentValue.length > 0) { try { data = JSON.parse(this.contentValue); + if (!isLiteralObject(data)) { + throw new TypeError(`Value is not a valid JSON`); + } this.objectValid = true; this.validationError = ''; } catch (ex) { From 7fb88eca2045adaf4b132b4994e7107f032f2ef1 Mon Sep 17 00:00:00 2001 From: mdheer Date: Tue, 6 Apr 2021 00:34:31 +0200 Subject: [PATCH 13/69] added functionality that lets you specify the path to the desired custom logo from the settings menu --- .../dashboard-page.component.ts | 22 ++++++++++++++---- .../dashboard-settings-dialog.component.html | 4 ++++ .../dashboard-settings-dialog.component.ts | 6 +++++ .../src/app/shared/models/dashboard.models.ts | 1 + .../assets/locale/locale.constant-en_US.json | 2 ++ ui-ngx/src/assets/nasa.png | Bin 0 -> 99886 bytes 6 files changed, 30 insertions(+), 5 deletions(-) create mode 100644 ui-ngx/src/assets/nasa.png diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts index 2fc5dc98de..8671c7d195 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts @@ -120,6 +120,8 @@ import { DisplayWidgetTypesPanelData, WidgetTypes } from '@home/components/dashboard-page/widget-types-panel.component'; +import { LogoComponent } from '@app/shared/components/logo.component'; +import { concat } from 'lodash'; // @dynamic @Component({ @@ -189,10 +191,10 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC currentDashboardScope: DashboardPageScope; addingLayoutCtx: DashboardPageLayoutContext; - - dashboard_logo = 'assets/example_logo.png'; - + logo_name_local = 'example_logo.png'; + dashboard_logo = 'assets/' + this.logo_name_local; + dashboardCtx: DashboardContext = { instanceId: this.utils.guid(), getDashboard: () => this.dashboard, @@ -490,6 +492,11 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC return true; } } + + public updateLogoName(): string { + this.dashboard_logo = 'assets/' + this.dashboard.configuration.settings.logoName + return this.dashboard.configuration.settings.logoName; + } public showRightLayoutSwitch(): boolean { return this.isMobile && this.layouts.right.show; @@ -611,11 +618,16 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], data: { settings: deepClone(this.dashboard.configuration.settings), - gridSettings + gridSettings, } - }).afterClosed().subscribe((data) => { + } + ).afterClosed().subscribe((data) => { if (data) { this.dashboard.configuration.settings = data.settings; + if (this.dashboard.configuration.settings) + { + this.updateLogoName(); + } const newGridSettings = data.gridSettings; if (newGridSettings) { const layout = this.dashboard.configuration.states[layoutKeys.state].layouts[layoutKeys.layout]; diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.html b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.html index 89d4510c44..f745a8508d 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.html @@ -51,6 +51,10 @@ openOnInput formControlName="titleColor"> + + dashboard.display-dashboard-enterLogoName + +
diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.ts index 195f7e6433..9aae5c0aaf 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.ts @@ -34,6 +34,7 @@ export interface DashboardSettingsDialogData { gridSettings?: GridSettings; } + @Component({ selector: 'tb-dashboard-settings-dialog', templateUrl: './dashboard-settings-dialog.component.html', @@ -45,13 +46,16 @@ export class DashboardSettingsDialogComponent extends DialogComponent, protected router: Router, @@ -69,6 +73,7 @@ export class DashboardSettingsDialogComponent extends DialogComponentjlmY<6&Kb?>BH({K`=}@< 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 z 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 JiR{Cm{{iIW1R?+c literal 0 HcmV?d00001 From bb90b2f1375514b32a802037a4d1e90017acb950 Mon Sep 17 00:00:00 2001 From: YevhenBondarenko Date: Tue, 6 Apr 2021 07:38:52 +0300 Subject: [PATCH 14/69] lwm2m queue mode --- .../secure/LwM2MBootstrapSecurityStore.java | 7 +++--- .../lwm2m/server/LwM2mQueuedRequest.java | 20 ++++++++++++++++ .../lwm2m/server/LwM2mTransportRequest.java | 23 ++++++++++++------- .../server/LwM2mTransportServiceImpl.java | 19 +++++++++++---- .../lwm2m/server/client/LwM2mClient.java | 5 ++++ 5 files changed, 58 insertions(+), 16 deletions(-) create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mQueuedRequest.java diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/secure/LwM2MBootstrapSecurityStore.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/secure/LwM2MBootstrapSecurityStore.java index ee2b3bac20..52638a99e2 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/secure/LwM2MBootstrapSecurityStore.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/secure/LwM2MBootstrapSecurityStore.java @@ -40,7 +40,7 @@ import org.thingsboard.server.transport.lwm2m.utils.TypeServer; import java.io.IOException; import java.security.GeneralSecurityException; -import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.UUID; @@ -70,8 +70,7 @@ public class LwM2MBootstrapSecurityStore implements BootstrapSecurityStore { @Override public List getAllByEndpoint(String endPoint) { - String endPointKey = endPoint; - ReadResultSecurityStore store = lwM2MCredentialsSecurityInfoValidator.createAndValidateCredentialsSecurityInfo(endPointKey, TypeServer.BOOTSTRAP); + ReadResultSecurityStore store = lwM2MCredentialsSecurityInfoValidator.createAndValidateCredentialsSecurityInfo(endPoint, TypeServer.BOOTSTRAP); if (store.getBootstrapJsonCredential() != null && store.getSecurityMode() < LwM2MSecurityMode.DEFAULT_MODE.code) { /** add value to store from BootstrapJson */ this.setBootstrapConfigScurityInfo(store); @@ -87,7 +86,7 @@ public class LwM2MBootstrapSecurityStore implements BootstrapSecurityStore { } catch (InvalidConfigurationException e) { log.error("", e); } - return store.getSecurityInfo() == null ? null : Arrays.asList(store.getSecurityInfo()); + return store.getSecurityInfo() == null ? null : Collections.singletonList(store.getSecurityInfo()); } } return null; diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mQueuedRequest.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mQueuedRequest.java new file mode 100644 index 0000000000..dfc88a3331 --- /dev/null +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mQueuedRequest.java @@ -0,0 +1,20 @@ +/** + * Copyright © 2016-2021 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.lwm2m.server; + +public interface LwM2mQueuedRequest { + void send(); +} diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportRequest.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportRequest.java index ad1e542ce1..e237146c8a 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportRequest.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportRequest.java @@ -16,6 +16,7 @@ package org.thingsboard.server.transport.lwm2m.server; import lombok.extern.slf4j.Slf4j; +import org.eclipse.californium.core.coap.CoAP; import org.eclipse.californium.core.coap.Response; import org.eclipse.leshan.core.attributes.Attribute; import org.eclipse.leshan.core.attributes.AttributeSet; @@ -34,6 +35,7 @@ import org.eclipse.leshan.core.request.ObserveRequest; import org.eclipse.leshan.core.request.ReadRequest; import org.eclipse.leshan.core.request.WriteAttributesRequest; import org.eclipse.leshan.core.request.WriteRequest; +import org.eclipse.leshan.core.request.exception.ClientSleepingException; import org.eclipse.leshan.core.response.CancelObservationResponse; import org.eclipse.leshan.core.response.DeleteResponse; import org.eclipse.leshan.core.response.DiscoverResponse; @@ -58,7 +60,6 @@ import java.util.Date; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; -import static org.eclipse.californium.core.coap.CoAP.ResponseCode.isSuccess; import static org.eclipse.leshan.core.attributes.Attribute.MINIMUM_PERIOD; import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.DEFAULT_TIMEOUT; import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.GET_TYPE_OPER_DISCOVER; @@ -122,7 +123,7 @@ public class LwM2mTransportRequest { DownlinkRequest request = null; ContentFormat contentFormat = contentFormatParam != null ? ContentFormat.fromName(contentFormatParam.toUpperCase()) : null; LwM2mClient lwM2MClient = lwM2mClientContext.getLwM2mClientWithReg(registration, null); - ResourceModel resource = lwM2MClient.getResourceModel(target); + ResourceModel resource = lwM2MClient.getResourceModel(targetIdVer); timeoutInMs = timeoutInMs > 0 ? timeoutInMs : DEFAULT_TIMEOUT; switch (typeOper) { case GET_TYPE_OPER_READ: @@ -215,13 +216,19 @@ public class LwM2mTransportRequest { request = new WriteAttributesRequest(resultIds.getObjectId(), attrSet); } break; - default: } if (request != null) { - this.sendRequest(registration, lwM2MClient, request, timeoutInMs); - } - else { + try { + this.sendRequest(registration, lwM2MClient, request, timeoutInMs); + } catch (ClientSleepingException e) { + DownlinkRequest finalRequest = request; + long finalTimeoutInMs = timeoutInMs; + lwM2MClient.getQueuedRequests().add(() -> sendRequest(registration, lwM2MClient, finalRequest, finalTimeoutInMs)); + } catch (Exception e) { + log.error("[{}] [{}] [{}] Failed to send downlink.", registration.getEndpoint(), targetIdVer, typeOper, e); + } + } else { log.error("[{}], [{}] - [{}] error SendRequest", registration.getEndpoint(), typeOper, targetIdVer); } } @@ -240,7 +247,7 @@ public class LwM2mTransportRequest { if (!lwM2MClient.isInit()) { lwM2MClient.initValue(this.serviceImpl, convertToIdVerFromObjectId(request.getPath().toString(), registration)); } - if (isSuccess(((Response) response.getCoapResponse()).getCode())) { + if (CoAP.ResponseCode.isSuccess(((Response) response.getCoapResponse()).getCode())) { this.handleResponse(registration, request.getPath().toString(), response, request); if (request instanceof WriteRequest && ((WriteRequest) request).isReplaceRequest()) { String msg = String.format("%s: sendRequest Replace: CoapCde - %s Lwm2m code - %d name - %s Resource path - %s value - %s SendRequest to Client", @@ -283,7 +290,7 @@ public class LwM2mTransportRequest { case FLOAT: // Double return (contentFormat == null) ? new WriteRequest(objectId, instanceId, resourceId, Double.parseDouble(value.toString())) : new WriteRequest(contentFormat, objectId, instanceId, resourceId, Double.parseDouble(value.toString())); case TIME: // Date - Date date = new Date(Long.decode(value.toString())); + Date date = new Date(Long.decode(value.toString())); return (contentFormat == null) ? new WriteRequest(objectId, instanceId, resourceId, date) : new WriteRequest(contentFormat, objectId, instanceId, resourceId, date); case OPAQUE: // byte[] value, base64 return (contentFormat == null) ? new WriteRequest(objectId, instanceId, resourceId, Hex.decodeHex(value.toString().toCharArray())) : new WriteRequest(contentFormat, objectId, instanceId, resourceId, Hex.decodeHex(value.toString().toCharArray())); diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportServiceImpl.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportServiceImpl.java index f8d2716808..96da8721c8 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportServiceImpl.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportServiceImpl.java @@ -146,9 +146,9 @@ public class LwM2mTransportServiceImpl implements LwM2mTransportService { * Next -> Create new LwM2MClient for current session -> setModelClient... * * @param registration - Registration LwM2M Client - * @param previousObsersations - may be null + * @param previousObservations - may be null */ - public void onRegistered(Registration registration, Collection previousObsersations) { + public void onRegistered(Registration registration, Collection previousObservations) { executorRegistered.submit(() -> { try { log.warn("[{}] [{{}] Client: create after Registration", registration.getEndpoint(), registration.getId()); @@ -188,6 +188,13 @@ public class LwM2mTransportServiceImpl implements LwM2mTransportService { LwM2mClient lwM2MClient = this.lwM2mClientContext.getLwM2MClient(sessionInfo); if (lwM2MClient.getDeviceId() == null && lwM2MClient.getProfileId() == null) { initLwM2mClient(lwM2MClient, sessionInfo); + } else { + if (registration.getBindingMode().useQueueMode()) { + LwM2mQueuedRequest request; + while ((request = lwM2MClient.getQueuedRequests().poll()) != null) { + request.send(); + } + } } log.info("Client: [{}] updatedReg [{}] name [{}] profile ", registration.getId(), registration.getEndpoint(), sessionInfo.getDeviceType()); @@ -206,7 +213,7 @@ public class LwM2mTransportServiceImpl implements LwM2mTransportService { * !!! Warn: if have not finishing unReg, then this operation will be finished on next Client`s connect */ public void unReg(Registration registration, Collection observations) { - executorUnRegistered.submit(() -> { + executorUnRegistered.submit(() -> { try { this.setCancelObservations(registration); this.sendLogsToThingsboard(LOG_LW2M_INFO + ": Client unRegistration", registration); @@ -239,8 +246,11 @@ public class LwM2mTransportServiceImpl implements LwM2mTransportService { } } + @Override public void onSleepingDev(Registration registration) { log.info("[{}] [{}] Received endpoint Sleeping version event", registration.getId(), registration.getEndpoint()); + this.sendLogsToThingsboard(LOG_LW2M_INFO + ": Client is sleeping!", registration); + //TODO: associate endpointId with device information. } @@ -417,6 +427,7 @@ public class LwM2mTransportServiceImpl implements LwM2mTransportService { */ protected void onAwakeDev(Registration registration) { log.info("[{}] [{}] Received endpoint Awake version event", registration.getId(), registration.getEndpoint()); + this.sendLogsToThingsboard(LOG_LW2M_INFO + ": Client is awake!", registration); //TODO: associate endpointId with device information. } @@ -612,7 +623,7 @@ public class LwM2mTransportServiceImpl implements LwM2mTransportService { if (GET_TYPE_OPER_READ.equals(typeOper)) { result = JacksonUtil.fromString(lwM2MClientProfile.getPostAttributeProfile().toString(), new TypeReference<>() { }); - result.addAll(JacksonUtil.fromString(lwM2MClientProfile.getPostTelemetryProfile().toString(), new TypeReference<>() { + result.addAll(JacksonUtil.convertValue(lwM2MClientProfile.getPostTelemetryProfile().toString(), new TypeReference<>() { })); } else { result = JacksonUtil.fromString(lwM2MClientProfile.getPostObserveProfile().toString(), new TypeReference<>() { diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClient.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClient.java index bdb783e3ff..59786e37c6 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClient.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClient.java @@ -25,13 +25,16 @@ import org.eclipse.leshan.server.registration.Registration; import org.eclipse.leshan.server.security.SecurityInfo; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceCredentialsResponseMsg; +import org.thingsboard.server.transport.lwm2m.server.LwM2mQueuedRequest; import org.thingsboard.server.transport.lwm2m.server.LwM2mTransportServiceImpl; import java.util.List; import java.util.Map; +import java.util.Queue; import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.CopyOnWriteArrayList; import java.util.stream.Collectors; @@ -54,6 +57,7 @@ public class LwM2mClient implements Cloneable { private final Map resources; private final Map delayedRequests; private final List pendingRequests; + private final Queue queuedRequests; private boolean init; public Object clone() throws CloneNotSupportedException { @@ -71,6 +75,7 @@ public class LwM2mClient implements Cloneable { this.profileId = profileId; this.sessionId = sessionId; this.init = false; + this.queuedRequests = new ConcurrentLinkedQueue<>(); } public boolean saveResourceValue(String pathRez, LwM2mResource rez, LwM2mModelProvider modelProvider) { From 45277c87898fa2be6c70c7b4b41df2d0b2e76bb8 Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Tue, 6 Apr 2021 11:57:48 +0300 Subject: [PATCH 15/69] UI: Fixed error: Expression has changed after it was checked. --- .../modules/home/components/dashboard/dashboard.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.html b/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.html index d893ec848f..c050b524cf 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.html +++ b/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.html @@ -80,7 +80,7 @@ (mousedown)="widgetMouseDown($event, widget)" (click)="widgetClicked($event, widget)" (contextmenu)="openWidgetContextMenu($event, widget)"> -

+
Date: Wed, 7 Apr 2021 18:03:02 +0300 Subject: [PATCH 16/69] UI: Fixed incorrect calculate minIntervalLimit --- ui-ngx/src/app/core/services/time.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui-ngx/src/app/core/services/time.service.ts b/ui-ngx/src/app/core/services/time.service.ts index 47c8d8a456..410b150e48 100644 --- a/ui-ngx/src/app/core/services/time.service.ts +++ b/ui-ngx/src/app/core/services/time.service.ts @@ -79,7 +79,7 @@ export class TimeService { public boundMinInterval(min: number): number { if (isDefined(min)) { - min = Math.floor(min / 1000) * 1000; + min = Math.ceil(min / 1000) * 1000; } return this.toBound(min, MIN_INTERVAL, MAX_INTERVAL, MIN_INTERVAL); } From 5672d2c4511977736efa0202e4d0b1453560156f Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Fri, 9 Apr 2021 11:43:11 +0300 Subject: [PATCH 17/69] UI: Added multiple-file-input; Add support load multiple resource --- ui-ngx/src/app/core/http/resource.service.ts | 23 +++- .../resources-library-table-config.resolve.ts | 19 +++- .../resource/resources-library.component.html | 4 +- .../resource/resources-library.component.ts | 23 +++- .../components/file-input.component.html | 4 +- .../shared/components/file-input.component.ts | 107 +++++++++++++----- .../src/app/shared/models/resource.models.ts | 5 + 7 files changed, 145 insertions(+), 40 deletions(-) diff --git a/ui-ngx/src/app/core/http/resource.service.ts b/ui-ngx/src/app/core/http/resource.service.ts index d8bc18291b..1b0796a389 100644 --- a/ui-ngx/src/app/core/http/resource.service.ts +++ b/ui-ngx/src/app/core/http/resource.service.ts @@ -18,10 +18,10 @@ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { PageLink } from '@shared/models/page/page-link'; import { defaultHttpOptionsFromConfig, RequestConfig } from '@core/http/http-utils'; -import { Observable } from 'rxjs'; +import { forkJoin, Observable, of } from 'rxjs'; import { PageData } from '@shared/models/page/page-data'; import { Resource, ResourceInfo } from '@shared/models/resource.models'; -import { map } from 'rxjs/operators'; +import { catchError, map, mergeMap } from 'rxjs/operators'; @Injectable({ providedIn: 'root' @@ -70,6 +70,25 @@ export class ResourceService { ); } + public saveResources(resources: Resource[], config?: RequestConfig) { + let partSize = 100; + partSize = resources.length > partSize ? partSize : resources.length; + const resourceObservables = []; + for (let i = 0; i < partSize; i++) { + resourceObservables.push(this.saveResource(resources[i], config).pipe(catchError(error => of(error)))); + } + return forkJoin(resourceObservables).pipe( + mergeMap((resource) => { + resources.splice(0, partSize); + if (resources.length) { + return this.saveResources(resources, config); + } else { + return of(resource); + } + }) + ); + } + public saveResource(resource: Resource, config?: RequestConfig): Observable { return this.http.post('/api/resource', resource, defaultHttpOptionsFromConfig(config)); } diff --git a/ui-ngx/src/app/modules/home/pages/resource/resources-library-table-config.resolve.ts b/ui-ngx/src/app/modules/home/pages/resource/resources-library-table-config.resolve.ts index 55dc474a3d..bdff99ff06 100644 --- a/ui-ngx/src/app/modules/home/pages/resource/resources-library-table-config.resolve.ts +++ b/ui-ngx/src/app/modules/home/pages/resource/resources-library-table-config.resolve.ts @@ -85,10 +85,27 @@ export class ResourcesLibraryTableConfigResolver implements Resolve this.resourceService.getResources(pageLink) as Observable>; this.config.loadEntity = id => this.resourceService.getResource(id.id); - this.config.saveEntity = resource => this.resourceService.saveResource(resource); + this.config.saveEntity = resource => this.saveResource(resource); this.config.deleteEntity = id => this.resourceService.deleteResource(id.id); } + saveResource(resource) { + if (Array.isArray(resource.data)) { + const resources = []; + resource.data.forEach((data, index) => { + resources.push({ + resourceType: resource.resourceType, + data, + fileName: resource.fileName[index], + title: resource.title + }); + }); + return this.resourceService.saveResources(resources, {resendRequest: true}); + } else { + return this.resourceService.saveResource(resource); + } + } + resolve(): EntityTableConfig { this.config.tableTitle = this.translate.instant('resource.resources-library'); const authUser = getCurrentAuthUser(this.store); diff --git a/ui-ngx/src/app/modules/home/pages/resource/resources-library.component.html b/ui-ngx/src/app/modules/home/pages/resource/resources-library.component.html index e4fa600f9c..60ac91b120 100644 --- a/ui-ngx/src/app/modules/home/pages/resource/resources-library.component.html +++ b/ui-ngx/src/app/modules/home/pages/resource/resources-library.component.html @@ -44,9 +44,11 @@ diff --git a/ui-ngx/src/app/modules/home/pages/resource/resources-library.component.ts b/ui-ngx/src/app/modules/home/pages/resource/resources-library.component.ts index 560b8a3d43..5f7a35c5b9 100644 --- a/ui-ngx/src/app/modules/home/pages/resource/resources-library.component.ts +++ b/ui-ngx/src/app/modules/home/pages/resource/resources-library.component.ts @@ -29,7 +29,7 @@ import { ResourceTypeMIMETypes, ResourceTypeTranslationMap } from '@shared/models/resource.models'; -import { distinctUntilChanged, takeUntil } from 'rxjs/operators'; +import { pairwise, startWith, takeUntil } from 'rxjs/operators'; @Component({ selector: 'tb-resources-library', @@ -54,15 +54,22 @@ export class ResourcesLibraryComponent extends EntityComponent impleme ngOnInit() { super.ngOnInit(); this.entityForm.get('resourceType').valueChanges.pipe( - distinctUntilChanged((oldValue, newValue) => [oldValue, newValue].includes(this.resourceType.LWM2M_MODEL)), + startWith(ResourceType.LWM2M_MODEL), + pairwise(), takeUntil(this.destroy$) - ).subscribe((type) => { + ).subscribe(([previousType, type]) => { + if (previousType === this.resourceType.LWM2M_MODEL) { + this.entityForm.get('title').setValidators(Validators.required); + this.entityForm.get('title').updateValueAndValidity({emitEvent: false}); + } if (type === this.resourceType.LWM2M_MODEL) { this.entityForm.get('title').clearValidators(); - } else { - this.entityForm.get('title').setValidators(Validators.required); + this.entityForm.get('title').updateValueAndValidity({emitEvent: false}); } - this.entityForm.get('title').updateValueAndValidity({emitEvent: false}); + this.entityForm.patchValue({ + data: null, + fileName: null + }, {emitEvent: false}); }); } @@ -119,4 +126,8 @@ export class ResourcesLibraryComponent extends EntityComponent impleme return '*/*'; } } + + convertToBase64File(data: string): string { + return window.btoa(data); + } } diff --git a/ui-ngx/src/app/shared/components/file-input.component.html b/ui-ngx/src/app/shared/components/file-input.component.html index ea0ae424bb..5f3263522c 100644 --- a/ui-ngx/src/app/shared/components/file-input.component.html +++ b/ui-ngx/src/app/shared/components/file-input.component.html @@ -18,7 +18,7 @@
+ [flowConfig]="{allowDuplicateUploads: true}">
diff --git a/ui-ngx/src/app/shared/components/file-input.component.ts b/ui-ngx/src/app/shared/components/file-input.component.ts index b91fba1def..0fcaf8b790 100644 --- a/ui-ngx/src/app/shared/components/file-input.component.ts +++ b/ui-ngx/src/app/shared/components/file-input.component.ts @@ -17,6 +17,7 @@ import { AfterViewInit, Component, + ElementRef, EventEmitter, forwardRef, Input, @@ -102,17 +103,34 @@ export class FileInputComponent extends PageComponent implements AfterViewInit, existingFileName: string; @Input() - convertToBase64 = false; + readAsBinary = false; + + private multipleFileValue = false; + + @Input() + set multipleFile(value: boolean) { + this.multipleFileValue = value; + if (this.flow?.flowJs) { + this.updateMultipleFileMode(this.multipleFile); + } + } + + get multipleFile(): boolean { + return this.multipleFileValue; + } @Output() - fileNameChanged = new EventEmitter(); + fileNameChanged = new EventEmitter(); - fileName: string; + fileName: string | string[]; fileContent: any; @ViewChild('flow', {static: true}) flow: FlowDirective; + @ViewChild('flowInput', {static: true}) + flowInput: ElementRef; + autoUploadSubscription: Subscription; private propagateChange = null; @@ -125,34 +143,60 @@ export class FileInputComponent extends PageComponent implements AfterViewInit, ngAfterViewInit() { this.autoUploadSubscription = this.flow.events$.subscribe(event => { - if (event.type === 'fileAdded') { - const file = event.event[0] as flowjs.FlowFile; - if (this.filterFile(file)) { - const reader = new FileReader(); - reader.onload = (loadEvent) => { - if (typeof reader.result === 'string') { - const fileContent = this.convertToBase64 ? window.btoa(reader.result) : reader.result; - if (fileContent && fileContent.length > 0) { - if (this.contentConvertFunction) { - this.fileContent = this.contentConvertFunction(fileContent); - } else { - this.fileContent = fileContent; - } - if (this.fileContent) { - this.fileName = file.name; - } else { - this.fileName = null; - } - this.updateModel(); - } + if (event.type === 'filesAdded') { + const readers = []; + (event.event[0] as flowjs.FlowFile[]).forEach(file => { + if (this.filterFile(file)) { + readers.push(this.readerAsFile(file)); + } + }); + if (readers.length) { + Promise.all(readers).then((filesContent) => { + filesContent = filesContent.filter(content => content.fileContent != null); + if (filesContent.length === 1) { + this.fileContent = filesContent[0].fileContent; + this.fileName = filesContent[0].fileName; + this.updateModel(); + } else if (filesContent.length > 1) { + this.fileContent = filesContent.map(content => content.fileContent); + this.fileName = filesContent.map(content => content.fileName); + this.updateModel(); + } + }); + } + } + }); + if (!this.multipleFile) { + this.updateMultipleFileMode(this.multipleFile); + } + } + + private readerAsFile(file: flowjs.FlowFile): Promise { + return new Promise((resolve) => { + const reader = new FileReader(); + reader.onload = () => { + let fileName = null; + let fileContent = null; + if (typeof reader.result === 'string') { + fileContent = reader.result; + if (fileContent && fileContent.length > 0) { + if (this.contentConvertFunction) { + fileContent = this.contentConvertFunction(fileContent); + } + if (fileContent) { + fileName = file.name; } - }; - if (this.convertToBase64) { - reader.readAsBinaryString(file.file); - } else { - reader.readAsText(file.file); } } + resolve({fileContent, fileName}); + }; + reader.onerror = () => { + resolve({fileContent: null, fileName: null}); + }; + if (this.readAsBinary) { + reader.readAsBinaryString(file.file); + } else { + reader.readAsText(file.file); } }); } @@ -207,4 +251,11 @@ export class FileInputComponent extends PageComponent implements AfterViewInit, this.fileContent = null; this.updateModel(); } + + private updateMultipleFileMode(multiple: boolean) { + this.flow.flowJs.opts.singleFile = !multiple; + if (!multiple) { + this.flowInput.nativeElement.removeAttribute('multiple'); + } + } } diff --git a/ui-ngx/src/app/shared/models/resource.models.ts b/ui-ngx/src/app/shared/models/resource.models.ts index 805a046673..154f3c65f9 100644 --- a/ui-ngx/src/app/shared/models/resource.models.ts +++ b/ui-ngx/src/app/shared/models/resource.models.ts @@ -59,3 +59,8 @@ export interface Resource extends ResourceInfo { data: string; fileName: string; } + +export interface Resources extends ResourceInfo { + data: string|string[]; + fileName: string|string[]; +} From 0acb4272a59c37e1aea828e9e05e3a5c84488275 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Mon, 12 Apr 2021 19:11:41 +0300 Subject: [PATCH 18/69] Create default edge rule chains oauth2 --- .../security/auth/oauth2/AbstractOAuth2ClientMapper.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/AbstractOAuth2ClientMapper.java b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/AbstractOAuth2ClientMapper.java index b3516bd364..4f38cf7a23 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/AbstractOAuth2ClientMapper.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/AbstractOAuth2ClientMapper.java @@ -17,8 +17,10 @@ package org.thingsboard.server.service.security.auth.oauth2; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; @@ -85,6 +87,10 @@ public abstract class AbstractOAuth2ClientMapper { @Autowired protected TbClusterService tbClusterService; + @Value("${edges.enabled}") + @Getter + private boolean edgesEnabled; + private final Lock userCreationLock = new ReentrantLock(); protected SecurityUser getOrCreateSecurityUserFromOAuth2User(OAuth2User oauth2User, OAuth2ClientRegistrationInfo clientRegistration) { @@ -171,6 +177,9 @@ public abstract class AbstractOAuth2ClientMapper { tenant.setTitle(tenantName); tenant = tenantService.saveTenant(tenant); installScripts.createDefaultRuleChains(tenant.getId()); + if (edgesEnabled) { + installScripts.createDefaultEdgeRuleChains(tenant.getId()); + } tenantProfileCache.evict(tenant.getId()); tbClusterService.onTenantChange(tenant, null); tbClusterService.onEntityStateChange(tenant.getId(), tenant.getId(), From 4a4a747301c05039b3f2d22f4d0986a880ac4b51 Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Tue, 13 Apr 2021 10:12:56 +0300 Subject: [PATCH 19/69] UI: Refactoring resource library --- ui-ngx/src/app/core/http/resource.service.ts | 6 ++-- .../resources-library-table-config.resolve.ts | 36 ++++++++++++------- .../resource/resources-library.component.html | 6 ++++ 3 files changed, 33 insertions(+), 15 deletions(-) diff --git a/ui-ngx/src/app/core/http/resource.service.ts b/ui-ngx/src/app/core/http/resource.service.ts index 1b0796a389..6d5a367c86 100644 --- a/ui-ngx/src/app/core/http/resource.service.ts +++ b/ui-ngx/src/app/core/http/resource.service.ts @@ -70,12 +70,12 @@ export class ResourceService { ); } - public saveResources(resources: Resource[], config?: RequestConfig) { + public saveResources(resources: Resource[], config?: RequestConfig): Observable { let partSize = 100; partSize = resources.length > partSize ? partSize : resources.length; - const resourceObservables = []; + const resourceObservables: Observable[] = []; for (let i = 0; i < partSize; i++) { - resourceObservables.push(this.saveResource(resources[i], config).pipe(catchError(error => of(error)))); + resourceObservables.push(this.saveResource(resources[i], config).pipe(catchError(() => of({} as Resource)))); } return forkJoin(resourceObservables).pipe( mergeMap((resource) => { diff --git a/ui-ngx/src/app/modules/home/pages/resource/resources-library-table-config.resolve.ts b/ui-ngx/src/app/modules/home/pages/resource/resources-library-table-config.resolve.ts index bdff99ff06..d6759310ac 100644 --- a/ui-ngx/src/app/modules/home/pages/resource/resources-library-table-config.resolve.ts +++ b/ui-ngx/src/app/modules/home/pages/resource/resources-library-table-config.resolve.ts @@ -24,7 +24,6 @@ import { import { Resolve } from '@angular/router'; import { Resource, ResourceInfo, ResourceTypeTranslationMap } from '@shared/models/resource.models'; import { EntityType, entityTypeResources, entityTypeTranslations } from '@shared/models/entity-type.models'; -import { Direction } from '@shared/models/page/sort-order'; import { NULL_UUID } from '@shared/models/id/has-uuid'; import { DatePipe } from '@angular/common'; import { TranslateService } from '@ngx-translate/core'; @@ -34,13 +33,14 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { Authority } from '@shared/models/authority.enum'; import { ResourcesLibraryComponent } from '@home/pages/resource/resources-library.component'; -import { Observable } from 'rxjs/internal/Observable'; -import { PageData } from '@shared/models/page/page-data'; +import { PageLink } from '@shared/models/page/page-link'; +import { EntityAction } from '@home/models/entity/entity-component.models'; +import { map } from 'rxjs/operators'; @Injectable() -export class ResourcesLibraryTableConfigResolver implements Resolve> { +export class ResourcesLibraryTableConfigResolver implements Resolve> { - private readonly config: EntityTableConfig = new EntityTableConfig(); + private readonly config: EntityTableConfig = new EntityTableConfig(); private readonly resourceTypesTranslationMap = ResourceTypeTranslationMap; constructor(private store: Store, @@ -52,17 +52,16 @@ export class ResourcesLibraryTableConfigResolver implements Resolve resource ? resource.title : ''; this.config.columns.push( new DateEntityTableColumn('createdTime', 'common.created-time', this.datePipe, '150px'), - new EntityTableColumn('title', 'widgets-bundle.title', '60%'), + new EntityTableColumn('title', 'resource.title', '60%'), new EntityTableColumn('resourceType', 'resource.resource-type', '40%', entity => this.resourceTypesTranslationMap.get(entity.resourceType)), - new EntityTableColumn('tenantId', 'widgets-bundle.system', '60px', + new EntityTableColumn('tenantId', 'resource.system', '60px', entity => { return checkBoxCell(entity.tenantId.id === NULL_UUID); }), @@ -83,10 +82,12 @@ export class ResourcesLibraryTableConfigResolver implements Resolve this.translate.instant('resource.delete-resources-title', {count}); this.config.deleteEntitiesContent = () => this.translate.instant('resource.delete-resources-text'); - this.config.entitiesFetchFunction = pageLink => this.resourceService.getResources(pageLink) as Observable>; + this.config.entitiesFetchFunction = pageLink => this.resourceService.getResources(pageLink); this.config.loadEntity = id => this.resourceService.getResource(id.id); this.config.saveEntity = resource => this.saveResource(resource); this.config.deleteEntity = id => this.resourceService.deleteResource(id.id); + + this.config.onEntityAction = action => this.onResourceAction(action); } saveResource(resource) { @@ -100,13 +101,15 @@ export class ResourcesLibraryTableConfigResolver implements Resolve response[0]) + ); } else { return this.resourceService.saveResource(resource); } } - resolve(): EntityTableConfig { + resolve(): EntityTableConfig { this.config.tableTitle = this.translate.instant('resource.resources-library'); const authUser = getCurrentAuthUser(this.store); this.config.deleteEnabled = (resource) => this.isResourceEditable(resource, authUser.authority); @@ -122,7 +125,16 @@ export class ResourcesLibraryTableConfigResolver implements Resolve): boolean { + switch (action.action) { + case 'uploadResource': + this.exportResource(action.event, action.entity); + return true; + } + return false; + } + + private isResourceEditable(resource: ResourceInfo, authority: Authority): boolean { if (authority === Authority.TENANT_ADMIN) { return resource && resource.tenantId && resource.tenantId.id !== NULL_UUID; } else { diff --git a/ui-ngx/src/app/modules/home/pages/resource/resources-library.component.html b/ui-ngx/src/app/modules/home/pages/resource/resources-library.component.html index 60ac91b120..5b98fa9c61 100644 --- a/ui-ngx/src/app/modules/home/pages/resource/resources-library.component.html +++ b/ui-ngx/src/app/modules/home/pages/resource/resources-library.component.html @@ -16,6 +16,12 @@ -->
+
@@ -85,7 +85,7 @@ ngxClipboard (cbOnSuccess)="onEdgeInfoCopied('key')" [cbContent]="entity?.routingKey" - [fxShow]="!isEdit && (edgeScope === 'tenant' || edgeScope === 'customer')"> + [fxShow]="!isEdit && edgeScope !== 'customer_user'"> edge.copy-edge-key @@ -93,7 +93,7 @@ ngxClipboard (cbOnSuccess)="onEdgeInfoCopied('secret')" [cbContent]="entity?.secret" - [fxShow]="!isEdit && (edgeScope === 'tenant' || edgeScope === 'customer')"> + [fxShow]="!isEdit && edgeScope !== 'customer_user'"> edge.copy-edge-secret @@ -101,7 +101,7 @@ ngxClipboard [disabled]="(isLoading$ | async)" (click)="onEntityAction($event, 'syncEdge')" - [fxShow]="!isEdit && (edgeScope === 'tenant' || edgeScope === 'customer')"> + [fxShow]="!isEdit && edgeScope !== 'customer_user'"> edge.sync @@ -132,7 +132,7 @@ [required]="true" [entityType]="entityType.EDGE"> -
+
edge.edge-license-key @@ -142,7 +142,7 @@
-
+
edge.cloud-endpoint-hint
edge.cloud-endpoint @@ -153,7 +153,7 @@
-
+
edge.edge-key @@ -164,7 +164,7 @@
-
+
edge.edge-secret From d60bb58fe2b523f5b42e5adf47ed0e146a5b0d72 Mon Sep 17 00:00:00 2001 From: Artem Babak Date: Tue, 13 Apr 2021 12:58:00 +0300 Subject: [PATCH 23/69] Fixed translation: unassign edges --- .../modules/home/pages/edge/edges-table-config.resolver.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui-ngx/src/app/modules/home/pages/edge/edges-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/edge/edges-table-config.resolver.ts index 96251948d5..6cbeff0efe 100644 --- a/ui-ngx/src/app/modules/home/pages/edge/edges-table-config.resolver.ts +++ b/ui-ngx/src/app/modules/home/pages/edge/edges-table-config.resolver.ts @@ -477,8 +477,8 @@ export class EdgesTableConfigResolver implements Resolve Date: Tue, 13 Apr 2021 13:09:19 +0300 Subject: [PATCH 24/69] Fix code style issues --- .../constructor/EntityDataMsgConstructor.java | 4 ++-- .../service/install/cql/CassandraDbHelper.java | 2 +- .../server/edge/imitator/EdgeImitator.java | 3 ++- .../lwm2m/server/client/LwM2mClient.java | 10 +++++----- .../server/dao/edge/EdgeServiceImpl.java | 2 +- .../rule/engine/edge/TbMsgPushToEdgeNode.java | 17 ++++++++--------- 6 files changed, 19 insertions(+), 19 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/EntityDataMsgConstructor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/EntityDataMsgConstructor.java index 9b204d3187..1546572531 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/EntityDataMsgConstructor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/EntityDataMsgConstructor.java @@ -15,13 +15,13 @@ */ package org.thingsboard.server.service.edge.rpc.constructor; +import com.google.gson.reflect.TypeToken; import com.google.gson.Gson; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; -import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.edge.EdgeEventActionType; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.transport.adaptor.JsonConverter; @@ -82,7 +82,7 @@ public class EntityDataMsgConstructor { AttributeDeleteMsg.Builder attributeDeleteMsg = AttributeDeleteMsg.newBuilder(); attributeDeleteMsg.setScope(entityData.getAsJsonObject().getAsJsonPrimitive("scope").getAsString()); JsonArray jsonArray = entityData.getAsJsonObject().getAsJsonArray("keys"); - List keys = new Gson().fromJson(jsonArray.toString(), List.class); + List keys = new Gson().fromJson(jsonArray.toString(), new TypeToken<>(){}.getType()); attributeDeleteMsg.addAllAttributeNames(keys); attributeDeleteMsg.build(); builder.setAttributeDeleteMsg(attributeDeleteMsg); diff --git a/application/src/main/java/org/thingsboard/server/service/install/cql/CassandraDbHelper.java b/application/src/main/java/org/thingsboard/server/service/install/cql/CassandraDbHelper.java index ca70c4206e..296d9f0a8d 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/cql/CassandraDbHelper.java +++ b/application/src/main/java/org/thingsboard/server/service/install/cql/CassandraDbHelper.java @@ -159,7 +159,7 @@ public class CassandraDbHelper { } else if (type.getProtocolCode() == ProtocolConstants.DataType.TIMESTAMP) { str = ""+row.getInstant(index).toEpochMilli(); } else if (type.getProtocolCode() == ProtocolConstants.DataType.BOOLEAN) { - str = new Boolean(row.getBoolean(index)).toString(); + str = Boolean.valueOf(row.getBoolean(index)).toString(); } else { str = row.getString(index); } diff --git a/application/src/test/java/org/thingsboard/server/edge/imitator/EdgeImitator.java b/application/src/test/java/org/thingsboard/server/edge/imitator/EdgeImitator.java index 48ff61a491..e630f2b212 100644 --- a/application/src/test/java/org/thingsboard/server/edge/imitator/EdgeImitator.java +++ b/application/src/test/java/org/thingsboard/server/edge/imitator/EdgeImitator.java @@ -270,7 +270,8 @@ public class EdgeImitator { responsesLatch = new CountDownLatch(messageAmount); } - public Optional findMessageByType(Class tClass) { + @SuppressWarnings("unchecked") + public Optional findMessageByType(Class tClass) { Optional result; try { lock.lock(); diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClient.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClient.java index bdb783e3ff..186f085397 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClient.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClient.java @@ -103,9 +103,9 @@ public class LwM2mClient implements Cloneable { * @param modelProvider - */ public void deleteResources(String pathIdVer, LwM2mModelProvider modelProvider) { - Set key = getKeysEqualsIdVer(pathIdVer); + Set key = getKeysEqualsIdVer(pathIdVer); key.forEach(pathRez -> { - LwM2mPath pathIds = new LwM2mPath(convertToObjectIdFromIdVer(pathRez.toString())); + LwM2mPath pathIds = new LwM2mPath(convertToObjectIdFromIdVer(pathRez)); ResourceModel resourceModel = modelProvider.getObjectModel(registration).getResourceModel(pathIds.getObjectId(), pathIds.getResourceId()); if (resourceModel != null) { this.resources.get(pathRez).setResourceModel(resourceModel); @@ -122,8 +122,8 @@ public class LwM2mClient implements Cloneable { * @param modelProvider - */ public void updateResourceModel(String idVer, LwM2mModelProvider modelProvider) { - Set key = getKeysEqualsIdVer(idVer); - key.forEach(k -> this.saveResourceModel(k.toString(), modelProvider)); + Set key = getKeysEqualsIdVer(idVer); + key.forEach(k -> this.saveResourceModel(k, modelProvider)); } private void saveResourceModel(String pathRez, LwM2mModelProvider modelProvider) { @@ -132,7 +132,7 @@ public class LwM2mClient implements Cloneable { this.resources.get(pathRez).setResourceModel(resourceModel); } - private Set getKeysEqualsIdVer(String idVer) { + private Set getKeysEqualsIdVer(String idVer) { return this.resources.keySet() .stream() .filter(e -> idVer.equals(e.split(LWM2M_SEPARATOR_PATH)[1])) diff --git a/dao/src/main/java/org/thingsboard/server/dao/edge/EdgeServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/edge/EdgeServiceImpl.java index fe50a63907..8401a43d21 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/edge/EdgeServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/edge/EdgeServiceImpl.java @@ -586,7 +586,7 @@ public class EdgeServiceImpl extends AbstractEntityService implements EdgeServic @Override public Object activateInstance(String edgeLicenseSecret, String releaseDate) { - Map params = new HashMap(); + Map params = new HashMap<>(); params.put("licenseSecret", edgeLicenseSecret); params.put("releaseDate", releaseDate); return this.restTemplate.postForEntity(EDGE_LICENSE_SERVER_ENDPOINT + "/api/license/activateInstance?licenseSecret={licenseSecret}&releaseDate={releaseDate}", (Object) null, Object.class, params); diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/edge/TbMsgPushToEdgeNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/edge/TbMsgPushToEdgeNode.java index 79ced87bd7..e766c2600c 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/edge/TbMsgPushToEdgeNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/edge/TbMsgPushToEdgeNode.java @@ -16,13 +16,14 @@ package org.thingsboard.rule.engine.edge; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; import org.springframework.util.StringUtils; +import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.api.EmptyNodeConfiguration; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; @@ -87,8 +88,6 @@ public class TbMsgPushToEdgeNode implements TbNode { private EmptyNodeConfiguration config; - private static final ObjectMapper json = new ObjectMapper(); - private static final String SCOPE = "scope"; @Override @@ -200,7 +199,7 @@ public class TbMsgPushToEdgeNode implements TbNode { EdgeEventActionType actionType = getEdgeEventActionTypeByMsgType(msgType); Map entityBody = new HashMap<>(); Map metadata = msg.getMetaData().getData(); - JsonNode dataJson = json.readTree(msg.getData()); + JsonNode dataJson = JacksonUtil.toJsonNode(msg.getData()); switch (actionType) { case ATTRIBUTES_UPDATED: case POST_ATTRIBUTES: @@ -208,7 +207,7 @@ public class TbMsgPushToEdgeNode implements TbNode { entityBody.put(SCOPE, getScope(metadata)); break; case ATTRIBUTES_DELETED: - List keys = json.treeToValue(dataJson.get("attributes"), List.class); + List keys = JacksonUtil.convertValue(dataJson.get("attributes"), new TypeReference<>() {}); entityBody.put("keys", keys); entityBody.put(SCOPE, getScope(metadata)); break; @@ -217,7 +216,7 @@ public class TbMsgPushToEdgeNode implements TbNode { entityBody.put("ts", metadata.get("ts")); break; } - return buildEdgeEvent(ctx.getTenantId(), actionType, msg.getOriginator().getId(), edgeEventTypeByEntityType, json.valueToTree(entityBody)); + return buildEdgeEvent(ctx.getTenantId(), actionType, msg.getOriginator().getId(), edgeEventTypeByEntityType, JacksonUtil.valueToTree(entityBody)); } } @@ -240,9 +239,9 @@ public class TbMsgPushToEdgeNode implements TbNode { return edgeEvent; } - private UUID getUUIDFromMsgData(TbMsg msg) throws JsonProcessingException { - JsonNode data = json.readTree(msg.getData()).get("id"); - String id = json.treeToValue(data.get("id"), String.class); + private UUID getUUIDFromMsgData(TbMsg msg) { + JsonNode data = JacksonUtil.toJsonNode(msg.getData()).get("id"); + String id = JacksonUtil.convertValue(data.get("id"), String.class); return UUID.fromString(id); } From 585f60fe15ab3912ab428cc9e93c31014584540c Mon Sep 17 00:00:00 2001 From: Artem Babak Date: Tue, 13 Apr 2021 13:14:10 +0300 Subject: [PATCH 25/69] Edge rulechains: open/export from 'edge' scope --- .../home/pages/edge/edge-routing.module.ts | 59 +++++++++---- .../pages/rulechain/rulechain.component.html | 14 +--- .../rulechains-table-config.resolver.ts | 82 +++++++++---------- 3 files changed, 89 insertions(+), 66 deletions(-) diff --git a/ui-ngx/src/app/modules/home/pages/edge/edge-routing.module.ts b/ui-ngx/src/app/modules/home/pages/edge/edge-routing.module.ts index da5aedf53b..c2eff33a11 100644 --- a/ui-ngx/src/app/modules/home/pages/edge/edge-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/edge/edge-routing.module.ts @@ -58,21 +58,6 @@ const routes: Routes = [ entitiesTableConfig: EdgesTableConfigResolver } }, - { - path: ':edgeId/ruleChains', - component: EntitiesTableComponent, - data: { - auth: [Authority.TENANT_ADMIN], - ruleChainsType: 'edge', - breadcrumb: { - label: 'edge.edge-rulechains', - icon: 'settings_ethernet' - }, - }, - resolve: { - entitiesTableConfig: RuleChainsTableConfigResolver - } - }, { path: ':edgeId/assets', component: EntitiesTableComponent, @@ -156,6 +141,50 @@ const routes: Routes = [ } ] }, + { + path: ':edgeId/ruleChains', + data: { + breadcrumb: { + label: 'edge.edge-rulechains', + icon: 'settings_ethernet' + } + }, + children: [ + { + path: '', + component: EntitiesTableComponent, + data: { + auth: [Authority.TENANT_ADMIN], + title: 'edge.rulechains', + ruleChainsType: 'edge' + }, + resolve: { + entitiesTableConfig: RuleChainsTableConfigResolver + } + }, + { + path: ':ruleChainId', + component: RuleChainPageComponent, + canDeactivate: [ConfirmOnExitGuard], + data: { + breadcrumb: { + labelFunction: ruleChainBreadcumbLabelFunction, + icon: 'settings_ethernet' + } as BreadCrumbConfig, + auth: [Authority.TENANT_ADMIN], + title: 'rulechain.edge-rulechain', + import: false, + ruleChainType: RuleChainType.EDGE + }, + resolve: { + ruleChain: RuleChainResolver, + ruleChainMetaData: ResolvedRuleChainMetaDataResolver, + ruleNodeComponents: RuleNodeComponentsResolver, + tooltipster: TooltipsterResolver + } + } + ] + }, { path: 'ruleChains', data: { diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain.component.html b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain.component.html index 5eda2edf34..66d5d6d74c 100644 --- a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain.component.html +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain.component.html @@ -19,19 +19,19 @@ -
diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rulechains-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/rulechain/rulechains-table-config.resolver.ts index 2b9572b7fa..b27fc2e997 100644 --- a/ui-ngx/src/app/modules/home/pages/rulechain/rulechains-table-config.resolver.ts +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rulechains-table-config.resolver.ts @@ -211,53 +211,51 @@ export class RuleChainsTableConfigResolver implements Resolve> { const actions: Array> = []; - if (ruleChainScope === 'tenant' || ruleChainScope === 'edges') { + actions.push( + { + name: this.translate.instant('rulechain.open-rulechain'), + icon: 'settings_ethernet', + isEnabled: () => true, + onAction: ($event, entity) => this.openRuleChain($event, entity) + }, + { + name: this.translate.instant('rulechain.export'), + icon: 'file_download', + isEnabled: () => true, + onAction: ($event, entity) => this.exportRuleChain($event, entity) + } + ); + if (ruleChainScope === 'tenant') { actions.push( { - name: this.translate.instant('rulechain.open-rulechain'), - icon: 'settings_ethernet', - isEnabled: () => true, - onAction: ($event, entity) => this.openRuleChain($event, entity) + name: this.translate.instant('rulechain.set-root'), + icon: 'flag', + isEnabled: (entity) => this.isNonRootRuleChain(entity), + onAction: ($event, entity) => this.setRootRuleChain($event, entity) + } + ); + } + if (ruleChainScope === 'edges') { + actions.push( + { + name: this.translate.instant('rulechain.set-edge-template-root-rulechain'), + icon: 'flag', + isEnabled: (entity) => this.isNonRootRuleChain(entity), + onAction: ($event, entity) => this.setEdgeTemplateRootRuleChain($event, entity) }, { - name: this.translate.instant('rulechain.export'), - icon: 'file_download', - isEnabled: () => true, - onAction: ($event, entity) => this.exportRuleChain($event, entity) + name: this.translate.instant('rulechain.set-auto-assign-to-edge'), + icon: 'bookmark_outline', + isEnabled: (entity) => this.isNotAutoAssignToEdgeRuleChain(entity), + onAction: ($event, entity) => this.setAutoAssignToEdgeRuleChain($event, entity) + }, + { + name: this.translate.instant('rulechain.unset-auto-assign-to-edge'), + icon: 'bookmark', + isEnabled: (entity) => this.isAutoAssignToEdgeRuleChain(entity), + onAction: ($event, entity) => this.unsetAutoAssignToEdgeRuleChain($event, entity) } ); - if (ruleChainScope === 'tenant') { - actions.push( - { - name: this.translate.instant('rulechain.set-root'), - icon: 'flag', - isEnabled: (entity) => this.isNonRootRuleChain(entity), - onAction: ($event, entity) => this.setRootRuleChain($event, entity) - } - ); - } - if (ruleChainScope === 'edges') { - actions.push( - { - name: this.translate.instant('rulechain.set-edge-template-root-rulechain'), - icon: 'flag', - isEnabled: (entity) => this.isNonRootRuleChain(entity), - onAction: ($event, entity) => this.setEdgeTemplateRootRuleChain($event, entity) - }, - { - name: this.translate.instant('rulechain.set-auto-assign-to-edge'), - icon: 'bookmark_outline', - isEnabled: (entity) => this.isNotAutoAssignToEdgeRuleChain(entity), - onAction: ($event, entity) => this.setAutoAssignToEdgeRuleChain($event, entity) - }, - { - name: this.translate.instant('rulechain.unset-auto-assign-to-edge'), - icon: 'bookmark', - isEnabled: (entity) => this.isAutoAssignToEdgeRuleChain(entity), - onAction: ($event, entity) => this.unsetAutoAssignToEdgeRuleChain($event, entity) - } - ); - } } if (ruleChainScope === 'edge') { actions.push( @@ -301,6 +299,8 @@ export class RuleChainsTableConfigResolver implements Resolve Date: Mon, 12 Apr 2021 14:43:14 +0300 Subject: [PATCH 26/69] Fix XSS policy file loading --- .../server/dao/service/NoXssValidator.java | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/NoXssValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/NoXssValidator.java index e16aebbfea..b1dcf5b47a 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/NoXssValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/NoXssValidator.java @@ -15,7 +15,6 @@ */ package org.thingsboard.server.dao.service; -import com.google.common.io.Resources; import lombok.extern.slf4j.Slf4j; import org.owasp.validator.html.AntiSamy; import org.owasp.validator.html.Policy; @@ -25,6 +24,7 @@ import org.thingsboard.server.common.data.validation.NoXss; import javax.validation.ConstraintValidator; import javax.validation.ConstraintValidatorContext; +import java.util.Optional; @Slf4j public class NoXssValidator implements ConstraintValidator { @@ -34,17 +34,21 @@ public class NoXssValidator implements ConstraintValidator { @Override public void initialize(NoXss constraintAnnotation) { if (xssPolicy == null) { - try { - xssPolicy = Policy.getInstance(Resources.getResource("xss-policy.xml")); - } catch (Exception e) { - log.error("Failed to set xss policy: {}", e.getMessage()); - } + xssPolicy = Optional.ofNullable(getClass().getClassLoader().getResourceAsStream("xss-policy.xml")) + .map(inputStream -> { + try { + return Policy.getInstance(inputStream); + } catch (Exception e) { + throw new RuntimeException(e); + } + }) + .orElseThrow(() -> new IllegalStateException("XSS policy file not found")); } } @Override public boolean isValid(Object value, ConstraintValidatorContext constraintValidatorContext) { - if (!(value instanceof String) || ((String) value).isEmpty() || xssPolicy == null) { + if (!(value instanceof String) || ((String) value).isEmpty()) { return true; } From fdd126303a115423f2583edb976aafb0909def65 Mon Sep 17 00:00:00 2001 From: Viacheslav Klimov Date: Mon, 12 Apr 2021 14:54:59 +0300 Subject: [PATCH 27/69] Validate more entities' fields --- .../org/thingsboard/server/common/data/DashboardInfo.java | 4 ++++ .../org/thingsboard/server/common/data/ShortCustomerInfo.java | 2 ++ .../java/org/thingsboard/server/common/data/TbResource.java | 2 ++ .../org/thingsboard/server/common/data/TbResourceInfo.java | 2 ++ .../server/common/data/device/profile/DeviceProfileData.java | 1 + .../profile/MqttDeviceProfileTransportConfiguration.java | 3 +++ .../thingsboard/server/common/data/widget/BaseWidgetType.java | 4 ++++ .../server/common/data/widget/WidgetTypeDetails.java | 3 ++- .../thingsboard/server/common/data/widget/WidgetTypeInfo.java | 3 +++ .../thingsboard/server/common/data/widget/WidgetsBundle.java | 4 ++++ 10 files changed, 27 insertions(+), 1 deletion(-) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/DashboardInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/DashboardInfo.java index 582df6a56a..854c5ff64b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/DashboardInfo.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/DashboardInfo.java @@ -19,14 +19,18 @@ import com.fasterxml.jackson.annotation.JsonProperty; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DashboardId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.validation.NoXss; +import javax.validation.Valid; import java.util.HashSet; import java.util.Set; public class DashboardInfo extends SearchTextBased implements HasName, HasTenantId { private TenantId tenantId; + @NoXss private String title; + @Valid private Set assignedCustomers; public DashboardInfo() { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ShortCustomerInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/ShortCustomerInfo.java index aa03cd7e59..d73dee6e1b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ShortCustomerInfo.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ShortCustomerInfo.java @@ -19,6 +19,7 @@ import lombok.AllArgsConstructor; import lombok.Getter; import lombok.Setter; import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.validation.NoXss; /** * Created by igor on 2/27/18. @@ -31,6 +32,7 @@ public class ShortCustomerInfo { private CustomerId customerId; @Getter @Setter + @NoXss private String title; @Getter @Setter diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/TbResource.java b/common/data/src/main/java/org/thingsboard/server/common/data/TbResource.java index 33e94b2e0c..28c7e73e26 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/TbResource.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/TbResource.java @@ -19,6 +19,7 @@ import lombok.Data; import lombok.EqualsAndHashCode; import lombok.extern.slf4j.Slf4j; import org.thingsboard.server.common.data.id.TbResourceId; +import org.thingsboard.server.common.data.validation.NoXss; @Slf4j @Data @@ -27,6 +28,7 @@ public class TbResource extends TbResourceInfo { private static final long serialVersionUID = 7379609705527272306L; + @NoXss private String fileName; private String data; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceInfo.java index d72982aeca..9414df3f78 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceInfo.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceInfo.java @@ -20,6 +20,7 @@ import lombok.EqualsAndHashCode; import lombok.extern.slf4j.Slf4j; import org.thingsboard.server.common.data.id.TbResourceId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.validation.NoXss; @Slf4j @Data @@ -27,6 +28,7 @@ import org.thingsboard.server.common.data.id.TenantId; public class TbResourceInfo extends SearchTextBased implements HasTenantId { private TenantId tenantId; + @NoXss private String title; private ResourceType resourceType; private String resourceKey; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileData.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileData.java index f5a438470e..f77e6388a5 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileData.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileData.java @@ -25,6 +25,7 @@ import java.util.List; public class DeviceProfileData implements Serializable { private DeviceProfileConfiguration configuration; + @Valid private DeviceProfileTransportConfiguration transportConfiguration; private DeviceProfileProvisionConfiguration provisionConfiguration; @Valid diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/MqttDeviceProfileTransportConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/MqttDeviceProfileTransportConfiguration.java index 3530cec583..63bb6324c6 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/MqttDeviceProfileTransportConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/MqttDeviceProfileTransportConfiguration.java @@ -17,11 +17,14 @@ package org.thingsboard.server.common.data.device.profile; import lombok.Data; import org.thingsboard.server.common.data.DeviceTransportType; +import org.thingsboard.server.common.data.validation.NoXss; @Data public class MqttDeviceProfileTransportConfiguration implements DeviceProfileTransportConfiguration { + @NoXss private String deviceTelemetryTopic = MqttTopics.DEVICE_TELEMETRY_TOPIC; + @NoXss private String deviceAttributesTopic = MqttTopics.DEVICE_ATTRIBUTES_TOPIC; private TransportPayloadTypeConfiguration transportPayloadTypeConfiguration; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/widget/BaseWidgetType.java b/common/data/src/main/java/org/thingsboard/server/common/data/widget/BaseWidgetType.java index e3463f3614..300015fa23 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/widget/BaseWidgetType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/widget/BaseWidgetType.java @@ -20,6 +20,7 @@ import org.thingsboard.server.common.data.BaseData; import org.thingsboard.server.common.data.HasTenantId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.WidgetTypeId; +import org.thingsboard.server.common.data.validation.NoXss; @Data public class BaseWidgetType extends BaseData implements HasTenantId { @@ -27,8 +28,11 @@ public class BaseWidgetType extends BaseData implements HasTenantI private static final long serialVersionUID = 8388684344603660756L; private TenantId tenantId; + @NoXss private String bundleAlias; + @NoXss private String alias; + @NoXss private String name; public BaseWidgetType() { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetTypeDetails.java b/common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetTypeDetails.java index 232a10e755..c43d59041c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetTypeDetails.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetTypeDetails.java @@ -15,14 +15,15 @@ */ package org.thingsboard.server.common.data.widget; -import com.fasterxml.jackson.databind.JsonNode; import lombok.Data; import org.thingsboard.server.common.data.id.WidgetTypeId; +import org.thingsboard.server.common.data.validation.NoXss; @Data public class WidgetTypeDetails extends WidgetType { private String image; + @NoXss private String description; public WidgetTypeDetails() { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetTypeInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetTypeInfo.java index 645483bcc3..2fde13552b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetTypeInfo.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetTypeInfo.java @@ -17,12 +17,15 @@ package org.thingsboard.server.common.data.widget; import lombok.Data; import org.thingsboard.server.common.data.id.WidgetTypeId; +import org.thingsboard.server.common.data.validation.NoXss; @Data public class WidgetTypeInfo extends BaseWidgetType { private String image; + @NoXss private String description; + @NoXss private String widgetType; public WidgetTypeInfo() { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetsBundle.java b/common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetsBundle.java index a15cd8baf5..df09f75922 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetsBundle.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetsBundle.java @@ -19,6 +19,7 @@ import org.thingsboard.server.common.data.HasTenantId; import org.thingsboard.server.common.data.SearchTextBased; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.WidgetsBundleId; +import org.thingsboard.server.common.data.validation.NoXss; import java.util.Arrays; @@ -27,9 +28,12 @@ public class WidgetsBundle extends SearchTextBased implements H private static final long serialVersionUID = -7627368878362410489L; private TenantId tenantId; + @NoXss private String alias; + @NoXss private String title; private String image; + @NoXss private String description; public WidgetsBundle() { From 5a648467bf0acb01c6050c7eacfa010c5e98d359 Mon Sep 17 00:00:00 2001 From: ShvaykaD Date: Tue, 13 Apr 2021 15:52:29 +0300 Subject: [PATCH 28/69] fixed typo in AbstractComponentDescriptorInsertRepository and AbstractEventInsertRepository --- .../component/AbstractComponentDescriptorInsertRepository.java | 2 +- .../server/dao/sql/event/AbstractEventInsertRepository.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/component/AbstractComponentDescriptorInsertRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/component/AbstractComponentDescriptorInsertRepository.java index c6c545b574..f4eb71eaed 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/component/AbstractComponentDescriptorInsertRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/component/AbstractComponentDescriptorInsertRepository.java @@ -51,11 +51,11 @@ public abstract class AbstractComponentDescriptorInsertRepository implements Com TransactionStatus transaction = getTransactionStatus(TransactionDefinition.PROPAGATION_REQUIRES_NEW); try { componentDescriptorEntity = processSaveOrUpdate(entity, insertOrUpdateOnUniqueKeyConflict); + transactionManager.commit(transaction); } catch (Throwable th) { log.trace("Could not execute the update statement for Component Descriptor with id {}, name {} and entityType {}", entity.getUuid(), entity.getName(), entity.getType()); transactionManager.rollback(transaction); } - transactionManager.commit(transaction); } else { log.trace("Could not execute the insert statement for Component Descriptor with id {}, name {} and entityType {}", entity.getUuid(), entity.getName(), entity.getType()); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/event/AbstractEventInsertRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/event/AbstractEventInsertRepository.java index fde9498702..93a33d1f9f 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/event/AbstractEventInsertRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/event/AbstractEventInsertRepository.java @@ -51,11 +51,11 @@ public abstract class AbstractEventInsertRepository implements EventInsertReposi TransactionStatus transaction = getTransactionStatus(TransactionDefinition.PROPAGATION_REQUIRES_NEW); try { eventEntity = processSaveOrUpdate(entity, insertOrUpdateOnUniqueKeyConflict); + transactionManager.commit(transaction); } catch (Throwable th) { log.trace("Could not execute the update statement for Entity with entityId {} and entityType {}", entity.getEventUid(), entity.getEventType()); transactionManager.rollback(transaction); } - transactionManager.commit(transaction); } else { log.trace("Could not execute the insert statement for Entity with entityId {} and entityType {}", entity.getEventUid(), entity.getEventType()); } From ae5632f8abbbfbb99395c727cd860f88c64821d6 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Wed, 14 Apr 2021 12:40:25 +0300 Subject: [PATCH 29/69] Fix docker images. Upgrade docker postgres to version 12. --- msa/black-box-tests/README.md | 1 + msa/tb/docker-cassandra/Dockerfile | 9 ++++----- msa/tb/docker-postgres/Dockerfile | 9 ++++----- msa/tb/pom.xml | 2 +- 4 files changed, 10 insertions(+), 11 deletions(-) diff --git a/msa/black-box-tests/README.md b/msa/black-box-tests/README.md index 044fe00255..98718a201b 100644 --- a/msa/black-box-tests/README.md +++ b/msa/black-box-tests/README.md @@ -10,6 +10,7 @@ To run the black box tests with using Docker, the local Docker images of Thingsb As result, in REPOSITORY column, next images should be present: thingsboard/tb-coap-transport + thingsboard/tb-lwm2m-transport thingsboard/tb-http-transport thingsboard/tb-mqtt-transport thingsboard/tb-node diff --git a/msa/tb/docker-cassandra/Dockerfile b/msa/tb/docker-cassandra/Dockerfile index f9eddd420c..ec92116c81 100644 --- a/msa/tb/docker-cassandra/Dockerfile +++ b/msa/tb/docker-cassandra/Dockerfile @@ -17,15 +17,14 @@ FROM thingsboard/openjdk11 RUN apt-get update -RUN apt-get install -y curl nmap procps -RUN echo 'deb http://ftp.us.debian.org/debian sid main' | tee --append /etc/apt/sources.list.d/debian.list > /dev/null -RUN echo 'deb http://apt.postgresql.org/pub/repos/apt/ sid-pgdg main' | tee --append /etc/apt/sources.list.d/pgdg.list > /dev/null +RUN apt-get install -y curl nmap procps gnupg2 +RUN echo 'deb http://apt.postgresql.org/pub/repos/apt/ buster-pgdg main' | tee --append /etc/apt/sources.list.d/pgdg.list > /dev/null RUN curl -L https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - RUN echo 'deb http://downloads.apache.org/cassandra/debian 40x main' | tee --append /etc/apt/sources.list.d/cassandra.list > /dev/null RUN curl -L https://downloads.apache.org/cassandra/KEYS | apt-key add - -ENV PG_MAJOR=11 +ENV PG_MAJOR=12 RUN apt-get update -RUN apt-get install -y cassandra cassandra-tools postgresql-11 +RUN apt-get install -y cassandra cassandra-tools postgresql-12 RUN update-rc.d cassandra disable RUN update-rc.d postgresql disable RUN sed -i.old '/ulimit/d' /etc/init.d/cassandra diff --git a/msa/tb/docker-postgres/Dockerfile b/msa/tb/docker-postgres/Dockerfile index 9b314a5a5d..7221bc08c6 100644 --- a/msa/tb/docker-postgres/Dockerfile +++ b/msa/tb/docker-postgres/Dockerfile @@ -17,13 +17,12 @@ FROM thingsboard/openjdk11 RUN apt-get update -RUN apt-get install -y curl -RUN echo 'deb http://ftp.us.debian.org/debian sid main' | tee --append /etc/apt/sources.list.d/debian.list > /dev/null -RUN echo 'deb http://apt.postgresql.org/pub/repos/apt/ sid-pgdg main' | tee --append /etc/apt/sources.list.d/pgdg.list > /dev/null +RUN apt-get install -y curl gnupg2 +RUN echo 'deb http://apt.postgresql.org/pub/repos/apt/ buster-pgdg main' | tee --append /etc/apt/sources.list.d/pgdg.list > /dev/null RUN curl -L https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - -ENV PG_MAJOR 11 +ENV PG_MAJOR 12 RUN apt-get update -RUN apt-get install -y postgresql-11 +RUN apt-get install -y postgresql-12 RUN update-rc.d postgresql disable COPY logback.xml ${pkg.name}.conf start-db.sh stop-db.sh start-tb.sh upgrade-tb.sh install-tb.sh ${pkg.name}.deb /tmp/ diff --git a/msa/tb/pom.xml b/msa/tb/pom.xml index efb9a709e9..a19c2c542f 100644 --- a/msa/tb/pom.xml +++ b/msa/tb/pom.xml @@ -39,7 +39,7 @@ tb-postgres tb-cassandra /usr/share/${pkg.name} - 2.4.2 + 3.3.0 From 123457f8eb5313770618615cea3570fd08e37c27 Mon Sep 17 00:00:00 2001 From: Andrii Shvaika Date: Wed, 14 Apr 2021 13:49:09 +0300 Subject: [PATCH 30/69] Prepared Statement initialization lock --- .../CassandraBaseTimeseriesDao.java | 151 ++++++++++++------ 1 file changed, 105 insertions(+), 46 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDao.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDao.java index 7d09578dd1..240d5a0b88 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDao.java @@ -62,6 +62,8 @@ import java.util.Collections; import java.util.List; import java.util.Optional; import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; import java.util.stream.Collectors; import static com.datastax.oss.driver.api.querybuilder.QueryBuilder.literal; @@ -107,6 +109,7 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD private PreparedStatement[] fetchStmtsDesc; private PreparedStatement deleteStmt; private PreparedStatement deletePartitionStmt; + private final Lock stmtCreationLock = new ReentrantLock(); private boolean isInstall() { return environment.acceptsProfiles(Profiles.of("install")); @@ -545,13 +548,20 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD private PreparedStatement getDeleteStmt() { if (deleteStmt == null) { - deleteStmt = prepare("DELETE FROM " + ModelConstants.TS_KV_CF + - " WHERE " + ModelConstants.ENTITY_TYPE_COLUMN + EQUALS_PARAM - + "AND " + ModelConstants.ENTITY_ID_COLUMN + EQUALS_PARAM - + "AND " + ModelConstants.KEY_COLUMN + EQUALS_PARAM - + "AND " + ModelConstants.PARTITION_COLUMN + EQUALS_PARAM - + "AND " + ModelConstants.TS_COLUMN + " >= ? " - + "AND " + ModelConstants.TS_COLUMN + " < ?"); + stmtCreationLock.lock(); + try { + if (deleteStmt == null) { + deleteStmt = prepare("DELETE FROM " + ModelConstants.TS_KV_CF + + " WHERE " + ModelConstants.ENTITY_TYPE_COLUMN + EQUALS_PARAM + + "AND " + ModelConstants.ENTITY_ID_COLUMN + EQUALS_PARAM + + "AND " + ModelConstants.KEY_COLUMN + EQUALS_PARAM + + "AND " + ModelConstants.PARTITION_COLUMN + EQUALS_PARAM + + "AND " + ModelConstants.TS_COLUMN + " >= ? " + + "AND " + ModelConstants.TS_COLUMN + " < ?"); + } + } finally { + stmtCreationLock.unlock(); + } } return deleteStmt; } @@ -585,27 +595,41 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD private PreparedStatement getDeletePartitionStmt() { if (deletePartitionStmt == null) { - deletePartitionStmt = prepare("DELETE FROM " + ModelConstants.TS_KV_PARTITIONS_CF + - " WHERE " + ModelConstants.ENTITY_TYPE_COLUMN + EQUALS_PARAM - + "AND " + ModelConstants.ENTITY_ID_COLUMN + EQUALS_PARAM - + "AND " + ModelConstants.PARTITION_COLUMN + EQUALS_PARAM - + "AND " + ModelConstants.KEY_COLUMN + EQUALS_PARAM); + stmtCreationLock.lock(); + try { + if (deletePartitionStmt == null) { + deletePartitionStmt = prepare("DELETE FROM " + ModelConstants.TS_KV_PARTITIONS_CF + + " WHERE " + ModelConstants.ENTITY_TYPE_COLUMN + EQUALS_PARAM + + "AND " + ModelConstants.ENTITY_ID_COLUMN + EQUALS_PARAM + + "AND " + ModelConstants.PARTITION_COLUMN + EQUALS_PARAM + + "AND " + ModelConstants.KEY_COLUMN + EQUALS_PARAM); + } + } finally { + stmtCreationLock.unlock(); + } } return deletePartitionStmt; } private PreparedStatement getSaveStmt(DataType dataType) { if (saveStmts == null) { - saveStmts = new PreparedStatement[DataType.values().length]; - for (DataType type : DataType.values()) { - saveStmts[type.ordinal()] = prepare(INSERT_INTO + ModelConstants.TS_KV_CF + - "(" + ModelConstants.ENTITY_TYPE_COLUMN + - "," + ModelConstants.ENTITY_ID_COLUMN + - "," + ModelConstants.KEY_COLUMN + - "," + ModelConstants.PARTITION_COLUMN + - "," + ModelConstants.TS_COLUMN + - "," + getColumnName(type) + ")" + - " VALUES(?, ?, ?, ?, ?, ?)"); + stmtCreationLock.lock(); + try { + if (saveStmts == null) { + saveStmts = new PreparedStatement[DataType.values().length]; + for (DataType type : DataType.values()) { + saveStmts[type.ordinal()] = prepare(INSERT_INTO + ModelConstants.TS_KV_CF + + "(" + ModelConstants.ENTITY_TYPE_COLUMN + + "," + ModelConstants.ENTITY_ID_COLUMN + + "," + ModelConstants.KEY_COLUMN + + "," + ModelConstants.PARTITION_COLUMN + + "," + ModelConstants.TS_COLUMN + + "," + getColumnName(type) + ")" + + " VALUES(?, ?, ?, ?, ?, ?)"); + } + } + } finally { + stmtCreationLock.unlock(); } } return saveStmts[dataType.ordinal()]; @@ -613,16 +637,23 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD private PreparedStatement getSaveTtlStmt(DataType dataType) { if (saveTtlStmts == null) { - saveTtlStmts = new PreparedStatement[DataType.values().length]; - for (DataType type : DataType.values()) { - saveTtlStmts[type.ordinal()] = prepare(INSERT_INTO + ModelConstants.TS_KV_CF + - "(" + ModelConstants.ENTITY_TYPE_COLUMN + - "," + ModelConstants.ENTITY_ID_COLUMN + - "," + ModelConstants.KEY_COLUMN + - "," + ModelConstants.PARTITION_COLUMN + - "," + ModelConstants.TS_COLUMN + - "," + getColumnName(type) + ")" + - " VALUES(?, ?, ?, ?, ?, ?) USING TTL ?"); + stmtCreationLock.lock(); + try { + if (saveTtlStmts == null) { + saveTtlStmts = new PreparedStatement[DataType.values().length]; + for (DataType type : DataType.values()) { + saveTtlStmts[type.ordinal()] = prepare(INSERT_INTO + ModelConstants.TS_KV_CF + + "(" + ModelConstants.ENTITY_TYPE_COLUMN + + "," + ModelConstants.ENTITY_ID_COLUMN + + "," + ModelConstants.KEY_COLUMN + + "," + ModelConstants.PARTITION_COLUMN + + "," + ModelConstants.TS_COLUMN + + "," + getColumnName(type) + ")" + + " VALUES(?, ?, ?, ?, ?, ?) USING TTL ?"); + } + } + } finally { + stmtCreationLock.unlock(); } } return saveTtlStmts[dataType.ordinal()]; @@ -630,24 +661,38 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD private PreparedStatement getPartitionInsertStmt() { if (partitionInsertStmt == null) { - partitionInsertStmt = prepare(INSERT_INTO + ModelConstants.TS_KV_PARTITIONS_CF + - "(" + ModelConstants.ENTITY_TYPE_COLUMN + - "," + ModelConstants.ENTITY_ID_COLUMN + - "," + ModelConstants.PARTITION_COLUMN + - "," + ModelConstants.KEY_COLUMN + ")" + - " VALUES(?, ?, ?, ?)"); + stmtCreationLock.lock(); + try { + if (partitionInsertStmt == null) { + partitionInsertStmt = prepare(INSERT_INTO + ModelConstants.TS_KV_PARTITIONS_CF + + "(" + ModelConstants.ENTITY_TYPE_COLUMN + + "," + ModelConstants.ENTITY_ID_COLUMN + + "," + ModelConstants.PARTITION_COLUMN + + "," + ModelConstants.KEY_COLUMN + ")" + + " VALUES(?, ?, ?, ?)"); + } + } finally { + stmtCreationLock.unlock(); + } } return partitionInsertStmt; } private PreparedStatement getPartitionInsertTtlStmt() { if (partitionInsertTtlStmt == null) { - partitionInsertTtlStmt = prepare(INSERT_INTO + ModelConstants.TS_KV_PARTITIONS_CF + - "(" + ModelConstants.ENTITY_TYPE_COLUMN + - "," + ModelConstants.ENTITY_ID_COLUMN + - "," + ModelConstants.PARTITION_COLUMN + - "," + ModelConstants.KEY_COLUMN + ")" + - " VALUES(?, ?, ?, ?) USING TTL ?"); + stmtCreationLock.lock(); + try { + if (partitionInsertTtlStmt == null) { + partitionInsertTtlStmt = prepare(INSERT_INTO + ModelConstants.TS_KV_PARTITIONS_CF + + "(" + ModelConstants.ENTITY_TYPE_COLUMN + + "," + ModelConstants.ENTITY_ID_COLUMN + + "," + ModelConstants.PARTITION_COLUMN + + "," + ModelConstants.KEY_COLUMN + ")" + + " VALUES(?, ?, ?, ?) USING TTL ?"); + } + } finally { + stmtCreationLock.unlock(); + } } return partitionInsertTtlStmt; } @@ -713,12 +758,26 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD switch (orderBy) { case ASC_ORDER: if (fetchStmtsAsc == null) { - fetchStmtsAsc = initFetchStmt(orderBy); + stmtCreationLock.lock(); + try { + if (fetchStmtsAsc == null) { + fetchStmtsAsc = initFetchStmt(orderBy); + } + } finally { + stmtCreationLock.unlock(); + } } return fetchStmtsAsc[aggType.ordinal()]; case DESC_ORDER: if (fetchStmtsDesc == null) { - fetchStmtsDesc = initFetchStmt(orderBy); + stmtCreationLock.lock(); + try { + if (fetchStmtsDesc == null) { + fetchStmtsDesc = initFetchStmt(orderBy); + } + } finally { + stmtCreationLock.unlock(); + } } return fetchStmtsDesc[aggType.ordinal()]; default: From 38de4838066d617f6d313e9c42b147c1bb131240 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Wed, 14 Apr 2021 13:57:37 +0300 Subject: [PATCH 31/69] Load edge rule chains when load demo data if edges enabled --- .../service/install/DefaultSystemDataLoaderService.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java b/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java index 3d21c66e23..6cbe616d94 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java @@ -17,8 +17,10 @@ package org.thingsboard.server.service.install; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Profile; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; @@ -136,6 +138,9 @@ public class DefaultSystemDataLoaderService implements SystemDataLoaderService { @Autowired private RuleChainService ruleChainService; + @Value("${edges.enabled}") + private boolean edgesEnabled; + @Bean protected BCryptPasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); @@ -234,6 +239,9 @@ public class DefaultSystemDataLoaderService implements SystemDataLoaderService { demoTenant.setTitle("Tenant"); demoTenant = tenantService.saveTenant(demoTenant); installScripts.loadDemoRuleChains(demoTenant.getId()); + if (edgesEnabled) { + installScripts.createDefaultEdgeRuleChains(demoTenant.getId()); + } createUser(Authority.TENANT_ADMIN, demoTenant.getId(), null, "tenant@thingsboard.org", "tenant"); Customer customerA = new Customer(); From 04df24c97a467c75e048f8713fdb0c716568c957 Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Wed, 14 Apr 2021 15:08:14 +0300 Subject: [PATCH 32/69] Rename getTenantDevices to getDeviceProfiles --- .../src/main/java/org/thingsboard/rest/client/RestClient.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java b/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java index de7ba29141..815897faaa 100644 --- a/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java +++ b/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java @@ -1338,7 +1338,7 @@ public class RestClient implements ClientHttpRequestInterceptor, Closeable { HttpEntity.EMPTY, DeviceProfile.class, deviceProfileId).getBody(); } - public PageData getTenantDevices(PageLink pageLink) { + public PageData getDeviceProfiles(PageLink pageLink) { Map params = new HashMap<>(); addPageLinkToParam(params, pageLink); return restTemplate.exchange( From a84103f1e40717e2acff3c6652d34baa3a184140 Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Wed, 14 Apr 2021 15:31:51 +0300 Subject: [PATCH 33/69] Restore old Nashorn sandbox version until bug in library is fixed (#4408) * Restore old nashorn sandbox version until bug in library is fixed * Change version of Nashorn Sandbox to 0.1.16 --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index c26964e8e9..d954128e96 100755 --- a/pom.xml +++ b/pom.xml @@ -99,7 +99,7 @@ org/thingsboard/server/extensions/core/plugin/telemetry/gen/**/* 5.0.2 - 0.1.31 + 0.1.16 2.6.0 4.1.1 2.57 From 2f07283876743dcdda6918483402feb1ff911a8d Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Wed, 14 Apr 2021 16:14:47 +0300 Subject: [PATCH 34/69] Removed upload of demo edge rule chains --- .../thingsboard/server/service/install/InstallScripts.java | 6 ------ 1 file changed, 6 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java b/application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java index 158cadc1a3..e2fb3ed657 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java +++ b/application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java @@ -259,18 +259,12 @@ public class InstallScripts { try { createDefaultRuleChains(tenantId); createDefaultRuleChain(tenantId, "Thermostat"); - loadEdgeDemoRuleChains(tenantId); } catch (Exception e) { log.error("Unable to load dashboard from json", e); throw new RuntimeException("Unable to load dashboard from json", e); } } - private void loadEdgeDemoRuleChains(TenantId tenantId) throws Exception { - Path edgeDemoRuleChainsDir = Paths.get(getDataDir(), JSON_DIR, DEMO_DIR, EDGE_MANAGEMENT, RULE_CHAINS_DIR); - loadRuleChainsFromPath(tenantId, edgeDemoRuleChainsDir); - } - public void createOAuth2Templates() throws Exception { Path oauth2ConfigTemplatesDir = Paths.get(getDataDir(), JSON_DIR, SYSTEM_DIR, OAUTH2_CONFIG_TEMPLATES_DIR); try (DirectoryStream dirStream = Files.newDirectoryStream(oauth2ConfigTemplatesDir, path -> path.toString().endsWith(JSON_EXT))) { From b187d762f9a1c2caf379c5d0f68de116ca594eed Mon Sep 17 00:00:00 2001 From: Igor Kulikov Date: Wed, 14 Apr 2021 16:19:03 +0300 Subject: [PATCH 35/69] Revert load default edge rule chains for demo data --- .../service/install/DefaultSystemDataLoaderService.java | 6 ------ 1 file changed, 6 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java b/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java index 6cbe616d94..1ba2ce3470 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java @@ -138,9 +138,6 @@ public class DefaultSystemDataLoaderService implements SystemDataLoaderService { @Autowired private RuleChainService ruleChainService; - @Value("${edges.enabled}") - private boolean edgesEnabled; - @Bean protected BCryptPasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); @@ -239,9 +236,6 @@ public class DefaultSystemDataLoaderService implements SystemDataLoaderService { demoTenant.setTitle("Tenant"); demoTenant = tenantService.saveTenant(demoTenant); installScripts.loadDemoRuleChains(demoTenant.getId()); - if (edgesEnabled) { - installScripts.createDefaultEdgeRuleChains(demoTenant.getId()); - } createUser(Authority.TENANT_ADMIN, demoTenant.getId(), null, "tenant@thingsboard.org", "tenant"); Customer customerA = new Customer(); From 206a3d386fd869f426b41954fb610107b424bc28 Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Wed, 14 Apr 2021 16:25:17 +0300 Subject: [PATCH 36/69] Edge event controller test stabilization --- .../BaseEdgeEventControllerTest.java | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseEdgeEventControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseEdgeEventControllerTest.java index 5edde458ce..f7ed71f177 100644 --- a/application/src/test/java/org/thingsboard/server/controller/BaseEdgeEventControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/BaseEdgeEventControllerTest.java @@ -42,7 +42,6 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. public class BaseEdgeEventControllerTest extends AbstractControllerTest { private Tenant savedTenant; - private TenantId tenantId; private User tenantAdmin; @Before @@ -52,7 +51,6 @@ public class BaseEdgeEventControllerTest extends AbstractControllerTest { Tenant tenant = new Tenant(); tenant.setTitle("My tenant"); savedTenant = doPost("/api/tenant", tenant, Tenant.class); - tenantId = savedTenant.getId(); Assert.assertNotNull(savedTenant); tenantAdmin = new User(); @@ -63,6 +61,10 @@ public class BaseEdgeEventControllerTest extends AbstractControllerTest { tenantAdmin.setLastName("Downs"); tenantAdmin = createUserAndLogin(tenantAdmin, "testPassword1"); + // sleep 1 seconds to avoid CREDENTIALS updated message for the user + // user credentials is going to be stored and updated event pushed to edge notification service + // while service will be processing this event edge could be already added and additional message will be pushed + Thread.sleep(1000); } @After @@ -75,7 +77,6 @@ public class BaseEdgeEventControllerTest extends AbstractControllerTest { @Test public void testGetEdgeEvents() throws Exception { - Thread.sleep(500); Edge edge = constructEdge("TestEdge", "default"); edge = doPost("/api/edge", edge, Edge.class); @@ -83,29 +84,31 @@ public class BaseEdgeEventControllerTest extends AbstractControllerTest { Device savedDevice = doPost("/api/device", device, Device.class); doPost("/api/edge/" + edge.getId().toString() + "/device/" + savedDevice.getId().toString(), Device.class); - Thread.sleep(500); Asset asset = constructAsset("TestAsset", "default"); Asset savedAsset = doPost("/api/asset", asset, Asset.class); doPost("/api/edge/" + edge.getId().toString() + "/asset/" + savedAsset.getId().toString(), Asset.class); - Thread.sleep(500); EntityRelation relation = new EntityRelation(savedAsset.getId(), savedDevice.getId(), EntityRelation.CONTAINS_TYPE); doPost("/api/relation", relation); - Thread.sleep(500); - List edgeEvents = doGetTypedWithTimePageLink("/api/edge/" + edge.getId().toString() + "/events?", - new TypeReference>() { - }, new TimePageLink(4)).getData(); - - Assert.assertFalse(edgeEvents.isEmpty()); + // wait while edge event for the relation entity persisted to DB + Thread.sleep(100); + List edgeEvents; + int attempt = 1; + do { + edgeEvents = doGetTypedWithTimePageLink("/api/edge/" + edge.getId().toString() + "/events?", + new TypeReference>() {}, new TimePageLink(4)).getData(); + attempt++; + Thread.sleep(100); + } while (edgeEvents.size() != 4 || attempt < 5); Assert.assertEquals(4, edgeEvents.size()); - Assert.assertEquals(EdgeEventType.RULE_CHAIN, edgeEvents.get(0).getType()); - Assert.assertEquals(EdgeEventType.DEVICE, edgeEvents.get(1).getType()); - Assert.assertEquals(EdgeEventType.ASSET, edgeEvents.get(2).getType()); - Assert.assertEquals(EdgeEventType.RELATION, edgeEvents.get(3).getType()); + Assert.assertTrue(edgeEvents.stream().anyMatch(ee -> EdgeEventType.RULE_CHAIN.equals(ee.getType()))); + Assert.assertTrue(edgeEvents.stream().anyMatch(ee -> EdgeEventType.DEVICE.equals(ee.getType()))); + Assert.assertTrue(edgeEvents.stream().anyMatch(ee -> EdgeEventType.ASSET.equals(ee.getType()))); + Assert.assertTrue(edgeEvents.stream().anyMatch(ee -> EdgeEventType.RELATION.equals(ee.getType()))); } private Device constructDevice(String name, String type) { From ba1aa7a579edd79e7b66dcc9157fae3339af6399 Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Wed, 14 Apr 2021 16:31:16 +0300 Subject: [PATCH 37/69] Base Edge test - refactoring and stability fixes. Device Profile processor null pointer exception fix is provision is not set --- .../DeviceProfileMsgConstructor.java | 8 +- .../thingsboard/server/edge/BaseEdgeTest.java | 424 +++++++++++------- .../server/edge/imitator/EdgeImitator.java | 65 ++- 3 files changed, 319 insertions(+), 178 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/DeviceProfileMsgConstructor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/DeviceProfileMsgConstructor.java index 316707b9ef..c3f5346170 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/DeviceProfileMsgConstructor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/constructor/DeviceProfileMsgConstructor.java @@ -40,8 +40,6 @@ public class DeviceProfileMsgConstructor { .setName(deviceProfile.getName()) .setDefault(deviceProfile.isDefault()) .setType(deviceProfile.getType().name()) - .setTransportType(deviceProfile.getTransportType().name()) - .setProvisionType(deviceProfile.getProvisionType().name()) .setProfileDataBytes(ByteString.copyFrom(dataDecodingEncodingService.encode(deviceProfile.getProfileData()))); // TODO: voba - should this be always null at the moment?? // if (deviceProfile.getDefaultRuleChainId() != null) { @@ -54,6 +52,12 @@ public class DeviceProfileMsgConstructor { if (deviceProfile.getDescription() != null) { builder.setDescription(deviceProfile.getDescription()); } + if (deviceProfile.getTransportType() != null) { + builder.setTransportType(deviceProfile.getTransportType().name()); + } + if (deviceProfile.getProvisionType() != null) { + builder.setProvisionType(deviceProfile.getProvisionType().name()); + } if (deviceProfile.getProvisionDeviceKey() != null) { builder.setProvisionDeviceKey(deviceProfile.getProvisionDeviceKey()); } diff --git a/application/src/test/java/org/thingsboard/server/edge/BaseEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/BaseEdgeTest.java index 6622a744b5..238e01f741 100644 --- a/application/src/test/java/org/thingsboard/server/edge/BaseEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/BaseEdgeTest.java @@ -32,10 +32,12 @@ import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.Dashboard; import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.Tenant; @@ -45,6 +47,15 @@ import org.thingsboard.server.common.data.alarm.AlarmInfo; import org.thingsboard.server.common.data.alarm.AlarmSeverity; import org.thingsboard.server.common.data.alarm.AlarmStatus; import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.device.profile.AlarmCondition; +import org.thingsboard.server.common.data.device.profile.AlarmConditionFilter; +import org.thingsboard.server.common.data.device.profile.AlarmConditionFilterKey; +import org.thingsboard.server.common.data.device.profile.AlarmConditionKeyType; +import org.thingsboard.server.common.data.device.profile.AlarmRule; +import org.thingsboard.server.common.data.device.profile.AllowCreateNewDevicesDeviceProfileProvisionConfiguration; +import org.thingsboard.server.common.data.device.profile.DeviceProfileAlarm; +import org.thingsboard.server.common.data.device.profile.DeviceProfileData; +import org.thingsboard.server.common.data.device.profile.SimpleAlarmConditionSpec; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.edge.EdgeEvent; import org.thingsboard.server.common.data.edge.EdgeEventActionType; @@ -57,6 +68,9 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.query.EntityKeyValueType; +import org.thingsboard.server.common.data.query.FilterPredicateValue; +import org.thingsboard.server.common.data.query.NumericFilterPredicate; import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.relation.RelationTypeGroup; import org.thingsboard.server.common.data.rule.RuleChain; @@ -71,7 +85,6 @@ import org.thingsboard.server.common.data.widget.WidgetsBundle; import org.thingsboard.server.common.transport.adaptor.JsonConverter; import org.thingsboard.server.controller.AbstractControllerTest; import org.thingsboard.server.dao.edge.EdgeEventService; -import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.edge.imitator.EdgeImitator; import org.thingsboard.server.gen.edge.AlarmUpdateMsg; import org.thingsboard.server.gen.edge.AssetUpdateMsg; @@ -81,6 +94,7 @@ import org.thingsboard.server.gen.edge.CustomerUpdateMsg; import org.thingsboard.server.gen.edge.DashboardUpdateMsg; import org.thingsboard.server.gen.edge.DeviceCredentialsRequestMsg; import org.thingsboard.server.gen.edge.DeviceCredentialsUpdateMsg; +import org.thingsboard.server.gen.edge.DeviceProfileUpdateMsg; import org.thingsboard.server.gen.edge.DeviceRpcCallMsg; import org.thingsboard.server.gen.edge.DeviceUpdateMsg; import org.thingsboard.server.gen.edge.EdgeConfiguration; @@ -106,6 +120,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Random; +import java.util.TreeMap; import java.util.UUID; import java.util.concurrent.TimeUnit; @@ -114,6 +129,8 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. @Slf4j abstract public class BaseEdgeTest extends AbstractControllerTest { + private static final String CUSTOM_DEVICE_PROFILE_NAME = "Thermostat"; + private Tenant savedTenant; private TenantId tenantId; private User tenantAdmin; @@ -145,11 +162,17 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { tenantAdmin.setLastName("Downs"); tenantAdmin = createUserAndLogin(tenantAdmin, "testPassword1"); + // sleep 1 seconds to avoid CREDENTIALS updated message for the user + // user credentials is going to be stored and updated event pushed to edge notification service + // while service will be processing this event edge could be already added and additional message will be pushed + Thread.sleep(1000); + installation(); edgeImitator = new EdgeImitator("localhost", 7070, edge.getRoutingKey(), edge.getSecret()); - // should be less, but events from SyncEdgeService stack with events from controller. will be fixed in next releases - edgeImitator.expectMessageAmount(7); + // TODO: voba - should be less, but events from SyncEdgeService stack with events from controller. will be fixed in next releases + // so ideally sync process should check current edge queue and add only missing entities to the edge queue + edgeImitator.expectMessageAmount(10); edgeImitator.connect(); } @@ -163,23 +186,49 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { .andExpect(status().isOk()); } - @Test public void test() throws Exception { testReceivedInitialData(); + Assert.assertEquals(10, edgeImitator.getDownlinkMsgs().size()); + testDevices(); + Assert.assertEquals(14, edgeImitator.getDownlinkMsgs().size()); + testAssets(); + Assert.assertEquals(18, edgeImitator.getDownlinkMsgs().size()); + testRuleChains(); + Assert.assertEquals(21, edgeImitator.getDownlinkMsgs().size()); + testDashboards(); + Assert.assertEquals(24, edgeImitator.getDownlinkMsgs().size()); + testRelations(); + Assert.assertEquals(26, edgeImitator.getDownlinkMsgs().size()); + testAlarms(); + Assert.assertEquals(29, edgeImitator.getDownlinkMsgs().size()); + testEntityView(); + Assert.assertEquals(31, edgeImitator.getDownlinkMsgs().size()); + testCustomer(); + Assert.assertEquals(33, edgeImitator.getDownlinkMsgs().size()); + testWidgetsBundleAndWidgetType(); + Assert.assertEquals(37, edgeImitator.getDownlinkMsgs().size()); + testTimeseries(); + Assert.assertEquals(38, edgeImitator.getDownlinkMsgs().size()); + testAttributes(); + Assert.assertEquals(41, edgeImitator.getDownlinkMsgs().size()); + testSendMessagesToCloud(); + Assert.assertEquals(50, edgeImitator.getDownlinkMsgs().size()); + testRpcCall(); + Assert.assertEquals(51, edgeImitator.getDownlinkMsgs().size()); } private Device findDeviceByName(String deviceName) throws Exception { @@ -204,10 +253,10 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { return asset; } - private Device saveDevice(String deviceName) throws Exception { + private Device saveDevice(String deviceName, String type) throws Exception { Device device = new Device(); device.setName(deviceName); - device.setType("test"); + device.setType(type); return doPost("/api/device", device, Device.class); } @@ -233,7 +282,7 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { edgeImitator.expectMessageAmount(1); edgeEventService.saveAsync(edgeEvent); clusterService.onEdgeEventUpdate(tenantId, edge.getId()); - edgeImitator.waitForMessages(); + Assert.assertTrue(edgeImitator.waitForMessages()); AbstractMessage latestMessage = edgeImitator.getLatestMessage(); Assert.assertTrue(latestMessage instanceof DeviceRpcCallMsg); @@ -243,7 +292,7 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { private void testReceivedInitialData() throws Exception { log.info("Checking received data"); - edgeImitator.waitForMessages(); + Assert.assertTrue(edgeImitator.waitForMessages()); EdgeConfiguration configuration = edgeImitator.getConfiguration(); Assert.assertNotNull(configuration); @@ -253,9 +302,9 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { UserId userId = edgeImitator.getUserId(); Assert.assertNotNull(userId); - Optional optionalMsg1 = edgeImitator.findMessageByType(DeviceUpdateMsg.class); - Assert.assertTrue(optionalMsg1.isPresent()); - DeviceUpdateMsg deviceUpdateMsg = optionalMsg1.get(); + Optional deviceUpdateMsgOpt = edgeImitator.findMessageByType(DeviceUpdateMsg.class); + Assert.assertTrue(deviceUpdateMsgOpt.isPresent()); + DeviceUpdateMsg deviceUpdateMsg = deviceUpdateMsgOpt.get(); Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, deviceUpdateMsg.getMsgType()); UUID deviceUUID = new UUID(deviceUpdateMsg.getIdMSB(), deviceUpdateMsg.getIdLSB()); Device device = doGet("/api/device/" + deviceUUID.toString(), Device.class); @@ -264,9 +313,25 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { new TypeReference>() {}, new PageLink(100)).getData(); Assert.assertTrue(edgeDevices.contains(device)); - Optional optionalMsg2 = edgeImitator.findMessageByType(AssetUpdateMsg.class); - Assert.assertTrue(optionalMsg2.isPresent()); - AssetUpdateMsg assetUpdateMsg = optionalMsg2.get(); + List deviceProfileUpdateMsgList = edgeImitator.findAllMessagesByType(DeviceProfileUpdateMsg.class); + Assert.assertEquals(3, deviceProfileUpdateMsgList.size()); + Optional deviceProfileUpdateMsgOpt = + deviceProfileUpdateMsgList.stream().filter(dfum -> CUSTOM_DEVICE_PROFILE_NAME.equals(dfum.getName())).findAny(); + Assert.assertTrue(deviceProfileUpdateMsgOpt.isPresent()); + DeviceProfileUpdateMsg deviceProfileUpdateMsg = deviceProfileUpdateMsgOpt.get(); + Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, deviceProfileUpdateMsg.getMsgType()); + UUID deviceProfileUUID = new UUID(deviceProfileUpdateMsg.getIdMSB(), deviceProfileUpdateMsg.getIdLSB()); + DeviceProfile deviceProfile = doGet("/api/deviceProfile/" + deviceProfileUUID.toString(), DeviceProfile.class); + Assert.assertNotNull(deviceProfile); + Assert.assertNotNull(deviceProfile.getProfileData()); + Assert.assertNotNull(deviceProfile.getProfileData().getAlarms()); + Assert.assertNotNull(deviceProfile.getProfileData().getAlarms().get(0).getClearRule()); + + testAutoGeneratedCodeByProtobuf(deviceProfileUpdateMsg); + + Optional assetUpdateMsgOpt = edgeImitator.findMessageByType(AssetUpdateMsg.class); + Assert.assertTrue(assetUpdateMsgOpt.isPresent()); + AssetUpdateMsg assetUpdateMsg = assetUpdateMsgOpt.get(); Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, assetUpdateMsg.getMsgType()); UUID assetUUID = new UUID(assetUpdateMsg.getIdMSB(), assetUpdateMsg.getIdLSB()); Asset asset = doGet("/api/asset/" + assetUUID.toString(), Asset.class); @@ -277,9 +342,9 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { testAutoGeneratedCodeByProtobuf(assetUpdateMsg); - Optional optionalMsg3 = edgeImitator.findMessageByType(RuleChainUpdateMsg.class); - Assert.assertTrue(optionalMsg3.isPresent()); - RuleChainUpdateMsg ruleChainUpdateMsg = optionalMsg3.get(); + Optional ruleChainUpdateMsgOpt = edgeImitator.findMessageByType(RuleChainUpdateMsg.class); + Assert.assertTrue(ruleChainUpdateMsgOpt.isPresent()); + RuleChainUpdateMsg ruleChainUpdateMsg = ruleChainUpdateMsgOpt.get(); Assert.assertEquals(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, ruleChainUpdateMsg.getMsgType()); UUID ruleChainUUID = new UUID(ruleChainUpdateMsg.getIdMSB(), ruleChainUpdateMsg.getIdLSB()); RuleChain ruleChain = doGet("/api/ruleChain/" + ruleChainUUID.toString(), RuleChain.class); @@ -296,13 +361,12 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { private void testDevices() throws Exception { log.info("Testing devices"); - Device savedDevice = saveDevice("Edge Device 2"); - + // 1 edgeImitator.expectMessageAmount(1); + Device savedDevice = saveDevice("Edge Device 2", "Default"); doPost("/api/edge/" + edge.getId().getId().toString() + "/device/" + savedDevice.getId().getId().toString(), Device.class); - edgeImitator.waitForMessages(); - + Assert.assertTrue(edgeImitator.waitForMessages()); AbstractMessage latestMessage = edgeImitator.getLatestMessage(); Assert.assertTrue(latestMessage instanceof DeviceUpdateMsg); DeviceUpdateMsg deviceUpdateMsg = (DeviceUpdateMsg) latestMessage; @@ -312,11 +376,11 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { Assert.assertEquals(deviceUpdateMsg.getName(), savedDevice.getName()); Assert.assertEquals(deviceUpdateMsg.getType(), savedDevice.getType()); + // 2 edgeImitator.expectMessageAmount(1); doDelete("/api/edge/" + edge.getId().getId().toString() + "/device/" + savedDevice.getId().getId().toString(), Device.class); - edgeImitator.waitForMessages(); - + Assert.assertTrue(edgeImitator.waitForMessages()); latestMessage = edgeImitator.getLatestMessage(); Assert.assertTrue(latestMessage instanceof DeviceUpdateMsg); deviceUpdateMsg = (DeviceUpdateMsg) latestMessage; @@ -324,11 +388,34 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { Assert.assertEquals(deviceUpdateMsg.getIdMSB(), savedDevice.getUuidId().getMostSignificantBits()); Assert.assertEquals(deviceUpdateMsg.getIdLSB(), savedDevice.getUuidId().getLeastSignificantBits()); + // 3 edgeImitator.expectMessageAmount(1); doDelete("/api/device/" + savedDevice.getId().getId().toString()) .andExpect(status().isOk()); - edgeImitator.waitForMessages(); + // we should not get any message because device is not assigned to edge any more + Assert.assertFalse(edgeImitator.waitForMessages(1)); + // 4 + edgeImitator.expectMessageAmount(1); + savedDevice = saveDevice("Edge Device 3", "Default"); + doPost("/api/edge/" + edge.getId().getId().toString() + + "/device/" + savedDevice.getId().getId().toString(), Device.class); + Assert.assertTrue(edgeImitator.waitForMessages()); + latestMessage = edgeImitator.getLatestMessage(); + Assert.assertTrue(latestMessage instanceof DeviceUpdateMsg); + deviceUpdateMsg = (DeviceUpdateMsg) latestMessage; + Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, deviceUpdateMsg.getMsgType()); + Assert.assertEquals(deviceUpdateMsg.getIdMSB(), savedDevice.getUuidId().getMostSignificantBits()); + Assert.assertEquals(deviceUpdateMsg.getIdLSB(), savedDevice.getUuidId().getLeastSignificantBits()); + Assert.assertEquals(deviceUpdateMsg.getName(), savedDevice.getName()); + Assert.assertEquals(deviceUpdateMsg.getType(), savedDevice.getType()); + + // 5 + edgeImitator.expectMessageAmount(1); + doDelete("/api/device/" + savedDevice.getId().getId().toString()) + .andExpect(status().isOk()); + // in this case we should get messages because device was assigned to edge + Assert.assertTrue(edgeImitator.waitForMessages()); latestMessage = edgeImitator.getLatestMessage(); Assert.assertTrue(latestMessage instanceof DeviceUpdateMsg); deviceUpdateMsg = (DeviceUpdateMsg) latestMessage; @@ -342,13 +429,13 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { private void testAssets() throws Exception { log.info("Testing assets"); - Asset savedAsset = saveAsset("Edge Asset 2"); + // 1 edgeImitator.expectMessageAmount(1); + Asset savedAsset = saveAsset("Edge Asset 2"); doPost("/api/edge/" + edge.getId().getId().toString() + "/asset/" + savedAsset.getId().getId().toString(), Asset.class); - edgeImitator.waitForMessages(); - + Assert.assertTrue(edgeImitator.waitForMessages()); AbstractMessage latestMessage = edgeImitator.getLatestMessage(); Assert.assertTrue(latestMessage instanceof AssetUpdateMsg); AssetUpdateMsg assetUpdateMsg = (AssetUpdateMsg) latestMessage; @@ -358,11 +445,11 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { Assert.assertEquals(assetUpdateMsg.getName(), savedAsset.getName()); Assert.assertEquals(assetUpdateMsg.getType(), savedAsset.getType()); + // 2 edgeImitator.expectMessageAmount(1); doDelete("/api/edge/" + edge.getId().getId().toString() + "/asset/" + savedAsset.getId().getId().toString(), Asset.class); - edgeImitator.waitForMessages(); - + Assert.assertTrue(edgeImitator.waitForMessages()); latestMessage = edgeImitator.getLatestMessage(); Assert.assertTrue(latestMessage instanceof AssetUpdateMsg); assetUpdateMsg = (AssetUpdateMsg) latestMessage; @@ -370,11 +457,32 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { Assert.assertEquals(assetUpdateMsg.getIdMSB(), savedAsset.getUuidId().getMostSignificantBits()); Assert.assertEquals(assetUpdateMsg.getIdLSB(), savedAsset.getUuidId().getLeastSignificantBits()); + // 3 edgeImitator.expectMessageAmount(1); doDelete("/api/asset/" + savedAsset.getId().getId().toString()) .andExpect(status().isOk()); - edgeImitator.waitForMessages(); + Assert.assertFalse(edgeImitator.waitForMessages(1)); + // 4 + edgeImitator.expectMessageAmount(1); + savedAsset = saveAsset("Edge Asset 3"); + doPost("/api/edge/" + edge.getId().getId().toString() + + "/asset/" + savedAsset.getId().getId().toString(), Asset.class); + Assert.assertTrue(edgeImitator.waitForMessages()); + latestMessage = edgeImitator.getLatestMessage(); + Assert.assertTrue(latestMessage instanceof AssetUpdateMsg); + assetUpdateMsg = (AssetUpdateMsg) latestMessage; + Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, assetUpdateMsg.getMsgType()); + Assert.assertEquals(assetUpdateMsg.getIdMSB(), savedAsset.getUuidId().getMostSignificantBits()); + Assert.assertEquals(assetUpdateMsg.getIdLSB(), savedAsset.getUuidId().getLeastSignificantBits()); + Assert.assertEquals(assetUpdateMsg.getName(), savedAsset.getName()); + Assert.assertEquals(assetUpdateMsg.getType(), savedAsset.getType()); + + // 5 + edgeImitator.expectMessageAmount(1); + doDelete("/api/asset/" + savedAsset.getId().getId().toString()) + .andExpect(status().isOk()); + Assert.assertTrue(edgeImitator.waitForMessages()); latestMessage = edgeImitator.getLatestMessage(); Assert.assertTrue(latestMessage instanceof AssetUpdateMsg); assetUpdateMsg = (AssetUpdateMsg) latestMessage; @@ -387,21 +495,21 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { private void testRuleChains() throws Exception { log.info("Testing RuleChains"); + + // 1 + edgeImitator.expectMessageAmount(1); RuleChain ruleChain = new RuleChain(); ruleChain.setName("Edge Test Rule Chain"); ruleChain.setType(RuleChainType.EDGE); RuleChain savedRuleChain = doPost("/api/ruleChain", ruleChain, RuleChain.class); - createRuleChainMetadata(savedRuleChain); - - // Wait before rule chain metadata saved to database before rule chain is assigned to edge + // sleep 1 seconds to avoid ENTITY_UPDATED_RPC_MESSAGE for the rule chain + // rule chain metadata is going to be stored and updated event pushed to edge notification service + // while service will be processing this event assignment rule chain to edge will be completed if bad timing Thread.sleep(1000); - - edgeImitator.expectMessageAmount(1); doPost("/api/edge/" + edge.getId().getId().toString() + "/ruleChain/" + savedRuleChain.getId().getId().toString(), RuleChain.class); - edgeImitator.waitForMessages(); - + Assert.assertTrue(edgeImitator.waitForMessages()); AbstractMessage latestMessage = edgeImitator.getLatestMessage(); Assert.assertTrue(latestMessage instanceof RuleChainUpdateMsg); RuleChainUpdateMsg ruleChainUpdateMsg = (RuleChainUpdateMsg) latestMessage; @@ -410,13 +518,14 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { Assert.assertEquals(ruleChainUpdateMsg.getIdLSB(), savedRuleChain.getUuidId().getLeastSignificantBits()); Assert.assertEquals(ruleChainUpdateMsg.getName(), savedRuleChain.getName()); + // 2 testRuleChainMetadataRequestMsg(savedRuleChain.getId()); + // 3 edgeImitator.expectMessageAmount(1); doDelete("/api/edge/" + edge.getId().getId().toString() + "/ruleChain/" + savedRuleChain.getId().getId().toString(), RuleChain.class); - edgeImitator.waitForMessages(); - + Assert.assertTrue(edgeImitator.waitForMessages()); latestMessage = edgeImitator.getLatestMessage(); Assert.assertTrue(latestMessage instanceof RuleChainUpdateMsg); ruleChainUpdateMsg = (RuleChainUpdateMsg) latestMessage; @@ -424,17 +533,11 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { Assert.assertEquals(ruleChainUpdateMsg.getIdMSB(), savedRuleChain.getUuidId().getMostSignificantBits()); Assert.assertEquals(ruleChainUpdateMsg.getIdLSB(), savedRuleChain.getUuidId().getLeastSignificantBits()); + // 4 edgeImitator.expectMessageAmount(1); doDelete("/api/ruleChain/" + savedRuleChain.getId().getId().toString()) .andExpect(status().isOk()); - edgeImitator.waitForMessages(); - - latestMessage = edgeImitator.getLatestMessage(); - Assert.assertTrue(latestMessage instanceof RuleChainUpdateMsg); - ruleChainUpdateMsg = (RuleChainUpdateMsg) latestMessage; - Assert.assertEquals(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE, ruleChainUpdateMsg.getMsgType()); - Assert.assertEquals(ruleChainUpdateMsg.getIdMSB(), savedRuleChain.getUuidId().getMostSignificantBits()); - Assert.assertEquals(ruleChainUpdateMsg.getIdLSB(), savedRuleChain.getUuidId().getLeastSignificantBits()); + Assert.assertFalse(edgeImitator.waitForMessages(1)); log.info("RuleChains tested successfully"); } @@ -452,8 +555,8 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { edgeImitator.expectResponsesAmount(1); edgeImitator.expectMessageAmount(1); edgeImitator.sendUplinkMsg(uplinkMsgBuilder.build()); - edgeImitator.waitForResponses(); - edgeImitator.waitForMessages(); + Assert.assertTrue(edgeImitator.waitForResponses()); + Assert.assertTrue(edgeImitator.waitForMessages()); AbstractMessage latestMessage = edgeImitator.getLatestMessage(); Assert.assertTrue(latestMessage instanceof RuleChainMetadataUpdateMsg); @@ -502,15 +605,15 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { private void testDashboards() throws Exception { log.info("Testing Dashboards"); + + // 1 + edgeImitator.expectMessageAmount(1); Dashboard dashboard = new Dashboard(); dashboard.setTitle("Edge Test Dashboard"); Dashboard savedDashboard = doPost("/api/dashboard", dashboard, Dashboard.class); - - edgeImitator.expectMessageAmount(1); doPost("/api/edge/" + edge.getId().getId().toString() + "/dashboard/" + savedDashboard.getId().getId().toString(), Dashboard.class); - edgeImitator.waitForMessages(); - + Assert.assertTrue(edgeImitator.waitForMessages()); AbstractMessage latestMessage = edgeImitator.getLatestMessage(); Assert.assertTrue(latestMessage instanceof DashboardUpdateMsg); DashboardUpdateMsg dashboardUpdateMsg = (DashboardUpdateMsg) latestMessage; @@ -518,25 +621,24 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { Assert.assertEquals(dashboardUpdateMsg.getIdMSB(), savedDashboard.getUuidId().getMostSignificantBits()); Assert.assertEquals(dashboardUpdateMsg.getIdLSB(), savedDashboard.getUuidId().getLeastSignificantBits()); Assert.assertEquals(dashboardUpdateMsg.getTitle(), savedDashboard.getName()); - testAutoGeneratedCodeByProtobuf(dashboardUpdateMsg); + // 2 edgeImitator.expectMessageAmount(1); savedDashboard.setTitle("Updated Edge Test Dashboard"); doPost("/api/dashboard", savedDashboard, Dashboard.class); - edgeImitator.waitForMessages(); - + Assert.assertTrue(edgeImitator.waitForMessages()); latestMessage = edgeImitator.getLatestMessage(); Assert.assertTrue(latestMessage instanceof DashboardUpdateMsg); dashboardUpdateMsg = (DashboardUpdateMsg) latestMessage; Assert.assertEquals(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, dashboardUpdateMsg.getMsgType()); Assert.assertEquals(dashboardUpdateMsg.getTitle(), savedDashboard.getName()); + // 3 edgeImitator.expectMessageAmount(1); doDelete("/api/edge/" + edge.getId().getId().toString() + "/dashboard/" + savedDashboard.getId().getId().toString(), Dashboard.class); - edgeImitator.waitForMessages(); - + Assert.assertTrue(edgeImitator.waitForMessages()); latestMessage = edgeImitator.getLatestMessage(); Assert.assertTrue(latestMessage instanceof DashboardUpdateMsg); dashboardUpdateMsg = (DashboardUpdateMsg) latestMessage; @@ -544,17 +646,11 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { Assert.assertEquals(dashboardUpdateMsg.getIdMSB(), savedDashboard.getUuidId().getMostSignificantBits()); Assert.assertEquals(dashboardUpdateMsg.getIdLSB(), savedDashboard.getUuidId().getLeastSignificantBits()); + // 4 edgeImitator.expectMessageAmount(1); doDelete("/api/dashboard/" + savedDashboard.getId().getId().toString()) .andExpect(status().isOk()); - edgeImitator.waitForMessages(); - - latestMessage = edgeImitator.getLatestMessage(); - Assert.assertTrue(latestMessage instanceof DashboardUpdateMsg); - dashboardUpdateMsg = (DashboardUpdateMsg) latestMessage; - Assert.assertEquals(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE, dashboardUpdateMsg.getMsgType()); - Assert.assertEquals(dashboardUpdateMsg.getIdMSB(), savedDashboard.getUuidId().getMostSignificantBits()); - Assert.assertEquals(dashboardUpdateMsg.getIdLSB(), savedDashboard.getUuidId().getLeastSignificantBits()); + Assert.assertFalse(edgeImitator.waitForMessages(1)); log.info("Dashboards tested successfully"); } @@ -562,19 +658,17 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { private void testRelations() throws Exception { log.info("Testing Relations"); + // 1 + edgeImitator.expectMessageAmount(1); Device device = findDeviceByName("Edge Device 1"); Asset asset = findAssetByName("Edge Asset 1"); - EntityRelation relation = new EntityRelation(); relation.setType("test"); relation.setFrom(device.getId()); relation.setTo(asset.getId()); relation.setTypeGroup(RelationTypeGroup.COMMON); - - edgeImitator.expectMessageAmount(1); doPost("/api/relation", relation); - edgeImitator.waitForMessages(); - + Assert.assertTrue(edgeImitator.waitForMessages()); AbstractMessage latestMessage = edgeImitator.getLatestMessage(); Assert.assertTrue(latestMessage instanceof RelationUpdateMsg); RelationUpdateMsg relationUpdateMsg = (RelationUpdateMsg) latestMessage; @@ -588,6 +682,7 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { Assert.assertEquals(relationUpdateMsg.getToEntityType(), relation.getTo().getEntityType().name()); Assert.assertEquals(relationUpdateMsg.getTypeGroup(), relation.getTypeGroup().name()); + // 2 edgeImitator.expectMessageAmount(1); doDelete("/api/relation?" + "fromId=" + relation.getFrom().getId().toString() + @@ -597,8 +692,7 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { "&toId=" + relation.getTo().getId().toString() + "&toType=" + relation.getTo().getEntityType().name()) .andExpect(status().isOk()); - edgeImitator.waitForMessages(); - + Assert.assertTrue(edgeImitator.waitForMessages()); latestMessage = edgeImitator.getLatestMessage(); Assert.assertTrue(latestMessage instanceof RelationUpdateMsg); relationUpdateMsg = (RelationUpdateMsg) latestMessage; @@ -617,18 +711,17 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { private void testAlarms() throws Exception { log.info("Testing Alarms"); - Device device = findDeviceByName("Edge Device 1"); + // 1 + edgeImitator.expectMessageAmount(1); + Device device = findDeviceByName("Edge Device 1"); Alarm alarm = new Alarm(); alarm.setOriginator(device.getId()); alarm.setStatus(AlarmStatus.ACTIVE_UNACK); alarm.setType("alarm"); alarm.setSeverity(AlarmSeverity.CRITICAL); - - edgeImitator.expectMessageAmount(1); Alarm savedAlarm = doPost("/api/alarm", alarm, Alarm.class); - edgeImitator.waitForMessages(); - + Assert.assertTrue(edgeImitator.waitForMessages()); AbstractMessage latestMessage = edgeImitator.getLatestMessage(); Assert.assertTrue(latestMessage instanceof AlarmUpdateMsg); AlarmUpdateMsg alarmUpdateMsg = (AlarmUpdateMsg) latestMessage; @@ -639,10 +732,10 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { Assert.assertEquals(alarmUpdateMsg.getStatus(), savedAlarm.getStatus().name()); Assert.assertEquals(alarmUpdateMsg.getSeverity(), savedAlarm.getSeverity().name()); + // 2 edgeImitator.expectMessageAmount(1); doPost("/api/alarm/" + savedAlarm.getId().getId().toString() + "/ack"); - edgeImitator.waitForMessages(); - + Assert.assertTrue(edgeImitator.waitForMessages()); latestMessage = edgeImitator.getLatestMessage(); Assert.assertTrue(latestMessage instanceof AlarmUpdateMsg); alarmUpdateMsg = (AlarmUpdateMsg) latestMessage; @@ -652,10 +745,10 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { Assert.assertEquals(alarmUpdateMsg.getOriginatorName(), device.getName()); Assert.assertEquals(alarmUpdateMsg.getStatus(), AlarmStatus.ACTIVE_ACK.name()); + // 3 edgeImitator.expectMessageAmount(1); doPost("/api/alarm/" + savedAlarm.getId().getId().toString() + "/clear"); - edgeImitator.waitForMessages(); - + Assert.assertTrue(edgeImitator.waitForMessages()); latestMessage = edgeImitator.getLatestMessage(); Assert.assertTrue(latestMessage instanceof AlarmUpdateMsg); alarmUpdateMsg = (AlarmUpdateMsg) latestMessage; @@ -665,26 +758,29 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { Assert.assertEquals(alarmUpdateMsg.getOriginatorName(), device.getName()); Assert.assertEquals(alarmUpdateMsg.getStatus(), AlarmStatus.CLEARED_ACK.name()); + // 4 + edgeImitator.expectMessageAmount(1); doDelete("/api/alarm/" + savedAlarm.getId().getId().toString()) .andExpect(status().isOk()); + Assert.assertFalse(edgeImitator.waitForMessages(1)); + log.info("Alarms tested successfully"); } private void testEntityView() throws Exception { log.info("Testing EntityView"); - Device device = findDeviceByName("Edge Device 1"); + // 1 + edgeImitator.expectMessageAmount(1); + Device device = findDeviceByName("Edge Device 1"); EntityView entityView = new EntityView(); entityView.setName("Edge EntityView 1"); entityView.setType("test"); entityView.setEntityId(device.getId()); EntityView savedEntityView = doPost("/api/entityView", entityView, EntityView.class); - - edgeImitator.expectMessageAmount(1); doPost("/api/edge/" + edge.getId().getId().toString() + "/entityView/" + savedEntityView.getId().getId().toString(), EntityView.class); - edgeImitator.waitForMessages(); - + Assert.assertTrue(edgeImitator.waitForMessages()); AbstractMessage latestMessage = edgeImitator.getLatestMessage(); Assert.assertTrue(latestMessage instanceof EntityViewUpdateMsg); EntityViewUpdateMsg entityViewUpdateMsg = (EntityViewUpdateMsg) latestMessage; @@ -697,11 +793,11 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { Assert.assertEquals(entityViewUpdateMsg.getEntityIdLSB(), device.getUuidId().getLeastSignificantBits()); Assert.assertEquals(entityViewUpdateMsg.getEntityType().name(), device.getId().getEntityType().name()); + // 2 edgeImitator.expectMessageAmount(1); doDelete("/api/edge/" + edge.getId().getId().toString() + "/entityView/" + savedEntityView.getId().getId().toString(), EntityView.class); - edgeImitator.waitForMessages(); - + Assert.assertTrue(edgeImitator.waitForMessages()); latestMessage = edgeImitator.getLatestMessage(); Assert.assertTrue(latestMessage instanceof EntityViewUpdateMsg); entityViewUpdateMsg = (EntityViewUpdateMsg) latestMessage; @@ -709,17 +805,11 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { Assert.assertEquals(entityViewUpdateMsg.getIdMSB(), savedEntityView.getUuidId().getMostSignificantBits()); Assert.assertEquals(entityViewUpdateMsg.getIdLSB(), savedEntityView.getUuidId().getLeastSignificantBits()); + // 3 edgeImitator.expectMessageAmount(1); doDelete("/api/entityView/" + savedEntityView.getId().getId().toString()) .andExpect(status().isOk()); - edgeImitator.waitForMessages(); - - latestMessage = edgeImitator.getLatestMessage(); - Assert.assertTrue(latestMessage instanceof EntityViewUpdateMsg); - entityViewUpdateMsg = (EntityViewUpdateMsg) latestMessage; - Assert.assertEquals(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE, entityViewUpdateMsg.getMsgType()); - Assert.assertEquals(entityViewUpdateMsg.getIdMSB(), savedEntityView.getUuidId().getMostSignificantBits()); - Assert.assertEquals(entityViewUpdateMsg.getIdLSB(), savedEntityView.getUuidId().getLeastSignificantBits()); + Assert.assertFalse(edgeImitator.waitForMessages(1)); log.info("EntityView tested successfully"); } @@ -727,15 +817,14 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { private void testCustomer() throws Exception { log.info("Testing Customer"); + // 1 + edgeImitator.expectMessageAmount(1); Customer customer = new Customer(); customer.setTitle("Edge Customer 1"); Customer savedCustomer = doPost("/api/customer", customer, Customer.class); - - edgeImitator.expectMessageAmount(1); doPost("/api/customer/" + savedCustomer.getId().getId().toString() + "/edge/" + edge.getId().getId().toString(), Edge.class); - edgeImitator.waitForMessages(); - + Assert.assertTrue(edgeImitator.waitForMessages()); AbstractMessage latestMessage = edgeImitator.getLatestMessage(); Assert.assertTrue(latestMessage instanceof CustomerUpdateMsg); CustomerUpdateMsg customerUpdateMsg = (CustomerUpdateMsg) latestMessage; @@ -743,13 +832,12 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { Assert.assertEquals(customerUpdateMsg.getIdMSB(), savedCustomer.getUuidId().getMostSignificantBits()); Assert.assertEquals(customerUpdateMsg.getIdLSB(), savedCustomer.getUuidId().getLeastSignificantBits()); Assert.assertEquals(customerUpdateMsg.getTitle(), savedCustomer.getTitle()); - testAutoGeneratedCodeByProtobuf(customerUpdateMsg); + // 2 edgeImitator.expectMessageAmount(1); doDelete("/api/customer/edge/" + edge.getId().getId().toString(), Edge.class); - edgeImitator.waitForMessages(); - + Assert.assertTrue(edgeImitator.waitForMessages()); latestMessage = edgeImitator.getLatestMessage(); Assert.assertTrue(latestMessage instanceof CustomerUpdateMsg); customerUpdateMsg = (CustomerUpdateMsg) latestMessage; @@ -757,17 +845,11 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { Assert.assertEquals(customerUpdateMsg.getIdMSB(), savedCustomer.getUuidId().getMostSignificantBits()); Assert.assertEquals(customerUpdateMsg.getIdLSB(), savedCustomer.getUuidId().getLeastSignificantBits()); + // 3 edgeImitator.expectMessageAmount(1); doDelete("/api/customer/" + savedCustomer.getId().getId().toString()) .andExpect(status().isOk()); - edgeImitator.waitForMessages(); - - latestMessage = edgeImitator.getLatestMessage(); - Assert.assertTrue(latestMessage instanceof CustomerUpdateMsg); - customerUpdateMsg = (CustomerUpdateMsg) latestMessage; - Assert.assertEquals(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE, customerUpdateMsg.getMsgType()); - Assert.assertEquals(customerUpdateMsg.getIdMSB(), savedCustomer.getUuidId().getMostSignificantBits()); - Assert.assertEquals(customerUpdateMsg.getIdLSB(), savedCustomer.getUuidId().getLeastSignificantBits()); + Assert.assertFalse(edgeImitator.waitForMessages(1)); log.info("Customer tested successfully"); } @@ -775,13 +857,12 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { private void testWidgetsBundleAndWidgetType() throws Exception { log.info("Testing WidgetsBundle and WidgetType"); + // 1 + edgeImitator.expectMessageAmount(1); WidgetsBundle widgetsBundle = new WidgetsBundle(); widgetsBundle.setTitle("Test Widget Bundle"); - - edgeImitator.expectMessageAmount(1); WidgetsBundle savedWidgetsBundle = doPost("/api/widgetsBundle", widgetsBundle, WidgetsBundle.class); - edgeImitator.waitForMessages(); - + Assert.assertTrue(edgeImitator.waitForMessages()); AbstractMessage latestMessage = edgeImitator.getLatestMessage(); Assert.assertTrue(latestMessage instanceof WidgetsBundleUpdateMsg); WidgetsBundleUpdateMsg widgetsBundleUpdateMsg = (WidgetsBundleUpdateMsg) latestMessage; @@ -790,20 +871,18 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { Assert.assertEquals(widgetsBundleUpdateMsg.getIdLSB(), savedWidgetsBundle.getUuidId().getLeastSignificantBits()); Assert.assertEquals(widgetsBundleUpdateMsg.getAlias(), savedWidgetsBundle.getAlias()); Assert.assertEquals(widgetsBundleUpdateMsg.getTitle(), savedWidgetsBundle.getTitle()); - testAutoGeneratedCodeByProtobuf(widgetsBundleUpdateMsg); + // 2 + edgeImitator.expectMessageAmount(1); WidgetType widgetType = new WidgetType(); widgetType.setName("Test Widget Type"); widgetType.setBundleAlias(savedWidgetsBundle.getAlias()); ObjectNode descriptor = mapper.createObjectNode(); descriptor.put("key", "value"); widgetType.setDescriptor(descriptor); - - edgeImitator.expectMessageAmount(1); WidgetType savedWidgetType = doPost("/api/widgetType", widgetType, WidgetType.class); - edgeImitator.waitForMessages(); - + Assert.assertTrue(edgeImitator.waitForMessages()); latestMessage = edgeImitator.getLatestMessage(); Assert.assertTrue(latestMessage instanceof WidgetTypeUpdateMsg); WidgetTypeUpdateMsg widgetTypeUpdateMsg = (WidgetTypeUpdateMsg) latestMessage; @@ -814,11 +893,11 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { Assert.assertEquals(widgetTypeUpdateMsg.getName(), savedWidgetType.getName()); Assert.assertEquals(JacksonUtil.toJsonNode(widgetTypeUpdateMsg.getDescriptorJson()), savedWidgetType.getDescriptor()); + // 3 edgeImitator.expectMessageAmount(1); doDelete("/api/widgetType/" + savedWidgetType.getId().getId().toString()) .andExpect(status().isOk()); - edgeImitator.waitForMessages(); - + Assert.assertTrue(edgeImitator.waitForMessages()); latestMessage = edgeImitator.getLatestMessage(); Assert.assertTrue(latestMessage instanceof WidgetTypeUpdateMsg); widgetTypeUpdateMsg = (WidgetTypeUpdateMsg) latestMessage; @@ -826,11 +905,11 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { Assert.assertEquals(widgetTypeUpdateMsg.getIdMSB(), savedWidgetType.getUuidId().getMostSignificantBits()); Assert.assertEquals(widgetTypeUpdateMsg.getIdLSB(), savedWidgetType.getUuidId().getLeastSignificantBits()); + // 4 edgeImitator.expectMessageAmount(1); doDelete("/api/widgetsBundle/" + savedWidgetsBundle.getId().getId().toString()) .andExpect(status().isOk()); - edgeImitator.waitForMessages(); - + Assert.assertTrue(edgeImitator.waitForMessages()); latestMessage = edgeImitator.getLatestMessage(); Assert.assertTrue(latestMessage instanceof WidgetsBundleUpdateMsg); widgetsBundleUpdateMsg = (WidgetsBundleUpdateMsg) latestMessage; @@ -843,15 +922,15 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { private void testTimeseries() throws Exception { log.info("Testing timeseries"); - Device device = findDeviceByName("Edge Device 1"); + edgeImitator.expectMessageAmount(1); + Device device = findDeviceByName("Edge Device 1"); String timeseriesData = "{\"data\":{\"temperature\":25},\"ts\":" + System.currentTimeMillis() + "}"; JsonNode timeseriesEntityData = mapper.readTree(timeseriesData); - EdgeEvent edgeEvent1 = constructEdgeEvent(tenantId, edge.getId(), EdgeEventActionType.TIMESERIES_UPDATED, device.getId().getId(), EdgeEventType.DEVICE, timeseriesEntityData); - edgeImitator.expectMessageAmount(1); - edgeEventService.saveAsync(edgeEvent1); + EdgeEvent edgeEvent = constructEdgeEvent(tenantId, edge.getId(), EdgeEventActionType.TIMESERIES_UPDATED, device.getId().getId(), EdgeEventType.DEVICE, timeseriesEntityData); + edgeEventService.saveAsync(edgeEvent); clusterService.onEdgeEventUpdate(tenantId, edge.getId()); - edgeImitator.waitForMessages(); + Assert.assertTrue(edgeImitator.waitForMessages()); AbstractMessage latestMessage = edgeImitator.getLatestMessage(); Assert.assertTrue(latestMessage instanceof EntityDataProto); @@ -890,7 +969,7 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { edgeImitator.expectMessageAmount(1); edgeEventService.saveAsync(edgeEvent); clusterService.onEdgeEventUpdate(tenantId, edge.getId()); - edgeImitator.waitForMessages(); + Assert.assertTrue(edgeImitator.waitForMessages()); AbstractMessage latestMessage = edgeImitator.getLatestMessage(); Assert.assertTrue(latestMessage instanceof EntityDataProto); @@ -916,7 +995,7 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { edgeImitator.expectMessageAmount(1); edgeEventService.saveAsync(edgeEvent); clusterService.onEdgeEventUpdate(tenantId, edge.getId()); - edgeImitator.waitForMessages(); + Assert.assertTrue(edgeImitator.waitForMessages()); AbstractMessage latestMessage = edgeImitator.getLatestMessage(); Assert.assertTrue(latestMessage instanceof EntityDataProto); @@ -941,7 +1020,7 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { edgeImitator.expectMessageAmount(1); edgeEventService.saveAsync(edgeEvent1); clusterService.onEdgeEventUpdate(tenantId, edge.getId()); - edgeImitator.waitForMessages(); + Assert.assertTrue(edgeImitator.waitForMessages()); AbstractMessage latestMessage = edgeImitator.getLatestMessage(); Assert.assertTrue(latestMessage instanceof EntityDataProto); @@ -996,8 +1075,8 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { edgeImitator.sendUplinkMsg(uplinkMsgBuilder.build()); - edgeImitator.waitForResponses(); - edgeImitator.waitForMessages(); + Assert.assertTrue(edgeImitator.waitForResponses()); + Assert.assertTrue(edgeImitator.waitForMessages()); AbstractMessage latestMessage = edgeImitator.getLatestMessage(); Assert.assertTrue(latestMessage instanceof DeviceUpdateMsg); @@ -1013,7 +1092,7 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { private void sendDeviceWithNameThatAlreadyExistsOnCloud() throws Exception { String deviceOnCloudName = RandomStringUtils.randomAlphanumeric(15); - Device deviceOnCloud = saveDevice(deviceOnCloudName); + Device deviceOnCloud = saveDevice(deviceOnCloudName, "Default"); UUID uuid = Uuids.timeBased(); @@ -1033,8 +1112,8 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { edgeImitator.sendUplinkMsg(uplinkMsgBuilder.build()); - edgeImitator.waitForResponses(); - edgeImitator.waitForMessages(); + Assert.assertTrue(edgeImitator.waitForResponses()); + Assert.assertTrue(edgeImitator.waitForMessages()); AbstractMessage latestMessage = edgeImitator.getLatestMessage(); Assert.assertTrue(latestMessage instanceof DeviceUpdateMsg); @@ -1063,7 +1142,7 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { edgeImitator.expectMessageAmount(1); doPost("/api/relation", relation); - edgeImitator.waitForMessages(); + Assert.assertTrue(edgeImitator.waitForMessages()); UplinkMsg.Builder uplinkMsgBuilder = UplinkMsg.newBuilder(); RelationRequestMsg.Builder relationRequestMsgBuilder = RelationRequestMsg.newBuilder(); @@ -1078,8 +1157,8 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { edgeImitator.expectResponsesAmount(1); edgeImitator.expectMessageAmount(1); edgeImitator.sendUplinkMsg(uplinkMsgBuilder.build()); - edgeImitator.waitForResponses(); - edgeImitator.waitForMessages(); + Assert.assertTrue(edgeImitator.waitForResponses()); + Assert.assertTrue(edgeImitator.waitForMessages()); AbstractMessage latestMessage = edgeImitator.getLatestMessage(); Assert.assertTrue(latestMessage instanceof RelationUpdateMsg); @@ -1115,7 +1194,7 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { edgeImitator.expectResponsesAmount(1); edgeImitator.sendUplinkMsg(uplinkMsgBuilder.build()); - edgeImitator.waitForResponses(); + Assert.assertTrue(edgeImitator.waitForResponses()); List alarms = doGetTypedWithPageLink("/api/alarm/{entityType}/{entityId}?", @@ -1158,7 +1237,7 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { edgeImitator.expectResponsesAmount(1); edgeImitator.sendUplinkMsg(uplinkMsgBuilder.build()); - edgeImitator.waitForResponses(); + Assert.assertTrue(edgeImitator.waitForResponses()); EntityRelation relation = doGet("/api/relation?" + "&fromId=" + device2.getId().getId().toString() + @@ -1212,7 +1291,7 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { testAutoGeneratedCodeByProtobuf(uplinkMsgBuilder2); edgeImitator.sendUplinkMsg(uplinkMsgBuilder2.build()); - edgeImitator.waitForResponses(); + Assert.assertTrue(edgeImitator.waitForResponses()); // Wait before device attributes saved to database before requesting them from controller Thread.sleep(1000); @@ -1243,8 +1322,8 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { edgeImitator.expectResponsesAmount(1); edgeImitator.expectMessageAmount(1); edgeImitator.sendUplinkMsg(uplinkMsgBuilder.build()); - edgeImitator.waitForResponses(); - edgeImitator.waitForMessages(); + Assert.assertTrue(edgeImitator.waitForResponses()); + Assert.assertTrue(edgeImitator.waitForMessages());; AbstractMessage latestMessage = edgeImitator.getLatestMessage(); Assert.assertTrue(latestMessage instanceof RuleChainMetadataUpdateMsg); @@ -1270,8 +1349,8 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { edgeImitator.expectResponsesAmount(1); edgeImitator.expectMessageAmount(1); edgeImitator.sendUplinkMsg(uplinkMsgBuilder.build()); - edgeImitator.waitForResponses(); - edgeImitator.waitForMessages(); + Assert.assertTrue(edgeImitator.waitForResponses()); + Assert.assertTrue(edgeImitator.waitForMessages()); AbstractMessage latestMessage = edgeImitator.getLatestMessage(); Assert.assertTrue(latestMessage instanceof UserCredentialsUpdateMsg); @@ -1299,8 +1378,8 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { edgeImitator.expectResponsesAmount(1); edgeImitator.expectMessageAmount(1); edgeImitator.sendUplinkMsg(uplinkMsgBuilder.build()); - edgeImitator.waitForResponses(); - edgeImitator.waitForMessages(); + Assert.assertTrue(edgeImitator.waitForResponses()); + Assert.assertTrue(edgeImitator.waitForMessages()); AbstractMessage latestMessage = edgeImitator.getLatestMessage(); Assert.assertTrue(latestMessage instanceof DeviceCredentialsUpdateMsg); @@ -1327,7 +1406,7 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { edgeImitator.expectResponsesAmount(1); edgeImitator.sendUplinkMsg(uplinkMsgBuilder.build()); - edgeImitator.waitForResponses(); + Assert.assertTrue(edgeImitator.waitForResponses()); } private void sendDeviceRpcResponse() throws Exception { @@ -1352,7 +1431,7 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { edgeImitator.expectResponsesAmount(1); edgeImitator.sendUplinkMsg(uplinkMsgBuilder.build()); - edgeImitator.waitForResponses(); + Assert.assertTrue(edgeImitator.waitForResponses()); } private void sendAttributesRequest() throws Exception { @@ -1383,8 +1462,8 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { edgeImitator.expectResponsesAmount(1); edgeImitator.expectMessageAmount(1); edgeImitator.sendUplinkMsg(uplinkMsgBuilder.build()); - edgeImitator.waitForResponses(); - edgeImitator.waitForMessages(); + Assert.assertTrue(edgeImitator.waitForResponses()); + Assert.assertTrue(edgeImitator.waitForMessages()); AbstractMessage latestMessage = edgeImitator.getLatestMessage(); Assert.assertTrue(latestMessage instanceof EntityDataProto); @@ -1416,7 +1495,7 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { edgeImitator.expectResponsesAmount(1); edgeImitator.sendUplinkMsg(upLinkMsgBuilder.build()); - edgeImitator.waitForResponses(); + Assert.assertTrue(edgeImitator.waitForResponses()); device = doGet("/api/device/" + device.getId().getId().toString(), Device.class); Assert.assertNotNull(device); List edgeDevices = doGetTypedWithPageLink("/api/edge/" + edge.getId().getId().toString() + "/devices?", @@ -1428,7 +1507,11 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { private void installation() throws Exception { edge = doPost("/api/edge", constructEdge("Test Edge", "test"), Edge.class); - Device savedDevice = saveDevice("Edge Device 1"); + DeviceProfile deviceProfile = this.createDeviceProfile(CUSTOM_DEVICE_PROFILE_NAME, null); + extendDeviceProfileData(deviceProfile); + doPost("/api/deviceProfile", deviceProfile, DeviceProfile.class); + + Device savedDevice = saveDevice("Edge Device 1", CUSTOM_DEVICE_PROFILE_NAME); doPost("/api/edge/" + edge.getId().getId().toString() + "/device/" + savedDevice.getId().getId().toString(), Device.class); @@ -1437,6 +1520,35 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { + "/asset/" + savedAsset.getId().getId().toString(), Asset.class); } + private void extendDeviceProfileData(DeviceProfile deviceProfile) { + DeviceProfileData profileData = deviceProfile.getProfileData(); + List alarms = new ArrayList<>(); + DeviceProfileAlarm deviceProfileAlarm = new DeviceProfileAlarm(); + deviceProfileAlarm.setAlarmType("High Temperature"); + AlarmRule alarmRule = new AlarmRule(); + alarmRule.setAlarmDetails("Alarm Details"); + AlarmCondition alarmCondition = new AlarmCondition(); + alarmCondition.setSpec(new SimpleAlarmConditionSpec()); + List condition = new ArrayList<>(); + AlarmConditionFilter alarmConditionFilter = new AlarmConditionFilter(); + alarmConditionFilter.setKey(new AlarmConditionFilterKey(AlarmConditionKeyType.ATTRIBUTE, "temperature")); + NumericFilterPredicate predicate = new NumericFilterPredicate(); + predicate.setOperation(NumericFilterPredicate.NumericOperation.GREATER); + predicate.setValue(new FilterPredicateValue<>(55.0)); + alarmConditionFilter.setPredicate(predicate); + alarmConditionFilter.setValueType(EntityKeyValueType.NUMERIC); + condition.add(alarmConditionFilter); + alarmCondition.setCondition(condition); + alarmRule.setCondition(alarmCondition); + deviceProfileAlarm.setClearRule(alarmRule); + TreeMap createRules = new TreeMap<>(); + createRules.put(AlarmSeverity.CRITICAL, alarmRule); + deviceProfileAlarm.setCreateRules(createRules); + alarms.add(deviceProfileAlarm); + profileData.setAlarms(alarms); + profileData.setProvisionConfiguration(new AllowCreateNewDevicesDeviceProfileProvisionConfiguration("123")); + } + private EdgeEvent constructEdgeEvent(TenantId tenantId, EdgeId edgeId, EdgeEventActionType edgeEventAction, UUID entityId, EdgeEventType edgeEventType, JsonNode entityBody) { EdgeEvent edgeEvent = new EdgeEvent(); edgeEvent.setEdgeId(edgeId); diff --git a/application/src/test/java/org/thingsboard/server/edge/imitator/EdgeImitator.java b/application/src/test/java/org/thingsboard/server/edge/imitator/EdgeImitator.java index e630f2b212..6b4234f151 100644 --- a/application/src/test/java/org/thingsboard/server/edge/imitator/EdgeImitator.java +++ b/application/src/test/java/org/thingsboard/server/edge/imitator/EdgeImitator.java @@ -32,6 +32,7 @@ import org.thingsboard.server.gen.edge.CustomerUpdateMsg; import org.thingsboard.server.gen.edge.DashboardUpdateMsg; import org.thingsboard.server.gen.edge.DeviceCredentialsRequestMsg; import org.thingsboard.server.gen.edge.DeviceCredentialsUpdateMsg; +import org.thingsboard.server.gen.edge.DeviceProfileUpdateMsg; import org.thingsboard.server.gen.edge.DeviceRpcCallMsg; import org.thingsboard.server.gen.edge.DeviceUpdateMsg; import org.thingsboard.server.gen.edge.DownlinkMsg; @@ -58,6 +59,7 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; +import java.util.stream.Collectors; @Slf4j public class EdgeImitator { @@ -154,88 +156,93 @@ public class EdgeImitator { private ListenableFuture> processDownlinkMsg(DownlinkMsg downlinkMsg) { List> result = new ArrayList<>(); - if (downlinkMsg.getDeviceUpdateMsgList() != null && !downlinkMsg.getDeviceUpdateMsgList().isEmpty()) { + if (downlinkMsg.getDeviceUpdateMsgCount() > 0) { for (DeviceUpdateMsg deviceUpdateMsg: downlinkMsg.getDeviceUpdateMsgList()) { result.add(saveDownlinkMsg(deviceUpdateMsg)); } } - if (downlinkMsg.getDeviceCredentialsUpdateMsgList() != null && !downlinkMsg.getDeviceCredentialsUpdateMsgList().isEmpty()) { + if (downlinkMsg.getDeviceProfileUpdateMsgCount() > 0) { + for (DeviceProfileUpdateMsg deviceProfileUpdateMsg : downlinkMsg.getDeviceProfileUpdateMsgList()) { + result.add(saveDownlinkMsg(deviceProfileUpdateMsg)); + } + } + if (downlinkMsg.getDeviceCredentialsUpdateMsgCount() > 0) { for (DeviceCredentialsUpdateMsg deviceCredentialsUpdateMsg: downlinkMsg.getDeviceCredentialsUpdateMsgList()) { result.add(saveDownlinkMsg(deviceCredentialsUpdateMsg)); } } - if (downlinkMsg.getAssetUpdateMsgList() != null && !downlinkMsg.getAssetUpdateMsgList().isEmpty()) { + if (downlinkMsg.getAssetUpdateMsgCount() > 0) { for (AssetUpdateMsg assetUpdateMsg: downlinkMsg.getAssetUpdateMsgList()) { result.add(saveDownlinkMsg(assetUpdateMsg)); } } - if (downlinkMsg.getRuleChainUpdateMsgList() != null && !downlinkMsg.getRuleChainUpdateMsgList().isEmpty()) { + if (downlinkMsg.getRuleChainUpdateMsgCount() > 0) { for (RuleChainUpdateMsg ruleChainUpdateMsg: downlinkMsg.getRuleChainUpdateMsgList()) { result.add(saveDownlinkMsg(ruleChainUpdateMsg)); } } - if (downlinkMsg.getRuleChainMetadataUpdateMsgList() != null && !downlinkMsg.getRuleChainMetadataUpdateMsgList().isEmpty()) { + if (downlinkMsg.getRuleChainMetadataUpdateMsgCount() > 0) { for (RuleChainMetadataUpdateMsg ruleChainMetadataUpdateMsg: downlinkMsg.getRuleChainMetadataUpdateMsgList()) { result.add(saveDownlinkMsg(ruleChainMetadataUpdateMsg)); } } - if (downlinkMsg.getDashboardUpdateMsgList() != null && !downlinkMsg.getDashboardUpdateMsgList().isEmpty()) { + if (downlinkMsg.getDashboardUpdateMsgCount() > 0) { for (DashboardUpdateMsg dashboardUpdateMsg: downlinkMsg.getDashboardUpdateMsgList()) { result.add(saveDownlinkMsg(dashboardUpdateMsg)); } } - if (downlinkMsg.getRelationUpdateMsgList() != null && !downlinkMsg.getRelationUpdateMsgList().isEmpty()) { + if (downlinkMsg.getRelationUpdateMsgCount() > 0) { for (RelationUpdateMsg relationUpdateMsg: downlinkMsg.getRelationUpdateMsgList()) { result.add(saveDownlinkMsg(relationUpdateMsg)); } } - if (downlinkMsg.getAlarmUpdateMsgList() != null && !downlinkMsg.getAlarmUpdateMsgList().isEmpty()) { + if (downlinkMsg.getAlarmUpdateMsgCount() > 0) { for (AlarmUpdateMsg alarmUpdateMsg: downlinkMsg.getAlarmUpdateMsgList()) { result.add(saveDownlinkMsg(alarmUpdateMsg)); } } - if (downlinkMsg.getEntityDataList() != null && !downlinkMsg.getEntityDataList().isEmpty()) { + if (downlinkMsg.getEntityDataCount() > 0) { for (EntityDataProto entityData: downlinkMsg.getEntityDataList()) { result.add(saveDownlinkMsg(entityData)); } } - if (downlinkMsg.getEntityViewUpdateMsgList() != null && !downlinkMsg.getEntityViewUpdateMsgList().isEmpty()) { + if (downlinkMsg.getEntityViewUpdateMsgCount() > 0) { for (EntityViewUpdateMsg entityViewUpdateMsg: downlinkMsg.getEntityViewUpdateMsgList()) { result.add(saveDownlinkMsg(entityViewUpdateMsg)); } } - if (downlinkMsg.getCustomerUpdateMsgList() != null && !downlinkMsg.getCustomerUpdateMsgList().isEmpty()) { + if (downlinkMsg.getCustomerUpdateMsgCount() > 0) { for (CustomerUpdateMsg customerUpdateMsg: downlinkMsg.getCustomerUpdateMsgList()) { result.add(saveDownlinkMsg(customerUpdateMsg)); } } - if (downlinkMsg.getWidgetsBundleUpdateMsgList() != null && !downlinkMsg.getWidgetsBundleUpdateMsgList().isEmpty()) { + if (downlinkMsg.getWidgetsBundleUpdateMsgCount() > 0) { for (WidgetsBundleUpdateMsg widgetsBundleUpdateMsg: downlinkMsg.getWidgetsBundleUpdateMsgList()) { result.add(saveDownlinkMsg(widgetsBundleUpdateMsg)); } } - if (downlinkMsg.getWidgetTypeUpdateMsgList() != null && !downlinkMsg.getWidgetTypeUpdateMsgList().isEmpty()) { + if (downlinkMsg.getWidgetTypeUpdateMsgCount() > 0) { for (WidgetTypeUpdateMsg widgetTypeUpdateMsg: downlinkMsg.getWidgetTypeUpdateMsgList()) { result.add(saveDownlinkMsg(widgetTypeUpdateMsg)); } } - if (downlinkMsg.getUserUpdateMsgList() != null && !downlinkMsg.getUserUpdateMsgList().isEmpty()) { + if (downlinkMsg.getUserUpdateMsgCount() > 0) { for (UserUpdateMsg userUpdateMsg: downlinkMsg.getUserUpdateMsgList()) { onUserUpdate(userUpdateMsg); result.add(saveDownlinkMsg(userUpdateMsg)); } } - if (downlinkMsg.getUserCredentialsUpdateMsgList() != null && !downlinkMsg.getUserCredentialsUpdateMsgList().isEmpty()) { + if (downlinkMsg.getUserCredentialsUpdateMsgCount() > 0) { for (UserCredentialsUpdateMsg userCredentialsUpdateMsg: downlinkMsg.getUserCredentialsUpdateMsgList()) { result.add(saveDownlinkMsg(userCredentialsUpdateMsg)); } } - if (downlinkMsg.getDeviceRpcCallMsgList() != null && !downlinkMsg.getDeviceRpcCallMsgList().isEmpty()) { + if (downlinkMsg.getDeviceRpcCallMsgCount() > 0) { for (DeviceRpcCallMsg deviceRpcCallMsg: downlinkMsg.getDeviceRpcCallMsgList()) { result.add(saveDownlinkMsg(deviceRpcCallMsg)); } } - if (downlinkMsg.getDeviceCredentialsRequestMsgList() != null && !downlinkMsg.getDeviceCredentialsRequestMsgList().isEmpty()) { + if (downlinkMsg.getDeviceCredentialsRequestMsgCount() > 0) { for (DeviceCredentialsRequestMsg deviceCredentialsRequestMsg: downlinkMsg.getDeviceCredentialsRequestMsgList()) { result.add(saveDownlinkMsg(deviceCredentialsRequestMsg)); } @@ -256,15 +263,21 @@ public class EdgeImitator { return Futures.immediateFuture(null); } - public void waitForMessages() throws InterruptedException { - messagesLatch.await(5, TimeUnit.SECONDS); + public boolean waitForMessages() throws InterruptedException { + return waitForMessages(5); + } + + public boolean waitForMessages(int timeout) throws InterruptedException { + return messagesLatch.await(timeout, TimeUnit.SECONDS); } public void expectMessageAmount(int messageAmount) { messagesLatch = new CountDownLatch(messageAmount); } - public void waitForResponses() throws InterruptedException { responsesLatch.await(5, TimeUnit.SECONDS); } + public boolean waitForResponses() throws InterruptedException { + return responsesLatch.await(5, TimeUnit.SECONDS); + } public void expectResponsesAmount(int messageAmount) { responsesLatch = new CountDownLatch(messageAmount); @@ -282,6 +295,18 @@ public class EdgeImitator { return result; } + @SuppressWarnings("unchecked") + public List findAllMessagesByType(Class tClass) { + List result; + try { + lock.lock(); + result = (List) downlinkMsgs.stream().filter(downlinkMsg -> downlinkMsg.getClass().isAssignableFrom(tClass)).collect(Collectors.toList()); + } finally { + lock.unlock(); + } + return result; + } + public AbstractMessage getLatestMessage() { return downlinkMsgs.get(downlinkMsgs.size() - 1); } From dbbb8e3aad419f2670cf47d95714628b03026716 Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Wed, 14 Apr 2021 17:57:10 +0300 Subject: [PATCH 38/69] Added support for device profile update msg --- .../server/controller/BaseEdgeControllerTest.java | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseEdgeControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseEdgeControllerTest.java index ba2d3ce61e..cc50cdd00d 100644 --- a/application/src/test/java/org/thingsboard/server/controller/BaseEdgeControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/BaseEdgeControllerTest.java @@ -38,6 +38,7 @@ import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.dao.model.ModelConstants; import org.thingsboard.server.edge.imitator.EdgeImitator; import org.thingsboard.server.gen.edge.AssetUpdateMsg; +import org.thingsboard.server.gen.edge.DeviceProfileUpdateMsg; import org.thingsboard.server.gen.edge.DeviceUpdateMsg; import org.thingsboard.server.gen.edge.RuleChainUpdateMsg; import org.thingsboard.server.gen.edge.UserCredentialsUpdateMsg; @@ -672,22 +673,24 @@ public abstract class BaseEdgeControllerTest extends AbstractControllerTest { edgeImitator.ignoreType(UserCredentialsUpdateMsg.class); edgeImitator.expectMessageAmount(7); edgeImitator.connect(); - edgeImitator.waitForMessages(); + Assert.assertTrue(edgeImitator.waitForMessages()); Assert.assertEquals(7, edgeImitator.getDownlinkMsgs().size()); Assert.assertTrue(edgeImitator.findMessageByType(RuleChainUpdateMsg.class).isPresent()); + Assert.assertTrue(edgeImitator.findMessageByType(DeviceProfileUpdateMsg.class).isPresent()); Assert.assertTrue(edgeImitator.findMessageByType(DeviceUpdateMsg.class).isPresent()); Assert.assertTrue(edgeImitator.findMessageByType(AssetUpdateMsg.class).isPresent()); Assert.assertTrue(edgeImitator.findMessageByType(UserUpdateMsg.class).isPresent()); edgeImitator.getDownlinkMsgs().clear(); - edgeImitator.expectMessageAmount(4); + edgeImitator.expectMessageAmount(7); doPost("/api/edge/sync/" + edge.getId()); - edgeImitator.waitForMessages(); + Assert.assertTrue(edgeImitator.waitForMessages()); - Assert.assertEquals(4, edgeImitator.getDownlinkMsgs().size()); + Assert.assertEquals(7, edgeImitator.getDownlinkMsgs().size()); Assert.assertTrue(edgeImitator.findMessageByType(RuleChainUpdateMsg.class).isPresent()); + Assert.assertTrue(edgeImitator.findMessageByType(DeviceProfileUpdateMsg.class).isPresent()); Assert.assertTrue(edgeImitator.findMessageByType(DeviceUpdateMsg.class).isPresent()); Assert.assertTrue(edgeImitator.findMessageByType(AssetUpdateMsg.class).isPresent()); Assert.assertTrue(edgeImitator.findMessageByType(UserUpdateMsg.class).isPresent()); From 0e9f8e91bc9b6e8fac9e7ce6a8b4350a9359c487 Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Thu, 15 Apr 2021 10:20:40 +0300 Subject: [PATCH 39/69] Refactored expected msg count --- .../thingsboard/server/edge/BaseEdgeTest.java | 42 ++++++++++++------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/edge/BaseEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/BaseEdgeTest.java index 238e01f741..2d38fa9299 100644 --- a/application/src/test/java/org/thingsboard/server/edge/BaseEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/BaseEdgeTest.java @@ -189,46 +189,60 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { @Test public void test() throws Exception { testReceivedInitialData(); - Assert.assertEquals(10, edgeImitator.getDownlinkMsgs().size()); + int expectedDownlinkSize = 10; + Assert.assertEquals(expectedDownlinkSize, edgeImitator.getDownlinkMsgs().size()); testDevices(); - Assert.assertEquals(14, edgeImitator.getDownlinkMsgs().size()); + expectedDownlinkSize = expectedDownlinkSize + 4; + Assert.assertEquals(expectedDownlinkSize, edgeImitator.getDownlinkMsgs().size()); testAssets(); - Assert.assertEquals(18, edgeImitator.getDownlinkMsgs().size()); + expectedDownlinkSize = expectedDownlinkSize + 4; + Assert.assertEquals(expectedDownlinkSize, edgeImitator.getDownlinkMsgs().size()); testRuleChains(); - Assert.assertEquals(21, edgeImitator.getDownlinkMsgs().size()); + expectedDownlinkSize = expectedDownlinkSize + 3; + Assert.assertEquals(expectedDownlinkSize, edgeImitator.getDownlinkMsgs().size()); testDashboards(); - Assert.assertEquals(24, edgeImitator.getDownlinkMsgs().size()); + expectedDownlinkSize = expectedDownlinkSize + 3; + Assert.assertEquals(expectedDownlinkSize, edgeImitator.getDownlinkMsgs().size()); testRelations(); - Assert.assertEquals(26, edgeImitator.getDownlinkMsgs().size()); + expectedDownlinkSize = expectedDownlinkSize + 2; + Assert.assertEquals(expectedDownlinkSize, edgeImitator.getDownlinkMsgs().size()); testAlarms(); - Assert.assertEquals(29, edgeImitator.getDownlinkMsgs().size()); + expectedDownlinkSize = expectedDownlinkSize + 3; + Assert.assertEquals(expectedDownlinkSize, edgeImitator.getDownlinkMsgs().size()); testEntityView(); - Assert.assertEquals(31, edgeImitator.getDownlinkMsgs().size()); + expectedDownlinkSize = expectedDownlinkSize + 2; + Assert.assertEquals(expectedDownlinkSize, edgeImitator.getDownlinkMsgs().size()); testCustomer(); - Assert.assertEquals(33, edgeImitator.getDownlinkMsgs().size()); + expectedDownlinkSize = expectedDownlinkSize + 2; + Assert.assertEquals(expectedDownlinkSize, edgeImitator.getDownlinkMsgs().size()); testWidgetsBundleAndWidgetType(); - Assert.assertEquals(37, edgeImitator.getDownlinkMsgs().size()); + expectedDownlinkSize = expectedDownlinkSize + 4; + Assert.assertEquals(expectedDownlinkSize, edgeImitator.getDownlinkMsgs().size()); testTimeseries(); - Assert.assertEquals(38, edgeImitator.getDownlinkMsgs().size()); + expectedDownlinkSize = expectedDownlinkSize + 1; + Assert.assertEquals(expectedDownlinkSize, edgeImitator.getDownlinkMsgs().size()); testAttributes(); - Assert.assertEquals(41, edgeImitator.getDownlinkMsgs().size()); + expectedDownlinkSize = expectedDownlinkSize + 3; + Assert.assertEquals(expectedDownlinkSize, edgeImitator.getDownlinkMsgs().size()); testSendMessagesToCloud(); - Assert.assertEquals(50, edgeImitator.getDownlinkMsgs().size()); + expectedDownlinkSize = expectedDownlinkSize + 9; + Assert.assertEquals(expectedDownlinkSize, edgeImitator.getDownlinkMsgs().size()); testRpcCall(); - Assert.assertEquals(51, edgeImitator.getDownlinkMsgs().size()); + expectedDownlinkSize = expectedDownlinkSize + 1; + Assert.assertEquals(expectedDownlinkSize, edgeImitator.getDownlinkMsgs().size()); } private Device findDeviceByName(String deviceName) throws Exception { From d6979e54dae1fd2350ddda9622be6d77d92d3823 Mon Sep 17 00:00:00 2001 From: nickAS21 <44275303+nickAS21@users.noreply.github.com> Date: Thu, 15 Apr 2021 11:53:45 +0300 Subject: [PATCH 40/69] lwm2m: back: Write Attributes - start (#4402) * lwm2m: back: Write Attributes - start * lwm2m: front: fis bug disable instances * lwm2m: transport: attr/discavery start * lwm2m: transport: attributeLwm2m after update profile * lwm2m: transport: attributeLwm2m after update profile (no delete attribute) * Lwm2m: back: test * Lwm2m: back: test2 * Lwm2m: back: test3 --- .../DefaultSystemDataLoaderService.java | 5 - .../secure/LwM2MBootstrapServers.java | 2 +- .../lwm2m/server/LwM2mSessionMsgListener.java | 11 +- .../lwm2m/server/LwM2mTransportHandler.java | 123 ++++++--- .../lwm2m/server/LwM2mTransportRequest.java | 47 +--- .../lwm2m/server/LwM2mTransportService.java | 4 + .../server/LwM2mTransportServiceImpl.java | 236 +++++++++++++----- .../server/client/LwM2mClientContext.java | 3 + .../server/client/LwM2mClientContextImpl.java | 23 +- .../server/client/LwM2mClientProfile.java | 8 + ui-ngx/src/app/core/http/resource.service.ts | 7 +- ui-ngx/src/app/core/services/time.service.ts | 18 +- ui-ngx/src/app/core/utils.ts | 2 +- ...ofile-transport-configuration.component.ts | 25 +- ...wm2m-observe-attr-telemetry.component.html | 3 +- ...wm2m-observe-attr-telemetry.component.scss | 8 + .../widget/lib/json-input-widget.component.ts | 6 +- .../widget/widget-components.module.ts | 3 +- .../asset/assets-table-config.resolver.ts | 2 +- .../dashboards-table-config.resolver.ts | 7 +- .../device/devices-table-config.resolver.ts | 54 ++-- .../home/pages/edge/edge-routing.module.ts | 12 +- .../entity-views-table-config.resolver.ts | 5 +- .../resource/resources-library.component.ts | 6 +- .../rulechains-table-config.resolver.ts | 72 +++--- .../shared/components/file-input.component.ts | 4 + .../components/json-object-edit.component.ts | 2 +- .../src/app/shared/models/resource.models.ts | 2 +- 28 files changed, 441 insertions(+), 259 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java b/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java index 1ba2ce3470..ec0daf2d71 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java @@ -17,10 +17,8 @@ package org.thingsboard.server.service.install; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; -import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Profile; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; @@ -60,11 +58,8 @@ import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.query.BooleanFilterPredicate; import org.thingsboard.server.common.data.query.DynamicValue; import org.thingsboard.server.common.data.query.DynamicValueSourceType; -import org.thingsboard.server.common.data.query.EntityKey; -import org.thingsboard.server.common.data.query.EntityKeyType; import org.thingsboard.server.common.data.query.EntityKeyValueType; import org.thingsboard.server.common.data.query.FilterPredicateValue; -import org.thingsboard.server.common.data.query.KeyFilter; import org.thingsboard.server.common.data.query.NumericFilterPredicate; import org.thingsboard.server.common.data.rule.RuleChainType; import org.thingsboard.server.common.data.security.Authority; diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/secure/LwM2MBootstrapServers.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/secure/LwM2MBootstrapServers.java index e3757701cb..b08f1dbcbe 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/secure/LwM2MBootstrapServers.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/secure/LwM2MBootstrapServers.java @@ -23,5 +23,5 @@ public class LwM2MBootstrapServers { private Integer lifetime = 300; private Integer defaultMinPeriod = 1; private boolean notifIfDisabled = true; - private String binding = "U"; + private String binding = "UQ"; } diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mSessionMsgListener.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mSessionMsgListener.java index 20baeba453..0bc78254a6 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mSessionMsgListener.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mSessionMsgListener.java @@ -18,6 +18,7 @@ package org.thingsboard.server.transport.lwm2m.server; import io.netty.util.concurrent.Future; import io.netty.util.concurrent.GenericFutureListener; import lombok.extern.slf4j.Slf4j; +import org.jetbrains.annotations.NotNull; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.ResourceType; @@ -74,12 +75,12 @@ public class LwM2mSessionMsgListener implements GenericFutureListener resourceUpdateMsgOpt) { + @Override + public void onResourceUpdate(@NotNull Optional resourceUpdateMsgOpt) { if (ResourceType.LWM2M_MODEL.name().equals(resourceUpdateMsgOpt.get().getResourceType())) { this.service.onResourceUpdate(resourceUpdateMsgOpt); } } - public void onResourceDelete(Optional resourceDeleteMsgOpt) { + @Override + public void onResourceDelete(@NotNull Optional resourceDeleteMsgOpt) { if (ResourceType.LWM2M_MODEL.name().equals(resourceDeleteMsgOpt.get().getResourceType())) { this.service.onResourceDelete(resourceDeleteMsgOpt); } diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportHandler.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportHandler.java index 0f0707ddf8..afb8add332 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportHandler.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportHandler.java @@ -22,15 +22,14 @@ import com.google.gson.JsonSyntaxException; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.eclipse.californium.core.network.config.NetworkConfig; +import org.eclipse.leshan.core.attributes.Attribute; +import org.eclipse.leshan.core.attributes.AttributeSet; import org.eclipse.leshan.core.model.ObjectModel; import org.eclipse.leshan.core.model.ResourceModel; -import org.eclipse.leshan.core.node.LwM2mMultipleResource; -import org.eclipse.leshan.core.node.LwM2mNode; -import org.eclipse.leshan.core.node.LwM2mObject; -import org.eclipse.leshan.core.node.LwM2mObjectInstance; import org.eclipse.leshan.core.node.LwM2mPath; -import org.eclipse.leshan.core.node.LwM2mSingleResource; import org.eclipse.leshan.core.node.codec.CodecException; +import org.eclipse.leshan.core.request.DownlinkRequest; +import org.eclipse.leshan.core.request.WriteAttributesRequest; import org.eclipse.leshan.core.util.Hex; import org.eclipse.leshan.server.californium.LeshanServerBuilder; import org.eclipse.leshan.server.registration.Registration; @@ -44,11 +43,19 @@ import org.thingsboard.server.transport.lwm2m.server.client.LwM2mClientProfile; import java.io.File; import java.io.IOException; +import java.util.ArrayList; import java.util.Arrays; import java.util.Date; import java.util.LinkedList; +import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import static org.eclipse.leshan.core.attributes.Attribute.DIMENSION; +import static org.eclipse.leshan.core.attributes.Attribute.MAXIMUM_PERIOD; +import static org.eclipse.leshan.core.attributes.Attribute.MINIMUM_PERIOD; +import static org.eclipse.leshan.core.attributes.Attribute.OBJECT_VERSION; import static org.thingsboard.server.common.data.lwm2m.LwM2mConstants.LWM2M_SEPARATOR_KEY; import static org.thingsboard.server.common.data.lwm2m.LwM2mConstants.LWM2M_SEPARATOR_PATH; @@ -56,8 +63,19 @@ import static org.thingsboard.server.common.data.lwm2m.LwM2mConstants.LWM2M_SEPA public class LwM2mTransportHandler { public static final String BASE_DEVICE_API_TOPIC = "v1/devices/me"; + + public static final String CLIENT_LWM2M_SETTINGS = "clientLwM2mSettings"; + public static final String BOOTSTRAP = "bootstrap"; + public static final String SERVERS = "servers"; + public static final String LWM2M_SERVER = "lwm2mServer"; + public static final String BOOTSTRAP_SERVER = "bootstrapServer"; + public static final String OBSERVE_ATTRIBUTE_TELEMETRY = "observeAttr"; public static final String ATTRIBUTE = "attribute"; public static final String TELEMETRY = "telemetry"; + public static final String KEY_NAME = "keyName"; + public static final String OBSERVE = "observe"; + public static final String ATTRIBUTE_LWM2M = "attributeLwm2m"; + private static final String REQUEST = "/request"; private static final String RESPONSE = "/response"; private static final String ATTRIBUTES = "/" + ATTRIBUTE; @@ -70,14 +88,6 @@ public class LwM2mTransportHandler { public static final String DEVICE_TELEMETRY_TOPIC = BASE_DEVICE_API_TOPIC + TELEMETRIES; public static final long DEFAULT_TIMEOUT = 2 * 60 * 1000L; // 2min in ms - public static final String OBSERVE_ATTRIBUTE_TELEMETRY = "observeAttr"; - public static final String CLIENT_LWM2M_SETTINGS = "clientLwM2mSettings"; - public static final String KEY_NAME = "keyName"; - public static final String OBSERVE = "observe"; - public static final String BOOTSTRAP = "bootstrap"; - public static final String SERVERS = "servers"; - public static final String LWM2M_SERVER = "lwm2mServer"; - public static final String BOOTSTRAP_SERVER = "bootstrapServer"; public static final String LOG_LW2M_TELEMETRY = "logLwm2m"; public static final String LOG_LW2M_INFO = "info"; @@ -144,19 +154,19 @@ public class LwM2mTransportHandler { throw new CodecException("Invalid value type for resource %s, type %s", resourcePath, type); } } - - public static LwM2mNode getLvM2mNodeToObject(LwM2mNode content) { - if (content instanceof LwM2mObject) { - return (LwM2mObject) content; - } else if (content instanceof LwM2mObjectInstance) { - return (LwM2mObjectInstance) content; - } else if (content instanceof LwM2mSingleResource) { - return (LwM2mSingleResource) content; - } else if (content instanceof LwM2mMultipleResource) { - return (LwM2mMultipleResource) content; - } - return null; - } +// +// public static LwM2mNode getLvM2mNodeToObject(LwM2mNode content) { +// if (content instanceof LwM2mObject) { +// return (LwM2mObject) content; +// } else if (content instanceof LwM2mObjectInstance) { +// return (LwM2mObjectInstance) content; +// } else if (content instanceof LwM2mSingleResource) { +// return (LwM2mSingleResource) content; +// } else if (content instanceof LwM2mMultipleResource) { +// return (LwM2mMultipleResource) content; +// } +// return null; +// } public static LwM2mClientProfile getNewProfileParameters(JsonObject profilesConfigData, TenantId tenantId) { LwM2mClientProfile lwM2MClientProfile = new LwM2mClientProfile(); @@ -166,6 +176,7 @@ public class LwM2mTransportHandler { lwM2MClientProfile.setPostAttributeProfile(profilesConfigData.get(OBSERVE_ATTRIBUTE_TELEMETRY).getAsJsonObject().get(ATTRIBUTE).getAsJsonArray()); lwM2MClientProfile.setPostTelemetryProfile(profilesConfigData.get(OBSERVE_ATTRIBUTE_TELEMETRY).getAsJsonObject().get(TELEMETRY).getAsJsonArray()); lwM2MClientProfile.setPostObserveProfile(profilesConfigData.get(OBSERVE_ATTRIBUTE_TELEMETRY).getAsJsonObject().get(OBSERVE).getAsJsonArray()); + lwM2MClientProfile.setPostAttributeLwm2mProfile(profilesConfigData.get(OBSERVE_ATTRIBUTE_TELEMETRY).getAsJsonObject().get(ATTRIBUTE_LWM2M).getAsJsonObject()); return lwM2MClientProfile; } @@ -184,6 +195,9 @@ public class LwM2mTransportHandler { * "attribute":["/2/0/1","/3/0/9"], * "telemetry":["/1/0/1","/2/0/1","/6/0/1"], * "observe":["/2/0","/2/0/0","/4/0/2"]} + * "attributeLwm2m": {"/3_1.0": {"ver": "currentTimeTest11"}, + * "/3_1.0/0": {"gt": 17}, + * "/3_1.0/0/9": {"pmax": 45}, "/3_1.2": {ver": "3_1.2"}} */ public static LwM2mClientProfile getLwM2MClientProfileFromThingsboard(DeviceProfile deviceProfile) { if (deviceProfile != null && ((Lwm2mDeviceProfileTransportConfiguration) deviceProfile.getProfileData().getTransportConfiguration()).getProperties().size() > 0) { @@ -192,7 +206,7 @@ public class LwM2mTransportHandler { ObjectMapper mapper = new ObjectMapper(); String profileStr = mapper.writeValueAsString(profile); JsonObject profileJson = (profileStr != null) ? validateJson(profileStr) : null; - return (getValidateCredentialsBodyFromThingsboard(profileJson)) ? LwM2mTransportHandler.getNewProfileParameters(profileJson, deviceProfile.getTenantId()) : null; + return getValidateCredentialsBodyFromThingsboard(profileJson) ? LwM2mTransportHandler.getNewProfileParameters(profileJson, deviceProfile.getTenantId()) : null; } catch (IOException e) { log.error("", e); } @@ -240,7 +254,10 @@ public class LwM2mTransportHandler { objectMsg.get(OBSERVE_ATTRIBUTE_TELEMETRY).getAsJsonObject().get(TELEMETRY).isJsonArray() && objectMsg.get(OBSERVE_ATTRIBUTE_TELEMETRY).getAsJsonObject().has(OBSERVE) && !objectMsg.get(OBSERVE_ATTRIBUTE_TELEMETRY).getAsJsonObject().get(OBSERVE).isJsonNull() && - objectMsg.get(OBSERVE_ATTRIBUTE_TELEMETRY).getAsJsonObject().get(OBSERVE).isJsonArray()); + objectMsg.get(OBSERVE_ATTRIBUTE_TELEMETRY).getAsJsonObject().get(OBSERVE).isJsonArray() && + objectMsg.get(OBSERVE_ATTRIBUTE_TELEMETRY).getAsJsonObject().has(ATTRIBUTE_LWM2M) && + !objectMsg.get(OBSERVE_ATTRIBUTE_TELEMETRY).getAsJsonObject().get(ATTRIBUTE_LWM2M).isJsonNull() && + objectMsg.get(OBSERVE_ATTRIBUTE_TELEMETRY).getAsJsonObject().get(ATTRIBUTE_LWM2M).isJsonObject()); } private static boolean getValidateBootstrapProfileFromThingsboard(JsonObject objectMsg) { @@ -360,4 +377,54 @@ public class LwM2mTransportHandler { return ObjectModel.DEFAULT_VERSION; } } + + /** + * As example: + * a)Write-Attributes/3/0/9?pmin=1 means the Battery Level value will be notified + * to the Server with a minimum interval of 1sec; + * this value is set at theResource level. + * b)Write-Attributes/3/0/9?pmin means the Battery Level will be notified + * to the Server with a minimum value (pmin) given by the default one + * (resource 2 of Object Server ID=1), + * or with another value if this Attribute has been set at another level + * (Object or Object Instance: see section5.1.1). + * c)Write-Attributes/3/0?pmin=10 means that all Resources of Instance 0 of the Object ‘Device (ID:3)’ + * will be notified to the Server with a minimum interval of 10 sec; + * this value is set at the Object Instance level. + * d)Write-Attributes /3/0/9?gt=45&st=10 means the Battery Level will be notified to the Server + * when: + * a.old value is 20 and new value is 35 due to step condition + * b.old value is 45 and new value is 50 due to gt condition + * c.old value is 50 and new value is 40 due to both gt and step conditions + * d.old value is 35 and new value is 20 due to step conditione) + * Write-Attributes /3/0/9?lt=20>=85&st=10 means the Battery Level will be notified to the Server + * when: + * a.old value is 17 and new value is 24 due to lt condition + * b.old value is 75 and new value is 90 due to both gt and step conditions + * String uriQueries = "pmin=10&pmax=60"; + * AttributeSet attributes = AttributeSet.parse(uriQueries); + * WriteAttributesRequest request = new WriteAttributesRequest(target, attributes); + * Attribute gt = new Attribute(GREATER_THAN, Double.valueOf("45")); + * Attribute st = new Attribute(LESSER_THAN, Double.valueOf("10")); + * Attribute pmax = new Attribute(MAXIMUM_PERIOD, "60"); + * Attribute [] attrs = {gt, st}; + */ + public static DownlinkRequest createWriteAttributeRequest(String target, Object params) { + AttributeSet attrSet = new AttributeSet(createWriteAttributes(params)); + return attrSet.getAttributes().size() > 0 ? new WriteAttributesRequest(target, attrSet) : null; + } + + private static Attribute[] createWriteAttributes(Object params) { + List attributeLists = new ArrayList(); + ObjectMapper oMapper = new ObjectMapper(); + Map map = oMapper.convertValue(params, ConcurrentHashMap.class); + map.forEach((k, v) -> { + if (!v.toString().isEmpty() || (v.toString().isEmpty() && OBJECT_VERSION.equals(k))) { + attributeLists.add(new Attribute(k, + (DIMENSION.equals(k) || MINIMUM_PERIOD.equals(k) || MAXIMUM_PERIOD.equals(k)) ? + ((Double) v).longValue() : v)); + } + }); + return (Attribute[]) attributeLists.toArray(Attribute[]::new); + } } diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportRequest.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportRequest.java index e237146c8a..2274eaf2a9 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportRequest.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportRequest.java @@ -18,8 +18,6 @@ package org.thingsboard.server.transport.lwm2m.server; import lombok.extern.slf4j.Slf4j; import org.eclipse.californium.core.coap.CoAP; import org.eclipse.californium.core.coap.Response; -import org.eclipse.leshan.core.attributes.Attribute; -import org.eclipse.leshan.core.attributes.AttributeSet; import org.eclipse.leshan.core.model.ResourceModel; import org.eclipse.leshan.core.node.LwM2mNode; import org.eclipse.leshan.core.node.LwM2mPath; @@ -33,7 +31,6 @@ import org.eclipse.leshan.core.request.DownlinkRequest; import org.eclipse.leshan.core.request.ExecuteRequest; import org.eclipse.leshan.core.request.ObserveRequest; import org.eclipse.leshan.core.request.ReadRequest; -import org.eclipse.leshan.core.request.WriteAttributesRequest; import org.eclipse.leshan.core.request.WriteRequest; import org.eclipse.leshan.core.request.exception.ClientSleepingException; import org.eclipse.leshan.core.response.CancelObservationResponse; @@ -60,7 +57,6 @@ import java.util.Date; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; -import static org.eclipse.leshan.core.attributes.Attribute.MINIMUM_PERIOD; import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.DEFAULT_TIMEOUT; import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.GET_TYPE_OPER_DISCOVER; import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.GET_TYPE_OPER_OBSERVE; @@ -75,6 +71,7 @@ import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandle import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.RESPONSE_CHANNEL; import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.convertToIdVerFromObjectId; import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.convertToObjectIdFromIdVer; +import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.createWriteAttributeRequest; @Slf4j @Service @@ -174,47 +171,7 @@ public class LwM2mTransportRequest { } break; case PUT_TYPE_OPER_WRITE_ATTRIBUTES: - /** - * As example: - * a)Write-Attributes/3/0/9?pmin=1 means the Battery Level value will be notified - * to the Server with a minimum interval of 1sec; - * this value is set at theResource level. - * b)Write-Attributes/3/0/9?pmin means the Battery Level will be notified - * to the Server with a minimum value (pmin) given by the default one - * (resource 2 of Object Server ID=1), - * or with another value if this Attribute has been set at another level - * (Object or Object Instance: see section5.1.1). - * c)Write-Attributes/3/0?pmin=10 means that all Resources of Instance 0 of the Object ‘Device (ID:3)’ - * will be notified to the Server with a minimum interval of 10 sec; - * this value is set at the Object Instance level. - * d)Write-Attributes /3/0/9?gt=45&st=10 means the Battery Level will be notified to the Server - * when: - * a.old value is 20 and new value is 35 due to step condition - * b.old value is 45 and new value is 50 due to gt condition - * c.old value is 50 and new value is 40 due to both gt and step conditions - * d.old value is 35 and new value is 20 due to step conditione) - * Write-Attributes /3/0/9?lt=20>=85&st=10 means the Battery Level will be notified to the Server - * when: - * a.old value is 17 and new value is 24 due to lt condition - * b.old value is 75 and new value is 90 due to both gt and step conditions - * String uriQueries = "pmin=10&pmax=60"; - * AttributeSet attributes = AttributeSet.parse(uriQueries); - * WriteAttributesRequest request = new WriteAttributesRequest(target, attributes); - * Attribute gt = new Attribute(GREATER_THAN, Double.valueOf("45")); - * Attribute st = new Attribute(LESSER_THAN, Double.valueOf("10")); - * Attribute pmax = new Attribute(MAXIMUM_PERIOD, "60"); - * Attribute [] attrs = {gt, st}; - */ - Attribute pmin = new Attribute(MINIMUM_PERIOD, Integer.toUnsignedLong(Integer.parseInt("1"))); - Attribute[] attrs = {pmin}; - AttributeSet attrSet = new AttributeSet(attrs); - if (resultIds.isResource()) { - request = new WriteAttributesRequest(resultIds.getObjectId(), resultIds.getObjectInstanceId(), resultIds.getResourceId(), attrSet); - } else if (resultIds.isObjectInstance()) { - request = new WriteAttributesRequest(resultIds.getObjectId(), resultIds.getObjectInstanceId(), attrSet); - } else if (resultIds.getObjectId() >= 0) { - request = new WriteAttributesRequest(resultIds.getObjectId(), attrSet); - } + request = createWriteAttributeRequest (target, params); break; } diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportService.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportService.java index fb917f1370..4f3cfc44c8 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportService.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportService.java @@ -51,6 +51,10 @@ public interface LwM2mTransportService { void onResourceDelete(Optional resourceDeleteMsgOpt); + void onToDeviceRpcRequest(TransportProtos.ToDeviceRpcRequestMsg toDeviceRequest); + + void onToServerRpcResponse(TransportProtos.ToServerRpcResponseMsg toServerResponse); + void doTrigger(Registration registration, String path); void doDisconnect(TransportProtos.SessionInfoProto sessionInfo); diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportServiceImpl.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportServiceImpl.java index 96da8721c8..8ab70d3af4 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportServiceImpl.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportServiceImpl.java @@ -74,11 +74,14 @@ import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; +import static org.eclipse.leshan.core.attributes.Attribute.OBJECT_VERSION; +import static org.thingsboard.server.common.data.lwm2m.LwM2mConstants.LWM2M_SEPARATOR_PATH; import static org.thingsboard.server.common.transport.util.JsonUtils.getJsonObject; import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.CLIENT_NOT_AUTHORIZED; import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.DEVICE_ATTRIBUTES_REQUEST; import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.DEVICE_ATTRIBUTES_TOPIC; import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.DEVICE_TELEMETRY_TOPIC; +import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.GET_TYPE_OPER_DISCOVER; import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.GET_TYPE_OPER_OBSERVE; import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.GET_TYPE_OPER_READ; import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.LOG_LW2M_ERROR; @@ -87,6 +90,7 @@ import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandle import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.LWM2M_STRATEGY_2; import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.POST_TYPE_OPER_EXECUTE; import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.POST_TYPE_OPER_WRITE_REPLACE; +import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.PUT_TYPE_OPER_WRITE_ATTRIBUTES; import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.SERVICE_CHANNEL; import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.convertToIdVerFromObjectId; import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.convertToObjectIdFromIdVer; @@ -213,7 +217,7 @@ public class LwM2mTransportServiceImpl implements LwM2mTransportService { * !!! Warn: if have not finishing unReg, then this operation will be finished on next Client`s connect */ public void unReg(Registration registration, Collection observations) { - executorUnRegistered.submit(() -> { + executorUnRegistered.submit(() -> { try { this.setCancelObservations(registration); this.sendLogsToThingsboard(LOG_LW2M_INFO + ": Client unRegistration", registration); @@ -365,17 +369,15 @@ public class LwM2mTransportServiceImpl implements LwM2mTransportService { } /** - * * @param resourceUpdateMsgOpt - */ @Override - public void onResourceUpdate (Optional resourceUpdateMsgOpt) { + public void onResourceUpdate(Optional resourceUpdateMsgOpt) { String idVer = resourceUpdateMsgOpt.get().getResourceKey(); lwM2mClientContext.getLwM2mClients().values().stream().forEach(e -> e.updateResourceModel(idVer, this.lwM2mTransportContextServer.getLwM2MTransportConfigServer().getModelProvider())); } /** - * * @param resourceDeleteMsgOpt - */ @Override @@ -384,6 +386,14 @@ public class LwM2mTransportServiceImpl implements LwM2mTransportService { lwM2mClientContext.getLwM2mClients().values().stream().forEach(e -> e.deleteResources(pathIdVer, this.lwM2mTransportContextServer.getLwM2MTransportConfigServer().getModelProvider())); } + public void onToDeviceRpcRequest(TransportProtos.ToDeviceRpcRequestMsg toDeviceRequest) { + log.info("[{}] toDeviceRpcRequest", toDeviceRequest); + } + + public void onToServerRpcResponse(TransportProtos.ToServerRpcResponseMsg toServerResponse) { + log.info("[{}] toServerRpcResponse", toServerResponse); + } + /** * Trigger Server path = "/1/0/8" *

@@ -496,15 +506,19 @@ public class LwM2mTransportServiceImpl implements LwM2mTransportService { */ private void initLwM2mFromClientValue(Registration registration, LwM2mClient lwM2MClient) { LwM2mClientProfile lwM2MClientProfile = lwM2mClientContext.getProfile(registration); - Set clientObjects = this.getAllOjectsInClient(registration); - if (clientObjects != null && LWM2M_STRATEGY_2 == LwM2mTransportHandler.getClientOnlyObserveAfterConnect(lwM2MClientProfile)) { - // #2 - lwM2MClient.getPendingRequests().addAll(clientObjects); - clientObjects.forEach(path -> lwM2mTransportRequest.sendAllRequest(registration, path, GET_TYPE_OPER_READ, ContentFormat.TLV.getName(), - null, null, this.lwM2mTransportContextServer.getLwM2MTransportConfigServer().getTimeout())); + Set clientObjects = lwM2mClientContext.getSupportedIdVerInClient(registration); + if (clientObjects != null && clientObjects.size() > 0) { + if (LWM2M_STRATEGY_2 == LwM2mTransportHandler.getClientOnlyObserveAfterConnect(lwM2MClientProfile)) { + // #2 + lwM2MClient.getPendingRequests().addAll(clientObjects); + clientObjects.forEach(path -> lwM2mTransportRequest.sendAllRequest(registration, path, GET_TYPE_OPER_READ, ContentFormat.TLV.getName(), + null, null, this.lwM2mTransportContextServer.getLwM2MTransportConfigServer().getTimeout())); + } + // #1 + this.initReadAttrTelemetryObserveToClient(registration, lwM2MClient, GET_TYPE_OPER_OBSERVE, clientObjects); + this.initReadAttrTelemetryObserveToClient(registration, lwM2MClient, PUT_TYPE_OPER_WRITE_ATTRIBUTES, clientObjects); + this.initReadAttrTelemetryObserveToClient(registration, lwM2MClient, GET_TYPE_OPER_DISCOVER, clientObjects); } - // #1 - this.initReadAttrTelemetryObserveToClient(registration, lwM2MClient, GET_TYPE_OPER_OBSERVE); } /** @@ -611,41 +625,61 @@ public class LwM2mTransportServiceImpl implements LwM2mTransportService { /** * Start observe/read: Attr/Telemetry - * #1 - Analyze: - * #1.1 path in resource profile == client resource + * #1 - Analyze: path in resource profile == client resource * * @param registration - */ - private void initReadAttrTelemetryObserveToClient(Registration registration, LwM2mClient lwM2MClient, String typeOper) { + private void initReadAttrTelemetryObserveToClient(Registration registration, LwM2mClient lwM2MClient, + String typeOper, Set clientObjects) { LwM2mClientProfile lwM2MClientProfile = lwM2mClientContext.getProfile(registration); - Set clientInstances = this.getAllInstancesInClient(registration); - Set result; + Set result = null; + ConcurrentHashMap params = null; if (GET_TYPE_OPER_READ.equals(typeOper)) { - result = JacksonUtil.fromString(lwM2MClientProfile.getPostAttributeProfile().toString(), new TypeReference<>() { - }); - result.addAll(JacksonUtil.convertValue(lwM2MClientProfile.getPostTelemetryProfile().toString(), new TypeReference<>() { - })); - } else { - result = JacksonUtil.fromString(lwM2MClientProfile.getPostObserveProfile().toString(), new TypeReference<>() { - }); + result = JacksonUtil.fromString(lwM2MClientProfile.getPostAttributeProfile().toString(), + new TypeReference<>() { + }); + result.addAll(JacksonUtil.fromString(lwM2MClientProfile.getPostTelemetryProfile().toString(), + new TypeReference<>() { + })); + } else if (GET_TYPE_OPER_OBSERVE.equals(typeOper)) { + result = JacksonUtil.fromString(lwM2MClientProfile.getPostObserveProfile().toString(), + new TypeReference<>() { + }); + } else if (GET_TYPE_OPER_DISCOVER.equals(typeOper)) { + result = this.getPathForWriteAttributes(lwM2MClientProfile.getPostAttributeLwm2mProfile()).keySet(); + ; + } else if (PUT_TYPE_OPER_WRITE_ATTRIBUTES.equals(typeOper)) { + params = this.getPathForWriteAttributes(lwM2MClientProfile.getPostAttributeLwm2mProfile()); + result = params.keySet(); } - Set pathSend = ConcurrentHashMap.newKeySet(); - result.forEach(target -> { - // #1.1 - String[] resPath = target.split("/"); - String instance = "/" + resPath[1] + "/" + resPath[2]; - if (clientInstances != null && clientInstances.size() > 0 && clientInstances.contains(instance)) { - pathSend.add(target); + if (!result.isEmpty()) { + // #1 + Set pathSend = result.stream().filter(target -> { + return target.split(LWM2M_SEPARATOR_PATH).length < 3 ? + clientObjects.contains("/" + target.split(LWM2M_SEPARATOR_PATH)[1]) : + clientObjects.contains("/" + target.split(LWM2M_SEPARATOR_PATH)[1] + "/" + target.split(LWM2M_SEPARATOR_PATH)[2]); + } + ) + .collect(Collectors.toUnmodifiableSet()); + if (!pathSend.isEmpty()) { + lwM2MClient.getPendingRequests().addAll(pathSend); + ConcurrentHashMap finalParams = params; + pathSend.forEach(target -> lwM2mTransportRequest.sendAllRequest(registration, target, typeOper, ContentFormat.TLV.getName(), + null, finalParams != null ? finalParams.get(target) : null, this.lwM2mTransportContextServer.getLwM2MTransportConfigServer().getTimeout())); + if (GET_TYPE_OPER_OBSERVE.equals(typeOper)) { + lwM2MClient.initValue(this, null); + } } - }); - lwM2MClient.getPendingRequests().addAll(pathSend); - pathSend.forEach(target -> lwM2mTransportRequest.sendAllRequest(registration, target, typeOper, ContentFormat.TLV.getName(), - null, null, this.lwM2mTransportContextServer.getLwM2MTransportConfigServer().getTimeout())); - if (GET_TYPE_OPER_OBSERVE.equals(typeOper)) { - lwM2MClient.initValue(this, null); } } + private ConcurrentHashMap getPathForWriteAttributes(JsonObject objectJson) { + ConcurrentHashMap pathAttributes = new Gson().fromJson(objectJson.toString(), + new TypeToken>() { + }.getType()); + return pathAttributes; + } + /** * Update parameters device in LwM2MClient * If new deviceProfile != old deviceProfile => update deviceProfile @@ -665,21 +699,6 @@ public class LwM2mTransportServiceImpl implements LwM2mTransportService { lwM2MClient.setProfileId(device.getDeviceProfileId().getId()); } - /** - * @param registration - - * @return - all object in client - */ - private Set getAllOjectsInClient(Registration registration) { - Set clientObjects = ConcurrentHashMap.newKeySet(); - Arrays.stream(registration.getObjectLinks()).forEach(url -> { - LwM2mPath pathIds = new LwM2mPath(url.getUrl()); - if (pathIds.isObjectInstance()) { - clientObjects.add("/" + pathIds.getObjectId()); - } - }); - return (clientObjects.size() > 0) ? clientObjects : null; - } - /** * @param registration - * @return all instances in client @@ -724,16 +743,20 @@ public class LwM2mTransportServiceImpl implements LwM2mTransportService { private void addParameters(String path, JsonObject parameters, Registration registration) { LwM2mClient lwM2MClient = lwM2mClientContext.getLwM2mClientWithReg(registration, null); JsonObject names = lwM2mClientContext.getProfiles().get(lwM2MClient.getProfileId()).getPostKeyNameProfile(); - String resName = names.get(path).getAsString(); - if (resName != null && !resName.isEmpty()) { - try { - String resValue = this.getResourceValueToString(lwM2MClient, path); - if (resValue != null) { - parameters.addProperty(resName, resValue); + if (names != null && names.has(path)) { + String resName = names.get(path).getAsString(); + if (resName != null && !resName.isEmpty()) { + try { + String resValue = this.getResourceValueToString(lwM2MClient, path); + if (resValue != null) { + parameters.addProperty(resName, resValue); + } + } catch (Exception e) { + log.error("Failed to add parameters.", e); } - } catch (Exception e) { - log.error("Failed to add parameters.", e); } + } else { + log.error("Failed to add parameters. path: [{}], names: [{}]", path, names); } } @@ -750,7 +773,7 @@ public class LwM2mTransportServiceImpl implements LwM2mTransportService { /** * @param lwM2MClient - - * @param path - + * @param path - * @return - return value of Resource by idPath */ private LwM2mResource returnResourceValueFromLwM2MClient(LwM2mClient lwM2MClient, String path) { @@ -779,6 +802,7 @@ public class LwM2mTransportServiceImpl implements LwM2mTransportService { * #3.1 Attribute isChange (add&del) * #3.2 Telemetry isChange (add&del) * #3.3 KeyName isChange (add) + * #3.4 attributeLwm2m isChange (update WrightAttribute: add/update/del) * #4 update * #4.1 add If #3 isChange, then analyze and update Value in Transport form Client and send Value to thingsboard * #4.2 del @@ -789,6 +813,9 @@ public class LwM2mTransportServiceImpl implements LwM2mTransportService { * -- path Attr/Telemetry includes newObserve and does not include oldObserve: send Request observe to Client * #5.3 Observe.del * -- different between newObserve and oldObserve: send Request cancel observe to client + * #6 + * #6.1 - update WriteAttribute + * #6.2 - del WriteAttribute * * @param registrationIds - * @param deviceProfile - @@ -803,6 +830,7 @@ public class LwM2mTransportServiceImpl implements LwM2mTransportService { Set telemetrySetOld = this.convertJsonArrayToSet(telemetryOld); JsonArray observeOld = lwM2MClientProfileOld.getPostObserveProfile(); JsonObject keyNameOld = lwM2MClientProfileOld.getPostKeyNameProfile(); + JsonObject attributeLwm2mOld = lwM2MClientProfileOld.getPostAttributeLwm2mProfile(); LwM2mClientProfile lwM2MClientProfileNew = lwM2mClientContext.getProfiles().get(deviceProfile.getUuidId()); JsonArray attributeNew = lwM2MClientProfileNew.getPostAttributeProfile(); @@ -811,32 +839,41 @@ public class LwM2mTransportServiceImpl implements LwM2mTransportService { Set telemetrySetNew = this.convertJsonArrayToSet(telemetryNew); JsonArray observeNew = lwM2MClientProfileNew.getPostObserveProfile(); JsonObject keyNameNew = lwM2MClientProfileNew.getPostKeyNameProfile(); + JsonObject attributeLwm2mNew = lwM2MClientProfileNew.getPostAttributeLwm2mProfile(); // #3 ResultsAnalyzerParameters sendAttrToThingsboard = new ResultsAnalyzerParameters(); // #3.1 if (!attributeOld.equals(attributeNew)) { - ResultsAnalyzerParameters postAttributeAnalyzer = this.getAnalyzerParameters(new Gson().fromJson(attributeOld, new TypeToken>() { - }.getType()), attributeSetNew); + ResultsAnalyzerParameters postAttributeAnalyzer = this.getAnalyzerParameters(new Gson().fromJson(attributeOld, + new TypeToken>() { + }.getType()), attributeSetNew); sendAttrToThingsboard.getPathPostParametersAdd().addAll(postAttributeAnalyzer.getPathPostParametersAdd()); sendAttrToThingsboard.getPathPostParametersDel().addAll(postAttributeAnalyzer.getPathPostParametersDel()); } // #3.2 if (!telemetryOld.equals(telemetryNew)) { - ResultsAnalyzerParameters postTelemetryAnalyzer = this.getAnalyzerParameters(new Gson().fromJson(telemetryOld, new TypeToken>() { - }.getType()), telemetrySetNew); + ResultsAnalyzerParameters postTelemetryAnalyzer = this.getAnalyzerParameters(new Gson().fromJson(telemetryOld, + new TypeToken>() { + }.getType()), telemetrySetNew); sendAttrToThingsboard.getPathPostParametersAdd().addAll(postTelemetryAnalyzer.getPathPostParametersAdd()); sendAttrToThingsboard.getPathPostParametersDel().addAll(postTelemetryAnalyzer.getPathPostParametersDel()); } // #3.3 if (!keyNameOld.equals(keyNameNew)) { - ResultsAnalyzerParameters keyNameChange = this.getAnalyzerKeyName(new Gson().fromJson(keyNameOld.toString(), new TypeToken>() { + ResultsAnalyzerParameters keyNameChange = this.getAnalyzerKeyName(new Gson().fromJson(keyNameOld.toString(), + new TypeToken>() { }.getType()), new Gson().fromJson(keyNameNew.toString(), new TypeToken>() { }.getType())); sendAttrToThingsboard.getPathPostParametersAdd().addAll(keyNameChange.getPathPostParametersAdd()); } + // #3.4, #6 + if (!attributeLwm2mOld.equals(attributeLwm2mNew)) { + this.getAnalyzerAttributeLwm2m(registrationIds, attributeLwm2mOld, attributeLwm2mNew); + } + // #4.1 add if (sendAttrToThingsboard.getPathPostParametersAdd().size() > 0) { // update value in Resources @@ -933,7 +970,7 @@ public class LwM2mTransportServiceImpl implements LwM2mTransportService { }); } - private ResultsAnalyzerParameters getAnalyzerKeyName(ConcurrentMap keyNameOld, ConcurrentMap keyNameNew) { + private ResultsAnalyzerParameters getAnalyzerKeyName(ConcurrentHashMap keyNameOld, ConcurrentHashMap keyNameNew) { ResultsAnalyzerParameters analyzerParameters = new ResultsAnalyzerParameters(); Set paths = keyNameNew.entrySet() .stream() @@ -943,6 +980,71 @@ public class LwM2mTransportServiceImpl implements LwM2mTransportService { return analyzerParameters; } + /** + * #3.4, #6 + * #6 + * #6.1 - send update WriteAttribute + * #6.2 - send empty WriteAttribute + * + * @param attributeLwm2mOld - + * @param attributeLwm2mNew - + * @return + */ + private void getAnalyzerAttributeLwm2m(Set registrationIds, JsonObject attributeLwm2mOld, JsonObject attributeLwm2mNew) { + ResultsAnalyzerParameters analyzerParameters = new ResultsAnalyzerParameters(); + ConcurrentHashMap lwm2mAttributesOld = new Gson().fromJson(attributeLwm2mOld.toString(), + new TypeToken>() { + }.getType()); + ConcurrentHashMap lwm2mAttributesNew = new Gson().fromJson(attributeLwm2mNew.toString(), + new TypeToken>() { + }.getType()); + Set pathOld = lwm2mAttributesOld.keySet(); + Set pathNew = lwm2mAttributesNew.keySet(); + analyzerParameters.setPathPostParametersAdd(pathNew + .stream().filter(p -> !pathOld.contains(p)).collect(Collectors.toSet())); + analyzerParameters.setPathPostParametersDel(pathOld + .stream().filter(p -> !pathNew.contains(p)).collect(Collectors.toSet())); + Set pathCommon = pathNew + .stream().filter(p -> pathOld.contains(p)).collect(Collectors.toSet()); + Set pathCommonChange = pathCommon + .stream().filter(p -> !lwm2mAttributesOld.get(p).equals(lwm2mAttributesNew.get(p))).collect(Collectors.toSet()); + analyzerParameters.getPathPostParametersAdd().addAll(pathCommonChange); + // #6 + // #6.2 + if (analyzerParameters.getPathPostParametersAdd().size() > 0) { + registrationIds.forEach(registrationId -> { + Registration registration = this.lwM2mClientContext.getRegistration(registrationId); + Set clientObjects = lwM2mClientContext.getSupportedIdVerInClient(registration); + Set pathSend = analyzerParameters.getPathPostParametersAdd().stream().filter(target -> clientObjects.contains("/" + target.split(LWM2M_SEPARATOR_PATH)[1])) + .collect(Collectors.toUnmodifiableSet()); + if (!pathSend.isEmpty()) { + ConcurrentHashMap finalParams = lwm2mAttributesNew; + pathSend.forEach(target -> lwM2mTransportRequest.sendAllRequest(registration, target, PUT_TYPE_OPER_WRITE_ATTRIBUTES, ContentFormat.TLV.getName(), + null, finalParams.get(target), this.lwM2mTransportContextServer.getLwM2MTransportConfigServer().getTimeout())); + } + }); + } + // #6.2 + if (analyzerParameters.getPathPostParametersDel().size() > 0) { + registrationIds.forEach(registrationId -> { + Registration registration = this.lwM2mClientContext.getRegistration(registrationId); + Set clientObjects = lwM2mClientContext.getSupportedIdVerInClient(registration); + Set pathSend = analyzerParameters.getPathPostParametersDel().stream().filter(target -> clientObjects.contains("/" + target.split(LWM2M_SEPARATOR_PATH)[1])) + .collect(Collectors.toUnmodifiableSet()); + if (!pathSend.isEmpty()) { + pathSend.forEach(target -> { + Map params = (Map) lwm2mAttributesOld.get(target); + params.clear(); + params.put(OBJECT_VERSION, ""); + lwM2mTransportRequest.sendAllRequest(registration, target, PUT_TYPE_OPER_WRITE_ATTRIBUTES, ContentFormat.TLV.getName(), + null, params, this.lwM2mTransportContextServer.getLwM2MTransportConfigServer().getTimeout()); + }); + } + }); + } + + } + private void cancelObserveIsValue(Registration registration, Set paramAnallyzer) { LwM2mClient lwM2MClient = lwM2mClientContext.getLwM2mClientWithReg(registration, null); paramAnallyzer.forEach(p -> { diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClientContext.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClientContext.java index 2aea3fdc3e..358f760fef 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClientContext.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClientContext.java @@ -20,6 +20,7 @@ import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.gen.transport.TransportProtos; import java.util.Map; +import java.util.Set; import java.util.UUID; public interface LwM2mClientContext { @@ -51,4 +52,6 @@ public interface LwM2mClientContext { Map setProfiles(Map profiles); boolean addUpdateProfileParameters(DeviceProfile deviceProfile); + + Set getSupportedIdVerInClient(Registration registration); } diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClientContextImpl.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClientContextImpl.java index 926d12516b..1ce16b9b92 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClientContextImpl.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClientContextImpl.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.transport.lwm2m.server.client; +import org.eclipse.leshan.core.node.LwM2mPath; import org.eclipse.leshan.server.registration.Registration; import org.eclipse.leshan.server.security.EditableSecurityStore; import org.springframework.stereotype.Service; @@ -27,11 +28,14 @@ import org.thingsboard.server.transport.lwm2m.secure.ReadResultSecurityStore; import org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler; import org.thingsboard.server.transport.lwm2m.utils.TypeServer; +import java.util.Arrays; import java.util.Map; +import java.util.Set; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import static org.thingsboard.server.transport.lwm2m.secure.LwM2MSecurityMode.NO_SEC; +import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.convertToIdVerFromObjectId; @Service @TbLwM2mTransportComponent @@ -90,7 +94,7 @@ public class LwM2mClientContextImpl implements LwM2mClientContext { @Override public LwM2mClient updateInSessionsLwM2MClient(Registration registration) { if (this.lwM2mClients.get(registration.getEndpoint()) == null) { - addLwM2mClientToSession(registration.getEndpoint()); + this.addLwM2mClientToSession(registration.getEndpoint()); } LwM2mClient lwM2MClient = lwM2mClients.get(registration.getEndpoint()); lwM2MClient.setRegistration(registration); @@ -169,4 +173,21 @@ public class LwM2mClientContextImpl implements LwM2mClientContext { } return false; } + + /** + * if isVer - ok or default ver=DEFAULT_LWM2M_VERSION + * @param registration - + * @return - all objectIdVer in client + */ + @Override + public Set getSupportedIdVerInClient(Registration registration) { + Set clientObjects = ConcurrentHashMap.newKeySet(); + Arrays.stream(registration.getObjectLinks()).forEach(url -> { + LwM2mPath pathIds = new LwM2mPath(url.getUrl()); + if (!pathIds.isRoot()) { + clientObjects.add(convertToIdVerFromObjectId(url.getUrl(), registration)); + } + }); + return (clientObjects.size() > 0) ? clientObjects : null; + } } diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClientProfile.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClientProfile.java index 1c4042bd1a..19af453f3c 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClientProfile.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClientProfile.java @@ -56,6 +56,13 @@ public class LwM2mClientProfile { */ private JsonArray postObserveProfile; + /** + * "attributeLwm2m": {"/3_1.0": {"ver": "currentTimeTest11"}, + * "/3_1.0/0": {"gt": 17}, + * "/3_1.0/0/9": {"pmax": 45}, "/3_1.2": {ver": "3_1.2"}} + */ + private JsonObject postAttributeLwm2mProfile; + public LwM2mClientProfile clone() { LwM2mClientProfile lwM2mClientProfile = new LwM2mClientProfile(); lwM2mClientProfile.postClientLwM2mSettings = this.deepCopy(this.postClientLwM2mSettings, JsonObject.class); @@ -63,6 +70,7 @@ public class LwM2mClientProfile { lwM2mClientProfile.postAttributeProfile = this.deepCopy(this.postAttributeProfile, JsonArray.class); lwM2mClientProfile.postTelemetryProfile = this.deepCopy(this.postTelemetryProfile, JsonArray.class); lwM2mClientProfile.postObserveProfile = this.deepCopy(this.postObserveProfile, JsonArray.class); + lwM2mClientProfile.postAttributeLwm2mProfile = this.deepCopy(this.postAttributeLwm2mProfile, JsonObject.class); return lwM2mClientProfile; } diff --git a/ui-ngx/src/app/core/http/resource.service.ts b/ui-ngx/src/app/core/http/resource.service.ts index 6d5a367c86..2fef72fedb 100644 --- a/ui-ngx/src/app/core/http/resource.service.ts +++ b/ui-ngx/src/app/core/http/resource.service.ts @@ -43,14 +43,17 @@ export class ResourceService { } public downloadResource(resourceId: string): Observable { - return this.http.get(`/api/resource/${resourceId}/download`, { responseType: 'arraybuffer', observe: 'response' }).pipe( + return this.http.get(`/api/resource/${resourceId}/download`, { + responseType: 'arraybuffer', + observe: 'response' + }).pipe( map((response) => { const headers = response.headers; const filename = headers.get('x-filename'); const contentType = headers.get('content-type'); const linkElement = document.createElement('a'); try { - const blob = new Blob([response.body], { type: contentType }); + const blob = new Blob([response.body], {type: contentType}); const url = URL.createObjectURL(blob); linkElement.setAttribute('href', url); linkElement.setAttribute('download', filename); diff --git a/ui-ngx/src/app/core/services/time.service.ts b/ui-ngx/src/app/core/services/time.service.ts index 410b150e48..23fc215573 100644 --- a/ui-ngx/src/app/core/services/time.service.ts +++ b/ui-ngx/src/app/core/services/time.service.ts @@ -31,7 +31,7 @@ import { isDefined } from '@core/utils'; export interface TimeInterval { name: string; - translateParams: {[key: string]: any}; + translateParams: { [key: string]: any }; value: number; } @@ -56,14 +56,14 @@ export class TimeService { public loadMaxDatapointsLimit(): Observable { return this.http.get('/api/dashboard/maxDatapointsLimit', defaultHttpOptions(true)).pipe( - map( (limit) => { - this.maxDatapointsLimit = limit; - if (!this.maxDatapointsLimit || this.maxDatapointsLimit <= MIN_LIMIT) { - this.maxDatapointsLimit = MIN_LIMIT + 1; - } - return this.maxDatapointsLimit; - }) - ); + map((limit) => { + this.maxDatapointsLimit = limit; + if (!this.maxDatapointsLimit || this.maxDatapointsLimit <= MIN_LIMIT) { + this.maxDatapointsLimit = MIN_LIMIT + 1; + } + return this.maxDatapointsLimit; + }) + ); } public matchesExistingInterval(min: number, max: number, intervalMs: number): boolean { diff --git a/ui-ngx/src/app/core/utils.ts b/ui-ngx/src/app/core/utils.ts index 225a1398b0..b2d7b8b2a7 100644 --- a/ui-ngx/src/app/core/utils.ts +++ b/ui-ngx/src/app/core/utils.ts @@ -291,7 +291,7 @@ export function deepClone(target: T, ignoreFields?: string[]): T { return cp.map((n: any) => deepClone(n)) as any; } if (typeof target === 'object' && target !== {}) { - const cp = { ...(target as { [key: string]: any }) } as { [key: string]: any }; + const cp = {...(target as { [key: string]: any })} as { [key: string]: any }; Object.keys(cp).forEach(k => { if (!ignoreFields || ignoreFields.indexOf(k) === -1) { cp[k] = deepClone(cp[k]); diff --git a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-device-profile-transport-configuration.component.ts b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-device-profile-transport-configuration.component.ts index 4231ab35ff..a9805c528b 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-device-profile-transport-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-device-profile-transport-configuration.component.ts @@ -14,12 +14,12 @@ /// limitations under the License. /// -import {DeviceProfileTransportConfiguration, DeviceTransportType} from '@shared/models/device.models'; -import {Component, forwardRef, Inject, Input} from '@angular/core'; -import {ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR, Validators} from '@angular/forms'; -import {Store} from '@ngrx/store'; -import {AppState} from '@app/core/core.state'; -import {coerceBooleanProperty} from '@angular/cdk/coercion'; +import { DeviceProfileTransportConfiguration } from '@shared/models/device.models'; +import { Component, forwardRef, Inject, Input } from '@angular/core'; +import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR, Validators } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@app/core/core.state'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { ATTRIBUTE, DEFAULT_BINDING, @@ -34,11 +34,11 @@ import { RESOURCES, TELEMETRY } from './lwm2m-profile-config.models'; -import {DeviceProfileService} from '@core/http/device-profile.service'; -import {deepClone, isDefinedAndNotNull, isEmpty, isUndefined} from '@core/utils'; -import {WINDOW} from '@core/services/window.service'; -import {JsonArray, JsonObject} from '@angular/compiler-cli/ngcc/src/packages/entry_point'; -import {Direction} from '@shared/models/page/sort-order'; +import { DeviceProfileService } from '@core/http/device-profile.service'; +import { deepClone, isDefinedAndNotNull, isEmpty, isUndefined } from '@core/utils'; +import { WINDOW } from '@core/services/window.service'; +import { JsonArray, JsonObject } from '@angular/compiler-cli/ngcc/src/packages/entry_point'; +import { Direction } from '@shared/models/page/sort-order'; @Component({ selector: 'tb-profile-lwm2m-device-transport-configuration', @@ -173,7 +173,7 @@ export class Lwm2mDeviceProfileTransportConfigurationComponent implements Contro } private updateObserveAttrTelemetryObjectFormGroup = (objectsList: ObjectLwM2M[]): void => { - this.lwm2mDeviceProfileFormGroup.patchValue({ + this.lwm2mDeviceProfileFormGroup.patchValue({ observeAttrTelemetry: deepClone(this.getObserveAttrTelemetryObjects(objectsList)) }, {emitEvent: false}); @@ -358,6 +358,7 @@ export class Lwm2mDeviceProfileTransportConfigurationComponent implements Contro this.configurationValue.observeAttr.observe = observeArray; this.configurationValue.observeAttr.attribute = attributeArray; this.configurationValue.observeAttr.telemetry = telemetryArray; + this.configurationValue.observeAttr.keyName = this.sortObjectKeyPathJson(KEY_NAME, keyNameNew); this.configurationValue.observeAttr.attributeLwm2m = attributeLwm2m; } } diff --git a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry.component.html b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry.component.html index 2f16e8677c..759e880ffa 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry.component.html @@ -47,6 +47,7 @@

-
+
{ + this.attributeUpdateFormGroup.valueChanges.subscribe(() => { this.ctx.detectChanges(); }); } @@ -191,14 +191,14 @@ export class JsonInputWidgetComponent extends PageComponent implements OnInit { saveAttributeObservable = this.attributeService.saveEntityAttributes( entityId, this.settings.attributeScope, - [ attributeToSave ], + [attributeToSave], {} ); } else { saveAttributeObservable = this.attributeService.saveEntityTimeseries( entityId, LatestTelemetry.LATEST_TELEMETRY, - [ attributeToSave ], + [attributeToSave], {} ); } diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts index 801ec12dc7..b55c21b179 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts @@ -87,4 +87,5 @@ import { JsonInputWidgetComponent } from '@home/components/widget/lib/json-input ImportExportService ] }) -export class WidgetComponentsModule { } +export class WidgetComponentsModule { +} diff --git a/ui-ngx/src/app/modules/home/pages/asset/assets-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/asset/assets-table-config.resolver.ts index 4ea3a459cd..ae24710467 100644 --- a/ui-ngx/src/app/modules/home/pages/asset/assets-table-config.resolver.ts +++ b/ui-ngx/src/app/modules/home/pages/asset/assets-table-config.resolver.ts @@ -89,7 +89,7 @@ export class AssetsTableConfigResolver implements Resolve this.translate.instant('asset.delete-asset-title', { assetName: asset.name }); + this.config.deleteEntityTitle = asset => this.translate.instant('asset.delete-asset-title', {assetName: asset.name}); this.config.deleteEntityContent = () => this.translate.instant('asset.delete-asset-text'); this.config.deleteEntitiesTitle = count => this.translate.instant('asset.delete-assets-title', {count}); this.config.deleteEntitiesContent = () => this.translate.instant('asset.delete-assets-text'); diff --git a/ui-ngx/src/app/modules/home/pages/dashboard/dashboards-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/dashboard/dashboards-table-config.resolver.ts index 0a85c81375..c572fa958f 100644 --- a/ui-ngx/src/app/modules/home/pages/dashboard/dashboards-table-config.resolver.ts +++ b/ui-ngx/src/app/modules/home/pages/dashboard/dashboards-table-config.resolver.ts @@ -93,7 +93,7 @@ export class DashboardsTableConfigResolver implements Resolve - this.translate.instant('dashboard.delete-dashboard-title', { dashboardTitle: dashboard.title }); + this.translate.instant('dashboard.delete-dashboard-title', {dashboardTitle: dashboard.title}); this.config.deleteEntityContent = () => this.translate.instant('dashboard.delete-dashboard-text'); this.config.deleteEntitiesTitle = count => this.translate.instant('dashboard.delete-dashboards-title', {count}); this.config.deleteEntitiesContent = () => this.translate.instant('dashboard.delete-dashboards-text'); @@ -363,8 +363,7 @@ export class DashboardsTableConfigResolver implements Resolve { - this.config.table.updateData(); + this.config.table.updateData(); }); } ); diff --git a/ui-ngx/src/app/modules/home/pages/device/devices-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/device/devices-table-config.resolver.ts index 36b93fa697..0a6c4e9610 100644 --- a/ui-ngx/src/app/modules/home/pages/device/devices-table-config.resolver.ts +++ b/ui-ngx/src/app/modules/home/pages/device/devices-table-config.resolver.ts @@ -97,7 +97,7 @@ export class DevicesTableConfigResolver implements Resolve this.translate.instant('device.delete-device-title', { deviceName: device.name }); + this.config.deleteEntityTitle = device => this.translate.instant('device.delete-device-title', {deviceName: device.name}); this.config.deleteEntityContent = () => this.translate.instant('device.delete-device-text'); this.config.deleteEntitiesTitle = count => this.translate.instant('device.delete-devices-title', {count}); this.config.deleteEntitiesContent = () => this.translate.instant('device.delete-devices-text'); @@ -109,7 +109,7 @@ export class DevicesTableConfigResolver implements Resolve this.deviceService.getDeviceInfo(savedDevice.id.id) - )); + )); }; this.config.onEntityAction = action => this.onDeviceAction(action); this.config.detailsReadonly = () => (this.config.componentsData.deviceScope === 'customer_user' || this.config.componentsData.deviceScope === 'edge_customer_user'); @@ -208,7 +208,7 @@ export class DevicesTableConfigResolver implements Resolve this.deviceService.getCustomerDeviceInfosByDeviceProfileId(this.customerId, pageLink, this.config.componentsData.deviceProfileId !== null ? - this.config.componentsData.deviceProfileId.id : ''); + this.config.componentsData.deviceProfileId.id : ''); this.config.deleteEntity = id => this.deviceService.unassignDeviceFromCustomer(id.id); } } @@ -250,26 +250,26 @@ export class DevicesTableConfigResolver implements Resolve (entity.customerId && entity.customerId.id !== NULL_UUID && !entity.customerIsPublic), - onAction: ($event, entity) => this.unassignFromCustomer($event, entity) - }, - { - name: this.translate.instant('device.make-private'), - icon: 'reply', - isEnabled: (entity) => (entity.customerId && entity.customerId.id !== NULL_UUID && entity.customerIsPublic), - onAction: ($event, entity) => this.unassignFromCustomer($event, entity) - }, - { - name: this.translate.instant('device.manage-credentials'), - icon: 'security', - isEnabled: () => true, - onAction: ($event, entity) => this.manageCredentials($event, entity) - } - ); + actions.push( + { + name: this.translate.instant('device.unassign-from-customer'), + icon: 'assignment_return', + isEnabled: (entity) => (entity.customerId && entity.customerId.id !== NULL_UUID && !entity.customerIsPublic), + onAction: ($event, entity) => this.unassignFromCustomer($event, entity) + }, + { + name: this.translate.instant('device.make-private'), + icon: 'reply', + isEnabled: (entity) => (entity.customerId && entity.customerId.id !== NULL_UUID && entity.customerIsPublic), + onAction: ($event, entity) => this.unassignFromCustomer($event, entity) + }, + { + name: this.translate.instant('device.manage-credentials'), + icon: 'security', + isEnabled: () => true, + onAction: ($event, entity) => this.manageCredentials($event, entity) + } + ); } if (deviceScope === 'customer_user' || deviceScope === 'edge_customer_user') { actions.push( @@ -452,10 +452,10 @@ export class DevicesTableConfigResolver implements Resolve { - if (res) { - this.config.table.updateData(); - } - }); + if (res) { + this.config.table.updateData(); + } + }); } unassignFromCustomer($event: Event, device: DeviceInfo) { diff --git a/ui-ngx/src/app/modules/home/pages/edge/edge-routing.module.ts b/ui-ngx/src/app/modules/home/pages/edge/edge-routing.module.ts index c2eff33a11..29f5ab3579 100644 --- a/ui-ngx/src/app/modules/home/pages/edge/edge-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/edge/edge-routing.module.ts @@ -33,8 +33,11 @@ import { RuleChainType } from '@shared/models/rule-chain.models'; import { importRuleChainBreadcumbLabelFunction, ResolvedRuleChainMetaDataResolver, - ruleChainBreadcumbLabelFunction, RuleChainImportGuard, - RuleChainResolver, RuleNodeComponentsResolver, TooltipsterResolver + ruleChainBreadcumbLabelFunction, + RuleChainImportGuard, + RuleChainResolver, + RuleNodeComponentsResolver, + TooltipsterResolver } from '@home/pages/rulechain/rulechain-routing.module'; const routes: Routes = [ @@ -249,7 +252,7 @@ const routes: Routes = [ } ] } - ] + ] }]; @NgModule({ @@ -259,4 +262,5 @@ const routes: Routes = [ EdgesTableConfigResolver ] }) -export class EdgeRoutingModule { } +export class EdgeRoutingModule { +} diff --git a/ui-ngx/src/app/modules/home/pages/entity-view/entity-views-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/entity-view/entity-views-table-config.resolver.ts index 04c7469f71..730ac2603a 100644 --- a/ui-ngx/src/app/modules/home/pages/entity-view/entity-views-table-config.resolver.ts +++ b/ui-ngx/src/app/modules/home/pages/entity-view/entity-views-table-config.resolver.ts @@ -89,7 +89,7 @@ export class EntityViewsTableConfigResolver implements Resolve - this.translate.instant('entity-view.delete-entity-view-title', { entityViewName: entityView.name }); + this.translate.instant('entity-view.delete-entity-view-title', {entityViewName: entityView.name}); this.config.deleteEntityContent = () => this.translate.instant('entity-view.delete-entity-view-text'); this.config.deleteEntitiesTitle = count => this.translate.instant('entity-view.delete-entity-views-title', {count}); this.config.deleteEntitiesContent = () => this.translate.instant('entity-view.delete-entity-views-text'); @@ -143,8 +143,7 @@ export class EntityViewsTableConfigResolver implements Resolve this.config.tableTitle = edge.name + ': ' + this.translate.instant('entity-view.entity-views') ); - } - else { + } else { this.config.tableTitle = this.translate.instant('entity-view.entity-views'); } this.config.columns = this.configureColumns(this.config.componentsData.entityViewScope); diff --git a/ui-ngx/src/app/modules/home/pages/resource/resources-library.component.ts b/ui-ngx/src/app/modules/home/pages/resource/resources-library.component.ts index 5f7a35c5b9..ce26499102 100644 --- a/ui-ngx/src/app/modules/home/pages/resource/resources-library.component.ts +++ b/ui-ngx/src/app/modules/home/pages/resource/resources-library.component.ts @@ -90,8 +90,10 @@ export class ResourcesLibraryComponent extends EntityComponent impleme buildForm(entity: Resource): FormGroup { return this.fb.group( { - resourceType: [{value: entity?.resourceType ? entity.resourceType : ResourceType.LWM2M_MODEL, - disabled: this.isEdit }, [Validators.required]], + resourceType: [{ + value: entity?.resourceType ? entity.resourceType : ResourceType.LWM2M_MODEL, + disabled: this.isEdit + }, [Validators.required]], data: [entity ? entity.data : null, [Validators.required]], fileName: [entity ? entity.fileName : null, [Validators.required]], title: [entity ? entity.title : '', []] diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rulechains-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/rulechain/rulechains-table-config.resolver.ts index b27fc2e997..37a21cab2e 100644 --- a/ui-ngx/src/app/modules/home/pages/rulechain/rulechains-table-config.resolver.ts +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rulechains-table-config.resolver.ts @@ -73,7 +73,7 @@ export class RuleChainsTableConfigResolver implements Resolve this.translate.instant('rulechain.delete-rulechain-title', - { ruleChainName: ruleChain.name }); + {ruleChainName: ruleChain.name}); this.config.deleteEntityContent = () => this.translate.instant('rulechain.delete-rulechain-text'); this.config.deleteEntitiesTitle = count => this.translate.instant('rulechain.delete-rulechains-title', {count}); this.config.deleteEntitiesContent = () => this.translate.instant('rulechain.delete-rulechains-text'); @@ -121,11 +121,11 @@ export class RuleChainsTableConfigResolver implements Resolve('root', 'rulechain.root', '60px', entity => { - if (ruleChainScope === 'edge') { - return checkBoxCell((this.config.componentsData.edge.rootRuleChainId.id === entity.id.id)); - } else { - return checkBoxCell(entity.root); - } + if (ruleChainScope === 'edge') { + return checkBoxCell((this.config.componentsData.edge.rootRuleChainId.id === entity.id.id)); + } else { + return checkBoxCell(entity.root); + } }) ); } else if (ruleChainScope === 'edges') { @@ -421,32 +421,32 @@ export class RuleChainsTableConfigResolver implements Resolve { - if (res) { - this.edgeService.findMissingToRelatedRuleChains(this.config.componentsData.edgeId).subscribe( - (missingRuleChains) => { - if (missingRuleChains && Object.keys(missingRuleChains).length > 0) { - const formattedMissingRuleChains: Array = new Array(); - for (const missingRuleChain of Object.keys(missingRuleChains)) { - const arrayOfMissingRuleChains = missingRuleChains[missingRuleChain]; - const tmp = '- \'' + missingRuleChain + '\': \'' + arrayOfMissingRuleChains.join('\', ') + '\''; - formattedMissingRuleChains.push(tmp); - } - const message = this.translate.instant('edge.missing-related-rule-chains-text', - {missingRuleChains: formattedMissingRuleChains.join('
')}); - this.dialogService.alert(this.translate.instant('edge.missing-related-rule-chains-title'), - message, this.translate.instant('action.close'), true).subscribe( - () => { - this.config.table.updateData(); + if (res) { + this.edgeService.findMissingToRelatedRuleChains(this.config.componentsData.edgeId).subscribe( + (missingRuleChains) => { + if (missingRuleChains && Object.keys(missingRuleChains).length > 0) { + const formattedMissingRuleChains: Array = new Array(); + for (const missingRuleChain of Object.keys(missingRuleChains)) { + const arrayOfMissingRuleChains = missingRuleChains[missingRuleChain]; + const tmp = '- \'' + missingRuleChain + '\': \'' + arrayOfMissingRuleChains.join('\', ') + '\''; + formattedMissingRuleChains.push(tmp); } - ); - } else { - this.config.table.updateData(); + const message = this.translate.instant('edge.missing-related-rule-chains-text', + {missingRuleChains: formattedMissingRuleChains.join('
')}); + this.dialogService.alert(this.translate.instant('edge.missing-related-rule-chains-title'), + message, this.translate.instant('action.close'), true).subscribe( + () => { + this.config.table.updateData(); + } + ); + } else { + this.config.table.updateData(); + } } - } - ); + ); + } } - } - ); + ); } unassignFromEdge($event: Event, ruleChain: RuleChain) { @@ -510,13 +510,13 @@ export class RuleChainsTableConfigResolver implements Resolve { - if (res) { - this.ruleChainService.setAutoAssignToEdgeRuleChain(ruleChain.id.id).subscribe( - () => { - this.config.table.updateData(); - } - ); - } + if (res) { + this.ruleChainService.setAutoAssignToEdgeRuleChain(ruleChain.id.id).subscribe( + () => { + this.config.table.updateData(); + } + ); + } } ); } diff --git a/ui-ngx/src/app/shared/components/file-input.component.ts b/ui-ngx/src/app/shared/components/file-input.component.ts index 0fcaf8b790..09f9c1b17c 100644 --- a/ui-ngx/src/app/shared/components/file-input.component.ts +++ b/ui-ngx/src/app/shared/components/file-input.component.ts @@ -73,9 +73,11 @@ export class FileInputComponent extends PageComponent implements AfterViewInit, contentConvertFunction: (content: string) => any; private requiredValue: boolean; + get required(): boolean { return this.requiredValue; } + @Input() set required(value: boolean) { const newVal = coerceBooleanProperty(value); @@ -85,9 +87,11 @@ export class FileInputComponent extends PageComponent implements AfterViewInit, } private requiredAsErrorValue: boolean; + get requiredAsError(): boolean { return this.requiredAsErrorValue; } + @Input() set requiredAsError(value: boolean) { const newVal = coerceBooleanProperty(value); diff --git a/ui-ngx/src/app/shared/components/json-object-edit.component.ts b/ui-ngx/src/app/shared/components/json-object-edit.component.ts index d82b66e7b6..78652faa18 100644 --- a/ui-ngx/src/app/shared/components/json-object-edit.component.ts +++ b/ui-ngx/src/app/shared/components/json-object-edit.component.ts @@ -22,7 +22,7 @@ import { ActionNotificationHide, ActionNotificationShow } from '@core/notificati import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { CancelAnimationFrame, RafService } from '@core/services/raf.service'; -import { guid, isUndefined, isDefinedAndNotNull, isLiteralObject } from '@core/utils'; +import { guid, isDefinedAndNotNull, isLiteralObject, isUndefined } from '@core/utils'; import { ResizeObserver } from '@juggle/resize-observer'; import { getAce } from '@shared/models/ace/ace.models'; diff --git a/ui-ngx/src/app/shared/models/resource.models.ts b/ui-ngx/src/app/shared/models/resource.models.ts index 154f3c65f9..082d09ac24 100644 --- a/ui-ngx/src/app/shared/models/resource.models.ts +++ b/ui-ngx/src/app/shared/models/resource.models.ts @@ -45,7 +45,7 @@ export const ResourceTypeTranslationMap = new Map( [ResourceType.LWM2M_MODEL, 'LWM2M model'], [ResourceType.PKCS_12, 'PKCS #12'], [ResourceType.JKS, 'JKS'] - ] + ] ); export interface ResourceInfo extends BaseData { From e08d1b2c0734596fa6e01f5dac3f70daf1c43112 Mon Sep 17 00:00:00 2001 From: Andrew Shvayka Date: Thu, 15 Apr 2021 12:05:04 +0300 Subject: [PATCH 41/69] Feature/event filters * Event filter implementation * Removed redundant imports * convert createdTime to column name * UI: Added ui to filter events parameters * Case sensitivity fixed, boolean Is Error added * UI: Added support event filter for error * Improvement for implementation of Event Filter * UI: Refactoring event filter * UI: Add trim * Filter added for all event types * UI: Updated filter events for new API * Refactoring * Code cleaning. Debug Rule Chain added * Removed redundant casts in JpaBaseEventDao, correct created_time check * Removed redundant abstract method in DebugEvent * refactoring of the filter queries * Filters for debug events * UI: Refactoring event table panel Co-authored-by: AndrewVolosytnykhThingsboard Co-authored-by: Vladyslav_Prykhodko Co-authored-by: AndrewVolosytnykhThingsboard <77969531+AndrewVolosytnykhThingsboard@users.noreply.github.com> --- .../server/controller/EventController.java | 37 ++++ .../src/main/resources/thingsboard.yml | 4 +- .../server/dao/event/EventService.java | 3 + .../server/common/data/event/DebugEvent.java | 45 +++++ .../data/event/DebugRuleChainEventFilter.java | 23 +++ .../data/event/DebugRuleNodeEventFilter.java | 23 +++ .../common/data/event/ErrorEventFilter.java | 36 ++++ .../server/common/data/event/EventFilter.java | 40 ++++ .../server/common/data/event/EventType.java | 20 ++ .../data/event/LifeCycleEventFilter.java | 37 ++++ .../data/event/StatisticsEventFilter.java | 36 ++++ .../server/dao/event/BaseEventService.java | 7 +- .../server/dao/event/EventDao.java | 3 + .../server/dao/sql/event/EventRepository.java | 171 +++++++++++++++++- .../server/dao/sql/event/JpaBaseEventDao.java | 106 +++++++++++ ui-ngx/src/app/core/http/event.service.ts | 8 +- .../event/event-filter-panel.component.html | 64 +++++++ .../event/event-filter-panel.component.scss | 35 ++++ .../event/event-filter-panel.component.ts | 101 +++++++++++ .../components/event/event-table-config.ts | 122 ++++++++++++- .../components/event/event-table.component.ts | 13 +- .../home/components/home-components.module.ts | 2 + ui-ngx/src/app/shared/models/event.models.ts | 34 ++++ .../assets/locale/locale.constant-cs_CZ.json | 5 +- .../assets/locale/locale.constant-de_DE.json | 5 +- .../assets/locale/locale.constant-el_GR.json | 5 +- .../assets/locale/locale.constant-en_US.json | 8 +- .../assets/locale/locale.constant-es_ES.json | 5 +- .../assets/locale/locale.constant-fa_IR.json | 5 +- .../assets/locale/locale.constant-fr_FR.json | 5 +- .../assets/locale/locale.constant-it_IT.json | 5 +- .../assets/locale/locale.constant-ja_JA.json | 5 +- .../assets/locale/locale.constant-ka_GE.json | 5 +- .../assets/locale/locale.constant-ko_KR.json | 5 +- .../assets/locale/locale.constant-lv_LV.json | 5 +- .../assets/locale/locale.constant-pt_BR.json | 5 +- .../assets/locale/locale.constant-ro_RO.json | 9 +- .../assets/locale/locale.constant-ru_RU.json | 5 +- .../assets/locale/locale.constant-sl_SI.json | 5 +- .../assets/locale/locale.constant-tr_TR.json | 5 +- .../assets/locale/locale.constant-uk_UA.json | 5 +- .../assets/locale/locale.constant-zh_CN.json | 5 +- .../assets/locale/locale.constant-zh_TW.json | 5 +- 43 files changed, 1018 insertions(+), 59 deletions(-) create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/event/DebugEvent.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/event/DebugRuleChainEventFilter.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/event/DebugRuleNodeEventFilter.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/event/ErrorEventFilter.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/event/EventFilter.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/event/EventType.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/event/LifeCycleEventFilter.java create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/event/StatisticsEventFilter.java create mode 100644 ui-ngx/src/app/modules/home/components/event/event-filter-panel.component.html create mode 100644 ui-ngx/src/app/modules/home/components/event/event-filter-panel.component.scss create mode 100644 ui-ngx/src/app/modules/home/components/event/event-filter-panel.component.ts diff --git a/application/src/main/java/org/thingsboard/server/controller/EventController.java b/application/src/main/java/org/thingsboard/server/controller/EventController.java index ac9e5a3ca2..7e49ba55d4 100644 --- a/application/src/main/java/org/thingsboard/server/controller/EventController.java +++ b/application/src/main/java/org/thingsboard/server/controller/EventController.java @@ -18,12 +18,14 @@ package org.thingsboard.server.controller; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.RestController; import org.thingsboard.server.common.data.Event; +import org.thingsboard.server.common.data.event.EventFilter; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityIdFactory; @@ -31,6 +33,7 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.TimePageLink; import org.thingsboard.server.dao.event.EventService; +import org.thingsboard.server.dao.model.ModelConstants; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.security.permission.Operation; @@ -101,4 +104,38 @@ public class EventController extends BaseController { } } + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @RequestMapping(value = "/events/{entityType}/{entityId}", method = RequestMethod.POST) + @ResponseBody + public PageData getEvents( + @PathVariable("entityType") String strEntityType, + @PathVariable("entityId") String strEntityId, + @RequestParam("tenantId") String strTenantId, + @RequestParam int pageSize, + @RequestParam int page, + @RequestBody EventFilter eventFilter, + @RequestParam(required = false) String textSearch, + @RequestParam(required = false) String sortProperty, + @RequestParam(required = false) String sortOrder, + @RequestParam(required = false) Long startTime, + @RequestParam(required = false) Long endTime) throws ThingsboardException { + checkParameter("EntityId", strEntityId); + checkParameter("EntityType", strEntityType); + try { + TenantId tenantId = new TenantId(toUUID(strTenantId)); + + EntityId entityId = EntityIdFactory.getByTypeAndId(strEntityType, strEntityId); + checkEntityId(entityId, Operation.READ); + + if(sortProperty != null && sortProperty.equals("createdTime") && eventFilter.hasFilterForJsonBody()) { + sortProperty = ModelConstants.CREATED_TIME_PROPERTY; + } + + TimePageLink pageLink = createTimePageLink(pageSize, page, textSearch, sortProperty, sortOrder, startTime, endTime); + return checkNotNull(eventService.findEventsByFilter(tenantId, entityId, eventFilter, pageLink)); + } catch (Exception e) { + throw handleException(e); + } + } + } diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index e53dd65c28..c5e6c69ff5 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -322,7 +322,7 @@ actors: # Enable/disable actor statistics enabled: "${ACTORS_STATISTICS_ENABLED:true}" js_print_interval_ms: "${ACTORS_JS_STATISTICS_PRINT_INTERVAL_MS:10000}" - persist_frequency: "${ACTORS_STATISTICS_PERSIST_FREQUENCY:3600000}" + persist_frequency: "${ACTORS_STATISTICS_PERSIST_FREQUENCY:10000}" cache: # caffeine or redis @@ -516,7 +516,7 @@ js: # Built-in JVM JavaScript environment properties local: # Use Sandboxed (secured) JVM JavaScript environment - use_js_sandbox: "${USE_LOCAL_JS_SANDBOX:false}" + use_js_sandbox: "${USE_LOCAL_JS_SANDBOX:true}" # Specify thread pool size for JavaScript sandbox resource monitor monitor_thread_pool_size: "${LOCAL_JS_SANDBOX_MONITOR_THREAD_POOL_SIZE:4}" # Maximum CPU time in milliseconds allowed for script execution diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/event/EventService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/event/EventService.java index 2bcb0d3df0..ea25568375 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/event/EventService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/event/EventService.java @@ -17,6 +17,7 @@ package org.thingsboard.server.dao.event; import com.google.common.util.concurrent.ListenableFuture; import org.thingsboard.server.common.data.Event; +import org.thingsboard.server.common.data.event.EventFilter; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; @@ -41,6 +42,8 @@ public interface EventService { List findLatestEvents(TenantId tenantId, EntityId entityId, String eventType, int limit); + PageData findEventsByFilter(TenantId tenantId, EntityId entityId, EventFilter eventFilter, TimePageLink pageLink); + void removeEvents(TenantId tenantId, EntityId entityId); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/event/DebugEvent.java b/common/data/src/main/java/org/thingsboard/server/common/data/event/DebugEvent.java new file mode 100644 index 0000000000..0e5bcbd015 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/event/DebugEvent.java @@ -0,0 +1,45 @@ +/** + * Copyright © 2016-2021 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.event; + +import lombok.Data; +import org.eclipse.leshan.core.util.StringUtils; + +@Data +public abstract class DebugEvent implements EventFilter { + + private String msgDirectionType; + private String server; + private String dataSearch; + private String metadataSearch; + private String entityName; + private String relationType; + private String entityId; + private String msgType; + private boolean isError; + private String error; + + public void setIsError(boolean isError) { + this.isError = isError; + } + + @Override + public boolean hasFilterForJsonBody() { + return !StringUtils.isEmpty(msgDirectionType) || !StringUtils.isEmpty(server) || !StringUtils.isEmpty(dataSearch) || !StringUtils.isEmpty(metadataSearch) + || !StringUtils.isEmpty(entityName) || !StringUtils.isEmpty(relationType) || !StringUtils.isEmpty(entityId) || !StringUtils.isEmpty(msgType) || !StringUtils.isEmpty(error) || isError; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/event/DebugRuleChainEventFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/event/DebugRuleChainEventFilter.java new file mode 100644 index 0000000000..cc16d780f0 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/event/DebugRuleChainEventFilter.java @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2021 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.event; + +public class DebugRuleChainEventFilter extends DebugEvent { + @Override + public EventType getEventType() { + return EventType.DEBUG_RULE_CHAIN; + } +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/event/DebugRuleNodeEventFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/event/DebugRuleNodeEventFilter.java new file mode 100644 index 0000000000..abe73e6e85 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/event/DebugRuleNodeEventFilter.java @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2021 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.event; + +public class DebugRuleNodeEventFilter extends DebugEvent { + @Override + public EventType getEventType() { + return EventType.DEBUG_RULE_NODE; + } +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/event/ErrorEventFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/event/ErrorEventFilter.java new file mode 100644 index 0000000000..59e6297a47 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/event/ErrorEventFilter.java @@ -0,0 +1,36 @@ +/** + * Copyright © 2016-2021 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.event; + +import lombok.Data; +import org.eclipse.leshan.core.util.StringUtils; + +@Data +public class ErrorEventFilter implements EventFilter { + private String server; + private String method; + private String error; + + @Override + public EventType getEventType() { + return EventType.ERROR; + } + + @Override + public boolean hasFilterForJsonBody() { + return !StringUtils.isEmpty(server) || !StringUtils.isEmpty(method) || !StringUtils.isEmpty(error); + } +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/event/EventFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/event/EventFilter.java new file mode 100644 index 0000000000..eebab7e7d4 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/event/EventFilter.java @@ -0,0 +1,40 @@ +/** + * Copyright © 2016-2021 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.event; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + include = JsonTypeInfo.As.PROPERTY, + property = "eventType") +@JsonSubTypes({ + @JsonSubTypes.Type(value = DebugRuleNodeEventFilter.class, name = "DEBUG_RULE_NODE"), + @JsonSubTypes.Type(value = DebugRuleChainEventFilter.class, name = "DEBUG_RULE_CHAIN"), + @JsonSubTypes.Type(value = ErrorEventFilter.class, name = "ERROR"), + @JsonSubTypes.Type(value = LifeCycleEventFilter.class, name = "LC_EVENT"), + @JsonSubTypes.Type(value = StatisticsEventFilter.class, name = "STATS") +}) +public interface EventFilter { + @JsonIgnore + EventType getEventType(); + + boolean hasFilterForJsonBody(); + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/event/EventType.java b/common/data/src/main/java/org/thingsboard/server/common/data/event/EventType.java new file mode 100644 index 0000000000..a0700fdfbc --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/event/EventType.java @@ -0,0 +1,20 @@ +/** + * Copyright © 2016-2021 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.event; + +public enum EventType { + ERROR, LC_EVENT, STATS, DEBUG_RULE_NODE, DEBUG_RULE_CHAIN +} \ No newline at end of file diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/event/LifeCycleEventFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/event/LifeCycleEventFilter.java new file mode 100644 index 0000000000..4936a83e3c --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/event/LifeCycleEventFilter.java @@ -0,0 +1,37 @@ +/** + * Copyright © 2016-2021 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.event; + +import lombok.Data; +import org.eclipse.leshan.core.util.StringUtils; + +@Data +public class LifeCycleEventFilter implements EventFilter { + private String server; + private String event; + private String status; + private String error; + + @Override + public EventType getEventType() { + return EventType.LC_EVENT; + } + + @Override + public boolean hasFilterForJsonBody() { + return !StringUtils.isEmpty(server) || !StringUtils.isEmpty(event) || !StringUtils.isEmpty(status) || !StringUtils.isEmpty(error); + } +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/event/StatisticsEventFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/event/StatisticsEventFilter.java new file mode 100644 index 0000000000..0d28ea27e9 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/event/StatisticsEventFilter.java @@ -0,0 +1,36 @@ +/** + * Copyright © 2016-2021 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.event; + +import lombok.Data; +import org.eclipse.leshan.core.util.StringUtils; + +@Data +public class StatisticsEventFilter implements EventFilter { + private String server; + private Integer messagesProcessed; + private Integer errorsOccurred; + + @Override + public EventType getEventType() { + return EventType.STATS; + } + + @Override + public boolean hasFilterForJsonBody() { + return !StringUtils.isEmpty(server) || (messagesProcessed != null && messagesProcessed > 0) || (errorsOccurred != null && errorsOccurred > 0); + } +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/event/BaseEventService.java b/dao/src/main/java/org/thingsboard/server/dao/event/BaseEventService.java index 4cc853b2b9..91bfa954fd 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/event/BaseEventService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/event/BaseEventService.java @@ -23,6 +23,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.Event; +import org.thingsboard.server.common.data.event.EventFilter; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; @@ -30,7 +31,6 @@ import org.thingsboard.server.common.data.page.TimePageLink; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.service.DataValidator; -import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Optional; @@ -111,6 +111,11 @@ public class BaseEventService implements EventService { return eventDao.findLatestEvents(tenantId.getId(), entityId, eventType, limit); } + @Override + public PageData findEventsByFilter(TenantId tenantId, EntityId entityId, EventFilter eventFilter, TimePageLink pageLink) { + return eventDao.findEventByFilter(tenantId.getId(), entityId, eventFilter, pageLink); + } + @Override public void removeEvents(TenantId tenantId, EntityId entityId) { PageData eventPageData; diff --git a/dao/src/main/java/org/thingsboard/server/dao/event/EventDao.java b/dao/src/main/java/org/thingsboard/server/dao/event/EventDao.java index 0cb4fccfb3..ba4e86c95e 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/event/EventDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/event/EventDao.java @@ -17,6 +17,7 @@ package org.thingsboard.server.dao.event; import com.google.common.util.concurrent.ListenableFuture; import org.thingsboard.server.common.data.Event; +import org.thingsboard.server.common.data.event.EventFilter; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; @@ -88,6 +89,8 @@ public interface EventDao extends Dao { */ PageData findEvents(UUID tenantId, EntityId entityId, String eventType, TimePageLink pageLink); + PageData findEventByFilter(UUID tenantId, EntityId entityId, EventFilter eventFilter, TimePageLink pageLink); + /** * Find latest events by tenantId, entityId and eventType. * diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/event/EventRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/event/EventRepository.java index c68d6f1a7e..270ab26329 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/event/EventRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/event/EventRepository.java @@ -44,11 +44,11 @@ public interface EventRepository extends PagingAndSortingRepository findLatestByTenantIdAndEntityTypeAndEntityIdAndEventType( - @Param("tenantId") UUID tenantId, - @Param("entityType") EntityType entityType, - @Param("entityId") UUID entityId, - @Param("eventType") String eventType, - Pageable pageable); + @Param("tenantId") UUID tenantId, + @Param("entityType") EntityType entityType, + @Param("entityId") UUID entityId, + @Param("eventType") String eventType, + Pageable pageable); @Query("SELECT e FROM EventEntity e WHERE " + "e.tenantId = :tenantId " + @@ -80,4 +80,165 @@ public interface EventRepository extends PagingAndSortingRepository= :startTime AND (:endTime = 0 OR e.created_time <= :endTime) " + + ") AS e WHERE " + + "(:type IS NULL OR lower(json_body->>'type') LIKE concat('%', lower(:type\\:\\:varchar), '%')) " + + "AND (:server IS NULL OR lower(json_body->>'server') LIKE concat('%', lower(:server\\:\\:varchar), '%')) " + + "AND (:entityName IS NULL OR lower(json_body->>'entityName') LIKE concat('%', lower(:entityName\\:\\:varchar), '%')) " + + "AND (:relationType IS NULL OR lower(json_body->>'relationType') LIKE concat('%', lower(:relationType\\:\\:varchar), '%')) " + + "AND (:bodyEntityId IS NULL OR lower(json_body->>'entityId') LIKE concat('%', lower(:bodyEntityId\\:\\:varchar), '%')) " + + "AND (:msgType IS NULL OR lower(json_body->>'msgType') LIKE concat('%', lower(:msgType\\:\\:varchar), '%')) " + + "AND ((:isError = FALSE) OR (json_body->>'error') IS NOT NULL) " + + "AND (:error IS NULL OR lower(json_body->>'error') LIKE concat('%', lower(:error\\:\\:varchar), '%')) " + + "AND (:data IS NULL OR lower(json_body->>'data') LIKE concat('%', lower(:data\\:\\:varchar), '%')) " + + "AND (:metadata IS NULL OR lower(json_body->>'metadata') LIKE concat('%', lower(:metadata\\:\\:varchar), '%')) ", + countQuery = "SELECT count(*) FROM " + + "(SELECT *, e.body\\:\\:jsonb as json_body FROM event e WHERE " + + "e.tenant_id = :tenantId " + + "AND e.entity_type = :entityType " + + "AND e.entity_id = :entityId " + + "AND e.event_type = :eventType " + + "AND e.created_time >= :startTime AND (:endTime = 0 OR e.created_time <= :endTime) " + + ") AS e WHERE " + + "(:type IS NULL OR lower(json_body->>'type') LIKE concat('%', lower(:type\\:\\:varchar), '%')) " + + "AND (:server IS NULL OR lower(json_body->>'server') LIKE concat('%', lower(:server\\:\\:varchar), '%')) " + + "AND (:entityName IS NULL OR lower(json_body->>'entityName') LIKE concat('%', lower(:entityName\\:\\:varchar), '%')) " + + "AND (:relationType IS NULL OR lower(json_body->>'relationType') LIKE concat('%', lower(:relationType\\:\\:varchar), '%')) " + + "AND (:bodyEntityId IS NULL OR lower(json_body->>'entityId') LIKE concat('%', lower(:bodyEntityId\\:\\:varchar), '%')) " + + "AND (:msgType IS NULL OR lower(json_body->>'msgType') LIKE concat('%', lower(:msgType\\:\\:varchar), '%')) " + + "AND ((:isError = FALSE) OR (json_body->>'error') IS NOT NULL) " + + "AND (:error IS NULL OR lower(json_body->>'error') LIKE concat('%', lower(:error\\:\\:varchar), '%')) " + + "AND (:data IS NULL OR lower(json_body->>'data') LIKE concat('%', lower(:data\\:\\:varchar), '%')) " + + "AND (:metadata IS NULL OR lower(json_body->>'metadata') LIKE concat('%', lower(:metadata\\:\\:varchar), '%'))" + ) + Page findDebugRuleNodeEvents(@Param("tenantId") UUID tenantId, + @Param("entityId") UUID entityId, + @Param("entityType") String entityType, + @Param("eventType") String eventType, + @Param("startTime") Long startTime, + @Param("endTime") Long endTime, + @Param("type") String type, + @Param("server") String server, + @Param("entityName") String entityName, + @Param("relationType") String relationType, + @Param("bodyEntityId") String bodyEntityId, + @Param("msgType") String msgType, + @Param("isError") boolean isError, + @Param("error") String error, + @Param("data") String data, + @Param("metadata") String metadata, + Pageable pageable); + + @Query(nativeQuery = true, + value = "SELECT e.id, e.created_time, e.body, e.entity_id, e.entity_type, e.event_type, e.event_uid, e.tenant_id, ts FROM " + + "(SELECT *, e.body\\:\\:jsonb as json_body FROM event e WHERE " + + "e.tenant_id = :tenantId " + + "AND e.entity_type = :entityType " + + "AND e.entity_id = :entityId " + + "AND e.event_type = 'ERROR' " + + "AND e.created_time >= :startTime AND (:endTime = 0 OR e.created_time <= :endTime) " + + ") AS e WHERE " + + "(:server IS NULL OR lower(json_body->>'server') LIKE concat('%', lower(:server\\:\\:varchar), '%')) " + + "AND (:method IS NULL OR lower(json_body->>'method') LIKE concat('%', lower(:method\\:\\:varchar), '%')) " + + "AND (:error IS NULL OR lower(json_body->>'error') LIKE concat('%', lower(:error\\:\\:varchar), '%'))", + countQuery = "SELECT count(*) FROM " + + "(SELECT *, e.body\\:\\:jsonb as json_body FROM event e WHERE " + + "e.tenant_id = :tenantId " + + "AND e.entity_type = :entityType " + + "AND e.entity_id = :entityId " + + "AND e.event_type = 'ERROR' " + + "AND e.created_time >= :startTime AND (:endTime = 0 OR e.created_time <= :endTime) " + + ") AS e WHERE " + + "(:server IS NULL OR lower(json_body->>'server') LIKE concat('%', lower(:server\\:\\:varchar), '%')) " + + "AND (:method IS NULL OR lower(json_body->>'method') LIKE concat('%', lower(:method\\:\\:varchar), '%')) " + + "AND (:error IS NULL OR lower(json_body->>'error') LIKE concat('%', lower(:error\\:\\:varchar), '%'))") + Page findErrorEvents(@Param("tenantId") UUID tenantId, + @Param("entityId") UUID entityId, + @Param("entityType") String entityType, + @Param("startTime") Long startTime, + @Param("endTime") Long endTIme, + @Param("server") String server, + @Param("method") String method, + @Param("error") String error, + Pageable pageable); + + @Query(nativeQuery = true, + value = "SELECT e.id, e.created_time, e.body, e.entity_id, e.entity_type, e.event_type, e.event_uid, e.tenant_id, ts FROM " + + "(SELECT *, e.body\\:\\:jsonb as json_body FROM event e WHERE " + + "e.tenant_id = :tenantId " + + "AND e.entity_type = :entityType " + + "AND e.entity_id = :entityId " + + "AND e.event_type = 'LC_EVENT' " + + "AND e.created_time >= :startTime AND (:endTime = 0 OR e.created_time <= :endTime) " + + ") AS e WHERE " + + "(:server IS NULL OR lower(json_body->>'server') LIKE concat('%', lower(:server\\:\\:varchar), '%')) " + + "AND (:event IS NULL OR lower(json_body->>'event') LIKE concat('%', lower(:event\\:\\:varchar), '%')) " + + "AND ((:statusFilterEnabled = FALSE) OR lower(json_body->>'success')\\:\\:boolean = :statusFilter) " + + "AND (:error IS NULL OR lower(json_body->>'error') LIKE concat('%', lower(:error\\:\\:varchar), '%'))" + , + countQuery = "SELECT count(*) FROM " + + "(SELECT *, e.body\\:\\:jsonb as json_body FROM event e WHERE " + + "e.tenant_id = :tenantId " + + "AND e.entity_type = :entityType " + + "AND e.entity_id = :entityId " + + "AND e.event_type = 'LC_EVENT' " + + "AND e.created_time >= :startTime AND (:endTime = 0 OR e.created_time <= :endTime) " + + ") AS e WHERE " + + "(:server IS NULL OR lower(json_body->>'server') LIKE concat('%', lower(:server\\:\\:varchar), '%')) " + + "AND (:event IS NULL OR lower(json_body->>'event') LIKE concat('%', lower(:event\\:\\:varchar), '%')) " + + "AND ((:statusFilterEnabled = FALSE) OR lower(json_body->>'success')\\:\\:boolean = :statusFilter) " + + "AND (:error IS NULL OR lower(json_body->>'error') LIKE concat('%', lower(:error\\:\\:varchar), '%'))" + ) + Page findLifeCycleEvents(@Param("tenantId") UUID tenantId, + @Param("entityId") UUID entityId, + @Param("entityType") String entityType, + @Param("startTime") Long startTime, + @Param("endTime") Long endTIme, + @Param("server") String server, + @Param("event") String event, + @Param("statusFilterEnabled") boolean statusFilterEnabled, + @Param("statusFilter") boolean statusFilter, + @Param("error") String error, + Pageable pageable); + + @Query(nativeQuery = true, + value = "SELECT e.id, e.created_time, e.body, e.entity_id, e.entity_type, e.event_type, e.event_uid, e.tenant_id, ts FROM " + + "(SELECT *, e.body\\:\\:jsonb as json_body FROM event e WHERE " + + "e.tenant_id = :tenantId " + + "AND e.entity_type = :entityType " + + "AND e.entity_id = :entityId " + + "AND e.event_type = 'STATS' " + + "AND e.created_time >= :startTime AND (:endTime = 0 OR e.created_time <= :endTime) " + + ") AS e WHERE " + + "(:server IS NULL OR lower(e.body\\:\\:json->>'server') LIKE concat('%', lower(:server\\:\\:varchar), '%')) " + + "AND (:messagesProcessed = 0 OR (json_body->>'messagesProcessed')\\:\\:integer >= :messagesProcessed) " + + "AND (:errorsOccurred = 0 OR (json_body->>'errorsOccurred')\\:\\:integer >= :errorsOccurred) ", + countQuery = "SELECT count(*) FROM " + + "(SELECT *, e.body\\:\\:jsonb as json_body FROM event e WHERE " + + "e.tenant_id = :tenantId " + + "AND e.entity_type = :entityType " + + "AND e.entity_id = :entityId " + + "AND e.event_type = 'LC_EVENT' " + + "AND e.created_time >= :startTime AND (:endTime = 0 OR e.created_time <= :endTime) " + + ") AS e WHERE " + + "(:server IS NULL OR lower(e.body\\:\\:json->>'server') LIKE concat('%', lower(:server\\:\\:varchar), '%')) " + + "AND (:messagesProcessed = 0 OR (json_body->>'messagesProcessed')\\:\\:integer >= :messagesProcessed) " + + "AND (:errorsOccurred = 0 OR (json_body->>'errorsOccurred')\\:\\:integer >= :errorsOccurred) ") + Page findStatisticsEvents(@Param("tenantId") UUID tenantId, + @Param("entityId") UUID entityId, + @Param("entityType") String entityType, + @Param("startTime") Long startTime, + @Param("endTime") Long endTIme, + @Param("server") String server, + @Param("messagesProcessed") Integer messagesProcessed, + @Param("errorsOccurred") Integer errorsOccurred, + Pageable pageable); + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/event/JpaBaseEventDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/event/JpaBaseEventDao.java index 06f54042d7..44848ec515 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/event/JpaBaseEventDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/event/JpaBaseEventDao.java @@ -24,6 +24,12 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.repository.CrudRepository; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.Event; +import org.thingsboard.server.common.data.event.DebugEvent; +import org.thingsboard.server.common.data.event.ErrorEventFilter; +import org.thingsboard.server.common.data.event.EventFilter; +import org.thingsboard.server.common.data.event.EventType; +import org.thingsboard.server.common.data.event.LifeCycleEventFilter; +import org.thingsboard.server.common.data.event.StatisticsEventFilter; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EventId; import org.thingsboard.server.common.data.id.TenantId; @@ -147,6 +153,98 @@ public class JpaBaseEventDao extends JpaAbstractDao implemen DaoUtil.toPageable(pageLink))); } + @Override + public PageData findEventByFilter(UUID tenantId, EntityId entityId, EventFilter eventFilter, TimePageLink pageLink) { + if (eventFilter.hasFilterForJsonBody()) { + switch (eventFilter.getEventType()) { + case DEBUG_RULE_NODE: + case DEBUG_RULE_CHAIN: + return findEventByFilter(tenantId, entityId, (DebugEvent) eventFilter, pageLink); + case LC_EVENT: + return findEventByFilter(tenantId, entityId, (LifeCycleEventFilter) eventFilter, pageLink); + case ERROR: + return findEventByFilter(tenantId, entityId, (ErrorEventFilter) eventFilter, pageLink); + case STATS: + return findEventByFilter(tenantId, entityId, (StatisticsEventFilter) eventFilter, pageLink); + default: + throw new RuntimeException("Not supported event type: " + eventFilter.getEventType()); + } + } else { + return findEvents(tenantId, entityId, eventFilter.getEventType().name(), pageLink); + } + } + + private PageData findEventByFilter(UUID tenantId, EntityId entityId, DebugEvent eventFilter, TimePageLink pageLink) { + return DaoUtil.toPageData( + eventRepository.findDebugRuleNodeEvents( + tenantId, + entityId.getId(), + entityId.getEntityType().name(), + eventFilter.getEventType().name(), + notNull(pageLink.getStartTime()), + notNull(pageLink.getEndTime()), + eventFilter.getMsgDirectionType(), + eventFilter.getServer(), + eventFilter.getEntityName(), + eventFilter.getRelationType(), + eventFilter.getEntityId(), + eventFilter.getMsgType(), + eventFilter.isError(), + eventFilter.getError(), + eventFilter.getDataSearch(), + eventFilter.getMetadataSearch(), + DaoUtil.toPageable(pageLink))); + } + + private PageData findEventByFilter(UUID tenantId, EntityId entityId, ErrorEventFilter eventFilter, TimePageLink pageLink) { + return DaoUtil.toPageData( + eventRepository.findErrorEvents( + tenantId, + entityId.getId(), + entityId.getEntityType().name(), + notNull(pageLink.getStartTime()), + notNull(pageLink.getEndTime()), + eventFilter.getServer(), + eventFilter.getMethod(), + eventFilter.getError(), + DaoUtil.toPageable(pageLink)) + ); + } + + private PageData findEventByFilter(UUID tenantId, EntityId entityId, LifeCycleEventFilter eventFilter, TimePageLink pageLink) { + boolean statusFilterEnabled = !StringUtils.isEmpty(eventFilter.getStatus()); + boolean statusFilter = statusFilterEnabled && eventFilter.getStatus().equalsIgnoreCase("Success"); + return DaoUtil.toPageData( + eventRepository.findLifeCycleEvents( + tenantId, + entityId.getId(), + entityId.getEntityType().name(), + notNull(pageLink.getStartTime()), + notNull(pageLink.getEndTime()), + eventFilter.getServer(), + eventFilter.getEvent(), + statusFilterEnabled, + statusFilter, + eventFilter.getError(), + DaoUtil.toPageable(pageLink)) + ); + } + + private PageData findEventByFilter(UUID tenantId, EntityId entityId, StatisticsEventFilter eventFilter, TimePageLink pageLink) { + return DaoUtil.toPageData( + eventRepository.findStatisticsEvents( + tenantId, + entityId.getId(), + entityId.getEntityType().name(), + notNull(pageLink.getStartTime()), + notNull(pageLink.getEndTime()), + eventFilter.getServer(), + notNull(eventFilter.getMessagesProcessed()), + notNull(eventFilter.getErrorsOccurred()), + DaoUtil.toPageable(pageLink)) + ); + } + @Override public List findLatestEvents(UUID tenantId, EntityId entityId, String eventType, int limit) { List latest = eventRepository.findLatestByTenantIdAndEntityTypeAndEntityIdAndEventType( @@ -177,4 +275,12 @@ public class JpaBaseEventDao extends JpaAbstractDao implemen return Optional.of(DaoUtil.getData(eventInsertRepository.saveOrUpdate(entity))); } + private long notNull(Long value) { + return value != null ? value : 0; + } + + private int notNull(Integer value) { + return value != null ? value : 0; + } + } diff --git a/ui-ngx/src/app/core/http/event.service.ts b/ui-ngx/src/app/core/http/event.service.ts index 9bf1b4e86f..fd740d73a7 100644 --- a/ui-ngx/src/app/core/http/event.service.ts +++ b/ui-ngx/src/app/core/http/event.service.ts @@ -21,7 +21,7 @@ import { HttpClient } from '@angular/common/http'; import { TimePageLink } from '@shared/models/page/page-link'; import { PageData } from '@shared/models/page/page-data'; import { EntityId } from '@shared/models/id/entity-id'; -import { DebugEventType, Event, EventType } from '@shared/models/event.models'; +import { DebugEventType, Event, EventType, FilterEventBody } from '@shared/models/event.models'; @Injectable({ providedIn: 'root' @@ -39,4 +39,10 @@ export class EventService { defaultHttpOptionsFromConfig(config)); } + public getFilterEvents(entityId: EntityId, eventType: EventType | DebugEventType, tenantId: string, + filters: FilterEventBody, pageLink: TimePageLink, config?: RequestConfig): Observable> { + return this.http.post>(`/api/events/${entityId.entityType}/${entityId.id}` + + `${pageLink.toQuery()}&tenantId=${tenantId}`, {...filters, eventType}, defaultHttpOptionsFromConfig(config)); + } + } diff --git a/ui-ngx/src/app/modules/home/components/event/event-filter-panel.component.html b/ui-ngx/src/app/modules/home/components/event/event-filter-panel.component.html new file mode 100644 index 0000000000..0568a2d572 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/event/event-filter-panel.component.html @@ -0,0 +1,64 @@ + +
+ + + + + {{ column.title | translate}} + + {{ 'event.all-events' | translate}} + + {{ value }} + + + + + + + {{ 'event.has-error' | translate }} + + + + + {{ column.title | translate}} + + + + + + {{ column.title | translate}} + + + + + +
+ + +
+
diff --git a/ui-ngx/src/app/modules/home/components/event/event-filter-panel.component.scss b/ui-ngx/src/app/modules/home/components/event/event-filter-panel.component.scss new file mode 100644 index 0000000000..971b81689f --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/event/event-filter-panel.component.scss @@ -0,0 +1,35 @@ +/** + * Copyright © 2016-2021 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host { + width: 100%; + min-width: 300px; + overflow: auto; + background: #fff; + border-radius: 4px; + box-shadow: + 0 7px 8px -4px rgba(0, 0, 0, .2), + 0 13px 19px 2px rgba(0, 0, 0, .14), + 0 5px 24px 4px rgba(0, 0, 0, .12); + + .mat-content { + overflow: hidden; + background-color: #fff; + } + + .mat-padding { + padding: 16px; + } +} diff --git a/ui-ngx/src/app/modules/home/components/event/event-filter-panel.component.ts b/ui-ngx/src/app/modules/home/components/event/event-filter-panel.component.ts new file mode 100644 index 0000000000..3d016e361e --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/event/event-filter-panel.component.ts @@ -0,0 +1,101 @@ +/// +/// Copyright © 2016-2021 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, Inject, InjectionToken } from '@angular/core'; +import { FormBuilder, FormGroup } from '@angular/forms'; +import { OverlayRef } from '@angular/cdk/overlay'; +import { EntityType } from '@shared/models/entity-type.models'; +import { FilterEventBody } from '@shared/models/event.models'; +import { deepTrim } from '@core/utils'; + +export const EVENT_FILTER_PANEL_DATA = new InjectionToken('AlarmFilterPanelData'); + +export interface EventFilterPanelData { + filterParams: FilterEventBody; + columns: Array; +} + +export interface FilterEntityColumn { + key: string; + title: string; +} + + +@Component({ + selector: 'tb-event-filter-panel', + templateUrl: './event-filter-panel.component.html', + styleUrls: ['./event-filter-panel.component.scss'] +}) +export class EventFilterPanelComponent { + + eventFilterFormGroup: FormGroup; + result: EventFilterPanelData; + + private conditionError = false; + + private msgDirectionTypes = ['IN', 'OUT']; + private statusTypes = ['Success', 'Failure']; + private entityTypes = Object.keys(EntityType); + + showColumns: FilterEntityColumn[] = []; + + constructor(@Inject(EVENT_FILTER_PANEL_DATA) + public data: EventFilterPanelData, + public overlayRef: OverlayRef, + private fb: FormBuilder) { + this.eventFilterFormGroup = this.fb.group({}); + this.data.columns.forEach((column) => { + this.showColumns.push(column); + this.eventFilterFormGroup.addControl(column.key, this.fb.control(this.data.filterParams[column.key] || '')); + if (column.key === 'isError') { + this.conditionError = true; + } + }); + } + + isSelector(key: string): string { + return ['msgDirectionType', 'status', 'entityName'].includes(key) ? key : ''; + } + + selectorValues(key: string): string[] { + switch (key) { + case 'msgDirectionType': + return this.msgDirectionTypes; + case 'status': + return this.statusTypes; + case 'entityName': + return this.entityTypes; + } + } + + update() { + const filter = deepTrim(Object.fromEntries(Object.entries(this.eventFilterFormGroup.value).filter(([_, v]) => v !== ''))); + this.result = { + filterParams: filter, + columns: this.data.columns + }; + this.overlayRef.dispose(); + } + + showErrorMsgFields() { + return !this.conditionError || this.eventFilterFormGroup.get('isError').value !== ''; + } + + cancel() { + this.overlayRef.dispose(); + } +} + diff --git a/ui-ngx/src/app/modules/home/components/event/event-table-config.ts b/ui-ngx/src/app/modules/home/components/event/event-table-config.ts index f18df5cd02..3066de554b 100644 --- a/ui-ngx/src/app/modules/home/components/event/event-table-config.ts +++ b/ui-ngx/src/app/modules/home/components/event/event-table-config.ts @@ -20,7 +20,7 @@ import { EntityTableColumn, EntityTableConfig } from '@home/models/entity/entities-table-config.models'; -import { DebugEventType, Event, EventType } from '@shared/models/event.models'; +import { DebugEventType, Event, EventType, FilterEventBody } from '@shared/models/event.models'; import { TimePageLink } from '@shared/models/page/page-link'; import { TranslateService } from '@ngx-translate/core'; import { DatePipe } from '@angular/common'; @@ -38,16 +38,29 @@ import { EventContentDialogComponent, EventContentDialogData } from '@home/components/event/event-content-dialog.component'; -import { sortObjectKeys } from '@core/utils'; +import { isEqual, sortObjectKeys } from '@core/utils'; +import { ConnectedPosition, Overlay, OverlayConfig, OverlayRef } from '@angular/cdk/overlay'; +import { ChangeDetectorRef, Injector, StaticProvider, ViewContainerRef } from '@angular/core'; +import { ComponentPortal } from '@angular/cdk/portal'; +import { + EVENT_FILTER_PANEL_DATA, + EventFilterPanelComponent, + EventFilterPanelData, + FilterEntityColumn +} from '@home/components/event/event-filter-panel.component'; export class EventTableConfig extends EntityTableConfig { eventTypeValue: EventType | DebugEventType; + private filterParams: FilterEventBody = {}; + private filterColumns: FilterEntityColumn[] = []; + set eventType(eventType: EventType | DebugEventType) { if (this.eventTypeValue !== eventType) { this.eventTypeValue = eventType; this.updateColumns(true); + this.updateFilterColumns(); } } @@ -66,7 +79,10 @@ export class EventTableConfig extends EntityTableConfig { public tenantId: string, private defaultEventType: EventType | DebugEventType, private disabledEventTypes: Array = null, - private debugEventTypes: Array = null) { + private debugEventTypes: Array = null, + private overlay: Overlay, + private viewContainerRef: ViewContainerRef, + private cd: ChangeDetectorRef) { super(); this.loadDataOnInit = false; this.tableTitle = ''; @@ -101,10 +117,20 @@ export class EventTableConfig extends EntityTableConfig { this.defaultSortOrder = {property: 'createdTime', direction: Direction.DESC}; this.updateColumns(); + this.updateFilterColumns(); + + this.headerActionDescriptors.push({ + name: this.translate.instant('event.events-filter'), + icon: 'filter_list', + isEnabled: () => true, + onAction: ($event) => { + this.editEventFilter($event); + } + }); } fetchEvents(pageLink: TimePageLink): Observable> { - return this.eventService.getEvents(this.entityId, this.eventType, this.tenantId, pageLink); + return this.eventService.getFilterEvents(this.entityId, this.eventType, this.tenantId, this.filterParams, pageLink); } updateColumns(updateTableColumns: boolean = false): void { @@ -169,7 +195,7 @@ export class EventTableConfig extends EntityTableConfig { }), false, key => ({ padding: '0 12px 0 0' })), - new EntityTableColumn('entity', 'event.entity', '100px', + new EntityTableColumn('entityName', 'event.entity-type', '100px', (entity) => entity.body.entityName, entity => ({ padding: '0 12px 0 0', }), false, key => ({ @@ -249,5 +275,91 @@ export class EventTableConfig extends EntityTableConfig { } }); } + + private updateFilterColumns() { + this.filterParams = {}; + this.filterColumns = [{key: 'server', title: 'event.server'}]; + switch (this.eventType) { + case EventType.ERROR: + this.filterColumns.push( + {key: 'method', title: 'event.method'}, + {key: 'error', title: 'event.error'} + ); + break; + case EventType.LC_EVENT: + this.filterColumns.push( + {key: 'method', title: 'event.event'}, + {key: 'status', title: 'event.status'}, + {key: 'error', title: 'event.error'} + ); + break; + case EventType.STATS: + this.filterColumns.push( + {key: 'messagesProcessed', title: 'event.messages-processed'}, + {key: 'errorsOccurred', title: 'event.errors-occurred'} + ); + break; + case DebugEventType.DEBUG_RULE_NODE: + case DebugEventType.DEBUG_RULE_CHAIN: + this.filterColumns.push( + {key: 'msgDirectionType', title: 'event.type'}, + {key: 'entityId', title: 'event.entity-id'}, + {key: 'entityName', title: 'event.entity-type'}, + {key: 'msgType', title: 'event.message-type'}, + {key: 'relationType', title: 'event.relation-type'}, + {key: 'dataSearch', title: 'event.data'}, + {key: 'metadataSearch', title: 'event.metadata'}, + {key: 'isError', title: 'event.error'}, + {key: 'error', title: 'event.error'} + ); + break; + } + } + + private editEventFilter($event: MouseEvent) { + if ($event) { + $event.stopPropagation(); + } + const target = $event.target || $event.srcElement || $event.currentTarget; + const config = new OverlayConfig(); + config.backdropClass = 'cdk-overlay-transparent-backdrop'; + config.hasBackdrop = true; + const connectedPosition: ConnectedPosition = { + originX: 'end', + originY: 'bottom', + overlayX: 'end', + overlayY: 'top' + }; + config.positionStrategy = this.overlay.position().flexibleConnectedTo(target as HTMLElement) + .withPositions([connectedPosition]); + + const overlayRef = this.overlay.create(config); + overlayRef.backdropClick().subscribe(() => { + overlayRef.dispose(); + }); + const providers: StaticProvider[] = [ + { + provide: EVENT_FILTER_PANEL_DATA, + useValue: { + columns: this.filterColumns, + filterParams: this.filterParams + } as EventFilterPanelData + }, + { + provide: OverlayRef, + useValue: overlayRef + } + ]; + const injector = Injector.create({parent: this.viewContainerRef.injector, providers}); + const componentRef = overlayRef.attach(new ComponentPortal(EventFilterPanelComponent, + this.viewContainerRef, injector)); + componentRef.onDestroy(() => { + if (componentRef.instance.result && !isEqual(this.filterParams, componentRef.instance.result.filterParams)) { + this.filterParams = componentRef.instance.result.filterParams; + this.table.updateData(); + } + }); + this.cd.detectChanges(); + } } diff --git a/ui-ngx/src/app/modules/home/components/event/event-table.component.ts b/ui-ngx/src/app/modules/home/components/event/event-table.component.ts index 6aaa256ab8..00aeb4f50c 100644 --- a/ui-ngx/src/app/modules/home/components/event/event-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/event/event-table.component.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { Component, Input, OnInit, ViewChild } from '@angular/core'; +import { ChangeDetectorRef, Component, Input, OnInit, ViewChild, ViewContainerRef } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; import { DatePipe } from '@angular/common'; import { MatDialog } from '@angular/material/dialog'; @@ -24,6 +24,7 @@ import { EventTableConfig } from './event-table-config'; import { EventService } from '@core/http/event.service'; import { DialogService } from '@core/services/dialog.service'; import { DebugEventType, EventType } from '@shared/models/event.models'; +import { Overlay } from '@angular/cdk/overlay'; @Component({ selector: 'tb-event-table', @@ -80,7 +81,10 @@ export class EventTableComponent implements OnInit { private dialogService: DialogService, private translate: TranslateService, private datePipe: DatePipe, - private dialog: MatDialog) { + private dialog: MatDialog, + private overlay: Overlay, + private viewContainerRef: ViewContainerRef, + private cd: ChangeDetectorRef) { } ngOnInit() { @@ -95,7 +99,10 @@ export class EventTableComponent implements OnInit { this.tenantId, this.defaultEventType, this.disabledEventTypes, - this.debugEventTypes + this.debugEventTypes, + this.overlay, + this.viewContainerRef, + this.cd ); } diff --git a/ui-ngx/src/app/modules/home/components/home-components.module.ts b/ui-ngx/src/app/modules/home/components/home-components.module.ts index 2c011a0214..14ec1fdc4e 100644 --- a/ui-ngx/src/app/modules/home/components/home-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/home-components.module.ts @@ -25,6 +25,7 @@ import { AuditLogDetailsDialogComponent } from '@home/components/audit-log/audit import { AuditLogTableComponent } from '@home/components/audit-log/audit-log-table.component'; import { EventTableHeaderComponent } from '@home/components/event/event-table-header.component'; import { EventTableComponent } from '@home/components/event/event-table.component'; +import { EventFilterPanelComponent } from '@home/components/event/event-filter-panel.component'; import { RelationTableComponent } from '@home/components/relation/relation-table.component'; import { RelationDialogComponent } from '@home/components/relation/relation-dialog.component'; import { AlarmTableHeaderComponent } from '@home/components/alarm/alarm-table-header.component'; @@ -149,6 +150,7 @@ import { DisplayWidgetTypesPanelComponent } from '@home/components/dashboard-pag EventContentDialogComponent, EventTableHeaderComponent, EventTableComponent, + EventFilterPanelComponent, EdgeDownlinkTableHeaderComponent, EdgeDownlinkTableComponent, RelationTableComponent, diff --git a/ui-ngx/src/app/shared/models/event.models.ts b/ui-ngx/src/app/shared/models/event.models.ts index 00fabd4526..b801bab750 100644 --- a/ui-ngx/src/app/shared/models/event.models.ts +++ b/ui-ngx/src/app/shared/models/event.models.ts @@ -19,6 +19,7 @@ import { TenantId } from '@shared/models/id/tenant-id'; import { EntityId } from '@shared/models/id/entity-id'; import { EventId } from './id/event-id'; import { ContentType } from '@shared/models/constants'; +import { EntityType } from '@shared/models/entity-type.models'; export enum EventType { ERROR = 'ERROR', @@ -83,3 +84,36 @@ export interface Event extends BaseData { uid: string; body: EventBody; } + +export interface BaseFilterEventBody { + server?: string; +} + +export interface ErrorFilterEventBody extends BaseFilterEventBody { + method?: string; +} + +export interface LcFilterEventEventBody extends BaseFilterEventBody { + method?: string; + status?: string; + isError?: boolean; +} + +export interface StatsFilterEventBody extends BaseFilterEventBody { + messagesProcessed?: string; + errorsOccurred?: string; +} + +export interface DebugFilterRuleNodeEventBody extends BaseFilterEventBody { + msgDirectionType?: string; + entityId?: string; + entityName?: EntityType; + msgId?: string; + msgType?: string; + relationType?: string; + dataSearch?: string; + metadataSearch?: string; + isError?: boolean; +} + +export type FilterEventBody = ErrorFilterEventBody & LcFilterEventEventBody & StatsFilterEventBody & DebugFilterRuleNodeEventBody; diff --git a/ui-ngx/src/assets/locale/locale.constant-cs_CZ.json b/ui-ngx/src/assets/locale/locale.constant-cs_CZ.json index 888154e3bf..92941c9404 100644 --- a/ui-ngx/src/assets/locale/locale.constant-cs_CZ.json +++ b/ui-ngx/src/assets/locale/locale.constant-cs_CZ.json @@ -1332,7 +1332,6 @@ "body": "Tělo", "method": "Metoda", "type": "Typ", - "entity": "Entita", "message-id": "Id zprávy", "message-type": "Typ zprávy", "data-type": "Typ dat", @@ -1344,7 +1343,9 @@ "success": "Úspěch", "failed": "Neúspěch", "messages-processed": "Zpracované zprávy", - "errors-occurred": "Vyskytly se chyby" + "errors-occurred": "Vyskytly se chyby", + "all-events": "Vše", + "entity-type": "Typ entity" }, "extension": { "extensions": "Rozšíření", diff --git a/ui-ngx/src/assets/locale/locale.constant-de_DE.json b/ui-ngx/src/assets/locale/locale.constant-de_DE.json index 712ad0fa2d..3dcf03e1b0 100644 --- a/ui-ngx/src/assets/locale/locale.constant-de_DE.json +++ b/ui-ngx/src/assets/locale/locale.constant-de_DE.json @@ -1094,7 +1094,6 @@ "body": "Inhalt", "method": "Methode", "type": "Typ", - "entity": "Entität", "message-id": "Nachrichten-Id", "message-type": "Nachrichten-Typ", "data-type": "Datentyp", @@ -1106,7 +1105,9 @@ "success": "Erfolg", "failed": "Fehlgeschlagen", "messages-processed": "Nachrichten verarbeitet", - "errors-occurred": "Fehler aufgetreten" + "errors-occurred": "Fehler aufgetreten", + "all-events": "Alle", + "entity-type": "Entitätstyp" }, "extension": { "extensions": "Erweiterungen", diff --git a/ui-ngx/src/assets/locale/locale.constant-el_GR.json b/ui-ngx/src/assets/locale/locale.constant-el_GR.json index 2d2ab96de5..d92b6e7884 100644 --- a/ui-ngx/src/assets/locale/locale.constant-el_GR.json +++ b/ui-ngx/src/assets/locale/locale.constant-el_GR.json @@ -1247,7 +1247,6 @@ "out": "Έξοδος", "metadata": "Μεταδεδομένα", "message": "Μήνυμα", - "entity": "Οντότητα", "message-id": "ID Μηνύματος", "message-type": "Τύπος Μηνύματος", "data-type": "Τύπος Δεδομένων", @@ -1258,7 +1257,9 @@ "success": "Επιτυχία", "failed": "Απέτυχε", "messages-processed": "Επεξεργασμένα μηνύματα", - "errors-occurred": "Παρουσιάστηκαν σφάλματα" + "errors-occurred": "Παρουσιάστηκαν σφάλματα", + "all-events": "Όλοι", + "entity-type": "Τύπος οντοτήτων" }, "extension": { "extensions": "Επεκτάσεις", diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index c16f0c62ef..d028bf5158 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -1616,6 +1616,7 @@ }, "event": { "event-type": "Event type", + "events-filter": "Events Filter", "type-error": "Error", "type-lc-event": "Lifecycle event", "type-stats": "Statistics", @@ -1629,7 +1630,6 @@ "body": "Body", "method": "Method", "type": "Type", - "entity": "Entity", "message-id": "Message Id", "message-type": "Message Type", "data-type": "Data Type", @@ -1641,7 +1641,11 @@ "success": "Success", "failed": "Failed", "messages-processed": "Messages processed", - "errors-occurred": "Errors occurred" + "errors-occurred": "Errors occurred", + "all-events": "All", + "has-error": "Has error", + "entity-id": "Entity Id", + "entity-type": "Entity type" }, "extension": { "extensions": "Extensions", diff --git a/ui-ngx/src/assets/locale/locale.constant-es_ES.json b/ui-ngx/src/assets/locale/locale.constant-es_ES.json index ba278e796e..9d18102a43 100644 --- a/ui-ngx/src/assets/locale/locale.constant-es_ES.json +++ b/ui-ngx/src/assets/locale/locale.constant-es_ES.json @@ -1521,7 +1521,6 @@ "body": "Cuerpo", "method": "Método", "type": "Tipo", - "entity": "Entidad", "message-id": "Id Mensaje", "message-type": "Tipo Mensaje", "data-type": "Tipo de Datos", @@ -1533,7 +1532,9 @@ "success": "Éxito", "failed": "Fallo", "messages-processed": "Mensajes procesados", - "errors-occurred": "Ocurrieron errores" + "errors-occurred": "Ocurrieron errores", + "all-events": "Todos", + "entity-type": "Tipo de entidad" }, "extension": { "extensions": "Extensiones", diff --git a/ui-ngx/src/assets/locale/locale.constant-fa_IR.json b/ui-ngx/src/assets/locale/locale.constant-fa_IR.json index 5327f0b33b..90a10f765c 100644 --- a/ui-ngx/src/assets/locale/locale.constant-fa_IR.json +++ b/ui-ngx/src/assets/locale/locale.constant-fa_IR.json @@ -881,7 +881,6 @@ "body": "بدنه", "method": "روش", "type": "نوع", - "entity": "موجودي", "message-id": "پيام ID", "message-type": "نوع پيام", "data-type": "نوع داده", @@ -893,7 +892,9 @@ "success": "موفقيت", "failed": "عدم موفقيت", "messages-processed": "پيام پردازش شد", - "errors-occurred": "خطاها رخ دادند" + "errors-occurred": "خطاها رخ دادند", + "all-events": "همه", + "entity-type": "نوع موجودي" }, "extension": { "extensions": "دنباله ها", diff --git a/ui-ngx/src/assets/locale/locale.constant-fr_FR.json b/ui-ngx/src/assets/locale/locale.constant-fr_FR.json index a5c25909b0..d3a6cf51a5 100644 --- a/ui-ngx/src/assets/locale/locale.constant-fr_FR.json +++ b/ui-ngx/src/assets/locale/locale.constant-fr_FR.json @@ -1115,7 +1115,6 @@ "body": "Corps", "data": "Données", "data-type": "Type de données", - "entity": "Entité", "error": "erreur", "type-edge-event": "Downlink", "errors-occurred": "Des erreurs sont survenues", @@ -1138,7 +1137,9 @@ "type-debug-rule-node": "Debug", "type-error": "Erreur", "type-lc-event": "Evénement du cycle de vie", - "type-stats": "Statistiques" + "type-stats": "Statistiques", + "all-events": "Tout", + "entity-type": "Type d'entité" }, "extension": { "add": "Ajouter une extension", diff --git a/ui-ngx/src/assets/locale/locale.constant-it_IT.json b/ui-ngx/src/assets/locale/locale.constant-it_IT.json index 363801ebc3..4050e48ad7 100644 --- a/ui-ngx/src/assets/locale/locale.constant-it_IT.json +++ b/ui-ngx/src/assets/locale/locale.constant-it_IT.json @@ -915,7 +915,6 @@ "body": "Body", "method": "Metodo", "type": "Tipo", - "entity": "Entità", "message-id": "Id Messaggio", "message-type": "Tipo Messaggio", "data-type": "Tipo di dato", @@ -927,7 +926,9 @@ "success": "Success", "failed": "Failed", "messages-processed": "Messaggi elaborati", - "errors-occurred": "Si sono verificati degli errori" + "errors-occurred": "Si sono verificati degli errori", + "all-events": "Tutte", + "entity-type": "Tipo entità" }, "extension": { "extensions": "Estensioni", diff --git a/ui-ngx/src/assets/locale/locale.constant-ja_JA.json b/ui-ngx/src/assets/locale/locale.constant-ja_JA.json index 88d666423c..f3696e9a23 100644 --- a/ui-ngx/src/assets/locale/locale.constant-ja_JA.json +++ b/ui-ngx/src/assets/locale/locale.constant-ja_JA.json @@ -768,7 +768,6 @@ "body": "体", "method": "方法", "type": "タイプ", - "entity": "エンティティ", "message-id": "メッセージID", "message-type": "メッセージタイプ", "data-type": "データ・タイプ", @@ -780,7 +779,9 @@ "success": "成功", "failed": "失敗", "messages-processed": "処理されたメッセージ", - "errors-occurred": "エラーが発生しました" + "errors-occurred": "エラーが発生しました", + "all-events": "すべて", + "entity-type": "エンティティタイプ" }, "extension": { "extensions": "拡張機能", diff --git a/ui-ngx/src/assets/locale/locale.constant-ka_GE.json b/ui-ngx/src/assets/locale/locale.constant-ka_GE.json index 06bde1bb02..6f5ba73674 100644 --- a/ui-ngx/src/assets/locale/locale.constant-ka_GE.json +++ b/ui-ngx/src/assets/locale/locale.constant-ka_GE.json @@ -959,7 +959,6 @@ "body": "სხეული", "method": "მეთოდი", "type": "ტიპი", - "entity": "ობიექტი", "message-id": "მესიჯის-ID", "message-type": "მესიჯის ტიპი", "data-type": "მონაცემთა ტიპი", @@ -971,7 +970,9 @@ "success": "წარმატება", "failed": "ვერ მოხერხდა", "messages-processed": "შეტყობინებების დამუშავება", - "errors-occurred": "შეცდომები მოხდა" + "errors-occurred": "შეცდომები მოხდა", + "all-events": "ყველა", + "entity-type": "ობიექტის ტიპი" }, "extension": { "extensions": "დამატებითი აპი", diff --git a/ui-ngx/src/assets/locale/locale.constant-ko_KR.json b/ui-ngx/src/assets/locale/locale.constant-ko_KR.json index f52c94a0f0..cbe962bc2a 100644 --- a/ui-ngx/src/assets/locale/locale.constant-ko_KR.json +++ b/ui-ngx/src/assets/locale/locale.constant-ko_KR.json @@ -1329,7 +1329,6 @@ "body": "Body", "method": "방법", "type": "유형", - "entity": "개체", "message-id": "메시지 ID", "message-type": "메시지 유형", "data-type": "데이터 유형", @@ -1341,7 +1340,9 @@ "success": "성공", "failed": "실패", "messages-processed": "처리된 메시지", - "errors-occurred": "오류가 발생했습니다" + "errors-occurred": "오류가 발생했습니다", + "all-events": "모두", + "entity-type": "개체 유형" }, "extension": { "extensions": "확장", diff --git a/ui-ngx/src/assets/locale/locale.constant-lv_LV.json b/ui-ngx/src/assets/locale/locale.constant-lv_LV.json index 76282308e7..c2b7ce6e79 100644 --- a/ui-ngx/src/assets/locale/locale.constant-lv_LV.json +++ b/ui-ngx/src/assets/locale/locale.constant-lv_LV.json @@ -894,7 +894,6 @@ "body": "Galvenā daļa", "method": "Metode", "type": "Tips", - "entity": "Vienība", "message-id": "Ziņojuma Id", "message-type": "Ziņojuma tips", "data-type": "Datu tips", @@ -906,7 +905,9 @@ "success": "Sekmīgi", "failed": "Kļūda", "messages-processed": "Ziņojumi apstrādāti", - "errors-occurred": "Kļūdas konstatētas" + "errors-occurred": "Kļūdas konstatētas", + "all-events": "Visi", + "entity-type": "Vienības tips" }, "extension": { "extensions": "Paplašinājumi", diff --git a/ui-ngx/src/assets/locale/locale.constant-pt_BR.json b/ui-ngx/src/assets/locale/locale.constant-pt_BR.json index 325229b47b..7f5c7ce239 100644 --- a/ui-ngx/src/assets/locale/locale.constant-pt_BR.json +++ b/ui-ngx/src/assets/locale/locale.constant-pt_BR.json @@ -997,7 +997,6 @@ "body": "Corpo", "method": "Método", "type": "Tipo", - "entity": "Entidade", "message-id": "ID de mensagem", "message-type": "Tipo de Mensagem", "data-type": "Tipo de Dados", @@ -1009,7 +1008,9 @@ "success": "Êxito", "failed": "Falhou", "messages-processed": "Mensagens processadas", - "errors-occurred": "Erros" + "errors-occurred": "Erros", + "all-events": "Tudo", + "entity-type": "Tipo de entidade" }, "extension": { "extensions": "Extensões", diff --git a/ui-ngx/src/assets/locale/locale.constant-ro_RO.json b/ui-ngx/src/assets/locale/locale.constant-ro_RO.json index 971ad02d00..80c9941f2a 100644 --- a/ui-ngx/src/assets/locale/locale.constant-ro_RO.json +++ b/ui-ngx/src/assets/locale/locale.constant-ro_RO.json @@ -945,7 +945,6 @@ "body": "Corp", "method": "Metodă", "type": "Tip", - "entity": "Entitate", "message-id": "ID Mesaj", "message-type": "Tip Mesaj", "data-type": "Tip Date", @@ -957,7 +956,9 @@ "success": "Succes", "failed": "Eşuat", "messages-processed": "Mesaje procesate", - "errors-occurred": "Au apărut erori" + "errors-occurred": "Au apărut erori", + "all-events": "Toate", + "entity-type": "Tip Entitate" }, "extension": { "extensions": "Extensii", @@ -1174,7 +1175,7 @@ "entity-field": "Câmp Entitate", "access-token": "Token De Acces" }, - "stepper-text":{ + "stepper-text": { "select-file": "Selectează un fişier", "configuration": "Importă configurație", "column-type": "Selectează tipul de coloane", @@ -1796,4 +1797,4 @@ "language": { "language": "Limba" } -} +} \ No newline at end of file diff --git a/ui-ngx/src/assets/locale/locale.constant-ru_RU.json b/ui-ngx/src/assets/locale/locale.constant-ru_RU.json index a78416a201..f015d01b18 100644 --- a/ui-ngx/src/assets/locale/locale.constant-ru_RU.json +++ b/ui-ngx/src/assets/locale/locale.constant-ru_RU.json @@ -948,7 +948,6 @@ "body": "Тело", "method": "Метод", "type": "Тип", - "entity": "Объект", "message-id": "ИД сообщения", "message-type": "Тип сообщения", "data-type": "Тип данных", @@ -960,7 +959,9 @@ "success": "Успех", "failed": "Неудача", "messages-processed": "Сообщения обработаны", - "errors-occurred": "Возникли ошибки" + "errors-occurred": "Возникли ошибки", + "all-events": "Все", + "entity-type": "Тип объекта" }, "extension": { "extensions": "Расширение", diff --git a/ui-ngx/src/assets/locale/locale.constant-sl_SI.json b/ui-ngx/src/assets/locale/locale.constant-sl_SI.json index 4dffbc6be2..7dd60aaa2f 100644 --- a/ui-ngx/src/assets/locale/locale.constant-sl_SI.json +++ b/ui-ngx/src/assets/locale/locale.constant-sl_SI.json @@ -1329,7 +1329,6 @@ "body": "Vsebina", "method": "Metoda", "type": "Vrsta", - "entity": "Entiteta", "message-id": "ID sporočila", "message-type": "Vrsta sporočila", "data-type": "Vrsta podatkov", @@ -1341,7 +1340,9 @@ "success": "Uspeh", "failed": "Ni uspelo", "messages-processed": "Obdelana sporočila", - "errors-occurred": "Prišlo je do napak" + "errors-occurred": "Prišlo je do napak", + "all-events": "Vse", + "entity-type": "Vrsta entitete" }, "extension": { "extensions": "Razširitve", diff --git a/ui-ngx/src/assets/locale/locale.constant-tr_TR.json b/ui-ngx/src/assets/locale/locale.constant-tr_TR.json index 06d259dace..dfe3dbbaef 100644 --- a/ui-ngx/src/assets/locale/locale.constant-tr_TR.json +++ b/ui-ngx/src/assets/locale/locale.constant-tr_TR.json @@ -915,7 +915,6 @@ "body": "İçerik //(Body)", "method": "Yöntem", "type": "Tür", - "entity": "Varlık", "message-id": "Mesaj Kimliği", "message-type": "Mesaj tipi", "data-type": "Veri tipi", @@ -927,7 +926,9 @@ "success": "Başarı", "failed": "Başarısız oldu", "messages-processed": "Mesajlar işlendi", - "errors-occurred": "Hatalar oluştu" + "errors-occurred": "Hatalar oluştu", + "all-events": "Tümü", + "entity-type": "Öğe türü" }, "extension": { "extensions": "Uzantılar", diff --git a/ui-ngx/src/assets/locale/locale.constant-uk_UA.json b/ui-ngx/src/assets/locale/locale.constant-uk_UA.json index f9f33d48b9..c45b19ad5f 100644 --- a/ui-ngx/src/assets/locale/locale.constant-uk_UA.json +++ b/ui-ngx/src/assets/locale/locale.constant-uk_UA.json @@ -1201,7 +1201,6 @@ "out": "Out", "metadata": "Метадані", "message": "Повідомлення", - "entity": "Сутність", "message-id": "Id повідомлення", "message-type": "Тип повідомлення", "data-type": "Тип даних", @@ -1212,7 +1211,9 @@ "success": "Успіх", "failed": "Невдача", "messages-processed": "Повідомлення опрацьовані", - "errors-occurred": "Виникли помилки" + "errors-occurred": "Виникли помилки", + "all-events": "Всі", + "entity-type": "Тип сутності" }, "extension": { "extensions": "Розширення", diff --git a/ui-ngx/src/assets/locale/locale.constant-zh_CN.json b/ui-ngx/src/assets/locale/locale.constant-zh_CN.json index c720981fc6..b7a3078c3b 100644 --- a/ui-ngx/src/assets/locale/locale.constant-zh_CN.json +++ b/ui-ngx/src/assets/locale/locale.constant-zh_CN.json @@ -1334,7 +1334,6 @@ "body": "整体", "data": "数据", "data-type": "数据类型", - "entity": "实体", "error": "错误", "errors-occurred": "错误发生", "event": "事件", @@ -1356,7 +1355,9 @@ "type-debug-rule-node": "调试", "type-error": "错误", "type-lc-event": "生命周期事件", - "type-stats": "类型统计" + "type-stats": "类型统计", + "all-events": "全部", + "entity-type": "实体类型" }, "extension": { "add": "添加扩展", diff --git a/ui-ngx/src/assets/locale/locale.constant-zh_TW.json b/ui-ngx/src/assets/locale/locale.constant-zh_TW.json index 14f4268724..bc8374a1c1 100644 --- a/ui-ngx/src/assets/locale/locale.constant-zh_TW.json +++ b/ui-ngx/src/assets/locale/locale.constant-zh_TW.json @@ -865,7 +865,6 @@ "body": "整體", "method": "方法", "type": "類型", - "entity": "實體", "message-id": "消息ID", "message-type": "消息類型", "data-type": "資料類型", @@ -877,7 +876,9 @@ "success": "成功", "failed": "失敗", "messages-processed": "消息處理", - "errors-occurred": "錯誤發生" + "errors-occurred": "錯誤發生", + "all-events": "所有", + "entity-type": "實體類型" }, "extension": { "extensions": "擴展", From f2eea6d82ba8a691df99f224a4849421dd124c60 Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Thu, 15 Apr 2021 16:31:03 +0300 Subject: [PATCH 42/69] Do not print disconnect error msg --- .../thingsboard/server/controller/BaseEdgeControllerTest.java | 4 +++- .../test/java/org/thingsboard/server/edge/BaseEdgeTest.java | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseEdgeControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseEdgeControllerTest.java index cc50cdd00d..246749881f 100644 --- a/application/src/test/java/org/thingsboard/server/controller/BaseEdgeControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/BaseEdgeControllerTest.java @@ -696,7 +696,9 @@ public abstract class BaseEdgeControllerTest extends AbstractControllerTest { Assert.assertTrue(edgeImitator.findMessageByType(UserUpdateMsg.class).isPresent()); edgeImitator.allowIgnoredTypes(); - edgeImitator.disconnect(); + try { + edgeImitator.disconnect(); + } catch (Exception ignored) {} doDelete("/api/device/" + savedDevice.getId().getId().toString()) .andExpect(status().isOk()); diff --git a/application/src/test/java/org/thingsboard/server/edge/BaseEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/BaseEdgeTest.java index 2d38fa9299..92548bb621 100644 --- a/application/src/test/java/org/thingsboard/server/edge/BaseEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/BaseEdgeTest.java @@ -178,7 +178,9 @@ abstract public class BaseEdgeTest extends AbstractControllerTest { @After public void afterTest() throws Exception { - edgeImitator.disconnect(); + try { + edgeImitator.disconnect(); + } catch (Exception ignored) {} loginSysAdmin(); From b034fe04e6e392de00389a6307c7651ec77f4188 Mon Sep 17 00:00:00 2001 From: Volodymyr Babak Date: Thu, 15 Apr 2021 16:33:57 +0300 Subject: [PATCH 43/69] Fix test wait condition --- .../server/controller/BaseEdgeEventControllerTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/src/test/java/org/thingsboard/server/controller/BaseEdgeEventControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/BaseEdgeEventControllerTest.java index f7ed71f177..c07a499f83 100644 --- a/application/src/test/java/org/thingsboard/server/controller/BaseEdgeEventControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/BaseEdgeEventControllerTest.java @@ -103,7 +103,7 @@ public class BaseEdgeEventControllerTest extends AbstractControllerTest { new TypeReference>() {}, new TimePageLink(4)).getData(); attempt++; Thread.sleep(100); - } while (edgeEvents.size() != 4 || attempt < 5); + } while (edgeEvents.size() != 4 && attempt < 5); Assert.assertEquals(4, edgeEvents.size()); Assert.assertTrue(edgeEvents.stream().anyMatch(ee -> EdgeEventType.RULE_CHAIN.equals(ee.getType()))); Assert.assertTrue(edgeEvents.stream().anyMatch(ee -> EdgeEventType.DEVICE.equals(ee.getType()))); From 2df1177ed92855c632000abb6d39dac33cc29ab1 Mon Sep 17 00:00:00 2001 From: Andrii Shvaika Date: Fri, 16 Apr 2021 11:15:29 +0300 Subject: [PATCH 44/69] Replace StringUtils with our implementation --- .../server/common/data/StringUtils.java | 27 +++++++++++++++++++ .../server/common/data/event/DebugEvent.java | 2 +- .../common/data/event/ErrorEventFilter.java | 2 +- .../data/event/LifeCycleEventFilter.java | 2 +- .../data/event/StatisticsEventFilter.java | 2 +- 5 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 common/data/src/main/java/org/thingsboard/server/common/data/StringUtils.java diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/StringUtils.java b/common/data/src/main/java/org/thingsboard/server/common/data/StringUtils.java new file mode 100644 index 0000000000..cbe7094715 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/StringUtils.java @@ -0,0 +1,27 @@ +/** + * Copyright © 2016-2021 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data; + +public class StringUtils { + + public static boolean isEmpty(String source) { + return source == null || source.isEmpty(); + } + + public static boolean isNotEmpty(String source) { + return source != null && !source.isEmpty(); + } +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/event/DebugEvent.java b/common/data/src/main/java/org/thingsboard/server/common/data/event/DebugEvent.java index 0e5bcbd015..a95949acbd 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/event/DebugEvent.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/event/DebugEvent.java @@ -16,7 +16,7 @@ package org.thingsboard.server.common.data.event; import lombok.Data; -import org.eclipse.leshan.core.util.StringUtils; +import org.thingsboard.server.common.data.StringUtils; @Data public abstract class DebugEvent implements EventFilter { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/event/ErrorEventFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/event/ErrorEventFilter.java index 59e6297a47..c9e9886c64 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/event/ErrorEventFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/event/ErrorEventFilter.java @@ -16,7 +16,7 @@ package org.thingsboard.server.common.data.event; import lombok.Data; -import org.eclipse.leshan.core.util.StringUtils; +import org.thingsboard.server.common.data.StringUtils; @Data public class ErrorEventFilter implements EventFilter { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/event/LifeCycleEventFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/event/LifeCycleEventFilter.java index 4936a83e3c..a871ccb106 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/event/LifeCycleEventFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/event/LifeCycleEventFilter.java @@ -16,7 +16,7 @@ package org.thingsboard.server.common.data.event; import lombok.Data; -import org.eclipse.leshan.core.util.StringUtils; +import org.thingsboard.server.common.data.StringUtils; @Data public class LifeCycleEventFilter implements EventFilter { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/event/StatisticsEventFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/event/StatisticsEventFilter.java index 0d28ea27e9..9fad8b4bca 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/event/StatisticsEventFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/event/StatisticsEventFilter.java @@ -16,7 +16,7 @@ package org.thingsboard.server.common.data.event; import lombok.Data; -import org.eclipse.leshan.core.util.StringUtils; +import org.thingsboard.server.common.data.StringUtils; @Data public class StatisticsEventFilter implements EventFilter { From 4a6e0c7aae3c305c5332c520d0447ebd697c805c Mon Sep 17 00:00:00 2001 From: nickAS21 <44275303+nickAS21@users.noreply.github.com> Date: Fri, 16 Apr 2021 12:12:11 +0300 Subject: [PATCH 45/69] Lwm2m fix bug 19 (#4422) * lwm2m: back: Write Attributes - start * lwm2m: front: fis bug disable instances * lwm2m: transport: attr/discavery start * lwm2m: transport: attributeLwm2m after update profile * lwm2m: transport: attributeLwm2m after update profile (no delete attribute) * Lwm2m: back: test * Lwm2m: back: test2 * Lwm2m: back: test3 * Lwm2m: transport fix bug Write attribute through the shared attribute does not work for Object 19 --- .../lwm2m/server/LwM2mTransportHandler.java | 8 ------ .../lwm2m/server/LwM2mTransportRequest.java | 18 ++++++++----- .../server/LwM2mTransportServiceImpl.java | 26 ++++++++++--------- 3 files changed, 26 insertions(+), 26 deletions(-) diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportHandler.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportHandler.java index afb8add332..ce65b072d6 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportHandler.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportHandler.java @@ -362,14 +362,6 @@ public class LwM2mTransportHandler { } } - public static Integer validateObjectIdFromKey(String key) { - try { - return Integer.parseInt(key.split(LWM2M_SEPARATOR_PATH)[1].split(LWM2M_SEPARATOR_KEY)[0]); - } catch (Exception e) { - return null; - } - } - public static String validateObjectVerFromKey(String key) { try { return (key.split(LWM2M_SEPARATOR_PATH)[1].split(LWM2M_SEPARATOR_KEY)[1]); diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportRequest.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportRequest.java index 2274eaf2a9..2c728260d0 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportRequest.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportRequest.java @@ -120,7 +120,7 @@ public class LwM2mTransportRequest { DownlinkRequest request = null; ContentFormat contentFormat = contentFormatParam != null ? ContentFormat.fromName(contentFormatParam.toUpperCase()) : null; LwM2mClient lwM2MClient = lwM2mClientContext.getLwM2mClientWithReg(registration, null); - ResourceModel resource = lwM2MClient.getResourceModel(targetIdVer); + ResourceModel resource = null; timeoutInMs = timeoutInMs > 0 ? timeoutInMs : DEFAULT_TIMEOUT; switch (typeOper) { case GET_TYPE_OPER_READ: @@ -142,6 +142,7 @@ public class LwM2mTransportRequest { request = new CancelObservationRequest(observation); break; case POST_TYPE_OPER_EXECUTE: + resource = lwM2MClient.getResourceModel(targetIdVer); if (params != null && resource != null && !resource.multiple) { request = new ExecuteRequest(target, (String) this.converter.convertValue(params, resource.type, ResourceModel.Type.STRING, resultIds)); } else { @@ -150,6 +151,7 @@ public class LwM2mTransportRequest { break; case POST_TYPE_OPER_WRITE_REPLACE: // Request to write a String Single-Instance Resource using the TLV content format. + resource = lwM2MClient.getResourceModel(targetIdVer); if (resource != null && contentFormat != null) { // if (contentFormat.equals(ContentFormat.TLV) && !resource.multiple) { if (contentFormat.equals(ContentFormat.TLV)) { @@ -171,7 +173,7 @@ public class LwM2mTransportRequest { } break; case PUT_TYPE_OPER_WRITE_ATTRIBUTES: - request = createWriteAttributeRequest (target, params); + request = createWriteAttributeRequest(target, params); break; } @@ -207,12 +209,16 @@ public class LwM2mTransportRequest { if (CoAP.ResponseCode.isSuccess(((Response) response.getCoapResponse()).getCode())) { this.handleResponse(registration, request.getPath().toString(), response, request); if (request instanceof WriteRequest && ((WriteRequest) request).isReplaceRequest()) { + LwM2mNode node = ((WriteRequest) request).getNode(); + Object value = this.converter.convertValue(((LwM2mSingleResource) node).getValue(), + ((LwM2mSingleResource) node).getType(), ResourceModel.Type.STRING, request.getPath()); String msg = String.format("%s: sendRequest Replace: CoapCde - %s Lwm2m code - %d name - %s Resource path - %s value - %s SendRequest to Client", - LOG_LW2M_INFO, ((Response) response.getCoapResponse()).getCode(), response.getCode().getCode(), response.getCode().getName(), request.getPath().toString(), - ((LwM2mSingleResource) ((WriteRequest) request).getNode()).getValue().toString()); + LOG_LW2M_INFO, ((Response) response.getCoapResponse()).getCode(), response.getCode().getCode(), + response.getCode().getName(), request.getPath().toString(), value); serviceImpl.sendLogsToThingsboard(msg, registration); - log.info("[{}] [{}] - [{}] [{}] Update SendRequest[{}]", registration.getEndpoint(), ((Response) response.getCoapResponse()).getCode(), response.getCode(), request.getPath().toString(), - ((LwM2mSingleResource) ((WriteRequest) request).getNode()).getValue()); + log.debug("[{}] [{}] - [{}] [{}] Update SendRequest[{}]", registration.getEndpoint(), + ((Response) response.getCoapResponse()).getCode(), response.getCode(), + request.getPath().toString(), value); } } else { String msg = String.format("%s: sendRequest: CoapCode - %s Lwm2m code - %d name - %s Resource path - %s SendRequest to Client", LOG_LW2M_ERROR, diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportServiceImpl.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportServiceImpl.java index 8ab70d3af4..d863504b44 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportServiceImpl.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportServiceImpl.java @@ -95,7 +95,6 @@ import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandle import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.convertToIdVerFromObjectId; import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.convertToObjectIdFromIdVer; import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.getAckCallback; -import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.validateObjectIdFromKey; import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.validateObjectVerFromKey; @Slf4j @@ -515,9 +514,10 @@ public class LwM2mTransportServiceImpl implements LwM2mTransportService { null, null, this.lwM2mTransportContextServer.getLwM2MTransportConfigServer().getTimeout())); } // #1 + this.initReadAttrTelemetryObserveToClient(registration, lwM2MClient, GET_TYPE_OPER_READ, clientObjects); this.initReadAttrTelemetryObserveToClient(registration, lwM2MClient, GET_TYPE_OPER_OBSERVE, clientObjects); this.initReadAttrTelemetryObserveToClient(registration, lwM2MClient, PUT_TYPE_OPER_WRITE_ATTRIBUTES, clientObjects); - this.initReadAttrTelemetryObserveToClient(registration, lwM2MClient, GET_TYPE_OPER_DISCOVER, clientObjects); +// this.initReadAttrTelemetryObserveToClient(registration, lwM2MClient, GET_TYPE_OPER_DISCOVER, clientObjects); } } @@ -748,9 +748,7 @@ public class LwM2mTransportServiceImpl implements LwM2mTransportService { if (resName != null && !resName.isEmpty()) { try { String resValue = this.getResourceValueToString(lwM2MClient, path); - if (resValue != null) { - parameters.addProperty(resName, resValue); - } + parameters.addProperty(resName, resValue); } catch (Exception e) { log.error("Failed to add parameters.", e); } @@ -907,10 +905,14 @@ public class LwM2mTransportServiceImpl implements LwM2mTransportService { // send Request observe to Client registrationIds.forEach(registrationId -> { Registration registration = lwM2mClientContext.getRegistration(registrationId); - this.readResourceValueObserve(registration, postObserveAnalyzer.getPathPostParametersAdd(), GET_TYPE_OPER_OBSERVE); + if (postObserveAnalyzer.getPathPostParametersAdd().size() > 0) { + this.readResourceValueObserve(registration, postObserveAnalyzer.getPathPostParametersAdd(), GET_TYPE_OPER_OBSERVE); + } // 5.3 del // send Request cancel observe to Client - this.cancelObserveIsValue(registration, postObserveAnalyzer.getPathPostParametersDel()); + if (postObserveAnalyzer.getPathPostParametersDel().size() > 0) { + this.cancelObserveIsValue(registration, postObserveAnalyzer.getPathPostParametersDel()); + } }); } } @@ -1253,11 +1255,11 @@ public class LwM2mTransportServiceImpl implements LwM2mTransportService { return new ArrayList<>(namesIsWritable); } - private boolean validateResourceInModel(LwM2mClient lwM2mClient, String pathKey, boolean isWritable) { - ResourceModel resourceModel = lwM2mClient.getResourceModel(pathKey); - Integer objectId = validateObjectIdFromKey(pathKey); - String objectVer = validateObjectVerFromKey(pathKey); - return resourceModel != null && (isWritable ? + private boolean validateResourceInModel(LwM2mClient lwM2mClient, String pathIdVer, boolean isWritableNotOptional) { + ResourceModel resourceModel = lwM2mClient.getResourceModel(pathIdVer); + Integer objectId = new LwM2mPath(convertToObjectIdFromIdVer(pathIdVer)).getObjectId(); + String objectVer = validateObjectVerFromKey(pathIdVer); + return resourceModel != null && (isWritableNotOptional ? objectId != null && objectVer != null && objectVer.equals(lwM2mClient.getRegistration().getSupportedVersion(objectId)) && resourceModel.operations.isWritable() : objectId != null && objectVer != null && objectVer.equals(lwM2mClient.getRegistration().getSupportedVersion(objectId))); } From 936171b80bd285f0b3d069e8b82a3ffb74c0cd7c Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Fri, 16 Apr 2021 11:57:09 +0300 Subject: [PATCH 46/69] UI: Improvement event statistics filter --- .../event/event-filter-panel.component.html | 9 +++++++++ .../components/event/event-filter-panel.component.ts | 12 ++++++++++-- .../home/components/event/event-table-config.ts | 4 ++-- ui-ngx/src/assets/locale/locale.constant-en_US.json | 3 +++ 4 files changed, 24 insertions(+), 4 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/event/event-filter-panel.component.html b/ui-ngx/src/app/modules/home/components/event/event-filter-panel.component.html index 0568a2d572..6a017f214e 100644 --- a/ui-ngx/src/app/modules/home/components/event/event-filter-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/event/event-filter-panel.component.html @@ -29,6 +29,15 @@ + + + {{ column.title | translate}} + + + {{ 'event.min-value' | translate }} + + + {{ 'event.has-error' | translate }} diff --git a/ui-ngx/src/app/modules/home/components/event/event-filter-panel.component.ts b/ui-ngx/src/app/modules/home/components/event/event-filter-panel.component.ts index 3d016e361e..f60f7a09f7 100644 --- a/ui-ngx/src/app/modules/home/components/event/event-filter-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/event/event-filter-panel.component.ts @@ -15,7 +15,7 @@ /// import { Component, Inject, InjectionToken } from '@angular/core'; -import { FormBuilder, FormGroup } from '@angular/forms'; +import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { OverlayRef } from '@angular/cdk/overlay'; import { EntityType } from '@shared/models/entity-type.models'; import { FilterEventBody } from '@shared/models/event.models'; @@ -59,7 +59,11 @@ export class EventFilterPanelComponent { this.eventFilterFormGroup = this.fb.group({}); this.data.columns.forEach((column) => { this.showColumns.push(column); - this.eventFilterFormGroup.addControl(column.key, this.fb.control(this.data.filterParams[column.key] || '')); + const validators = []; + if (this.isNumberFields(column.key)) { + validators.push(Validators.min(0)); + } + this.eventFilterFormGroup.addControl(column.key, this.fb.control(this.data.filterParams[column.key] || '', validators)); if (column.key === 'isError') { this.conditionError = true; } @@ -70,6 +74,10 @@ export class EventFilterPanelComponent { return ['msgDirectionType', 'status', 'entityName'].includes(key) ? key : ''; } + isNumberFields(key: string): string { + return ['messagesProcessed', 'errorsOccurred'].includes(key) ? key : ''; + } + selectorValues(key: string): string[] { switch (key) { case 'msgDirectionType': diff --git a/ui-ngx/src/app/modules/home/components/event/event-table-config.ts b/ui-ngx/src/app/modules/home/components/event/event-table-config.ts index 3066de554b..1dc663684f 100644 --- a/ui-ngx/src/app/modules/home/components/event/event-table-config.ts +++ b/ui-ngx/src/app/modules/home/components/event/event-table-config.ts @@ -295,8 +295,8 @@ export class EventTableConfig extends EntityTableConfig { break; case EventType.STATS: this.filterColumns.push( - {key: 'messagesProcessed', title: 'event.messages-processed'}, - {key: 'errorsOccurred', title: 'event.errors-occurred'} + {key: 'messagesProcessed', title: 'event.min-messages-processed'}, + {key: 'errorsOccurred', title: 'event.min-errors-occurred'} ); break; case DebugEventType.DEBUG_RULE_NODE: diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index d028bf5158..a24bfd83ad 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -1641,7 +1641,10 @@ "success": "Success", "failed": "Failed", "messages-processed": "Messages processed", + "min-messages-processed": "Minimum messages processed", "errors-occurred": "Errors occurred", + "min-errors-occurred": "Minimum errors occurred", + "min-value": "Minimum value is 0.", "all-events": "All", "has-error": "Has error", "entity-id": "Entity Id", From f1d89ace5d62409a2c433bfa47721df724e8ee84 Mon Sep 17 00:00:00 2001 From: Andrii Shvaika Date: Fri, 16 Apr 2021 14:29:57 +0300 Subject: [PATCH 47/69] Removed invalid dependency --- .../server/controller/BaseController.java | 2 +- .../controller/TbResourceController.java | 8 +- .../service/install/InstallScripts.java | 4 +- .../resource/DefaultTbResourceService.java | 202 ++++++++++++++++++ .../service}/resource/TbResourceService.java | 12 +- .../transport/DefaultTransportApiService.java | 6 +- .../server/service/ServiceSqlTestSuite.java | 43 ++++ .../resource}/BaseTbResourceServiceTest.java | 56 ++++- .../server/dao/resource/ResourceService.java | 50 +++++ common/data/pom.xml | 4 - dao/pom.xml | 4 - ...eService.java => BaseResourceService.java} | 122 +---------- .../server/dao/tenant/TenantServiceImpl.java | 4 +- .../dao/service/AbstractServiceTest.java | 6 +- .../service/sql/TbResourceServiceSqlTest.java | 23 -- 15 files changed, 368 insertions(+), 178 deletions(-) create mode 100644 application/src/main/java/org/thingsboard/server/service/resource/DefaultTbResourceService.java rename {common/dao-api/src/main/java/org/thingsboard/server/dao => application/src/main/java/org/thingsboard/server/service}/resource/TbResourceService.java (90%) create mode 100644 application/src/test/java/org/thingsboard/server/service/ServiceSqlTestSuite.java rename {dao/src/test/java/org/thingsboard/server/dao/service => application/src/test/java/org/thingsboard/server/service/resource}/BaseTbResourceServiceTest.java (88%) create mode 100644 common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ResourceService.java rename dao/src/main/java/org/thingsboard/server/dao/resource/{BaseTbResourceService.java => BaseResourceService.java} (55%) delete mode 100644 dao/src/test/java/org/thingsboard/server/dao/service/sql/TbResourceServiceSqlTest.java diff --git a/application/src/main/java/org/thingsboard/server/controller/BaseController.java b/application/src/main/java/org/thingsboard/server/controller/BaseController.java index 8d46cce8e1..bc9b91cec4 100644 --- a/application/src/main/java/org/thingsboard/server/controller/BaseController.java +++ b/application/src/main/java/org/thingsboard/server/controller/BaseController.java @@ -110,7 +110,6 @@ import org.thingsboard.server.dao.model.ModelConstants; import org.thingsboard.server.dao.oauth2.OAuth2ConfigTemplateService; import org.thingsboard.server.dao.oauth2.OAuth2Service; import org.thingsboard.server.dao.relation.RelationService; -import org.thingsboard.server.dao.resource.TbResourceService; import org.thingsboard.server.dao.rule.RuleChainService; import org.thingsboard.server.dao.tenant.TbTenantProfileCache; import org.thingsboard.server.dao.tenant.TenantProfileService; @@ -130,6 +129,7 @@ import org.thingsboard.server.service.edge.rpc.init.SyncEdgeService; import org.thingsboard.server.service.lwm2m.LwM2MModelsRepository; import org.thingsboard.server.service.profile.TbDeviceProfileCache; import org.thingsboard.server.service.queue.TbClusterService; +import org.thingsboard.server.service.resource.TbResourceService; import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.permission.AccessControlService; import org.thingsboard.server.service.security.permission.Operation; diff --git a/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java b/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java index 3ac679e0d7..5e924a6d29 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java @@ -36,8 +36,8 @@ import org.thingsboard.server.common.data.lwm2m.LwM2mObject; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.security.Authority; -import org.thingsboard.server.dao.resource.TbResourceService; import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.resource.TbResourceService; import org.thingsboard.server.service.security.permission.Operation; import org.thingsboard.server.service.security.permission.Resource; @@ -52,12 +52,6 @@ public class TbResourceController extends BaseController { public static final String RESOURCE_ID = "resourceId"; - private final TbResourceService resourceService; - - public TbResourceController(TbResourceService resourceService) { - this.resourceService = resourceService; - } - @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") @RequestMapping(value = "/resource/{resourceId}/download", method = RequestMethod.GET) @ResponseBody diff --git a/application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java b/application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java index e2fb3ed657..67d981e2fa 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java +++ b/application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java @@ -35,7 +35,7 @@ import org.thingsboard.server.common.data.widget.WidgetsBundle; import org.thingsboard.server.dao.dashboard.DashboardService; import org.thingsboard.server.dao.exception.DataValidationException; import org.thingsboard.server.dao.oauth2.OAuth2ConfigTemplateService; -import org.thingsboard.server.dao.resource.TbResourceService; +import org.thingsboard.server.dao.resource.ResourceService; import org.thingsboard.server.dao.rule.RuleChainService; import org.thingsboard.server.dao.widget.WidgetTypeService; import org.thingsboard.server.dao.widget.WidgetsBundleService; @@ -97,7 +97,7 @@ public class InstallScripts { private OAuth2ConfigTemplateService oAuth2TemplateService; @Autowired - private TbResourceService resourceService; + private ResourceService resourceService; private Path getTenantRuleChainsDir() { return Paths.get(getDataDir(), JSON_DIR, TENANT_DIR, RULE_CHAINS_DIR); diff --git a/application/src/main/java/org/thingsboard/server/service/resource/DefaultTbResourceService.java b/application/src/main/java/org/thingsboard/server/service/resource/DefaultTbResourceService.java new file mode 100644 index 0000000000..81c59609ff --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/resource/DefaultTbResourceService.java @@ -0,0 +1,202 @@ +/** + * Copyright © 2016-2021 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.resource; + +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.eclipse.leshan.core.model.DDFFileParser; +import org.eclipse.leshan.core.model.DefaultDDFFileValidator; +import org.eclipse.leshan.core.model.InvalidDDFFileException; +import org.eclipse.leshan.core.model.ObjectModel; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.ResourceType; +import org.thingsboard.server.common.data.TbResource; +import org.thingsboard.server.common.data.TbResourceInfo; +import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.TbResourceId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.lwm2m.LwM2mInstance; +import org.thingsboard.server.common.data.lwm2m.LwM2mObject; +import org.thingsboard.server.common.data.lwm2m.LwM2mResourceObserve; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.dao.resource.ResourceService; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; + +import static org.thingsboard.server.common.data.lwm2m.LwM2mConstants.LWM2M_SEPARATOR_KEY; +import static org.thingsboard.server.common.data.lwm2m.LwM2mConstants.LWM2M_SEPARATOR_SEARCH_TEXT; +import static org.thingsboard.server.dao.device.DeviceServiceImpl.INCORRECT_TENANT_ID; +import static org.thingsboard.server.dao.service.Validator.validateId; + +@Slf4j +@Service +public class DefaultTbResourceService implements TbResourceService { + + private final ResourceService resourceService; + private final DDFFileParser ddfFileParser; + + public DefaultTbResourceService(ResourceService resourceService) { + this.resourceService = resourceService; + this.ddfFileParser = new DDFFileParser(new DefaultDDFFileValidator()); + } + + @Override + public TbResource saveResource(TbResource resource) throws ThingsboardException { + log.trace("Executing saveResource [{}]", resource); + if (StringUtils.isEmpty(resource.getData())) { + throw new DataValidationException("Resource data should be specified!"); + } + if (ResourceType.LWM2M_MODEL.equals(resource.getResourceType())) { + try { + List objectModels = + ddfFileParser.parseEx(new ByteArrayInputStream(Base64.getDecoder().decode(resource.getData())), resource.getSearchText()); + if (!objectModels.isEmpty()) { + ObjectModel objectModel = objectModels.get(0); + + String resourceKey = objectModel.id + LWM2M_SEPARATOR_KEY + objectModel.getVersion(); + String name = objectModel.name; + resource.setResourceKey(resourceKey); + if (resource.getId() == null) { + resource.setTitle(name + " id=" + objectModel.id + " v" + objectModel.getVersion()); + } + resource.setSearchText(resourceKey + LWM2M_SEPARATOR_SEARCH_TEXT + name); + } else { + throw new DataValidationException(String.format("Could not parse the XML of objectModel with name %s", resource.getSearchText())); + } + } catch (InvalidDDFFileException | IOException e) { + throw new ThingsboardException(e, ThingsboardErrorCode.GENERAL); + } + if (resource.getResourceType().equals(ResourceType.LWM2M_MODEL) && toLwM2mObject(resource) == null) { + throw new DataValidationException(String.format("Could not parse the XML of objectModel with name %s", resource.getSearchText())); + } + } else { + resource.setResourceKey(resource.getFileName()); + } + + return resourceService.saveResource(resource); + } + + @Override + public TbResource getResource(TenantId tenantId, ResourceType resourceType, String resourceId) { + return resourceService.getResource(tenantId, resourceType, resourceId); + } + + @Override + public TbResource findResourceById(TenantId tenantId, TbResourceId resourceId) { + return resourceService.findResourceById(tenantId, resourceId); + } + + @Override + public TbResourceInfo findResourceInfoById(TenantId tenantId, TbResourceId resourceId) { + return resourceService.findResourceInfoById(tenantId, resourceId); + } + + @Override + public PageData findAllTenantResourcesByTenantId(TenantId tenantId, PageLink pageLink) { + return resourceService.findAllTenantResourcesByTenantId(tenantId, pageLink); + } + + @Override + public PageData findTenantResourcesByTenantId(TenantId tenantId, PageLink pageLink) { + return resourceService.findTenantResourcesByTenantId(tenantId, pageLink); + } + + @Override + public List findLwM2mObject(TenantId tenantId, String sortOrder, String sortProperty, String[] objectIds) { + log.trace("Executing findByTenantId [{}]", tenantId); + validateId(tenantId, INCORRECT_TENANT_ID + tenantId); + List resources = resourceService.findTenantResourcesByResourceTypeAndObjectIds(tenantId, ResourceType.LWM2M_MODEL, + objectIds); + return resources.stream() + .map(this::toLwM2mObject) + .sorted(getComparator(sortProperty, sortOrder)) + .collect(Collectors.toList()); + } + + @Override + public List findLwM2mObjectPage(TenantId tenantId, String sortProperty, String sortOrder, PageLink pageLink) { + log.trace("Executing findByTenantId [{}]", tenantId); + validateId(tenantId, INCORRECT_TENANT_ID + tenantId); + PageData resourcePageData = resourceService.findTenantResourcesByResourceTypeAndPageLink(tenantId, ResourceType.LWM2M_MODEL, pageLink); + return resourcePageData.getData().stream() + .map(this::toLwM2mObject) + .sorted(getComparator(sortProperty, sortOrder)) + .collect(Collectors.toList()); + } + + @Override + public void deleteResource(TenantId tenantId, TbResourceId resourceId) { + resourceService.deleteResource(tenantId, resourceId); + } + + @Override + public void deleteResourcesByTenantId(TenantId tenantId) { + resourceService.deleteResourcesByTenantId(tenantId); + } + + private Comparator getComparator(String sortProperty, String sortOrder) { + Comparator comparator; + if ("name".equals(sortProperty)) { + comparator = Comparator.comparing(LwM2mObject::getName); + } else { + comparator = Comparator.comparingLong(LwM2mObject::getId); + } + return "DESC".equals(sortOrder) ? comparator.reversed() : comparator; + } + + private LwM2mObject toLwM2mObject(TbResource resource) { + try { + DDFFileParser ddfFileParser = new DDFFileParser(new DefaultDDFFileValidator()); + List objectModels = + ddfFileParser.parseEx(new ByteArrayInputStream(Base64.getDecoder().decode(resource.getData())), resource.getSearchText()); + if (objectModels.size() == 0) { + return null; + } else { + ObjectModel obj = objectModels.get(0); + LwM2mObject lwM2mObject = new LwM2mObject(); + lwM2mObject.setId(obj.id); + lwM2mObject.setKeyId(resource.getResourceKey()); + lwM2mObject.setName(obj.name); + lwM2mObject.setMultiple(obj.multiple); + lwM2mObject.setMandatory(obj.mandatory); + LwM2mInstance instance = new LwM2mInstance(); + instance.setId(0); + List resources = new ArrayList<>(); + obj.resources.forEach((k, v) -> { + if (!v.operations.isExecutable()) { + LwM2mResourceObserve lwM2MResourceObserve = new LwM2mResourceObserve(k, v.name, false, false, false); + resources.add(lwM2MResourceObserve); + } + }); + instance.setResources(resources.toArray(LwM2mResourceObserve[]::new)); + lwM2mObject.setInstances(new LwM2mInstance[]{instance}); + return lwM2mObject; + } + } catch (IOException | InvalidDDFFileException e) { + log.error("Could not parse the XML of objectModel with name [{}]", resource.getSearchText(), e); + return null; + } + } +} diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/TbResourceService.java b/application/src/main/java/org/thingsboard/server/service/resource/TbResourceService.java similarity index 90% rename from common/dao-api/src/main/java/org/thingsboard/server/dao/resource/TbResourceService.java rename to application/src/main/java/org/thingsboard/server/service/resource/TbResourceService.java index 0093b43dfe..cc35eac26e 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/TbResourceService.java +++ b/application/src/main/java/org/thingsboard/server/service/resource/TbResourceService.java @@ -13,24 +13,23 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.dao.resource; +package org.thingsboard.server.service.resource; -import org.eclipse.leshan.core.model.InvalidDDFFileException; +import org.thingsboard.server.common.data.ResourceType; import org.thingsboard.server.common.data.TbResource; import org.thingsboard.server.common.data.TbResourceInfo; -import org.thingsboard.server.common.data.ResourceType; +import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.TbResourceId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.lwm2m.LwM2mObject; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; -import java.io.IOException; import java.util.List; - public interface TbResourceService { - TbResource saveResource(TbResource resource) throws InvalidDDFFileException, IOException; + + TbResource saveResource(TbResource resource) throws ThingsboardException; TbResource getResource(TenantId tenantId, ResourceType resourceType, String resourceId); @@ -55,4 +54,5 @@ public interface TbResourceService { void deleteResource(TenantId tenantId, TbResourceId resourceId); void deleteResourcesByTenantId(TenantId tenantId); + } diff --git a/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java b/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java index 9613d708d2..6944809515 100644 --- a/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java +++ b/application/src/main/java/org/thingsboard/server/service/transport/DefaultTransportApiService.java @@ -56,7 +56,7 @@ import org.thingsboard.server.dao.device.provision.ProvisionFailedException; import org.thingsboard.server.dao.device.provision.ProvisionRequest; import org.thingsboard.server.dao.device.provision.ProvisionResponse; import org.thingsboard.server.dao.relation.RelationService; -import org.thingsboard.server.dao.resource.TbResourceService; +import org.thingsboard.server.dao.resource.ResourceService; import org.thingsboard.server.dao.tenant.TbTenantProfileCache; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.TransportProtos.DeviceInfoProto; @@ -108,7 +108,7 @@ public class DefaultTransportApiService implements TransportApiService { private final TbClusterService tbClusterService; private final DataDecodingEncodingService dataDecodingEncodingService; private final DeviceProvisionService deviceProvisionService; - private final TbResourceService resourceService; + private final ResourceService resourceService; private final ConcurrentMap deviceCreationLocks = new ConcurrentHashMap<>(); @@ -117,7 +117,7 @@ public class DefaultTransportApiService implements TransportApiService { RelationService relationService, DeviceCredentialsService deviceCredentialsService, DeviceStateService deviceStateService, DbCallbackExecutorService dbCallbackExecutorService, TbClusterService tbClusterService, DataDecodingEncodingService dataDecodingEncodingService, - DeviceProvisionService deviceProvisionService, TbResourceService resourceService) { + DeviceProvisionService deviceProvisionService, ResourceService resourceService) { this.deviceProfileCache = deviceProfileCache; this.tenantProfileCache = tenantProfileCache; this.apiUsageStateService = apiUsageStateService; diff --git a/application/src/test/java/org/thingsboard/server/service/ServiceSqlTestSuite.java b/application/src/test/java/org/thingsboard/server/service/ServiceSqlTestSuite.java new file mode 100644 index 0000000000..0d470c78b9 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/ServiceSqlTestSuite.java @@ -0,0 +1,43 @@ +/** + * Copyright © 2016-2021 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service; + +import org.junit.BeforeClass; +import org.junit.ClassRule; +import org.junit.extensions.cpsuite.ClasspathSuite; +import org.junit.runner.RunWith; +import org.thingsboard.server.dao.CustomSqlUnit; +import org.thingsboard.server.queue.memory.InMemoryStorage; + +import java.util.Arrays; + +@RunWith(ClasspathSuite.class) +@ClasspathSuite.ClassnameFilters({ + "org.thingsboard.server.service.resource.*Test", + }) +public class ServiceSqlTestSuite { + + @ClassRule + public static CustomSqlUnit sqlUnit = new CustomSqlUnit( + Arrays.asList("sql/schema-types-hsql.sql", "sql/schema-ts-hsql.sql", "sql/schema-entities-hsql.sql", "sql/schema-entities-idx.sql", "sql/system-data.sql"), + "sql/hsql/drop-all-tables.sql", + "sql-test.properties"); + + @BeforeClass + public static void cleanupInMemStorage(){ + InMemoryStorage.getInstance().cleanup(); + } +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/BaseTbResourceServiceTest.java b/application/src/test/java/org/thingsboard/server/service/resource/BaseTbResourceServiceTest.java similarity index 88% rename from dao/src/test/java/org/thingsboard/server/dao/service/BaseTbResourceServiceTest.java rename to application/src/test/java/org/thingsboard/server/service/resource/BaseTbResourceServiceTest.java index 21842af176..ffdefc27ed 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/BaseTbResourceServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/resource/BaseTbResourceServiceTest.java @@ -13,28 +13,37 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.dao.service; +package org.thingsboard.server.service.resource; import com.datastax.oss.driver.api.core.uuid.Uuids; import org.junit.After; import org.junit.Assert; import org.junit.Before; import org.junit.Test; +import org.springframework.beans.factory.annotation.Autowired; import org.thingsboard.server.common.data.ResourceType; import org.thingsboard.server.common.data.TbResource; import org.thingsboard.server.common.data.TbResourceInfo; import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.controller.AbstractControllerTest; import org.thingsboard.server.dao.exception.DataValidationException; +import org.thingsboard.server.dao.service.AbstractServiceTest; +import org.thingsboard.server.dao.service.DaoSqlTest; import java.util.ArrayList; import java.util.Base64; import java.util.Collections; import java.util.List; -public abstract class BaseTbResourceServiceTest extends AbstractServiceTest { +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@DaoSqlTest +public class BaseTbResourceServiceTest extends AbstractControllerTest { private static final String LWM2M_TEST_MODEL = "\n" + "\n" + @@ -67,18 +76,38 @@ public abstract class BaseTbResourceServiceTest extends AbstractServiceTest { private TenantId tenantId; + @Autowired + private TbResourceService resourceService; + + private Tenant savedTenant; + private User tenantAdmin; + @Before - public void before() { + public void beforeTest() throws Exception { + loginSysAdmin(); + Tenant tenant = new Tenant(); tenant.setTitle("My tenant"); - Tenant savedTenant = tenantService.saveTenant(tenant); - Assert.assertNotNull(savedTenant); + savedTenant = doPost("/api/tenant", tenant, Tenant.class); tenantId = savedTenant.getId(); + Assert.assertNotNull(savedTenant); + + tenantAdmin = new User(); + tenantAdmin.setAuthority(Authority.TENANT_ADMIN); + tenantAdmin.setTenantId(savedTenant.getId()); + tenantAdmin.setEmail("tenant2@thingsboard.org"); + tenantAdmin.setFirstName("Joe"); + tenantAdmin.setLastName("Downs"); + + tenantAdmin = createUserAndLogin(tenantAdmin, "testPassword1"); } @After - public void after() { - tenantService.deleteTenant(tenantId); + public void afterTest() throws Exception { + loginSysAdmin(); + + doDelete("/api/tenant/" + savedTenant.getId().getId().toString()) + .andExpect(status().isOk()); } @Test @@ -239,9 +268,10 @@ public abstract class BaseTbResourceServiceTest extends AbstractServiceTest { @Test public void testFindTenantResourcesByTenantId() throws Exception { + loginSysAdmin(); Tenant tenant = new Tenant(); tenant.setTitle("Test tenant"); - tenant = tenantService.saveTenant(tenant); + tenant = doPost("/api/tenant", tenant, Tenant.class); TenantId tenantId = tenant.getId(); @@ -279,14 +309,17 @@ public abstract class BaseTbResourceServiceTest extends AbstractServiceTest { Assert.assertFalse(pageData.hasNext()); Assert.assertTrue(pageData.getData().isEmpty()); - tenantService.deleteTenant(tenantId); + doDelete("/api/tenant/" + tenantId.getId().toString()) + .andExpect(status().isOk()); } @Test public void testFindAllTenantResourcesByTenantId() throws Exception { + loginSysAdmin(); + Tenant tenant = new Tenant(); tenant.setTitle("Test tenant"); - tenant = tenantService.saveTenant(tenant); + tenant = doPost("/api/tenant", tenant, Tenant.class); TenantId tenantId = tenant.getId(); @@ -344,7 +377,8 @@ public abstract class BaseTbResourceServiceTest extends AbstractServiceTest { Assert.assertFalse(pageData.hasNext()); Assert.assertTrue(pageData.getData().isEmpty()); - tenantService.deleteTenant(tenantId); + doDelete("/api/tenant/" + tenantId.getId().toString()) + .andExpect(status().isOk()); } } diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ResourceService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ResourceService.java new file mode 100644 index 0000000000..68c2c1f5f0 --- /dev/null +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ResourceService.java @@ -0,0 +1,50 @@ +/** + * Copyright © 2016-2021 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.resource; + +import org.thingsboard.server.common.data.ResourceType; +import org.thingsboard.server.common.data.TbResource; +import org.thingsboard.server.common.data.TbResourceInfo; +import org.thingsboard.server.common.data.id.TbResourceId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; + +import java.util.List; + +public interface ResourceService { + TbResource saveResource(TbResource resource); + + TbResource getResource(TenantId tenantId, ResourceType resourceType, String resourceId); + + TbResource findResourceById(TenantId tenantId, TbResourceId resourceId); + + TbResourceInfo findResourceInfoById(TenantId tenantId, TbResourceId resourceId); + + PageData findAllTenantResourcesByTenantId(TenantId tenantId, PageLink pageLink); + + PageData findTenantResourcesByTenantId(TenantId tenantId, PageLink pageLink); + + List findTenantResourcesByResourceTypeAndObjectIds(TenantId tenantId, ResourceType lwm2mModel, String[] objectIds); + + PageData findTenantResourcesByResourceTypeAndPageLink(TenantId tenantId, ResourceType lwm2mModel, PageLink pageLink); + + void deleteResource(TenantId tenantId, TbResourceId resourceId); + + void deleteResourcesByTenantId(TenantId tenantId); + + +} diff --git a/common/data/pom.xml b/common/data/pom.xml index 92efc053a9..d011fcc5a7 100644 --- a/common/data/pom.xml +++ b/common/data/pom.xml @@ -87,10 +87,6 @@ org.thingsboard protobuf-dynamic - - org.eclipse.leshan - leshan-core - diff --git a/dao/pom.xml b/dao/pom.xml index 9ba3033970..cc25e1a13c 100644 --- a/dao/pom.xml +++ b/dao/pom.xml @@ -227,10 +227,6 @@ org.elasticsearch.client rest - - org.eclipse.leshan - leshan-core - diff --git a/dao/src/main/java/org/thingsboard/server/dao/resource/BaseTbResourceService.java b/dao/src/main/java/org/thingsboard/server/dao/resource/BaseResourceService.java similarity index 55% rename from dao/src/main/java/org/thingsboard/server/dao/resource/BaseTbResourceService.java rename to dao/src/main/java/org/thingsboard/server/dao/resource/BaseResourceService.java index 0df2f19e80..97675d4b3e 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/resource/BaseTbResourceService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/resource/BaseResourceService.java @@ -17,10 +17,6 @@ package org.thingsboard.server.dao.resource; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; -import org.eclipse.leshan.core.model.DDFFileParser; -import org.eclipse.leshan.core.model.DefaultDDFFileValidator; -import org.eclipse.leshan.core.model.InvalidDDFFileException; -import org.eclipse.leshan.core.model.ObjectModel; import org.hibernate.exception.ConstraintViolationException; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.ResourceType; @@ -29,9 +25,6 @@ import org.thingsboard.server.common.data.TbResourceInfo; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.id.TbResourceId; import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.data.lwm2m.LwM2mInstance; -import org.thingsboard.server.common.data.lwm2m.LwM2mObject; -import org.thingsboard.server.common.data.lwm2m.LwM2mResourceObserve; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.dao.exception.DataValidationException; @@ -41,63 +34,30 @@ import org.thingsboard.server.dao.service.PaginatedRemover; import org.thingsboard.server.dao.service.Validator; import org.thingsboard.server.dao.tenant.TenantDao; -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.util.ArrayList; -import java.util.Base64; -import java.util.Comparator; import java.util.List; import java.util.Optional; -import java.util.stream.Collectors; -import static org.thingsboard.server.common.data.lwm2m.LwM2mConstants.LWM2M_SEPARATOR_KEY; -import static org.thingsboard.server.common.data.lwm2m.LwM2mConstants.LWM2M_SEPARATOR_SEARCH_TEXT; import static org.thingsboard.server.dao.device.DeviceServiceImpl.INCORRECT_TENANT_ID; import static org.thingsboard.server.dao.service.Validator.validateId; @Service @Slf4j -public class BaseTbResourceService implements TbResourceService { +public class BaseResourceService implements ResourceService { public static final String INCORRECT_RESOURCE_ID = "Incorrect resourceId "; private final TbResourceDao resourceDao; private final TbResourceInfoDao resourceInfoDao; private final TenantDao tenantDao; - private final DDFFileParser ddfFileParser; - public BaseTbResourceService(TbResourceDao resourceDao, TbResourceInfoDao resourceInfoDao, TenantDao tenantDao) { + + public BaseResourceService(TbResourceDao resourceDao, TbResourceInfoDao resourceInfoDao, TenantDao tenantDao) { this.resourceDao = resourceDao; this.resourceInfoDao = resourceInfoDao; this.tenantDao = tenantDao; - this.ddfFileParser = new DDFFileParser(new DefaultDDFFileValidator()); } @Override - public TbResource saveResource(TbResource resource) throws InvalidDDFFileException, IOException { - log.trace("Executing saveResource [{}]", resource); - if (StringUtils.isEmpty(resource.getData())) { - throw new DataValidationException("Resource data should be specified!"); - } - if (ResourceType.LWM2M_MODEL.equals(resource.getResourceType())) { - List objectModels = - ddfFileParser.parseEx(new ByteArrayInputStream(Base64.getDecoder().decode(resource.getData())), resource.getSearchText()); - if (!objectModels.isEmpty()) { - ObjectModel objectModel = objectModels.get(0); - - String resourceKey = objectModel.id + LWM2M_SEPARATOR_KEY + objectModel.getVersion(); - String name = objectModel.name; - resource.setResourceKey(resourceKey); - if (resource.getId() == null) { - resource.setTitle(name + " id=" + objectModel.id + " v" + objectModel.getVersion()); - } - resource.setSearchText(resourceKey + LWM2M_SEPARATOR_SEARCH_TEXT + name); - } else { - throw new DataValidationException(String.format("Could not parse the XML of objectModel with name %s", resource.getSearchText())); - } - } else { - resource.setResourceKey(resource.getFileName()); - } - + public TbResource saveResource(TbResource resource) { resourceValidator.validate(resource, TbResourceInfo::getTenantId); try { @@ -111,7 +71,6 @@ public class BaseTbResourceService implements TbResourceService { throw t; } } - } @Override @@ -156,31 +115,17 @@ public class BaseTbResourceService implements TbResourceService { } @Override - public List findLwM2mObjectPage(TenantId tenantId, String sortProperty, String sortOrder, PageLink pageLink) { - log.trace("Executing findByTenantId [{}]", tenantId); + public List findTenantResourcesByResourceTypeAndObjectIds(TenantId tenantId, ResourceType resourceType, String[] objectIds) { + log.trace("Executing findTenantResourcesByResourceTypeAndObjectIds [{}][{}][{}]", tenantId, resourceType, objectIds); validateId(tenantId, INCORRECT_TENANT_ID + tenantId); - PageData resourcePageData = resourceDao.findResourcesByTenantIdAndResourceType( - tenantId, - ResourceType.LWM2M_MODEL, pageLink); - return resourcePageData.getData().stream() - .map(this::toLwM2mObject) - .sorted(getComparator(sortProperty, sortOrder)) - .collect(Collectors.toList()); + return resourceDao.findResourcesByTenantIdAndResourceType(tenantId, resourceType, objectIds, null); } @Override - public List findLwM2mObject(TenantId tenantId, String sortOrder, - String sortProperty, - String[] objectIds) { - log.trace("Executing findByTenantId [{}]", tenantId); + public PageData findTenantResourcesByResourceTypeAndPageLink(TenantId tenantId, ResourceType resourceType, PageLink pageLink) { + log.trace("Executing findTenantResourcesByResourceTypeAndPageLink [{}][{}][{}]", tenantId, resourceType, pageLink); validateId(tenantId, INCORRECT_TENANT_ID + tenantId); - List resources = resourceDao.findResourcesByTenantIdAndResourceType(tenantId, ResourceType.LWM2M_MODEL, - objectIds, - null); - return resources.stream() - .map(this::toLwM2mObject) - .sorted(getComparator(sortProperty, sortOrder)) - .collect(Collectors.toList()); + return resourceDao.findResourcesByTenantIdAndResourceType(tenantId, resourceType, pageLink); } @Override @@ -190,50 +135,6 @@ public class BaseTbResourceService implements TbResourceService { tenantResourcesRemover.removeEntities(tenantId, tenantId); } - private LwM2mObject toLwM2mObject(TbResource resource) { - try { - DDFFileParser ddfFileParser = new DDFFileParser(new DefaultDDFFileValidator()); - List objectModels = - ddfFileParser.parseEx(new ByteArrayInputStream(Base64.getDecoder().decode(resource.getData())), resource.getSearchText()); - if (objectModels.size() == 0) { - return null; - } else { - ObjectModel obj = objectModels.get(0); - LwM2mObject lwM2mObject = new LwM2mObject(); - lwM2mObject.setId(obj.id); - lwM2mObject.setKeyId(resource.getResourceKey()); - lwM2mObject.setName(obj.name); - lwM2mObject.setMultiple(obj.multiple); - lwM2mObject.setMandatory(obj.mandatory); - LwM2mInstance instance = new LwM2mInstance(); - instance.setId(0); - List resources = new ArrayList<>(); - obj.resources.forEach((k, v) -> { - if (!v.operations.isExecutable()) { - LwM2mResourceObserve lwM2MResourceObserve = new LwM2mResourceObserve(k, v.name, false, false, false); - resources.add(lwM2MResourceObserve); - } - }); - instance.setResources(resources.toArray(LwM2mResourceObserve[]::new)); - lwM2mObject.setInstances(new LwM2mInstance[]{instance}); - return lwM2mObject; - } - } catch (IOException | InvalidDDFFileException e) { - log.error("Could not parse the XML of objectModel with name [{}]", resource.getSearchText(), e); - return null; - } - } - - private Comparator getComparator(String sortProperty, String sortOrder) { - Comparator comparator; - if ("name".equals(sortProperty)) { - comparator = Comparator.comparing(LwM2mObject::getName); - } else { - comparator = Comparator.comparingLong(LwM2mObject::getId); - } - return "DESC".equals(sortOrder) ? comparator.reversed() : comparator; - } - private DataValidator resourceValidator = new DataValidator<>() { @Override @@ -259,9 +160,6 @@ public class BaseTbResourceService implements TbResourceService { throw new DataValidationException("Resource is referencing to non-existent tenant!"); } } - if (resource.getResourceType().equals(ResourceType.LWM2M_MODEL) && toLwM2mObject(resource) == null) { - throw new DataValidationException(String.format("Could not parse the XML of objectModel with name %s", resource.getSearchText())); - } } }; diff --git a/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java index 9a08c65fef..b514b43cf3 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/tenant/TenantServiceImpl.java @@ -35,7 +35,7 @@ import org.thingsboard.server.dao.device.DeviceService; import org.thingsboard.server.dao.entity.AbstractEntityService; import org.thingsboard.server.dao.entityview.EntityViewService; import org.thingsboard.server.dao.exception.DataValidationException; -import org.thingsboard.server.dao.resource.TbResourceService; +import org.thingsboard.server.dao.resource.ResourceService; import org.thingsboard.server.dao.rule.RuleChainService; import org.thingsboard.server.dao.service.DataValidator; import org.thingsboard.server.dao.service.PaginatedRemover; @@ -90,7 +90,7 @@ public class TenantServiceImpl extends AbstractEntityService implements TenantSe private RuleChainService ruleChainService; @Autowired - private TbResourceService resourceService; + private ResourceService resourceService; @Override public Tenant findTenantById(TenantId tenantId) { diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/AbstractServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/AbstractServiceTest.java index cfd232b330..0761f28d33 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/AbstractServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/AbstractServiceTest.java @@ -54,7 +54,7 @@ import org.thingsboard.server.dao.entity.EntityService; import org.thingsboard.server.dao.entityview.EntityViewService; import org.thingsboard.server.dao.event.EventService; import org.thingsboard.server.dao.relation.RelationService; -import org.thingsboard.server.dao.resource.TbResourceService; +import org.thingsboard.server.dao.resource.ResourceService; import org.thingsboard.server.dao.rule.RuleChainService; import org.thingsboard.server.dao.settings.AdminSettingsService; import org.thingsboard.server.dao.tenant.TenantProfileService; @@ -152,9 +152,9 @@ public abstract class AbstractServiceTest { protected DeviceProfileService deviceProfileService; @Autowired - protected TbResourceService resourceService; + protected ResourceService resourceService; - class IdComparator implements Comparator { + public class IdComparator implements Comparator { @Override public int compare(D o1, D o2) { return o1.getId().getId().compareTo(o2.getId().getId()); diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/sql/TbResourceServiceSqlTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/sql/TbResourceServiceSqlTest.java deleted file mode 100644 index 2af4881c84..0000000000 --- a/dao/src/test/java/org/thingsboard/server/dao/service/sql/TbResourceServiceSqlTest.java +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Copyright © 2016-2021 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.dao.service.sql; - -import org.thingsboard.server.dao.service.BaseTbResourceServiceTest; -import org.thingsboard.server.dao.service.DaoSqlTest; - -@DaoSqlTest -public class TbResourceServiceSqlTest extends BaseTbResourceServiceTest { -} From e50892516851144c1454be1fc36336ed9144a0d7 Mon Sep 17 00:00:00 2001 From: Andrii Shvaika Date: Fri, 16 Apr 2021 14:34:39 +0300 Subject: [PATCH 48/69] Fix for statistics collection period --- application/src/main/resources/thingsboard.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index c5e6c69ff5..5ba575a413 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -322,7 +322,7 @@ actors: # Enable/disable actor statistics enabled: "${ACTORS_STATISTICS_ENABLED:true}" js_print_interval_ms: "${ACTORS_JS_STATISTICS_PRINT_INTERVAL_MS:10000}" - persist_frequency: "${ACTORS_STATISTICS_PERSIST_FREQUENCY:10000}" + persist_frequency: "${ACTORS_STATISTICS_PERSIST_FREQUENCY:3600000}" cache: # caffeine or redis From 9b2009a50131bfd4cb4b36551d4158ac41ace56f Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Fri, 16 Apr 2021 14:59:35 +0300 Subject: [PATCH 49/69] UI: Refactoring dashboard logo --- .../dashboard-page.component.html | 34 +++++++----------- .../dashboard-page.component.scss | 12 +++---- .../dashboard-page.component.ts | 23 ++++-------- .../dashboard-settings-dialog.component.html | 17 ++++----- .../dashboard-settings-dialog.component.ts | 9 ++--- .../dashboard-toolbar.component.scss | 12 ------- .../dashboard-toolbar.component.ts | 2 -- .../src/app/shared/models/dashboard.models.ts | 4 +-- ui-ngx/src/assets/example_logo.png | Bin 34158 -> 0 bytes .../assets/locale/locale.constant-en_US.json | 5 ++- ui-ngx/src/assets/nasa.png | Bin 99886 -> 0 bytes 11 files changed, 39 insertions(+), 79 deletions(-) delete mode 100644 ui-ngx/src/assets/example_logo.png delete mode 100644 ui-ngx/src/assets/nasa.png diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.html b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.html index 1160acf808..7344a14310 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.html +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.html @@ -46,26 +46,18 @@ view_compact - -
- - - - - - -
+ + +
@@ -297,4 +289,4 @@ -
\ No newline at end of file + diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.scss b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.scss index ed795e129e..6ace69d312 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.scss +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.scss @@ -54,12 +54,9 @@ div.tb-dashboard-page { z-index: 13; pointer-events: none; - .db-logo-and-title { - height: $toolbar-height; - .whitelabel-logo-db{ - height: 100%; - width: auto; - } + .dashboard_logo{ + height: 75%; + margin-right: 16px; } &.tb-dashboard-toolbar-opened { @@ -71,9 +68,8 @@ div.tb-dashboard-page { right: 18px; transition: right .3s cubic-bezier(.55, 0, .55, .2) .2s; } - + } - } .tb-dashboard-container { &.tb-dashboard-toolbar-opened { &.is-fullscreen { diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts index 1b5f16dae6..78bc07c3d0 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts @@ -121,8 +121,6 @@ import { DisplayWidgetTypesPanelData, WidgetTypes } from '@home/components/dashboard-page/widget-types-panel.component'; -import { LogoComponent } from '@app/shared/components/logo.component'; -import { concat } from 'lodash'; import { DashboardWidgetSelectComponent } from '@home/components/dashboard-page/dashboard-widget-select.component'; // @dynamic @@ -192,9 +190,8 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC addingLayoutCtx: DashboardPageLayoutContext; - logo_name_local = 'example_logo.png'; - dashboard_logo = 'assets/' + this.logo_name_local; - + logo = 'assets/logo_title_white.svg'; + dashboardCtx: DashboardContext = { instanceId: this.utils.guid(), getDashboard: () => this.dashboard, @@ -490,15 +487,14 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC isDefined(this.dashboard.configuration.settings.showDashboardLogo)) { return this.dashboard.configuration.settings.showDashboardLogo; } else { - return true; + return false; } } - public updateLogoName(): string { - this.dashboard_logo = 'assets/' + this.dashboard.configuration.settings.logoName - return this.dashboard.configuration.settings.logoName; + public get dashboardLogo(): string { + return this.dashboard.configuration.settings.dashboardLogoUrl || this.logo; } - + public showRightLayoutSwitch(): boolean { return this.isMobile && this.layouts.right.show; } @@ -621,14 +617,9 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC settings: deepClone(this.dashboard.configuration.settings), gridSettings, } - } - ).afterClosed().subscribe((data) => { + }).afterClosed().subscribe((data) => { if (data) { this.dashboard.configuration.settings = data.settings; - if (this.dashboard.configuration.settings) - { - this.updateLogoName(); - } const newGridSettings = data.gridSettings; if (newGridSettings) { const layout = this.dashboard.configuration.states[layoutKeys.state].layouts[layoutKeys.layout]; diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.html b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.html index f745a8508d..3a20d146dd 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.html @@ -51,15 +51,9 @@ openOnInput formControlName="titleColor"> - - dashboard.display-dashboard-enterLogoName - - -
- - {{ 'dashboard.display-dashboard-showDashboardLogo' | translate }} - +
{{ 'dashboard.display-dashboards-selection' | translate }} @@ -76,6 +70,13 @@ {{ 'dashboard.display-dashboard-export' | translate }}
+ + {{ 'dashboard.display-dashboard-toolbar-logo' | translate }} + + +
, protected router: Router, @@ -73,7 +69,6 @@ export class DashboardSettingsDialogComponent extends DialogComponent div { - height: $toolbar-height; - .whitelabel-logo-db { - width: auto; - max-width: 100%; - height: 30px; - max-height: 100%; - margin-left: 20px; - margin-top: 5px; - margin-right: 30px; - } - } @media #{$mat-gt-sm} { height: $toolbar-height; diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-toolbar.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-toolbar.component.ts index 176375acb3..201f70787a 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-toolbar.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-toolbar.component.ts @@ -43,6 +43,4 @@ export class DashboardToolbarComponent implements OnInit { this.triggerClick.emit(); } - white_label_logo = 'assets/white_label_logo.png'; - } diff --git a/ui-ngx/src/app/shared/models/dashboard.models.ts b/ui-ngx/src/app/shared/models/dashboard.models.ts index 5e96c31ea0..6a90101923 100644 --- a/ui-ngx/src/app/shared/models/dashboard.models.ts +++ b/ui-ngx/src/app/shared/models/dashboard.models.ts @@ -86,8 +86,8 @@ export interface DashboardSettings { showDashboardsSelect?: boolean; showEntitiesSelect?: boolean; showFilters?: boolean; - showDashboardLogo?: boolean; - logoName?: string; + showDashboardLogo?: boolean; + dashboardLogoUrl?: string; showDashboardTimewindow?: boolean; showDashboardExport?: boolean; toolbarAlwaysOpen?: boolean; diff --git a/ui-ngx/src/assets/example_logo.png b/ui-ngx/src/assets/example_logo.png deleted file mode 100644 index 44cd0245616ead898328fe8c2549c8268d3d4a7a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 34158 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 diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index cd3741bca8..1f7f733dac 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -725,9 +725,8 @@ "display-filters": "Display filters", "display-dashboard-timewindow": "Display timewindow", "display-dashboard-export": "Display export", - "display-dashboard-showDashboardLogo": "Display dashboard logo", - "display-dashboard-enterLogoName": "Enter logo filename (must be in assets folder)", - "display-dashboard-exampleLogoName": "example_logo.png", + "display-dashboard-toolbar-logo": "Display dashboard logo in fullscreen mode", + "dashboard-logo-image": "Dashboard image logo", "import": "Import dashboard", "export": "Export dashboard", "export-failed-error": "Unable to export dashboard: {{error}}", diff --git a/ui-ngx/src/assets/nasa.png b/ui-ngx/src/assets/nasa.png deleted file mode 100644 index 027407a775edb3c513c78c587ea24c61d57c9192..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 99886 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 z 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 JiR{Cm{{iIW1R?+c From a4347776adeb084323e4ba7e33f685dc6ba94679 Mon Sep 17 00:00:00 2001 From: Vladyslav_Prykhodko Date: Fri, 16 Apr 2021 15:40:44 +0300 Subject: [PATCH 50/69] UI: Refactoring dashboard logo translation --- .../dashboard-page/dashboard-settings-dialog.component.html | 2 +- ui-ngx/src/assets/locale/locale.constant-en_US.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.html b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.html index 3a20d146dd..244045314b 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.html @@ -71,7 +71,7 @@

- {{ 'dashboard.display-dashboard-toolbar-logo' | translate }} + {{ 'dashboard.display-dashboard-logo' | translate }} Date: Fri, 16 Apr 2021 17:58:49 +0300 Subject: [PATCH 51/69] Fix show dashboard logo in fullscreen mode --- .../components/dashboard-page/dashboard-page.component.html | 2 +- .../home/components/dashboard-page/dashboard-page.component.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.html b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.html index 7344a14310..3f03ea4fff 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.html +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.html @@ -56,7 +56,7 @@ [syncStateWithQueryParam]="syncStateWithQueryParam" [states]="dashboardConfiguration.states"> -
Date: Wed, 31 Mar 2021 22:01:08 +0300 Subject: [PATCH 52/69] queue consumer: implemented nonblocking subscribe method to prevent locks on repartition event. moved message decoding out of the lock to reduce locking time. using monotonic time instead wall-clock. added debug messages --- .../AbstractTbQueueConsumerTemplate.java | 140 ++++++++++-------- .../queue/kafka/TbKafkaConsumerTemplate.java | 3 + 2 files changed, 85 insertions(+), 58 deletions(-) diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/common/AbstractTbQueueConsumerTemplate.java b/common/queue/src/main/java/org/thingsboard/server/queue/common/AbstractTbQueueConsumerTemplate.java index 6bd2a69b2b..43f7321120 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/common/AbstractTbQueueConsumerTemplate.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/common/AbstractTbQueueConsumerTemplate.java @@ -21,22 +21,28 @@ import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; import org.thingsboard.server.queue.TbQueueConsumer; import org.thingsboard.server.queue.TbQueueMsg; +import javax.annotation.Nonnull; import java.io.IOException; import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Queue; import java.util.Set; -import java.util.concurrent.locks.Lock; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.ReentrantLock; import java.util.stream.Collectors; +import static java.util.Collections.emptyList; + @Slf4j public abstract class AbstractTbQueueConsumerTemplate implements TbQueueConsumer { private volatile boolean subscribed; protected volatile boolean stopped = false; protected volatile Set partitions; - protected final Lock consumerLock = new ReentrantLock(); + protected final ReentrantLock consumerLock = new ReentrantLock(); //NonfairSync + final Queue> subscribeQueue = new ConcurrentLinkedQueue<>(); @Getter private final String topic; @@ -47,84 +53,101 @@ public abstract class AbstractTbQueueConsumerTemplate i @Override public void subscribe() { - consumerLock.lock(); - try { - partitions = Collections.singleton(new TopicPartitionInfo(topic, null, null, true)); - subscribed = false; - } finally { - consumerLock.unlock(); + log.info("enqueue topic subscribe {} ", topic); + if (stopped) { + log.error("trying subscribe, but consumer stopped for topic {}", topic); + return; } + subscribeQueue.add(Collections.singleton(new TopicPartitionInfo(topic, null, null, true))); } @Override public void subscribe(Set partitions) { - consumerLock.lock(); - try { - this.partitions = partitions; - subscribed = false; - } finally { - consumerLock.unlock(); + log.info("enqueue topics subscribe {} ", partitions); + if (stopped) { + log.error("trying subscribe, but consumer stopped for topic {}", topic); + return; } + subscribeQueue.add(partitions); } @Override public List poll(long durationInMillis) { + List records; + long startNanos = System.nanoTime(); + if (stopped) { + return errorAndReturnEmpty(); + } if (!subscribed && partitions == null) { - try { - Thread.sleep(durationInMillis); - } catch (InterruptedException e) { - log.debug("Failed to await subscription", e); + return sleepAndReturnEmpty(startNanos, durationInMillis); + } + + if (consumerLock.isLocked()) { + log.error("poll. consumerLock is locked. will wait with no timeout. it looks like a race conditions or deadlock", new RuntimeException("stacktrace")); + } + + consumerLock.lock(); + try { + while (!subscribeQueue.isEmpty()) { + subscribed = false; + partitions = subscribeQueue.poll(); + } + if (!subscribed) { + List topicNames = partitions.stream().map(TopicPartitionInfo::getFullTopicName).collect(Collectors.toList()); + doSubscribe(topicNames); + subscribed = true; } - } else { - long pollStartTs = System.currentTimeMillis(); - consumerLock.lock(); + records = partitions.isEmpty() ? emptyList() : doPoll(durationInMillis); + } finally { + consumerLock.unlock(); + } + + if (records.isEmpty()) { return sleepAndReturnEmpty(startNanos, durationInMillis); } + + return decodeRecords(records); + } + + @Nonnull + List decodeRecords(@Nonnull List records) { + List result = new ArrayList<>(records.size()); + records.forEach(record -> { try { - if (!subscribed) { - List topicNames = partitions.stream().map(TopicPartitionInfo::getFullTopicName).collect(Collectors.toList()); - doSubscribe(topicNames); - subscribed = true; + if (record != null) { + result.add(decode(record)); } + } catch (IOException e) { + log.error("Failed decode record: [{}]", record); + throw new RuntimeException("Failed to decode record: ", e); + } + }); + return result; + } - List records; - if (partitions.isEmpty()) { - records = Collections.emptyList(); - } else { - records = doPoll(durationInMillis); - } - if (!records.isEmpty()) { - List result = new ArrayList<>(records.size()); - records.forEach(record -> { - try { - if (record != null) { - result.add(decode(record)); - } - } catch (IOException e) { - log.error("Failed decode record: [{}]", record); - throw new RuntimeException("Failed to decode record: ", e); - } - }); - return result; - } else { - long pollDuration = System.currentTimeMillis() - pollStartTs; - if (pollDuration < durationInMillis) { - try { - Thread.sleep(durationInMillis - pollDuration); - } catch (InterruptedException e) { - if (!stopped) { - log.error("Failed to wait.", e); - } - } - } + List errorAndReturnEmpty() { + log.error("poll invoked but consumer stopped for topic" + topic, new RuntimeException("stacktrace")); + return emptyList(); + } + + List sleepAndReturnEmpty(final long startNanos, final long durationInMillis) { + long durationNanos = TimeUnit.MILLISECONDS.toNanos(durationInMillis); + long spentNanos = System.nanoTime() - startNanos; + if (spentNanos < durationNanos) { + try { + Thread.sleep(Math.max(TimeUnit.NANOSECONDS.toMillis(durationNanos - spentNanos), 1)); + } catch (InterruptedException e) { + if (!stopped) { + log.error("Failed to wait", e); } - } finally { - consumerLock.unlock(); } } - return Collections.emptyList(); + return emptyList(); } @Override public void commit() { + if (consumerLock.isLocked()) { + log.error("commit. consumerLock is locked. will wait with no timeout. it looks like a race conditions or deadlock", new RuntimeException("stacktrace")); + } consumerLock.lock(); try { doCommit(); @@ -135,6 +158,7 @@ public abstract class AbstractTbQueueConsumerTemplate i @Override public void unsubscribe() { + log.info("unsubscribe topic and stop consumer {}", getTopic()); stopped = true; consumerLock.lock(); try { diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaConsumerTemplate.java b/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaConsumerTemplate.java index b1b3d5ce05..4429f3518f 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaConsumerTemplate.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/kafka/TbKafkaConsumerTemplate.java @@ -72,8 +72,10 @@ public class TbKafkaConsumerTemplate extends AbstractTbQue protected void doSubscribe(List topicNames) { if (!topicNames.isEmpty()) { topicNames.forEach(admin::createTopicIfNotExists); + log.info("subscribe topics {}", topicNames); consumer.subscribe(topicNames); } else { + log.info("unsubscribe due to empty topic list"); consumer.unsubscribe(); } } @@ -102,6 +104,7 @@ public class TbKafkaConsumerTemplate extends AbstractTbQue @Override protected void doUnsubscribe() { + log.info("unsubscribe topic and close consumer for topic {}", getTopic()); if (consumer != null) { consumer.unsubscribe(); consumer.close(); From e64b1074a794f348df5d6149c4685001289c8e47 Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Wed, 31 Mar 2021 22:15:00 +0300 Subject: [PATCH 53/69] queue consumer: added queue check on the very first poll in the lifecycle before any subscribe method calls --- .../server/queue/common/AbstractTbQueueConsumerTemplate.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/common/AbstractTbQueueConsumerTemplate.java b/common/queue/src/main/java/org/thingsboard/server/queue/common/AbstractTbQueueConsumerTemplate.java index 43f7321120..4481e52461 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/common/AbstractTbQueueConsumerTemplate.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/common/AbstractTbQueueConsumerTemplate.java @@ -78,7 +78,7 @@ public abstract class AbstractTbQueueConsumerTemplate i if (stopped) { return errorAndReturnEmpty(); } - if (!subscribed && partitions == null) { + if (!subscribed && partitions == null && subscribeQueue.isEmpty()) { return sleepAndReturnEmpty(startNanos, durationInMillis); } From 562637ad340d2f643e149528035f4c7b6a584101 Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Thu, 1 Apr 2021 11:54:39 +0300 Subject: [PATCH 54/69] ZKDiscoveryService: fix potential outdated server info on recalculatePartitions. info logs added on application event --- .../server/queue/discovery/ZkDiscoveryService.java | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/discovery/ZkDiscoveryService.java b/common/queue/src/main/java/org/thingsboard/server/queue/discovery/ZkDiscoveryService.java index 5253f57248..7e7f280cc9 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/discovery/ZkDiscoveryService.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/discovery/ZkDiscoveryService.java @@ -125,8 +125,10 @@ public class ZkDiscoveryService implements DiscoveryService, PathChildrenCacheLi log.debug("Ignoring application ready event, ZK client is not started, ZK client state [{}]", client.getState()); return; } + log.info("Going to publish current server..."); publishCurrentServer(); - partitionService.recalculatePartitions(serviceInfoProvider.getServiceInfo(), getOtherServers()); + log.info("Going to recalculate partitions..."); + recalculatePartitions(); } public synchronized void publishCurrentServer() { @@ -281,11 +283,19 @@ public class ZkDiscoveryService implements DiscoveryService, PathChildrenCacheLi case CHILD_ADDED: case CHILD_UPDATED: case CHILD_REMOVED: - partitionService.recalculatePartitions(serviceInfoProvider.getServiceInfo(), getOtherServers()); + recalculatePartitions(); break; default: break; } } + /** + * A single entry point to recalculate partitions + * Synchronized to ensure that other servers info is up to date + * */ + synchronized void recalculatePartitions() { + partitionService.recalculatePartitions(serviceInfoProvider.getServiceInfo(), getOtherServers()); + } + } From 08d9ea302f520c5c941bc096909f7365ccc1c87c Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Fri, 16 Apr 2021 16:33:26 +0300 Subject: [PATCH 55/69] logback: logger template added for Top Rule Nodes by max execution time --- application/src/main/resources/logback.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/application/src/main/resources/logback.xml b/application/src/main/resources/logback.xml index f8085ec985..caf78e1537 100644 --- a/application/src/main/resources/logback.xml +++ b/application/src/main/resources/logback.xml @@ -37,6 +37,9 @@ + + + From 3228312cdb66bbbe35ca1494de83bae384bcbc13 Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Mon, 12 Apr 2021 16:48:20 +0300 Subject: [PATCH 56/69] alarms count: performance fix based on pg_stat analysis. Counters queries refactored as two subqueries: inner join (count with relations) + left 'anti' join (count without relations). AlarmServiceSqlTest passed. CE --- .../server/dao/sql/alarm/AlarmRepository.java | 36 ++++++++++++++----- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/alarm/AlarmRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/alarm/AlarmRepository.java index b422f1cd4a..446125e4fb 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/alarm/AlarmRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/alarm/AlarmRepository.java @@ -50,23 +50,41 @@ public interface AlarmRepository extends CrudRepository { "AND (:startTime IS NULL OR a.createdTime >= :startTime) " + "AND (:endTime IS NULL OR a.createdTime <= :endTime) " + "AND ((:alarmStatuses) IS NULL OR a.status in (:alarmStatuses)) " + - "AND (LOWER(a.type) LIKE LOWER(CONCAT(:searchText, '%'))" + - "OR LOWER(a.severity) LIKE LOWER(CONCAT(:searchText, '%'))" + - "OR LOWER(a.status) LIKE LOWER(CONCAT(:searchText, '%')))", - countQuery = "SELECT count(a) FROM AlarmEntity a " + - "LEFT JOIN RelationEntity re ON a.id = re.toId " + + "AND (LOWER(a.type) LIKE LOWER(CONCAT(:searchText, '%')) " + + " OR LOWER(a.severity) LIKE LOWER(CONCAT(:searchText, '%')) " + + " OR LOWER(a.status) LIKE LOWER(CONCAT(:searchText, '%'))) " + , + countQuery = "" + + "SELECT count(a) + " + //alarms with relations only + " (SELECT count(a) FROM AlarmEntity a " + //alarms WITHOUT any relations + " LEFT JOIN RelationEntity re ON a.id = re.toId " + + " AND re.relationTypeGroup = 'ALARM' " + + " AND re.toType = 'ALARM' " + + " AND re.fromId = :affectedEntityId " + + " AND re.fromType = :affectedEntityType " + + " WHERE a.tenantId = :tenantId " + + " AND (a.originatorId = :affectedEntityId) " + + " AND (re.fromId IS NULL) " + //anti join + " AND (:startTime IS NULL OR a.createdTime >= :startTime) " + + " AND (:endTime IS NULL OR a.createdTime <= :endTime) " + + " AND ((:alarmStatuses) IS NULL OR a.status in (:alarmStatuses)) " + + " AND (LOWER(a.type) LIKE LOWER(CONCAT(:searchText, '%')) " + + " OR LOWER(a.severity) LIKE LOWER(CONCAT(:searchText, '%')) " + + " OR LOWER(a.status) LIKE LOWER(CONCAT(:searchText, '%'))) " + + " )" + + "FROM AlarmEntity a " + + "INNER JOIN RelationEntity re ON a.id = re.toId " + "AND re.relationTypeGroup = 'ALARM' " + "AND re.toType = 'ALARM' " + "AND re.fromId = :affectedEntityId " + "AND re.fromType = :affectedEntityType " + "WHERE a.tenantId = :tenantId " + - "AND (a.originatorId = :affectedEntityId or re.fromId IS NOT NULL) " + "AND (:startTime IS NULL OR a.createdTime >= :startTime) " + "AND (:endTime IS NULL OR a.createdTime <= :endTime) " + "AND ((:alarmStatuses) IS NULL OR a.status in (:alarmStatuses)) " + - "AND (LOWER(a.type) LIKE LOWER(CONCAT(:searchText, '%'))" + - "OR LOWER(a.severity) LIKE LOWER(CONCAT(:searchText, '%'))" + - "OR LOWER(a.status) LIKE LOWER(CONCAT(:searchText, '%')))") + "AND (LOWER(a.type) LIKE LOWER(CONCAT(:searchText, '%')) " + + " OR LOWER(a.severity) LIKE LOWER(CONCAT(:searchText, '%')) " + + " OR LOWER(a.status) LIKE LOWER(CONCAT(:searchText, '%'))) ") Page findAlarms(@Param("tenantId") UUID tenantId, @Param("affectedEntityId") UUID affectedEntityId, @Param("affectedEntityType") String affectedEntityType, From b8291be084bc2b843f61842194c82ff90d157239 Mon Sep 17 00:00:00 2001 From: Valerii Sosliuk Date: Fri, 16 Apr 2021 17:52:38 +0300 Subject: [PATCH 57/69] Update Latest TS by timestamp There is a bug when the historic data arrives and overrides the value with morerecent timestamp in ts_kv_latest table. This PR adds possibility to update the ts_kv_latest table only if the value that arrives has the newer timestamp than the one that is already in ts_kv_latest --- .../src/main/resources/thingsboard.yml | 1 + .../psql/PsqlLatestInsertTsRepository.java | 27 ++++++++++++++++--- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 5ba575a413..a9234a0abb 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -243,6 +243,7 @@ sql: batch_max_delay: "${SQL_TS_LATEST_BATCH_MAX_DELAY_MS:100}" stats_print_interval_ms: "${SQL_TS_LATEST_BATCH_STATS_PRINT_MS:10000}" batch_threads: "${SQL_TS_LATEST_BATCH_THREADS:4}" + update_by_latest_ts: "${SQL_TS_UPDATE_BY_LATEST_TIMESTAMP:true}" # Specify whether to sort entities before batch update. Should be enabled for cluster mode to avoid deadlocks batch_sort: "${SQL_BATCH_SORT:false}" # Specify whether to remove null characters from strValue of attributes and timeseries before insert diff --git a/dao/src/main/java/org/thingsboard/server/dao/sqlts/insert/latest/psql/PsqlLatestInsertTsRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sqlts/insert/latest/psql/PsqlLatestInsertTsRepository.java index 7a056f4872..d97bc1c051 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sqlts/insert/latest/psql/PsqlLatestInsertTsRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sqlts/insert/latest/psql/PsqlLatestInsertTsRepository.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao.sqlts.insert.latest.psql; +import org.springframework.beans.factory.annotation.Value; import org.springframework.jdbc.core.BatchPreparedStatementSetter; import org.springframework.stereotype.Repository; import org.springframework.transaction.TransactionStatus; @@ -37,20 +38,34 @@ import java.util.List; @Transactional public class PsqlLatestInsertTsRepository extends AbstractInsertRepository implements InsertLatestTsRepository { + @Value("${sql.ts_latest.update_by_latest_ts:true}") + private Boolean updateByLatestTs; + private static final String BATCH_UPDATE = - "UPDATE ts_kv_latest SET ts = ?, bool_v = ?, str_v = ?, long_v = ?, dbl_v = ?, json_v = cast(? AS json) WHERE entity_id = ? and key = ?"; + "UPDATE ts_kv_latest SET ts = ?, bool_v = ?, str_v = ?, long_v = ?, dbl_v = ?, json_v = cast(? AS json) WHERE entity_id = ? AND key = ?;"; private static final String INSERT_OR_UPDATE = "INSERT INTO ts_kv_latest (entity_id, key, ts, bool_v, str_v, long_v, dbl_v, json_v) VALUES(?, ?, ?, ?, ?, ?, ?, cast(? AS json)) " + "ON CONFLICT (entity_id, key) DO UPDATE SET ts = ?, bool_v = ?, str_v = ?, long_v = ?, dbl_v = ?, json_v = cast(? AS json);"; + private static final String BATCH_UPDATE_BY_LATEST_TS = + "UPDATE ts_kv_latest SET ts = ?, bool_v = ?, str_v = ?, long_v = ?, dbl_v = ?, json_v = cast(? AS json) WHERE entity_id = ? AND key = ? AND ts <= ?;"; + + + private static final String INSERT_OR_UPDATE_BY_LATEST_TS = + "INSERT INTO ts_kv_latest (entity_id, key, ts, bool_v, str_v, long_v, dbl_v, json_v) VALUES(?, ?, ?, ?, ?, ?, ?, cast(? AS json)) " + + "ON CONFLICT (entity_id, key) DO UPDATE SET ts = ?, bool_v = ?, str_v = ?, long_v = ?, dbl_v = ?, json_v = cast(? AS json) WHERE ts_kv_latest.ts <= ?;"; + @Override public void saveOrUpdate(List entities) { transactionTemplate.execute(new TransactionCallbackWithoutResult() { @Override protected void doInTransactionWithoutResult(TransactionStatus status) { - int[] result = jdbcTemplate.batchUpdate(BATCH_UPDATE, new BatchPreparedStatementSetter() { + String batchUpdateQuery = updateByLatestTs ? BATCH_UPDATE_BY_LATEST_TS : BATCH_UPDATE; + String insertOrUpdateQuery = updateByLatestTs ? INSERT_OR_UPDATE_BY_LATEST_TS : INSERT_OR_UPDATE; + + int[] result = jdbcTemplate.batchUpdate(batchUpdateQuery, new BatchPreparedStatementSetter() { @Override public void setValues(PreparedStatement ps, int i) throws SQLException { TsKvLatestEntity tsKvLatestEntity = entities.get(i); @@ -80,6 +95,9 @@ public class PsqlLatestInsertTsRepository extends AbstractInsertRepository imple ps.setObject(7, tsKvLatestEntity.getEntityId()); ps.setInt(8, tsKvLatestEntity.getKey()); + if (updateByLatestTs) { + ps.setLong(9, tsKvLatestEntity.getTs()); + } } @Override @@ -102,7 +120,7 @@ public class PsqlLatestInsertTsRepository extends AbstractInsertRepository imple } } - jdbcTemplate.batchUpdate(INSERT_OR_UPDATE, new BatchPreparedStatementSetter() { + jdbcTemplate.batchUpdate(insertOrUpdateQuery, new BatchPreparedStatementSetter() { @Override public void setValues(PreparedStatement ps, int i) throws SQLException { TsKvLatestEntity tsKvLatestEntity = insertEntities.get(i); @@ -111,6 +129,9 @@ public class PsqlLatestInsertTsRepository extends AbstractInsertRepository imple ps.setLong(3, tsKvLatestEntity.getTs()); ps.setLong(9, tsKvLatestEntity.getTs()); + if (updateByLatestTs) { + ps.setLong(15, tsKvLatestEntity.getTs()); + } if (tsKvLatestEntity.getBooleanValue() != null) { ps.setBoolean(4, tsKvLatestEntity.getBooleanValue()); From e7fd07652e085dc9bfcd945bc9a7a2404aeb41fc Mon Sep 17 00:00:00 2001 From: Andrii Shvaika Date: Mon, 19 Apr 2021 13:16:55 +0300 Subject: [PATCH 58/69] Removed code duplicates and fixed one minor issue --- .../latest/psql/PsqlLatestInsertTsRepository.java | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/sqlts/insert/latest/psql/PsqlLatestInsertTsRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sqlts/insert/latest/psql/PsqlLatestInsertTsRepository.java index d97bc1c051..5b18fde202 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sqlts/insert/latest/psql/PsqlLatestInsertTsRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sqlts/insert/latest/psql/PsqlLatestInsertTsRepository.java @@ -42,20 +42,15 @@ public class PsqlLatestInsertTsRepository extends AbstractInsertRepository imple private Boolean updateByLatestTs; private static final String BATCH_UPDATE = - "UPDATE ts_kv_latest SET ts = ?, bool_v = ?, str_v = ?, long_v = ?, dbl_v = ?, json_v = cast(? AS json) WHERE entity_id = ? AND key = ?;"; - + "UPDATE ts_kv_latest SET ts = ?, bool_v = ?, str_v = ?, long_v = ?, dbl_v = ?, json_v = cast(? AS json) WHERE entity_id = ? AND key = ?"; private static final String INSERT_OR_UPDATE = "INSERT INTO ts_kv_latest (entity_id, key, ts, bool_v, str_v, long_v, dbl_v, json_v) VALUES(?, ?, ?, ?, ?, ?, ?, cast(? AS json)) " + - "ON CONFLICT (entity_id, key) DO UPDATE SET ts = ?, bool_v = ?, str_v = ?, long_v = ?, dbl_v = ?, json_v = cast(? AS json);"; - - private static final String BATCH_UPDATE_BY_LATEST_TS = - "UPDATE ts_kv_latest SET ts = ?, bool_v = ?, str_v = ?, long_v = ?, dbl_v = ?, json_v = cast(? AS json) WHERE entity_id = ? AND key = ? AND ts <= ?;"; + "ON CONFLICT (entity_id, key) DO UPDATE SET ts = ?, bool_v = ?, str_v = ?, long_v = ?, dbl_v = ?, json_v = cast(? AS json)"; + private static final String BATCH_UPDATE_BY_LATEST_TS = BATCH_UPDATE + " AND ts_kv_latest.ts <= ?"; - private static final String INSERT_OR_UPDATE_BY_LATEST_TS = - "INSERT INTO ts_kv_latest (entity_id, key, ts, bool_v, str_v, long_v, dbl_v, json_v) VALUES(?, ?, ?, ?, ?, ?, ?, cast(? AS json)) " + - "ON CONFLICT (entity_id, key) DO UPDATE SET ts = ?, bool_v = ?, str_v = ?, long_v = ?, dbl_v = ?, json_v = cast(? AS json) WHERE ts_kv_latest.ts <= ?;"; + private static final String INSERT_OR_UPDATE_BY_LATEST_TS = INSERT_OR_UPDATE + " WHERE ts_kv_latest.ts <= ?"; @Override public void saveOrUpdate(List entities) { From 27788790df30fc349d34073614f5b05ff01ae5ca Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Tue, 16 Mar 2021 18:15:04 +0200 Subject: [PATCH 59/69] dependency upgrade: grpc.version up to 1.36.0. explicitly added protobuf-java-util due to grpc not depends on it anymore --- common/queue/pom.xml | 4 ++++ pom.xml | 7 ++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/common/queue/pom.xml b/common/queue/pom.xml index e0b30dd654..31ec44e8f7 100644 --- a/common/queue/pom.xml +++ b/common/queue/pom.xml @@ -112,6 +112,10 @@ com.google.protobuf protobuf-java + + com.google.protobuf + protobuf-java-util + org.apache.curator curator-recipes diff --git a/pom.xml b/pom.xml index d954128e96..a44ade17de 100755 --- a/pom.xml +++ b/pom.xml @@ -77,7 +77,7 @@ 4.2.0 3.5.5 3.11.4 - 1.22.1 + 1.36.0 1.18.18 1.2.4 4.1.60.Final @@ -1301,6 +1301,11 @@ protobuf-java ${protobuf.version} + + com.google.protobuf + protobuf-java-util + ${protobuf.version} + io.grpc grpc-netty From 8b653d7065271c2392a7a5fcd648fc9be06064ae Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Thu, 11 Mar 2021 13:18:58 +0200 Subject: [PATCH 60/69] rule chain. implemented save/update/delete @Transactional. Added sync DAO methods to run in transaction. Moved loadRuleChainMetaData outside of saveRuleChainMetaData transaction --- .../controller/RuleChainController.java | 3 +- .../server/dao/rule/RuleChainService.java | 2 +- .../dao/relation/BaseRelationService.java | 50 ++++++++++++----- .../server/dao/relation/RelationDao.java | 8 ++- .../server/dao/rule/BaseRuleChainService.java | 54 ++++++++----------- .../dao/sql/relation/JpaRelationDao.java | 22 +++++--- .../dao/service/BaseRuleChainServiceTest.java | 6 ++- 7 files changed, 90 insertions(+), 55 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/RuleChainController.java b/application/src/main/java/org/thingsboard/server/controller/RuleChainController.java index 85ec0950e3..d5fcc2706f 100644 --- a/application/src/main/java/org/thingsboard/server/controller/RuleChainController.java +++ b/application/src/main/java/org/thingsboard/server/controller/RuleChainController.java @@ -244,7 +244,8 @@ public class RuleChainController extends BaseController { } RuleChain ruleChain = checkRuleChain(ruleChainMetaData.getRuleChainId(), Operation.WRITE); - RuleChainMetaData savedRuleChainMetaData = checkNotNull(ruleChainService.saveRuleChainMetaData(tenantId, ruleChainMetaData)); + checkNotNull(ruleChainService.saveRuleChainMetaData(tenantId, ruleChainMetaData) ? true : null); + RuleChainMetaData savedRuleChainMetaData = checkNotNull(ruleChainService.loadRuleChainMetaData(tenantId, ruleChainMetaData.getRuleChainId())); if (RuleChainType.CORE.equals(ruleChain.getType())) { tbClusterService.onEntityStateChange(ruleChain.getTenantId(), ruleChain.getId(), ComponentLifecycleEvent.UPDATED); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/rule/RuleChainService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/rule/RuleChainService.java index 086d954e27..5740abb354 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/rule/RuleChainService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/rule/RuleChainService.java @@ -43,7 +43,7 @@ public interface RuleChainService { boolean setRootRuleChain(TenantId tenantId, RuleChainId ruleChainId); - RuleChainMetaData saveRuleChainMetaData(TenantId tenantId, RuleChainMetaData ruleChainMetaData); + boolean saveRuleChainMetaData(TenantId tenantId, RuleChainMetaData ruleChainMetaData); RuleChainMetaData loadRuleChainMetaData(TenantId tenantId, RuleChainId ruleChainId); diff --git a/dao/src/main/java/org/thingsboard/server/dao/relation/BaseRelationService.java b/dao/src/main/java/org/thingsboard/server/dao/relation/BaseRelationService.java index 44b328fd9f..15ddb9a390 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/relation/BaseRelationService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/relation/BaseRelationService.java @@ -179,11 +179,28 @@ public class BaseRelationService implements RelationService { @Override public void deleteEntityRelations(TenantId tenantId, EntityId entityId) { - try { - deleteEntityRelationsAsync(tenantId, entityId).get(); - } catch (InterruptedException | ExecutionException e) { - throw new RuntimeException(e); + log.trace("Executing deleteEntityRelations [{}]", entityId); + validate(entityId); + final Cache cache = cacheManager.getCache(RELATIONS_CACHE); + List inboundRelations = new ArrayList<>(); + for (RelationTypeGroup typeGroup : RelationTypeGroup.values()) { + inboundRelations.addAll(relationDao.findAllByTo(tenantId, entityId, typeGroup)); + } + + List outboundRelations = new ArrayList<>(); + for (RelationTypeGroup typeGroup : RelationTypeGroup.values()) { + outboundRelations.addAll(relationDao.findAllByFrom(tenantId, entityId, typeGroup)); + } + + for (EntityRelation relation : inboundRelations){ + delete(tenantId, cache, relation, true); } + + for (EntityRelation relation : outboundRelations){ + delete(tenantId, cache, relation, false); + } + + relationDao.deleteOutboundRelations(tenantId, entityId); } @Override @@ -193,14 +210,14 @@ public class BaseRelationService implements RelationService { validate(entityId); List>> inboundRelationsList = new ArrayList<>(); for (RelationTypeGroup typeGroup : RelationTypeGroup.values()) { - inboundRelationsList.add(relationDao.findAllByTo(tenantId, entityId, typeGroup)); + inboundRelationsList.add(relationDao.findAllByToAsync(tenantId, entityId, typeGroup)); } ListenableFuture>> inboundRelations = Futures.allAsList(inboundRelationsList); List>> outboundRelationsList = new ArrayList<>(); for (RelationTypeGroup typeGroup : RelationTypeGroup.values()) { - outboundRelationsList.add(relationDao.findAllByFrom(tenantId, entityId, typeGroup)); + outboundRelationsList.add(relationDao.findAllByFromAsync(tenantId, entityId, typeGroup)); } ListenableFuture>> outboundRelations = Futures.allAsList(outboundRelationsList); @@ -242,6 +259,15 @@ public class BaseRelationService implements RelationService { } } + boolean delete(TenantId tenantId, Cache cache, EntityRelation relation, boolean deleteFromDb) { + cacheEviction(relation, cache); + if (deleteFromDb) { + return relationDao.deleteRelation(tenantId, relation); + } else { + return false; + } + } + private void cacheEviction(EntityRelation relation, Cache cache) { List fromToTypeAndTypeGroup = new ArrayList<>(); fromToTypeAndTypeGroup.add(relation.getFrom()); @@ -283,7 +309,7 @@ public class BaseRelationService implements RelationService { validate(from); validateTypeGroup(typeGroup); try { - return relationDao.findAllByFrom(tenantId, from, typeGroup).get(); + return relationDao.findAllByFromAsync(tenantId, from, typeGroup).get(); } catch (InterruptedException | ExecutionException e) { throw new RuntimeException(e); } @@ -306,7 +332,7 @@ public class BaseRelationService implements RelationService { if (fromCache != null) { return Futures.immediateFuture(fromCache); } else { - ListenableFuture> relationsFuture = relationDao.findAllByFrom(tenantId, from, typeGroup); + ListenableFuture> relationsFuture = relationDao.findAllByFromAsync(tenantId, from, typeGroup); Futures.addCallback(relationsFuture, new FutureCallback>() { @Override @@ -327,7 +353,7 @@ public class BaseRelationService implements RelationService { log.trace("Executing findInfoByFrom [{}][{}]", from, typeGroup); validate(from); validateTypeGroup(typeGroup); - ListenableFuture> relations = relationDao.findAllByFrom(tenantId, from, typeGroup); + ListenableFuture> relations = relationDao.findAllByFromAsync(tenantId, from, typeGroup); return Futures.transformAsync(relations, relations1 -> { List> futures = new ArrayList<>(); @@ -365,7 +391,7 @@ public class BaseRelationService implements RelationService { validate(to); validateTypeGroup(typeGroup); try { - return relationDao.findAllByTo(tenantId, to, typeGroup).get(); + return relationDao.findAllByToAsync(tenantId, to, typeGroup).get(); } catch (InterruptedException | ExecutionException e) { throw new RuntimeException(e); } @@ -388,7 +414,7 @@ public class BaseRelationService implements RelationService { if (fromCache != null) { return Futures.immediateFuture(fromCache); } else { - ListenableFuture> relationsFuture = relationDao.findAllByTo(tenantId, to, typeGroup); + ListenableFuture> relationsFuture = relationDao.findAllByToAsync(tenantId, to, typeGroup); Futures.addCallback(relationsFuture, new FutureCallback>() { @Override @@ -409,7 +435,7 @@ public class BaseRelationService implements RelationService { log.trace("Executing findInfoByTo [{}][{}]", to, typeGroup); validate(to); validateTypeGroup(typeGroup); - ListenableFuture> relations = relationDao.findAllByTo(tenantId, to, typeGroup); + ListenableFuture> relations = relationDao.findAllByToAsync(tenantId, to, typeGroup); return Futures.transformAsync(relations, relations1 -> { List> futures = new ArrayList<>(); diff --git a/dao/src/main/java/org/thingsboard/server/dao/relation/RelationDao.java b/dao/src/main/java/org/thingsboard/server/dao/relation/RelationDao.java index a8c4d9bbc2..d60f176832 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/relation/RelationDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/relation/RelationDao.java @@ -31,11 +31,15 @@ import java.util.List; */ public interface RelationDao { - ListenableFuture> findAllByFrom(TenantId tenantId, EntityId from, RelationTypeGroup typeGroup); + ListenableFuture> findAllByFromAsync(TenantId tenantId, EntityId from, RelationTypeGroup typeGroup); + + List findAllByFrom(TenantId tenantId, EntityId from, RelationTypeGroup typeGroup); ListenableFuture> findAllByFromAndType(TenantId tenantId, EntityId from, String relationType, RelationTypeGroup typeGroup); - ListenableFuture> findAllByTo(TenantId tenantId, EntityId to, RelationTypeGroup typeGroup); + ListenableFuture> findAllByToAsync(TenantId tenantId, EntityId to, RelationTypeGroup typeGroup); + + List findAllByTo(TenantId tenantId, EntityId to, RelationTypeGroup typeGroup); ListenableFuture> findAllByToAndType(TenantId tenantId, EntityId to, String relationType, RelationTypeGroup typeGroup); diff --git a/dao/src/main/java/org/thingsboard/server/dao/rule/BaseRuleChainService.java b/dao/src/main/java/org/thingsboard/server/dao/rule/BaseRuleChainService.java index b85a257ed2..baa3ae8272 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/rule/BaseRuleChainService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/rule/BaseRuleChainService.java @@ -26,6 +26,7 @@ import org.hibernate.exception.ConstraintViolationException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.thingsboard.server.common.data.BaseData; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.Tenant; @@ -96,23 +97,19 @@ public class BaseRuleChainService extends AbstractEntityService implements RuleC private TbTenantProfileCache tenantProfileCache; @Override + @Transactional public RuleChain saveRuleChain(RuleChain ruleChain) { ruleChainValidator.validate(ruleChain, RuleChain::getTenantId); RuleChain savedRuleChain = ruleChainDao.save(ruleChain.getTenantId(), ruleChain); if (ruleChain.isRoot() && ruleChain.getId() == null) { - try { - createRelation(ruleChain.getTenantId(), new EntityRelation(savedRuleChain.getTenantId(), savedRuleChain.getId(), - EntityRelation.CONTAINS_TYPE, RelationTypeGroup.RULE_CHAIN)); - } catch (Exception e) { - log.warn("[{}] Failed to create tenant to root rule chain relation. from: [{}], to: [{}]", - savedRuleChain.getTenantId(), savedRuleChain.getTenantId(), savedRuleChain.getId(), e); - throw new RuntimeException(e); - } + createRelation(ruleChain.getTenantId(), new EntityRelation(savedRuleChain.getTenantId(), savedRuleChain.getId(), + EntityRelation.CONTAINS_TYPE, RelationTypeGroup.RULE_CHAIN)); } return savedRuleChain; } @Override + @Transactional public boolean setRootRuleChain(TenantId tenantId, RuleChainId ruleChainId) { RuleChain ruleChain = ruleChainDao.findById(tenantId, ruleChainId.getId()); if (!ruleChain.isRoot()) { @@ -145,11 +142,12 @@ public class BaseRuleChainService extends AbstractEntityService implements RuleC } @Override - public RuleChainMetaData saveRuleChainMetaData(TenantId tenantId, RuleChainMetaData ruleChainMetaData) { + @Transactional + public boolean saveRuleChainMetaData(TenantId tenantId, RuleChainMetaData ruleChainMetaData) { Validator.validateId(ruleChainMetaData.getRuleChainId(), "Incorrect rule chain id."); RuleChain ruleChain = findRuleChainById(tenantId, ruleChainMetaData.getRuleChainId()); if (ruleChain == null) { - return null; + return false; } if (CollectionUtils.isNotEmpty(ruleChainMetaData.getConnections())) { @@ -184,14 +182,8 @@ public class BaseRuleChainService extends AbstractEntityService implements RuleC for (RuleNode node : toAddOrUpdate) { node.setRuleChainId(ruleChain.getId()); RuleNode savedNode = ruleNodeDao.save(tenantId, node); - try { - createRelation(tenantId, new EntityRelation(ruleChainMetaData.getRuleChainId(), savedNode.getId(), - EntityRelation.CONTAINS_TYPE, RelationTypeGroup.RULE_CHAIN)); - } catch (Exception e) { - log.warn("[{}] Failed to create rule chain to rule node relation. from: [{}], to: [{}]", - ruleChainMetaData.getRuleChainId(), savedNode.getId()); - throw new RuntimeException(e); - } + createRelation(tenantId, new EntityRelation(ruleChainMetaData.getRuleChainId(), savedNode.getId(), + EntityRelation.CONTAINS_TYPE, RelationTypeGroup.RULE_CHAIN)); int index = nodes.indexOf(node); nodes.set(index, savedNode); ruleNodeIndexMap.put(savedNode.getId(), index); @@ -213,12 +205,7 @@ public class BaseRuleChainService extends AbstractEntityService implements RuleC EntityId from = nodes.get(nodeConnection.getFromIndex()).getId(); EntityId to = nodes.get(nodeConnection.getToIndex()).getId(); String type = nodeConnection.getType(); - try { - createRelation(tenantId, new EntityRelation(from, to, type, RelationTypeGroup.RULE_NODE)); - } catch (Exception e) { - log.warn("[{}] Failed to create rule node relation. from: [{}], to: [{}]", from, to); - throw new RuntimeException(e); - } + createRelation(tenantId, new EntityRelation(from, to, type, RelationTypeGroup.RULE_NODE)); } } if (ruleChainMetaData.getRuleChainConnections() != null) { @@ -226,16 +213,11 @@ public class BaseRuleChainService extends AbstractEntityService implements RuleC EntityId from = nodes.get(nodeToRuleChainConnection.getFromIndex()).getId(); EntityId to = nodeToRuleChainConnection.getTargetRuleChainId(); String type = nodeToRuleChainConnection.getType(); - try { - createRelation(tenantId, new EntityRelation(from, to, type, RelationTypeGroup.RULE_NODE, nodeToRuleChainConnection.getAdditionalInfo())); - } catch (Exception e) { - log.warn("[{}] Failed to create rule node to rule chain relation. from: [{}], to: [{}]", from, to); - throw new RuntimeException(e); - } + createRelation(tenantId, new EntityRelation(from, to, type, RelationTypeGroup.RULE_NODE, nodeToRuleChainConnection.getAdditionalInfo())); } } - return loadRuleChainMetaData(tenantId, ruleChainMetaData.getRuleChainId()); + return true; } private void validateCircles(List connectionInfos) { @@ -410,6 +392,7 @@ public class BaseRuleChainService extends AbstractEntityService implements RuleC } @Override + @Transactional public void deleteRuleChainById(TenantId tenantId, RuleChainId ruleChainId) { Validator.validateId(ruleChainId, "Incorrect rule chain id for delete request."); RuleChain ruleChain = ruleChainDao.findById(tenantId, ruleChainId.getId()); @@ -716,6 +699,15 @@ public class BaseRuleChainService extends AbstractEntityService implements RuleC ruleNodeDao.removeById(tenantId, entityId.getId()); } + private void createRelation(TenantId tenantId, EntityRelation relation) { + log.debug("Creating relation: {}", relation); + relationService.saveRelation(tenantId, relation); + } + + private void deleteRelation(TenantId tenantId, EntityRelation relation) { + log.debug("Deleting relation: {}", relation); + relationService.deleteRelation(tenantId, relation); + } private DataValidator ruleChainValidator = new DataValidator() { diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/relation/JpaRelationDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/relation/JpaRelationDao.java index 89b63b87cf..5e10cabda2 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/relation/JpaRelationDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/relation/JpaRelationDao.java @@ -49,12 +49,17 @@ public class JpaRelationDao extends JpaAbstractDaoListeningExecutorService imple private RelationInsertRepository relationInsertRepository; @Override - public ListenableFuture> findAllByFrom(TenantId tenantId, EntityId from, RelationTypeGroup typeGroup) { - return service.submit(() -> DaoUtil.convertDataList( + public ListenableFuture> findAllByFromAsync(TenantId tenantId, EntityId from, RelationTypeGroup typeGroup) { + return service.submit(() -> findAllByFrom(tenantId, from, typeGroup)); + } + + @Override + public List findAllByFrom(TenantId tenantId, EntityId from, RelationTypeGroup typeGroup) { + return DaoUtil.convertDataList( relationRepository.findAllByFromIdAndFromTypeAndRelationTypeGroup( from.getId(), from.getEntityType().name(), - typeGroup.name()))); + typeGroup.name())); } @Override @@ -68,12 +73,17 @@ public class JpaRelationDao extends JpaAbstractDaoListeningExecutorService imple } @Override - public ListenableFuture> findAllByTo(TenantId tenantId, EntityId to, RelationTypeGroup typeGroup) { - return service.submit(() -> DaoUtil.convertDataList( + public ListenableFuture> findAllByToAsync(TenantId tenantId, EntityId to, RelationTypeGroup typeGroup) { + return service.submit(() -> findAllByTo(tenantId, to, typeGroup)); + } + + @Override + public List findAllByTo(TenantId tenantId, EntityId to, RelationTypeGroup typeGroup) { + return DaoUtil.convertDataList( relationRepository.findAllByToIdAndToTypeAndRelationTypeGroup( to.getId(), to.getEntityType().name(), - typeGroup.name()))); + typeGroup.name())); } @Override diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/BaseRuleChainServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/BaseRuleChainServiceTest.java index e7cd349e73..3f4b103ce2 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/BaseRuleChainServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/BaseRuleChainServiceTest.java @@ -292,7 +292,8 @@ public abstract class BaseRuleChainServiceTest extends AbstractServiceTest { ruleNodes.set(name3Index, ruleNode4); - RuleChainMetaData updatedRuleChainMetaData = ruleChainService.saveRuleChainMetaData(tenantId, savedRuleChainMetaData); + Assert.assertTrue(ruleChainService.saveRuleChainMetaData(tenantId, savedRuleChainMetaData)); + RuleChainMetaData updatedRuleChainMetaData = ruleChainService.loadRuleChainMetaData(tenantId, savedRuleChainMetaData.getRuleChainId()); Assert.assertEquals(3, updatedRuleChainMetaData.getNodes().size()); Assert.assertEquals(3, updatedRuleChainMetaData.getConnections().size()); @@ -404,7 +405,8 @@ public abstract class BaseRuleChainServiceTest extends AbstractServiceTest { ruleChainMetaData.addConnectionInfo(0,2,"fail"); ruleChainMetaData.addConnectionInfo(1,2,"success"); - return ruleChainService.saveRuleChainMetaData(tenantId, ruleChainMetaData); + Assert.assertTrue(ruleChainService.saveRuleChainMetaData(tenantId, ruleChainMetaData)); + return ruleChainService.loadRuleChainMetaData(tenantId, ruleChainMetaData.getRuleChainId()); } private RuleChainMetaData createRuleChainMetadataWithCirclingRelation() throws Exception { From e17ba0531d55e979d40ec61c72ac0f73cfcd8542 Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Fri, 16 Apr 2021 18:50:25 +0300 Subject: [PATCH 61/69] rule chain: fixed conflict after cherry-pick. removed createRelation and deleteRelation. It moved to the AbstractEntityService before --- .../server/dao/rule/BaseRuleChainService.java | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/dao/src/main/java/org/thingsboard/server/dao/rule/BaseRuleChainService.java b/dao/src/main/java/org/thingsboard/server/dao/rule/BaseRuleChainService.java index baa3ae8272..786d86d8dd 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/rule/BaseRuleChainService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/rule/BaseRuleChainService.java @@ -699,16 +699,6 @@ public class BaseRuleChainService extends AbstractEntityService implements RuleC ruleNodeDao.removeById(tenantId, entityId.getId()); } - private void createRelation(TenantId tenantId, EntityRelation relation) { - log.debug("Creating relation: {}", relation); - relationService.saveRelation(tenantId, relation); - } - - private void deleteRelation(TenantId tenantId, EntityRelation relation) { - log.debug("Deleting relation: {}", relation); - relationService.deleteRelation(tenantId, relation); - } - private DataValidator ruleChainValidator = new DataValidator() { @Override From a100d07daf0db81ee28dc63ad6b4ef36e7e08ad8 Mon Sep 17 00:00:00 2001 From: AndrewVolosytnykhThingsboard Date: Tue, 13 Apr 2021 19:36:35 +0300 Subject: [PATCH 62/69] Fixed incorrect RestClient method getAlarms --- .../thingsboard/rest/client/RestClient.java | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java b/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java index 815897faaa..0e04cc132e 100644 --- a/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java +++ b/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java @@ -305,17 +305,25 @@ public class RestClient implements ClientHttpRequestInterceptor, Closeable { restTemplate.postForLocation(baseURL + "/api/alarm/{alarmId}/clear", null, alarmId.getId()); } - public PageData getAlarms(EntityId entityId, AlarmSearchStatus searchStatus, AlarmStatus status, TimePageLink pageLink, Boolean fetchOriginator) { + public PageData getAlarms(EntityId entityId, AlarmSearchStatus searchStatus, AlarmStatus status, TimePageLink pageLink, String offset, Boolean fetchOriginator) { + String urlSecondPart = "/api/alarm/{entityType}/{entityId}?fetchOriginator={fetchOriginator}&"; Map params = new HashMap<>(); params.put("entityType", entityId.getEntityType().name()); params.put("entityId", entityId.getId().toString()); - params.put("searchStatus", searchStatus.name()); - params.put("status", status.name()); + if(searchStatus != null) { + params.put("searchStatus", searchStatus.name()); + urlSecondPart += "searchStatus={searchStatus}&"; + } + if(status != null) { + params.put("status", status.name()); + urlSecondPart += "status={status}&"; + } params.put("fetchOriginator", String.valueOf(fetchOriginator)); + params.put("offset", offset); addTimePageLinkToParam(params, pageLink); return restTemplate.exchange( - baseURL + "/api/alarm/{entityType}/{entityId}?searchStatus={searchStatus}&status={status}&fetchOriginator={fetchOriginator}&" + getTimeUrlParams(pageLink), + baseURL + urlSecondPart + getUrlParams(pageLink) + getTimeUrlParams(pageLink), HttpMethod.GET, HttpEntity.EMPTY, new ParameterizedTypeReference>() { @@ -2841,7 +2849,7 @@ public class RestClient implements ClientHttpRequestInterceptor, Closeable { } private String getTimeUrlParams(TimePageLink pageLink) { - String urlParams = "limit={limit}&ascOrder={ascOrder}"; + String urlParams = ""; if (pageLink.getStartTime() != null) { urlParams += "&startTime={startTime}"; } From fad1040f249a74e2be3d4fcf63b6def20736230f Mon Sep 17 00:00:00 2001 From: AndrewVolosytnykhThingsboard Date: Wed, 14 Apr 2021 13:26:30 +0300 Subject: [PATCH 63/69] Refactoring: offset added to request URL --- .../main/java/org/thingsboard/rest/client/RestClient.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java b/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java index 0e04cc132e..e39284c547 100644 --- a/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java +++ b/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java @@ -127,6 +127,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -305,7 +306,7 @@ public class RestClient implements ClientHttpRequestInterceptor, Closeable { restTemplate.postForLocation(baseURL + "/api/alarm/{alarmId}/clear", null, alarmId.getId()); } - public PageData getAlarms(EntityId entityId, AlarmSearchStatus searchStatus, AlarmStatus status, TimePageLink pageLink, String offset, Boolean fetchOriginator) { + public PageData getAlarms(EntityId entityId, AlarmSearchStatus searchStatus, AlarmStatus status, TimePageLink pageLink, UUID offset, Boolean fetchOriginator) { String urlSecondPart = "/api/alarm/{entityType}/{entityId}?fetchOriginator={fetchOriginator}&"; Map params = new HashMap<>(); params.put("entityType", entityId.getEntityType().name()); @@ -319,7 +320,10 @@ public class RestClient implements ClientHttpRequestInterceptor, Closeable { urlSecondPart += "status={status}&"; } params.put("fetchOriginator", String.valueOf(fetchOriginator)); - params.put("offset", offset); + if(offset != null) { + params.put("offset", offset.toString()); + urlSecondPart += "offset={offset}&"; + } addTimePageLinkToParam(params, pageLink); return restTemplate.exchange( From 5d64775f675defa240308846c2c173129cec1f5d Mon Sep 17 00:00:00 2001 From: AndrewVolosytnykhThingsboard Date: Wed, 14 Apr 2021 17:10:50 +0300 Subject: [PATCH 64/69] Correct call of dependent methods --- .../src/main/java/org/thingsboard/rest/client/RestClient.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java b/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java index e39284c547..79b03d2958 100644 --- a/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java +++ b/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java @@ -327,7 +327,7 @@ public class RestClient implements ClientHttpRequestInterceptor, Closeable { addTimePageLinkToParam(params, pageLink); return restTemplate.exchange( - baseURL + urlSecondPart + getUrlParams(pageLink) + getTimeUrlParams(pageLink), + baseURL + urlSecondPart + getTimeUrlParams(pageLink), HttpMethod.GET, HttpEntity.EMPTY, new ParameterizedTypeReference>() { @@ -2853,7 +2853,7 @@ public class RestClient implements ClientHttpRequestInterceptor, Closeable { } private String getTimeUrlParams(TimePageLink pageLink) { - String urlParams = ""; + String urlParams = getUrlParams(pageLink); if (pageLink.getStartTime() != null) { urlParams += "&startTime={startTime}"; } From e3caf24a9b337805854d167b01535ff42023d573 Mon Sep 17 00:00:00 2001 From: AndrewVolosytnykhThingsboard Date: Fri, 16 Apr 2021 20:57:05 +0300 Subject: [PATCH 65/69] Removed redundant parameter for AlarmController, AlarmQuery and RestClient.getAlarms --- .../server/controller/AlarmController.java | 10 ++-------- .../server/common/data/alarm/AlarmQuery.java | 4 ---- .../java/org/thingsboard/rest/client/RestClient.java | 11 +++-------- 3 files changed, 5 insertions(+), 20 deletions(-) diff --git a/application/src/main/java/org/thingsboard/server/controller/AlarmController.java b/application/src/main/java/org/thingsboard/server/controller/AlarmController.java index 3f32e878f4..9453bd46b7 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AlarmController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AlarmController.java @@ -46,8 +46,6 @@ import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.security.permission.Operation; import org.thingsboard.server.service.security.permission.Resource; -import java.util.UUID; - @RestController @TbCoreComponent @RequestMapping("/api") @@ -177,7 +175,6 @@ public class AlarmController extends BaseController { @RequestParam(required = false) String sortOrder, @RequestParam(required = false) Long startTime, @RequestParam(required = false) Long endTime, - @RequestParam(required = false) String offset, @RequestParam(required = false) Boolean fetchOriginator ) throws ThingsboardException { checkParameter("EntityId", strEntityId); @@ -191,12 +188,9 @@ public class AlarmController extends BaseController { } checkEntityId(entityId, Operation.READ); TimePageLink pageLink = createTimePageLink(pageSize, page, textSearch, sortProperty, sortOrder, startTime, endTime); - UUID idOffsetUuid = null; - if (StringUtils.isNotEmpty(offset)) { - idOffsetUuid = toUUID(offset); - } + try { - return checkNotNull(alarmService.findAlarms(getCurrentUser().getTenantId(), new AlarmQuery(entityId, pageLink, alarmSearchStatus, alarmStatus, fetchOriginator, idOffsetUuid)).get()); + return checkNotNull(alarmService.findAlarms(getCurrentUser().getTenantId(), new AlarmQuery(entityId, pageLink, alarmSearchStatus, alarmStatus, fetchOriginator)).get()); } catch (Exception e) { throw handleException(e); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmQuery.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmQuery.java index 91bd902e9a..80e3730955 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmQuery.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmQuery.java @@ -19,11 +19,8 @@ import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import org.thingsboard.server.common.data.id.EntityId; -import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.TimePageLink; -import java.util.UUID; - /** * Created by ashvayka on 11.05.17. */ @@ -37,6 +34,5 @@ public class AlarmQuery { private AlarmSearchStatus searchStatus; private AlarmStatus status; private Boolean fetchOriginator; - private UUID idOffset; } diff --git a/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java b/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java index 79b03d2958..0b48487a63 100644 --- a/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java +++ b/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java @@ -121,13 +121,11 @@ import org.thingsboard.server.common.data.widget.WidgetsBundle; import java.io.Closeable; import java.io.IOException; import java.net.URI; -import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; -import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -306,11 +304,12 @@ public class RestClient implements ClientHttpRequestInterceptor, Closeable { restTemplate.postForLocation(baseURL + "/api/alarm/{alarmId}/clear", null, alarmId.getId()); } - public PageData getAlarms(EntityId entityId, AlarmSearchStatus searchStatus, AlarmStatus status, TimePageLink pageLink, UUID offset, Boolean fetchOriginator) { + public PageData getAlarms(EntityId entityId, AlarmSearchStatus searchStatus, AlarmStatus status, TimePageLink pageLink, Boolean fetchOriginator) { String urlSecondPart = "/api/alarm/{entityType}/{entityId}?fetchOriginator={fetchOriginator}&"; Map params = new HashMap<>(); params.put("entityType", entityId.getEntityType().name()); params.put("entityId", entityId.getId().toString()); + params.put("fetchOriginator", String.valueOf(fetchOriginator)); if(searchStatus != null) { params.put("searchStatus", searchStatus.name()); urlSecondPart += "searchStatus={searchStatus}&"; @@ -319,11 +318,7 @@ public class RestClient implements ClientHttpRequestInterceptor, Closeable { params.put("status", status.name()); urlSecondPart += "status={status}&"; } - params.put("fetchOriginator", String.valueOf(fetchOriginator)); - if(offset != null) { - params.put("offset", offset.toString()); - urlSecondPart += "offset={offset}&"; - } + addTimePageLinkToParam(params, pageLink); return restTemplate.exchange( From 91e0a77afd5d34ab324681ceee3db2c164cebd37 Mon Sep 17 00:00:00 2001 From: AndrewVolosytnykhThingsboard Date: Mon, 19 Apr 2021 15:36:38 +0300 Subject: [PATCH 66/69] Fixed incorrect adding of additional parameters to URL in getAlarms method --- .../main/java/org/thingsboard/rest/client/RestClient.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java b/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java index 0b48487a63..e51117c080 100644 --- a/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java +++ b/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java @@ -305,18 +305,18 @@ public class RestClient implements ClientHttpRequestInterceptor, Closeable { } public PageData getAlarms(EntityId entityId, AlarmSearchStatus searchStatus, AlarmStatus status, TimePageLink pageLink, Boolean fetchOriginator) { - String urlSecondPart = "/api/alarm/{entityType}/{entityId}?fetchOriginator={fetchOriginator}&"; + String urlSecondPart = "/api/alarm/{entityType}/{entityId}?fetchOriginator={fetchOriginator}"; Map params = new HashMap<>(); params.put("entityType", entityId.getEntityType().name()); params.put("entityId", entityId.getId().toString()); params.put("fetchOriginator", String.valueOf(fetchOriginator)); if(searchStatus != null) { params.put("searchStatus", searchStatus.name()); - urlSecondPart += "searchStatus={searchStatus}&"; + urlSecondPart += "&searchStatus={searchStatus}"; } if(status != null) { params.put("status", status.name()); - urlSecondPart += "status={status}&"; + urlSecondPart += "&status={status}"; } addTimePageLinkToParam(params, pageLink); From cb9450104ebdaf9a440cd763d0cd3562c3d926a0 Mon Sep 17 00:00:00 2001 From: Sergey Matvienko Date: Mon, 19 Apr 2021 20:16:55 +0300 Subject: [PATCH 67/69] device count performance for 100k+ devices per tenant. Added additional tests for the DeviceService --- .../server/dao/device/DeviceService.java | 2 + .../DefaultTenantProfileConfiguration.java | 6 + .../server/dao/device/DeviceServiceImpl.java | 5 + .../dao/sql/device/DeviceRepository.java | 13 +- .../dao/service/AbstractServiceTest.java | 11 ++ .../dao/service/BaseDeviceServiceTest.java | 183 ++++++++++++------ 6 files changed, 160 insertions(+), 60 deletions(-) diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceService.java index 06a2fe4e7d..b3cfb66a47 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/device/DeviceService.java @@ -98,4 +98,6 @@ public interface DeviceService { PageData findDevicesByTenantIdAndEdgeId(TenantId tenantId, EdgeId edgeId, PageLink pageLink); PageData findDevicesByTenantIdAndEdgeIdAndType(TenantId tenantId, EdgeId edgeId, String type, PageLink pageLink); + + long countByTenantId(TenantId tenantId); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java index fc63133510..72713f12d3 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java @@ -15,10 +15,16 @@ */ package org.thingsboard.server.common.data.tenant.profile; +import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; import org.thingsboard.server.common.data.ApiUsageRecordKey; import org.thingsboard.server.common.data.TenantProfileType; +@AllArgsConstructor +@NoArgsConstructor +@Builder @Data public class DefaultTenantProfileConfiguration implements TenantProfileConfiguration { diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java index d8f2444cb5..9d7fa59ad8 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceServiceImpl.java @@ -615,6 +615,11 @@ public class DeviceServiceImpl extends AbstractEntityService implements DeviceSe return deviceDao.findDevicesByTenantIdAndEdgeIdAndType(tenantId.getId(), edgeId.getId(), type, pageLink); } + @Override + public long countByTenantId(TenantId tenantId) { + return deviceDao.countByTenantId(tenantId); + } + private DataValidator deviceValidator = new DataValidator() { diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/device/DeviceRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/device/DeviceRepository.java index 9f16397a65..0de59ac8be 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/device/DeviceRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/device/DeviceRepository.java @@ -189,5 +189,16 @@ public interface DeviceRepository extends PagingAndSortingRepository + * There is two way to count devices. + * OPTIMAL: count(*) + * - returns _row_count_ and use index-only scan (super fast). + * SLOW: count(id) + * - returns _NON_NULL_id_count and performs table scan to verify isNull for each id in filtered rows. + * */ + @Query("SELECT count(*) FROM DeviceEntity d WHERE d.tenantId = :tenantId") + Long countByTenantId(@Param("tenantId") UUID tenantId); } diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/AbstractServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/AbstractServiceTest.java index 0761f28d33..327b05ee22 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/AbstractServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/AbstractServiceTest.java @@ -32,6 +32,7 @@ import org.thingsboard.server.common.data.DeviceProfileType; import org.thingsboard.server.common.data.DeviceTransportType; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.Event; +import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.device.profile.DefaultDeviceProfileConfiguration; import org.thingsboard.server.common.data.device.profile.DefaultDeviceProfileTransportConfiguration; import org.thingsboard.server.common.data.device.profile.DeviceProfileData; @@ -70,6 +71,8 @@ import java.util.Comparator; import java.util.HashMap; import java.util.Map; +import static org.junit.Assert.assertNotNull; + @RunWith(SpringRunner.class) @ContextConfiguration(classes = AbstractServiceTest.class, loader = AnnotationConfigContextLoader.class) @@ -225,4 +228,12 @@ public abstract class AbstractServiceTest { return deviceProfile; } + public TenantId createTenant() { + Tenant tenant = new Tenant(); + tenant.setTitle("My tenant " + Uuids.timeBased()); + Tenant savedTenant = tenantService.saveTenant(tenant); + assertNotNull(savedTenant); + return savedTenant.getId(); + } + } diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/BaseDeviceServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/BaseDeviceServiceTest.java index b323d0088c..16f7ab2868 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/BaseDeviceServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/BaseDeviceServiceTest.java @@ -21,13 +21,19 @@ import org.junit.After; import org.junit.Assert; import org.junit.Before; import org.junit.Test; -import org.thingsboard.server.common.data.*; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.DeviceInfo; +import org.thingsboard.server.common.data.EntitySubtype; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.security.DeviceCredentials; import org.thingsboard.server.common.data.security.DeviceCredentialsType; +import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.dao.exception.DataValidationException; import java.util.ArrayList; @@ -37,33 +43,93 @@ import java.util.List; import static org.thingsboard.server.dao.model.ModelConstants.NULL_UUID; public abstract class BaseDeviceServiceTest extends AbstractServiceTest { - + private IdComparator idComparator = new IdComparator<>(); - + private TenantId tenantId; + private TenantId anotherTenantId; @Before public void before() { - Tenant tenant = new Tenant(); - tenant.setTitle("My tenant"); - Tenant savedTenant = tenantService.saveTenant(tenant); - Assert.assertNotNull(savedTenant); - tenantId = savedTenant.getId(); + tenantId = createTenant(); + anotherTenantId = createTenant(); } @After public void after() { tenantService.deleteTenant(tenantId); + tenantService.deleteTenant(anotherTenantId); + + tenantProfileService.deleteTenantProfiles(tenantId); + tenantProfileService.deleteTenantProfiles(anotherTenantId); + } + + @Test + public void testSaveDevicesWithoutMaxDeviceLimit() { + Device device = this.saveDevice(tenantId, "My device"); + deleteDevice(tenantId, device); } @Test - public void testSaveDevice() { + public void testSaveDevicesWithInfiniteMaxDeviceLimit() { + TenantProfile defaultTenantProfile = tenantProfileService.findDefaultTenantProfile(tenantId); + defaultTenantProfile.getProfileData().setConfiguration(DefaultTenantProfileConfiguration.builder().maxDevices(Long.MAX_VALUE).build()); + tenantProfileService.saveTenantProfile(tenantId, defaultTenantProfile); + + Device device = this.saveDevice(tenantId, "My device"); + deleteDevice(tenantId, device); + } + + @Test(expected = DataValidationException.class) + public void testSaveDevicesWithMaxDeviceOutOfLimit() { + TenantProfile defaultTenantProfile = tenantProfileService.findDefaultTenantProfile(tenantId); + defaultTenantProfile.getProfileData().setConfiguration(DefaultTenantProfileConfiguration.builder().maxDevices(1).build()); + tenantProfileService.saveTenantProfile(tenantId, defaultTenantProfile); + + Assert.assertEquals(0, deviceService.countByTenantId(tenantId)); + + this.saveDevice(tenantId, "My first device"); + Assert.assertEquals(1, deviceService.countByTenantId(tenantId)); + + this.saveDevice(tenantId, "My second device that out of maxDeviceCount limit"); + } + + @Test + public void testCountByTenantId() { + Assert.assertEquals(0, deviceService.countByTenantId(tenantId)); + Assert.assertEquals(0, deviceService.countByTenantId(anotherTenantId)); + Assert.assertEquals(0, deviceService.countByTenantId(TenantId.SYS_TENANT_ID)); + + Device anotherDevice = this.saveDevice(anotherTenantId, "My device 1"); + Assert.assertEquals(1, deviceService.countByTenantId(anotherTenantId)); + + int maxDevices = 8; + List devices = new ArrayList<>(maxDevices); + + for (int i = 1; i <= maxDevices; i++) { + devices.add(this.saveDevice(tenantId, "My device " + i)); + Assert.assertEquals(i, deviceService.countByTenantId(tenantId)); + } + + Assert.assertEquals(maxDevices, deviceService.countByTenantId(tenantId)); + Assert.assertEquals(1, deviceService.countByTenantId(anotherTenantId)); + Assert.assertEquals(0, deviceService.countByTenantId(TenantId.SYS_TENANT_ID)); + + devices.forEach(device -> deleteDevice(tenantId, device)); + deleteDevice(anotherTenantId, anotherDevice); + } + + void deleteDevice(TenantId tenantId, Device device) { + deviceService.deleteDevice(tenantId, device.getId()); + } + + Device saveDevice(TenantId tenantId, final String name) { Device device = new Device(); device.setTenantId(tenantId); - device.setName("My device"); + device.setName(name); device.setType("default"); Device savedDevice = deviceService.saveDevice(device); - + Assert.assertNotNull(savedDevice); Assert.assertNotNull(savedDevice.getId()); Assert.assertTrue(savedDevice.getCreatedTime() > 0); @@ -71,7 +137,7 @@ public abstract class BaseDeviceServiceTest extends AbstractServiceTest { Assert.assertNotNull(savedDevice.getCustomerId()); Assert.assertEquals(NULL_UUID, savedDevice.getCustomerId().getId()); Assert.assertEquals(device.getName(), savedDevice.getName()); - + DeviceCredentials deviceCredentials = deviceCredentialsService.findDeviceCredentialsByDeviceId(tenantId, savedDevice.getId()); Assert.assertNotNull(deviceCredentials); Assert.assertNotNull(deviceCredentials.getId()); @@ -79,16 +145,15 @@ public abstract class BaseDeviceServiceTest extends AbstractServiceTest { Assert.assertEquals(DeviceCredentialsType.ACCESS_TOKEN, deviceCredentials.getCredentialsType()); Assert.assertNotNull(deviceCredentials.getCredentialsId()); Assert.assertEquals(20, deviceCredentials.getCredentialsId().length()); - - savedDevice.setName("My new device"); - + + savedDevice.setName("New " + savedDevice.getName()); + deviceService.saveDevice(savedDevice); Device foundDevice = deviceService.findDeviceById(tenantId, savedDevice.getId()); Assert.assertEquals(foundDevice.getName(), savedDevice.getName()); - - deviceService.deleteDevice(tenantId, savedDevice.getId()); + return foundDevice; } - + @Test(expected = DataValidationException.class) public void testSaveDeviceWithEmptyName() { Device device = new Device(); @@ -96,7 +161,7 @@ public abstract class BaseDeviceServiceTest extends AbstractServiceTest { device.setTenantId(tenantId); deviceService.saveDevice(device); } - + @Test(expected = DataValidationException.class) public void testSaveDeviceWithEmptyTenant() { Device device = new Device(); @@ -104,7 +169,7 @@ public abstract class BaseDeviceServiceTest extends AbstractServiceTest { device.setType("default"); deviceService.saveDevice(device); } - + @Test(expected = DataValidationException.class) public void testSaveDeviceWithInvalidTenant() { Device device = new Device(); @@ -113,7 +178,7 @@ public abstract class BaseDeviceServiceTest extends AbstractServiceTest { device.setTenantId(new TenantId(Uuids.timeBased())); deviceService.saveDevice(device); } - + @Test(expected = DataValidationException.class) public void testAssignDeviceToNonExistentCustomer() { Device device = new Device(); @@ -149,7 +214,7 @@ public abstract class BaseDeviceServiceTest extends AbstractServiceTest { tenantService.deleteTenant(tenant.getId()); } } - + @Test public void testFindDeviceById() { Device device = new Device(); @@ -198,7 +263,7 @@ public abstract class BaseDeviceServiceTest extends AbstractServiceTest { devices.forEach((device) -> { deviceService.deleteDevice(tenantId, device.getId()); }); } } - + @Test public void testDeleteDevice() { Device device = new Device(); @@ -214,15 +279,15 @@ public abstract class BaseDeviceServiceTest extends AbstractServiceTest { DeviceCredentials foundDeviceCredentials = deviceCredentialsService.findDeviceCredentialsByDeviceId(tenantId, savedDevice.getId()); Assert.assertNull(foundDeviceCredentials); } - + @Test public void testFindDevicesByTenantId() { Tenant tenant = new Tenant(); tenant.setTitle("Test tenant"); tenant = tenantService.saveTenant(tenant); - + TenantId tenantId = tenant.getId(); - + List devices = new ArrayList<>(); for (int i=0;i<178;i++) { Device device = new Device(); @@ -231,7 +296,7 @@ public abstract class BaseDeviceServiceTest extends AbstractServiceTest { device.setType("default"); devices.add(deviceService.saveDevice(device)); } - + List loadedDevices = new ArrayList<>(); PageLink pageLink = new PageLink(23); PageData pageData = null; @@ -242,19 +307,19 @@ public abstract class BaseDeviceServiceTest extends AbstractServiceTest { pageLink = pageLink.nextPageLink(); } } while (pageData.hasNext()); - + Collections.sort(devices, idComparator); Collections.sort(loadedDevices, idComparator); - + Assert.assertEquals(devices, loadedDevices); - + deviceService.deleteDevicesByTenantId(tenantId); pageLink = new PageLink(33); pageData = deviceService.findDevicesByTenantId(tenantId, pageLink); Assert.assertFalse(pageData.hasNext()); Assert.assertTrue(pageData.getData().isEmpty()); - + tenantService.deleteTenant(tenantId); } @@ -284,7 +349,7 @@ public abstract class BaseDeviceServiceTest extends AbstractServiceTest { device.setType("default"); devicesTitle2.add(new DeviceInfo(deviceService.saveDevice(device), null, false, "default")); } - + List loadedDevicesTitle1 = new ArrayList<>(); PageLink pageLink = new PageLink(15, 0, title1); PageData pageData = null; @@ -295,12 +360,12 @@ public abstract class BaseDeviceServiceTest extends AbstractServiceTest { pageLink = pageLink.nextPageLink(); } } while (pageData.hasNext()); - + Collections.sort(devicesTitle1, idComparator); Collections.sort(loadedDevicesTitle1, idComparator); - + Assert.assertEquals(devicesTitle1, loadedDevicesTitle1); - + List loadedDevicesTitle2 = new ArrayList<>(); pageLink = new PageLink(4, 0, title2); do { @@ -313,22 +378,22 @@ public abstract class BaseDeviceServiceTest extends AbstractServiceTest { Collections.sort(devicesTitle2, idComparator); Collections.sort(loadedDevicesTitle2, idComparator); - + Assert.assertEquals(devicesTitle2, loadedDevicesTitle2); for (Device device : loadedDevicesTitle1) { deviceService.deleteDevice(tenantId, device.getId()); } - + pageLink = new PageLink(4, 0, title1); pageData = deviceService.findDeviceInfosByTenantId(tenantId, pageLink); Assert.assertFalse(pageData.hasNext()); Assert.assertEquals(0, pageData.getData().size()); - + for (Device device : loadedDevicesTitle2) { deviceService.deleteDevice(tenantId, device.getId()); } - + pageLink = new PageLink(4, 0, title2); pageData = deviceService.findDeviceInfosByTenantId(tenantId, pageLink); Assert.assertFalse(pageData.hasNext()); @@ -413,21 +478,21 @@ public abstract class BaseDeviceServiceTest extends AbstractServiceTest { Assert.assertFalse(pageData.hasNext()); Assert.assertEquals(0, pageData.getData().size()); } - + @Test public void testFindDevicesByTenantIdAndCustomerId() { Tenant tenant = new Tenant(); tenant.setTitle("Test tenant"); tenant = tenantService.saveTenant(tenant); - + TenantId tenantId = tenant.getId(); - + Customer customer = new Customer(); customer.setTitle("Test customer"); customer.setTenantId(tenantId); customer = customerService.saveCustomer(customer); CustomerId customerId = customer.getId(); - + List devices = new ArrayList<>(); for (int i=0;i<278;i++) { Device device = new Device(); @@ -437,7 +502,7 @@ public abstract class BaseDeviceServiceTest extends AbstractServiceTest { device = deviceService.saveDevice(device); devices.add(new DeviceInfo(deviceService.assignDeviceToCustomer(tenantId, device.getId(), customerId), customer.getTitle(), customer.isPublic(), "default")); } - + List loadedDevices = new ArrayList<>(); PageLink pageLink = new PageLink(23); PageData pageData = null; @@ -448,31 +513,31 @@ public abstract class BaseDeviceServiceTest extends AbstractServiceTest { pageLink = pageLink.nextPageLink(); } } while (pageData.hasNext()); - + Collections.sort(devices, idComparator); Collections.sort(loadedDevices, idComparator); - + Assert.assertEquals(devices, loadedDevices); - + deviceService.unassignCustomerDevices(tenantId, customerId); pageLink = new PageLink(33); pageData = deviceService.findDeviceInfosByTenantIdAndCustomerId(tenantId, customerId, pageLink); Assert.assertFalse(pageData.hasNext()); Assert.assertTrue(pageData.getData().isEmpty()); - + tenantService.deleteTenant(tenantId); } - + @Test public void testFindDevicesByTenantIdCustomerIdAndName() { - + Customer customer = new Customer(); customer.setTitle("Test customer"); customer.setTenantId(tenantId); customer = customerService.saveCustomer(customer); CustomerId customerId = customer.getId(); - + String title1 = "Device title 1"; List devicesTitle1 = new ArrayList<>(); for (int i=0;i<175;i++) { @@ -499,7 +564,7 @@ public abstract class BaseDeviceServiceTest extends AbstractServiceTest { device = deviceService.saveDevice(device); devicesTitle2.add(deviceService.assignDeviceToCustomer(tenantId, device.getId(), customerId)); } - + List loadedDevicesTitle1 = new ArrayList<>(); PageLink pageLink = new PageLink(15, 0, title1); PageData pageData = null; @@ -510,12 +575,12 @@ public abstract class BaseDeviceServiceTest extends AbstractServiceTest { pageLink = pageLink.nextPageLink(); } } while (pageData.hasNext()); - + Collections.sort(devicesTitle1, idComparator); Collections.sort(loadedDevicesTitle1, idComparator); - + Assert.assertEquals(devicesTitle1, loadedDevicesTitle1); - + List loadedDevicesTitle2 = new ArrayList<>(); pageLink = new PageLink(4, 0, title2); do { @@ -528,22 +593,22 @@ public abstract class BaseDeviceServiceTest extends AbstractServiceTest { Collections.sort(devicesTitle2, idComparator); Collections.sort(loadedDevicesTitle2, idComparator); - + Assert.assertEquals(devicesTitle2, loadedDevicesTitle2); for (Device device : loadedDevicesTitle1) { deviceService.deleteDevice(tenantId, device.getId()); } - + pageLink = new PageLink(4, 0, title1); pageData = deviceService.findDevicesByTenantIdAndCustomerId(tenantId, customerId, pageLink); Assert.assertFalse(pageData.hasNext()); Assert.assertEquals(0, pageData.getData().size()); - + for (Device device : loadedDevicesTitle2) { deviceService.deleteDevice(tenantId, device.getId()); } - + pageLink = new PageLink(4, 0, title2); pageData = deviceService.findDevicesByTenantIdAndCustomerId(tenantId, customerId, pageLink); Assert.assertFalse(pageData.hasNext()); From 51b0d50542ff0da65c7abd722e3c0f6332c8a5ee Mon Sep 17 00:00:00 2001 From: nickAS21 <44275303+nickAS21@users.noreply.github.com> Date: Tue, 20 Apr 2021 10:44:11 +0300 Subject: [PATCH 68/69] Lwm2m discovery (#4438) * Lwm2m: fix bug delete zero updateAttribute * Lwm2m: front add select binding * Lwm2m: discovery only for test * Lwm2m: remove type_cast_enabled from the main branch. * Lwm2m: remove type_cast_enabled from the main branch. * Lwm2m: remove type_cast_enabled from the main branch. * Lwm2m: remove double code. --- .../secure/LwM2MBootstrapSecurityStore.java | 5 +- .../lwm2m/server/LwM2mServerListener.java | 6 +- .../server/LwM2mTransportContextServer.java | 134 +++++++-- .../lwm2m/server/LwM2mTransportHandler.java | 16 +- .../lwm2m/server/LwM2mTransportRequest.java | 12 +- .../server/LwM2mTransportServiceImpl.java | 276 ++++++++++-------- .../lwm2m/server/client/LwM2mClient.java | 8 +- .../server/client/LwM2mClientContextImpl.java | 4 +- .../client/ResultsAddKeyValueProto.java | 34 +++ .../server/client/ResultsResourceValue.java | 32 ++ .../lwm2m/utils/LwM2mValueConverterImpl.java | 23 +- .../src/main/resources/tb-lwm2m-transport.yml | 21 +- ...ile-transport-configuration.component.html | 17 +- ...ofile-transport-configuration.component.ts | 8 +- .../lwm2m/lwm2m-profile-config.models.ts | 32 +- 15 files changed, 420 insertions(+), 208 deletions(-) create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/ResultsAddKeyValueProto.java create mode 100644 common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/ResultsResourceValue.java diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/secure/LwM2MBootstrapSecurityStore.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/secure/LwM2MBootstrapSecurityStore.java index 52638a99e2..358ba499d4 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/secure/LwM2MBootstrapSecurityStore.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/secure/LwM2MBootstrapSecurityStore.java @@ -35,7 +35,6 @@ import org.thingsboard.server.transport.lwm2m.secure.LwM2mCredentialsSecurityInf import org.thingsboard.server.transport.lwm2m.secure.ReadResultSecurityStore; import org.thingsboard.server.transport.lwm2m.server.LwM2mSessionMsgListener; import org.thingsboard.server.transport.lwm2m.server.LwM2mTransportContextServer; -import org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler; import org.thingsboard.server.transport.lwm2m.utils.TypeServer; import java.io.IOException; @@ -165,13 +164,13 @@ public class LwM2MBootstrapSecurityStore implements BootstrapSecurityStore { lwM2MBootstrapConfig.bootstrapServer = new LwM2MServerBootstrap(lwM2MBootstrapConfig.bootstrapServer, profileServerBootstrap); lwM2MBootstrapConfig.lwm2mServer = new LwM2MServerBootstrap(lwM2MBootstrapConfig.lwm2mServer, profileLwm2mServer); String logMsg = String.format("%s: getParametersBootstrap: %s Access connect client with bootstrap server.", LOG_LW2M_INFO, store.getEndPoint()); - context.sendParametersOnThingsboard(context.getTelemetryMsgObject(logMsg), LwM2mTransportHandler.DEVICE_TELEMETRY_TOPIC, sessionInfo); + context.sendParametersOnThingsboardTelemetry(context.getKvLogyToThingsboard(logMsg), sessionInfo); return lwM2MBootstrapConfig; } else { log.error(" [{}] Different values SecurityMode between of client and profile.", store.getEndPoint()); log.error("{} getParametersBootstrap: [{}] Different values SecurityMode between of client and profile.", LOG_LW2M_ERROR, store.getEndPoint()); String logMsg = String.format("%s: getParametersBootstrap: %s Different values SecurityMode between of client and profile.", LOG_LW2M_ERROR, store.getEndPoint()); - context.sendParametersOnThingsboard(context.getTelemetryMsgObject(logMsg), LwM2mTransportHandler.DEVICE_TELEMETRY_TOPIC, sessionInfo); + context.sendParametersOnThingsboardTelemetry(context.getKvLogyToThingsboard(logMsg), sessionInfo); return null; } } diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mServerListener.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mServerListener.java index ffe2f76602..9b2f3cb7eb 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mServerListener.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mServerListener.java @@ -26,7 +26,7 @@ import org.eclipse.leshan.server.registration.RegistrationUpdate; import java.util.Collection; -import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.convertToIdVerFromObjectId; +import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.convertPathFromObjectIdToIdVer; @Slf4j public class LwM2mServerListener { @@ -92,7 +92,7 @@ public class LwM2mServerListener { public void onResponse(Observation observation, Registration registration, ObserveResponse response) { if (registration != null) { try { - service.onObservationResponse(registration, convertToIdVerFromObjectId(observation.getPath().toString(), registration), response); + service.onObservationResponse(registration, convertPathFromObjectIdToIdVer(observation.getPath().toString(), registration), response); } catch (Exception e) { log.error("[{}] onResponse", e.toString()); @@ -107,7 +107,7 @@ public class LwM2mServerListener { @Override public void newObservation(Observation observation, Registration registration) { -// log.info("Received newObservation from [{}] endpoint [{}] ", observation.getPath(), registration.getEndpoint()); + log.info("Received newObservation from [{}] endpoint [{}] ", observation.getPath(), registration.getEndpoint()); } }; diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportContextServer.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportContextServer.java index 170530f084..ed0a1d16e6 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportContextServer.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportContextServer.java @@ -30,31 +30,33 @@ package org.thingsboard.server.transport.lwm2m.server; * limitations under the License. */ -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.eclipse.leshan.core.model.DDFFileParser; import org.eclipse.leshan.core.model.DefaultDDFFileValidator; import org.eclipse.leshan.core.model.InvalidDDFFileException; import org.eclipse.leshan.core.model.ObjectModel; +import org.eclipse.leshan.core.model.ResourceModel; +import org.eclipse.leshan.core.node.codec.CodecException; import org.springframework.stereotype.Component; import org.thingsboard.server.common.transport.TransportContext; import org.thingsboard.server.common.transport.TransportResourceCache; import org.thingsboard.server.common.transport.TransportService; import org.thingsboard.server.common.transport.TransportServiceCallback; -import org.thingsboard.server.common.transport.adaptor.AdaptorException; import org.thingsboard.server.common.transport.lwm2m.LwM2MTransportConfigServer; +import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.TransportProtos.PostAttributeMsg; import org.thingsboard.server.gen.transport.TransportProtos.PostTelemetryMsg; import org.thingsboard.server.gen.transport.TransportProtos.SessionInfoProto; -import org.thingsboard.server.gen.transport.TransportProtos.ValidateDeviceCredentialsResponseMsg; import org.thingsboard.server.queue.util.TbLwM2mTransportComponent; import org.thingsboard.server.transport.lwm2m.server.adaptors.LwM2MJsonAdaptor; import java.io.ByteArrayInputStream; import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import static org.thingsboard.server.gen.transport.TransportProtos.KeyValueType.BOOLEAN_V; import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.LOG_LW2M_TELEMETRY; @Slf4j @@ -91,7 +93,7 @@ public class LwM2mTransportContextServer extends TransportContext { /** * send to Thingsboard Attribute || Telemetry * - * @param msg - JsonObject: [{name: value}] + * @param msg - JsonObject: [{name: value}] * @return - dummy */ private TransportServiceCallback getPubAckCallbackSendAttrTelemetry(final T msg) { @@ -108,33 +110,29 @@ public class LwM2mTransportContextServer extends TransportContext { }; } - public void sendParametersOnThingsboard(JsonElement msg, String topicName, SessionInfoProto sessionInfo) { - try { - if (topicName.equals(LwM2mTransportHandler.DEVICE_ATTRIBUTES_TOPIC)) { - PostAttributeMsg postAttributeMsg = adaptor.convertToPostAttributes(msg); - TransportServiceCallback call = this.getPubAckCallbackSendAttrTelemetry(postAttributeMsg); - transportService.process(sessionInfo, postAttributeMsg, this.getPubAckCallbackSendAttrTelemetry(call)); - } else if (topicName.equals(LwM2mTransportHandler.DEVICE_TELEMETRY_TOPIC)) { - PostTelemetryMsg postTelemetryMsg = adaptor.convertToPostTelemetry(msg); - TransportServiceCallback call = this.getPubAckCallbackSendAttrTelemetry(postTelemetryMsg); - transportService.process(sessionInfo, postTelemetryMsg, this.getPubAckCallbackSendAttrTelemetry(call)); - } - } catch (AdaptorException e) { - log.error("[{}] Failed to process publish msg [{}]", topicName, e); - log.info("[{}] Closing current session due to invalid publish", topicName); - } + public void sendParametersOnThingsboardAttribute(List result, SessionInfoProto sessionInfo) { + PostAttributeMsg.Builder request = PostAttributeMsg.newBuilder(); + request.addAllKv(result); + PostAttributeMsg postAttributeMsg = request.build(); + TransportServiceCallback call = this.getPubAckCallbackSendAttrTelemetry(postAttributeMsg); + transportService.process(sessionInfo, postAttributeMsg, this.getPubAckCallbackSendAttrTelemetry(call)); } - public JsonObject getTelemetryMsgObject(String logMsg) { - JsonObject telemetries = new JsonObject(); - telemetries.addProperty(LOG_LW2M_TELEMETRY, logMsg); - return telemetries; + public void sendParametersOnThingsboardTelemetry(List result, SessionInfoProto sessionInfo) { + PostTelemetryMsg.Builder request = PostTelemetryMsg.newBuilder(); + TransportProtos.TsKvListProto.Builder builder = TransportProtos.TsKvListProto.newBuilder(); + builder.setTs(System.currentTimeMillis()); + builder.addAllKv(result); + request.addTsKvList(builder.build()); + PostTelemetryMsg postTelemetryMsg = request.build(); + TransportServiceCallback call = this.getPubAckCallbackSendAttrTelemetry(postTelemetryMsg); + transportService.process(sessionInfo, postTelemetryMsg, this.getPubAckCallbackSendAttrTelemetry(call)); } /** * @return - sessionInfo after access connect client */ - public SessionInfoProto getValidateSessionInfo(ValidateDeviceCredentialsResponseMsg msg, long mostSignificantBits, long leastSignificantBits) { + public SessionInfoProto getValidateSessionInfo(TransportProtos.ValidateDeviceCredentialsResponseMsg msg, long mostSignificantBits, long leastSignificantBits) { return SessionInfoProto.newBuilder() .setNodeId(this.getNodeId()) .setSessionIdMSB(mostSignificantBits) @@ -159,4 +157,90 @@ public class LwM2mTransportContextServer extends TransportContext { return null; } } + + /** + * + * @param logMsg - info about Logs + * @return- KeyValueProto for telemetry (Logs) + */ + public List getKvLogyToThingsboard(String logMsg) { + List result = new ArrayList<>(); + result.add(TransportProtos.KeyValueProto.newBuilder() + .setKey(LOG_LW2M_TELEMETRY) + .setType(TransportProtos.KeyValueType.STRING_V) + .setStringV(logMsg).build()); + return result; + } + + /** + * @return - KeyValueProto for attribute/telemetry (change value) + * @throws CodecException - + */ + + public TransportProtos.KeyValueProto getKvAttrTelemetryToThingsboard(ResourceModel.Type resourceType, String resourceName, Object value, boolean isMultiInstances) { + TransportProtos.KeyValueProto.Builder kvProto = TransportProtos.KeyValueProto.newBuilder().setKey(resourceName); + if (isMultiInstances) { + kvProto.setType(TransportProtos.KeyValueType.JSON_V) + .setJsonV((String) value); + } + else { + switch (resourceType) { + case BOOLEAN: + kvProto.setType(BOOLEAN_V).setBoolV((Boolean) value).build(); + break; + case STRING: + case TIME: + case OPAQUE: + case OBJLNK: + kvProto.setType(TransportProtos.KeyValueType.STRING_V).setStringV((String) value); + break; + case INTEGER: + kvProto.setType(TransportProtos.KeyValueType.LONG_V).setLongV((Long) value); + break; + case FLOAT: + kvProto.setType(TransportProtos.KeyValueType.DOUBLE_V).setDoubleV((Double) value); + } + } + return kvProto.build(); + } + + /** + * + * @param currentType - + * @param resourcePath - + * @return + */ + public ResourceModel.Type getResourceModelTypeEqualsKvProtoValueType(ResourceModel.Type currentType, String resourcePath) { + switch (currentType) { + case BOOLEAN: + return ResourceModel.Type.BOOLEAN; + case STRING: + case TIME: + case OPAQUE: + case OBJLNK: + return ResourceModel.Type.STRING; + case INTEGER: + return ResourceModel.Type.INTEGER; + case FLOAT: + return ResourceModel.Type.FLOAT; + default: + } + throw new CodecException("Invalid ResourceModel_Type for resource %s, got %s", resourcePath, currentType); + } + + public Object getValueFromKvProto (TransportProtos.KeyValueProto kv) { + switch (kv.getType()) { + case BOOLEAN_V: + return kv.getBoolV(); + case LONG_V: + return kv.getLongV(); + case DOUBLE_V: + return kv.getDoubleV(); + case STRING_V: + return kv.getStringV(); + case JSON_V: + return kv.getJsonV(); + } + return null; + } } diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportHandler.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportHandler.java index ce65b072d6..86dd5b7960 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportHandler.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportHandler.java @@ -75,6 +75,8 @@ public class LwM2mTransportHandler { public static final String KEY_NAME = "keyName"; public static final String OBSERVE = "observe"; public static final String ATTRIBUTE_LWM2M = "attributeLwm2m"; + public static final String RESOURCE_VALUE = "resValue"; + public static final String RESOURCE_TYPE = "resType"; private static final String REQUEST = "/request"; private static final String RESPONSE = "/response"; @@ -333,28 +335,30 @@ public class LwM2mTransportHandler { }; } - public static String convertToObjectIdFromIdVer(String key) { + public static String convertPathFromIdVerToObjectId(String pathIdVer) { try { - String[] keyArray = key.split(LWM2M_SEPARATOR_PATH); + String[] keyArray = pathIdVer.split(LWM2M_SEPARATOR_PATH); if (keyArray.length > 1 && keyArray[1].split(LWM2M_SEPARATOR_KEY).length == 2) { keyArray[1] = keyArray[1].split(LWM2M_SEPARATOR_KEY)[0]; return StringUtils.join(keyArray, LWM2M_SEPARATOR_PATH); - } else { - return key; + } + else { + return pathIdVer; } } catch (Exception e) { return null; } } - public static String convertToIdVerFromObjectId(String path, Registration registration) { + public static String convertPathFromObjectIdToIdVer(String path, Registration registration) { String ver = registration.getSupportedObject().get(new LwM2mPath(path).getObjectId()); try { String[] keyArray = path.split(LWM2M_SEPARATOR_PATH); if (keyArray.length > 1) { keyArray[1] = keyArray[1] + LWM2M_SEPARATOR_KEY + ver; return StringUtils.join(keyArray, LWM2M_SEPARATOR_PATH); - } else { + } + else { return path; } } catch (Exception e) { diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportRequest.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportRequest.java index 2c728260d0..d717c90418 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportRequest.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportRequest.java @@ -69,8 +69,8 @@ import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandle import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.PUT_TYPE_OPER_WRITE_ATTRIBUTES; import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.PUT_TYPE_OPER_WRITE_UPDATE; import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.RESPONSE_CHANNEL; -import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.convertToIdVerFromObjectId; -import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.convertToObjectIdFromIdVer; +import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.convertPathFromIdVerToObjectId; +import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.convertPathFromObjectIdToIdVer; import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.createWriteAttributeRequest; @Slf4j @@ -114,7 +114,7 @@ public class LwM2mTransportRequest { */ public void sendAllRequest(Registration registration, String targetIdVer, String typeOper, String contentFormatParam, Observation observation, Object params, long timeoutInMs) { - String target = convertToObjectIdFromIdVer(targetIdVer); + String target = convertPathFromIdVerToObjectId(targetIdVer); LwM2mPath resultIds = new LwM2mPath(target); if (registration != null && resultIds.getObjectId() >= 0) { DownlinkRequest request = null; @@ -204,7 +204,7 @@ public class LwM2mTransportRequest { private void sendRequest(Registration registration, LwM2mClient lwM2MClient, DownlinkRequest request, long timeoutInMs) { leshanServer.send(registration, request, timeoutInMs, (ResponseCallback) response -> { if (!lwM2MClient.isInit()) { - lwM2MClient.initValue(this.serviceImpl, convertToIdVerFromObjectId(request.getPath().toString(), registration)); + lwM2MClient.initValue(this.serviceImpl, convertPathFromObjectIdToIdVer(request.getPath().toString(), registration)); } if (CoAP.ResponseCode.isSuccess(((Response) response.getCoapResponse()).getCode())) { this.handleResponse(registration, request.getPath().toString(), response, request); @@ -228,7 +228,7 @@ public class LwM2mTransportRequest { } }, e -> { if (!lwM2MClient.isInit()) { - lwM2MClient.initValue(this.serviceImpl, convertToIdVerFromObjectId(request.getPath().toString(), registration)); + lwM2MClient.initValue(this.serviceImpl, convertPathFromObjectIdToIdVer(request.getPath().toString(), registration)); } String msg = String.format("%s: sendRequest: Resource path - %s msg error - %s SendRequest to Client", LOG_LW2M_ERROR, request.getPath().toString(), e.toString()); @@ -287,7 +287,7 @@ public class LwM2mTransportRequest { * @param response - */ private void sendResponse(Registration registration, String path, LwM2mResponse response, DownlinkRequest request) { - String pathIdVer = convertToIdVerFromObjectId(path, registration); + String pathIdVer = convertPathFromObjectIdToIdVer(path, registration); if (response instanceof ReadResponse) { serviceImpl.onObservationResponse(registration, pathIdVer, (ReadResponse) response); } else if (response instanceof CancelObservationResponse) { diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportServiceImpl.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportServiceImpl.java index d863504b44..05f386ad1a 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportServiceImpl.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/LwM2mTransportServiceImpl.java @@ -18,6 +18,7 @@ package org.thingsboard.server.transport.lwm2m.server; import com.fasterxml.jackson.core.type.TypeReference; import com.google.common.collect.Sets; import com.google.gson.Gson; +import com.google.gson.GsonBuilder; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; @@ -42,7 +43,6 @@ import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.transport.TransportService; import org.thingsboard.server.common.transport.adaptor.AdaptorException; -import org.thingsboard.server.common.transport.adaptor.JsonConverter; import org.thingsboard.server.common.transport.service.DefaultTransportService; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.gen.transport.TransportProtos.AttributeUpdateNotificationMsg; @@ -52,12 +52,12 @@ import org.thingsboard.server.queue.util.TbLwM2mTransportComponent; import org.thingsboard.server.transport.lwm2m.server.client.LwM2mClient; import org.thingsboard.server.transport.lwm2m.server.client.LwM2mClientContext; import org.thingsboard.server.transport.lwm2m.server.client.LwM2mClientProfile; +import org.thingsboard.server.transport.lwm2m.server.client.ResultsAddKeyValueProto; import org.thingsboard.server.transport.lwm2m.server.client.ResultsAnalyzerParameters; import org.thingsboard.server.transport.lwm2m.utils.LwM2mValueConverterImpl; import javax.annotation.PostConstruct; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collection; import java.util.HashSet; import java.util.LinkedHashSet; @@ -76,24 +76,19 @@ import java.util.stream.Collectors; import static org.eclipse.leshan.core.attributes.Attribute.OBJECT_VERSION; import static org.thingsboard.server.common.data.lwm2m.LwM2mConstants.LWM2M_SEPARATOR_PATH; -import static org.thingsboard.server.common.transport.util.JsonUtils.getJsonObject; import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.CLIENT_NOT_AUTHORIZED; import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.DEVICE_ATTRIBUTES_REQUEST; -import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.DEVICE_ATTRIBUTES_TOPIC; -import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.DEVICE_TELEMETRY_TOPIC; import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.GET_TYPE_OPER_DISCOVER; import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.GET_TYPE_OPER_OBSERVE; import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.GET_TYPE_OPER_READ; import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.LOG_LW2M_ERROR; import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.LOG_LW2M_INFO; -import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.LOG_LW2M_TELEMETRY; import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.LWM2M_STRATEGY_2; import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.POST_TYPE_OPER_EXECUTE; import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.POST_TYPE_OPER_WRITE_REPLACE; import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.PUT_TYPE_OPER_WRITE_ATTRIBUTES; import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.SERVICE_CHANNEL; -import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.convertToIdVerFromObjectId; -import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.convertToObjectIdFromIdVer; +import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.convertPathFromIdVerToObjectId; import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.getAckCallback; import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.validateObjectVerFromKey; @@ -163,6 +158,7 @@ public class LwM2mTransportServiceImpl implements LwM2mTransportService { transportService.registerAsyncSession(sessionInfo, new LwM2mSessionMsgListener(this, sessionInfo)); transportService.process(sessionInfo, DefaultTransportService.getSessionEventMsg(SessionEvent.OPEN), null); transportService.process(sessionInfo, TransportProtos.SubscribeToAttributeUpdatesMsg.newBuilder().build(), null); + transportService.process(sessionInfo, TransportProtos.SubscribeToRPCMsg.newBuilder().build(), null); this.initLwM2mFromClientValue(registration, lwM2MClient); this.sendLogsToThingsboard(LOG_LW2M_INFO + ": Client create after Registration", registration); } else { @@ -309,33 +305,32 @@ public class LwM2mTransportServiceImpl implements LwM2mTransportService { @Override public void onAttributeUpdate(AttributeUpdateNotificationMsg msg, TransportProtos.SessionInfoProto sessionInfo) { if (msg.getSharedUpdatedCount() > 0) { - JsonElement el = JsonConverter.toJson(msg); - el.getAsJsonObject().entrySet().forEach(de -> { - String pathIdVer = this.getPathAttributeUpdate(sessionInfo, de.getKey()); - String value = de.getValue().getAsString(); + msg.getSharedUpdatedList().forEach(tsKvProto -> { + String pathName = tsKvProto.getKv().getKey(); + String pathIdVer = this.validatePathIntoProfile(sessionInfo, pathName); + Object valueNew = this.lwM2mTransportContextServer.getValueFromKvProto(tsKvProto.getKv()); LwM2mClient lwM2MClient = lwM2mClientContext.getLwM2mClient(new UUID(sessionInfo.getSessionIdMSB(), sessionInfo.getSessionIdLSB())); - LwM2mClientProfile clientProfile = lwM2mClientContext.getProfile(new UUID(sessionInfo.getDeviceProfileIdMSB(), sessionInfo.getDeviceProfileIdLSB())); - if (pathIdVer != null && !pathIdVer.isEmpty() && (this.validatePathInAttrProfile(clientProfile, pathIdVer) || this.validatePathInTelemetryProfile(clientProfile, pathIdVer))) { + if (pathIdVer != null) { ResourceModel resourceModel = lwM2MClient.getResourceModel(pathIdVer); if (resourceModel != null && resourceModel.operations.isWritable()) { - lwM2mTransportRequest.sendAllRequest(lwM2MClient.getRegistration(), pathIdVer, POST_TYPE_OPER_WRITE_REPLACE, - ContentFormat.TLV.getName(), null, value, this.lwM2mTransportContextServer.getLwM2MTransportConfigServer().getTimeout()); + this.updateResourcesValueToClient(lwM2MClient, this.getResourceValueFormatKv(lwM2MClient, pathIdVer), valueNew, pathIdVer); } else { - log.error("Resource path - [{}] value - [{}] is not Writable and cannot be updated", pathIdVer, value); + log.error("Resource path - [{}] value - [{}] is not Writable and cannot be updated", pathIdVer, valueNew); String logMsg = String.format("%s: attributeUpdate: Resource path - %s value - %s is not Writable and cannot be updated", - LOG_LW2M_ERROR, pathIdVer, value); + LOG_LW2M_ERROR, pathIdVer, valueNew); this.sendLogsToThingsboard(logMsg, lwM2MClient.getRegistration()); } } else { - log.error("Attribute name - [{}] value - [{}] is not present as attribute in profile and cannot be updated", de.getKey(), value); + log.error("Resource name name - [{}] value - [{}] is not present as attribute/telemetry in profile and cannot be updated", pathName, valueNew); String logMsg = String.format("%s: attributeUpdate: attribute name - %s value - %s is not present as attribute in profile and cannot be updated", - LOG_LW2M_ERROR, de.getKey(), value); + LOG_LW2M_ERROR, pathName, valueNew); this.sendLogsToThingsboard(logMsg, lwM2MClient.getRegistration()); } }); } else if (msg.getSharedDeletedCount() > 0) { log.info("[{}] delete [{}] onAttributeUpdate", msg.getSharedDeletedList(), sessionInfo); } + } /** @@ -462,32 +457,13 @@ public class LwM2mTransportServiceImpl implements LwM2mTransportService { } /** - * @param msg - text msg + * @param logMsg - text msg * @param registration - Id of Registration LwM2M Client */ - public void sendLogsToThingsboard(String msg, Registration registration) { - if (msg != null) { - JsonObject telemetries = new JsonObject(); - telemetries.addProperty(LOG_LW2M_TELEMETRY, msg); - this.updateParametersOnThingsboard(telemetries, DEVICE_TELEMETRY_TOPIC, registration); - } - } - - - /** - * // !!! Ok - * Prepare send to Thigsboard callback - Attribute or Telemetry - * - * @param msg - JsonArray: [{name: value}] - * @param topicName - Api Attribute or Telemetry - * @param registration - Id of Registration LwM2M Client - */ - public void updateParametersOnThingsboard(JsonElement msg, String topicName, Registration registration) { + public void sendLogsToThingsboard(String logMsg, Registration registration) { SessionInfoProto sessionInfo = this.getValidateSessionInfo(registration); - if (sessionInfo != null) { - lwM2mTransportContextServer.sendParametersOnThingsboard(msg, topicName, sessionInfo); - } else { - log.error("Client: [{}] updateParametersOnThingsboard [{}] sessionInfo ", registration, null); + if (logMsg != null && sessionInfo != null) { + this.lwM2mTransportContextServer.sendParametersOnThingsboardTelemetry(this.lwM2mTransportContextServer.getKvLogyToThingsboard(logMsg), sessionInfo); } } @@ -517,7 +493,7 @@ public class LwM2mTransportServiceImpl implements LwM2mTransportService { this.initReadAttrTelemetryObserveToClient(registration, lwM2MClient, GET_TYPE_OPER_READ, clientObjects); this.initReadAttrTelemetryObserveToClient(registration, lwM2MClient, GET_TYPE_OPER_OBSERVE, clientObjects); this.initReadAttrTelemetryObserveToClient(registration, lwM2MClient, PUT_TYPE_OPER_WRITE_ATTRIBUTES, clientObjects); -// this.initReadAttrTelemetryObserveToClient(registration, lwM2MClient, GET_TYPE_OPER_DISCOVER, clientObjects); + this.initReadAttrTelemetryObserveToClient(registration, lwM2MClient, GET_TYPE_OPER_DISCOVER, clientObjects); } } @@ -577,17 +553,20 @@ public class LwM2mTransportServiceImpl implements LwM2mTransportService { * @param registration - Registration LwM2M Client */ private void updateAttrTelemetry(Registration registration, Set paths) { - JsonObject attributes = new JsonObject(); - JsonObject telemetries = new JsonObject(); try { - this.getParametersFromProfile(attributes, telemetries, registration, paths); + ResultsAddKeyValueProto results = getParametersFromProfile(registration, paths); + SessionInfoProto sessionInfo = this.getValidateSessionInfo(registration); + if (results != null && sessionInfo != null) { + if (results.getResultAttributes().size() > 0) { + this.lwM2mTransportContextServer.sendParametersOnThingsboardAttribute(results.getResultAttributes(), sessionInfo); + } + if (results.getResultTelemetries().size() > 0) { + this.lwM2mTransportContextServer.sendParametersOnThingsboardTelemetry(results.getResultTelemetries(), sessionInfo); + } + } } catch (Exception e) { log.error("UpdateAttrTelemetry", e); } - if (attributes.getAsJsonObject().entrySet().size() > 0) - this.updateParametersOnThingsboard(attributes, DEVICE_ATTRIBUTES_TOPIC, registration); - if (telemetries.getAsJsonObject().entrySet().size() > 0) - this.updateParametersOnThingsboard(telemetries, DEVICE_TELEMETRY_TOPIC, registration); } /** @@ -700,73 +679,99 @@ public class LwM2mTransportServiceImpl implements LwM2mTransportService { } /** - * @param registration - - * @return all instances in client - */ - private Set getAllInstancesInClient(Registration registration) { - Set clientInstances = ConcurrentHashMap.newKeySet(); - Arrays.stream(registration.getObjectLinks()).forEach(url -> { - LwM2mPath pathIds = new LwM2mPath(url.getUrl()); - if (pathIds.isObjectInstance()) { - clientInstances.add(convertToIdVerFromObjectId(url.getUrl(), registration)); - } - }); - return (clientInstances.size() > 0) ? clientInstances : null; - } - - /** - * @param attributes - new JsonObject - * @param telemetry - new JsonObject + * // * @param attributes - new JsonObject + * // * @param telemetry - new JsonObject + * * @param registration - Registration LwM2M Client * @param path - */ - private void getParametersFromProfile(JsonObject attributes, JsonObject telemetry, Registration registration, Set path) { + private ResultsAddKeyValueProto getParametersFromProfile(Registration registration, Set path) { if (path != null && path.size() > 0) { + ResultsAddKeyValueProto results = new ResultsAddKeyValueProto(); LwM2mClientProfile lwM2MClientProfile = lwM2mClientContext.getProfile(registration); - lwM2MClientProfile.getPostAttributeProfile().forEach(idVer -> { - if (path.contains(idVer.getAsString())) { - this.addParameters(idVer.getAsString(), attributes, registration); + List resultAttributes = new ArrayList<>(); + lwM2MClientProfile.getPostAttributeProfile().forEach(pathIdVer -> { + if (path.contains(pathIdVer.getAsString())) { + TransportProtos.KeyValueProto kvAttr = this.getKvToThingsboard(pathIdVer.getAsString(), registration); + if (kvAttr != null) { + resultAttributes.add(kvAttr); + } } }); - lwM2MClientProfile.getPostTelemetryProfile().forEach(idVer -> { - if (path.contains(idVer.getAsString())) { - this.addParameters(idVer.getAsString(), telemetry, registration); + List resultTelemetries = new ArrayList<>(); + lwM2MClientProfile.getPostTelemetryProfile().forEach(pathIdVer -> { + if (path.contains(pathIdVer.getAsString())) { + TransportProtos.KeyValueProto kvAttr = this.getKvToThingsboard(pathIdVer.getAsString(), registration); + if (kvAttr != null) { + resultTelemetries.add(kvAttr); + } } }); + if (resultAttributes.size() > 0) { + results.setResultAttributes(resultAttributes); + } + if (resultTelemetries.size() > 0) { + results.setResultTelemetries(resultTelemetries); + } + return results; } + return null; } - /** - * @param parameters - JsonObject attributes/telemetry - * @param registration - Registration LwM2M Client - */ - private void addParameters(String path, JsonObject parameters, Registration registration) { - LwM2mClient lwM2MClient = lwM2mClientContext.getLwM2mClientWithReg(registration, null); +// public TransportProtos.KeyValueProto getKvToThingsboard(String pathIdVer, Registration registration) { +// ResultsResourceValue resultsResourceValue = getResultsResourceValue(pathIdVer, registration); +// return resultsResourceValue != null ? this.lwM2mTransportContextServer.getKvAttrTelemetryToThingsboard(resultsResourceValue) : null; +// } + + private TransportProtos.KeyValueProto getKvToThingsboard(String pathIdVer, Registration registration) { + LwM2mClient lwM2MClient = this.lwM2mClientContext.getLwM2mClientWithReg(null, registration.getId()); JsonObject names = lwM2mClientContext.getProfiles().get(lwM2MClient.getProfileId()).getPostKeyNameProfile(); - if (names != null && names.has(path)) { - String resName = names.get(path).getAsString(); - if (resName != null && !resName.isEmpty()) { + if (names != null && names.has(pathIdVer)) { + String resourceName = names.get(pathIdVer).getAsString(); + if (resourceName != null && !resourceName.isEmpty()) { try { - String resValue = this.getResourceValueToString(lwM2MClient, path); - parameters.addProperty(resName, resValue); + LwM2mResource resourceValue = lwM2MClient != null ? getResourceValueFromLwM2MClient(lwM2MClient, pathIdVer) : null; + if (resourceValue != null) { + ResourceModel.Type currentType = resourceValue.getType(); + ResourceModel.Type expectedType = this.lwM2mTransportContextServer.getResourceModelTypeEqualsKvProtoValueType(currentType, pathIdVer); + Object valueKvProto = null; + if (resourceValue.isMultiInstances()) { + valueKvProto = new JsonObject(); + Object finalvalueKvProto = valueKvProto; + Gson gson = new GsonBuilder().create(); + resourceValue.getValues().forEach((k, v) -> { + Object val = this.converter.convertValue(resourceValue.getValue(), currentType, expectedType, + new LwM2mPath(convertPathFromIdVerToObjectId(pathIdVer))); + JsonElement element = gson.toJsonTree(val, val.getClass()); + ((JsonObject) finalvalueKvProto).add(String.valueOf(k), element); + }); + valueKvProto = gson.toJson(valueKvProto); + } else { + valueKvProto = this.converter.convertValue(resourceValue.getValue(), currentType, expectedType, + new LwM2mPath(convertPathFromIdVerToObjectId(pathIdVer))); + } + return valueKvProto != null ? this.lwM2mTransportContextServer.getKvAttrTelemetryToThingsboard(currentType, resourceName, valueKvProto, resourceValue.isMultiInstances()) : null; + } } catch (Exception e) { log.error("Failed to add parameters.", e); } } } else { - log.error("Failed to add parameters. path: [{}], names: [{}]", path, names); + log.error("Failed to add parameters. path: [{}], names: [{}]", pathIdVer, names); } + return null; } /** - * @param path - path resource - * @return - value of Resource or null + * @param pathIdVer - path resource + * @return - value of Resource into format KvProto or null */ - private String getResourceValueToString(LwM2mClient lwM2MClient, String path) { - LwM2mPath pathIds = new LwM2mPath(convertToObjectIdFromIdVer(path)); - LwM2mResource resourceValue = this.returnResourceValueFromLwM2MClient(lwM2MClient, path); - return resourceValue == null ? null : - this.converter.convertValue(resourceValue.isMultiInstances() ? resourceValue.getValues() : resourceValue.getValue(), resourceValue.getType(), ResourceModel.Type.STRING, pathIds).toString(); + private Object getResourceValueFormatKv(LwM2mClient lwM2MClient, String pathIdVer) { + LwM2mResource resourceValue = this.getResourceValueFromLwM2MClient(lwM2MClient, pathIdVer); + ResourceModel.Type currentType = resourceValue.getType(); + ResourceModel.Type expectedType = this.lwM2mTransportContextServer.getResourceModelTypeEqualsKvProtoValueType(currentType, pathIdVer); + return this.converter.convertValue(resourceValue.getValue(), currentType, expectedType, + new LwM2mPath(convertPathFromIdVerToObjectId(pathIdVer))); } /** @@ -774,9 +779,9 @@ public class LwM2mTransportServiceImpl implements LwM2mTransportService { * @param path - * @return - return value of Resource by idPath */ - private LwM2mResource returnResourceValueFromLwM2MClient(LwM2mClient lwM2MClient, String path) { + private LwM2mResource getResourceValueFromLwM2MClient(LwM2mClient lwM2MClient, String path) { LwM2mResource resourceValue = null; - if (new LwM2mPath(convertToObjectIdFromIdVer(path)).isResource()) { + if (new LwM2mPath(convertPathFromIdVerToObjectId(path)).isResource()) { resourceValue = lwM2MClient.getResources().get(path).getLwM2mResource(); } return resourceValue; @@ -959,7 +964,7 @@ public class LwM2mTransportServiceImpl implements LwM2mTransportService { */ private void readResourceValueObserve(Registration registration, Set targets, String typeOper) { targets.forEach(target -> { - LwM2mPath pathIds = new LwM2mPath(convertToObjectIdFromIdVer(target)); + LwM2mPath pathIds = new LwM2mPath(convertPathFromIdVerToObjectId(target)); if (pathIds.isResource()) { if (GET_TYPE_OPER_READ.equals(typeOper)) { lwM2mTransportRequest.sendAllRequest(registration, target, typeOper, @@ -1050,19 +1055,23 @@ public class LwM2mTransportServiceImpl implements LwM2mTransportService { private void cancelObserveIsValue(Registration registration, Set paramAnallyzer) { LwM2mClient lwM2MClient = lwM2mClientContext.getLwM2mClientWithReg(registration, null); paramAnallyzer.forEach(p -> { - if (this.returnResourceValueFromLwM2MClient(lwM2MClient, p) != null) { - this.setCancelObservationRecourse(registration, convertToObjectIdFromIdVer(p)); + if (this.getResourceValueFromLwM2MClient(lwM2MClient, p) != null) { + this.setCancelObservationRecourse(registration, convertPathFromIdVerToObjectId(p)); } } ); } - private void putDelayedUpdateResourcesClient(LwM2mClient lwM2MClient, Object valueOld, Object valueNew, String path) { + private void updateResourcesValueToClient(LwM2mClient lwM2MClient, Object valueOld, Object valueNew, String path) { if (valueNew != null && (valueOld == null || !valueNew.toString().equals(valueOld.toString()))) { lwM2mTransportRequest.sendAllRequest(lwM2MClient.getRegistration(), path, POST_TYPE_OPER_WRITE_REPLACE, ContentFormat.TLV.getName(), null, valueNew, this.lwM2mTransportContextServer.getLwM2MTransportConfigServer().getTimeout()); } else { - log.error("05 delayError"); + log.error("Failed update resource [{}] [{}]", path, valueNew); + String logMsg = String.format("%s: Failed update resource path - %s value - %s. Value is not changed or bad", + LOG_LW2M_ERROR, path, valueNew); + this.sendLogsToThingsboard(logMsg, lwM2MClient.getRegistration()); + log.info("Failed update resource [{}] [{}]", path, valueNew); } } @@ -1082,9 +1091,9 @@ public class LwM2mTransportServiceImpl implements LwM2mTransportService { * @param name - * @return path if path isPresent in postProfile */ - private String getPathAttributeUpdate(TransportProtos.SessionInfoProto sessionInfo, String name) { - String profilePath = this.getPathAttributeUpdateProfile(sessionInfo, name); - return !profilePath.isEmpty() ? profilePath : null; + private String validatePathIntoProfile(TransportProtos.SessionInfoProto sessionInfo, String name) { + String pathIdVer = this.getPresentPathIntoProfile(sessionInfo, name); + return !pathIdVer.isEmpty() ? pathIdVer : null; } /** @@ -1094,7 +1103,7 @@ public class LwM2mTransportServiceImpl implements LwM2mTransportService { * @param name - * @return - */ - private String getPathAttributeUpdateProfile(TransportProtos.SessionInfoProto sessionInfo, String name) { + private String getPresentPathIntoProfile(TransportProtos.SessionInfoProto sessionInfo, String name) { LwM2mClientProfile profile = lwM2mClientContext.getProfile(new UUID(sessionInfo.getDeviceProfileIdMSB(), sessionInfo.getDeviceProfileIdLSB())); LwM2mClient lwM2mClient = lwM2mClientContext.getLwM2MClient(sessionInfo); return profile.getPostKeyNameProfile().getAsJsonObject().entrySet().stream() @@ -1105,40 +1114,49 @@ public class LwM2mTransportServiceImpl implements LwM2mTransportService { /** * Update resource value on client: if there is a difference in values between the current resource values and the shared attribute values * #1 Get path resource by result attributesResponse - * #1.1 If two names have equal path => last time attribute - * #2.1 if there is a difference in values between the current resource values and the shared attribute values - * => send to client Request Update of value (new value from shared attribute) - * and LwM2MClient.delayedRequests.add(path) - * #2.1 if there is not a difference in values between the current resource values and the shared attribute values * * @param attributesResponse - * @param sessionInfo - */ public void onGetAttributesResponse(TransportProtos.GetAttributeResponseMsg attributesResponse, TransportProtos.SessionInfoProto sessionInfo) { try { - LwM2mClient lwM2MClient = lwM2mClientContext.getLwM2MClient(sessionInfo); - attributesResponse.getSharedAttributeListList().forEach(attr -> { - String path = this.getPathAttributeUpdate(sessionInfo, attr.getKv().getKey()); - if (path != null) { - // #1.1 - if (lwM2MClient.getDelayedRequests().containsKey(path) && attr.getTs() > lwM2MClient.getDelayedRequests().get(path).getTs()) { - lwM2MClient.getDelayedRequests().put(path, attr); - } else { - lwM2MClient.getDelayedRequests().put(path, attr); - } - } - }); - // #2.1 - lwM2MClient.getDelayedRequests().forEach((k, v) -> { - ArrayList listV = new ArrayList<>(); - listV.add(v.getKv()); - this.putDelayedUpdateResourcesClient(lwM2MClient, this.getResourceValueToString(lwM2MClient, k), getJsonObject(listV).get(v.getKv().getKey()), k); - }); + List tsKvProtos = attributesResponse.getSharedAttributeListList(); + this.updateAttriuteFromThingsboard(tsKvProtos, sessionInfo); } catch (Exception e) { log.error(String.valueOf(e)); } } + /** + * #1.1 If two names have equal path => last time attribute + * #2.1 if there is a difference in values between the current resource values and the shared attribute values + * => send to client Request Update of value (new value from shared attribute) + * and LwM2MClient.delayedRequests.add(path) + * #2.1 if there is not a difference in values between the current resource values and the shared attribute values + * + * @param tsKvProtos + * @param sessionInfo + */ + public void updateAttriuteFromThingsboard(List tsKvProtos, TransportProtos.SessionInfoProto sessionInfo) { + LwM2mClient lwM2MClient = lwM2mClientContext.getLwM2MClient(sessionInfo); + tsKvProtos.forEach(tsKvProto -> { + String pathIdVer = this.validatePathIntoProfile(sessionInfo, tsKvProto.getKv().getKey()); + if (pathIdVer != null) { + // #1.1 + if (lwM2MClient.getDelayedRequests().containsKey(pathIdVer) && tsKvProto.getTs() > lwM2MClient.getDelayedRequests().get(pathIdVer).getTs()) { + lwM2MClient.getDelayedRequests().put(pathIdVer, tsKvProto); + } else if (!lwM2MClient.getDelayedRequests().containsKey(pathIdVer)) { + lwM2MClient.getDelayedRequests().put(pathIdVer, tsKvProto); + } + } + }); + // #2.1 + lwM2MClient.getDelayedRequests().forEach((pathIdVer, tsKvProto) -> { + this.updateResourcesValueToClient(lwM2MClient, this.getResourceValueFormatKv(lwM2MClient, pathIdVer), + this.lwM2mTransportContextServer.getValueFromKvProto(tsKvProto.getKv()), pathIdVer); + }); + } + /** * @param lwM2MClient - * @return SessionInfoProto - @@ -1257,7 +1275,7 @@ public class LwM2mTransportServiceImpl implements LwM2mTransportService { private boolean validateResourceInModel(LwM2mClient lwM2mClient, String pathIdVer, boolean isWritableNotOptional) { ResourceModel resourceModel = lwM2mClient.getResourceModel(pathIdVer); - Integer objectId = new LwM2mPath(convertToObjectIdFromIdVer(pathIdVer)).getObjectId(); + Integer objectId = new LwM2mPath(convertPathFromIdVerToObjectId(pathIdVer)).getObjectId(); String objectVer = validateObjectVerFromKey(pathIdVer); return resourceModel != null && (isWritableNotOptional ? objectId != null && objectVer != null && objectVer.equals(lwM2mClient.getRegistration().getSupportedVersion(objectId)) && resourceModel.operations.isWritable() : diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClient.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClient.java index 56e8fd5c7b..5e736d8e0f 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClient.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClient.java @@ -39,7 +39,7 @@ import java.util.concurrent.CopyOnWriteArrayList; import java.util.stream.Collectors; import static org.thingsboard.server.common.data.lwm2m.LwM2mConstants.LWM2M_SEPARATOR_PATH; -import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.convertToObjectIdFromIdVer; +import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.convertPathFromIdVerToObjectId; @Slf4j @Data @@ -83,7 +83,7 @@ public class LwM2mClient implements Cloneable { this.resources.get(pathRez).setLwM2mResource(rez); return true; } else { - LwM2mPath pathIds = new LwM2mPath(convertToObjectIdFromIdVer(pathRez)); + LwM2mPath pathIds = new LwM2mPath(convertPathFromIdVerToObjectId(pathRez)); ResourceModel resourceModel = modelProvider.getObjectModel(registration).getResourceModel(pathIds.getObjectId(), pathIds.getResourceId()); if (resourceModel != null) { this.resources.put(pathRez, new ResourceValue(rez, resourceModel)); @@ -110,7 +110,7 @@ public class LwM2mClient implements Cloneable { public void deleteResources(String pathIdVer, LwM2mModelProvider modelProvider) { Set key = getKeysEqualsIdVer(pathIdVer); key.forEach(pathRez -> { - LwM2mPath pathIds = new LwM2mPath(convertToObjectIdFromIdVer(pathRez)); + LwM2mPath pathIds = new LwM2mPath(convertPathFromIdVerToObjectId(pathRez)); ResourceModel resourceModel = modelProvider.getObjectModel(registration).getResourceModel(pathIds.getObjectId(), pathIds.getResourceId()); if (resourceModel != null) { this.resources.get(pathRez).setResourceModel(resourceModel); @@ -132,7 +132,7 @@ public class LwM2mClient implements Cloneable { } private void saveResourceModel(String pathRez, LwM2mModelProvider modelProvider) { - LwM2mPath pathIds = new LwM2mPath(convertToObjectIdFromIdVer(pathRez)); + LwM2mPath pathIds = new LwM2mPath(convertPathFromIdVerToObjectId(pathRez)); ResourceModel resourceModel = modelProvider.getObjectModel(registration).getResourceModel(pathIds.getObjectId(), pathIds.getResourceId()); this.resources.get(pathRez).setResourceModel(resourceModel); } diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClientContextImpl.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClientContextImpl.java index 1ce16b9b92..cca729f5ff 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClientContextImpl.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/LwM2mClientContextImpl.java @@ -35,7 +35,7 @@ import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import static org.thingsboard.server.transport.lwm2m.secure.LwM2MSecurityMode.NO_SEC; -import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.convertToIdVerFromObjectId; +import static org.thingsboard.server.transport.lwm2m.server.LwM2mTransportHandler.convertPathFromObjectIdToIdVer; @Service @TbLwM2mTransportComponent @@ -185,7 +185,7 @@ public class LwM2mClientContextImpl implements LwM2mClientContext { Arrays.stream(registration.getObjectLinks()).forEach(url -> { LwM2mPath pathIds = new LwM2mPath(url.getUrl()); if (!pathIds.isRoot()) { - clientObjects.add(convertToIdVerFromObjectId(url.getUrl(), registration)); + clientObjects.add(convertPathFromObjectIdToIdVer(url.getUrl(), registration)); } }); return (clientObjects.size() > 0) ? clientObjects : null; diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/ResultsAddKeyValueProto.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/ResultsAddKeyValueProto.java new file mode 100644 index 0000000000..5c7edbe328 --- /dev/null +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/ResultsAddKeyValueProto.java @@ -0,0 +1,34 @@ +/** + * Copyright © 2016-2021 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.lwm2m.server.client; + +import lombok.Data; +import org.thingsboard.server.gen.transport.TransportProtos; + +import java.util.ArrayList; +import java.util.List; + +@Data +public class ResultsAddKeyValueProto { + List resultAttributes; + List resultTelemetries; + + public ResultsAddKeyValueProto() { + this.resultAttributes = new ArrayList<>(); + this.resultTelemetries = new ArrayList<>(); + } + +} diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/ResultsResourceValue.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/ResultsResourceValue.java new file mode 100644 index 0000000000..36cd8943fb --- /dev/null +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/client/ResultsResourceValue.java @@ -0,0 +1,32 @@ +/** + * Copyright © 2016-2021 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.lwm2m.server.client; + +import lombok.Data; +import org.thingsboard.server.common.data.kv.DataType; + +@Data +public class ResultsResourceValue { + DataType dataType; + Object value; + String resourceName; + + public ResultsResourceValue (DataType dataType, Object value, String resourceName) { + this.dataType = dataType; + this.value = value; + this.resourceName = resourceName; + } +} diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/utils/LwM2mValueConverterImpl.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/utils/LwM2mValueConverterImpl.java index 0faa20952e..dce34c9cf6 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/utils/LwM2mValueConverterImpl.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/utils/LwM2mValueConverterImpl.java @@ -18,14 +18,12 @@ package org.thingsboard.server.transport.lwm2m.utils; import lombok.extern.slf4j.Slf4j; import org.eclipse.leshan.core.model.ResourceModel.Type; import org.eclipse.leshan.core.node.LwM2mPath; +import org.eclipse.leshan.core.node.ObjectLink; import org.eclipse.leshan.core.node.codec.CodecException; import org.eclipse.leshan.core.node.codec.LwM2mValueConverter; import org.eclipse.leshan.core.util.Hex; import org.eclipse.leshan.core.util.StringUtils; -import javax.xml.datatype.DatatypeConfigurationException; -import javax.xml.datatype.DatatypeFactory; -import javax.xml.datatype.XMLGregorianCalendar; import java.math.BigInteger; import java.text.DateFormat; import java.text.SimpleDateFormat; @@ -111,15 +109,16 @@ public class LwM2mValueConverterImpl implements LwM2mValueConverter { case INTEGER: log.debug("Trying to convert long value {} to date", value); /** let's assume we received the millisecond since 1970/1/1 */ - return new Date((Long) value); + return new Date(((Number) value).longValue() * 1000L); case STRING: log.debug("Trying to convert string value {} to date", value); /** let's assume we received an ISO 8601 format date */ try { - DatatypeFactory datatypeFactory = DatatypeFactory.newInstance(); - XMLGregorianCalendar cal = datatypeFactory.newXMLGregorianCalendar((String) value); - return cal.toGregorianCalendar().getTime(); - } catch (DatatypeConfigurationException | IllegalArgumentException e) { + return new Date(Long.decode(value.toString())); +// DatatypeFactory datatypeFactory = DatatypeFactory.newInstance(); +// XMLGregorianCalendar cal = datatypeFactory.newXMLGregorianCalendar((String) value); +// return cal.toGregorianCalendar().getTime(); + } catch (IllegalArgumentException e) { log.debug("Unable to convert string to date", e); throw new CodecException("Unable to convert string (%s) to date for resource %s", value, resourcePath); @@ -147,6 +146,8 @@ public class LwM2mValueConverterImpl implements LwM2mValueConverter { return formatter.format(new Date(timeValue)); case OPAQUE: return Hex.encodeHexString((byte[])value); + case OBJLNK: + return ObjectLink.decodeFromString((String) value); default: break; } @@ -164,10 +165,14 @@ public class LwM2mValueConverterImpl implements LwM2mValueConverter { } } break; + case OBJLNK: + if (currentType == Type.STRING) { + return ObjectLink.fromPath(value.toString()); + } default: } throw new CodecException("Invalid value type for resource %s, expected %s, got %s", resourcePath, expectedType, currentType); } -} + } diff --git a/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml b/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml index ec63dcae75..922aed5450 100644 --- a/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml +++ b/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml @@ -119,6 +119,18 @@ redis: # LWM2M server parameters transport: + sessions: + inactivity_timeout: "${TB_TRANSPORT_SESSIONS_INACTIVITY_TIMEOUT:300000}" + report_timeout: "${TB_TRANSPORT_SESSIONS_REPORT_TIMEOUT:30000}" + json: + # Cast String data types to Numeric if possible when processing Telemetry/Attributes JSON + type_cast_enabled: "${JSON_TYPE_CAST_ENABLED:false}" + # Maximum allowed string value length when processing Telemetry/Attributes JSON (0 value disables string value length check) + max_string_value_length: "${JSON_MAX_STRING_VALUE_LENGTH:0}" + client_side_rpc: + timeout: "${CLIENT_SIDE_RPC_TIMEOUT:60000}" + # Enable/disable http/mqtt/coap transport protocols (has higher priority than certain protocol's 'enabled' property) + api_enabled: "${TB_TRANSPORT_API_ENABLED:true}" # Local LwM2M transport parameters lwm2m: # Enable/disable lvm2m transport protocol. @@ -180,15 +192,6 @@ transport: # Use redis for Security and Registration stores redis.enabled: "${LWM2M_REDIS_ENABLED:false}" - sessions: - inactivity_timeout: "${TB_TRANSPORT_SESSIONS_INACTIVITY_TIMEOUT:300000}" - report_timeout: "${TB_TRANSPORT_SESSIONS_REPORT_TIMEOUT:30000}" - json: - # Cast String data types to Numeric if possible when processing Telemetry/Attributes JSON - type_cast_enabled: "${JSON_TYPE_CAST_ENABLED:true}" - # Maximum allowed string value length when processing Telemetry/Attributes JSON (0 value disables string value length check) - max_string_value_length: "${JSON_MAX_STRING_VALUE_LENGTH:0}" - queue: type: "${TB_QUEUE_TYPE:kafka}" # kafka (Apache Kafka) or aws-sqs (AWS SQS) or pubsub (PubSub) or service-bus (Azure Service Bus) or rabbitmq (RabbitMQ) kafka: diff --git a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-device-profile-transport-configuration.component.html b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-device-profile-transport-configuration.component.html index 84797bcb98..5363d05d74 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-device-profile-transport-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-device-profile-transport-configuration.component.html @@ -82,8 +82,6 @@ {{ 'device-profile.lwm2m.required' | translate }} - -
{{ 'device-profile.lwm2m.default-min-period' | translate }} @@ -93,13 +91,16 @@ {{ 'device-profile.lwm2m.required' | translate }} - +
+
+ {{ 'device-profile.lwm2m.binding' | translate }} - - - {{ 'device-profile.lwm2m.binding' | translate }} - {{ 'device-profile.lwm2m.required' | translate }} - + + + {{ bindingModeTypeNamesMap.get(bindingModeType[bindingMode]) }} + +
diff --git a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-device-profile-transport-configuration.component.ts b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-device-profile-transport-configuration.component.ts index a9805c528b..d6f6823db6 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-device-profile-transport-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-device-profile-transport-configuration.component.ts @@ -22,7 +22,8 @@ import { AppState } from '@app/core/core.state'; import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { ATTRIBUTE, - DEFAULT_BINDING, + BINDING_MODE, + BINDING_MODE_NAMES, getDefaultProfileConfig, INSTANCES, KEY_NAME, @@ -55,6 +56,9 @@ export class Lwm2mDeviceProfileTransportConfigurationComponent implements Contro private requiredValue: boolean; private disabled = false; + bindingModeType = BINDING_MODE; + bindingModeTypes = Object.keys(BINDING_MODE); + bindingModeTypeNamesMap = BINDING_MODE_NAMES; lwm2mDeviceProfileFormGroup: FormGroup; lwm2mDeviceConfigFormGroup: FormGroup; bootstrapServers: string; @@ -86,7 +90,7 @@ export class Lwm2mDeviceProfileTransportConfigurationComponent implements Contro lifetime: [null, Validators.required], defaultMinPeriod: [null, Validators.required], notifIfDisabled: [true, []], - binding: [DEFAULT_BINDING, Validators.required], + binding:[], bootstrapServer: [null, Validators.required], lwm2mServer: [null, Validators.required], }); diff --git a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-profile-config.models.ts b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-profile-config.models.ts index 678f7e260a..ff927bc09f 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-profile-config.models.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-profile-config.models.ts @@ -44,6 +44,35 @@ export const KEY_REGEXP_NUMBER = /^(\-?|\+?)\d*$/; export const INSTANCES_ID_VALUE_MIN = 0; export const INSTANCES_ID_VALUE_MAX = 65535; + +export enum BINDING_MODE { + U = 'U', + UQ = 'UQ', + T = 'T', + TQ = 'TQ', + S = 'S', + SQ = 'SQ', + US = 'US', + TS = 'TS', + UQS = 'UQS', + TQS = 'TQS' +} + +export const BINDING_MODE_NAMES = new Map( + [ + [BINDING_MODE.U, 'U: UDP connection in standard mode'], + [BINDING_MODE.UQ, 'UQ: UDP connection in queue mode'], + [BINDING_MODE.US, 'US: both UDP and SMS connections active, both in standard mode'], + [BINDING_MODE.UQS, 'UQS: both UDP and SMS connections active; UDP in queue mode, SMS in standard mode'], + [BINDING_MODE.T,'T: TCP connection in standard mode'], + [BINDING_MODE.TQ, 'TQ: TCP connection in queue mode'], + [BINDING_MODE.TS, 'TS: both TCP and SMS connections active, both in standard mode'], + [BINDING_MODE.TQS, 'TQS: both TCP and SMS connections active; TCP in queue mode, SMS in standard mode'], + [BINDING_MODE.S, 'S: SMS connection in standard mode'], + [BINDING_MODE.SQ, 'SQ: SMS connection in queue mode'] + ] +); + export enum ATTRIBUTE_LWM2M_ENUM { dim = 'dim', ver = 'ver', @@ -62,8 +91,7 @@ export const ATTRIBUTE_LWM2M_LABEL = new Map( [ATTRIBUTE_LWM2M_ENUM.pmax, 'pmax='], [ATTRIBUTE_LWM2M_ENUM.gt, '>'], [ATTRIBUTE_LWM2M_ENUM.lt, '<'], - [ATTRIBUTE_LWM2M_ENUM.st, 'st='], - + [ATTRIBUTE_LWM2M_ENUM.st, 'st='] ] ); From 321fb731a6537399a6e4572837967fe41cdaf58a Mon Sep 17 00:00:00 2001 From: ShvaykaD Date: Tue, 20 Apr 2021 11:14:27 +0300 Subject: [PATCH 69/69] Support of server-side RPC requests via protobuf for CoAP and MQTT * rpc response schema added * rpc request proto * fixed device profile validation * fix error messages --- .../AbstractTransportIntegrationTest.java | 42 +++--- .../coap/AbstractCoapIntegrationTest.java | 24 +++- ...AttributesRequestProtoIntegrationTest.java | 2 +- .../AbstractCoapProvisionJsonDeviceTest.java | 12 +- .../AbstractCoapProvisionProtoDeviceTest.java | 12 +- ...tractCoapServerSideRpcIntegrationTest.java | 6 +- ...tCoapServerSideRpcJsonIntegrationTest.java | 4 +- ...CoapServerSideRpcProtoIntegrationTest.java | 130 +++++++++++++++--- ...actCoapTimeseriesProtoIntegrationTest.java | 2 +- .../mqtt/AbstractMqttIntegrationTest.java | 26 +++- ...AttributesRequestProtoIntegrationTest.java | 2 +- .../AbstractMqttProvisionJsonDeviceTest.java | 14 +- .../AbstractMqttProvisionProtoDeviceTest.java | 14 +- ...tractMqttServerSideRpcIntegrationTest.java | 2 +- ...MqttServerSideRpcProtoIntegrationTest.java | 96 ++++++++++++- ...actMqttTimeseriesProtoIntegrationTest.java | 6 +- .../ProtoTransportPayloadConfiguration.java | 55 +++++++- .../transport/coap/CoapTransportResource.java | 42 ++++-- .../coap/adaptors/CoapTransportAdaptor.java | 5 +- .../coap/adaptors/JsonCoapAdaptor.java | 5 +- .../coap/adaptors/ProtoCoapAdaptor.java | 15 +- .../mqtt/adaptors/ProtoMqttAdaptor.java | 22 ++- .../mqtt/session/DeviceSessionCtx.java | 13 ++ .../transport/adaptor/AdaptorException.java | 4 + .../transport/adaptor/ProtoConverter.java | 38 +++-- .../dao/device/DeviceProfileServiceImpl.java | 54 +++++++- ...ile-transport-configuration.component.html | 15 ++ ...ofile-transport-configuration.component.ts | 6 +- ...ile-transport-configuration.component.html | 15 ++ ...ofile-transport-configuration.component.ts | 14 +- ui-ngx/src/app/shared/models/device.models.ts | 31 ++++- .../assets/locale/locale.constant-en_US.json | 5 + 32 files changed, 568 insertions(+), 165 deletions(-) diff --git a/application/src/test/java/org/thingsboard/server/transport/AbstractTransportIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/AbstractTransportIntegrationTest.java index 89ef53b19d..74523852a9 100644 --- a/application/src/test/java/org/thingsboard/server/transport/AbstractTransportIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/AbstractTransportIntegrationTest.java @@ -15,35 +15,11 @@ */ package org.thingsboard.server.transport; -import com.fasterxml.jackson.databind.node.ObjectNode; import lombok.extern.slf4j.Slf4j; -import org.eclipse.paho.client.mqttv3.MqttAsyncClient; -import org.eclipse.paho.client.mqttv3.MqttConnectOptions; -import org.eclipse.paho.client.mqttv3.MqttException; -import org.eclipse.paho.client.mqttv3.MqttMessage; -import org.eclipse.paho.client.mqttv3.persist.MemoryPersistence; -import org.junit.Assert; -import org.springframework.util.StringUtils; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; -import org.thingsboard.server.common.data.DeviceProfileProvisionType; -import org.thingsboard.server.common.data.DeviceProfileType; -import org.thingsboard.server.common.data.DeviceTransportType; import org.thingsboard.server.common.data.Tenant; -import org.thingsboard.server.common.data.TransportPayloadType; import org.thingsboard.server.common.data.User; -import org.thingsboard.server.common.data.device.profile.AllowCreateNewDevicesDeviceProfileProvisionConfiguration; -import org.thingsboard.server.common.data.device.profile.CheckPreProvisionedDevicesDeviceProfileProvisionConfiguration; -import org.thingsboard.server.common.data.device.profile.DefaultDeviceProfileConfiguration; -import org.thingsboard.server.common.data.device.profile.DeviceProfileData; -import org.thingsboard.server.common.data.device.profile.DeviceProfileProvisionConfiguration; -import org.thingsboard.server.common.data.device.profile.DisabledDeviceProfileProvisionConfiguration; -import org.thingsboard.server.common.data.device.profile.JsonTransportPayloadConfiguration; -import org.thingsboard.server.common.data.device.profile.MqttDeviceProfileTransportConfiguration; -import org.thingsboard.server.common.data.device.profile.ProtoTransportPayloadConfiguration; -import org.thingsboard.server.common.data.device.profile.TransportPayloadTypeConfiguration; -import org.thingsboard.server.common.data.security.Authority; -import org.thingsboard.server.common.data.security.DeviceCredentials; import org.thingsboard.server.controller.AbstractControllerTest; import org.thingsboard.server.gen.transport.TransportProtos; @@ -51,8 +27,6 @@ import java.util.ArrayList; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertNotNull; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @Slf4j @@ -105,6 +79,22 @@ public abstract class AbstractTransportIntegrationTest extends AbstractControlle " }\n" + "}"; + protected static final String DEVICE_RPC_RESPONSE_PROTO_SCHEMA = "syntax =\"proto3\";\n" + + "package rpc;\n" + + "\n" + + "message RpcResponseMsg {\n" + + " string payload = 1;\n" + + "}"; + + protected static final String DEVICE_RPC_REQUEST_PROTO_SCHEMA = "syntax =\"proto3\";\n" + + "package rpc;\n" + + "\n" + + "message RpcRequestMsg {\n" + + " string method = 1;\n" + + " int32 requestId = 2;\n" + + " string params = 3;\n" + + "}"; + protected Tenant savedTenant; protected User tenantAdmin; diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/AbstractCoapIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/AbstractCoapIntegrationTest.java index ff64d70423..83023e21b9 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/AbstractCoapIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/AbstractCoapIntegrationTest.java @@ -53,7 +53,7 @@ import static org.junit.Assert.assertNotNull; public abstract class AbstractCoapIntegrationTest extends AbstractTransportIntegrationTest { protected void processBeforeTest(String deviceName, CoapDeviceType coapDeviceType, TransportPayloadType payloadType) throws Exception { - this.processBeforeTest(deviceName, coapDeviceType, payloadType, null, null, DeviceProfileProvisionType.DISABLED, null, null); + this.processBeforeTest(deviceName, coapDeviceType, payloadType, null, null, null, null, null, null, DeviceProfileProvisionType.DISABLED); } protected void processBeforeTest(String deviceName, @@ -61,8 +61,11 @@ public abstract class AbstractCoapIntegrationTest extends AbstractTransportInteg TransportPayloadType payloadType, String telemetryProtoSchema, String attributesProtoSchema, - DeviceProfileProvisionType provisionType, - String provisionKey, String provisionSecret + String rpcResponseProtoSchema, + String rpcRequestProtoSchema, + String provisionKey, + String provisionSecret, + DeviceProfileProvisionType provisionType ) throws Exception { loginSysAdmin(); @@ -85,7 +88,7 @@ public abstract class AbstractCoapIntegrationTest extends AbstractTransportInteg device.setType("default"); if (coapDeviceType != null) { - DeviceProfile coapDeviceProfile = createCoapDeviceProfile(payloadType, coapDeviceType, attributesProtoSchema, provisionType, provisionKey, provisionSecret, telemetryProtoSchema); + DeviceProfile coapDeviceProfile = createCoapDeviceProfile(payloadType, coapDeviceType, provisionSecret, provisionType, provisionKey, attributesProtoSchema, telemetryProtoSchema, rpcResponseProtoSchema, rpcRequestProtoSchema); deviceProfile = doPost("/api/deviceProfile", coapDeviceProfile, DeviceProfile.class); device.setType(deviceProfile.getName()); device.setDeviceProfileId(deviceProfile.getId()); @@ -103,8 +106,9 @@ public abstract class AbstractCoapIntegrationTest extends AbstractTransportInteg } protected DeviceProfile createCoapDeviceProfile(TransportPayloadType transportPayloadType, CoapDeviceType coapDeviceType, - String attributesProtoSchema, DeviceProfileProvisionType provisionType, - String provisionKey, String provisionSecret, String telemetryProtoSchema) { + String provisionSecret, DeviceProfileProvisionType provisionType, + String provisionKey, String attributesProtoSchema, + String telemetryProtoSchema, String rpcResponseProtoSchema, String rpcRequestProtoSchema) { DeviceProfile deviceProfile = new DeviceProfile(); deviceProfile.setName(transportPayloadType.name()); deviceProfile.setType(DeviceProfileType.DEFAULT); @@ -127,8 +131,16 @@ public abstract class AbstractCoapIntegrationTest extends AbstractTransportInteg if (StringUtils.isEmpty(attributesProtoSchema)) { attributesProtoSchema = DEVICE_ATTRIBUTES_PROTO_SCHEMA; } + if (StringUtils.isEmpty(rpcResponseProtoSchema)) { + rpcResponseProtoSchema = DEVICE_RPC_RESPONSE_PROTO_SCHEMA; + } + if (StringUtils.isEmpty(rpcRequestProtoSchema)) { + rpcRequestProtoSchema = DEVICE_RPC_REQUEST_PROTO_SCHEMA; + } protoTransportPayloadConfiguration.setDeviceTelemetryProtoSchema(telemetryProtoSchema); protoTransportPayloadConfiguration.setDeviceAttributesProtoSchema(attributesProtoSchema); + protoTransportPayloadConfiguration.setDeviceRpcResponseProtoSchema(rpcResponseProtoSchema); + protoTransportPayloadConfiguration.setDeviceRpcRequestProtoSchema(rpcRequestProtoSchema); transportPayloadTypeConfiguration = protoTransportPayloadConfiguration; } else { transportPayloadTypeConfiguration = new JsonTransportPayloadConfiguration(); diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/attributes/request/AbstractCoapAttributesRequestProtoIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/attributes/request/AbstractCoapAttributesRequestProtoIntegrationTest.java index 4fbe103430..5bef44434e 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/attributes/request/AbstractCoapAttributesRequestProtoIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/attributes/request/AbstractCoapAttributesRequestProtoIntegrationTest.java @@ -79,7 +79,7 @@ public abstract class AbstractCoapAttributesRequestProtoIntegrationTest extends @Test public void testRequestAttributesValuesFromTheServer() throws Exception { super.processBeforeTest("Test Request attribute values from the server proto", CoapDeviceType.DEFAULT, - TransportPayloadType.PROTOBUF, null, ATTRIBUTES_SCHEMA_STR, DeviceProfileProvisionType.DISABLED, null, null); + TransportPayloadType.PROTOBUF, null, ATTRIBUTES_SCHEMA_STR, null, null, null, null, DeviceProfileProvisionType.DISABLED); processTestRequestAttributesValuesFromTheServer(); } diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/provision/AbstractCoapProvisionJsonDeviceTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/provision/AbstractCoapProvisionJsonDeviceTest.java index b4a55ae271..caecaea8ec 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/provision/AbstractCoapProvisionJsonDeviceTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/provision/AbstractCoapProvisionJsonDeviceTest.java @@ -88,7 +88,7 @@ public abstract class AbstractCoapProvisionJsonDeviceTest extends AbstractCoapIn private void processTestProvisioningDisabledDevice() throws Exception { - super.processBeforeTest("Test Provision device", CoapDeviceType.DEFAULT, TransportPayloadType.JSON, null, null, DeviceProfileProvisionType.DISABLED, null, null); + super.processBeforeTest("Test Provision device", CoapDeviceType.DEFAULT, TransportPayloadType.JSON, null, null, null, null, null, null, DeviceProfileProvisionType.DISABLED); byte[] result = createCoapClientAndPublish().getPayload(); JsonObject response = JsonUtils.parse(new String(result)).getAsJsonObject(); Assert.assertEquals("Provision data was not found!", response.get("errorMsg").getAsString()); @@ -97,7 +97,7 @@ public abstract class AbstractCoapProvisionJsonDeviceTest extends AbstractCoapIn private void processTestProvisioningCreateNewDeviceWithoutCredentials() throws Exception { - super.processBeforeTest("Test Provision device3", CoapDeviceType.DEFAULT, TransportPayloadType.JSON, null, null, DeviceProfileProvisionType.ALLOW_CREATE_NEW_DEVICES, "testProvisionKey", "testProvisionSecret"); + super.processBeforeTest("Test Provision device3", CoapDeviceType.DEFAULT, TransportPayloadType.JSON, null, null, null, null, "testProvisionKey", "testProvisionSecret", DeviceProfileProvisionType.ALLOW_CREATE_NEW_DEVICES); byte[] result = createCoapClientAndPublish().getPayload(); JsonObject response = JsonUtils.parse(new String(result)).getAsJsonObject(); @@ -113,7 +113,7 @@ public abstract class AbstractCoapProvisionJsonDeviceTest extends AbstractCoapIn private void processTestProvisioningCreateNewDeviceWithAccessToken() throws Exception { - super.processBeforeTest("Test Provision device3", CoapDeviceType.DEFAULT, TransportPayloadType.JSON, null, null, DeviceProfileProvisionType.ALLOW_CREATE_NEW_DEVICES, "testProvisionKey", "testProvisionSecret"); + super.processBeforeTest("Test Provision device3", CoapDeviceType.DEFAULT, TransportPayloadType.JSON, null, null, null, null, "testProvisionKey", "testProvisionSecret", DeviceProfileProvisionType.ALLOW_CREATE_NEW_DEVICES); String requestCredentials = ",\"credentialsType\": \"ACCESS_TOKEN\",\"token\": \"test_token\""; byte[] result = createCoapClientAndPublish(requestCredentials).getPayload(); JsonObject response = JsonUtils.parse(new String(result)).getAsJsonObject(); @@ -132,7 +132,7 @@ public abstract class AbstractCoapProvisionJsonDeviceTest extends AbstractCoapIn private void processTestProvisioningCreateNewDeviceWithCert() throws Exception { - super.processBeforeTest("Test Provision device3", CoapDeviceType.DEFAULT, TransportPayloadType.JSON, null, null, DeviceProfileProvisionType.ALLOW_CREATE_NEW_DEVICES, "testProvisionKey", "testProvisionSecret"); + super.processBeforeTest("Test Provision device3", CoapDeviceType.DEFAULT, TransportPayloadType.JSON, null, null, null, null, "testProvisionKey", "testProvisionSecret", DeviceProfileProvisionType.ALLOW_CREATE_NEW_DEVICES); String requestCredentials = ",\"credentialsType\": \"X509_CERTIFICATE\",\"hash\": \"testHash\""; byte[] result = createCoapClientAndPublish(requestCredentials).getPayload(); JsonObject response = JsonUtils.parse(new String(result)).getAsJsonObject(); @@ -156,7 +156,7 @@ public abstract class AbstractCoapProvisionJsonDeviceTest extends AbstractCoapIn } private void processTestProvisioningCheckPreProvisionedDevice() throws Exception { - super.processBeforeTest("Test Provision device", CoapDeviceType.DEFAULT, TransportPayloadType.JSON, null, null, DeviceProfileProvisionType.CHECK_PRE_PROVISIONED_DEVICES, "testProvisionKey", "testProvisionSecret"); + super.processBeforeTest("Test Provision device", CoapDeviceType.DEFAULT, TransportPayloadType.JSON, null, null, null, null, "testProvisionKey", "testProvisionSecret", DeviceProfileProvisionType.CHECK_PRE_PROVISIONED_DEVICES); byte[] result = createCoapClientAndPublish().getPayload(); JsonObject response = JsonUtils.parse(new String(result)).getAsJsonObject(); @@ -167,7 +167,7 @@ public abstract class AbstractCoapProvisionJsonDeviceTest extends AbstractCoapIn } private void processTestProvisioningWithBadKeyDevice() throws Exception { - super.processBeforeTest("Test Provision device", CoapDeviceType.DEFAULT, TransportPayloadType.JSON, null, null, DeviceProfileProvisionType.CHECK_PRE_PROVISIONED_DEVICES, "testProvisionKeyOrig", "testProvisionSecret"); + super.processBeforeTest("Test Provision device", CoapDeviceType.DEFAULT, TransportPayloadType.JSON, null, null, null, null, "testProvisionKeyOrig", "testProvisionSecret", DeviceProfileProvisionType.CHECK_PRE_PROVISIONED_DEVICES); byte[] result = createCoapClientAndPublish().getPayload(); JsonObject response = JsonUtils.parse(new String(result)).getAsJsonObject(); Assert.assertEquals("Provision data was not found!", response.get("errorMsg").getAsString()); diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/provision/AbstractCoapProvisionProtoDeviceTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/provision/AbstractCoapProvisionProtoDeviceTest.java index 9f1d8f1f18..682d9c7e7b 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/provision/AbstractCoapProvisionProtoDeviceTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/provision/AbstractCoapProvisionProtoDeviceTest.java @@ -92,14 +92,14 @@ public abstract class AbstractCoapProvisionProtoDeviceTest extends AbstractCoapI private void processTestProvisioningDisabledDevice() throws Exception { - super.processBeforeTest("Test Provision device", CoapDeviceType.DEFAULT, TransportPayloadType.PROTOBUF, null, null, DeviceProfileProvisionType.DISABLED, null, null); + super.processBeforeTest("Test Provision device", CoapDeviceType.DEFAULT, TransportPayloadType.PROTOBUF, null, null, null, null, null, null, DeviceProfileProvisionType.DISABLED); ProvisionDeviceResponseMsg result = ProvisionDeviceResponseMsg.parseFrom(createCoapClientAndPublish().getPayload()); Assert.assertNotNull(result); Assert.assertEquals(ProvisionResponseStatus.NOT_FOUND.name(), result.getStatus().toString()); } private void processTestProvisioningCreateNewDeviceWithoutCredentials() throws Exception { - super.processBeforeTest("Test Provision device3", CoapDeviceType.DEFAULT, TransportPayloadType.PROTOBUF, null, null, DeviceProfileProvisionType.ALLOW_CREATE_NEW_DEVICES, "testProvisionKey", "testProvisionSecret"); + super.processBeforeTest("Test Provision device3", CoapDeviceType.DEFAULT, TransportPayloadType.PROTOBUF, null, null, null, null, "testProvisionKey", "testProvisionSecret", DeviceProfileProvisionType.ALLOW_CREATE_NEW_DEVICES); ProvisionDeviceResponseMsg response = ProvisionDeviceResponseMsg.parseFrom(createCoapClientAndPublish().getPayload()); Device createdDevice = deviceService.findDeviceByTenantIdAndName(savedTenant.getTenantId(), "Test Provision device"); @@ -113,7 +113,7 @@ public abstract class AbstractCoapProvisionProtoDeviceTest extends AbstractCoapI } private void processTestProvisioningCreateNewDeviceWithAccessToken() throws Exception { - super.processBeforeTest("Test Provision device3", CoapDeviceType.DEFAULT, TransportPayloadType.PROTOBUF, null, null, DeviceProfileProvisionType.ALLOW_CREATE_NEW_DEVICES, "testProvisionKey", "testProvisionSecret"); + super.processBeforeTest("Test Provision device3", CoapDeviceType.DEFAULT, TransportPayloadType.PROTOBUF, null, null, null, null, "testProvisionKey", "testProvisionSecret", DeviceProfileProvisionType.ALLOW_CREATE_NEW_DEVICES); CredentialsDataProto requestCredentials = CredentialsDataProto.newBuilder().setValidateDeviceTokenRequestMsg(ValidateDeviceTokenRequestMsg.newBuilder().setToken("test_token").build()).build(); ProvisionDeviceResponseMsg response = ProvisionDeviceResponseMsg.parseFrom(createCoapClientAndPublish(createTestsProvisionMessage(CredentialsType.ACCESS_TOKEN, requestCredentials)).getPayload()); @@ -131,7 +131,7 @@ public abstract class AbstractCoapProvisionProtoDeviceTest extends AbstractCoapI } private void processTestProvisioningCreateNewDeviceWithCert() throws Exception { - super.processBeforeTest("Test Provision device3", CoapDeviceType.DEFAULT, TransportPayloadType.PROTOBUF, null, null, DeviceProfileProvisionType.ALLOW_CREATE_NEW_DEVICES, "testProvisionKey", "testProvisionSecret"); + super.processBeforeTest("Test Provision device3", CoapDeviceType.DEFAULT, TransportPayloadType.PROTOBUF, null, null, null, null, "testProvisionKey", "testProvisionSecret", DeviceProfileProvisionType.ALLOW_CREATE_NEW_DEVICES); CredentialsDataProto requestCredentials = CredentialsDataProto.newBuilder().setValidateDeviceX509CertRequestMsg(ValidateDeviceX509CertRequestMsg.newBuilder().setHash("testHash").build()).build(); ProvisionDeviceResponseMsg response = ProvisionDeviceResponseMsg.parseFrom(createCoapClientAndPublish(createTestsProvisionMessage(CredentialsType.X509_CERTIFICATE, requestCredentials)).getPayload()); @@ -155,7 +155,7 @@ public abstract class AbstractCoapProvisionProtoDeviceTest extends AbstractCoapI } private void processTestProvisioningCheckPreProvisionedDevice() throws Exception { - super.processBeforeTest("Test Provision device", CoapDeviceType.DEFAULT, TransportPayloadType.PROTOBUF, null, null, DeviceProfileProvisionType.CHECK_PRE_PROVISIONED_DEVICES, "testProvisionKey", "testProvisionSecret"); + super.processBeforeTest("Test Provision device", CoapDeviceType.DEFAULT, TransportPayloadType.PROTOBUF, null, null, null, null, "testProvisionKey", "testProvisionSecret", DeviceProfileProvisionType.CHECK_PRE_PROVISIONED_DEVICES); ProvisionDeviceResponseMsg response = ProvisionDeviceResponseMsg.parseFrom(createCoapClientAndPublish().getPayload()); DeviceCredentials deviceCredentials = deviceCredentialsService.findDeviceCredentialsByDeviceId(savedTenant.getTenantId(), savedDevice.getId()); @@ -165,7 +165,7 @@ public abstract class AbstractCoapProvisionProtoDeviceTest extends AbstractCoapI } private void processTestProvisioningWithBadKeyDevice() throws Exception { - super.processBeforeTest("Test Provision device", CoapDeviceType.DEFAULT, TransportPayloadType.PROTOBUF, null, null, DeviceProfileProvisionType.CHECK_PRE_PROVISIONED_DEVICES, "testProvisionKeyOrig", "testProvisionSecret"); + super.processBeforeTest("Test Provision device", CoapDeviceType.DEFAULT, TransportPayloadType.PROTOBUF, null, null, null, null, "testProvisionKeyOrig", "testProvisionSecret", DeviceProfileProvisionType.CHECK_PRE_PROVISIONED_DEVICES); ProvisionDeviceResponseMsg response = ProvisionDeviceResponseMsg.parseFrom(createCoapClientAndPublish().getPayload()); Assert.assertEquals(ProvisionResponseStatus.NOT_FOUND.name(), response.getStatus().toString()); } diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/rpc/AbstractCoapServerSideRpcIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/rpc/AbstractCoapServerSideRpcIntegrationTest.java index a36660267e..d5d56db63b 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/rpc/AbstractCoapServerSideRpcIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/rpc/AbstractCoapServerSideRpcIntegrationTest.java @@ -124,7 +124,7 @@ public abstract class AbstractCoapServerSideRpcIntegrationTest extends AbstractC return COAP_BASE_URL + token + "/" + FeatureType.RPC.name().toLowerCase() + "/" + requestId; } - private class TestCoapCallback implements CoapHandler { + protected class TestCoapCallback implements CoapHandler { private final CoapClient client; private final CountDownLatch latch; @@ -136,7 +136,7 @@ public abstract class AbstractCoapServerSideRpcIntegrationTest extends AbstractC private Integer observe; - private TestCoapCallback(CoapClient client, CountDownLatch latch, boolean isOneWayRpc) { + TestCoapCallback(CoapClient client, CountDownLatch latch, boolean isOneWayRpc) { this.client = client; this.latch = latch; this.isOneWayRpc = isOneWayRpc; @@ -144,7 +144,7 @@ public abstract class AbstractCoapServerSideRpcIntegrationTest extends AbstractC @Override public void onLoad(CoapResponse response) { - log.warn("coap response: {}, {}", response, response.getCode()); + log.warn("coap response: {}, {}", response.getResponseText(), response.getCode()); assertNotNull(response.getPayload()); assertEquals(response.getCode(), CoAP.ResponseCode.CONTENT); observe = response.getOptions().getObserve(); diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/rpc/AbstractCoapServerSideRpcJsonIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/rpc/AbstractCoapServerSideRpcJsonIntegrationTest.java index 5387d3c7bf..cecdb1fae0 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/rpc/AbstractCoapServerSideRpcJsonIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/rpc/AbstractCoapServerSideRpcJsonIntegrationTest.java @@ -36,12 +36,12 @@ public abstract class AbstractCoapServerSideRpcJsonIntegrationTest extends Abstr } @Test - public void testServerMqttOneWayRpc() throws Exception { + public void testServerCoapOneWayRpc() throws Exception { processOneWayRpcTest(); } @Test - public void testServerMqttTwoWayRpc() throws Exception { + public void testServerCoapTwoWayRpc() throws Exception { processTwoWayRpcTest(); } diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/rpc/AbstractCoapServerSideRpcProtoIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/rpc/AbstractCoapServerSideRpcProtoIntegrationTest.java index edbdf69848..706a560024 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/rpc/AbstractCoapServerSideRpcProtoIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/rpc/AbstractCoapServerSideRpcProtoIntegrationTest.java @@ -15,26 +15,62 @@ */ package org.thingsboard.server.transport.coap.rpc; +import com.github.os72.protobuf.dynamic.DynamicSchema; +import com.google.protobuf.Descriptors; +import com.google.protobuf.DynamicMessage; +import com.google.protobuf.InvalidProtocolBufferException; +import com.squareup.wire.schema.internal.parser.ProtoFileElement; import lombok.extern.slf4j.Slf4j; import org.eclipse.californium.core.CoapClient; import org.eclipse.californium.core.CoapHandler; +import org.eclipse.californium.core.CoapObserveRelation; import org.eclipse.californium.core.CoapResponse; +import org.eclipse.californium.core.coap.CoAP; import org.eclipse.californium.core.coap.MediaTypeRegistry; +import org.eclipse.californium.core.coap.Request; import org.junit.After; import org.junit.Before; import org.junit.Test; import org.thingsboard.server.common.data.CoapDeviceType; +import org.thingsboard.server.common.data.DeviceProfileProvisionType; import org.thingsboard.server.common.data.TransportPayloadType; -import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.common.data.device.profile.CoapDeviceProfileTransportConfiguration; +import org.thingsboard.server.common.data.device.profile.CoapDeviceTypeConfiguration; +import org.thingsboard.server.common.data.device.profile.DefaultCoapDeviceTypeConfiguration; +import org.thingsboard.server.common.data.device.profile.DeviceProfileTransportConfiguration; +import org.thingsboard.server.common.data.device.profile.ProtoTransportPayloadConfiguration; +import org.thingsboard.server.common.data.device.profile.TransportPayloadTypeConfiguration; +import org.thingsboard.server.common.msg.session.FeatureType; +import java.util.List; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @Slf4j public abstract class AbstractCoapServerSideRpcProtoIntegrationTest extends AbstractCoapServerSideRpcIntegrationTest { + private static final String RPC_REQUEST_PROTO_SCHEMA = "syntax =\"proto3\";\n" + + "package rpc;\n" + + "\n" + + "message RpcRequestMsg {\n" + + " string method = 1;\n" + + " int32 requestId = 2;\n" + + " Params params = 3;\n" + + "\n" + + " message Params {\n" + + " string pin = 1;\n" + + " int32 value = 2;\n" + + " }\n" + + "}"; + @Before public void beforeTest() throws Exception { - processBeforeTest("RPC test device", CoapDeviceType.DEFAULT, TransportPayloadType.PROTOBUF); + processBeforeTest("RPC test device", CoapDeviceType.DEFAULT, TransportPayloadType.PROTOBUF, null, null, null, RPC_REQUEST_PROTO_SCHEMA, null, null, DeviceProfileProvisionType.DISABLED); } @After @@ -43,33 +79,91 @@ public abstract class AbstractCoapServerSideRpcProtoIntegrationTest extends Abst } @Test - public void testServerMqttOneWayRpc() throws Exception { + public void testServerCoapOneWayRpc() throws Exception { processOneWayRpcTest(); } @Test - public void testServerMqttTwoWayRpc() throws Exception { + public void testServerCoapTwoWayRpc() throws Exception { processTwoWayRpcTest(); } + protected void processTwoWayRpcTest() throws Exception { + CoapClient client = getCoapClient(FeatureType.RPC); + client.useCONs(); + + CountDownLatch latch = new CountDownLatch(1); + TestCoapCallback testCoapCallback = new TestCoapCallback(client, latch, false); + + Request request = Request.newGet().setObserve(); + request.setType(CoAP.Type.CON); + CoapObserveRelation observeRelation = client.observe(request, testCoapCallback); + + String setGpioRequest = "{\"method\":\"setGpio\",\"params\":{\"pin\": \"26\",\"value\": 1}}"; + String deviceId = savedDevice.getId().getId().toString(); + + String expected = "{\"payload\":\"{\\\"value1\\\":\\\"A\\\",\\\"value2\\\":\\\"B\\\"}\"}"; + + String result = doPostAsync("/api/plugins/rpc/twoway/" + deviceId, setGpioRequest, String.class, status().isOk()); + latch.await(3, TimeUnit.SECONDS); + + assertEquals(expected, result); + assertEquals(0, testCoapCallback.getObserve().intValue()); + observeRelation.proactiveCancel(); + assertTrue(observeRelation.isCanceled()); + } + @Override protected void processOnLoadResponse(CoapResponse response, CoapClient client, Integer observe, CountDownLatch latch) { client.setURI(getRpcResponseFeatureTokenUrl(accessToken, observe)); - TransportProtos.ToDeviceRpcResponseMsg toDeviceRpcResponseMsg = TransportProtos.ToDeviceRpcResponseMsg.newBuilder() - .setPayload(DEVICE_RESPONSE) - .setRequestId(observe) - .build(); - client.post(new CoapHandler() { - @Override - public void onLoad(CoapResponse response) { - log.warn("Command Response Ack: {}, {}", response.getCode(), response.getResponseText()); - latch.countDown(); - } + ProtoTransportPayloadConfiguration protoTransportPayloadConfiguration = getProtoTransportPayloadConfiguration(); + ProtoFileElement rpcRequestProtoSchemaFile = protoTransportPayloadConfiguration.getTransportProtoSchema(RPC_REQUEST_PROTO_SCHEMA); + DynamicSchema rpcRequestProtoSchema = protoTransportPayloadConfiguration.getDynamicSchema(rpcRequestProtoSchemaFile, ProtoTransportPayloadConfiguration.RPC_REQUEST_PROTO_SCHEMA); - @Override - public void onError() { - log.warn("Command Response Ack Error, No connect"); + byte[] requestPayload = response.getPayload(); + DynamicMessage.Builder rpcRequestMsg = rpcRequestProtoSchema.newMessageBuilder("RpcRequestMsg"); + Descriptors.Descriptor rpcRequestMsgDescriptor = rpcRequestMsg.getDescriptorForType(); + assertNotNull(rpcRequestMsgDescriptor); + try { + DynamicMessage dynamicMessage = DynamicMessage.parseFrom(rpcRequestMsgDescriptor, requestPayload); + List fields = rpcRequestMsgDescriptor.getFields(); + for (Descriptors.FieldDescriptor fieldDescriptor: fields) { + assertTrue(dynamicMessage.hasField(fieldDescriptor)); } - }, toDeviceRpcResponseMsg.toByteArray(), MediaTypeRegistry.APPLICATION_JSON); + ProtoFileElement rpcResponseProtoSchemaFile = protoTransportPayloadConfiguration.getTransportProtoSchema(DEVICE_RPC_RESPONSE_PROTO_SCHEMA); + DynamicSchema rpcResponseProtoSchema = protoTransportPayloadConfiguration.getDynamicSchema(rpcResponseProtoSchemaFile, ProtoTransportPayloadConfiguration.RPC_RESPONSE_PROTO_SCHEMA); + DynamicMessage.Builder rpcResponseBuilder = rpcResponseProtoSchema.newMessageBuilder("RpcResponseMsg"); + Descriptors.Descriptor rpcResponseMsgDescriptor = rpcResponseBuilder.getDescriptorForType(); + assertNotNull(rpcResponseMsgDescriptor); + DynamicMessage rpcResponseMsg = rpcResponseBuilder + .setField(rpcResponseMsgDescriptor.findFieldByName("payload"), DEVICE_RESPONSE) + .build(); + client.post(new CoapHandler() { + @Override + public void onLoad(CoapResponse response) { + log.warn("Command Response Ack: {}, {}", response.getCode(), response.getResponseText()); + latch.countDown(); + } + + @Override + public void onError() { + log.warn("Command Response Ack Error, No connect"); + } + }, rpcResponseMsg.toByteArray(), MediaTypeRegistry.APPLICATION_JSON); + } catch (InvalidProtocolBufferException e) { + log.warn("Command Response Ack Error, Invalid response received: ", e); + } + } + + private ProtoTransportPayloadConfiguration getProtoTransportPayloadConfiguration() { + DeviceProfileTransportConfiguration transportConfiguration = deviceProfile.getProfileData().getTransportConfiguration(); + assertTrue(transportConfiguration instanceof CoapDeviceProfileTransportConfiguration); + CoapDeviceProfileTransportConfiguration coapDeviceProfileTransportConfiguration = (CoapDeviceProfileTransportConfiguration) transportConfiguration; + CoapDeviceTypeConfiguration coapDeviceTypeConfiguration = coapDeviceProfileTransportConfiguration.getCoapDeviceTypeConfiguration(); + assertTrue(coapDeviceTypeConfiguration instanceof DefaultCoapDeviceTypeConfiguration); + DefaultCoapDeviceTypeConfiguration defaultCoapDeviceTypeConfiguration = (DefaultCoapDeviceTypeConfiguration) coapDeviceTypeConfiguration; + TransportPayloadTypeConfiguration transportPayloadTypeConfiguration = defaultCoapDeviceTypeConfiguration.getTransportPayloadTypeConfiguration(); + assertTrue(transportPayloadTypeConfiguration instanceof ProtoTransportPayloadConfiguration); + return (ProtoTransportPayloadConfiguration) transportPayloadTypeConfiguration; } } diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/telemetry/timeseries/AbstractCoapTimeseriesProtoIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/telemetry/timeseries/AbstractCoapTimeseriesProtoIntegrationTest.java index 2ae686b211..0cc3db9cf1 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/telemetry/timeseries/AbstractCoapTimeseriesProtoIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/telemetry/timeseries/AbstractCoapTimeseriesProtoIntegrationTest.java @@ -114,7 +114,7 @@ public abstract class AbstractCoapTimeseriesProtoIntegrationTest extends Abstrac " }\n" + " }\n" + "}"; - super.processBeforeTest("Test Post Telemetry device proto payload", CoapDeviceType.DEFAULT, TransportPayloadType.PROTOBUF, schemaStr, null, DeviceProfileProvisionType.DISABLED, null, null); + super.processBeforeTest("Test Post Telemetry device proto payload", CoapDeviceType.DEFAULT, TransportPayloadType.PROTOBUF, schemaStr, null, null, null, null, null, DeviceProfileProvisionType.DISABLED); DeviceProfileTransportConfiguration transportConfiguration = deviceProfile.getProfileData().getTransportConfiguration(); assertTrue(transportConfiguration instanceof CoapDeviceProfileTransportConfiguration); CoapDeviceProfileTransportConfiguration coapDeviceProfileTransportConfiguration = (CoapDeviceProfileTransportConfiguration) transportConfiguration; diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/AbstractMqttIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/AbstractMqttIntegrationTest.java index cd074b3197..0d11c9a773 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/AbstractMqttIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/AbstractMqttIntegrationTest.java @@ -62,7 +62,7 @@ public abstract class AbstractMqttIntegrationTest extends AbstractTransportInteg protected DeviceProfile deviceProfile; protected void processBeforeTest (String deviceName, String gatewayName, TransportPayloadType payloadType, String telemetryTopic, String attributesTopic) throws Exception { - this.processBeforeTest(deviceName, gatewayName, payloadType, telemetryTopic, attributesTopic, null, null, DeviceProfileProvisionType.DISABLED, null, null); + this.processBeforeTest(deviceName, gatewayName, payloadType, telemetryTopic, attributesTopic, null, null, null, null, null, null, DeviceProfileProvisionType.DISABLED); } protected void processBeforeTest(String deviceName, @@ -72,9 +72,12 @@ public abstract class AbstractMqttIntegrationTest extends AbstractTransportInteg String attributesTopic, String telemetryProtoSchema, String attributesProtoSchema, - DeviceProfileProvisionType provisionType, - String provisionKey, String provisionSecret - ) throws Exception { + String rpcResponseProtoSchema, + String rpcRequestProtoSchema, + String provisionKey, + String provisionSecret, + DeviceProfileProvisionType provisionType + ) throws Exception { loginSysAdmin(); Tenant tenant = new Tenant(); @@ -103,7 +106,7 @@ public abstract class AbstractMqttIntegrationTest extends AbstractTransportInteg gateway.setAdditionalInfo(additionalInfo); if (payloadType != null) { - DeviceProfile mqttDeviceProfile = createMqttDeviceProfile(payloadType, telemetryTopic, attributesTopic, telemetryProtoSchema, attributesProtoSchema, provisionType, provisionKey, provisionSecret); + DeviceProfile mqttDeviceProfile = createMqttDeviceProfile(payloadType, telemetryTopic, attributesTopic, telemetryProtoSchema, attributesProtoSchema, rpcResponseProtoSchema, rpcRequestProtoSchema, provisionKey, provisionSecret, provisionType); deviceProfile = doPost("/api/deviceProfile", mqttDeviceProfile, DeviceProfile.class); device.setType(deviceProfile.getName()); device.setDeviceProfileId(deviceProfile.getId()); @@ -157,8 +160,9 @@ public abstract class AbstractMqttIntegrationTest extends AbstractTransportInteg protected DeviceProfile createMqttDeviceProfile(TransportPayloadType transportPayloadType, String telemetryTopic, String attributesTopic, String telemetryProtoSchema, String attributesProtoSchema, - DeviceProfileProvisionType provisionType, - String provisionKey, String provisionSecret) { + String rpcResponseProtoSchema, String rpcRequestProtoSchema, + String provisionKey, String provisionSecret, + DeviceProfileProvisionType provisionType) { DeviceProfile deviceProfile = new DeviceProfile(); deviceProfile.setName(transportPayloadType.name()); deviceProfile.setType(DeviceProfileType.DEFAULT); @@ -186,8 +190,16 @@ public abstract class AbstractMqttIntegrationTest extends AbstractTransportInteg if (StringUtils.isEmpty(attributesProtoSchema)) { attributesProtoSchema = DEVICE_ATTRIBUTES_PROTO_SCHEMA; } + if (StringUtils.isEmpty(rpcResponseProtoSchema)) { + rpcResponseProtoSchema = DEVICE_RPC_RESPONSE_PROTO_SCHEMA; + } + if (StringUtils.isEmpty(rpcRequestProtoSchema)) { + rpcRequestProtoSchema = DEVICE_RPC_REQUEST_PROTO_SCHEMA; + } protoTransportPayloadConfiguration.setDeviceTelemetryProtoSchema(telemetryProtoSchema); protoTransportPayloadConfiguration.setDeviceAttributesProtoSchema(attributesProtoSchema); + protoTransportPayloadConfiguration.setDeviceRpcResponseProtoSchema(rpcResponseProtoSchema); + protoTransportPayloadConfiguration.setDeviceRpcRequestProtoSchema(rpcRequestProtoSchema); transportPayloadTypeConfiguration = protoTransportPayloadConfiguration; } mqttDeviceProfileTransportConfiguration.setTransportPayloadTypeConfiguration(transportPayloadTypeConfiguration); diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/attributes/request/AbstractMqttAttributesRequestProtoIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/attributes/request/AbstractMqttAttributesRequestProtoIntegrationTest.java index ff8b54c79c..6949a544de 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/attributes/request/AbstractMqttAttributesRequestProtoIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/attributes/request/AbstractMqttAttributesRequestProtoIntegrationTest.java @@ -83,7 +83,7 @@ public abstract class AbstractMqttAttributesRequestProtoIntegrationTest extends @Test public void testRequestAttributesValuesFromTheServer() throws Exception { super.processBeforeTest("Test Request attribute values from the server proto", "Gateway Test Request attribute values from the server proto", - TransportPayloadType.PROTOBUF, null, null, null, ATTRIBUTES_SCHEMA_STR, DeviceProfileProvisionType.DISABLED, null, null); + TransportPayloadType.PROTOBUF, null, null, null, ATTRIBUTES_SCHEMA_STR, null, null, null, null, DeviceProfileProvisionType.DISABLED); processTestRequestAttributesValuesFromTheServer(); } diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/provision/AbstractMqttProvisionJsonDeviceTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/provision/AbstractMqttProvisionJsonDeviceTest.java index 106c801676..870b62b195 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/provision/AbstractMqttProvisionJsonDeviceTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/provision/AbstractMqttProvisionJsonDeviceTest.java @@ -94,7 +94,7 @@ public abstract class AbstractMqttProvisionJsonDeviceTest extends AbstractMqttIn protected void processTestProvisioningDisabledDevice() throws Exception { - super.processBeforeTest("Test Provision device", "Test Provision gateway", TransportPayloadType.JSON, null, null, null, null, DeviceProfileProvisionType.DISABLED, null, null); + super.processBeforeTest("Test Provision device", "Test Provision gateway", TransportPayloadType.JSON, null, null, null, null, null, null, null, null, DeviceProfileProvisionType.DISABLED); byte[] result = createMqttClientAndPublish().getPayloadBytes(); JsonObject response = JsonUtils.parse(new String(result)).getAsJsonObject(); Assert.assertEquals("Provision data was not found!", response.get("errorMsg").getAsString()); @@ -103,7 +103,7 @@ public abstract class AbstractMqttProvisionJsonDeviceTest extends AbstractMqttIn protected void processTestProvisioningCreateNewDeviceWithoutCredentials() throws Exception { - super.processBeforeTest("Test Provision device3", "Test Provision gateway", TransportPayloadType.JSON, null, null, null, null, DeviceProfileProvisionType.ALLOW_CREATE_NEW_DEVICES, "testProvisionKey", "testProvisionSecret"); + super.processBeforeTest("Test Provision device3", "Test Provision gateway", TransportPayloadType.JSON, null, null, null, null, null, null, "testProvisionKey", "testProvisionSecret", DeviceProfileProvisionType.ALLOW_CREATE_NEW_DEVICES); byte[] result = createMqttClientAndPublish().getPayloadBytes(); JsonObject response = JsonUtils.parse(new String(result)).getAsJsonObject(); @@ -119,7 +119,7 @@ public abstract class AbstractMqttProvisionJsonDeviceTest extends AbstractMqttIn protected void processTestProvisioningCreateNewDeviceWithAccessToken() throws Exception { - super.processBeforeTest("Test Provision device3", "Test Provision gateway", TransportPayloadType.JSON, null, null, null, null, DeviceProfileProvisionType.ALLOW_CREATE_NEW_DEVICES, "testProvisionKey", "testProvisionSecret"); + super.processBeforeTest("Test Provision device3", "Test Provision gateway", TransportPayloadType.JSON, null, null, null, null, null, null, "testProvisionKey", "testProvisionSecret", DeviceProfileProvisionType.ALLOW_CREATE_NEW_DEVICES); String requestCredentials = ",\"credentialsType\": \"ACCESS_TOKEN\",\"token\": \"test_token\""; byte[] result = createMqttClientAndPublish(requestCredentials).getPayloadBytes(); JsonObject response = JsonUtils.parse(new String(result)).getAsJsonObject(); @@ -138,7 +138,7 @@ public abstract class AbstractMqttProvisionJsonDeviceTest extends AbstractMqttIn protected void processTestProvisioningCreateNewDeviceWithCert() throws Exception { - super.processBeforeTest("Test Provision device3", "Test Provision gateway", TransportPayloadType.JSON, null, null, null, null, DeviceProfileProvisionType.ALLOW_CREATE_NEW_DEVICES, "testProvisionKey", "testProvisionSecret"); + super.processBeforeTest("Test Provision device3", "Test Provision gateway", TransportPayloadType.JSON, null, null, null, null, null, null, "testProvisionKey", "testProvisionSecret", DeviceProfileProvisionType.ALLOW_CREATE_NEW_DEVICES); String requestCredentials = ",\"credentialsType\": \"X509_CERTIFICATE\",\"hash\": \"testHash\""; byte[] result = createMqttClientAndPublish(requestCredentials).getPayloadBytes(); JsonObject response = JsonUtils.parse(new String(result)).getAsJsonObject(); @@ -163,7 +163,7 @@ public abstract class AbstractMqttProvisionJsonDeviceTest extends AbstractMqttIn protected void processTestProvisioningCreateNewDeviceWithMqttBasic() throws Exception { - super.processBeforeTest("Test Provision device3", "Test Provision gateway", TransportPayloadType.JSON, null, null, null, null, DeviceProfileProvisionType.ALLOW_CREATE_NEW_DEVICES, "testProvisionKey", "testProvisionSecret"); + super.processBeforeTest("Test Provision device3", "Test Provision gateway", TransportPayloadType.JSON, null, null, null, null, null, null, "testProvisionKey", "testProvisionSecret", DeviceProfileProvisionType.ALLOW_CREATE_NEW_DEVICES); String requestCredentials = ",\"credentialsType\": \"MQTT_BASIC\",\"clientId\": \"test_clientId\",\"username\": \"test_username\",\"password\": \"test_password\""; byte[] result = createMqttClientAndPublish(requestCredentials).getPayloadBytes(); JsonObject response = JsonUtils.parse(new String(result)).getAsJsonObject(); @@ -188,7 +188,7 @@ public abstract class AbstractMqttProvisionJsonDeviceTest extends AbstractMqttIn } protected void processTestProvisioningCheckPreProvisionedDevice() throws Exception { - super.processBeforeTest("Test Provision device", "Test Provision gateway", TransportPayloadType.JSON, null, null, null, null, DeviceProfileProvisionType.CHECK_PRE_PROVISIONED_DEVICES, "testProvisionKey", "testProvisionSecret"); + super.processBeforeTest("Test Provision device", "Test Provision gateway", TransportPayloadType.JSON, null, null, null, null, null, null, "testProvisionKey", "testProvisionSecret", DeviceProfileProvisionType.CHECK_PRE_PROVISIONED_DEVICES); byte[] result = createMqttClientAndPublish().getPayloadBytes(); JsonObject response = JsonUtils.parse(new String(result)).getAsJsonObject(); @@ -199,7 +199,7 @@ public abstract class AbstractMqttProvisionJsonDeviceTest extends AbstractMqttIn } protected void processTestProvisioningWithBadKeyDevice() throws Exception { - super.processBeforeTest("Test Provision device", "Test Provision gateway", TransportPayloadType.JSON, null, null, null, null, DeviceProfileProvisionType.CHECK_PRE_PROVISIONED_DEVICES, "testProvisionKeyOrig", "testProvisionSecret"); + super.processBeforeTest("Test Provision device", "Test Provision gateway", TransportPayloadType.JSON, null, null, null, null, null, null, "testProvisionKeyOrig", "testProvisionSecret", DeviceProfileProvisionType.CHECK_PRE_PROVISIONED_DEVICES); byte[] result = createMqttClientAndPublish().getPayloadBytes(); JsonObject response = JsonUtils.parse(new String(result)).getAsJsonObject(); Assert.assertEquals("Provision data was not found!", response.get("errorMsg").getAsString()); diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/provision/AbstractMqttProvisionProtoDeviceTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/provision/AbstractMqttProvisionProtoDeviceTest.java index 53fde9412b..97ebbfe4a7 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/provision/AbstractMqttProvisionProtoDeviceTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/provision/AbstractMqttProvisionProtoDeviceTest.java @@ -101,14 +101,14 @@ public abstract class AbstractMqttProvisionProtoDeviceTest extends AbstractMqttI protected void processTestProvisioningDisabledDevice() throws Exception { - super.processBeforeTest("Test Provision device", "Test Provision gateway", TransportPayloadType.PROTOBUF, null, null, null, null, DeviceProfileProvisionType.DISABLED, null, null); + super.processBeforeTest("Test Provision device", "Test Provision gateway", TransportPayloadType.PROTOBUF, null, null, null, null, null, null, null, null, DeviceProfileProvisionType.DISABLED); ProvisionDeviceResponseMsg result = ProvisionDeviceResponseMsg.parseFrom(createMqttClientAndPublish().getPayloadBytes()); Assert.assertNotNull(result); Assert.assertEquals(ProvisionResponseStatus.NOT_FOUND.name(), result.getStatus().toString()); } protected void processTestProvisioningCreateNewDeviceWithoutCredentials() throws Exception { - super.processBeforeTest("Test Provision device3", "Test Provision gateway", TransportPayloadType.PROTOBUF, null, null, null, null, DeviceProfileProvisionType.ALLOW_CREATE_NEW_DEVICES, "testProvisionKey", "testProvisionSecret"); + super.processBeforeTest("Test Provision device3", "Test Provision gateway", TransportPayloadType.PROTOBUF, null, null, null, null, null, null, "testProvisionKey", "testProvisionSecret", DeviceProfileProvisionType.ALLOW_CREATE_NEW_DEVICES); ProvisionDeviceResponseMsg response = ProvisionDeviceResponseMsg.parseFrom(createMqttClientAndPublish().getPayloadBytes()); Device createdDevice = deviceService.findDeviceByTenantIdAndName(savedTenant.getTenantId(), "Test Provision device"); @@ -122,7 +122,7 @@ public abstract class AbstractMqttProvisionProtoDeviceTest extends AbstractMqttI } protected void processTestProvisioningCreateNewDeviceWithAccessToken() throws Exception { - super.processBeforeTest("Test Provision device3", "Test Provision gateway", TransportPayloadType.PROTOBUF, null, null,null, null, DeviceProfileProvisionType.ALLOW_CREATE_NEW_DEVICES, "testProvisionKey", "testProvisionSecret"); + super.processBeforeTest("Test Provision device3", "Test Provision gateway", TransportPayloadType.PROTOBUF, null, null,null, null, null, null, "testProvisionKey", "testProvisionSecret", DeviceProfileProvisionType.ALLOW_CREATE_NEW_DEVICES); CredentialsDataProto requestCredentials = CredentialsDataProto.newBuilder().setValidateDeviceTokenRequestMsg(ValidateDeviceTokenRequestMsg.newBuilder().setToken("test_token").build()).build(); ProvisionDeviceResponseMsg response = ProvisionDeviceResponseMsg.parseFrom(createMqttClientAndPublish(createTestsProvisionMessage(CredentialsType.ACCESS_TOKEN, requestCredentials)).getPayloadBytes()); @@ -140,7 +140,7 @@ public abstract class AbstractMqttProvisionProtoDeviceTest extends AbstractMqttI } protected void processTestProvisioningCreateNewDeviceWithCert() throws Exception { - super.processBeforeTest("Test Provision device3", "Test Provision gateway", TransportPayloadType.PROTOBUF, null, null, null, null, DeviceProfileProvisionType.ALLOW_CREATE_NEW_DEVICES, "testProvisionKey", "testProvisionSecret"); + super.processBeforeTest("Test Provision device3", "Test Provision gateway", TransportPayloadType.PROTOBUF, null, null, null, null, null, null, "testProvisionKey", "testProvisionSecret", DeviceProfileProvisionType.ALLOW_CREATE_NEW_DEVICES); CredentialsDataProto requestCredentials = CredentialsDataProto.newBuilder().setValidateDeviceX509CertRequestMsg(ValidateDeviceX509CertRequestMsg.newBuilder().setHash("testHash").build()).build(); ProvisionDeviceResponseMsg response = ProvisionDeviceResponseMsg.parseFrom(createMqttClientAndPublish(createTestsProvisionMessage(CredentialsType.X509_CERTIFICATE, requestCredentials)).getPayloadBytes()); @@ -164,7 +164,7 @@ public abstract class AbstractMqttProvisionProtoDeviceTest extends AbstractMqttI } protected void processTestProvisioningCreateNewDeviceWithMqttBasic() throws Exception { - super.processBeforeTest("Test Provision device3", "Test Provision gateway", TransportPayloadType.PROTOBUF, null, null, null, null, DeviceProfileProvisionType.ALLOW_CREATE_NEW_DEVICES, "testProvisionKey", "testProvisionSecret"); + super.processBeforeTest("Test Provision device3", "Test Provision gateway", TransportPayloadType.PROTOBUF, null, null, null, null, null, null, "testProvisionKey", "testProvisionSecret", DeviceProfileProvisionType.ALLOW_CREATE_NEW_DEVICES); CredentialsDataProto requestCredentials = CredentialsDataProto.newBuilder().setValidateBasicMqttCredRequestMsg( ValidateBasicMqttCredRequestMsg.newBuilder() .setClientId("test_clientId") @@ -195,7 +195,7 @@ public abstract class AbstractMqttProvisionProtoDeviceTest extends AbstractMqttI } protected void processTestProvisioningCheckPreProvisionedDevice() throws Exception { - super.processBeforeTest("Test Provision device", "Test Provision gateway", TransportPayloadType.PROTOBUF, null, null, null, null, DeviceProfileProvisionType.CHECK_PRE_PROVISIONED_DEVICES, "testProvisionKey", "testProvisionSecret"); + super.processBeforeTest("Test Provision device", "Test Provision gateway", TransportPayloadType.PROTOBUF, null, null, null, null, null, null, "testProvisionKey", "testProvisionSecret", DeviceProfileProvisionType.CHECK_PRE_PROVISIONED_DEVICES); ProvisionDeviceResponseMsg response = ProvisionDeviceResponseMsg.parseFrom(createMqttClientAndPublish().getPayloadBytes()); DeviceCredentials deviceCredentials = deviceCredentialsService.findDeviceCredentialsByDeviceId(savedTenant.getTenantId(), savedDevice.getId()); @@ -205,7 +205,7 @@ public abstract class AbstractMqttProvisionProtoDeviceTest extends AbstractMqttI } protected void processTestProvisioningWithBadKeyDevice() throws Exception { - super.processBeforeTest("Test Provision device", "Test Provision gateway", TransportPayloadType.PROTOBUF, null, null, null, null, DeviceProfileProvisionType.CHECK_PRE_PROVISIONED_DEVICES, "testProvisionKeyOrig", "testProvisionSecret"); + super.processBeforeTest("Test Provision device", "Test Provision gateway", TransportPayloadType.PROTOBUF, null, null, null, null, null, null, "testProvisionKeyOrig", "testProvisionSecret", DeviceProfileProvisionType.CHECK_PRE_PROVISIONED_DEVICES); ProvisionDeviceResponseMsg response = ProvisionDeviceResponseMsg.parseFrom(createMqttClientAndPublish().getPayloadBytes()); Assert.assertEquals(ProvisionResponseStatus.NOT_FOUND.name(), response.getStatus().toString()); } diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/rpc/AbstractMqttServerSideRpcIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/rpc/AbstractMqttServerSideRpcIntegrationTest.java index 90de461627..f24cb0c2bc 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/rpc/AbstractMqttServerSideRpcIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/rpc/AbstractMqttServerSideRpcIntegrationTest.java @@ -180,7 +180,7 @@ public abstract class AbstractMqttServerSideRpcIntegrationTest extends AbstractM return message; } - private class TestMqttCallback implements MqttCallback { + protected class TestMqttCallback implements MqttCallback { private final MqttAsyncClient client; private final CountDownLatch latch; diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/rpc/AbstractMqttServerSideRpcProtoIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/rpc/AbstractMqttServerSideRpcProtoIntegrationTest.java index 575dfdb052..eea800fb0e 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/rpc/AbstractMqttServerSideRpcProtoIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/rpc/AbstractMqttServerSideRpcProtoIntegrationTest.java @@ -15,25 +15,60 @@ */ package org.thingsboard.server.transport.mqtt.rpc; +import com.github.os72.protobuf.dynamic.DynamicSchema; +import com.google.protobuf.Descriptors; +import com.google.protobuf.DynamicMessage; import com.google.protobuf.InvalidProtocolBufferException; +import com.squareup.wire.schema.internal.parser.ProtoFileElement; import lombok.extern.slf4j.Slf4j; +import org.eclipse.californium.core.CoapHandler; +import org.eclipse.californium.core.CoapResponse; +import org.eclipse.californium.core.coap.MediaTypeRegistry; import org.eclipse.paho.client.mqttv3.MqttAsyncClient; import org.eclipse.paho.client.mqttv3.MqttException; import org.eclipse.paho.client.mqttv3.MqttMessage; +import org.jetbrains.annotations.NotNull; import org.junit.After; +import org.junit.Assert; import org.junit.Before; import org.junit.Test; +import org.thingsboard.server.common.data.DeviceProfileProvisionType; import org.thingsboard.server.common.data.TransportPayloadType; +import org.thingsboard.server.common.data.device.profile.DeviceProfileTransportConfiguration; +import org.thingsboard.server.common.data.device.profile.MqttDeviceProfileTransportConfiguration; import org.thingsboard.server.common.data.device.profile.MqttTopics; +import org.thingsboard.server.common.data.device.profile.ProtoTransportPayloadConfiguration; +import org.thingsboard.server.common.data.device.profile.TransportPayloadTypeConfiguration; import org.thingsboard.server.gen.transport.TransportApiProtos; -import org.thingsboard.server.gen.transport.TransportProtos; + +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @Slf4j public abstract class AbstractMqttServerSideRpcProtoIntegrationTest extends AbstractMqttServerSideRpcIntegrationTest { + private static final String RPC_REQUEST_PROTO_SCHEMA = "syntax =\"proto3\";\n" + + "package rpc;\n" + + "\n" + + "message RpcRequestMsg {\n" + + " string method = 1;\n" + + " int32 requestId = 2;\n" + + " Params params = 3;\n" + + "\n" + + " message Params {\n" + + " string pin = 1;\n" + + " int32 value = 2;\n" + + " }\n" + + "}"; + @Before public void beforeTest() throws Exception { - processBeforeTest("RPC test device", "RPC test gateway", TransportPayloadType.PROTOBUF, null, null); + processBeforeTest("RPC test device", "RPC test gateway", TransportPayloadType.PROTOBUF, null, null, null, null, null, RPC_REQUEST_PROTO_SCHEMA, null, null, DeviceProfileProvisionType.DISABLED); } @After @@ -83,14 +118,55 @@ public abstract class AbstractMqttServerSideRpcProtoIntegrationTest extends Abst return builder.build(); } + protected void processTwoWayRpcTest() throws Exception { + MqttAsyncClient client = getMqttAsyncClient(accessToken); + client.subscribe(MqttTopics.DEVICE_RPC_REQUESTS_SUB_TOPIC, 1); + + CountDownLatch latch = new CountDownLatch(1); + TestMqttCallback callback = new TestMqttCallback(client, latch); + client.setCallback(callback); + + Thread.sleep(1000); + + String setGpioRequest = "{\"method\":\"setGpio\",\"params\":{\"pin\": \"26\",\"value\": 1}}"; + String deviceId = savedDevice.getId().getId().toString(); + + String result = doPostAsync("/api/plugins/rpc/twoway/" + deviceId, setGpioRequest, String.class, status().isOk()); + String expected = "{\"payload\":\"{\\\"value1\\\":\\\"A\\\",\\\"value2\\\":\\\"B\\\"}\"}"; + latch.await(3, TimeUnit.SECONDS); + Assert.assertEquals(expected, result); + } + protected MqttMessage processMessageArrived(String requestTopic, MqttMessage mqttMessage) throws MqttException, InvalidProtocolBufferException { MqttMessage message = new MqttMessage(); if (requestTopic.startsWith(MqttTopics.BASE_DEVICE_API_TOPIC)) { - TransportProtos.ToDeviceRpcResponseMsg toDeviceRpcResponseMsg = TransportProtos.ToDeviceRpcResponseMsg.newBuilder() - .setPayload(DEVICE_RESPONSE) - .setRequestId(0) + ProtoTransportPayloadConfiguration protoTransportPayloadConfiguration = getProtoTransportPayloadConfiguration(); + ProtoFileElement rpcRequestProtoSchemaFile = protoTransportPayloadConfiguration.getTransportProtoSchema(RPC_REQUEST_PROTO_SCHEMA); + DynamicSchema rpcRequestProtoSchema = protoTransportPayloadConfiguration.getDynamicSchema(rpcRequestProtoSchemaFile, ProtoTransportPayloadConfiguration.RPC_REQUEST_PROTO_SCHEMA); + + byte[] requestPayload = mqttMessage.getPayload(); + DynamicMessage.Builder rpcRequestMsg = rpcRequestProtoSchema.newMessageBuilder("RpcRequestMsg"); + Descriptors.Descriptor rpcRequestMsgDescriptor = rpcRequestMsg.getDescriptorForType(); + assertNotNull(rpcRequestMsgDescriptor); + try { + DynamicMessage dynamicMessage = DynamicMessage.parseFrom(rpcRequestMsgDescriptor, requestPayload); + List fields = rpcRequestMsgDescriptor.getFields(); + for (Descriptors.FieldDescriptor fieldDescriptor: fields) { + assertTrue(dynamicMessage.hasField(fieldDescriptor)); + } + ProtoFileElement transportProtoSchemaFile = protoTransportPayloadConfiguration.getTransportProtoSchema(DEVICE_RPC_RESPONSE_PROTO_SCHEMA); + DynamicSchema rpcResponseProtoSchema = protoTransportPayloadConfiguration.getDynamicSchema(transportProtoSchemaFile, ProtoTransportPayloadConfiguration.RPC_RESPONSE_PROTO_SCHEMA); + + DynamicMessage.Builder rpcResponseBuilder = rpcResponseProtoSchema.newMessageBuilder("RpcResponseMsg"); + Descriptors.Descriptor rpcResponseMsgDescriptor = rpcResponseBuilder.getDescriptorForType(); + assertNotNull(rpcResponseMsgDescriptor); + DynamicMessage rpcResponseMsg = rpcResponseBuilder + .setField(rpcResponseMsgDescriptor.findFieldByName("payload"), DEVICE_RESPONSE) .build(); - message.setPayload(toDeviceRpcResponseMsg.toByteArray()); + message.setPayload(rpcResponseMsg.toByteArray()); + } catch (InvalidProtocolBufferException e) { + log.warn("Command Response Ack Error, Invalid response received: ", e); + } } else { TransportApiProtos.GatewayDeviceRpcRequestMsg msg = TransportApiProtos.GatewayDeviceRpcRequestMsg.parseFrom(mqttMessage.getPayload()); String deviceName = msg.getDeviceName(); @@ -105,6 +181,14 @@ public abstract class AbstractMqttServerSideRpcProtoIntegrationTest extends Abst return message; } + private ProtoTransportPayloadConfiguration getProtoTransportPayloadConfiguration() { + DeviceProfileTransportConfiguration transportConfiguration = deviceProfile.getProfileData().getTransportConfiguration(); + assertTrue(transportConfiguration instanceof MqttDeviceProfileTransportConfiguration); + MqttDeviceProfileTransportConfiguration mqttTransportConfiguration = (MqttDeviceProfileTransportConfiguration) transportConfiguration; + TransportPayloadTypeConfiguration transportPayloadTypeConfiguration = mqttTransportConfiguration.getTransportPayloadTypeConfiguration(); + assertTrue(transportPayloadTypeConfiguration instanceof ProtoTransportPayloadConfiguration); + return (ProtoTransportPayloadConfiguration) transportPayloadTypeConfiguration; + } } diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/telemetry/timeseries/AbstractMqttTimeseriesProtoIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/telemetry/timeseries/AbstractMqttTimeseriesProtoIntegrationTest.java index 93a004318d..6140b7d8ed 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/telemetry/timeseries/AbstractMqttTimeseriesProtoIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/telemetry/timeseries/AbstractMqttTimeseriesProtoIntegrationTest.java @@ -119,7 +119,7 @@ public abstract class AbstractMqttTimeseriesProtoIntegrationTest extends Abstrac " }\n" + " }\n" + "}"; - super.processBeforeTest("Test Post Telemetry device proto payload", "Test Post Telemetry gateway proto payload", TransportPayloadType.PROTOBUF, POST_DATA_TELEMETRY_TOPIC, null, schemaStr, null, DeviceProfileProvisionType.DISABLED, null, null); + super.processBeforeTest("Test Post Telemetry device proto payload", "Test Post Telemetry gateway proto payload", TransportPayloadType.PROTOBUF, POST_DATA_TELEMETRY_TOPIC, null, schemaStr, null, null, null, null, null, DeviceProfileProvisionType.DISABLED); List expectedKeys = Arrays.asList("key1", "key2", "key3", "key4", "key5"); DeviceProfileTransportConfiguration transportConfiguration = deviceProfile.getProfileData().getTransportConfiguration(); assertTrue(transportConfiguration instanceof MqttDeviceProfileTransportConfiguration); @@ -172,7 +172,7 @@ public abstract class AbstractMqttTimeseriesProtoIntegrationTest extends Abstrac @Test public void testPushMqttTelemetryGateway() throws Exception { - super.processBeforeTest("Test Post Telemetry device proto payload", "Test Post Telemetry gateway proto payload", TransportPayloadType.PROTOBUF, null, null, null, null, DeviceProfileProvisionType.DISABLED, null, null); + super.processBeforeTest("Test Post Telemetry device proto payload", "Test Post Telemetry gateway proto payload", TransportPayloadType.PROTOBUF, null, null, null, null, null, null, null, null, DeviceProfileProvisionType.DISABLED); TransportApiProtos.GatewayTelemetryMsg.Builder gatewayTelemetryMsgProtoBuilder = TransportApiProtos.GatewayTelemetryMsg.newBuilder(); List expectedKeys = Arrays.asList("key1", "key2", "key3", "key4", "key5"); String deviceName1 = "Device A"; @@ -186,7 +186,7 @@ public abstract class AbstractMqttTimeseriesProtoIntegrationTest extends Abstrac @Test public void testGatewayConnect() throws Exception { - super.processBeforeTest("Test Post Telemetry device proto payload", "Test Post Telemetry gateway proto payload", TransportPayloadType.PROTOBUF, POST_DATA_TELEMETRY_TOPIC, null, null, null, DeviceProfileProvisionType.DISABLED, null, null); + super.processBeforeTest("Test Post Telemetry device proto payload", "Test Post Telemetry gateway proto payload", TransportPayloadType.PROTOBUF, POST_DATA_TELEMETRY_TOPIC, null, null, null, null, null, null, null, DeviceProfileProvisionType.DISABLED); String deviceName = "Device A"; TransportApiProtos.ConnectMsg connectMsgProto = getConnectProto(deviceName); MqttAsyncClient client = getMqttAsyncClient(gatewayAccessToken); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/ProtoTransportPayloadConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/ProtoTransportPayloadConfiguration.java index 13982f7e0b..159fbced16 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/ProtoTransportPayloadConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/ProtoTransportPayloadConfiguration.java @@ -15,7 +15,6 @@ */ package org.thingsboard.server.common.data.device.profile; -import com.fasterxml.jackson.annotation.JsonIgnore; import com.github.os72.protobuf.dynamic.DynamicSchema; import com.github.os72.protobuf.dynamic.EnumDefinition; import com.github.os72.protobuf.dynamic.MessageDefinition; @@ -46,9 +45,13 @@ public class ProtoTransportPayloadConfiguration implements TransportPayloadTypeC public static final Location LOCATION = new Location("", "", -1, -1); public static final String ATTRIBUTES_PROTO_SCHEMA = "attributes proto schema"; public static final String TELEMETRY_PROTO_SCHEMA = "telemetry proto schema"; + public static final String RPC_RESPONSE_PROTO_SCHEMA = "rpc response proto schema"; + public static final String RPC_REQUEST_PROTO_SCHEMA = "rpc request proto schema"; private String deviceTelemetryProtoSchema; private String deviceAttributesProtoSchema; + private String deviceRpcRequestProtoSchema; + private String deviceRpcResponseProtoSchema; @Override public TransportPayloadType getTransportPayloadType() { @@ -63,13 +66,45 @@ public class ProtoTransportPayloadConfiguration implements TransportPayloadTypeC return getDescriptor(deviceAttributesProtoSchema, ATTRIBUTES_PROTO_SCHEMA); } + public Descriptors.Descriptor getRpcResponseDynamicMessageDescriptor(String deviceRpcResponseProtoSchema) { + return getDescriptor(deviceRpcResponseProtoSchema, RPC_RESPONSE_PROTO_SCHEMA); + } + + public DynamicMessage.Builder getRpcRequestDynamicMessageBuilder(String deviceRpcRequestProtoSchema) { + return getDynamicMessageBuilder(deviceRpcRequestProtoSchema, RPC_REQUEST_PROTO_SCHEMA); + } + + public String getDeviceRpcResponseProtoSchema() { + if (!isEmptyStr(deviceRpcResponseProtoSchema)) { + return deviceRpcResponseProtoSchema; + } else { + return "syntax =\"proto3\";\n" + + "package rpc;\n" + + "\n" + + "message RpcResponseMsg {\n" + + " string payload = 1;\n" + + "}"; + } + } + + public String getDeviceRpcRequestProtoSchema() { + if (!isEmptyStr(deviceRpcRequestProtoSchema)) { + return deviceRpcRequestProtoSchema; + } else { + return "syntax =\"proto3\";\n" + + "package rpc;\n" + + "\n" + + "message RpcRequestMsg {\n" + + " string method = 1;\n" + + " int32 requestId = 2;\n" + + " string params = 3;\n" + + "}"; + } + } + private Descriptors.Descriptor getDescriptor(String protoSchema, String schemaName) { try { - ProtoFileElement protoFileElement = getTransportProtoSchema(protoSchema); - DynamicSchema dynamicSchema = getDynamicSchema(protoFileElement, schemaName); - String lastMsgName = getMessageTypes(protoFileElement.getTypes()).stream() - .map(MessageElement::getName).reduce((previous, last) -> last).get(); - DynamicMessage.Builder builder = dynamicSchema.newMessageBuilder(lastMsgName); + DynamicMessage.Builder builder = getDynamicMessageBuilder(protoSchema, schemaName); return builder.getDescriptorForType(); } catch (Exception e) { log.warn("Failed to get Message Descriptor due to {}", e.getMessage()); @@ -77,6 +112,14 @@ public class ProtoTransportPayloadConfiguration implements TransportPayloadTypeC } } + public DynamicMessage.Builder getDynamicMessageBuilder(String protoSchema, String schemaName) { + ProtoFileElement protoFileElement = getTransportProtoSchema(protoSchema); + DynamicSchema dynamicSchema = getDynamicSchema(protoFileElement, schemaName); + String lastMsgName = getMessageTypes(protoFileElement.getTypes()).stream() + .map(MessageElement::getName).reduce((previous, last) -> last).get(); + return dynamicSchema.newMessageBuilder(lastMsgName); + } + public DynamicSchema getDynamicSchema(ProtoFileElement protoFileElement, String schemaName) { DynamicSchema.Builder schemaBuilder = DynamicSchema.newBuilder(); schemaBuilder.setName(schemaName); diff --git a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportResource.java b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportResource.java index 8d756631e8..dcbb62e0c5 100644 --- a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportResource.java +++ b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/CoapTransportResource.java @@ -17,6 +17,7 @@ package org.thingsboard.server.transport.coap; import com.google.gson.JsonParseException; import com.google.protobuf.Descriptors; +import com.google.protobuf.DynamicMessage; import lombok.Data; import lombok.extern.slf4j.Slf4j; import org.eclipse.californium.core.coap.CoAP; @@ -261,7 +262,8 @@ public class CoapTransportResource extends AbstractCoapTransportResource { TransportProtos.SessionInfoProto currentAttrSession = tokenToSessionIdMap.get(getTokenFromRequest(request)); if (currentAttrSession == null) { attributeSubscriptions.add(sessionId); - registerAsyncCoapSession(exchange, sessionInfo, coapTransportAdaptor, getTokenFromRequest(request)); + registerAsyncCoapSession(exchange, sessionInfo, coapTransportAdaptor, + transportConfigurationContainer.getRpcRequestDynamicMessageBuilder(), getTokenFromRequest(request)); transportService.process(sessionInfo, TransportProtos.SubscribeToAttributeUpdatesMsg.getDefaultInstance(), new CoapNoOpCallback(exchange)); } @@ -281,7 +283,8 @@ public class CoapTransportResource extends AbstractCoapTransportResource { TransportProtos.SessionInfoProto currentRpcSession = tokenToSessionIdMap.get(getTokenFromRequest(request)); if (currentRpcSession == null) { rpcSubscriptions.add(sessionId); - registerAsyncCoapSession(exchange, sessionInfo, coapTransportAdaptor, getTokenFromRequest(request)); + registerAsyncCoapSession(exchange, sessionInfo, coapTransportAdaptor, + transportConfigurationContainer.getRpcRequestDynamicMessageBuilder(), getTokenFromRequest(request)); transportService.process(sessionInfo, TransportProtos.SubscribeToRPCMsg.getDefaultInstance(), new CoapNoOpCallback(exchange)); @@ -303,17 +306,19 @@ public class CoapTransportResource extends AbstractCoapTransportResource { break; case TO_DEVICE_RPC_RESPONSE: transportService.process(sessionInfo, - coapTransportAdaptor.convertToDeviceRpcResponse(sessionId, request), + coapTransportAdaptor.convertToDeviceRpcResponse(sessionId, request, transportConfigurationContainer.getRpcResponseMsgDescriptor()), new CoapOkCallback(exchange, CoAP.ResponseCode.CREATED, CoAP.ResponseCode.INTERNAL_SERVER_ERROR)); break; case TO_SERVER_RPC_REQUEST: - transportService.registerSyncSession(sessionInfo, getCoapSessionListener(exchange, coapTransportAdaptor), timeout); + transportService.registerSyncSession(sessionInfo, getCoapSessionListener(exchange, coapTransportAdaptor, + transportConfigurationContainer.getRpcRequestDynamicMessageBuilder()), timeout); transportService.process(sessionInfo, coapTransportAdaptor.convertToServerRpcRequest(sessionId, request), new CoapNoOpCallback(exchange)); break; case GET_ATTRIBUTES_REQUEST: - transportService.registerSyncSession(sessionInfo, getCoapSessionListener(exchange, coapTransportAdaptor), timeout); + transportService.registerSyncSession(sessionInfo, getCoapSessionListener(exchange, coapTransportAdaptor, + transportConfigurationContainer.getRpcRequestDynamicMessageBuilder()), timeout); transportService.process(sessionInfo, coapTransportAdaptor.convertToGetAttributes(sessionId, request), new CoapNoOpCallback(exchange)); @@ -330,14 +335,14 @@ public class CoapTransportResource extends AbstractCoapTransportResource { return tokenToSessionIdMap.remove(token); } - private void registerAsyncCoapSession(CoapExchange exchange, TransportProtos.SessionInfoProto sessionInfo, CoapTransportAdaptor coapTransportAdaptor, String token) { + private void registerAsyncCoapSession(CoapExchange exchange, TransportProtos.SessionInfoProto sessionInfo, CoapTransportAdaptor coapTransportAdaptor, DynamicMessage.Builder rpcRequestDynamicMessageBuilder, String token) { tokenToSessionIdMap.putIfAbsent(token, sessionInfo); - transportService.registerAsyncSession(sessionInfo, getCoapSessionListener(exchange, coapTransportAdaptor)); + transportService.registerAsyncSession(sessionInfo, getCoapSessionListener(exchange, coapTransportAdaptor, rpcRequestDynamicMessageBuilder)); transportService.process(sessionInfo, getSessionEventMsg(TransportProtos.SessionEvent.OPEN), null); } - private CoapSessionListener getCoapSessionListener(CoapExchange exchange, CoapTransportAdaptor coapTransportAdaptor) { - return new CoapSessionListener(exchange, coapTransportAdaptor); + private CoapSessionListener getCoapSessionListener(CoapExchange exchange, CoapTransportAdaptor coapTransportAdaptor, DynamicMessage.Builder rpcRequestDynamicMessageBuilder) { + return new CoapSessionListener(exchange, coapTransportAdaptor, rpcRequestDynamicMessageBuilder); } private String getTokenFromRequest(Request request) { @@ -423,10 +428,12 @@ public class CoapTransportResource extends AbstractCoapTransportResource { private final CoapExchange exchange; private final CoapTransportAdaptor coapTransportAdaptor; + private final DynamicMessage.Builder rpcRequestDynamicMessageBuilder; - CoapSessionListener(CoapExchange exchange, CoapTransportAdaptor coapTransportAdaptor) { + CoapSessionListener(CoapExchange exchange, CoapTransportAdaptor coapTransportAdaptor, DynamicMessage.Builder rpcRequestDynamicMessageBuilder) { this.exchange = exchange; this.coapTransportAdaptor = coapTransportAdaptor; + this.rpcRequestDynamicMessageBuilder = rpcRequestDynamicMessageBuilder; } @Override @@ -457,7 +464,7 @@ public class CoapTransportResource extends AbstractCoapTransportResource { @Override public void onToDeviceRpcRequest(TransportProtos.ToDeviceRpcRequestMsg msg) { try { - exchange.respond(coapTransportAdaptor.convertToPublish(isConRequest(), msg)); + exchange.respond(coapTransportAdaptor.convertToPublish(isConRequest(), msg, rpcRequestDynamicMessageBuilder)); } catch (AdaptorException e) { log.trace("Failed to reply due to error", e); exchange.respond(CoAP.ResponseCode.INTERNAL_SERVER_ERROR); @@ -545,9 +552,14 @@ public class CoapTransportResource extends AbstractCoapTransportResource { (ProtoTransportPayloadConfiguration) transportPayloadTypeConfiguration; String deviceTelemetryProtoSchema = protoTransportPayloadConfiguration.getDeviceTelemetryProtoSchema(); String deviceAttributesProtoSchema = protoTransportPayloadConfiguration.getDeviceAttributesProtoSchema(); + String deviceRpcRequestProtoSchema = protoTransportPayloadConfiguration.getDeviceRpcRequestProtoSchema(); + String deviceRpcResponseProtoSchema = protoTransportPayloadConfiguration.getDeviceRpcResponseProtoSchema(); return new TransportConfigurationContainer(false, protoTransportPayloadConfiguration.getTelemetryDynamicMessageDescriptor(deviceTelemetryProtoSchema), - protoTransportPayloadConfiguration.getAttributesDynamicMessageDescriptor(deviceAttributesProtoSchema)); + protoTransportPayloadConfiguration.getAttributesDynamicMessageDescriptor(deviceAttributesProtoSchema), + protoTransportPayloadConfiguration.getRpcResponseDynamicMessageDescriptor(deviceRpcResponseProtoSchema), + protoTransportPayloadConfiguration.getRpcRequestDynamicMessageBuilder(deviceRpcRequestProtoSchema) + ); } } else { throw new AdaptorException("Invalid CoapDeviceTypeConfiguration type: " + coapDeviceTypeConfiguration.getClass().getSimpleName() + "!"); @@ -567,11 +579,15 @@ public class CoapTransportResource extends AbstractCoapTransportResource { private boolean jsonPayload; private Descriptors.Descriptor telemetryMsgDescriptor; private Descriptors.Descriptor attributesMsgDescriptor; + private Descriptors.Descriptor rpcResponseMsgDescriptor; + private DynamicMessage.Builder rpcRequestDynamicMessageBuilder; - public TransportConfigurationContainer(boolean jsonPayload, Descriptors.Descriptor telemetryMsgDescriptor, Descriptors.Descriptor attributesMsgDescriptor) { + public TransportConfigurationContainer(boolean jsonPayload, Descriptors.Descriptor telemetryMsgDescriptor, Descriptors.Descriptor attributesMsgDescriptor, Descriptors.Descriptor rpcResponseMsgDescriptor, DynamicMessage.Builder rpcRequestDynamicMessageBuilder) { this.jsonPayload = jsonPayload; this.telemetryMsgDescriptor = telemetryMsgDescriptor; this.attributesMsgDescriptor = attributesMsgDescriptor; + this.rpcResponseMsgDescriptor = rpcResponseMsgDescriptor; + this.rpcRequestDynamicMessageBuilder = rpcRequestDynamicMessageBuilder; } public TransportConfigurationContainer(boolean jsonPayload) { diff --git a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/CoapTransportAdaptor.java b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/CoapTransportAdaptor.java index 1d4703ead4..b30c86307b 100644 --- a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/CoapTransportAdaptor.java +++ b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/CoapTransportAdaptor.java @@ -16,6 +16,7 @@ package org.thingsboard.server.transport.coap.adaptors; import com.google.protobuf.Descriptors; +import com.google.protobuf.DynamicMessage; import org.eclipse.californium.core.coap.Request; import org.eclipse.californium.core.coap.Response; import org.thingsboard.server.common.transport.adaptor.AdaptorException; @@ -32,7 +33,7 @@ public interface CoapTransportAdaptor { TransportProtos.GetAttributeRequestMsg convertToGetAttributes(UUID sessionId, Request inbound) throws AdaptorException; - TransportProtos.ToDeviceRpcResponseMsg convertToDeviceRpcResponse(UUID sessionId, Request inbound) throws AdaptorException; + TransportProtos.ToDeviceRpcResponseMsg convertToDeviceRpcResponse(UUID sessionId, Request inbound, Descriptors.Descriptor rpcResponseMsgDescriptor) throws AdaptorException; TransportProtos.ToServerRpcRequestMsg convertToServerRpcRequest(UUID sessionId, Request inbound) throws AdaptorException; @@ -42,7 +43,7 @@ public interface CoapTransportAdaptor { Response convertToPublish(boolean isConfirmable, TransportProtos.AttributeUpdateNotificationMsg notificationMsg) throws AdaptorException; - Response convertToPublish(boolean isConfirmable, TransportProtos.ToDeviceRpcRequestMsg rpcRequest) throws AdaptorException; + Response convertToPublish(boolean isConfirmable, TransportProtos.ToDeviceRpcRequestMsg rpcRequest, DynamicMessage.Builder rpcRequestDynamicMessageBuilder) throws AdaptorException; Response convertToPublish(TransportProtos.ToServerRpcResponseMsg msg) throws AdaptorException; diff --git a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/JsonCoapAdaptor.java b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/JsonCoapAdaptor.java index e382b2a3ee..85d35301c3 100644 --- a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/JsonCoapAdaptor.java +++ b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/JsonCoapAdaptor.java @@ -20,6 +20,7 @@ import com.google.gson.JsonObject; import com.google.gson.JsonParser; import com.google.gson.JsonSyntaxException; import com.google.protobuf.Descriptors; +import com.google.protobuf.DynamicMessage; import lombok.extern.slf4j.Slf4j; import org.eclipse.californium.core.coap.CoAP; import org.eclipse.californium.core.coap.Request; @@ -64,7 +65,7 @@ public class JsonCoapAdaptor implements CoapTransportAdaptor { } @Override - public TransportProtos.ToDeviceRpcResponseMsg convertToDeviceRpcResponse(UUID sessionId, Request inbound) throws AdaptorException { + public TransportProtos.ToDeviceRpcResponseMsg convertToDeviceRpcResponse(UUID sessionId, Request inbound, Descriptors.Descriptor rpcResponseMsgDescriptor) throws AdaptorException { Optional requestId = CoapTransportResource.getRequestId(inbound); String payload = validatePayload(sessionId, inbound, false); JsonObject response = new JsonParser().parse(payload).getAsJsonObject(); @@ -95,7 +96,7 @@ public class JsonCoapAdaptor implements CoapTransportAdaptor { } @Override - public Response convertToPublish(boolean isConfirmable, TransportProtos.ToDeviceRpcRequestMsg msg) throws AdaptorException { + public Response convertToPublish(boolean isConfirmable, TransportProtos.ToDeviceRpcRequestMsg msg, DynamicMessage.Builder rpcRequestDynamicMessageBuilder) throws AdaptorException { return getObserveNotification(isConfirmable, JsonConverter.toJson(msg, true)); } diff --git a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/ProtoCoapAdaptor.java b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/ProtoCoapAdaptor.java index d4c53fdc9a..120e49f697 100644 --- a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/ProtoCoapAdaptor.java +++ b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/adaptors/ProtoCoapAdaptor.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.transport.coap.adaptors; +import com.google.gson.JsonElement; import com.google.gson.JsonParser; import com.google.protobuf.Descriptors; import com.google.protobuf.DynamicMessage; @@ -63,16 +64,16 @@ public class ProtoCoapAdaptor implements CoapTransportAdaptor { } @Override - public TransportProtos.ToDeviceRpcResponseMsg convertToDeviceRpcResponse(UUID sessionId, Request inbound) throws AdaptorException { + public TransportProtos.ToDeviceRpcResponseMsg convertToDeviceRpcResponse(UUID sessionId, Request inbound, Descriptors.Descriptor rpcResponseMsgDescriptor) throws AdaptorException { Optional requestId = CoapTransportResource.getRequestId(inbound); if (requestId.isEmpty()) { throw new AdaptorException("Request id is missing!"); } else { try { - String payload = TransportProtos.ToDeviceRpcResponseMsg.parseFrom(inbound.getPayload()).getPayload(); - return TransportProtos.ToDeviceRpcResponseMsg.newBuilder().setRequestId(requestId.get()) - .setPayload(payload).build(); - } catch (InvalidProtocolBufferException e) { + JsonElement response = new JsonParser().parse(dynamicMsgToJson(inbound.getPayload(), rpcResponseMsgDescriptor)); + return TransportProtos.ToDeviceRpcResponseMsg.newBuilder().setRequestId(requestId.orElseThrow(() -> new AdaptorException("Request id is missing!"))) + .setPayload(response.toString()).build(); + } catch (Exception e) { throw new AdaptorException(e); } } @@ -112,8 +113,8 @@ public class ProtoCoapAdaptor implements CoapTransportAdaptor { } @Override - public Response convertToPublish(boolean isConfirmable, TransportProtos.ToDeviceRpcRequestMsg msg) throws AdaptorException { - return getObserveNotification(isConfirmable, msg.toByteArray()); + public Response convertToPublish(boolean isConfirmable, TransportProtos.ToDeviceRpcRequestMsg rpcRequest, DynamicMessage.Builder rpcRequestDynamicMessageBuilder) throws AdaptorException { + return getObserveNotification(isConfirmable, ProtoConverter.convertToRpcRequest(rpcRequest, rpcRequestDynamicMessageBuilder)); } @Override diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/ProtoMqttAdaptor.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/ProtoMqttAdaptor.java index c948e755c2..30178bd44f 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/ProtoMqttAdaptor.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/adaptors/ProtoMqttAdaptor.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.transport.mqtt.adaptors; +import com.google.gson.JsonElement; import com.google.gson.JsonParser; import com.google.protobuf.Descriptors; import com.google.protobuf.DynamicMessage; @@ -64,9 +65,9 @@ public class ProtoMqttAdaptor implements MqttTransportAdaptor { public TransportProtos.PostAttributeMsg convertToPostAttributes(MqttDeviceAwareSessionContext ctx, MqttPublishMessage inbound) throws AdaptorException { DeviceSessionCtx deviceSessionCtx = (DeviceSessionCtx) ctx; byte[] bytes = toBytes(inbound.payload()); - Descriptors.Descriptor attributesDynamicMessage = getDescriptor(deviceSessionCtx.getAttributesDynamicMessageDescriptor()); + Descriptors.Descriptor attributesDynamicMessageDescriptor = getDescriptor(deviceSessionCtx.getAttributesDynamicMessageDescriptor()); try { - return JsonConverter.convertToAttributesProto(new JsonParser().parse(dynamicMsgToJson(bytes, attributesDynamicMessage))); + return JsonConverter.convertToAttributesProto(new JsonParser().parse(dynamicMsgToJson(bytes, attributesDynamicMessageDescriptor))); } catch (Exception e) { throw new AdaptorException(e); } @@ -86,8 +87,8 @@ public class ProtoMqttAdaptor implements MqttTransportAdaptor { public TransportProtos.GetAttributeRequestMsg convertToGetAttributes(MqttDeviceAwareSessionContext ctx, MqttPublishMessage inbound) throws AdaptorException { byte[] bytes = toBytes(inbound.payload()); String topicName = inbound.variableHeader().topicName(); - int requestId = getRequestId(topicName, MqttTopics.DEVICE_ATTRIBUTES_REQUEST_TOPIC_PREFIX); try { + int requestId = getRequestId(topicName, MqttTopics.DEVICE_ATTRIBUTES_REQUEST_TOPIC_PREFIX); return ProtoConverter.convertToGetAttributeRequestMessage(bytes, requestId); } catch (InvalidProtocolBufferException e) { log.warn("Failed to decode get attributes request", e); @@ -97,10 +98,15 @@ public class ProtoMqttAdaptor implements MqttTransportAdaptor { @Override public TransportProtos.ToDeviceRpcResponseMsg convertToDeviceRpcResponse(MqttDeviceAwareSessionContext ctx, MqttPublishMessage mqttMsg) throws AdaptorException { + DeviceSessionCtx deviceSessionCtx = (DeviceSessionCtx) ctx; + String topicName = mqttMsg.variableHeader().topicName(); byte[] bytes = toBytes(mqttMsg.payload()); + Descriptors.Descriptor rpcResponseDynamicMessageDescriptor = getDescriptor(deviceSessionCtx.getRpcResponseDynamicMessageDescriptor()); try { - return TransportProtos.ToDeviceRpcResponseMsg.parseFrom(bytes); - } catch (RuntimeException | InvalidProtocolBufferException e) { + int requestId = getRequestId(topicName, MqttTopics.DEVICE_RPC_RESPONSE_TOPIC); + JsonElement response = new JsonParser().parse(dynamicMsgToJson(bytes, rpcResponseDynamicMessageDescriptor)); + return TransportProtos.ToDeviceRpcResponseMsg.newBuilder().setRequestId(requestId).setPayload(response.toString()).build(); + } catch (Exception e) { log.warn("Failed to decode Rpc response", e); throw new AdaptorException(e); } @@ -145,8 +151,10 @@ public class ProtoMqttAdaptor implements MqttTransportAdaptor { @Override - public Optional convertToPublish(MqttDeviceAwareSessionContext ctx, TransportProtos.ToDeviceRpcRequestMsg rpcRequest) { - return Optional.of(createMqttPublishMsg(ctx, MqttTopics.DEVICE_RPC_REQUESTS_TOPIC + rpcRequest.getRequestId(), ProtoConverter.convertToRpcRequest(rpcRequest))); + public Optional convertToPublish(MqttDeviceAwareSessionContext ctx, TransportProtos.ToDeviceRpcRequestMsg rpcRequest) throws AdaptorException { + DeviceSessionCtx deviceSessionCtx = (DeviceSessionCtx) ctx; + DynamicMessage.Builder rpcRequestDynamicMessageBuilder = deviceSessionCtx.getRpcRequestDynamicMessageBuilder(); + return Optional.of(createMqttPublishMsg(ctx, MqttTopics.DEVICE_RPC_REQUESTS_TOPIC + rpcRequest.getRequestId(), ProtoConverter.convertToRpcRequest(rpcRequest, rpcRequestDynamicMessageBuilder))); } @Override diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java index efc5c057ed..3804d96cf6 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/session/DeviceSessionCtx.java @@ -16,6 +16,7 @@ package org.thingsboard.server.transport.mqtt.session; import com.google.protobuf.Descriptors; +import com.google.protobuf.DynamicMessage; import io.netty.channel.ChannelHandlerContext; import lombok.Getter; import lombok.Setter; @@ -60,6 +61,8 @@ public class DeviceSessionCtx extends MqttDeviceAwareSessionContext { private volatile TransportPayloadType payloadType = TransportPayloadType.JSON; private volatile Descriptors.Descriptor attributesDynamicMessageDescriptor; private volatile Descriptors.Descriptor telemetryDynamicMessageDescriptor; + private volatile Descriptors.Descriptor rpcResponseDynamicMessageDescriptor; + private volatile DynamicMessage.Builder rpcRequestDynamicMessageBuilder; @Getter @Setter @@ -102,6 +105,14 @@ public class DeviceSessionCtx extends MqttDeviceAwareSessionContext { return attributesDynamicMessageDescriptor; } + public Descriptors.Descriptor getRpcResponseDynamicMessageDescriptor() { + return rpcResponseDynamicMessageDescriptor; + } + + public DynamicMessage.Builder getRpcRequestDynamicMessageBuilder() { + return rpcRequestDynamicMessageBuilder; + } + @Override public void setDeviceProfile(DeviceProfile deviceProfile) { super.setDeviceProfile(deviceProfile); @@ -136,5 +147,7 @@ public class DeviceSessionCtx extends MqttDeviceAwareSessionContext { ProtoTransportPayloadConfiguration protoTransportPayloadConfig = (ProtoTransportPayloadConfiguration) transportPayloadTypeConfiguration; telemetryDynamicMessageDescriptor = protoTransportPayloadConfig.getTelemetryDynamicMessageDescriptor(protoTransportPayloadConfig.getDeviceTelemetryProtoSchema()); attributesDynamicMessageDescriptor = protoTransportPayloadConfig.getAttributesDynamicMessageDescriptor(protoTransportPayloadConfig.getDeviceAttributesProtoSchema()); + rpcResponseDynamicMessageDescriptor = protoTransportPayloadConfig.getRpcResponseDynamicMessageDescriptor(protoTransportPayloadConfig.getDeviceRpcResponseProtoSchema()); + rpcRequestDynamicMessageBuilder = protoTransportPayloadConfig.getRpcRequestDynamicMessageBuilder(protoTransportPayloadConfig.getDeviceRpcRequestProtoSchema()); } } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/AdaptorException.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/AdaptorException.java index 42c99bc428..d0d471a784 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/AdaptorException.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/AdaptorException.java @@ -31,4 +31,8 @@ public class AdaptorException extends Exception { super(cause); } + public AdaptorException(String message, Exception cause) { + super(message, cause); + } + } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/ProtoConverter.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/ProtoConverter.java index ed440ebcfe..620728cda4 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/ProtoConverter.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/adaptor/ProtoConverter.java @@ -15,10 +15,13 @@ */ package org.thingsboard.server.common.transport.adaptor; +import com.google.gson.Gson; import com.google.gson.JsonElement; +import com.google.gson.JsonObject; import com.google.gson.JsonParser; -import com.google.gson.JsonPrimitive; +import com.google.protobuf.DynamicMessage; import com.google.protobuf.InvalidProtocolBufferException; +import com.google.protobuf.util.JsonFormat; import lombok.extern.slf4j.Slf4j; import org.springframework.util.CollectionUtils; import org.springframework.util.StringUtils; @@ -34,6 +37,7 @@ import java.util.List; @Slf4j public class ProtoConverter { + public static final Gson GSON = new Gson(); public static final JsonParser JSON_PARSER = new JsonParser(); public static TransportProtos.PostTelemetryMsg convertToTelemetryProto(byte[] payload) throws InvalidProtocolBufferException, IllegalArgumentException { @@ -170,26 +174,20 @@ public class ProtoConverter { return kvList; } - public static byte[] convertToRpcRequest(TransportProtos.ToDeviceRpcRequestMsg toDeviceRpcRequestMsg) { - TransportProtos.ToDeviceRpcRequestMsg.Builder toDeviceRpcRequestMsgBuilder = toDeviceRpcRequestMsg.newBuilderForType(); - toDeviceRpcRequestMsgBuilder.mergeFrom(toDeviceRpcRequestMsg); - toDeviceRpcRequestMsgBuilder.setParams(parseParams(toDeviceRpcRequestMsg)); - TransportProtos.ToDeviceRpcRequestMsg result = toDeviceRpcRequestMsgBuilder.build(); - return result.toByteArray(); - } - - private static String parseParams(TransportProtos.ToDeviceRpcRequestMsg toDeviceRpcRequestMsg) { + public static byte[] convertToRpcRequest(TransportProtos.ToDeviceRpcRequestMsg toDeviceRpcRequestMsg, DynamicMessage.Builder rpcRequestDynamicMessageBuilder) throws AdaptorException { + rpcRequestDynamicMessageBuilder.clear(); + JsonObject rpcRequestJson = new JsonObject(); + rpcRequestJson.addProperty("method", toDeviceRpcRequestMsg.getMethodName()); + rpcRequestJson.addProperty("requestId", toDeviceRpcRequestMsg.getRequestId()); String params = toDeviceRpcRequestMsg.getParams(); - JsonElement jsonElementParams = JSON_PARSER.parse(params); - if (!jsonElementParams.isJsonPrimitive()) { - return params; - } else { - JsonPrimitive primitiveParams = jsonElementParams.getAsJsonPrimitive(); - if (jsonElementParams.getAsJsonPrimitive().isString()) { - return primitiveParams.getAsString(); - } else { - return params; - } + try { + JsonElement paramsElement = JSON_PARSER.parse(params); + rpcRequestJson.add("params", paramsElement); + JsonFormat.parser().ignoringUnknownFields().merge(GSON.toJson(rpcRequestJson), rpcRequestDynamicMessageBuilder); + DynamicMessage dynamicRpcRequest = rpcRequestDynamicMessageBuilder.build(); + return dynamicRpcRequest.toByteArray(); + } catch (Exception e) { + throw new AdaptorException("Failed to convert ToDeviceRpcRequestMsg to Dynamic Rpc request message due to: ", e); } } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java index bff523d956..48879d2f15 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/device/DeviceProfileServiceImpl.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.dao.device; +import com.google.protobuf.Descriptors; +import com.google.protobuf.DynamicMessage; import com.squareup.wire.Syntax; import com.squareup.wire.schema.Field; import com.squareup.wire.schema.Location; @@ -87,6 +89,8 @@ public class DeviceProfileServiceImpl extends AbstractEntityService implements D private static final Location LOCATION = new Location("", "", -1, -1); private static final String ATTRIBUTES_PROTO_SCHEMA = "attributes proto schema"; private static final String TELEMETRY_PROTO_SCHEMA = "telemetry proto schema"; + private static final String RPC_REQUEST_PROTO_SCHEMA = "rpc request proto schema"; + private static final String RPC_RESPONSE_PROTO_SCHEMA = "rpc response proto schema"; private static String invalidSchemaProvidedMessage(String schemaName) { return "[Transport Configuration] invalid " + schemaName + " provided!"; @@ -357,9 +361,10 @@ public class DeviceProfileServiceImpl extends AbstractEntityService implements D if (transportConfiguration instanceof MqttDeviceProfileTransportConfiguration) { MqttDeviceProfileTransportConfiguration mqttTransportConfiguration = (MqttDeviceProfileTransportConfiguration) transportConfiguration; if (mqttTransportConfiguration.getTransportPayloadTypeConfiguration() instanceof ProtoTransportPayloadConfiguration) { - ProtoTransportPayloadConfiguration protoTransportPayloadTypeConfiguration = + ProtoTransportPayloadConfiguration protoTransportPayloadConfiguration = (ProtoTransportPayloadConfiguration) mqttTransportConfiguration.getTransportPayloadTypeConfiguration(); - validateProtoSchemas(protoTransportPayloadTypeConfiguration); + validateProtoSchemas(protoTransportPayloadConfiguration); + validateRpcRequestDynamicMessageFields(protoTransportPayloadConfiguration); } } else if (transportConfiguration instanceof CoapDeviceProfileTransportConfiguration) { CoapDeviceProfileTransportConfiguration coapDeviceProfileTransportConfiguration = (CoapDeviceProfileTransportConfiguration) transportConfiguration; @@ -370,6 +375,7 @@ public class DeviceProfileServiceImpl extends AbstractEntityService implements D if (transportPayloadTypeConfiguration instanceof ProtoTransportPayloadConfiguration) { ProtoTransportPayloadConfiguration protoTransportPayloadConfiguration = (ProtoTransportPayloadConfiguration) transportPayloadTypeConfiguration; validateProtoSchemas(protoTransportPayloadConfiguration); + validateRpcRequestDynamicMessageFields(protoTransportPayloadConfiguration); } } } @@ -417,6 +423,8 @@ public class DeviceProfileServiceImpl extends AbstractEntityService implements D try { validateTransportProtoSchema(protoTransportPayloadTypeConfiguration.getDeviceAttributesProtoSchema(), ATTRIBUTES_PROTO_SCHEMA); validateTransportProtoSchema(protoTransportPayloadTypeConfiguration.getDeviceTelemetryProtoSchema(), TELEMETRY_PROTO_SCHEMA); + validateTransportProtoSchema(protoTransportPayloadTypeConfiguration.getDeviceRpcRequestProtoSchema(), RPC_REQUEST_PROTO_SCHEMA); + validateTransportProtoSchema(protoTransportPayloadTypeConfiguration.getDeviceRpcResponseProtoSchema(), RPC_RESPONSE_PROTO_SCHEMA); } catch (Exception exception) { throw new DataValidationException(exception.getMessage()); } @@ -539,6 +547,48 @@ public class DeviceProfileServiceImpl extends AbstractEntityService implements D }; + private void validateRpcRequestDynamicMessageFields(ProtoTransportPayloadConfiguration protoTransportPayloadTypeConfiguration) { + DynamicMessage.Builder rpcRequestDynamicMessageBuilder = protoTransportPayloadTypeConfiguration.getRpcRequestDynamicMessageBuilder(protoTransportPayloadTypeConfiguration.getDeviceRpcRequestProtoSchema()); + Descriptors.Descriptor rpcRequestDynamicMessageDescriptor = rpcRequestDynamicMessageBuilder.getDescriptorForType(); + if (rpcRequestDynamicMessageDescriptor == null) { + throw new DataValidationException(invalidSchemaProvidedMessage(RPC_REQUEST_PROTO_SCHEMA) + " Failed to get rpcRequestDynamicMessageDescriptor!"); + } else { + if (CollectionUtils.isEmpty(rpcRequestDynamicMessageDescriptor.getFields()) || rpcRequestDynamicMessageDescriptor.getFields().size() != 3) { + throw new DataValidationException(invalidSchemaProvidedMessage(RPC_REQUEST_PROTO_SCHEMA) + " " + rpcRequestDynamicMessageDescriptor.getName() + " message should always contains 3 fields: method, requestId and params!"); + } + Descriptors.FieldDescriptor methodFieldDescriptor = rpcRequestDynamicMessageDescriptor.findFieldByName("method"); + if (methodFieldDescriptor == null) { + throw new DataValidationException(invalidSchemaProvidedMessage(RPC_REQUEST_PROTO_SCHEMA) + " Failed to get field descriptor for field: method!"); + } else { + if (!Descriptors.FieldDescriptor.Type.STRING.equals(methodFieldDescriptor.getType())) { + throw new DataValidationException(invalidSchemaProvidedMessage(RPC_REQUEST_PROTO_SCHEMA) + " Field 'method' has invalid data type. Only string type is supported!"); + } + if (methodFieldDescriptor.isRepeated()) { + throw new DataValidationException(invalidSchemaProvidedMessage(RPC_REQUEST_PROTO_SCHEMA) + " Field 'method' has invalid label!"); + } + } + Descriptors.FieldDescriptor requestIdFieldDescriptor = rpcRequestDynamicMessageDescriptor.findFieldByName("requestId"); + if (requestIdFieldDescriptor == null) { + throw new DataValidationException(invalidSchemaProvidedMessage(RPC_REQUEST_PROTO_SCHEMA) + " Failed to get field descriptor for field: requestId!"); + } else { + if (!Descriptors.FieldDescriptor.Type.INT32.equals(requestIdFieldDescriptor.getType())) { + throw new DataValidationException(invalidSchemaProvidedMessage(RPC_REQUEST_PROTO_SCHEMA) + " Field 'requestId' has invalid data type. Only int32 type is supported!"); + } + if (requestIdFieldDescriptor.isRepeated()) { + throw new DataValidationException(invalidSchemaProvidedMessage(RPC_REQUEST_PROTO_SCHEMA) + " Field 'requestId' has invalid label!"); + } + } + Descriptors.FieldDescriptor paramsFieldDescriptor = rpcRequestDynamicMessageDescriptor.findFieldByName("params"); + if (paramsFieldDescriptor == null) { + throw new DataValidationException(invalidSchemaProvidedMessage(RPC_REQUEST_PROTO_SCHEMA) + " Failed to get field descriptor for field: params!"); + } else { + if (paramsFieldDescriptor.isRepeated()) { + throw new DataValidationException(invalidSchemaProvidedMessage(RPC_REQUEST_PROTO_SCHEMA) + "Field 'params' has invalid label!"); + } + } + } + } + private PaginatedRemover tenantDeviceProfilesRemover = new PaginatedRemover() { diff --git a/ui-ngx/src/app/modules/home/components/profile/device/coap-device-profile-transport-configuration.component.html b/ui-ngx/src/app/modules/home/components/profile/device/coap-device-profile-transport-configuration.component.html index e04ecfb258..320373e52c 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/coap-device-profile-transport-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device/coap-device-profile-transport-configuration.component.html @@ -61,6 +61,21 @@ {{ 'device-profile.attributes-proto-schema-required' | translate}} + + device-profile.rpc-request-proto-schema + + + {{ 'device-profile.rpc-request-proto-schema-required' | translate}} + + device-profile.rpc-request-proto-schema-hint + + + device-profile.rpc-response-proto-schema + + + {{ 'device-profile.rpc-response-proto-schema-required' | translate}} + +
diff --git a/ui-ngx/src/app/modules/home/components/profile/device/coap-device-profile-transport-configuration.component.ts b/ui-ngx/src/app/modules/home/components/profile/device/coap-device-profile-transport-configuration.component.ts index 4a686d39bd..dc1da3d973 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/coap-device-profile-transport-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device/coap-device-profile-transport-configuration.component.ts @@ -24,6 +24,8 @@ import { coapDeviceTypeTranslationMap, CoapTransportDeviceType, defaultAttributesSchema, + defaultRpcRequestSchema, + defaultRpcResponseSchema, defaultTelemetrySchema, DeviceProfileTransportConfiguration, DeviceTransportType, @@ -59,7 +61,9 @@ export class CoapDeviceProfileTransportConfigurationComponent implements Control private transportPayloadTypeConfiguration = this.fb.group({ transportPayloadType: [TransportPayloadType.JSON, Validators.required], deviceTelemetryProtoSchema: [defaultTelemetrySchema, Validators.required], - deviceAttributesProtoSchema: [defaultAttributesSchema, Validators.required] + deviceAttributesProtoSchema: [defaultAttributesSchema, Validators.required], + deviceRpcRequestProtoSchema: [defaultRpcRequestSchema, Validators.required], + deviceRpcResponseProtoSchema: [defaultRpcResponseSchema, Validators.required] }); get required(): boolean { diff --git a/ui-ngx/src/app/modules/home/components/profile/device/mqtt-device-profile-transport-configuration.component.html b/ui-ngx/src/app/modules/home/components/profile/device/mqtt-device-profile-transport-configuration.component.html index 670e6fbb72..55f0869dbc 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/mqtt-device-profile-transport-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device/mqtt-device-profile-transport-configuration.component.html @@ -88,6 +88,21 @@ {{ 'device-profile.attributes-proto-schema-required' | translate}} + + device-profile.rpc-request-proto-schema + + + {{ 'device-profile.rpc-request-proto-schema-required' | translate}} + + device-profile.rpc-request-proto-schema-hint + + + device-profile.rpc-response-proto-schema + + + {{ 'device-profile.rpc-response-proto-schema-required' | translate}} + + diff --git a/ui-ngx/src/app/modules/home/components/profile/device/mqtt-device-profile-transport-configuration.component.ts b/ui-ngx/src/app/modules/home/components/profile/device/mqtt-device-profile-transport-configuration.component.ts index a61ad841aa..381e2f9e8d 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/mqtt-device-profile-transport-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device/mqtt-device-profile-transport-configuration.component.ts @@ -29,6 +29,8 @@ import { AppState } from '@app/core/core.state'; import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { defaultAttributesSchema, + defaultRpcRequestSchema, + defaultRpcResponseSchema, defaultTelemetrySchema, DeviceProfileTransportConfiguration, DeviceTransportType, @@ -90,7 +92,9 @@ export class MqttDeviceProfileTransportConfigurationComponent implements Control transportPayloadTypeConfiguration: this.fb.group({ transportPayloadType: [TransportPayloadType.JSON, Validators.required], deviceTelemetryProtoSchema: [defaultTelemetrySchema, Validators.required], - deviceAttributesProtoSchema: [defaultAttributesSchema, Validators.required] + deviceAttributesProtoSchema: [defaultAttributesSchema, Validators.required], + deviceRpcRequestProtoSchema: [defaultRpcRequestSchema, Validators.required], + deviceRpcResponseProtoSchema: [defaultRpcResponseSchema, Validators.required] }) }, {validator: this.uniqueDeviceTopicValidator} ); @@ -139,15 +143,21 @@ export class MqttDeviceProfileTransportConfigurationComponent implements Control if (forceUpdated) { transportPayloadTypeForm.patchValue({ deviceTelemetryProtoSchema: defaultTelemetrySchema, - deviceAttributesProtoSchema: defaultAttributesSchema + deviceAttributesProtoSchema: defaultAttributesSchema, + deviceRpcRequestProtoSchema: defaultRpcRequestSchema, + deviceRpcResponseProtoSchema: defaultRpcResponseSchema }, {emitEvent: false}); } if (type === TransportPayloadType.PROTOBUF && !this.disabled) { transportPayloadTypeForm.get('deviceTelemetryProtoSchema').enable({emitEvent: false}); transportPayloadTypeForm.get('deviceAttributesProtoSchema').enable({emitEvent: false}); + transportPayloadTypeForm.get('deviceRpcRequestProtoSchema').enable({emitEvent: false}); + transportPayloadTypeForm.get('deviceRpcResponseProtoSchema').enable({emitEvent: false}); } else { transportPayloadTypeForm.get('deviceTelemetryProtoSchema').disable({emitEvent: false}); transportPayloadTypeForm.get('deviceAttributesProtoSchema').disable({emitEvent: false}); + transportPayloadTypeForm.get('deviceRpcRequestProtoSchema').enable({emitEvent: false}); + transportPayloadTypeForm.get('deviceRpcResponseProtoSchema').disable({emitEvent: false}); } } diff --git a/ui-ngx/src/app/shared/models/device.models.ts b/ui-ngx/src/app/shared/models/device.models.ts index 52990127bf..46b457a2ee 100644 --- a/ui-ngx/src/app/shared/models/device.models.ts +++ b/ui-ngx/src/app/shared/models/device.models.ts @@ -140,6 +140,24 @@ export const defaultAttributesSchema = ' string serialNumber = 2;\n' + '}'; +export const defaultRpcRequestSchema = + 'syntax ="proto3";\n' + + 'package rpc;\n' + + '\n' + + 'message RpcRequestMsg {\n' + + ' string method = 1;\n' + + ' int32 requestId = 2;\n' + + ' string params = 3;\n' + + '}'; + +export const defaultRpcResponseSchema = + 'syntax ="proto3";\n' + + 'package rpc;\n' + + '\n' + + 'message RpcResponseMsg {\n' + + ' string payload = 1;\n' + + '}'; + export const coapDeviceTypeTranslationMap = new Map( [ [CoapTransportDeviceType.DEFAULT, 'device-profile.coap-device-type-default'], @@ -170,6 +188,13 @@ export const deviceTransportTypeConfigurationInfoMap = new Map