55_DWD_OpenData.pm 76 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996
  1. # -----------------------------------------------------------------------------
  2. # $Id: 55_DWD_OpenData.pm 17420 2018-09-28 18:54:58Z jensb $
  3. # -----------------------------------------------------------------------------
  4. =encoding UTF-8
  5. =head1 NAME
  6. DWD_OpenData - A FHEM Perl module to retrieve forecasts and alerts from the
  7. DWD Open Data Server.
  8. =head1 LICENSE AND COPYRIGHT
  9. Copyright (C) 2018 Jens B.
  10. Copyright (C) 2018 JoWiemann (use of HttpUtils instead of LWP::Simple)
  11. All rights reserved
  12. This script is free software; you can redistribute it and/or modify
  13. it under the terms of the GNU General Public License as published by
  14. the Free Software Foundation; either version 2 of the License, or
  15. (at your option) any later version.
  16. The GNU General Public License can be found at
  17. http://www.gnu.org/copyleft/gpl.html.
  18. A copy is found in the textfile GPL.txt and important notices to the license
  19. from the author is found in LICENSE.txt distributed with these scripts.
  20. This script is distributed in the hope that it will be useful,
  21. but WITHOUT ANY WARRANTY; without even the implied warranty of
  22. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  23. GNU General Public License for more details.
  24. This copyright notice MUST APPEAR in all copies of the script!
  25. =cut
  26. package DWD_OpenData;
  27. use strict;
  28. use warnings;
  29. use Encode;
  30. use File::Temp qw(tempfile);
  31. use IO::Uncompress::Unzip qw(unzip $UnzipError);
  32. use POSIX;
  33. use Storable qw(freeze thaw);
  34. use Time::HiRes qw(gettimeofday);
  35. use Time::Local;
  36. use Time::Piece;
  37. use Blocking;
  38. use HttpUtils;
  39. use feature qw(switch);
  40. no if $] >= 5.017011, warnings => 'experimental';
  41. use constant UPDATE_DISTRICTS => -1;
  42. use constant UPDATE_COMMUNEUNIONS => -2;
  43. use constant UPDATE_ALL => -3;
  44. require Exporter;
  45. our $VERSION = 1.010.002;
  46. our @ISA = qw(Exporter);
  47. our @EXPORT = qw(GetForecast GetAlerts UpdateAlerts UPDATE_DISTRICTS UPDATE_COMMUNEUNIONS UPDATE_ALL);
  48. our @EXPORT_OK = qw(IsCommuneUnionWarncellId);
  49. my %forecastPropertyAliases = ( 'TX' => 'Tx', 'TN' => 'Tn', 'TG' => 'Tg', 'TM' => 'Tm' );
  50. my %forecastPropertyPeriods = (
  51. 'DD' => 1, 'DRR1' => 1, 'E_DD' => 1, 'E_FF' => 1, 'E_PPP' => 1, 'E_Td' => 1, 'E_TTT' => 1, 'FF' => 1, 'FX1' => 1, 'FX3' => 1, 'FX625' => 1, 'FX640' => 1, 'FX655' => 1, 'FXh' => 1, 'FXh25' => 1, 'FXh40' => 1, 'FXh55' => 1, 'N' => 1, 'N05' => 1, 'Neff' => 1, 'Nh' => 1, 'Nl' => 1, 'Nlm' => 1, 'Nm' => 1, 'PPPP' => 1, 'R101' => 1, 'R102' => 1, 'R103' => 1, 'R105' => 1, 'R107' => 1, 'R110' => 1, 'R120' => 1, 'R130' => 1, 'R150' => 1, 'R600' => 1, 'R602' => 1, 'R610' => 1, 'R650' => 1, 'RR1c' => 1, 'RR1o1' => 1, 'RR1u1' => 1, 'RR1w1' => 1, 'RR3c' => 1, 'RR6c' => 1, 'RRL1c' => 1, 'RRS1c' => 1, 'RRS3c' => 1, 'RRad1' => 1, 'Rad1h' => 1, 'RRhc' => 1, 'Rh00' => 1, 'Rh02' => 1, 'Rh10' => 1, 'Rh50' => 1, 'SunD1' => 1, 'SunD3' => 1, 'T5cm' => 1, 'Td' => 1, 'TTT' => 1, 'VV' => 1, 'VV10' => 1, 'W1W2' => 1, 'WPc11' => 1, 'WPc31' => 1, 'WPc61' => 1, 'WPcd1' => 1, 'WPch1' => 1, 'ww' => 1, 'ww3' => 1, 'wwC' => 1, 'wwC6' => 1, 'wwCh' => 1, 'wwD' => 1, 'wwD6' => 1, 'wwDh' => 1, 'wwF' => 1, 'wwF6' => 1, 'wwFh' => 1, 'wwL' => 1, 'wwL6' => 1, 'wwLh' => 1, 'wwM' => 1, 'wwM6' => 1, 'wwMd' => 1, 'wwMh' => 1, 'wwP' => 1, 'wwP6' => 1, 'wwPd' => 1, 'wwPh' => 1, 'wwS' => 1, 'wwS6' => 1, 'wwSh' => 1, 'wwT' => 1, 'wwT6' => 1, 'wwTd' => 1, 'wwTh' => 1, 'wwZ' => 1, 'wwZ6' => 1, 'wwZh' => 1,
  52. 'PEvap' => 24, 'PSd00' => 24, 'PSd30' => 24, 'PSd60' => 24, 'RRdc' => 24, 'RSunD' => 24, 'Rd00' => 24, 'Rd02' => 24, 'Rd10' => 24, 'Rd50' => 24, 'SunD' => 24, 'Tg' => 24, 'Tm' => 24, 'Tn' => 24, 'Tx' => 24
  53. );
  54. my %forecastDefaultProperties = (
  55. 'Tg' => 1, 'Tn' => 1, 'Tx' => 1, 'DD' => 1, 'FX1' => 1, 'Neff' => 1, 'RR6c' => 1, 'RRhc' => 1, 'Rh00' => 1, 'TTT' => 1, 'ww' => 1
  56. );
  57. # 1 = temperature in K, 2 = integer value, 3 = wind speed in m/s, 4 = pressure in Pa
  58. my %forecastPropertyTypes = (
  59. 'Tx' => 1, 'Tn' => 1, 'Tg' => 1, 'Tm'=> 1, 'Td' => 1, 'T5cm' => 1, 'TTT' => 1,
  60. 'DD' => 2, 'Neff' => 2, 'Nh' => 2, 'Nl' => 2, 'Nlm' => 2, 'Nm' => 2, 'Rh00' => 2, 'ww' => 2, 'ww3' => 2, 'WPc11' => 2, 'WPc31' => 2, 'WPc61' => 2, 'WPch1' => 2, 'WPcd1' => 2,
  61. 'FF' => 3, 'FX1' => 3, 'FX3' => 3, 'FXh' => 3,
  62. 'PPPP' => 4
  63. );
  64. my @wwdText = ('Bewölkungsentwicklung nicht beobachtet',
  65. 'Bewölkung abnehmend',
  66. 'Bewölkung unverändert',
  67. 'Bewölkung zunehmend',
  68. # 4 Dunst, Rauch, Staub oder Sand
  69. 'Sicht durch Rauch oder Asche vermindert',
  70. 'trockener Dunst (relative Feuchte < 80 %)',
  71. 'verbreiteter Schwebstaub, nicht vom Wind herangeführt',
  72. 'Staub oder Sand bzw. Gischt, vom Wind herangeführt',
  73. 'gut entwickelte Staub- oder Sandwirbel',
  74. 'Staub- oder Sandsturm im Gesichtskreis, aber nicht an der Station',
  75. # 10 Trockenereignisse
  76. 'feuchter Dunst (relative Feuchte > 80 %)',
  77. 'Schwaden von Bodennebel',
  78. 'durchgehender Bodennebel',
  79. 'Wetterleuchten sichtbar, kein Donner gehört',
  80. 'Niederschlag im Gesichtskreis, nicht den Boden erreichend',
  81. 'Niederschlag in der Ferne (> 5 km), aber nicht an der Station',
  82. 'Niederschlag in der Nähe (< 5 km), aber nicht an der Station',
  83. 'Gewitter (Donner hörbar), aber kein Niederschlag an der Station',
  84. 'Markante Böen im Gesichtskreis, aber kein Niederschlag an der Station',
  85. 'Tromben (trichterförmige Wolkenschläuche) im Gesichtskreis',
  86. # 20 Ereignisse der letzten Stunde, aber nicht zur Beobachtungszeit
  87. 'nach Sprühregen oder Schneegriesel',
  88. 'nach Regen',
  89. 'nach Schneefall',
  90. 'nach Schneeregen oder Eiskörnern',
  91. 'nach gefrierendem Regen',
  92. 'nach Regenschauer',
  93. 'nach Schneeschauer',
  94. 'nach Graupel- oder Hagelschauer',
  95. 'nach Nebel',
  96. 'nach Gewitter',
  97. # 30 Staubsturm, Sandsturm, Schneefegen oder -treiben
  98. 'leichter oder mäßiger Sandsturm, an Intensität abnehmend',
  99. 'leichter oder mäßiger Sandsturm, unveränderte Intensität',
  100. 'leichter oder mäßiger Sandsturm, an Intensität zunehmend',
  101. 'schwerer Sandsturm, an Intensität abnehmend',
  102. 'schwerer Sandsturm, unveränderte Intensität',
  103. 'schwerer Sandsturm, an Intensität zunehmend',
  104. 'leichtes oder mäßiges Schneefegen, unter Augenhöhe',
  105. 'starkes Schneefegen, unter Augenhöhe',
  106. 'leichtes oder mäßiges Schneetreiben, über Augenhöhe',
  107. 'starkes Schneetreiben, über Augenhöhe',
  108. # 40 Nebel oder Eisnebel
  109. 'Nebel in einiger Entfernung',
  110. 'Nebel in Schwaden oder Bänken',
  111. 'Nebel, Himmel erkennbar, dünner werdend',
  112. 'Nebel, Himmel nicht erkennbar, dünner werdend',
  113. 'Nebel, Himmel erkennbar, unverändert',
  114. 'Nebel, Himmel nicht erkennbar, unverändert',
  115. 'Nebel, Himmel erkennbar, dichter werdend',
  116. 'Nebel, Himmel nicht erkennbar, dichter werdend',
  117. 'Nebel mit Reifansatz, Himmel erkennbar',
  118. 'Nebel mit Reifansatz, Himmel nicht erkennbar',
  119. # 50 Sprühregen
  120. 'unterbrochener leichter Sprühregen',
  121. 'durchgehend leichter Sprühregen',
  122. 'unterbrochener mäßiger Sprühregen',
  123. 'durchgehend mäßiger Sprühregen',
  124. 'unterbrochener starker Sprühregen',
  125. 'durchgehend starker Sprühregen',
  126. 'leichter gefrierender Sprühregen',
  127. 'mäßiger oder starker gefrierender Sprühregen',
  128. 'leichter Sprühregen mit Regen',
  129. 'mäßiger oder starker Sprühregen mit Regen',
  130. # 60 Regen
  131. 'unterbrochener leichter Regen oder einzelne Regentropfen',
  132. 'durchgehend leichter Regen',
  133. 'unterbrochener mäßiger Regen',
  134. 'durchgehend mäßiger Regen',
  135. 'unterbrochener starker Regen',
  136. 'durchgehend starker Regen',
  137. 'leichter gefrierender Regen',
  138. 'mäßiger oder starker gefrierender Regen',
  139. 'leichter Schneeregen',
  140. 'mäßiger oder starker Schneeregen',
  141. # 70 Schnee
  142. 'unterbrochener leichter Schneefall oder einzelne Schneeflocken',
  143. 'durchgehend leichter Schneefall',
  144. 'unterbrochener mäßiger Schneefall',
  145. 'durchgehend mäßiger Schneefall',
  146. 'unterbrochener starker Schneefall',
  147. 'durchgehend starker Schneefall',
  148. 'Eisnadeln (Polarschnee)',
  149. 'Schneegriesel',
  150. 'Schneekristalle',
  151. 'Eiskörner (gefrorene Regentropfen)',
  152. # 80 Schauer
  153. 'leichter Regenschauer',
  154. 'mäßiger oder starker Regenschauer',
  155. 'äußerst heftiger Regenschauer',
  156. 'leichter Schneeregenschauer',
  157. 'mäßiger oder starker Schneeregenschauer',
  158. 'leichter Schneeschauer',
  159. 'mäßiger oder starker Schneeschauer',
  160. 'leichter Graupelschauer',
  161. 'mäßiger oder starker Graupelschauer',
  162. 'leichter Hagelschauer',
  163. 'mäßiger oder starker Hagelschauer',
  164. # 90 Gewitter
  165. 'Gewitter in der letzten Stunde, zurzeit leichter Regen',
  166. 'Gewitter in der letzten Stunde, zurzeit mäßiger oder starker Regen',
  167. 'Gewitter in der letzten Stunde, zurzeit leichter Schneefall/Schneeregen/Graupel/Hagel',
  168. 'Gewitter in der letzten Stunde, zurzeit mäßiger oder starker Schneefall/Schneeregen/Graupel/Hagel',
  169. 'leichtes oder mäßiges Gewitter mit Regen oder Schnee',
  170. 'leichtes oder mäßiges Gewitter mit Graupel oder Hagel',
  171. 'starkes Gewitter mit Regen oder Schnee',
  172. 'starkes Gewitter mit Sandsturm',
  173. 'starkes Gewitter mit Graupel oder Hagel');
  174. my @alerts_data = [ undef, undef ];
  175. my @alerts_received = [ undef, undef ];
  176. my @alerts_updating = [ undef, undef ];
  177. =head1 FHEM CALLBACK FUNCTIONS
  178. =head2 Define($$)
  179. FHEM I<DefFn>
  180. =over
  181. =item * param hash: hash of DWD_OpenData device
  182. =item * param def: module define parameters, will be ignored
  183. =item * return undef on success or error message
  184. =back
  185. =cut
  186. sub Define($$) {
  187. my ($hash, $def) = @_;
  188. my $name = $hash->{NAME};
  189. # test TZ environment variable
  190. if (!defined($ENV{"TZ"})) {
  191. $hash->{FHEM_TZ} = undef;
  192. } else {
  193. $hash->{FHEM_TZ} = $ENV{"TZ"};
  194. }
  195. # cache timezone attribute
  196. $hash->{'.TZ'} = ::AttrVal($hash, 'timezone', $hash->{FHEM_TZ});
  197. ::readingsSingleUpdate($hash, 'state', ::IsDisabled($name)? 'disabled' : 'defined', 1);
  198. ::InternalTimer(gettimeofday() + 3, 'DWD_OpenData::Timer', $hash, 0);
  199. return undef;
  200. }
  201. =head2 Undef($$)
  202. FHEM I<UndefFn>
  203. =over
  204. =item * param hash: hash of DWD_OpenData device
  205. =item * param arg: module undefine arguments, will be ignored
  206. =back
  207. =cut
  208. sub Undef($$) {
  209. my ($hash, $arg) = @_;
  210. my $name = $hash->{NAME};
  211. ::RemoveInternalTimer($hash);
  212. return undef;
  213. }
  214. =head2 Shutdown($)
  215. FHEM I<ShutdownFn>
  216. =over
  217. =item * param hash: hash of DWD_OpenData device
  218. =back
  219. =cut
  220. sub Shutdown($) {
  221. my ($hash) = @_;
  222. my $name = $hash->{NAME};
  223. ::RemoveInternalTimer($hash);
  224. if (defined($hash->{".alertsBlockingCall"})) {
  225. ::BlockingKill($hash->{".alertsBlockingCall"});
  226. }
  227. if (defined($hash->{".alertsFile"})) {
  228. close($hash->{".alertsFileHandle"});
  229. unlink($hash->{".alertsFile"});
  230. delete($hash->{".alertsFile"});
  231. }
  232. return undef;
  233. }
  234. =head2 Attr(@)
  235. FHEM I<AttrFn>
  236. =over
  237. =item * param command: "set" or "del"
  238. =item * param name: name of DWD_OpenData device
  239. =item * param attribute: attribute name
  240. =item * param value: attribute value
  241. =item * return C<undef> on success or error message
  242. =back
  243. =cut
  244. sub Attr(@) {
  245. my ($command, $name, $attribute, $value) = @_;
  246. my $hash = $::defs{$name};
  247. given($command) {
  248. when("set") {
  249. given($attribute) {
  250. when("disable") {
  251. # enable/disable polling
  252. if ($main::init_done) {
  253. if ($value) {
  254. ::RemoveInternalTimer($hash);
  255. ::readingsSingleUpdate($hash, 'state', 'disabled', 1);
  256. } else {
  257. ::readingsSingleUpdate($hash, 'state', 'defined', 1);
  258. ::InternalTimer(gettimeofday() + 3, 'DWD_OpenData::Timer', $hash, 0);
  259. }
  260. }
  261. }
  262. when("forecastWW2Text") {
  263. if (!$value) {
  264. ::CommandDeleteReading(undef, "$name fc.*wwd");
  265. }
  266. }
  267. when("timezone") {
  268. if (defined($value) && length($value) > 0) {
  269. $hash->{'.TZ'} = $value;
  270. } else {
  271. return "timezone (e.g. Europe/Berlin) required";
  272. }
  273. }
  274. }
  275. }
  276. when("del") {
  277. given($attribute) {
  278. when("disable") {
  279. ::readingsSingleUpdate($hash, 'state', 'defined', 1);
  280. ::InternalTimer(gettimeofday() + 3, 'DWD_OpenData::Timer', $hash, 0);
  281. }
  282. when("forecastWW2Text") {
  283. ::CommandDeleteReading(undef, "$name fc.*wwd");
  284. }
  285. when("timezone") {
  286. $hash->{'.TZ'} = $hash->{FHEM_TZ};
  287. }
  288. }
  289. }
  290. }
  291. return undef;
  292. }
  293. =head2 Get($@)
  294. FHEM I<GetFn>
  295. =over
  296. =item * param hash: hash of DWD_OpenData device
  297. =item * param a: array of FHEM command line arguments, min. length 2, a[1] holds get command
  298. =item * return requested data or error message
  299. =back
  300. =cut
  301. sub Get($@)
  302. {
  303. my ($hash, @a) = @_;
  304. my $name = $hash->{NAME};
  305. my $result = undef;
  306. my $command = lc($a[1]);
  307. given($command) {
  308. when("alerts") {
  309. my $warncellId = $a[2];
  310. $warncellId = ::AttrVal($name, 'alertArea', undef) if (!defined($warncellId));
  311. if (defined($warncellId)) {
  312. my $communeUnion = IsCommuneUnionWarncellId($warncellId);
  313. if (defined($alerts_updating[$communeUnion]) && (time() - $alerts_updating[$communeUnion] < 60)) {
  314. # abort if update is in progress
  315. $result = "alerts cache update in progress, please wait and try again";
  316. } elsif (defined($alerts_received[$communeUnion]) && (time() - $alerts_received[$communeUnion] < 900)) {
  317. # use cache if not older than 15 minutes
  318. $result = UpdateAlerts($hash, $warncellId);
  319. } else {
  320. # update cache if older than 15 minutes
  321. $result = GetAlerts($hash, $warncellId);
  322. }
  323. } else {
  324. $result = "warncell id required for $name get $command";
  325. }
  326. }
  327. when("forecast") {
  328. my $station = $a[2];
  329. $station = ::AttrVal($name, 'forecastStation', undef) if (!defined($station));
  330. if (defined($station)) {
  331. $result = GetForecast($hash, $station);
  332. } else {
  333. $result = "station code required for $name get $command";
  334. }
  335. }
  336. when("updatealertscache") {
  337. my $updateMode = undef;
  338. my $option = lc($a[2]);
  339. given($option) {
  340. when("communeunions") {
  341. $updateMode = UPDATE_COMMUNEUNIONS;
  342. }
  343. when("districts") {
  344. $updateMode = UPDATE_DISTRICTS;
  345. }
  346. when("all") {
  347. $updateMode = UPDATE_ALL;
  348. }
  349. default {
  350. return "update mode 'communeUnions', 'districts' or 'all' required for $name get $command";
  351. }
  352. }
  353. my $communeUnion = IsCommuneUnionWarncellId($updateMode);
  354. if (defined($alerts_updating[$communeUnion]) && (time() - $alerts_updating[$communeUnion] < 60)) {
  355. # abort if update is in progress
  356. $result = "alerts cache update in progress, please wait and try again";
  357. } else {
  358. # update cache if older than 15 minutes
  359. $result = GetAlerts($hash, $updateMode);
  360. }
  361. }
  362. default {
  363. $result = "unknown get command $command, choose one of alerts forecast updateAlertsCache:communeUnions,districts,all";
  364. }
  365. }
  366. return $result;
  367. }
  368. =head2 Timer($)
  369. FHEM I<InternalTimer> function
  370. =over
  371. =item * param hash: hash of DWD_OpenData device
  372. =back
  373. =cut
  374. sub Timer($)
  375. {
  376. my ($hash) = @_;
  377. my $name = $hash->{NAME};
  378. ::Log3 $name, 5, "$name: Timer START";
  379. my $time = time();
  380. my ($tSec, $tMin, $tHour, $tMday, $tMon, $tYear, $tWday, $tYday, $tIsdst) = Localtime($hash, $time);
  381. my $actQuarter = int($tMin/15);
  382. if ($actQuarter == 0) {
  383. my $forecastStation = ::AttrVal($name, 'forecastStation', undef);
  384. if (defined($forecastStation)) {
  385. my $result = GetForecast($hash, $forecastStation);
  386. if (defined($result)) {
  387. ::Log3 $name, 4, "$name: error retrieving forecast: $result";
  388. }
  389. }
  390. }
  391. my $warncellId = ::AttrVal($name, 'alertArea', undef);
  392. if (defined($warncellId)) {
  393. # skip update if already in progress
  394. my $communeUnion = IsCommuneUnionWarncellId($warncellId);
  395. if (!defined($alerts_updating[$communeUnion]) || (time() - $alerts_updating[$communeUnion] >= 60)) {
  396. my $result = GetAlerts($hash, $warncellId);
  397. if (defined($result)) {
  398. ::Log3 $name, 4, "$name: error retrieving alerts: $result";
  399. }
  400. }
  401. }
  402. # schedule next for 5 seconds past next quarter
  403. my $nextQuarterSeconds = Timelocal($hash, 0, $actQuarter*15, $tHour, $tMday, $tMon, $tYear) + 905;
  404. ::InternalTimer($nextQuarterSeconds, 'DWD_OpenData::Timer', $hash, 0);
  405. ::Log3 $name, 5, "$name: Timer END";
  406. }
  407. =head1 MODULE FUNCTIONS
  408. =head2 Timelocal($$)
  409. =over
  410. =item * param hash: hash of DWD_OpenData device
  411. =item * param ta: localtime array in device timezone
  412. =item * return epoch seconds
  413. =back
  414. =cut
  415. sub Timelocal($@) {
  416. my ($hash, @ta) = @_;
  417. if (defined($hash->{'.TZ'})) {
  418. $ENV{"TZ"} = $hash->{'.TZ'};
  419. }
  420. my $t = timelocal(@ta);
  421. if (defined($hash->{FHEM_TZ})) {
  422. $ENV{"TZ"} = $hash->{FHEM_TZ};
  423. } else {
  424. delete $ENV{"TZ"};
  425. }
  426. return $t;
  427. }
  428. =head2 Localtime(@)
  429. =over
  430. =item * param hash: hash of DWD_OpenData device
  431. =item * param t: epoch seconds
  432. =item * return localtime array in device timezone
  433. =back
  434. =cut
  435. sub Localtime(@) {
  436. my ($hash, $t) = @_;
  437. if (defined($hash->{'.TZ'})) {
  438. $ENV{"TZ"} = $hash->{'.TZ'};
  439. }
  440. my @ta = localtime($t);
  441. if (defined($hash->{FHEM_TZ})) {
  442. $ENV{"TZ"} = $hash->{FHEM_TZ};
  443. } else {
  444. delete $ENV{"TZ"};
  445. }
  446. return @ta;
  447. }
  448. =head2 FormatDateTimeLocal($$)
  449. =over
  450. =item * param hash: hash of DWD_OpenData device
  451. =item * param t: epoch seconds
  452. =item * return date time string with with format "YYYY-MM-DD HH:MM:SS" in device timezone
  453. =back
  454. =cut
  455. sub FormatDateTimeLocal($$) {
  456. return strftime('%Y-%m-%d %H:%M:%S', Localtime(@_));
  457. }
  458. =head2 FormatDateLocal($$)
  459. =over
  460. =item * param hash: hash of DWD_OpenData device
  461. =item * param t: epoch seconds
  462. =item * return date string with with format "YYYY-MM-DD" in device timezone
  463. =back
  464. =cut
  465. sub FormatDateLocal($$) {
  466. return strftime('%Y-%m-%d', Localtime(@_));
  467. }
  468. =head2 FormatTimeLocal($$)
  469. =over
  470. =item * param hash: hash of DWD_OpenData device
  471. =item * param t: epoch seconds
  472. =item * return time string with format "HH:MM" in device timezone
  473. =back
  474. =cut
  475. sub FormatTimeLocal($$) {
  476. return strftime('%H:%M', Localtime(@_));
  477. }
  478. =head2 FormatWeekdayLocal($$)
  479. =over
  480. =item * param hash: hash of DWD_OpenData device
  481. =item * param t: epoch seconds
  482. =item * return abbreviated weekday name in device timezone
  483. =back
  484. =cut
  485. sub FormatWeekdayLocal($$) {
  486. return strftime('%a', Localtime(@_));
  487. }
  488. =head2 ParseDateTimeLocal($$)
  489. =over
  490. =item * param hash: hash of DWD_OpenData device
  491. =item * param s: date string with format "YYYY-MM-DD HH:MM:SS" in device timezone
  492. =item * return epoch seconds or C<undef> on error
  493. =back
  494. =cut
  495. sub ParseDateTimeLocal($$) {
  496. my ($hash, $s) = @_;
  497. my $t;
  498. eval { $t = Timelocal($hash, ::strptime($s, '%Y-%m-%d %H:%M:%S')) };
  499. return $t;
  500. }
  501. =head2 ParseDateLocal($$)
  502. =over
  503. =item * param hash: hash of DWD_OpenData device
  504. =item * param s: date string with format "YYYY-MM-DD" in device timezone
  505. =item * return epoch seconds or C<undef> on error
  506. =back
  507. =cut
  508. sub ParseDateLocal($$) {
  509. my ($hash, $s) = @_;
  510. my $t;
  511. eval { $t = Timelocal($hash, ::strptime($s, '%Y-%m-%d')) };
  512. return $t;
  513. }
  514. =head2 ParseCAPTime($)
  515. =over
  516. =item * param s: time string with format "YYYY-MM-DDThh:mm:ssZZZ:ZZ"
  517. =item * return epoch seconds
  518. =back
  519. =cut
  520. sub ParseCAPTime($) {
  521. my ($s) = @_;
  522. $s =~ s|(.+):|$1|; # remove colon from time zone offset
  523. #Log 1, "ParseCAPTime: " . $s;
  524. return Time::Piece->strptime($s, '%Y-%m-%dT%H:%M:%S%z')->epoch;
  525. }
  526. =head2 ParseKMLTime($)
  527. =over
  528. =item * param s: time string with format "YYYY-MM-DDThh:mm:ss.000Z"
  529. =item * return epoch seconds
  530. =back
  531. =cut
  532. sub ParseKMLTime($) {
  533. my ($s) = @_;
  534. $s =~ s|(.+)\.000Z|$1|; # remove milliseconds and timezone
  535. return Time::Piece->strptime($s, '%Y-%m-%dT%H:%M:%S')->epoch;
  536. }
  537. =head2 IsCommuneUnionWarncellId($)
  538. =over
  539. =item * param warncellId: numeric wanrcell id
  540. =item * return true if warncell id belongs to commune union group
  541. =back
  542. =cut
  543. sub IsCommuneUnionWarncellId($) {
  544. my ($warncellId) = @_;
  545. return int($warncellId/100000000) == 5 || int($warncellId/100000000) == 8
  546. || $warncellId == UPDATE_COMMUNEUNIONS || $warncellId == UPDATE_ALL? 1 : 0;
  547. }
  548. =head2 RotateForecast($$;$)
  549. =over
  550. =item * param hash: hash of DWD_OpenData device
  551. =item * param station: station name, string
  552. =item * param today: epoch of today 00:00, optional
  553. =item * return count of available forecast days
  554. =back
  555. =cut
  556. sub RotateForecast($$;$)
  557. {
  558. my ($hash, $station, $today) = @_;
  559. my $name = $hash->{NAME};
  560. my $daysAvailable = 0;
  561. while (defined(::ReadingsVal($name, 'fc'.$daysAvailable.'_date', undef))) {
  562. $daysAvailable++;
  563. }
  564. #::Log3 $name, 5, "$name: A $daysAvailable";
  565. my $oT = ::ReadingsVal($name, 'fc0_date', undef);
  566. my $oldToday = defined($oT)? ParseDateLocal($hash, $oT) : undef;
  567. my $stationChanged = ::ReadingsVal($name, 'fc_station', '') ne $station;
  568. if ($stationChanged) {
  569. # different station, delete all existing readings
  570. ::CommandDeleteReading(undef, "$name fc.*");
  571. $daysAvailable = 0;
  572. } elsif (defined($oldToday)) {
  573. # same station, shift existing readings
  574. if (!defined($today)) {
  575. my $time = time();
  576. my ($tSec, $tMin, $tHour, $tMday, $tMon, $tYear, $tWday, $tYday, $tIsdst) = Localtime($hash, $time);
  577. $today = Timelocal($hash, 0, 0, 0, $tMday, $tMon, $tYear);
  578. }
  579. my $daysForward = sprintf("%0.0f", $today - $oldToday); # round()
  580. if ($daysForward > 0) {
  581. # different day
  582. if ($daysForward < $daysAvailable) {
  583. my @shiftProperties = ( 'date', 'weekday' );
  584. my $forecastResolution = ::AttrVal($name, 'forecastResolution', 6);
  585. while (my($property, $period) = each %forecastPropertyPeriods) {
  586. if ($period == 24) {
  587. push(@shiftProperties, $property);
  588. } else {
  589. for (my $s=0; $s<24/$forecastResolution; $s++) {
  590. push(@shiftProperties, $s.'_'.$property);
  591. }
  592. }
  593. }
  594. for (my $s=0; $s<24/$forecastResolution; $s++) {
  595. push(@shiftProperties, $s.'_time');
  596. push(@shiftProperties, $s.'_wwd');
  597. }
  598. # shift readings forward by days
  599. for (my $d=0; $d<($daysAvailable - $daysForward); $d++) {
  600. my $sourcePrefix = 'fc'.($daysForward + $d).'_';
  601. my $destinationPrefix = 'fc'.$d.'_';
  602. foreach my $property (@shiftProperties) {
  603. my $value = ::ReadingsVal($name, $sourcePrefix.$property, undef);
  604. if (defined($value)) {
  605. ::readingsBulkUpdate($hash, $destinationPrefix.$property, $value);
  606. } else {
  607. ::CommandDeleteReading(undef, $destinationPrefix.$property);
  608. }
  609. }
  610. }
  611. # delete existing readings of all days that have not been written
  612. for (my $d=($daysAvailable - $daysForward); $d<$daysAvailable; $d++) {
  613. ::CommandDeleteReading(undef, "$name fc".$d."_.*");
  614. }
  615. $daysAvailable -= $daysForward;
  616. } else {
  617. # nothing to shift, delete existing readings
  618. ::CommandDeleteReading(undef, "$name fc.*");
  619. $daysAvailable = 0;
  620. }
  621. }
  622. }
  623. return $daysAvailable;
  624. }
  625. sub ProcessForecast($$$);
  626. =head2 GetForecast($$)
  627. =over
  628. =item * param hash: hash of DWD_OpenData device
  629. =item * param station: station name, string
  630. =back
  631. =cut
  632. sub GetForecast($$)
  633. {
  634. my ($hash, $station) = @_;
  635. my $name = $hash->{NAME};
  636. if (!::IsDisabled($name)) {
  637. # test if XML module is available
  638. eval {
  639. require XML::LibXML;
  640. };
  641. if ($@) {
  642. return "$name: Perl module XML::LibXML not found, see commandref for details how to fix";
  643. }
  644. # @TODO move RotateForecast
  645. # get forecast for station from DWD server
  646. ::readingsSingleUpdate($hash, 'state', 'fetching', 0);
  647. my $url = 'https://opendata.dwd.de/weather/local_forecasts/mos/MOSMIX_L/single_stations/' . $station . '/kml/MOSMIX_L_LATEST_' . $station . '.kmz ';
  648. my $param = {
  649. url => $url,
  650. method => "GET",
  651. timeout => 10,
  652. callback => \&ProcessForecast,
  653. hash => $hash,
  654. station => $station
  655. };
  656. ::Log3 $name, 5, "$name: GetForecast START (PID $$): $url";
  657. ::HttpUtils_NonblockingGet($param);
  658. ::Log3 $name, 5, "$name: GetForecast END";
  659. } else {
  660. return "disabled";
  661. }
  662. }
  663. =head2 ProcessForecast($$$)
  664. =over
  665. =item * param param: parameter hash from call to HttpUtils_NonblockingGet
  666. =item * param httpError: nothing or HTTP error string
  667. =item * param fileContent: data retrieved from URL
  668. =item * return C<undef> on success or error message
  669. =back
  670. =cut
  671. sub ProcessForecast($$$)
  672. {
  673. my ($param, $httpError, $fileContent) = @_;
  674. my $hash = $param->{hash};
  675. my $name = $hash->{NAME};
  676. my $url = $param->{url};
  677. my $code = $param->{code};
  678. my $station = $param->{station};
  679. ::Log3 $name, 5, "$name: ProcessForecast START";
  680. # preprocess existing readings
  681. ::readingsBeginUpdate($hash);
  682. my $time = time();
  683. my ($tSec, $tMin, $tHour, $tMday, $tMon, $tYear, $tWday, $tYday, $tIsdst) = Localtime($hash, $time);
  684. my $today = Timelocal($hash, 0, 0, 0, $tMday, $tMon, $tYear);
  685. my $daysAvailable = RotateForecast($hash, $station, $today);
  686. my $relativeDay = 0;
  687. eval {
  688. if (defined($httpError) && length($httpError) > 0) {
  689. die "error retrieving URL '$url': $httpError";
  690. }
  691. if (defined($code) && $code != 200) {
  692. die "error $code retrieving URL '$url'";
  693. }
  694. if (!defined($fileContent) || length($fileContent) == 0) {
  695. die "no data retrieved from URL '$url'";
  696. }
  697. ::Log3 $name, 5, "$name: ProcessForecast: data received, $daysAvailable days currently exist with readings";
  698. # prepare processing
  699. ::readingsBulkUpdate($hash, 'state', 'processing');
  700. my $forecastWW2Text = ::AttrVal($name, 'forecastWW2Text', 0);
  701. my $forecastDays = ::AttrVal($name, 'forecastDays', 6);
  702. my $forecastResolution = ::AttrVal($name, 'forecastResolution', 6);
  703. my $forecastProperties = ::AttrVal($name, 'forecastProperties', undef);
  704. my @properties = split(',', $forecastProperties) if (defined($forecastProperties));
  705. my %selectedProperties;
  706. if (!@properties) {
  707. # no selection: use defaults
  708. %selectedProperties = %forecastDefaultProperties;
  709. } else {
  710. # use selected properties
  711. foreach my $property (@properties) {
  712. $property =~ s/^\s+|\s+$//g; # trim
  713. $selectedProperties{$property} = 1;
  714. }
  715. }
  716. # create memory mapped file from received data and unzip
  717. open my $zipFileHandle, '<', \$fileContent;
  718. my @xmlStrings;
  719. unzip($zipFileHandle => \@xmlStrings, MultiStream => 1) or die "unzip failed: $UnzipError\n";
  720. ::readingsBulkUpdate($hash, "fc_station", $station);
  721. # parse XML strings (files from zip)
  722. foreach my $xmlString (@xmlStrings) {
  723. if (substr(${$xmlString}, 0, 2) eq 'PK') {
  724. # empty string, skip
  725. next;
  726. }
  727. # parse XML string
  728. ::Log3 $name, 5, "$name: ProcessForecast: parsing XML document";
  729. my $dom = XML::LibXML->load_xml(string => $xmlString);
  730. if (!$dom) {
  731. die "parsing XML failed";
  732. }
  733. ::Log3 $name, 5, "$name: ProcessForecast: extracting data";
  734. # extract header
  735. my @timestamps;
  736. my $defaultUndefSign = '-';
  737. my $productDefinitionNodeList = $dom->getElementsByLocalName('ProductDefinition');
  738. if ($productDefinitionNodeList->size()) {
  739. my $productDefinitionNode = $productDefinitionNodeList->get_node(1);
  740. foreach my $productDefinitionChildNode ($productDefinitionNode->nonBlankChildNodes()) {
  741. if ($productDefinitionChildNode->nodeName() eq 'dwd:Issuer') {
  742. my $issuer = $productDefinitionChildNode->textContent();
  743. ::readingsBulkUpdate($hash, "fc_copyright", "Datenbasis: $issuer");
  744. } elsif ($productDefinitionChildNode->nodeName() eq 'dwd:IssueTime') {
  745. my $issueTime = $productDefinitionChildNode->textContent();
  746. # ignore issue time, use now
  747. ::readingsBulkUpdate($hash, "fc_time", FormatDateTimeLocal($hash, $time));
  748. } elsif ($productDefinitionChildNode->nodeName() eq 'dwd:ForecastTimeSteps') {
  749. foreach my $forecastTimeStepsChildNode ($productDefinitionChildNode->nonBlankChildNodes()) {
  750. if ($forecastTimeStepsChildNode->nodeName() eq 'dwd:TimeStep') {
  751. my $forecastTimeSteps = $forecastTimeStepsChildNode->textContent();
  752. push(@timestamps, ParseKMLTime($forecastTimeSteps));
  753. }
  754. }
  755. } elsif ($productDefinitionChildNode->nodeName() eq 'dwd:FormatCfg') {
  756. foreach my $formatCfgChildNode ($productDefinitionChildNode->nonBlankChildNodes()) {
  757. if ($formatCfgChildNode->nodeName() eq 'dwd:DefaultUndefSign') {
  758. $defaultUndefSign = $formatCfgChildNode->textContent();
  759. }
  760. }
  761. }
  762. }
  763. }
  764. # extract data
  765. my %properties;
  766. my $placemarkNodeList = $dom->getElementsByLocalName('Placemark');
  767. if ($placemarkNodeList->size()) {
  768. my $placemarkNode = $placemarkNodeList->get_node(1);
  769. foreach my $placemarkChildNode ($placemarkNode->nonBlankChildNodes()) {
  770. if ($placemarkChildNode->nodeName() eq 'kml:description') {
  771. my $description = $placemarkChildNode->textContent();
  772. ::readingsBulkUpdate($hash, "fc_description", encode('UTF-8', $description));
  773. } elsif ($placemarkChildNode->nodeName() eq 'kml:ExtendedData') {
  774. foreach my $extendedDataChildNode ($placemarkChildNode->nonBlankChildNodes()) {
  775. if ($extendedDataChildNode->nodeName() eq 'dwd:Forecast') {
  776. my $elementName = $extendedDataChildNode->getAttribute('dwd:elementName');
  777. # convert some elements names for backward compatibility
  778. my $alias = $forecastPropertyAliases{$elementName};
  779. if (defined($alias)) { $elementName = $alias };
  780. my $selectedProperty = $selectedProperties{$elementName};
  781. if (defined($selectedProperty)) {
  782. my $textContent = $extendedDataChildNode->nonBlankChildNodes()->get_node(1)->textContent();
  783. $textContent =~ s/^\s+|\s+$//g; # trim outside
  784. $textContent =~ s/\s+/ /g; # trim inside
  785. my @values = split(' ',$textContent);
  786. $properties{$elementName} = \@values;
  787. }
  788. }
  789. }
  790. } elsif ($placemarkChildNode->nodeName() eq 'kml:Point') {
  791. my $coordinates = $placemarkChildNode->nonBlankChildNodes()->get_node(1)->textContent();
  792. ::readingsBulkUpdate($hash, "fc_coordinates", $coordinates);
  793. }
  794. }
  795. }
  796. ::Log3 $name, 5, "$name: ProcessForecast: creating readings";
  797. # create readings
  798. my $lastDayPrefix = '';
  799. for my $i (0 .. $#timestamps) {
  800. # analyse date relation between forecast and today
  801. my $forecastTime = $timestamps[$i];
  802. my ($fcSec, $fcMin, $fcHour, $fcMday, $fcMon, $fcYear, $fcWday, $fcYday, $fcIsdst) = Localtime($hash, $forecastTime);
  803. my $forecastDate = Timelocal($hash, 0, 0, 0, $fcMday, $fcMon, $fcYear);
  804. $relativeDay = sprintf("%.0f", ($forecastDate - $today)/(24*60*60)); # Perl equivalent for round()
  805. if ($relativeDay > $forecastDays) {
  806. # max. number of days processed, done
  807. last;
  808. }
  809. if ($relativeDay < 0) {
  810. # forecast is older than today, skip
  811. next;
  812. }
  813. # write data
  814. my $dayPrefix = 'fc'.$relativeDay.'_';
  815. if ($dayPrefix ne $lastDayPrefix) {
  816. ::readingsBulkUpdate($hash, $dayPrefix.'date', FormatDateLocal($hash, $forecastTime));
  817. ::readingsBulkUpdate($hash, $dayPrefix.'weekday', FormatWeekdayLocal($hash, $forecastTime));
  818. $lastDayPrefix = $dayPrefix;
  819. }
  820. # some values are only available every 3, 6 or 12 hours relative to 00:00 UTC
  821. my $hourPrefix = undef;
  822. my $fcHourUTC = (gmtime($forecastTime))[2];
  823. #::Log3 $name, 5, "$name: fcHourUTC $fcHourUTC";
  824. if ($fcHourUTC%$forecastResolution == 0) {
  825. $hourPrefix = int($fcHour/$forecastResolution).'_';
  826. #::Log3 $name, 5, "$name: hourPrefix $hourPrefix";
  827. ::readingsBulkUpdate($hash, $dayPrefix.$hourPrefix.'time', FormatTimeLocal($hash, $forecastTime));
  828. }
  829. while (my($property, $values) = each %properties) {
  830. #::Log3 $name, 5, "$name: $property vs=" . scalar(@$values) . " ts=" . $#timestamps . " -> " . $values->[$i];
  831. if (defined($values->[$i])) {
  832. my $value = $values->[$i];
  833. if ($value ne $defaultUndefSign) {
  834. $value =~ s/,/./g; # decimal point
  835. my $forecastPropertyType = $forecastPropertyTypes{$property};
  836. if (defined($forecastPropertyType)) {
  837. if ($forecastPropertyType == 1) {
  838. $value -= 273.15; # K -> °C
  839. if (length($value) > 6) {
  840. $value = sprintf('%0.2f', $value); # round to compensate floating point granularity
  841. }
  842. }
  843. elsif ($forecastPropertyType == 2) {
  844. $value = sprintf('%0.0f', $value); # round()
  845. if ($forecastWW2Text && ($property eq 'ww') && defined($hourPrefix) && length($value) > 0) {
  846. ::readingsBulkUpdate($hash, $dayPrefix.$hourPrefix.'wwd', $wwdText[$value]);
  847. }
  848. }
  849. elsif ($forecastPropertyType == 3) {
  850. $value *= 3.6; # m/s -> km/h
  851. $value = sprintf('%0.0f', $value); # round()
  852. }
  853. elsif ($forecastPropertyType == 4) {
  854. $value /= 100; # Pa -> hPa
  855. $value = sprintf('%0.1f', $value); # round(1)
  856. }
  857. }
  858. #::Log3 $name, 5, "$name: $fcHour $dayPrefix $hourPrefix | $property -> $value | $forecastPropertyType";
  859. my $forecastPropertyPeriod = $forecastPropertyPeriods{$property};
  860. if ($forecastPropertyPeriod == 24) {
  861. # day property
  862. ::readingsBulkUpdate($hash, $dayPrefix.$property, $value);
  863. } elsif (defined($hourPrefix)) {
  864. # hour property
  865. ::readingsBulkUpdate($hash, $dayPrefix.$hourPrefix.$property, $value);
  866. }
  867. }
  868. }
  869. }
  870. }
  871. }
  872. };
  873. # abort on exception
  874. if ($@) {
  875. my @parts = split(' at ', $@);
  876. if (@parts) {
  877. ::readingsBulkUpdate($hash, 'state', "forecast error: $parts[0]");
  878. ::Log3 $name, 4, "$name: ProcessForecast error: $parts[0]";
  879. } else {
  880. ::readingsBulkUpdate($hash, 'state', "forecast error: $@");
  881. ::Log3 $name, 4, "$name: ProcessForecast error: $@";
  882. }
  883. ::readingsEndUpdate($hash, 1);
  884. return @parts? $parts[0] : $@;
  885. }
  886. # delete existing readings of all days that have not been written
  887. if ($daysAvailable > $relativeDay + 1) {
  888. ::Log3 $name, 5, "$name: deleting days with index " . ($relativeDay + 1) . " to " . ($daysAvailable - 1);
  889. for (my $d=($relativeDay + 1); $d<$daysAvailable; $d++) {
  890. ::CommandDeleteReading(undef, "$name fc".$d."_.*");
  891. }
  892. }
  893. ::readingsBulkUpdate($hash, 'state', 'forecast updated');
  894. ::readingsEndUpdate($hash, 1);
  895. ::Log3 $name, 5, "$name: ProcessForecast END";
  896. return undef;
  897. }
  898. =head2 GetAlerts($$)
  899. =over
  900. =item * param hash: hash of DWD_OpenData device
  901. =item * param warncellId: numeric id of warncell, may also be C<UPDATE_DISTRICTS>, C<UPDATE_COMMUNEUNIONS> or C<UPDATE_ALL>
  902. =back
  903. =cut
  904. sub GetAlerts($$)
  905. {
  906. my ($hash, $warncellId) = @_;
  907. my $name = $hash->{NAME};
  908. if (!::IsDisabled($name)) {
  909. ::Log3 $name, 5, "$name: GetAlerts START (PID $$)";
  910. # test if XML module is available
  911. eval {
  912. require XML::LibXML;
  913. };
  914. if ($@) {
  915. return "$name: Perl module XML::LibXML not found, see commandref for details how to fix";
  916. }
  917. # @TODO delete expired alerts?
  918. # download, unzip and parse using BlockingCall
  919. my $communeUnion = IsCommuneUnionWarncellId($warncellId);
  920. if (defined($hash->{".alertsFile".$communeUnion})) {
  921. # delete old temp file
  922. close($hash->{".alertsFileHandle".$communeUnion});
  923. unlink($hash->{".alertsFile".$communeUnion});
  924. }
  925. ($hash->{".alertsFileHandle".$communeUnion}, $hash->{".alertsFile".$communeUnion}) = tempfile(UNLINK => 1);
  926. $hash->{".warncellId"} = $warncellId;
  927. if (defined($hash->{".alertsBlockingCall".$communeUnion})) {
  928. # kill old blocking call
  929. ::BlockingKill($hash->{".alertsBlockingCall".$communeUnion});
  930. }
  931. $hash->{".alertsBlockingCall".$communeUnion} = ::BlockingCall("DWD_OpenData::GetAlertsStart", $hash, "DWD_OpenData::GetAlertsFinish", 60, "DWD_OpenData::GetAlertsAbort", $hash);
  932. $alerts_updating[$communeUnion] = time();
  933. ::readingsSingleUpdate($hash, 'state', 'updating alerts cache', 1);
  934. ::Log3 $name, 5, "$name: GetAlerts END";
  935. return undef;
  936. } else {
  937. return "disabled";
  938. }
  939. }
  940. sub ProcessAlerts($$$);
  941. =head2 GetAlertsStart($)
  942. BlockingCall I<BlockingFn> callback
  943. =over
  944. =item * param hash: hash of DWD_OpenData device
  945. =item * return result required by function L</GetAlertsFinish(@)>
  946. =back
  947. ATTENTION: This method is executed in a different process than FHEM.
  948. The device hash is from the time of the process initiation.
  949. Any changes to the device hash or readings are not visible
  950. in FHEM.
  951. =cut
  952. sub GetAlertsStart($)
  953. {
  954. my ($hash) = @_;
  955. my $name = $hash->{NAME};
  956. my $warncellId = $hash->{".warncellId"};
  957. # get communion (5, 8) or district (1, 9) alerts for Germany from DWD server
  958. my $communeUnion = IsCommuneUnionWarncellId($warncellId);
  959. my $alertLanguage = ::AttrVal($name, 'alertLanguage', 'DE');
  960. my $url = 'https://opendata.dwd.de/weather/alerts/cap/'.($communeUnion? 'COMMUNEUNION' : 'DISTRICT').'_CELLS_STAT/Z_CAP_C_EDZW_LATEST_PVW_STATUS_PREMIUMCELLS_'.($communeUnion? 'COMMUNEUNION' : 'DISTRICT').'_'.$alertLanguage.'.zip';
  961. my $param = {
  962. url => $url,
  963. method => "GET",
  964. timeout => 30,
  965. hash => $hash,
  966. warncellId => $warncellId
  967. };
  968. ::Log3 $name, 5, "$name: GetAlertsStart START (PID $$): $url";
  969. my ($httpError, $fileContent) = ::HttpUtils_BlockingGet($param);
  970. # process retrieved data
  971. my $result = ProcessAlerts($param, $httpError, $fileContent);
  972. ::Log3 $name, 5, "$name: GetAlertsStart END";
  973. return $result;
  974. }
  975. =head2 ProcessAlerts($$$)
  976. =over
  977. =item * param hash: hash of DWD_OpenData device
  978. =item * return result required by function L</GetAlertsFinish(@)>
  979. =back
  980. ATTENTION: This method is executed in a different process than FHEM.
  981. The device hash is from the time of the process initiation.
  982. Any changes to the device hash or readings are not visible
  983. in FHEM.
  984. =cut
  985. sub ProcessAlerts($$$)
  986. {
  987. my ($param, $httpError, $fileContent) = @_;
  988. my $time = time();
  989. my $hash = $param->{hash};
  990. my $name = $hash->{NAME};
  991. my $url = $param->{url};
  992. my $code = $param->{code};
  993. my $warncellId = $param->{warncellId};
  994. ::Log3 $name, 5, "$name: ProcessAlerts START (PID $$)";
  995. my %alerts;
  996. eval {
  997. if (defined($httpError) && length($httpError) > 0) {
  998. die "error retrieving URL '$url': $httpError";
  999. }
  1000. if (defined($code) && $code != 200) {
  1001. die "error $code retrieving URL '$url'";
  1002. }
  1003. if (!defined($fileContent) || length($fileContent) == 0) {
  1004. die "no data retrieved from URL '$url'";
  1005. }
  1006. ::Log3 $name, 5, "$name: ProcessAlerts: data received";
  1007. # create memory mapped file from received data and unzip
  1008. open my $zipFileHandle, '<', \$fileContent;
  1009. my @xmlStrings;
  1010. unzip($zipFileHandle => \@xmlStrings, MultiStream => 1) or die "unzip failed: $UnzipError\n";
  1011. # parse XML strings
  1012. foreach my $xmlString (@xmlStrings) {
  1013. if (substr(${$xmlString}, 0, 2) eq 'PK') {
  1014. # empty string, skip
  1015. next;
  1016. }
  1017. # parse XML string
  1018. ::Log3 $name, 5, "$name: ProcessAlerts: parsing XML document";
  1019. my $dom = XML::LibXML->load_xml(string => $xmlString);
  1020. if (!$dom) {
  1021. die "parsing XML failed";
  1022. }
  1023. my $xpc = XML::LibXML::XPathContext->new($dom);
  1024. $xpc->registerNs('cap', 'urn:oasis:names:tc:emergency:cap:1.2');
  1025. my $alert = {};
  1026. my $alertNode = $dom->documentElement();
  1027. foreach my $alertChildNode ($alertNode->nonBlankChildNodes()) {
  1028. #::Log3 $name, 5, "$name: ProcessAlerts child node: " . $alertChildNode->nodeName();
  1029. if ($alertChildNode->nodeName() eq 'identifier') {
  1030. $alert->{identifier} = $alertChildNode->textContent();
  1031. #::Log3 $name, 5, "$name: ProcessAlerts identifier: " . $alert->{identifier};
  1032. } elsif ($alertChildNode->nodeName() eq 'status') {
  1033. $alert->{status} = $alertChildNode->textContent();
  1034. } elsif ($alertChildNode->nodeName() eq 'msgType') {
  1035. $alert->{msgType} = $alertChildNode->textContent();
  1036. } elsif ($alertChildNode->nodeName() eq 'references') {
  1037. # get list of references, separated by whitespace, each reference consisting of 3 parts: sender, identifier, sent
  1038. $alert->{references} = [];
  1039. my @references = split(' ', $alertChildNode->textContent());
  1040. foreach my $reference (@references) {
  1041. my @parts = split(',', $reference);
  1042. if (scalar(@parts) == 3) {
  1043. push(@{$alert->{references}}, $parts[2]);
  1044. }
  1045. }
  1046. } elsif ($alertChildNode->nodeName() eq 'info') {
  1047. foreach my $infoChildNode ($alertChildNode->nonBlankChildNodes()) {
  1048. #::Log3 $name, 5, "$name: ProcessAlerts child node: '" . $infoChildNode->nodeName() . "'";
  1049. if ($infoChildNode->nodeName() eq 'category') {
  1050. $alert->{category} = $infoChildNode->textContent();
  1051. } elsif ($infoChildNode->nodeName() eq 'event') {
  1052. $alert->{event} = $infoChildNode->textContent();
  1053. } elsif ($infoChildNode->nodeName() eq 'responseType') {
  1054. $alert->{responseType} = $infoChildNode->textContent();
  1055. } elsif ($infoChildNode->nodeName() eq 'urgency') {
  1056. $alert->{urgency} = $infoChildNode->textContent();
  1057. } elsif ($infoChildNode->nodeName() eq 'severity') {
  1058. $alert->{severity} = $infoChildNode->textContent();
  1059. } elsif ($infoChildNode->nodeName() eq 'eventCode') {
  1060. $xpc->setContextNode($infoChildNode);
  1061. my $valueName = $xpc->findvalue("./cap:valueName");
  1062. if ($valueName eq 'LICENSE') {
  1063. $alert->{license} = $xpc->findvalue("./cap:value");
  1064. } elsif ($valueName eq 'II') {
  1065. $alert->{eventCode} = $xpc->findvalue("./cap:value");
  1066. } elsif ($valueName eq 'GROUP') {
  1067. $alert->{eventGroup} = $xpc->findvalue("./cap:value");
  1068. } elsif ($valueName eq 'AREA_COLOR') {
  1069. $alert->{areaColor} = $xpc->findvalue("./cap:value");
  1070. $alert->{areaColor} =~ s/ /, /g;
  1071. }
  1072. } elsif ($infoChildNode->nodeName() eq 'onset') {
  1073. $alert->{onset} = ParseCAPTime($infoChildNode->textContent());
  1074. } elsif ($infoChildNode->nodeName() eq 'expires') {
  1075. $alert->{expires} = ParseCAPTime($infoChildNode->textContent());
  1076. } elsif ($infoChildNode->nodeName() eq 'headline') {
  1077. $alert->{headline} = $infoChildNode->textContent();
  1078. } elsif ($infoChildNode->nodeName() eq 'description') {
  1079. $alert->{description} = $infoChildNode->textContent();
  1080. } elsif ($infoChildNode->nodeName() eq 'instruction') {
  1081. $alert->{instruction} = $infoChildNode->textContent();
  1082. } elsif ($infoChildNode->nodeName() eq 'area') {
  1083. $xpc->setContextNode($infoChildNode);
  1084. my $valueName = $xpc->findvalue("./cap:geocode/cap:valueName");
  1085. if ($valueName eq 'WARNCELLID') {
  1086. if (!defined($alert->{warncellid})) {
  1087. $alert->{warncellid} = [];
  1088. $alert->{areaDesc} = [];
  1089. $alert->{altitude} = [];
  1090. $alert->{ceiling} = [];
  1091. }
  1092. #::Log3 $name, 5, "$name: ProcessAlerts warncellid: " . $xpc->findvalue("./cap:geocode/cap:value");
  1093. push(@{$alert->{warncellid}}, $xpc->findvalue("./cap:geocode/cap:value"));
  1094. push(@{$alert->{areaDesc}}, $xpc->findvalue("./cap:areaDesc"));
  1095. push(@{$alert->{altitude}}, $xpc->findvalue("./cap:altitude"));
  1096. push(@{$alert->{ceiling}}, $xpc->findvalue("./cap:ceiling"));
  1097. }
  1098. }
  1099. }
  1100. }
  1101. }
  1102. #::Log3 $name, 5, "$name: ProcessAlerts header: $alert->{identifier}, $alert->{status}, $alert->{msgType}: $alert->{headline}, $alert->{warncellids}[0]";
  1103. if ($alert->{status} ne 'Test' && $alert->{responseType} ne 'Monitor') {
  1104. $alerts{$alert->{identifier}} = $alert;
  1105. }
  1106. }
  1107. };
  1108. my $errorMessage = '';
  1109. if ($@) {
  1110. # exception
  1111. my @parts = split(/ at |\n/, $@); # discard anything after " at " or newline
  1112. if (@parts) {
  1113. $errorMessage = $parts[0];
  1114. ::Log3 $name, 4, "$name: ProcessAlerts error: $parts[0]";
  1115. } else {
  1116. $errorMessage = $@;
  1117. ::Log3 $name, 4, "$name: ProcessAlerts error: $@";
  1118. }
  1119. } else {
  1120. # alerts parsed successfully
  1121. my $communeUnion = IsCommuneUnionWarncellId($warncellId);
  1122. if (defined($hash->{".alertsFile".$communeUnion})) {
  1123. if (open(my $file, ">", $hash->{".alertsFile".$communeUnion})) {
  1124. # write alerts to temp file
  1125. binmode($file);
  1126. my $frozenAlerts = freeze(\%alerts);
  1127. ::Log3 $name, 5, "$name: ProcessAlerts temp file " . $hash->{".alertsFile".$communeUnion} . " alerts " . keys(%alerts) . " size " . length($frozenAlerts);
  1128. print($file $frozenAlerts);
  1129. close($file);
  1130. } else {
  1131. $errorMessage = $!;
  1132. ::Log3 $name, 3, "$name: ProcessAlerts error opening temp file: $errorMessage";
  1133. }
  1134. } else {
  1135. $errorMessage = 'result file name not defined';
  1136. ::Log3 $name, 3, "$name: ProcessAlerts error: temp file name not defined";
  1137. }
  1138. }
  1139. # get rid of newlines and commas because of Blocking InformFn parameter restrictions
  1140. $errorMessage =~ s/\n/; /g;
  1141. $errorMessage =~ s/,/;/g;
  1142. ::Log3 $name, 5, "$name: ProcessAlerts END";
  1143. return [$name, $errorMessage, $warncellId, $time];
  1144. }
  1145. =head2 GetAlertsFinish(@)
  1146. BlockingCall I<FinishFn> callback, expects array returned by function L</GetAlertsStart($)> as single parameter
  1147. =over
  1148. =item * param name: name of DWD_OpenData device
  1149. =item * param errorMessage: empty string or processing error message
  1150. =item * param warncellId: numeric warncell id for which alers have been requested, may also be C<UPDATE_DISTRICTS>, C<UPDATE_COMMUNEUNIONS> or C<UPDATE_ALL>
  1151. =item * param time: epoch time when alerts where received
  1152. =back
  1153. =cut
  1154. sub GetAlertsFinish(@)
  1155. {
  1156. my ($name, $errorMessage, $warncellId, $time) = @_;
  1157. if (defined($name)) {
  1158. ::Log3 $name, 5, "$name: GetAlertsFinish START (PID $$)";
  1159. my $hash = $::defs{$name};
  1160. my $communeUnion = IsCommuneUnionWarncellId($warncellId);
  1161. if (defined($errorMessage) && length($errorMessage) > 0) {
  1162. $alerts_updating[$communeUnion] = undef;
  1163. ::readingsSingleUpdate($hash, 'state', "alerts error: $errorMessage", 1);
  1164. } elsif (defined($hash->{".alertsFile".$communeUnion})) {
  1165. # deserialize alerts
  1166. my $fh = $hash->{".alertsFileHandle".$communeUnion};
  1167. my $terminator = $/;
  1168. $/ = undef; # enable slurp file read mode
  1169. my $frozenAlerts = <$fh>;
  1170. $/ = $terminator; # restore default file read mode
  1171. close($hash->{".alertsFileHandle".$communeUnion});
  1172. unlink($hash->{".alertsFile".$communeUnion});
  1173. my %newAlerts = %{thaw($frozenAlerts)};
  1174. ::Log3 $name, 5, "$name: GetAlertsFinish temp file " . $hash->{".alertsFile".$communeUnion} . " alerts " . keys(%newAlerts) . " size " . length($frozenAlerts);
  1175. delete($hash->{".alertsFile".$communeUnion});
  1176. # @TODO delete global alert list when no differential updates are available
  1177. my $alerts = {};
  1178. # update global alert list
  1179. foreach my $alert (values(%newAlerts)) {
  1180. my $indentifierExists = defined($alerts->{$alert->{identifier}});
  1181. if ($indentifierExists) {
  1182. ::Log3 $name, 5, "$name: ProcessAlerts identifier " . $alert->{identifier} . " already known, data not updated";
  1183. } elsif ($alert->{msgType} eq 'Alert') {
  1184. # add new alert
  1185. $alerts->{$alert->{identifier}} = $alert;
  1186. } elsif ($alert->{msgType} eq 'Update') {
  1187. # delete old alerts
  1188. foreach my $reference (@{$alert->{references}}) {
  1189. delete $alerts->{$reference};
  1190. }
  1191. # add new alert
  1192. $alerts->{$alert->{identifier}} = $alert;
  1193. } elsif ($alert->{msgType} eq 'Cancel') {
  1194. # delete old alerts
  1195. foreach my $reference (@{$alert->{references}}) {
  1196. delete $alerts->{$reference};
  1197. }
  1198. }
  1199. }
  1200. $alerts_data[$communeUnion] = $alerts;
  1201. $alerts_received[$communeUnion] = $time;
  1202. $alerts_updating[$communeUnion] = undef;
  1203. if ($warncellId >= 0) {
  1204. # update alert readings for warncell id
  1205. UpdateAlerts($hash, $warncellId);
  1206. } elsif ($warncellId == UPDATE_ALL) {
  1207. if (!defined($alerts_updating[0]) || (time() - $alerts_updating[0] >= 60)) {
  1208. # communeunions cache updated, start district cache update;
  1209. GetAlerts($hash, UPDATE_DISTRICTS);
  1210. }
  1211. } else {
  1212. ::readingsSingleUpdate($hash, 'state', "alerts cache updated", 1);
  1213. }
  1214. } else {
  1215. ::readingsSingleUpdate($hash, 'state', "alerts error: result file name not defined", 1);
  1216. ::Log3 $name, 3, "$name: GetAlertsFinish error: temp file name not defined";
  1217. }
  1218. $hash->{ALERTS_IN_CACHE} = (ref($alerts_data[0]) eq 'HASH'? scalar(keys(%{$alerts_data[0]})) : 0) + (ref($alerts_data[1]) eq 'HASH'? scalar(keys(%{$alerts_data[1]})) : 0);
  1219. ::Log3 $name, 5, "$name: GetAlertsFinish END";
  1220. } else {
  1221. ::Log 3, "GetAlertsFinish error: device name missing";
  1222. }
  1223. }
  1224. =head2 GetAlertsAbort($)
  1225. BlockingCall I<AbortFn> callback
  1226. =over
  1227. =item * param hash: hash of DWD_OpenData device
  1228. =back
  1229. =cut
  1230. sub GetAlertsAbort($)
  1231. {
  1232. my ($hash, $errorMessage) = @_;
  1233. my $name = $hash->{NAME};
  1234. ::Log3 $name, 3, "$name: GetAlertsAbort error: retrieving weather alerts failed, $errorMessage";
  1235. ::readingsSingleUpdate($hash, 'state', "alerts error: retrieving weather alerts failed, $errorMessage", 1);
  1236. }
  1237. =head2 UpdateAlerts($$)
  1238. update alert readings for given warncell id from global alerts list
  1239. =over
  1240. =item * param hash: hash of DWD_OpenData device
  1241. =item * param warncellId: numeric warncell id greater zero
  1242. =item * return C<undef> or error message
  1243. =back
  1244. =cut
  1245. sub UpdateAlerts($$)
  1246. {
  1247. my ($hash, $warncellId) = @_;
  1248. my $name = $hash->{NAME};
  1249. # delete existing alert readings
  1250. ::CommandDeleteReading(undef, "$name a_.*");
  1251. ::readingsBeginUpdate($hash);
  1252. # order alerts by onset
  1253. my $communeUnion = IsCommuneUnionWarncellId($warncellId);
  1254. my $alerts = $alerts_data[$communeUnion];
  1255. my @identifiers = sort { $alerts->{$a}->{onset} <=> $alerts->{$b}->{onset} } keys(%{$alerts});
  1256. my $index = 0;
  1257. foreach my $identifier (@identifiers) {
  1258. my $alert = $alerts->{$identifier};
  1259. # find alert for selected warncell
  1260. my $areaIndex = 0;
  1261. foreach my $wcId (@{$alert->{warncellid}}) {
  1262. if ($wcId == $warncellId) {
  1263. # alert found, create readings
  1264. my $prefix = 'a_'.$index.'_';
  1265. ::readingsBulkUpdate($hash, $prefix.'category', $alert->{category});
  1266. ::readingsBulkUpdate($hash, $prefix.'event', $alert->{eventCode});
  1267. ::readingsBulkUpdate($hash, $prefix.'eventDesc', encode('UTF-8', $alert->{event}));
  1268. ::readingsBulkUpdate($hash, $prefix.'eventGroup', $alert->{eventGroup});
  1269. ::readingsBulkUpdate($hash, $prefix.'responseType', $alert->{responseType});
  1270. ::readingsBulkUpdate($hash, $prefix.'urgency', $alert->{urgency});
  1271. ::readingsBulkUpdate($hash, $prefix.'severity', $alert->{severity});
  1272. ::readingsBulkUpdate($hash, $prefix.'areaColor', $alert->{areaColor});
  1273. ::readingsBulkUpdate($hash, $prefix.'onset', FormatDateTimeLocal($hash, $alert->{onset}));
  1274. ::readingsBulkUpdate($hash, $prefix.'expires', FormatDateTimeLocal($hash, $alert->{expires}));
  1275. ::readingsBulkUpdate($hash, $prefix.'headline', encode('UTF-8', $alert->{headline}));
  1276. ::readingsBulkUpdate($hash, $prefix.'description', encode('UTF-8', $alert->{description}));
  1277. ::readingsBulkUpdate($hash, $prefix.'instruction', encode('UTF-8', $alert->{instruction}));
  1278. ::readingsBulkUpdate($hash, $prefix.'area', $alert->{warncellid}[$areaIndex]);
  1279. ::readingsBulkUpdate($hash, $prefix.'areaDesc', encode('UTF-8', $alert->{areaDesc}[$areaIndex]));
  1280. ::readingsBulkUpdate($hash, $prefix.'altitude', floor(0.3048*$alert->{altitude}[$areaIndex] + 0.5));
  1281. ::readingsBulkUpdate($hash, $prefix.'ceiling', floor(0.3048*$alert->{ceiling}[$areaIndex] + 0.5));
  1282. $index++;
  1283. last();
  1284. }
  1285. $areaIndex++;
  1286. }
  1287. # license
  1288. if ($index == 1 && defined($alert->{license})) {
  1289. ::readingsBulkUpdate($hash, 'a_copyright', encode('UTF-8', $alert->{license}));
  1290. }
  1291. }
  1292. # alert count and receive time
  1293. ::readingsBulkUpdate($hash, 'a_count', $index);
  1294. ::readingsBulkUpdate($hash, "a_time", FormatDateTimeLocal($hash, $alerts_received[$communeUnion]));
  1295. ::readingsBulkUpdate($hash, 'state', "alerts updated");
  1296. ::readingsEndUpdate($hash, 1);
  1297. return undef;
  1298. }
  1299. # -----------------------------------------------------------------------------
  1300. package main;
  1301. =head1 FHEM INIT FUNCTION
  1302. =head2 DWD_OpenData_Initialize($)
  1303. FHEM I<Initialize> function
  1304. =over
  1305. =item * param hash: hash of DWD_OpenData device
  1306. =back
  1307. =cut
  1308. sub DWD_OpenData_Initialize($) {
  1309. my ($hash) = @_;
  1310. my $name = $hash->{NAME};
  1311. $hash->{DefFn} = 'DWD_OpenData::Define';
  1312. $hash->{UndefFn} = 'DWD_OpenData::Undef';
  1313. $hash->{ShutdownFn} = 'DWD_OpenData::Shutdown';
  1314. $hash->{AttrFn} = 'DWD_OpenData::Attr';
  1315. $hash->{GetFn} = 'DWD_OpenData::Get';
  1316. $hash->{AttrList} = 'disable:0,1 '
  1317. .'forecastStation forecastDays forecastProperties forecastResolution:3,6 forecastWW2Text:0,1 '
  1318. .'alertArea alertLanguage:DE,EN '
  1319. .'timezone '
  1320. .$readingFnAttributes;
  1321. }
  1322. # -----------------------------------------------------------------------------
  1323. 1;
  1324. # -----------------------------------------------------------------------------
  1325. #
  1326. # CHANGES
  1327. #
  1328. # 22.09.2018 jensb
  1329. # feature: forecast rotation for offline update reenabled
  1330. #
  1331. # 20.09.2018 jensb
  1332. # feature: CSV based forecast replaced by KML based forecast
  1333. #
  1334. # 04.07.2018 jensb
  1335. # bugfix: mark strptime as non package function in ParseDateTimeLocal and ParseDateLocal
  1336. #
  1337. # 23.06.2018 jensb
  1338. # bugfix: added use for package Encode
  1339. #
  1340. # 16.06.2018 jensb
  1341. # enhancement: trim alert values
  1342. #
  1343. # 14.06.2018 jensb
  1344. # coding: functions converted to package DWD_OpenData
  1345. #
  1346. # 13.05.2018 jensb
  1347. # bugfix: total alerts in cache
  1348. #
  1349. # 06.05.2018 jensb
  1350. # feature: detect empty alerts zip file
  1351. # bugfix: preprocess exception messages from ProcessAlerts because Blocking FinishFn parameter content may not contain commas or newlines
  1352. #
  1353. # 22.04.2018 jensb
  1354. # feature: relaxed installation prerequisites (Text::CSV_XS now forecast specific, TZ does not need to be defined)
  1355. #
  1356. # 16.04.2018 jensb
  1357. # bugfix: alerts push on scalar
  1358. #
  1359. # 13.04.2018 jensb
  1360. # feature: forecast weekday reading
  1361. #
  1362. # 28.03.2018 jensb
  1363. # feature: support for CAP alerts
  1364. #
  1365. # 22.03.2018 jensb
  1366. # bugfix: replaced trunc with round when calculating delta days to cope with summertime
  1367. #
  1368. # 18.02.2018 jensb
  1369. # feature: LWP::Simple replaced by HttpUtils_NonblockingGet (provided by JoWiemann)
  1370. #
  1371. # -----------------------------------------------------------------------------
  1372. # -----------------------------------------------------------------------------
  1373. #
  1374. # @TODO forecast: if a property is not available for a given hour the value of the previous or next hour is to be used/interpolated
  1375. # @TODO alerts: queue get commands while cache is updating
  1376. # @TODO history: https://opendata.dwd.de/weather/weather_reports/poi/
  1377. #
  1378. # -----------------------------------------------------------------------------
  1379. =head1 FHEM COMMANDREF METADATA
  1380. =over
  1381. =item device
  1382. =item summary DWD Open Data weather alerts and forecast
  1383. =item summary_DE DWD Open Data Wetterwarnungen und Wettervorhersage
  1384. =back
  1385. =head1 INSTALLATION AND CONFIGURATION
  1386. =begin html
  1387. <a name="DWD_OpenData"></a>
  1388. <h3>DWD_OpenData</h3>
  1389. <ul>
  1390. The Deutsche Wetterdienst (DWD) provides public weather related data via its <a href="https://www.dwd.de/DE/leistungen/opendata/opendata.html">Open Data Server</a>. Any usage of the service and the data provided by the DWD is subject to the usage conditions on the Open Data Server webpage. An overview of the available content can be found at <a href="https://www.dwd.de/DE/leistungen/opendata/help/inhalt_allgemein/opendata_content_de_en_xls.xls">OpenData_weather_content.xls</a>. <br><br>
  1391. This modules provides two elements of the available data:
  1392. <ul> <br>
  1393. <li>weather forecasts:
  1394. <a href="https://opendata.dwd.de/weather/local_forecasts/mos/MOSMIX_L/single_stations/">Total lists of local forecasts of WMO, national and interpolated stations, all variables, 3, 9, 15, 21 UTC</a>. More than 70 properties are available for worldwide POIs and the German DWD network. This data typically spans 10 days and is updated by the DWD every 6 hours.<br><br>
  1395. You can request forecasts for different stations in sequence using the command <code>get forecast &lt;station code&gt;</code> or for one station continuously using the attribute <code>forecastStation</code>. To get continuous mode for more than one station you need to create separate DWD_OpenData devices. <br><br>
  1396. In continuous mode the forecast data will be shifted by one day at midnight without requiring new data from the DWD.<br><br>
  1397. </li> <br>
  1398. <li>weather alerts:
  1399. <a href="https://opendata.dwd.de/weather/alerts/cap">Warning status for Germany as union of referenced community/district warnings</a>. This data is updated by the DWD as required. <br><br>
  1400. After updating the alerts cache using the command <code>get updateAlertsCache &lt;mode&gt;</code> you can request alerts for different warncells in sequence using the command <code>get alerts &lt;warncell id&gt;</code>. Setting the attribute <code>alertArea</code> will enable continuous mode. To get continuous mode for more than one station you need to create separate DWD_OpenData devices. <br><br>
  1401. Notes: This function is not suitable to rely on to ensure your safety! It will cause significant download traffic if used in continuous mode (more than 1 GB per day are possible). The device needs to keep all alerts for Germany in memory at all times to comply with the requirements of the common alerting protocol (CAP), even if only one warn cell is monitored. Depending on the weather activity this requires noticeable amounts of memory and CPU.
  1402. </li>
  1403. </ul> <br>
  1404. Installation notes: <br><br>
  1405. <ul>
  1406. <li>This module requires the additional Perl module <code>XML::LibXML</code> for weather alerts. It can be installed depending on your OS and your preferences (e.g. <code>sudo apt-get install libxml-libxml-perl</code> or using CPAN). </li><br>
  1407. <li>Data is fetched from the DWD Open Data Server using the FHEM module HttpUtils. If you use a proxy for internet access you need to set the global attribute <code>proxy</code> to a suitable value in the format <code>myProxyHost:myProxyPort</code>. </li><br>
  1408. <li>Verify that your FHEM time is correct by entering <code>{localtime()}</code> into the FHEM command line. If not, check the system time and timezone of your FHEM server and adjust appropriately. It may be necessary to add <code>export TZ=`cat /etc/timezone`</code> or something similar to your FHEM start script <code>/etc/init.d/fhem</code> or your system configuration file <code>/etc/profile</code>. If <code>/etc/timezone</code> does not exists or is undefined execute <code>tzselect</code> to find your timezone and write the result into this file. After making changes restart FHEM and enter <code>{$ENV{TZ}}</code> into the FHEM command line to verify. To fix the timezone temporarily without restarting FHEM enter <code>{$ENV{TZ}='Europe/Berlin'}</code> or something similar into the FHEM command line. Again use <code>tzselect</code> to fine a valid timezone name. </li><br>
  1409. <li>The weekday of the forecast will be in the language of your FHEM system. Enter <code>{$ENV{LANG}}</code> into the FHEM command line to verify.
  1410. If nothing is displayed or you see an unexpected language setting, add <code>export LANG=de_DE.UTF-8</code> or something similar to your FHEM start script, restart FHEM and check again. If you get a locale warning when starting FHEM the required language pack might be missing. It can be installed depending on your OS and your preferences (e.g. <code>dpkg-reconfigure locales</code>, <code>apt-get install language-pack-de</code> or something similar). </li><br>
  1411. <li>The digits in a warncell id of a communeunion or a district are mostly identical to an <i>Amtliche Gemeindekennziffer</i> if you strip of the 1st digit from the warncell id. You can lookup an Amtliche Gemeindekennziffer using the name of a communeunion or district e.g. at <a href="https://www.statistik-bw.de/Statistik-Portal/gemeindeverz.asp">Statistische &Auml;mter des Bundes und der L&auml;nder</a>. Then add 8 for a communeunion or 1 or 9 for a district at the beginning and try to find an exact or near match in the <a href="https://www.dwd.de/DE/leistungen/opendata/help/warnungen/cap_warncellids_csv.csv">Warncell-IDs for CAP alerts catalogue</a>. This approach is an alternative to <i>guessing</i> the right warncell id by the name of a communeunion or district. </li><br>
  1412. <li>Like some other Perl modules this module temporarily modifies the TZ environment variable for timezone conversions. This may cause unexpected results in multi threaded environments. </li><br>
  1413. <li>The forecast reading names do not contain absolute days or hours to keep them independent of summertime adjustments. Forecast days are counted relative to "today" of the timezone defined by the attribute of the same name or the timezone specified by the Perl TZ environment variable if undefined. </li><br>
  1414. <li>Starting on 17.09.2018 the forecast data is no longer available in CSV format and is based on the KML format instead. While most of the properties of the CSV format are still available in KML format, their names have changed and you will have to adjust your existing installation accordingly. </li><br>
  1415. </ul><br>
  1416. <a name="DWD_OpenDatadefine"></a>
  1417. <b>Define</b> <br><br>
  1418. <code>define &lt;name&gt; DWD_OpenData</code> <br><br><br>
  1419. <a name="DWD_OpenDataget"></a>
  1420. <b>Get</b>
  1421. <ul> <br>
  1422. <li>
  1423. <code>get forecast [&lt;station code&gt;]</code><br>
  1424. Fetch forecast for a station from DWD and update readings. The station code is either a 5 digit WMO station code or an alphanumeric DWD station code from the <a href="https://www.dwd.de/DE/leistungen/met_verfahren_mosmix/mosmix_stationskatalog.pdf">MOSMIX station catalogue</a>. If the attribute <code>forecastStation</code> is set, no <i>station code</i> must be provided. <br>
  1425. The operation is performed non-blocking.
  1426. </li> <br>
  1427. <li>
  1428. <code>get alerts [&lt;warncell id&gt;]</code><br>
  1429. Set alert readings for given warncell id. A warncell id is a 9 digit numeric value from the <a href="https://www.dwd.de/DE/leistungen/opendata/help/warnungen/cap_warncellids_csv.csv">Warncell-IDs for CAP alerts catalogue</a>. Supported ids start with 8 (communeunion), 1 and 9 (district) or 5 (coast). If the attribute <code>alertArea</code> is set, no <i>warncell id</i> must be provided. <br>
  1430. If the alerts cache is empty or older than 15 minutes the cache is updated first and the operation is non-blocking. If the cache is valid the operation is blocking. If a cache update is already in progress the operation fails. <br>
  1431. To verify that alerts are provided for the warncell id you selected you should consult another source, wait for an alert situation and compare.
  1432. </li> <br>
  1433. <li>
  1434. <code>get updateAlertsCache { communeUnions|districts|all }</code><br>
  1435. Fetch alerts to update the alerts cache. Note that 'coast' alerts are part of the 'communeUnion' cache data. <br>
  1436. The operation is performed non-blocking because it typically requires several seconds. If a cache update is already in progress the operation fails. <br>
  1437. This command can be used before querying several warncells in sequence or to force a higher update frequency than the built-in 15 minutes. Note that all DWD_OpenData devices share a single alerts cache so updating the cache via one of the devices is sufficient.
  1438. </li>
  1439. </ul> <br><br>
  1440. <a name="DWD_OpenDataattr"></a>
  1441. <b>Attributes</b><br>
  1442. <ul> <br>
  1443. <li>disable {0|1}, default: 0<br>
  1444. Disable fetching data.
  1445. </li><br>
  1446. <li>timezone &lt;tz&gt;, default: OS dependent<br>
  1447. <a href="https://en.wikipedia.org/wiki/List_of_tz_database_time_zones">IANA TZ string</a> for date and time readings (e.g. "Europe/Berlin"), can be used to assume the perspective of a station that is in a different timezone or if your OS timezone settings do not match your local timezone. Alternatively you may use <code>tzselect</code> on the Linux command line to find a valid timezone string.
  1448. </li><br>
  1449. </ul>
  1450. <b>forecast</b> related:
  1451. <ul> <br>
  1452. <li>forecastStation &lt;station code&gt;, default: none<br>
  1453. Setting forecastStation enables automatic updates every hour.
  1454. The station code is either a 5 digit WMO station code or an alphanumeric DWD station code from the <a href="https://www.dwd.de/DE/leistungen/met_verfahren_mosmix/mosmix_stationskatalog.pdf">MOSMIX station catalogue</a>.
  1455. </li><br>
  1456. <li>forecastDays &lt;n&gt;, default: 6<br>
  1457. Limits number of forecast days. Setting 0 will still provide forecast data for today. The maximum value is 9 (for today and 9 future days).
  1458. </li><br>
  1459. <li>forecastResolution {3|6}, default: 6 h<br>
  1460. Time resolution (number of hours between 2 samples).
  1461. </li><br>
  1462. <li>forecastProperties [&lt;p1&gt;[,&lt;p2&gt;]...] , default: Tx, Tn, Tg, TTT, DD, FX1, Neff, RR6c, RRhc, Rh00, ww<br>
  1463. A list of the properties available can be found <a href="https://opendata.dwd.de/weather/lib/MetElementDefinition.xml">here</a>.
  1464. If you remove a property from the list existing readings must be deleted manually in continuous mode.<br>
  1465. Note: Not all properties are available for all stations and for all hours.
  1466. </li><br>
  1467. <li>forecastWW2Text {0|1}, default: 0<br>
  1468. Create additional wwd readings containing the weather code as a descriptive text in German language.
  1469. </li><br>
  1470. </ul>
  1471. <b>alert</b> related:
  1472. <ul> <br>
  1473. <li>alertArea &lt;warncell id&gt;, default: none<br>
  1474. Setting alertArea enables automatic updates of the alerts cache every 15 minutes.
  1475. A warncell id is a 9 digit numeric value from the <a href="https://www.dwd.de/DE/leistungen/opendata/help/warnungen/cap_warncellids_csv.csv">Warncell-IDs for CAP alerts catalogue</a>. Supported ids start with 8 (communeunion), 1 and 9 (district) or 5 (coast). To verify that alerts are provided for the warncell id you selected you should consult another source, wait for an alert situation and compare.
  1476. </li>
  1477. <li>alertLanguage [DE|EN], default: DE<br>
  1478. Language of descriptive alert properties.</a>.
  1479. </li>
  1480. </ul> <br><br>
  1481. <a name="DWD_OpenDatareadings"></a>
  1482. <b>Readings</b> <br><br>
  1483. The <b>forecast</b> readings are build like this: <br><br>
  1484. <code>fc&lt;day&gt;_[&lt;sample&gt;_]&lt;property&gt;</code> <br><br>
  1485. A description of the more than 70 properties available and their units of measurement can be found <a href="https://opendata.dwd.de/weather/lib/MetElementDefinition.xml">here</a>. The units of measurement for temperatures and wind speeds are converted to °C and km/h respectively. Only a few choice properties are listed in the following paragraphs: <br><br>
  1486. <ul>
  1487. <li>day - relative day (0 .. 9) based on the timezone attribute where 0 is today</li><br>
  1488. <li>sample - relative time (0 .. 3 or 7) equivalent to multiples of 6 or 3 hours UTC depending on the forecastHours attribute</li><br>
  1489. <li>day properties (typically for 06:00 station time, see raw data of station for time relation)
  1490. <ul>
  1491. <li>date - date based on the timezone attribute</li>
  1492. <li>weekday - abbreviated weekday based on the timezone attribute in the language of your FHEM system</li>
  1493. <li>Tn [°C] - minimum temperature of previous 24 hours</li>
  1494. <li>Tx [°C] - maximum temperature of previous 24 hours (typically for 18:00 station time)</li>
  1495. <li>Tm [°C] - average temperature of previous 24 hours</li>
  1496. <li>Tg [°C] - minimum temperature 5 cm above ground of previous 24 hours</li>
  1497. <li>PEvap [kg/m2] - evapotranspiration of previous 24 hours</li>
  1498. <li>SunD [s] - total sunshine duration of previous 24 hours</li>
  1499. </ul>
  1500. </li><br>
  1501. <li>hour properties
  1502. <ul>
  1503. <li>time - hour based the timezone attribute</li>
  1504. <li>TTT [°C] - dry bulb temperature at 2 meter above ground</li>
  1505. <li>Td [°C] - dew point temperature at 2 meter above ground</li>
  1506. <li>DD [°] - average wind direction 10 m above ground</li>
  1507. <li>FF [km/h] - average wind speed 10 m above ground</li>
  1508. <li>FX1 [km/h] - maximum wind speed in the last hour</li>
  1509. <li>RR6c [kg/m2] - precipitation amount in the last 6 hours</li>
  1510. <li>R600 [%] - probability of rain in the last 6 hours</li>
  1511. <li>RRhc [kg/m2] - precipitation amount in the last 12 hours</li>
  1512. <li>Rh00 [%] - probability of rain in the last 12 hours</li>
  1513. <li>RRdc [kg/m2] - precipitation amount in the last 24 hours</li>
  1514. <li>Rd00 [%] - probability of rain in the last 24 hours</li>
  1515. <li>ww - weather code (see WMO 4680/4677, SYNOP)</li>
  1516. <li>wwd - German weather code description</li>
  1517. <li>VV [m] - horizontal visibility</li>
  1518. <li>Neff [%] - effective cloud cover</li>
  1519. <li>Nl [%] - lower level cloud cover below 2000 m</li>
  1520. <li>Nm [%] - medium level cloud cover below 7000 m</li>
  1521. <li>Nh [%] - high level cloud cover obove 7000 m</li>
  1522. <li>PPPP [hPa] - pressure equivalent at sea level</li>
  1523. </ul>
  1524. </li>
  1525. </ul> <br>
  1526. Additionally there are global forecast readings:
  1527. <ul>
  1528. <ul>
  1529. <li>fc_station - forecast station code (WMO or DWD)</li>
  1530. <li>fc_description - station description</li>
  1531. <li>fc_coordinates - world coordinat and height of station</li>
  1532. <li>fc_time - time the forecast updated was downloaded based on the timezone attribute</li>
  1533. <li>fc_copyright - legal information, must be displayed with forecast data, see DWD usage conditions</li>
  1534. </ul>
  1535. </ul> <br><br>
  1536. The <b>alert</b> readings are ordered by onset and are build like this: <br><br>
  1537. <code>a_&lt;index&gt;_&lt;property&gt;</code> <br><br>
  1538. <ul>
  1539. <li>index - alert index, starting with 0, total a_count, ordered by onset</li><br>
  1540. <li>alert properties
  1541. <ul>
  1542. <li>category - 'Met' or 'Health'</li>
  1543. <li>event - numeric event code, see DWD documentation for details</li>
  1544. <li>eventDesc - short event description in selected language</li>
  1545. <li>eventGroup - event group, see DWD documentation for details</li>
  1546. <li>responseType - 'None' = no instructions, 'Prepare' = instructions, 'AllClear' = alert cleared</li>
  1547. <li>urgency - 'Immediate' = warning or 'Future' = information</li>
  1548. <li>severity - 'Minor', 'Moderate', 'Severe' or 'Extreme'</li>
  1549. <li>areaColor - RGB colour depending on urgency and severity, comma separated decimal triple</li>
  1550. <li>onset - start time of alert based on the timezone attribute</li>
  1551. <li>expires - end time of alert based on the timezone attribute</li>
  1552. <li>headline - headline in selected language, typically a combination of the properties urgency and event</li>
  1553. <li>description - description of the alert in selected language</li>
  1554. <li>instruction - safety instructions in selected language</li>
  1555. <li>area - numeric warncell id</li>
  1556. <li>areaDesc - description of area, e.g. 'Stadt Berlin'</li>
  1557. <li>altitude - min. altitude [m]</li>
  1558. <li>ceiling - max. altitude [m]</li>
  1559. </ul>
  1560. </li><br>
  1561. </ul>
  1562. Additionally there are some global alert readings:<br><br>
  1563. <ul>
  1564. <ul>
  1565. <li>a_time - time the last alert update was downloaded based on the timezone attribute</li>
  1566. <li>a_count - number of alerts available for selected warncell id</li>
  1567. <li>a_copyright - legal information, must be displayed with forecast data, see DWD usage conditions, not available if count is zero</li>
  1568. </ul>
  1569. </ul> <br>
  1570. Alerts should be considered active for onset <= now < expires and responseType != 'AllClear' independent of urgency.<br>
  1571. Inactive alerts with responseType = 'AllClear' may provide relevant instructions.<br><br>
  1572. Note that all alert readings are completely replaced and reindexed with each update! <br><br>
  1573. Further information regarding the alert properties can be found in the documentation of the <a href="https://www.dwd.de/DE/leistungen/opendata/help/warnungen/cap_dwd_profile_de_pdf.pdf">CAP DWS Profile</a>. <br>
  1574. </ul> <br>
  1575. =end html
  1576. =begin html_DE
  1577. <a name="DWD_OpenData"></a>
  1578. <h3>DWD_OpenData</h3>
  1579. <ul>
  1580. Der Deutsche Wetterdienst (DWD) stellt Wetterdaten &uuml;ber den <a href="https://www.dwd.de/DE/leistungen/opendata/opendata.html">Open Data Server</a> zur Verf&uuml;gung. Die Verwendung dieses Dienstes und der vom DWD zur Verf&uuml;gung gestellten Daten unterliegt den auf der OpenData Webseite beschriebenen Bedingungen. Einen &Uuml;berblick &uuml;ber die verf&uuml;gbaren Daten findet man in der Tabelle <a href="https://www.dwd.de/DE/leistungen/opendata/help/inhalt_allgemein/opendata_content_de_en_xls.xls">OpenData_weather_content.xls</a>. <br><br>
  1581. Eine Installationsbeschreibung findet sich in der <a href="https://wiki.fhem.de/wiki/DWD_OpenData">FHEMWiki</a>. <br><br>
  1582. Eine detaillierte Modulbeschreibung gibt es auf Englisch - siehe die englische Modulhilfe von <a href="commandref.html#DWD_OpenData">DWD_OpenData</a>. <br>
  1583. </ul> <br>
  1584. =end html_DE
  1585. =cut