From ad1e4b76d6ec4fef42423077e000be57ff148d53 Mon Sep 17 00:00:00 2001 From: zhongjin Date: Tue, 2 Oct 2018 21:20:21 +0800 Subject: [PATCH] Initial commit --- .gitignore | 3 + .npmignore | 10 + .travis.yml | 23 ++ Gruntfile.js | 222 ++++++++++++ LICENSE | 21 ++ README.md | 82 +++++ admin/index.html | 481 ++++++++++++++++++++++++++ admin/owntracks.png | Bin 0 -> 6221 bytes appveyor.yml | 25 ++ img/settings1.png | Bin 0 -> 57002 bytes io-package.json | 78 +++++ lib/utils.js | 83 +++++ main.js | 374 ++++++++++++++++++++ package.json | 51 +++ tasks/jscs.js | 17 + tasks/jscsRules.js | 36 ++ tasks/jshint.js | 17 + test/lib/setup.js | 728 +++++++++++++++++++++++++++++++++++++++ test/testAdapter.js | 140 ++++++++ test/testPackageFiles.js | 91 +++++ 20 files changed, 2482 insertions(+) create mode 100644 .gitignore create mode 100644 .npmignore create mode 100644 .travis.yml create mode 100644 Gruntfile.js create mode 100644 LICENSE create mode 100644 README.md create mode 100644 admin/index.html create mode 100644 admin/owntracks.png create mode 100644 appveyor.yml create mode 100644 img/settings1.png create mode 100644 io-package.json create mode 100644 lib/utils.js create mode 100644 main.js create mode 100644 package.json create mode 100644 tasks/jscs.js create mode 100644 tasks/jscsRules.js create mode 100644 tasks/jshint.js create mode 100644 test/lib/setup.js create mode 100644 test/testAdapter.js create mode 100644 test/testPackageFiles.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b86dece --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/node_modules +/.idea +/tmp diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..b42acdb --- /dev/null +++ b/.npmignore @@ -0,0 +1,10 @@ +Gruntfile.js +tasks +node_modules +.idea +.git +/node_modules +test +tmp +.travis.yml +appveyor.yml diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..5a5145d --- /dev/null +++ b/.travis.yml @@ -0,0 +1,23 @@ +os: + - linux + - osx +language: node_js +node_js: + - '4' + - '6' + - '8' + - '10' +before_script: + - export NPMVERSION=$(echo "$($(which npm) -v)"|cut -c1) + - 'if [[ $NPMVERSION == 5 ]]; then npm install -g npm@5; fi' + - npm -v + - npm install winston@2.3.1 + - 'npm install https://git.spacen.net/yunkong2/yunkong2.js-controller/tarball/master --production' +env: + - CXX=g++-4.8 +addons: + apt: + sources: + - ubuntu-toolchain-r-test + packages: + - g++-4.8 diff --git a/Gruntfile.js b/Gruntfile.js new file mode 100644 index 0000000..544040d --- /dev/null +++ b/Gruntfile.js @@ -0,0 +1,222 @@ +// To use this file in WebStorm, right click on the file name in the Project Panel (normally left) and select "Open Grunt Console" + +/** @namespace __dirname */ +/* jshint -W097 */// jshint strict:false +/*jslint node: true */ +"use strict"; + +module.exports = function (grunt) { + + var srcDir = __dirname + '/'; + var pkg = grunt.file.readJSON('package.json'); + var adaptName = pkg.name.substring('yunkong2.'.length); + var iopackage = grunt.file.readJSON('io-package.json'); + var version = (pkg && pkg.version) ? pkg.version : iopackage.common.version; + var newname = grunt.option('name'); + var author = grunt.option('author') || 'bluefox'; + var email = grunt.option('email') || 'dogafox@gmail.com'; + var fs = require('fs'); + + // check arguments + if (process.argv[2] == 'rename') { + console.log('Try to rename to "' + newname + '"'); + if (!newname) { + console.log('Please write the new template name, like: "grunt rename --name=mywidgetset" --author="Author Name"'); + process.exit(); + } + if (newname.indexOf(' ') != -1) { + console.log('Name may not have space in it.'); + process.exit(); + } + if (newname.toLowerCase() != newname) { + console.log('Name must be lower case.'); + process.exit(); + } + if (fs.existsSync(__dirname + '/admin/owntracks.png')) { + fs.renameSync(__dirname + '/admin/owntracks.png', __dirname + '/admin/' + newname + '.png'); + } + if (fs.existsSync(__dirname + '/widgets/owntracks.html')) { + fs.renameSync(__dirname + '/widgets/owntracks.html', __dirname + '/widgets/' + newname + '.html'); + } + if (fs.existsSync(__dirname + '/widgets/template/js/owntracks.js')) { + fs.renameSync(__dirname + '/widgets/template/js/owntracks.js', __dirname + '/widgets/template/js/' + newname + '.js'); + } + if (fs.existsSync(__dirname + '/widgets/owntracks')) { + fs.renameSync(__dirname + '/widgets/owntracks', __dirname + '/widgets/' + newname); + } + } + + // Project configuration. + grunt.initConfig({ + pkg: pkg, + + replace: { + version: { + options: { + patterns: [ + { + match: /version: *"[\.0-9]*"/, + replacement: 'version: "' + version + '"' + }, + { + match: /"version": *"[\.0-9]*",/g, + replacement: '"version": "' + version + '",' + }, + { + match: /version: *"[\.0-9]*",/g, + replacement: 'version: "' + version + '",' + } + ] + }, + files: [ + { + expand: true, + flatten: true, + src: [ + srcDir + 'package.json', + srcDir + 'io-package.json' + ], + dest: srcDir + }, + { + expand: true, + flatten: true, + src: [ + srcDir + 'widgets/' + adaptName + '.html' + ], + dest: srcDir + 'widgets' + }, + { + expand: true, + flatten: true, + src: [ + srcDir + 'widgets/' + adaptName + '/js/' + adaptName + '.js' + ], + dest: srcDir + 'widgets/' + adaptName + '/js/' + } + ] + }, + name: { + options: { + patterns: [ + { + match: /template\-rest/g, + replacement: newname + }, + { + match: /Template\-rest/g, + replacement: newname ? (newname[0].toUpperCase() + newname.substring(1)) : 'Owntracks' + }, + { + match: /bluefox/g, + replacement: author + }, + { + match: /dogafox@gmail.com/g, + replacement: email + } + ] + }, + files: [ + { + expand: true, + flatten: true, + src: [ + srcDir + 'io-package.json', + srcDir + 'LICENSE', + srcDir + 'package.json', + srcDir + 'README.md', + srcDir + 'io-package.json', + srcDir + 'main.js', + srcDir + 'Gruntfile.js' + ], + dest: srcDir + }, + { + expand: true, + flatten: true, + src: [ + srcDir + 'widgets/' + newname +'.html' + ], + dest: srcDir + 'widgets' + }, + { + expand: true, + flatten: true, + src: [ + srcDir + 'admin/index.html' + ], + dest: srcDir + 'admin' + }, + { + expand: true, + flatten: true, + src: [ + srcDir + 'widgets/' + newname + '/js/' + newname +'.js' + ], + dest: srcDir + 'widgets/' + newname + '/js' + }, + { + expand: true, + flatten: true, + src: [ + srcDir + 'widgets/' + newname + '/css/*.css' + ], + dest: srcDir + 'widgets/' + newname + '/css' + } + ] + } + }, + // Javascript code styler + jscs: require(__dirname + '/tasks/jscs.js'), + // Lint + jshint: require(__dirname + '/tasks/jshint.js'), + + http: { + get_hjscs: { + options: { + url: 'https://raw.githubusercontent.com/yunkong2/yunkong2.js-controller/master/tasks/jscs.js' + }, + dest: 'tasks/jscs.js' + }, + get_jshint: { + options: { + url: 'https://raw.githubusercontent.com/yunkong2/yunkong2.js-controller/master/tasks/jshint.js' + }, + dest: 'tasks/jshint.js' + }, + get_jscsRules: { + options: { + url: 'https://raw.githubusercontent.com/yunkong2/yunkong2.js-controller/master/tasks/jscsRules.js' + }, + dest: 'tasks/jscsRules.js' + } + } + }); + + grunt.loadNpmTasks('grunt-replace'); + grunt.loadNpmTasks('grunt-contrib-jshint'); + grunt.loadNpmTasks('grunt-jscs'); + grunt.loadNpmTasks('grunt-http'); + + grunt.registerTask('default', [ + 'http', + 'replace:version', + 'jshint', + 'jscs' + ]); + + grunt.registerTask('prepublish', [ + 'http', + 'replace:version' + ]); + + grunt.registerTask('p', [ + 'http', + 'replace:version' + ]); + + grunt.registerTask('rename', [ + 'replace:name' + ]); +}; diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..737d792 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 'bluefox' <'dogafox@gmail.com'> + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..09d2931 --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ +![Logo](admin/owntracks.png) +# yunkong2.owntracks +================= +[![NPM version](http://img.shields.io/npm/v/yunkong2.owntracks.svg)](https://www.npmjs.com/package/yunkong2.owntracks) +[![Downloads](https://img.shields.io/npm/dm/yunkong2.owntracks.svg)](https://www.npmjs.com/package/yunkong2.owntracks) + +[![NPM](https://nodei.co/npm/yunkong2.owntracks.png?downloads=true)](https://nodei.co/npm/yunkong2.owntracks/) + + +[OwnTracks](http://owntracks.org/) is an app for android and iOS. + +Link for: +- Andorid - [https://play.google.com/store/apps/details?id=org.owntracks.android](https://play.google.com/store/apps/details?id=org.owntracks.android) +- iOS - [https://itunes.apple.com/de/app/owntracks/id692424691?mt=8](https://itunes.apple.com/de/app/owntracks/id692424691?mt=8) + +App sends continuously your position (position of device) to some specific server. In our case it will be yunkong2 server. + +The MQTT protocol will be used for communication. + +##Usage +OwnTracks Adapter starts on port 1883 (configurable) a MQTT server to receive the messages from devices with coordinates. +The problem is that this server must be reachable from internet. +Normally there is a router or firewall, that must be configured to forward traffic. + +Settings in App: +- Connection/Mode - MQTT private +- Connection/Host/Host - IP address of your system or DynDNS domain. E.g. http://www.noip.com/ lets use domain name instead of IP address. +- Connection/Host/Port - 1883 or your port on your router +- Connection/Host/WebSockets - false +- Connection/Identification/Username - yunkong2 +- Connection/Identification/Password - from adapter settings +- Connection/Identification/DeviceID - Name of device or person. For this device the states will be created. E.g. if deviceID is "Mark", following states will be created after first contact: + + - owntracks.0.users.Mark.longitude + - owntracks.0.users.Mark.latitude + +- Connection/Identification/TrackerID - Short name of user (up to 2 letters) to write it on map. +- Connection/Security/TLS - off + +### Icons +You can define for every user an icon. Just upload per drag&drop or with mouse click you image. It will be automatically scaled to 64x64. + +The name must be equal to DeviceID in OwnTracks app. + +![Settings](img/settings1.png) + +## Changelog + +#### 0.3.0 (2018-06-05) +* (matspi) Fix handling of publish messages + +#### 0.2.0 (2017-01-03) +* (jp112sdl) added two properties timestamp and datetime + +#### 0.1.1 (2016-09-05) +* (bluefox) add pictures + +#### 0.1.0 (2016-09-04) +* (bluefox) initial release + +## License +The MIT License (MIT) + +Copyright (c) 2016-2017 bluefox + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/admin/index.html b/admin/index.html new file mode 100644 index 0000000..516d667 --- /dev/null +++ b/admin/index.html @@ -0,0 +1,481 @@ + + + + + + + + + + + + + + +
+ + + + +

OwnTracks adapter settings

+
+ +
+ + + + + + + + + +

Server settings

+
+
+ + + + + + + + + + + +
NamePicture
+
+
+
+ + diff --git a/admin/owntracks.png b/admin/owntracks.png new file mode 100644 index 0000000000000000000000000000000000000000..0e40bc46fd2487a88be2fafeb34c92ff796c8eda GIT binary patch literal 6221 zcmV-T7_#SyP)N2bZe?^J zG%heMIczh2P5=NG%Sl8*RCr$PU3qXE)tQ%JNwP(@sM>>tT|!cuRP7&|N~J<7yR{Wc z1)d(sHzo)Y2v-seBm|NG8%|F!OIA|%NS5(|FKo7hga8uVw$>%M1V5 zr|S3mRs9*iW(2ad@Y{Yh{E=VN4Pq7j-|`6Q@1U+LLE{7ZyC_T3U#EYc9-gm{Q}RPU z^yOCHM1@{2iXVcPaL3G2>S)fj?f2n4R9p8YQ*ga?Roh)pDvUYX$RBt5r>*q{W%)3eCRWaby& zlEK(8QOhA%3Tjwiu5mk=kJ_-P;VbzQ1Nd{J_TYy{Q&&0yrI3% zNbF!V`SL?AWagIMCf;xeQjl-YD$xIfde01EtCc~V>!f!fML0$8l@H~nw;gg1SHOm4 z<%R!U(ezo=Nav}+29MwekQ71H&(eG5MdE~O9Ew_cGmXpNqMmVv8fXv;z8_%`G~<|} zh5v*&M>NN^gPcI|*Qf{3FivkCRHi;bB1TX&qh2X6{MSrQfnNH1^z!8-{I32Da;=iu zqJu2L{OrLTDDqw(EPn*1Pu2)SgtdxV{(ZDzq$$%3IXT7GD{A;YdZFX} zUvP-=ku1SMpB5e)2(ar!v!(|yL+8&Ay+8q9o9xAg7(bC4AtiVYZ7Q1A+sH1e{CbvV zXk@~=WiK?uB&Zo(WDW8WNQ-9nCKR>mMn%=MET%*?>NZ;0mh7BK6D z)ixN;$X;NG7Ld;=gy+G^ie~o)(DO+{hlKA0BWdJ0o{0&djoj>eI;E>KzZzmnUMW*@ zOPKP1i-^U)9l2#i|k@YC+qC);l$s^TQ9P`M^3TTyAQCB*VMDO z%2u;Srj@fSDnrRHL!(QdPt94v zj7@vkxwZ}=K5GFzI!Ow(aBV&N)jJjZnRvFWPEL~QaPIEB(49gq&LEQB)lbMOR&~WcjGB>VF>H4)WyT2&R z)-)X8!I{_4)7#5d)PKQpsE(+s>>cbT$)??x7aA_aWi7ChhfKdKi#cFk5#${@eNHrY zV(9I?%+6ly;KRH%G|V$LHL&+S+s@|JY~{q?Fy$B~?x$hmxwcD5D_P5_R`%rVO4nQh zDM3S~X8fZNn>B#xr^3toKg16k3Rn{a^a1hL1E5^u-G# zmykPf#}eoleVXx8A->d*T~zr`)ELe32iZsx+-sk#8eg1v21TMq`6s&KsU|Sj+hRBi>z|p85z2@2ZRoriz0j|2~ z_*rgpv+a(;sqB$zDVtQXiXA?Ep2g!BPSFVA*E1JKEQ+j5n@I6mPnH^fiiDVamJmC3 zsN&ePA$^D~q=tHEn!yg7Y!yuy9q5Tmx9(^6PLB9=X?ZpfJ^Dc!`(xe!(Z3n?9y!g% z7djsgP@6PjE4>hBf(QhzBQr9K3UajfkJid~! zjtj)la%S~r)@7o&h#T&k?5qOtrDU4^dO|aX*uqQ#X_Xad7e|okuinupno15NXFgc{ zxhHylf5H+Juis^wK>B2Dy&KBglvPlAs}Qd_pfRKXj`e;7aL>}Xy}h$bG?N_b8d`WV zM^=Uxh`hswy$3~YlLVfR@$L_-}5D{4i}*z*SiBoE7!*gbIl9d2-oh|DcJ_j3mVxN*?PV{0 zSYw%)#xU%ak5{nHtA=ez<^*Y2Y1QNn>iXKk6)`h~4Tny$Q55*f?zf9nucIULD{c|u z9tgT%0-0n-gFmEjV~zWFQt!_~idS-fqQvu%!8>|ppu!<dsR2`-QmsArQEZddG74jrI^zz2$X#MUxB!reX8+b(RSQS;pEJQTi_G zm6-jL-J2+3X^^joWd)GQblMG2TP~7j2Kn);L8C)}!EnuZfG%(} z@Bhd&gU{bb16J?&^HLWEdgBY^YA(jHt^k?FiJz>rq-P?q-1@~)QSv}wgUWashRW`> zjhfLlJWn4j#C`=dOqe6R!7jr8zh517wGA?Ag%ue3r5>35f_h@}iL;_)b6}ScR{Eyu zpHcT&)perOfq=3-HXYYG83%ATP$0w}0X7XMCF_n}o-W5Do0~ToR@+~Q6>>hku=RG|dX45mA zo+0Erx(Y+z5#lHRpBIAvmtJfavEj0odRa8IP;fXVg>f<(^>^=9vDUWa_YWXG`Qv-l zDfQ5?!P1y3o5GtKEQvP~qu!0PhlKbs`4h&MHY69?M<_tUq3GGN&s&bCH2%N0u#7JQ zPZeJrKg(AMC+mu^OfA;xrWN(gE+_yjurX1HpC^E-Wf$2=Y~PL*nAJ5NNqX8BLo2NF zPi1FtA5cW~_C`M4D|?nHNdd4VEEVC!qkbdPcGefW~~Q zbhYU+twewr0xOV`8j+YN0D?^`MmkBbiEksr*R`CS`|qQYSH^eYM8oE$Bou(mG?s;* zIv;yFHW{3`*v@Ay6OIQ+0dOcqG_2m;;*te0I{9g&zJlvr-5EYdnih!aaPVg-ix$uD3sbyUaqASYe&Mkh6{dAVyztt>8a|Sc z0T$)AbCx7M#7vbwH9Rqcv|zgIM<+O8)?%LWl>v$|`_s+l{*fB*m5*z9)Q{|)8O?y_ z%I>?GP|kSP_`}L!wUNYN7n#; zg(Td%#>1jH1_C>SaTbvD-u{FcMURX^Sd!OcxST!|V8;=xj--q$jL&5M>Yc?gH;XLZ zyq8-E*}YDXWM7s+W&j^smR?{tA(W!UwNZT}gf*jT6TeP&e?laoA$T)YfQs6<1ssd0 z^ZXTCMX3V;SHjN^u;5wAQqjUounxe{WnlgJDE07IOTwE^v|3{(9aaF;`Dl#At|!m8 zvGGCo%s)u+-!j(~06F&U&BsKu3_;bw!xLIe z4un9Kk007Bxj!yCqySjy{Nd^yqBJ>n95}{CI=mQEMv{G52EFWuvLBr!B4I3{hPjUh zjPp}kBIbkw(0%CHv1TG+=_1R(EC_5JF+d@ zec#WCuZ{%6Ph|MC^0Cqj+#oglFQ=8qoIeK;D^zaX%eQ*PEi5|}fUbkYJQB5W<`|bZO4=EvAb*(Ci1OXW>{_)1116 z3V@YLh~#CvjQa!DY8p>xd#CpccHlG^tO~uJ6u|kTU`>b}J@EBr0t2u}W8s>5egaq2 zhPFTfu!;$54;F9PXL>ALA8_~tKfzQ|o|`0@@5$)z4c$A2B6KJNVvD$@gGmA4-*L~_f)M{MP-x?BK6w+5 zHR=Pb(jVXRn%}=dYLAzM`1#J@dt?`Rfe;41;TLOvT{K{panIpX{7YXr@*UQ}Oo*7m zef8rNth9DFPfW$y-yaQ7LhLK_!Uv_~)Vme6Y=jVhMh1=&B_-&QU+e*r57jp|B-8`@ z17{6&#NoG*h)Q*3@y30JJbj)a$@;>mVEMla@n>XUt1$g%gY06j5a;>rv0$&C-N2y_ z4^8!^+3zE{rMk>O;L7%J0G}C=UF;RYmp(P8l3j@N0}^fkBLk~W7qOFyX5g%?x3GLmeMW|FHH;+6ql3DZNeo4rV!&(CM$gg~6e?n5U%HN|U2ivDMr zemO)bFvfUob}%Z*=RP{!NGMfo6>#tqK4+T_ejg;6wq+HR-zmf#9hrgRZ~8T(QC5f- ziFn|DOOYy0Eqj*;x_Wx}Hvv32#T%6*)7t+Ee)6|M%(3AM>d#Z}@_aD(CCLYeH||a; zRU9wy3`|pFI4Z4+CrQCyQ2(V6uQ@VvOK+nQ!6|uR-XxB0flsy@IC{?MFMp*FGNpqd&48m`o_W{(9X*MV$8!bc&k3={4}3a~Uewvo#->R4 zhF)1IollEt5UF&@EE$-+%XH{hx(c69O`W`Dqmk5V2mbYTJJD)k1O>VP$=@ zC$@7XLJItP6gB*;kZ0l$iJ{}N!o5uR=%(YbcNrxDoLijo!PPF1WVaLNKnigt2Sp42 zDQ)rsf>{3b?7S*=-h!!Pe9RJuRiRATv%E^o`bGafPy)cxVMR0QWe@Q*DIS=y%=|;t zv4Cmkc`LT1o!@=M&~elsWqhM&xdqu&#FzIG+e{-EVCWEGT;;bjjqJA?dYWerc+#Hlu z#LGmgcv8tK>BdgDhbfgCJ-nMIIu%vljW53n@i>BEIBT9^;pK^hQksszKT=X!pBAQPno^_SCebl_Ez@H?sJ z|Ct)s+rs}66rA`}<6(-d&&ghBhzU^BPxGOkR|3BH!G_(P7rK*_z;j2?r3;baQ`8D1 zdD2bzS8$hF@@+-aYlecu_=$-fV%X{Qu5D1%1ve_H@m4xcB|A+_=_*0vMaL#~oB}>o z?$biUNeOTm)sQvlPYm%ieUm>}_7mHubln8DD3LXoo)y$DN*m%DBt`gVN%(I@&hEVi zfJ8>NX8e?5gAMR^vPQWUNJ8(zm%Fm_^&brCULOtrofrN)pBjFO8lv%9N*;+IuimGY z-9PAjeKN=@SaK^Vz(i7ngM(QSu)&7ReIvc+?^E8fGw6WiQLb@2xrNtB37Q6%YtXyt zo%WCd{2|$mn%5B6u&jd8Td9{!AZ6H0ijcNLM|y`g#5q1S^l-xa^M`=^LDh}^yzpI$ zs?VjyJc^zqYiMtgdOOt>2Qp>lmESq!<1GOjHZs5B7HoE;#$80F^rV+2o5;L(lHiY% zqApZ|dQK+Q6?GPsAA+TzSb;3V&7=sUi3Q{$c924}IcOa&M|!z&fu3K7gO};~+1$jZ z--J#21N#=U3Paz)=kLTw!XP0vMS7)y;Hx*Oc?e|Y__lbiSRM5D9`ffUB>0yU)fj>E z_6HofJ_K(7i6#uQvhqrQ1jSH-#*-w_S0WZeUqCEVwD1xVXmy0)CHOy5migpbGzi(B rAASPg)kBhoXXtV~x(X*HWMuq55JFpkdUqWJ00000NkvXXu0mjf0XheO literal 0 HcmV?d00001 diff --git a/appveyor.yml b/appveyor.yml new file mode 100644 index 0000000..d278435 --- /dev/null +++ b/appveyor.yml @@ -0,0 +1,25 @@ +version: 'test-{build}' +environment: + matrix: + - nodejs_version: '4' + - nodejs_version: '6' + - nodejs_version: '8' + - nodejs_version: '10' +platform: + - x86 + - x64 +clone_folder: 'c:\projects\%APPVEYOR_PROJECT_NAME%' +install: + - ps: 'Install-Product node $env:nodejs_version $env:platform' + - ps: '$NpmVersion = (npm -v).Substring(0,1)' + - ps: 'if($NpmVersion -eq 5) { npm install -g npm@5 }' + - ps: npm --version + - npm install + - npm install winston@2.3.1 + - 'npm install https://git.spacen.net/yunkong2/yunkong2.js-controller/tarball/master --production' +test_script: + - echo %cd% + - node --version + - npm --version + - npm test +build: 'off' diff --git a/img/settings1.png b/img/settings1.png new file mode 100644 index 0000000000000000000000000000000000000000..613cea06adba2eb1141efc5b567ebcefd694e84b GIT binary patch literal 57002 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%0ACRND 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 W{#?m&2{~>80T)hSSb*A(pZ6bbQlC!% literal 0 HcmV?d00001 diff --git a/io-package.json b/io-package.json new file mode 100644 index 0000000..bfd0be0 --- /dev/null +++ b/io-package.json @@ -0,0 +1,78 @@ +{ + "common": { + "name": "owntracks", + "version": "0.3.0", + "title": "OwnTracks adapter", + "desc": { + "en": "yunkong2 OwnTracks Adapter", + "de": "yunkong2 OwnTracks Adapter", + "ru": "yunkong2 OwnTracks драйвер" + }, + "news": { + "0.3.0": { + "en": "Fix handling of publish messages", + "de": "Problem in der Abarbeitung von publish-Nachrichten behoben" + }, + "0.2.0": { + "en": "added two properties timestamp and datetime", + "de": "Timestamp und datetime sind hinzugefügt", + "ru": "Добавлены время последнего сообщения в разных форматах" + }, + "0.1.1": { + "en": "add pictures", + "de": "Unterstützung von Bildern", + "ru": "Возможность добавить иконки" + }, + "0.1.0": { + "en": "initial checkin", + "de": "Erste version", + "ru": "первая версия" + } + }, + "platform": "Javascript/Node.js", + "mode": "daemon", + "icon": "owntracks.png", + "extIcon": "https://git.spacen.net/yunkong2/yunkong2.owntracks/raw/master/admin/owntracks.png", + "keywords": [ + "owntracks", + "position", + "gps", + "geo" + ], + "readme": "https://git.spacen.net/yunkong2/yunkong2.owntracks/blob/master/README.md", + "loglevel": "info", + "type": "geoposition", + "authors": [ + { + "name": "bluefox", + "email": "dogafox@gmail.com" + } + ] + }, + "native": { + "port": 1883, + "user": "yunkong2", + "pass": "", + "secure": false, + "pictures": [], + "bind": "0.0.0.0", + "certPublic": "defaultPublic", + "certPrivate": "defaultPrivate", + "certChained": "", + "leEnabled": false, + "leUpdate": false, + "leCheckPort": 80 + }, + "objects": [], + "instanceObjects": [ + { + "_id": "users", + "type": "channel", + "common": { + "role": "users", + "name": "List of users" + }, + "native": {} + } + ] +} \ No newline at end of file diff --git a/lib/utils.js b/lib/utils.js new file mode 100644 index 0000000..6af37ff --- /dev/null +++ b/lib/utils.js @@ -0,0 +1,83 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); + +let controllerDir; +let appName; + +/** + * returns application name + * + * The name of the application can be different and this function finds it out. + * + * @returns {string} + */ + function getAppName() { + const parts = __dirname.replace(/\\/g, '/').split('/'); + return parts[parts.length - 2].split('.')[0]; +} + +/** + * looks for js-controller home folder + * + * @param {boolean} isInstall + * @returns {string} + */ +function getControllerDir(isInstall) { + // Find the js-controller location + const possibilities = [ + 'yunkong2.js-controller', + 'yunkong2.js-controller', + ]; + /** @type {string} */ + let controllerPath; + for (const pkg of possibilities) { + try { + const possiblePath = require.resolve(pkg); + if (fs.existsSync(possiblePath)) { + controllerPath = possiblePath; + break; + } + } catch (e) { /* not found */ } + } + if (controllerPath == null) { + if (!isInstall) { + console.log('Cannot find js-controller'); + process.exit(10); + } else { + process.exit(); + } + } + // we found the controller + return path.dirname(controllerPath); +} + +/** + * reads controller base settings + * + * @alias getConfig + * @returns {object} + */ + function getConfig() { + let configPath; + if (fs.existsSync( + configPath = path.join(controllerDir, 'conf', appName + '.json') + )) { + return JSON.parse(fs.readFileSync(configPath, 'utf8')); + } else if (fs.existsSync( + configPath = path.join(controllerDir, 'conf', + appName.toLowerCase() + '.json') + )) { + return JSON.parse(fs.readFileSync(configPath, 'utf8')); + } else { + throw new Error('Cannot find ' + controllerDir + '/conf/' + appName + '.json'); + } +} +appName = getAppName(); +controllerDir = getControllerDir(typeof process !== 'undefined' && process.argv && process.argv.indexOf('--install') !== -1); +const adapter = require(path.join(controllerDir, 'lib/adapter.js')); + +exports.controllerDir = controllerDir; +exports.getConfig = getConfig; +exports.Adapter = adapter; +exports.appName = appName; diff --git a/main.js b/main.js new file mode 100644 index 0000000..c63c5d4 --- /dev/null +++ b/main.js @@ -0,0 +1,374 @@ +/* jshint -W097 */// jshint strict:false +/*jslint node: true */ +'use strict'; +var utils = require(__dirname + '/lib/utils'); // Get common adapter utils +var adapter = utils.Adapter('owntracks'); +//var LE = require(utils.controllerDir + '/lib/letsencrypt.js'); +var createStreamServer = require('create-stream-server'); +var mqtt = require('mqtt-connection'); + +var server; +var clients = {}; +var objects = {}; + +function decrypt(key, value) { + var result = ''; + for (var i = 0; i < value.length; ++i) { + result += String.fromCharCode(key[i % key.length].charCodeAt(0) ^ value.charCodeAt(i)); + } + return result; +} + +// is called when adapter shuts down - callback has to be called under any circumstances! +adapter.on('unload', function (callback) { + try { + adapter.log.info('cleaned everything up...'); + if (server) { + server.destroy(); + server = null; + } + callback(); + } catch (e) { + callback(); + } +}); + +adapter.on('ready', main); + +function createUser(user) { + var id = adapter.namespace + '.users.' + user.replace(/\s|\./g, '_'); + adapter.getForeignObject(id + '.battery', function (err, obj) { + if (!obj) { + adapter.setForeignObject(id + '.battery', { + common: { + name: 'Device battery level for ' + user, + min: 0, + max: 100, + unit: '%', + role: 'battery', + type: 'number' + }, + type: 'state', + native: {} + }); + } + }); + adapter.getForeignObject(id + '.latitude', function (err, obj) { + if (!obj) { + adapter.setForeignObject(id + '.latitude', { + common: { + name: 'Latitude for ' + user, + role: 'gps.latitude', + type: 'number' + }, + type: 'state', + native: {} + }); + } + }); + adapter.getForeignObject(id + '.longitude', function (err, obj) { + if (!obj) { + adapter.setForeignObject(id + '.longitude', { + common: { + name: 'Longitude for ' + user, + role: 'gps.longitude', + type: 'number' + }, + type: 'state', + native: {} + }); + } + }); + adapter.getForeignObject(id + '.accuracy', function (err, obj) { + if (!obj) { + adapter.setForeignObject(id + '.accuracy', { + common: { + name: 'Accuracy for ' + user, + role: 'state', + unit: 'm', + type: 'number' + }, + type: 'state', + native: {} + }); + } + }); + adapter.getForeignObject(id + '.timestamp', function (err, obj) { + if (!obj) { + adapter.setForeignObject(id + '.timestamp', { + common: { + name: 'Timestamp for ' + user, + role: 'state', + type: 'number' + }, + type: 'state', + native: {} + }); + } + }); + adapter.getForeignObject(id + '.datetime', function (err, obj) { + if (!obj) { + adapter.setForeignObject(id + '.datetime', { + common: { + name: 'Datetime for ' + user, + role: 'state', + type: 'string' + }, + type: 'state', + native: {} + }); + } + }); +} + +function sendState2Client(client, topic, payload) { + // client has subscription for this ID + if (client._subsID && client._subsID[topic]) { + client.publish({topic: topic, payload: payload}); + } else + // Check patterns + if (client._subs) { + for (var s in client._subs) { + if (!client._subs.hasOwnProperty(s)) continue; + if (client._subs[s].regex.exec(topic)) { + client.publish({topic: topic, payload: payload}); + break; + } + } + } +} + +function processTopic(topic, payload, ignoreClient) { + for (var k in clients) { + // if get and set have different topic names, send state to issuing client too. + if (clients[k] === ignoreClient) continue; + sendState2Client(clients[k], topic, payload); + } +} + +var cltFunction = function (client) { + client.on('connect', function (packet) { + client.id = packet.clientId; + if (adapter.config.user) { + if (adapter.config.user != packet.username || + adapter.config.pass != packet.password) { + adapter.log.warn('Client [' + packet.clientId + '] has invalid password(' + packet.password + ') or username(' + packet.username + ')'); + client.connack({returnCode: 4}); + if (clients[client.id]) delete clients[client.id]; + client.stream.end(); + return; + } + } + + adapter.log.info('Client [' + packet.clientId + '] connected'); + client.connack({returnCode: 0}); + clients[client.id] = client; + }); + + client.on('publish', function (packet) { + var isAck = true; + var topic = packet.topic; + var message = packet.payload; + adapter.log.debug('publish "' + topic + '": ' + message); + + if (packet.qos == 1) { + client.puback({ messageId: packet.messageId}); + } + else if (packet.qos == 2) { + client.pubrec({ messageId: packet.messageId}); + } + // "owntracks/yunkong2/klte": + // { + // "_type":"location", // location, lwt, transition, configuration, beacon, cmd, steps, card, waypoint + // "acc":50, // accuracy of location in meters + // "batt":46, // is the device's battery level in percent (integer) + // "lat":49.0026446, // latitude + // "lon":8.3832128, // longitude + // "tid":"te", // is a configurable tracker-ID - ignored + // "tst":1472987109 // UNIX timestamp in seconds + // } + var parts = topic.split('/'); + if (parts[1] !== adapter.config.user) { + adapter.log.warn('publish "' + topic + '": invalid user name - "' + parts[1] + '"'); + return; + } + if (!objects[parts[2]]) { + // create object + createUser(parts[2]); + objects[parts[2]] = true; + } + processTopic(topic, message); + try { + var obj = JSON.parse(message); + if (obj._type === 'location') { + if (obj.acc !== undefined) { + adapter.setState('users.' + parts[2] + '.accuracy', {val: obj.acc, ts: obj.tst * 1000, ack: true}); + } + if (obj.batt !== undefined) { + adapter.setState('users.' + parts[2] + '.battery', {val: obj.batt, ts: obj.tst * 1000, ack: true}); + } + if (obj.lon !== undefined) { + adapter.setState('users.' + parts[2] + '.longitude', {val: obj.lon, ts: obj.tst * 1000, ack: true}); + } + if (obj.lat !== undefined) { + adapter.setState('users.' + parts[2] + '.latitude', {val: obj.lat, ts: obj.tst * 1000, ack: true}); + } + if (obj.tst !== undefined) { + adapter.setState('users.' + parts[2] + '.timestamp', {val: obj.tst, ts: obj.tst * 1000, ack: true}); + var date = new Date(obj.tst * 1000); + var day = '0' + date.getDate(); + var month = '0' + (date.getMonth() + 1); + var year = date.getFullYear(); + var hours = '0' + date.getHours(); + var minutes = '0' + date.getMinutes(); + var seconds = '0' + date.getSeconds(); + var formattedTime = day.substr(-2) + '.' + month.substr(-2) + '.' + year + ' ' + hours.substr(-2) + ':' + minutes.substr(-2) + ':' + seconds.substr(-2); + + adapter.setState('users.' + parts[2] + '.datetime', {val: formattedTime, ts: obj.tst * 1000, ack: true}); + } + + } + } catch (e) { + adapter.log.error('Cannot parse payload: ' + message); + } + }); + + client.on('subscribe', function (packet) { + var granted = []; + if (!client._subsID) client._subsID = {}; + if (!client._subs) client._subs = {}; + + for (var i = 0; i < packet.subscriptions.length; i++) { + granted.push(packet.subscriptions[i].qos); + + var topic = packet.subscriptions[i].topic; + + adapter.log.debug('Subscribe on ' + topic); + // if pattern without wildchars + if (topic.indexOf('*') === -1 && topic.indexOf('#') === -1 && topic.indexOf('+') === -1) { + client._subsID[topic] = { + qos: packet.subscriptions[i].qos + }; + } else { + // "owntracks/+/+/info" => owntracks\/.+\/.+\/info + var pattern = topic.replace(/\//g, '\\/').replace(/\+/g, '[^\\/]+').replace(/\*/g, '.*'); + pattern = '^' + pattern + '$'; + + // add simple pattern + client._subs[topic] = { + regex: new RegExp(pattern), + qos: packet.subscriptions[i].qos, + pattern: topic + }; + } + } + + client.suback({granted: granted, messageId: packet.messageId}); + //Subscribe on owntracks/+/+ + //Subscribe on owntracks/+/+/info + //Subscribe on owntracks/yunkong2/denis/cmd + //Subscribe on owntracks/+/+/event + //Subscribe on owntracks/+/+/waypoint + + // send to client all images + if (adapter.config.pictures && adapter.config.pictures.length) { + setTimeout(function () { + for (var p = 0; p < adapter.config.pictures.length; p++) { + var text = adapter.config.pictures[p].base64.split(',')[1]; // string has form data:;base64,TEXT== + sendState2Client(client, 'owntracks/' + adapter.config.user + '/' + adapter.config.pictures[p].name + '/info', + JSON.stringify({ + _type: 'card', + name: adapter.config.pictures[p].name, + face: text + }) + ); + } + }, 200); + } + }); + + client.on('pingreq', function (packet) { + adapter.log.debug('Client [' + client.id + '] pingreq'); + client.pingresp(); + }); + + client.on('disconnect', function (packet) { + adapter.log.info('Client [' + client.id + '] disconnected'); + client.stream.end(); + }); + + client.on('close', function (err) { + adapter.log.info('Client [' + client.id + '] closed'); + delete clients[client.id]; + }); + + client.on('error', function (err) { + adapter.log.warn('[' + client.id + '] ' + err); + }); +}; + + +function initMqttServer(config) { + var serverConfig = {}; + var options = { + ssl: config.certificates, + emitEvents: true // default + }; + + config.port = parseInt(config.port, 10) || 1883; + + if (config.ssl) { + serverConfig.mqtts = 'ssl://0.0.0.0:' + config.port; + if (config.webSocket) { + serverConfig.mqtwss = 'wss://0.0.0.0:' + (config.port + 1); + } + } else { + serverConfig.mqtts = 'tcp://0.0.0.0:' + config.port; + if (config.webSocket) { + serverConfig.mqtwss = 'ws://0.0.0.0:' + (config.port + 1); + } + } + + server = createStreamServer(serverConfig, options, function (clientStream) { + cltFunction(mqtt(clientStream, { + notData: !options.emitEvents + })); + }); + + // to start + server.listen(function () { + if (config.ssl) { + adapter.log.info('Starting MQTT (Secure) ' + (config.user ? 'authenticated ' : '') + 'server on port ' + config.port); + if (config.webSocket) { + adapter.log.info('Starting MQTT-WebSocket (Secure) ' + (config.user ? 'authenticated ' : '') + 'server on port ' + (config.port + 1)); + } + } else { + adapter.log.info('Starting MQTT ' + (config.user ? 'authenticated ' : '') + 'server on port ' + config.port); + if (config.webSocket) { + adapter.log.info('Starting MQTT-WebSocket ' + (config.user ? 'authenticated ' : '') + 'server on port ' + (config.port + 1)); + } + } + }); +} + +function main() { + //noinspection JSUnresolvedVariable + adapter.config.pass = decrypt('Zgfr56gFe87jJOM', adapter.config.pass); + + if (!adapter.config.user) { + adapter.log.error('Empty user name not allowed!'); + process.stop(-1); + return; + } + + if (adapter.config.secure) { + // Load certificates + adapter.getCertificates(function (err, certificates, leConfig) { + adapter.config.certificates = certificates; + adapter.config.leConfig = leConfig; + initMqttServer(adapter.config); + }); + } else { + initMqttServer(adapter.config); + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..e1b7891 --- /dev/null +++ b/package.json @@ -0,0 +1,51 @@ +{ + "name": "yunkong2.owntracks", + "version": "0.3.0", + "description": "yunkong2 OwnTracks Adapter", + "author": { + "name": "bluefox", + "email": "dogafox@gmail.com" + }, + "contributors": [ + { + "name": "bluefox", + "email": "dogafox@gmail.com" + }, + { + "name": "matspi", + "email": "matthias.spiller@fari.software" + } + ], + "homepage": "https://git.spacen.net/yunkong2/yunkong2.owntracks", + "license": "MIT", + "keywords": [ + "yunkong2", + "owntracks" + ], + "repository": { + "type": "git", + "url": "https://git.spacen.net/yunkong2/yunkong2.owntracks" + }, + "dependencies": { + "create-stream-server": "^0.1.1", + "mqtt-connection": "^2.1.1" + }, + "devDependencies": { + "grunt": "^1.0.1", + "grunt-replace": "^1.0.1", + "grunt-contrib-jshint": "^1.1.0", + "grunt-jscs": "^3.0.1", + "grunt-http": "^2.2.0", + "mocha": "^4.1.0", + "chai": "^4.1.2", + "request": "^2.75.0" + }, + "main": "main.js", + "scripts": { + "test": "node node_modules/mocha/bin/mocha --exit" + }, + "bugs": { + "url": "https://git.spacen.net/yunkong2/yunkong2.owntracks/issues" + }, + "readmeFilename": "README.md" +} \ No newline at end of file diff --git a/tasks/jscs.js b/tasks/jscs.js new file mode 100644 index 0000000..588b6f2 --- /dev/null +++ b/tasks/jscs.js @@ -0,0 +1,17 @@ +var srcDir = __dirname + "/../"; + +module.exports = { + all: { + src: [ + srcDir + "*.js", + srcDir + "lib/*.js", + srcDir + "adapter/example/*.js", + srcDir + "tasks/**/*.js", + srcDir + "www/**/*.js", + '!' + srcDir + "www/lib/**/*.js", + '!' + srcDir + 'node_modules/**/*.js', + '!' + srcDir + 'adapter/*/node_modules/**/*.js' + ], + options: require('./jscsRules.js') + } +}; \ No newline at end of file diff --git a/tasks/jscsRules.js b/tasks/jscsRules.js new file mode 100644 index 0000000..ded301d --- /dev/null +++ b/tasks/jscsRules.js @@ -0,0 +1,36 @@ +module.exports = { + force: true, + "requireCurlyBraces": ["else", "for", "while", "do", "try", "catch"], /*"if",*/ + "requireSpaceAfterKeywords": ["if", "else", "for", "while", "do", "switch", "return", "try", "catch"], + "requireSpaceBeforeBlockStatements": true, + "requireParenthesesAroundIIFE": true, + "disallowSpacesInFunctionDeclaration": {"beforeOpeningRoundBrace": true}, + "disallowSpacesInNamedFunctionExpression": {"beforeOpeningRoundBrace": true}, + "requireSpacesInFunctionExpression": {"beforeOpeningCurlyBrace": true}, + "requireSpacesInAnonymousFunctionExpression": {"beforeOpeningRoundBrace": true, "beforeOpeningCurlyBrace": true}, + "requireSpacesInNamedFunctionExpression": {"beforeOpeningCurlyBrace": true}, + "requireSpacesInFunctionDeclaration": {"beforeOpeningCurlyBrace": true}, + "disallowMultipleVarDecl": true, + "requireBlocksOnNewline": true, + "disallowEmptyBlocks": true, + "disallowSpacesInsideObjectBrackets": true, + "disallowSpacesInsideArrayBrackets": true, + "disallowSpaceAfterObjectKeys": true, + "disallowSpacesInsideParentheses": true, + "requireCommaBeforeLineBreak": true, + //"requireAlignedObjectValues": "all", + "requireOperatorBeforeLineBreak": ["?", "+", "-", "/", "*", "=", "==", "===", "!=", "!==", ">", ">=", "<", "<="], +// "disallowLeftStickedOperators": ["?", "+", "/", "*", "=", "==", "===", "!=", "!==", ">", ">=", "<", "<="], +// "requireRightStickedOperators": ["!"], +// "requireSpaceAfterBinaryOperators": ["?", "+", "/", "*", ":", "=", "==", "===", "!=", "!==", ">", ">=", "<", "<="], + //"disallowSpaceAfterBinaryOperators": [","], + "disallowSpaceAfterPrefixUnaryOperators": ["++", "--", "+", "-", "~", "!"], + "disallowSpaceBeforePostfixUnaryOperators": ["++", "--"], + "requireSpaceBeforeBinaryOperators": ["+", "-", "/", "*", "=", "==", "===", "!=", "!=="], + "requireSpaceAfterBinaryOperators": ["?", ">", ",", ">=", "<=", "<", "+", "-", "/", "*", "=", "==", "===", "!=", "!=="], + //"validateIndentation": 4, + //"validateQuoteMarks": { "mark": "\"", "escape": true }, + "disallowMixedSpacesAndTabs": true, + "disallowKeywordsOnNewLine": ["else", "catch"] + +}; diff --git a/tasks/jshint.js b/tasks/jshint.js new file mode 100644 index 0000000..f823ebc --- /dev/null +++ b/tasks/jshint.js @@ -0,0 +1,17 @@ +var srcDir = __dirname + "/../"; + +module.exports = { + options: { + force: true + }, + all: [ + srcDir + "*.js", + srcDir + "lib/*.js", + srcDir + "adapter/example/*.js", + srcDir + "tasks/**/*.js", + srcDir + "www/**/*.js", + '!' + srcDir + "www/lib/**/*.js", + '!' + srcDir + 'node_modules/**/*.js', + '!' + srcDir + 'adapter/*/node_modules/**/*.js' + ] +}; \ No newline at end of file diff --git a/test/lib/setup.js b/test/lib/setup.js new file mode 100644 index 0000000..e2a1680 --- /dev/null +++ b/test/lib/setup.js @@ -0,0 +1,728 @@ +/* jshint -W097 */// jshint strict:false +/*jslint node: true */ +// check if tmp directory exists +var fs = require('fs'); +var path = require('path'); +var child_process = require('child_process'); +var rootDir = path.normalize(__dirname + '/../../'); +var pkg = require(rootDir + 'package.json'); +var debug = typeof v8debug === 'object'; +pkg.main = pkg.main || 'main.js'; + +var adapterName = path.normalize(rootDir).replace(/\\/g, '/').split('/'); +adapterName = adapterName[adapterName.length - 2]; +var adapterStarted = false; + +function getAppName() { + var parts = __dirname.replace(/\\/g, '/').split('/'); + return parts[parts.length - 3].split('.')[0]; +} + +var appName = getAppName().toLowerCase(); + +var objects; +var states; + +var pid = null; + +function copyFileSync(source, target) { + + var targetFile = target; + + //if target is a directory a new file with the same name will be created + if (fs.existsSync(target)) { + if ( fs.lstatSync( target ).isDirectory() ) { + targetFile = path.join(target, path.basename(source)); + } + } + + try { + fs.writeFileSync(targetFile, fs.readFileSync(source)); + } + catch (err) { + console.log("file copy error: " +source +" -> " + targetFile + " (error ignored)"); + } +} + +function copyFolderRecursiveSync(source, target, ignore) { + var files = []; + + var base = path.basename(source); + if (base === adapterName) { + base = pkg.name; + } + //check if folder needs to be created or integrated + var targetFolder = path.join(target, base); + if (!fs.existsSync(targetFolder)) { + fs.mkdirSync(targetFolder); + } + + //copy + if (fs.lstatSync(source).isDirectory()) { + files = fs.readdirSync(source); + files.forEach(function (file) { + if (ignore && ignore.indexOf(file) !== -1) { + return; + } + + var curSource = path.join(source, file); + var curTarget = path.join(targetFolder, file); + if (fs.lstatSync(curSource).isDirectory()) { + // ignore grunt files + if (file.indexOf('grunt') !== -1) return; + if (file === 'chai') return; + if (file === 'mocha') return; + copyFolderRecursiveSync(curSource, targetFolder, ignore); + } else { + copyFileSync(curSource, curTarget); + } + }); + } +} + +if (!fs.existsSync(rootDir + 'tmp')) { + fs.mkdirSync(rootDir + 'tmp'); +} + +function storeOriginalFiles() { + console.log('Store original files...'); + var dataDir = rootDir + 'tmp/' + appName + '-data/'; + + var f = fs.readFileSync(dataDir + 'objects.json'); + var objects = JSON.parse(f.toString()); + if (objects['system.adapter.admin.0'] && objects['system.adapter.admin.0'].common) { + objects['system.adapter.admin.0'].common.enabled = false; + } + if (objects['system.adapter.admin.1'] && objects['system.adapter.admin.1'].common) { + objects['system.adapter.admin.1'].common.enabled = false; + } + + fs.writeFileSync(dataDir + 'objects.json.original', JSON.stringify(objects)); + try { + f = fs.readFileSync(dataDir + 'states.json'); + fs.writeFileSync(dataDir + 'states.json.original', f); + } + catch (err) { + console.log('no states.json found - ignore'); + } +} + +function restoreOriginalFiles() { + console.log('restoreOriginalFiles...'); + var dataDir = rootDir + 'tmp/' + appName + '-data/'; + + var f = fs.readFileSync(dataDir + 'objects.json.original'); + fs.writeFileSync(dataDir + 'objects.json', f); + try { + f = fs.readFileSync(dataDir + 'states.json.original'); + fs.writeFileSync(dataDir + 'states.json', f); + } + catch (err) { + console.log('no states.json.original found - ignore'); + } + +} + +function checkIsAdapterInstalled(cb, counter, customName) { + customName = customName || pkg.name.split('.').pop(); + counter = counter || 0; + var dataDir = rootDir + 'tmp/' + appName + '-data/'; + console.log('checkIsAdapterInstalled...'); + + try { + var f = fs.readFileSync(dataDir + 'objects.json'); + var objects = JSON.parse(f.toString()); + if (objects['system.adapter.' + customName + '.0']) { + console.log('checkIsAdapterInstalled: ready!'); + setTimeout(function () { + if (cb) cb(); + }, 100); + return; + } else { + console.warn('checkIsAdapterInstalled: still not ready'); + } + } catch (err) { + + } + + if (counter > 20) { + console.error('checkIsAdapterInstalled: Cannot install!'); + if (cb) cb('Cannot install'); + } else { + console.log('checkIsAdapterInstalled: wait...'); + setTimeout(function() { + checkIsAdapterInstalled(cb, counter + 1); + }, 1000); + } +} + +function checkIsControllerInstalled(cb, counter) { + counter = counter || 0; + var dataDir = rootDir + 'tmp/' + appName + '-data/'; + + console.log('checkIsControllerInstalled...'); + try { + var f = fs.readFileSync(dataDir + 'objects.json'); + var objects = JSON.parse(f.toString()); + if (objects['system.adapter.admin.0']) { + console.log('checkIsControllerInstalled: installed!'); + setTimeout(function () { + if (cb) cb(); + }, 100); + return; + } + } catch (err) { + + } + + if (counter > 20) { + console.log('checkIsControllerInstalled: Cannot install!'); + if (cb) cb('Cannot install'); + } else { + console.log('checkIsControllerInstalled: wait...'); + setTimeout(function() { + checkIsControllerInstalled(cb, counter + 1); + }, 1000); + } +} + +function installAdapter(customName, cb) { + if (typeof customName === 'function') { + cb = customName; + customName = null; + } + customName = customName || pkg.name.split('.').pop(); + console.log('Install adapter...'); + var startFile = 'node_modules/' + appName + '.js-controller/' + appName + '.js'; + // make first install + if (debug) { + child_process.execSync('node ' + startFile + ' add ' + customName + ' --enabled false', { + cwd: rootDir + 'tmp', + stdio: [0, 1, 2] + }); + checkIsAdapterInstalled(function (error) { + if (error) console.error(error); + console.log('Adapter installed.'); + if (cb) cb(); + }); + } else { + // add controller + var _pid = child_process.fork(startFile, ['add', customName, '--enabled', 'false'], { + cwd: rootDir + 'tmp', + stdio: [0, 1, 2, 'ipc'] + }); + + waitForEnd(_pid, function () { + checkIsAdapterInstalled(function (error) { + if (error) console.error(error); + console.log('Adapter installed.'); + if (cb) cb(); + }); + }); + } +} + +function waitForEnd(_pid, cb) { + if (!_pid) { + cb(-1, -1); + return; + } + _pid.on('exit', function (code, signal) { + if (_pid) { + _pid = null; + cb(code, signal); + } + }); + _pid.on('close', function (code, signal) { + if (_pid) { + _pid = null; + cb(code, signal); + } + }); +} + +function installJsController(cb) { + console.log('installJsController...'); + if (!fs.existsSync(rootDir + 'tmp/node_modules/' + appName + '.js-controller') || + !fs.existsSync(rootDir + 'tmp/' + appName + '-data')) { + // try to detect appName.js-controller in node_modules/appName.js-controller + // travis CI installs js-controller into node_modules + if (fs.existsSync(rootDir + 'node_modules/' + appName + '.js-controller')) { + console.log('installJsController: no js-controller => copy it from "' + rootDir + 'node_modules/' + appName + '.js-controller"'); + // copy all + // stop controller + console.log('Stop controller if running...'); + var _pid; + if (debug) { + // start controller + _pid = child_process.exec('node ' + appName + '.js stop', { + cwd: rootDir + 'node_modules/' + appName + '.js-controller', + stdio: [0, 1, 2] + }); + } else { + _pid = child_process.fork(appName + '.js', ['stop'], { + cwd: rootDir + 'node_modules/' + appName + '.js-controller', + stdio: [0, 1, 2, 'ipc'] + }); + } + + waitForEnd(_pid, function () { + // copy all files into + if (!fs.existsSync(rootDir + 'tmp')) fs.mkdirSync(rootDir + 'tmp'); + if (!fs.existsSync(rootDir + 'tmp/node_modules')) fs.mkdirSync(rootDir + 'tmp/node_modules'); + + if (!fs.existsSync(rootDir + 'tmp/node_modules/' + appName + '.js-controller')){ + console.log('Copy js-controller...'); + copyFolderRecursiveSync(rootDir + 'node_modules/' + appName + '.js-controller', rootDir + 'tmp/node_modules/'); + } + + console.log('Setup js-controller...'); + var __pid; + if (debug) { + // start controller + _pid = child_process.exec('node ' + appName + '.js setup first --console', { + cwd: rootDir + 'tmp/node_modules/' + appName + '.js-controller', + stdio: [0, 1, 2] + }); + } else { + __pid = child_process.fork(appName + '.js', ['setup', 'first', '--console'], { + cwd: rootDir + 'tmp/node_modules/' + appName + '.js-controller', + stdio: [0, 1, 2, 'ipc'] + }); + } + waitForEnd(__pid, function () { + checkIsControllerInstalled(function () { + // change ports for object and state DBs + var config = require(rootDir + 'tmp/' + appName + '-data/' + appName + '.json'); + config.objects.port = 19001; + config.states.port = 19000; + fs.writeFileSync(rootDir + 'tmp/' + appName + '-data/' + appName + '.json', JSON.stringify(config, null, 2)); + console.log('Setup finished.'); + + copyAdapterToController(); + + installAdapter(function () { + storeOriginalFiles(); + if (cb) cb(true); + }); + }); + }); + }); + } else { + // check if port 9000 is free, else admin adapter will be added to running instance + var client = new require('net').Socket(); + client.connect(9000, '127.0.0.1', function() { + console.error('Cannot initiate fisrt run of test, because one instance of application is running on this PC. Stop it and repeat.'); + process.exit(0); + }); + + setTimeout(function () { + client.destroy(); + if (!fs.existsSync(rootDir + 'tmp/node_modules/' + appName + '.js-controller')) { + console.log('installJsController: no js-controller => install from git'); + + child_process.execSync('npm install https://git.spacen.net/' + appName + '/' + appName + '.js-controller/tarball/master --prefix ./ --production', { + cwd: rootDir + 'tmp/', + stdio: [0, 1, 2] + }); + } else { + console.log('Setup js-controller...'); + var __pid; + if (debug) { + // start controller + child_process.exec('node ' + appName + '.js setup first', { + cwd: rootDir + 'tmp/node_modules/' + appName + '.js-controller', + stdio: [0, 1, 2] + }); + } else { + child_process.fork(appName + '.js', ['setup', 'first'], { + cwd: rootDir + 'tmp/node_modules/' + appName + '.js-controller', + stdio: [0, 1, 2, 'ipc'] + }); + } + } + + // let npm install admin and run setup + checkIsControllerInstalled(function () { + var _pid; + + if (fs.existsSync(rootDir + 'node_modules/' + appName + '.js-controller/' + appName + '.js')) { + _pid = child_process.fork(appName + '.js', ['stop'], { + cwd: rootDir + 'node_modules/' + appName + '.js-controller', + stdio: [0, 1, 2, 'ipc'] + }); + } + + waitForEnd(_pid, function () { + // change ports for object and state DBs + var config = require(rootDir + 'tmp/' + appName + '-data/' + appName + '.json'); + config.objects.port = 19001; + config.states.port = 19000; + fs.writeFileSync(rootDir + 'tmp/' + appName + '-data/' + appName + '.json', JSON.stringify(config, null, 2)); + + copyAdapterToController(); + + installAdapter(function () { + storeOriginalFiles(); + if (cb) cb(true); + }); + }); + }); + }, 1000); + } + } else { + setTimeout(function () { + console.log('installJsController: js-controller installed'); + if (cb) cb(false); + }, 0); + } +} + +function copyAdapterToController() { + console.log('Copy adapter...'); + // Copy adapter to tmp/node_modules/appName.adapter + copyFolderRecursiveSync(rootDir, rootDir + 'tmp/node_modules/', ['.idea', 'test', 'tmp', '.git', appName + '.js-controller']); + console.log('Adapter copied.'); +} + +function clearControllerLog() { + var dirPath = rootDir + 'tmp/log'; + var files; + try { + if (fs.existsSync(dirPath)) { + console.log('Clear controller log...'); + files = fs.readdirSync(dirPath); + } else { + console.log('Create controller log directory...'); + files = []; + fs.mkdirSync(dirPath); + } + } catch(e) { + console.error('Cannot read "' + dirPath + '"'); + return; + } + if (files.length > 0) { + try { + for (var i = 0; i < files.length; i++) { + var filePath = dirPath + '/' + files[i]; + fs.unlinkSync(filePath); + } + console.log('Controller log cleared'); + } catch (err) { + console.error('cannot clear log: ' + err); + } + } +} + +function clearDB() { + var dirPath = rootDir + 'tmp/yunkong2-data/sqlite'; + var files; + try { + if (fs.existsSync(dirPath)) { + console.log('Clear sqlite DB...'); + files = fs.readdirSync(dirPath); + } else { + console.log('Create controller log directory...'); + files = []; + fs.mkdirSync(dirPath); + } + } catch(e) { + console.error('Cannot read "' + dirPath + '"'); + return; + } + if (files.length > 0) { + try { + for (var i = 0; i < files.length; i++) { + var filePath = dirPath + '/' + files[i]; + fs.unlinkSync(filePath); + } + console.log('Clear sqlite DB'); + } catch (err) { + console.error('cannot clear DB: ' + err); + } + } +} + +function setupController(cb) { + installJsController(function (isInited) { + clearControllerLog(); + clearDB(); + + if (!isInited) { + restoreOriginalFiles(); + copyAdapterToController(); + } + // read system.config object + var dataDir = rootDir + 'tmp/' + appName + '-data/'; + + var objs; + try { + objs = fs.readFileSync(dataDir + 'objects.json'); + objs = JSON.parse(objs); + } + catch (e) { + console.log('ERROR reading/parsing system configuration. Ignore'); + objs = {'system.config': {}}; + } + if (!objs || !objs['system.config']) { + objs = {'system.config': {}}; + } + + if (cb) cb(objs['system.config']); + }); +} + +function startAdapter(objects, states, callback) { + if (adapterStarted) { + console.log('Adapter already started ...'); + if (callback) callback(objects, states); + return; + } + adapterStarted = true; + console.log('startAdapter...'); + if (fs.existsSync(rootDir + 'tmp/node_modules/' + pkg.name + '/' + pkg.main)) { + try { + if (debug) { + // start controller + pid = child_process.exec('node node_modules/' + pkg.name + '/' + pkg.main + ' --console silly', { + cwd: rootDir + 'tmp', + stdio: [0, 1, 2] + }); + } else { + // start controller + pid = child_process.fork('node_modules/' + pkg.name + '/' + pkg.main, ['--console', 'silly'], { + cwd: rootDir + 'tmp', + stdio: [0, 1, 2, 'ipc'] + }); + } + } catch (error) { + console.error(JSON.stringify(error)); + } + } else { + console.error('Cannot find: ' + rootDir + 'tmp/node_modules/' + pkg.name + '/' + pkg.main); + } + if (callback) callback(objects, states); +} + +function startController(isStartAdapter, onObjectChange, onStateChange, callback) { + if (typeof isStartAdapter === 'function') { + callback = onStateChange; + onStateChange = onObjectChange; + onObjectChange = isStartAdapter; + isStartAdapter = true; + } + + if (onStateChange === undefined) { + callback = onObjectChange; + onObjectChange = undefined; + } + + if (pid) { + console.error('Controller is already started!'); + } else { + console.log('startController...'); + adapterStarted = false; + var isObjectConnected; + var isStatesConnected; + + var Objects = require(rootDir + 'tmp/node_modules/' + appName + '.js-controller/lib/objects/objectsInMemServer'); + objects = new Objects({ + connection: { + "type" : "file", + "host" : "127.0.0.1", + "port" : 19001, + "user" : "", + "pass" : "", + "noFileCache": false, + "connectTimeout": 2000 + }, + logger: { + silly: function (msg) { + console.log(msg); + }, + debug: function (msg) { + console.log(msg); + }, + info: function (msg) { + console.log(msg); + }, + warn: function (msg) { + console.warn(msg); + }, + error: function (msg) { + console.error(msg); + } + }, + connected: function () { + isObjectConnected = true; + if (isStatesConnected) { + console.log('startController: started!'); + if (isStartAdapter) { + startAdapter(objects, states, callback); + } else { + if (callback) { + callback(objects, states); + callback = null; + } + } + } + }, + change: onObjectChange + }); + + // Just open in memory DB itself + var States = require(rootDir + 'tmp/node_modules/' + appName + '.js-controller/lib/states/statesInMemServer'); + states = new States({ + connection: { + type: 'file', + host: '127.0.0.1', + port: 19000, + options: { + auth_pass: null, + retry_max_delay: 15000 + } + }, + logger: { + silly: function (msg) { + console.log(msg); + }, + debug: function (msg) { + console.log(msg); + }, + info: function (msg) { + console.log(msg); + }, + warn: function (msg) { + console.log(msg); + }, + error: function (msg) { + console.log(msg); + } + }, + connected: function () { + isStatesConnected = true; + if (isObjectConnected) { + console.log('startController: started!!'); + if (isStartAdapter) { + startAdapter(objects, states, callback); + } else { + if (callback) { + callback(objects, states); + callback = null; + } + } + } + }, + change: onStateChange + }); + } +} + +function stopAdapter(cb) { + if (!pid) { + console.error('Controller is not running!'); + if (cb) { + setTimeout(function () { + cb(false); + }, 0); + } + } else { + adapterStarted = false; + pid.on('exit', function (code, signal) { + if (pid) { + console.log('child process terminated due to receipt of signal ' + signal); + if (cb) cb(); + pid = null; + } + }); + + pid.on('close', function (code, signal) { + if (pid) { + if (cb) cb(); + pid = null; + } + }); + + pid.kill('SIGTERM'); + } +} + +function _stopController() { + if (objects) { + objects.destroy(); + objects = null; + } + if (states) { + states.destroy(); + states = null; + } +} + +function stopController(cb) { + var timeout; + if (objects) { + console.log('Set system.adapter.' + pkg.name + '.0'); + objects.setObject('system.adapter.' + pkg.name + '.0', { + common:{ + enabled: false + } + }); + } + + stopAdapter(function () { + if (timeout) { + clearTimeout(timeout); + timeout = null; + } + + _stopController(); + + if (cb) { + cb(true); + cb = null; + } + }); + + timeout = setTimeout(function () { + timeout = null; + console.log('child process NOT terminated'); + + _stopController(); + + if (cb) { + cb(false); + cb = null; + } + pid = null; + }, 5000); +} + +// Setup the adapter +function setAdapterConfig(common, native, instance) { + var objects = JSON.parse(fs.readFileSync(rootDir + 'tmp/' + appName + '-data/objects.json').toString()); + var id = 'system.adapter.' + adapterName.split('.').pop() + '.' + (instance || 0); + if (common) objects[id].common = common; + if (native) objects[id].native = native; + fs.writeFileSync(rootDir + 'tmp/' + appName + '-data/objects.json', JSON.stringify(objects)); +} + +// Read config of the adapter +function getAdapterConfig(instance) { + var objects = JSON.parse(fs.readFileSync(rootDir + 'tmp/' + appName + '-data/objects.json').toString()); + var id = 'system.adapter.' + adapterName.split('.').pop() + '.' + (instance || 0); + return objects[id]; +} + +if (typeof module !== undefined && module.parent) { + module.exports.getAdapterConfig = getAdapterConfig; + module.exports.setAdapterConfig = setAdapterConfig; + module.exports.startController = startController; + module.exports.stopController = stopController; + module.exports.setupController = setupController; + module.exports.stopAdapter = stopAdapter; + module.exports.startAdapter = startAdapter; + module.exports.installAdapter = installAdapter; + module.exports.appName = appName; + module.exports.adapterName = adapterName; + module.exports.adapterStarted = adapterStarted; +} diff --git a/test/testAdapter.js b/test/testAdapter.js new file mode 100644 index 0000000..ae9c289 --- /dev/null +++ b/test/testAdapter.js @@ -0,0 +1,140 @@ +/* jshint -W097 */// jshint strict:false +/*jslint node: true */ +var expect = require('chai').expect; +var setup = require(__dirname + '/lib/setup'); + +var objects = null; +var states = null; +var onStateChanged = null; +var onObjectChanged = null; +var sendToID = 1; + +var adapterShortName = setup.adapterName.substring(setup.adapterName.indexOf('.')+1); + +function checkConnectionOfAdapter(cb, counter) { + counter = counter || 0; + console.log('Try check #' + counter); + if (counter > 30) { + if (cb) cb('Cannot check connection'); + return; + } + + states.getState('system.adapter.' + adapterShortName + '.0.alive', function (err, state) { + if (err) console.error(err); + if (state && state.val) { + if (cb) cb(); + } else { + setTimeout(function () { + checkConnectionOfAdapter(cb, counter + 1); + }, 1000); + } + }); +} + +function checkValueOfState(id, value, cb, counter) { + counter = counter || 0; + if (counter > 20) { + if (cb) cb('Cannot check value Of State ' + id); + return; + } + + states.getState(id, function (err, state) { + if (err) console.error(err); + if (value === null && !state) { + if (cb) cb(); + } else + if (state && (value === undefined || state.val === value)) { + if (cb) cb(); + } else { + setTimeout(function () { + checkValueOfState(id, value, cb, counter + 1); + }, 500); + } + }); +} + +function sendTo(target, command, message, callback) { + onStateChanged = function (id, state) { + if (id === 'messagebox.system.adapter.test.0') { + callback(state.message); + } + }; + + states.pushMessage('system.adapter.' + target, { + command: command, + message: message, + from: 'system.adapter.test.0', + callback: { + message: message, + id: sendToID++, + ack: false, + time: (new Date()).getTime() + } + }); +} + +describe('Test ' + adapterShortName + ' adapter', function() { + before('Test ' + adapterShortName + ' adapter: Start js-controller', function (_done) { + this.timeout(600000); // because of first install from npm + + setup.setupController(function () { + var config = setup.getAdapterConfig(); + // enable adapter + config.common.enabled = true; + config.common.loglevel = 'debug'; + + //config.native.dbtype = 'sqlite'; + + setup.setAdapterConfig(config.common, config.native); + + setup.startController(true, function(id, obj) {}, function (id, state) { + if (onStateChanged) onStateChanged(id, state); + }, + function (_objects, _states) { + objects = _objects; + states = _states; + _done(); + }); + }); + }); + +/* + ENABLE THIS WHEN ADAPTER RUNS IN DEAMON MODE TO CHECK THAT IT HAS STARTED SUCCESSFULLY +*/ + it('Test ' + adapterShortName + ' adapter: Check if adapter started', function (done) { + this.timeout(60000); + checkConnectionOfAdapter(function (res) { + if (res) console.log(res); + expect(res).not.to.be.equal('Cannot check connection'); + objects.setObject('system.adapter.test.0', { + common: { + + }, + type: 'instance' + }, + function () { + states.subscribeMessage('system.adapter.test.0'); + done(); + }); + }); + }); +/**/ + +/* + PUT YOUR OWN TESTS HERE USING + it('Testname', function ( done) { + ... + }); + + You can also use "sendTo" method to send messages to the started adapter +*/ + + after('Test ' + adapterShortName + ' adapter: Stop js-controller', function (done) { + this.timeout(10000); + + setup.stopController(function (normalTerminated) { + console.log('Adapter normal terminated: ' + normalTerminated); + done(); + }); + }); +}); diff --git a/test/testPackageFiles.js b/test/testPackageFiles.js new file mode 100644 index 0000000..d0759c0 --- /dev/null +++ b/test/testPackageFiles.js @@ -0,0 +1,91 @@ +/* jshint -W097 */ +/* jshint strict:false */ +/* jslint node: true */ +/* jshint expr: true */ +var expect = require('chai').expect; +var fs = require('fs'); + +describe('Test package.json and io-package.json', function() { + it('Test package files', function (done) { + console.log(); + + var fileContentIOPackage = fs.readFileSync(__dirname + '/../io-package.json', 'utf8'); + var ioPackage = JSON.parse(fileContentIOPackage); + + var fileContentNPMPackage = fs.readFileSync(__dirname + '/../package.json', 'utf8'); + var npmPackage = JSON.parse(fileContentNPMPackage); + + expect(ioPackage).to.be.an('object'); + expect(npmPackage).to.be.an('object'); + + expect(ioPackage.common.version, 'ERROR: Version number in io-package.json needs to exist').to.exist; + expect(npmPackage.version, 'ERROR: Version number in package.json needs to exist').to.exist; + + expect(ioPackage.common.version, 'ERROR: Version numbers in package.json and io-package.json needs to match').to.be.equal(npmPackage.version); + + if (!ioPackage.common.news || !ioPackage.common.news[ioPackage.common.version]) { + console.log('WARNING: No news entry for current version exists in io-package.json, no rollback in Admin possible!'); + console.log(); + } + + expect(npmPackage.author, 'ERROR: Author in package.json needs to exist').to.exist; + expect(ioPackage.common.authors, 'ERROR: Authors in io-package.json needs to exist').to.exist; + + if (ioPackage.common.name.indexOf('template') !== 0) { + if (Array.isArray(ioPackage.common.authors)) { + expect(ioPackage.common.authors.length, 'ERROR: Author in io-package.json needs to be set').to.not.be.equal(0); + if (ioPackage.common.authors.length === 1) { + expect(ioPackage.common.authors[0], 'ERROR: Author in io-package.json needs to be a real name').to.not.be.equal('my Name '); + } + } + else { + expect(ioPackage.common.authors, 'ERROR: Author in io-package.json needs to be a real name').to.not.be.equal('my Name '); + } + } + else { + console.log('WARNING: Testing for set authors field in io-package skipped because template adapter'); + console.log(); + } + expect(fs.existsSync(__dirname + '/../README.md'), 'ERROR: README.md needs to exist! Please create one with description, detail information and changelog. English is mandatory.').to.be.true; + if (!ioPackage.common.titleLang || typeof ioPackage.common.titleLang !== 'object') { + console.log('WARNING: titleLang is not existing in io-package.json. Please add'); + console.log(); + } + if ( + ioPackage.common.title.indexOf('yunkong2') !== -1 || + ioPackage.common.title.indexOf('yunkong2') !== -1 || + ioPackage.common.title.indexOf('adapter') !== -1 || + ioPackage.common.title.indexOf('Adapter') !== -1 + ) { + console.log('WARNING: title contains Adapter or yunkong2. It is clear anyway, that it is adapter for yunkong2.'); + console.log(); + } + + if (ioPackage.common.name.indexOf('vis-') !== 0) { + if (!ioPackage.common.materialize || !fs.existsSync(__dirname + '/../admin/index_m.html') || !fs.existsSync(__dirname + '/../gulpfile.js')) { + console.log('WARNING: Admin3 support is missing! Please add it'); + console.log(); + } + if (ioPackage.common.materialize) { + expect(fs.existsSync(__dirname + '/../admin/index_m.html'), 'Admin3 support is enabled in io-package.json, but index_m.html is missing!').to.be.true; + } + } + + var licenseFileExists = fs.existsSync(__dirname + '/../LICENSE'); + var fileContentReadme = fs.readFileSync(__dirname + '/../README.md', 'utf8'); + if (fileContentReadme.indexOf('## Changelog') === -1) { + console.log('Warning: The README.md should have a section ## Changelog'); + console.log(); + } + expect((licenseFileExists || fileContentReadme.indexOf('## License') !== -1), 'A LICENSE must exist as LICENSE file or as part of the README.md').to.be.true; + if (!licenseFileExists) { + console.log('Warning: The License should also exist as LICENSE file'); + console.log(); + } + if (fileContentReadme.indexOf('## License') === -1) { + console.log('Warning: The README.md should also have a section ## License to be shown in Admin3'); + console.log(); + } + done(); + }); +});